@slicedearth/whoisleuth-cli 2.2.0 → 2.4.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 (282) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +20 -15
  3. package/bin/whoisleuth.mjs +8 -1
  4. package/cli/archive-content-digest.mjs +14 -0
  5. package/cli/archive-inspect.mjs +20 -53
  6. package/cli/arguments.mjs +454 -1748
  7. package/cli/artifact-validation/case-response.mjs +66 -35
  8. package/cli/artifact-validation/investigation-capsule.mjs +52 -31
  9. package/cli/artifact-validation/lookup-evidence.mjs +12 -6
  10. package/cli/artifact-validation/signed-review.mjs +112 -72
  11. package/cli/artifact-validation/structure-primitives.mjs +1 -105
  12. package/cli/artifact-verify.mjs +90 -24
  13. package/cli/assurance-command-runner.mjs +39 -4
  14. package/cli/bagit-folder.mjs +57 -0
  15. package/cli/bagit-review.mjs +23 -0
  16. package/cli/bounded-json.mjs +1 -1
  17. package/cli/bulk-checkpoint.mjs +34 -3
  18. package/cli/bulk-command-runner.mjs +2 -0
  19. package/cli/bulk-output.mjs +29 -3
  20. package/cli/case-command.mjs +195 -0
  21. package/cli/case-pack.mjs +66 -111
  22. package/cli/command-argument-grammar.mjs +215 -0
  23. package/cli/command-reference.mjs +342 -285
  24. package/cli/completion.mjs +303 -80
  25. package/cli/config-profile.mjs +7 -5
  26. package/cli/ct-event-intake.mjs +12 -12
  27. package/cli/discover.mjs +4 -3
  28. package/cli/discovery-observation-snapshot.mjs +59 -18
  29. package/cli/discovery-scan-command-runner.mjs +21 -0
  30. package/cli/discovery-scan.mjs +13 -4
  31. package/cli/discovery-workflow.mjs +1 -1
  32. package/cli/domain-control-monitor.mjs +50 -20
  33. package/cli/domain-control-observations.mjs +149 -102
  34. package/cli/errors.mjs +33 -1
  35. package/cli/evidence-command-runner.mjs +16 -5
  36. package/cli/evidence-signing.mjs +2 -2
  37. package/cli/external-observation-mapping.mjs +7 -8
  38. package/cli/formatters/terminal-lookup.mjs +18 -46
  39. package/cli/formatters/terminal-shared.mjs +5 -3
  40. package/cli/formatters/terminal-technology.mjs +114 -0
  41. package/cli/inline-command-families.mjs +2 -0
  42. package/cli/input.mjs +116 -0
  43. package/cli/interactive-launcher.mjs +2 -160
  44. package/cli/interchange-report.mjs +18 -8
  45. package/cli/investigation-artifacts.mjs +176 -0
  46. package/cli/investigation-checkpoint.mjs +10 -0
  47. package/cli/investigation-folder.mjs +114 -0
  48. package/cli/investigation-manifest.mjs +1 -112
  49. package/cli/investigation-package-review.mjs +103 -0
  50. package/cli/investigation-plan.mjs +32 -19
  51. package/cli/investigation-run.mjs +188 -33
  52. package/cli/local-document-checkpoint.mjs +170 -0
  53. package/cli/lookalike-calibration.mjs +7 -2
  54. package/cli/lookup-brief.mjs +4 -2
  55. package/cli/lookup-browser-view.mjs +1 -1
  56. package/cli/lookup-browser.mjs +5 -12
  57. package/cli/lookup-command-runner.mjs +17 -6
  58. package/cli/lookup-diff.mjs +37 -3
  59. package/cli/lookup-plan.mjs +11 -5
  60. package/cli/lookup-reconcile.mjs +10 -8
  61. package/cli/mail-header-review.mjs +467 -0
  62. package/cli/mail-review.mjs +66 -14
  63. package/cli/manual.mjs +1 -0
  64. package/cli/network-command-runner.mjs +2 -0
  65. package/cli/output-file.mjs +31 -5
  66. package/cli/registry-doctor.mjs +3 -1
  67. package/cli/registry-support.mjs +1 -1
  68. package/cli/retained-artifact-diff.mjs +2 -2
  69. package/cli/review-command-runner.mjs +70 -0
  70. package/cli/risk-calibration.mjs +19 -17
  71. package/cli/runner.mjs +62 -7
  72. package/cli/sharing-review.mjs +21 -34
  73. package/cli/signer-trust.mjs +104 -0
  74. package/cli/source-reliability.mjs +74 -83
  75. package/cli/terminal-input.mjs +168 -0
  76. package/cli/workflow-command-runner.mjs +68 -13
  77. package/docs/cli-reference.md +77 -24
  78. package/docs/cli.md +300 -25
  79. package/lib/abort.mjs +4 -1
  80. package/lib/availability.mjs +161 -112
  81. package/lib/bounded-file.mjs +3 -1
  82. package/lib/bounded-html-document.mjs +125 -0
  83. package/lib/bounded-json.mjs +51 -8
  84. package/lib/browser-library-profile.mjs +42 -51
  85. package/lib/browser-library-worker.mjs +112 -0
  86. package/lib/client-behavior-profile.mjs +3 -3
  87. package/lib/ct-search.mjs +11 -4
  88. package/lib/dns-convergence-review.mjs +1 -0
  89. package/lib/dns-delegation-health.mjs +24 -14
  90. package/lib/dns-inheritance-review.mjs +217 -0
  91. package/lib/dns-intelligence.mjs +73 -26
  92. package/lib/dns-mx.mjs +1 -1
  93. package/lib/dnssec-chain-validation.mjs +6 -0
  94. package/lib/dnssec-evidence-validation.mjs +11 -3
  95. package/lib/domain-change-packet.mjs +49 -16
  96. package/lib/domain-change-review.mjs +14 -8
  97. package/lib/domain-control-flight-recorder.mjs +221 -117
  98. package/lib/domain-control-manifest.mjs +129 -69
  99. package/lib/domain-posture-analysis.mjs +20 -7
  100. package/lib/domain-posture-parsers.mjs +201 -8
  101. package/lib/domain-posture.mjs +147 -29
  102. package/lib/evidence-export-privacy.mjs +561 -0
  103. package/lib/evidence-export.mjs +11 -543
  104. package/lib/evidence-report.mjs +5 -3
  105. package/lib/favicon.mjs +117 -41
  106. package/lib/generated/cisa-kev-catalog.mjs +12 -4
  107. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  108. package/lib/generated/retire-browser-catalog.mjs +2335 -940
  109. package/lib/hostname.mjs +2 -38
  110. package/lib/html-signals.mjs +111 -108
  111. package/lib/http-evidence-bounds.mjs +2 -1
  112. package/lib/http-intelligence.mjs +39 -26
  113. package/lib/local-geoip-evidence.mjs +19 -3
  114. package/lib/lookup-cache.mjs +34 -11
  115. package/lib/lookup-child-profile-contract.mjs +24 -15
  116. package/lib/lookup-diagnostics.mjs +24 -1
  117. package/lib/lookup-progress.mjs +30 -13
  118. package/lib/lookup-response-contract.mjs +1048 -0
  119. package/lib/lookup-response.mjs +8 -10
  120. package/lib/lookup-source-progress.mjs +40 -14
  121. package/lib/lookup.mjs +48 -27
  122. package/lib/nameserver-preflight-review.mjs +1 -0
  123. package/lib/observed-network-context.mjs +43 -27
  124. package/lib/outbound-request-bounds.mjs +4 -1
  125. package/lib/page-fingerprints.mjs +69 -133
  126. package/lib/page-role-profile.mjs +12 -8
  127. package/lib/perceptual-hash.mjs +43 -14
  128. package/lib/rdap-bootstrap.mjs +72 -22
  129. package/lib/rdap-capabilities.mjs +1 -1
  130. package/lib/rdap-client.mjs +15 -7
  131. package/lib/rdap-normalization.mjs +33 -18
  132. package/lib/rdap-search-workbench.mjs +1 -1
  133. package/lib/rdap-transport.mjs +47 -20
  134. package/lib/rdap-types.mjs +2 -1
  135. package/lib/rdap.mjs +18 -3
  136. package/lib/registrable-domain.mjs +21 -1
  137. package/lib/registrar-standing-contract.mjs +1 -0
  138. package/lib/registry-capabilities.mjs +5 -1
  139. package/lib/registry-capability-catalogue.mjs +2 -1
  140. package/lib/registry-dates.mjs +2 -190
  141. package/lib/registry-insights.mjs +85 -34
  142. package/lib/response-policy.mjs +86 -48
  143. package/lib/risk-calibration-summary.mjs +18 -6
  144. package/lib/rpki-evidence.mjs +8 -5
  145. package/lib/safe-fetch.mjs +54 -47
  146. package/lib/security-txt.mjs +42 -7
  147. package/lib/service-binding-dns.mjs +8 -0
  148. package/lib/smtp-transport-review.mjs +27 -15
  149. package/lib/sslbl-certificates.generated.mjs +136 -135
  150. package/lib/static-html-analysis.mjs +178 -151
  151. package/lib/structured-data-identity.mjs +1 -0
  152. package/lib/technology-markup.mjs +100 -0
  153. package/lib/threat-intelligence-manifests.mjs +11 -139
  154. package/lib/threat-intelligence-runtime.mjs +24 -52
  155. package/lib/threat-intelligence-targets.mjs +6 -34
  156. package/lib/threat-intelligence-types.mjs +39 -0
  157. package/lib/typosquat-generator.mjs +19 -15
  158. package/lib/urlhaus-intelligence.mjs +8 -3
  159. package/lib/urlscan-intelligence.mjs +1 -1
  160. package/lib/website-security-posture.mjs +30 -6
  161. package/lib/website-technology.mjs +153 -45
  162. package/lib/whois-authority.mjs +14 -2
  163. package/lib/whois-chain.mjs +9 -2
  164. package/lib/whois-common-formats.mjs +5 -5
  165. package/lib/whois-contracts.mjs +2 -1
  166. package/lib/whois-dialects.mjs +2 -2
  167. package/lib/whois-parser-support.mjs +8 -1
  168. package/lib/whois-transport.mjs +23 -7
  169. package/lib/zone-intent-review.mjs +16 -6
  170. package/package.json +2 -1
  171. package/packages/cases/case-attachment-model.mjs +83 -0
  172. package/packages/cases/case-evidence-model.mjs +156 -62
  173. package/packages/cases/case-export-input.mjs +53 -0
  174. package/packages/cases/case-migration-model.mjs +127 -44
  175. package/packages/cases/case-model.mjs +1 -0
  176. package/packages/cases/case-recheck-model.mjs +91 -0
  177. package/packages/cases/case-record-contracts.mjs +5 -20
  178. package/packages/cases/case-record-core.mjs +12 -22
  179. package/packages/cases/case-record-decisions.mjs +72 -0
  180. package/packages/cases/case-record-model.mjs +1 -0
  181. package/packages/cases/case-record-operations.mjs +95 -31
  182. package/packages/cases/case-record-projection.mjs +221 -0
  183. package/packages/cases/case-report.mjs +70 -54
  184. package/packages/cases/case-response-actions.mjs +654 -0
  185. package/packages/cases/case-response-model.mjs +64 -1115
  186. package/packages/cases/case-response-outcomes.mjs +317 -0
  187. package/packages/cases/case-response-packet-render.mjs +153 -0
  188. package/packages/cases/case-response-packet-types.mjs +1 -0
  189. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  190. package/packages/cases/case-response-packet.mjs +74 -572
  191. package/packages/cases/case-response-records.mjs +94 -0
  192. package/packages/cases/case-response-review-inputs.mjs +371 -0
  193. package/packages/cases/case-response-values.mjs +85 -0
  194. package/packages/cases/case-review-package.mjs +45 -0
  195. package/packages/cases/case-selection.mjs +46 -0
  196. package/packages/cases/case-storage-model.mjs +31 -2
  197. package/packages/cases/response-route-freshness.mjs +16 -0
  198. package/packages/comparison/capture-context.mjs +61 -0
  199. package/packages/comparison/comparison-ledger-bulk.mjs +13 -11
  200. package/packages/comparison/page-similarity.mjs +5 -2
  201. package/packages/comparison/relationship-evidence.mjs +85 -8
  202. package/packages/comparison/relationship-provenance.mjs +93 -0
  203. package/packages/contracts/analyst-interchange.mjs +216 -0
  204. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  205. package/packages/contracts/analyst-review-state.mjs +5 -4
  206. package/packages/contracts/case-portability.mjs +366 -83
  207. package/packages/contracts/case-views-contract.mjs +12 -0
  208. package/packages/contracts/case-views.mjs +26 -0
  209. package/packages/contracts/cli-command-semantics.mjs +94 -0
  210. package/packages/contracts/cli-lookup.mjs +0 -1
  211. package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
  212. package/packages/contracts/domain-control-manifest.mjs +127 -73
  213. package/packages/contracts/domain-control-monitor.mjs +48 -45
  214. package/packages/contracts/domain-control-review.mjs +179 -121
  215. package/packages/contracts/domain-name.mjs +21 -0
  216. package/packages/contracts/external-observation-interchange.mjs +2 -1
  217. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  218. package/packages/contracts/http-url.mjs +2 -0
  219. package/packages/contracts/investigation-package-limits.mjs +24 -0
  220. package/packages/contracts/investigation-portability.mjs +56 -27
  221. package/packages/contracts/investigation-run.mjs +16 -0
  222. package/packages/contracts/ip-address.mjs +34 -0
  223. package/packages/contracts/lookup-evidence.mjs +35 -0
  224. package/packages/contracts/offline-comparison.mjs +1 -2
  225. package/packages/contracts/page-fingerprints.mjs +15 -0
  226. package/packages/contracts/risk-calibration.mjs +15 -11
  227. package/packages/contracts/schema-compatibility.mjs +1 -1
  228. package/packages/contracts/schema-lifecycle.mjs +83 -153
  229. package/packages/contracts/selected-file-limits.mjs +6 -0
  230. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  231. package/packages/contracts/web-capture.mjs +10 -0
  232. package/packages/contracts/workspace-portability.mjs +183 -211
  233. package/packages/evidence/artifact-integrity.mjs +8 -2
  234. package/packages/evidence/artifact-structure.mjs +105 -0
  235. package/packages/evidence/decision-fact.mjs +4 -1
  236. package/packages/evidence/domain-control-runtime.mjs +201 -33
  237. package/packages/evidence/domain-posture-context.mjs +79 -0
  238. package/packages/evidence/image-regions.mjs +56 -0
  239. package/packages/evidence/latest-observations.mjs +28 -0
  240. package/packages/evidence/lookup-target.mjs +104 -0
  241. package/packages/evidence/observation.mjs +16 -1
  242. package/packages/evidence/passphrase-encryption.mjs +44 -0
  243. package/packages/evidence/record-identity.mjs +72 -0
  244. package/packages/evidence/registry-dates.mjs +190 -0
  245. package/packages/evidence/retained-file.mjs +69 -0
  246. package/packages/evidence/review-age.mjs +14 -0
  247. package/packages/interchange/bagit.mjs +342 -0
  248. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  249. package/packages/interchange/crc32.mjs +17 -0
  250. package/packages/interchange/external-findings-import.mjs +92 -51
  251. package/packages/interchange/web-capture-import.mjs +382 -0
  252. package/packages/investigation/bulk-mail-exposure.mjs +2 -1
  253. package/packages/investigation/investigation-bagit.mjs +14 -0
  254. package/packages/investigation/investigation-capsule.mjs +33 -5
  255. package/packages/investigation/investigation-manifest.mjs +200 -0
  256. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  257. package/packages/investigation/investigation-package.mjs +200 -0
  258. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  259. package/packages/investigation/lookup-asset-graph.mjs +193 -165
  260. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  261. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  262. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  263. package/packages/monitoring/analyst-review-state.mjs +38 -90
  264. package/packages/workspace/brand-profile-model.mjs +276 -22
  265. package/packages/workspace/bulk-columns.mjs +17 -0
  266. package/packages/workspace/bulk-review-model.mjs +23 -11
  267. package/packages/workspace/bulk-session-model.mjs +100 -24
  268. package/packages/workspace/bulk-sort.mjs +1 -0
  269. package/packages/workspace/case-views.mjs +102 -0
  270. package/packages/workspace/detection-rule-model.mjs +70 -22
  271. package/packages/workspace/hostile-input.mjs +29 -8
  272. package/packages/workspace/investigation-template-model.mjs +19 -4
  273. package/packages/workspace/page-baseline.mjs +18 -15
  274. package/packages/workspace/relationship-observation-model.mjs +41 -23
  275. package/packages/workspace/watchlist-history.mjs +2 -2
  276. package/packages/workspace/watchlist-store.mjs +8 -2
  277. package/packages/workspace/website-snapshot-model.mjs +164 -26
  278. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  279. package/packages/workspace/workspace-archive.mjs +87 -21
  280. package/third-party-notices.txt +29 -2
  281. package/cli/evidence-command-arguments.mjs +0 -156
  282. package/packages/investigation/bulk-review-export.mjs +0 -63
