Files
CertScope-Public/lib/inspect/dns.ts
T

187 lines
9.3 KiB
TypeScript

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<Resolver, "resolve4" | "resolve6" | "resolveMx" | "resolveNs" | "resolveTxt" | "cancel">;
export type ResolverFactory = () => ResolverLike;
type Captured<T> = { 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<T>(
probe: string,
operation: (resolver: ResolverLike) => Promise<T>,
deadline: number,
resolverFactory: ResolverFactory,
): Promise<Captured<T>> {
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<DnsFamilyResult> {
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<T>(probe: string, fn: (resolver: ResolverLike) => Promise<T>, deadline: number, resolverFactory: ResolverFactory): Promise<Captured<T>> {
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<SpfPosture["allQualifier"]>;
const qualifier: SpfQualifier = (mechanisms[index][0] === "-" || mechanisms[index][0] === "~" || mechanisms[index][0] === "?" || mechanisms[index][0] === "+")
? mechanisms[index][0] as SpfQualifier
: "+";
const posture: Record<SpfQualifier, Pick<SpfPosture, "status" | "reason">> = {
"-": { 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<string, string> } {
const tags = new Map<string, string>();
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<string[] | null>): 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 },
};
}