@slicedearth/whoisleuth-cli 1.45.0 → 1.47.4

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 (133) hide show
  1. package/DISCLOSURE +7 -3
  2. package/NOTICE +5 -4
  3. package/README.md +59 -6
  4. package/bin/whoisleuth.mjs +5 -3
  5. package/cli/arguments.mjs +256 -9
  6. package/cli/artifact-structure.mjs +1833 -0
  7. package/cli/artifact-verify.mjs +242 -49
  8. package/cli/bounded-json.mjs +1 -0
  9. package/cli/bulk-checkpoint.mjs +2 -3
  10. package/cli/bulk-command-runner.mjs +2 -5
  11. package/cli/case-pack.mjs +819 -34
  12. package/cli/command-reference.mjs +48 -24
  13. package/cli/completion.mjs +16 -8
  14. package/cli/ct-event-intake.mjs +32 -2
  15. package/cli/discovery-scan-command-runner.mjs +4 -5
  16. package/cli/discovery-workflow.mjs +2 -3
  17. package/cli/doctor.mjs +2 -2
  18. package/cli/domain-control-monitor.mjs +21 -15
  19. package/cli/domain-control-observations.mjs +25 -2
  20. package/cli/evidence-command-runner.mjs +17 -11
  21. package/cli/evidence-signing.mjs +73 -45
  22. package/cli/export-evidence.mjs +11 -4
  23. package/cli/formatters/html.mjs +3 -3
  24. package/cli/formatters/terminal.mjs +3 -0
  25. package/cli/input.mjs +57 -0
  26. package/cli/interchange-report.mjs +226 -0
  27. package/cli/investigation-manifest.mjs +16 -17
  28. package/cli/investigation-run.mjs +71 -23
  29. package/cli/local-mmdb-review.mjs +45 -14
  30. package/cli/lookup-command-runner.mjs +2 -2
  31. package/cli/lookup-diff.mjs +9 -0
  32. package/cli/network-command-runner.mjs +78 -0
  33. package/cli/offline-evidence-review.mjs +28 -8
  34. package/cli/page-compare.mjs +8 -5
  35. package/cli/registry-cohort.mjs +385 -61
  36. package/cli/registry-support.mjs +2 -1
  37. package/cli/retained-artifact-diff.mjs +376 -0
  38. package/cli/risk-calibration.mjs +7 -5
  39. package/cli/runner.mjs +147 -185
  40. package/cli/saved-lookup.mjs +26 -3
  41. package/cli/sharing-review.mjs +12 -6
  42. package/docs/cli-reference.md +444 -70
  43. package/docs/cli.md +24 -5
  44. package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
  45. package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
  46. package/frontend/src/lib/analysis/brand-profile-model.js +29 -5
  47. package/frontend/src/lib/analysis/bulk-domain-comparison.js +25 -5
  48. package/frontend/src/lib/analysis/bulk-mail-exposure.js +66 -15
  49. package/frontend/src/lib/analysis/bulk-retry-plan.js +1 -1
  50. package/frontend/src/lib/analysis/bulk-review-export.js +7 -6
  51. package/frontend/src/lib/analysis/bulk-review-model.js +3 -2
  52. package/frontend/src/lib/analysis/bulk-session-model.js +192 -26
  53. package/frontend/src/lib/analysis/bulk-sort.js +4 -0
  54. package/frontend/src/lib/analysis/case-brand-profile-references.js +85 -0
  55. package/frontend/src/lib/analysis/case-evidence-model.js +8 -0
  56. package/frontend/src/lib/analysis/case-investigation-branch-model.js +134 -0
  57. package/frontend/src/lib/analysis/case-migration-model.js +83 -31
  58. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  59. package/frontend/src/lib/analysis/case-record-model.js +2 -0
  60. package/frontend/src/lib/analysis/case-record-operations.js +42 -12
  61. package/frontend/src/lib/analysis/case-report.js +39 -6
  62. package/frontend/src/lib/analysis/case-response-model.js +66 -7
  63. package/frontend/src/lib/analysis/case-response-packet.js +15 -5
  64. package/frontend/src/lib/analysis/comparison-ledger-bulk.js +366 -0
  65. package/frontend/src/lib/analysis/comparison-ledger-contract.js +241 -0
  66. package/frontend/src/lib/analysis/comparison-ledger-json.js +79 -0
  67. package/frontend/src/lib/analysis/comparison-ledger-serialization.js +51 -0
  68. package/frontend/src/lib/analysis/domain-control-manifest-core.js +145 -0
  69. package/frontend/src/lib/analysis/domain-control-records.js +88 -0
  70. package/frontend/src/lib/analysis/external-findings-import.js +70 -6
  71. package/frontend/src/lib/analysis/investigation-capsule.js +47 -13
  72. package/frontend/src/lib/analysis/investigation-guide.js +52 -10
  73. package/frontend/src/lib/analysis/investigation-template-model.js +9 -6
  74. package/frontend/src/lib/analysis/lookup-claim-passport.js +236 -0
  75. package/frontend/src/lib/analysis/lookup-claim-readiness.js +280 -0
  76. package/frontend/src/lib/analysis/lookup-decision-support.js +61 -6
  77. package/frontend/src/lib/analysis/lookup-presentation.js +62 -0
  78. package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
  79. package/frontend/src/lib/analysis/utils.js +26 -3
  80. package/frontend/src/lib/analysis/visualization-matrix.js +71 -28
  81. package/frontend/src/lib/analysis/visualization-models.js +63 -94
  82. package/frontend/src/lib/analysis/visualization-time-series.js +53 -12
  83. package/frontend/src/lib/analysis/website-snapshot-model.js +6 -0
  84. package/frontend/src/lib/analysis/workspace-archive.js +104 -14
  85. package/lib/application-version.mjs +3 -0
  86. package/lib/availability.mjs +14 -12
  87. package/lib/bounded-file.mjs +13 -0
  88. package/lib/bounded-json-response.mjs +17 -4
  89. package/lib/bounded-json.mjs +249 -0
  90. package/lib/certificate-extension-profile.mjs +2 -1
  91. package/lib/cryptographic-assurance.mjs +155 -0
  92. package/lib/ct-search.mjs +87 -47
  93. package/lib/dns-intelligence.mjs +3 -2
  94. package/lib/dns-resolver-selection.mjs +6 -4
  95. package/lib/dnssec-chain-validation.mjs +1053 -0
  96. package/lib/dnssec-evidence-validation.mjs +10 -3
  97. package/lib/domain-change-packet.mjs +7 -5
  98. package/lib/domain-control-manifest.mjs +17 -129
  99. package/lib/domain-posture.mjs +7 -4
  100. package/lib/evidence-export.mjs +751 -78
  101. package/lib/evidence-report-markdown.mjs +3 -0
  102. package/lib/evidence-report.mjs +5 -0
  103. package/lib/favicon.mjs +8 -5
  104. package/lib/http-evidence-bounds.mjs +3 -0
  105. package/lib/http-intelligence.mjs +2 -5
  106. package/lib/interchange-fidelity-registry.mjs +122 -0
  107. package/lib/local-geoip-evidence.mjs +2 -1
  108. package/lib/lookup-network-evidence-bounds.mjs +9 -0
  109. package/lib/lookup-response-contract.mjs +691 -3
  110. package/lib/lookup.mjs +2 -2
  111. package/lib/observation.mjs +7 -6
  112. package/lib/outbound-identity.mjs +2 -3
  113. package/lib/perceptual-hash.mjs +12 -4
  114. package/lib/portable-generator.mjs +21 -0
  115. package/lib/provider-policy-admission.mjs +9 -4
  116. package/lib/rdap-normalization.mjs +18 -5
  117. package/lib/rdap-registrar.mjs +5 -1
  118. package/lib/registry-capabilities.mjs +3 -0
  119. package/lib/registry-capability-catalogue.mjs +12 -2
  120. package/lib/registry-comparison.mjs +28 -11
  121. package/lib/risk-calibration-summary.mjs +338 -0
  122. package/lib/rpki-evidence.mjs +9 -8
  123. package/lib/safe-fetch.mjs +59 -10
  124. package/lib/security-txt.mjs +26 -3
  125. package/lib/service-binding-dns.mjs +6 -1
  126. package/lib/smtp-transport-review.mjs +784 -0
  127. package/lib/sslbl-certificates.generated.mjs +136 -134
  128. package/lib/threat-intelligence-runtime.mjs +3 -11
  129. package/lib/threat-intelligence-types.mjs +18 -1
  130. package/lib/tls-intelligence.mjs +11 -5
  131. package/lib/tlsa-evidence.mjs +37 -7
  132. package/package.json +1 -1
  133. package/third-party-notices.txt +95 -5817
