@slicedearth/whoisleuth-cli 1.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/LICENSES/Retire.js-Apache-2.0.txt +199 -0
- package/NOTICE +76 -0
- package/README.md +84 -0
- package/TRADEMARKS.md +41 -0
- package/bin/whoisleuth.mjs +35 -0
- package/cli/archive-inspect.mjs +198 -0
- package/cli/arguments.mjs +1022 -0
- package/cli/artifact-verify.mjs +210 -0
- package/cli/bulk-checkpoint.mjs +218 -0
- package/cli/bulk-command-runner.mjs +109 -0
- package/cli/bulk-output.mjs +112 -0
- package/cli/bulk.mjs +133 -0
- package/cli/compare.mjs +238 -0
- package/cli/completion.mjs +175 -0
- package/cli/csv.mjs +1 -0
- package/cli/discover.mjs +50 -0
- package/cli/discovery-command-runner.mjs +36 -0
- package/cli/discovery-observation-snapshot.mjs +315 -0
- package/cli/discovery-scan-command-runner.mjs +141 -0
- package/cli/discovery-scan.mjs +325 -0
- package/cli/discovery-snapshot.mjs +133 -0
- package/cli/discovery-workflow.mjs +73 -0
- package/cli/doctor.mjs +137 -0
- package/cli/errors.mjs +21 -0
- package/cli/evidence-command-arguments.mjs +145 -0
- package/cli/evidence-command-runner.mjs +106 -0
- package/cli/evidence-signing.mjs +215 -0
- package/cli/exit-codes.mjs +9 -0
- package/cli/export-evidence.mjs +28 -0
- package/cli/formatters/evidence-report.mjs +3 -0
- package/cli/formatters/html.mjs +69 -0
- package/cli/formatters/json.mjs +135 -0
- package/cli/formatters/markdown.mjs +3 -0
- package/cli/formatters/terminal.mjs +658 -0
- package/cli/http.mjs +27 -0
- package/cli/lookup-command-runner.mjs +87 -0
- package/cli/lookup-diff.mjs +171 -0
- package/cli/mail-review.mjs +204 -0
- package/cli/manual.mjs +34 -0
- package/cli/network-command-runner.mjs +88 -0
- package/cli/output-file.mjs +97 -0
- package/cli/page-compare.mjs +123 -0
- package/cli/posture.mjs +20 -0
- package/cli/progress-events.mjs +53 -0
- package/cli/progress.mjs +56 -0
- package/cli/registry-access.mjs +3 -0
- package/cli/registry-support.mjs +120 -0
- package/cli/risk-calibration.mjs +294 -0
- package/cli/runner-types.mjs +1 -0
- package/cli/runner.mjs +726 -0
- package/cli/saved-lookup.mjs +87 -0
- package/cli/source-reliability.mjs +727 -0
- package/cli/strict-exit.mjs +55 -0
- package/cli/terminal-presentation.mjs +147 -0
- package/docs/cli.md +827 -0
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +93 -0
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +391 -0
- package/frontend/src/lib/analysis/artifact-integrity.js +21 -0
- package/frontend/src/lib/analysis/brand-profile-model.js +421 -0
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +246 -0
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +192 -0
- package/frontend/src/lib/analysis/bulk-retry-plan.js +86 -0
- package/frontend/src/lib/analysis/bulk-review-export.js +62 -0
- package/frontend/src/lib/analysis/bulk-review-model.js +266 -0
- package/frontend/src/lib/analysis/bulk-session-model.js +515 -0
- package/frontend/src/lib/analysis/bulk-sort.js +49 -0
- package/frontend/src/lib/analysis/campaign-model.js +265 -0
- package/frontend/src/lib/analysis/case-evidence-model.js +642 -0
- package/frontend/src/lib/analysis/case-migration-model.js +248 -0
- package/frontend/src/lib/analysis/case-model.js +5 -0
- package/frontend/src/lib/analysis/case-record-contracts.js +44 -0
- package/frontend/src/lib/analysis/case-record-core.js +232 -0
- package/frontend/src/lib/analysis/case-record-model.js +7 -0
- package/frontend/src/lib/analysis/case-record-operations.js +224 -0
- package/frontend/src/lib/analysis/case-response-model.js +640 -0
- package/frontend/src/lib/analysis/case-response-packet.js +573 -0
- package/frontend/src/lib/analysis/case-storage-model.js +93 -0
- package/frontend/src/lib/analysis/detection-rule-model.js +306 -0
- package/frontend/src/lib/analysis/dns-record-normalization.js +11 -0
- package/frontend/src/lib/analysis/http-summary.js +165 -0
- package/frontend/src/lib/analysis/investigation-guide.js +500 -0
- package/frontend/src/lib/analysis/investigation-template-model.js +144 -0
- package/frontend/src/lib/analysis/page-baseline.js +272 -0
- package/frontend/src/lib/analysis/page-similarity.js +179 -0
- package/frontend/src/lib/analysis/relationship-evidence.js +265 -0
- package/frontend/src/lib/analysis/relationship-observation-model.js +423 -0
- package/frontend/src/lib/analysis/scoring.js +135 -0
- package/frontend/src/lib/analysis/shortlist-model.js +209 -0
- package/frontend/src/lib/analysis/utils.js +265 -0
- package/frontend/src/lib/analysis/watchlist-history.js +643 -0
- package/frontend/src/lib/analysis/watchlist-store.js +127 -0
- package/frontend/src/lib/analysis/website-snapshot-model.js +302 -0
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +282 -0
- package/frontend/src/lib/analysis/workspace-archive.js +472 -0
- package/lib/abort.mjs +26 -0
- package/lib/abusech-auth.mjs +19 -0
- package/lib/availability.mjs +716 -0
- package/lib/bounded-contract-normalizers.mjs +61 -0
- package/lib/browser-library-profile.mjs +310 -0
- package/lib/bulk-comparison-evidence.mjs +83 -0
- package/lib/classify.mjs +93 -0
- package/lib/client-behavior-profile.mjs +83 -0
- package/lib/credential-surface-profile.mjs +66 -0
- package/lib/csv.mjs +23 -0
- package/lib/ct-query.mjs +27 -0
- package/lib/ct-search.mjs +393 -0
- package/lib/distributed-operation-budget.mjs +461 -0
- package/lib/dns-delegation-health.mjs +344 -0
- package/lib/dns-intelligence.mjs +739 -0
- package/lib/dns-mx.mjs +24 -0
- package/lib/dns-resolver-selection.mjs +33 -0
- package/lib/domain-posture-analysis.mjs +254 -0
- package/lib/domain-posture-parsers.mjs +358 -0
- package/lib/domain-posture.mjs +745 -0
- package/lib/error-detail.mjs +10 -0
- package/lib/evidence-export.mjs +431 -0
- package/lib/evidence-report-markdown.mjs +71 -0
- package/lib/evidence-report.mjs +391 -0
- package/lib/external-intelligence-risk.mjs +108 -0
- package/lib/favicon.mjs +146 -0
- package/lib/feature-policy.mjs +70 -0
- package/lib/generated/retire-browser-catalog.mjs +8724 -0
- package/lib/generated/unicode-confusables-17.mjs +93 -0
- package/lib/hostname.mjs +38 -0
- package/lib/html-signals.mjs +680 -0
- package/lib/http-intelligence.mjs +232 -0
- package/lib/idn-confusable-policy.mjs +86 -0
- package/lib/idn-confusables.mjs +465 -0
- package/lib/lookup-cache.mjs +130 -0
- package/lib/lookup-progress.mjs +291 -0
- package/lib/lookup-source-progress.mjs +109 -0
- package/lib/lookup-threat-provider-inventory.mjs +13 -0
- package/lib/lookup.mjs +592 -0
- package/lib/observation.mjs +114 -0
- package/lib/observed-network-context.mjs +355 -0
- package/lib/operation-budget.mjs +489 -0
- package/lib/page-fingerprints.mjs +481 -0
- package/lib/page-role-profile.mjs +117 -0
- package/lib/perceptual-hash.mjs +451 -0
- package/lib/rdap-attempts.mjs +34 -0
- package/lib/rdap-bootstrap.mjs +173 -0
- package/lib/rdap-capabilities.mjs +292 -0
- package/lib/rdap-client.mjs +125 -0
- package/lib/rdap-normalization.mjs +691 -0
- package/lib/rdap-registrar.mjs +196 -0
- package/lib/rdap-transport.mjs +39 -0
- package/lib/rdap-types.mjs +1 -0
- package/lib/rdap-validation.mjs +94 -0
- package/lib/rdap.mjs +16 -0
- package/lib/registry-access.mjs +32 -0
- package/lib/registry-capabilities.mjs +117 -0
- package/lib/registry-capability-catalogue.mjs +1206 -0
- package/lib/registry-comparison.mjs +318 -0
- package/lib/registry-dates.mjs +190 -0
- package/lib/registry-insights.mjs +325 -0
- package/lib/response-policy.mjs +256 -0
- package/lib/risk-scoring.mjs +164 -0
- package/lib/safe-fetch.mjs +446 -0
- package/lib/security-txt.mjs +359 -0
- package/lib/service-binding-dns.mjs +608 -0
- package/lib/sslbl-certificates.generated.mjs +143 -0
- package/lib/sslbl-intelligence.mjs +241 -0
- package/lib/static-html-analysis.mjs +340 -0
- package/lib/structured-data-identity.mjs +323 -0
- package/lib/threat-intelligence-contract.mjs +5 -0
- package/lib/threat-intelligence-definition-registry.mjs +34 -0
- package/lib/threat-intelligence-fixtures.mjs +82 -0
- package/lib/threat-intelligence-manifests.mjs +512 -0
- package/lib/threat-intelligence-runtime.mjs +662 -0
- package/lib/threat-intelligence-targets.mjs +168 -0
- package/lib/threat-intelligence-types.mjs +3 -0
- package/lib/threatfox-intelligence.mjs +307 -0
- package/lib/tls-intelligence.mjs +814 -0
- package/lib/typosquat-generator.mjs +884 -0
- package/lib/urlhaus-intelligence.mjs +361 -0
- package/lib/urlscan-intelligence.mjs +349 -0
- package/lib/website-security-posture.mjs +325 -0
- package/lib/website-technology.mjs +437 -0
- package/lib/whois-authority.mjs +98 -0
- package/lib/whois-chain.mjs +261 -0
- package/lib/whois-contacts.mjs +163 -0
- package/lib/whois-contracts.mjs +3 -0
- package/lib/whois-normalization.mjs +27 -0
- package/lib/whois-transport.mjs +100 -0
- package/lib/whois-values.mjs +92 -0
- package/lib/whois.mjs +1187 -0
- package/package.json +55 -0
- package/third-party-notices.txt +5571 -0
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
// Bounded DNS wire support for RFC 9460 HTTPS and SVCB records.
|
|
2
|
+
//
|
|
3
|
+
// Node's DNS resolver does not expose RR types 64 or 65. This module sends one
|
|
4
|
+
// ordinary recursive query to a system-configured DNS server, retries only
|
|
5
|
+
// across that bounded trusted resolver list, and falls back to DNS-over-TCP
|
|
6
|
+
// only when the same resolver sets the truncated bit. Published service
|
|
7
|
+
// parameters are evidence only; they are never used as connection targets.
|
|
8
|
+
import { randomInt } from 'node:crypto';
|
|
9
|
+
import { getServers } from 'node:dns';
|
|
10
|
+
import { createSocket } from 'node:dgram';
|
|
11
|
+
import { createConnection, isIP } from 'node:net';
|
|
12
|
+
const SVCB_TYPE = 64;
|
|
13
|
+
const HTTPS_TYPE = 65;
|
|
14
|
+
const DNS_CLASS_IN = 1;
|
|
15
|
+
const DNS_HEADER_BYTES = 12;
|
|
16
|
+
const DNS_TIMEOUT_MS = 3500;
|
|
17
|
+
const MAX_DNS_MESSAGE_BYTES = 65_535;
|
|
18
|
+
const MAX_DNS_RESOLVERS = 3;
|
|
19
|
+
const MAX_DNS_RECORDS = 96;
|
|
20
|
+
const MAX_SERVICE_BINDING_RECORDS = 16;
|
|
21
|
+
const MAX_SERVICE_PARAMETERS = 24;
|
|
22
|
+
const MAX_MANDATORY_KEYS = 16;
|
|
23
|
+
const MAX_ALPN_IDS = 16;
|
|
24
|
+
const MAX_ALPN_DISPLAY_BYTES = 64;
|
|
25
|
+
const MAX_ADDRESS_HINTS = 8;
|
|
26
|
+
const MAX_NAME_POINTERS = 32;
|
|
27
|
+
const MAX_DNS_NAME_BYTES = 253;
|
|
28
|
+
const MAX_ERROR_LENGTH = 180;
|
|
29
|
+
const KNOWN_PARAMETER_NAMES = new Map([
|
|
30
|
+
[0, 'mandatory'],
|
|
31
|
+
[1, 'alpn'],
|
|
32
|
+
[2, 'no-default-alpn'],
|
|
33
|
+
[3, 'port'],
|
|
34
|
+
[4, 'ipv4hint'],
|
|
35
|
+
[5, 'ech'],
|
|
36
|
+
[6, 'ipv6hint'],
|
|
37
|
+
[7, 'dohpath'],
|
|
38
|
+
[8, 'ohttp'],
|
|
39
|
+
[9, 'tls-supported-groups'],
|
|
40
|
+
[10, 'docpath'],
|
|
41
|
+
[11, 'pvd'],
|
|
42
|
+
[12, 'oots'],
|
|
43
|
+
]);
|
|
44
|
+
const INTERPRETED_PARAMETER_KEYS = new Set([0, 1, 2, 3, 4, 6]);
|
|
45
|
+
class ServiceBindingDnsError extends Error {
|
|
46
|
+
code;
|
|
47
|
+
constructor(message, code = 'EBADRESP') {
|
|
48
|
+
super(message.slice(0, MAX_ERROR_LENGTH));
|
|
49
|
+
this.name = 'ServiceBindingDnsError';
|
|
50
|
+
this.code = code;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function assertRange(buffer, offset, length, boundary = buffer.length) {
|
|
54
|
+
if (!Number.isInteger(offset) || !Number.isInteger(length) || offset < 0 || length < 0 || offset + length > boundary) {
|
|
55
|
+
throw new ServiceBindingDnsError('DNS response ended inside a field');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function recordTypeCode(type) {
|
|
59
|
+
return type === 'SVCB' ? SVCB_TYPE : HTTPS_TYPE;
|
|
60
|
+
}
|
|
61
|
+
function normalizeQueryName(value) {
|
|
62
|
+
const name = String(value || '').trim().toLowerCase().replace(/\.+$/u, '');
|
|
63
|
+
const labels = name.split('.');
|
|
64
|
+
if (!name
|
|
65
|
+
|| Buffer.byteLength(name, 'ascii') > MAX_DNS_NAME_BYTES
|
|
66
|
+
|| labels.some((label) => !label || label.length > 63 || !/^[a-z0-9_-]+$/u.test(label))) {
|
|
67
|
+
throw new ServiceBindingDnsError('Invalid DNS service-binding query name', 'EINVAL');
|
|
68
|
+
}
|
|
69
|
+
return name;
|
|
70
|
+
}
|
|
71
|
+
function encodeDnsName(value) {
|
|
72
|
+
const name = normalizeQueryName(value);
|
|
73
|
+
const parts = [];
|
|
74
|
+
for (const label of name.split('.')) {
|
|
75
|
+
const bytes = Buffer.from(label, 'ascii');
|
|
76
|
+
parts.push(Buffer.from([bytes.length]), bytes);
|
|
77
|
+
}
|
|
78
|
+
parts.push(Buffer.from([0]));
|
|
79
|
+
return Buffer.concat(parts);
|
|
80
|
+
}
|
|
81
|
+
function buildServiceBindingDnsQuery(name, type, transactionId = randomInt(0x1_0000)) {
|
|
82
|
+
if (!Number.isInteger(transactionId) || transactionId < 0 || transactionId > 0xffff) {
|
|
83
|
+
throw new ServiceBindingDnsError('Invalid DNS transaction identifier', 'EINVAL');
|
|
84
|
+
}
|
|
85
|
+
const qname = encodeDnsName(name);
|
|
86
|
+
const message = Buffer.alloc(DNS_HEADER_BYTES + qname.length + 4);
|
|
87
|
+
message.writeUInt16BE(transactionId, 0);
|
|
88
|
+
message.writeUInt16BE(0x0100, 2);
|
|
89
|
+
message.writeUInt16BE(1, 4);
|
|
90
|
+
qname.copy(message, DNS_HEADER_BYTES);
|
|
91
|
+
message.writeUInt16BE(recordTypeCode(type), DNS_HEADER_BYTES + qname.length);
|
|
92
|
+
message.writeUInt16BE(DNS_CLASS_IN, DNS_HEADER_BYTES + qname.length + 2);
|
|
93
|
+
return message;
|
|
94
|
+
}
|
|
95
|
+
function decodeDnsName(message, startOffset, boundary = message.length, allowCompression = true) {
|
|
96
|
+
const labels = [];
|
|
97
|
+
const pointers = new Set();
|
|
98
|
+
let offset = startOffset;
|
|
99
|
+
let nextOffset = null;
|
|
100
|
+
let encodedBytes = 0;
|
|
101
|
+
while (true) {
|
|
102
|
+
assertRange(message, offset, 1, boundary);
|
|
103
|
+
const length = message[offset];
|
|
104
|
+
if ((length & 0xc0) === 0xc0) {
|
|
105
|
+
if (!allowCompression)
|
|
106
|
+
throw new ServiceBindingDnsError('Compressed SVCB target name is malformed');
|
|
107
|
+
assertRange(message, offset, 2, boundary);
|
|
108
|
+
const pointer = ((length & 0x3f) << 8) | message[offset + 1];
|
|
109
|
+
if (pointer >= message.length || pointers.has(pointer) || pointers.size >= MAX_NAME_POINTERS) {
|
|
110
|
+
throw new ServiceBindingDnsError('DNS name compression loop or invalid pointer');
|
|
111
|
+
}
|
|
112
|
+
pointers.add(pointer);
|
|
113
|
+
if (nextOffset === null)
|
|
114
|
+
nextOffset = offset + 2;
|
|
115
|
+
offset = pointer;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if ((length & 0xc0) !== 0)
|
|
119
|
+
throw new ServiceBindingDnsError('DNS name label uses an unsupported encoding');
|
|
120
|
+
offset += 1;
|
|
121
|
+
if (length === 0) {
|
|
122
|
+
if (nextOffset === null)
|
|
123
|
+
nextOffset = offset;
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
if (length > 63)
|
|
127
|
+
throw new ServiceBindingDnsError('DNS name label is too long');
|
|
128
|
+
assertRange(message, offset, length, boundary);
|
|
129
|
+
encodedBytes += length + 1;
|
|
130
|
+
if (encodedBytes > MAX_DNS_NAME_BYTES + 1)
|
|
131
|
+
throw new ServiceBindingDnsError('DNS name is too long');
|
|
132
|
+
const bytes = message.subarray(offset, offset + length);
|
|
133
|
+
if ([...bytes].some((byte) => byte < 0x21 || byte > 0x7e)) {
|
|
134
|
+
throw new ServiceBindingDnsError('DNS name contains an unsafe label byte');
|
|
135
|
+
}
|
|
136
|
+
const label = bytes.toString('ascii').toLowerCase();
|
|
137
|
+
if (!/^[a-z0-9_-]+$/u.test(label)) {
|
|
138
|
+
throw new ServiceBindingDnsError('DNS name contains an unsupported label');
|
|
139
|
+
}
|
|
140
|
+
labels.push(label);
|
|
141
|
+
offset += length;
|
|
142
|
+
}
|
|
143
|
+
return { name: labels.length ? labels.join('.') : '.', nextOffset: nextOffset };
|
|
144
|
+
}
|
|
145
|
+
function formatIpv4(bytes) {
|
|
146
|
+
return [...bytes].join('.');
|
|
147
|
+
}
|
|
148
|
+
function formatIpv6(bytes) {
|
|
149
|
+
if (bytes.length !== 16)
|
|
150
|
+
throw new ServiceBindingDnsError('Invalid IPv6 hint length');
|
|
151
|
+
const groups = Array.from({ length: 8 }, (_, index) => bytes.readUInt16BE(index * 2));
|
|
152
|
+
let bestStart = -1;
|
|
153
|
+
let bestLength = 0;
|
|
154
|
+
for (let index = 0; index < groups.length;) {
|
|
155
|
+
if (groups[index] !== 0) {
|
|
156
|
+
index += 1;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
let end = index;
|
|
160
|
+
while (end < groups.length && groups[end] === 0)
|
|
161
|
+
end += 1;
|
|
162
|
+
if (end - index > bestLength && end - index >= 2) {
|
|
163
|
+
bestStart = index;
|
|
164
|
+
bestLength = end - index;
|
|
165
|
+
}
|
|
166
|
+
index = end;
|
|
167
|
+
}
|
|
168
|
+
if (bestStart === -1)
|
|
169
|
+
return groups.map((group) => group.toString(16)).join(':');
|
|
170
|
+
const left = groups.slice(0, bestStart).map((group) => group.toString(16)).join(':');
|
|
171
|
+
const right = groups.slice(bestStart + bestLength).map((group) => group.toString(16)).join(':');
|
|
172
|
+
return `${left}::${right}`;
|
|
173
|
+
}
|
|
174
|
+
function boundedPush(target, value, limit) {
|
|
175
|
+
if (target.length >= limit)
|
|
176
|
+
return true;
|
|
177
|
+
target.push(value);
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
function displayAlpn(value) {
|
|
181
|
+
if (!value.length || value.length > MAX_ALPN_DISPLAY_BYTES)
|
|
182
|
+
return null;
|
|
183
|
+
if ([...value].every((byte) => byte >= 0x21 && byte <= 0x7e))
|
|
184
|
+
return value.toString('ascii');
|
|
185
|
+
return `hex:${value.toString('hex')}`;
|
|
186
|
+
}
|
|
187
|
+
function parseServiceParameters(message, startOffset, endOffset) {
|
|
188
|
+
const parameters = {
|
|
189
|
+
mandatory: [],
|
|
190
|
+
alpn: [],
|
|
191
|
+
noDefaultAlpn: false,
|
|
192
|
+
port: null,
|
|
193
|
+
ipv4hint: [],
|
|
194
|
+
ipv6hint: [],
|
|
195
|
+
opaque: [],
|
|
196
|
+
unknownKeys: [],
|
|
197
|
+
unsupportedMandatoryKeys: [],
|
|
198
|
+
};
|
|
199
|
+
const keys = new Set();
|
|
200
|
+
let offset = startOffset;
|
|
201
|
+
let previousKey = -1;
|
|
202
|
+
let truncated = false;
|
|
203
|
+
let parameterCount = 0;
|
|
204
|
+
while (offset < endOffset) {
|
|
205
|
+
assertRange(message, offset, 4, endOffset);
|
|
206
|
+
const key = message.readUInt16BE(offset);
|
|
207
|
+
const length = message.readUInt16BE(offset + 2);
|
|
208
|
+
offset += 4;
|
|
209
|
+
assertRange(message, offset, length, endOffset);
|
|
210
|
+
if (key <= previousKey)
|
|
211
|
+
throw new ServiceBindingDnsError('SVCB parameter keys are not strictly increasing');
|
|
212
|
+
previousKey = key;
|
|
213
|
+
keys.add(key);
|
|
214
|
+
parameterCount += 1;
|
|
215
|
+
if (parameterCount > MAX_SERVICE_PARAMETERS)
|
|
216
|
+
truncated = true;
|
|
217
|
+
const value = message.subarray(offset, offset + length);
|
|
218
|
+
offset += length;
|
|
219
|
+
if (key === 0) {
|
|
220
|
+
if (!length || length % 2 !== 0)
|
|
221
|
+
throw new ServiceBindingDnsError('Invalid mandatory SVCB parameter');
|
|
222
|
+
let priorMandatory = -1;
|
|
223
|
+
for (let index = 0; index < length; index += 2) {
|
|
224
|
+
const mandatoryKey = value.readUInt16BE(index);
|
|
225
|
+
if (mandatoryKey === 0 || mandatoryKey <= priorMandatory) {
|
|
226
|
+
throw new ServiceBindingDnsError('Invalid mandatory SVCB key list');
|
|
227
|
+
}
|
|
228
|
+
priorMandatory = mandatoryKey;
|
|
229
|
+
truncated = boundedPush(parameters.mandatory, mandatoryKey, MAX_MANDATORY_KEYS) || truncated;
|
|
230
|
+
}
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (key === 1) {
|
|
234
|
+
if (!length)
|
|
235
|
+
throw new ServiceBindingDnsError('Invalid empty ALPN SVCB parameter');
|
|
236
|
+
let alpnOffset = 0;
|
|
237
|
+
while (alpnOffset < value.length) {
|
|
238
|
+
const alpnLength = value[alpnOffset];
|
|
239
|
+
alpnOffset += 1;
|
|
240
|
+
if (!alpnLength || alpnOffset + alpnLength > value.length) {
|
|
241
|
+
throw new ServiceBindingDnsError('Invalid ALPN SVCB parameter');
|
|
242
|
+
}
|
|
243
|
+
const alpn = displayAlpn(value.subarray(alpnOffset, alpnOffset + alpnLength));
|
|
244
|
+
if (alpn)
|
|
245
|
+
truncated = boundedPush(parameters.alpn, alpn, MAX_ALPN_IDS) || truncated;
|
|
246
|
+
else
|
|
247
|
+
truncated = true;
|
|
248
|
+
alpnOffset += alpnLength;
|
|
249
|
+
}
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (key === 2) {
|
|
253
|
+
if (length !== 0)
|
|
254
|
+
throw new ServiceBindingDnsError('Invalid no-default-alpn SVCB parameter');
|
|
255
|
+
parameters.noDefaultAlpn = true;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (key === 3) {
|
|
259
|
+
if (length !== 2)
|
|
260
|
+
throw new ServiceBindingDnsError('Invalid port SVCB parameter');
|
|
261
|
+
parameters.port = value.readUInt16BE(0);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (key === 4) {
|
|
265
|
+
if (!length || length % 4 !== 0)
|
|
266
|
+
throw new ServiceBindingDnsError('Invalid IPv4 hint SVCB parameter');
|
|
267
|
+
for (let index = 0; index < length; index += 4) {
|
|
268
|
+
truncated = boundedPush(parameters.ipv4hint, formatIpv4(value.subarray(index, index + 4)), MAX_ADDRESS_HINTS) || truncated;
|
|
269
|
+
}
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (key === 6) {
|
|
273
|
+
if (!length || length % 16 !== 0)
|
|
274
|
+
throw new ServiceBindingDnsError('Invalid IPv6 hint SVCB parameter');
|
|
275
|
+
for (let index = 0; index < length; index += 16) {
|
|
276
|
+
truncated = boundedPush(parameters.ipv6hint, formatIpv6(value.subarray(index, index + 16)), MAX_ADDRESS_HINTS) || truncated;
|
|
277
|
+
}
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const opaque = {
|
|
281
|
+
key,
|
|
282
|
+
name: KNOWN_PARAMETER_NAMES.get(key) || null,
|
|
283
|
+
length,
|
|
284
|
+
};
|
|
285
|
+
if (KNOWN_PARAMETER_NAMES.has(key)) {
|
|
286
|
+
truncated = boundedPush(parameters.opaque, opaque, MAX_SERVICE_PARAMETERS) || truncated;
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
truncated = boundedPush(parameters.unknownKeys, key, MAX_SERVICE_PARAMETERS) || truncated;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (parameters.noDefaultAlpn && !keys.has(1)) {
|
|
293
|
+
throw new ServiceBindingDnsError('SVCB no-default-alpn parameter requires alpn');
|
|
294
|
+
}
|
|
295
|
+
for (const mandatoryKey of parameters.mandatory) {
|
|
296
|
+
if (!keys.has(mandatoryKey))
|
|
297
|
+
throw new ServiceBindingDnsError('SVCB mandatory key is absent from the record');
|
|
298
|
+
if (!INTERPRETED_PARAMETER_KEYS.has(mandatoryKey)) {
|
|
299
|
+
parameters.unsupportedMandatoryKeys.push(mandatoryKey);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return { parameters, truncated, keys };
|
|
303
|
+
}
|
|
304
|
+
function emptyParameters() {
|
|
305
|
+
return {
|
|
306
|
+
mandatory: [],
|
|
307
|
+
alpn: [],
|
|
308
|
+
noDefaultAlpn: false,
|
|
309
|
+
port: null,
|
|
310
|
+
ipv4hint: [],
|
|
311
|
+
ipv6hint: [],
|
|
312
|
+
opaque: [],
|
|
313
|
+
unknownKeys: [],
|
|
314
|
+
unsupportedMandatoryKeys: [],
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
function parseServiceBindingRecord(message, owner, type, ttl, rdataOffset, rdataEnd) {
|
|
318
|
+
assertRange(message, rdataOffset, 3, rdataEnd);
|
|
319
|
+
const priority = message.readUInt16BE(rdataOffset);
|
|
320
|
+
const targetResult = decodeDnsName(message, rdataOffset + 2, rdataEnd, false);
|
|
321
|
+
if (targetResult.nextOffset > rdataEnd)
|
|
322
|
+
throw new ServiceBindingDnsError('SVCB target exceeds its record');
|
|
323
|
+
const parsed = parseServiceParameters(message, targetResult.nextOffset, rdataEnd);
|
|
324
|
+
const mode = priority === 0 ? 'alias' : 'service';
|
|
325
|
+
const targetIsRoot = targetResult.name === '.';
|
|
326
|
+
const parametersIgnored = mode === 'alias' && parsed.keys.size > 0;
|
|
327
|
+
const parameters = parametersIgnored ? emptyParameters() : parsed.parameters;
|
|
328
|
+
const target = targetIsRoot
|
|
329
|
+
? mode === 'service' ? owner : null
|
|
330
|
+
: targetResult.name;
|
|
331
|
+
return {
|
|
332
|
+
record: {
|
|
333
|
+
type,
|
|
334
|
+
owner,
|
|
335
|
+
ttl,
|
|
336
|
+
priority,
|
|
337
|
+
mode,
|
|
338
|
+
target,
|
|
339
|
+
targetIsOwner: mode === 'service' && targetIsRoot,
|
|
340
|
+
serviceUnavailable: mode === 'alias' && targetIsRoot,
|
|
341
|
+
compatible: mode === 'alias' || parameters.unsupportedMandatoryKeys.length === 0,
|
|
342
|
+
parametersIgnored,
|
|
343
|
+
parameters,
|
|
344
|
+
},
|
|
345
|
+
truncated: parsed.truncated,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
function parseServiceBindingDnsResponse(messageValue, expected) {
|
|
349
|
+
const message = Buffer.from(messageValue);
|
|
350
|
+
if (message.length < DNS_HEADER_BYTES || message.length > MAX_DNS_MESSAGE_BYTES) {
|
|
351
|
+
throw new ServiceBindingDnsError('DNS response size is invalid');
|
|
352
|
+
}
|
|
353
|
+
const transactionId = message.readUInt16BE(0);
|
|
354
|
+
const flags = message.readUInt16BE(2);
|
|
355
|
+
const questionCount = message.readUInt16BE(4);
|
|
356
|
+
const answerCount = message.readUInt16BE(6);
|
|
357
|
+
const authorityCount = message.readUInt16BE(8);
|
|
358
|
+
const additionalCount = message.readUInt16BE(10);
|
|
359
|
+
const recordCount = answerCount + authorityCount + additionalCount;
|
|
360
|
+
if (transactionId !== expected.transactionId || (flags & 0x8000) === 0 || (flags & 0x7800) !== 0) {
|
|
361
|
+
throw new ServiceBindingDnsError('DNS response header does not match the query');
|
|
362
|
+
}
|
|
363
|
+
if (questionCount !== 1 || recordCount > MAX_DNS_RECORDS) {
|
|
364
|
+
throw new ServiceBindingDnsError('DNS response record count is invalid');
|
|
365
|
+
}
|
|
366
|
+
let offset = DNS_HEADER_BYTES;
|
|
367
|
+
const question = decodeDnsName(message, offset);
|
|
368
|
+
offset = question.nextOffset;
|
|
369
|
+
assertRange(message, offset, 4);
|
|
370
|
+
const questionType = message.readUInt16BE(offset);
|
|
371
|
+
const questionClass = message.readUInt16BE(offset + 2);
|
|
372
|
+
offset += 4;
|
|
373
|
+
if (question.name !== normalizeQueryName(expected.name)
|
|
374
|
+
|| questionType !== recordTypeCode(expected.type)
|
|
375
|
+
|| questionClass !== DNS_CLASS_IN) {
|
|
376
|
+
throw new ServiceBindingDnsError('DNS response question does not match the query');
|
|
377
|
+
}
|
|
378
|
+
const records = [];
|
|
379
|
+
let truncated = false;
|
|
380
|
+
for (let index = 0; index < recordCount; index += 1) {
|
|
381
|
+
const ownerResult = decodeDnsName(message, offset);
|
|
382
|
+
offset = ownerResult.nextOffset;
|
|
383
|
+
assertRange(message, offset, 10);
|
|
384
|
+
const rrType = message.readUInt16BE(offset);
|
|
385
|
+
const rrClass = message.readUInt16BE(offset + 2);
|
|
386
|
+
const ttl = message.readUInt32BE(offset + 4);
|
|
387
|
+
const rdataLength = message.readUInt16BE(offset + 8);
|
|
388
|
+
offset += 10;
|
|
389
|
+
const rdataEnd = offset + rdataLength;
|
|
390
|
+
assertRange(message, offset, rdataLength);
|
|
391
|
+
if (index < answerCount
|
|
392
|
+
&& rrType === recordTypeCode(expected.type)
|
|
393
|
+
&& rrClass === DNS_CLASS_IN) {
|
|
394
|
+
const parsed = parseServiceBindingRecord(message, ownerResult.name, expected.type, ttl, offset, rdataEnd);
|
|
395
|
+
truncated = parsed.truncated || truncated;
|
|
396
|
+
if (records.length < MAX_SERVICE_BINDING_RECORDS)
|
|
397
|
+
records.push(parsed.record);
|
|
398
|
+
else
|
|
399
|
+
truncated = true;
|
|
400
|
+
}
|
|
401
|
+
offset = rdataEnd;
|
|
402
|
+
}
|
|
403
|
+
if (offset !== message.length)
|
|
404
|
+
throw new ServiceBindingDnsError('DNS response has trailing bytes');
|
|
405
|
+
const unique = new Map();
|
|
406
|
+
for (const record of records)
|
|
407
|
+
unique.set(JSON.stringify(record), record);
|
|
408
|
+
const sorted = [...unique.values()].sort((left, right) => (left.priority - right.priority
|
|
409
|
+
|| left.owner.localeCompare(right.owner)
|
|
410
|
+
|| String(left.target).localeCompare(String(right.target))
|
|
411
|
+
|| left.ttl - right.ttl));
|
|
412
|
+
return {
|
|
413
|
+
records: sorted,
|
|
414
|
+
truncated,
|
|
415
|
+
truncatedResponse: (flags & 0x0200) !== 0,
|
|
416
|
+
rcode: flags & 0x000f,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
function parseResolverEndpoint(value) {
|
|
420
|
+
const server = String(value || '').trim();
|
|
421
|
+
if (!server)
|
|
422
|
+
return null;
|
|
423
|
+
const directFamily = isIP(server);
|
|
424
|
+
if (directFamily === 4 || directFamily === 6) {
|
|
425
|
+
return { address: server, port: 53, family: directFamily };
|
|
426
|
+
}
|
|
427
|
+
const bracketed = server.match(/^\[([^\]]+)\]:(\d{1,5})$/u);
|
|
428
|
+
if (bracketed && isIP(bracketed[1]) === 6) {
|
|
429
|
+
const port = Number(bracketed[2]);
|
|
430
|
+
return port >= 1 && port <= 65535
|
|
431
|
+
? { address: bracketed[1], port, family: 6 }
|
|
432
|
+
: null;
|
|
433
|
+
}
|
|
434
|
+
const ipv4Port = server.match(/^([^:]+):(\d{1,5})$/u);
|
|
435
|
+
if (ipv4Port && isIP(ipv4Port[1]) === 4) {
|
|
436
|
+
const port = Number(ipv4Port[2]);
|
|
437
|
+
return port >= 1 && port <= 65535
|
|
438
|
+
? { address: ipv4Port[1], port, family: 4 }
|
|
439
|
+
: null;
|
|
440
|
+
}
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
function defaultTcpExchange(query, resolver, options) {
|
|
444
|
+
return new Promise((resolve, reject) => {
|
|
445
|
+
const socket = createConnection({
|
|
446
|
+
host: resolver.address,
|
|
447
|
+
port: resolver.port,
|
|
448
|
+
family: resolver.family,
|
|
449
|
+
});
|
|
450
|
+
const chunks = [];
|
|
451
|
+
let totalLength = 0;
|
|
452
|
+
let settled = false;
|
|
453
|
+
const finish = (error, response) => {
|
|
454
|
+
if (settled)
|
|
455
|
+
return;
|
|
456
|
+
settled = true;
|
|
457
|
+
socket.destroy();
|
|
458
|
+
if (error)
|
|
459
|
+
reject(error);
|
|
460
|
+
else
|
|
461
|
+
resolve(response);
|
|
462
|
+
};
|
|
463
|
+
socket.setTimeout(options.timeoutMs, () => finish(new ServiceBindingDnsError('DNS TCP query timed out', 'ETIMEOUT')));
|
|
464
|
+
socket.once('error', (error) => finish(error));
|
|
465
|
+
socket.on('data', (chunk) => {
|
|
466
|
+
totalLength += chunk.length;
|
|
467
|
+
if (totalLength > MAX_DNS_MESSAGE_BYTES + 2) {
|
|
468
|
+
finish(new ServiceBindingDnsError('DNS TCP response exceeds the size limit'));
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
chunks.push(chunk);
|
|
472
|
+
const buffered = Buffer.concat(chunks, totalLength);
|
|
473
|
+
if (buffered.length < 2)
|
|
474
|
+
return;
|
|
475
|
+
const expectedLength = buffered.readUInt16BE(0);
|
|
476
|
+
if (!expectedLength || expectedLength > MAX_DNS_MESSAGE_BYTES) {
|
|
477
|
+
finish(new ServiceBindingDnsError('DNS TCP response length is invalid'));
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (buffered.length >= expectedLength + 2) {
|
|
481
|
+
if (buffered.length !== expectedLength + 2) {
|
|
482
|
+
finish(new ServiceBindingDnsError('DNS TCP response has trailing bytes'));
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
finish(undefined, buffered.subarray(2));
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
socket.once('connect', () => {
|
|
489
|
+
const length = Buffer.alloc(2);
|
|
490
|
+
length.writeUInt16BE(query.length, 0);
|
|
491
|
+
socket.write(Buffer.concat([length, query]));
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
function defaultDnsExchange(query, resolver, options) {
|
|
496
|
+
return new Promise((resolve, reject) => {
|
|
497
|
+
const socket = createSocket(resolver.family === 6 ? 'udp6' : 'udp4');
|
|
498
|
+
let settled = false;
|
|
499
|
+
const timer = setTimeout(() => {
|
|
500
|
+
finish(new ServiceBindingDnsError('DNS UDP query timed out', 'ETIMEOUT'));
|
|
501
|
+
}, options.timeoutMs);
|
|
502
|
+
const finish = (error, response) => {
|
|
503
|
+
if (settled)
|
|
504
|
+
return;
|
|
505
|
+
settled = true;
|
|
506
|
+
clearTimeout(timer);
|
|
507
|
+
try {
|
|
508
|
+
socket.close();
|
|
509
|
+
}
|
|
510
|
+
catch {
|
|
511
|
+
// A synchronous send failure can occur before the UDP socket binds.
|
|
512
|
+
}
|
|
513
|
+
if (error)
|
|
514
|
+
reject(error);
|
|
515
|
+
else
|
|
516
|
+
resolve(response);
|
|
517
|
+
};
|
|
518
|
+
socket.once('error', (error) => finish(error));
|
|
519
|
+
socket.once('message', (message) => {
|
|
520
|
+
if (message.length > MAX_DNS_MESSAGE_BYTES) {
|
|
521
|
+
finish(new ServiceBindingDnsError('DNS UDP response exceeds the size limit'));
|
|
522
|
+
}
|
|
523
|
+
else {
|
|
524
|
+
finish(undefined, Buffer.from(message));
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
socket.connect(resolver.port, resolver.address, () => {
|
|
528
|
+
socket.send(query, (error) => {
|
|
529
|
+
if (error)
|
|
530
|
+
finish(error);
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
function isTruncatedDnsResponse(message, transactionId) {
|
|
536
|
+
return message.length >= DNS_HEADER_BYTES
|
|
537
|
+
&& message.length <= MAX_DNS_MESSAGE_BYTES
|
|
538
|
+
&& message.readUInt16BE(0) === transactionId
|
|
539
|
+
&& (message.readUInt16BE(2) & 0x8200) === 0x8200;
|
|
540
|
+
}
|
|
541
|
+
function missingResponseError(rcode) {
|
|
542
|
+
if (rcode === 0)
|
|
543
|
+
return null;
|
|
544
|
+
if (rcode === 3)
|
|
545
|
+
return new ServiceBindingDnsError('DNS name does not exist', 'ENOTFOUND');
|
|
546
|
+
const names = {
|
|
547
|
+
1: 'format error',
|
|
548
|
+
2: 'server failure',
|
|
549
|
+
4: 'not implemented',
|
|
550
|
+
5: 'query refused',
|
|
551
|
+
};
|
|
552
|
+
return new ServiceBindingDnsError(`DNS resolver returned ${names[rcode] || `rcode ${rcode}`}`, 'EDNSFAIL');
|
|
553
|
+
}
|
|
554
|
+
async function resolveServiceBindingRecords(nameValue, type, options = {}) {
|
|
555
|
+
const name = normalizeQueryName(nameValue);
|
|
556
|
+
const transactionId = options.transactionId ?? randomInt(0x1_0000);
|
|
557
|
+
const query = buildServiceBindingDnsQuery(name, type, transactionId);
|
|
558
|
+
const timeoutMs = Math.max(250, Math.min(10_000, Number(options.timeoutMs) || DNS_TIMEOUT_MS));
|
|
559
|
+
const resolvers = [...new Set((options.servers || getServers())
|
|
560
|
+
.map(parseResolverEndpoint)
|
|
561
|
+
.filter((value) => value !== null)
|
|
562
|
+
.map((value) => JSON.stringify(value)))]
|
|
563
|
+
.slice(0, MAX_DNS_RESOLVERS)
|
|
564
|
+
.map((value) => JSON.parse(value));
|
|
565
|
+
if (!resolvers.length)
|
|
566
|
+
throw new ServiceBindingDnsError('No eligible system DNS resolver is configured', 'ENODATA');
|
|
567
|
+
const exchange = options.exchange || defaultDnsExchange;
|
|
568
|
+
const deadline = Date.now() + timeoutMs;
|
|
569
|
+
let lastError = null;
|
|
570
|
+
for (const resolver of resolvers) {
|
|
571
|
+
const udpTimeoutMs = deadline - Date.now();
|
|
572
|
+
if (udpTimeoutMs <= 0)
|
|
573
|
+
break;
|
|
574
|
+
try {
|
|
575
|
+
let response = await exchange(query, resolver, { timeoutMs: udpTimeoutMs });
|
|
576
|
+
if (isTruncatedDnsResponse(response, transactionId)) {
|
|
577
|
+
const tcpTimeoutMs = deadline - Date.now();
|
|
578
|
+
if (tcpTimeoutMs <= 0) {
|
|
579
|
+
throw new ServiceBindingDnsError('DNS service-binding query timed out', 'ETIMEOUT');
|
|
580
|
+
}
|
|
581
|
+
response = await (options.tcpExchange || defaultTcpExchange)(query, resolver, { timeoutMs: tcpTimeoutMs });
|
|
582
|
+
}
|
|
583
|
+
const parsed = parseServiceBindingDnsResponse(response, { transactionId, name, type });
|
|
584
|
+
if (parsed.truncatedResponse)
|
|
585
|
+
throw new ServiceBindingDnsError('DNS TCP response is still truncated');
|
|
586
|
+
const responseError = missingResponseError(parsed.rcode);
|
|
587
|
+
if (responseError)
|
|
588
|
+
throw responseError;
|
|
589
|
+
if (!parsed.records.length)
|
|
590
|
+
throw new ServiceBindingDnsError('No service-binding records were returned', 'ENODATA');
|
|
591
|
+
return { records: parsed.records, truncated: parsed.truncated };
|
|
592
|
+
}
|
|
593
|
+
catch (error) {
|
|
594
|
+
lastError = error;
|
|
595
|
+
const code = error && typeof error === 'object' ? String(error.code || '') : '';
|
|
596
|
+
if (code === 'ENODATA' || code === 'ENOTFOUND')
|
|
597
|
+
throw error;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
if (Date.now() >= deadline) {
|
|
601
|
+
throw new ServiceBindingDnsError('DNS service-binding query timed out', 'ETIMEOUT');
|
|
602
|
+
}
|
|
603
|
+
const detail = String(lastError instanceof Error ? lastError.message : lastError || 'DNS query failed')
|
|
604
|
+
.replace(/[\u0000-\u001f\u007f]+/gu, ' ')
|
|
605
|
+
.slice(0, MAX_ERROR_LENGTH);
|
|
606
|
+
throw new ServiceBindingDnsError(detail || 'DNS query failed', 'EDNSFAIL');
|
|
607
|
+
}
|
|
608
|
+
export { DNS_TIMEOUT_MS as SERVICE_BINDING_DNS_TIMEOUT_MS, HTTPS_TYPE, MAX_SERVICE_BINDING_RECORDS, SVCB_TYPE, ServiceBindingDnsError, buildServiceBindingDnsQuery, formatIpv6, parseResolverEndpoint, parseServiceBindingDnsResponse, resolveServiceBindingRecords, };
|