@slicedearth/whoisleuth-cli 1.39.1 → 1.45.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/README.md +23 -4
- package/bin/whoisleuth.mjs +31 -9
- package/cli/archive-inspect.mjs +37 -2
- package/cli/arguments.mjs +361 -256
- package/cli/artifact-verify.mjs +35 -1
- package/cli/bulk-checkpoint.mjs +72 -39
- package/cli/bulk-command-runner.mjs +34 -4
- package/cli/bulk.mjs +18 -7
- package/cli/case-pack.mjs +136 -0
- package/cli/ci-report.mjs +73 -0
- package/cli/collection-preflight.mjs +59 -0
- package/cli/command-reference.mjs +395 -0
- package/cli/completion.mjs +67 -13
- package/cli/config-profile.mjs +183 -0
- package/cli/ct-event-intake.mjs +159 -0
- package/cli/discovery-observation-snapshot.mjs +8 -14
- package/cli/discovery-scan-command-runner.mjs +29 -2
- package/cli/discovery-snapshot.mjs +8 -15
- package/cli/domain-control-monitor.mjs +104 -0
- package/cli/domain-control-observations.mjs +235 -0
- package/cli/errors.mjs +7 -1
- package/cli/evidence-command-arguments.mjs +11 -0
- package/cli/evidence-command-runner.mjs +1 -0
- package/cli/external-observation-mapping.mjs +162 -0
- package/cli/fail-policy.mjs +64 -0
- package/cli/formatters/html.mjs +1 -1
- package/cli/formatters/json.mjs +12 -4
- package/cli/formatters/terminal.mjs +1 -1
- package/cli/http.mjs +2 -1
- package/cli/investigation-manifest.mjs +113 -0
- package/cli/investigation-run.mjs +112 -0
- package/cli/lookup-brief.mjs +116 -0
- package/cli/lookup-command-runner.mjs +8 -1
- package/cli/lookup-diff.mjs +17 -19
- package/cli/lookup-reconcile.mjs +1 -1
- package/cli/mail-review.mjs +5 -5
- package/cli/manual.mjs +1 -1
- package/cli/network-command-runner.mjs +4 -1
- package/cli/offline-evidence-review.mjs +105 -8
- package/cli/open-asset-model-bridge.mjs +169 -0
- package/cli/registry-cohort.mjs +121 -0
- package/cli/registry-doctor.mjs +106 -11
- package/cli/registry-fixture-scaffold.mjs +74 -0
- package/cli/runner.mjs +388 -315
- package/docs/cli-reference.md +266 -21
- package/docs/cli.md +43 -7
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +2 -2
- package/frontend/src/lib/analysis/brand-profile-model.js +50 -6
- package/frontend/src/lib/analysis/case-evidence-model.js +1 -1
- package/frontend/src/lib/analysis/case-record-core.js +1 -1
- package/frontend/src/lib/analysis/case-record-operations.js +1 -1
- package/frontend/src/lib/analysis/case-report.js +573 -0
- package/frontend/src/lib/analysis/case-response-model.js +30 -1
- package/frontend/src/lib/analysis/detection-rule-model.js +36 -0
- package/frontend/src/lib/analysis/external-findings-import.js +320 -0
- package/frontend/src/lib/analysis/lookup-asset-graph.js +8 -8
- package/frontend/src/lib/analysis/lookup-decision-support.js +9 -2
- package/frontend/src/lib/analysis/lookup-presentation.js +14 -30
- package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
- package/frontend/src/lib/analysis/scoring.js +1 -1
- package/frontend/src/lib/analysis/utils.js +1 -1
- package/frontend/src/lib/analysis/visualization-models.js +2 -3
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -4
- package/frontend/src/lib/analysis/workspace-archive.js +5 -7
- package/lib/abusech-auth.mjs +1 -1
- package/lib/availability.mjs +1 -1
- package/lib/bounded-contract-normalizers.mjs +33 -0
- package/lib/bounded-file.mjs +69 -0
- package/lib/bounded-json-response.mjs +157 -0
- package/lib/browser-library-profile.mjs +167 -38
- package/lib/dns-convergence-review.mjs +183 -0
- package/lib/dns-delegation-health.mjs +196 -3
- package/lib/domain-change-packet.mjs +133 -0
- package/lib/domain-change-review.mjs +307 -0
- package/lib/domain-control-flight-recorder.mjs +268 -0
- package/lib/domain-portfolio-review.mjs +151 -0
- package/lib/evidence-export.mjs +1 -1
- package/lib/html-signals.mjs +5 -11
- package/lib/lookup-cache.mjs +5 -1
- package/lib/nameserver-preflight-review.mjs +159 -0
- package/lib/page-language-signals.mjs +124 -0
- package/lib/rdap-transport.mjs +6 -6
- package/lib/risk-scoring.mjs +42 -1
- package/lib/safe-fetch.mjs +37 -32
- package/lib/static-html-analysis.mjs +29 -1
- package/lib/threat-intelligence-runtime.mjs +1 -2
- package/lib/trust-store-comparison.mjs +146 -0
- package/lib/website-technology.mjs +22 -12
- package/lib/zone-intent-review.mjs +534 -0
- package/package.json +1 -1
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { isIP } from 'node:net';
|
|
3
|
+
import { domainToASCII } from 'node:url';
|
|
4
|
+
import { exactKeys, requireIsoTimestamp, requireRecord } from "./bounded-contract-normalizers.mjs";
|
|
5
|
+
export const ZONE_INTENT_INPUT_SCHEMA = 'whoisleuth.zone-intent.input';
|
|
6
|
+
export const ZONE_INTENT_REVIEW_SCHEMA = 'whoisleuth.zone-intent.review';
|
|
7
|
+
export const ZONE_INTENT_REVIEW_VERSION = 1;
|
|
8
|
+
export const MAX_ZONE_TEXT_BYTES = 1024 * 1024;
|
|
9
|
+
export const MAX_ZONE_RECORDS = 5_000;
|
|
10
|
+
export const MAX_ZONE_COMPARISONS = 2_000;
|
|
11
|
+
const ROOT_KEYS = new Set(['schema', 'version', 'origin', 'desired', 'observed']);
|
|
12
|
+
const DESIRED_KEYS = new Set(['format', 'zoneText', 'records']);
|
|
13
|
+
const OBSERVED_KEYS = new Set(['state', 'source', 'observedAt', 'records']);
|
|
14
|
+
const RECORD_KEYS = new Set(['owner', 'ttl', 'type', 'value']);
|
|
15
|
+
const SUPPORTED_TYPES = new Set([
|
|
16
|
+
'A', 'AAAA', 'CAA', 'CDNSKEY', 'CDS', 'CNAME', 'CSYNC', 'DS', 'HTTPS', 'MX', 'NS', 'SRV', 'SVCB', 'TLSA', 'TXT',
|
|
17
|
+
]);
|
|
18
|
+
function boundedText(value, label, maximum = 500) {
|
|
19
|
+
if (typeof value !== 'string' || value.length > maximum || /[\u0000-\u001f\u007f]/u.test(value)) {
|
|
20
|
+
throw new TypeError(`${label} must be bounded text without control characters.`);
|
|
21
|
+
}
|
|
22
|
+
const normalised = value.replace(/\s+/gu, ' ').trim();
|
|
23
|
+
if (!normalised || normalised.length > maximum)
|
|
24
|
+
throw new TypeError(`${label} must contain from 1 to ${maximum} characters.`);
|
|
25
|
+
return normalised;
|
|
26
|
+
}
|
|
27
|
+
function timestamp(value, label) {
|
|
28
|
+
return requireIsoTimestamp(value, label);
|
|
29
|
+
}
|
|
30
|
+
function domainName(value, origin, label, mode = 'absolute') {
|
|
31
|
+
const supplied = boundedText(value, label, 253).toLowerCase();
|
|
32
|
+
const absolute = supplied.endsWith('.');
|
|
33
|
+
const text = supplied.replace(/\.$/u, '');
|
|
34
|
+
const candidate = text === '@'
|
|
35
|
+
? origin
|
|
36
|
+
: !origin || absolute || mode === 'absolute'
|
|
37
|
+
? text
|
|
38
|
+
: `${text}.${origin}`;
|
|
39
|
+
const ascii = domainToASCII(candidate ?? '');
|
|
40
|
+
if (!ascii || !ascii.includes('.') || ascii.length > 253 || ascii.split('.').some((part) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(part))) {
|
|
41
|
+
throw new TypeError(`${label} must be a valid domain name.`);
|
|
42
|
+
}
|
|
43
|
+
return ascii;
|
|
44
|
+
}
|
|
45
|
+
function ownerName(value, origin, label, mode = 'absolute') {
|
|
46
|
+
const supplied = boundedText(value, label, 253).toLowerCase();
|
|
47
|
+
const absolute = supplied.endsWith('.');
|
|
48
|
+
const text = supplied.replace(/\.$/u, '');
|
|
49
|
+
const candidate = text === '@'
|
|
50
|
+
? origin
|
|
51
|
+
: absolute || mode === 'absolute'
|
|
52
|
+
? text
|
|
53
|
+
: `${text}.${origin}`;
|
|
54
|
+
if (!candidate || candidate.length > 253 || candidate.split('.').some((part) => (!part
|
|
55
|
+
|| part.length > 63
|
|
56
|
+
|| (part !== '*' && !/^[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$/u.test(part)))))
|
|
57
|
+
throw new TypeError(`${label} must be a valid DNS owner name.`);
|
|
58
|
+
return candidate;
|
|
59
|
+
}
|
|
60
|
+
function integerToken(value, minimum, maximum, label) {
|
|
61
|
+
if (!/^\d+$/u.test(value))
|
|
62
|
+
throw new TypeError(`${label} must be an integer.`);
|
|
63
|
+
const parsed = Number(value);
|
|
64
|
+
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum)
|
|
65
|
+
throw new TypeError(`${label} is outside its supported range.`);
|
|
66
|
+
return parsed;
|
|
67
|
+
}
|
|
68
|
+
function canonicalText(value) {
|
|
69
|
+
return value.replace(/\s+/gu, ' ').trim();
|
|
70
|
+
}
|
|
71
|
+
function txtDigest(value) {
|
|
72
|
+
return `sha256:${createHash('sha256').update(value).digest('hex')}`;
|
|
73
|
+
}
|
|
74
|
+
function boundedRecordText(value, label, maximum) {
|
|
75
|
+
if (typeof value !== 'string' || value.length > maximum || /[\u0000-\u001f\u007f]/u.test(value)) {
|
|
76
|
+
throw new TypeError(`${label} must be bounded text without control characters.`);
|
|
77
|
+
}
|
|
78
|
+
const trimmed = value.trim();
|
|
79
|
+
if (!trimmed)
|
|
80
|
+
throw new TypeError(`${label} must contain from 1 to ${maximum} characters.`);
|
|
81
|
+
return trimmed;
|
|
82
|
+
}
|
|
83
|
+
function decodeTxtRdata(rawValue) {
|
|
84
|
+
const value = boundedRecordText(rawValue, 'TXT data', 16_384);
|
|
85
|
+
if (!value.startsWith('"'))
|
|
86
|
+
return value;
|
|
87
|
+
let index = 0;
|
|
88
|
+
let output = '';
|
|
89
|
+
let segments = 0;
|
|
90
|
+
while (index < value.length) {
|
|
91
|
+
while (/\s/u.test(value[index] ?? ''))
|
|
92
|
+
index += 1;
|
|
93
|
+
if (index >= value.length)
|
|
94
|
+
break;
|
|
95
|
+
if (value[index] !== '"')
|
|
96
|
+
throw new TypeError('TXT quoted character strings are malformed.');
|
|
97
|
+
index += 1;
|
|
98
|
+
segments += 1;
|
|
99
|
+
let closed = false;
|
|
100
|
+
while (index < value.length) {
|
|
101
|
+
const character = value[index];
|
|
102
|
+
if (character === '"') {
|
|
103
|
+
closed = true;
|
|
104
|
+
index += 1;
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
if (character !== '\\') {
|
|
108
|
+
output += character;
|
|
109
|
+
index += 1;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const decimal = value.slice(index + 1, index + 4);
|
|
113
|
+
if (/^\d{3}$/u.test(decimal)) {
|
|
114
|
+
const byte = Number(decimal);
|
|
115
|
+
if (byte > 255)
|
|
116
|
+
throw new TypeError('TXT decimal escape is outside the supported byte range.');
|
|
117
|
+
output += String.fromCharCode(byte);
|
|
118
|
+
index += 4;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const escaped = value[index + 1];
|
|
122
|
+
if (escaped === undefined)
|
|
123
|
+
throw new TypeError('TXT data ends inside an escape sequence.');
|
|
124
|
+
output += escaped;
|
|
125
|
+
index += 2;
|
|
126
|
+
}
|
|
127
|
+
if (!closed)
|
|
128
|
+
throw new TypeError('TXT quoted character string is unterminated.');
|
|
129
|
+
if (index < value.length && !/\s/u.test(value[index] ?? '')) {
|
|
130
|
+
throw new TypeError('TXT quoted character strings must be separated by whitespace.');
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (!segments || !output || output.length > 16_384)
|
|
134
|
+
throw new TypeError('TXT data is invalid or too large.');
|
|
135
|
+
return output;
|
|
136
|
+
}
|
|
137
|
+
export function normaliseRdata(type, rawValue, origin, nameMode = 'absolute') {
|
|
138
|
+
const value = type === 'TXT' ? boundedRecordText(rawValue, 'TXT data', 16_384) : canonicalText(rawValue);
|
|
139
|
+
const tokens = value.split(' ');
|
|
140
|
+
const host = (input, label) => domainName(input, origin, label, nameMode);
|
|
141
|
+
if (type === 'A' || type === 'AAAA') {
|
|
142
|
+
const family = type === 'A' ? 4 : 6;
|
|
143
|
+
if (tokens.length !== 1 || isIP(tokens[0] ?? '') !== family)
|
|
144
|
+
throw new TypeError(`${type} data must contain one valid address.`);
|
|
145
|
+
return { value: tokens[0].toLowerCase(), valueTreatment: 'normalised' };
|
|
146
|
+
}
|
|
147
|
+
if (type === 'CNAME' || type === 'NS') {
|
|
148
|
+
if (tokens.length !== 1)
|
|
149
|
+
throw new TypeError(`${type} data must contain one hostname.`);
|
|
150
|
+
return { value: host(tokens[0], `${type} target`), valueTreatment: 'normalised' };
|
|
151
|
+
}
|
|
152
|
+
if (type === 'MX') {
|
|
153
|
+
if (tokens.length !== 2)
|
|
154
|
+
throw new TypeError('MX data must contain preference and exchange.');
|
|
155
|
+
return { value: `${integerToken(tokens[0], 0, 65_535, 'MX preference')} ${host(tokens[1], 'MX exchange')}`, valueTreatment: 'normalised' };
|
|
156
|
+
}
|
|
157
|
+
if (type === 'CAA') {
|
|
158
|
+
if (tokens.length < 3)
|
|
159
|
+
throw new TypeError('CAA data must contain flags, tag, and value.');
|
|
160
|
+
const flags = integerToken(tokens[0], 0, 255, 'CAA flags');
|
|
161
|
+
const tag = tokens[1].toLowerCase();
|
|
162
|
+
if (!/^[a-z0-9-]{1,15}$/u.test(tag))
|
|
163
|
+
throw new TypeError('CAA tag is invalid.');
|
|
164
|
+
const caaValue = tokens.slice(2).join(' ').replace(/^"|"$/gu, '');
|
|
165
|
+
if (!caaValue || caaValue.length > 500)
|
|
166
|
+
throw new TypeError('CAA value is invalid.');
|
|
167
|
+
return { value: `${flags} ${tag} ${caaValue}`, valueTreatment: 'normalised' };
|
|
168
|
+
}
|
|
169
|
+
if (type === 'DS' || type === 'CDS') {
|
|
170
|
+
if (tokens.length !== 4 || !/^[a-f0-9]{2,128}$/iu.test(tokens[3] ?? ''))
|
|
171
|
+
throw new TypeError(`${type} data is invalid.`);
|
|
172
|
+
return {
|
|
173
|
+
value: `${integerToken(tokens[0], 0, 65_535, `${type} key tag`)} ${integerToken(tokens[1], 0, 255, `${type} algorithm`)} ${integerToken(tokens[2], 0, 255, `${type} digest type`)} ${tokens[3].toLowerCase()}`,
|
|
174
|
+
valueTreatment: 'normalised',
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
if (type === 'CDNSKEY') {
|
|
178
|
+
if (tokens.length !== 4 || !/^[a-z0-9+/=]{1,4096}$/iu.test(tokens[3] ?? ''))
|
|
179
|
+
throw new TypeError('CDNSKEY data is invalid.');
|
|
180
|
+
return {
|
|
181
|
+
value: `${integerToken(tokens[0], 0, 65_535, 'CDNSKEY flags')} ${integerToken(tokens[1], 0, 255, 'CDNSKEY protocol')} ${integerToken(tokens[2], 0, 255, 'CDNSKEY algorithm')} ${tokens[3]}`,
|
|
182
|
+
valueTreatment: 'normalised',
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
if (type === 'CSYNC') {
|
|
186
|
+
if (tokens.length < 3)
|
|
187
|
+
throw new TypeError('CSYNC data must contain serial, flags, and record types.');
|
|
188
|
+
const types = [...new Set(tokens.slice(2).map((token) => token.toUpperCase()))].sort();
|
|
189
|
+
if (types.some((token) => !/^[A-Z][A-Z0-9-]{0,15}$/u.test(token)))
|
|
190
|
+
throw new TypeError('CSYNC record types are invalid.');
|
|
191
|
+
return { value: `${integerToken(tokens[0], 0, 0xffff_ffff, 'CSYNC serial')} ${integerToken(tokens[1], 0, 65_535, 'CSYNC flags')} ${types.join(' ')}`, valueTreatment: 'normalised' };
|
|
192
|
+
}
|
|
193
|
+
if (type === 'SRV') {
|
|
194
|
+
if (tokens.length !== 4)
|
|
195
|
+
throw new TypeError('SRV data must contain priority, weight, port, and target.');
|
|
196
|
+
return {
|
|
197
|
+
value: `${integerToken(tokens[0], 0, 65_535, 'SRV priority')} ${integerToken(tokens[1], 0, 65_535, 'SRV weight')} ${integerToken(tokens[2], 0, 65_535, 'SRV port')} ${host(tokens[3], 'SRV target')}`,
|
|
198
|
+
valueTreatment: 'normalised',
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
if (type === 'TLSA') {
|
|
202
|
+
if (tokens.length !== 4 || !/^[a-f0-9]{2,8192}$/iu.test(tokens[3] ?? ''))
|
|
203
|
+
throw new TypeError('TLSA data is invalid.');
|
|
204
|
+
return {
|
|
205
|
+
value: `${integerToken(tokens[0], 0, 3, 'TLSA usage')} ${integerToken(tokens[1], 0, 1, 'TLSA selector')} ${integerToken(tokens[2], 0, 2, 'TLSA matching type')} ${tokens[3].toLowerCase()}`,
|
|
206
|
+
valueTreatment: 'normalised',
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
if (type === 'SVCB' || type === 'HTTPS') {
|
|
210
|
+
if (tokens.length < 2)
|
|
211
|
+
throw new TypeError(`${type} data must contain priority and target.`);
|
|
212
|
+
const priority = integerToken(tokens[0], 0, 65_535, `${type} priority`);
|
|
213
|
+
const target = tokens[1] === '.' ? '.' : host(tokens[1], `${type} target`);
|
|
214
|
+
const parameters = tokens.slice(2);
|
|
215
|
+
if (parameters.length > 24 || parameters.some((token) => token.length > 500))
|
|
216
|
+
throw new TypeError(`${type} parameters exceed the supported bound.`);
|
|
217
|
+
return { value: `${priority} ${target}${parameters.length ? ` ${parameters.join(' ')}` : ''}`, valueTreatment: 'normalised' };
|
|
218
|
+
}
|
|
219
|
+
if (type === 'TXT') {
|
|
220
|
+
return { value: txtDigest(decodeTxtRdata(value)), valueTreatment: 'sha256' };
|
|
221
|
+
}
|
|
222
|
+
throw new TypeError(`Unsupported record type ${type}.`);
|
|
223
|
+
}
|
|
224
|
+
function normaliseRecord(value, origin, label) {
|
|
225
|
+
const source = requireRecord(value, label);
|
|
226
|
+
exactKeys(source, RECORD_KEYS, label);
|
|
227
|
+
const typeText = boundedText(source.type, `${label}.type`, 16).toUpperCase();
|
|
228
|
+
if (!SUPPORTED_TYPES.has(typeText))
|
|
229
|
+
throw new TypeError(`${label}.type is unsupported.`);
|
|
230
|
+
const type = typeText;
|
|
231
|
+
const owner = ownerName(source.owner, origin, `${label}.owner`);
|
|
232
|
+
const ttl = source.ttl === null || source.ttl === undefined
|
|
233
|
+
? null
|
|
234
|
+
: integerToken(String(source.ttl), 0, 0x7fff_ffff, `${label}.ttl`);
|
|
235
|
+
const rawValue = type === 'TXT'
|
|
236
|
+
? boundedRecordText(source.value, `${label}.value`, 16_384)
|
|
237
|
+
: boundedText(source.value, `${label}.value`, 16_384);
|
|
238
|
+
const normalised = normaliseRdata(type, rawValue, origin, 'absolute');
|
|
239
|
+
return Object.freeze({ owner, ttl, type, ...normalised });
|
|
240
|
+
}
|
|
241
|
+
function stripComment(line) {
|
|
242
|
+
let quoted = false;
|
|
243
|
+
let escaped = false;
|
|
244
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
245
|
+
const char = line[index];
|
|
246
|
+
if (escaped) {
|
|
247
|
+
escaped = false;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (char === '\\') {
|
|
251
|
+
escaped = true;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (char === '"')
|
|
255
|
+
quoted = !quoted;
|
|
256
|
+
else if (char === ';' && !quoted)
|
|
257
|
+
return line.slice(0, index);
|
|
258
|
+
}
|
|
259
|
+
return line;
|
|
260
|
+
}
|
|
261
|
+
function stripGroupingParentheses(line) {
|
|
262
|
+
let output = '';
|
|
263
|
+
let quoted = false;
|
|
264
|
+
let escaped = false;
|
|
265
|
+
for (const char of line) {
|
|
266
|
+
if (escaped) {
|
|
267
|
+
output += char;
|
|
268
|
+
escaped = false;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (char === '\\') {
|
|
272
|
+
output += char;
|
|
273
|
+
escaped = true;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (char === '"')
|
|
277
|
+
quoted = !quoted;
|
|
278
|
+
if (!quoted && (char === '(' || char === ')'))
|
|
279
|
+
output += ' ';
|
|
280
|
+
else
|
|
281
|
+
output += char;
|
|
282
|
+
}
|
|
283
|
+
return output;
|
|
284
|
+
}
|
|
285
|
+
function tokenise(line) {
|
|
286
|
+
const tokens = [];
|
|
287
|
+
let current = '';
|
|
288
|
+
let quoted = false;
|
|
289
|
+
let escaped = false;
|
|
290
|
+
for (const char of line) {
|
|
291
|
+
if (escaped) {
|
|
292
|
+
current += char;
|
|
293
|
+
escaped = false;
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (char === '\\') {
|
|
297
|
+
current += char;
|
|
298
|
+
escaped = true;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (char === '"') {
|
|
302
|
+
quoted = !quoted;
|
|
303
|
+
current += char;
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (/\s/u.test(char) && !quoted) {
|
|
307
|
+
if (current) {
|
|
308
|
+
tokens.push(current);
|
|
309
|
+
current = '';
|
|
310
|
+
}
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
current += char;
|
|
314
|
+
}
|
|
315
|
+
if (escaped || quoted)
|
|
316
|
+
throw new TypeError('Unterminated quoted or escaped zone value.');
|
|
317
|
+
if (current)
|
|
318
|
+
tokens.push(current);
|
|
319
|
+
return tokens;
|
|
320
|
+
}
|
|
321
|
+
function parseBindZone(zoneText, initialOrigin) {
|
|
322
|
+
if (typeof zoneText !== 'string' || Buffer.byteLength(zoneText, 'utf8') > MAX_ZONE_TEXT_BYTES) {
|
|
323
|
+
throw new TypeError(`Zone text is limited to ${MAX_ZONE_TEXT_BYTES} bytes.`);
|
|
324
|
+
}
|
|
325
|
+
const logical = [];
|
|
326
|
+
let buffer = '';
|
|
327
|
+
let startLine = 0;
|
|
328
|
+
let depth = 0;
|
|
329
|
+
let ownerOmitted = false;
|
|
330
|
+
const lines = zoneText.replace(/^\uFEFF/u, '').split(/\r?\n/u);
|
|
331
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
332
|
+
const raw = lines[index] ?? '';
|
|
333
|
+
const clean = stripComment(raw);
|
|
334
|
+
if (!buffer && !clean.trim())
|
|
335
|
+
continue;
|
|
336
|
+
if (!buffer) {
|
|
337
|
+
startLine = index + 1;
|
|
338
|
+
ownerOmitted = /^\s/u.test(clean);
|
|
339
|
+
}
|
|
340
|
+
let quoted = false;
|
|
341
|
+
let escaped = false;
|
|
342
|
+
for (const char of clean) {
|
|
343
|
+
if (escaped) {
|
|
344
|
+
escaped = false;
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (char === '\\') {
|
|
348
|
+
escaped = true;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (char === '"')
|
|
352
|
+
quoted = !quoted;
|
|
353
|
+
if (!quoted && char === '(')
|
|
354
|
+
depth += 1;
|
|
355
|
+
if (!quoted && char === ')')
|
|
356
|
+
depth -= 1;
|
|
357
|
+
if (depth < 0)
|
|
358
|
+
throw new TypeError(`Zone line ${index + 1} closes an unopened parenthesis.`);
|
|
359
|
+
}
|
|
360
|
+
buffer += `${buffer ? ' ' : ''}${stripGroupingParentheses(clean)}`;
|
|
361
|
+
if (depth === 0) {
|
|
362
|
+
logical.push({ line: startLine, text: buffer.trim(), ownerOmitted });
|
|
363
|
+
buffer = '';
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
if (depth !== 0 || buffer)
|
|
367
|
+
throw new TypeError('Zone text ends inside a parenthesised record.');
|
|
368
|
+
let origin = initialOrigin;
|
|
369
|
+
let lastOwner = initialOrigin;
|
|
370
|
+
const records = [];
|
|
371
|
+
const rejected = [];
|
|
372
|
+
let truncated = false;
|
|
373
|
+
for (const entry of logical) {
|
|
374
|
+
try {
|
|
375
|
+
const tokens = tokenise(entry.text);
|
|
376
|
+
if (!tokens.length)
|
|
377
|
+
continue;
|
|
378
|
+
const directive = tokens[0]?.toUpperCase();
|
|
379
|
+
if (directive === '$ORIGIN') {
|
|
380
|
+
if (tokens.length !== 2)
|
|
381
|
+
throw new TypeError('$ORIGIN requires one domain name.');
|
|
382
|
+
origin = domainName(tokens[1], origin, '$ORIGIN', 'master-file');
|
|
383
|
+
lastOwner = origin;
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
if (directive === '$TTL')
|
|
387
|
+
continue;
|
|
388
|
+
if (directive?.startsWith('$'))
|
|
389
|
+
throw new TypeError(`${directive} is deliberately unsupported.`);
|
|
390
|
+
let offset = 0;
|
|
391
|
+
const ownerToken = entry.ownerOmitted ? lastOwner : tokens[offset++];
|
|
392
|
+
if (!ownerToken)
|
|
393
|
+
throw new TypeError('Record owner is missing.');
|
|
394
|
+
const owner = ownerName(ownerToken, origin, 'record owner', 'master-file');
|
|
395
|
+
lastOwner = owner;
|
|
396
|
+
let ttl = null;
|
|
397
|
+
if (/^\d+$/u.test(tokens[offset] ?? ''))
|
|
398
|
+
ttl = integerToken(tokens[offset++], 0, 0x7fff_ffff, 'record TTL');
|
|
399
|
+
if ((tokens[offset] ?? '').toUpperCase() === 'IN')
|
|
400
|
+
offset += 1;
|
|
401
|
+
const typeText = (tokens[offset++] ?? '').toUpperCase();
|
|
402
|
+
if (!SUPPORTED_TYPES.has(typeText))
|
|
403
|
+
throw new TypeError(`Record type ${typeText || '(missing)'} is unsupported.`);
|
|
404
|
+
const rawValue = tokens.slice(offset).join(' ');
|
|
405
|
+
const normalised = normaliseRdata(typeText, rawValue, origin, 'master-file');
|
|
406
|
+
if (records.length >= MAX_ZONE_RECORDS) {
|
|
407
|
+
truncated = true;
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
records.push(Object.freeze({ owner, ttl, type: typeText, ...normalised }));
|
|
411
|
+
}
|
|
412
|
+
catch (error) {
|
|
413
|
+
if (rejected.length < 100)
|
|
414
|
+
rejected.push(Object.freeze({
|
|
415
|
+
line: entry.line,
|
|
416
|
+
reason: String(error instanceof Error ? error.message : error).slice(0, 240),
|
|
417
|
+
}));
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return { records, rejected, truncated };
|
|
421
|
+
}
|
|
422
|
+
function deduplicate(records) {
|
|
423
|
+
const byKey = new Map();
|
|
424
|
+
for (const item of records)
|
|
425
|
+
byKey.set(`${item.owner}\u0000${item.type}\u0000${item.value}`, item);
|
|
426
|
+
return [...byKey.values()].sort((left, right) => (left.owner.localeCompare(right.owner) || left.type.localeCompare(right.type) || left.value.localeCompare(right.value)));
|
|
427
|
+
}
|
|
428
|
+
function parseDesired(value, origin) {
|
|
429
|
+
const source = requireRecord(value, 'desired');
|
|
430
|
+
exactKeys(source, DESIRED_KEYS, 'desired');
|
|
431
|
+
if (source.format === 'bind')
|
|
432
|
+
return parseBindZone(source.zoneText, origin);
|
|
433
|
+
if (source.format !== 'records')
|
|
434
|
+
throw new TypeError('desired.format must be bind or records.');
|
|
435
|
+
if (!Array.isArray(source.records) || source.records.length > MAX_ZONE_RECORDS)
|
|
436
|
+
throw new TypeError(`desired.records is limited to ${MAX_ZONE_RECORDS} records.`);
|
|
437
|
+
return { records: source.records.map((item, index) => normaliseRecord(item, origin, `desired.records[${index}]`)), rejected: [], truncated: false };
|
|
438
|
+
}
|
|
439
|
+
function recordGroups(records) {
|
|
440
|
+
const groups = new Map();
|
|
441
|
+
for (const item of records) {
|
|
442
|
+
const key = `${item.owner}\u0000${item.type}`;
|
|
443
|
+
const group = groups.get(key) ?? { owner: item.owner, type: item.type, values: [], ttls: [] };
|
|
444
|
+
group.values.push(item.value);
|
|
445
|
+
if (item.ttl !== null)
|
|
446
|
+
group.ttls.push(item.ttl);
|
|
447
|
+
groups.set(key, group);
|
|
448
|
+
}
|
|
449
|
+
for (const group of groups.values()) {
|
|
450
|
+
group.values = [...new Set(group.values)].sort();
|
|
451
|
+
group.ttls = [...new Set(group.ttls)].sort((left, right) => left - right);
|
|
452
|
+
}
|
|
453
|
+
return groups;
|
|
454
|
+
}
|
|
455
|
+
export function reviewZoneIntent(inputRaw, generatedAtValue = new Date().toISOString()) {
|
|
456
|
+
const input = requireRecord(inputRaw, 'Zone intent input');
|
|
457
|
+
if (input.schema !== ZONE_INTENT_INPUT_SCHEMA || input.version !== 1)
|
|
458
|
+
throw new TypeError(`Zone intent input must use ${ZONE_INTENT_INPUT_SCHEMA} version 1.`);
|
|
459
|
+
exactKeys(input, ROOT_KEYS, 'Zone intent input');
|
|
460
|
+
const origin = domainName(input.origin, null, 'origin');
|
|
461
|
+
const desiredResult = parseDesired(input.desired, origin);
|
|
462
|
+
const desired = deduplicate(desiredResult.records);
|
|
463
|
+
const observedInput = requireRecord(input.observed, 'observed');
|
|
464
|
+
exactKeys(observedInput, OBSERVED_KEYS, 'observed');
|
|
465
|
+
const state = observedInput.state;
|
|
466
|
+
if (state !== 'observed' && state !== 'partial' && state !== 'unavailable' && state !== 'unsupported')
|
|
467
|
+
throw new TypeError('observed.state is unsupported.');
|
|
468
|
+
const source = boundedText(observedInput.source, 'observed.source', 120);
|
|
469
|
+
const observedAt = timestamp(observedInput.observedAt, 'observed.observedAt');
|
|
470
|
+
if (!Array.isArray(observedInput.records) || observedInput.records.length > MAX_ZONE_RECORDS)
|
|
471
|
+
throw new TypeError(`observed.records is limited to ${MAX_ZONE_RECORDS} records.`);
|
|
472
|
+
const observed = deduplicate(observedInput.records.map((item, index) => normaliseRecord(item, origin, `observed.records[${index}]`)));
|
|
473
|
+
const desiredGroups = recordGroups(desired);
|
|
474
|
+
const observedGroups = recordGroups(observed);
|
|
475
|
+
const allKeys = [...new Set([...desiredGroups.keys(), ...observedGroups.keys()])].sort();
|
|
476
|
+
const comparisonsTruncated = allKeys.length > MAX_ZONE_COMPARISONS;
|
|
477
|
+
const keys = allKeys.slice(0, MAX_ZONE_COMPARISONS);
|
|
478
|
+
const comparisons = keys.map((key) => {
|
|
479
|
+
const expected = desiredGroups.get(key);
|
|
480
|
+
const actual = observedGroups.get(key);
|
|
481
|
+
const owner = expected?.owner ?? actual?.owner ?? origin;
|
|
482
|
+
const type = expected?.type ?? actual?.type ?? 'A';
|
|
483
|
+
const expectedValues = expected?.values ?? [];
|
|
484
|
+
const observedValues = actual?.values ?? [];
|
|
485
|
+
const aligned = expectedValues.length === observedValues.length && expectedValues.every((item, index) => item === observedValues[index]);
|
|
486
|
+
const comparisonState = state !== 'observed'
|
|
487
|
+
? state
|
|
488
|
+
: aligned ? 'aligned' : !actual ? 'missing' : !expected ? 'unexpected' : 'different';
|
|
489
|
+
return Object.freeze({
|
|
490
|
+
owner,
|
|
491
|
+
type,
|
|
492
|
+
state: comparisonState,
|
|
493
|
+
desiredValues: Object.freeze(expectedValues),
|
|
494
|
+
observedValues: Object.freeze(observedValues),
|
|
495
|
+
desiredTtls: Object.freeze(expected?.ttls ?? []),
|
|
496
|
+
observedTtls: Object.freeze(actual?.ttls ?? []),
|
|
497
|
+
source,
|
|
498
|
+
observedAt,
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
const counts = Object.freeze({
|
|
502
|
+
aligned: comparisons.filter((item) => item.state === 'aligned').length,
|
|
503
|
+
different: comparisons.filter((item) => item.state === 'different').length,
|
|
504
|
+
missing: comparisons.filter((item) => item.state === 'missing').length,
|
|
505
|
+
unexpected: comparisons.filter((item) => item.state === 'unexpected').length,
|
|
506
|
+
incomplete: comparisons.filter((item) => ['partial', 'unavailable', 'unsupported'].includes(item.state)).length,
|
|
507
|
+
});
|
|
508
|
+
return Object.freeze({
|
|
509
|
+
schema: ZONE_INTENT_REVIEW_SCHEMA,
|
|
510
|
+
version: ZONE_INTENT_REVIEW_VERSION,
|
|
511
|
+
generatedAt: timestamp(generatedAtValue, 'generatedAt'),
|
|
512
|
+
origin,
|
|
513
|
+
desired: Object.freeze({
|
|
514
|
+
records: Object.freeze(desired),
|
|
515
|
+
rejected: Object.freeze(desiredResult.rejected),
|
|
516
|
+
truncated: desiredResult.truncated,
|
|
517
|
+
}),
|
|
518
|
+
observation: Object.freeze({ state: state, source, observedAt, records: Object.freeze(observed) }),
|
|
519
|
+
comparisons: Object.freeze(comparisons),
|
|
520
|
+
counts,
|
|
521
|
+
complete: comparisons.length > 0
|
|
522
|
+
&& state === 'observed'
|
|
523
|
+
&& !desiredResult.truncated
|
|
524
|
+
&& desiredResult.rejected.length === 0
|
|
525
|
+
&& !comparisonsTruncated,
|
|
526
|
+
truncated: desiredResult.truncated || comparisonsTruncated,
|
|
527
|
+
limitations: Object.freeze([
|
|
528
|
+
'This local review parses only a bounded, deliberately supported subset of DNS master-file syntax and never applies a DNS change.',
|
|
529
|
+
'TXT values are represented only by SHA-256 digests so tokens and other published text are not copied into the result.',
|
|
530
|
+
'Partial, unavailable, unsupported, rejected, or truncated input cannot establish that a desired record is absent.',
|
|
531
|
+
'A matching review does not prove global propagation, DNSSEC validity, service health, ownership, control, intent, or safety.',
|
|
532
|
+
]),
|
|
533
|
+
});
|
|
534
|
+
}
|