@@ -160,7 +160,8 @@ function validateDnssecEvidence(input) {
160
160
  limitations: Object.freeze(limitations),
161
161
  });
162
162
  }
163
- if (input.delegationSigned === false && dsRecords.length === 0) {
163
+ const incompleteInput = rejectedCount > 0 || truncated;
164
+ if (input.delegationSigned === false && dsRecords.length === 0 && !incompleteInput) {
164
165
  findings.push('The supplied delegation state was unsigned and no DS records were supplied.');
165
166
  return Object.freeze({
166
167
  schema: DNSSEC_EVIDENCE_SCHEMA,
@@ -191,18 +192,24 @@ function validateDnssecEvidence(input) {
191
192
  if (matched)
192
193
  matchedDsCount += 1;
193
194
  }
194
- const definiteConflict = input.delegationSigned === false && dsRecords.length > 0
195
+ const observedConflict = input.delegationSigned === false && dsRecords.length > 0
195
196
  || input.delegationSigned === true && dsRecords.length === 0 && !truncated
196
197
  || signatureState === 'outside_window'
197
198
  || (dsRecords.length > 0 && dnskeys.length > 0 && matchedDsCount === 0 && unsupportedDigestCount < dsRecords.length);
199
+ const definiteConflict = observedConflict && !incompleteInput;
198
200
  if (definiteConflict)
199
201
  findings.push('The supplied delegation, signature-time, DS, or DNSKEY evidence conflicts.');
202
+ else if (observedConflict)
203
+ findings.push('The retained subset contains conflicting evidence, but omitted or rejected records prevent a definitive conflict conclusion.');
200
204
  if (matchedDsCount > 0)
201
205
  findings.push(`${matchedDsCount} supplied DS record(s) matched a supplied DNSKEY.`);
202
206
  if (unsupportedDigestCount > 0)
203
207
  findings.push(`${unsupportedDigestCount} DS digest type(s) were retained as unsupported.`);
204
208
  if (signatureState === 'mixed')
205
209
  findings.push('The supplied RRSIG validity windows were mixed at the observation time.');
210
+ if (input.delegationSigned === false && dsRecords.length === 0 && incompleteInput) {
211
+ findings.push('The retained subset contains no usable DS record, but omitted or rejected records prevent a definitive unsigned conclusion.');
212
+ }
206
213
  if (rejectedCount > 0)
207
214
  findings.push(`${rejectedCount} malformed record(s) were rejected.`);
208
215
  if (truncated)
@@ -210,7 +217,7 @@ function validateDnssecEvidence(input) {
210
217
  const completeLocalRelationship = dsRecords.length > 0
211
218
  && dnskeys.length > 0
212
219
  && matchedDsCount > 0
213
- && !definiteConflict
220
+ && !observedConflict
214
221
  && rejectedCount === 0
215
222
  && !truncated
216
223
  && unsupportedDigestCount === 0
@@ -1,10 +1,11 @@
1
1
  import { exactKeys, requireBoundedString, requireDomainName, requireIsoTimestamp, requireRecord, } from "./bounded-contract-normalizers.mjs";
2
2
  import { DOMAIN_ASSURANCE_INPUT_SCHEMA, buildDomainAssurance, } from "./domain-assurance.mjs";
3
3
  import { DOMAIN_CHANGE_INPUT_SCHEMA, reviewDomainChange, } from "./domain-change-review.mjs";
4
- import { sha256ArtifactDigest } from "../frontend/src/lib/analysis/artifact-integrity.js";
4
+ import { SORTED_JSON_V2, sha256ArtifactDigestV2 } from "../frontend/src/lib/analysis/artifact-integrity.js";
5
5
  export const DOMAIN_CHANGE_PACKET_INPUT_SCHEMA = 'whoisleuth.domain-change-packet.input';
6
6
  export const DOMAIN_CHANGE_PACKET_SCHEMA = 'whoisleuth.domain-change-packet';
7
- export const DOMAIN_CHANGE_PACKET_VERSION = 1;
7
+ export const DOMAIN_CHANGE_PACKET_INPUT_VERSION = 1;
8
+ export const DOMAIN_CHANGE_PACKET_VERSION = 2;
8
9
  export const MAX_DOMAIN_CHANGE_PACKET_INPUT_BYTES = 6 * 1024 * 1024;
9
10
  const ROOT_KEYS = new Set([
10
11
  'schema',
@@ -57,8 +58,8 @@ function changeSummary(before, after) {
57
58
  }
58
59
  export async function buildDomainChangePacket(inputRaw, generatedAtValue = new Date().toISOString()) {
59
60
  const input = record(inputRaw, 'Domain change packet input');
60
- if (input.schema !== DOMAIN_CHANGE_PACKET_INPUT_SCHEMA || input.version !== 1) {
61
- throw new TypeError(`Domain change packet input must use ${DOMAIN_CHANGE_PACKET_INPUT_SCHEMA} version 1.`);
61
+ if (input.schema !== DOMAIN_CHANGE_PACKET_INPUT_SCHEMA || input.version !== DOMAIN_CHANGE_PACKET_INPUT_VERSION) {
62
+ throw new TypeError(`Domain change packet input must use ${DOMAIN_CHANGE_PACKET_INPUT_SCHEMA} version ${DOMAIN_CHANGE_PACKET_INPUT_VERSION}.`);
62
63
  }
63
64
  exactKeys(input, ROOT_KEYS, 'Domain change packet input');
64
65
  const domain = requireDomainName(input.domain, 'domain');
@@ -105,7 +106,8 @@ export async function buildDomainChangePacket(inputRaw, generatedAtValue = new D
105
106
  ...unsigned,
106
107
  integrity: Object.freeze({
107
108
  algorithm: 'SHA-256',
108
- digestSha256: await sha256ArtifactDigest(unsigned),
109
+ canonicalization: SORTED_JSON_V2,
110
+ digestSha256: await sha256ArtifactDigestV2(unsigned),
109
111
  }),
110
112
  });
111
113
  }
@@ -1,27 +1,21 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { domainToASCII } from 'node:url';
3
- import { canonicalArtifactJson } from "../frontend/src/lib/analysis/artifact-integrity.js";
3
+ import { canonicalArtifactJsonV2, SORTED_JSON_V2, } from "../frontend/src/lib/analysis/artifact-integrity.js";
4
+ import { buildUnsignedDomainControlPassport, DOMAIN_CONTROL_PASSPORT_INPUT_SCHEMA, DOMAIN_CONTROL_MANIFEST_VERSION, DOMAIN_CONTROL_PASSPORT_SCHEMA, MAX_DOMAIN_CONTROL_PASSPORT_ENTRIES, normalizeDomainControlPassportDocument, } from "../frontend/src/lib/analysis/domain-control-manifest-core.js";
5
+ import { canonicalDomainControlRecordList } from "../frontend/src/lib/analysis/domain-control-records.js";
4
6
  import { exactKeys } from "./bounded-contract-normalizers.mjs";
5
- export const DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA = 'whoisleuth.domain-control-manifest-input';
6
- export const DOMAIN_CONTROL_MANIFEST_SCHEMA = 'whoisleuth.domain-control-manifest';
7
- export const DOMAIN_CONTROL_MANIFEST_VERSION = 1;
7
+ export const DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA = DOMAIN_CONTROL_PASSPORT_INPUT_SCHEMA;
8
+ export const DOMAIN_CONTROL_MANIFEST_SCHEMA = DOMAIN_CONTROL_PASSPORT_SCHEMA;
9
+ export { DOMAIN_CONTROL_MANIFEST_VERSION };
8
10
  export const DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA = 'whoisleuth.domain-control-review-input';
9
11
  export const DOMAIN_CONTROL_REVIEW_SCHEMA = 'whoisleuth.domain-control-review';
10
12
  export const DOMAIN_CONTROL_REVIEW_VERSION = 1;
11
- export const MAX_DOMAIN_CONTROL_ENTRIES = 100;
13
+ export const MAX_DOMAIN_CONTROL_ENTRIES = MAX_DOMAIN_CONTROL_PASSPORT_ENTRIES;
12
14
  export const MAX_DOMAIN_CONTROL_RECORDS = 32;
13
- const MANIFEST_KEYS = new Set(['schema', 'version', 'generatedAt', 'expiresAt', 'entries', 'limitations', 'integrity']);
14
- const MANIFEST_ENTRY_KEYS = new Set(['domain', 'nameservers', 'ds', 'mx', 'caa', 'tlsIssuer', 'tlsSpkiSha256', 'registrarLock', 'renewalReviewAt', 'note']);
15
- const MANIFEST_INTEGRITY_KEYS = new Set(['algorithm', 'canonicalization', 'digestSha256']);
16
- const MANIFEST_INPUT_KEYS = new Set(['schema', 'version', 'expiresAt', 'entries']);
17
15
  const REVIEW_INPUT_KEYS = new Set(['schema', 'version', 'manifest', 'observations']);
18
16
  const OBSERVATION_KEYS = new Set(['domain', 'fields']);
19
17
  const OBSERVATION_FIELDS = new Set(['nameservers', 'ds', 'mx', 'caa', 'tlsIssuer', 'tlsSpkiSha256', 'registrarLock']);
20
18
  const OBSERVATION_FIELD_KEYS = new Set(['state', 'values', 'source', 'observedAt']);
21
- const DOMAIN_CONTROL_LIMITATIONS = Object.freeze([
22
- 'This analyst-authored manifest records intended domain-control state. It does not collect evidence or change registrar, DNS, mail, or certificate configuration.',
23
- 'Empty desired fields are unconfigured rather than claims that a record should be absent.',
24
- ]);
25
19
  function record(value) {
26
20
  return value && typeof value === 'object' && !Array.isArray(value)
27
21
  ? value
@@ -70,135 +64,29 @@ function spkiFingerprint(value) {
70
64
  const text = boundedText(value, 64)?.toLowerCase();
71
65
  return text && /^[a-f0-9]{64}$/u.test(text) ? text : null;
72
66
  }
73
- function normalizeEntry(value) {
74
- const source = record(value);
75
- const normalizedDomain = domain(source?.domain);
76
- if (!source || !normalizedDomain)
77
- return null;
78
- const registrarLock = source.registrarLock === 'required' || source.registrarLock === 'not_required'
79
- ? source.registrarLock
80
- : null;
81
- return Object.freeze({
82
- domain: normalizedDomain,
83
- nameservers: Object.freeze(hostnameList(source.nameservers)),
84
- ds: Object.freeze(textList(source.ds, 500)),
85
- mx: Object.freeze(hostnameList(source.mx)),
86
- caa: Object.freeze(textList(source.caa, 500)),
87
- tlsIssuer: boundedText(source.tlsIssuer, 300)?.toLowerCase() ?? null,
88
- tlsSpkiSha256: spkiFingerprint(source.tlsSpkiSha256),
89
- registrarLock,
90
- renewalReviewAt: timestamp(source.renewalReviewAt),
91
- note: boundedText(source.note, 500),
92
- });
93
- }
94
- function unsignedManifest(value) {
95
- return value;
96
- }
97
- function integrityDigest(value) {
98
- return `sha256:${createHash('sha256').update(canonicalArtifactJson(value)).digest('hex')}`;
99
- }
100
67
  export function buildDomainControlManifest(input, generatedAtValue = new Date().toISOString()) {
101
- const source = record(input);
102
- if (!source || source.schema !== DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA || source.version !== 1) {
103
- throw new TypeError(`Domain control manifest input must use ${DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA} version 1.`);
104
- }
105
- exactKeys(source, MANIFEST_INPUT_KEYS, 'Domain control manifest input');
106
- const generatedAt = timestamp(generatedAtValue);
107
- const expiresAt = timestamp(source.expiresAt);
108
- if (!generatedAt || !expiresAt || Date.parse(expiresAt) <= Date.parse(generatedAt)) {
109
- throw new TypeError('Domain control manifest expiry must be a valid time after generation.');
110
- }
111
- if (!Array.isArray(source.entries) || source.entries.length < 1 || source.entries.length > MAX_DOMAIN_CONTROL_ENTRIES) {
112
- throw new TypeError(`Domain control manifest input must contain between 1 and ${MAX_DOMAIN_CONTROL_ENTRIES} entries.`);
113
- }
114
- const entries = source.entries.map((entry, index) => {
115
- exactKeys(entry, MANIFEST_ENTRY_KEYS, `Domain control manifest input entry ${index + 1}`);
116
- return normalizeEntry(entry);
117
- });
118
- if (entries.some((entry) => entry === null))
119
- throw new TypeError('Domain control manifest contains an invalid entry.');
120
- const normalizedEntries = entries;
121
- const domains = new Set(normalizedEntries.map((entry) => entry.domain));
122
- if (domains.size !== normalizedEntries.length)
123
- throw new TypeError('Domain control manifest entries must use unique domains.');
124
- const base = Object.freeze({
125
- schema: DOMAIN_CONTROL_MANIFEST_SCHEMA,
126
- version: DOMAIN_CONTROL_MANIFEST_VERSION,
127
- generatedAt,
128
- expiresAt,
129
- entries: Object.freeze([...normalizedEntries].sort((left, right) => left.domain.localeCompare(right.domain))),
130
- limitations: DOMAIN_CONTROL_LIMITATIONS,
131
- });
68
+ const unsigned = buildUnsignedDomainControlPassport(input, generatedAtValue);
132
69
  return Object.freeze({
133
- ...base,
70
+ ...unsigned,
134
71
  integrity: Object.freeze({
135
72
  algorithm: 'SHA-256',
136
- canonicalization: 'sorted-json-v1',
137
- digestSha256: integrityDigest(unsignedManifest(base)),
73
+ canonicalization: SORTED_JSON_V2,
74
+ digestSha256: `sha256:${createHash('sha256').update(canonicalArtifactJsonV2(unsigned)).digest('hex')}`,
138
75
  }),
139
76
  });
140
77
  }
141
78
  export function verifyDomainControlManifest(value) {
142
- const source = record(value);
143
- const integrity = record(source?.integrity);
144
- if (!source
145
- || source.schema !== DOMAIN_CONTROL_MANIFEST_SCHEMA
146
- || source.version !== DOMAIN_CONTROL_MANIFEST_VERSION
147
- || !Array.isArray(source.entries)
148
- || source.entries.length < 1
149
- || source.entries.length > MAX_DOMAIN_CONTROL_ENTRIES
150
- || !Array.isArray(source.limitations)
151
- || source.limitations.length !== DOMAIN_CONTROL_LIMITATIONS.length
152
- || source.limitations.some((limitation, index) => limitation !== DOMAIN_CONTROL_LIMITATIONS[index])
153
- || !integrity
154
- || integrity.algorithm !== 'SHA-256'
155
- || integrity.canonicalization !== 'sorted-json-v1'
156
- || typeof integrity.digestSha256 !== 'string'
157
- || !/^sha256:[a-f0-9]{64}$/u.test(integrity.digestSha256)) {
158
- throw new TypeError('Domain control manifest has an unsupported or malformed structure.');
159
- }
160
- exactKeys(source, MANIFEST_KEYS, 'Domain control manifest');
161
- exactKeys(integrity, MANIFEST_INTEGRITY_KEYS, 'Domain control manifest integrity');
162
- for (const [index, entry] of source.entries.entries()) {
163
- exactKeys(entry, MANIFEST_ENTRY_KEYS, `Domain control manifest entry ${index + 1}`);
164
- }
165
- const generatedAt = timestamp(source.generatedAt);
166
- const expiresAt = timestamp(source.expiresAt);
167
- const entries = source.entries.map(normalizeEntry);
168
- if (!generatedAt || !expiresAt || Date.parse(expiresAt) <= Date.parse(generatedAt) || entries.some((entry) => entry === null)) {
169
- throw new TypeError('Domain control manifest has invalid normalised content.');
170
- }
171
- const normalizedEntries = entries;
172
- if (new Set(normalizedEntries.map((entry) => entry.domain)).size !== normalizedEntries.length) {
173
- throw new TypeError('Domain control manifest entries must use unique domains.');
174
- }
175
- const { integrity: _integrity, ...unsigned } = source;
176
- const canonical = Object.freeze({
177
- schema: DOMAIN_CONTROL_MANIFEST_SCHEMA,
178
- version: DOMAIN_CONTROL_MANIFEST_VERSION,
179
- generatedAt,
180
- expiresAt,
181
- entries: Object.freeze([...normalizedEntries].sort((left, right) => left.domain.localeCompare(right.domain))),
182
- limitations: DOMAIN_CONTROL_LIMITATIONS,
183
- });
184
- let suppliedCanonical;
185
- try {
186
- suppliedCanonical = canonicalArtifactJson(unsigned);
187
- }
188
- catch {
189
- throw new TypeError('Domain control manifest has invalid normalised content.');
190
- }
191
- if (suppliedCanonical !== canonicalArtifactJson(canonical)) {
192
- throw new TypeError('Domain control manifest must use its canonical normalised content.');
193
- }
194
- if (`sha256:${createHash('sha256').update(suppliedCanonical).digest('hex')}` !== integrity.digestSha256) {
79
+ const normalized = normalizeDomainControlPassportDocument(value);
80
+ if (`sha256:${createHash('sha256').update(normalized.canonicalUnsigned).digest('hex')}` !== normalized.manifest.integrity.digestSha256) {
195
81
  throw new TypeError('Domain control manifest failed its SHA-256 integrity check.');
196
82
  }
197
- return source;
83
+ return normalized.manifest;
198
84
  }
199
85
  function fieldValues(field, value) {
200
- if (field === 'nameservers' || field === 'mx')
86
+ if (field === 'nameservers')
201
87
  return hostnameList(value);
88
+ if (field === 'mx' || field === 'caa' || field === 'ds')
89
+ return canonicalDomainControlRecordList(Array.isArray(value) ? value : [value], field);
202
90
  if (field === 'tlsSpkiSha256') {
203
91
  const normalized = Array.isArray(value) ? value.map(spkiFingerprint).filter(Boolean) : [spkiFingerprint(value)].filter(Boolean);
204
92
  return [...new Set(normalized)].sort().slice(0, MAX_DOMAIN_CONTROL_RECORDS);
@@ -602,15 +602,18 @@ function buildPostureReport(domain, input) {
602
602
  summary[item.status] += 1;
603
603
  return { domain, summary, checks };
604
604
  }
605
- async function fetchMtaStsPolicy(domain) {
605
+ async function fetchMtaStsPolicy(domain, fetcher = safeFetch) {
606
606
  const controller = new AbortController();
607
607
  const timeout = setTimeout(() => controller.abort(), POLICY_TIMEOUT_MS);
608
608
  try {
609
609
  const url = `https://mta-sts.${domain}/.well-known/mta-sts.txt`;
610
- const res = await safeFetch(url, {
610
+ // RFC 8461 section 3.3 requires a direct HTTPS 200 response and forbids
611
+ // following HTTP redirects for policy discovery. A zero-hop safe fetch
612
+ // also keeps the returned body bound to the required policy host/path.
613
+ const res = await fetcher(url, {
611
614
  signal: controller.signal,
612
615
  headers: whoisleuthRequestHeaders({ Accept: 'text/plain' }),
613
- });
616
+ }, 0);
614
617
  if (!res.ok) {
615
618
  // Not reading this body - release it explicitly instead of leaving an
616
619
  // unconsumed stream (and the connection it's tied to) open until
@@ -743,4 +746,4 @@ async function checkDomainPosture(domain, { dkimSelectors = [], retiredDkimSelec
743
746
  externalDependencies,
744
747
  };
745
748
  }
746
- export { normalizeAuditDomain, normalizeDkimSelectors, normalizeMailProtectionProfile, matchesMtaPattern, buildPostureReport, checkDomainPosture, };
749
+ export { normalizeAuditDomain, normalizeDkimSelectors, normalizeMailProtectionProfile, matchesMtaPattern, fetchMtaStsPolicy, buildPostureReport, checkDomainPosture, };