@@ -1,6 +1,7 @@
1
1
  // IANA RDAP bootstrap retrieval, bounded stale caching, and authority
2
2
  // selection for domain, IP, and ASN queries.
3
3
  import net from 'node:net';
4
+ import { abortable } from "./abort.mjs";
4
5
  import { fetchRdapDetailedWithTimeout, } from "./rdap-transport.mjs";
5
6
  const BOOTSTRAP_TTL_MS = 60 * 60 * 1000;
6
7
  const BOOTSTRAP_STALE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
@@ -116,21 +117,17 @@ function validBootstrap(data) {
116
117
  && Array.isArray(service[1]) && service[1].length > 0
117
118
  && service[1].some((url) => typeof url === 'string' && /^https?:\/\//i.test(url))));
118
119
  }
119
- async function fetchBootstrap(kind, options = {}) {
120
- if (!BOOTSTRAP_KINDS.has(kind))
121
- throw new Error(`Unsupported RDAP bootstrap kind: ${kind}`);
122
- const now = typeof options.now === 'function' ? options.now : Date.now;
123
- const fetchUpstream = options.fetchUpstream || fetchRdapDetailedWithTimeout;
124
- const cached = bootstrapCache.get(kind);
125
- if (cached && now() - cached.fetchedAt < BOOTSTRAP_TTL_MS)
126
- return cached.data;
127
- const inflight = bootstrapInflight.get(kind);
128
- if (inflight)
129
- return inflight;
130
- const request = (async () => {
120
+ function startBootstrap(kind, options) {
121
+ const now = options.now ?? Date.now;
122
+ const fetchUpstream = options.fetchUpstream ?? fetchRdapDetailedWithTimeout;
123
+ const controller = new AbortController();
124
+ const { signal } = controller;
125
+ const request = abortable(async () => {
131
126
  try {
127
+ signal.throwIfAborted();
132
128
  const requestedEndpoint = `https://data.iana.org/rdap/${kind}.json`;
133
- const response = await fetchUpstream(requestedEndpoint, {}, BOOTSTRAP_FETCH_TIMEOUT_MS);
129
+ const response = await fetchUpstream(requestedEndpoint, { signal }, BOOTSTRAP_FETCH_TIMEOUT_MS);
130
+ signal.throwIfAborted();
134
131
  const finalEndpoint = admitRdapEndpoint(response.finalUrl ?? requestedEndpoint);
135
132
  if (finalEndpoint !== requestedEndpoint) {
136
133
  throw new Error(`IANA bootstrap redirected outside its fixed source endpoint for ${kind}`);
@@ -152,21 +149,74 @@ async function fetchBootstrap(kind, options = {}) {
152
149
  return data;
153
150
  }
154
151
  catch (cause) {
152
+ signal.throwIfAborted();
155
153
  const fallback = bootstrapCache.get(kind);
156
154
  if (fallback && now() - fallback.fetchedAt <= BOOTSTRAP_STALE_TTL_MS) {
157
155
  return fallback.data;
158
156
  }
159
157
  throw cause;
160
158
  }
161
- finally {
162
- bootstrapInflight.delete(kind);
159
+ }, signal);
160
+ const flight = {
161
+ controller, readers: 0, settled: false,
162
+ request: request.finally(() => {
163
+ flight.settled = true;
164
+ if (bootstrapInflight.get(kind) === flight)
165
+ bootstrapInflight.delete(kind);
166
+ }),
167
+ };
168
+ bootstrapInflight.set(kind, flight);
169
+ return flight;
170
+ }
171
+ async function fetchBootstrap(kind, options = {}) {
172
+ if (!BOOTSTRAP_KINDS.has(kind))
173
+ throw new Error(`Unsupported RDAP bootstrap kind: ${kind}`);
174
+ options.signal?.throwIfAborted();
175
+ const now = options.now ?? Date.now;
176
+ const cached = bootstrapCache.get(kind);
177
+ if (cached && now() - cached.fetchedAt < BOOTSTRAP_TTL_MS)
178
+ return cached.data;
179
+ const flight = bootstrapInflight.get(kind) ?? startBootstrap(kind, options);
180
+ flight.readers++;
181
+ let released = false;
182
+ const release = () => {
183
+ if (released)
184
+ return;
185
+ released = true;
186
+ // One deadline releases only its reader. The fixed-source transport lives
187
+ // until the last reader leaves, and never caches a cancelled late response.
188
+ flight.readers--;
189
+ if (!flight.readers && !flight.settled) {
190
+ flight.controller.abort();
191
+ if (bootstrapInflight.get(kind) === flight)
192
+ bootstrapInflight.delete(kind);
163
193
  }
164
- })();
165
- bootstrapInflight.set(kind, request);
166
- return request;
194
+ };
195
+ const { signal } = options;
196
+ let cancel = () => { };
197
+ const result = new Promise((resolve, reject) => {
198
+ // Attach before observing cancellation so even an abandoned queued flight
199
+ // retains a rejection handler. Release synchronously to avoid starting it.
200
+ flight.request.then(resolve, reject);
201
+ cancel = () => { release(); reject(signal?.reason); };
202
+ signal?.addEventListener('abort', cancel, { once: true });
203
+ if (signal?.aborted)
204
+ cancel();
205
+ });
206
+ try {
207
+ return await result;
208
+ }
209
+ finally {
210
+ signal?.removeEventListener('abort', cancel);
211
+ release();
212
+ if (flight.controller.signal.aborted)
213
+ await flight.request.catch(() => { });
214
+ }
167
215
  }
168
216
  function clearRdapBootstrapCache() {
169
217
  bootstrapCache.clear();
218
+ for (const flight of bootstrapInflight.values())
219
+ flight.controller.abort();
170
220
  bootstrapInflight.clear();
171
221
  }
172
222
  function uniqueRdapBases(urls) {
@@ -185,9 +235,9 @@ function uniqueRdapBases(urls) {
185
235
  return true;
186
236
  });
187
237
  }
188
- async function findRdapBases(type, value) {
238
+ async function findRdapBases(type, value, options = {}) {
189
239
  if (type === 'domain') {
190
- const bootstrap = await fetchBootstrap('dns');
240
+ const bootstrap = await fetchBootstrap('dns', options);
191
241
  const tld = value.split('.').pop()?.toLowerCase() || '';
192
242
  for (const [tlds, urls] of bootstrap.services) {
193
243
  if (tlds.some((entry) => entry.toLowerCase() === tld))
@@ -196,7 +246,7 @@ async function findRdapBases(type, value) {
196
246
  return [];
197
247
  }
198
248
  if (type === 'ipv4' || type === 'ipv6') {
199
- const bootstrap = await fetchBootstrap(type);
249
+ const bootstrap = await fetchBootstrap(type, options);
200
250
  const matcher = type === 'ipv4' ? ipInCidrV4 : ipInCidrV6;
201
251
  let best = null;
202
252
  let bestPrefix = -1;
@@ -215,7 +265,7 @@ async function findRdapBases(type, value) {
215
265
  return best || [];
216
266
  }
217
267
  if (type === 'asn') {
218
- const bootstrap = await fetchBootstrap('asn');
268
+ const bootstrap = await fetchBootstrap('asn', options);
219
269
  const number = parseInt(value.replace(/^AS/i, ''), 10);
220
270
  for (const [ranges, urls] of bootstrap.services) {
221
271
  for (const range of ranges) {
@@ -245,7 +245,7 @@ export function inspectRdapCapabilities(parsedValue, status) {
245
245
  break;
246
246
  }
247
247
  const declarations = [...unique].sort().map((value) => {
248
- const entry = EXTENSION_CATALOG[value];
248
+ const entry = Object.hasOwn(EXTENSION_CATALOG, value) ? EXTENSION_CATALOG[value] : undefined;
249
249
  return entry
250
250
  ? {
251
251
  identifier: value,
@@ -1,7 +1,7 @@
1
1
  import { cached } from "./lookup-cache.mjs";
2
2
  import { findRdapBases, uniqueRdapBases, } from "./rdap-bootstrap.mjs";
3
3
  import { rdapAttempt, rdapFailure } from "./rdap-attempts.mjs";
4
- import { fetchRdapDetailedWithTimeout, } from "./rdap-transport.mjs";
4
+ import { fetchRdapDetailedWithTimeout, RdapBodyAdmissionError, } from "./rdap-transport.mjs";
5
5
  import { admitRdapObjectEndpoint, validateRdapResponse, } from "./rdap-validation.mjs";
6
6
  const UPSTREAM_TIMEOUT_MS = 7_000;
7
7
  const UPSTREAM_TOTAL_DEADLINE_MS = 12_000;
@@ -24,20 +24,23 @@ function rdapPathFor(type, value) {
24
24
  * Query the bounded bootstrap candidate set while retaining every attempted
25
25
  * endpoint as separately attributed transport evidence.
26
26
  */
27
- export async function fetchRdapFromBasesWithParser(type, value, bases, parseRdap, fetchUpstream = fetchRdapDetailedWithTimeout) {
27
+ export async function fetchRdapFromBasesWithParser(type, value, bases, parseRdap, fetchUpstream = fetchRdapDetailedWithTimeout, signal) {
28
+ signal?.throwIfAborted();
28
29
  const candidates = uniqueRdapBases(bases).slice(0, MAX_RDAP_ENDPOINTS);
29
30
  if (candidates.length === 0)
30
31
  return null;
31
32
  const startedAt = Date.now();
32
33
  const attempts = [];
33
34
  for (const base of candidates) {
35
+ signal?.throwIfAborted();
34
36
  const elapsed = Date.now() - startedAt;
35
37
  const remaining = UPSTREAM_TOTAL_DEADLINE_MS - elapsed;
36
38
  if (remaining <= 0)
37
39
  break;
38
40
  const url = `${base.replace(/\/$/, '')}/${rdapPathFor(type, value)}`;
39
41
  try {
40
- const upstream = await fetchUpstream(url, { headers: { Accept: 'application/rdap+json' } }, Math.min(UPSTREAM_TIMEOUT_MS, remaining));
42
+ const upstream = await fetchUpstream(url, { headers: { Accept: 'application/rdap+json' }, ...(signal ? { signal } : {}) }, Math.min(UPSTREAM_TIMEOUT_MS, remaining));
43
+ signal?.throwIfAborted();
41
44
  const selectedEndpoint = admitRdapObjectEndpoint(type, value, upstream.finalUrl ?? url);
42
45
  if (!selectedEndpoint) {
43
46
  attempts.push(rdapAttempt(url, 'invalid_response', {
@@ -110,6 +113,11 @@ export async function fetchRdapFromBasesWithParser(type, value, bases, parseRdap
110
113
  };
111
114
  }
112
115
  catch (cause) {
116
+ signal?.throwIfAborted();
117
+ if (cause instanceof RdapBodyAdmissionError) {
118
+ attempts.push(rdapAttempt(url, 'invalid_response', { status: cause.status, detail: cause.message }));
119
+ continue;
120
+ }
113
121
  const detail = String(errorProperty(cause, 'message') || 'request failed');
114
122
  const outcome = errorProperty(cause, 'name') === 'AbortError' ||
115
123
  /timed? out|time limit/i.test(detail)
@@ -125,9 +133,9 @@ export async function fetchRdapFromBasesWithParser(type, value, bases, parseRdap
125
133
  : 'the total upstream deadline expired';
126
134
  throw Object.assign(new Error(`RDAP lookup failed across ${candidates.length} endpoint(s): ${detail}`), { attempts });
127
135
  }
128
- export async function fetchRdapRecordWithParser(type, value, parseRdap) {
136
+ export async function fetchRdapRecordWithParser(type, value, parseRdap, options = {}) {
129
137
  return cached(`rdap:${type}:${value}`, async () => {
130
- const bases = await findRdapBases(type, value);
131
- return fetchRdapFromBasesWithParser(type, value, bases, parseRdap);
132
- });
138
+ const bases = await findRdapBases(type, value, options);
139
+ return fetchRdapFromBasesWithParser(type, value, bases, parseRdap, options.fetchUpstream, options.signal);
140
+ }, options.signal);
133
141
  }
@@ -1,8 +1,9 @@
1
1
  // Bounded normalization of untrusted RDAP response objects.
2
2
  import net from 'node:net';
3
3
  import { registryDateIso } from "./registry-dates.mjs";
4
- import {} from "./rdap-types.mjs";
4
+ import { MAX_RDAP_STATUSES, MAX_RDAP_REDACTIONS, } from "./rdap-types.mjs";
5
5
  const MAX_RDAP_ENTITIES = 100;
6
+ const MAX_RDAP_EVENTS = 100;
6
7
  const MAX_RDAP_ENTITY_DEPTH = 6;
7
8
  const MAX_ENTITIES_PER_ROLE = 5;
8
9
  const MAX_VCARD_ENTRIES = 100;
@@ -10,7 +11,6 @@ const MAX_ENTITY_ROLES = 12;
10
11
  const MAX_CONTACT_VALUES = 8;
11
12
  const MAX_ENTITY_LINKS = 10;
12
13
  const MAX_RDAP_LINKS = 20;
13
- const MAX_RDAP_REDACTIONS = 100;
14
14
  const MAX_RDAP_VARIANT_GROUPS = 20;
15
15
  const MAX_RDAP_VARIANT_NAMES = 50;
16
16
  const MAX_RDAP_SERVER_TRUNCATION_REASONS = 8;
@@ -166,8 +166,10 @@ function contactValuesTruncated(vcardArray) {
166
166
  return rawPart.length > 300 || /[\u0000-\u001f\u007f]/u.test(rawPart) || rawPart.trim() !== '';
167
167
  });
168
168
  const retainedParts = parts.filter((part) => part !== null);
169
+ // Empty structured components publish no address; they are not omitted
170
+ // evidence. Supplied non-blank values still pass every validation bound.
169
171
  if (rejectedNonBlankPart
170
- || boundedString(retainedParts.join(', '), 1000) === null)
172
+ || (retainedParts.length > 0 && boundedString(retainedParts.join(', '), 1000) === null))
171
173
  return true;
172
174
  }
173
175
  return false;
@@ -506,18 +508,33 @@ function boundedEventString(value, maxLength) {
506
508
  return null;
507
509
  return value.trim() || null;
508
510
  }
509
- function normalizeRdapEvents(value) {
511
+ function normalizedEventInventory(value) {
510
512
  if (!Array.isArray(value))
511
- return [];
512
- return value.slice(0, 100).map((event) => {
513
- if (!event || typeof event !== 'object' || Array.isArray(event))
514
- return null;
513
+ return { items: [], truncated: value !== undefined };
514
+ let truncated = value.length > MAX_RDAP_EVENTS;
515
+ const items = [];
516
+ for (const event of value.slice(0, MAX_RDAP_EVENTS)) {
517
+ if (!event || typeof event !== 'object' || Array.isArray(event)) {
518
+ truncated = true;
519
+ continue;
520
+ }
515
521
  const record = event;
516
522
  const action = boundedEventString(record.eventAction, 100)?.toLowerCase().replace(/\s+/g, ' ') || null;
517
523
  const date = boundedEventString(record.eventDate, 64);
518
524
  const actor = boundedEventString(record.eventActor, 160);
519
- return action || date ? { action, date, actor } : null;
520
- }).filter((event) => event !== null);
525
+ if (record.eventAction !== undefined && action === null
526
+ || record.eventDate !== undefined && date === null
527
+ || record.eventActor !== undefined && actor === null)
528
+ truncated = true;
529
+ if (action || date)
530
+ items.push({ action, date, actor });
531
+ else
532
+ truncated = true;
533
+ }
534
+ return { items, truncated };
535
+ }
536
+ function normalizeRdapEvents(value) {
537
+ return normalizedEventInventory(value).items;
521
538
  }
522
539
  function lifecycleDate(events, action, newest) {
523
540
  let selected = null;
@@ -697,7 +714,8 @@ function entityInventory(entities) {
697
714
  function parseRdapObject(type, data) {
698
715
  if (!data || typeof data !== 'object' || Array.isArray(data))
699
716
  return null;
700
- const events = normalizeRdapEvents(data.events);
717
+ const eventInfo = normalizedEventInventory(data.events);
718
+ const events = eventInfo.items;
701
719
  const redactionInfo = normalizeRedactions(data.redacted);
702
720
  const noticesInfo = summarizeTextBlocks(data.notices);
703
721
  const remarksInfo = summarizeTextBlocks(data.remarks);
@@ -708,12 +726,11 @@ function parseRdapObject(type, data) {
708
726
  // to every RDAP object type. Unlike set-like fields, status order and
709
727
  // repetition remain as published for backwards compatibility.
710
728
  const statuses = Array.isArray(data.status)
711
- ? data.status.slice(0, 100)
729
+ ? data.status.slice(0, MAX_RDAP_STATUSES)
712
730
  .map((status) => boundedString(status, 160))
713
731
  .filter((status) => status !== null)
714
732
  : [];
715
- const inspectedStatusCount = Array.isArray(data.status) ? Math.min(data.status.length, 100) : 0;
716
- const inspectedEventCount = Array.isArray(data.events) ? Math.min(data.events.length, 100) : 0;
733
+ const inspectedStatusCount = Array.isArray(data.status) ? Math.min(data.status.length, MAX_RDAP_STATUSES) : 0;
717
734
  const common = {
718
735
  objectClassName: boundedString(data.objectClassName, 80, { lower: true }),
719
736
  language: boundedString(data.lang, 35, { lower: true }),
@@ -732,12 +749,10 @@ function parseRdapObject(type, data) {
732
749
  serverTruncationReasons,
733
750
  statuses,
734
751
  statusesTruncated: data.status !== undefined && (!Array.isArray(data.status)
735
- || data.status.length > 100
752
+ || data.status.length > MAX_RDAP_STATUSES
736
753
  || statuses.length < inspectedStatusCount),
737
754
  events,
738
- eventsTruncated: data.events !== undefined && (!Array.isArray(data.events)
739
- || data.events.length > 100
740
- || events.length < inspectedEventCount),
755
+ eventsTruncated: eventInfo.truncated,
741
756
  lifecycle: summarizeLifecycle(events),
742
757
  };
743
758
  if (type === 'domain') {
@@ -213,7 +213,7 @@ function inspectRdapReverseSearchResponse(value, requestedProperties = []) {
213
213
  mappings.push(Object.freeze({
214
214
  property,
215
215
  propertyPath,
216
- state: REGISTERED_PROPERTY_PATHS[property]?.includes(propertyPath)
216
+ state: Object.hasOwn(REGISTERED_PROPERTY_PATHS, property) && REGISTERED_PROPERTY_PATHS[property]?.includes(propertyPath)
217
217
  ? 'registered'
218
218
  : 'unrecognized',
219
219
  }));
@@ -2,38 +2,65 @@
2
2
  // normalization, and registrar enrichment remain separate concerns in
3
3
  // lib/rdap.mts.
4
4
  import { readTextCapped, safeFetch, safeFetchDetailed } from "./safe-fetch.mjs";
5
+ import { abortable } from "./abort.mjs";
5
6
  const MAX_RDAP_BYTES = 2000000;
6
- async function fetchRdapWithTimeout(url, options, timeoutMs, dependencies = {}) {
7
- const controller = new AbortController();
8
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
9
- try {
10
- const response = await (dependencies.fetch ?? safeFetch)(url, { ...options, signal: controller.signal });
11
- const { text, truncated } = await (dependencies.readText ?? readTextCapped)(response, MAX_RDAP_BYTES, { fatalUtf8: true });
12
- if (truncated)
13
- throw new Error(`Response from ${url} exceeded ${MAX_RDAP_BYTES} bytes`);
14
- return { status: response.status, ok: response.ok, text };
15
- }
16
- finally {
17
- clearTimeout(timeout);
7
+ export class RdapBodyAdmissionError extends Error {
8
+ status;
9
+ kind;
10
+ constructor(status, kind) {
11
+ super(kind === 'too_large' ? `RDAP response exceeded ${MAX_RDAP_BYTES} bytes.`
12
+ : kind === 'invalid_encoding' ? 'RDAP response contains invalid UTF-8 encoding.'
13
+ : 'RDAP response body could not be read completely.');
14
+ this.name = 'RdapBodyAdmissionError';
15
+ this.status = status;
16
+ this.kind = kind;
18
17
  }
19
18
  }
20
- async function fetchRdapDetailedWithTimeout(url, options, timeoutMs, dependencies = {}) {
19
+ async function fetchRdapTransport(url, options, timeoutMs, fetchResponse, readText) {
20
+ options.signal?.throwIfAborted();
21
21
  const controller = new AbortController();
22
+ const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
22
23
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
24
+ let response;
25
+ const cancelBody = () => { void response?.body?.cancel().catch(() => { }); };
23
26
  try {
24
- const result = await (dependencies.fetchDetailed ?? safeFetchDetailed)(url, { ...options, signal: controller.signal });
25
- const { text, truncated } = await (dependencies.readText ?? readTextCapped)(result.response, MAX_RDAP_BYTES, { fatalUtf8: true });
27
+ const result = await abortable(async () => {
28
+ const result = await fetchResponse(url, { ...options, signal });
29
+ response = result.response;
30
+ if (signal.aborted) {
31
+ cancelBody();
32
+ signal.throwIfAborted();
33
+ }
34
+ return result;
35
+ }, signal);
36
+ let body;
37
+ try {
38
+ body = await abortable(() => readText(result.response, MAX_RDAP_BYTES, { fatalUtf8: true }), signal);
39
+ }
40
+ catch (error) {
41
+ signal.throwIfAborted();
42
+ const invalidEncoding = error instanceof TypeError && 'code' in error
43
+ && error.code === 'ERR_ENCODING_INVALID_ENCODED_DATA';
44
+ throw new RdapBodyAdmissionError(result.response.status, invalidEncoding ? 'invalid_encoding' : 'unreadable');
45
+ }
46
+ const { text, truncated } = body;
26
47
  if (truncated)
27
- throw new Error(`Response from ${url} exceeded ${MAX_RDAP_BYTES} bytes`);
48
+ throw new RdapBodyAdmissionError(result.response.status, 'too_large');
28
49
  return {
29
- status: result.response.status,
30
- ok: result.response.ok,
31
- text,
32
- finalUrl: result.finalUrl,
50
+ status: result.response.status, ok: result.response.ok, text,
51
+ ...(result.finalUrl === undefined ? {} : { finalUrl: result.finalUrl }),
33
52
  };
34
53
  }
35
54
  finally {
36
55
  clearTimeout(timeout);
56
+ if (signal.aborted)
57
+ cancelBody();
37
58
  }
38
59
  }
60
+ async function fetchRdapWithTimeout(url, options, timeoutMs, dependencies = {}) {
61
+ return fetchRdapTransport(url, options, timeoutMs, async (target, request) => ({ response: await (dependencies.fetch ?? safeFetch)(target, request) }), dependencies.readText ?? readTextCapped);
62
+ }
63
+ async function fetchRdapDetailedWithTimeout(url, options, timeoutMs, dependencies = {}) {
64
+ return fetchRdapTransport(url, options, timeoutMs, dependencies.fetchDetailed ?? safeFetchDetailed, dependencies.readText ?? readTextCapped);
65
+ }
39
66
  export { fetchRdapDetailedWithTimeout, fetchRdapWithTimeout, };
@@ -1 +1,2 @@
1
- export {};
1
+ export const MAX_RDAP_STATUSES = 100;
2
+ export const MAX_RDAP_REDACTIONS = 100;
package/lib/rdap.mjs CHANGED
@@ -4,18 +4,33 @@ 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 { fetchRdapDetailedWithTimeout, } from "./rdap-transport.mjs";
7
- import { registryServiceAdmissionFor } from "./registry-capabilities.mjs";
7
+ import { REGISTRY_CAPABILITIES_VERSION, registryServiceAdmissionFor } from "./registry-capabilities.mjs";
8
+ // A null record does not establish domain absence. Keep the HTTP explanation
9
+ // aligned with the same admission decision used before bootstrap discovery.
10
+ function rdapUnavailableResponse(type, value) {
11
+ const admission = type === 'domain' ? registryServiceAdmissionFor(value, 'rdap') : null;
12
+ if (admission?.allowed === false) {
13
+ return {
14
+ error: 'RDAP collection was not attempted because the retained registry capability policy does not admit this service.',
15
+ source: 'retained_registry_capability_policy',
16
+ capabilityVersion: REGISTRY_CAPABILITIES_VERSION,
17
+ limitation: admission.limitation,
18
+ };
19
+ }
20
+ return { error: `No RDAP registry found for "${value}" via IANA bootstrap` };
21
+ }
8
22
  async function fetchRdapFromBases(type, value, bases, fetchUpstream = fetchRdapDetailedWithTimeout) {
9
23
  return fetchRdapFromBasesWithParser(type, value, bases, parseRdap, fetchUpstream);
10
24
  }
11
25
  async function fetchRdapRecord(type, value, options = {}) {
26
+ options.signal?.throwIfAborted();
12
27
  if (type === 'domain' && registryServiceAdmissionFor(value, 'rdap')?.allowed === false) {
13
28
  return null;
14
29
  }
15
30
  const fetchRecord = options.fetchRecord ?? fetchRdapRecordWithParser;
16
- return fetchRecord(type, value, parseRdap);
31
+ return fetchRecord(type, value, parseRdap, options.signal ? { signal: options.signal } : {});
17
32
  }
18
33
  async function fetchRegistrarRdapRecord(domain, registryRecord, options = {}) {
19
34
  return fetchRegistrarRdapRecordWithParser(domain, registryRecord, parseRdap, options);
20
35
  }
21
- export { BOOTSTRAP_TTL_MS, BOOTSTRAP_STALE_TTL_MS, fetchBootstrap, clearRdapBootstrapCache, fetchRdapRecord, fetchRdapFromBases, fetchRegistrarRdapRecord, selectRegistrarRdapLink, uniqueBases, parseRdap, normalizeRdapEvents, summarizeLifecycle, };
36
+ export { BOOTSTRAP_TTL_MS, BOOTSTRAP_STALE_TTL_MS, fetchBootstrap, clearRdapBootstrapCache, fetchRdapRecord, rdapUnavailableResponse, fetchRdapFromBases, fetchRegistrarRdapRecord, selectRegistrarRdapLink, uniqueBases, parseRdap, normalizeRdapEvents, summarizeLifecycle, };
@@ -1,5 +1,25 @@
1
- import { getDomain } from 'tldts';
1
+ import { getDomain, getPublicSuffix } from 'tldts';
2
2
  import { isValidAsciiDomainName } from "./hostname.mjs";
3
+ import { MAX_DOMAIN_NAME_LENGTH, MAX_DOMAIN_LABEL_LENGTH } from "../packages/contracts/domain-name.mjs";
4
+ // Reserve the shortest generated name and its separating dot.
5
+ export const MAX_DISCOVERY_SUFFIX_LENGTH = MAX_DOMAIN_NAME_LENGTH - 2;
6
+ /** ICANN suffix rules for a plain ASCII hostname; private suffixes do not change identity. */
7
+ export function publicSuffixForAsciiHostname(value) {
8
+ if (!isValidAsciiDomainName(value, { requireDot: false, requireLowercase: true }))
9
+ return null;
10
+ return getPublicSuffix(value);
11
+ }
12
+ /** Preserve reserved single labels; compound suffixes must match the local ICANN suffix rules. */
13
+ export function normalizeDiscoverySuffix(raw) {
14
+ if (typeof raw !== 'string' || raw.length > MAX_DISCOVERY_SUFFIX_LENGTH + 1 || /[\u0000-\u001f\u007f]/u.test(raw))
15
+ return null;
16
+ const value = raw.trim().toLowerCase().replace(/^\./u, '');
17
+ if (!value || value.length > MAX_DISCOVERY_SUFFIX_LENGTH)
18
+ return null;
19
+ if (value.length <= MAX_DOMAIN_LABEL_LENGTH && /^[a-z]{2,}$/u.test(value))
20
+ return value;
21
+ return value.includes('.') && publicSuffixForAsciiHostname(value) === value ? value : null;
22
+ }
3
23
  // Returns one canonical registrable-domain identity for an already public DNS
4
24
  // hostname. Callers retain the original hostname separately when subdomain
5
25
  // context matters; authority, evidence binding and scoring use this value.
@@ -101,6 +101,7 @@ function validNullableTimestamp(value) {
101
101
  function validNullableDate(value) {
102
102
  return value === null || (typeof value === 'string'
103
103
  && ISO_DATE_RE.test(value)
104
+ && Number.isFinite(Date.parse(`${value}T00:00:00.000Z`))
104
105
  && new Date(`${value}T00:00:00.000Z`).toISOString().startsWith(value));
105
106
  }
106
107
  function validNullableText(value, maximum = MAX_REGISTRAR_STANDING_TEXT_LENGTH) {
@@ -98,7 +98,11 @@ function registryServiceAdmissionFor(value, service) {
98
98
  if (!capability || (service !== 'rdap' && service !== 'whois'))
99
99
  return null;
100
100
  if (service === 'rdap') {
101
- const allowed = capability.rdapAccessProfile === 'iana-bootstrap';
101
+ // A retained absence is a discovery hint, not an access restriction.
102
+ // Both current profiles permit bounded bootstrap discovery; unknown or
103
+ // future restriction profiles remain denied until explicitly handled.
104
+ const allowed = capability.rdapAccessProfile === 'iana-bootstrap'
105
+ || capability.rdapAccessProfile === 'no-iana-service';
102
106
  return {
103
107
  service,
104
108
  state: allowed ? 'allowed' : 'unsupported',
@@ -1,10 +1,11 @@
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
+ export const REGISTRY_STANDARDS_COVERAGE_SCHEMA = 'whoisleuth.registry-standards-coverage';
4
5
  const REGISTRY_CAPABILITIES_VERSION = 29;
5
6
  const MAX_CAPABILITY_INPUT_LENGTH = 253;
6
7
  const REGISTRY_STANDARDS_COVERAGE_SNAPSHOT = Object.freeze({
7
- schema: 'whoisleuth.registry-standards-coverage',
8
+ schema: REGISTRY_STANDARDS_COVERAGE_SCHEMA,
8
9
  version: 1,
9
10
  verifiedAt: '2026-08-03',
10
11
  sources: Object.freeze({