@slicedearth/whoisleuth-cli 2.4.0 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (300) hide show
  1. package/NOTICE +2 -2
  2. package/cli/argument-values.mjs +55 -0
  3. package/cli/arguments.mjs +24 -470
  4. package/cli/artifact-validation/case-response.mjs +21 -6
  5. package/cli/artifact-validation/lookup-evidence.mjs +3 -3
  6. package/cli/artifact-validation/signed-review.mjs +59 -13
  7. package/cli/artifact-verify.mjs +2 -0
  8. package/cli/assurance-arguments.mjs +56 -0
  9. package/cli/assurance-command-definitions.mjs +179 -0
  10. package/cli/assurance-command-runner.mjs +1 -1
  11. package/cli/bounded-json.mjs +1 -1
  12. package/cli/bulk-checkpoint.mjs +1 -1
  13. package/cli/bulk-command-runner.mjs +2 -2
  14. package/cli/bulk.mjs +2 -3
  15. package/cli/case-command.mjs +20 -3
  16. package/cli/case-pack.mjs +12 -2
  17. package/cli/ci-report.mjs +6 -3
  18. package/cli/collection-arguments.mjs +123 -0
  19. package/cli/collection-command-definitions.mjs +330 -0
  20. package/cli/collection-preflight.mjs +8 -1
  21. package/cli/command-argument-grammar.mjs +8 -210
  22. package/cli/command-definition.mjs +329 -0
  23. package/cli/command-reference.mjs +55 -1388
  24. package/cli/command-seeds.mjs +18 -0
  25. package/cli/compare.mjs +1 -1
  26. package/cli/config-profile.mjs +1 -1
  27. package/cli/ct-event-intake.mjs +4 -4
  28. package/cli/discover.mjs +2 -2
  29. package/cli/discovery-observation-snapshot.mjs +2 -2
  30. package/cli/discovery-scan-command-runner.mjs +3 -3
  31. package/cli/discovery-scan.mjs +1 -1
  32. package/cli/discovery-snapshot.mjs +2 -2
  33. package/cli/discovery-workflow.mjs +4 -4
  34. package/cli/domain-control-monitor.mjs +6 -6
  35. package/cli/domain-control-observations.mjs +2 -1
  36. package/cli/errors.mjs +5 -6
  37. package/cli/evidence-arguments.mjs +46 -0
  38. package/cli/evidence-command-definitions.mjs +69 -0
  39. package/cli/evidence-command-runner.mjs +2 -3
  40. package/cli/evidence-signing.mjs +3 -2
  41. package/cli/external-observation-mapping.mjs +2 -2
  42. package/cli/formatters/terminal-command-formats.mjs +6 -0
  43. package/cli/formatters/terminal-lookup.mjs +6 -0
  44. package/cli/history-arguments.mjs +39 -0
  45. package/cli/history-command-definitions.mjs +88 -0
  46. package/cli/indicator-set-command.mjs +33 -0
  47. package/cli/inline-command-families.mjs +6 -47
  48. package/cli/inline-command-runner.mjs +4 -31
  49. package/cli/input.mjs +2 -2
  50. package/cli/intake-command.mjs +107 -0
  51. package/cli/interchange-report.mjs +4 -2
  52. package/cli/investigation-artifacts.mjs +1 -1
  53. package/cli/investigation-plan.mjs +32 -154
  54. package/cli/investigation-recipes.mjs +177 -0
  55. package/cli/investigation-run.mjs +78 -12
  56. package/cli/local-mmdb-review.mjs +197 -17
  57. package/cli/lookalike-calibration.mjs +1 -1
  58. package/cli/lookup-brief.mjs +2 -2
  59. package/cli/lookup-command-runner.mjs +18 -35
  60. package/cli/lookup-diff.mjs +10 -9
  61. package/cli/lookup-plan.mjs +10 -14
  62. package/cli/lookup-reconcile.mjs +6 -5
  63. package/cli/lookup-timeline.mjs +7 -6
  64. package/cli/mail-header-review.mjs +24 -247
  65. package/cli/mail-review.mjs +9 -7
  66. package/cli/network-arguments.mjs +51 -0
  67. package/cli/network-command-definitions.mjs +130 -0
  68. package/cli/network-command-runner.mjs +43 -31
  69. package/cli/offline-evidence-review.mjs +39 -6
  70. package/cli/posture.mjs +1 -1
  71. package/cli/registry-fixture-scaffold.mjs +1 -1
  72. package/cli/retained-artifact-diff.mjs +8 -7
  73. package/cli/review-arguments.mjs +131 -0
  74. package/cli/review-command-definitions.mjs +273 -0
  75. package/cli/review-command-runner.mjs +68 -158
  76. package/cli/risk-calibration.mjs +8 -8
  77. package/cli/runner.mjs +20 -32
  78. package/cli/saved-lookup.mjs +2 -2
  79. package/cli/selected-input-worker.mjs +63 -0
  80. package/cli/sharing-review.mjs +5 -64
  81. package/cli/source-reliability.mjs +2 -2
  82. package/cli/support-arguments.mjs +49 -0
  83. package/cli/support-command-definitions.mjs +209 -0
  84. package/cli/support-command-runner.mjs +2 -2
  85. package/cli/workflow-arguments.mjs +86 -0
  86. package/cli/workflow-command-definitions.mjs +78 -0
  87. package/cli/workflow-command-runner.mjs +8 -1
  88. package/docs/cli-reference.md +27 -0
  89. package/docs/cli.md +223 -2
  90. package/docs/contextual-reviews.md +155 -0
  91. package/lib/analyst-taxonomy.mjs +2 -40
  92. package/lib/application-version.mjs +1 -1
  93. package/lib/availability.mjs +13 -789
  94. package/lib/bounded-contract-normalizers.mjs +4 -0
  95. package/lib/bounded-json.mjs +2 -330
  96. package/lib/browser-library-profile.mjs +62 -84
  97. package/lib/candidate-provenance-bounds.mjs +2 -0
  98. package/lib/classify.mjs +1 -1
  99. package/lib/ct-query.mjs +2 -27
  100. package/lib/ct-response-bounds.mjs +2 -9
  101. package/lib/ct-search.mjs +83 -47
  102. package/lib/dmarc-discovery.mjs +62 -0
  103. package/lib/dns-convergence-review.mjs +21 -8
  104. package/lib/dns-delegation-health.mjs +12 -8
  105. package/lib/dns-inheritance-review.mjs +53 -63
  106. package/lib/dns-intelligence.mjs +9 -7
  107. package/lib/dns-query-coverage.mjs +40 -0
  108. package/lib/domain-assurance.mjs +1 -1
  109. package/lib/domain-change-packet.mjs +12 -13
  110. package/lib/domain-change-review.mjs +32 -14
  111. package/lib/domain-control-flight-recorder.mjs +103 -27
  112. package/lib/domain-control-manifest.mjs +25 -17
  113. package/lib/domain-posture-analysis.mjs +22 -5
  114. package/lib/domain-posture.mjs +16 -15
  115. package/lib/domain-registration.mjs +392 -0
  116. package/lib/evidence-export-privacy.mjs +22 -15
  117. package/lib/evidence-export.mjs +3 -3
  118. package/lib/evidence-report.mjs +4 -1
  119. package/lib/external-intelligence-risk.mjs +2 -124
  120. package/lib/generated/cisa-kev-catalog.mjs +41 -3
  121. package/lib/generated/registrar-standing-catalogue.mjs +18 -10
  122. package/lib/generated/retire-browser-catalog.mjs +332 -19
  123. package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
  124. package/lib/html-signals.mjs +7 -2
  125. package/lib/http-evidence-bounds.mjs +2 -8
  126. package/lib/http-intelligence.mjs +13 -1
  127. package/lib/idn-confusable-policy.mjs +6 -4
  128. package/lib/idn-confusables.mjs +1 -1
  129. package/lib/interchange-fidelity-registry.mjs +10 -0
  130. package/lib/lookup-contract-primitives.mjs +1 -1
  131. package/lib/lookup-diagnostics.mjs +1 -23
  132. package/lib/lookup-network-evidence-bounds.mjs +1 -10
  133. package/lib/lookup-progress.mjs +50 -1
  134. package/lib/lookup-response-contract.mjs +69 -40
  135. package/lib/lookup-response.mjs +5 -15
  136. package/lib/lookup-source-progress.mjs +8 -22
  137. package/lib/lookup.mjs +1 -1
  138. package/lib/opportunity-scoring.mjs +2 -107
  139. package/lib/outbound-identity.mjs +1 -1
  140. package/lib/page-fingerprints.mjs +18 -1
  141. package/lib/perceptual-hash-comparison.mjs +2 -34
  142. package/lib/perceptual-hash.mjs +36 -10
  143. package/lib/portable-generator.mjs +2 -29
  144. package/lib/project-metadata.mjs +2 -8
  145. package/lib/rdap-client.mjs +11 -7
  146. package/lib/registrable-domain.mjs +2 -56
  147. package/lib/registrar-standing-contract.mjs +1 -1
  148. package/lib/registrar-standing.mjs +9 -13
  149. package/lib/registry-capabilities.mjs +2 -2
  150. package/lib/registry-capability-catalogue.mjs +27 -19
  151. package/lib/risk-calibration-summary.mjs +2 -352
  152. package/lib/risk-scoring.mjs +2 -345
  153. package/lib/scoring-evidence-quality.mjs +2 -107
  154. package/lib/semantic-version.mjs +2 -54
  155. package/lib/service-binding-dns.mjs +27 -0
  156. package/lib/smtp-transport-review.mjs +48 -19
  157. package/lib/sslbl-certificates.generated.mjs +141 -139
  158. package/lib/static-html-analysis.mjs +1 -1
  159. package/lib/structured-data-identity.mjs +1 -1
  160. package/lib/threat-intelligence-manifests.mjs +1 -1
  161. package/lib/threat-intelligence-runtime.mjs +1 -1
  162. package/lib/threat-intelligence-targets.mjs +1 -1
  163. package/lib/threat-intelligence-types.mjs +2 -75
  164. package/lib/typosquat-generator.mjs +11 -2
  165. package/lib/website-enrichment.mjs +388 -0
  166. package/lib/whois-authority.mjs +43 -5
  167. package/lib/whois-normalization.mjs +3 -1
  168. package/lib/whois-parser.mjs +5 -3
  169. package/package.json +13 -14
  170. package/packages/analysis/analyst-taxonomy.mjs +40 -0
  171. package/packages/analysis/bounded-json.mjs +330 -0
  172. package/packages/analysis/candidate-provenance-bounds.mjs +4 -0
  173. package/packages/analysis/cli-argument-grammar.mjs +216 -0
  174. package/packages/analysis/ct-query.mjs +27 -0
  175. package/packages/analysis/ct-response-bounds.mjs +9 -0
  176. package/packages/analysis/external-intelligence-risk.mjs +124 -0
  177. package/packages/analysis/http-evidence-bounds.mjs +8 -0
  178. package/packages/analysis/lookup-network-evidence-bounds.mjs +10 -0
  179. package/packages/analysis/opportunity-scoring.mjs +107 -0
  180. package/packages/analysis/perceptual-hash-comparison.mjs +34 -0
  181. package/packages/analysis/portable-generator.mjs +29 -0
  182. package/packages/analysis/project-metadata.mjs +8 -0
  183. package/packages/analysis/registrable-domain.mjs +56 -0
  184. package/packages/analysis/risk-calibration-summary.mjs +352 -0
  185. package/packages/analysis/risk-scoring.mjs +345 -0
  186. package/packages/analysis/scoring-evidence-quality.mjs +107 -0
  187. package/packages/analysis/semantic-version.mjs +54 -0
  188. package/packages/analysis/threat-intelligence-types.mjs +75 -0
  189. package/packages/cases/case-evidence-links.mjs +135 -0
  190. package/packages/cases/case-evidence-model.mjs +78 -12
  191. package/packages/cases/case-evidence-quality.mjs +5 -0
  192. package/packages/cases/case-export-input.mjs +7 -5
  193. package/packages/cases/case-incident-context.mjs +52 -0
  194. package/packages/cases/case-markdown.mjs +11 -0
  195. package/packages/cases/case-migration-model.mjs +40 -24
  196. package/packages/cases/case-recheck-model.mjs +90 -32
  197. package/packages/cases/case-record-contracts.mjs +1 -1
  198. package/packages/cases/case-record-core.mjs +1 -1
  199. package/packages/cases/case-record-operations.mjs +40 -112
  200. package/packages/cases/case-record-projection.mjs +8 -1
  201. package/packages/cases/case-record-version-input.mjs +19 -0
  202. package/packages/cases/case-report.mjs +63 -27
  203. package/packages/cases/case-requested-evidence.mjs +146 -0
  204. package/packages/cases/case-response-actions.mjs +103 -1
  205. package/packages/cases/case-response-model.mjs +9 -1
  206. package/packages/cases/case-response-outcomes.mjs +29 -8
  207. package/packages/cases/case-response-packet-render.mjs +13 -9
  208. package/packages/cases/case-response-packet.mjs +25 -11
  209. package/packages/cases/case-response-records.mjs +0 -1
  210. package/packages/cases/case-response-review-inputs.mjs +14 -3
  211. package/packages/cases/case-storage-model.mjs +1 -1
  212. package/packages/cases/case-workflow-metadata.mjs +225 -0
  213. package/packages/cases/case-workflow-migration.mjs +43 -0
  214. package/packages/cases/http-summary.mjs +6 -1
  215. package/packages/comparison/favicon-similarity.mjs +1 -1
  216. package/packages/comparison/page-similarity.mjs +1 -1
  217. package/packages/comparison/relationship-evidence.mjs +21 -9
  218. package/packages/comparison/relationship-provenance.mjs +7 -1
  219. package/packages/contracts/analyst-interchange.mjs +28 -10
  220. package/packages/contracts/case-portability.mjs +118 -25
  221. package/packages/contracts/cli-command-semantics.mjs +2 -0
  222. package/packages/contracts/cli-grammar.mjs +6 -0
  223. package/packages/contracts/cli-lookup-brief.mjs +2 -0
  224. package/packages/contracts/context-review.mjs +24 -0
  225. package/packages/contracts/document-intake.mjs +11 -0
  226. package/packages/contracts/domain-control-flight-recorder.mjs +30 -14
  227. package/packages/contracts/domain-control-manifest.mjs +1 -1
  228. package/packages/contracts/domain-control-monitor.mjs +13 -2
  229. package/packages/contracts/domain-control-review.mjs +36 -15
  230. package/packages/contracts/har-review.mjs +2 -0
  231. package/packages/contracts/identity-events.mjs +4 -0
  232. package/packages/contracts/investigation-run.mjs +2 -0
  233. package/packages/contracts/ip-address.mjs +10 -0
  234. package/packages/contracts/mail-authentication.mjs +5 -0
  235. package/packages/contracts/message-intake.mjs +31 -0
  236. package/packages/contracts/offline-comparison.mjs +4 -1
  237. package/packages/contracts/passive-mail.mjs +10 -0
  238. package/packages/contracts/web-capture.mjs +2 -2
  239. package/packages/contracts/workspace-portability.mjs +63 -17
  240. package/packages/evidence/collection-quality.mjs +67 -0
  241. package/packages/evidence/dns-query-outcome.mjs +72 -0
  242. package/packages/evidence/email-recipient.mjs +18 -0
  243. package/packages/evidence/lookup-target.mjs +5 -1
  244. package/packages/evidence/observation.mjs +2 -0
  245. package/packages/evidence/png-pixels.mjs +117 -0
  246. package/packages/evidence/public-address-policy.mjs +83 -0
  247. package/packages/interchange/defensive-indicator-export.mjs +264 -0
  248. package/packages/interchange/external-findings-import.mjs +4 -12
  249. package/packages/interchange/managed-indicator-export.mjs +35 -0
  250. package/packages/interchange/managed-indicator-set.mjs +210 -0
  251. package/packages/interchange/misp-indicator-export.mjs +93 -0
  252. package/packages/interchange/sharing-policy.mjs +56 -0
  253. package/packages/interchange/stix-indicator-export.mjs +129 -0
  254. package/packages/interchange/web-capture-import.mjs +22 -15
  255. package/packages/investigation/acquisition-decision-packet.mjs +1 -1
  256. package/packages/investigation/bulk-mail-exposure.mjs +7 -13
  257. package/packages/investigation/capture-coverage.mjs +57 -0
  258. package/packages/investigation/connector-provenance-review.mjs +100 -0
  259. package/packages/investigation/context-review.mjs +35 -0
  260. package/packages/investigation/document-intake.mjs +81 -0
  261. package/packages/investigation/docx-intake.mjs +133 -0
  262. package/packages/investigation/domain-history-review.mjs +100 -0
  263. package/packages/investigation/domain-transition-review.mjs +47 -0
  264. package/packages/investigation/evidence-coverage-ledger.mjs +9 -1
  265. package/packages/investigation/har-intake.mjs +68 -0
  266. package/packages/investigation/identity-event-intake.mjs +131 -0
  267. package/packages/investigation/identity-incident-review.mjs +39 -0
  268. package/packages/investigation/incident-sequence-review.mjs +58 -0
  269. package/packages/investigation/intake-report.mjs +19 -0
  270. package/packages/investigation/investigation-manifest.mjs +2 -2
  271. package/packages/investigation/link-intake.mjs +179 -0
  272. package/packages/investigation/lookup-asset-graph.mjs +1 -1
  273. package/packages/investigation/mail-authentication-review.mjs +222 -0
  274. package/packages/investigation/mail-header-identity.mjs +209 -0
  275. package/packages/investigation/message-intake.mjs +195 -0
  276. package/packages/investigation/page-behaviour.mjs +113 -0
  277. package/packages/investigation/pdf-intake.mjs +155 -0
  278. package/packages/investigation/platform-continuity-review.mjs +60 -0
  279. package/packages/investigation/qr-intake.mjs +56 -0
  280. package/packages/investigation/requested-action-hints.mjs +11 -0
  281. package/packages/investigation/selected-input-review.mjs +17 -0
  282. package/packages/investigation/storefront-review.mjs +70 -0
  283. package/packages/workspace/brand-profile-model.mjs +2 -2
  284. package/packages/workspace/bulk-review-model.mjs +28 -26
  285. package/packages/workspace/bulk-session-model.mjs +23 -13
  286. package/packages/workspace/campaign-model.mjs +1 -1
  287. package/packages/workspace/detection-rule-model.mjs +1 -1
  288. package/packages/workspace/hostile-input.mjs +1 -1
  289. package/packages/workspace/investigation-guide.mjs +2 -2
  290. package/packages/workspace/investigation-template-model.mjs +26 -2
  291. package/packages/workspace/page-baseline.mjs +2 -2
  292. package/packages/workspace/relationship-observation-model.mjs +17 -13
  293. package/packages/workspace/shortlist-model.mjs +14 -5
  294. package/packages/workspace/watchlist-history.mjs +28 -12
  295. package/packages/workspace/watchlist-store.mjs +17 -5
  296. package/packages/workspace/website-snapshot-model.mjs +28 -3
  297. package/packages/workspace/workspace-archive-crypto.mjs +1 -1
  298. package/packages/workspace/workspace-archive.mjs +5 -4
  299. package/third-party-notices.txt +549 -16
  300. package/lib/hostname.mjs +0 -2
