Files
CertScope-Public/app/components/report/evidence-views.tsx
T

60 lines
9.1 KiB
TypeScript

import { analyzeHeaders, unavailableHeaderPosture, type HeaderControlStatus, type HeaderMap } from "@/lib/inspect/headers";
import { statusForHttp } from "@/lib/inspect/http-status";
import {
CopyIcon,
collectorLabel,
collectorMeta,
compact,
finiteNumber,
formatCertificateName,
formatMxRecords,
hasValue,
isRecord,
terminalHttpsResult,
triState,
} from "./report-utils";
type CopyHandler = (label: string, value: string) => void;
function EvidenceValue({ label, value, onCopy }: { label: string; value: unknown; onCopy: CopyHandler }) {
const text = label === "MX" ? formatMxRecords(value) : compact(value);
return <div className="evidence-value"><span>{text}</span><button type="button" aria-label={`Copy ${label} evidence`} onClick={() => onCopy(label, text)}><CopyIcon /></button></div>;
}
export function CollectorState({ value, label }: { value: Record<string, unknown>; label: string }) {
const meta = collectorMeta(value);
return <div className="collector-state"><div><strong>{label}</strong><span className={`status-${meta.status === "ok" ? "good" : meta.status === "partial" ? "warning" : "error"}`}>{collectorLabel(meta.status)}</span></div>{meta.errors.length ? <ul>{meta.errors.map((error, index) => <li key={index}>{isRecord(error) && hasValue(error.message) ? String(error.message) : String(error)}</li>)}</ul> : <p>No collector errors recorded.</p>}</div>;
}
export function DnsEvidence({ dns, highlightedEvidence, onCopy }: { dns: Record<string, unknown>; highlightedEvidence: string | null; onCopy: CopyHandler }) {
const rows: Array<[string, unknown]> = [["A", dns.a], ["AAAA", dns.aaaa], ["MX", dns.mx], ["NS", dns.ns], ["TXT", dns.txt], ["SPF", dns.spf], ["DMARC", dns.dmarc]];
const spfPosture = isRecord(dns.spfPosture) ? dns.spfPosture : null;
const dmarcPosture = isRecord(dns.dmarcPosture) ? dns.dmarcPosture : null;
return <div className="evidence-layout"><section id="dns-records" tabIndex={-1} className={`evidence-card ${highlightedEvidence === "dns-records" ? "evidence-highlight" : ""}`}><h3>DNS records</h3><div className="evidence-table">{rows.map(([label, value]) => <div className="evidence-row" key={label}><strong>{label}</strong><EvidenceValue label={label} value={value} onCopy={onCopy} /></div>)}</div></section><section id="dns-mail-posture" tabIndex={-1} className={`evidence-card ${highlightedEvidence === "dns-mail-posture" ? "evidence-highlight" : ""}`}><h3>Parsed mail posture</h3><dl className="posture-list"><div><dt>SPF</dt><dd>{compact(spfPosture?.status ?? dns.spf)}</dd></div><div><dt>SPF terminal all</dt><dd>{compact(spfPosture?.allQualifier ?? "Unavailable")}</dd></div><div><dt>DMARC</dt><dd>{compact(dmarcPosture?.status ?? dns.dmarc)}</dd></div><div><dt>Policy</dt><dd>{compact(dmarcPosture?.policy ?? "Unavailable")}</dd></div><div><dt>Records</dt><dd>{Array.isArray(dns.dmarc) ? dns.dmarc.length : 0} DMARC / {Array.isArray(dns.spf) ? dns.spf.length : 0} SPF</dd></div></dl></section><CollectorState value={dns} label="DNS collector" /></div>;
}
export function TlsEvidence({ tls, highlightedEvidence }: { tls: Record<string, unknown>; highlightedEvidence: string | null }) {
const attempts = Array.isArray(tls.attempts) ? tls.attempts : [];
const status = collectorMeta(tls).status;
return <div className="evidence-layout"><section id="tls-certificate" tabIndex={-1} className={`evidence-card ${highlightedEvidence === "tls-certificate" ? "evidence-highlight" : ""}`}><h3>Certificate</h3><dl className="posture-list"><div><dt>Protocol</dt><dd>{compact(tls.protocol)}</dd></div><div><dt>Endpoint</dt><dd>{compact(tls.address)}</dd></div><div><dt>Authorized</dt><dd className={tls.authorized === true ? "value-good" : tls.authorized === false ? "value-bad" : "value-muted"}>{triState(tls.authorized, status !== "failed")}</dd></div><div><dt>Hostname match</dt><dd className={tls.hostnameValid === true ? "value-good" : tls.hostnameValid === false ? "value-bad" : "value-muted"}>{triState(tls.hostnameValid, status !== "failed")}</dd></div><div><dt>Issuer</dt><dd>{formatCertificateName(tls.issuer)}</dd></div><div><dt>Subject</dt><dd>{formatCertificateName(tls.subject)}</dd></div><div><dt>SANs</dt><dd>{compact(tls.subjectaltname)}</dd></div><div><dt>Valid from</dt><dd>{compact(tls.validFrom)}</dd></div><div><dt>Valid to</dt><dd>{compact(tls.validTo)}</dd></div><div><dt>Days remaining</dt><dd className={finiteNumber(tls.daysRemaining) && tls.daysRemaining <= 30 ? "value-warning" : "value-muted"}>{finiteNumber(tls.daysRemaining) ? `${tls.daysRemaining} days` : "Unavailable"}</dd></div><div><dt>Fingerprint</dt><dd>{compact(tls.fingerprint256)}</dd></div></dl></section><section id="tls-attempts" tabIndex={-1} className={`evidence-card ${highlightedEvidence === "tls-attempts" ? "evidence-highlight" : ""}`}><h3>Connection attempts</h3>{attempts.length ? <ul className="attempt-list">{attempts.map((attempt, index) => <li key={index}><strong>{compact(isRecord(attempt) ? attempt.address : null)}</strong><span>{compact(isRecord(attempt) ? attempt.error : attempt)}</span></li>)}</ul> : <p className="empty-evidence">The first validated endpoint completed the TLS handshake.</p>}<CollectorState value={tls} label="TLS collector" /></section></div>;
}
function controlTone(status: HeaderControlStatus) {
return status === "strong" || status === "good" ? "good" : status === "unavailable" ? "muted" : "warning";
}
export function HttpEvidence({ http, highlightedEvidence }: { http: Record<string, unknown>; highlightedEvidence: string | null }) {
const terminal = terminalHttpsResult(http);
const headers = terminal && isRecord(terminal.headers) ? terminal.headers as HeaderMap : null;
const chain = Array.isArray(http.httpsChain) ? http.httpsChain.filter(isRecord) : [];
const plain = isRecord(http.http) ? http.http : null;
const plainStatus = statusForHttp(plain?.status);
const posture = headers ? analyzeHeaders(headers) : unavailableHeaderPosture();
const redirectHops = [plain, ...chain].filter((hop): hop is Record<string, unknown> => hop !== null && hasValue(hop.url));
const observedPlain = plain !== null && !("error" in plain);
const observedRedirectTarget = isRecord(http.httpRedirectTarget) && "validated" in http.httpRedirectTarget;
const labels: Record<string, string> = { hsts: "HSTS", csp: "CSP", nosniff: "Nosniff", "referrer-policy": "Referrer-Policy", "frame-protection": "Frame protection" };
const headerNames = ["strict-transport-security", "content-security-policy", "x-content-type-options", "referrer-policy", "x-frame-options"];
return <div className="evidence-layout http-evidence-layout"><section id="http-redirect-chain" tabIndex={-1} className={`evidence-card redirect-evidence ${highlightedEvidence === "http-redirect-chain" ? "evidence-highlight" : ""}`}><header className="evidence-card-heading"><div><p>Redirect path</p><h3>HTTP to HTTPS</h3></div><span className={`status-badge ${plainStatus.tone}`}>{plainStatus.label}</span></header>{redirectHops.length ? <ol className="redirect-flow">{redirectHops.map((hop, index) => <li key={index}><article className={`redirect-hop ${"error" in hop ? "error" : statusForHttp(hop.status).tone}`}><span className="redirect-step">{index + 1}</span><div><span className="redirect-protocol">{String(hop.url).startsWith("https:") ? "HTTPS" : "HTTP"}</span><strong>{compact(hop.url)}</strong>{hasValue(hop.address) && <small>Validated endpoint · {String(hop.address)}</small>}</div><b className={`status-chip ${"error" in hop ? "error" : statusForHttp(hop.status).tone}`}>{"error" in hop ? compact(hop.code ?? "Error") : `HTTP ${compact(hop.status)}`}</b></article>{index < redirectHops.length - 1 && <div className="redirect-connector" aria-hidden="true">↓</div>}</li>)}</ol> : <p className="empty-evidence">No HTTP(S) response was recorded.</p>}<dl className="posture-list redirect-facts"><div><dt>Validated target</dt><dd>{triState(isRecord(http.httpRedirectTarget) ? http.httpRedirectTarget.validated : undefined, observedRedirectTarget)}</dd></div><div><dt>HTTPS redirect</dt><dd>{triState(http.httpRedirectsToHttps, observedPlain)}</dd></div></dl>{http.httpsDowngrade === true && <p className="inline-alert error">HTTPS downgrade blocked before the HTTP target was requested.</p>}</section><section id="http-header-posture" tabIndex={-1} className={`evidence-card header-evidence ${highlightedEvidence === "http-header-posture" ? "evidence-highlight" : ""}`}><header className="evidence-card-heading"><div><p>Final HTTPS response</p><h3>Security header posture</h3></div><span className="status-badge">{posture.aggregate.label}</span></header><div className="header-matrix">{posture.controls.map((control) => <div key={control.id} title={control.reason}><span>{labels[control.id]}</span><strong className={`value-${controlTone(control.status)}`}>{control.status}</strong><small>{control.reason}</small></div>)}</div>{headers ? <details className="header-details"><summary>Show normalized header evidence</summary><ul className="header-list">{headerNames.map((name) => <li key={name}><span>{name}</span><code>{compact(headers[name])}</code></li>)}</ul></details> : <p className="empty-evidence">No terminal HTTPS response; controls are unavailable rather than missing.</p>}</section><CollectorState value={http} label="HTTP collector" /></div>;
}