@slicedearth/whoisleuth-cli 1.45.0 → 2.0.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 (268) hide show
  1. package/DISCLOSURE +7 -3
  2. package/NOTICE +5 -4
  3. package/README.md +74 -124
  4. package/SECURITY.md +6 -3
  5. package/bin/whoisleuth.mjs +7 -4
  6. package/cli/archive-inspect.mjs +28 -10
  7. package/cli/arguments.mjs +450 -173
  8. package/cli/artifact-structure.mjs +2507 -0
  9. package/cli/artifact-verify.mjs +286 -64
  10. package/cli/bounded-json.mjs +1 -0
  11. package/cli/bulk-checkpoint.mjs +35 -23
  12. package/cli/bulk-command-runner.mjs +2 -5
  13. package/cli/bulk.mjs +9 -2
  14. package/cli/case-pack.mjs +435 -46
  15. package/cli/ci-report.mjs +6 -1
  16. package/cli/collection-preflight.mjs +2 -2
  17. package/cli/command-catalogue.mjs +12 -3
  18. package/cli/command-reference.mjs +948 -106
  19. package/cli/completion.mjs +504 -218
  20. package/cli/config-profile.mjs +13 -3
  21. package/cli/ct-event-intake.mjs +32 -2
  22. package/cli/discover.mjs +7 -1
  23. package/cli/discovery-observation-snapshot.mjs +24 -28
  24. package/cli/discovery-scan-command-runner.mjs +13 -14
  25. package/cli/discovery-scan.mjs +17 -3
  26. package/cli/discovery-snapshot.mjs +15 -7
  27. package/cli/discovery-workflow.mjs +2 -3
  28. package/cli/doctor.mjs +2 -2
  29. package/cli/domain-control-monitor.mjs +215 -36
  30. package/cli/domain-control-observations.mjs +34 -17
  31. package/cli/errors.mjs +8 -3
  32. package/cli/evidence-command-runner.mjs +17 -11
  33. package/cli/evidence-signing.mjs +68 -50
  34. package/cli/export-evidence.mjs +11 -4
  35. package/cli/external-observation-mapping.mjs +3 -3
  36. package/cli/formatters/html.mjs +3 -3
  37. package/cli/formatters/json.mjs +20 -32
  38. package/cli/formatters/terminal.mjs +586 -22
  39. package/cli/input.mjs +62 -0
  40. package/cli/interactive-launcher.mjs +226 -0
  41. package/cli/interchange-report.mjs +226 -0
  42. package/cli/investigation-manifest.mjs +16 -17
  43. package/cli/investigation-plan.mjs +167 -16
  44. package/cli/investigation-run.mjs +80 -24
  45. package/cli/local-mmdb-review.mjs +45 -14
  46. package/cli/lookalike-calibration.mjs +3 -1
  47. package/cli/lookup-brief.mjs +13 -8
  48. package/cli/lookup-browser.mjs +844 -0
  49. package/cli/lookup-command-runner.mjs +96 -33
  50. package/cli/lookup-diff.mjs +11 -2
  51. package/cli/lookup-plan.mjs +1 -0
  52. package/cli/mail-review.mjs +206 -94
  53. package/cli/manual.mjs +5 -5
  54. package/cli/network-command-runner.mjs +78 -0
  55. package/cli/offline-evidence-review.mjs +45 -14
  56. package/cli/open-asset-model-bridge.mjs +1 -1
  57. package/cli/output-file.mjs +70 -14
  58. package/cli/page-compare.mjs +18 -10
  59. package/cli/progress-events.mjs +2 -1
  60. package/cli/progress.mjs +3 -3
  61. package/cli/registry-cohort.mjs +343 -61
  62. package/cli/registry-doctor.mjs +67 -28
  63. package/cli/registry-support.mjs +2 -1
  64. package/cli/retained-artifact-diff.mjs +375 -0
  65. package/cli/risk-calibration.mjs +89 -40
  66. package/cli/runner.mjs +322 -246
  67. package/cli/saved-lookup.mjs +439 -49
  68. package/cli/sharing-review.mjs +18 -7
  69. package/cli/source-reliability.mjs +75 -36
  70. package/cli/terminal-presentation.mjs +43 -18
  71. package/docs/cli-reference.md +125 -1334
  72. package/docs/cli.md +87 -236
  73. package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -0
  74. package/frontend/src/lib/analysis/domain-control-records.js +3 -0
  75. package/lib/application-version.mjs +3 -0
  76. package/lib/availability.mjs +72 -52
  77. package/lib/bounded-contract-normalizers.mjs +2 -4
  78. package/lib/bounded-file.mjs +91 -4
  79. package/lib/bounded-json.mjs +287 -0
  80. package/lib/browser-library-profile.mjs +1 -1
  81. package/lib/certificate-extension-profile.mjs +2 -1
  82. package/lib/classify.mjs +56 -5
  83. package/lib/client-behavior-profile.mjs +1 -1
  84. package/lib/credential-surface-profile.mjs +1 -1
  85. package/lib/cryptographic-assurance.mjs +173 -0
  86. package/lib/ct-query.mjs +3 -3
  87. package/lib/ct-search.mjs +120 -64
  88. package/lib/distributed-operation-budget.mjs +4 -2
  89. package/lib/dns-delegation-health.mjs +9 -6
  90. package/lib/dns-intelligence.mjs +4 -3
  91. package/lib/dns-resolver-selection.mjs +6 -4
  92. package/lib/dnssec-chain-validation.mjs +1052 -0
  93. package/lib/dnssec-evidence-validation.mjs +23 -10
  94. package/lib/domain-assurance.mjs +12 -12
  95. package/lib/domain-change-packet.mjs +7 -5
  96. package/lib/domain-control-flight-recorder.mjs +442 -78
  97. package/lib/domain-control-manifest.mjs +314 -171
  98. package/lib/domain-posture-analysis.mjs +5 -2
  99. package/lib/domain-posture.mjs +7 -4
  100. package/lib/encrypted-dns-contract.mjs +2 -3
  101. package/lib/evidence-export.mjs +1243 -88
  102. package/lib/evidence-report-markdown.mjs +3 -0
  103. package/lib/evidence-report.mjs +5 -0
  104. package/lib/external-intelligence-risk.mjs +26 -10
  105. package/lib/favicon.mjs +8 -5
  106. package/lib/feature-policy.mjs +1 -1
  107. package/lib/homepage-metadata-contract.mjs +290 -0
  108. package/lib/hostname.mjs +1 -1
  109. package/lib/html-signals.mjs +89 -5
  110. package/lib/http-evidence-bounds.mjs +3 -0
  111. package/lib/http-intelligence.mjs +332 -6
  112. package/lib/interchange-fidelity-registry.mjs +124 -0
  113. package/lib/local-geoip-evidence.mjs +2 -1
  114. package/lib/lookup-cache.mjs +6 -4
  115. package/lib/lookup-network-evidence-bounds.mjs +9 -0
  116. package/lib/lookup.mjs +27 -4
  117. package/lib/observed-network-context.mjs +1 -1
  118. package/lib/outbound-identity.mjs +2 -3
  119. package/lib/page-fingerprints.mjs +2 -2
  120. package/lib/page-role-profile.mjs +1 -1
  121. package/lib/perceptual-hash.mjs +12 -4
  122. package/lib/portable-generator.mjs +29 -0
  123. package/lib/provider-policy-admission.mjs +11 -4
  124. package/lib/rdap-bootstrap.mjs +76 -16
  125. package/lib/rdap-client.mjs +20 -12
  126. package/lib/rdap-normalization.mjs +324 -80
  127. package/lib/rdap-registrar.mjs +5 -1
  128. package/lib/rdap-transport.mjs +2 -2
  129. package/lib/rdap-validation.mjs +65 -14
  130. package/lib/rdap.mjs +2 -2
  131. package/lib/registrable-domain.mjs +36 -0
  132. package/lib/registry-capabilities.mjs +3 -0
  133. package/lib/registry-capability-catalogue.mjs +12 -2
  134. package/lib/registry-comparison.mjs +28 -11
  135. package/lib/registry-insights.mjs +7 -0
  136. package/lib/response-policy.mjs +27 -4
  137. package/lib/risk-calibration-summary.mjs +340 -0
  138. package/lib/risk-scoring.mjs +4 -3
  139. package/lib/rpki-evidence.mjs +9 -8
  140. package/lib/safe-fetch.mjs +121 -50
  141. package/lib/security-txt.mjs +27 -4
  142. package/lib/service-binding-dns.mjs +6 -1
  143. package/lib/smtp-transport-review.mjs +787 -0
  144. package/lib/sslbl-certificates.generated.mjs +136 -134
  145. package/lib/sslbl-intelligence.mjs +6 -8
  146. package/lib/static-html-analysis.mjs +301 -4
  147. package/lib/structured-data-identity.mjs +51 -19
  148. package/lib/threat-intelligence-fixtures.mjs +2 -4
  149. package/lib/threat-intelligence-manifests.mjs +2 -7
  150. package/lib/threat-intelligence-runtime.mjs +5 -15
  151. package/lib/threat-intelligence-types.mjs +21 -1
  152. package/lib/threatfox-intelligence.mjs +7 -3
  153. package/lib/tls-intelligence.mjs +15 -9
  154. package/lib/tlsa-evidence.mjs +37 -7
  155. package/lib/urlhaus-intelligence.mjs +4 -2
  156. package/lib/urlscan-intelligence.mjs +6 -5
  157. package/lib/website-security-posture.mjs +1 -1
  158. package/lib/website-technology.mjs +1 -1
  159. package/lib/whois-authority.mjs +43 -8
  160. package/lib/whois-chain.mjs +25 -3
  161. package/lib/whois-normalization.mjs +3 -1
  162. package/lib/whois-transport.mjs +12 -3
  163. package/lib/zone-intent-review.mjs +1 -1
  164. package/package.json +12 -4
  165. package/packages/cases/case-brand-profile-references.mjs +85 -0
  166. package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +43 -14
  167. package/packages/cases/case-investigation-branch-model.mjs +132 -0
  168. package/packages/cases/case-migration-model.mjs +476 -0
  169. package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
  170. package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -19
  171. package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
  172. package/packages/cases/case-record-model.mjs +9 -0
  173. package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +106 -33
  174. package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +143 -16
  175. package/packages/cases/case-response-model.mjs +1604 -0
  176. package/packages/cases/case-response-packet.mjs +1312 -0
  177. package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
  178. package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
  179. package/packages/cases/opaque-reference-id.mjs +9 -0
  180. package/packages/comparison/comparison-ledger-bulk.mjs +366 -0
  181. package/packages/comparison/comparison-ledger-contract.mjs +241 -0
  182. package/packages/comparison/comparison-ledger-json.mjs +80 -0
  183. package/packages/comparison/comparison-ledger-serialization.mjs +52 -0
  184. package/packages/comparison/favicon-similarity.mjs +78 -0
  185. package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
  186. package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
  187. package/packages/contracts/analyst-review-state-contract.mjs +61 -0
  188. package/packages/contracts/analyst-review-state.mjs +50 -0
  189. package/packages/contracts/case-portability.mjs +1310 -0
  190. package/packages/contracts/cli-command-catalogue.mjs +2 -0
  191. package/packages/contracts/cli-lookup.mjs +394 -0
  192. package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
  193. package/packages/contracts/domain-control-manifest.mjs +1027 -0
  194. package/packages/contracts/domain-control-monitor.mjs +310 -0
  195. package/packages/contracts/domain-control-review.mjs +828 -0
  196. package/packages/contracts/domain-name.mjs +4 -0
  197. package/packages/contracts/external-observation-interchange.mjs +224 -0
  198. package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
  199. package/packages/contracts/investigation-portability.mjs +331 -0
  200. package/packages/contracts/offline-comparison.mjs +160 -0
  201. package/packages/contracts/risk-calibration.mjs +1288 -0
  202. package/packages/contracts/schema-compatibility.mjs +71 -0
  203. package/packages/contracts/schema-lifecycle.mjs +1702 -0
  204. package/packages/contracts/sslbl-snapshot.mjs +17 -0
  205. package/packages/contracts/workspace-portability.mjs +1347 -0
  206. package/packages/evidence/artifact-integrity.mjs +65 -0
  207. package/packages/evidence/decision-fact.mjs +735 -0
  208. package/packages/evidence/domain-control-runtime.mjs +547 -0
  209. package/packages/evidence/domain-name.mjs +41 -0
  210. package/packages/evidence/observation.mjs +384 -0
  211. package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +86 -20
  212. package/packages/interchange/retained-text.mjs +8 -0
  213. package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +11 -8
  214. package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +28 -9
  215. package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +67 -18
  216. package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +10 -9
  217. package/packages/investigation/bulk-review-policy.mjs +1 -0
  218. package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +50 -18
  219. package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
  220. package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
  221. package/packages/investigation/lookup-claim-passport.mjs +232 -0
  222. package/packages/investigation/lookup-claim-readiness.mjs +287 -0
  223. package/packages/investigation/lookup-investigation-brief.mjs +172 -0
  224. package/packages/monitoring/analyst-review-state.mjs +680 -0
  225. package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +97 -50
  226. package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +15 -15
  227. package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +179 -39
  228. package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +12 -5
  229. package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
  230. package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
  231. package/packages/workspace/hostile-input.mjs +172 -0
  232. package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +51 -46
  233. package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +15 -15
  234. package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
  235. package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
  236. package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
  237. package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
  238. package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
  239. package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +18 -12
  240. package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
  241. package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +195 -84
  242. package/third-party-notices.txt +112 -5834
  243. package/frontend/src/lib/analysis/artifact-integrity.js +0 -21
  244. package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
  245. package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
  246. package/frontend/src/lib/analysis/case-migration-model.js +0 -253
  247. package/frontend/src/lib/analysis/case-record-model.js +0 -7
  248. package/frontend/src/lib/analysis/case-response-model.js +0 -669
  249. package/frontend/src/lib/analysis/case-response-packet.js +0 -573
  250. package/frontend/src/lib/analysis/lookup-decision-support.js +0 -487
  251. package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
  252. package/frontend/src/lib/analysis/lookup-presentation.js +0 -58
  253. package/frontend/src/lib/analysis/lookup-response.js +0 -2
  254. package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
  255. package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
  256. package/frontend/src/lib/analysis/scoring.js +0 -55
  257. package/frontend/src/lib/analysis/utils.js +0 -232
  258. package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
  259. package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
  260. package/frontend/src/lib/analysis/visualization-matrix.js +0 -88
  261. package/frontend/src/lib/analysis/visualization-models.js +0 -369
  262. package/frontend/src/lib/analysis/visualization-time-series.js +0 -144
  263. package/lib/bounded-json-response.mjs +0 -157
  264. package/lib/lookup-response-contract.mjs +0 -346
  265. package/lib/observation.mjs +0 -114
  266. /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
  267. /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
  268. /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
