585 lines
26 KiB
TypeScript
585 lines
26 KiB
TypeScript
import { parseDmarcRecord, summarizeSpf } from "./dns";
|
|
import { analyzeHeaders, type HeaderMap } from "./headers";
|
|
import type { Finding } from "./types";
|
|
|
|
type Context = {
|
|
dns: Record<string, unknown>;
|
|
tls: Record<string, unknown>;
|
|
http: Record<string, unknown>;
|
|
};
|
|
type ProbeError = { probe?: string; message?: string };
|
|
|
|
export type DiagnosticRule = { id: string; evaluate: (context: Context) => Finding | null };
|
|
|
|
const meta = (value: Record<string, unknown>) => value._meta as Record<string, unknown> | undefined;
|
|
const metaStatus = (value: Record<string, unknown>) => {
|
|
const status = meta(value)?.status;
|
|
return status === "ok" || status === "partial" || status === "failed" ? status : "failed";
|
|
};
|
|
const probeFailed = (value: Record<string, unknown>, 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<string, unknown> => typeof value === "object" && value !== null;
|
|
const hasMailService = (dns: Record<string, unknown>) => 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<string, unknown>) => 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<string, unknown>) => {
|
|
const result = http.http;
|
|
return isRecord(result) && !("error" in result) ? result : null;
|
|
};
|
|
const terminalHttpsResult = (http: Record<string, unknown>) => {
|
|
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<string, unknown>) => {
|
|
const terminal = terminalHttpsResult(http);
|
|
if (!terminal || !isRecord(terminal.headers)) return null;
|
|
return terminal.headers as HeaderMap;
|
|
};
|
|
const hasChainCode = (http: Record<string, unknown>, code: string) =>
|
|
Array.isArray(http.httpsChain) && http.httpsChain.some((hop) => isRecord(hop) && hop.code === code);
|
|
const hasBlockedRedirect = (http: Record<string, unknown>) =>
|
|
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<string, unknown>, 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<Finding["severity"], number> = { critical: 0, warning: 1, info: 2, good: 3 };
|
|
|
|
export function evaluateDiagnostics(
|
|
dns: Record<string, unknown>,
|
|
tls: Record<string, unknown>,
|
|
http: Record<string, unknown>,
|
|
): 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));
|
|
}
|