@slicedearth/whoisleuth-cli 1.32.2 → 1.36.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +43 -5
  2. package/SECURITY.md +35 -0
  3. package/cli/arguments.mjs +180 -8
  4. package/cli/artifact-verify.mjs +32 -0
  5. package/cli/bulk-command-runner.mjs +3 -1
  6. package/cli/bulk-output.mjs +6 -1
  7. package/cli/bulk.mjs +1 -2
  8. package/cli/command-catalogue.mjs +33 -0
  9. package/cli/completion.mjs +70 -7
  10. package/cli/doctor.mjs +78 -43
  11. package/cli/export-evidence.mjs +9 -2
  12. package/cli/formatters/html.mjs +1 -1
  13. package/cli/formatters/terminal.mjs +13 -7
  14. package/cli/investigation-plan.mjs +112 -0
  15. package/cli/lookup-command-runner.mjs +8 -0
  16. package/cli/lookup-diff.mjs +16 -5
  17. package/cli/lookup-plan.mjs +99 -0
  18. package/cli/lookup-timeline.mjs +123 -0
  19. package/cli/mail-review.mjs +47 -2
  20. package/cli/manual.mjs +2 -1
  21. package/cli/offline-evidence-review.mjs +141 -0
  22. package/cli/registry-support.mjs +1 -4
  23. package/cli/runner.mjs +214 -11
  24. package/docs/cli.md +200 -45
  25. package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -6
  26. package/frontend/src/lib/analysis/bulk-retry-plan.js +3 -10
  27. package/frontend/src/lib/analysis/bulk-source-coverage.js +76 -0
  28. package/frontend/src/lib/analysis/http-summary.js +2 -1
  29. package/frontend/src/lib/analysis/utils.js +7 -40
  30. package/frontend/src/lib/analysis/website-snapshot-model.js +57 -3
  31. package/lib/availability.mjs +30 -10
  32. package/lib/bulk-limits.mjs +13 -0
  33. package/lib/csv.mjs +1 -1
  34. package/lib/ct-response-bounds.mjs +9 -0
  35. package/lib/ct-search.mjs +66 -12
  36. package/lib/dns-delegation-health.mjs +56 -5
  37. package/lib/dnssec-evidence-validation.mjs +235 -0
  38. package/lib/domain-control-manifest.mjs +339 -0
  39. package/lib/domain-posture.mjs +2 -1
  40. package/lib/encrypted-dns-contract.mjs +81 -0
  41. package/lib/evidence-export.mjs +17 -3
  42. package/lib/evidence-report-markdown.mjs +3 -3
  43. package/lib/evidence-report.mjs +3 -0
  44. package/lib/favicon.mjs +3 -8
  45. package/lib/html-signals.mjs +4 -0
  46. package/lib/http-evidence-bounds.mjs +4 -0
  47. package/lib/http-intelligence.mjs +2 -1
  48. package/lib/ip-prefix.mjs +54 -0
  49. package/lib/local-geoip-evidence.mjs +89 -0
  50. package/lib/outbound-identity.mjs +9 -0
  51. package/lib/outbound-request-bounds.mjs +9 -0
  52. package/lib/perceptual-hash-comparison.mjs +34 -0
  53. package/lib/perceptual-hash.mjs +3 -31
  54. package/lib/project-metadata.mjs +8 -0
  55. package/lib/provider-policy-admission.mjs +49 -0
  56. package/lib/rdap-search-workbench.mjs +157 -0
  57. package/lib/rdap.mjs +7 -2
  58. package/lib/registry-capabilities.mjs +31 -1
  59. package/lib/registry-capability-catalogue.mjs +11 -10
  60. package/lib/response-policy.mjs +81 -6
  61. package/lib/rpki-evidence.mjs +105 -0
  62. package/lib/safe-fetch.mjs +2 -1
  63. package/lib/semantic-version.mjs +54 -0
  64. package/lib/static-html-analysis.mjs +27 -0
  65. package/lib/threatfox-intelligence.mjs +10 -5
  66. package/lib/tlsa-evidence.mjs +194 -0
  67. package/lib/urlhaus-intelligence.mjs +10 -5
  68. package/lib/urlscan-intelligence.mjs +10 -5
  69. package/lib/website-security-posture.mjs +11 -4
  70. package/lib/whois-chain.mjs +14 -152
  71. package/lib/whois.mjs +2 -2
  72. package/package.json +7 -5
  73. package/third-party-notices.txt +2 -2
