import { canonicalJson, sha256, simhash, type ConnectorMetadata, type NormalizedContent, type Observation, type SensorEndpoint } from "@websensor/core"; import { httpFetchWithRetry } from "./fetcher"; import { NormalizeError, type WebSensorConnector } from "./types"; /** * HTTP response-headers connector (security + infrastructure posture). Performs a GET (HEAD is often * refused or answered differently by CDNs) and keeps only the headers that describe policy or stack: * HSTS, CSP, frame/content-type/referrer/permissions policies, CORS, cookies flags (names only), * server / powered-by / CDN markers, cache policy, alt-svc, reporting endpoints, robots tag. * Values that change on every request (dates, request ids, ray ids, ages, ETags) are excluded. * * Config: { method?: "GET" | "HEAD", include?: [extra header names], exclude?: [names] } */ const KEEP = [ "strict-transport-security", "content-security-policy", "content-security-policy-report-only", "x-frame-options", "x-content-type-options", "referrer-policy", "permissions-policy", "cross-origin-opener-policy", "cross-origin-embedder-policy", "cross-origin-resource-policy", "access-control-allow-origin", "access-control-allow-methods", "access-control-allow-headers", "access-control-allow-credentials", "server", "x-powered-by", "via", "x-served-by", "x-cache", "cf-cache-status", "x-vercel-cache", "x-amz-cf-pop", "x-akamai-transformed", "x-azure-ref", "x-fastly-request-id", "cache-control", "content-type", "content-language", "alt-svc", "report-to", "reporting-endpoints", "nel", "x-robots-tag", "link", "vary", "content-encoding", "x-dns-prefetch-control", "expect-ct", "x-xss-protection", "origin-agent-cluster", "accept-ch", "critical-ch", "x-ua-compatible", "www-authenticate", "location", ]; const CDN_MARKERS: [RegExp, string][] = [ [/cloudflare/i, "Cloudflare"], [/cloudfront|x-amz-cf/i, "CloudFront"], [/akamai/i, "Akamai"], [/fastly/i, "Fastly"], [/vercel/i, "Vercel"], [/netlify/i, "Netlify"], [/azure|x-azure/i, "Azure"], [/gws|google/i, "Google"], [/nginx/i, "nginx"], [/apache/i, "Apache"], [/microsoft-iis/i, "IIS"], [/envoy/i, "Envoy"], [/caddy/i, "Caddy"], [/openresty/i, "OpenResty"], [/litespeed/i, "LiteSpeed"], [/imperva|incapsula/i, "Imperva"], [/sucuri/i, "Sucuri"], ]; /** Strip per-request noise inside kept headers (nonces, request ids, ray ids). */ function scrub(name: string, value: string): string { let v = value; if (name.startsWith("content-security-policy")) v = v.replace(/'nonce-[^']+'/g, "'nonce-…'").replace(/\s+/g, " ").trim(); if (name === "x-fastly-request-id" || name === "x-served-by" || name === "x-amz-cf-pop" || name === "x-azure-ref") return "present"; if (name === "link") v = v.replace(/<[^>]*>/g, (m) => m.replace(/[?&](v|ver|hash|_)=[^&>]+/g, "")); if (name === "set-cookie") return v; return v; } export class HeadersConnector implements WebSensorConnector { mode = "json" as const; metadata(): ConnectorMetadata { return { key: "headers", name: "HTTP response headers", sensorTypes: ["HTTP_HEADERS"], description: "Security & infrastructure headers (HSTS, CSP, CORS, server/CDN markers, cache policy, cookies flags)", version: "1.0.0" }; } async fetch(endpoint: SensorEndpoint): Promise { const cfg = endpoint.config as { method?: "GET" | "HEAD"; headers?: Record }; const obs = await httpFetchWithRetry(endpoint.id, endpoint.url, { method: cfg.method ?? "GET", headers: cfg.headers, accept: "text/html,application/xhtml+xml,*/*;q=0.8", keepAllHeaders: true, maxBytes: 24 * 1024 * 1024, timeoutMs: 25_000 }); // The body is irrelevant; keep the observation small (headers live in meta). if (obs.body) obs.body = Buffer.from(JSON.stringify({ status: obs.meta.status, finalUrl: obs.meta.finalUrl, headers: obs.meta.headers })); else if (!obs.error) obs.body = Buffer.from(JSON.stringify({ status: obs.meta.status, finalUrl: obs.meta.finalUrl, headers: obs.meta.headers })); return obs; } async normalize(endpoint: SensorEndpoint, obs: Observation): Promise { if (!obs.body) throw new NormalizeError("no_body", "Observation has no body"); const j = JSON.parse(obs.body.toString("utf8")) as { status: number; finalUrl: string; headers: Record }; const cfg = endpoint.config as { include?: string[]; exclude?: string[] }; const keep = new Set([...KEEP, ...(cfg.include ?? []).map((s) => s.toLowerCase())]); for (const x of cfg.exclude ?? []) keep.delete(x.toLowerCase()); const kept: Record = {}; for (const [k, v] of Object.entries(j.headers)) { const name = k.toLowerCase(); if (keep.has(name)) kept[name] = scrub(name, v); } // Cookies: names + flags only (values are secrets / per-session). const cookies = (j.headers["set-cookie"] ?? "") .split(/,(?=\s*[A-Za-z0-9_.-]+=)/) .map((c) => c.trim()) .filter(Boolean) .map((c) => { const [nv, ...attrs] = c.split(";"); return `${nv!.split("=")[0]!.trim()} [${attrs.map((a) => a.trim().split("=")[0]!.toLowerCase()).filter((a) => /secure|httponly|samesite|partitioned/.test(a)).sort().join(",")}]`; }) .sort(); const posture = { status: j.status, finalUrl: j.finalUrl.replace(/\/$/, ""), https: j.finalUrl.startsWith("https://"), hsts: Boolean(kept["strict-transport-security"]), hstsPreload: /preload/i.test(kept["strict-transport-security"] ?? ""), csp: Boolean(kept["content-security-policy"]), frameProtection: Boolean(kept["x-frame-options"]) || /frame-ancestors/i.test(kept["content-security-policy"] ?? ""), nosniff: /nosniff/i.test(kept["x-content-type-options"] ?? ""), referrerPolicy: kept["referrer-policy"] ?? null, permissionsPolicy: Boolean(kept["permissions-policy"]), cors: kept["access-control-allow-origin"] ?? null, server: kept.server ?? null, poweredBy: kept["x-powered-by"] ?? null, stack: CDN_MARKERS.filter(([re]) => re.test(`${kept.server ?? ""} ${kept.via ?? ""} ${kept["x-powered-by"] ?? ""} ${Object.keys(kept).join(" ")}`)).map(([, n]) => n), cookies, }; const json = { posture, headers: kept }; const c = canonicalJson(json); const score = [posture.https, posture.hsts, posture.csp, posture.frameProtection, posture.nosniff, Boolean(posture.referrerPolicy), posture.permissionsPolicy].filter(Boolean).length; return { mode: "json", json, title: `Headers ${new URL(endpoint.url).hostname}`, rawHash: sha256(obs.body), canonicalHash: sha256(c), semanticHash: simhash(c), extractionConfidence: 1, extra: { securityScore: `${score}/7`, stack: posture.stack, server: posture.server, status: j.status } }; } }