SPB Git forge

spb/websensor

Public
33commits 1branches 0releases
3.4 MBsize
maindefault branch
10 days agolast push
TypeScript 55.4% Python 43.2% SQL 1.2%
6.8 KB · 119 lines typescript
Raw Blame History
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