@@ -0,0 +1,235 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { createHash } from 'node:crypto';
3
+ import { domainToASCII } from 'node:url';
4
+ const DNSSEC_EVIDENCE_SCHEMA = 'whoisleuth.dnssec-evidence-validation';
5
+ const DNSSEC_EVIDENCE_VERSION = 1;
6
+ const MAX_DNSSEC_RECORDS = 50;
7
+ const MAX_PUBLIC_KEY_BYTES = 8192;
8
+ function record(value) {
9
+ return value && typeof value === 'object' && !Array.isArray(value)
10
+ ? value
11
+ : null;
12
+ }
13
+ function integer(value, minimum, maximum) {
14
+ return Number.isInteger(value) && Number(value) >= minimum && Number(value) <= maximum
15
+ ? Number(value)
16
+ : null;
17
+ }
18
+ function ownerName(value) {
19
+ if (typeof value !== 'string' || value.length > 253)
20
+ return null;
21
+ const ascii = domainToASCII(value.trim().replace(/\.$/u, '').toLowerCase());
22
+ if (!ascii || ascii.length > 253)
23
+ return null;
24
+ const labels = ascii.split('.');
25
+ if (labels.some((label) => !label || label.length > 63))
26
+ return null;
27
+ return ascii;
28
+ }
29
+ function hexadecimal(value, length) {
30
+ if (typeof value !== 'string')
31
+ return null;
32
+ const normalized = value.replace(/\s+/gu, '').toUpperCase();
33
+ if (!normalized || !/^[0-9A-F]+$/u.test(normalized) || normalized.length % 2 !== 0)
34
+ return null;
35
+ if (length !== undefined && normalized.length !== length)
36
+ return null;
37
+ return normalized;
38
+ }
39
+ function normalizeDs(value) {
40
+ const source = record(value);
41
+ if (!source)
42
+ return null;
43
+ const keyTag = integer(source.keyTag, 0, 65_535);
44
+ const algorithm = integer(source.algorithm, 0, 255);
45
+ const digestType = integer(source.digestType, 0, 255);
46
+ const expectedLength = digestType === 1 ? 40 : digestType === 2 ? 64 : digestType === 4 ? 96 : undefined;
47
+ const digest = hexadecimal(source.digest, expectedLength);
48
+ return keyTag === null || algorithm === null || digestType === null || !digest
49
+ ? null
50
+ : Object.freeze({ keyTag, algorithm, digestType, digest });
51
+ }
52
+ function normalizeDnskey(value) {
53
+ const source = record(value);
54
+ if (!source)
55
+ return null;
56
+ const flags = integer(source.flags, 0, 65_535);
57
+ const protocol = integer(source.protocol, 0, 255);
58
+ const algorithm = integer(source.algorithm, 0, 255);
59
+ if (flags === null || protocol === null || algorithm === null || typeof source.publicKeyBase64 !== 'string')
60
+ return null;
61
+ if (!/^[A-Za-z0-9+/]+={0,2}$/u.test(source.publicKeyBase64) || source.publicKeyBase64.length > MAX_PUBLIC_KEY_BYTES * 2)
62
+ return null;
63
+ const decoded = Buffer.from(source.publicKeyBase64, 'base64');
64
+ if (!decoded.length || decoded.length > MAX_PUBLIC_KEY_BYTES || decoded.toString('base64') !== source.publicKeyBase64)
65
+ return null;
66
+ return Object.freeze({ flags, protocol, algorithm, publicKeyBase64: source.publicKeyBase64 });
67
+ }
68
+ function dnskeyRdata(value) {
69
+ const key = Buffer.from(value.publicKeyBase64, 'base64');
70
+ const rdata = Buffer.allocUnsafe(4 + key.length);
71
+ rdata.writeUInt16BE(value.flags, 0);
72
+ rdata[2] = value.protocol;
73
+ rdata[3] = value.algorithm;
74
+ key.copy(rdata, 4);
75
+ return rdata;
76
+ }
77
+ function dnskeyTag(rdata) {
78
+ let accumulator = 0;
79
+ for (let index = 0; index < rdata.length; index += 1) {
80
+ accumulator += index & 1 ? rdata[index] : rdata[index] << 8;
81
+ }
82
+ accumulator += (accumulator >> 16) & 0xffff;
83
+ return accumulator & 0xffff;
84
+ }
85
+ function ownerWire(value) {
86
+ const output = [];
87
+ for (const label of value.split('.')) {
88
+ const bytes = Buffer.from(label, 'ascii');
89
+ output.push(bytes.length, ...bytes);
90
+ }
91
+ output.push(0);
92
+ return Buffer.from(output);
93
+ }
94
+ function dsDigest(owner, dnskey, digestType) {
95
+ const hash = digestType === 1 ? 'sha1' : digestType === 2 ? 'sha256' : digestType === 4 ? 'sha384' : null;
96
+ if (!hash)
97
+ return null;
98
+ return createHash(hash).update(ownerWire(owner)).update(dnskeyRdata(dnskey)).digest('hex').toUpperCase();
99
+ }
100
+ function normalizeRrsig(value) {
101
+ const source = record(value);
102
+ if (!source || typeof source.inception !== 'string' || typeof source.expiration !== 'string')
103
+ return null;
104
+ const inception = Date.parse(source.inception);
105
+ const expiration = Date.parse(source.expiration);
106
+ if (!Number.isFinite(inception) || !Number.isFinite(expiration) || inception > expiration)
107
+ return null;
108
+ return Object.freeze({ inception, expiration });
109
+ }
110
+ function signatureTimeState(value, observedAt) {
111
+ if (!value.length || typeof observedAt !== 'string')
112
+ return 'unavailable';
113
+ const observed = Date.parse(observedAt);
114
+ if (!Number.isFinite(observed))
115
+ return 'unavailable';
116
+ let withinWindow = 0;
117
+ for (const item of value) {
118
+ if (observed >= item.inception && observed <= item.expiration)
119
+ withinWindow += 1;
120
+ }
121
+ if (withinWindow === value.length)
122
+ return 'within_window';
123
+ return withinWindow === 0 ? 'outside_window' : 'mixed';
124
+ }
125
+ function validateDnssecEvidence(input) {
126
+ const normalizedOwner = ownerName(input.ownerName);
127
+ const rawDs = Array.isArray(input.dsRecords) ? input.dsRecords : [];
128
+ const rawDnskeys = Array.isArray(input.dnskeyRecords) ? input.dnskeyRecords : [];
129
+ const rawRrsigs = Array.isArray(input.rrSigRecords) ? input.rrSigRecords : [];
130
+ const dsRecords = rawDs.slice(0, MAX_DNSSEC_RECORDS).map(normalizeDs).filter((item) => item !== null);
131
+ const dnskeys = rawDnskeys.slice(0, MAX_DNSSEC_RECORDS).map(normalizeDnskey).filter((item) => item !== null);
132
+ const rrsigs = rawRrsigs.slice(0, MAX_DNSSEC_RECORDS).map(normalizeRrsig).filter((item) => item !== null);
133
+ const rejectedCount = Math.min(rawDs.length, MAX_DNSSEC_RECORDS) - dsRecords.length
134
+ + Math.min(rawDnskeys.length, MAX_DNSSEC_RECORDS) - dnskeys.length
135
+ + Math.min(rawRrsigs.length, MAX_DNSSEC_RECORDS) - rrsigs.length;
136
+ const truncated = rawDs.length > MAX_DNSSEC_RECORDS
137
+ || rawDnskeys.length > MAX_DNSSEC_RECORDS
138
+ || rawRrsigs.length > MAX_DNSSEC_RECORDS;
139
+ const signatureState = signatureTimeState(rrsigs, input.observedAt);
140
+ const findings = [];
141
+ const limitations = [
142
+ 'This offline review verifies supplied DS and DNSKEY relationships only. It does not retrieve or authenticate a complete chain to a configured root trust anchor.',
143
+ 'Missing records may reflect incomplete input and are not treated as proof that DNSSEC is absent.',
144
+ ];
145
+ if (!normalizedOwner) {
146
+ return Object.freeze({
147
+ schema: DNSSEC_EVIDENCE_SCHEMA,
148
+ version: DNSSEC_EVIDENCE_VERSION,
149
+ state: 'invalid',
150
+ ownerName: null,
151
+ dsRecordCount: dsRecords.length,
152
+ dnskeyRecordCount: dnskeys.length,
153
+ rrsigRecordCount: rrsigs.length,
154
+ matchedDsCount: 0,
155
+ unsupportedDigestCount: 0,
156
+ rejectedCount,
157
+ truncated,
158
+ signatureTimeState: signatureState,
159
+ findings: Object.freeze(['The supplied owner name was invalid.']),
160
+ limitations: Object.freeze(limitations),
161
+ });
162
+ }
163
+ if (input.delegationSigned === false && dsRecords.length === 0) {
164
+ findings.push('The supplied delegation state was unsigned and no DS records were supplied.');
165
+ return Object.freeze({
166
+ schema: DNSSEC_EVIDENCE_SCHEMA,
167
+ version: DNSSEC_EVIDENCE_VERSION,
168
+ state: 'unsigned',
169
+ ownerName: normalizedOwner,
170
+ dsRecordCount: 0,
171
+ dnskeyRecordCount: dnskeys.length,
172
+ rrsigRecordCount: rrsigs.length,
173
+ matchedDsCount: 0,
174
+ unsupportedDigestCount: 0,
175
+ rejectedCount,
176
+ truncated,
177
+ signatureTimeState: signatureState,
178
+ findings: Object.freeze(findings),
179
+ limitations: Object.freeze(limitations),
180
+ });
181
+ }
182
+ const unsupportedDigestCount = dsRecords.filter((item) => ![1, 2, 4].includes(item.digestType)).length;
183
+ let matchedDsCount = 0;
184
+ for (const ds of dsRecords) {
185
+ const matched = dnskeys.some((dnskey) => {
186
+ if (dnskey.algorithm !== ds.algorithm)
187
+ return false;
188
+ const rdata = dnskeyRdata(dnskey);
189
+ return dnskeyTag(rdata) === ds.keyTag && dsDigest(normalizedOwner, dnskey, ds.digestType) === ds.digest;
190
+ });
191
+ if (matched)
192
+ matchedDsCount += 1;
193
+ }
194
+ const definiteConflict = input.delegationSigned === false && dsRecords.length > 0
195
+ || input.delegationSigned === true && dsRecords.length === 0 && !truncated
196
+ || signatureState === 'outside_window'
197
+ || (dsRecords.length > 0 && dnskeys.length > 0 && matchedDsCount === 0 && unsupportedDigestCount < dsRecords.length);
198
+ if (definiteConflict)
199
+ findings.push('The supplied delegation, signature-time, DS, or DNSKEY evidence conflicts.');
200
+ if (matchedDsCount > 0)
201
+ findings.push(`${matchedDsCount} supplied DS record(s) matched a supplied DNSKEY.`);
202
+ if (unsupportedDigestCount > 0)
203
+ findings.push(`${unsupportedDigestCount} DS digest type(s) were retained as unsupported.`);
204
+ if (signatureState === 'mixed')
205
+ findings.push('The supplied RRSIG validity windows were mixed at the observation time.');
206
+ if (rejectedCount > 0)
207
+ findings.push(`${rejectedCount} malformed record(s) were rejected.`);
208
+ if (truncated)
209
+ findings.push('The supplied record set exceeded the review bound and was truncated.');
210
+ const completeLocalRelationship = dsRecords.length > 0
211
+ && dnskeys.length > 0
212
+ && matchedDsCount > 0
213
+ && !definiteConflict
214
+ && rejectedCount === 0
215
+ && !truncated
216
+ && unsupportedDigestCount === 0
217
+ && signatureState !== 'mixed';
218
+ return Object.freeze({
219
+ schema: DNSSEC_EVIDENCE_SCHEMA,
220
+ version: DNSSEC_EVIDENCE_VERSION,
221
+ state: definiteConflict ? 'conflict' : completeLocalRelationship ? 'consistent' : 'partial',
222
+ ownerName: normalizedOwner,
223
+ dsRecordCount: dsRecords.length,
224
+ dnskeyRecordCount: dnskeys.length,
225
+ rrsigRecordCount: rrsigs.length,
226
+ matchedDsCount,
227
+ unsupportedDigestCount,
228
+ rejectedCount,
229
+ truncated,
230
+ signatureTimeState: signatureState,
231
+ findings: Object.freeze(findings),
232
+ limitations: Object.freeze(limitations),
233
+ });
234
+ }
235
+ export { DNSSEC_EVIDENCE_SCHEMA, DNSSEC_EVIDENCE_VERSION, MAX_DNSSEC_RECORDS, dnskeyTag, validateDnssecEvidence, };
@@ -0,0 +1,339 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { domainToASCII } from 'node:url';
3
+ import { canonicalArtifactJson } from "../frontend/src/lib/analysis/artifact-integrity.js";
4
+ export const DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA = 'whoisleuth.domain-control-manifest-input';
5
+ export const DOMAIN_CONTROL_MANIFEST_SCHEMA = 'whoisleuth.domain-control-manifest';
6
+ export const DOMAIN_CONTROL_MANIFEST_VERSION = 1;
7
+ export const DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA = 'whoisleuth.domain-control-review-input';
8
+ export const DOMAIN_CONTROL_REVIEW_SCHEMA = 'whoisleuth.domain-control-review';
9
+ export const DOMAIN_CONTROL_REVIEW_VERSION = 1;
10
+ export const MAX_DOMAIN_CONTROL_ENTRIES = 100;
11
+ export const MAX_DOMAIN_CONTROL_RECORDS = 32;
12
+ function record(value) {
13
+ return value && typeof value === 'object' && !Array.isArray(value)
14
+ ? value
15
+ : null;
16
+ }
17
+ function boundedText(value, maximum = 300) {
18
+ if (typeof value !== 'string' || value.length > maximum * 4 || /[\u0000-\u001f\u007f]/u.test(value))
19
+ return null;
20
+ return value.replace(/\s+/gu, ' ').trim().slice(0, maximum) || null;
21
+ }
22
+ function timestamp(value) {
23
+ const text = boundedText(value, 64);
24
+ if (!text)
25
+ return null;
26
+ const parsed = Date.parse(text);
27
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
28
+ }
29
+ function domain(value) {
30
+ const text = boundedText(value, 253)?.toLowerCase().replace(/\.$/u, '');
31
+ if (!text)
32
+ return null;
33
+ const ascii = domainToASCII(text);
34
+ if (!ascii || ascii.length > 253 || ascii.split('.').some((label) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(label)))
35
+ return null;
36
+ return ascii;
37
+ }
38
+ function hostnameList(value) {
39
+ if (!Array.isArray(value))
40
+ return [];
41
+ return [...new Set(value.slice(0, MAX_DOMAIN_CONTROL_RECORDS * 4)
42
+ .map(domain)
43
+ .filter((item) => item !== null))]
44
+ .sort()
45
+ .slice(0, MAX_DOMAIN_CONTROL_RECORDS);
46
+ }
47
+ function textList(value, maximum = 300) {
48
+ if (!Array.isArray(value))
49
+ return [];
50
+ return [...new Set(value.slice(0, MAX_DOMAIN_CONTROL_RECORDS * 4)
51
+ .map((item) => boundedText(item, maximum)?.toLowerCase() ?? null)
52
+ .filter((item) => item !== null))]
53
+ .sort()
54
+ .slice(0, MAX_DOMAIN_CONTROL_RECORDS);
55
+ }
56
+ function spkiFingerprint(value) {
57
+ const text = boundedText(value, 64)?.toLowerCase();
58
+ return text && /^[a-f0-9]{64}$/u.test(text) ? text : null;
59
+ }
60
+ function normalizeEntry(value) {
61
+ const source = record(value);
62
+ const normalizedDomain = domain(source?.domain);
63
+ if (!source || !normalizedDomain)
64
+ return null;
65
+ const registrarLock = source.registrarLock === 'required' || source.registrarLock === 'not_required'
66
+ ? source.registrarLock
67
+ : null;
68
+ return Object.freeze({
69
+ domain: normalizedDomain,
70
+ nameservers: Object.freeze(hostnameList(source.nameservers)),
71
+ ds: Object.freeze(textList(source.ds, 500)),
72
+ mx: Object.freeze(hostnameList(source.mx)),
73
+ caa: Object.freeze(textList(source.caa, 500)),
74
+ tlsIssuer: boundedText(source.tlsIssuer, 300)?.toLowerCase() ?? null,
75
+ tlsSpkiSha256: spkiFingerprint(source.tlsSpkiSha256),
76
+ registrarLock,
77
+ renewalReviewAt: timestamp(source.renewalReviewAt),
78
+ note: boundedText(source.note, 500),
79
+ });
80
+ }
81
+ function unsignedManifest(value) {
82
+ return value;
83
+ }
84
+ function integrityDigest(value) {
85
+ return `sha256:${createHash('sha256').update(canonicalArtifactJson(value)).digest('hex')}`;
86
+ }
87
+ export function buildDomainControlManifest(input, generatedAtValue = new Date().toISOString()) {
88
+ const source = record(input);
89
+ if (!source || source.schema !== DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA || source.version !== 1) {
90
+ throw new TypeError(`Domain control manifest input must use ${DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA} version 1.`);
91
+ }
92
+ const generatedAt = timestamp(generatedAtValue);
93
+ const expiresAt = timestamp(source.expiresAt);
94
+ if (!generatedAt || !expiresAt || Date.parse(expiresAt) <= Date.parse(generatedAt)) {
95
+ throw new TypeError('Domain control manifest expiry must be a valid time after generation.');
96
+ }
97
+ if (!Array.isArray(source.entries) || source.entries.length < 1 || source.entries.length > MAX_DOMAIN_CONTROL_ENTRIES) {
98
+ throw new TypeError(`Domain control manifest input must contain between 1 and ${MAX_DOMAIN_CONTROL_ENTRIES} entries.`);
99
+ }
100
+ const entries = source.entries.map(normalizeEntry);
101
+ if (entries.some((entry) => entry === null))
102
+ throw new TypeError('Domain control manifest contains an invalid entry.');
103
+ const normalizedEntries = entries;
104
+ const domains = new Set(normalizedEntries.map((entry) => entry.domain));
105
+ if (domains.size !== normalizedEntries.length)
106
+ throw new TypeError('Domain control manifest entries must use unique domains.');
107
+ const base = Object.freeze({
108
+ schema: DOMAIN_CONTROL_MANIFEST_SCHEMA,
109
+ version: DOMAIN_CONTROL_MANIFEST_VERSION,
110
+ generatedAt,
111
+ expiresAt,
112
+ entries: Object.freeze([...normalizedEntries].sort((left, right) => left.domain.localeCompare(right.domain))),
113
+ limitations: Object.freeze([
114
+ 'This analyst-authored manifest records intended domain-control state. It does not collect evidence or change registrar, DNS, mail, or certificate configuration.',
115
+ 'Empty desired fields are unconfigured rather than claims that a record should be absent.',
116
+ ]),
117
+ });
118
+ return Object.freeze({
119
+ ...base,
120
+ integrity: Object.freeze({
121
+ algorithm: 'SHA-256',
122
+ canonicalization: 'sorted-json-v1',
123
+ digestSha256: integrityDigest(unsignedManifest(base)),
124
+ }),
125
+ });
126
+ }
127
+ export function verifyDomainControlManifest(value) {
128
+ const source = record(value);
129
+ const integrity = record(source?.integrity);
130
+ if (!source
131
+ || source.schema !== DOMAIN_CONTROL_MANIFEST_SCHEMA
132
+ || source.version !== DOMAIN_CONTROL_MANIFEST_VERSION
133
+ || !Array.isArray(source.entries)
134
+ || source.entries.length < 1
135
+ || source.entries.length > MAX_DOMAIN_CONTROL_ENTRIES
136
+ || !Array.isArray(source.limitations)
137
+ || !integrity
138
+ || integrity.algorithm !== 'SHA-256'
139
+ || integrity.canonicalization !== 'sorted-json-v1'
140
+ || typeof integrity.digestSha256 !== 'string'
141
+ || !/^sha256:[a-f0-9]{64}$/u.test(integrity.digestSha256)) {
142
+ throw new TypeError('Domain control manifest has an unsupported or malformed structure.');
143
+ }
144
+ const generatedAt = timestamp(source.generatedAt);
145
+ const expiresAt = timestamp(source.expiresAt);
146
+ const entries = source.entries.map(normalizeEntry);
147
+ if (!generatedAt || !expiresAt || Date.parse(expiresAt) <= Date.parse(generatedAt) || entries.some((entry) => entry === null)) {
148
+ throw new TypeError('Domain control manifest has invalid normalized content.');
149
+ }
150
+ const normalizedEntries = entries;
151
+ if (new Set(normalizedEntries.map((entry) => entry.domain)).size !== normalizedEntries.length) {
152
+ throw new TypeError('Domain control manifest entries must use unique domains.');
153
+ }
154
+ const { integrity: _integrity, ...unsigned } = source;
155
+ if (integrityDigest(unsigned) !== integrity.digestSha256) {
156
+ throw new TypeError('Domain control manifest failed its SHA-256 integrity check.');
157
+ }
158
+ return source;
159
+ }
160
+ function fieldValues(field, value) {
161
+ if (field === 'nameservers' || field === 'mx')
162
+ return hostnameList(value);
163
+ if (field === 'tlsSpkiSha256') {
164
+ const normalized = Array.isArray(value) ? value.map(spkiFingerprint).filter(Boolean) : [spkiFingerprint(value)].filter(Boolean);
165
+ return [...new Set(normalized)].sort().slice(0, MAX_DOMAIN_CONTROL_RECORDS);
166
+ }
167
+ if (field === 'registrarLock') {
168
+ const values = Array.isArray(value) ? value : [value];
169
+ return [...new Set(values.filter((item) => item === 'required' || item === 'not_required'))];
170
+ }
171
+ return textList(Array.isArray(value) ? value : [value], 500);
172
+ }
173
+ function normalizeObservation(value) {
174
+ const source = record(value);
175
+ const normalizedDomain = domain(source?.domain);
176
+ const rawFields = record(source?.fields);
177
+ if (!source || !normalizedDomain || !rawFields)
178
+ return null;
179
+ const fields = {};
180
+ for (const field of ['nameservers', 'ds', 'mx', 'caa', 'tlsIssuer', 'tlsSpkiSha256', 'registrarLock']) {
181
+ const raw = record(rawFields[field]);
182
+ if (!raw)
183
+ continue;
184
+ const state = raw.state;
185
+ if (state !== 'observed' && state !== 'partial' && state !== 'unavailable' && state !== 'unsupported')
186
+ return null;
187
+ const sourceLabel = boundedText(raw.source, 120);
188
+ if (!sourceLabel)
189
+ return null;
190
+ fields[field] = Object.freeze({
191
+ state,
192
+ values: Object.freeze(fieldValues(field, raw.values)),
193
+ source: sourceLabel,
194
+ observedAt: timestamp(raw.observedAt),
195
+ });
196
+ }
197
+ return Object.freeze({ domain: normalizedDomain, fields: Object.freeze(fields) });
198
+ }
199
+ function desiredValues(entry, field) {
200
+ if (field === 'tlsIssuer')
201
+ return entry.tlsIssuer ? [entry.tlsIssuer] : [];
202
+ if (field === 'tlsSpkiSha256')
203
+ return entry.tlsSpkiSha256 ? [entry.tlsSpkiSha256] : [];
204
+ if (field === 'registrarLock')
205
+ return entry.registrarLock ? [entry.registrarLock] : [];
206
+ return [...entry[field]];
207
+ }
208
+ function compareField(entry, observation, field) {
209
+ const desired = desiredValues(entry, field);
210
+ if (!desired.length)
211
+ return Object.freeze({
212
+ field, state: 'not_configured', desired: Object.freeze([]), observed: Object.freeze([]), source: null, observedAt: null,
213
+ explanation: 'No desired value was configured for this field.',
214
+ });
215
+ const evidence = observation?.fields[field];
216
+ if (!evidence)
217
+ return Object.freeze({
218
+ field, state: 'unavailable', desired: Object.freeze(desired), observed: Object.freeze([]), source: null, observedAt: null,
219
+ explanation: 'No separately attributed observation was supplied for this field.',
220
+ });
221
+ if (evidence.state !== 'observed')
222
+ return Object.freeze({
223
+ field,
224
+ state: evidence.state,
225
+ desired: Object.freeze(desired),
226
+ observed: evidence.values,
227
+ source: evidence.source,
228
+ observedAt: evidence.observedAt,
229
+ explanation: evidence.state === 'partial'
230
+ ? 'The supplied observation was partial, so a difference is inconclusive.'
231
+ : `The supplied source reported this field as ${evidence.state}.`,
232
+ });
233
+ const aligned = desired.length === evidence.values.length && desired.every((item, index) => item === evidence.values[index]);
234
+ return Object.freeze({
235
+ field,
236
+ state: aligned ? 'aligned' : 'drift',
237
+ desired: Object.freeze(desired),
238
+ observed: evidence.values,
239
+ source: evidence.source,
240
+ observedAt: evidence.observedAt,
241
+ explanation: aligned
242
+ ? 'The complete supplied observation matches the desired value.'
243
+ : 'The complete supplied observation differs from the desired value.',
244
+ });
245
+ }
246
+ function renewalComparison(entry, now) {
247
+ if (!entry.renewalReviewAt)
248
+ return Object.freeze({
249
+ field: 'renewalReviewAt', state: 'not_configured', desired: Object.freeze([]), observed: Object.freeze([]), source: null, observedAt: null,
250
+ explanation: 'No renewal review date was configured.',
251
+ });
252
+ const due = Date.parse(entry.renewalReviewAt) <= Date.parse(now);
253
+ return Object.freeze({
254
+ field: 'renewalReviewAt', state: due ? 'due' : 'aligned', desired: Object.freeze([entry.renewalReviewAt]), observed: Object.freeze([]), source: null, observedAt: null,
255
+ explanation: due ? 'The configured renewal review date is due.' : 'The configured renewal review date is still in the future.',
256
+ });
257
+ }
258
+ export function reviewDomainControlManifest(input, generatedAtValue = new Date().toISOString()) {
259
+ const source = record(input);
260
+ if (!source || source.schema !== DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA || source.version !== 1) {
261
+ throw new TypeError(`Domain control review input must use ${DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA} version 1.`);
262
+ }
263
+ const generatedAt = timestamp(generatedAtValue);
264
+ if (!generatedAt)
265
+ throw new TypeError('Domain control review time is invalid.');
266
+ const manifest = verifyDomainControlManifest(source.manifest);
267
+ if (!Array.isArray(source.observations) || source.observations.length > MAX_DOMAIN_CONTROL_ENTRIES * 2) {
268
+ throw new TypeError('Domain control observations must be a bounded array.');
269
+ }
270
+ const observations = source.observations.map(normalizeObservation);
271
+ if (observations.some((item) => item === null))
272
+ throw new TypeError('Domain control review contains an invalid observation.');
273
+ const normalizedObservations = observations;
274
+ if (new Set(normalizedObservations.map((item) => item.domain)).size !== normalizedObservations.length) {
275
+ throw new TypeError('Domain control observations must use unique domains.');
276
+ }
277
+ const observationMap = new Map(normalizedObservations.map((item) => [item.domain, item]));
278
+ const fields = ['nameservers', 'ds', 'mx', 'caa', 'tlsIssuer', 'tlsSpkiSha256', 'registrarLock'];
279
+ const domains = manifest.entries.map((entry) => {
280
+ const observation = observationMap.get(entry.domain) ?? null;
281
+ const comparisons = [...fields.map((field) => compareField(entry, observation, field)), renewalComparison(entry, generatedAt)];
282
+ const state = comparisons.some((item) => item.state === 'drift' || item.state === 'due')
283
+ ? 'drift'
284
+ : comparisons.some((item) => item.state === 'partial' || item.state === 'unavailable' || item.state === 'unsupported')
285
+ ? 'partial'
286
+ : 'aligned';
287
+ return Object.freeze({ domain: entry.domain, state, comparisons: Object.freeze(comparisons) });
288
+ });
289
+ const comparisons = domains.flatMap((item) => item.comparisons);
290
+ const counts = Object.fromEntries(['aligned', 'drift', 'partial', 'unavailable', 'unsupported', 'not_configured', 'due']
291
+ .map((state) => [state, comparisons.filter((item) => item.state === state).length]));
292
+ const expired = Date.parse(manifest.expiresAt) <= Date.parse(generatedAt);
293
+ return Object.freeze({
294
+ schema: DOMAIN_CONTROL_REVIEW_SCHEMA,
295
+ version: DOMAIN_CONTROL_REVIEW_VERSION,
296
+ generatedAt,
297
+ state: expired ? 'expired' : domains.some((item) => item.state === 'drift') ? 'drift' : domains.some((item) => item.state === 'partial') ? 'partial' : 'aligned',
298
+ manifest: Object.freeze({
299
+ generatedAt: manifest.generatedAt,
300
+ expiresAt: manifest.expiresAt,
301
+ digestSha256: manifest.integrity.digestSha256,
302
+ expired,
303
+ }),
304
+ counts: Object.freeze(counts),
305
+ domains: Object.freeze(domains),
306
+ ignoredObservationCount: normalizedObservations.filter((item) => !manifest.entries.some((entry) => entry.domain === item.domain)).length,
307
+ limitations: Object.freeze([
308
+ 'This local review compares analyst-authored desired state with separately supplied observations and performs no collection or configuration change.',
309
+ 'Only complete observations can produce drift. Partial, unavailable, unsupported, or missing evidence remains inconclusive.',
310
+ 'A valid manifest digest or signature establishes file integrity, not the correctness of the desired state or supplied observations.',
311
+ ]),
312
+ });
313
+ }
314
+ export function formatDomainControlResult(value) {
315
+ if (value.schema === DOMAIN_CONTROL_MANIFEST_SCHEMA) {
316
+ return [
317
+ 'Domain control manifest',
318
+ `Domains ${value.entries.length}`,
319
+ `Expires ${value.expiresAt}`,
320
+ `Digest ${value.integrity.digestSha256}`,
321
+ '',
322
+ 'The manifest records desired state only and performs no collection or configuration change.',
323
+ '',
324
+ ].join('\n');
325
+ }
326
+ return [
327
+ 'Domain control review',
328
+ `State ${value.state}`,
329
+ `Domains ${value.domains.length}`,
330
+ `Drift ${value.counts.drift ?? 0}`,
331
+ `Partial ${(value.counts.partial ?? 0) + (value.counts.unavailable ?? 0) + (value.counts.unsupported ?? 0)}`,
332
+ `Due ${value.counts.due ?? 0}`,
333
+ '',
334
+ ...value.domains.map((item) => `${item.domain} ${item.state}`),
335
+ '',
336
+ ...value.limitations.map((item) => `Limitation: ${item}`),
337
+ '',
338
+ ].join('\n');
339
+ }
@@ -5,6 +5,7 @@ import { promises as dns } from 'node:dns';
5
5
  import { fetchRdapRecord } from "./rdap.mjs";
