import tls from "node:tls"; import { assertUrlAllowed, canonicalJson, sha256, simhash, type ConnectorMetadata, type NormalizedContent, type Observation, type SensorEndpoint } from "@websensor/core"; import { hostOf } from "./dns"; import { NormalizeError, type WebSensorConnector } from "./types"; /** * TLS certificate connector. Sensor URL: `tls://example.com[:443]`. Opens a TLS session (SNI = host, * no HTTP request), records the leaf certificate (subject, issuer, SANs, validity, key/sig algorithm, * serial), the chain issuers, negotiated protocol/cipher and ALPN. A CA change, a new SAN, a renewal * or a downgraded protocol becomes a JSON change; `extra.daysToExpiry` lets the heuristics raise * `certificate_expiring` when < 14 days. * * Config: { port?: 443, servername?: host, minVersion?: "TLSv1.2" } */ export interface TlsSnapshot { host: string; port: number; subject: Record; issuer: Record; sans: string[]; validFrom: string; validTo: string; serialNumber: string; fingerprint256: string; keyType: string | null; keyBits: number | null; signatureAlgorithm: string | null; chain: string[]; protocol: string | null; cipher: string | null; alpn: string | null; authorized: boolean; authorizationError: string | null; } export function probeTls(host: string, port = 443, servername = host, timeoutMs = 12_000): Promise { return new Promise((resolve, reject) => { const socket = tls.connect({ host, port, servername, rejectUnauthorized: false, ALPNProtocols: ["h2", "http/1.1"], minVersion: "TLSv1", timeout: timeoutMs }); const timer = setTimeout(() => { socket.destroy(); reject(new Error(`TLS handshake timeout after ${timeoutMs} ms`)); }, timeoutMs); socket.once("error", (e) => { clearTimeout(timer); reject(e); }); socket.once("secureConnect", () => { clearTimeout(timer); try { const cert = socket.getPeerCertificate(true) as tls.DetailedPeerCertificate; const chain: string[] = []; let c: tls.DetailedPeerCertificate | undefined = cert.issuerCertificate; const seen = new Set(); while (c && c.fingerprint256 && !seen.has(c.fingerprint256)) { seen.add(c.fingerprint256); chain.push(flat(c.subject)); c = c.issuerCertificate; } const sans = String(cert.subjectaltname ?? "") .split(",") .map((s) => s.trim().replace(/^DNS:/, "")) .filter(Boolean) .sort(); const cipher = socket.getCipher(); const snap: TlsSnapshot = { host, port, subject: strMap(cert.subject), issuer: strMap(cert.issuer), sans, validFrom: new Date(cert.valid_from).toISOString(), validTo: new Date(cert.valid_to).toISOString(), serialNumber: cert.serialNumber, fingerprint256: cert.fingerprint256, keyType: (cert as { asn1Curve?: string; bits?: number }).asn1Curve ? `EC ${(cert as { asn1Curve?: string }).asn1Curve}` : (cert as { bits?: number }).bits ? "RSA" : null, keyBits: (cert as { bits?: number }).bits ?? null, signatureAlgorithm: null, chain, protocol: socket.getProtocol(), cipher: cipher?.name ?? null, alpn: typeof socket.alpnProtocol === "string" ? socket.alpnProtocol : null, authorized: socket.authorized, authorizationError: socket.authorizationError ? String(socket.authorizationError) : null, }; socket.end(); resolve(snap); } catch (e) { socket.destroy(); reject(e); } }); }); } function strMap(o: unknown): Record { const out: Record = {}; if (o && typeof o === "object") for (const [k, v] of Object.entries(o as Record)) out[k] = Array.isArray(v) ? v.join(", ") : String(v); return out; } function flat(o: unknown): string { const m = strMap(o); return [m.CN, m.O, m.C].filter(Boolean).join(" / "); } export class TlsConnector implements WebSensorConnector { mode = "json" as const; metadata(): ConnectorMetadata { return { key: "tls", name: "TLS certificate", sensorTypes: ["TLS"], description: "Leaf certificate (issuer, SANs, validity, key), chain, protocol/cipher/ALPN — CA changes, renewals, expiry", version: "1.0.0" }; } async fetch(endpoint: SensorEndpoint): Promise { const t0 = Date.now(); const host = hostOf(endpoint.url); const cfg = endpoint.config as { port?: number; servername?: string }; let port = cfg.port ?? 443; try { const u = new URL(endpoint.url); if (u.port) port = Number(u.port); } catch { /* keep default */ } 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: {} }; try { await assertUrlAllowed(`https://${host}:${port}/`); } catch (e) { return { sensorId: endpoint.id, url: endpoint.url, fetchedAt: new Date(), notModified: false, error: { code: "ssrf_blocked", message: (e as Error).message }, meta: { ...meta, status: 0 } }; } try { const snap = await probeTls(host, port, cfg.servername ?? host); const body = Buffer.from(JSON.stringify(snap)); 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: "tls", 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 snap = JSON.parse(obs.body.toString("utf8")) as TlsSnapshot; // Compare the stable identity of the certificate, not the serial/fingerprint alone: a routine // renewal by the same CA with the same SANs changes validity + serial → still a (minor) change. const comparable = { subject: snap.subject, issuer: snap.issuer, sans: snap.sans, validFrom: snap.validFrom, validTo: snap.validTo, keyType: snap.keyType, keyBits: snap.keyBits, chain: snap.chain, protocol: snap.protocol, alpn: snap.alpn, authorized: snap.authorized, authorizationError: snap.authorizationError }; const c = canonicalJson(comparable); const daysToExpiry = Math.round((new Date(snap.validTo).getTime() - Date.now()) / 86400e3); return { mode: "json", json: comparable, title: `TLS ${snap.host}:${snap.port}`, rawHash: sha256(obs.body), canonicalHash: sha256(c), semanticHash: simhash(c), publishedAt: new Date(snap.validFrom), extractionConfidence: 1, extra: { issuer: snap.issuer.O ?? snap.issuer.CN, cn: snap.subject.CN, sanCount: snap.sans.length, daysToExpiry, expiring: daysToExpiry < 14, protocol: snap.protocol, cipher: snap.cipher, authorized: snap.authorized, serial: snap.serialNumber, fingerprint256: snap.fingerprint256 }, }; } }