@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
package/lib/ct-search.mjs CHANGED
@@ -8,13 +8,13 @@
8
8
  // Express server and the Netlify Functions.
9
9
  import { parse } from 'tldts';
10
10
  import { setTimeout as sleep } from 'node:timers/promises';
11
- import { normalizeCtQuery } from "./ct-query.mjs";
11
+ import { normalizeCtQuery } from "../packages/analysis/ct-query.mjs";
12
12
  import { safeFetch, readTextCapped } from "./safe-fetch.mjs";
13
13
  import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
14
14
  import { createObservation } from "../packages/evidence/observation.mjs";
15
- import { isValidAsciiHostname } from "./hostname.mjs";
15
+ import { isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
16
16
  import { normalizeCtTimestamp } from "../packages/evidence/observation.mjs";
17
- import { MAX_CT_RESPONSE_CERTIFICATE_GROUPS, MAX_CT_RESPONSE_DOMAINS_PER_GROUP, MAX_CT_RESPONSE_HOSTNAMES_PER_GROUP, MAX_CT_RESPONSE_HOSTNAMES_PER_MATCH, MAX_CT_RESPONSE_RESULTS, MAX_CT_RESPONSE_TIMESTAMP_LENGTH, } from "./ct-response-bounds.mjs";
17
+ import { MAX_CT_RESPONSE_CERTIFICATE_GROUPS, MAX_CT_RESPONSE_DOMAINS_PER_GROUP, MAX_CT_RESPONSE_HOSTNAMES_PER_GROUP, MAX_CT_RESPONSE_HOSTNAMES_PER_MATCH, MAX_CT_RESPONSE_RESULTS, MAX_CT_RESPONSE_TIMESTAMP_LENGTH, } from "../packages/analysis/ct-response-bounds.mjs";
18
18
  const CRT_SH_TIMEOUT_MS = 20000;
19
19
  const MAX_RESULTS = MAX_CT_RESPONSE_RESULTS;
20
20
  // crt.sh's response size scales with how many certificates ever matched the
@@ -38,6 +38,27 @@ const RETRY_DELAY_MS = 1500;
38
38
  // delay before a status-code retry above), so this caps the worst case at
39
39
  // two attempts (~40s) instead of stacking on top of MAX_RETRIES.
40
40
  const MAX_TIMEOUT_RETRIES = 1;
41
+ const COLLECTION_ERRORS = {
42
+ CT_TIMEOUT: [504, 'Certificate search timed out. Try a narrower keyword or try again later.'],
43
+ CT_OVERLOADED: [503, 'Certificate search is temporarily overloaded. Try again later.'],
44
+ CT_QUERY_TOO_BROAD: [422, 'Certificate search returned too many results. Try a narrower keyword.'],
45
+ CT_INVALID_RESPONSE: [502, 'Certificate search returned invalid UTF-8 or an unexpected response format (expected a JSON array). Try again later.'],
46
+ CT_UPSTREAM_ERROR: [502, 'Certificate search could not retrieve a response from its source. Try again later.'],
47
+ };
48
+ export class CtCollectionError extends Error {
49
+ code;
50
+ constructor(code) {
51
+ super(COLLECTION_ERRORS[code][1]);
52
+ this.code = code;
53
+ this.name = 'CtCollectionError';
54
+ }
55
+ }
56
+ /** Only this finite collection vocabulary may cross an HTTP error boundary. */
57
+ export function ctCollectionErrorResponse(error) {
58
+ return error instanceof CtCollectionError
59
+ ? { statusCode: COLLECTION_ERRORS[error.code][0], body: { error: COLLECTION_ERRORS[error.code][1], errorCode: error.code } }
60
+ : null;
61
+ }
41
62
  // Row-count defense: a hostile or malformed response could fit a very large
42
63
  // number of tiny rows into the 5 MB byte cap and impose excessive
43
64
  // iteration/set overhead after parsing. Legitimate responses under the byte
@@ -75,61 +96,76 @@ function normalizeHostname(raw) {
75
96
  function delay(ms, signal) {
76
97
  return sleep(ms, undefined, signal ? { signal } : {});
77
98
  }
78
- async function fetchCrtSh(keyword, attempt = 0, dependencies = {}) {
99
+ async function fetchCrtSh(keyword, dependencies = {}) {
79
100
  dependencies.signal?.throwIfAborted();
80
101
  const fetcher = dependencies.fetcher || safeFetch;
81
102
  const wait = dependencies.delay || delay;
82
- const controller = new AbortController();
83
- const signal = dependencies.signal ? AbortSignal.any([dependencies.signal, controller.signal]) : controller.signal;
84
- const timeout = setTimeout(() => controller.abort(), CRT_SH_TIMEOUT_MS);
85
- try {
86
- let res;
103
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
104
+ dependencies.signal?.throwIfAborted();
105
+ const controller = new AbortController();
106
+ const signal = dependencies.signal ? AbortSignal.any([dependencies.signal, controller.signal]) : controller.signal;
107
+ const timeout = setTimeout(() => controller.abort(), CRT_SH_TIMEOUT_MS);
108
+ let retryDelay = 0;
87
109
  try {
88
- res = await fetcher(`https://crt.sh/?q=${encodeURIComponent(keyword)}&output=json`, {
110
+ const res = await fetcher(`https://crt.sh/?q=${encodeURIComponent(keyword)}&output=json`, {
89
111
  headers: whoisleuthRequestHeaders({ Accept: 'application/json' }),
90
112
  signal,
91
113
  }, 0);
92
- }
93
- catch (err) {
94
- dependencies.signal?.throwIfAborted();
95
- if (!(err instanceof Error) || err.name !== 'AbortError')
96
- throw err;
97
- if (attempt < MAX_TIMEOUT_RETRIES)
98
- return fetchCrtSh(keyword, attempt + 1, dependencies);
99
- throw new Error(`crt.sh took too long to respond (>${CRT_SH_TIMEOUT_MS / 1000}s per attempt, ${attempt + 1} attempts) - ` +
100
- "it can be slow for broad search terms or under heavy load; try a narrower keyword or try again shortly.");
101
- }
102
- if (!res.ok) {
103
- // Not reading this body - release it explicitly instead of leaving an
104
- // unconsumed stream (and the connection it's tied to) open until
105
- // undici's own idle-timeout eventually notices.
106
- await res.body?.cancel().catch(() => { });
107
- if (RETRYABLE_STATUSES.has(res.status) && attempt < MAX_RETRIES) {
108
- await wait(RETRY_DELAY_MS * (attempt + 1), dependencies.signal);
109
- dependencies.signal?.throwIfAborted();
110
- return fetchCrtSh(keyword, attempt + 1, dependencies);
114
+ if (!res.ok) {
115
+ await res.body?.cancel().catch(() => { });
116
+ if (RETRYABLE_STATUSES.has(res.status) && attempt < MAX_RETRIES) {
117
+ retryDelay = RETRY_DELAY_MS * (attempt + 1);
118
+ }
119
+ else {
120
+ throw new CtCollectionError(RETRYABLE_STATUSES.has(res.status) || res.status === 429 ? 'CT_OVERLOADED' : 'CT_UPSTREAM_ERROR');
121
+ }
122
+ }
123
+ else {
124
+ let text;
125
+ try {
126
+ const body = await readTextCapped(res, CRT_SH_MAX_BYTES, { fatalUtf8: true });
127
+ signal.throwIfAborted();
128
+ if (body.truncated)
129
+ throw new CtCollectionError('CT_QUERY_TOO_BROAD');
130
+ text = body.text;
131
+ }
132
+ catch (error) {
133
+ // Preserve transport/deadline failures for the attempt retry below.
134
+ if (error instanceof TypeError && !signal.aborted) {
135
+ throw new CtCollectionError('CT_INVALID_RESPONSE');
136
+ }
137
+ throw error;
138
+ }
139
+ let data;
140
+ try {
141
+ data = JSON.parse(text);
142
+ }
143
+ catch {
144
+ throw new CtCollectionError('CT_INVALID_RESPONSE');
145
+ }
146
+ if (!Array.isArray(data))
147
+ throw new CtCollectionError('CT_INVALID_RESPONSE');
148
+ if (data.length > MAX_CT_ROWS)
149
+ throw new CtCollectionError('CT_QUERY_TOO_BROAD');
150
+ return data;
111
151
  }
112
- throw new Error(RETRYABLE_STATUSES.has(res.status)
113
- ? `crt.sh is temporarily overloaded (${res.status} after ${attempt + 1} attempts) - it's a free public service that intermittently struggles under load; try again in a moment.`
114
- : `crt.sh returned ${res.status}`);
115
- }
116
- const { text, truncated } = await readTextCapped(res, CRT_SH_MAX_BYTES, { fatalUtf8: true });
117
- signal.throwIfAborted();
118
- if (truncated) {
119
- throw new Error(`crt.sh returned more than ${CRT_SH_MAX_BYTES / (1024 * 1024)}MB of results for "${keyword}" - try a narrower/more specific keyword.`);
120
152
  }
121
- const data = JSON.parse(text);
122
- if (!Array.isArray(data)) {
123
- throw new Error('crt.sh returned an unexpected response format (expected a JSON array).');
153
+ catch (error) {
154
+ dependencies.signal?.throwIfAborted();
155
+ if (!controller.signal.aborted && (!(error instanceof Error) || error.name !== 'AbortError'))
156
+ throw error;
157
+ if (attempt >= MAX_TIMEOUT_RETRIES)
158
+ throw new CtCollectionError('CT_TIMEOUT');
124
159
  }
125
- if (data.length > MAX_CT_ROWS) {
126
- throw new Error(`crt.sh returned too many rows (${data.length}) for "${keyword}" - try a narrower keyword.`);
160
+ finally {
161
+ // End this attempt before delay/retry: body reads share the same deadline
162
+ // as headers, and earlier timers cannot fire during a later attempt.
163
+ clearTimeout(timeout);
127
164
  }
128
- return data;
129
- }
130
- finally {
131
- clearTimeout(timeout);
165
+ if (retryDelay)
166
+ await wait(retryDelay, dependencies.signal);
132
167
  }
168
+ throw new CtCollectionError('CT_UPSTREAM_ERROR');
133
169
  }
134
170
  // ---------------------------------------------------------------------------
135
171
  // Certificate identity
@@ -485,7 +521,7 @@ async function searchCertificateTransparency(keyword, dependencies = {}) {
485
521
  }),
486
522
  };
487
523
  }
488
- const data = await fetchCrtSh(trimmed, 0, dependencies);
524
+ const data = await fetchCrtSh(trimmed, dependencies);
489
525
  const summary = summarizeCtResults(data);
490
526
  return {
491
527
  certCount: data.length,
@@ -0,0 +1,62 @@
1
+ import { isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
2
+ import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
3
+ import { parseDmarcRecords } from "./domain-posture-parsers.mjs";
4
+ export function requireDmarcDomain(domain) {
5
+ if (typeof domain !== 'string' || domain.length > 253 || !isValidAsciiHostname(domain) || domain !== domain.toLowerCase()) {
6
+ throw new TypeError('The additional DNS review requires a normalised domain.');
7
+ }
8
+ }
9
+ export function dmarcPolicyObservation(owner, observation) {
10
+ const records = observation.records;
11
+ let bytes = 0;
12
+ const admitted = Array.isArray(records) && records.length <= 64 && records.every(raw => {
13
+ const parts = typeof raw === 'string' ? [raw] : raw;
14
+ return Array.isArray(parts) && parts.length <= 256 && parts.every(part => {
15
+ if (typeof part !== 'string' || part.length > 4096)
16
+ return false;
17
+ bytes += Buffer.byteLength(part, 'utf8');
18
+ return bytes <= 65_536;
19
+ });
20
+ });
21
+ const parsed = parseDmarcRecords(admitted ? records : []);
22
+ const unavailable = Boolean(observation.error) || !admitted;
23
+ const psd = parsed.tags.psd?.toLowerCase() ?? 'u';
24
+ const valid = !unavailable && parsed.valid && ['u', 'n', 'y'].includes(psd);
25
+ const incomplete = unavailable || (!valid && parsed.records.length === 1);
26
+ const state = unavailable ? 'unavailable' : valid ? 'observed' : parsed.records.length ? 'invalid' : 'not found';
27
+ const description = valid
28
+ ? `p=${parsed.policy}; sp=${parsed.subdomainPolicy}; np=${parsed.nonexistentSubdomainPolicy}; psd=${psd}; test=${parsed.testMode ? 'yes' : 'no'}`
29
+ : state;
30
+ const time = typeof observation.observedAt === 'string' && observation.observedAt.length <= 64
31
+ ? normalizeExplicitIsoTimestamp(observation.observedAt) : null;
32
+ return { owner, parsed, psd, valid, unavailable, incomplete,
33
+ record: `Recursive DNS TXT _dmarc.${owner} · ${time ?? 'time unavailable'} · ${description}` };
34
+ }
35
+ /** RFC 9989 sections 4.10–4.10.2: at most eight owners, never PSL inference. */
36
+ export async function walkDmarcTree(domain, exact, resolve, purpose) {
37
+ requireDmarcDomain(domain);
38
+ const rows = [dmarcPolicyObservation(domain, exact)];
39
+ const first = rows[0];
40
+ const labels = domain.split('.');
41
+ let next = labels.length >= 8 ? labels.slice(-7) : labels.slice(1);
42
+ const policyOnly = purpose === 'policy' && first.valid;
43
+ if (!policyOnly && !first.unavailable && !(first.valid && ['n', 'y'].includes(first.psd))) {
44
+ while (next.length) {
45
+ const owner = next.join('.');
46
+ const row = dmarcPolicyObservation(owner, await resolve(`_dmarc.${owner}`));
47
+ rows.push(row);
48
+ if (row.unavailable || (row.valid && ['n', 'y'].includes(row.psd)))
49
+ break;
50
+ next = next.slice(1);
51
+ }
52
+ }
53
+ const available = rows.filter(row => row.valid);
54
+ const boundary = available.find(row => row.psd === 'n' || (row.psd === 'y' && row.owner !== domain));
55
+ const incomplete = rows.some(row => row.incomplete);
56
+ // An exact policy alone does not establish the organisational boundary.
57
+ const organisationalDomain = incomplete || (policyOnly && !['n', 'y'].includes(first.psd)) ? null
58
+ : boundary?.psd === 'y'
59
+ ? labels.slice(-(boundary.owner.split('.').length + 1)).join('.')
60
+ : boundary?.owner ?? available.at(-1)?.owner ?? domain;
61
+ return { rows, available, boundary, incomplete, organisationalDomain };
62
+ }
@@ -1,14 +1,18 @@
1
1
  import { exactKeys, requireBoundedString, requireDomainName, requireIsoTimestamp, requireRecord, } from "./bounded-contract-normalizers.mjs";
2
2
  import { normaliseRdata } from "./zone-intent-review.mjs";
3
+ import { dnsQueryState, normaliseDnsQueryCoverage } from "./dns-query-coverage.mjs";
3
4
  export const DNS_CONVERGENCE_INPUT_SCHEMA = 'whoisleuth.dns-convergence.input';
4
5
  export const DNS_CONVERGENCE_REVIEW_SCHEMA = 'whoisleuth.dns-convergence.review';
5
- export const DNS_CONVERGENCE_REVIEW_VERSION = 1;
6
+ export const DNS_CONVERGENCE_INPUT_VERSION = 2;
7
+ export const SUPPORTED_DNS_CONVERGENCE_INPUT_VERSIONS = Object.freeze([1, DNS_CONVERGENCE_INPUT_VERSION]);
8
+ export const DNS_CONVERGENCE_REVIEW_VERSION = 2;
6
9
  export const MAX_DNS_CONVERGENCE_SNAPSHOTS = 100;
7
10
  export const MAX_DNS_CONVERGENCE_RECORDS = 2_000;
8
11
  const RECORD_TYPES = new Set(['A', 'AAAA', 'CAA', 'CNAME', 'MX', 'NS', 'SOA', 'SRV', 'SVCB', 'HTTPS', 'TLSA', 'TXT']);
9
12
  const ROOT_KEYS = new Set(['schema', 'version', 'domain', 'expected', 'snapshots']);
10
13
  const EXPECTED_KEYS = new Set(['owner', 'type', 'values']);
11
14
  const SNAPSHOT_KEYS = new Set(['observer', 'source', 'observedAt', 'state', 'records']);
15
+ const COVERED_SNAPSHOT_KEYS = new Set([...SNAPSHOT_KEYS, 'queries']);
12
16
  const RECORD_KEYS = new Set(['owner', 'type', 'value', 'ttl']);
13
17
  function type(value, label) {
14
18
  const candidate = requireBoundedString(value, label, 16).toUpperCase();
@@ -83,8 +87,8 @@ function expectedRows(value, apex) {
83
87
  }
84
88
  export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toISOString()) {
85
89
  const input = requireRecord(inputRaw, 'DNS convergence input');
86
- if (input.schema !== DNS_CONVERGENCE_INPUT_SCHEMA || input.version !== 1) {
87
- throw new TypeError(`DNS convergence input must use ${DNS_CONVERGENCE_INPUT_SCHEMA} version 1.`);
90
+ if (input.schema !== DNS_CONVERGENCE_INPUT_SCHEMA || !SUPPORTED_DNS_CONVERGENCE_INPUT_VERSIONS.includes(input.version)) {
91
+ throw new TypeError(`DNS convergence input must use ${DNS_CONVERGENCE_INPUT_SCHEMA} version 1 or ${DNS_CONVERGENCE_INPUT_VERSION}.`);
88
92
  }
89
93
  exactKeys(input, ROOT_KEYS, 'DNS convergence input');
90
94
  const apex = requireDomainName(input.domain, 'domain');
@@ -93,9 +97,10 @@ export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toI
93
97
  throw new TypeError(`snapshots must contain between 2 and ${MAX_DNS_CONVERGENCE_SNAPSHOTS} entries.`);
94
98
  }
95
99
  let recordCount = 0;
100
+ let queryCount = 0;
96
101
  const snapshots = input.snapshots.map((raw, index) => {
97
102
  const item = requireRecord(raw, `snapshots[${index}]`);
98
- exactKeys(item, SNAPSHOT_KEYS, `snapshots[${index}]`);
103
+ exactKeys(item, input.version === 1 ? SNAPSHOT_KEYS : COVERED_SNAPSHOT_KEYS, `snapshots[${index}]`);
99
104
  if (!Array.isArray(item.records))
100
105
  throw new TypeError(`snapshots[${index}].records must be an array.`);
101
106
  recordCount += item.records.length;
@@ -104,12 +109,20 @@ export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toI
104
109
  const observationState = state(item.state, `snapshots[${index}].state`);
105
110
  if (observationState === 'unavailable' && item.records.length)
106
111
  throw new TypeError(`snapshots[${index}] cannot contain records when unavailable.`);
112
+ const records = Object.freeze(item.records.map((entry, recordIndex) => record(entry, apex, `snapshots[${index}].records[${recordIndex}]`)));
113
+ const queries = normaliseDnsQueryCoverage(item.queries, {
114
+ legacy: input.version === 1, label: `snapshots[${index}].queries`,
115
+ maximum: MAX_DNS_CONVERGENCE_RECORDS - queryCount, state: observationState, records,
116
+ owner: (value, label) => owner(value, apex, label), type,
117
+ });
118
+ queryCount += queries.length;
107
119
  return Object.freeze({
108
120
  observer: requireBoundedString(item.observer, `snapshots[${index}].observer`, 120),
109
121
  source: requireBoundedString(item.source, `snapshots[${index}].source`, 240),
110
122
  observedAt: requireIsoTimestamp(item.observedAt, `snapshots[${index}].observedAt`),
111
123
  state: observationState,
112
- records: Object.freeze(item.records.map((entry, recordIndex) => record(entry, apex, `snapshots[${index}].records[${recordIndex}]`))),
124
+ records,
125
+ queries,
113
126
  });
114
127
  }).sort((left, right) => left.observedAt.localeCompare(right.observedAt) || left.observer.localeCompare(right.observer));
115
128
  const observers = [...new Set(snapshots.map((item) => item.observer))];
@@ -118,7 +131,7 @@ export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toI
118
131
  const latestByObserver = observers.map((observer) => snapshots.filter((item) => item.observer === observer).at(-1));
119
132
  const keys = [...new Set([
120
133
  ...expected.map((item) => `${item.owner}\u0000${item.type}`),
121
- ...snapshots.flatMap((item) => item.records.map((entry) => `${entry.owner}\u0000${entry.type}`)),
134
+ ...snapshots.flatMap((item) => item.queries.map((entry) => `${entry.owner}\u0000${entry.type}`)),
122
135
  ])].sort();
123
136
  const rows = keys.map((key) => {
124
137
  const [recordOwner = '', recordType = ''] = key.split('\u0000');
@@ -134,7 +147,7 @@ export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toI
134
147
  observer: snapshot.observer,
135
148
  source: snapshot.source,
136
149
  observedAt: snapshot.observedAt,
137
- state: snapshot.state,
150
+ state: dnsQueryState(snapshot.queries, recordOwner, recordType),
138
151
  values: Object.freeze(values),
139
152
  cacheUntil,
140
153
  });
@@ -178,7 +191,7 @@ export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toI
178
191
  'This local workbench compares only supplied observations and makes no DNS request.',
179
192
  'Resolver differences can reflect caches, split-horizon policy, interception, collection timing, or propagation; they do not establish which value is authoritative.',
180
193
  'Cache-until times are simple observation-time plus supplied TTL projections and do not reveal actual cache age, refresh behaviour, or resolver policy.',
181
- 'Partial and unavailable snapshots remain incomplete and are never interpreted as record absence.',
194
+ 'Only an explicitly observed query can establish an empty answer. Unqueried, partial and unavailable record types are never interpreted as absence; legacy inputs establish scope only for their supplied records.',
182
195
  ]),
183
196
  });
184
197
  }
@@ -6,7 +6,7 @@ import { promises as dns } from 'node:dns';
6
6
  import * as net from 'node:net';
7
7
  import { createObservation } from "../packages/evidence/observation.mjs";
8
8
  import { normalizeResolverMxRecord } from "../packages/evidence/domain-control-runtime.mjs";
9
- import { isValidAsciiHostname } from "./hostname.mjs";
9
+ import { isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
10
10
  import { isPrivateAddress } from "./safe-fetch.mjs";
11
11
  const DNS_DELEGATION_HEALTH_VERSION = 1;
12
12
  const MAX_AUTHORITIES = 4;
@@ -465,8 +465,10 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
465
465
  .filter((primary) => primary !== null))];
466
466
  const soaSerialConflict = soaSerials.length > 1;
467
467
  const soaPrimaryConflict = soaPrimaries.length > 1;
468
- const inBailiwick = candidates.filter((nameserver) => (nameserver === domain || nameserver.endsWith(`.${domain}`)));
469
- const missingGlue = inBailiwick.filter((nameserver) => (!(registry.nameserverDetails.find((item) => item.name === nameserver)?.addresses.length)));
468
+ const glueInventoryComplete = (parentStatus === 'success' && parentNameservers.length > 0 && parentQuery.truncated !== true)
469
+ || (registry.nameservers.length > 0 && !registry.truncated);
470
+ const inBailiwick = eligibleAuthorities.filter((nameserver) => (nameserver === domain || nameserver.endsWith(`.${domain}`)));
471
+ const unobservedGlue = inBailiwick.filter((nameserver) => (!(registry.nameserverDetails.find((item) => item.name === nameserver)?.addresses.length)));
470
472
  const dnssecMismatch = (registry.delegationSigned === true && registry.dsRecordCount === 0) || (registry.delegationSigned === false && registry.dsRecordCount > 0);
471
473
  const recordMatrix = authorityRecordMatrix(authorities);
472
474
  const recordCollectionAttempted = authorities.some((authority) => authority.recordSets.length > 0);
@@ -507,11 +509,13 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
507
509
  : soaPrimaryConflict
508
510
  ? 'Authoritative servers published different SOA primary nameservers'
509
511
  : 'Observed authority SOA serials and primary nameservers agree', retainedSoa.map((entry) => (`${entry.nameserver}: serial ${entry.soa.serial ?? 'unavailable'}, primary ${entry.soa.nsname ?? 'unavailable'}, refresh ${entry.soa.refresh ?? 'unavailable'}, retry ${entry.soa.retry ?? 'unavailable'}, expire ${entry.soa.expire ?? 'unavailable'}`)).join(' | ') || 'No complete SOA observation was available.', 'Confirm zone propagation and the intended SOA primary before relying on a DNS change or secondary-server state.'),
510
- finding('in_bailiwick_glue', 'In-bailiwick glue', !inBailiwick.length ? 'healthy' : missingGlue.length ? 'warning' : 'healthy', !inBailiwick.length
511
- ? 'No in-bailiwick nameserver requires registry glue'
512
- : missingGlue.length
513
- ? 'Registry glue was not observed for every in-bailiwick nameserver'
514
- : 'Registry publication includes observed glue for each in-bailiwick nameserver', missingGlue.length ? `Missing observed glue: ${missingGlue.join(', ')}.` : `In-bailiwick nameservers: ${inBailiwick.join(', ') || 'none'}.`, 'Publish current A and AAAA glue at the registry for every in-bailiwick nameserver.'),
512
+ finding('in_bailiwick_glue', 'In-bailiwick glue', !glueInventoryComplete || unobservedGlue.length ? 'unknown' : 'healthy', !glueInventoryComplete
513
+ ? 'The delegated nameserver inventory is unavailable or incomplete'
514
+ : !inBailiwick.length
515
+ ? 'No in-bailiwick nameserver requires registry glue'
516
+ : unobservedGlue.length
517
+ ? 'Registry address publication does not establish glue for every in-bailiwick nameserver'
518
+ : 'Registry publication includes observed glue for each in-bailiwick nameserver', unobservedGlue.length ? `No address publication retained for: ${unobservedGlue.join(', ')}. Optional RDAP address fields and recursive NS answers do not establish that parent-zone glue is absent.` : `In-bailiwick nameservers: ${inBailiwick.join(', ') || 'none'}.`, 'Verify parent-zone glue with the registry or DNS operator before deciding whether an update is needed.'),
515
519
  finding('dnssec_delegation', 'DNSSEC delegation publication', dnssecMismatch ? 'warning'
516
520
  : registry.delegationSigned === null && registry.dsRecordCount === 0 ? 'unknown' : 'healthy', dnssecMismatch
517
521
  ? 'Registry DNSSEC fields are internally inconsistent'
@@ -1,20 +1,16 @@
1
1
  import { promises as dns } from 'node:dns';
2
2
  import { parse as parseDomain } from 'tldts';
3
- import { parseDmarcRecords } from "./domain-posture-parsers.mjs";
3
+ import { validateDmarcExternalReporting } from "./domain-posture-analysis.mjs";
4
+ import { requireDmarcDomain, walkDmarcTree } from "./dmarc-discovery.mjs";
4
5
  import { buildDnssecQuery, DNS_TYPE_NS, normalizeResolverEndpoint, parseDnssecResponse } from "./dnssec-chain-validation.mjs";
5
6
  import { defaultTcpExchange } from "./service-binding-dns.mjs";
6
- import { isValidAsciiHostname } from "./hostname.mjs";
7
- import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
7
+ import { isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
8
8
  const MAX_PARENT_SERVERS = 2;
9
9
  const MAX_NS = 16;
10
10
  const QUERY_TIMEOUT_MS = 2200;
11
11
  const REVIEW_TIMEOUT_MS = 10_000;
12
+ export const MAX_DMARC_REVIEW_QUERIES = 32;
12
13
  const MISSING = new Set(['ENODATA', 'ENOTFOUND', 'ENONAME']);
13
- function requireDomain(domain) {
14
- if (typeof domain !== 'string' || domain.length > 253 || !isValidAsciiHostname(domain) || domain !== domain.toLowerCase()) {
15
- throw new TypeError('The additional DNS review requires a normalised domain.');
16
- }
17
- }
18
14
  /** Explicit query parameter; malformed opt-ins never broaden collection. */
19
15
  export function parseInheritedDnsSelection(value) {
20
16
  if (value === undefined || value === '')
@@ -26,53 +22,11 @@ export function parseInheritedDnsSelection(value) {
26
22
  function check(id, label, status, summary, detail, records) {
27
23
  return { id, label, status, summary, detail, records, remediation: '' };
28
24
  }
29
- function policyObservation(owner, observation) {
30
- const records = observation.records;
31
- let bytes = 0;
32
- const admitted = Array.isArray(records) && records.length <= 64 && records.every(raw => {
33
- const parts = typeof raw === 'string' ? [raw] : raw;
34
- return Array.isArray(parts) && parts.length <= 256 && parts.every(part => {
35
- if (typeof part !== 'string' || part.length > 4096)
36
- return false;
37
- bytes += Buffer.byteLength(part, 'utf8');
38
- return bytes <= 65_536;
39
- });
40
- });
41
- const parsed = parseDmarcRecords(admitted ? records : []);
42
- const unavailable = Boolean(observation.error) || !admitted;
43
- const valid = !unavailable && parsed.valid && ['u', 'n', 'y'].includes(parsed.tags.psd?.toLowerCase() ?? 'u');
44
- const state = unavailable ? 'unavailable' : valid ? 'observed' : parsed.records.length ? 'invalid' : 'not found';
45
- const description = valid
46
- ? `p=${parsed.policy}; sp=${parsed.subdomainPolicy}; np=${parsed.nonexistentSubdomainPolicy}; psd=${parsed.tags.psd?.toLowerCase() ?? 'u'}; test=${parsed.testMode ? 'yes' : 'no'}`
47
- : state;
48
- const time = typeof observation.observedAt === 'string' && observation.observedAt.length <= 64
49
- ? normalizeExplicitIsoTimestamp(observation.observedAt) : null;
50
- return { owner, parsed, valid, unavailable, record: `Recursive DNS TXT _dmarc.${owner} · ${time ?? 'time unavailable'} · ${description}` };
51
- }
52
25
  /** RFC 9989 sections 4.10–4.10.2; at most eight owners including the exact name. */
53
26
  export async function reviewInheritedDmarc(domain, exact, resolve) {
54
- requireDomain(domain);
55
- const rows = [policyObservation(domain, exact)];
56
- const labels = domain.split('.');
57
- let next = labels.length >= 8 ? labels.slice(-7) : labels.slice(1);
58
- if (!rows[0].valid && !rows[0].unavailable) {
59
- while (next.length) {
60
- const owner = next.join('.');
61
- const row = policyObservation(owner, await resolve(`_dmarc.${owner}`));
62
- rows.push(row);
63
- if (row.unavailable || (row.valid && ['n', 'y'].includes(row.parsed.tags.psd?.toLowerCase() ?? '')))
64
- break;
65
- next = next.slice(1);
66
- }
67
- }
68
- const available = rows.filter(row => row.valid);
27
+ const { rows, available, boundary, incomplete, organisationalDomain } = await walkDmarcTree(domain, exact, resolve, 'policy');
69
28
  const explicit = rows[0].valid ? rows[0] : null;
70
- const boundary = available.find(row => row.parsed.tags.psd?.toLowerCase() === 'n' || row.parsed.tags.psd?.toLowerCase() === 'y');
71
- const organisational = boundary?.parsed.tags.psd?.toLowerCase() === 'y'
72
- ? domain.split('.').slice(-(boundary.owner.split('.').length + 1)).join('.')
73
- : boundary?.owner ?? available.at(-1)?.owner ?? null;
74
- const selected = explicit ?? available.find(row => row.owner === organisational) ?? (boundary?.parsed.tags.psd?.toLowerCase() === 'y' ? boundary : null);
75
- const incomplete = rows.some(row => row.unavailable || (!row.valid && row.parsed.records.length === 1));
29
+ const selected = explicit ?? available.find(row => row.owner === organisationalDomain) ?? (boundary?.psd === 'y' ? boundary : null);
76
30
  const summary = incomplete ? 'Inherited policy could not be determined from the collected records.'
77
31
  : selected ? `${selected.owner === domain ? 'Exact-name' : 'Inherited'} policy published at _dmarc.${selected.owner}.`
78
32
  : 'No applicable DMARC policy was found in the completed tree walk.';
@@ -90,7 +44,7 @@ function nameservers(value) {
90
44
  return names.every(name => name.length <= 253 && isValidAsciiHostname(name)) ? [...new Set(names)].sort() : null;
91
45
  }
92
46
  export async function reviewParentDelegation(domain, recursive, dependencies, signal) {
93
- requireDomain(domain);
47
+ requireDmarcDomain(domain);
94
48
  const parsed = parseDomain(domain, { allowPrivateDomains: false });
95
49
  const child = parsed.domain;
96
50
  const parent = parsed.publicSuffix;
@@ -175,8 +129,8 @@ export async function reviewParentDelegation(domain, recursive, dependencies, si
175
129
  }
176
130
  return finish();
177
131
  }
178
- export async function collectDnsInheritanceChecks(domain, exact, recursive, options = {}, injected) {
179
- requireDomain(domain);
132
+ export async function collectDnsInheritanceReview(domain, exact, recursive, options = {}, injected) {
133
+ requireDmarcDomain(domain);
180
134
  options.signal?.throwIfAborted();
181
135
  const timeout = AbortSignal.timeout(REVIEW_TIMEOUT_MS);
182
136
  const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
@@ -188,10 +142,13 @@ export async function collectDnsInheritanceChecks(domain, exact, recursive, opti
188
142
  resolve4: (name) => resolver.resolve4(name), resolve6: (name) => resolver.resolve6(name),
189
143
  exchange: defaultTcpExchange, now: () => new Date(),
190
144
  };
191
- const resolve = async (owner) => {
145
+ let questions = 0;
146
+ const cache = new Map([[`_dmarc.${domain}`, Promise.resolve(exact)]]);
147
+ const query = async (owner) => {
192
148
  signal.throwIfAborted();
193
149
  if (owner.length > 253)
194
150
  return { records: [], error: 'The DNS question exceeds the name limit.' };
151
+ questions += 1;
195
152
  try {
196
153
  return { records: await dependencies.resolveTxt(owner), error: null, observedAt: dependencies.now().toISOString() };
197
154
  }
@@ -200,17 +157,50 @@ export async function collectDnsInheritanceChecks(domain, exact, recursive, opti
200
157
  return { records: [], error: MISSING.has(String(error?.code)) ? null : 'DNS observation unavailable.', observedAt: dependencies.now().toISOString() };
201
158
  }
202
159
  };
203
- const operations = [reviewInheritedDmarc(domain, exact, resolve), reviewParentDelegation(domain, recursive, dependencies, signal)];
160
+ const resolve = (owner) => {
161
+ options.signal?.throwIfAborted();
162
+ const existing = cache.get(owner);
163
+ if (existing)
164
+ return existing;
165
+ if (questions >= MAX_DMARC_REVIEW_QUERIES)
166
+ return Promise.resolve({ records: [], error: 'The shared DMARC query budget was reached.' });
167
+ const pending = query(owner);
168
+ cache.set(owner, pending);
169
+ return pending;
170
+ };
171
+ const organisations = new Map();
172
+ const boundaryRecords = new Map();
173
+ const boundaryResults = new Map();
174
+ const resolveOrganisationalDomain = (owner) => {
175
+ const existing = organisations.get(owner);
176
+ if (existing)
177
+ return existing;
178
+ const pending = (async () => {
179
+ const walk = await walkDmarcTree(owner, await resolve(`_dmarc.${owner}`), resolve, 'organisation');
180
+ for (const row of walk.rows)
181
+ if (cache.has(`_dmarc.${row.owner}`))
182
+ boundaryRecords.set(row.owner, row.record);
183
+ boundaryResults.set(owner, `${owner}: organisational domain ${walk.organisationalDomain ?? 'unknown'}.`);
184
+ return walk.organisationalDomain;
185
+ })();
186
+ organisations.set(owner, pending);
187
+ return pending;
188
+ };
189
+ const operations = [
190
+ reviewInheritedDmarc(domain, exact, resolve),
191
+ reviewParentDelegation(domain, recursive, dependencies, signal),
192
+ validateDmarcExternalReporting(domain, exact, resolve, resolveOrganisationalDomain),
193
+ ];
204
194
  try {
205
- return await Promise.all(operations);
206
- }
207
- catch {
195
+ const [policy, parent, reporting] = await Promise.allSettled(operations);
208
196
  options.signal?.throwIfAborted();
209
- const settled = await Promise.allSettled(operations);
210
- return settled.map((result, index) => result.status === 'fulfilled' ? result.value : check(index === 0 ? 'dmarc_inheritance' : 'parent_delegation', index === 0 ? 'Inherited DMARC policy' : 'Direct parent delegation', 'warning', 'The additional review could not complete.', 'No absence or effective-policy conclusion is available from the incomplete review.', []));
197
+ const checks = [policy, parent].map((result, index) => result.status === 'fulfilled' ? result.value : check(index === 0 ? 'dmarc_inheritance' : 'parent_delegation', index === 0 ? 'Inherited DMARC policy' : 'Direct parent delegation', 'warning', 'The additional review could not complete.', 'No absence or effective-policy conclusion is available from the incomplete review.', []));
198
+ const dmarcAuthorizations = reporting.status === 'fulfilled' ? reporting.value : [];
199
+ if (organisations.size)
200
+ checks.push(check('dmarc_reporting_boundaries', 'DMARC reporting boundaries', reporting.status === 'rejected' || dmarcAuthorizations.some(item => !['self', 'authorized'].includes(item.state)) ? 'warning' : 'info', 'Reporting destinations were reviewed using DNS-derived organisational boundaries.', `${questions} additional DMARC TXT questions used; the shared limit is ${MAX_DMARC_REVIEW_QUERIES} within ten seconds. Each tree walk examines at most eight owners. Unavailable boundaries remain unknown; a missing optional authorisation record does not establish a configuration error.`, [...boundaryResults.values(), ...boundaryRecords.values()]));
201
+ return { checks, dmarcAuthorizations };
211
202
  }
212
203
  finally {
213
- await Promise.allSettled(operations);
214
204
  signal.removeEventListener('abort', cancel);
215
205
  cancel();
216
206
  }
@@ -1,5 +1,5 @@
1
1
  // Bounded DNS evidence for investigative deep scans. This collector keeps
2
- // authoritative absence distinct from resolver failure, preserves only record
2
+ // resolver-reported negative answers distinct from failures, preserves only record
3
3
  // types that aid domain triage, and never treats shared infrastructure as proof
4
4
  // of common ownership or maliciousness.
5
5
  import { promises as dns } from 'node:dns';
@@ -11,7 +11,8 @@ import { createObservation } from "../packages/evidence/observation.mjs";
11
11
  import { MAX_LOOKUP_DNS_RECORDS_PER_TYPE, MAX_LOOKUP_REVERSE_DNS_PTR_RECORDS, } from "./lookup-network-evidence-bounds.mjs";
12
12
  import { isPrivateAddress } from "./safe-fetch.mjs";
13
13
  import { resolveServiceBindingRecords } from "./service-binding-dns.mjs";
14
- import { canonicalRegistrableDomain } from "./registrable-domain.mjs";
14
+ import { canonicalRegistrableDomain } from "../packages/analysis/registrable-domain.mjs";
15
+ import { DNS_QUERY_OUTCOMES, dnsQueryFailureOutcome } from "../packages/evidence/dns-query-outcome.mjs";
15
16
  const DNS_TIMEOUT_MS = 5000;
16
17
  const MAX_RECORDS_PER_TYPE = MAX_LOOKUP_DNS_RECORDS_PER_TYPE;
17
18
  const MAX_PTR_RECORDS = MAX_LOOKUP_REVERSE_DNS_PTR_RECORDS;
@@ -24,7 +25,6 @@ const MAX_SERVICE_ALPN_IDS = 16;
24
25
  const MAX_SERVICE_ADDRESS_HINTS = 8;
25
26
  const MAX_CAA_TREE_QUERIES = 8;
26
27
  const DNS_CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f]/u;
27
- const MISSING_CODES = new Set(['ENODATA', 'ENOTFOUND', 'ENONAME']);
28
28
  function skippedDnsIntelligence(detail = 'DNS intelligence is disabled by deployment policy.', { includeExtendedContext = false, includeInheritedCaa = false, } = {}) {
29
29
  const skipped = { status: 'skipped', error: null, truncated: false, discarded: 0 };
30
30
  const recordTypes = ['a', 'aaaa', 'cname', 'ns', 'mx', 'spf', 'dmarc', 'caa'];
@@ -386,7 +386,7 @@ function normalizePtr(records) {
386
386
  }
387
387
  function withTimeout(factory, timeoutMs) {
388
388
  return new Promise((resolve, reject) => {
389
- const timer = setTimeout(() => reject(new Error('DNS query timed out')), timeoutMs);
389
+ const timer = setTimeout(() => reject(Object.assign(new Error('DNS query timed out'), { code: 'ETIMEOUT' })), timeoutMs);
390
390
  Promise.resolve().then(factory).then((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); });
391
391
  });
392
392
  }
@@ -396,6 +396,7 @@ async function query(factory, normalize, timeoutMs) {
396
396
  const unusable = !normalized.records.length && (normalized.truncated || normalized.discarded > 0);
397
397
  return {
398
398
  status: unusable ? 'error' : normalized.records.length ? 'success' : 'not_found',
399
+ detail: unusable ? 'invalid_response' : normalized.records.length ? 'records' : 'empty_answer',
399
400
  records: normalized.records,
400
401
  error: unusable ? 'The returned record family could not be retained completely; absence is unknown.' : null,
401
402
  truncated: normalized.truncated,
@@ -403,9 +404,9 @@ async function query(factory, normalize, timeoutMs) {
403
404
  };
404
405
  }
405
406
  catch (error) {
406
- if (error && typeof error === 'object' && MISSING_CODES.has(String(error.code)))
407
- return { status: 'not_found', records: [], error: null, truncated: false, discarded: 0 };
408
- return { status: 'error', records: [], error: boundedError(error), truncated: false, discarded: 0 };
407
+ const detail = dnsQueryFailureOutcome(error && typeof error === 'object' ? error.code : undefined);
408
+ const missing = DNS_QUERY_OUTCOMES[detail].status === 'not_found';
409
+ return { status: missing ? 'not_found' : 'error', detail, records: [], error: missing ? null : boundedError(error), truncated: false, discarded: 0 };
409
410
  }
410
411
  }
411
412
  function completeQuery(result) {
@@ -683,6 +684,7 @@ async function collectDnsIntelligence(domain, options = {}) {
683
684
  diagnostics: {
684
685
  ...Object.fromEntries(Object.entries(queries).map(([name, item]) => [name, {
685
686
  status: item.status,
687
+ detail: item.detail,
686
688
  error: item.error,
687
689
  truncated: item.truncated,
688
690
  discarded: item.discarded,