@slicedearth/whoisleuth-cli 1.32.0

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 (189) hide show
  1. package/LICENSE +661 -0
  2. package/LICENSES/Retire.js-Apache-2.0.txt +199 -0
  3. package/NOTICE +76 -0
  4. package/README.md +84 -0
  5. package/TRADEMARKS.md +41 -0
  6. package/bin/whoisleuth.mjs +35 -0
  7. package/cli/archive-inspect.mjs +198 -0
  8. package/cli/arguments.mjs +1022 -0
  9. package/cli/artifact-verify.mjs +210 -0
  10. package/cli/bulk-checkpoint.mjs +218 -0
  11. package/cli/bulk-command-runner.mjs +109 -0
  12. package/cli/bulk-output.mjs +112 -0
  13. package/cli/bulk.mjs +133 -0
  14. package/cli/compare.mjs +238 -0
  15. package/cli/completion.mjs +175 -0
  16. package/cli/csv.mjs +1 -0
  17. package/cli/discover.mjs +50 -0
  18. package/cli/discovery-command-runner.mjs +36 -0
  19. package/cli/discovery-observation-snapshot.mjs +315 -0
  20. package/cli/discovery-scan-command-runner.mjs +141 -0
  21. package/cli/discovery-scan.mjs +325 -0
  22. package/cli/discovery-snapshot.mjs +133 -0
  23. package/cli/discovery-workflow.mjs +73 -0
  24. package/cli/doctor.mjs +137 -0
  25. package/cli/errors.mjs +21 -0
  26. package/cli/evidence-command-arguments.mjs +145 -0
  27. package/cli/evidence-command-runner.mjs +106 -0
  28. package/cli/evidence-signing.mjs +215 -0
  29. package/cli/exit-codes.mjs +9 -0
  30. package/cli/export-evidence.mjs +28 -0
  31. package/cli/formatters/evidence-report.mjs +3 -0
  32. package/cli/formatters/html.mjs +69 -0
  33. package/cli/formatters/json.mjs +135 -0
  34. package/cli/formatters/markdown.mjs +3 -0
  35. package/cli/formatters/terminal.mjs +658 -0
  36. package/cli/http.mjs +27 -0
  37. package/cli/lookup-command-runner.mjs +87 -0
  38. package/cli/lookup-diff.mjs +171 -0
  39. package/cli/mail-review.mjs +204 -0
  40. package/cli/manual.mjs +34 -0
  41. package/cli/network-command-runner.mjs +88 -0
  42. package/cli/output-file.mjs +97 -0
  43. package/cli/page-compare.mjs +123 -0
  44. package/cli/posture.mjs +20 -0
  45. package/cli/progress-events.mjs +53 -0
  46. package/cli/progress.mjs +56 -0
  47. package/cli/registry-access.mjs +3 -0
  48. package/cli/registry-support.mjs +120 -0
  49. package/cli/risk-calibration.mjs +294 -0
  50. package/cli/runner-types.mjs +1 -0
  51. package/cli/runner.mjs +726 -0
  52. package/cli/saved-lookup.mjs +87 -0
  53. package/cli/source-reliability.mjs +727 -0
  54. package/cli/strict-exit.mjs +55 -0
  55. package/cli/terminal-presentation.mjs +147 -0
  56. package/docs/cli.md +827 -0
  57. package/frontend/src/lib/analysis/acquisition-decision-packet.js +93 -0
  58. package/frontend/src/lib/analysis/acquisition-due-diligence.js +391 -0
  59. package/frontend/src/lib/analysis/artifact-integrity.js +21 -0
  60. package/frontend/src/lib/analysis/brand-profile-model.js +421 -0
  61. package/frontend/src/lib/analysis/bulk-domain-comparison.js +246 -0
  62. package/frontend/src/lib/analysis/bulk-mail-exposure.js +192 -0
  63. package/frontend/src/lib/analysis/bulk-retry-plan.js +86 -0
  64. package/frontend/src/lib/analysis/bulk-review-export.js +62 -0
  65. package/frontend/src/lib/analysis/bulk-review-model.js +266 -0
  66. package/frontend/src/lib/analysis/bulk-session-model.js +515 -0
  67. package/frontend/src/lib/analysis/bulk-sort.js +49 -0
  68. package/frontend/src/lib/analysis/campaign-model.js +265 -0
  69. package/frontend/src/lib/analysis/case-evidence-model.js +642 -0
  70. package/frontend/src/lib/analysis/case-migration-model.js +248 -0
  71. package/frontend/src/lib/analysis/case-model.js +5 -0
  72. package/frontend/src/lib/analysis/case-record-contracts.js +44 -0
  73. package/frontend/src/lib/analysis/case-record-core.js +232 -0
  74. package/frontend/src/lib/analysis/case-record-model.js +7 -0
  75. package/frontend/src/lib/analysis/case-record-operations.js +224 -0
  76. package/frontend/src/lib/analysis/case-response-model.js +640 -0
  77. package/frontend/src/lib/analysis/case-response-packet.js +573 -0
  78. package/frontend/src/lib/analysis/case-storage-model.js +93 -0
  79. package/frontend/src/lib/analysis/detection-rule-model.js +306 -0
  80. package/frontend/src/lib/analysis/dns-record-normalization.js +11 -0
  81. package/frontend/src/lib/analysis/http-summary.js +165 -0
  82. package/frontend/src/lib/analysis/investigation-guide.js +500 -0
  83. package/frontend/src/lib/analysis/investigation-template-model.js +144 -0
  84. package/frontend/src/lib/analysis/page-baseline.js +272 -0
  85. package/frontend/src/lib/analysis/page-similarity.js +179 -0
  86. package/frontend/src/lib/analysis/relationship-evidence.js +265 -0
  87. package/frontend/src/lib/analysis/relationship-observation-model.js +423 -0
  88. package/frontend/src/lib/analysis/scoring.js +135 -0
  89. package/frontend/src/lib/analysis/shortlist-model.js +209 -0
  90. package/frontend/src/lib/analysis/utils.js +265 -0
  91. package/frontend/src/lib/analysis/watchlist-history.js +643 -0
  92. package/frontend/src/lib/analysis/watchlist-store.js +127 -0
  93. package/frontend/src/lib/analysis/website-snapshot-model.js +302 -0
  94. package/frontend/src/lib/analysis/workspace-archive-crypto.js +282 -0
  95. package/frontend/src/lib/analysis/workspace-archive.js +472 -0
  96. package/lib/abort.mjs +26 -0
  97. package/lib/abusech-auth.mjs +19 -0
  98. package/lib/availability.mjs +716 -0
  99. package/lib/bounded-contract-normalizers.mjs +61 -0
  100. package/lib/browser-library-profile.mjs +310 -0
  101. package/lib/bulk-comparison-evidence.mjs +83 -0
  102. package/lib/classify.mjs +93 -0
  103. package/lib/client-behavior-profile.mjs +83 -0
  104. package/lib/credential-surface-profile.mjs +66 -0
  105. package/lib/csv.mjs +23 -0
  106. package/lib/ct-query.mjs +27 -0
  107. package/lib/ct-search.mjs +393 -0
  108. package/lib/distributed-operation-budget.mjs +461 -0
  109. package/lib/dns-delegation-health.mjs +344 -0
  110. package/lib/dns-intelligence.mjs +739 -0
  111. package/lib/dns-mx.mjs +24 -0
  112. package/lib/dns-resolver-selection.mjs +33 -0
  113. package/lib/domain-posture-analysis.mjs +254 -0
  114. package/lib/domain-posture-parsers.mjs +358 -0
  115. package/lib/domain-posture.mjs +745 -0
  116. package/lib/error-detail.mjs +10 -0
  117. package/lib/evidence-export.mjs +431 -0
  118. package/lib/evidence-report-markdown.mjs +71 -0
  119. package/lib/evidence-report.mjs +391 -0
  120. package/lib/external-intelligence-risk.mjs +108 -0
  121. package/lib/favicon.mjs +146 -0
  122. package/lib/feature-policy.mjs +70 -0
  123. package/lib/generated/retire-browser-catalog.mjs +8724 -0
  124. package/lib/generated/unicode-confusables-17.mjs +93 -0
  125. package/lib/hostname.mjs +38 -0
  126. package/lib/html-signals.mjs +680 -0
  127. package/lib/http-intelligence.mjs +232 -0
  128. package/lib/idn-confusable-policy.mjs +86 -0
  129. package/lib/idn-confusables.mjs +465 -0
  130. package/lib/lookup-cache.mjs +130 -0
  131. package/lib/lookup-progress.mjs +291 -0
  132. package/lib/lookup-source-progress.mjs +109 -0
  133. package/lib/lookup-threat-provider-inventory.mjs +13 -0
  134. package/lib/lookup.mjs +592 -0
  135. package/lib/observation.mjs +114 -0
  136. package/lib/observed-network-context.mjs +355 -0
  137. package/lib/operation-budget.mjs +489 -0
  138. package/lib/page-fingerprints.mjs +481 -0
  139. package/lib/page-role-profile.mjs +117 -0
  140. package/lib/perceptual-hash.mjs +451 -0
  141. package/lib/rdap-attempts.mjs +34 -0
  142. package/lib/rdap-bootstrap.mjs +173 -0
  143. package/lib/rdap-capabilities.mjs +292 -0
  144. package/lib/rdap-client.mjs +125 -0
  145. package/lib/rdap-normalization.mjs +691 -0
  146. package/lib/rdap-registrar.mjs +196 -0
  147. package/lib/rdap-transport.mjs +39 -0
  148. package/lib/rdap-types.mjs +1 -0
  149. package/lib/rdap-validation.mjs +94 -0
  150. package/lib/rdap.mjs +16 -0
  151. package/lib/registry-access.mjs +32 -0
  152. package/lib/registry-capabilities.mjs +117 -0
  153. package/lib/registry-capability-catalogue.mjs +1206 -0
  154. package/lib/registry-comparison.mjs +318 -0
  155. package/lib/registry-dates.mjs +190 -0
  156. package/lib/registry-insights.mjs +325 -0
  157. package/lib/response-policy.mjs +256 -0
  158. package/lib/risk-scoring.mjs +164 -0
  159. package/lib/safe-fetch.mjs +446 -0
  160. package/lib/security-txt.mjs +359 -0
  161. package/lib/service-binding-dns.mjs +608 -0
  162. package/lib/sslbl-certificates.generated.mjs +143 -0
  163. package/lib/sslbl-intelligence.mjs +241 -0
  164. package/lib/static-html-analysis.mjs +340 -0
  165. package/lib/structured-data-identity.mjs +323 -0
  166. package/lib/threat-intelligence-contract.mjs +5 -0
  167. package/lib/threat-intelligence-definition-registry.mjs +34 -0
  168. package/lib/threat-intelligence-fixtures.mjs +82 -0
  169. package/lib/threat-intelligence-manifests.mjs +512 -0
  170. package/lib/threat-intelligence-runtime.mjs +662 -0
  171. package/lib/threat-intelligence-targets.mjs +168 -0
  172. package/lib/threat-intelligence-types.mjs +3 -0
  173. package/lib/threatfox-intelligence.mjs +307 -0
  174. package/lib/tls-intelligence.mjs +814 -0
  175. package/lib/typosquat-generator.mjs +884 -0
  176. package/lib/urlhaus-intelligence.mjs +361 -0
  177. package/lib/urlscan-intelligence.mjs +349 -0
  178. package/lib/website-security-posture.mjs +325 -0
  179. package/lib/website-technology.mjs +437 -0
  180. package/lib/whois-authority.mjs +98 -0
  181. package/lib/whois-chain.mjs +261 -0
  182. package/lib/whois-contacts.mjs +163 -0
  183. package/lib/whois-contracts.mjs +3 -0
  184. package/lib/whois-normalization.mjs +27 -0
  185. package/lib/whois-transport.mjs +100 -0
  186. package/lib/whois-values.mjs +92 -0
  187. package/lib/whois.mjs +1187 -0
  188. package/package.json +55 -0
  189. package/third-party-notices.txt +5571 -0
