@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.
- package/README.md +43 -5
- package/SECURITY.md +35 -0
- package/cli/arguments.mjs +180 -8
- package/cli/artifact-verify.mjs +32 -0
- package/cli/bulk-command-runner.mjs +3 -1
- package/cli/bulk-output.mjs +6 -1
- package/cli/bulk.mjs +1 -2
- package/cli/command-catalogue.mjs +33 -0
- package/cli/completion.mjs +70 -7
- package/cli/doctor.mjs +78 -43
- package/cli/export-evidence.mjs +9 -2
- package/cli/formatters/html.mjs +1 -1
- package/cli/formatters/terminal.mjs +13 -7
- package/cli/investigation-plan.mjs +112 -0
- package/cli/lookup-command-runner.mjs +8 -0
- package/cli/lookup-diff.mjs +16 -5
- package/cli/lookup-plan.mjs +99 -0
- package/cli/lookup-timeline.mjs +123 -0
- package/cli/mail-review.mjs +47 -2
- package/cli/manual.mjs +2 -1
- package/cli/offline-evidence-review.mjs +141 -0
- package/cli/registry-support.mjs +1 -4
- package/cli/runner.mjs +214 -11
- package/docs/cli.md +200 -45
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -6
- package/frontend/src/lib/analysis/bulk-retry-plan.js +3 -10
- package/frontend/src/lib/analysis/bulk-source-coverage.js +76 -0
- package/frontend/src/lib/analysis/http-summary.js +2 -1
- package/frontend/src/lib/analysis/utils.js +7 -40
- package/frontend/src/lib/analysis/website-snapshot-model.js +57 -3
- package/lib/availability.mjs +30 -10
- package/lib/bulk-limits.mjs +13 -0
- package/lib/csv.mjs +1 -1
- package/lib/ct-response-bounds.mjs +9 -0
- package/lib/ct-search.mjs +66 -12
- package/lib/dns-delegation-health.mjs +56 -5
- package/lib/dnssec-evidence-validation.mjs +235 -0
- package/lib/domain-control-manifest.mjs +339 -0
- package/lib/domain-posture.mjs +2 -1
- package/lib/encrypted-dns-contract.mjs +81 -0
- package/lib/evidence-export.mjs +17 -3
- package/lib/evidence-report-markdown.mjs +3 -3
- package/lib/evidence-report.mjs +3 -0
- package/lib/favicon.mjs +3 -8
- package/lib/html-signals.mjs +4 -0
- package/lib/http-evidence-bounds.mjs +4 -0
- package/lib/http-intelligence.mjs +2 -1
- package/lib/ip-prefix.mjs +54 -0
- package/lib/local-geoip-evidence.mjs +89 -0
- package/lib/outbound-identity.mjs +9 -0
- package/lib/outbound-request-bounds.mjs +9 -0
- package/lib/perceptual-hash-comparison.mjs +34 -0
- package/lib/perceptual-hash.mjs +3 -31
- package/lib/project-metadata.mjs +8 -0
- package/lib/provider-policy-admission.mjs +49 -0
- package/lib/rdap-search-workbench.mjs +157 -0
- package/lib/rdap.mjs +7 -2
- package/lib/registry-capabilities.mjs +31 -1
- package/lib/registry-capability-catalogue.mjs +11 -10
- package/lib/response-policy.mjs +81 -6
- package/lib/rpki-evidence.mjs +105 -0
- package/lib/safe-fetch.mjs +2 -1
- package/lib/semantic-version.mjs +54 -0
- package/lib/static-html-analysis.mjs +27 -0
- package/lib/threatfox-intelligence.mjs +10 -5
- package/lib/tlsa-evidence.mjs +194 -0
- package/lib/urlhaus-intelligence.mjs +10 -5
- package/lib/urlscan-intelligence.mjs +10 -5
- package/lib/website-security-posture.mjs +11 -4
- package/lib/whois-chain.mjs +14 -152
- package/lib/whois.mjs +2 -2
- package/package.json +7 -5
- 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
|
+
}
|
package/lib/domain-posture.mjs
CHANGED
|
@@ -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: {
|
|
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, };
|