import { Resolver } from "node:dns/promises"; import { createDeadline, remainingMs, withDeadline } from "./deadline"; export const DNS_TIMEOUT_MS = 4_000; export type ProbeError = { probe: string; message: string }; export type DnsFamilyResult = { addresses: string[]; status: "ok" | "absent" | "failed"; error: ProbeError | null }; export type ResolverLike = Pick; export type ResolverFactory = () => ResolverLike; type Captured = { value: T | null; error: ProbeError | null }; export type DmarcPosture = { status: "missing" | "invalid" | "monitor" | "quarantine" | "reject"; policy: "none" | "quarantine" | "reject" | null; pct: number | null; record: string | null; reason: string; }; export type SpfPosture = { status: "missing" | "invalid" | "present" | "hardfail" | "softfail" | "neutral" | "permissive" | "incomplete"; allQualifier: "-" | "~" | "?" | "+" | null; record: string | null; reason: string; }; export function isAbsentDnsError(error: unknown): boolean { const code = typeof error === "object" && error && "code" in error ? String((error as { code?: unknown }).code) : ""; return code === "ENODATA" || code === "ENOTFOUND" || code === "EAI_NONAME"; } function errorCode(error: unknown) { return typeof error === "object" && error !== null && "code" in error ? String((error as { code?: unknown }).code ?? "") : ""; } function failureMessage(probe: string, error: unknown) { return errorCode(error) === "ETIMEOUT" ? probe + " DNS query timed out." : probe + " DNS probe failed."; } async function resolveWithBudget( probe: string, operation: (resolver: ResolverLike) => Promise, deadline: number, resolverFactory: ResolverFactory, ): Promise> { const resolver = resolverFactory(); try { if (!remainingMs(deadline)) return { value: null, error: { probe, message: probe + " DNS query timed out." } }; const value = await withDeadline( operation(resolver), deadline, () => resolver.cancel(), "DNS resolution timed out.", ); return { value, error: null }; } catch (error) { if (isAbsentDnsError(error)) return { value: null, error: null }; return { value: null, error: { probe, message: failureMessage(probe, error) } }; } finally { resolver.cancel(); } } export async function resolveDnsFamily(host: string, family: 4 | 6, deadline = createDeadline(DNS_TIMEOUT_MS), resolverFactory: ResolverFactory = () => new Resolver()): Promise { const probe = family === 4 ? "A" : "AAAA"; const captured = await resolveWithBudget(probe, (resolver) => family === 4 ? resolver.resolve4(host) : resolver.resolve6(host), deadline, resolverFactory); return familyResult(captured); } async function capture(probe: string, fn: (resolver: ResolverLike) => Promise, deadline: number, resolverFactory: ResolverFactory): Promise> { return resolveWithBudget(probe, fn, deadline, resolverFactory); } const flattenTxt = (records: string[][]) => records.map((parts) => parts.join("")); const isSpfRecord = (value: string) => value.trimStart().toLowerCase().startsWith("v=spf1"); /** * This deliberately reads only the visible terminal `all` mechanism. It does * not resolve includes, macros or SPF lookup chains. */ export function summarizeSpf(records: string[]): SpfPosture { if (!records.length) return { status: "missing", allQualifier: null, record: null, reason: "No SPF record was observed." }; if (records.length !== 1) return { status: "invalid", allQualifier: null, record: records[0] ?? null, reason: "Multiple SPF records were observed." }; const record = records[0]; const mechanisms = record.trim().split(/\s+/).slice(1).filter(Boolean); const allIndexes = mechanisms.map((mechanism, index) => /^[+\-~?]?all$/i.test(mechanism) ? index : -1).filter((index) => index >= 0); if (allIndexes.length !== 1) return { status: "incomplete", allQualifier: null, record, reason: "No single terminal all mechanism was observed." }; const index = allIndexes[0]; if (index !== mechanisms.length - 1) return { status: "invalid", allQualifier: null, record, reason: "The SPF all mechanism is not terminal." }; type SpfQualifier = NonNullable; const qualifier: SpfQualifier = (mechanisms[index][0] === "-" || mechanisms[index][0] === "~" || mechanisms[index][0] === "?" || mechanisms[index][0] === "+") ? mechanisms[index][0] as SpfQualifier : "+"; const posture: Record> = { "-": { status: "hardfail", reason: "The terminal -all mechanism rejects unauthorized senders." }, "~": { status: "softfail", reason: "The terminal ~all mechanism soft-fails unauthorized senders." }, "?": { status: "neutral", reason: "The terminal ?all mechanism is neutral for unauthorized senders." }, "+": { status: "permissive", reason: "The terminal +all mechanism authorizes every sender." }, }; return { ...posture[qualifier], allQualifier: qualifier, record }; } export function parseDmarcRecord(record: string): { valid: boolean; policy: DmarcPosture["policy"]; pct: number | null; tags: Record } { const tags = new Map(); let invalid = false; for (const part of record.split(";")) { const trimmed = part.trim(); if (!trimmed) continue; const [rawName, ...rawValue] = trimmed.split("="); const name = rawName.trim().toLowerCase(); const value = rawValue.join("=").trim().toLowerCase(); if (!/^[a-z][a-z0-9-]*$/.test(name) || !value || tags.has(name)) invalid = true; else tags.set(name, value); } if (tags.get("v") !== "dmarc1") invalid = true; const policyValue = tags.get("p"); const policy = policyValue === "none" || policyValue === "quarantine" || policyValue === "reject" ? policyValue : null; if (!policy) invalid = true; const pctRaw = tags.get("pct"); const pct = pctRaw === undefined ? 100 : /^\d+$/.test(pctRaw) ? Number(pctRaw) : null; if (pct === null || !Number.isInteger(pct) || pct < 0 || pct > 100) invalid = true; return { valid: !invalid, policy, pct, tags: Object.fromEntries(tags) }; } export function summarizeDmarc(records: string[]): DmarcPosture { if (!records.length) return { status: "missing", policy: null, pct: null, record: null, reason: "No DMARC record was observed." }; if (records.length !== 1) return { status: "invalid", policy: null, pct: null, record: records[0] ?? null, reason: "Multiple DMARC records were observed." }; const parsed = parseDmarcRecord(records[0]); if (!parsed.valid || !parsed.policy) return { status: "invalid", policy: parsed.policy, pct: parsed.pct, record: records[0], reason: "The DMARC record is malformed or uses an unsupported policy." }; const monitorOnly = parsed.policy === "none" || parsed.pct === 0; const status = monitorOnly ? "monitor" : parsed.policy === "quarantine" ? "quarantine" : "reject"; return { status, policy: parsed.policy, pct: parsed.pct, record: records[0], reason: parsed.policy === "none" ? "p=none monitors failures without enforcement." : parsed.pct === 0 ? "pct=0 applies the policy to no messages." : "An enforcing DMARC policy was observed." }; } export interface DnsCollectionOptions { deadline?: number; resolverFactory?: ResolverFactory; } function familyResult(captured: Captured): DnsFamilyResult { const addresses = captured.value ?? []; return { addresses, status: captured.error ? "failed" : addresses.length ? "ok" : "absent", error: captured.error }; } export async function collectDns(host: string, options: DnsCollectionOptions = {}) { const deadline = options.deadline ?? createDeadline(DNS_TIMEOUT_MS); const resolverFactory = options.resolverFactory ?? (() => new Resolver()); const [a, aaaa, mx, ns, txtRaw, dmarcRaw] = await Promise.all([ capture("A", (resolver) => resolver.resolve4(host), deadline, resolverFactory), capture("AAAA", (resolver) => resolver.resolve6(host), deadline, resolverFactory), capture("MX", (resolver) => resolver.resolveMx(host), deadline, resolverFactory), capture("NS", (resolver) => resolver.resolveNs(host), deadline, resolverFactory), capture("TXT", (resolver) => resolver.resolveTxt(host), deadline, resolverFactory), capture("DMARC", (resolver) => resolver.resolveTxt("_dmarc." + host), deadline, resolverFactory), ]); const txt = flattenTxt(txtRaw.value ?? []); const dmarc = flattenTxt(dmarcRaw.value ?? []); const errors = [a.error, aaaa.error, mx.error, ns.error, txtRaw.error, dmarcRaw.error] .filter((item): item is ProbeError => Boolean(item)); const valuesAvailable = Boolean((a.value?.length ?? 0) + (aaaa.value?.length ?? 0) + (mx.value?.length ?? 0) + (ns.value?.length ?? 0) + txt.length + dmarc.length); const status = errors.length ? (valuesAvailable ? "partial" : "failed") : "ok"; const ipv4 = familyResult(a); const ipv6 = familyResult(aaaa); return { addresses: [...ipv4.addresses, ...ipv6.addresses], families: { ipv4, ipv6 }, a: a.value ?? [], aaaa: aaaa.value ?? [], mx: mx.value ?? [], ns: ns.value ?? [], txt, spf: txt.filter(isSpfRecord), dmarc, spfPosture: summarizeSpf(txt.filter(isSpfRecord)), dmarcPosture: summarizeDmarc(dmarc), _meta: { status, partial: status === "partial", errors }, }; }