@@ -0,0 +1,344 @@
1
+ // Bounded delegation-health evidence for deep single-domain Lookup. Registry
2
+ // publication, recursive parent-view answers, and direct nameserver queries
3
+ // remain separately attributed. The result never participates in registration
4
+ // availability and never treats a failed query as record absence.
5
+ import { promises as dns } from 'node:dns';
6
+ import * as net from 'node:net';
7
+ import { createObservation } from "./observation.mjs";
8
+ import { isPrivateAddress } from "./safe-fetch.mjs";
9
+ const DNS_DELEGATION_HEALTH_VERSION = 1;
10
+ const MAX_AUTHORITIES = 4;
11
+ const MAX_AUTHORITY_ADDRESSES = 2;
12
+ const MAX_NAMESERVERS = 16;
13
+ const MAX_ERROR_LENGTH = 180;
14
+ const DNS_DELEGATION_TIMEOUT_MS = 2200;
15
+ const LAME_CODES = new Set(['ENOTAUTH', 'EREFUSED']);
16
+ function record(value) {
17
+ return value && typeof value === 'object' && !Array.isArray(value)
18
+ ? value
19
+ : {};
20
+ }
21
+ function boundedText(value, maximum) {
22
+ if (typeof value !== 'string' || value.length > maximum || /[\u0000-\u001f\u007f]/u.test(value))
23
+ return null;
24
+ return value.replace(/\s+/gu, ' ').trim() || null;
25
+ }
26
+ function hostname(value) {
27
+ const normalized = boundedText(value, 253)?.toLowerCase().replace(/\.+$/u, '') ?? '';
28
+ if (!normalized || !normalized.includes('.'))
29
+ return null;
30
+ return normalized.split('.').every((label) => (/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/iu.test(label))) ? normalized : null;
31
+ }
32
+ function hostnames(value, maximum = MAX_NAMESERVERS) {
33
+ const output = new Set();
34
+ for (const item of Array.isArray(value) ? value.slice(0, maximum * 2) : []) {
35
+ const normalized = hostname(item);
36
+ if (normalized)
37
+ output.add(normalized);
38
+ if (output.size >= maximum)
39
+ break;
40
+ }
41
+ return [...output].sort();
42
+ }
43
+ function publicAddress(value) {
44
+ if (typeof value !== 'string' || value.length > 80 || /[\u0000-\u0020\u007f%]/u.test(value))
45
+ return null;
46
+ const normalized = value.toLowerCase();
47
+ return net.isIP(normalized) && !isPrivateAddress(normalized) ? normalized : null;
48
+ }
49
+ function errorCode(error) {
50
+ const value = record(error).code;
51
+ return typeof value === 'string' ? value.slice(0, 40) : '';
52
+ }
53
+ function errorDetail(error) {
54
+ return String(error instanceof Error ? error.message : error || 'DNS query failed')
55
+ .replace(/[\u0000-\u001f\u007f]+/gu, ' ')
56
+ .replace(/\s+/gu, ' ')
57
+ .trim()
58
+ .slice(0, MAX_ERROR_LENGTH) || 'DNS query failed';
59
+ }
60
+ function withTimeout(promise, timeoutMs) {
61
+ return new Promise((resolve, reject) => {
62
+ const timer = setTimeout(() => reject(Object.assign(new Error('DNS query timed out'), { code: 'ETIMEOUT' })), timeoutMs);
63
+ promise.then((value) => {
64
+ clearTimeout(timer);
65
+ resolve(value);
66
+ }, (error) => {
67
+ clearTimeout(timer);
68
+ reject(error);
69
+ });
70
+ });
71
+ }
72
+ async function defaultAuthorityQuery(input) {
73
+ const resolver = new dns.Resolver({
74
+ timeout: Math.max(250, Math.min(input.timeoutMs, DNS_DELEGATION_TIMEOUT_MS)),
75
+ tries: 1,
76
+ });
77
+ resolver.setServers([net.isIP(input.address) === 6 ? `[${input.address}]:53` : input.address]);
78
+ const [nameservers, soa] = await Promise.allSettled([
79
+ resolver.resolveNs(input.domain),
80
+ resolver.resolveSoa(input.domain),
81
+ ]);
82
+ const reason = nameservers.status === 'rejected'
83
+ ? nameservers.reason
84
+ : soa.status === 'rejected'
85
+ ? soa.reason
86
+ : null;
87
+ if (reason !== null) {
88
+ return {
89
+ nameservers: nameservers.status === 'fulfilled' ? nameservers.value : [],
90
+ soaPrimary: soa.status === 'fulfilled' ? soa.value.nsname : null,
91
+ errorCode: errorCode(reason),
92
+ error: errorDetail(reason),
93
+ };
94
+ }
95
+ return {
96
+ nameservers: nameservers.status === 'fulfilled' ? nameservers.value : [],
97
+ soaPrimary: soa.status === 'fulfilled' ? soa.value.nsname : null,
98
+ errorCode: null,
99
+ error: null,
100
+ };
101
+ }
102
+ function registryProjection(value) {
103
+ const registry = record(value);
104
+ const nameserverDetails = (Array.isArray(registry.nameserverDetails)
105
+ ? registry.nameserverDetails
106
+ : [])
107
+ .slice(0, MAX_NAMESERVERS)
108
+ .map((item) => {
109
+ const detail = record(item);
110
+ const name = hostname(detail.name);
111
+ if (!name)
112
+ return null;
113
+ const addresses = [...new Set((Array.isArray(detail.addresses) ? detail.addresses : [])
114
+ .map(publicAddress)
115
+ .filter((address) => address !== null))]
116
+ .slice(0, MAX_AUTHORITY_ADDRESSES);
117
+ return { name, addresses };
118
+ })
119
+ .filter((item) => item !== null);
120
+ return {
121
+ nameservers: hostnames(registry.nameservers),
122
+ nameserverDetails,
123
+ delegationSigned: typeof registry.delegationSigned === 'boolean'
124
+ ? registry.delegationSigned
125
+ : null,
126
+ dsRecordCount: Array.isArray(registry.dsData) ? Math.min(registry.dsData.length, 50) : 0,
127
+ truncated: registry.nameserversTruncated === true
128
+ || registry.nameserverAddressesTruncated === true
129
+ || registry.dsDataTruncated === true,
130
+ };
131
+ }
132
+ function sameSet(left, right) {
133
+ return left.length === right.length && left.every((value, index) => value === right[index]);
134
+ }
135
+ function finding(id, label, state, summary, detail, remediation) {
136
+ return { id, label, state, summary, detail, remediation };
137
+ }
138
+ function skippedDnsDelegationHealth(detail = 'Authoritative delegation checks were not collected for this lookup.') {
139
+ return {
140
+ delegationHealthVersion: DNS_DELEGATION_HEALTH_VERSION,
141
+ ...createObservation({
142
+ status: 'skipped',
143
+ scanMode: 'deep',
144
+ source: 'dns_delegation',
145
+ complete: false,
146
+ limitations: [
147
+ detail,
148
+ 'A skipped delegation check is not evidence that the delegation is healthy or unhealthy.',
149
+ ],
150
+ diagnostics: { authorityCount: 0, queriedAddressCount: 0 },
151
+ }),
152
+ detail,
153
+ parent: { state: 'not_collected', nameservers: [], error: null },
154
+ registry: {
155
+ nameservers: [],
156
+ nameserverDetails: [],
157
+ delegationSigned: null,
158
+ dsRecordCount: 0,
159
+ truncated: false,
160
+ },
161
+ authorities: [],
162
+ findings: [],
163
+ };
164
+ }
165
+ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options = {}) {
166
+ const domain = hostname(domainValue);
167
+ if (!domain)
168
+ return skippedDnsDelegationHealth('The domain was not eligible for authoritative delegation checks.');
169
+ const registry = registryProjection(options.registryEvidence);
170
+ const parentQuery = record(parentQueryValue);
171
+ const parentStatus = parentQuery.status === 'success' || parentQuery.status === 'not_found'
172
+ ? parentQuery.status
173
+ : 'error';
174
+ const parentNameservers = hostnames(parentQuery.records);
175
+ const candidates = [...new Set([...parentNameservers, ...registry.nameservers])]
176
+ .sort()
177
+ .slice(0, MAX_AUTHORITIES);
178
+ const resolve4 = options.resolve4 || dns.resolve4;
179
+ const resolve6 = options.resolve6 || dns.resolve6;
180
+ const queryAuthority = options.queryAuthority || defaultAuthorityQuery;
181
+ const timeoutMs = Math.max(250, Math.min(5000, Number(options.timeoutMs) || DNS_DELEGATION_TIMEOUT_MS));
182
+ const now = options.now || Date.now;
183
+ const started = now();
184
+ const authorities = await Promise.all(candidates.map(async (nameserver) => {
185
+ const glue = registry.nameserverDetails.find((item) => item.name === nameserver)?.addresses ?? [];
186
+ const resolved = glue.length
187
+ ? glue
188
+ : (await Promise.allSettled([
189
+ withTimeout(Promise.resolve(resolve4(nameserver)), timeoutMs),
190
+ withTimeout(Promise.resolve(resolve6(nameserver)), timeoutMs),
191
+ ])).flatMap((result) => result.status === 'fulfilled' && Array.isArray(result.value)
192
+ ? result.value
193
+ : [])
194
+ .map(publicAddress)
195
+ .filter((address) => address !== null);
196
+ const addresses = [...new Set(resolved)].slice(0, MAX_AUTHORITY_ADDRESSES);
197
+ const attempts = await Promise.all(addresses.map(async (address) => {
198
+ try {
199
+ const raw = await withTimeout(queryAuthority({ domain, nameserver, address, timeoutMs }), timeoutMs);
200
+ const query = record(raw);
201
+ const code = boundedText(query.errorCode, 40);
202
+ const error = boundedText(query.error, MAX_ERROR_LENGTH);
203
+ const nameservers = hostnames(query.nameservers);
204
+ const soaPrimary = hostname(query.soaPrimary);
205
+ const hasAnswer = nameservers.length > 0 || soaPrimary !== null;
206
+ const state = error && hasAnswer
207
+ ? 'partial'
208
+ : code && LAME_CODES.has(code)
209
+ ? 'lame'
210
+ : error || !hasAnswer
211
+ ? 'unreachable'
212
+ : 'success';
213
+ return { address, state, nameservers, soaPrimary, errorCode: code, error };
214
+ }
215
+ catch (error) {
216
+ const code = errorCode(error);
217
+ return {
218
+ address,
219
+ state: LAME_CODES.has(code) ? 'lame' : 'unreachable',
220
+ nameservers: [],
221
+ soaPrimary: null,
222
+ errorCode: code || null,
223
+ error: errorDetail(error),
224
+ };
225
+ }
226
+ }));
227
+ const successful = attempts.find((attempt) => attempt.state === 'success');
228
+ const partial = attempts.find((attempt) => attempt.state === 'partial');
229
+ const retained = successful ?? partial;
230
+ return {
231
+ nameserver,
232
+ addressSource: glue.length ? 'registry_glue' : 'recursive_address',
233
+ addresses,
234
+ state: successful
235
+ ? 'success'
236
+ : partial
237
+ ? 'partial'
238
+ : attempts.some((attempt) => attempt.state === 'lame')
239
+ ? 'lame'
240
+ : 'unreachable',
241
+ nameservers: retained?.nameservers ?? [],
242
+ soaPrimary: retained?.soaPrimary ?? null,
243
+ attempts,
244
+ };
245
+ }));
246
+ const successfulAuthorities = authorities.filter((authority) => authority.state === 'success');
247
+ const partialAuthorities = authorities.filter((authority) => authority.state === 'partial');
248
+ const lameAuthorities = authorities.filter((authority) => authority.state === 'lame');
249
+ const unreachableAuthorities = authorities.filter((authority) => authority.state === 'unreachable');
250
+ const authoritySets = successfulAuthorities.map((authority) => authority.nameservers);
251
+ const inconsistentAuthoritySets = authoritySets.some((set) => (!sameSet(set, authoritySets[0] ?? [])
252
+ || (parentNameservers.length > 0 && !sameSet(set, parentNameservers))));
253
+ const inBailiwick = candidates.filter((nameserver) => (nameserver === domain || nameserver.endsWith(`.${domain}`)));
254
+ const missingGlue = inBailiwick.filter((nameserver) => (!(registry.nameserverDetails.find((item) => item.name === nameserver)?.addresses.length)));
255
+ const dnssecMismatch = (registry.delegationSigned === true && registry.dsRecordCount === 0) || (registry.delegationSigned === false && registry.dsRecordCount > 0);
256
+ const findings = [
257
+ finding('parent_registry_ns', 'Parent and registry nameservers', !parentNameservers.length || !registry.nameservers.length
258
+ ? 'unknown'
259
+ : sameSet(parentNameservers, registry.nameservers) ? 'healthy' : 'warning', !parentNameservers.length || !registry.nameservers.length
260
+ ? 'Comparison is incomplete'
261
+ : sameSet(parentNameservers, registry.nameservers)
262
+ ? 'Parent view and registry publication agree'
263
+ : 'Parent view and registry publication differ', `Parent view: ${parentNameservers.join(', ') || 'unavailable'}. Registry publication: ${registry.nameservers.join(', ') || 'unavailable'}.`, 'Confirm the intended delegation with the registry and DNS operator before changing nameservers.'),
264
+ finding('authority_reachability', 'Direct nameserver reachability', lameAuthorities.length ? 'danger'
265
+ : partialAuthorities.length || unreachableAuthorities.length ? 'warning'
266
+ : successfulAuthorities.length ? 'healthy' : 'unknown', lameAuthorities.length
267
+ ? `${lameAuthorities.length} nameserver${lameAuthorities.length === 1 ? '' : 's'} refused or was not authoritative`
268
+ : partialAuthorities.length
269
+ ? `${partialAuthorities.length} nameserver${partialAuthorities.length === 1 ? '' : 's'} returned only part of the direct NS and SOA evidence`
270
+ : unreachableAuthorities.length
271
+ ? `${unreachableAuthorities.length} nameserver${unreachableAuthorities.length === 1 ? '' : 's'} could not be confirmed`
272
+ : successfulAuthorities.length
273
+ ? 'Selected nameservers answered direct NS and SOA queries'
274
+ : 'No eligible public nameserver address was available', `Successful: ${successfulAuthorities.length}. Partial: ${partialAuthorities.length}. Lame or refused: ${lameAuthorities.length}. Unreachable or unresolved: ${unreachableAuthorities.length}.`, 'Restore authoritative service on every delegated nameserver and verify public address reachability before relying on the delegation.'),
275
+ finding('authority_ns_consistency', 'Authoritative nameserver agreement', !successfulAuthorities.length ? 'unknown'
276
+ : inconsistentAuthoritySets ? 'warning' : 'healthy', !successfulAuthorities.length
277
+ ? 'No direct authority answer was available'
278
+ : inconsistentAuthoritySets
279
+ ? 'Direct nameserver answers are inconsistent'
280
+ : 'Direct nameserver answers agree with the observed parent view', successfulAuthorities.map((authority) => `${authority.nameserver}: ${authority.nameservers.join(', ') || 'no NS answer'}`).join(' | ') || 'No direct answers.', 'Align the NS set served by every authority with the intended parent delegation before cutover.'),
281
+ finding('in_bailiwick_glue', 'In-bailiwick glue', !inBailiwick.length ? 'healthy' : missingGlue.length ? 'warning' : 'healthy', !inBailiwick.length
282
+ ? 'No in-bailiwick nameserver requires registry glue'
283
+ : missingGlue.length
284
+ ? 'Registry glue was not observed for every in-bailiwick nameserver'
285
+ : 'Registry publication includes observed glue for each in-bailiwick nameserver', missingGlue.length ? `Missing observed glue: ${missingGlue.join(', ')}.` : `In-bailiwick nameservers: ${inBailiwick.join(', ') || 'none'}.`, 'Publish current A and AAAA glue at the registry for every in-bailiwick nameserver.'),
286
+ finding('dnssec_delegation', 'DNSSEC delegation publication', dnssecMismatch ? 'warning'
287
+ : registry.delegationSigned === null && registry.dsRecordCount === 0 ? 'unknown' : 'healthy', dnssecMismatch
288
+ ? 'Registry DNSSEC fields are internally inconsistent'
289
+ : registry.delegationSigned === null && registry.dsRecordCount === 0
290
+ ? 'Registry DNSSEC publication was unavailable'
291
+ : registry.delegationSigned
292
+ ? `${registry.dsRecordCount} registry DS record${registry.dsRecordCount === 1 ? '' : 's'} observed`
293
+ : 'Registry publication marks the delegation unsigned', `delegationSigned: ${registry.delegationSigned === null ? 'unavailable' : registry.delegationSigned}. DS records: ${registry.dsRecordCount}.`, 'Confirm the registry DS set matches the intended zone-signing keys before enabling, rotating, or removing DNSSEC.'),
294
+ ];
295
+ const collectionIncomplete = parentStatus === 'error'
296
+ || parentQuery.truncated === true
297
+ || registry.truncated
298
+ || authorities.some((authority) => authority.state !== 'success');
299
+ const status = !candidates.length && parentStatus === 'error'
300
+ ? 'error'
301
+ : collectionIncomplete ? 'partial' : 'success';
302
+ const observedAt = (options.observedAt || (() => new Date().toISOString()))();
303
+ return {
304
+ delegationHealthVersion: DNS_DELEGATION_HEALTH_VERSION,
305
+ ...createObservation({
306
+ status,
307
+ observedAt,
308
+ scanMode: 'deep',
309
+ source: 'dns_delegation',
310
+ durationMs: Math.max(0, now() - started),
311
+ complete: !collectionIncomplete,
312
+ truncated: parentQuery.truncated === true || registry.truncated,
313
+ limitations: [
314
+ 'The parent nameserver set is a point-in-time recursive resolver view; registry nameservers and glue come from the separately attributed RDAP publication.',
315
+ 'Direct queries use at most four selected nameservers and two validated public addresses per nameserver.',
316
+ 'A direct answer does not prove global reachability, and a failed query is not evidence that the record is absent.',
317
+ 'DNS health does not decide registration availability, ownership, control, intent, safety, or maliciousness.',
318
+ 'The DNSSEC check compares bounded registry publication fields; it does not validate the full cryptographic chain.',
319
+ ],
320
+ diagnostics: {
321
+ authorityCount: authorities.length,
322
+ queriedAddressCount: authorities.reduce((sum, authority) => sum + authority.attempts.length, 0),
323
+ successfulAuthorityCount: successfulAuthorities.length,
324
+ partialAuthorityCount: partialAuthorities.length,
325
+ lameAuthorityCount: lameAuthorities.length,
326
+ unreachableAuthorityCount: unreachableAuthorities.length,
327
+ },
328
+ }),
329
+ detail: status === 'success'
330
+ ? 'The bounded delegation-health collection completed.'
331
+ : status === 'partial'
332
+ ? 'The delegation-health collection is partial; review each source state before changing DNS.'
333
+ : 'The delegation-health collection could not confirm a parent or direct authority view.',
334
+ parent: {
335
+ state: parentStatus,
336
+ nameservers: parentNameservers,
337
+ error: parentStatus === 'error' ? boundedText(parentQuery.error, MAX_ERROR_LENGTH) : null,
338
+ },
339
+ registry,
340
+ authorities,
341
+ findings,
342
+ };
343
+ }
344
+ export { DNS_DELEGATION_HEALTH_VERSION, DNS_DELEGATION_TIMEOUT_MS, MAX_AUTHORITIES, MAX_AUTHORITY_ADDRESSES, collectDnsDelegationHealth, skippedDnsDelegationHealth, };