@@ -2,6 +2,7 @@
2
2
  // injected so every traversal can be fixture-tested and the caller retains the
3
3
  // existing DNS timeout and missing/error semantics.
4
4
  import { parse as parseDomain } from 'tldts';
5
+ import { isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
5
6
  import { parseDmarcRecords, parseDmarcReportingAuthorization, parseReportingDestination, parseSpfRecords } from "./domain-posture-parsers.mjs";
6
7
  const SPF_LOOKUP_LIMIT = 10;
7
8
  const SPF_VOID_LOOKUP_LIMIT = 2;
@@ -166,9 +167,10 @@ function reportDestinationDomain(value) {
166
167
  if (/[!,;]/u.test(uri))
167
168
  return null;
168
169
  const destination = parseReportingDestination(uri);
169
- return destination?.scheme === 'mailto' ? strictHostname(destination.host) : null;
170
+ const domain = destination?.scheme === 'mailto' ? strictHostname(destination.host) : null;
171
+ return domain && isValidAsciiHostname(domain) ? domain : null;
170
172
  }
171
- async function validateDmarcExternalReporting(ownerDomain, dmarcQuery, resolveTxt) {
173
+ async function validateDmarcExternalReporting(ownerDomain, dmarcQuery, resolveTxt, resolveOrganisationalDomain) {
172
174
  if (dmarcQuery.error)
173
175
  return [];
174
176
  const parsed = parseDmarcRecords(dmarcQuery.records);
@@ -192,7 +194,7 @@ async function validateDmarcExternalReporting(ownerDomain, dmarcQuery, resolveTx
192
194
  });
193
195
  continue;
194
196
  }
195
- if (destinationDomain === ownerDomain || destinationDomain.endsWith(`.${ownerDomain}`)) {
197
+ if (destinationDomain === ownerDomain) {
196
198
  results.push({
197
199
  destination: destinationDomain,
198
200
  reportType: item.reportType,
@@ -202,6 +204,20 @@ async function validateDmarcExternalReporting(ownerDomain, dmarcQuery, resolveTx
202
204
  });
203
205
  continue;
204
206
  }
207
+ const ownerOrganisation = resolveOrganisationalDomain ? await resolveOrganisationalDomain(ownerDomain) : null;
208
+ const recipientOrganisation = ownerOrganisation && resolveOrganisationalDomain ? await resolveOrganisationalDomain(destinationDomain) : null;
209
+ if (ownerOrganisation && ownerOrganisation === recipientOrganisation) {
210
+ results.push({ destination: destinationDomain, reportType: item.reportType, recordName: null, state: 'self', error: null });
211
+ continue;
212
+ }
213
+ const externalConfirmed = Boolean(ownerOrganisation && recipientOrganisation);
214
+ // Descendant reporting previously made no authorisation request. Do not
215
+ // broaden default collection to resolve its unknown organisational scope.
216
+ if (!resolveOrganisationalDomain && destinationDomain.endsWith(`.${ownerDomain}`)) {
217
+ results.push({ destination: destinationDomain, reportType: item.reportType, recordName: null, state: 'unavailable',
218
+ error: 'The organisational boundary was not checked. Select the additional DNS review to determine whether external reporting authorisation is required.' });
219
+ continue;
220
+ }
205
221
  const recordName = `${ownerDomain}._report._dmarc.${destinationDomain}`;
206
222
  if (recordName.length > 253) {
207
223
  results.push({ destination: destinationDomain, reportType: item.reportType, recordName: null, state: 'unavailable', error: 'The reporting authorisation query exceeds the DNS name limit.' });
@@ -223,8 +239,9 @@ async function validateDmarcExternalReporting(ownerDomain, dmarcQuery, resolveTx
223
239
  destination: destinationDomain,
224
240
  reportType: item.reportType,
225
241
  recordName,
226
- state: query.error ? 'unavailable' : authorized ? 'authorized' : 'not_found',
227
- error: query.error,
242
+ state: query.error ? 'unavailable' : authorized ? 'authorized' : externalConfirmed ? 'not_found' : 'unavailable',
243
+ error: query.error ?? (!authorized && !externalConfirmed
244
+ ? 'The organisational boundary is unknown; no authorisation requirement can be inferred from the missing record.' : null),
228
245
  });
229
246
  }
230
247
  return results;
@@ -7,7 +7,7 @@ import { nonEmptyErrorMessage } from "./error-detail.mjs";
7
7
  import { safeFetch, readTextCapped } from "./safe-fetch.mjs";
8
8
  import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
9
9
  import { classifyMxRecords } from "./dns-mx.mjs";
10
- import { collectDnsInheritanceChecks } from "./dns-inheritance-review.mjs";
10
+ import { collectDnsInheritanceReview } from "./dns-inheritance-review.mjs";
11
11
  import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
12
12
  import { DOMAIN_POSTURE_COMPARISON_VERSION, MAX_POSTURE_CHECK_RECORDS, POSTURE_CHECK_SOURCES } from "../packages/evidence/domain-posture-context.mjs";
13
13
  import { canonicalPostureRecords } from "../packages/evidence/domain-control-runtime.mjs";
@@ -202,16 +202,18 @@ function dmarcCheck(query, authorizations = []) {
202
202
  parsed.failureReporting ? 'Failure reporting is configured.' : 'No failure reporting destination is configured.',
203
203
  ...parsed.issues,
204
204
  ];
205
- const external = authorizations.filter((authorization) => authorization.state !== 'self');
206
- const unresolvedExternal = external.filter((authorization) => authorization.state !== 'authorized');
205
+ const unresolvedExternal = authorizations.filter((authorization) => !['self', 'authorized'].includes(authorization.state));
207
206
  const intendedDestinations = parsed.aggregateDestinations.length + parsed.failureDestinations.length;
208
207
  const omittedDestinations = Math.max(0, intendedDestinations - authorizations.length);
209
208
  if (omittedDestinations) {
210
209
  details.push(`Reporting authorisation coverage: ${authorizations.length} of ${intendedDestinations} destinations reviewed; ${omittedDestinations} not checked within the bounded collection. Omitted destinations are not assumed authorised.`);
211
210
  }
212
- if (external.length > 0) {
213
- details.push(`${external.length} external reporting destination${external.length === 1 ? '' : 's'} checked; `
214
- + `${unresolvedExternal.length} could not be authorized.`);
211
+ if (authorizations.length > 0) {
212
+ details.push(`${authorizations.length} reporting destination${authorizations.length === 1 ? '' : 's'} reviewed; `
213
+ + `${unresolvedExternal.length} need further evidence.`);
214
+ }
215
+ if (unresolvedExternal.some(item => item.state === 'unavailable')) {
216
+ details.push('An unknown organisational boundary or unavailable query does not establish a missing authorisation requirement.');
215
217
  }
216
218
  if (parsed.testMode) {
217
219
  return check('dmarc', 'DMARC', 'warning', 'Policy is in test mode (t=y)', {
@@ -235,9 +237,7 @@ function dmarcCheck(query, authorizations = []) {
235
237
  return check('dmarc', 'DMARC', 'warning', `Enforced at p=${parsed.policy}; reporting authorisation is incomplete`, {
236
238
  detail: `${details.join(' ')} ${unresolvedExternal.map((authorization) => `${authorization.destination}: ${authorization.state}.`).join(' ')}`,
237
239
  records: parsed.records,
238
- remediation: omittedDestinations > 0
239
- ? 'Review the unchecked destinations and confirm any required external reporting authorisation before relying on delivery.'
240
- : 'Publish the required external reporting authorisation record or remove the unavailable destination.',
240
+ remediation: 'Review unresolved destinations and their organisational boundaries, then confirm any required external reporting authorisation before relying on delivery.',
241
241
  });
242
242
  }
243
243
  if (!parsed.aggregateReporting) {
@@ -785,17 +785,18 @@ async function collectDomainPosture(domain, { dkimSelectors = [], retiredDkimSel
785
785
  const enrichment = [
786
786
  parsedMtaDns?.valid ? dependencies.fetchMtaStsPolicy(domain, signal) : Promise.resolve(null),
787
787
  expandSpfPolicy(domain, spf, resolveEnrichmentTxt),
788
- validateDmarcExternalReporting(domain, dmarc, resolveEnrichmentTxt),
789
788
  includeInheritedDns === true
790
- ? (dependencies.collectDnsInheritanceChecks
791
- ? dependencies.collectDnsInheritanceChecks(domain, dmarc, nameservers, signal ? { signal } : {})
789
+ ? (dependencies.collectDnsInheritanceReview
790
+ ? dependencies.collectDnsInheritanceReview(domain, dmarc, nameservers, signal ? { signal } : {})
792
791
  : Promise.reject(new TypeError('The explicit DNS review collector is unavailable.')))
793
- : Promise.resolve([]),
792
+ : validateDmarcExternalReporting(domain, dmarc, resolveEnrichmentTxt)
793
+ .then(dmarcAuthorizations => ({ checks: [], dmarcAuthorizations })),
794
794
  ];
795
795
  // A rejected policy request must not release the operation's capacity while
796
796
  // other started enrichment collectors are still settling.
797
- const [mtaStsPolicy, spfExpansion, dmarcAuthorizations, inheritanceChecks] = await Promise.all(enrichment)
797
+ const [mtaStsPolicy, spfExpansion, dnsReview] = await Promise.all(enrichment)
798
798
  .finally(() => Promise.allSettled(enrichment));
799
+ const { dmarcAuthorizations, checks: inheritanceChecks } = dnsReview;
799
800
  signal?.throwIfAborted();
800
801
  const dnssec = !rdap
801
802
  ? { value: null, error: 'RDAP did not return a domain record.' }
@@ -884,7 +885,7 @@ async function checkDomainPosture(domain, options = {}, dependencies) {
884
885
  now: () => new Date(),
885
886
  setTimer: (callback, milliseconds) => setTimeout(callback, milliseconds),
886
887
  clearTimer: handle => clearTimeout(handle),
887
- collectDnsInheritanceChecks,
888
+ collectDnsInheritanceReview,
888
889
  });
889
890
  }
890
891
  finally {
@@ -0,0 +1,392 @@
1
+ // Registration authority, lifecycle dates and positive-only delegation evidence.
2
+ import { promises as dns } from 'node:dns';
3
+ import { abortable } from "./abort.mjs";
4
+ import { fetchRdapRecord } from "./rdap.mjs";
5
+ import { buildWhoisChain, parseWhoisChain } from "./whois.mjs";
6
+ import { featureDecision, networkFeaturePolicy } from "./feature-policy.mjs";
7
+ import { parseRegistryDate, registryDateIso } from "./registry-dates.mjs";
8
+ import { nonEmptyErrorMessage } from "./error-detail.mjs";
9
+ import { registryServiceAdmissionFor } from "./registry-capabilities.mjs";
10
+ import { recordOrEmpty as errorRecord } from "./json-record.mjs";
11
+ const DNS_DELEGATION_TIMEOUT_MS = 4000;
12
+ const MAX_DELEGATION_NAMESERVERS = 50;
13
+ const MISSING_DNS_CODES = new Set(['ENODATA', 'ENOTFOUND', 'ENONAME', 'NXDOMAIN']);
14
+ function registryStatusToken(value) {
15
+ return value.toLowerCase().replace(/[^a-z0-9]+/gu, '');
16
+ }
17
+ function rdapEventDate(events, action) {
18
+ const date = events.find((event) => event.action === action)?.date;
19
+ return typeof date === 'string' ? date : null;
20
+ }
21
+ function computeAgeDays(dateStr) {
22
+ const d = parseRegistryDate(dateStr);
23
+ return d ? Math.floor((Date.now() - d.getTime()) / 86400000) : null;
24
+ }
25
+ function computeDaysUntil(dateStr) {
26
+ const d = parseRegistryDate(dateStr);
27
+ return d ? Math.ceil((d.getTime() - Date.now()) / 86400000) : null;
28
+ }
29
+ const PRIVACY_MARKERS = [
30
+ /redacted for privacy/i,
31
+ /data protected/i,
32
+ /privacy\s*protect/i,
33
+ /whoisguard/i,
34
+ /domains by proxy/i,
35
+ /perfect privacy/i,
36
+ /contact privacy/i,
37
+ /private registration/i,
38
+ /identity protect/i,
39
+ /not disclosed/i,
40
+ /withheld for privacy/i,
41
+ ];
42
+ // Explicit privacy/redaction markers establish a positive privacy signal.
43
+ // Usable public contact data establishes a negative one. A missing or blank
44
+ // contact can instead reflect registry policy, access tier, parser coverage,
45
+ // or ordinary omission, so it remains inconclusive rather than being promoted
46
+ // to an affirmative privacy claim.
47
+ function isPrivacyProtected(registrant) {
48
+ if (!registrant)
49
+ return null;
50
+ const blob = [registrant.name, registrant.org, registrant.email].filter(Boolean).join(' ');
51
+ if (!blob)
52
+ return null;
53
+ return PRIVACY_MARKERS.some((re) => re.test(blob));
54
+ }
55
+ // RDAP Lookup retains a bounded multi-value contact inventory, but Bulk and
56
+ // watchlist availability records deliberately keep the historical compact
57
+ // shape. This prevents repeated contact arrays and registry links from
58
+ // expanding browser-local stores or flowing into case evidence implicitly.
59
+ function compactContact(contact) {
60
+ if (!contact || typeof contact !== 'object' || Array.isArray(contact))
61
+ return null;
62
+ const value = contact;
63
+ const compact = {
64
+ handle: value.handle || null,
65
+ name: value.name || null,
66
+ org: value.org || null,
67
+ email: value.email || null,
68
+ phone: value.phone || null,
69
+ address: value.address || null,
70
+ };
71
+ return Object.values(compact).some(Boolean) ? compact : null;
72
+ }
73
+ // A number of ccTLD registries publish neither an RDAP bootstrap entry nor a
74
+ // generally reachable port-43 WHOIS service. A positive NS answer cannot
75
+ // replace registry registration data, but it does prove that the registrable
76
+ // domain has an active DNS delegation. Use that as a bounded, positive-only
77
+ // fallback: no answer is never interpreted as availability because registered
78
+ // domains can legitimately be undelegated.
79
+ async function checkDnsDelegation(domain, options = {}) {
80
+ options.signal?.throwIfAborted();
81
+ // Cancel only this lookup's c-ares requests, never the shared DNS resolver.
82
+ const ownedResolver = options.resolver ? null : new dns.Resolver();
83
+ const resolve = options.resolver || ((name) => ownedResolver.resolveNs(name));
84
+ const controller = new AbortController();
85
+ const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
86
+ const cancel = () => ownedResolver?.cancel();
87
+ signal.addEventListener('abort', cancel, { once: true });
88
+ const timer = setTimeout(() => controller.abort(new Error('DNS delegation lookup timed out')), DNS_DELEGATION_TIMEOUT_MS);
89
+ try {
90
+ const records = await abortable(() => resolve(domain), signal);
91
+ const validNameservers = [...new Set((Array.isArray(records) ? records : [])
92
+ .filter((value) => typeof value === 'string')
93
+ .map((value) => value.trim().replace(/\.+$/, '').toLowerCase())
94
+ .filter((value) => value.length > 0 && value.length <= 253)
95
+ .filter((value) => value.split('.').every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(label))))]
96
+ .sort();
97
+ const normalized = validNameservers.slice(0, MAX_DELEGATION_NAMESERVERS);
98
+ return {
99
+ delegated: normalized.length > 0,
100
+ nameservers: normalized,
101
+ nameserversTruncated: validNameservers.length > MAX_DELEGATION_NAMESERVERS,
102
+ error: null,
103
+ };
104
+ }
105
+ catch (err) {
106
+ options.signal?.throwIfAborted();
107
+ const error = errorRecord(err);
108
+ if (typeof error.code === 'string' && MISSING_DNS_CODES.has(error.code)) {
109
+ return { delegated: false, nameservers: [], nameserversTruncated: false, error: null };
110
+ }
111
+ return {
112
+ delegated: false,
113
+ nameservers: [],
114
+ nameserversTruncated: false,
115
+ error: nonEmptyErrorMessage(err, String(err)).slice(0, 180),
116
+ };
117
+ }
118
+ finally {
119
+ clearTimeout(timer);
120
+ signal.removeEventListener('abort', cancel);
121
+ }
122
+ }
123
+ function registryPolicyDetail(domain, fast) {
124
+ const details = [];
125
+ const rdapAdmission = registryServiceAdmissionFor(domain, 'rdap');
126
+ if (rdapAdmission && !rdapAdmission.allowed) {
127
+ details.push('RDAP was not queried because the registry access policy does not permit collection.');
128
+ }
129
+ const whoisAdmission = fast ? null : registryServiceAdmissionFor(domain, 'whois');
130
+ if (whoisAdmission && !whoisAdmission.allowed) {
131
+ details.push(whoisAdmission.state === 'permission_required'
132
+ ? 'WHOIS was not queried because registry permission or source authorisation is required.'
133
+ : 'WHOIS was not queried because IANA publishes no domain WHOIS service for this suffix.');
134
+ }
135
+ return details.length ? ` ${details.join(' ')}` : '';
136
+ }
137
+ // fast: true skips the WHOIS fallback (no TCP:43 chain) and the homepage
138
+ // fetch (no for-sale/parking detection) - just RDAP plus the signals
139
+ // derivable from it (age, expiry proximity, privacy). Meant for scanning
140
+ // large sourcing candidate lists quickly and gently on registry rate
141
+ // limits; anything it can't resolve (state "unknown") is meant to get a
142
+ // follow-up deep check (fast: false, the default) on the shortlist only.
143
+ async function resolveDomainRegistration(domain, options, featurePolicy) {
144
+ options.signal?.throwIfAborted();
145
+ const fast = options.fast === true;
146
+ const rdapEnabled = featureDecision('rdap', featurePolicy).enabled;
147
+ const whoisEnabled = featureDecision('whois', featurePolicy).enabled;
148
+ const dnsIntelligenceEnabled = featureDecision('dns_intelligence', featurePolicy).enabled;
149
+ const hasPreloadedRdap = Object.prototype.hasOwnProperty.call(options, 'rdapRecord');
150
+ const hasPreloadedWhois = Object.prototype.hasOwnProperty.call(options, 'whoisChain');
151
+ const hasPreloadedRdapPromise = Object.prototype.hasOwnProperty.call(options, 'rdapRecordPromise');
152
+ const hasPreloadedWhoisPromise = Object.prototype.hasOwnProperty.call(options, 'whoisChainPromise');
153
+ const hasPreloadedDnsDelegation = Object.prototype.hasOwnProperty.call(options, 'dnsDelegation');
154
+ let nameservers = [];
155
+ let statuses = [];
156
+ let rdapServer = null;
157
+ let rdapFound = false;
158
+ let registrar = null;
159
+ let registrant = null;
160
+ let abuse = null;
161
+ let createdDate = null;
162
+ let expiryDate = null;
163
+ let createdDateIso = null;
164
+ let expiryDateIso = null;
165
+ let registrationSource = null;
166
+ let registrationConfidence = 'high';
167
+ let dnssec = null;
168
+ let registryDnsEvidence = null;
169
+ const assessment = (registration) => ({
170
+ registration, registryDnsEvidence, nameservers, dnssec,
171
+ });
172
+ if (rdapEnabled) {
173
+ try {
174
+ // Shared with /api/rdap (lib/rdap.mts's fetchRdapRecord) rather than a
175
+ // separate fetch+parse here - same registry data either way, and this
176
+ // also picks up that function's short-TTL cache (lib/lookup-cache.mts)
177
+ // and upstream timeout for free.
178
+ const recordValue = hasPreloadedRdapPromise
179
+ ? await abortable(() => options.rdapRecordPromise, options.signal)
180
+ : hasPreloadedRdap
181
+ ? options.rdapRecord
182
+ : await fetchRdapRecord('domain', domain, options.signal ? { signal: options.signal } : {});
183
+ options.signal?.throwIfAborted();
184
+ const record = errorRecord(recordValue);
185
+ const recordRdapServer = typeof record.rdapServer === 'string' ? record.rdapServer : null;
186
+ const upstreamStatus = typeof record.upstreamStatus === 'number' ? record.upstreamStatus : null;
187
+ if (Object.keys(record).length) {
188
+ rdapServer = recordRdapServer;
189
+ if (upstreamStatus === 404) {
190
+ const cleartext = record.transportSecurity === 'http'
191
+ || (recordRdapServer !== null && /^http:\/\//iu.test(recordRdapServer));
192
+ return assessment({
193
+ state: 'available',
194
+ confidence: cleartext ? 'medium' : 'high',
195
+ detail: `The registry's RDAP service has no record for this domain.${cleartext ? ' This answer arrived over unencrypted HTTP, so its transport was not authenticated.' : ''}`,
196
+ source: 'rdap',
197
+ rdapServer: recordRdapServer,
198
+ });
199
+ }
200
+ const parsed = errorRecord(record.parsed);
201
+ if (Object.keys(parsed).length) {
202
+ registryDnsEvidence = parsed;
203
+ statuses = Array.isArray(parsed.statuses)
204
+ ? parsed.statuses.filter((status) => typeof status === 'string').map((status) => status.toLowerCase())
205
+ : [];
206
+ nameservers = Array.isArray(parsed.nameservers)
207
+ ? parsed.nameservers.filter((nameserver) => typeof nameserver === 'string')
208
+ : [];
209
+ registrar = compactContact(parsed.registrar);
210
+ registrant = compactContact(parsed.registrant);
211
+ abuse = compactContact(parsed.abuse);
212
+ const events = Array.isArray(parsed.events)
213
+ ? parsed.events.map(errorRecord)
214
+ : [];
215
+ const lifecycle = errorRecord(parsed.lifecycle);
216
+ createdDate = typeof lifecycle.createdDate === 'string'
217
+ ? lifecycle.createdDate
218
+ : rdapEventDate(events, 'registration');
219
+ expiryDate = typeof lifecycle.expiryDate === 'string'
220
+ ? lifecycle.expiryDate
221
+ : rdapEventDate(events, 'expiration');
222
+ createdDateIso = typeof lifecycle.createdDateIso === 'string'
223
+ ? lifecycle.createdDateIso
224
+ : registryDateIso(createdDate);
225
+ expiryDateIso = typeof lifecycle.expiryDateIso === 'string'
226
+ ? lifecycle.expiryDateIso
227
+ : registryDateIso(expiryDate);
228
+ dnssec = typeof parsed.dnssec === 'string' ? parsed.dnssec : null;
229
+ rdapFound = true;
230
+ registrationSource = 'rdap';
231
+ }
232
+ }
233
+ }
234
+ catch {
235
+ options.signal?.throwIfAborted();
236
+ /* fall through to WHOIS-based detection (deep mode only) */
237
+ }
238
+ }
239
+ const dnsDelegationPromise = !rdapFound && dnsIntelligenceEnabled
240
+ ? hasPreloadedDnsDelegation
241
+ ? Promise.resolve(options.dnsDelegation)
242
+ : checkDnsDelegation(domain, {
243
+ ...(options.resolveNs ? { resolver: options.resolveNs } : {}),
244
+ ...(options.signal ? { signal: options.signal } : {}),
245
+ })
246
+ : null;
247
+ // DNS may settle while the independent WHOIS source is still pending. Keep
248
+ // its rejection observed even if WHOIS supplies an early conclusive return.
249
+ void dnsDelegationPromise?.catch(() => { });
250
+ let whoisChain = null;
251
+ let whoisParsed = null;
252
+ if (!rdapFound && !fast && whoisEnabled) {
253
+ try {
254
+ whoisChain = (hasPreloadedWhoisPromise
255
+ ? await abortable(() => options.whoisChainPromise, options.signal)
256
+ : hasPreloadedWhois
257
+ ? options.whoisChain
258
+ : await buildWhoisChain(domain, options.signal ? { signal: options.signal } : {})) ?? null;
259
+ options.signal?.throwIfAborted();
260
+ if (!Array.isArray(whoisChain))
261
+ throw new Error('WHOIS chain unavailable');
262
+ const parsed = parseWhoisChain(whoisChain, domain);
263
+ whoisParsed = parsed;
264
+ if (parsed.notFound) {
265
+ return assessment({
266
+ state: 'available',
267
+ confidence: 'medium',
268
+ detail: `WHOIS reports no matching record for this domain${parsed.notFoundSource ? ` (per ${parsed.notFoundSource})` : ''}.`,
269
+ source: 'whois',
270
+ });
271
+ }
272
+ if (parsed.registrationStatus === 'registered') {
273
+ if (parsed.nameservers.length)
274
+ nameservers = parsed.nameservers;
275
+ if (parsed.statuses.length)
276
+ statuses = parsed.statuses.map((status) => status.toLowerCase());
277
+ if (parsed.registrar) {
278
+ registrar = {
279
+ handle: null,
280
+ ianaId: parsed.registrarIanaId || null,
281
+ name: parsed.registrar,
282
+ org: null,
283
+ email: parsed.abuseEmail || null,
284
+ phone: parsed.abusePhone || null,
285
+ };
286
+ }
287
+ if (parsed.registrantName || parsed.registrantOrg || parsed.registrantEmail || parsed.registrantPhone) {
288
+ registrant = {
289
+ handle: null,
290
+ name: parsed.registrantName || null,
291
+ org: parsed.registrantOrg || null,
292
+ email: parsed.registrantEmail || null,
293
+ phone: parsed.registrantPhone || null,
294
+ };
295
+ }
296
+ if (parsed.abuseEmail || parsed.abusePhone) {
297
+ abuse = { handle: null, name: null, org: parsed.registrar || null, email: parsed.abuseEmail || null, phone: parsed.abusePhone || null };
298
+ }
299
+ createdDate = parsed.createdDate || null;
300
+ expiryDate = parsed.expiryDate || null;
301
+ createdDateIso = parsed.createdDateIso || parsed.lifecycle?.createdDateIso || registryDateIso(createdDate);
302
+ expiryDateIso = parsed.expiryDateIso || parsed.lifecycle?.expiryDateIso || registryDateIso(expiryDate);
303
+ if (!dnssec)
304
+ dnssec = parsed.dnssec || null;
305
+ registrationSource = 'whois';
306
+ }
307
+ }
308
+ catch {
309
+ options.signal?.throwIfAborted();
310
+ /* if both RDAP and WHOIS fail, we simply can't determine availability */
311
+ }
312
+ }
313
+ // WHOIS ran and was not a confirmed not-found, but produced no positive
314
+ // registration evidence either - e.g. the registry answered inconclusively
315
+ // or every referral hop failed/rate-limited. Report "unknown" rather than
316
+ // fabricating "registered" from an empty record.
317
+ const hasWhoisRegistrationData = whoisParsed?.registrationStatus === 'registered';
318
+ let dnsDelegated = false;
319
+ if (!rdapFound && !hasWhoisRegistrationData && dnsDelegationPromise) {
320
+ const delegation = await dnsDelegationPromise;
321
+ if (delegation && delegation.delegated === true && Array.isArray(delegation.nameservers) && delegation.nameservers.length) {
322
+ dnsDelegated = true;
323
+ nameservers = delegation.nameservers;
324
+ registrationSource = 'dns';
325
+ registrationConfidence = 'medium';
326
+ }
327
+ }
328
+ options.signal?.throwIfAborted();
329
+ if (!rdapFound && !hasWhoisRegistrationData && !dnsDelegated) {
330
+ const disabledSources = [
331
+ !rdapEnabled ? 'RDAP' : null,
332
+ !fast && !whoisEnabled ? 'WHOIS' : null,
333
+ !dnsIntelligenceEnabled ? 'DNS intelligence' : null,
334
+ ].filter(Boolean);
335
+ const disabledDetail = disabledSources.length
336
+ ? ` ${disabledSources.join(', ')} ${disabledSources.length === 1 ? 'is' : 'are'} disabled by deployment policy.`
337
+ : '';
338
+ const policyDetail = registryPolicyDetail(domain, fast);
339
+ return assessment({
340
+ state: 'unknown',
341
+ confidence: 'low',
342
+ detail: fast
343
+ ? `No enabled registration source produced a record or authoritative delegation. A fast scan cannot determine registration status.${disabledDetail}${policyDetail}`
344
+ : whoisParsed && whoisParsed.failedHop
345
+ ? `WHOIS was inconclusive - a referral hop did not answer conclusively (${whoisParsed.failedHop}).${policyDetail}`
346
+ : `No enabled registration source returned conclusive data or an authoritative DNS delegation.${disabledDetail}${policyDetail}`,
347
+ ...(!fast && whoisEnabled ? { source: 'whois' } : {}),
348
+ });
349
+ }
350
+ const domainAgeDays = computeAgeDays(createdDateIso || createdDate);
351
+ const expiresInDays = computeDaysUntil(expiryDateIso || expiryDate);
352
+ // DNS proves delegation only; it says nothing about whether registry
353
+ // contact data is privacy-protected or merely unavailable.
354
+ const privacyProtected = registrationSource === 'dns' ? null : isPrivacyProtected(registrant);
355
+ const baseInfo = {
356
+ nameservers,
357
+ statuses,
358
+ registrar,
359
+ registrant,
360
+ abuse,
361
+ createdDate,
362
+ expiryDate,
363
+ createdDateIso,
364
+ expiryDateIso,
365
+ rdapServer,
366
+ domainAgeDays,
367
+ expiresInDays,
368
+ privacyProtected,
369
+ dnssec,
370
+ source: registrationSource,
371
+ };
372
+ if (statuses.some((status) => {
373
+ const normalizedStatus = registryStatusToken(status);
374
+ return normalizedStatus.includes('pendingdelete') || normalizedStatus.includes('redemptionperiod');
375
+ })) {
376
+ return assessment({
377
+ state: 'expiring',
378
+ confidence: 'medium',
379
+ detail: 'Domain is in redemption/pending-delete status and may become available soon.',
380
+ ...baseInfo,
381
+ });
382
+ }
383
+ return assessment({
384
+ state: 'registered',
385
+ confidence: registrationConfidence,
386
+ detail: registrationSource === 'dns'
387
+ ? 'Authoritative DNS delegation confirms the domain is registered, but RDAP/WHOIS registration details were unavailable.'
388
+ : 'Domain is registered. Run a deep check for parking/for-sale detection.',
389
+ ...baseInfo,
390
+ });
391
+ }
392
+ export { resolveDomainRegistration, checkDnsDelegation, isPrivacyProtected };
@@ -1,9 +1,10 @@
1
1
  // Portable tree bounds, private-field exclusion and availability projection.
2
2
  // Source-specific publication assembly remains in evidence-export.mts.
3
- import { isSafeJsonObjectKey } from "./bounded-json.mjs";
3
+ import { isSafeJsonObjectKey } from "../packages/analysis/bounded-json.mjs";
4
4
  import { recordOrNull } from "./json-record.mjs";
5
- import { isUriShapedLabel } from "./portable-generator.mjs";
5
+ import { isUriShapedLabel } from "../packages/analysis/portable-generator.mjs";
6
6
  import { validHttpDeliveryMetadata, validPagePublicationMetadata } from "./homepage-metadata-contract.mjs";
7
+ import { normalizeWebCollectionQuality } from "../packages/evidence/collection-quality.mjs";
7
8
  import { LOOKUP_EVIDENCE_PORTABLE_MAX_BYTES, LOOKUP_EVIDENCE_PORTABLE_MAX_ENTRIES, LOOKUP_EVIDENCE_PORTABLE_MAX_DEPTH, LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS, LOOKUP_EVIDENCE_PORTABLE_MAX_KEY_LENGTH, LOOKUP_EVIDENCE_PORTABLE_MAX_STRING_LENGTH, } from "../packages/contracts/lookup-evidence.mjs";
8
9
  const PUBLIC_LOOKUP_AVAILABILITY_ANALYSIS_KEYS = new Set([
9
10
  'applicable', 'type', 'domain', 'state', 'confidence', 'detail', 'source',
@@ -22,7 +23,9 @@ const LOOKUP_AVAILABILITY_ANALYSIS_KEYS = new Set([
22
23
  ...[...PUBLIC_LOOKUP_AVAILABILITY_ANALYSIS_KEYS]
23
24
  .filter((key) => !['registrar', 'registrant', 'abuse'].includes(key)),
24
25
  'registryContactsExcluded', 'observationHostname', 'webObservationMode',
26
+ 'webCollectionQuality',
25
27
  ]);
28
+ const HISTORICAL_LOOKUP_AVAILABILITY_ANALYSIS_KEYS = new Set([...LOOKUP_AVAILABILITY_ANALYSIS_KEYS].filter(key => key !== 'webCollectionQuality'));
26
29
  const PRIVATE_EVIDENCE_KEYS = new Set([
27
30
  'authorization', 'proxyauthorization', 'cookie', 'cookies', 'setcookie',
28
31
  'session', 'sessionid', 'sessiontoken', 'token', 'accesstoken', 'refreshtoken',
@@ -174,7 +177,7 @@ function privateEvidenceKey(value, item) {
174
177
  || /(?:request|response|authorization|proxy)headers?$/u.test(normalized)
175
178
  || (normalized.includes('credential') && !normalized.startsWith('credentialsurface'));
176
179
  }
177
- export function portableUri(value) {
180
+ export function portableUri(value, legacyUris = false) {
178
181
  const text = boundedString(value, 2048);
179
182
  if (!text)
180
183
  return null;
@@ -184,7 +187,7 @@ export function portableUri(value) {
184
187
  || url.username
185
188
  || url.password)
186
189
  return null;
187
- if (url.protocol === 'http:' || url.protocol === 'https:') {
190
+ if (!legacyUris || url.protocol === 'http:' || url.protocol === 'https:') {
188
191
  url.search = '';
189
192
  url.hash = '';
190
193
  }
@@ -268,7 +271,7 @@ function projectLookupEvidenceAvailabilityPublicValue(value, path, state, depth)
268
271
  if (typeof value === 'string') {
269
272
  const normalizedParent = normalizedEvidenceKey(path.at(-1) || '');
270
273
  if (PORTABLE_URL_KEYS.has(normalizedParent))
271
- return portableUri(value);
274
+ return portableUri(value, true);
272
275
  if (PORTABLE_ORIGIN_COLLECTION_KEYS.has(normalizedParent))
273
276
  return portableOrigin(value);
274
277
  return projectPortableString(value);
@@ -313,21 +316,21 @@ function projectLookupEvidenceAvailabilityPublicValue(value, path, state, depth)
313
316
  }
314
317
  return output;
315
318
  }
316
- function projectLookupEvidenceAvailabilityValue(value, path, state, depth) {
319
+ function projectLookupEvidenceAvailabilityValue(value, path, state, depth, legacyUris) {
317
320
  consumePortableProjectionEntry(state, depth);
318
321
  if (value === null || typeof value === 'boolean' || typeof value === 'number')
319
322
  return value;
320
323
  if (typeof value === 'string') {
321
324
  const normalizedParent = normalizedEvidenceKey(path.at(-1) || '');
322
325
  if (PORTABLE_URL_KEYS.has(normalizedParent))
323
- return portableUri(value);
326
+ return portableUri(value, legacyUris);
324
327
  if (PORTABLE_ORIGIN_COLLECTION_KEYS.has(normalizedParent))
325
328
  return portableOrigin(value);
326
329
  return projectLookupEvidenceAvailabilityString(value);
327
330
  }
328
331
  if (Array.isArray(value)) {
329
332
  return value.slice(0, LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS)
330
- .map((item) => projectLookupEvidenceAvailabilityValue(item, path, state, depth + 1));
333
+ .map((item) => projectLookupEvidenceAvailabilityValue(item, path, state, depth + 1, legacyUris));
331
334
  }
332
335
  const source = recordOrNull(value);
333
336
  if (!source)
@@ -371,7 +374,7 @@ function projectLookupEvidenceAvailabilityValue(value, path, state, depth) {
371
374
  && (!LOOKUP_AVAILABILITY_VALUE_PATHS.has(parentPath)
372
375
  || (item !== null && typeof item !== 'string')))
373
376
  continue;
374
- output[key] = projectLookupEvidenceAvailabilityValue(item, [...path, key], state, depth + 1);
377
+ output[key] = projectLookupEvidenceAvailabilityValue(item, [...path, key], state, depth + 1, legacyUris);
375
378
  }
376
379
  return output;
377
380
  }
@@ -381,7 +384,7 @@ function projectLookupEvidenceAvailabilityValue(value, path, state, depth) {
381
384
  * extension cannot become authenticated evidence merely by avoiding a
382
385
  * credential-key denylist.
383
386
  */
384
- function projectLookupEvidenceAvailabilityWithKeys(value, rootKeys, registryContactsExcluded, currentPrivacyRules) {
387
+ function projectLookupEvidenceAvailabilityWithKeys(value, rootKeys, registryContactsExcluded, currentPrivacyRules, legacyUris = false) {
385
388
  const source = recordOrNull(value);
386
389
  if (!source)
387
390
  return null;
@@ -394,8 +397,12 @@ function projectLookupEvidenceAvailabilityWithKeys(value, rootKeys, registryCont
394
397
  const item = source[key];
395
398
  if (privateEvidenceKey(key, item))
396
399
  continue;
400
+ if (key === 'webCollectionQuality') {
401
+ output[key] = normalizeWebCollectionQuality(item);
402
+ continue;
403
+ }
397
404
  output[key] = currentPrivacyRules
398
- ? projectLookupEvidenceAvailabilityValue(item, [key], state, 1)
405
+ ? projectLookupEvidenceAvailabilityValue(item, [key], state, 1, legacyUris)
399
406
  : projectLookupEvidenceAvailabilityPublicValue(item, [key], state, 1);
400
407
  }
401
408
  if (registryContactsExcluded)
@@ -437,10 +444,10 @@ export function projectLookupEvidenceAvailabilityPublic(value) {
437
444
  * and abuse contact routes are deliberately excluded at the root boundary;
438
445
  * the marker prevents that privacy omission from being read as source absence.
439
446
  */
440
- export function projectLookupEvidenceAvailability(value) {
441
- return projectLookupEvidenceAvailabilityWithKeys(value, LOOKUP_AVAILABILITY_ANALYSIS_KEYS, true, true);
447
+ export function projectLookupEvidenceAvailability(value, { legacyUris = false } = {}) {
448
+ return projectLookupEvidenceAvailabilityWithKeys(value, legacyUris ? HISTORICAL_LOOKUP_AVAILABILITY_ANALYSIS_KEYS : LOOKUP_AVAILABILITY_ANALYSIS_KEYS, true, true, legacyUris);
442
449
  }
443
- export function assertLookupEvidencePrivacySafeTree(value) {
450
+ export function assertLookupEvidencePrivacySafeTree(value, { legacyUris = false } = {}) {
444
451
  assertLookupEvidencePortableTree(value);
445
452
  const pending = [value];
446
453
  while (pending.length) {
@@ -466,7 +473,7 @@ export function assertLookupEvidencePrivacySafeTree(value) {
466
473
  if (privateEvidenceKey(normalized, item)) {
467
474
  throw new TypeError('Lookup evidence contains excluded private request or session material.');
468
475
  }
469
- if (typeof item === 'string' && PORTABLE_URL_KEYS.has(normalized) && portableUri(item) !== item) {
476
+ if (typeof item === 'string' && PORTABLE_URL_KEYS.has(normalized) && portableUri(item, legacyUris) !== item) {
470
477
  throw new TypeError('Lookup evidence contains a credential-bearing or non-portable URL.');
471
478
  }
472
479
  pending.push(item);