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%
7.2 KB · 156 lines typescript
Raw Blame History
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