@slicedearth/whoisleuth-cli 1.39.1 → 1.43.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 +15 -0
- package/bin/whoisleuth.mjs +13 -2
- 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 +7 -11
- package/cli/bulk-command-runner.mjs +19 -2
- 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 +33 -10
- package/cli/config-profile.mjs +180 -0
- package/cli/ct-event-intake.mjs +159 -0
- package/cli/discovery-scan-command-runner.mjs +15 -1
- package/cli/domain-control-monitor.mjs +104 -0
- package/cli/domain-control-observations.mjs +235 -0
- 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/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/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 +73 -0
- package/cli/runner.mjs +368 -306
- package/docs/cli-reference.md +240 -14
- package/docs/cli.md +42 -7
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +1 -1
- package/frontend/src/lib/analysis/brand-profile-model.js +46 -4
- 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-decision-support.js +7 -0
- package/frontend/src/lib/analysis/lookup-presentation.js +10 -20
- package/frontend/src/lib/analysis/scoring.js +1 -1
- package/frontend/src/lib/analysis/visualization-models.js +2 -3
- package/frontend/src/lib/analysis/workspace-archive.js +2 -0
- package/lib/abusech-auth.mjs +1 -1
- package/lib/availability.mjs +1 -1
- package/lib/bounded-contract-normalizers.mjs +33 -0
- 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/nameserver-preflight-review.mjs +159 -0
- package/lib/page-language-signals.mjs +124 -0
- package/lib/risk-scoring.mjs +42 -1
- package/lib/static-html-analysis.mjs +29 -1
- package/lib/trust-store-comparison.mjs +146 -0
- package/lib/website-technology.mjs +24 -10
- package/lib/zone-intent-review.mjs +534 -0
- package/package.json +1 -1
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { exactKeys, requireBoundedString, requireDomainName, requireIsoTimestamp, requireRecord, } from "./bounded-contract-normalizers.mjs";
|
|
2
|
+
import { normaliseRdata } from "./zone-intent-review.mjs";
|
|
3
|
+
export const DNS_CONVERGENCE_INPUT_SCHEMA = 'whoisleuth.dns-convergence.input';
|
|
4
|
+
export const DNS_CONVERGENCE_REVIEW_SCHEMA = 'whoisleuth.dns-convergence.review';
|
|
5
|
+
export const DNS_CONVERGENCE_REVIEW_VERSION = 1;
|
|
6
|
+
export const MAX_DNS_CONVERGENCE_SNAPSHOTS = 100;
|
|
7
|
+
export const MAX_DNS_CONVERGENCE_RECORDS = 2_000;
|
|
8
|
+
const RECORD_TYPES = new Set(['A', 'AAAA', 'CAA', 'CNAME', 'MX', 'NS', 'SOA', 'SRV', 'SVCB', 'HTTPS', 'TLSA', 'TXT']);
|
|
9
|
+
const ROOT_KEYS = new Set(['schema', 'version', 'domain', 'expected', 'snapshots']);
|
|
10
|
+
const EXPECTED_KEYS = new Set(['owner', 'type', 'values']);
|
|
11
|
+
const SNAPSHOT_KEYS = new Set(['observer', 'source', 'observedAt', 'state', 'records']);
|
|
12
|
+
const RECORD_KEYS = new Set(['owner', 'type', 'value', 'ttl']);
|
|
13
|
+
function type(value, label) {
|
|
14
|
+
const candidate = requireBoundedString(value, label, 16).toUpperCase();
|
|
15
|
+
if (!RECORD_TYPES.has(candidate))
|
|
16
|
+
throw new TypeError(`${label} is unsupported.`);
|
|
17
|
+
return candidate;
|
|
18
|
+
}
|
|
19
|
+
function owner(value, apex, label) {
|
|
20
|
+
const candidate = requireBoundedString(value, label, 253).toLowerCase().replace(/\.$/u, '');
|
|
21
|
+
if (candidate === '@')
|
|
22
|
+
return apex;
|
|
23
|
+
if (candidate.length > 253 || candidate.split('.').some((part) => !/^[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$/u.test(part))) {
|
|
24
|
+
throw new TypeError(`${label} must be a valid DNS owner name.`);
|
|
25
|
+
}
|
|
26
|
+
return candidate;
|
|
27
|
+
}
|
|
28
|
+
function state(value, label) {
|
|
29
|
+
if (value !== 'observed' && value !== 'partial' && value !== 'unavailable')
|
|
30
|
+
throw new TypeError(`${label} is unsupported.`);
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
function ttl(value, label) {
|
|
34
|
+
if (value === null || value === undefined)
|
|
35
|
+
return null;
|
|
36
|
+
if (!Number.isSafeInteger(value) || Number(value) < 0 || Number(value) > 0x7fff_ffff)
|
|
37
|
+
throw new TypeError(`${label} is outside its supported range.`);
|
|
38
|
+
return Number(value);
|
|
39
|
+
}
|
|
40
|
+
function normaliseValue(recordType, value) {
|
|
41
|
+
if (recordType !== 'SOA')
|
|
42
|
+
return normaliseRdata(recordType, value, null);
|
|
43
|
+
const fields = value.trim().split(/\s+/u);
|
|
44
|
+
if (fields.length >= 2) {
|
|
45
|
+
fields[0] = fields[0]?.toLowerCase().replace(/\.$/u, '') ?? '';
|
|
46
|
+
fields[1] = fields[1]?.toLowerCase().replace(/\.$/u, '') ?? '';
|
|
47
|
+
}
|
|
48
|
+
return { value: fields.join(' '), valueTreatment: 'normalised' };
|
|
49
|
+
}
|
|
50
|
+
function record(value, apex, label) {
|
|
51
|
+
const input = requireRecord(value, label);
|
|
52
|
+
exactKeys(input, RECORD_KEYS, label);
|
|
53
|
+
const recordType = type(input.type, `${label}.type`);
|
|
54
|
+
const normalised = normaliseValue(recordType, requireBoundedString(input.value, `${label}.value`, 16_384));
|
|
55
|
+
return Object.freeze({
|
|
56
|
+
owner: owner(input.owner, apex, `${label}.owner`),
|
|
57
|
+
type: recordType,
|
|
58
|
+
value: normalised.value,
|
|
59
|
+
valueTreatment: normalised.valueTreatment,
|
|
60
|
+
ttl: ttl(input.ttl, `${label}.ttl`),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
function expectedRows(value, apex) {
|
|
64
|
+
if (value === null || value === undefined)
|
|
65
|
+
return Object.freeze([]);
|
|
66
|
+
if (!Array.isArray(value) || value.length > 100)
|
|
67
|
+
throw new TypeError('expected must contain no more than 100 rows.');
|
|
68
|
+
const keys = new Set();
|
|
69
|
+
return Object.freeze(value.map((raw, index) => {
|
|
70
|
+
const input = requireRecord(raw, `expected[${index}]`);
|
|
71
|
+
exactKeys(input, EXPECTED_KEYS, `expected[${index}]`);
|
|
72
|
+
const recordType = type(input.type, `expected[${index}].type`);
|
|
73
|
+
const recordOwner = owner(input.owner, apex, `expected[${index}].owner`);
|
|
74
|
+
const key = `${recordOwner}\u0000${recordType}`;
|
|
75
|
+
if (keys.has(key))
|
|
76
|
+
throw new TypeError(`expected[${index}] duplicates an owner and type.`);
|
|
77
|
+
keys.add(key);
|
|
78
|
+
if (!Array.isArray(input.values) || input.values.length > 100)
|
|
79
|
+
throw new TypeError(`expected[${index}].values must contain no more than 100 entries.`);
|
|
80
|
+
const values = [...new Set(input.values.map((entry, valueIndex) => normaliseValue(recordType, requireBoundedString(entry, `expected[${index}].values[${valueIndex}]`, 16_384)).value))].sort();
|
|
81
|
+
return Object.freeze({ owner: recordOwner, type: recordType, values: Object.freeze(values) });
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toISOString()) {
|
|
85
|
+
const input = requireRecord(inputRaw, 'DNS convergence input');
|
|
86
|
+
if (input.schema !== DNS_CONVERGENCE_INPUT_SCHEMA || input.version !== 1) {
|
|
87
|
+
throw new TypeError(`DNS convergence input must use ${DNS_CONVERGENCE_INPUT_SCHEMA} version 1.`);
|
|
88
|
+
}
|
|
89
|
+
exactKeys(input, ROOT_KEYS, 'DNS convergence input');
|
|
90
|
+
const apex = requireDomainName(input.domain, 'domain');
|
|
91
|
+
const expected = expectedRows(input.expected, apex);
|
|
92
|
+
if (!Array.isArray(input.snapshots) || input.snapshots.length < 2 || input.snapshots.length > MAX_DNS_CONVERGENCE_SNAPSHOTS) {
|
|
93
|
+
throw new TypeError(`snapshots must contain between 2 and ${MAX_DNS_CONVERGENCE_SNAPSHOTS} entries.`);
|
|
94
|
+
}
|
|
95
|
+
let recordCount = 0;
|
|
96
|
+
const snapshots = input.snapshots.map((raw, index) => {
|
|
97
|
+
const item = requireRecord(raw, `snapshots[${index}]`);
|
|
98
|
+
exactKeys(item, SNAPSHOT_KEYS, `snapshots[${index}]`);
|
|
99
|
+
if (!Array.isArray(item.records))
|
|
100
|
+
throw new TypeError(`snapshots[${index}].records must be an array.`);
|
|
101
|
+
recordCount += item.records.length;
|
|
102
|
+
if (recordCount > MAX_DNS_CONVERGENCE_RECORDS)
|
|
103
|
+
throw new TypeError(`DNS convergence input is limited to ${MAX_DNS_CONVERGENCE_RECORDS} records.`);
|
|
104
|
+
const observationState = state(item.state, `snapshots[${index}].state`);
|
|
105
|
+
if (observationState === 'unavailable' && item.records.length)
|
|
106
|
+
throw new TypeError(`snapshots[${index}] cannot contain records when unavailable.`);
|
|
107
|
+
return Object.freeze({
|
|
108
|
+
observer: requireBoundedString(item.observer, `snapshots[${index}].observer`, 120),
|
|
109
|
+
source: requireBoundedString(item.source, `snapshots[${index}].source`, 240),
|
|
110
|
+
observedAt: requireIsoTimestamp(item.observedAt, `snapshots[${index}].observedAt`),
|
|
111
|
+
state: observationState,
|
|
112
|
+
records: Object.freeze(item.records.map((entry, recordIndex) => record(entry, apex, `snapshots[${index}].records[${recordIndex}]`))),
|
|
113
|
+
});
|
|
114
|
+
}).sort((left, right) => left.observedAt.localeCompare(right.observedAt) || left.observer.localeCompare(right.observer));
|
|
115
|
+
const observers = [...new Set(snapshots.map((item) => item.observer))];
|
|
116
|
+
if (observers.length < 2)
|
|
117
|
+
throw new TypeError('DNS convergence review requires at least two distinct observer labels.');
|
|
118
|
+
const latestByObserver = observers.map((observer) => snapshots.filter((item) => item.observer === observer).at(-1));
|
|
119
|
+
const keys = [...new Set([
|
|
120
|
+
...expected.map((item) => `${item.owner}\u0000${item.type}`),
|
|
121
|
+
...snapshots.flatMap((item) => item.records.map((entry) => `${entry.owner}\u0000${entry.type}`)),
|
|
122
|
+
])].sort();
|
|
123
|
+
const rows = keys.map((key) => {
|
|
124
|
+
const [recordOwner = '', recordType = ''] = key.split('\u0000');
|
|
125
|
+
const expectedRow = expected.find((item) => item.owner === recordOwner && item.type === recordType);
|
|
126
|
+
const observations = latestByObserver.map((snapshot) => {
|
|
127
|
+
const matching = snapshot.records.filter((entry) => entry.owner === recordOwner && entry.type === recordType);
|
|
128
|
+
const values = [...new Set(matching.map((entry) => entry.value))].sort();
|
|
129
|
+
const ttls = matching.flatMap((entry) => entry.ttl === null ? [] : [entry.ttl]);
|
|
130
|
+
const cacheUntil = ttls.length
|
|
131
|
+
? new Date(Date.parse(snapshot.observedAt) + Math.max(...ttls) * 1_000).toISOString()
|
|
132
|
+
: null;
|
|
133
|
+
return Object.freeze({
|
|
134
|
+
observer: snapshot.observer,
|
|
135
|
+
source: snapshot.source,
|
|
136
|
+
observedAt: snapshot.observedAt,
|
|
137
|
+
state: snapshot.state,
|
|
138
|
+
values: Object.freeze(values),
|
|
139
|
+
cacheUntil,
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
const complete = observations.filter((item) => item.state === 'observed');
|
|
143
|
+
const incomplete = observations.some((item) => item.state !== 'observed');
|
|
144
|
+
const signatures = new Set(complete.map((item) => JSON.stringify(item.values)));
|
|
145
|
+
const aligned = complete.length >= 2 && signatures.size === 1;
|
|
146
|
+
const matchesExpected = expectedRow
|
|
147
|
+
? aligned && JSON.stringify(complete[0]?.values ?? []) === JSON.stringify(expectedRow.values)
|
|
148
|
+
: null;
|
|
149
|
+
const rowState = incomplete ? 'incomplete'
|
|
150
|
+
: complete.length < 2 ? 'insufficient'
|
|
151
|
+
: !aligned ? 'divergent'
|
|
152
|
+
: matchesExpected === false ? 'unexpected'
|
|
153
|
+
: 'converged';
|
|
154
|
+
return Object.freeze({
|
|
155
|
+
owner: recordOwner,
|
|
156
|
+
type: recordType,
|
|
157
|
+
state: rowState,
|
|
158
|
+
expectedValues: expectedRow?.values ?? null,
|
|
159
|
+
observations: Object.freeze(observations),
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
const reasons = Object.freeze([
|
|
163
|
+
...rows.filter((row) => row.state !== 'converged').map((row) => `${row.owner} ${row.type} is ${row.state}.`),
|
|
164
|
+
...latestByObserver.filter((item) => item.state !== 'observed').map((item) => `${item.observer} latest evidence is ${item.state}.`),
|
|
165
|
+
].slice(0, 200));
|
|
166
|
+
return Object.freeze({
|
|
167
|
+
schema: DNS_CONVERGENCE_REVIEW_SCHEMA,
|
|
168
|
+
version: DNS_CONVERGENCE_REVIEW_VERSION,
|
|
169
|
+
generatedAt: requireIsoTimestamp(generatedAtValue, 'generatedAt'),
|
|
170
|
+
domain: apex,
|
|
171
|
+
state: reasons.length ? 'review' : 'converged',
|
|
172
|
+
observers: Object.freeze(observers),
|
|
173
|
+
snapshots: Object.freeze(snapshots),
|
|
174
|
+
rows: Object.freeze(rows),
|
|
175
|
+
gate: Object.freeze({ pass: reasons.length === 0, reasons }),
|
|
176
|
+
limitations: Object.freeze([
|
|
177
|
+
'This local workbench compares only supplied observations and makes no DNS request.',
|
|
178
|
+
'Resolver differences can reflect caches, split-horizon policy, interception, collection timing, or propagation; they do not establish which value is authoritative.',
|
|
179
|
+
'Cache-until times are simple observation-time plus supplied TTL projections and do not reveal actual cache age, refresh behaviour, or resolver policy.',
|
|
180
|
+
'Partial and unavailable snapshots remain incomplete and are never interpreted as record absence.',
|
|
181
|
+
]),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
@@ -11,6 +11,7 @@ const MAX_AUTHORITIES = 4;
|
|
|
11
11
|
const MAX_AUTHORITY_ADDRESSES = 2;
|
|
12
12
|
const MAX_NAMESERVERS = 16;
|
|
13
13
|
const MAX_ERROR_LENGTH = 180;
|
|
14
|
+
const MAX_AUTHORITY_RECORD_VALUES = 16;
|
|
14
15
|
const DNS_DELEGATION_TIMEOUT_MS = 2200;
|
|
15
16
|
const LAME_CODES = new Set(['ENOTAUTH', 'EREFUSED']);
|
|
16
17
|
const MAX_SOA_VALUE = 0xffff_ffff;
|
|
@@ -47,6 +48,12 @@ function publicAddress(value) {
|
|
|
47
48
|
const normalized = value.toLowerCase();
|
|
48
49
|
return net.isIP(normalized) && !isPrivateAddress(normalized) ? normalized : null;
|
|
49
50
|
}
|
|
51
|
+
function observedAddress(value) {
|
|
52
|
+
if (typeof value !== 'string' || value.length > 80 || /[\u0000-\u0020\u007f%]/u.test(value))
|
|
53
|
+
return null;
|
|
54
|
+
const normalised = value.toLowerCase();
|
|
55
|
+
return net.isIP(normalised) ? normalised : null;
|
|
56
|
+
}
|
|
50
57
|
function errorCode(error) {
|
|
51
58
|
const value = record(error).code;
|
|
52
59
|
return typeof value === 'string' ? value.slice(0, 40) : '';
|
|
@@ -125,6 +132,127 @@ async function defaultAuthorityQuery(input) {
|
|
|
125
132
|
error: null,
|
|
126
133
|
};
|
|
127
134
|
}
|
|
135
|
+
function normaliseAuthorityValueSet(type, value) {
|
|
136
|
+
const input = Array.isArray(value) ? value : [];
|
|
137
|
+
const rows = input.slice(0, 256);
|
|
138
|
+
let rejected = input.length - rows.length;
|
|
139
|
+
const values = rows.flatMap((item) => {
|
|
140
|
+
if (type === 'A' || type === 'AAAA') {
|
|
141
|
+
// Returned record data is evidence, not a transport destination. Retain
|
|
142
|
+
// syntactically valid private and reserved addresses while continuing to
|
|
143
|
+
// require public addresses for the direct nameserver connection itself.
|
|
144
|
+
const address = observedAddress(item);
|
|
145
|
+
if (address && net.isIP(address) === (type === 'A' ? 4 : 6))
|
|
146
|
+
return [address];
|
|
147
|
+
rejected += 1;
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
const source = record(item);
|
|
151
|
+
if (type === 'MX') {
|
|
152
|
+
const exchange = hostname(source.exchange);
|
|
153
|
+
const priority = Number(source.priority);
|
|
154
|
+
if (exchange && Number.isSafeInteger(priority) && priority >= 0 && priority <= 65_535)
|
|
155
|
+
return [`${priority} ${exchange}`];
|
|
156
|
+
rejected += 1;
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
const critical = Number(source.critical);
|
|
160
|
+
const properties = ['issue', 'issuewild', 'iodef'].flatMap((key) => {
|
|
161
|
+
const candidate = boundedText(source[key], 500);
|
|
162
|
+
return candidate ? [`${Number.isSafeInteger(critical) && critical >= 0 && critical <= 255 ? critical : 0} ${key} ${candidate}`] : [];
|
|
163
|
+
});
|
|
164
|
+
if (!properties.length)
|
|
165
|
+
rejected += 1;
|
|
166
|
+
return properties;
|
|
167
|
+
});
|
|
168
|
+
const unique = [...new Set(values)].sort();
|
|
169
|
+
const overflow = Math.max(0, unique.length - MAX_AUTHORITY_RECORD_VALUES);
|
|
170
|
+
return {
|
|
171
|
+
values: unique.slice(0, MAX_AUTHORITY_RECORD_VALUES),
|
|
172
|
+
truncated: overflow > 0 || input.length > rows.length,
|
|
173
|
+
discarded: rejected + overflow,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
function normaliseRetainedAuthorityValues(value) {
|
|
177
|
+
const input = Array.isArray(value) ? value : [];
|
|
178
|
+
const rows = input.slice(0, 256);
|
|
179
|
+
const valid = rows.map((item) => boundedText(item, 500)).filter((item) => item !== null);
|
|
180
|
+
const unique = [...new Set(valid)].sort();
|
|
181
|
+
const overflow = Math.max(0, unique.length - MAX_AUTHORITY_RECORD_VALUES);
|
|
182
|
+
return {
|
|
183
|
+
values: unique.slice(0, MAX_AUTHORITY_RECORD_VALUES),
|
|
184
|
+
truncated: overflow > 0 || input.length > rows.length,
|
|
185
|
+
discarded: input.length - rows.length + (rows.length - valid.length) + overflow,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
function normaliseAuthorityValues(type, value) {
|
|
189
|
+
return normaliseAuthorityValueSet(type, value).values;
|
|
190
|
+
}
|
|
191
|
+
async function defaultAuthorityRecordQuery(input) {
|
|
192
|
+
const resolver = new dns.Resolver({
|
|
193
|
+
timeout: Math.max(250, Math.min(input.timeoutMs, DNS_DELEGATION_TIMEOUT_MS)),
|
|
194
|
+
tries: 1,
|
|
195
|
+
});
|
|
196
|
+
resolver.setServers([net.isIP(input.address) === 6 ? `[${input.address}]:53` : input.address]);
|
|
197
|
+
const queries = [
|
|
198
|
+
['A', resolver.resolve4(input.domain)],
|
|
199
|
+
['AAAA', resolver.resolve6(input.domain)],
|
|
200
|
+
['CAA', resolver.resolveCaa(input.domain)],
|
|
201
|
+
['MX', resolver.resolveMx(input.domain)],
|
|
202
|
+
];
|
|
203
|
+
return Promise.all(queries.map(async ([type, promise]) => {
|
|
204
|
+
try {
|
|
205
|
+
const response = await withTimeout(promise, input.timeoutMs);
|
|
206
|
+
const normalised = normaliseAuthorityValueSet(type, response);
|
|
207
|
+
return {
|
|
208
|
+
type,
|
|
209
|
+
state: normalised.truncated || normalised.discarded > 0
|
|
210
|
+
? 'partial'
|
|
211
|
+
: normalised.values.length ? 'success' : 'not_found',
|
|
212
|
+
...normalised,
|
|
213
|
+
error: normalised.discarded > 0 && !normalised.values.length ? 'No usable record values were retained.' : null,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
const code = errorCode(error);
|
|
218
|
+
const missing = code === 'ENODATA' || code === 'ENOTFOUND';
|
|
219
|
+
return {
|
|
220
|
+
type,
|
|
221
|
+
state: missing ? 'not_found' : 'error',
|
|
222
|
+
values: [],
|
|
223
|
+
error: missing ? null : errorDetail(error),
|
|
224
|
+
truncated: false,
|
|
225
|
+
discarded: 0,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
}));
|
|
229
|
+
}
|
|
230
|
+
function authorityRecordMatrix(authorities) {
|
|
231
|
+
const types = ['A', 'AAAA', 'CAA', 'MX'];
|
|
232
|
+
return types.map((type) => {
|
|
233
|
+
const observations = authorities.map((authority) => {
|
|
234
|
+
const result = authority.recordSets.find((item) => item.type === type);
|
|
235
|
+
return {
|
|
236
|
+
nameserver: authority.nameserver,
|
|
237
|
+
state: result?.state ?? 'not_collected',
|
|
238
|
+
values: result?.values ?? [],
|
|
239
|
+
error: result?.error ?? null,
|
|
240
|
+
truncated: result?.truncated === true,
|
|
241
|
+
discarded: typeof result?.discarded === 'number' ? result.discarded : 0,
|
|
242
|
+
};
|
|
243
|
+
});
|
|
244
|
+
const complete = observations.filter((item) => item.state === 'success' || item.state === 'not_found');
|
|
245
|
+
const signatures = new Set(complete.map((item) => JSON.stringify(item.values)));
|
|
246
|
+
return {
|
|
247
|
+
type,
|
|
248
|
+
state: observations.some((item) => item.state === 'error' || item.state === 'partial' || item.truncated || item.discarded > 0) ? 'partial'
|
|
249
|
+
: complete.length < 2 ? 'insufficient'
|
|
250
|
+
: observations.some((item) => item.state === 'not_collected') ? 'partial'
|
|
251
|
+
: signatures.size === 1 ? 'aligned' : 'different',
|
|
252
|
+
observations,
|
|
253
|
+
};
|
|
254
|
+
});
|
|
255
|
+
}
|
|
128
256
|
function registryProjection(value) {
|
|
129
257
|
const registry = record(value);
|
|
130
258
|
const nameserverDetails = (Array.isArray(registry.nameserverDetails)
|
|
@@ -185,6 +313,7 @@ function skippedDnsDelegationHealth(detail = 'Authoritative delegation checks we
|
|
|
185
313
|
truncated: false,
|
|
186
314
|
},
|
|
187
315
|
authorities: [],
|
|
316
|
+
recordMatrix: [],
|
|
188
317
|
findings: [],
|
|
189
318
|
};
|
|
190
319
|
}
|
|
@@ -204,6 +333,8 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
204
333
|
const resolve4 = options.resolve4 || dns.resolve4;
|
|
205
334
|
const resolve6 = options.resolve6 || dns.resolve6;
|
|
206
335
|
const queryAuthority = options.queryAuthority || defaultAuthorityQuery;
|
|
336
|
+
const queryAuthorityRecords = options.queryAuthorityRecords
|
|
337
|
+
?? (options.queryAuthority ? null : defaultAuthorityRecordQuery);
|
|
207
338
|
const timeoutMs = Math.max(250, Math.min(5000, Number(options.timeoutMs) || DNS_DELEGATION_TIMEOUT_MS));
|
|
208
339
|
const now = options.now || Date.now;
|
|
209
340
|
const started = now();
|
|
@@ -256,6 +387,47 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
256
387
|
const successful = attempts.find((attempt) => attempt.state === 'success');
|
|
257
388
|
const partial = attempts.find((attempt) => attempt.state === 'partial');
|
|
258
389
|
const retained = successful ?? partial;
|
|
390
|
+
let recordSets = [];
|
|
391
|
+
const recordAddress = retained?.address ?? addresses[0];
|
|
392
|
+
if (queryAuthorityRecords && recordAddress) {
|
|
393
|
+
try {
|
|
394
|
+
recordSets = (await withTimeout(queryAuthorityRecords({ domain, nameserver, address: recordAddress, timeoutMs }), timeoutMs))
|
|
395
|
+
.filter((item) => ['A', 'AAAA', 'CAA', 'MX'].includes(item.type))
|
|
396
|
+
.slice(0, 4)
|
|
397
|
+
.map((item) => {
|
|
398
|
+
const normalised = normaliseRetainedAuthorityValues(item.values);
|
|
399
|
+
const suppliedState = ['success', 'not_found', 'partial', 'error'].includes(item.state)
|
|
400
|
+
? item.state : 'error';
|
|
401
|
+
const discarded = normalised.discarded + (typeof item.discarded === 'number' && Number.isSafeInteger(item.discarded) && item.discarded > 0
|
|
402
|
+
? Math.min(item.discarded, 10_000) : 0);
|
|
403
|
+
const truncated = item.truncated === true || normalised.truncated;
|
|
404
|
+
const partial = suppliedState === 'partial'
|
|
405
|
+
|| truncated
|
|
406
|
+
|| discarded > 0
|
|
407
|
+
|| (suppliedState === 'success' && normalised.values.length === 0)
|
|
408
|
+
|| (suppliedState === 'not_found' && normalised.values.length > 0);
|
|
409
|
+
return {
|
|
410
|
+
type: item.type,
|
|
411
|
+
state: suppliedState === 'error' ? 'error' : partial ? 'partial' : suppliedState,
|
|
412
|
+
values: normalised.values,
|
|
413
|
+
error: boundedText(item.error, MAX_ERROR_LENGTH)
|
|
414
|
+
?? (partial && !normalised.values.length ? 'No usable record values were retained.' : null),
|
|
415
|
+
truncated,
|
|
416
|
+
discarded,
|
|
417
|
+
};
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
catch (error) {
|
|
421
|
+
recordSets = ['A', 'AAAA', 'CAA', 'MX'].map((type) => ({
|
|
422
|
+
type: type,
|
|
423
|
+
state: 'error',
|
|
424
|
+
values: [],
|
|
425
|
+
error: errorDetail(error),
|
|
426
|
+
truncated: false,
|
|
427
|
+
discarded: 0,
|
|
428
|
+
}));
|
|
429
|
+
}
|
|
430
|
+
}
|
|
259
431
|
return {
|
|
260
432
|
nameserver,
|
|
261
433
|
addressSource: glue.length ? 'registry_glue' : 'recursive_address',
|
|
@@ -270,6 +442,7 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
270
442
|
nameservers: retained?.nameservers ?? [],
|
|
271
443
|
soaPrimary: retained?.soaPrimary ?? null,
|
|
272
444
|
soa: retained?.soa ?? null,
|
|
445
|
+
recordSets,
|
|
273
446
|
attempts,
|
|
274
447
|
};
|
|
275
448
|
}));
|
|
@@ -295,6 +468,11 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
295
468
|
const inBailiwick = candidates.filter((nameserver) => (nameserver === domain || nameserver.endsWith(`.${domain}`)));
|
|
296
469
|
const missingGlue = inBailiwick.filter((nameserver) => (!(registry.nameserverDetails.find((item) => item.name === nameserver)?.addresses.length)));
|
|
297
470
|
const dnssecMismatch = (registry.delegationSigned === true && registry.dsRecordCount === 0) || (registry.delegationSigned === false && registry.dsRecordCount > 0);
|
|
471
|
+
const recordMatrix = authorityRecordMatrix(authorities);
|
|
472
|
+
const recordCollectionAttempted = authorities.some((authority) => authority.recordSets.length > 0);
|
|
473
|
+
const recordDifferences = recordMatrix.filter((row) => row.state === 'different');
|
|
474
|
+
const recordPartial = recordMatrix.filter((row) => row.state === 'partial');
|
|
475
|
+
const recordInsufficient = recordMatrix.filter((row) => row.state === 'insufficient');
|
|
298
476
|
const findings = [
|
|
299
477
|
finding('parent_registry_ns', 'Parent and registry nameservers', !parentNameservers.length || !registry.nameservers.length
|
|
300
478
|
? 'unknown'
|
|
@@ -342,11 +520,20 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
342
520
|
: registry.delegationSigned
|
|
343
521
|
? `${registry.dsRecordCount} registry DS record${registry.dsRecordCount === 1 ? '' : 's'} observed`
|
|
344
522
|
: 'Registry publication marks the delegation unsigned', `delegationSigned: ${registry.delegationSigned === null ? 'unavailable' : registry.delegationSigned}. DS records: ${registry.dsRecordCount}.`, 'Confirm the registry DS set matches the intended zone-signing keys before enabling, rotating, or removing DNSSEC.'),
|
|
523
|
+
...(authorities.some((authority) => authority.recordSets.length) ? [finding('authority_record_consistency', 'Authoritative record consistency', recordDifferences.length ? 'warning'
|
|
524
|
+
: recordPartial.length || recordInsufficient.length ? 'unknown' : 'healthy', recordDifferences.length
|
|
525
|
+
? `Direct authority answers differ for ${recordDifferences.map((row) => row.type).join(', ')}`
|
|
526
|
+
: recordPartial.length
|
|
527
|
+
? 'Extended authority record comparison is incomplete'
|
|
528
|
+
: recordInsufficient.length
|
|
529
|
+
? 'Fewer than two complete authority observations were available for one or more record types'
|
|
530
|
+
: 'Observed A, AAAA, CAA, and MX answers agree across selected authorities', recordMatrix.map((row) => `${row.type}: ${row.state}`).join(' · '), 'Review propagation timing and the intended zone values before relying on a DNS change.')] : []),
|
|
345
531
|
];
|
|
346
532
|
const collectionIncomplete = parentStatus === 'error'
|
|
347
533
|
|| parentQuery.truncated === true
|
|
348
534
|
|| registry.truncated
|
|
349
|
-
|| authorities.some((authority) => authority.state !== 'success')
|
|
535
|
+
|| authorities.some((authority) => authority.state !== 'success')
|
|
536
|
+
|| (recordCollectionAttempted && (recordPartial.length > 0 || recordInsufficient.length > 0));
|
|
350
537
|
const status = !candidates.length && parentStatus === 'error'
|
|
351
538
|
? 'error'
|
|
352
539
|
: collectionIncomplete ? 'partial' : 'success';
|
|
@@ -360,10 +547,13 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
360
547
|
source: 'dns_delegation',
|
|
361
548
|
durationMs: Math.max(0, now() - started),
|
|
362
549
|
complete: !collectionIncomplete,
|
|
363
|
-
truncated: parentQuery.truncated === true
|
|
550
|
+
truncated: parentQuery.truncated === true
|
|
551
|
+
|| registry.truncated
|
|
552
|
+
|| (recordCollectionAttempted && recordMatrix.some((row) => row.observations.some((item) => item.truncated))),
|
|
364
553
|
limitations: [
|
|
365
554
|
'The parent nameserver set is a point-in-time recursive resolver view; registry nameservers and glue come from the separately attributed RDAP publication.',
|
|
366
555
|
'Direct queries use at most four selected nameservers and two validated public addresses per nameserver.',
|
|
556
|
+
'Extended A, AAAA, CAA, and MX comparison uses only one selected validated public address per nameserver and caps each record set at sixteen values.',
|
|
367
557
|
'A direct answer does not prove global reachability, and a failed query is not evidence that the record is absent.',
|
|
368
558
|
'DNS health does not decide registration availability, ownership, control, intent, safety, or maliciousness.',
|
|
369
559
|
'The DNSSEC check compares bounded registry publication fields; it does not validate the full cryptographic chain.',
|
|
@@ -375,6 +565,8 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
375
565
|
partialAuthorityCount: partialAuthorities.length,
|
|
376
566
|
lameAuthorityCount: lameAuthorities.length,
|
|
377
567
|
unreachableAuthorityCount: unreachableAuthorities.length,
|
|
568
|
+
truncatedAuthorityRecordSetCount: recordMatrix.reduce((count, row) => (count + row.observations.filter((item) => item.truncated).length), 0),
|
|
569
|
+
discardedAuthorityRecordValueCount: recordMatrix.reduce((count, row) => (count + row.observations.reduce((sum, item) => sum + item.discarded, 0)), 0),
|
|
378
570
|
},
|
|
379
571
|
}),
|
|
380
572
|
detail: status === 'success'
|
|
@@ -389,7 +581,8 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
389
581
|
},
|
|
390
582
|
registry,
|
|
391
583
|
authorities,
|
|
584
|
+
recordMatrix,
|
|
392
585
|
findings,
|
|
393
586
|
};
|
|
394
587
|
}
|
|
395
|
-
export { DNS_DELEGATION_HEALTH_VERSION, DNS_DELEGATION_TIMEOUT_MS, MAX_AUTHORITIES, MAX_AUTHORITY_ADDRESSES, collectDnsDelegationHealth, skippedDnsDelegationHealth, };
|
|
588
|
+
export { DNS_DELEGATION_HEALTH_VERSION, DNS_DELEGATION_TIMEOUT_MS, MAX_AUTHORITIES, MAX_AUTHORITY_ADDRESSES, collectDnsDelegationHealth, normaliseAuthorityValues, skippedDnsDelegationHealth, };
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { exactKeys, requireBoundedString, requireDomainName, requireIsoTimestamp, requireRecord, } from "./bounded-contract-normalizers.mjs";
|
|
2
|
+
import { DOMAIN_ASSURANCE_INPUT_SCHEMA, buildDomainAssurance, } from "./domain-assurance.mjs";
|
|
3
|
+
import { DOMAIN_CHANGE_INPUT_SCHEMA, reviewDomainChange, } from "./domain-change-review.mjs";
|
|
4
|
+
import { sha256ArtifactDigest } from "../frontend/src/lib/analysis/artifact-integrity.js";
|
|
5
|
+
export const DOMAIN_CHANGE_PACKET_INPUT_SCHEMA = 'whoisleuth.domain-change-packet.input';
|
|
6
|
+
export const DOMAIN_CHANGE_PACKET_SCHEMA = 'whoisleuth.domain-change-packet';
|
|
7
|
+
export const DOMAIN_CHANGE_PACKET_VERSION = 1;
|
|
8
|
+
export const MAX_DOMAIN_CHANGE_PACKET_INPUT_BYTES = 6 * 1024 * 1024;
|
|
9
|
+
const ROOT_KEYS = new Set([
|
|
10
|
+
'schema',
|
|
11
|
+
'version',
|
|
12
|
+
'domain',
|
|
13
|
+
'reference',
|
|
14
|
+
'preChange',
|
|
15
|
+
'postChange',
|
|
16
|
+
'assurance',
|
|
17
|
+
]);
|
|
18
|
+
function record(value, label) {
|
|
19
|
+
return requireRecord(value, label);
|
|
20
|
+
}
|
|
21
|
+
function domainFromInput(value, label) {
|
|
22
|
+
const input = record(value, label);
|
|
23
|
+
if (input.schema !== DOMAIN_CHANGE_INPUT_SCHEMA) {
|
|
24
|
+
throw new TypeError(`${label} must use ${DOMAIN_CHANGE_INPUT_SCHEMA}.`);
|
|
25
|
+
}
|
|
26
|
+
return requireDomainName(input.domain, `${label}.domain`);
|
|
27
|
+
}
|
|
28
|
+
function assuranceDomain(value) {
|
|
29
|
+
const input = record(value, 'assurance');
|
|
30
|
+
if (input.schema !== DOMAIN_ASSURANCE_INPUT_SCHEMA || input.kind !== 'planned-change') {
|
|
31
|
+
throw new TypeError('assurance must contain a planned-change domain assurance input.');
|
|
32
|
+
}
|
|
33
|
+
return requireDomainName(input.domain, 'assurance.domain');
|
|
34
|
+
}
|
|
35
|
+
function changeSummary(before, after) {
|
|
36
|
+
const rows = (review) => new Map(review.authoritativeRecordMatrix.map((row) => [`${row.owner}\u0000${row.type}`, row]));
|
|
37
|
+
const beforeRows = rows(before);
|
|
38
|
+
const afterRows = rows(after);
|
|
39
|
+
const keys = [...new Set([...beforeRows.keys(), ...afterRows.keys()])].sort();
|
|
40
|
+
return Object.freeze(keys.slice(0, 500).flatMap((key) => {
|
|
41
|
+
const left = beforeRows.get(key);
|
|
42
|
+
const right = afterRows.get(key);
|
|
43
|
+
const leftValues = left?.observations.flatMap((item) => item.values) ?? [];
|
|
44
|
+
const rightValues = right?.observations.flatMap((item) => item.values) ?? [];
|
|
45
|
+
const beforeValues = [...new Set(leftValues)].sort();
|
|
46
|
+
const afterValues = [...new Set(rightValues)].sort();
|
|
47
|
+
if (JSON.stringify(beforeValues) === JSON.stringify(afterValues))
|
|
48
|
+
return [];
|
|
49
|
+
const [owner, type] = key.split('\u0000');
|
|
50
|
+
return [Object.freeze({
|
|
51
|
+
owner: owner ?? '',
|
|
52
|
+
type: type ?? '',
|
|
53
|
+
beforeValues: Object.freeze(beforeValues),
|
|
54
|
+
afterValues: Object.freeze(afterValues),
|
|
55
|
+
})];
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
export async function buildDomainChangePacket(inputRaw, generatedAtValue = new Date().toISOString()) {
|
|
59
|
+
const input = record(inputRaw, 'Domain change packet input');
|
|
60
|
+
if (input.schema !== DOMAIN_CHANGE_PACKET_INPUT_SCHEMA || input.version !== 1) {
|
|
61
|
+
throw new TypeError(`Domain change packet input must use ${DOMAIN_CHANGE_PACKET_INPUT_SCHEMA} version 1.`);
|
|
62
|
+
}
|
|
63
|
+
exactKeys(input, ROOT_KEYS, 'Domain change packet input');
|
|
64
|
+
const domain = requireDomainName(input.domain, 'domain');
|
|
65
|
+
const domains = [
|
|
66
|
+
domainFromInput(input.preChange, 'preChange'),
|
|
67
|
+
domainFromInput(input.postChange, 'postChange'),
|
|
68
|
+
assuranceDomain(input.assurance),
|
|
69
|
+
];
|
|
70
|
+
if (domains.some((candidate) => candidate !== domain)) {
|
|
71
|
+
throw new TypeError('The packet, change reviews, and assurance plan must refer to the same domain.');
|
|
72
|
+
}
|
|
73
|
+
const generatedAt = requireIsoTimestamp(generatedAtValue, 'generatedAt');
|
|
74
|
+
const preChange = reviewDomainChange(input.preChange, generatedAt);
|
|
75
|
+
const postChange = reviewDomainChange(input.postChange, generatedAt);
|
|
76
|
+
const assurance = buildDomainAssurance(input.assurance, generatedAt);
|
|
77
|
+
const assuranceReasons = assurance.result.review.reasons;
|
|
78
|
+
const gateReasons = Object.freeze([
|
|
79
|
+
...preChange.gate.reasons.map((reason) => `Pre-change evidence: ${reason}`),
|
|
80
|
+
...postChange.gate.reasons.map((reason) => `Post-change evidence: ${reason}`),
|
|
81
|
+
...assuranceReasons.map((reason) => `Change plan: ${reason}`),
|
|
82
|
+
].slice(0, 100));
|
|
83
|
+
const unsigned = Object.freeze({
|
|
84
|
+
schema: DOMAIN_CHANGE_PACKET_SCHEMA,
|
|
85
|
+
version: DOMAIN_CHANGE_PACKET_VERSION,
|
|
86
|
+
generatedAt,
|
|
87
|
+
domain,
|
|
88
|
+
reference: requireBoundedString(input.reference, 'reference', 200),
|
|
89
|
+
state: gateReasons.length ? 'review' : 'ready',
|
|
90
|
+
gate: Object.freeze({ pass: gateReasons.length === 0, reasons: gateReasons }),
|
|
91
|
+
summary: Object.freeze({
|
|
92
|
+
changedAuthoritativeRecordSets: changeSummary(preChange, postChange),
|
|
93
|
+
preChangeState: preChange.state,
|
|
94
|
+
postChangeState: postChange.state,
|
|
95
|
+
assuranceState: assurance.result.review.state,
|
|
96
|
+
}),
|
|
97
|
+
evidence: Object.freeze({ preChange, postChange, assurance }),
|
|
98
|
+
limitations: Object.freeze([
|
|
99
|
+
'This packet is assembled only from analyst-supplied observations and planning metadata and makes no network request or configuration change.',
|
|
100
|
+
'A ready result means the supplied bounded checks passed; it does not prove control, propagation completion, successful recovery, or absence of unobserved dependencies.',
|
|
101
|
+
'The integrity digest detects later changes to this packet but does not authenticate its author. Use a separately managed signing key when signer authentication is required.',
|
|
102
|
+
]),
|
|
103
|
+
});
|
|
104
|
+
return Object.freeze({
|
|
105
|
+
...unsigned,
|
|
106
|
+
integrity: Object.freeze({
|
|
107
|
+
algorithm: 'SHA-256',
|
|
108
|
+
digestSha256: await sha256ArtifactDigest(unsigned),
|
|
109
|
+
}),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
export function formatDomainChangePacket(packet) {
|
|
113
|
+
const lines = [
|
|
114
|
+
'Domain change assurance packet',
|
|
115
|
+
`Domain ${packet.domain}`,
|
|
116
|
+
`Reference ${packet.reference}`,
|
|
117
|
+
`State ${packet.state}`,
|
|
118
|
+
`Changed record sets ${packet.summary.changedAuthoritativeRecordSets.length}`,
|
|
119
|
+
`Pre-change review ${packet.summary.preChangeState}`,
|
|
120
|
+
`Post-change review ${packet.summary.postChangeState}`,
|
|
121
|
+
`Change plan ${packet.summary.assuranceState}`,
|
|
122
|
+
`Integrity ${packet.integrity.digestSha256}`,
|
|
123
|
+
];
|
|
124
|
+
if (packet.gate.reasons.length) {
|
|
125
|
+
lines.push('', 'Review reasons:');
|
|
126
|
+
for (const reason of packet.gate.reasons)
|
|
127
|
+
lines.push(` - ${reason}`);
|
|
128
|
+
}
|
|
129
|
+
lines.push('', 'Limitations:');
|
|
130
|
+
for (const limitation of packet.limitations)
|
|
131
|
+
lines.push(` - ${limitation}`);
|
|
132
|
+
return `${lines.join('\n')}\n`;
|
|
133
|
+
}
|