TypeScript 55.4%
Python 43.2%
SQL 1.2%
1import { Resolver } from "node:dns/promises";2import { canonicalJson, isBlockedHostname, sha256, simhash, type ConnectorMetadata, type NormalizedContent, type Observation, type SensorEndpoint } from "@websensor/core";3import { NormalizeError, type WebSensorConnector } from "./types";45/**6 * DNS posture connector. Sensor URL: `dns://example.com` (any scheme is accepted; only the host is7 * used). Resolves A, AAAA, NS, MX, TXT, CAA and SOA (serial excluded — it changes on every zone8 * edit) through public resolvers, sorts every record set and compares the JSON. Detects: hosting9 * migrations (A/AAAA), provider changes (NS), mail changes (MX), SPF/DMARC/verification tokens (TXT),10 * CA restrictions (CAA), DNSSEC (DS present), and the resolver failing to answer.11 *12 * Config: { records?: ["A","AAAA","NS","MX","TXT","CAA","SOA","DS"], resolvers?: ["1.1.1.1","8.8.8.8"], includeDmarc?: true }13 */14const DEFAULT_RECORDS = ["A", "AAAA", "NS", "MX", "TXT", "CAA", "SOA", "DS"] as const;15type RecordType = (typeof DEFAULT_RECORDS)[number];16const DEFAULT_RESOLVERS = ["1.1.1.1", "8.8.8.8", "9.9.9.9"];1718export function hostOf(url: string): string {19 try {20 return new URL(url).hostname.replace(/\.$/, "").toLowerCase();21 } catch {22 return url.replace(/^[a-z]+:\/\//i, "").split("/")[0]!.toLowerCase();23 }24}2526export async function resolveRecords(host: string, records: readonly RecordType[], servers: string[], includeDmarc: boolean): Promise<Record<string, unknown>> {27 const r = new Resolver({ timeout: 5000, tries: 2 });28 r.setServers(servers);29 const out: Record<string, unknown> = {};30 const safe = async <T>(fn: () => Promise<T>): Promise<T | { error: string }> => {31 try {32 return await fn();33 } catch (e) {34 const code = (e as NodeJS.ErrnoException).code ?? "ERR";35 return code === "ENODATA" || code === "ENOTFOUND" ? ([] as unknown as T) : { error: code };36 }37 };38 for (const t of records) {39 switch (t) {40 case "A":41 out.A = norm(await safe(() => r.resolve4(host)));42 break;43 case "AAAA":44 out.AAAA = norm(await safe(() => r.resolve6(host)));45 break;46 case "NS":47 out.NS = norm(await safe(() => r.resolveNs(host)), true);48 break;49 case "MX": {50 const mx = await safe(() => r.resolveMx(host));51 out.MX = Array.isArray(mx) ? mx.map((m) => `${m.priority} ${m.exchange.toLowerCase().replace(/\.$/, "")}`).sort() : mx;52 break;53 }54 case "TXT": {55 const txt = await safe(() => r.resolveTxt(host));56 out.TXT = Array.isArray(txt) ? txt.map((parts) => parts.join("")).sort() : txt;57 break;58 }59 case "CAA": {60 const caa = await safe(() => r.resolveCaa(host));61 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;62 break;63 }64 case "SOA": {65 const soa = await safe(() => r.resolveSoa(host));66 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;67 break;68 }69 case "DS": {70 // node:dns has no DS query; use resolveAny as a best effort (may be unsupported by resolver).71 const any = await safe(() => r.resolveAny(host));72 out.DNSSEC_ANY_TYPES = Array.isArray(any) ? [...new Set(any.map((a) => a.type))].sort() : undefined;73 break;74 }75 }76 }77 if (includeDmarc) {78 const dmarc = await safe(() => r.resolveTxt(`_dmarc.${host}`));79 out.DMARC = Array.isArray(dmarc) ? dmarc.map((p) => p.join("")).sort() : dmarc;80 }81 return out;82}8384function norm(v: unknown, lower = false): unknown {85 if (!Array.isArray(v)) return v;86 return (v as string[]).map((s) => (lower ? s.toLowerCase().replace(/\.$/, "") : s)).sort();87}8889export class DnsConnector implements WebSensorConnector {90 mode = "json" as const;91 metadata(): ConnectorMetadata {92 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" };93 }94 async fetch(endpoint: SensorEndpoint): Promise<Observation> {95 const t0 = Date.now();96 const host = hostOf(endpoint.url);97 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: {} };98 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 } };99 const cfg = endpoint.config as { records?: RecordType[]; resolvers?: string[]; includeDmarc?: boolean };100 try {101 const records = await resolveRecords(host, cfg.records ?? DEFAULT_RECORDS, cfg.resolvers ?? DEFAULT_RESOLVERS, cfg.includeDmarc ?? true);102 const body = Buffer.from(JSON.stringify({ host, records }));103 const failed = Object.values(records).every((v) => v && typeof v === "object" && "error" in (v as object));104 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 } };105 return { sensorId: endpoint.id, url: endpoint.url, fetchedAt: new Date(), notModified: false, body, meta: { ...meta, contentLength: body.length, durationMs: Date.now() - t0 } };106 } catch (e) {107 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 } };108 }109 }110 async normalize(endpoint: SensorEndpoint, obs: Observation): Promise<NormalizedContent> {111 if (!obs.body) throw new NormalizeError("no_body", "Observation has no body");112 const j = JSON.parse(obs.body.toString("utf8")) as { host: string; records: Record<string, unknown> };113 const c = canonicalJson(j.records);114 const rec = j.records;115 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) ?? [] };116 return { mode: "json", json: rec, title: `DNS ${j.host}`, rawHash: sha256(obs.body), canonicalHash: sha256(c), semanticHash: simhash(c), extractionConfidence: 1, extra: summary };117 }118}119