6
6
  import { nonEmptyErrorMessage } from "./error-detail.mjs";
7
7
  import { safeFetch, readTextCapped } from "./safe-fetch.mjs";
8
+ import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
8
9
  import { classifyMxRecords } from "./dns-mx.mjs";
9
10
  import { parseSpfRecords, parseDmarcRecords, parseMtaStsDnsRecords, parseMtaStsPolicy, parseTlsRptRecords, parseBimiRecords, parseDkimRecords, } from "./domain-posture-parsers.mjs";
10
11
  import { buildExternalDependencies, expandSpfPolicy, validateDmarcExternalReporting, } from "./domain-posture-analysis.mjs";
@@ -608,7 +609,7 @@ async function fetchMtaStsPolicy(domain) {
608
609
  const url = `https://mta-sts.${domain}/.well-known/mta-sts.txt`;
609
610
  const res = await safeFetch(url, {
610
611
  signal: controller.signal,
611
- headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WHOISleuth-Posture/1.0)', Accept: 'text/plain' },
612
+ headers: whoisleuthRequestHeaders({ Accept: 'text/plain' }),
612
613
  });
613
614
  if (!res.ok) {
614
615
  // Not reading this body - release it explicitly instead of leaving an
@@ -0,0 +1,81 @@
1
+ const ENCRYPTED_DNS_CONTRACT_SCHEMA = 'whoisleuth.encrypted-dns-adapter';
2
+ const ENCRYPTED_DNS_CONTRACT_VERSION = 1;
3
+ const DNS_TYPES = new Set(['A', 'AAAA', 'CAA', 'CNAME', 'DNSKEY', 'DS', 'HTTPS', 'MX', 'NS', 'RRSIG', 'SOA', 'SVCB', 'TLSA', 'TXT']);
4
+ function record(value) {
5
+ return value && typeof value === 'object' && !Array.isArray(value)
6
+ ? value
7
+ : null;
8
+ }
9
+ function httpsUrl(value) {
10
+ if (typeof value !== 'string' || value.length > 2048)
11
+ return null;
12
+ try {
13
+ const url = new URL(value);
14
+ return url.protocol === 'https:' && !url.username && !url.password ? url.toString() : null;
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
20
+ function normalizeEncryptedDnsAdapter(value) {
21
+ const source = record(value);
22
+ if (!source)
23
+ throw new TypeError('Encrypted DNS adapter must be an object.');
24
+ const id = typeof source.id === 'string' && /^[a-z][a-z0-9-]{0,47}$/u.test(source.id) ? source.id : null;
25
+ const label = typeof source.label === 'string' && source.label.length <= 80 && !/[\u0000-\u001f\u007f]/u.test(source.label)
26
+ ? source.label.trim()
27
+ : null;
28
+ const endpoint = httpsUrl(source.endpoint);
29
+ const termsUrl = httpsUrl(source.termsUrl);
30
+ const privacyUrl = httpsUrl(source.privacyUrl);
31
+ const reviewedAt = typeof source.reviewedAt === 'string' && Number.isFinite(Date.parse(source.reviewedAt))
32
+ ? new Date(source.reviewedAt).toISOString()
33
+ : null;
34
+ const method = source.method === 'GET' || source.method === 'POST' ? source.method : null;
35
+ const representation = source.representation === 'dns-json' || source.representation === 'dns-wire'
36
+ ? source.representation
37
+ : null;
38
+ const retention = source.queryRetention;
39
+ const maxResponseBytes = Number(source.maxResponseBytes);
40
+ const timeoutMs = Number(source.timeoutMs);
41
+ if (!id || !label || !endpoint || !termsUrl || !privacyUrl || !reviewedAt || !method || !representation
42
+ || !['none', 'limited', 'provider_defined', 'unknown'].includes(String(retention))
43
+ || !Number.isInteger(maxResponseBytes) || maxResponseBytes < 1024 || maxResponseBytes > 2 * 1024 * 1024
44
+ || !Number.isInteger(timeoutMs) || timeoutMs < 250 || timeoutMs > 15_000) {
45
+ throw new TypeError('Encrypted DNS adapter did not satisfy the bounded privacy and transport contract.');
46
+ }
47
+ return Object.freeze({
48
+ id,
49
+ label,
50
+ endpoint,
51
+ method,
52
+ representation,
53
+ termsUrl,
54
+ privacyUrl,
55
+ reviewedAt,
56
+ queryRetention: retention,
57
+ maxResponseBytes,
58
+ timeoutMs,
59
+ });
60
+ }
61
+ function planEncryptedDnsQuery(adapter, input) {
62
+ const name = typeof input.name === 'string' ? input.name.trim().toLowerCase().replace(/\.$/u, '') : '';
63
+ const type = typeof input.type === 'string' ? input.type.trim().toUpperCase() : '';
64
+ if (!name || name.length > 253 || !/^[a-z0-9._-]+$/u.test(name) || !DNS_TYPES.has(type)) {
65
+ throw new TypeError('Encrypted DNS query name or record type was invalid or unsupported.');
66
+ }
67
+ return Object.freeze({
68
+ schema: ENCRYPTED_DNS_CONTRACT_SCHEMA,
69
+ version: ENCRYPTED_DNS_CONTRACT_VERSION,
70
+ state: 'approval_required',
71
+ adapter: Object.freeze({ id: adapter.id, label: adapter.label, endpoint: adapter.endpoint }),
72
+ query: Object.freeze({ name, type }),
73
+ disclosure: Object.freeze({
74
+ target: name,
75
+ recipient: new URL(adapter.endpoint).hostname,
76
+ queryRetention: adapter.queryRetention,
77
+ }),
78
+ limitation: 'This plan does not execute a request. Provider availability, answer authority, validation, filtering, retention, and regional behavior remain untested.',
79
+ });
80
+ }
81
+ export { ENCRYPTED_DNS_CONTRACT_SCHEMA, ENCRYPTED_DNS_CONTRACT_VERSION, normalizeEncryptedDnsAdapter, planEncryptedDnsQuery, };