import { parseDmarcRecord, summarizeSpf } from "./dns"; import { analyzeHeaders, type HeaderMap } from "./headers"; import type { Finding } from "./types"; type Context = { dns: Record; tls: Record; http: Record; }; type ProbeError = { probe?: string; message?: string }; export type DiagnosticRule = { id: string; evaluate: (context: Context) => Finding | null }; const meta = (value: Record) => value._meta as Record | undefined; const metaStatus = (value: Record) => { const status = meta(value)?.status; return status === "ok" || status === "partial" || status === "failed" ? status : "failed"; }; const probeFailed = (value: Record, probe: string) => { const errors = meta(value)?.errors; return Array.isArray(errors) && errors.some((error) => typeof error === "object" && error !== null && String((error as ProbeError).probe ?? "").toUpperCase() === probe.toUpperCase(), ); }; const finiteNumber = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value); const httpStatus = (value: unknown): value is number => finiteNumber(value) && Number.isInteger(value) && value >= 100 && value <= 599; const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null; const hasMailService = (dns: Record) => Array.isArray(dns.mx) && dns.mx.some((record) => { if (!isRecord(record)) return false; const exchange = record.exchange; const normalizedExchange = typeof exchange === "string" ? exchange.trim() : ""; return normalizedExchange !== "" && normalizedExchange !== "."; }); const hasInvalidMx = (dns: Record) => Array.isArray(dns.mx) && dns.mx.some((record) => { if (!isRecord(record) || typeof record.exchange !== "string") return true; const exchange = record.exchange.trim(); if (!exchange || exchange === ".") return record.priority !== 0; return record.priority !== undefined && (!finiteNumber(record.priority) || !Number.isInteger(record.priority) || record.priority < 0 || record.priority > 65_535); }); const isSpfRecord = (value: unknown) => typeof value === "string" && value.trimStart().toLowerCase().startsWith("v=spf1"); const plainHttpResult = (http: Record) => { const result = http.http; return isRecord(result) && !("error" in result) ? result : null; }; const terminalHttpsResult = (http: Record) => { const chain = Array.isArray(http.httpsChain) ? http.httpsChain.filter(isRecord) : []; const last = chain.at(-1); return last && !("error" in last) ? last : null; }; const terminalHeaders = (http: Record) => { const terminal = terminalHttpsResult(http); if (!terminal || !isRecord(terminal.headers)) return null; return terminal.headers as HeaderMap; }; const hasChainCode = (http: Record, code: string) => Array.isArray(http.httpsChain) && http.httpsChain.some((hop) => isRecord(hop) && hop.code === code); const hasBlockedRedirect = (http: Record) => hasChainCode(http, "REDIRECT_TARGET_BLOCKED") || hasChainCode(http, "INVALID_REDIRECT") || (isRecord(http.httpRedirectTarget) && (http.httpRedirectTarget.code === "REDIRECT_TARGET_BLOCKED" || http.httpRedirectTarget.code === "INVALID_REDIRECT")); const tlsHasError = (tls: Record, code: string) => String(tls.authorizationError ?? "").toUpperCase().includes(code); export const DIAGNOSTIC_RULES: DiagnosticRule[] = [ { id: "collector.dns_partial", evaluate: ({ dns }) => metaStatus(dns) !== "ok" ? { id: "collector.dns_partial", category: "dns", severity: "info", title: "Some DNS probes were incomplete", summary: "At least one DNS record-type query failed; available evidence is still shown.", evidence: ["dns._meta"], remediation: "Retry later or compare against an authoritative resolver before acting on missing records.", scoreImpact: 0, } : null, }, { id: "collector.tls_partial", evaluate: ({ tls }) => metaStatus(tls) !== "ok" ? { id: "collector.tls_partial", category: "tls", severity: "info", title: "TLS probe was incomplete", summary: "The TLS connection did not produce a complete certificate result; no certificate deduction is applied.", evidence: ["tls._meta"], remediation: "Retry from the expected network path and inspect the TLS probe evidence.", scoreImpact: 0, } : null, }, { id: "collector.http_partial", evaluate: ({ http }) => metaStatus(http) !== "ok" ? { id: "collector.http_partial", category: "http", severity: "info", title: "Some HTTP probes were incomplete", summary: "One or more HTTP/HTTPS requests or redirect hops failed; successful evidence is preserved.", evidence: ["http._meta"], remediation: "Inspect the failed hop and retry from the expected network path.", scoreImpact: 0, } : null, }, { id: "tls.verification", evaluate: ({ tls }) => tls.authorized === false && tls.hostnameValid !== false && tls.notYetValid !== true && tls.validityDatesValid !== false && !(finiteNumber(tls.daysRemaining) && tls.daysRemaining < 0) && !tlsHasError(tls, "CERT_HAS_EXPIRED") ? { id: "tls.verification", category: "tls", severity: "critical", title: "TLS trust verification failed", summary: String(tls.authorizationError ?? "The certificate chain could not be verified."), evidence: ["tls.authorized", "tls.authorizationError"], remediation: "Verify the certificate chain and issuer configuration.", scoreImpact: -30, } : null, }, { id: "tls.validity_dates", evaluate: ({ tls }) => metaStatus(tls) === "ok" && tls.validityDatesValid === false ? { id: "tls.validity_dates", category: "tls", severity: "warning", title: "Certificate validity dates are malformed", summary: "The peer certificate did not expose a coherent valid-from/valid-to window.", evidence: ["tls.validFrom", "tls.validTo", "tls.validityDatesValid"], remediation: "Serve a certificate with parseable validity dates and a valid chronological window.", scoreImpact: -12, } : null, }, { id: "tls.hostname", evaluate: ({ tls }) => tls.hostnameValid === false ? { id: "tls.hostname", category: "tls", severity: "critical", title: "Certificate hostname mismatch", summary: String(tls.hostnameError ?? "The certificate does not match the requested hostname."), evidence: ["tls.hostnameValid", "tls.subjectaltname"], remediation: "Issue a certificate whose SANs cover the hostname.", scoreImpact: -25, } : null, }, { id: "tls.expired", evaluate: ({ tls }) => tls.validityDatesValid !== false && finiteNumber(tls.daysRemaining) && tls.daysRemaining < 0 ? { id: "tls.expired", category: "tls", severity: "critical", title: "Certificate has expired", summary: "The certificate expired " + Math.abs(tls.daysRemaining) + " day(s) ago.", evidence: ["tls.daysRemaining", "tls.validTo"], remediation: "Renew and deploy a valid certificate immediately.", scoreImpact: -35, } : null, }, { id: "tls.not_yet_valid", evaluate: ({ tls }) => tls.validityDatesValid !== false && tls.notYetValid === true ? { id: "tls.not_yet_valid", category: "tls", severity: "critical", title: "Certificate is not valid yet", summary: "The certificate valid-from date is in the future.", evidence: ["tls.validFrom", "tls.notYetValid"], remediation: "Deploy a certificate whose validity window includes the current time.", scoreImpact: -20, } : null, }, { id: "tls.expiry_critical", evaluate: ({ tls }) => tls.validityDatesValid !== false && finiteNumber(tls.daysRemaining) && tls.daysRemaining >= 0 && tls.daysRemaining <= 14 ? { id: "tls.expiry_critical", category: "tls", severity: "critical", title: "Certificate expires very soon", summary: "Only " + tls.daysRemaining + " day(s) of certificate validity remain.", evidence: ["tls.daysRemaining", "tls.validTo"], remediation: "Renew and deploy the replacement certificate now.", scoreImpact: -15, } : null, }, { id: "tls.expiry_warning", evaluate: ({ tls }) => tls.validityDatesValid !== false && finiteNumber(tls.daysRemaining) && tls.daysRemaining > 14 && tls.daysRemaining <= 30 ? { id: "tls.expiry_warning", category: "tls", severity: "warning", title: "Certificate expires soon", summary: tls.daysRemaining + " day(s) of certificate validity remain.", evidence: ["tls.daysRemaining", "tls.validTo"], remediation: "Schedule certificate renewal.", scoreImpact: -8, } : null, }, { id: "http.https_downgrade", evaluate: ({ http }) => http.httpsDowngrade === true || hasChainCode(http, "HTTPS_DOWNGRADE") ? { id: "http.https_downgrade", category: "http", severity: "critical", title: "HTTPS redirect downgrades to HTTP", summary: "The HTTPS redirect chain attempted to leave HTTPS; the downgraded response was not treated as a secure terminal result.", evidence: ["http.httpsChain[].code", "http.httpsChain[].url"], remediation: "Keep every redirect in the HTTPS scheme and remove the downgrade hop.", scoreImpact: -15, } : null, }, { id: "http.redirect_loop", evaluate: ({ http }) => hasChainCode(http, "REDIRECT_LOOP") ? { id: "http.redirect_loop", category: "http", severity: "warning", title: "HTTPS redirect loop detected", summary: "The normalized redirect chain revisited a URL and was stopped safely.", evidence: ["http.httpsChain[].code", "http.httpsChain[].url"], remediation: "Remove the redirect cycle and leave one canonical HTTPS destination.", scoreImpact: -8, } : null, }, { id: "http.redirect_blocked", evaluate: ({ http }) => hasBlockedRedirect(http) ? { id: "http.redirect_blocked", category: "http", severity: "warning", title: "Redirect target was blocked by policy", summary: "A redirect target did not satisfy the public-host, scheme or port policy; no request was made to that target.", evidence: ["http.httpsChain[].code", "http.httpRedirectTarget.code"], remediation: "Keep redirects on public hostnames and default HTTP(S) ports without credentials.", scoreImpact: -8, } : null, }, { id: "http.redirect_limit", evaluate: ({ http }) => hasChainCode(http, "REDIRECT_LIMIT") ? { id: "http.redirect_limit", category: "http", severity: "info", title: "HTTPS redirect limit reached", summary: "The redirect chain was bounded before a terminal response was observed.", evidence: ["http.httpsChain[].code"], remediation: "Reduce redirect hops so the canonical HTTPS response is reached directly.", scoreImpact: 0, } : null, }, { id: "http.redirect", evaluate: ({ http }) => plainHttpResult(http) && http.httpRedirectsToHttps === false ? { id: "http.redirect", category: "http", severity: "warning", title: "HTTP does not redirect directly to HTTPS", summary: "The port 80 response did not advertise a validated HTTPS redirect.", evidence: ["http.httpRedirectsToHttps", "http.http.location"], remediation: "Redirect plain HTTP requests to the HTTPS origin.", scoreImpact: -5, } : null, }, { id: "http.terminal_client_error", evaluate: ({ http }) => { const terminal = terminalHttpsResult(http); const status = terminal?.status; return httpStatus(status) && status >= 400 && status < 500 ? { id: "http.terminal_client_error", category: "http", severity: "warning", title: "HTTPS response returned a client error", summary: "The terminal HTTPS response returned HTTP " + status + ".", evidence: ["http.httpsChain[].status"], remediation: "Confirm that the inspected hostname serves the intended resource and status.", scoreImpact: -4, } : null; }, }, { id: "http.terminal_server_error", evaluate: ({ http }) => { const terminal = terminalHttpsResult(http); const status = terminal?.status; return httpStatus(status) && status >= 500 ? { id: "http.terminal_server_error", category: "http", severity: "critical", title: "HTTPS response returned a server error", summary: "The terminal HTTPS response returned HTTP " + status + ".", evidence: ["http.httpsChain[].status"], remediation: "Investigate the origin service and restore a healthy HTTPS response.", scoreImpact: -10, } : null; }, }, { id: "http.hsts_missing", evaluate: ({ http }) => { const headers = terminalHeaders(http); const posture = headers ? analyzeHeaders(headers).hsts : null; return posture?.status === "missing" ? { id: "http.hsts_missing", category: "http", severity: "warning", title: "HSTS header is missing", summary: "The final HTTPS response does not advertise HTTP Strict Transport Security.", evidence: ["http.httpsChain[].headers.strict-transport-security"], remediation: "Enable HSTS after confirming HTTPS is correct for the site.", scoreImpact: -8, } : null; }, }, { id: "http.hsts_invalid", evaluate: ({ http }) => { const headers = terminalHeaders(http); const posture = headers ? analyzeHeaders(headers).hsts : null; return posture?.status === "invalid" ? { id: "http.hsts_invalid", category: "http", severity: "warning", title: "HSTS policy is invalid", summary: posture.reason, evidence: ["http.httpsChain[].headers.strict-transport-security"], remediation: "Publish one HSTS header with a non-negative numeric max-age.", scoreImpact: -6, } : null; }, }, { id: "http.hsts_disabled", evaluate: ({ http }) => { const headers = terminalHeaders(http); const posture = headers ? analyzeHeaders(headers).hsts : null; return posture?.status === "disabled" ? { id: "http.hsts_disabled", category: "http", severity: "warning", title: "HSTS is disabled", summary: posture.reason, evidence: ["http.httpsChain[].headers.strict-transport-security"], remediation: "Use a positive HSTS max-age after confirming HTTPS coverage.", scoreImpact: -8, } : null; }, }, { id: "http.hsts_weak", evaluate: ({ http }) => { const headers = terminalHeaders(http); const posture = headers ? analyzeHeaders(headers).hsts : null; return posture?.status === "weak" || posture?.status === "invalid" ? { id: "http.hsts_weak", category: "http", severity: "warning", title: "HSTS policy is short-lived", summary: posture.reason, evidence: ["http.httpsChain[].headers.strict-transport-security"], remediation: "After validating HTTPS coverage, use a long-lived HSTS max-age.", scoreImpact: -4, } : null; }, }, { id: "http.csp_missing", evaluate: ({ http }) => { const headers = terminalHeaders(http); const posture = headers ? analyzeHeaders(headers).csp : null; return posture?.status === "missing" ? { id: "http.csp_missing", category: "http", severity: "warning", title: "Content-Security-Policy is missing", summary: "No CSP header was observed on the final HTTPS response.", evidence: ["http.httpsChain[].headers.content-security-policy"], remediation: "Deploy a tested CSP appropriate for the application.", scoreImpact: -6, } : null; }, }, { id: "http.csp_weak", evaluate: ({ http }) => { const headers = terminalHeaders(http); const posture = headers ? analyzeHeaders(headers).csp : null; return posture?.status === "weak" ? { id: "http.csp_weak", category: "http", severity: "warning", title: "Content-Security-Policy is weak", summary: posture.reason, evidence: ["http.httpsChain[].headers.content-security-policy"], remediation: "Replace broad sources and unsafe directives with a tested restrictive policy.", scoreImpact: -4, } : null; }, }, { id: "http.nosniff_missing", evaluate: ({ http }) => { const headers = terminalHeaders(http); const hasHeader = headers && headers["x-content-type-options"] !== null && headers["x-content-type-options"] !== undefined && headers["x-content-type-options"] !== ""; return headers && !hasHeader ? { id: "http.nosniff_missing", category: "http", severity: "warning", title: "X-Content-Type-Options is missing", summary: "The final HTTPS response does not set nosniff protection.", evidence: ["http.httpsChain[].headers.x-content-type-options"], remediation: "Set X-Content-Type-Options: nosniff.", scoreImpact: -3, } : null; }, }, { id: "http.nosniff_invalid", evaluate: ({ http }) => { const headers = terminalHeaders(http); const value = headers?.["x-content-type-options"]; const present = value !== null && value !== undefined && value !== ""; return headers && present && analyzeHeaders(headers).nosniff.status !== "strong" ? { id: "http.nosniff_invalid", category: "http", severity: "warning", title: "X-Content-Type-Options is invalid", summary: "The header is present but does not have the required nosniff value.", evidence: ["http.httpsChain[].headers.x-content-type-options"], remediation: "Set the exact value X-Content-Type-Options: nosniff.", scoreImpact: -3, } : null; }, }, { id: "http.referrer_policy", evaluate: ({ http }) => { const headers = terminalHeaders(http); const posture = headers ? analyzeHeaders(headers).referrerPolicy : null; return posture?.status === "weak" || posture?.status === "invalid" ? { id: "http.referrer_policy", category: "http", severity: "warning", title: "Referrer-Policy needs attention", summary: posture.reason, evidence: ["http.httpsChain[].headers.referrer-policy"], remediation: "Use a recognized restrictive policy such as strict-origin-when-cross-origin.", scoreImpact: -2, } : null; }, }, { id: "http.referrer_policy_missing", evaluate: ({ http }) => { const headers = terminalHeaders(http); const posture = headers ? analyzeHeaders(headers).referrerPolicy : null; return posture?.status === "missing" ? { id: "http.referrer_policy_missing", category: "http", severity: "warning", title: "Referrer-Policy is missing", summary: posture.reason, evidence: ["http.httpsChain[].headers.referrer-policy"], remediation: "Set a restrictive Referrer-Policy such as strict-origin-when-cross-origin.", scoreImpact: -2, } : null; }, }, { id: "http.frame_protection", evaluate: ({ http }) => { const headers = terminalHeaders(http); const posture = headers ? analyzeHeaders(headers).frameProtection : null; return posture?.status === "missing" || posture?.status === "weak" || posture?.status === "invalid" ? { id: "http.frame_protection", category: "http", severity: "warning", title: "Clickjacking protection needs attention", summary: posture.reason, evidence: ["http.httpsChain[].headers.content-security-policy", "http.httpsChain[].headers.x-frame-options"], remediation: "Use CSP frame-ancestors or X-Frame-Options: DENY/SAMEORIGIN.", scoreImpact: -4, } : null; }, }, { id: "http.server_disclosure", evaluate: ({ http }) => { const headers = terminalHeaders(http); return headers?.server ? { id: "http.server_disclosure", category: "http", severity: "info", title: "Server identity is disclosed", summary: "The response exposes a Server header, which may help fingerprint the origin.", evidence: ["http.httpsChain[].headers.server"], remediation: "Remove unnecessary version detail from server identification where practical.", scoreImpact: 0, } : null; }, }, { id: "http.powered_by_disclosure", evaluate: ({ http }) => { const headers = terminalHeaders(http); return headers?.["x-powered-by"] ? { id: "http.powered_by_disclosure", category: "http", severity: "info", title: "X-Powered-By is disclosed", summary: "The response exposes an implementation-identifying X-Powered-By header.", evidence: ["http.httpsChain[].headers.x-powered-by"], remediation: "Remove X-Powered-By unless the disclosure is intentional.", scoreImpact: 0, } : null; }, }, { id: "dns.spf_missing", evaluate: ({ dns }) => { const spf = Array.isArray(dns.spf) ? dns.spf.filter(isSpfRecord) : []; return hasMailService(dns) && !spf.length && !probeFailed(dns, "TXT") ? { id: "dns.spf_missing", category: "dns", severity: "warning", title: "Mail domain has no SPF record", summary: "MX records exist but no v=spf1 TXT record was found.", evidence: ["dns.mx", "dns.spf"], remediation: "Publish one SPF record that covers all legitimate senders.", scoreImpact: -4, } : null; }, }, { id: "dns.spf_multiple", evaluate: ({ dns }) => { const spf = Array.isArray(dns.spf) ? dns.spf.filter(isSpfRecord) : []; return spf.length > 1 ? { id: "dns.spf_multiple", category: "dns", severity: "critical", title: "Multiple SPF records were found", summary: "Multiple v=spf1 records are invalid because receivers cannot combine them reliably.", evidence: ["dns.spf"], remediation: "Merge the mechanisms into one SPF record and remove the duplicates.", scoreImpact: -10, } : null; }, }, { id: "dns.spf_permissive", evaluate: ({ dns }) => { const spf = Array.isArray(dns.spf) ? dns.spf.filter(isSpfRecord) : []; const posture = summarizeSpf(spf); return posture.status === "permissive" ? { id: "dns.spf_permissive", category: "dns", severity: "warning", title: "SPF authorizes every sender", summary: posture.reason, evidence: ["dns.spf", "dns.spfPosture"], remediation: "Replace +all with a sender policy that ends in -all after validating legitimate sources.", scoreImpact: -5, } : null; }, }, { id: "dns.dmarc_missing", evaluate: ({ dns }) => { const dmarc = Array.isArray(dns.dmarc) ? dns.dmarc : []; return hasMailService(dns) && !dmarc.length && !probeFailed(dns, "DMARC") ? { id: "dns.dmarc_missing", category: "dns", severity: "warning", title: "Mail domain has no DMARC record", summary: "MX records exist but no _dmarc TXT record was found.", evidence: ["dns.mx", "dns.dmarc"], remediation: "Publish a DMARC policy and validate legitimate senders first.", scoreImpact: -4, } : null; }, }, { id: "dns.dmarc_multiple", evaluate: ({ dns }) => { const dmarc = Array.isArray(dns.dmarc) ? dns.dmarc : []; return dmarc.length > 1 ? { id: "dns.dmarc_multiple", category: "dns", severity: "warning", title: "Multiple DMARC records were found", summary: "Multiple DMARC records make policy selection ambiguous.", evidence: ["dns.dmarc"], remediation: "Publish one authoritative DMARC record at _dmarc.", scoreImpact: -6, } : null; }, }, { id: "dns.dmarc_invalid", evaluate: ({ dns }) => { const dmarc = Array.isArray(dns.dmarc) ? dns.dmarc.filter((value): value is string => typeof value === "string") : []; return dmarc.length === 1 && !parseDmarcRecord(dmarc[0]).valid ? { id: "dns.dmarc_invalid", category: "dns", severity: "warning", title: "DMARC record is invalid", summary: "The DMARC record is malformed or does not contain a supported p= policy.", evidence: ["dns.dmarc"], remediation: "Correct the DMARC version, policy and tag syntax.", scoreImpact: -6, } : null; }, }, { id: "dns.dmarc_monitor", evaluate: ({ dns }) => { const dmarc = Array.isArray(dns.dmarc) ? dns.dmarc.filter((value): value is string => typeof value === "string") : []; const parsed = dmarc.length === 1 ? parseDmarcRecord(dmarc[0]) : null; return parsed && parsed.valid && (parsed.policy === "none" || parsed.pct === 0) ? { id: "dns.dmarc_monitor", category: "dns", severity: "info", title: "DMARC is not fully enforcing", summary: parsed.policy === "none" ? "DMARC uses p=none, so failing mail is monitored rather than rejected or quarantined." : "DMARC uses pct=0, so the policy applies to no messages.", evidence: ["dns.dmarc"], remediation: "Move to quarantine or reject after validating legitimate senders.", scoreImpact: 0, } : null; }, }, { id: "dns.mx_invalid", evaluate: ({ dns }) => hasInvalidMx(dns) ? { id: "dns.mx_invalid", category: "dns", severity: "warning", title: "MX record data is malformed", summary: "At least one MX answer has an invalid exchange or priority value.", evidence: ["dns.mx"], remediation: "Publish valid MX records, or use a null MX with exchange '.' and priority 0 when mail is not accepted.", scoreImpact: -6, } : null, }, ]; const severityRank: Record = { critical: 0, warning: 1, info: 2, good: 3 }; export function evaluateDiagnostics( dns: Record, tls: Record, http: Record, ): Finding[] { const context = { dns, tls, http }; return DIAGNOSTIC_RULES .map((rule) => rule.evaluate(context)) .filter((value): value is Finding => Boolean(value)) .sort((left, right) => severityRank[left.severity] - severityRank[right.severity] || left.scoreImpact - right.scoreImpact || left.id.localeCompare(right.id)); }