@slicedearth/whoisleuth-cli 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (282) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +20 -15
  3. package/bin/whoisleuth.mjs +8 -1
  4. package/cli/archive-content-digest.mjs +14 -0
  5. package/cli/archive-inspect.mjs +20 -53
  6. package/cli/arguments.mjs +454 -1748
  7. package/cli/artifact-validation/case-response.mjs +66 -35
  8. package/cli/artifact-validation/investigation-capsule.mjs +52 -31
  9. package/cli/artifact-validation/lookup-evidence.mjs +12 -6
  10. package/cli/artifact-validation/signed-review.mjs +112 -72
  11. package/cli/artifact-validation/structure-primitives.mjs +1 -105
  12. package/cli/artifact-verify.mjs +90 -24
  13. package/cli/assurance-command-runner.mjs +39 -4
  14. package/cli/bagit-folder.mjs +57 -0
  15. package/cli/bagit-review.mjs +23 -0
  16. package/cli/bounded-json.mjs +1 -1
  17. package/cli/bulk-checkpoint.mjs +34 -3
  18. package/cli/bulk-command-runner.mjs +2 -0
  19. package/cli/bulk-output.mjs +29 -3
  20. package/cli/case-command.mjs +195 -0
  21. package/cli/case-pack.mjs +66 -111
  22. package/cli/command-argument-grammar.mjs +215 -0
  23. package/cli/command-reference.mjs +342 -285
  24. package/cli/completion.mjs +303 -80
  25. package/cli/config-profile.mjs +7 -5
  26. package/cli/ct-event-intake.mjs +12 -12
  27. package/cli/discover.mjs +4 -3
  28. package/cli/discovery-observation-snapshot.mjs +59 -18
  29. package/cli/discovery-scan-command-runner.mjs +21 -0
  30. package/cli/discovery-scan.mjs +13 -4
  31. package/cli/discovery-workflow.mjs +1 -1
  32. package/cli/domain-control-monitor.mjs +50 -20
  33. package/cli/domain-control-observations.mjs +149 -102
  34. package/cli/errors.mjs +33 -1
  35. package/cli/evidence-command-runner.mjs +16 -5
  36. package/cli/evidence-signing.mjs +2 -2
  37. package/cli/external-observation-mapping.mjs +7 -8
  38. package/cli/formatters/terminal-lookup.mjs +18 -46
  39. package/cli/formatters/terminal-shared.mjs +5 -3
  40. package/cli/formatters/terminal-technology.mjs +114 -0
  41. package/cli/inline-command-families.mjs +2 -0
  42. package/cli/input.mjs +116 -0
  43. package/cli/interactive-launcher.mjs +2 -160
  44. package/cli/interchange-report.mjs +18 -8
  45. package/cli/investigation-artifacts.mjs +176 -0
  46. package/cli/investigation-checkpoint.mjs +10 -0
  47. package/cli/investigation-folder.mjs +114 -0
  48. package/cli/investigation-manifest.mjs +1 -112
  49. package/cli/investigation-package-review.mjs +103 -0
  50. package/cli/investigation-plan.mjs +32 -19
  51. package/cli/investigation-run.mjs +188 -33
  52. package/cli/local-document-checkpoint.mjs +170 -0
  53. package/cli/lookalike-calibration.mjs +7 -2
  54. package/cli/lookup-brief.mjs +4 -2
  55. package/cli/lookup-browser-view.mjs +1 -1
  56. package/cli/lookup-browser.mjs +5 -12
  57. package/cli/lookup-command-runner.mjs +17 -6
  58. package/cli/lookup-diff.mjs +37 -3
  59. package/cli/lookup-plan.mjs +11 -5
  60. package/cli/lookup-reconcile.mjs +10 -8
  61. package/cli/mail-header-review.mjs +467 -0
  62. package/cli/mail-review.mjs +66 -14
  63. package/cli/manual.mjs +1 -0
  64. package/cli/network-command-runner.mjs +2 -0
  65. package/cli/output-file.mjs +31 -5
  66. package/cli/registry-doctor.mjs +3 -1
  67. package/cli/registry-support.mjs +1 -1
  68. package/cli/retained-artifact-diff.mjs +2 -2
  69. package/cli/review-command-runner.mjs +70 -0
  70. package/cli/risk-calibration.mjs +19 -17
  71. package/cli/runner.mjs +62 -7
  72. package/cli/sharing-review.mjs +21 -34
  73. package/cli/signer-trust.mjs +104 -0
  74. package/cli/source-reliability.mjs +74 -83
  75. package/cli/terminal-input.mjs +168 -0
  76. package/cli/workflow-command-runner.mjs +68 -13
  77. package/docs/cli-reference.md +77 -24
  78. package/docs/cli.md +300 -25
  79. package/lib/abort.mjs +4 -1
  80. package/lib/availability.mjs +161 -112
  81. package/lib/bounded-file.mjs +3 -1
  82. package/lib/bounded-html-document.mjs +125 -0
  83. package/lib/bounded-json.mjs +51 -8
  84. package/lib/browser-library-profile.mjs +42 -51
  85. package/lib/browser-library-worker.mjs +112 -0
  86. package/lib/client-behavior-profile.mjs +3 -3
  87. package/lib/ct-search.mjs +11 -4
  88. package/lib/dns-convergence-review.mjs +1 -0
  89. package/lib/dns-delegation-health.mjs +24 -14
  90. package/lib/dns-inheritance-review.mjs +217 -0
  91. package/lib/dns-intelligence.mjs +73 -26
  92. package/lib/dns-mx.mjs +1 -1
  93. package/lib/dnssec-chain-validation.mjs +6 -0
  94. package/lib/dnssec-evidence-validation.mjs +11 -3
  95. package/lib/domain-change-packet.mjs +49 -16
  96. package/lib/domain-change-review.mjs +14 -8
  97. package/lib/domain-control-flight-recorder.mjs +221 -117
  98. package/lib/domain-control-manifest.mjs +129 -69
  99. package/lib/domain-posture-analysis.mjs +20 -7
  100. package/lib/domain-posture-parsers.mjs +201 -8
  101. package/lib/domain-posture.mjs +147 -29
  102. package/lib/evidence-export-privacy.mjs +561 -0
  103. package/lib/evidence-export.mjs +11 -543
  104. package/lib/evidence-report.mjs +5 -3
  105. package/lib/favicon.mjs +117 -41
  106. package/lib/generated/cisa-kev-catalog.mjs +12 -4
  107. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  108. package/lib/generated/retire-browser-catalog.mjs +2335 -940
  109. package/lib/hostname.mjs +2 -38
  110. package/lib/html-signals.mjs +111 -108
  111. package/lib/http-evidence-bounds.mjs +2 -1
  112. package/lib/http-intelligence.mjs +39 -26
  113. package/lib/local-geoip-evidence.mjs +19 -3
  114. package/lib/lookup-cache.mjs +34 -11
  115. package/lib/lookup-child-profile-contract.mjs +24 -15
  116. package/lib/lookup-diagnostics.mjs +24 -1
  117. package/lib/lookup-progress.mjs +30 -13
  118. package/lib/lookup-response-contract.mjs +1048 -0
  119. package/lib/lookup-response.mjs +8 -10
  120. package/lib/lookup-source-progress.mjs +40 -14
  121. package/lib/lookup.mjs +48 -27
  122. package/lib/nameserver-preflight-review.mjs +1 -0
  123. package/lib/observed-network-context.mjs +43 -27
  124. package/lib/outbound-request-bounds.mjs +4 -1
  125. package/lib/page-fingerprints.mjs +69 -133
  126. package/lib/page-role-profile.mjs +12 -8
  127. package/lib/perceptual-hash.mjs +43 -14
  128. package/lib/rdap-bootstrap.mjs +72 -22
  129. package/lib/rdap-capabilities.mjs +1 -1
  130. package/lib/rdap-client.mjs +15 -7
  131. package/lib/rdap-normalization.mjs +33 -18
  132. package/lib/rdap-search-workbench.mjs +1 -1
  133. package/lib/rdap-transport.mjs +47 -20
  134. package/lib/rdap-types.mjs +2 -1
  135. package/lib/rdap.mjs +18 -3
  136. package/lib/registrable-domain.mjs +21 -1
  137. package/lib/registrar-standing-contract.mjs +1 -0
  138. package/lib/registry-capabilities.mjs +5 -1
  139. package/lib/registry-capability-catalogue.mjs +2 -1
  140. package/lib/registry-dates.mjs +2 -190
  141. package/lib/registry-insights.mjs +85 -34
  142. package/lib/response-policy.mjs +86 -48
  143. package/lib/risk-calibration-summary.mjs +18 -6
  144. package/lib/rpki-evidence.mjs +8 -5
  145. package/lib/safe-fetch.mjs +54 -47
  146. package/lib/security-txt.mjs +42 -7
  147. package/lib/service-binding-dns.mjs +8 -0
  148. package/lib/smtp-transport-review.mjs +27 -15
  149. package/lib/sslbl-certificates.generated.mjs +136 -135
  150. package/lib/static-html-analysis.mjs +178 -151
  151. package/lib/structured-data-identity.mjs +1 -0
  152. package/lib/technology-markup.mjs +100 -0
  153. package/lib/threat-intelligence-manifests.mjs +11 -139
  154. package/lib/threat-intelligence-runtime.mjs +24 -52
  155. package/lib/threat-intelligence-targets.mjs +6 -34
  156. package/lib/threat-intelligence-types.mjs +39 -0
  157. package/lib/typosquat-generator.mjs +19 -15
  158. package/lib/urlhaus-intelligence.mjs +8 -3
  159. package/lib/urlscan-intelligence.mjs +1 -1
  160. package/lib/website-security-posture.mjs +30 -6
  161. package/lib/website-technology.mjs +153 -45
  162. package/lib/whois-authority.mjs +14 -2
  163. package/lib/whois-chain.mjs +9 -2
  164. package/lib/whois-common-formats.mjs +5 -5
  165. package/lib/whois-contracts.mjs +2 -1
  166. package/lib/whois-dialects.mjs +2 -2
  167. package/lib/whois-parser-support.mjs +8 -1
  168. package/lib/whois-transport.mjs +23 -7
  169. package/lib/zone-intent-review.mjs +16 -6
  170. package/package.json +2 -1
  171. package/packages/cases/case-attachment-model.mjs +83 -0
  172. package/packages/cases/case-evidence-model.mjs +156 -62
  173. package/packages/cases/case-export-input.mjs +53 -0
  174. package/packages/cases/case-migration-model.mjs +127 -44
  175. package/packages/cases/case-model.mjs +1 -0
  176. package/packages/cases/case-recheck-model.mjs +91 -0
  177. package/packages/cases/case-record-contracts.mjs +5 -20
  178. package/packages/cases/case-record-core.mjs +12 -22
  179. package/packages/cases/case-record-decisions.mjs +72 -0
  180. package/packages/cases/case-record-model.mjs +1 -0
  181. package/packages/cases/case-record-operations.mjs +95 -31
  182. package/packages/cases/case-record-projection.mjs +221 -0
  183. package/packages/cases/case-report.mjs +70 -54
  184. package/packages/cases/case-response-actions.mjs +654 -0
  185. package/packages/cases/case-response-model.mjs +64 -1115
  186. package/packages/cases/case-response-outcomes.mjs +317 -0
  187. package/packages/cases/case-response-packet-render.mjs +153 -0
  188. package/packages/cases/case-response-packet-types.mjs +1 -0
  189. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  190. package/packages/cases/case-response-packet.mjs +74 -572
  191. package/packages/cases/case-response-records.mjs +94 -0
  192. package/packages/cases/case-response-review-inputs.mjs +371 -0
  193. package/packages/cases/case-response-values.mjs +85 -0
  194. package/packages/cases/case-review-package.mjs +45 -0
  195. package/packages/cases/case-selection.mjs +46 -0
  196. package/packages/cases/case-storage-model.mjs +31 -2
  197. package/packages/cases/response-route-freshness.mjs +16 -0
  198. package/packages/comparison/capture-context.mjs +61 -0
  199. package/packages/comparison/comparison-ledger-bulk.mjs +13 -11
  200. package/packages/comparison/page-similarity.mjs +5 -2
  201. package/packages/comparison/relationship-evidence.mjs +85 -8
  202. package/packages/comparison/relationship-provenance.mjs +93 -0
  203. package/packages/contracts/analyst-interchange.mjs +216 -0
  204. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  205. package/packages/contracts/analyst-review-state.mjs +5 -4
  206. package/packages/contracts/case-portability.mjs +366 -83
  207. package/packages/contracts/case-views-contract.mjs +12 -0
  208. package/packages/contracts/case-views.mjs +26 -0
  209. package/packages/contracts/cli-command-semantics.mjs +94 -0
  210. package/packages/contracts/cli-lookup.mjs +0 -1
  211. package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
  212. package/packages/contracts/domain-control-manifest.mjs +127 -73
  213. package/packages/contracts/domain-control-monitor.mjs +48 -45
  214. package/packages/contracts/domain-control-review.mjs +179 -121
  215. package/packages/contracts/domain-name.mjs +21 -0
  216. package/packages/contracts/external-observation-interchange.mjs +2 -1
  217. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  218. package/packages/contracts/http-url.mjs +2 -0
  219. package/packages/contracts/investigation-package-limits.mjs +24 -0
  220. package/packages/contracts/investigation-portability.mjs +56 -27
  221. package/packages/contracts/investigation-run.mjs +16 -0
  222. package/packages/contracts/ip-address.mjs +34 -0
  223. package/packages/contracts/lookup-evidence.mjs +35 -0
  224. package/packages/contracts/offline-comparison.mjs +1 -2
  225. package/packages/contracts/page-fingerprints.mjs +15 -0
  226. package/packages/contracts/risk-calibration.mjs +15 -11
  227. package/packages/contracts/schema-compatibility.mjs +1 -1
  228. package/packages/contracts/schema-lifecycle.mjs +83 -153
  229. package/packages/contracts/selected-file-limits.mjs +6 -0
  230. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  231. package/packages/contracts/web-capture.mjs +10 -0
  232. package/packages/contracts/workspace-portability.mjs +183 -211
  233. package/packages/evidence/artifact-integrity.mjs +8 -2
  234. package/packages/evidence/artifact-structure.mjs +105 -0
  235. package/packages/evidence/decision-fact.mjs +4 -1
  236. package/packages/evidence/domain-control-runtime.mjs +201 -33
  237. package/packages/evidence/domain-posture-context.mjs +79 -0
  238. package/packages/evidence/image-regions.mjs +56 -0
  239. package/packages/evidence/latest-observations.mjs +28 -0
  240. package/packages/evidence/lookup-target.mjs +104 -0
  241. package/packages/evidence/observation.mjs +16 -1
  242. package/packages/evidence/passphrase-encryption.mjs +44 -0
  243. package/packages/evidence/record-identity.mjs +72 -0
  244. package/packages/evidence/registry-dates.mjs +190 -0
  245. package/packages/evidence/retained-file.mjs +69 -0
  246. package/packages/evidence/review-age.mjs +14 -0
  247. package/packages/interchange/bagit.mjs +342 -0
  248. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  249. package/packages/interchange/crc32.mjs +17 -0
  250. package/packages/interchange/external-findings-import.mjs +92 -51
  251. package/packages/interchange/web-capture-import.mjs +382 -0
  252. package/packages/investigation/bulk-mail-exposure.mjs +2 -1
  253. package/packages/investigation/investigation-bagit.mjs +14 -0
  254. package/packages/investigation/investigation-capsule.mjs +33 -5
  255. package/packages/investigation/investigation-manifest.mjs +200 -0
  256. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  257. package/packages/investigation/investigation-package.mjs +200 -0
  258. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  259. package/packages/investigation/lookup-asset-graph.mjs +193 -165
  260. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  261. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  262. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  263. package/packages/monitoring/analyst-review-state.mjs +38 -90
  264. package/packages/workspace/brand-profile-model.mjs +276 -22
  265. package/packages/workspace/bulk-columns.mjs +17 -0
  266. package/packages/workspace/bulk-review-model.mjs +23 -11
  267. package/packages/workspace/bulk-session-model.mjs +100 -24
  268. package/packages/workspace/bulk-sort.mjs +1 -0
  269. package/packages/workspace/case-views.mjs +102 -0
  270. package/packages/workspace/detection-rule-model.mjs +70 -22
  271. package/packages/workspace/hostile-input.mjs +29 -8
  272. package/packages/workspace/investigation-template-model.mjs +19 -4
  273. package/packages/workspace/page-baseline.mjs +18 -15
  274. package/packages/workspace/relationship-observation-model.mjs +41 -23
  275. package/packages/workspace/watchlist-history.mjs +2 -2
  276. package/packages/workspace/watchlist-store.mjs +8 -2
  277. package/packages/workspace/website-snapshot-model.mjs +164 -26
  278. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  279. package/packages/workspace/workspace-archive.mjs +87 -21
  280. package/third-party-notices.txt +29 -2
  281. package/cli/evidence-command-arguments.mjs +0 -156
  282. package/packages/investigation/bulk-review-export.mjs +0 -63
