@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,739 @@
1
+ // Bounded DNS evidence for investigative deep scans. This collector keeps
2
+ // authoritative absence distinct from resolver failure, preserves only record
3
+ // types that aid domain triage, and never treats shared infrastructure as proof
4
+ // of common ownership or maliciousness.
5
+ import { promises as dns } from 'node:dns';
6
+ import * as net from 'node:net';
7
+ import { classifyMxRecords } from "./dns-mx.mjs";
8
+ import { collectDnsDelegationHealth, skippedDnsDelegationHealth, } from "./dns-delegation-health.mjs";
9
+ import { createObservation } from "./observation.mjs";
10
+ import { isPrivateAddress } from "./safe-fetch.mjs";
11
+ import { resolveServiceBindingRecords } from "./service-binding-dns.mjs";
12
+ const DNS_TIMEOUT_MS = 5000;
13
+ const MAX_RECORDS_PER_TYPE = 16;
14
+ const MAX_PTR_RECORDS = 8;
15
+ const MAX_HOSTNAME_LENGTH = 253;
16
+ const MAX_POLICY_LENGTH = 1024;
17
+ const MAX_ERROR_LENGTH = 180;
18
+ const MAX_DNS_UINT32 = 0xffff_ffff;
19
+ const MAX_SERVICE_PARAMETER_KEYS = 24;
20
+ const MAX_SERVICE_ALPN_IDS = 16;
21
+ const MAX_SERVICE_ADDRESS_HINTS = 8;
22
+ const MAX_CAA_TREE_QUERIES = 8;
23
+ const DNS_CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f]/u;
24
+ const MISSING_CODES = new Set(['ENODATA', 'ENOTFOUND', 'ENONAME']);
25
+ function skippedDnsIntelligence(detail = 'DNS intelligence is disabled by deployment policy.', { includeExtendedContext = false, includeInheritedCaa = false, } = {}) {
26
+ const skipped = { status: 'skipped', error: null, truncated: false, discarded: 0 };
27
+ const recordTypes = ['a', 'aaaa', 'cname', 'ns', 'mx', 'spf', 'dmarc', 'caa'];
28
+ if (includeExtendedContext)
29
+ recordTypes.push('soa', 'https');
30
+ const diagnostics = Object.fromEntries(recordTypes
31
+ .map((name) => [name, { ...skipped }]));
32
+ return {
33
+ ...createObservation({
34
+ status: 'skipped',
35
+ scanMode: 'deep',
36
+ source: 'dns',
37
+ complete: false,
38
+ limitations: [detail],
39
+ diagnostics,
40
+ }),
41
+ records: {
42
+ a: [], aaaa: [], cname: [], ns: [], mx: [], spf: [], dmarc: [], caa: [],
43
+ ...(includeExtendedContext ? { soa: [], https: [] } : {}),
44
+ },
45
+ ...(includeInheritedCaa ? {
46
+ caaPolicy: {
47
+ ...createObservation({
48
+ status: 'skipped',
49
+ scanMode: 'deep',
50
+ source: 'dns',
51
+ complete: false,
52
+ limitations: [detail],
53
+ diagnostics: {
54
+ tree: { status: 'skipped', count: 0, truncated: false },
55
+ },
56
+ }),
57
+ policyVersion: 1,
58
+ queryLimit: MAX_CAA_TREE_QUERIES,
59
+ queriedOwners: [],
60
+ effectiveOwner: null,
61
+ inherited: null,
62
+ records: [],
63
+ },
64
+ } : {}),
65
+ hasMx: null,
66
+ hasNullMx: null,
67
+ mxHosts: [],
68
+ hasSpf: null,
69
+ hasDmarc: null,
70
+ delegation: includeExtendedContext
71
+ ? skippedDnsDelegationHealth(detail)
72
+ : null,
73
+ };
74
+ }
75
+ function skippedReverseDnsIntelligence(detail = 'Reverse DNS intelligence is disabled by deployment policy.') {
76
+ return {
77
+ ...createObservation({
78
+ status: 'skipped',
79
+ scanMode: 'deep',
80
+ source: 'reverse_dns',
81
+ complete: false,
82
+ limitations: [detail],
83
+ diagnostics: {
84
+ ptr: { status: 'skipped', error: null, truncated: false, discarded: 0 },
85
+ },
86
+ }),
87
+ records: { ptr: [] },
88
+ };
89
+ }
90
+ function failedReverseDnsIntelligence(error) {
91
+ const detail = boundedError(error);
92
+ return {
93
+ ...createObservation({
94
+ status: 'error',
95
+ scanMode: 'deep',
96
+ source: 'reverse_dns',
97
+ complete: false,
98
+ limitations: [
99
+ 'Reverse DNS collection failed. Resolver failure is not evidence that no PTR record exists.',
100
+ ],
101
+ diagnostics: {
102
+ ptr: { status: 'error', error: detail, truncated: false, discarded: 0 },
103
+ },
104
+ }),
105
+ records: { ptr: [] },
106
+ };
107
+ }
108
+ function boundedError(error) {
109
+ return String(error instanceof Error ? error.message : error || 'DNS query failed').replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, MAX_ERROR_LENGTH);
110
+ }
111
+ function normalizeHostname(value) {
112
+ const hostname = String(value || '').trim().toLowerCase().replace(/\.+$/, '');
113
+ if (!hostname || hostname.length > MAX_HOSTNAME_LENGTH || /[\u0000-\u0020\u007f]/.test(hostname))
114
+ return null;
115
+ if (!hostname.split('.').every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(label)))
116
+ return null;
117
+ return hostname;
118
+ }
119
+ function boundedUnique(values, limit = MAX_RECORDS_PER_TYPE) {
120
+ const unique = [...new Set(values)].sort();
121
+ return { records: unique.slice(0, limit), truncated: unique.length > limit };
122
+ }
123
+ function normalizeAddresses(records, family) {
124
+ const values = [];
125
+ let discarded = 0;
126
+ for (const record of Array.isArray(records) ? records : []) {
127
+ const value = typeof record === 'string' ? record : record && typeof record === 'object' ? record.address : null;
128
+ if (typeof value !== 'string' || net.isIP(value) !== family)
129
+ discarded += 1;
130
+ else
131
+ values.push(value.toLowerCase());
132
+ }
133
+ return { ...boundedUnique(values), discarded };
134
+ }
135
+ function normalizeHostnames(records) {
136
+ const values = [];
137
+ let discarded = 0;
138
+ for (const record of Array.isArray(records) ? records : []) {
139
+ const value = normalizeHostname(record);
140
+ if (value)
141
+ values.push(value);
142
+ else
143
+ discarded += 1;
144
+ }
145
+ return { ...boundedUnique(values), discarded };
146
+ }
147
+ function normalizeMx(records) {
148
+ const byKey = new Map();
149
+ let discarded = 0;
150
+ for (const record of Array.isArray(records) ? records : []) {
151
+ if (!record || typeof record !== 'object') {
152
+ discarded += 1;
153
+ continue;
154
+ }
155
+ const value = record;
156
+ const exchange = value.exchange === '' || value.exchange === '.' ? '' : normalizeHostname(value.exchange);
157
+ const priority = Number(value.priority);
158
+ if (exchange === null || !Number.isInteger(priority) || priority < 0 || priority > 65535) {
159
+ discarded += 1;
160
+ continue;
161
+ }
162
+ byKey.set(`${priority}:${exchange}`, { priority, exchange });
163
+ }
164
+ const values = [...byKey.values()].sort((a, b) => a.priority - b.priority || a.exchange.localeCompare(b.exchange));
165
+ return { records: values.slice(0, MAX_RECORDS_PER_TYPE), truncated: values.length > MAX_RECORDS_PER_TYPE, discarded };
166
+ }
167
+ function normalizeTxtPolicies(records, prefix) {
168
+ const values = [];
169
+ let discarded = 0;
170
+ for (const chunks of Array.isArray(records) ? records : []) {
171
+ if (!Array.isArray(chunks) || chunks.some((chunk) => typeof chunk !== 'string')) {
172
+ discarded += 1;
173
+ continue;
174
+ }
175
+ const value = chunks.join('').trim();
176
+ if (!value.toLowerCase().startsWith(prefix) || !value || value.length > MAX_POLICY_LENGTH || /[\u0000-\u001f\u007f]/.test(value)) {
177
+ if (value.toLowerCase().startsWith(prefix))
178
+ discarded += 1;
179
+ continue;
180
+ }
181
+ values.push(value);
182
+ }
183
+ return { ...boundedUnique(values), discarded };
184
+ }
185
+ function normalizeCaa(records) {
186
+ const byKey = new Map();
187
+ let discarded = 0;
188
+ for (const record of Array.isArray(records) ? records : []) {
189
+ const entry = record && typeof record === 'object' ? record : {};
190
+ const critical = Number(entry.critical);
191
+ const tag = String(entry.tag || '').trim().toLowerCase();
192
+ const value = String(entry.value || '').trim();
193
+ if (!Number.isInteger(critical) || critical < 0 || critical > 255 || !/^[a-z0-9-]{1,15}$/.test(tag) || !value || value.length > MAX_POLICY_LENGTH || /[\u0000-\u001f\u007f]/.test(value)) {
194
+ discarded += 1;
195
+ continue;
196
+ }
197
+ byKey.set(`${critical}:${tag}:${value}`, { critical, tag, value });
198
+ }
199
+ const values = [...byKey.values()].sort((a, b) => a.tag.localeCompare(b.tag) || a.value.localeCompare(b.value) || a.critical - b.critical);
200
+ return { records: values.slice(0, MAX_RECORDS_PER_TYPE), truncated: values.length > MAX_RECORDS_PER_TYPE, discarded };
201
+ }
202
+ function dnsUint32(value) {
203
+ const number = Number(value);
204
+ return Number.isSafeInteger(number) && number >= 0 && number <= MAX_DNS_UINT32
205
+ ? number
206
+ : null;
207
+ }
208
+ function normalizeSoa(record) {
209
+ const entry = record && typeof record === 'object' && !Array.isArray(record)
210
+ ? record
211
+ : {};
212
+ const nsname = normalizeHostname(entry.nsname);
213
+ const hostmaster = normalizeHostname(entry.hostmaster);
214
+ const serial = dnsUint32(entry.serial);
215
+ const refresh = dnsUint32(entry.refresh);
216
+ const retry = dnsUint32(entry.retry);
217
+ const expire = dnsUint32(entry.expire);
218
+ const minttl = dnsUint32(entry.minttl);
219
+ if (!nsname || !hostmaster || [serial, refresh, retry, expire, minttl].some((value) => value === null)) {
220
+ return { records: [], truncated: false, discarded: 1 };
221
+ }
222
+ return {
223
+ records: [{
224
+ nsname,
225
+ hostmaster,
226
+ serial: serial,
227
+ refresh: refresh,
228
+ retry: retry,
229
+ expire: expire,
230
+ minttl: minttl,
231
+ }],
232
+ truncated: false,
233
+ discarded: 0,
234
+ };
235
+ }
236
+ function boundedUint16List(value, limit) {
237
+ if (!Array.isArray(value))
238
+ return null;
239
+ const values = [];
240
+ for (const item of value.slice(0, limit)) {
241
+ const number = Number(item);
242
+ if (!Number.isInteger(number) || number < 0 || number > 0xffff)
243
+ return null;
244
+ values.push(number);
245
+ }
246
+ return values;
247
+ }
248
+ function boundedAlpnList(value) {
249
+ if (!Array.isArray(value))
250
+ return null;
251
+ const values = [];
252
+ for (const item of value.slice(0, MAX_SERVICE_ALPN_IDS)) {
253
+ if (typeof item !== 'string'
254
+ || !item
255
+ || item.length > 132
256
+ || DNS_CONTROL_CHARACTER_RE.test(item)
257
+ || !/^(?:hex:[0-9a-f]+|[\x21-\x7e]+)$/u.test(item))
258
+ return null;
259
+ values.push(item);
260
+ }
261
+ return values;
262
+ }
263
+ function boundedAddressHints(value, family) {
264
+ if (!Array.isArray(value))
265
+ return null;
266
+ const values = [];
267
+ for (const item of value.slice(0, MAX_SERVICE_ADDRESS_HINTS)) {
268
+ if (typeof item !== 'string' || net.isIP(item) !== family)
269
+ return null;
270
+ values.push(item.toLowerCase());
271
+ }
272
+ return values;
273
+ }
274
+ function normalizeServiceBindings(value) {
275
+ const envelope = value && typeof value === 'object' && !Array.isArray(value)
276
+ ? value
277
+ : null;
278
+ const input = Array.isArray(value)
279
+ ? value
280
+ : Array.isArray(envelope?.records)
281
+ ? envelope.records
282
+ : [];
283
+ const records = [];
284
+ let discarded = 0;
285
+ let truncated = envelope?.truncated === true || input.length > MAX_RECORDS_PER_TYPE;
286
+ for (const candidate of input.slice(0, MAX_RECORDS_PER_TYPE)) {
287
+ const record = candidate && typeof candidate === 'object' && !Array.isArray(candidate)
288
+ ? candidate
289
+ : {};
290
+ const parameters = record.parameters && typeof record.parameters === 'object' && !Array.isArray(record.parameters)
291
+ ? record.parameters
292
+ : {};
293
+ const owner = normalizeHostname(record.owner);
294
+ const target = record.target === null ? null : normalizeHostname(record.target);
295
+ const ttl = dnsUint32(record.ttl);
296
+ const priority = Number(record.priority);
297
+ const mode = record.mode;
298
+ const mandatory = boundedUint16List(parameters.mandatory, MAX_SERVICE_PARAMETER_KEYS);
299
+ const alpn = boundedAlpnList(parameters.alpn);
300
+ const ipv4hint = boundedAddressHints(parameters.ipv4hint, 4);
301
+ const ipv6hint = boundedAddressHints(parameters.ipv6hint, 6);
302
+ const unknownKeys = boundedUint16List(parameters.unknownKeys, MAX_SERVICE_PARAMETER_KEYS);
303
+ const unsupportedMandatoryKeys = boundedUint16List(parameters.unsupportedMandatoryKeys, MAX_SERVICE_PARAMETER_KEYS);
304
+ const port = parameters.port === null ? null : Number(parameters.port);
305
+ const opaqueInput = Array.isArray(parameters.opaque) ? parameters.opaque : [];
306
+ const opaque = [];
307
+ let invalidOpaque = opaqueInput.length > MAX_SERVICE_PARAMETER_KEYS;
308
+ for (const item of opaqueInput.slice(0, MAX_SERVICE_PARAMETER_KEYS)) {
309
+ const entry = item && typeof item === 'object' && !Array.isArray(item)
310
+ ? item
311
+ : {};
312
+ const key = Number(entry.key);
313
+ const length = Number(entry.length);
314
+ const name = entry.name === null ? null : String(entry.name || '');
315
+ if (!Number.isInteger(key) || key < 0 || key > 0xffff
316
+ || !Number.isInteger(length) || length < 0 || length > 0xffff
317
+ || (name !== null && !/^[a-z0-9-]{1,63}$/u.test(name))) {
318
+ invalidOpaque = true;
319
+ break;
320
+ }
321
+ opaque.push({ key, name, length });
322
+ }
323
+ if (record.type !== 'HTTPS'
324
+ || !owner
325
+ || (record.serviceUnavailable !== true && !target)
326
+ || ttl === null
327
+ || !Number.isInteger(priority) || priority < 0 || priority > 0xffff
328
+ || (mode !== 'alias' && mode !== 'service')
329
+ || mode !== (priority === 0 ? 'alias' : 'service')
330
+ || typeof record.targetIsOwner !== 'boolean'
331
+ || typeof record.serviceUnavailable !== 'boolean'
332
+ || typeof record.compatible !== 'boolean'
333
+ || typeof record.parametersIgnored !== 'boolean'
334
+ || !mandatory || !alpn || !ipv4hint || !ipv6hint || !unknownKeys || !unsupportedMandatoryKeys
335
+ || invalidOpaque
336
+ || typeof parameters.noDefaultAlpn !== 'boolean'
337
+ || (port !== null && (!Number.isInteger(port) || port < 0 || port > 0xffff))) {
338
+ discarded += 1;
339
+ continue;
340
+ }
341
+ records.push({
342
+ type: 'HTTPS',
343
+ owner,
344
+ ttl,
345
+ priority,
346
+ mode,
347
+ target,
348
+ targetIsOwner: record.targetIsOwner,
349
+ serviceUnavailable: record.serviceUnavailable,
350
+ compatible: record.compatible,
351
+ parametersIgnored: record.parametersIgnored,
352
+ parameters: {
353
+ mandatory,
354
+ alpn,
355
+ noDefaultAlpn: parameters.noDefaultAlpn,
356
+ port,
357
+ ipv4hint,
358
+ ipv6hint,
359
+ opaque,
360
+ unknownKeys,
361
+ unsupportedMandatoryKeys,
362
+ },
363
+ });
364
+ }
365
+ const unique = new Map(records.map((record) => [JSON.stringify(record), record]));
366
+ return {
367
+ records: [...unique.values()].sort((left, right) => (left.priority - right.priority
368
+ || left.owner.localeCompare(right.owner)
369
+ || String(left.target).localeCompare(String(right.target))
370
+ || left.ttl - right.ttl)),
371
+ truncated,
372
+ discarded,
373
+ };
374
+ }
375
+ function normalizePtr(records) {
376
+ const normalized = normalizeHostnames(records);
377
+ return {
378
+ records: normalized.records.slice(0, MAX_PTR_RECORDS),
379
+ truncated: normalized.truncated || normalized.records.length > MAX_PTR_RECORDS,
380
+ discarded: normalized.discarded,
381
+ };
382
+ }
383
+ function withTimeout(factory, timeoutMs) {
384
+ return new Promise((resolve, reject) => {
385
+ const timer = setTimeout(() => reject(new Error('DNS query timed out')), timeoutMs);
386
+ Promise.resolve().then(factory).then((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); });
387
+ });
388
+ }
389
+ async function query(factory, normalize, timeoutMs) {
390
+ try {
391
+ const normalized = normalize(await withTimeout(factory, timeoutMs));
392
+ return {
393
+ status: normalized.records.length ? 'success' : 'not_found',
394
+ records: normalized.records,
395
+ error: null,
396
+ truncated: normalized.truncated,
397
+ discarded: normalized.discarded || 0,
398
+ };
399
+ }
400
+ catch (error) {
401
+ if (error && typeof error === 'object' && MISSING_CODES.has(String(error.code)))
402
+ return { status: 'not_found', records: [], error: null, truncated: false, discarded: 0 };
403
+ return { status: 'error', records: [], error: boundedError(error), truncated: false, discarded: 0 };
404
+ }
405
+ }
406
+ async function collectEffectiveCaaPolicy(domain, options = {}) {
407
+ const normalizedDomain = normalizeHostname(domain);
408
+ const queryLimit = Math.max(1, Math.min(MAX_CAA_TREE_QUERIES, Math.floor(options.queryLimit || MAX_CAA_TREE_QUERIES)));
409
+ const now = options.now || Date.now;
410
+ const started = now();
411
+ const limitations = [
412
+ 'The effective CAA policy is the first non-empty RRset found from the exact hostname toward, but not including, the DNS root, following RFC 8659.',
413
+ 'The deployment resolver handles CNAME or DNAME alias processing. WHOISleuth does not independently follow aliases during the parent-tree walk.',
414
+ `The tree walk is capped at ${queryLimit} owner names. Resolver failure is not treated as an empty RRset and stops the walk.`,
415
+ ];
416
+ if (!normalizedDomain) {
417
+ return {
418
+ ...createObservation({
419
+ status: 'error',
420
+ observedAt: (options.observedAt || (() => new Date().toISOString()))(),
421
+ scanMode: 'deep',
422
+ source: 'dns',
423
+ durationMs: Math.max(0, now() - started),
424
+ complete: false,
425
+ limitations,
426
+ diagnostics: {
427
+ tree: { status: 'error', detail: 'The CAA owner name was invalid.', count: 0, truncated: false },
428
+ },
429
+ }),
430
+ policyVersion: 1,
431
+ queryLimit,
432
+ queriedOwners: [],
433
+ effectiveOwner: null,
434
+ inherited: null,
435
+ records: [],
436
+ };
437
+ }
438
+ const labels = normalizedDomain.split('.');
439
+ const allOwners = labels.map((_, index) => labels.slice(index).join('.'));
440
+ const owners = allOwners.slice(0, queryLimit);
441
+ const limitReached = allOwners.length > owners.length;
442
+ const resolver = options.resolver || dns.resolveCaa;
443
+ const timeoutMs = options.timeoutMs || DNS_TIMEOUT_MS;
444
+ const queries = [];
445
+ for (const [index, owner] of owners.entries()) {
446
+ const result = index === 0 && options.directResult
447
+ ? options.directResult
448
+ : await query(() => resolver(owner), normalizeCaa, timeoutMs);
449
+ queries.push({
450
+ owner,
451
+ status: result.status,
452
+ error: result.error,
453
+ truncated: result.truncated,
454
+ discarded: result.discarded,
455
+ });
456
+ if (result.status === 'error') {
457
+ return {
458
+ ...createObservation({
459
+ status: index === 0 ? 'error' : 'partial',
460
+ observedAt: (options.observedAt || (() => new Date().toISOString()))(),
461
+ scanMode: 'deep',
462
+ source: 'dns',
463
+ durationMs: Math.max(0, now() - started),
464
+ complete: false,
465
+ limitations,
466
+ diagnostics: {
467
+ tree: {
468
+ status: index === 0 ? 'error' : 'partial',
469
+ error: result.error,
470
+ count: queries.length,
471
+ truncated: false,
472
+ },
473
+ },
474
+ }),
475
+ policyVersion: 1,
476
+ queryLimit,
477
+ queriedOwners: queries,
478
+ effectiveOwner: null,
479
+ inherited: null,
480
+ records: [],
481
+ };
482
+ }
483
+ if (result.records.length > 0) {
484
+ const incomplete = result.truncated || result.discarded > 0;
485
+ return {
486
+ ...createObservation({
487
+ status: incomplete ? 'partial' : 'success',
488
+ observedAt: (options.observedAt || (() => new Date().toISOString()))(),
489
+ scanMode: 'deep',
490
+ source: 'dns',
491
+ durationMs: Math.max(0, now() - started),
492
+ complete: !incomplete,
493
+ truncated: result.truncated,
494
+ limitations,
495
+ diagnostics: {
496
+ tree: {
497
+ status: incomplete ? 'partial' : 'success',
498
+ detail: owner,
499
+ count: queries.length,
500
+ truncated: result.truncated,
501
+ discarded: result.discarded,
502
+ },
503
+ },
504
+ }),
505
+ policyVersion: 1,
506
+ queryLimit,
507
+ queriedOwners: queries,
508
+ effectiveOwner: owner,
509
+ inherited: owner !== normalizedDomain,
510
+ records: result.records,
511
+ };
512
+ }
513
+ }
514
+ return {
515
+ ...createObservation({
516
+ status: limitReached ? 'partial' : 'not_found',
517
+ observedAt: (options.observedAt || (() => new Date().toISOString()))(),
518
+ scanMode: 'deep',
519
+ source: 'dns',
520
+ durationMs: Math.max(0, now() - started),
521
+ complete: !limitReached,
522
+ truncated: limitReached,
523
+ limitations,
524
+ diagnostics: {
525
+ tree: {
526
+ status: limitReached ? 'partial' : 'not_found',
527
+ detail: limitReached
528
+ ? 'The owner-name query cap was reached before the DNS root.'
529
+ : 'No applicable CAA RRset was observed.',
530
+ count: queries.length,
531
+ truncated: limitReached,
532
+ },
533
+ },
534
+ }),
535
+ policyVersion: 1,
536
+ queryLimit,
537
+ queriedOwners: queries,
538
+ effectiveOwner: null,
539
+ inherited: null,
540
+ records: [],
541
+ };
542
+ }
543
+ async function collectDnsIntelligence(domain, options = {}) {
544
+ const resolvers = options.resolvers || {};
545
+ const includeExtendedContext = options.includeExtendedContext === true;
546
+ const includeInheritedCaa = options.includeInheritedCaa === true;
547
+ const timeoutMs = options.timeoutMs || DNS_TIMEOUT_MS;
548
+ const now = options.now || Date.now;
549
+ const started = now();
550
+ const invoke = (name, fallback, value = domain) => () => (resolvers[name] || fallback)(value);
551
+ const aPromise = query(invoke('resolve4', dns.resolve4), (records) => normalizeAddresses(records, 4), timeoutMs);
552
+ const aaaaPromise = query(invoke('resolve6', dns.resolve6), (records) => normalizeAddresses(records, 6), timeoutMs);
553
+ const cnamePromise = query(invoke('resolveCname', dns.resolveCname), normalizeHostnames, timeoutMs);
554
+ const nsPromise = query(invoke('resolveNs', dns.resolveNs), normalizeHostnames, timeoutMs);
555
+ const mxPromise = query(invoke('resolveMx', dns.resolveMx), normalizeMx, timeoutMs);
556
+ const spfPromise = query(invoke('resolveTxt', dns.resolveTxt), (records) => normalizeTxtPolicies(records, 'v=spf1'), timeoutMs);
557
+ const dmarcPromise = query(invoke('resolveTxt', dns.resolveTxt, `_dmarc.${domain}`), (records) => normalizeTxtPolicies(records, 'v=dmarc1'), timeoutMs);
558
+ const caaPromise = query(invoke('resolveCaa', dns.resolveCaa), normalizeCaa, timeoutMs);
559
+ const caaPolicyPromise = includeInheritedCaa
560
+ ? caaPromise.then((directResult) => collectEffectiveCaaPolicy(domain, {
561
+ resolver: resolvers.resolveCaa || dns.resolveCaa,
562
+ directResult,
563
+ timeoutMs,
564
+ now,
565
+ ...(options.observedAt ? { observedAt: options.observedAt } : {}),
566
+ }))
567
+ : Promise.resolve(null);
568
+ const soaPromise = includeExtendedContext
569
+ ? query(invoke('resolveSoa', dns.resolveSoa), normalizeSoa, timeoutMs)
570
+ : Promise.resolve(null);
571
+ const httpsPromise = includeExtendedContext
572
+ ? query(invoke('resolveHttps', (value) => resolveServiceBindingRecords(value, 'HTTPS', { timeoutMs })), normalizeServiceBindings, timeoutMs)
573
+ : Promise.resolve(null);
574
+ const delegationPromise = includeExtendedContext
575
+ ? nsPromise.then((parentNameservers) => collectDnsDelegationHealth(domain, parentNameservers, {
576
+ registryEvidence: options.registryEvidence,
577
+ resolve4: (resolvers.resolve4 || dns.resolve4),
578
+ resolve6: (resolvers.resolve6 || dns.resolve6),
579
+ ...(options.queryAuthority ? { queryAuthority: options.queryAuthority } : {}),
580
+ timeoutMs,
581
+ now,
582
+ ...(options.observedAt ? { observedAt: options.observedAt } : {}),
583
+ }))
584
+ : Promise.resolve(null);
585
+ const [a, aaaa, cname, ns, mx, spf, dmarc, caa, caaPolicy, soa, https, delegation] = await Promise.all([
586
+ aPromise,
587
+ aaaaPromise,
588
+ cnamePromise,
589
+ nsPromise,
590
+ mxPromise,
591
+ spfPromise,
592
+ dmarcPromise,
593
+ caaPromise,
594
+ caaPolicyPromise,
595
+ soaPromise,
596
+ httpsPromise,
597
+ delegationPromise,
598
+ ]);
599
+ const queries = {
600
+ a, aaaa, cname, ns, mx, spf, dmarc, caa,
601
+ ...(soa ? { soa } : {}),
602
+ ...(https ? { https } : {}),
603
+ };
604
+ const values = Object.values(queries);
605
+ const errorCount = values.filter((item) => item.status === 'error').length;
606
+ const truncated = values.some((item) => item.truncated) || delegation?.truncated === true;
607
+ const discardedCount = values.reduce((sum, item) => sum + item.discarded, 0);
608
+ const incomplete = errorCount > 0
609
+ || truncated
610
+ || discardedCount > 0
611
+ || (caaPolicy !== null && caaPolicy.complete !== true)
612
+ || (delegation !== null && delegation.status !== 'success');
613
+ const classifiedMx = mx.status === 'error' ? null : classifyMxRecords(mx.records);
614
+ return {
615
+ ...createObservation({
616
+ status: errorCount === values.length ? 'error' : incomplete ? 'partial' : 'success',
617
+ observedAt: (options.observedAt || (() => new Date().toISOString()))(),
618
+ scanMode: 'deep',
619
+ source: 'dns',
620
+ durationMs: Math.max(0, now() - started),
621
+ complete: !incomplete,
622
+ truncated,
623
+ limitations: [
624
+ 'DNS answers are point-in-time resolver observations and may change or differ by location.',
625
+ 'CNAME targets are not followed recursively, and shared DNS infrastructure does not prove common ownership.',
626
+ 'Only SPF and DMARC policy TXT records are retained; unrelated TXT records are discarded.',
627
+ ...(soa ? ['SOA publication is operator context and does not prove hosting control, ownership, intent, or maliciousness.'] : []),
628
+ ...(https ? [
629
+ 'HTTPS service-binding records are published connection hints. WHOISleuth does not follow their aliases or connect to their targets, ports, or address hints.',
630
+ 'SVCB uses protocol-specific underscored query names. The domain lookup therefore queries the HTTPS-compatible record only; the shared resolver supports SVCB for explicit future service queries.',
631
+ ] : []),
632
+ ...(delegation ? ['Delegation health compares separately attributed registry, recursive parent-view, and direct nameserver evidence and never decides domain availability.'] : []),
633
+ ...(caaPolicy ? ['Effective CAA is separately attributed from the exact-owner CAA answer and never affects availability or Risk.'] : []),
634
+ ],
635
+ diagnostics: {
636
+ ...Object.fromEntries(Object.entries(queries).map(([name, item]) => [name, {
637
+ status: item.status,
638
+ error: item.error,
639
+ truncated: item.truncated,
640
+ discarded: item.discarded,
641
+ }])),
642
+ ...(delegation ? {
643
+ delegation: {
644
+ status: delegation.status,
645
+ truncated: delegation.truncated,
646
+ count: delegation.authorities.length,
647
+ },
648
+ } : {}),
649
+ ...(caaPolicy ? {
650
+ caa_policy: {
651
+ status: caaPolicy.status,
652
+ detail: caaPolicy.effectiveOwner || 'No effective owner observed',
653
+ truncated: caaPolicy.truncated,
654
+ count: caaPolicy.queriedOwners.length,
655
+ },
656
+ } : {}),
657
+ },
658
+ }),
659
+ records: {
660
+ a: a.records,
661
+ aaaa: aaaa.records,
662
+ cname: cname.records,
663
+ ns: ns.records,
664
+ mx: mx.records,
665
+ spf: spf.records,
666
+ dmarc: dmarc.records,
667
+ caa: caa.records,
668
+ ...(soa ? { soa: soa.records } : {}),
669
+ ...(https ? { https: https.records } : {}),
670
+ },
671
+ hasMx: classifiedMx ? classifiedMx.hasMx : null,
672
+ hasNullMx: classifiedMx ? classifiedMx.hasNullMx : null,
673
+ mxHosts: classifiedMx ? classifiedMx.mxHosts : [],
674
+ hasSpf: spf.status === 'error' ? null : spf.records.length > 0,
675
+ hasDmarc: dmarc.status === 'error' ? null : dmarc.records.length > 0,
676
+ delegation,
677
+ ...(caaPolicy ? { caaPolicy } : {}),
678
+ };
679
+ }
680
+ async function collectReverseDnsIntelligence(address, options = {}) {
681
+ const eligible = options.isEligibleAddress
682
+ || ((value) => net.isIP(value) !== 0 && !isPrivateAddress(value));
683
+ if (!eligible(address)) {
684
+ return {
685
+ ...createObservation({
686
+ status: 'unsupported',
687
+ scanMode: 'deep',
688
+ source: 'reverse_dns',
689
+ complete: false,
690
+ limitations: [
691
+ 'Reverse DNS is collected only for validated public IP addresses.',
692
+ ],
693
+ diagnostics: {
694
+ ptr: {
695
+ status: 'unsupported',
696
+ detail: 'The address is invalid, private, reserved, or otherwise outside the public PTR lookup boundary.',
697
+ truncated: false,
698
+ discarded: 0,
699
+ },
700
+ },
701
+ }),
702
+ records: { ptr: [] },
703
+ };
704
+ }
705
+ const timeoutMs = options.timeoutMs || DNS_TIMEOUT_MS;
706
+ const now = options.now || Date.now;
707
+ const started = now();
708
+ const ptr = await query(() => (options.resolver || dns.resolvePtr)(address), normalizePtr, timeoutMs);
709
+ const incomplete = ptr.status === 'error' || ptr.truncated || ptr.discarded > 0;
710
+ return {
711
+ ...createObservation({
712
+ status: ptr.status === 'error'
713
+ ? 'error'
714
+ : incomplete
715
+ ? 'partial'
716
+ : ptr.status,
717
+ observedAt: (options.observedAt || (() => new Date().toISOString()))(),
718
+ scanMode: 'deep',
719
+ source: 'reverse_dns',
720
+ durationMs: Math.max(0, now() - started),
721
+ complete: !incomplete,
722
+ truncated: ptr.truncated,
723
+ limitations: [
724
+ 'PTR records are point-in-time reverse-DNS publications controlled by the address operator.',
725
+ 'A PTR name can be absent, stale, generic, or misleading and does not prove hosting control, ownership, service identity, intent, or maliciousness.',
726
+ ],
727
+ diagnostics: {
728
+ ptr: {
729
+ status: ptr.status,
730
+ error: ptr.error,
731
+ truncated: ptr.truncated,
732
+ discarded: ptr.discarded,
733
+ },
734
+ },
735
+ }),
736
+ records: { ptr: ptr.records },
737
+ };
738
+ }
739
+ export { collectDnsIntelligence, collectEffectiveCaaPolicy, collectReverseDnsIntelligence, failedReverseDnsIntelligence, skippedDnsIntelligence, skippedReverseDnsIntelligence, normalizeAddresses, normalizeHostnames, normalizeMx, normalizeTxtPolicies, normalizeCaa, normalizeSoa, normalizeServiceBindings, normalizePtr, };