import { Resolver } from "node:dns/promises"; import { canonicalJson, isBlockedHostname, sha256, simhash, type ConnectorMetadata, type NormalizedContent, type Observation, type SensorEndpoint } from "@websensor/core"; import { NormalizeError, type WebSensorConnector } from "./types"; /** * DNS posture connector. Sensor URL: `dns://example.com` (any scheme is accepted; only the host is * used). Resolves A, AAAA, NS, MX, TXT, CAA and SOA (serial excluded — it changes on every zone * edit) through public resolvers, sorts every record set and compares the JSON. Detects: hosting * migrations (A/AAAA), provider changes (NS), mail changes (MX), SPF/DMARC/verification tokens (TXT), * CA restrictions (CAA), DNSSEC (DS present), and the resolver failing to answer. * * Config: { records?: ["A","AAAA","NS","MX","TXT","CAA","SOA","DS"], resolvers?: ["1.1.1.1","8.8.8.8"], includeDmarc?: true } */ const DEFAULT_RECORDS = ["A", "AAAA", "NS", "MX", "TXT", "CAA", "SOA", "DS"] as const; type RecordType = (typeof DEFAULT_RECORDS)[number]; const DEFAULT_RESOLVERS = ["1.1.1.1", "8.8.8.8", "9.9.9.9"]; export function hostOf(url: string): string { try { return new URL(url).hostname.replace(/\.$/, "").toLowerCase(); } catch { return url.replace(/^[a-z]+:\/\//i, "").split("/")[0]!.toLowerCase(); } } export async function resolveRecords(host: string, records: readonly RecordType[], servers: string[], includeDmarc: boolean): Promise> { const r = new Resolver({ timeout: 5000, tries: 2 }); r.setServers(servers); const out: Record = {}; const safe = async (fn: () => Promise): Promise => { try { return await fn(); } catch (e) { const code = (e as NodeJS.ErrnoException).code ?? "ERR"; return code === "ENODATA" || code === "ENOTFOUND" ? ([] as unknown as T) : { error: code }; } }; for (const t of records) { switch (t) { case "A": out.A = norm(await safe(() => r.resolve4(host))); break; case "AAAA": out.AAAA = norm(await safe(() => r.resolve6(host))); break; case "NS": out.NS = norm(await safe(() => r.resolveNs(host)), true); break; case "MX": { const mx = await safe(() => r.resolveMx(host)); out.MX = Array.isArray(mx) ? mx.map((m) => `${m.priority} ${m.exchange.toLowerCase().replace(/\.$/, "")}`).sort() : mx; break; } case "TXT": { const txt = await safe(() => r.resolveTxt(host)); out.TXT = Array.isArray(txt) ? txt.map((parts) => parts.join("")).sort() : txt; break; } case "CAA": { const caa = await safe(() => r.resolveCaa(host)); out.CAA = Array.isArray(caa) ? caa.map((c) => `${c.critical} ${Object.entries(c).filter(([k]) => k !== "critical").map(([k, v]) => `${k}=${String(v)}`).join(" ")}`).sort() : caa; break; } case "SOA": { const soa = await safe(() => r.resolveSoa(host)); out.SOA = soa && !Array.isArray(soa) && !("error" in soa) ? { nsname: soa.nsname.toLowerCase(), hostmaster: soa.hostmaster.toLowerCase(), refresh: soa.refresh, retry: soa.retry, expire: soa.expire, minttl: soa.minttl } : soa; break; } case "DS": { // node:dns has no DS query; use resolveAny as a best effort (may be unsupported by resolver). const any = await safe(() => r.resolveAny(host)); out.DNSSEC_ANY_TYPES = Array.isArray(any) ? [...new Set(any.map((a) => a.type))].sort() : undefined; break; } } } if (includeDmarc) { const dmarc = await safe(() => r.resolveTxt(`_dmarc.${host}`)); out.DMARC = Array.isArray(dmarc) ? dmarc.map((p) => p.join("")).sort() : dmarc; } return out; } function norm(v: unknown, lower = false): unknown { if (!Array.isArray(v)) return v; return (v as string[]).map((s) => (lower ? s.toLowerCase().replace(/\.$/, "") : s)).sort(); } export class DnsConnector implements WebSensorConnector { mode = "json" as const; metadata(): ConnectorMetadata { return { key: "dns", name: "DNS records", sensorTypes: ["DNS"], description: "A/AAAA/NS/MX/TXT/CAA/SOA/DMARC snapshot via public resolvers — hosting, mail, provider and policy changes", version: "1.0.0" }; } async fetch(endpoint: SensorEndpoint): Promise { const t0 = Date.now(); const host = hostOf(endpoint.url); const meta = { status: 200, url: endpoint.url, finalUrl: endpoint.url, contentType: "application/json", contentLength: 0, etag: null, lastModified: null, durationMs: 0, redirects: 0, method: "API" as const, headers: {} }; if (!host || isBlockedHostname(host)) return { sensorId: endpoint.id, url: endpoint.url, fetchedAt: new Date(), notModified: false, error: { code: "ssrf_blocked", message: `Host ${host} is not allowed` }, meta: { ...meta, status: 0 } }; const cfg = endpoint.config as { records?: RecordType[]; resolvers?: string[]; includeDmarc?: boolean }; try { const records = await resolveRecords(host, cfg.records ?? DEFAULT_RECORDS, cfg.resolvers ?? DEFAULT_RESOLVERS, cfg.includeDmarc ?? true); const body = Buffer.from(JSON.stringify({ host, records })); const failed = Object.values(records).every((v) => v && typeof v === "object" && "error" in (v as object)); if (failed) return { sensorId: endpoint.id, url: endpoint.url, fetchedAt: new Date(), notModified: false, error: { code: "dns", message: `All queries failed for ${host}` }, meta: { ...meta, status: 0, durationMs: Date.now() - t0 } }; return { sensorId: endpoint.id, url: endpoint.url, fetchedAt: new Date(), notModified: false, body, meta: { ...meta, contentLength: body.length, durationMs: Date.now() - t0 } }; } catch (e) { return { sensorId: endpoint.id, url: endpoint.url, fetchedAt: new Date(), notModified: false, error: { code: "dns", message: (e as Error).message }, meta: { ...meta, status: 0, durationMs: Date.now() - t0 } }; } } async normalize(endpoint: SensorEndpoint, obs: Observation): Promise { if (!obs.body) throw new NormalizeError("no_body", "Observation has no body"); const j = JSON.parse(obs.body.toString("utf8")) as { host: string; records: Record }; const c = canonicalJson(j.records); const rec = j.records; const summary = { a: (rec.A as string[] | undefined)?.length ?? 0, aaaa: (rec.AAAA as string[] | undefined)?.length ?? 0, ns: (rec.NS as string[] | undefined) ?? [], mx: (rec.MX as string[] | undefined) ?? [], spf: ((rec.TXT as string[] | undefined) ?? []).find((t) => /^v=spf1/i.test(t)) ?? null, dmarc: ((rec.DMARC as string[] | undefined) ?? [])[0] ?? null, caa: (rec.CAA as string[] | undefined) ?? [] }; return { mode: "json", json: rec, title: `DNS ${j.host}`, rawHash: sha256(obs.body), canonicalHash: sha256(c), semanticHash: simhash(c), extractionConfidence: 1, extra: summary }; } }