@@ -2,6 +2,8 @@
2
2
  // Kept separate from DNS/network I/O so policy edge cases can be tested with
3
3
  // synthetic records and the Express/Netlify paths share identical behavior.
4
4
  import { createPublicKey } from 'node:crypto';
5
+ import { isIP } from 'node:net';
6
+ import { domainToASCII } from 'node:url';
5
7
  function joinTxtRecords(records) {
6
8
  if (!Array.isArray(records))
7
9
  return [];
@@ -46,6 +48,7 @@ function parseSpfRecords(records) {
46
48
  terminalPolicy: null,
47
49
  redirect: null,
48
50
  includes: [],
51
+ authorizingTerms: [],
49
52
  dnsLookupTerms: 0,
50
53
  issues: [],
51
54
  };
@@ -61,6 +64,108 @@ function parseSpfRecords(records) {
61
64
  if (!record)
62
65
  return result;
63
66
  const terms = record.split(/\s+/).slice(1).filter(Boolean);
67
+ let invalidSyntax = false;
68
+ const modifierNames = new Set();
69
+ const literalDomain = (value, label) => {
70
+ if (!value) {
71
+ result.issues.push(`${label} requires a domain-spec.`);
72
+ invalidSyntax = true;
73
+ return false;
74
+ }
75
+ if (value.includes('%')) {
76
+ result.issues.push(`${label} uses SPF macros that this bounded audit does not evaluate.`);
77
+ return true;
78
+ }
79
+ const hostname = value.toLowerCase().replace(/\.$/u, '');
80
+ if (/[^\x00-\x7f]/u.test(value) || hostname.length > 253 || !hostname.includes('.') || !hostname.split('.').every((part) => /^[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$/u.test(part))) {
81
+ result.issues.push(`${label} has an invalid literal domain-spec.`);
82
+ invalidSyntax = true;
83
+ return false;
84
+ }
85
+ return true;
86
+ };
87
+ for (const term of terms) {
88
+ const qualifier = /^[+?~-]/u.test(term) ? term[0] : '+';
89
+ const body = qualifier === '+' && term[0] !== '+' ? term : term.slice(1);
90
+ const lower = body.toLowerCase();
91
+ const modifier = body.match(/^([a-z][a-z0-9_.-]*)=(.*)$/iu);
92
+ if (modifier) {
93
+ const name = modifier[1].toLowerCase();
94
+ const value = modifier[2];
95
+ if (modifierNames.has(name)) {
96
+ result.issues.push(`Duplicate SPF modifier: ${name}.`);
97
+ invalidSyntax = true;
98
+ }
99
+ modifierNames.add(name);
100
+ if (name === 'redirect') {
101
+ literalDomain(value, 'The redirect modifier');
102
+ if (qualifier !== '+') {
103
+ result.issues.push('SPF modifiers cannot have a qualifier.');
104
+ invalidSyntax = true;
105
+ }
106
+ result.authorizingTerms.push(term);
107
+ }
108
+ else if (name === 'exp') {
109
+ literalDomain(value, 'The exp modifier');
110
+ }
111
+ else {
112
+ result.issues.push(`The ${name} SPF modifier is not evaluated by this bounded audit.`);
113
+ }
114
+ continue;
115
+ }
116
+ if (lower === 'all')
117
+ continue;
118
+ const include = body.match(/^include:(.*)$/iu);
119
+ if (include) {
120
+ literalDomain(include[1], 'The include mechanism');
121
+ if (qualifier !== '-')
122
+ result.authorizingTerms.push(term);
123
+ continue;
124
+ }
125
+ const exists = body.match(/^exists:(.*)$/iu);
126
+ if (exists) {
127
+ literalDomain(exists[1], 'The exists mechanism');
128
+ if (qualifier !== '-')
129
+ result.authorizingTerms.push(term);
130
+ continue;
131
+ }
132
+ const ptr = body.match(/^ptr(?::(.*))?$/iu);
133
+ if (ptr) {
134
+ if (ptr[1] !== undefined)
135
+ literalDomain(ptr[1], 'The ptr mechanism');
136
+ if (qualifier !== '-')
137
+ result.authorizingTerms.push(term);
138
+ continue;
139
+ }
140
+ const address = body.match(/^(ip4|ip6):(.*)$/iu);
141
+ if (address) {
142
+ const family = address[1].toLowerCase() === 'ip4' ? 4 : 6;
143
+ const [literal, prefix, ...extra] = address[2].split('/');
144
+ const maximumPrefix = family === 4 ? 32 : 128;
145
+ if (extra.length || isIP(literal || '') !== family || (prefix !== undefined && (!/^\d+$/u.test(prefix) || Number(prefix) > maximumPrefix))) {
146
+ result.issues.push(`The ${address[1].toLowerCase()} mechanism has an invalid address or prefix length.`);
147
+ invalidSyntax = true;
148
+ }
149
+ if (qualifier !== '-')
150
+ result.authorizingTerms.push(term);
151
+ continue;
152
+ }
153
+ const hostMechanism = body.match(/^(a|mx)(?::([^/]+))?(?:\/(\d+))?(?:\/\/(\d+))?$/iu);
154
+ if (hostMechanism) {
155
+ if (hostMechanism[2] !== undefined)
156
+ literalDomain(hostMechanism[2], `The ${hostMechanism[1].toLowerCase()} mechanism`);
157
+ if ((hostMechanism[3] !== undefined && Number(hostMechanism[3]) > 32)
158
+ || (hostMechanism[4] !== undefined && Number(hostMechanism[4]) > 128)) {
159
+ result.issues.push(`The ${hostMechanism[1].toLowerCase()} mechanism has an invalid prefix length.`);
160
+ invalidSyntax = true;
161
+ }
162
+ if (qualifier !== '-')
163
+ result.authorizingTerms.push(term);
164
+ continue;
165
+ }
166
+ result.issues.push(`Unsupported or malformed SPF term: ${term}.`);
167
+ invalidSyntax = true;
168
+ }
64
169
  const allIndex = terms.findIndex((term) => /^[+?~-]?all$/i.test(term));
65
170
  const allTerm = allIndex === -1 ? null : terms[allIndex];
66
171
  const redirectTerm = terms.find((term) => /^redirect=/i.test(term));
@@ -89,12 +194,76 @@ function parseSpfRecords(records) {
89
194
  result.terminalPolicy = 'none';
90
195
  result.issues.push('No all mechanism or redirect modifier defines a terminal policy.');
91
196
  }
92
- result.valid = true;
197
+ result.valid = !invalidSyntax;
93
198
  return result;
94
199
  }
200
+ function hasDmarcVersion(record) {
201
+ return /^[vV][ \t]*=[ \t]*DMARC1(?:[ \t]*;|[ \t]*$)/u.test(record);
202
+ }
203
+ function parseDmarcReportingAuthorization(record) {
204
+ const text = joinTxtRecords([record])[0] || '';
205
+ if (!hasDmarcVersion(text))
206
+ return null;
207
+ const fields = text.split(';');
208
+ if (!fields[fields.length - 1]?.trim())
209
+ fields.pop();
210
+ // RFC 9990 section 4 requires a complete tag list, not a version prefix.
211
+ if (fields.some((field) => !/^[ \t]*[a-zA-Z]+[ \t]*=[ \t]*[\x20-\x3a\x3c-\x7e]+[ \t]*$/u.test(field)))
212
+ return null;
213
+ const parsed = parseTagList(text);
214
+ return parsed.duplicates.length || parsed.malformed.length ? null : parsed.tags;
215
+ }
216
+ function parseReportingDestination(value) {
217
+ // Validate URI spelling before URL parsing can repair whitespace, escapes or
218
+ // missing authority delimiters. DNS collection already bounds the input.
219
+ if (!value || /[^a-zA-Z0-9._~:/?#\[\]@!$&'()*+,;=%-]/u.test(value) || /%(?![a-fA-F0-9]{2})/u.test(value))
220
+ return null;
221
+ if (/^mailto:/iu.test(value)) {
222
+ const parts = value.slice(7).split('?');
223
+ if (parts.length > 2 || value.includes('#'))
224
+ return null;
225
+ const headers = parts[1];
226
+ if (headers !== undefined && !headers.split('&').every((field) => /^[a-zA-Z0-9._~!$'()*+,:@%\-]*=[a-zA-Z0-9._~!$'()*+,:@%\-]*$/u.test(field)))
227
+ return null;
228
+ let address;
229
+ try {
230
+ address = decodeURIComponent(parts[0] || '');
231
+ }
232
+ catch {
233
+ return null;
234
+ }
235
+ const at = address.lastIndexOf('@');
236
+ if (at <= 0)
237
+ return null;
238
+ const local = address.slice(0, at);
239
+ const dotAtom = /^[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*$/u;
240
+ const quoted = /^"(?:[\x20-\x21\x23-\x5b\x5d-\x7e]|\\[\x20-\x7e])*"$/u;
241
+ if (!dotAtom.test(local) && !quoted.test(local))
242
+ return null;
243
+ const domain = address.slice(at + 1);
244
+ if (domain.startsWith('[') && domain.endsWith(']')) {
245
+ const literal = domain.slice(1, -1);
246
+ return isIP(literal.replace(/^IPv6:/iu, '')) ? { scheme: 'mailto', host: domain } : null;
247
+ }
248
+ const host = domainToASCII(domain).toLowerCase();
249
+ if (!host || host.length > 253 || !host.split('.').every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(label)))
250
+ return null;
251
+ return { scheme: 'mailto', host };
252
+ }
253
+ const authority = /^https:\/\/([^/?#]+)/iu.exec(value)?.[1];
254
+ if (!authority || authority.includes('@') || value.includes('#'))
255
+ return null;
256
+ try {
257
+ const uri = new URL(value);
258
+ return uri.hostname && !uri.username && !uri.password && !uri.hash ? { scheme: 'https', host: uri.hostname } : null;
259
+ }
260
+ catch {
261
+ return null;
262
+ }
263
+ }
95
264
  function parseDmarcRecords(records) {
96
265
  const allRecords = joinTxtRecords(records);
97
- const dmarcRecords = allRecords.filter((record) => /^v\s*=\s*dmarc1(?:\s*;|$)/i.test(record));
266
+ const dmarcRecords = allRecords.filter(hasDmarcVersion);
98
267
  const result = {
99
268
  records: dmarcRecords,
100
269
  valid: false,
@@ -152,8 +321,12 @@ function parseDmarcRecords(records) {
152
321
  result.failureReporting = Boolean(parsed.tags.ruf);
153
322
  result.aggregateDestinations = (parsed.tags.rua || '').split(',').map((value) => value.trim()).filter(Boolean);
154
323
  result.failureDestinations = (parsed.tags.ruf || '').split(',').map((value) => value.trim()).filter(Boolean);
324
+ const emptyDestination = [parsed.tags.rua, parsed.tags.ruf].some((value) => value !== undefined && value.split(',').some((destination) => !destination.trim()));
325
+ if (emptyDestination)
326
+ result.issues.push('A DMARC reporting URI list contains an empty destination.');
155
327
  result.valid = parsed.duplicates.length === 0
156
328
  && parsed.malformed.length === 0
329
+ && !emptyDestination
157
330
  && Boolean(result.policy && result.subdomainPolicy && result.nonexistentSubdomainPolicy)
158
331
  && (!parsed.tags.t || /^[yn]$/i.test(parsed.tags.t));
159
332
  return result;
@@ -230,7 +403,7 @@ function parseMtaStsPolicy(text) {
230
403
  }
231
404
  function parseTlsRptRecords(records) {
232
405
  const allRecords = joinTxtRecords(records);
233
- const policyRecords = allRecords.filter((record) => /^v\s*=\s*tlsrptv1(?:\s*;|$)/i.test(record));
406
+ const policyRecords = allRecords.filter((record) => /^v=TLSRPTv1(?:[ \t]*;|$)/u.test(record));
234
407
  const result = { records: policyRecords, valid: false, record: policyRecords[0] || null, rua: [], issues: [] };
235
408
  if (policyRecords.length === 0) {
236
409
  result.issues.push('No TLS-RPT policy record was found.');
@@ -241,14 +414,26 @@ function parseTlsRptRecords(records) {
241
414
  return result;
242
415
  }
243
416
  const parsed = parseTagList(policyRecords[0]);
244
- result.rua = (parsed.tags.rua || '').split(',').map((value) => value.trim()).filter(Boolean);
417
+ const destinations = (parsed.tags.rua || '').split(',').map((value) => value.trim());
418
+ result.rua = destinations.filter(Boolean);
245
419
  if (result.rua.length === 0)
246
420
  result.issues.push('The TLS-RPT record has no aggregate report destination.');
421
+ if (destinations.some((destination) => /[!,;]/u.test(destination) || !parseReportingDestination(destination))) {
422
+ result.issues.push('TLS-RPT destinations must be complete supported mailto or HTTPS URIs; reserved reporting delimiters must be percent-encoded.');
423
+ }
424
+ const fields = (policyRecords[0] || '').split(';');
425
+ if (!fields[fields.length - 1]?.trim())
426
+ fields.pop();
427
+ if (fields.slice(1).some((field) => {
428
+ const text = field.trim();
429
+ return text.startsWith('rua=') ? false : !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,31}=[\x21-\x3a\x3c\x3e-\x7e]+$/u.test(text) || /^rua=/iu.test(text);
430
+ }))
431
+ result.issues.push('The TLS-RPT record contains an invalid field name or value.');
247
432
  if (parsed.duplicates.length)
248
433
  result.issues.push(`Duplicate TLS-RPT tag${parsed.duplicates.length === 1 ? '' : 's'}: ${parsed.duplicates.join(', ')}.`);
249
434
  if (parsed.malformed.length)
250
435
  result.issues.push(`Malformed TLS-RPT field${parsed.malformed.length === 1 ? '' : 's'}: ${parsed.malformed.join(', ')}.`);
251
- result.valid = result.rua.length > 0 && parsed.duplicates.length === 0 && parsed.malformed.length === 0;
436
+ result.valid = result.issues.length === 0;
252
437
  return result;
253
438
  }
254
439
  function parseBimiRecords(records) {
@@ -321,8 +506,14 @@ function parseDkimRecords(selector, records) {
321
506
  }
322
507
  else {
323
508
  try {
509
+ const decodedKey = Buffer.from(encodedKey, 'base64');
510
+ if (result.keyType === 'ed25519' && decodedKey.length !== 32) {
511
+ throw new Error('Ed25519 DKIM keys use an exact 32-byte raw public key.');
512
+ }
324
513
  const key = createPublicKey({
325
- key: Buffer.from(encodedKey, 'base64'),
514
+ key: result.keyType === 'ed25519'
515
+ ? Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), decodedKey])
516
+ : decodedKey,
326
517
  format: 'der',
327
518
  type: 'spki',
328
519
  });
@@ -341,7 +532,9 @@ function parseDkimRecords(selector, records) {
341
532
  }
342
533
  catch {
343
534
  result.keyParseState = 'invalid';
344
- result.issues.push('The DKIM public key could not be parsed as SubjectPublicKeyInfo.');
535
+ result.issues.push(result.keyType === 'ed25519'
536
+ ? 'The Ed25519 DKIM public key must be exactly 32 raw bytes.'
537
+ : 'The DKIM public key could not be parsed as SubjectPublicKeyInfo.');
345
538
  }
346
539
  }
347
540
  }
@@ -355,4 +548,4 @@ function parseDkimRecords(selector, records) {
355
548
  result.valid = result.issues.length === 0;
356
549
  return result;
357
550
  }
358
- export { joinTxtRecords, parseTagList, parseSpfRecords, parseDmarcRecords, parseMtaStsDnsRecords, parseMtaStsPolicy, parseTlsRptRecords, parseBimiRecords, parseDkimRecords, };
551
+ export { joinTxtRecords, parseTagList, parseSpfRecords, parseDmarcRecords, parseDmarcReportingAuthorization, parseReportingDestination, parseMtaStsDnsRecords, parseMtaStsPolicy, parseTlsRptRecords, parseBimiRecords, parseDkimRecords, };
@@ -7,6 +7,10 @@ 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";
11
+ import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
12
+ import { DOMAIN_POSTURE_COMPARISON_VERSION, MAX_POSTURE_CHECK_RECORDS, POSTURE_CHECK_SOURCES } from "../packages/evidence/domain-posture-context.mjs";
13
+ import { canonicalPostureRecords } from "../packages/evidence/domain-control-runtime.mjs";
10
14
  import { parseSpfRecords, parseDmarcRecords, parseMtaStsDnsRecords, parseMtaStsPolicy, parseTlsRptRecords, parseBimiRecords, parseDkimRecords, } from "./domain-posture-parsers.mjs";
11
15
  import { buildExternalDependencies, expandSpfPolicy, validateDmarcExternalReporting, } from "./domain-posture-analysis.mjs";
12
16
  const DNS_TIMEOUT_MS = 6000;
@@ -23,6 +27,22 @@ function errorRecord(value) {
23
27
  function asMxRecords(records) {
24
28
  return records;
25
29
  }
30
+ function completeMxRecords(records) {
31
+ const normalized = [];
32
+ for (const raw of records) {
33
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
34
+ return null;
35
+ const value = raw;
36
+ const exchange = typeof value.exchange === 'string' ? value.exchange.trim().toLowerCase().replace(/\.+$/u, '') : null;
37
+ const priority = Number(value.priority);
38
+ if (exchange === null || !Number.isInteger(priority) || priority < 0 || priority > 65_535)
39
+ return null;
40
+ if (exchange && !normalizeAuditDomain(exchange))
41
+ return null;
42
+ normalized.push({ exchange, priority });
43
+ }
44
+ return normalized;
45
+ }
26
46
  function trimTerminalDots(value) {
27
47
  let end = value.length;
28
48
  while (end > 0 && value.charCodeAt(end - 1) === 46)
@@ -72,14 +92,23 @@ function withTimeout(promise, label, timeoutMs = DNS_TIMEOUT_MS, timers = {
72
92
  });
73
93
  }
74
94
  async function resolveDns(label, factory, timeoutMs = DNS_TIMEOUT_MS, timers) {
95
+ const completed = (result) => {
96
+ let observedAt = null;
97
+ try {
98
+ const at = timers?.now?.();
99
+ observedAt = at instanceof Date && Number.isFinite(at.getTime()) ? at.toISOString() : null;
100
+ }
101
+ catch { /* Unknown source time remains explicit. */ }
102
+ return { ...result, observedAt };
103
+ };
75
104
  try {
76
- return { records: await withTimeout(factory(), label, timeoutMs, timers), error: null };
105
+ return completed({ records: await withTimeout(factory(), label, timeoutMs, timers), error: null });
77
106
  }
78
107
  catch (err) {
79
108
  const error = errorRecord(err);
80
109
  if (typeof error.code === 'string' && MISSING_DNS_CODES.has(error.code))
81
- return { records: [], error: null };
82
- return { records: [], error: nonEmptyErrorMessage(err, String(err)) };
110
+ return completed({ records: [], error: null });
111
+ return completed({ records: [], error: nonEmptyErrorMessage(err, String(err)) });
83
112
  }
84
113
  }
85
114
  function check(id, label, status, summary, { detail = '', records = [], remediation = '' } = {}) {
@@ -175,6 +204,11 @@ function dmarcCheck(query, authorizations = []) {
175
204
  ];
176
205
  const external = authorizations.filter((authorization) => authorization.state !== 'self');
177
206
  const unresolvedExternal = external.filter((authorization) => authorization.state !== 'authorized');
207
+ const intendedDestinations = parsed.aggregateDestinations.length + parsed.failureDestinations.length;
208
+ const omittedDestinations = Math.max(0, intendedDestinations - authorizations.length);
209
+ if (omittedDestinations) {
210
+ 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
+ }
178
212
  if (external.length > 0) {
179
213
  details.push(`${external.length} external reporting destination${external.length === 1 ? '' : 's'} checked; `
180
214
  + `${unresolvedExternal.length} could not be authorized.`);
@@ -197,11 +231,13 @@ function dmarcCheck(query, authorizations = []) {
197
231
  remediation: 'Set sp and np to quarantine or reject unless weaker subdomain treatment is intentional.',
198
232
  });
199
233
  }
200
- if (unresolvedExternal.length > 0) {
201
- return check('dmarc', 'DMARC', 'warning', `Enforced at p=${parsed.policy}; external reporting authorization is incomplete`, {
234
+ if (unresolvedExternal.length > 0 || omittedDestinations > 0) {
235
+ return check('dmarc', 'DMARC', 'warning', `Enforced at p=${parsed.policy}; reporting authorisation is incomplete`, {
202
236
  detail: `${details.join(' ')} ${unresolvedExternal.map((authorization) => `${authorization.destination}: ${authorization.state}.`).join(' ')}`,
203
237
  records: parsed.records,
204
- remediation: 'Publish the required external reporting authorisation record or remove the unavailable destination.',
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.',
205
241
  });
206
242
  }
207
243
  if (!parsed.aggregateReporting) {
@@ -316,7 +352,11 @@ function matchesMtaPattern(host, pattern) {
316
352
  const normalizedPattern = String(pattern || '').toLowerCase().replace(/\.+$/, '');
317
353
  if (normalizedPattern.startsWith('*.')) {
318
354
  const suffix = normalizedPattern.slice(1);
319
- return normalizedHost.endsWith(suffix) && normalizedHost.length > suffix.length;
355
+ if (!normalizedHost.endsWith(suffix))
356
+ return false;
357
+ const label = normalizedHost.slice(0, -suffix.length);
358
+ // MTA-STS permits a wildcard for exactly one leftmost DNS label.
359
+ return /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(label);
320
360
  }
321
361
  return normalizedHost === normalizedPattern;
322
362
  }
@@ -356,7 +396,22 @@ function mtaStsCheck(dnsQuery, policyFetch, mxQuery) {
356
396
  remediation: 'Configure the policy host to return Content-Type: text/plain.',
357
397
  });
358
398
  }
359
- const mxHosts = mxQuery.error ? [] : classifyMxRecords(asMxRecords(mxQuery.records)).mxHosts;
399
+ if (mxQuery.error) {
400
+ return check('mta_sts', 'MTA-STS', 'info', 'Policy is valid but MX coverage could not be evaluated', {
401
+ detail: mxQuery.error,
402
+ records,
403
+ remediation: 'Retry the MX lookup before concluding that the policy covers every current mail exchanger.',
404
+ });
405
+ }
406
+ const reviewedMx = completeMxRecords(mxQuery.records);
407
+ if (!reviewedMx) {
408
+ return check('mta_sts', 'MTA-STS', 'info', 'Policy is valid but MX coverage is incomplete', {
409
+ detail: 'One or more MX records were malformed or outside the bounded hostname and priority contract.',
410
+ records,
411
+ remediation: 'Retry and review the MX evidence before concluding that the policy covers every current mail exchanger.',
412
+ });
413
+ }
414
+ const mxHosts = classifyMxRecords(reviewedMx).mxHosts;
360
415
  const unmatched = mxHosts.filter((host) => !policy.mx.some((pattern) => matchesMtaPattern(host, pattern)));
361
416
  if (unmatched.length) {
362
417
  return check('mta_sts', 'MTA-STS', 'danger', 'Policy does not cover every published MX host', {
@@ -507,7 +562,7 @@ function defensiveMailProfileCheck(profile, input) {
507
562
  const mx = input.mx.error ? null : classifyMxRecords(asMxRecords(input.mx.records));
508
563
  const requirements = {
509
564
  nullMx: mx?.hasNullMx === true,
510
- restrictiveSpf: spf?.valid === true && spf.terminalPolicy === 'fail' && spf.dnsLookupTerms === 0,
565
+ restrictiveSpf: spf?.valid === true && spf.terminalPolicy === 'fail' && spf.authorizingTerms.length === 0,
511
566
  rejectingDmarc: dmarc?.valid === true
512
567
  && dmarc.policy === 'reject'
513
568
  && dmarc.subdomainPolicy === 'reject'
@@ -534,10 +589,11 @@ function defensiveMailProfileCheck(profile, input) {
534
589
  function registrationLockCheck(registry) {
535
590
  if (registry.error)
536
591
  return queryFailureCheck('registration_lock', 'Registration controls', registry.error);
537
- const statuses = registry.statuses.map((status) => status.toLowerCase().replace(/[^a-z]/gu, ''));
592
+ const statuses = canonicalPostureRecords('registration_lock', registry.statuses) ?? [];
538
593
  if (statuses.length === 0) {
539
594
  return check('registration_lock', 'Registration controls', 'info', 'Registry lock state is unavailable', {
540
595
  detail: 'No normalised EPP status was returned. This does not describe registrar account security.',
596
+ records: registry.statuses,
541
597
  });
542
598
  }
543
599
  const transferLocks = statuses.filter((status) => ['clienttransferprohibited', 'servertransferprohibited'].includes(status));
@@ -604,11 +660,36 @@ function buildPostureReport(domain, input) {
604
660
  ...(input.registry && input.nameservers ? [nameserverCheck(input.nameservers, input.registry)] : []),
605
661
  ];
606
662
  const summary = { pass: 0, warning: 0, danger: 0, info: 0 };
607
- for (const item of checks)
663
+ for (const item of checks) {
608
664
  summary[item.status] += 1;
665
+ const source = POSTURE_CHECK_SOURCES[item.id];
666
+ const query = item.id === 'nameservers' ? input.nameservers : item.id === 'mx' ? input.mx : item.id === 'caa' ? input.caa : undefined;
667
+ const registry = item.id === 'registration_lock' ? input.registry : undefined;
668
+ if (!source || (!query && !registry))
669
+ continue;
670
+ const omittedRecords = Math.max(0, item.records.length - MAX_POSTURE_CHECK_RECORDS);
671
+ const records = item.records.slice(0, MAX_POSTURE_CHECK_RECORDS);
672
+ const error = query?.error ?? registry?.error;
673
+ const retained = canonicalPostureRecords(item.id, records);
674
+ const rawRecords = query?.records ?? registry?.statuses ?? [];
675
+ const raw = item.id === 'caa'
676
+ ? canonicalPostureRecords(item.id, rawRecords.map((record) => record && typeof record === 'object' ? caaDisplay(record) : record))
677
+ : canonicalPostureRecords(item.id, rawRecords);
678
+ const complete = !error && omittedRecords === 0 && retained !== null && raw !== null
679
+ && retained.length === raw.length && retained.every((value, index) => value === raw[index])
680
+ && (!registry || (registry.statusesComplete === true && records.length > 0));
681
+ item.sourceContext = {
682
+ version: DOMAIN_POSTURE_COMPARISON_VERSION, source,
683
+ observedAt: normalizeExplicitIsoTimestamp(query?.observedAt ?? registry?.observedAt),
684
+ state: error ? 'unavailable' : complete ? 'complete' : 'partial',
685
+ omittedRecords: complete ? 0 : omittedRecords || null,
686
+ };
687
+ item.records = records;
688
+ }
609
689
  return { domain, summary, checks };
610
690
  }
611
- async function fetchMtaStsPolicy(domain, fetcher = safeFetch) {
691
+ async function fetchMtaStsPolicy(domain, fetcher = safeFetch, signal) {
692
+ signal?.throwIfAborted();
612
693
  const controller = new AbortController();
613
694
  const timeout = setTimeout(() => controller.abort(), POLICY_TIMEOUT_MS);
614
695
  try {
@@ -617,10 +698,10 @@ async function fetchMtaStsPolicy(domain, fetcher = safeFetch) {
617
698
  // following HTTP redirects for policy discovery. A zero-hop safe fetch
618
699
  // also keeps the returned body bound to the required policy host/path.
619
700
  const res = await fetcher(url, {
620
- signal: controller.signal,
701
+ signal: signal ? AbortSignal.any([signal, controller.signal]) : controller.signal,
621
702
  headers: whoisleuthRequestHeaders({ Accept: 'text/plain' }),
622
703
  }, 0);
623
- if (!res.ok) {
704
+ if (res.status !== 200) {
624
705
  // Not reading this body - release it explicitly instead of leaving an
625
706
  // unconsumed stream (and the connection it's tied to) open until
626
707
  // undici's own idle-timeout eventually notices.
@@ -628,11 +709,13 @@ async function fetchMtaStsPolicy(domain, fetcher = safeFetch) {
628
709
  return { text: '', contentType: res.headers.get('content-type'), error: `Policy endpoint returned HTTP ${res.status}.` };
629
710
  }
630
711
  const body = await readTextCapped(res, MAX_POLICY_BYTES);
712
+ signal?.throwIfAborted();
631
713
  if (body.truncated)
632
714
  return { text: '', contentType: res.headers.get('content-type'), error: `Policy exceeds ${MAX_POLICY_BYTES} bytes.` };
633
715
  return { text: body.text, contentType: res.headers.get('content-type'), error: null };
634
716
  }
635
717
  catch (err) {
718
+ signal?.throwIfAborted();
636
719
  return {
637
720
  text: '',
638
721
  contentType: null,
@@ -645,17 +728,8 @@ async function fetchMtaStsPolicy(domain, fetcher = safeFetch) {
645
728
  clearTimeout(timeout);
646
729
  }
647
730
  }
648
- async function checkDomainPosture(domain, { dkimSelectors = [], retiredDkimSelectors = [], mailProtectionProfile = 'standard', } = {}, dependencies = {
649
- resolveTxt: (name) => dns.resolveTxt(name),
650
- resolveMx: (name) => dns.resolveMx(name),
651
- resolveNs: (name) => dns.resolveNs(name),
652
- resolveCaa: (name) => dns.resolveCaa(name),
653
- fetchRdapRecord,
654
- fetchMtaStsPolicy: (name) => fetchMtaStsPolicy(name),
655
- now: () => new Date(),
656
- setTimer: (callback, milliseconds) => setTimeout(callback, milliseconds),
657
- clearTimer: (handle) => clearTimeout(handle),
658
- }) {
731
+ async function collectDomainPosture(domain, { dkimSelectors = [], retiredDkimSelectors = [], mailProtectionProfile = 'standard', includeInheritedDns, signal, }, dependencies) {
732
+ signal?.throwIfAborted();
659
733
  const normalizedDomain = normalizeAuditDomain(domain);
660
734
  if (!normalizedDomain)
661
735
  throw new Error('Invalid domain name for posture audit.');
@@ -686,10 +760,11 @@ async function checkDomainPosture(domain, { dkimSelectors = [], retiredDkimSelec
686
760
  ...await resolveDns(`TXT ${selector}._domainkey.${domain}`, () => dependencies.resolveTxt(`${selector}._domainkey.${domain}`), DNS_TIMEOUT_MS, dependencies),
687
761
  })),
688
762
  ]),
689
- dependencies.fetchRdapRecord('domain', domain).catch((err) => ({
763
+ dependencies.fetchRdapRecord('domain', domain, signal ? { signal } : {}).catch((err) => ({
690
764
  error: nonEmptyErrorMessage(err, String(err)),
691
765
  })),
692
766
  ]);
767
+ signal?.throwIfAborted();
693
768
  const parsedMtaDns = mtaStsDns.error ? null : parseMtaStsDnsRecords(mtaStsDns.records);
694
769
  const enrichmentStartedAt = dependencies.now();
695
770
  if (!(enrichmentStartedAt instanceof Date) || !Number.isFinite(enrichmentStartedAt.getTime())) {
@@ -697,6 +772,7 @@ async function checkDomainPosture(domain, { dkimSelectors = [], retiredDkimSelec
697
772
  }
698
773
  const enrichmentDeadline = enrichmentStartedAt.getTime() + POSTURE_ENRICHMENT_DEADLINE_MS;
699
774
  const resolveEnrichmentTxt = (name) => {
775
+ signal?.throwIfAborted();
700
776
  const observedAt = dependencies.now();
701
777
  if (!(observedAt instanceof Date) || !Number.isFinite(observedAt.getTime())) {
702
778
  return Promise.resolve({ records: [], error: 'The bounded posture-enrichment clock was unavailable.' });
@@ -706,11 +782,21 @@ async function checkDomainPosture(domain, { dkimSelectors = [], retiredDkimSelec
706
782
  return Promise.resolve({ records: [], error: 'The bounded posture-enrichment deadline was reached.' });
707
783
  return resolveDns(`TXT ${name}`, () => dependencies.resolveTxt(name), Math.min(DNS_TIMEOUT_MS, remaining), dependencies);
708
784
  };
709
- const [mtaStsPolicy, spfExpansion, dmarcAuthorizations] = await Promise.all([
710
- parsedMtaDns?.valid ? dependencies.fetchMtaStsPolicy(domain) : Promise.resolve(null),
785
+ const enrichment = [
786
+ parsedMtaDns?.valid ? dependencies.fetchMtaStsPolicy(domain, signal) : Promise.resolve(null),
711
787
  expandSpfPolicy(domain, spf, resolveEnrichmentTxt),
712
788
  validateDmarcExternalReporting(domain, dmarc, resolveEnrichmentTxt),
713
- ]);
789
+ includeInheritedDns === true
790
+ ? (dependencies.collectDnsInheritanceChecks
791
+ ? dependencies.collectDnsInheritanceChecks(domain, dmarc, nameservers, signal ? { signal } : {})
792
+ : Promise.reject(new TypeError('The explicit DNS review collector is unavailable.')))
793
+ : Promise.resolve([]),
794
+ ];
795
+ // A rejected policy request must not release the operation's capacity while
796
+ // other started enrichment collectors are still settling.
797
+ const [mtaStsPolicy, spfExpansion, dmarcAuthorizations, inheritanceChecks] = await Promise.all(enrichment)
798
+ .finally(() => Promise.allSettled(enrichment));
799
+ signal?.throwIfAborted();
714
800
  const dnssec = !rdap
715
801
  ? { value: null, error: 'RDAP did not return a domain record.' }
716
802
  : 'error' in rdap
@@ -727,6 +813,8 @@ async function checkDomainPosture(domain, { dkimSelectors = [], retiredDkimSelec
727
813
  dsRecordCount: parsedDomain.dsData.length,
728
814
  dsDataTruncated: parsedDomain.dsDataTruncated,
729
815
  error: null,
816
+ observedAt: normalizeExplicitIsoTimestamp(rdap && !('error' in rdap) ? rdap.fetchedAt : null),
817
+ statusesComplete: parsedDomain.statusesTruncated === false && parsedDomain.serverTruncated === false,
730
818
  }
731
819
  : {
732
820
  statuses: [],
@@ -763,6 +851,10 @@ async function checkDomainPosture(domain, { dkimSelectors = [], retiredDkimSelec
763
851
  if (!(checkedAt instanceof Date) || !Number.isFinite(checkedAt.getTime())) {
764
852
  throw new TypeError('Domain-posture completion time must be valid.');
765
853
  }
854
+ for (const item of inheritanceChecks) {
855
+ report.checks.push(item);
856
+ report.summary[item.status] += 1;
857
+ }
766
858
  return {
767
859
  ...report,
768
860
  checkedAt: checkedAt.toISOString(),
@@ -774,4 +866,30 @@ async function checkDomainPosture(domain, { dkimSelectors = [], retiredDkimSelec
774
866
  externalDependencies,
775
867
  };
776
868
  }
869
+ async function checkDomainPosture(domain, options = {}, dependencies) {
870
+ options.signal?.throwIfAborted();
871
+ // Cancelling one audit must never cancel another audit's resolver.
872
+ const resolver = dependencies ? null : new dns.Resolver();
873
+ const cancel = () => resolver?.cancel();
874
+ options.signal?.addEventListener('abort', cancel, { once: true });
875
+ try {
876
+ options.signal?.throwIfAborted();
877
+ return await collectDomainPosture(domain, options, dependencies ?? {
878
+ resolveTxt: name => resolver.resolveTxt(name),
879
+ resolveMx: name => resolver.resolveMx(name),
880
+ resolveNs: name => resolver.resolveNs(name),
881
+ resolveCaa: name => resolver.resolveCaa(name),
882
+ fetchRdapRecord,
883
+ fetchMtaStsPolicy: (name, signal) => fetchMtaStsPolicy(name, safeFetch, signal),
884
+ now: () => new Date(),
885
+ setTimer: (callback, milliseconds) => setTimeout(callback, milliseconds),
886
+ clearTimer: handle => clearTimeout(handle),
887
+ collectDnsInheritanceChecks,
888
+ });
889
+ }
890
+ finally {
891
+ options.signal?.removeEventListener('abort', cancel);
892
+ cancel();
893
+ }
894
+ }
777
895
  export { normalizeAuditDomain, normalizeDkimSelectors, normalizeMailProtectionProfile, matchesMtaPattern, fetchMtaStsPolicy, buildPostureReport, checkDomainPosture, };