@slicedearth/whoisleuth-cli 1.36.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 +32 -2
- package/bin/whoisleuth.mjs +52 -24
- package/cli/archive-inspect.mjs +37 -2
- package/cli/arguments.mjs +493 -157
- package/cli/artifact-verify.mjs +91 -15
- 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/compare.mjs +1 -1
- package/cli/completion.mjs +49 -11
- 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/doctor.mjs +11 -5
- package/cli/domain-control-monitor.mjs +104 -0
- package/cli/domain-control-observations.mjs +235 -0
- package/cli/evidence-command-arguments.mjs +12 -1
- package/cli/evidence-command-runner.mjs +3 -2
- package/cli/evidence-signing.mjs +4 -4
- package/cli/external-observation-mapping.mjs +162 -0
- package/cli/fail-policy.mjs +64 -0
- package/cli/formatters/html.mjs +2 -2
- package/cli/formatters/json.mjs +13 -1
- package/cli/formatters/terminal.mjs +7 -3
- package/cli/investigation-manifest.mjs +113 -0
- package/cli/investigation-plan.mjs +2 -2
- package/cli/investigation-run.mjs +112 -0
- package/cli/local-mmdb-review.mjs +76 -0
- package/cli/lookalike-calibration.mjs +121 -0
- package/cli/lookup-brief.mjs +116 -0
- package/cli/lookup-command-runner.mjs +12 -2
- package/cli/lookup-diff.mjs +17 -19
- package/cli/lookup-plan.mjs +3 -3
- package/cli/lookup-reconcile.mjs +141 -0
- package/cli/lookup-timeline.mjs +1 -1
- package/cli/mail-review.mjs +1 -1
- package/cli/network-command-runner.mjs +4 -1
- package/cli/offline-evidence-review.mjs +125 -10
- package/cli/open-asset-model-bridge.mjs +169 -0
- package/cli/registry-cohort.mjs +121 -0
- package/cli/registry-doctor.mjs +220 -0
- package/cli/registry-fixture-scaffold.mjs +73 -0
- package/cli/risk-calibration.mjs +117 -9
- package/cli/runner.mjs +561 -280
- package/cli/saved-lookup.mjs +2 -2
- package/cli/sharing-review.mjs +202 -0
- package/cli/source-reliability.mjs +1 -1
- package/docs/cli-reference.md +1336 -0
- package/docs/cli.md +259 -940
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +7 -7
- package/frontend/src/lib/analysis/brand-profile-model.js +46 -4
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -12
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +1 -1
- package/frontend/src/lib/analysis/bulk-session-model.js +7 -2
- package/frontend/src/lib/analysis/case-evidence-model.js +26 -4
- package/frontend/src/lib/analysis/case-migration-model.js +6 -1
- package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
- package/frontend/src/lib/analysis/case-record-core.js +5 -1
- package/frontend/src/lib/analysis/case-record-operations.js +6 -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/case-response-packet.js +7 -7
- package/frontend/src/lib/analysis/detection-rule-model.js +36 -0
- package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
- package/frontend/src/lib/analysis/external-findings-import.js +320 -0
- package/frontend/src/lib/analysis/investigation-capsule.js +105 -0
- package/frontend/src/lib/analysis/lookup-asset-graph.js +806 -0
- package/frontend/src/lib/analysis/lookup-decision-support.js +487 -0
- package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
- package/frontend/src/lib/analysis/lookup-presentation.js +64 -0
- package/frontend/src/lib/analysis/lookup-response.js +2 -0
- package/frontend/src/lib/analysis/lookup-source-refresh.js +304 -0
- package/frontend/src/lib/analysis/lookup-summary-model.js +398 -0
- package/frontend/src/lib/analysis/page-similarity.js +1 -1
- package/frontend/src/lib/analysis/relationship-evidence.js +3 -3
- package/frontend/src/lib/analysis/relationship-observation-model.js +4 -4
- package/frontend/src/lib/analysis/scoring.js +9 -89
- package/frontend/src/lib/analysis/shortlist-model.js +8 -4
- package/frontend/src/lib/analysis/visualization-bounds.js +26 -0
- package/frontend/src/lib/analysis/visualization-force-graph.js +352 -0
- package/frontend/src/lib/analysis/visualization-matrix.js +88 -0
- package/frontend/src/lib/analysis/visualization-models.js +369 -0
- package/frontend/src/lib/analysis/visualization-time-series.js +144 -0
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -1
- package/frontend/src/lib/analysis/workspace-archive.js +4 -2
- package/lib/abusech-auth.mjs +1 -1
- package/lib/analyst-taxonomy.mjs +40 -0
- package/lib/availability.mjs +2 -2
- package/lib/bounded-contract-normalizers.mjs +33 -0
- package/lib/browser-library-profile.mjs +15 -2
- package/lib/certificate-extension-profile.mjs +71 -0
- package/lib/credential-surface-profile.mjs +1 -1
- package/lib/dns-convergence-review.mjs +183 -0
- package/lib/dns-delegation-health.mjs +196 -3
- package/lib/domain-assurance.mjs +368 -0
- 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-control-manifest.mjs +50 -7
- package/lib/domain-portfolio-review.mjs +151 -0
- package/lib/domain-posture.mjs +9 -9
- package/lib/encrypted-dns-contract.mjs +1 -1
- package/lib/evidence-export.mjs +1 -1
- package/lib/evidence-report-markdown.mjs +1 -1
- package/lib/evidence-report.mjs +1 -1
- package/lib/generated/cisa-kev-catalog.mjs +1668 -0
- package/lib/html-signals.mjs +5 -11
- package/lib/ip-prefix.mjs +34 -1
- package/lib/local-geoip-evidence.mjs +2 -2
- package/lib/lookup-response-contract.mjs +346 -0
- package/lib/nameserver-preflight-review.mjs +159 -0
- package/lib/observed-network-context.mjs +1 -1
- package/lib/opportunity-scoring.mjs +107 -0
- package/lib/page-fingerprints.mjs +1 -1
- package/lib/page-language-signals.mjs +124 -0
- package/lib/rdap-capabilities.mjs +6 -6
- package/lib/rdap-search-workbench.mjs +96 -6
- package/lib/registry-access.mjs +1 -1
- package/lib/registry-capability-catalogue.mjs +4 -4
- package/lib/registry-insights.mjs +6 -6
- package/lib/risk-scoring.mjs +247 -83
- package/lib/scoring-evidence-quality.mjs +107 -0
- package/lib/security-txt.mjs +3 -3
- package/lib/static-html-analysis.mjs +29 -1
- package/lib/threat-intelligence-manifests.mjs +1 -1
- package/lib/threatfox-intelligence.mjs +1 -1
- package/lib/tls-intelligence.mjs +13 -3
- package/lib/trust-store-comparison.mjs +146 -0
- package/lib/urlhaus-intelligence.mjs +1 -1
- package/lib/website-security-posture.mjs +5 -5
- package/lib/website-technology.mjs +25 -11
- package/lib/whois-chain.mjs +1 -1
- package/lib/zone-intent-review.mjs +534 -0
- package/package.json +5 -1
- package/third-party-notices.txt +911 -19
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
// are treated as untrusted inputs and are not retained in the result.
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
7
7
|
import { RETIRE_BROWSER_CATALOG } from "./generated/retire-browser-catalog.mjs";
|
|
8
|
+
import { CISA_KEV_CATALOG } from "./generated/cisa-kev-catalog.mjs";
|
|
8
9
|
import { createObservation } from "./observation.mjs";
|
|
9
10
|
import { MAX_INLINE_SCRIPT_CHARS, MAX_INLINE_SCRIPT_TOTAL_CHARS, MAX_SCRIPT_ELEMENTS, MAX_STATIC_HTML_CHARS, analyzeStaticHtml, } from "./static-html-analysis.mjs";
|
|
10
|
-
const BROWSER_LIBRARY_PROFILE_VERSION =
|
|
11
|
+
const BROWSER_LIBRARY_PROFILE_VERSION = 2;
|
|
11
12
|
const MAX_LIBRARY_HTML_CHARS = MAX_STATIC_HTML_CHARS;
|
|
12
13
|
const MAX_LIBRARY_FINDINGS = 16;
|
|
13
14
|
const MAX_ADVISORY_IDENTIFIERS = 16;
|
|
@@ -27,6 +28,7 @@ const SEVERITY_WEIGHT = Object.freeze({
|
|
|
27
28
|
critical: 4,
|
|
28
29
|
});
|
|
29
30
|
const CATALOG_COMPONENTS = RETIRE_BROWSER_CATALOG.components;
|
|
31
|
+
const KNOWN_EXPLOITED_IDENTIFIERS = new Set(CISA_KEV_CATALOG.identifiers);
|
|
30
32
|
function record(value) {
|
|
31
33
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
32
34
|
? value
|
|
@@ -221,6 +223,8 @@ function findingFromDetection(detected) {
|
|
|
221
223
|
weaknesses.add(cwe);
|
|
222
224
|
}
|
|
223
225
|
}
|
|
226
|
+
const advisoryIdentifiers = [...identifiers].sort();
|
|
227
|
+
const knownExploitedIdentifiers = advisoryIdentifiers.filter((identifier) => KNOWN_EXPLOITED_IDENTIFIERS.has(identifier));
|
|
224
228
|
return {
|
|
225
229
|
id: detected.component,
|
|
226
230
|
name: detected.component,
|
|
@@ -228,7 +232,9 @@ function findingFromDetection(detected) {
|
|
|
228
232
|
detectionMethods: [...detected.detections].sort(),
|
|
229
233
|
advisoryCount: vulnerabilities.length,
|
|
230
234
|
highestSeverity,
|
|
231
|
-
advisoryIdentifiers
|
|
235
|
+
advisoryIdentifiers,
|
|
236
|
+
knownExploitedCount: knownExploitedIdentifiers.length,
|
|
237
|
+
knownExploitedIdentifiers,
|
|
232
238
|
weaknessClasses: [...weaknesses].sort(),
|
|
233
239
|
};
|
|
234
240
|
}
|
|
@@ -265,6 +271,7 @@ function analyzeBrowserLibraries(input = {}) {
|
|
|
265
271
|
const limitations = [
|
|
266
272
|
'Library versions are inferred from passive static signatures and may be absent, transformed, or misleading.',
|
|
267
273
|
'A catalogue advisory match identifies a component version associated with a published advisory; it does not establish reachability or exploitability.',
|
|
274
|
+
'A CISA KEV match means that the advisory identifier appears in the pinned known-exploited catalogue; it does not establish that the detected page exposes or executes the affected code path.',
|
|
268
275
|
'Referenced scripts are not fetched, executed, or retained, and unmatched scripts are not evidence that no library is present.',
|
|
269
276
|
];
|
|
270
277
|
if (input.sourceTruncated === true)
|
|
@@ -286,6 +293,11 @@ function analyzeBrowserLibraries(input = {}) {
|
|
|
286
293
|
version: RETIRE_BROWSER_CATALOG.catalogVersion,
|
|
287
294
|
sourceRevision: RETIRE_BROWSER_CATALOG.sourceRevision,
|
|
288
295
|
},
|
|
296
|
+
knownExploitedCatalog: {
|
|
297
|
+
name: 'CISA KEV',
|
|
298
|
+
version: CISA_KEV_CATALOG.catalogVersion,
|
|
299
|
+
releasedAt: CISA_KEV_CATALOG.releasedAt,
|
|
300
|
+
},
|
|
289
301
|
...createObservation({
|
|
290
302
|
status: truncated ? 'partial' : 'success',
|
|
291
303
|
observedAt: input.observedAt,
|
|
@@ -302,6 +314,7 @@ function analyzeBrowserLibraries(input = {}) {
|
|
|
302
314
|
catalogComponents: Object.keys(CATALOG_COMPONENTS).length,
|
|
303
315
|
findings: allFindings.length,
|
|
304
316
|
advisoryMatches: allFindings.reduce((sum, finding) => sum + finding.advisoryCount, 0),
|
|
317
|
+
knownExploitedMatches: allFindings.reduce((sum, finding) => sum + finding.knownExploitedCount, 0),
|
|
305
318
|
},
|
|
306
319
|
}),
|
|
307
320
|
findings: allFindings.slice(0, MAX_LIBRARY_FINDINGS),
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Narrow X.509 extension parsing for fields unavailable through Node's native
|
|
2
|
+
// X509Certificate API. Raw certificates and extension locations are never
|
|
3
|
+
// retained; malformed extensions fail soft and leave an explicit partial flag.
|
|
4
|
+
import 'reflect-metadata';
|
|
5
|
+
import { CertificatePolicyExtension, CRLDistributionPointsExtension, X509Certificate, } from '@peculiar/x509';
|
|
6
|
+
export const MAX_CERTIFICATE_POLICIES = 16;
|
|
7
|
+
export const MAX_CRL_DISTRIBUTION_POINTS = 32;
|
|
8
|
+
function emptyProfile(partial = false) {
|
|
9
|
+
return Object.freeze({
|
|
10
|
+
certificatePolicies: Object.freeze({ oids: Object.freeze([]), truncated: false }),
|
|
11
|
+
crlDistributionPoints: Object.freeze({ total: 0, http: 0, https: 0, ldap: 0, other: 0, truncated: false }),
|
|
12
|
+
parsed: false,
|
|
13
|
+
partial,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
function distributionUris(extension) {
|
|
17
|
+
const uris = [];
|
|
18
|
+
for (const point of extension.distributionPoints.slice(0, MAX_CRL_DISTRIBUTION_POINTS + 1)) {
|
|
19
|
+
for (const name of point.distributionPoint?.fullName ?? []) {
|
|
20
|
+
if (typeof name.uniformResourceIdentifier === 'string')
|
|
21
|
+
uris.push(name.uniformResourceIdentifier);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return uris;
|
|
25
|
+
}
|
|
26
|
+
export function parseCertificateExtensionProfile(raw) {
|
|
27
|
+
if (!Buffer.isBuffer(raw) || raw.length === 0 || raw.length > 256 * 1024)
|
|
28
|
+
return emptyProfile();
|
|
29
|
+
try {
|
|
30
|
+
const encoded = Uint8Array.from(raw).buffer;
|
|
31
|
+
const certificate = new X509Certificate(encoded);
|
|
32
|
+
const policies = certificate.getExtension(CertificatePolicyExtension)?.policies ?? [];
|
|
33
|
+
const crlExtension = certificate.getExtension(CRLDistributionPointsExtension);
|
|
34
|
+
const uris = crlExtension ? distributionUris(crlExtension) : [];
|
|
35
|
+
const retainedPolicies = [...new Set(policies.filter((oid) => /^\d+(?:\.\d+)+$/u.test(oid)))]
|
|
36
|
+
.slice(0, MAX_CERTIFICATE_POLICIES);
|
|
37
|
+
const retainedUris = uris.slice(0, MAX_CRL_DISTRIBUTION_POINTS);
|
|
38
|
+
const schemes = { http: 0, https: 0, ldap: 0, other: 0 };
|
|
39
|
+
for (const uri of retainedUris) {
|
|
40
|
+
let protocol = '';
|
|
41
|
+
try {
|
|
42
|
+
protocol = new URL(uri).protocol.toLowerCase();
|
|
43
|
+
}
|
|
44
|
+
catch { /* counted as other */ }
|
|
45
|
+
if (protocol === 'http:')
|
|
46
|
+
schemes.http += 1;
|
|
47
|
+
else if (protocol === 'https:')
|
|
48
|
+
schemes.https += 1;
|
|
49
|
+
else if (protocol === 'ldap:' || protocol === 'ldaps:')
|
|
50
|
+
schemes.ldap += 1;
|
|
51
|
+
else
|
|
52
|
+
schemes.other += 1;
|
|
53
|
+
}
|
|
54
|
+
return Object.freeze({
|
|
55
|
+
certificatePolicies: Object.freeze({
|
|
56
|
+
oids: Object.freeze(retainedPolicies),
|
|
57
|
+
truncated: policies.length > retainedPolicies.length,
|
|
58
|
+
}),
|
|
59
|
+
crlDistributionPoints: Object.freeze({
|
|
60
|
+
total: retainedUris.length,
|
|
61
|
+
...schemes,
|
|
62
|
+
truncated: uris.length > retainedUris.length,
|
|
63
|
+
}),
|
|
64
|
+
parsed: true,
|
|
65
|
+
partial: policies.length > retainedPolicies.length || uris.length > retainedUris.length,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return emptyProfile(true);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -18,7 +18,7 @@ function analyzeCredentialSurfaceProfile(input = {}) {
|
|
|
18
18
|
const partial = truncated || unclassifiedActions > 0;
|
|
19
19
|
const limitations = [
|
|
20
20
|
'Static input categories use only bounded type and autocomplete declarations; JavaScript-rendered, non-semantic, disabled, and hidden controls are not classified.',
|
|
21
|
-
'Category counts can overlap when one input declares more than one
|
|
21
|
+
'Category counts can overlap when one input declares more than one recognised purpose.',
|
|
22
22
|
'External submission can be legitimate and does not establish phishing, unsafe handling, ownership, intent, or maliciousness.',
|
|
23
23
|
'No field names, values, labels, placeholders, complete action URLs, paths, queries, fragments, or arbitrary attributes are retained.',
|
|
24
24
|
];
|
|
@@ -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, };
|