@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.
Files changed (139) hide show
  1. package/README.md +32 -2
  2. package/bin/whoisleuth.mjs +52 -24
  3. package/cli/archive-inspect.mjs +37 -2
  4. package/cli/arguments.mjs +493 -157
  5. package/cli/artifact-verify.mjs +91 -15
  6. package/cli/bulk-checkpoint.mjs +7 -11
  7. package/cli/bulk-command-runner.mjs +19 -2
  8. package/cli/case-pack.mjs +136 -0
  9. package/cli/ci-report.mjs +73 -0
  10. package/cli/collection-preflight.mjs +59 -0
  11. package/cli/command-reference.mjs +395 -0
  12. package/cli/compare.mjs +1 -1
  13. package/cli/completion.mjs +49 -11
  14. package/cli/config-profile.mjs +180 -0
  15. package/cli/ct-event-intake.mjs +159 -0
  16. package/cli/discovery-scan-command-runner.mjs +15 -1
  17. package/cli/doctor.mjs +11 -5
  18. package/cli/domain-control-monitor.mjs +104 -0
  19. package/cli/domain-control-observations.mjs +235 -0
  20. package/cli/evidence-command-arguments.mjs +12 -1
  21. package/cli/evidence-command-runner.mjs +3 -2
  22. package/cli/evidence-signing.mjs +4 -4
  23. package/cli/external-observation-mapping.mjs +162 -0
  24. package/cli/fail-policy.mjs +64 -0
  25. package/cli/formatters/html.mjs +2 -2
  26. package/cli/formatters/json.mjs +13 -1
  27. package/cli/formatters/terminal.mjs +7 -3
  28. package/cli/investigation-manifest.mjs +113 -0
  29. package/cli/investigation-plan.mjs +2 -2
  30. package/cli/investigation-run.mjs +112 -0
  31. package/cli/local-mmdb-review.mjs +76 -0
  32. package/cli/lookalike-calibration.mjs +121 -0
  33. package/cli/lookup-brief.mjs +116 -0
  34. package/cli/lookup-command-runner.mjs +12 -2
  35. package/cli/lookup-diff.mjs +17 -19
  36. package/cli/lookup-plan.mjs +3 -3
  37. package/cli/lookup-reconcile.mjs +141 -0
  38. package/cli/lookup-timeline.mjs +1 -1
  39. package/cli/mail-review.mjs +1 -1
  40. package/cli/network-command-runner.mjs +4 -1
  41. package/cli/offline-evidence-review.mjs +125 -10
  42. package/cli/open-asset-model-bridge.mjs +169 -0
  43. package/cli/registry-cohort.mjs +121 -0
  44. package/cli/registry-doctor.mjs +220 -0
  45. package/cli/registry-fixture-scaffold.mjs +73 -0
  46. package/cli/risk-calibration.mjs +117 -9
  47. package/cli/runner.mjs +561 -280
  48. package/cli/saved-lookup.mjs +2 -2
  49. package/cli/sharing-review.mjs +202 -0
  50. package/cli/source-reliability.mjs +1 -1
  51. package/docs/cli-reference.md +1336 -0
  52. package/docs/cli.md +259 -940
  53. package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
  54. package/frontend/src/lib/analysis/acquisition-due-diligence.js +7 -7
  55. package/frontend/src/lib/analysis/brand-profile-model.js +46 -4
  56. package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -12
  57. package/frontend/src/lib/analysis/bulk-mail-exposure.js +1 -1
  58. package/frontend/src/lib/analysis/bulk-session-model.js +7 -2
  59. package/frontend/src/lib/analysis/case-evidence-model.js +26 -4
  60. package/frontend/src/lib/analysis/case-migration-model.js +6 -1
  61. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  62. package/frontend/src/lib/analysis/case-record-core.js +5 -1
  63. package/frontend/src/lib/analysis/case-record-operations.js +6 -1
  64. package/frontend/src/lib/analysis/case-report.js +573 -0
  65. package/frontend/src/lib/analysis/case-response-model.js +30 -1
  66. package/frontend/src/lib/analysis/case-response-packet.js +7 -7
  67. package/frontend/src/lib/analysis/detection-rule-model.js +36 -0
  68. package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
  69. package/frontend/src/lib/analysis/external-findings-import.js +320 -0
  70. package/frontend/src/lib/analysis/investigation-capsule.js +105 -0
  71. package/frontend/src/lib/analysis/lookup-asset-graph.js +806 -0
  72. package/frontend/src/lib/analysis/lookup-decision-support.js +487 -0
  73. package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
  74. package/frontend/src/lib/analysis/lookup-presentation.js +64 -0
  75. package/frontend/src/lib/analysis/lookup-response.js +2 -0
  76. package/frontend/src/lib/analysis/lookup-source-refresh.js +304 -0
  77. package/frontend/src/lib/analysis/lookup-summary-model.js +398 -0
  78. package/frontend/src/lib/analysis/page-similarity.js +1 -1
  79. package/frontend/src/lib/analysis/relationship-evidence.js +3 -3
  80. package/frontend/src/lib/analysis/relationship-observation-model.js +4 -4
  81. package/frontend/src/lib/analysis/scoring.js +9 -89
  82. package/frontend/src/lib/analysis/shortlist-model.js +8 -4
  83. package/frontend/src/lib/analysis/visualization-bounds.js +26 -0
  84. package/frontend/src/lib/analysis/visualization-force-graph.js +352 -0
  85. package/frontend/src/lib/analysis/visualization-matrix.js +88 -0
  86. package/frontend/src/lib/analysis/visualization-models.js +369 -0
  87. package/frontend/src/lib/analysis/visualization-time-series.js +144 -0
  88. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -1
  89. package/frontend/src/lib/analysis/workspace-archive.js +4 -2
  90. package/lib/abusech-auth.mjs +1 -1
  91. package/lib/analyst-taxonomy.mjs +40 -0
  92. package/lib/availability.mjs +2 -2
  93. package/lib/bounded-contract-normalizers.mjs +33 -0
  94. package/lib/browser-library-profile.mjs +15 -2
  95. package/lib/certificate-extension-profile.mjs +71 -0
  96. package/lib/credential-surface-profile.mjs +1 -1
  97. package/lib/dns-convergence-review.mjs +183 -0
  98. package/lib/dns-delegation-health.mjs +196 -3
  99. package/lib/domain-assurance.mjs +368 -0
  100. package/lib/domain-change-packet.mjs +133 -0
  101. package/lib/domain-change-review.mjs +307 -0
  102. package/lib/domain-control-flight-recorder.mjs +268 -0
  103. package/lib/domain-control-manifest.mjs +50 -7
  104. package/lib/domain-portfolio-review.mjs +151 -0
  105. package/lib/domain-posture.mjs +9 -9
  106. package/lib/encrypted-dns-contract.mjs +1 -1
  107. package/lib/evidence-export.mjs +1 -1
  108. package/lib/evidence-report-markdown.mjs +1 -1
  109. package/lib/evidence-report.mjs +1 -1
  110. package/lib/generated/cisa-kev-catalog.mjs +1668 -0
  111. package/lib/html-signals.mjs +5 -11
  112. package/lib/ip-prefix.mjs +34 -1
  113. package/lib/local-geoip-evidence.mjs +2 -2
  114. package/lib/lookup-response-contract.mjs +346 -0
  115. package/lib/nameserver-preflight-review.mjs +159 -0
  116. package/lib/observed-network-context.mjs +1 -1
  117. package/lib/opportunity-scoring.mjs +107 -0
  118. package/lib/page-fingerprints.mjs +1 -1
  119. package/lib/page-language-signals.mjs +124 -0
  120. package/lib/rdap-capabilities.mjs +6 -6
  121. package/lib/rdap-search-workbench.mjs +96 -6
  122. package/lib/registry-access.mjs +1 -1
  123. package/lib/registry-capability-catalogue.mjs +4 -4
  124. package/lib/registry-insights.mjs +6 -6
  125. package/lib/risk-scoring.mjs +247 -83
  126. package/lib/scoring-evidence-quality.mjs +107 -0
  127. package/lib/security-txt.mjs +3 -3
  128. package/lib/static-html-analysis.mjs +29 -1
  129. package/lib/threat-intelligence-manifests.mjs +1 -1
  130. package/lib/threatfox-intelligence.mjs +1 -1
  131. package/lib/tls-intelligence.mjs +13 -3
  132. package/lib/trust-store-comparison.mjs +146 -0
  133. package/lib/urlhaus-intelligence.mjs +1 -1
  134. package/lib/website-security-posture.mjs +5 -5
  135. package/lib/website-technology.mjs +25 -11
  136. package/lib/whois-chain.mjs +1 -1
  137. package/lib/zone-intent-review.mjs +534 -0
  138. package/package.json +5 -1
  139. 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 = 1;
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: [...identifiers].sort(),
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 recognized purpose.',
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 || registry.truncated,
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, };