feat(24-04): implement NS normalization and delegation-comparison module

- normalizeNsList: lowercase, strip trailing dot, dedupe, sort, [] for non-arrays
- compareNsDelegation: set-diff mismatch with unjudgeable-empty-authoritative guard
- resolveLiveNs: dedicated dns.Resolver() pinned to 1.1.1.1/8.8.8.8, never touches
  the process-global resolver (D-12, T-24-13)
- checkAllZoneDelegations: bounded-concurrency batch check, lookup errors reported
  separately from mismatches (T-24-14)
This commit is contained in:
lorentz 2026-08-05 20:21:30 -04:00
parent 7396f07f2e
commit 06ebae5a5c

View file

@ -0,0 +1,195 @@
/**
* D-12 DNS-delegation health check helpers.
*
* Split into a pure half (normalization + set comparison, fully unit-testable)
* and an I/O half (live public DNS lookup via a dedicated `dns.Resolver()`
* instance). The pure half never touches the network; the I/O half never
* touches the process-global resolver see the CRITICAL comment on
* `resolveLiveNs` below (24-RESEARCH.md Pitfall 5 / threat T-24-13).
*/
import { Resolver } from 'dns';
// ============================================================================
// Pure half — NS normalization + comparison (unit-testable, no I/O)
// ============================================================================
/**
* Normalize an unknown input (expected to be a JSONB-sourced string array,
* e.g. route53_zones.authoritative_name_servers or a raw DNS answer) into a
* lowercase, trailing-dot-stripped, de-duplicated, sorted list of hostnames.
*
* Returns [] for null, undefined, or any non-array input rather than
* throwing callers treat an empty list as "unjudgeable", not an error.
*/
export function normalizeNsList(input: unknown): string[] {
if (!Array.isArray(input)) return [];
const set = new Set<string>();
for (const entry of input) {
const normalized = String(entry).trim().toLowerCase().replace(/\.$/, '');
if (normalized.length > 0) set.add(normalized);
}
return Array.from(set).sort();
}
export interface NsDelegationComparison {
mismatch: boolean;
authoritative: string[];
live: string[];
missingFromLive: string[];
extraInLive: string[];
}
/**
* Compare Route 53's authoritative NS list against a live public DNS answer.
*
* - An empty (normalized) authoritative list is unjudgeable a zone Pulse
* has no recorded NS data for cannot be flagged as drifted. Returns
* `mismatch: false`.
* - A non-empty authoritative list with an empty live answer IS a delegation
* problem (the domain resolved to nothing) returns `mismatch: true`.
* - Otherwise mismatch is true iff either side has an entry the other lacks.
*/
export function compareNsDelegation(authoritative: unknown, live: unknown): NsDelegationComparison {
const authNorm = normalizeNsList(authoritative);
const liveNorm = normalizeNsList(live);
if (authNorm.length === 0) {
return { mismatch: false, authoritative: authNorm, live: liveNorm, missingFromLive: [], extraInLive: [] };
}
if (liveNorm.length === 0) {
return {
mismatch: true,
authoritative: authNorm,
live: liveNorm,
missingFromLive: [...authNorm],
extraInLive: [],
};
}
const authSet = new Set(authNorm);
const liveSet = new Set(liveNorm);
const missingFromLive = authNorm.filter((ns) => !liveSet.has(ns));
const extraInLive = liveNorm.filter((ns) => !authSet.has(ns));
return {
mismatch: missingFromLive.length > 0 || extraInLive.length > 0,
authoritative: authNorm,
live: liveNorm,
missingFromLive,
extraInLive,
};
}
// ============================================================================
// I/O half — live public DNS lookup (dedicated resolver instance)
// ============================================================================
export type ResolveLiveNsResult =
| { ok: true; nameServers: string[] }
| { ok: false; error: string };
/**
* Resolve a domain's live NS records against a dedicated public resolver
* (Cloudflare 1.1.1.1 + Google 8.8.8.8), NOT the container's default
* resolver (24-RESEARCH.md Pitfall 5).
*
* CRITICAL: this constructs its own `new Resolver()` instance and calls
* `setServers()` on THAT instance only. The process-global `dns.setServers()`
* is never called anywhere in this file doing so would repoint DNS
* resolution for the entire Node process, including Postgres/Redis/AWS
* hostname resolution (T-24-13).
*/
export function resolveLiveNs(domain: string, timeoutMs = 5000): Promise<ResolveLiveNsResult> {
const target = domain.trim().replace(/\.$/, '');
const resolver = new Resolver();
resolver.setServers(['1.1.1.1', '8.8.8.8']);
return new Promise((resolve) => {
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
resolver.cancel();
resolve({ ok: false, error: `DNS lookup timed out after ${timeoutMs}ms` });
}, timeoutMs);
resolver.resolveNs(target, (err, addresses) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (err) {
resolve({ ok: false, error: err.message });
return;
}
resolve({ ok: true, nameServers: addresses });
});
});
}
export interface ZoneDelegationInput {
id: string;
name: string;
authoritativeNameServers: unknown;
}
export interface ZoneDelegationResult {
zoneId: string;
zoneName: string;
mismatch: boolean;
error?: string;
missingFromLive: string[];
extraInLive: string[];
}
/**
* Check live NS delegation for a batch of zones, bounded to at most
* `opts.concurrency` (default 5) concurrent lookups so a large zone list
* does not open hundreds of concurrent UDP sockets (T-24-14).
*
* Zones whose authoritativeNameServers normalizes to an empty list are
* skipped (unjudgeable). A lookup failure yields `{ mismatch: false, error }`
* an unreachable resolver is an infrastructure problem, not evidence of
* delegation drift, and must never be reported as a mismatch.
*/
export async function checkAllZoneDelegations(
zones: ZoneDelegationInput[],
opts?: { concurrency?: number },
): Promise<ZoneDelegationResult[]> {
const concurrency = opts?.concurrency ?? 5;
const judgeable = zones.filter((z) => normalizeNsList(z.authoritativeNameServers).length > 0);
const results: ZoneDelegationResult[] = [];
let cursor = 0;
async function worker(): Promise<void> {
while (cursor < judgeable.length) {
const idx = cursor;
cursor += 1;
const zone = judgeable[idx];
const live = await resolveLiveNs(zone.name);
if (!live.ok) {
results.push({
zoneId: zone.id,
zoneName: zone.name,
mismatch: false,
error: live.error,
missingFromLive: [],
extraInLive: [],
});
continue;
}
const comparison = compareNsDelegation(zone.authoritativeNameServers, live.nameServers);
results.push({
zoneId: zone.id,
zoneName: zone.name,
mismatch: comparison.mismatch,
missingFromLive: comparison.missingFromLive,
extraInLive: comparison.extraInLive,
});
}
}
const workers = Array.from({ length: Math.min(concurrency, judgeable.length) }, () => worker());
await Promise.all(workers);
return results;
}