TypeScript 55.4%
Python 43.2%
SQL 1.2%
1import tls from "node:tls";2import { assertUrlAllowed, canonicalJson, sha256, simhash, type ConnectorMetadata, type NormalizedContent, type Observation, type SensorEndpoint } from "@websensor/core";3import { hostOf } from "./dns";4import { NormalizeError, type WebSensorConnector } from "./types";56/**7 * TLS certificate connector. Sensor URL: `tls://example.com[:443]`. Opens a TLS session (SNI = host,8 * no HTTP request), records the leaf certificate (subject, issuer, SANs, validity, key/sig algorithm,9 * serial), the chain issuers, negotiated protocol/cipher and ALPN. A CA change, a new SAN, a renewal10 * or a downgraded protocol becomes a JSON change; `extra.daysToExpiry` lets the heuristics raise11 * `certificate_expiring` when < 14 days.12 *13 * Config: { port?: 443, servername?: host, minVersion?: "TLSv1.2" }14 */15export interface TlsSnapshot {16 host: string;17 port: number;18 subject: Record<string, string>;19 issuer: Record<string, string>;20 sans: string[];21 validFrom: string;22 validTo: string;23 serialNumber: string;24 fingerprint256: string;25 keyType: string | null;26 keyBits: number | null;27 signatureAlgorithm: string | null;28 chain: string[];29 protocol: string | null;30 cipher: string | null;31 alpn: string | null;32 authorized: boolean;33 authorizationError: string | null;34}3536export function probeTls(host: string, port = 443, servername = host, timeoutMs = 12_000): Promise<TlsSnapshot> {37 return new Promise((resolve, reject) => {38 const socket = tls.connect({ host, port, servername, rejectUnauthorized: false, ALPNProtocols: ["h2", "http/1.1"], minVersion: "TLSv1", timeout: timeoutMs });39 const timer = setTimeout(() => {40 socket.destroy();41 reject(new Error(`TLS handshake timeout after ${timeoutMs} ms`));42 }, timeoutMs);43 socket.once("error", (e) => {44 clearTimeout(timer);45 reject(e);46 });47 socket.once("secureConnect", () => {48 clearTimeout(timer);49 try {50 const cert = socket.getPeerCertificate(true) as tls.DetailedPeerCertificate;51 const chain: string[] = [];52 let c: tls.DetailedPeerCertificate | undefined = cert.issuerCertificate;53 const seen = new Set<string>();54 while (c && c.fingerprint256 && !seen.has(c.fingerprint256)) {55 seen.add(c.fingerprint256);56 chain.push(flat(c.subject));57 c = c.issuerCertificate;58 }59 const sans = String(cert.subjectaltname ?? "")60 .split(",")61 .map((s) => s.trim().replace(/^DNS:/, ""))62 .filter(Boolean)63 .sort();64 const cipher = socket.getCipher();65 const snap: TlsSnapshot = {66 host,67 port,68 subject: strMap(cert.subject),69 issuer: strMap(cert.issuer),70 sans,71 validFrom: new Date(cert.valid_from).toISOString(),72 validTo: new Date(cert.valid_to).toISOString(),73 serialNumber: cert.serialNumber,74 fingerprint256: cert.fingerprint256,75 keyType: (cert as { asn1Curve?: string; bits?: number }).asn1Curve ? `EC ${(cert as { asn1Curve?: string }).asn1Curve}` : (cert as { bits?: number }).bits ? "RSA" : null,76 keyBits: (cert as { bits?: number }).bits ?? null,77 signatureAlgorithm: null,78 chain,79 protocol: socket.getProtocol(),80 cipher: cipher?.name ?? null,81 alpn: typeof socket.alpnProtocol === "string" ? socket.alpnProtocol : null,82 authorized: socket.authorized,83 authorizationError: socket.authorizationError ? String(socket.authorizationError) : null,84 };85 socket.end();86 resolve(snap);87 } catch (e) {88 socket.destroy();89 reject(e);90 }91 });92 });93}9495function strMap(o: unknown): Record<string, string> {96 const out: Record<string, string> = {};97 if (o && typeof o === "object") for (const [k, v] of Object.entries(o as Record<string, unknown>)) out[k] = Array.isArray(v) ? v.join(", ") : String(v);98 return out;99}100function flat(o: unknown): string {101 const m = strMap(o);102 return [m.CN, m.O, m.C].filter(Boolean).join(" / ");103}104105export class TlsConnector implements WebSensorConnector {106 mode = "json" as const;107 metadata(): ConnectorMetadata {108 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" };109 }110 async fetch(endpoint: SensorEndpoint): Promise<Observation> {111 const t0 = Date.now();112 const host = hostOf(endpoint.url);113 const cfg = endpoint.config as { port?: number; servername?: string };114 let port = cfg.port ?? 443;115 try {116 const u = new URL(endpoint.url);117 if (u.port) port = Number(u.port);118 } catch {119 /* keep default */120 }121 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: {} };122 try {123 await assertUrlAllowed(`https://${host}:${port}/`);124 } catch (e) {125 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 } };126 }127 try {128 const snap = await probeTls(host, port, cfg.servername ?? host);129 const body = Buffer.from(JSON.stringify(snap));130 return { sensorId: endpoint.id, url: endpoint.url, fetchedAt: new Date(), notModified: false, body, meta: { ...meta, contentLength: body.length, durationMs: Date.now() - t0 } };131 } catch (e) {132 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 } };133 }134 }135 async normalize(endpoint: SensorEndpoint, obs: Observation): Promise<NormalizedContent> {136 if (!obs.body) throw new NormalizeError("no_body", "Observation has no body");137 const snap = JSON.parse(obs.body.toString("utf8")) as TlsSnapshot;138 // Compare the stable identity of the certificate, not the serial/fingerprint alone: a routine139 // renewal by the same CA with the same SANs changes validity + serial → still a (minor) change.140 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 };141 const c = canonicalJson(comparable);142 const daysToExpiry = Math.round((new Date(snap.validTo).getTime() - Date.now()) / 86400e3);143 return {144 mode: "json",145 json: comparable,146 title: `TLS ${snap.host}:${snap.port}`,147 rawHash: sha256(obs.body),148 canonicalHash: sha256(c),149 semanticHash: simhash(c),150 publishedAt: new Date(snap.validFrom),151 extractionConfidence: 1,152 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 },153 };154 }155}156