@@ -0,0 +1,787 @@
1
+ // Explicit, authorised SMTP transport review for analyst-selected MX hosts.
2
+ // This module is deliberately disconnected from Lookup, Bulk, monitoring,
3
+ // and recipes. It resolves only selected hosts through one caller-selected
4
+ // public resolver, revalidates and pins one public address, issues EHLO and
5
+ // optionally STARTTLS, and never authenticates, enumerates recipients, tests
6
+ // relay, or transmits a message.
7
+ import { Buffer } from 'node:buffer';
8
+ import * as crypto from 'node:crypto';
9
+ import * as net from 'node:net';
10
+ import * as tls from 'node:tls';
11
+ import { DNS_TYPE_A, DNS_TYPE_AAAA, DNS_TYPE_CNAME, DNS_TYPE_TLSA, DnssecQuerySession, collectValidatedRrset, normalizeDnsName, normalizeResolverEndpoint, parseDnssecTrustAnchor, validateDnssecChainWithContext, } from "./dnssec-chain-validation.mjs";
12
+ import { isPrivateAddress } from "./safe-fetch.mjs";
13
+ import { analyzeTlsaEvidence } from "./tlsa-evidence.mjs";
14
+ import { normalizeExplicitIsoTimestamp, normalizeLegacyIsoTimestamp } from "../packages/evidence/observation.mjs";
15
+ const MAIL_TRANSPORT_INPUT_SCHEMA = 'whoisleuth.mail-transport.input';
16
+ const MAIL_TRANSPORT_INPUT_VERSION = 1;
17
+ const MAIL_TRANSPORT_REVIEW_SCHEMA = 'whoisleuth.cli.mail-transport-review';
18
+ const MAIL_TRANSPORT_REVIEW_VERSION = 1;
19
+ const MAX_MAIL_TRANSPORT_INPUT_BYTES = 256 * 1024;
20
+ const MAX_MAIL_TRANSPORT_TARGETS = 3;
21
+ const MAX_MAIL_TRANSPORT_CONCURRENCY = 1;
22
+ const MAX_MAIL_TRANSPORT_ADDRESS_CANDIDATES = 16;
23
+ const MAX_MAIL_TRANSPORT_DNS_ALIASES = 4;
24
+ const MAX_SMTP_RESPONSE_BYTES = 16 * 1024;
25
+ const MAX_SMTP_REPLY_LINES = 64;
26
+ const MAX_SMTP_LINE_BYTES = 1_000;
27
+ const MAX_SMTP_CAPABILITIES = 32;
28
+ const MAX_SMTP_CERTIFICATE_BYTES = 256 * 1024;
29
+ const SMTP_ENDPOINT_TIMEOUT_MS = 8_000;
30
+ const MAIL_TRANSPORT_TOTAL_TIMEOUT_MS = 30_000;
31
+ const SMTP_PORT = 25;
32
+ const EHLO_COMMAND = 'EHLO whoisleuth.invalid\r\n';
33
+ const STARTTLS_COMMAND = 'STARTTLS\r\n';
34
+ const MAX_ERROR_LENGTH = 240;
35
+ const ACTIVE_DANE_TA_LIMITATION = 'If a DANE-TA TLSA usage 2 association is published, active STARTTLS review retains only the observed leaf certificate and cannot complete that comparison because it does not construct or validate a certificate path to a TLSA trust anchor.';
36
+ function boundedError(value) {
37
+ const message = value instanceof Error ? value.message : String(value || 'Mail transport review failed');
38
+ return message.replace(/[\u0000-\u001f\u007f]+/gu, ' ').replace(/\s+/gu, ' ').trim().slice(0, MAX_ERROR_LENGTH) || 'Mail transport review failed';
39
+ }
40
+ function exactRecord(value, keys, label) {
41
+ if (!value || typeof value !== 'object' || Array.isArray(value))
42
+ throw new TypeError(`${label} must be one object.`);
43
+ const record = value;
44
+ const unexpected = Object.keys(record).find((key) => !keys.has(key));
45
+ if (unexpected)
46
+ throw new TypeError(`${label} contains unsupported field "${unexpected}".`);
47
+ return record;
48
+ }
49
+ function timestamp(value, label, legacy = false) {
50
+ const normalized = normalizeExplicitIsoTimestamp(value)
51
+ ?? (legacy ? normalizeLegacyIsoTimestamp(value) : null);
52
+ if (!normalized)
53
+ throw new TypeError(`${label} must be one bounded ISO timestamp${legacy ? '' : ' with an explicit timezone'}.`);
54
+ return normalized;
55
+ }
56
+ function text(value, label, maximum = 160) {
57
+ if (typeof value !== 'string' || value.length > maximum * 4)
58
+ throw new TypeError(`${label} must be bounded text.`);
59
+ const normalized = value.replace(/[\u0000-\u001f\u007f]+/gu, ' ').replace(/\s+/gu, ' ').trim();
60
+ if (!normalized || normalized.length > maximum)
61
+ throw new TypeError(`${label} must contain from 1 to ${maximum} characters.`);
62
+ return normalized;
63
+ }
64
+ function policyEvidence(value, label) {
65
+ if (value === null || value === undefined)
66
+ return Object.freeze({ state: 'unavailable', source: null, observedAt: null, completeness: 'unavailable' });
67
+ const record = exactRecord(value, new Set(['state', 'source', 'observedAt', 'completeness']), `policyContext.${label}`);
68
+ const allowedStates = label === 'mtaSts'
69
+ ? ['enforce', 'testing', 'none', 'not_published', 'partial', 'unavailable']
70
+ : ['published', 'not_published', 'partial', 'unavailable'];
71
+ const state = text(record.state, `policyContext.${label}.state`, 32);
72
+ const completeness = text(record.completeness, `policyContext.${label}.completeness`, 16);
73
+ if (!allowedStates.includes(state) || !['complete', 'partial', 'unavailable'].includes(completeness))
74
+ throw new TypeError(`policyContext.${label} has an unsupported state or completeness.`);
75
+ const source = record.source === null ? null : text(record.source, `policyContext.${label}.source`, 160);
76
+ const observedAt = record.observedAt === null ? null : timestamp(record.observedAt, `policyContext.${label}.observedAt`, true);
77
+ const unavailable = completeness === 'unavailable';
78
+ if ((unavailable && (source !== null || observedAt !== null)) || (!unavailable && (source === null || observedAt === null))) {
79
+ throw new TypeError(`policyContext.${label} unavailable evidence must omit source and time; retained evidence requires both.`);
80
+ }
81
+ return Object.freeze({ state, source, observedAt, completeness: completeness });
82
+ }
83
+ function parseMailTransportInput(value) {
84
+ const source = typeof value === 'string' ? (() => {
85
+ if (Buffer.byteLength(value, 'utf8') > MAX_MAIL_TRANSPORT_INPUT_BYTES)
86
+ throw new TypeError(`Mail transport input is limited to ${MAX_MAIL_TRANSPORT_INPUT_BYTES} bytes.`);
87
+ try {
88
+ return JSON.parse(value);
89
+ }
90
+ catch {
91
+ throw new TypeError('Mail transport input is not valid JSON.');
92
+ }
93
+ })() : value;
94
+ const input = exactRecord(source, new Set(['schema', 'version', 'domain', 'mxHosts', 'policyContext']), 'Mail transport input');
95
+ if (input.schema !== MAIL_TRANSPORT_INPUT_SCHEMA || input.version !== MAIL_TRANSPORT_INPUT_VERSION) {
96
+ throw new TypeError(`Mail transport input must use ${MAIL_TRANSPORT_INPUT_SCHEMA} version ${MAIL_TRANSPORT_INPUT_VERSION}.`);
97
+ }
98
+ const domain = normalizeDnsName(input.domain);
99
+ if (!domain || !Array.isArray(input.mxHosts) || input.mxHosts.length < 1 || input.mxHosts.length > MAX_MAIL_TRANSPORT_TARGETS) {
100
+ throw new TypeError(`Mail transport input requires one domain and 1 to ${MAX_MAIL_TRANSPORT_TARGETS} selected MX hosts.`);
101
+ }
102
+ const mxHosts = input.mxHosts.map((host) => normalizeDnsName(host));
103
+ if (mxHosts.some((host) => host === null) || new Set(mxHosts).size !== mxHosts.length)
104
+ throw new TypeError('Mail transport MX hosts must be valid and unique.');
105
+ const policy = input.policyContext === undefined || input.policyContext === null
106
+ ? {}
107
+ : exactRecord(input.policyContext, new Set(['mtaSts', 'tlsRpt']), 'policyContext');
108
+ return Object.freeze({
109
+ schema: MAIL_TRANSPORT_INPUT_SCHEMA,
110
+ version: MAIL_TRANSPORT_INPUT_VERSION,
111
+ domain,
112
+ mxHosts: Object.freeze(mxHosts),
113
+ policyContext: Object.freeze({ mtaSts: policyEvidence(policy.mtaSts, 'mtaSts'), tlsRpt: policyEvidence(policy.tlsRpt, 'tlsRpt') }),
114
+ });
115
+ }
116
+ function normalizeSmtpReply(linesValue) {
117
+ if (!Array.isArray(linesValue) || linesValue.length < 1 || linesValue.length > MAX_SMTP_REPLY_LINES)
118
+ throw new Error('SMTP reply line count is invalid.');
119
+ const lines = linesValue.map((line) => {
120
+ if (typeof line !== 'string' || Buffer.byteLength(line, 'utf8') > MAX_SMTP_LINE_BYTES || /[^\u0009\u0020-\u007e]/u.test(line)) {
121
+ throw new Error('SMTP reply contains an invalid or oversized line.');
122
+ }
123
+ return line;
124
+ });
125
+ const bytes = lines.reduce((total, line) => total + Buffer.byteLength(line, 'utf8') + 2, 0);
126
+ if (bytes > MAX_SMTP_RESPONSE_BYTES)
127
+ throw new Error('SMTP reply exceeds the response-byte limit.');
128
+ const first = /^(\d{3})([- ])(.*)$/u.exec(lines[0]);
129
+ if (!first)
130
+ throw new Error('SMTP reply does not begin with a three-digit status.');
131
+ const code = Number(first[1]);
132
+ for (let index = 0; index < lines.length; index += 1) {
133
+ const match = /^(\d{3})([- ])(.*)$/u.exec(lines[index]);
134
+ if (!match || Number(match[1]) !== code || (index < lines.length - 1 && match[2] !== '-') || (index === lines.length - 1 && match[2] !== ' ')) {
135
+ throw new Error('SMTP multiline reply framing is invalid.');
136
+ }
137
+ }
138
+ const sha256 = crypto.createHash('sha256').update(`${lines.join('\r\n')}\r\n`, 'ascii').digest('hex');
139
+ return Object.freeze({ code, lines: Object.freeze(lines), bytes, sha256 });
140
+ }
141
+ function smtpCapabilities(reply) {
142
+ if (!reply || reply.code !== 250)
143
+ return Object.freeze([]);
144
+ const output = [];
145
+ for (const line of reply.lines.slice(1)) {
146
+ const capability = line.slice(4).trim().split(/\s+/u)[0]?.toUpperCase() || '';
147
+ if (!/^[A-Z0-9][A-Z0-9-]{0,31}$/u.test(capability) || output.includes(capability))
148
+ continue;
149
+ if (output.length >= MAX_SMTP_CAPABILITIES)
150
+ break;
151
+ output.push(capability);
152
+ }
153
+ return Object.freeze(output.sort());
154
+ }
155
+ async function runSmtpConversation(hostname, connection) {
156
+ try {
157
+ const greeting = await connection.readReply();
158
+ if (greeting.code !== 220) {
159
+ const diagnostics = connection.diagnostics();
160
+ return Object.freeze({ connectedAddress: connection.remoteAddress, greeting, ehlo: null, capabilities: Object.freeze([]), starttlsAdvertised: false, starttlsReply: null, starttlsState: 'rejected', tls: null, ...diagnostics });
161
+ }
162
+ connection.assertIdle();
163
+ await connection.write(EHLO_COMMAND);
164
+ const ehlo = await connection.readReply();
165
+ const capabilities = smtpCapabilities(ehlo);
166
+ const starttlsAdvertised = capabilities.includes('STARTTLS');
167
+ if (!starttlsAdvertised) {
168
+ const diagnostics = connection.diagnostics();
169
+ return Object.freeze({ connectedAddress: connection.remoteAddress, greeting, ehlo, capabilities, starttlsAdvertised, starttlsReply: null, starttlsState: 'not_advertised', tls: null, ...diagnostics });
170
+ }
171
+ connection.assertIdle();
172
+ await connection.write(STARTTLS_COMMAND);
173
+ const starttlsReply = await connection.readReply();
174
+ if (starttlsReply.code !== 220) {
175
+ const diagnostics = connection.diagnostics();
176
+ return Object.freeze({ connectedAddress: connection.remoteAddress, greeting, ehlo, capabilities, starttlsAdvertised, starttlsReply, starttlsState: 'rejected', tls: null, ...diagnostics });
177
+ }
178
+ connection.assertIdle();
179
+ try {
180
+ const tlsEvidence = await connection.startTls(hostname);
181
+ const diagnostics = connection.diagnostics();
182
+ return Object.freeze({ connectedAddress: connection.remoteAddress, greeting, ehlo, capabilities, starttlsAdvertised, starttlsReply, starttlsState: 'negotiated', tls: tlsEvidence, ...diagnostics });
183
+ }
184
+ catch {
185
+ const diagnostics = connection.diagnostics();
186
+ return Object.freeze({ connectedAddress: connection.remoteAddress, greeting, ehlo, capabilities, starttlsAdvertised, starttlsReply, starttlsState: 'failed', tls: null, ...diagnostics });
187
+ }
188
+ }
189
+ finally {
190
+ connection.destroy();
191
+ }
192
+ }
193
+ class SocketReplyReader {
194
+ #socket;
195
+ #buffer = Buffer.alloc(0);
196
+ #waiters = [];
197
+ #endedError = null;
198
+ bytesRead = 0;
199
+ lineCount = 0;
200
+ constructor(socket) {
201
+ this.#socket = socket;
202
+ socket.on('data', (chunk) => this.#receive(Buffer.from(chunk)));
203
+ socket.once('error', (error) => this.#fail(error));
204
+ socket.once('end', () => this.#fail(new Error('SMTP server closed the connection before completing a reply.')));
205
+ socket.once('timeout', () => this.#fail(new Error('SMTP transport timed out.')));
206
+ }
207
+ #receive(chunk) {
208
+ if (this.#endedError)
209
+ return;
210
+ this.bytesRead += chunk.length;
211
+ if (this.bytesRead > MAX_SMTP_RESPONSE_BYTES)
212
+ return this.#fail(new Error('SMTP response exceeds the response-byte limit.'));
213
+ this.#buffer = Buffer.concat([this.#buffer, chunk]);
214
+ if (this.#buffer.length > MAX_SMTP_LINE_BYTES && this.#buffer.indexOf('\r\n') === -1)
215
+ return this.#fail(new Error('SMTP response line exceeds the line-byte limit.'));
216
+ this.#drain();
217
+ }
218
+ #drain() {
219
+ while (this.#waiters.length) {
220
+ const boundary = this.#buffer.indexOf('\r\n');
221
+ if (boundary === -1)
222
+ return;
223
+ if (boundary > MAX_SMTP_LINE_BYTES)
224
+ return this.#fail(new Error('SMTP response line exceeds the line-byte limit.'));
225
+ const line = this.#buffer.subarray(0, boundary).toString('utf8');
226
+ this.#buffer = this.#buffer.subarray(boundary + 2);
227
+ this.lineCount += 1;
228
+ if (this.lineCount > MAX_SMTP_REPLY_LINES)
229
+ return this.#fail(new Error('SMTP response exceeds the line-count limit.'));
230
+ this.#waiters.shift()?.resolve(line);
231
+ }
232
+ }
233
+ #fail(error) {
234
+ if (this.#endedError)
235
+ return;
236
+ this.#endedError = error;
237
+ this.#socket.destroy();
238
+ for (const waiter of this.#waiters.splice(0))
239
+ waiter.reject(error);
240
+ }
241
+ readLine() {
242
+ if (this.#endedError)
243
+ return Promise.reject(this.#endedError);
244
+ return new Promise((resolve, reject) => {
245
+ this.#waiters.push({ resolve, reject });
246
+ this.#drain();
247
+ });
248
+ }
249
+ async readReply() {
250
+ const lines = [];
251
+ let code = null;
252
+ while (lines.length < MAX_SMTP_REPLY_LINES) {
253
+ const line = await this.readLine();
254
+ lines.push(line);
255
+ const match = /^(\d{3})([- ])/u.exec(line);
256
+ if (!match)
257
+ throw new Error('SMTP reply framing is invalid.');
258
+ if (code === null)
259
+ code = match[1];
260
+ if (match[1] !== code)
261
+ throw new Error('SMTP multiline reply status changed unexpectedly.');
262
+ if (match[2] === ' ')
263
+ return normalizeSmtpReply(lines);
264
+ }
265
+ throw new Error('SMTP reply exceeds the line-count limit.');
266
+ }
267
+ assertIdle() {
268
+ if (this.#endedError)
269
+ throw this.#endedError;
270
+ if (this.#waiters.length || this.#buffer.length) {
271
+ throw new Error('SMTP connection contained an unsolicited buffered reply before the next command.');
272
+ }
273
+ }
274
+ release() {
275
+ this.assertIdle();
276
+ this.#socket.removeAllListeners('data');
277
+ this.#socket.removeAllListeners('error');
278
+ this.#socket.removeAllListeners('end');
279
+ this.#socket.removeAllListeners('timeout');
280
+ }
281
+ }
282
+ function smtpStartTlsOptions(socket, hostname) {
283
+ return {
284
+ socket,
285
+ servername: hostname,
286
+ rejectUnauthorized: false,
287
+ // Keep CA-path authorization independent from the separately recorded
288
+ // endpoint-identity comparison performed by certificateObservation().
289
+ checkServerIdentity: () => undefined,
290
+ };
291
+ }
292
+ async function defaultSmtpProbe(options) {
293
+ const socket = net.createConnection({ host: options.address, port: SMTP_PORT, family: options.family });
294
+ socket.setTimeout(options.timeoutMs);
295
+ const aborted = () => socket.destroy(new Error('SMTP endpoint review timed out.'));
296
+ options.signal.addEventListener('abort', aborted, { once: true });
297
+ try {
298
+ await new Promise((resolve, reject) => {
299
+ const cleanup = () => {
300
+ socket.removeListener('connect', connected);
301
+ socket.removeListener('error', failed);
302
+ socket.removeListener('timeout', timedOut);
303
+ };
304
+ const connected = () => { cleanup(); resolve(); };
305
+ const failed = (error) => { cleanup(); socket.destroy(); reject(error); };
306
+ const timedOut = () => { cleanup(); socket.destroy(); reject(new Error('SMTP connection timed out.')); };
307
+ socket.once('connect', connected);
308
+ socket.once('error', failed);
309
+ socket.once('timeout', timedOut);
310
+ if (options.signal.aborted)
311
+ aborted();
312
+ });
313
+ const reader = new SocketReplyReader(socket);
314
+ const connection = {
315
+ remoteAddress: socket.remoteAddress || null,
316
+ readReply: () => reader.readReply(),
317
+ assertIdle: () => reader.assertIdle(),
318
+ write: (command) => new Promise((resolve, reject) => socket.write(command, (error) => error ? reject(error) : resolve())),
319
+ startTls: async (hostname) => {
320
+ reader.release();
321
+ const tlsSocket = tls.connect(smtpStartTlsOptions(socket, hostname));
322
+ tlsSocket.setTimeout(options.timeoutMs);
323
+ await new Promise((resolve, reject) => {
324
+ const cleanup = () => {
325
+ tlsSocket.removeListener('secureConnect', secured);
326
+ tlsSocket.removeListener('error', failed);
327
+ tlsSocket.removeListener('timeout', timedOut);
328
+ };
329
+ const secured = () => { cleanup(); resolve(); };
330
+ const failed = (error) => { cleanup(); tlsSocket.destroy(); reject(error); };
331
+ const timedOut = () => { cleanup(); tlsSocket.destroy(); reject(new Error('SMTP STARTTLS handshake timed out.')); };
332
+ tlsSocket.once('secureConnect', secured);
333
+ tlsSocket.once('error', failed);
334
+ tlsSocket.once('timeout', timedOut);
335
+ });
336
+ const cipher = tlsSocket.getCipher();
337
+ return Object.freeze({
338
+ peerCertificate: tlsSocket.getPeerCertificate(false),
339
+ authorized: typeof tlsSocket.authorized === 'boolean' ? tlsSocket.authorized : null,
340
+ authorizationError: tlsSocket.authorizationError ? boundedError(tlsSocket.authorizationError) : null,
341
+ protocol: tlsSocket.getProtocol(),
342
+ cipherName: typeof cipher?.name === 'string' ? cipher.name.slice(0, 128) : null,
343
+ remoteAddress: tlsSocket.remoteAddress || null,
344
+ });
345
+ },
346
+ diagnostics: () => ({ bytesRead: reader.bytesRead, lineCount: reader.lineCount }),
347
+ destroy: () => socket.destroy(),
348
+ };
349
+ return await runSmtpConversation(options.hostname, connection);
350
+ }
351
+ finally {
352
+ options.signal.removeEventListener('abort', aborted);
353
+ socket.destroy();
354
+ }
355
+ }
356
+ async function resolveSelectedHost(session, host) {
357
+ let current = host;
358
+ const aliases = [];
359
+ for (let depth = 0; depth <= MAX_MAIL_TRANSPORT_DNS_ALIASES; depth += 1) {
360
+ const ipv4 = await session.query(current, DNS_TYPE_A, { fresh: true });
361
+ const ipv6 = await session.query(current, DNS_TYPE_AAAA, { fresh: true });
362
+ const combined = [...ipv4.records, ...ipv6.records];
363
+ const addresses = combined.flatMap((record) => {
364
+ if (record.owner !== current || (record.data.kind !== 'A' && record.data.kind !== 'AAAA'))
365
+ return [];
366
+ const data = record.data;
367
+ if (isPrivateAddress(data.address))
368
+ throw new Error('Selected MX host resolved to a private or reserved address.');
369
+ return [{ address: data.address, family: data.kind === 'A' ? 4 : 6 }];
370
+ });
371
+ const uniqueAddresses = [...new Map(addresses.map((entry) => [`${entry.family}:${entry.address}`, entry])).values()];
372
+ if (uniqueAddresses.length > MAX_MAIL_TRANSPORT_ADDRESS_CANDIDATES)
373
+ throw new Error('Selected MX host exceeded the address-candidate bound.');
374
+ if (uniqueAddresses.length)
375
+ return { addresses: uniqueAddresses, aliases };
376
+ const cnameTargets = [...new Set(combined.flatMap((record) => (record.owner === current && record.data.kind === 'CNAME' ? [record.data.name] : [])))];
377
+ if (cnameTargets.length !== 1)
378
+ throw new Error('Selected MX host did not resolve to one bounded public address set.');
379
+ const next = normalizeDnsName(cnameTargets[0]);
380
+ if (!next || aliases.includes(next) || depth >= MAX_MAIL_TRANSPORT_DNS_ALIASES)
381
+ throw new Error('Selected MX host exceeded the DNS alias bound.');
382
+ aliases.push(next);
383
+ current = next;
384
+ }
385
+ throw new Error('Selected MX host exceeded the DNS alias bound.');
386
+ }
387
+ function certificateObservation(host, tlsEvidence) {
388
+ if (!tlsEvidence || !tlsEvidence.peerCertificate || typeof tlsEvidence.peerCertificate !== 'object') {
389
+ return {
390
+ observation: Object.freeze({ state: 'unavailable', sha256: null, spkiSha256: null, protocol: null, cipherName: null, pkixState: 'unavailable', identityState: 'unavailable', authorizationError: null, identityError: null }),
391
+ certificateDer: null,
392
+ spkiDer: null,
393
+ };
394
+ }
395
+ const peer = tlsEvidence.peerCertificate;
396
+ const raw = Buffer.isBuffer(peer.raw) && peer.raw.length > 0 && peer.raw.length <= MAX_SMTP_CERTIFICATE_BYTES ? Buffer.from(peer.raw) : null;
397
+ let spkiDer = null;
398
+ if (raw) {
399
+ try {
400
+ spkiDer = Buffer.from(new crypto.X509Certificate(raw).publicKey.export({ format: 'der', type: 'spki' }));
401
+ }
402
+ catch {
403
+ spkiDer = null;
404
+ }
405
+ }
406
+ let identityError = null;
407
+ if (raw) {
408
+ try {
409
+ identityError = tls.checkServerIdentity(host, peer) || null;
410
+ }
411
+ catch (error) {
412
+ identityError = error instanceof Error ? error : new Error('Certificate identity comparison failed.');
413
+ }
414
+ }
415
+ const pkixState = tlsEvidence.authorized === true ? 'validated' : tlsEvidence.authorized === false ? 'failed' : 'unavailable';
416
+ const identityState = raw ? identityError ? 'misaligned' : 'aligned' : 'unavailable';
417
+ return {
418
+ observation: Object.freeze({
419
+ state: raw ? 'observed' : 'unavailable',
420
+ sha256: raw ? crypto.createHash('sha256').update(raw).digest('hex') : null,
421
+ spkiSha256: spkiDer ? crypto.createHash('sha256').update(spkiDer).digest('hex') : null,
422
+ protocol: typeof tlsEvidence.protocol === 'string' ? tlsEvidence.protocol.slice(0, 32) : null,
423
+ cipherName: typeof tlsEvidence.cipherName === 'string' ? tlsEvidence.cipherName.slice(0, 128) : null,
424
+ pkixState,
425
+ identityState,
426
+ authorizationError: tlsEvidence.authorizationError ? boundedError(tlsEvidence.authorizationError) : null,
427
+ identityError: identityError ? boundedError(identityError) : null,
428
+ }),
429
+ certificateDer: raw,
430
+ spkiDer,
431
+ };
432
+ }
433
+ function unavailableTlsa(state, limitation) {
434
+ return Object.freeze({ state, recordCount: 0, signatureState: 'unavailable', dane: null, limitations: Object.freeze([limitation]) });
435
+ }
436
+ async function collectTlsa(session, host, dnssecContext, certificate, observedAt) {
437
+ if (dnssecContext.report.state !== 'secure') {
438
+ const mapped = ['insecure', 'bogus', 'indeterminate', 'timed_out', 'unsupported'].includes(dnssecContext.report.state)
439
+ ? dnssecContext.report.state
440
+ : 'unavailable';
441
+ return unavailableTlsa(mapped, `TLSA evidence was not treated as DANE because DNSSEC validation was ${dnssecContext.report.state}.`);
442
+ }
443
+ const serviceName = `_25._tcp.${host}`;
444
+ try {
445
+ const result = await collectValidatedRrset(session, dnssecContext, serviceName, DNS_TYPE_TLSA, observedAt);
446
+ if (result.verification.state !== 'valid') {
447
+ const state = result.verification.state === 'bogus' ? 'bogus' : result.verification.state === 'unsupported' ? 'unsupported' : 'indeterminate';
448
+ return unavailableTlsa(state, result.verification.detail);
449
+ }
450
+ const records = result.records.filter((record) => record.data.kind === 'TLSA');
451
+ if (!records.length)
452
+ return unavailableTlsa('not_published', 'A validated DNSSEC denial showed no TLSA RRset for the selected SMTP service.');
453
+ const dane = analyzeTlsaEvidence({
454
+ serviceName,
455
+ dnssecState: 'validated',
456
+ pkixValidationState: certificate.observation.pkixState,
457
+ records: records.map(({ data }) => ({ usage: data.usage, selector: data.selector, matchingType: data.matchingType, associationData: data.associationData.toString('hex') })),
458
+ certificateDerBase64: certificate.certificateDer?.toString('base64'),
459
+ spkiDerBase64: certificate.spkiDer?.toString('base64'),
460
+ });
461
+ return Object.freeze({
462
+ state: 'validated',
463
+ recordCount: records.length,
464
+ signatureState: 'validated',
465
+ dane,
466
+ limitations: Object.freeze([
467
+ 'TLSA records were validated through the separately reported DNSSEC chain before certificate comparison.',
468
+ ...(records.some((record) => record.data.usage === 2) ? [ACTIVE_DANE_TA_LIMITATION] : []),
469
+ 'A TLSA association match is endpoint evidence only and does not establish successful message delivery, ownership, safety, or maliciousness.',
470
+ ]),
471
+ });
472
+ }
473
+ catch (error) {
474
+ const timedOut = /timed out/iu.test(boundedError(error));
475
+ return unavailableTlsa(timedOut ? 'timed_out' : 'indeterminate', boundedError(error));
476
+ }
477
+ }
478
+ function addressObservation(selected, state) {
479
+ return Object.freeze({ value: selected?.address ?? null, family: selected?.family ?? null, state });
480
+ }
481
+ function addressAuthentication(selected) {
482
+ return selected
483
+ ? Object.freeze({
484
+ state: 'not_evaluated',
485
+ detail: 'The selected A or AAAA RRset and any CNAME chain were not cryptographically validated; public-address revalidation is a separate transport control.',
486
+ })
487
+ : Object.freeze({ state: 'unavailable', detail: 'No address candidate was retained for cryptographic authentication review.' });
488
+ }
489
+ function endpointLimitations(addressState) {
490
+ const addressLimitation = addressState === 'connected'
491
+ ? 'The endpoint observation is one point-in-time connection to one revalidated and pinned public address. Other addresses or later observations may differ.'
492
+ : 'The endpoint did not produce confirmed connection evidence. Its address field retains only the highest completed public selection or revalidation stage, if any.';
493
+ return Object.freeze([
494
+ addressLimitation,
495
+ 'Public-address validation, fresh revalidation, and connection pinning do not cryptographically authenticate the selected A or AAAA RRset or any CNAME chain.',
496
+ 'Only the SMTP greeting, EHLO capability names, and optional STARTTLS negotiation were observed. No authentication, recipient, mailbox, catch-all, relay, or message command was attempted.',
497
+ 'The raw SMTP greeting, reply text, certificate bytes, TLS session material, and DNS wire responses are not retained.',
498
+ 'Shared addresses, greeting digests, or certificate digests are exact relationship leads only; they do not establish a rogue server, common ownership, coordination, safety, or maliciousness.',
499
+ ]);
500
+ }
501
+ async function withTimeout(operation, timeoutMs, setTimer, clearTimer, onTimeout) {
502
+ let timer;
503
+ try {
504
+ return await Promise.race([
505
+ operation,
506
+ new Promise((_, reject) => {
507
+ timer = setTimer(() => {
508
+ onTimeout();
509
+ reject(new Error('SMTP endpoint review timed out.'));
510
+ }, timeoutMs);
511
+ }),
512
+ ]);
513
+ }
514
+ finally {
515
+ if (timer !== undefined)
516
+ clearTimer(timer);
517
+ }
518
+ }
519
+ function validatedResolution(value) {
520
+ if (!value || !Array.isArray(value.addresses) || !Array.isArray(value.aliases)
521
+ || value.addresses.length < 1 || value.addresses.length > MAX_MAIL_TRANSPORT_ADDRESS_CANDIDATES
522
+ || value.aliases.length > MAX_MAIL_TRANSPORT_DNS_ALIASES) {
523
+ throw new Error('Selected MX host returned an invalid or oversized resolution result.');
524
+ }
525
+ const addresses = value.addresses.map((entry) => {
526
+ if (!entry || net.isIP(entry.address) !== entry.family || isPrivateAddress(entry.address)) {
527
+ throw new Error('Selected MX host returned a private, reserved, or invalid address candidate.');
528
+ }
529
+ return Object.freeze({ address: entry.address.toLowerCase(), family: entry.family });
530
+ });
531
+ const aliases = value.aliases.map((entry) => normalizeDnsName(entry));
532
+ if (aliases.some((entry) => entry === null) || new Set(aliases).size !== aliases.length) {
533
+ throw new Error('Selected MX host returned an invalid DNS alias chain.');
534
+ }
535
+ return Object.freeze({ addresses, aliases: Object.freeze(aliases) });
536
+ }
537
+ function emptyCertificate() {
538
+ return Object.freeze({ state: 'unavailable', sha256: null, spkiSha256: null, protocol: null, cipherName: null, pkixState: 'unavailable', identityState: 'unavailable', authorizationError: null, identityError: null });
539
+ }
540
+ async function collectEndpoint(options) {
541
+ let initial = null;
542
+ let revalidated = null;
543
+ let dnssec = null;
544
+ let smtp = null;
545
+ let selected = null;
546
+ let addressState = 'unavailable';
547
+ let revalidationMatched = false;
548
+ let certificate = { observation: emptyCertificate(), certificateDer: null, spkiDer: null };
549
+ let stage = 'resolution';
550
+ try {
551
+ initial = validatedResolution(await options.resolveHost(options.session, options.host));
552
+ selected = initial.addresses[0] ?? null;
553
+ if (!selected || net.isIP(selected.address) !== selected.family || isPrivateAddress(selected.address)) {
554
+ throw new Error('Selected MX host returned no validated public address.');
555
+ }
556
+ addressState = 'selected';
557
+ stage = 'dnssec';
558
+ dnssec = await options.validateDnssec({ target: options.host, resolver: options.resolver, trustAnchor: options.anchor, observedAt: options.observedAt, ownedOrAuthorized: true, session: options.session });
559
+ stage = 'revalidation';
560
+ revalidated = validatedResolution(await options.resolveHost(options.session, options.host));
561
+ if (!revalidated.addresses.some((entry) => entry.address === selected?.address && entry.family === selected?.family)) {
562
+ throw new Error('Selected MX address changed during mandatory pre-connection revalidation.');
563
+ }
564
+ revalidationMatched = true;
565
+ addressState = 'revalidated';
566
+ stage = 'smtp';
567
+ const remainingMs = options.remainingMs();
568
+ if (remainingMs <= 0)
569
+ throw new Error('Mail transport total run timed out before the SMTP connection.');
570
+ const probeTimeout = Math.max(1, Math.min(SMTP_ENDPOINT_TIMEOUT_MS, remainingMs));
571
+ const controller = new AbortController();
572
+ try {
573
+ smtp = await withTimeout(options.probe({ hostname: options.host, address: selected.address, family: selected.family, timeoutMs: probeTimeout, signal: controller.signal }), probeTimeout, options.setTimer, options.clearTimer, () => controller.abort());
574
+ }
575
+ finally {
576
+ controller.abort();
577
+ }
578
+ if (smtp.connectedAddress !== selected.address)
579
+ throw new Error('SMTP connection did not use the selected pinned address.');
580
+ addressState = 'connected';
581
+ if (smtp.tls?.remoteAddress && smtp.tls.remoteAddress !== selected.address)
582
+ throw new Error('SMTP STARTTLS connection did not remain pinned to the selected address.');
583
+ stage = 'tlsa';
584
+ certificate = certificateObservation(options.host, smtp.tls);
585
+ const tlsa = await options.collectTlsaEvidence(options.session, options.host, dnssec, certificate, options.observedAt);
586
+ const smtpState = smtp.greeting.code === 220 && smtp.ehlo?.code === 250 ? 'observed' : 'rejected';
587
+ const tlsaComplete = !['indeterminate', 'timed_out', 'unavailable'].includes(tlsa.state)
588
+ && (tlsa.state !== 'validated' || !tlsa.dane || !['partial', 'unavailable', 'invalid'].includes(tlsa.dane.state));
589
+ const certificateComplete = smtp.starttlsState !== 'negotiated' || certificate.observation.state === 'observed';
590
+ const complete = smtpState === 'observed'
591
+ && dnssec.report.completeness === 'complete'
592
+ && tlsaComplete
593
+ && certificateComplete;
594
+ return Object.freeze({
595
+ host: options.host,
596
+ observedAt: options.observedAt,
597
+ state: complete ? 'complete' : 'partial',
598
+ address: addressObservation(selected, addressState),
599
+ addressAuthentication: addressAuthentication(selected),
600
+ resolution: Object.freeze({ state: 'public_revalidated', initialAddressCount: initial.addresses.length, revalidatedAddressCount: revalidated.addresses.length, aliasCount: Math.max(initial.aliases.length, revalidated.aliases.length) }),
601
+ smtp: Object.freeze({ state: smtpState, greetingCode: smtp.greeting.code, greetingSha256: smtp.greeting.sha256, capabilities: smtp.capabilities, starttlsAdvertised: smtp.starttlsAdvertised, responseBytes: smtp.bytesRead, responseLines: smtp.lineCount }),
602
+ starttls: Object.freeze({ state: smtp.starttlsState }),
603
+ certificate: certificate.observation,
604
+ dnssec: dnssec.report,
605
+ tlsa,
606
+ failure: null,
607
+ limitations: endpointLimitations(addressState),
608
+ });
609
+ }
610
+ catch (error) {
611
+ const detail = boundedError(error);
612
+ const timedOut = /timed out/iu.test(detail);
613
+ const connectedSmtp = addressState === 'connected' ? smtp : null;
614
+ const connected = connectedSmtp !== null;
615
+ const smtpState = connectedSmtp ? connectedSmtp.greeting.code === 220 && connectedSmtp.ehlo?.code === 250 ? 'observed' : 'rejected'
616
+ : timedOut && stage === 'smtp' ? 'timed_out' : 'unavailable';
617
+ return Object.freeze({
618
+ host: options.host,
619
+ observedAt: options.observedAt,
620
+ state: connected ? 'partial' : timedOut ? 'timed_out' : 'unavailable',
621
+ address: addressObservation(selected, addressState),
622
+ addressAuthentication: addressAuthentication(selected),
623
+ resolution: Object.freeze({ state: revalidationMatched ? 'public_revalidated' : timedOut && !initial ? 'timed_out' : 'failed', initialAddressCount: initial?.addresses.length ?? 0, revalidatedAddressCount: revalidated?.addresses.length ?? 0, aliasCount: Math.max(initial?.aliases.length ?? 0, revalidated?.aliases.length ?? 0) }),
624
+ smtp: Object.freeze({ state: smtpState, greetingCode: connectedSmtp?.greeting.code ?? null, greetingSha256: connectedSmtp?.greeting.sha256 ?? null, capabilities: connectedSmtp?.capabilities ?? Object.freeze([]), starttlsAdvertised: connectedSmtp?.starttlsAdvertised ?? null, responseBytes: connectedSmtp?.bytesRead ?? 0, responseLines: connectedSmtp?.lineCount ?? 0 }),
625
+ starttls: Object.freeze({ state: connectedSmtp?.starttlsState ?? (timedOut && stage === 'smtp' ? 'timed_out' : 'unavailable') }),
626
+ certificate: connected ? certificate.observation : emptyCertificate(),
627
+ dnssec: dnssec?.report ?? null,
628
+ tlsa: unavailableTlsa('unavailable', 'TLSA and DANE comparison was unavailable because endpoint collection did not complete.'),
629
+ failure: Object.freeze({ stage, detail }),
630
+ limitations: endpointLimitations(addressState),
631
+ });
632
+ }
633
+ }
634
+ function buildRelationships(endpoints) {
635
+ const definitions = [
636
+ ['connected_address', (endpoint) => endpoint.address.state === 'connected' ? endpoint.address.value : null],
637
+ ['greeting_sha256', (endpoint) => endpoint.smtp.greetingSha256],
638
+ ['certificate_sha256', (endpoint) => endpoint.certificate.sha256],
639
+ ];
640
+ const relationships = [];
641
+ for (const [basis, valueFor] of definitions) {
642
+ const groups = new Map();
643
+ for (const endpoint of endpoints) {
644
+ const value = valueFor(endpoint);
645
+ if (!value)
646
+ continue;
647
+ const hosts = groups.get(value) ?? [];
648
+ hosts.push(endpoint.host);
649
+ groups.set(value, hosts);
650
+ }
651
+ for (const [value, hosts] of groups) {
652
+ const unique = [...new Set(hosts)].sort();
653
+ if (unique.length < 2)
654
+ continue;
655
+ relationships.push(Object.freeze({
656
+ basis,
657
+ value,
658
+ endpoints: Object.freeze(unique),
659
+ interpretation: 'review_lead',
660
+ limitation: 'An exact shared transport value is a review lead only and does not establish a rogue endpoint, ownership, coordination, intent, safety, or maliciousness.',
661
+ }));
662
+ }
663
+ }
664
+ return Object.freeze(relationships.sort((left, right) => left.basis.localeCompare(right.basis) || left.value.localeCompare(right.value)));
665
+ }
666
+ async function collectMailTransportReview(inputValue, options, dependencies = {}) {
667
+ if (options.ownedOrAuthorized !== true || options.activeProbeAcknowledged !== true) {
668
+ throw new TypeError('Mail transport review requires explicit owned-or-authorised scope and active-probing acknowledgement for every run.');
669
+ }
670
+ const input = parseMailTransportInput(inputValue);
671
+ const resolver = normalizeResolverEndpoint(options.resolver);
672
+ if (!resolver)
673
+ throw new TypeError('Mail transport review requires one analyst-selected public resolver IP address.');
674
+ const anchor = parseDnssecTrustAnchor(options.trustAnchor);
675
+ const now = dependencies.now ?? Date.now;
676
+ const observedAt = dependencies.observedAt ?? (() => new Date().toISOString());
677
+ const setTimer = dependencies.setTimer ?? ((callback, milliseconds) => setTimeout(callback, milliseconds));
678
+ const clearTimer = dependencies.clearTimer ?? ((handle) => clearTimeout(handle));
679
+ const started = now();
680
+ const generatedAt = timestamp(observedAt(), 'generatedAt');
681
+ const session = new DnssecQuerySession({
682
+ resolver,
683
+ totalTimeoutMs: MAIL_TRANSPORT_TOTAL_TIMEOUT_MS,
684
+ ...dependencies.dnsSessionOptions,
685
+ });
686
+ const endpoints = [];
687
+ for (const host of input.mxHosts) {
688
+ const remainingMs = MAIL_TRANSPORT_TOTAL_TIMEOUT_MS - Math.max(0, now() - started);
689
+ if (remainingMs <= 0) {
690
+ endpoints.push(Object.freeze({
691
+ host, observedAt: timestamp(observedAt(), 'observedAt'), state: 'timed_out',
692
+ address: addressObservation(null, 'unavailable'), addressAuthentication: addressAuthentication(null),
693
+ resolution: Object.freeze({ state: 'timed_out', initialAddressCount: 0, revalidatedAddressCount: 0, aliasCount: 0 }),
694
+ smtp: Object.freeze({ state: 'timed_out', greetingCode: null, greetingSha256: null, capabilities: Object.freeze([]), starttlsAdvertised: null, responseBytes: 0, responseLines: 0 }),
695
+ starttls: Object.freeze({ state: 'timed_out' }), certificate: emptyCertificate(), dnssec: null,
696
+ tlsa: unavailableTlsa('timed_out', 'The total mail-transport run limit expired before this endpoint was queried.'),
697
+ failure: Object.freeze({ stage: 'total_run', detail: 'Mail transport total run timed out.' }), limitations: endpointLimitations('unavailable'),
698
+ }));
699
+ continue;
700
+ }
701
+ endpoints.push(await collectEndpoint({
702
+ host,
703
+ resolver: resolver.address,
704
+ anchor,
705
+ session,
706
+ probe: dependencies.probe ?? defaultSmtpProbe,
707
+ resolveHost: dependencies.resolveHost ?? resolveSelectedHost,
708
+ validateDnssec: dependencies.validateDnssec ?? validateDnssecChainWithContext,
709
+ collectTlsaEvidence: dependencies.collectTlsaEvidence ?? collectTlsa,
710
+ observedAt: timestamp(observedAt(), 'observedAt'),
711
+ remainingMs: () => MAIL_TRANSPORT_TOTAL_TIMEOUT_MS - Math.max(0, now() - started),
712
+ setTimer,
713
+ clearTimer,
714
+ }));
715
+ }
716
+ const runState = endpoints.every((endpoint) => endpoint.state === 'complete') ? 'complete'
717
+ : endpoints.some((endpoint) => endpoint.state === 'complete' || endpoint.state === 'partial') ? 'partial' : 'unavailable';
718
+ return Object.freeze({
719
+ schema: MAIL_TRANSPORT_REVIEW_SCHEMA,
720
+ version: MAIL_TRANSPORT_REVIEW_VERSION,
721
+ generatedAt,
722
+ domain: input.domain,
723
+ runState,
724
+ authorization: Object.freeze({ ownedOrAuthorized: true, activeProbeAcknowledged: true }),
725
+ resolver: Object.freeze({ address: resolver.address, port: 53 }),
726
+ trustAnchor: Object.freeze({ zone: anchor.zone, source: anchor.source, reviewedAt: anchor.reviewedAt }),
727
+ policyContext: input.policyContext,
728
+ endpoints: Object.freeze(endpoints),
729
+ relationships: buildRelationships(endpoints),
730
+ bounds: Object.freeze({
731
+ targets: endpoints.length,
732
+ targetLimit: MAX_MAIL_TRANSPORT_TARGETS,
733
+ concurrency: MAX_MAIL_TRANSPORT_CONCURRENCY,
734
+ dnsQueries: session.queryCount,
735
+ dnsQueryLimit: session.maximumQueries,
736
+ dnsResponseBytes: session.responseBytes,
737
+ dnsResponseByteLimit: session.maximumResponseBytes,
738
+ smtpResponseByteLimitPerTarget: MAX_SMTP_RESPONSE_BYTES,
739
+ smtpReplyLineLimitPerTarget: MAX_SMTP_REPLY_LINES,
740
+ smtpLineByteLimit: MAX_SMTP_LINE_BYTES,
741
+ smtpCapabilityLimitPerTarget: MAX_SMTP_CAPABILITIES,
742
+ addressCandidateLimitPerResolution: MAX_MAIL_TRANSPORT_ADDRESS_CANDIDATES,
743
+ dnsAliasLimitPerResolution: MAX_MAIL_TRANSPORT_DNS_ALIASES,
744
+ certificateByteLimitPerTarget: MAX_SMTP_CERTIFICATE_BYTES,
745
+ connectionsPerTarget: 1,
746
+ endpointTimeoutMs: SMTP_ENDPOINT_TIMEOUT_MS,
747
+ totalTimeoutMs: MAIL_TRANSPORT_TOTAL_TIMEOUT_MS,
748
+ retries: 0,
749
+ }),
750
+ limitations: Object.freeze([
751
+ 'This command performs active network collection only for the selected MX hosts in this explicitly authorised run. It is not invoked by Lookup, Bulk, monitoring, or automatic recipes.',
752
+ 'DNSSEC, TLSA or DANE, PKIX, certificate identity, STARTTLS, SMTP transport, MTA-STS context, and TLS-RPT context retain separate states and provenance. One family never supplies or upgrades another family.',
753
+ ACTIVE_DANE_TA_LIMITATION,
754
+ 'Public-address validation, fresh resolution, and connection pinning are reported separately from DNSSEC. They do not cryptographically authenticate the selected A or AAAA RRset or any CNAME chain.',
755
+ 'No message is sent; no authentication, relay, recipient, mailbox, user, or catch-all test is performed; and no retry is attempted.',
756
+ 'MTA-STS and TLS-RPT fields are bounded supplied context only. This action does not fetch an MTA-STS policy or a reporting-provider record.',
757
+ 'The review does not affect Risk, Opportunity, registration availability, ownership, activity, safety, or maliciousness.',
758
+ ]),
759
+ });
760
+ }
761
+ function formatMailTransportReview(review) {
762
+ const lines = [
763
+ 'Authorised mail transport review',
764
+ `Domain ${review.domain}`,
765
+ `State ${review.runState}`,
766
+ `Resolver ${review.resolver.address}:${review.resolver.port}`,
767
+ `Targets ${review.endpoints.length}/${review.bounds.targetLimit}`,
768
+ '',
769
+ 'Selected MX endpoints:',
770
+ ];
771
+ for (const endpoint of review.endpoints) {
772
+ lines.push(` - ${endpoint.host}: ${endpoint.state}`, ` address=${endpoint.address.value ?? 'unavailable'} address_state=${endpoint.address.state} address_authentication=${endpoint.addressAuthentication.state} smtp=${endpoint.smtp.state} starttls=${endpoint.starttls.state} dnssec_chain=${endpoint.dnssec?.state ?? 'unavailable'} tlsa=${endpoint.tlsa.state} pkix=${endpoint.certificate.pkixState}`);
773
+ if (endpoint.failure)
774
+ lines.push(` failure=${endpoint.failure.stage}: ${endpoint.failure.detail}`);
775
+ }
776
+ if (review.relationships.length) {
777
+ lines.push('', 'Exact relationship leads:');
778
+ for (const relationship of review.relationships) {
779
+ lines.push(` - ${relationship.basis}: ${relationship.endpoints.join(', ')}`);
780
+ }
781
+ }
782
+ lines.push('', 'Limitations:');
783
+ for (const limitation of review.limitations)
784
+ lines.push(` - ${limitation}`);
785
+ return `${lines.join('\n')}\n`;
786
+ }
787
+ export { EHLO_COMMAND, MAIL_TRANSPORT_INPUT_SCHEMA, MAIL_TRANSPORT_INPUT_VERSION, MAIL_TRANSPORT_REVIEW_SCHEMA, MAIL_TRANSPORT_REVIEW_VERSION, MAIL_TRANSPORT_TOTAL_TIMEOUT_MS, MAX_MAIL_TRANSPORT_CONCURRENCY, MAX_MAIL_TRANSPORT_ADDRESS_CANDIDATES, MAX_MAIL_TRANSPORT_DNS_ALIASES, MAX_MAIL_TRANSPORT_INPUT_BYTES, MAX_MAIL_TRANSPORT_TARGETS, MAX_SMTP_CAPABILITIES, MAX_SMTP_LINE_BYTES, MAX_SMTP_REPLY_LINES, MAX_SMTP_RESPONSE_BYTES, SMTP_ENDPOINT_TIMEOUT_MS, STARTTLS_COMMAND, collectMailTransportReview, certificateObservation, formatMailTransportReview, normalizeSmtpReply, parseMailTransportInput, resolveSelectedHost, runSmtpConversation, smtpStartTlsOptions, smtpCapabilities, };