Files

274 lines
24 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
import { inspectionToMarkdown } from "@/lib/inspect/report";
import { analyzeHeaders, type HeaderMap } from "@/lib/inspect/headers";
import { statusForHttp } from "@/lib/inspect/http-status";
import type { Finding, InspectionResult } from "@/lib/inspect/types";
import { ReportActions } from "./components/report/report-actions";
import { DnsEvidence, HttpEvidence, TlsEvidence } from "./components/report/evidence-views";
import {
CloseIcon,
Gauge,
type FindingFilter,
type Tab,
collectorLabel,
collectorMeta,
compact,
downloadText,
finiteNumber,
formatMxRecords,
findingIcon,
formatInspectedAt,
isRecord,
severityLabel,
tabs,
terminalHttpsResult,
toneForScore,
} from "./components/report/report-utils";
function headerRows(http: Record<string, unknown>) {
const terminal = terminalHttpsResult(http);
return terminal && isRecord(terminal.headers) ? terminal.headers as HeaderMap : null;
}
export function ReportView({ result, source, isLoading = false, requestedHost, requestError, onReinspect, onInspectAnother }: {
result: InspectionResult;
source: "demo" | "live";
isLoading?: boolean;
requestedHost: string | null;
requestError: { requestedHost: string; message: string } | null;
onReinspect: (host: string) => void;
onInspectAnother: (host: string) => void;
}) {
const isDemo = source === "demo";
const [tab, setTab] = useState<Tab>("overview");
const [selectedFindingId, setSelectedFindingId] = useState<string | null>(null);
const [showAllFindings, setShowAllFindings] = useState(false);
const [showAllAddresses, setShowAllAddresses] = useState(false);
const [showAllIssues, setShowAllIssues] = useState(false);
const [filter, setFilter] = useState<FindingFilter>("all");
const [wrapRaw, setWrapRaw] = useState(false);
const [highlightedEvidence, setHighlightedEvidence] = useState<string | null>(null);
const [inspectAnotherOpen, setInspectAnotherOpen] = useState(false);
const [nextHost, setNextHost] = useState("");
const [statusMessage, setStatusMessage] = useState("");
const [manualCopy, setManualCopy] = useState<{ label: string; value: string } | null>(null);
const manualCopyRef = useRef<HTMLTextAreaElement | null>(null);
const dialogRef = useRef<HTMLElement | null>(null);
const closeRef = useRef<HTMLButtonElement | null>(null);
const originatingFindingRef = useRef<HTMLButtonElement | null>(null);
const findings = useMemo(() => {
const severityRank: Record<Finding["severity"], number> = { critical: 0, warning: 1, info: 2, good: 3 };
return [...result.findings].sort((left, right) => severityRank[left.severity] - severityRank[right.severity] || left.scoreImpact - right.scoreImpact || left.id.localeCompare(right.id));
}, [result.findings]);
const filteredFindings = useMemo(() => filter === "all" ? findings : findings.filter((finding) => finding.severity === filter), [findings, filter]);
const displayFindings = showAllFindings ? filteredFindings : filteredFindings.slice(0, 4);
const severityCounts = useMemo(() => (["critical", "warning", "info"] as const)
.map((severity) => ({ severity, count: findings.filter((finding) => finding.severity === severity).length }))
.filter(({ count }) => count > 0), [findings]);
const selectedFinding = findings.find((finding) => finding.id === selectedFindingId) ?? null;
const dns = result.dns as Record<string, unknown>;
const tls = result.tls as Record<string, unknown>;
const http = result.http as Record<string, unknown>;
const headers = headerRows(http);
const httpsChain = Array.isArray(http.httpsChain) ? http.httpsChain.filter(isRecord) : [];
const terminal = terminalHttpsResult(http);
const dnsCollectorStatus = collectorMeta(dns).status;
const tlsCollectorStatus = collectorMeta(tls).status;
const httpCollectorStatus = collectorMeta(http).status;
const tlsVerified = tls.authorized === true && tls.hostnameValid === true;
const tlsStatus = tls.error ? "TLS probe incomplete" : tlsVerified ? "Verified certificate" : "Certificate needs attention";
const httpTerminalStatus = statusForHttp(terminal?.status);
const httpStatus = httpCollectorStatus === "ok"
? httpTerminalStatus
: { label: httpCollectorStatus === "failed" ? "HTTP probe failed" : "HTTP evidence partial", tone: httpCollectorStatus === "failed" ? "red" as const : "muted" as const };
const scoreTone = (category: "overall" | "dns" | "tls" | "http", value: number) => {
const complete = category === "overall"
? result.completeness.status === "complete"
: ({ dns: dnsCollectorStatus, tls: tlsCollectorStatus, http: httpCollectorStatus }[category] === "ok");
return complete ? toneForScore(value) : "muted" as const;
};
const addresses = Array.isArray(dns.addresses) ? dns.addresses.map((address) => String(address)).filter(Boolean) : [];
const responses = [http.http, ...httpsChain].filter(isRecord).filter((hop) => finiteNumber(hop.status));
const redirectCount = responses.filter((hop) => Number(hop.status) >= 300 && Number(hop.status) < 400).length;
useEffect(() => {
if (manualCopy) manualCopyRef.current?.focus();
}, [manualCopy]);
useEffect(() => {
if (!selectedFinding) return;
const previousOverflow = document.body.style.overflow;
const backgroundElements = [...document.querySelectorAll<HTMLElement>(".app-topbar, .cert-main > *:not(.drawer-backdrop)")];
const previousInert = backgroundElements.map((element) => element.hasAttribute("inert"));
backgroundElements.forEach((element) => element.setAttribute("inert", ""));
document.body.style.overflow = "hidden";
const focusTarget = window.setTimeout(() => closeRef.current?.focus(), 0);
const dialog = dialogRef.current;
const closeOnKey = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
setSelectedFindingId(null);
return;
}
if (event.key !== "Tab" || !dialog) return;
const focusable = [...dialog.querySelectorAll<HTMLElement>("button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])")].filter((element) => !element.hasAttribute("disabled"));
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
dialog?.addEventListener("keydown", closeOnKey);
return () => {
window.clearTimeout(focusTarget);
dialog?.removeEventListener("keydown", closeOnKey);
backgroundElements.forEach((element, index) => {
if (!previousInert[index]) element.removeAttribute("inert");
});
document.body.style.overflow = previousOverflow;
if (originatingFindingRef.current?.isConnected) originatingFindingRef.current.focus();
else document.getElementById("raw-tab")?.focus();
};
}, [selectedFinding]);
function announce(message: string) {
setStatusMessage(message);
}
async function copyText(label: string, value: string) {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
announce(label + " copied");
return;
}
} catch { /* Try the selection-based browser fallback below. */ }
const field = document.createElement("textarea");
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
field.value = value;
field.readOnly = true;
field.style.position = "fixed";
field.style.opacity = "0";
document.body.appendChild(field);
field.select();
let copied = false;
try { copied = document.execCommand("copy"); } catch { /* Manual copy remains available. */ }
field.remove();
previousFocus?.focus();
if (copied) {
announce(label + " copied");
} else {
setManualCopy({ label, value });
announce(label + " ready to copy manually");
}
}
async function share() {
const text = inspectionToMarkdown(result);
try {
if (navigator.share) {
await navigator.share({ title: "CertScope — " + result.host, text });
announce("Report shared");
} else await copyText("Report", text);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") announce("Share cancelled");
else await copyText("Report", text);
}
}
function exportJson() {
downloadText(result.host + "-certscope.json", JSON.stringify(result, null, 2), "application/json");
announce("JSON export downloaded");
}
function exportMarkdown() {
downloadText(result.host + "-certscope.md", inspectionToMarkdown(result), "text/markdown");
announce("Markdown export downloaded");
}
function switchTab(next: Tab) {
setTab(next);
}
function submitAnotherHost(event: FormEvent) {
event.preventDefault();
if (!nextHost.trim()) return;
onInspectAnother(nextHost.trim());
}
function handleTabKey(event: React.KeyboardEvent<HTMLButtonElement>, index: number) {
const direction = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
if (event.key === "Home") {
event.preventDefault();
switchTab(tabs[0]);
document.getElementById(tabs[0] + "-tab")?.focus();
return;
}
if (event.key === "End") {
event.preventDefault();
const last = tabs.at(-1)!;
switchTab(last);
document.getElementById(last + "-tab")?.focus();
return;
}
if (!direction) return;
event.preventDefault();
const nextIndex = (index + direction + tabs.length) % tabs.length;
switchTab(tabs[nextIndex]);
document.getElementById(tabs[nextIndex] + "-tab")?.focus();
}
function evidenceTargetFor(finding: Finding) {
const pointer = finding.evidence.join(" ");
if (pointer.includes("tls.daysRemaining") || pointer.includes("tls.validTo") || pointer.includes("tls.validFrom")) return { tab: "tls" as const, id: "tls-certificate" };
if (pointer.includes("tls.")) return { tab: "tls" as const, id: "tls-certificate" };
if (pointer.includes("dns.dmarc") || pointer.includes("dns.spf")) return { tab: "dns" as const, id: "dns-mail-posture" };
if (pointer.includes("dns.")) return { tab: "dns" as const, id: "dns-records" };
if (pointer.includes("headers") || pointer.includes("header")) return { tab: "http" as const, id: "http-header-posture" };
if (pointer.includes("httpsChain") || pointer.includes("http.http")) return { tab: "http" as const, id: "http-redirect-chain" };
return { tab: "raw" as const, id: "raw-evidence" };
}
function viewEvidence(finding: Finding) {
const target = evidenceTargetFor(finding);
setSelectedFindingId(null);
switchTab(target.tab);
window.setTimeout(() => {
const element = document.getElementById(target.id);
element?.scrollIntoView({ behavior: "smooth", block: "center" });
element?.focus();
setHighlightedEvidence(target.id);
window.setTimeout(() => setHighlightedEvidence((current) => current === target.id ? null : current), 2_000);
}, 0);
}
return <>
<section className="report-workspace" aria-label="Inspection report">
<h1 className="sr-only">CertScope inspection report for {result.host}</h1>
<header className="report-context-bar"><div className="report-identity"><p>{isDemo ? "Deterministic demo report" : "Live inspection report"}</p><h2>{result.host}</h2></div><dl className="report-context-meta"><div><dt>Coverage</dt><dd><span className={"status-badge " + result.completeness.status}>{result.completeness.status === "complete" ? "Complete" : result.completeness.status === "partial" ? "Partial" : "Incomplete"}</span> {result.completeness.complete}/{result.completeness.total} collectors</dd></div><div><dt>Addresses</dt><dd className="address-list">{addresses.length ? <>{(showAllAddresses ? addresses : addresses.slice(0, 4)).join(" · ")}{addresses.length > 4 && <button type="button" className="inline-expand" onClick={() => setShowAllAddresses((show) => !show)}>{showAllAddresses ? "Show fewer addresses" : `+${addresses.length - 4} more`}</button>}</> : "Unavailable"}</dd></div><div><dt>Last inspected</dt><dd title={result.inspectedAt}>{formatInspectedAt(result.inspectedAt)}</dd></div></dl>{inspectAnotherOpen && <form className="workspace-host-form" onSubmit={submitAnotherHost}><label htmlFor="workspace-hostname">Another public hostname</label><input id="workspace-hostname" value={nextHost} onChange={(event) => setNextHost(event.target.value)} placeholder="example.com" autoComplete="off" spellCheck={false} /><button type="submit" disabled={isLoading || !nextHost.trim()}>{isLoading ? "Inspecting…" : "Inspect"}</button></form>}</header>
<div className="score-row">
{(() => { const tone = scoreTone("overall", result.score.overall); return <div className={"metric-card overall " + tone}><div><span>Overall posture</span><strong>{result.score.overall}<small>/ 100</small></strong><em>{result.completeness.status === "complete" ? `(DNS ${result.score.categories.dns} + TLS ${result.score.categories.tls} + HTTP ${result.score.categories.http}) ÷ 3 = ${result.score.overall} (rounded)` : "Review incomplete evidence before acting"}</em></div><Gauge value={result.score.overall} tone={tone} /></div>; })()}
<div className="category-score-grid">{[["DNS", result.score.categories.dns, result.completeness.collectors.dns === "ok" ? "DNS evidence complete" : "DNS evidence partial"], ["TLS", result.score.categories.tls, tlsStatus], ["HTTP", result.score.categories.http, httpStatus.label]].map(([label, value, description]) => { const tone = scoreTone(String(label).toLowerCase() as "dns" | "tls" | "http", Number(value)); return <div className={"metric-card " + String(label).toLowerCase() + " " + tone} key={String(label)}><div><span>{String(label)}</span><strong>{Number(value)}<small>/100</small></strong><em>{String(description)}</em></div><Gauge value={Number(value)} tone={tone} /></div>; })}</div>
</div>
<div className="report-status-row"><span className={"status-badge " + result.completeness.status}>{result.completeness.status === "complete" ? "Complete inspection" : result.completeness.status === "partial" ? "Partial inspection" : "Inspection incomplete"}</span><span>{result.completeness.complete}/{result.completeness.total} collectors complete</span><span className="provenance-badge">{isDemo ? "Deterministic demo" : "Live inspection"}</span>{isLoading && requestedHost && <span role="status">{requestedHost === result.host ? "Re-inspecting" : "Inspecting"} {requestedHost}…</span>}</div>
{requestError && <div className="error-banner previous-report-banner" role="alert"><strong>Previous report remains displayed</strong><span>{requestError.message}</span><small>The {isDemo ? "deterministic demo" : "live"} report for {result.host} is still available while {requestError.requestedHost} could not be inspected.</small></div>}
{result.completeness.issues.length > 0 && <p className={"completeness-note " + result.completeness.status}><strong>Inspection incomplete</strong>{(showAllIssues ? result.completeness.issues : result.completeness.issues.slice(0, 3)).join(" · ")}{result.completeness.issues.length > 3 && <button type="button" className="inline-expand" onClick={() => setShowAllIssues((show) => !show)}>{showAllIssues ? "Show fewer issues" : `+${result.completeness.issues.length - 3} more`}</button>} Remaining evidence is still valid.</p>}
<div className="report-tabs-bar"><nav aria-label="Report sections" role="tablist">{tabs.map((item, index) => <button type="button" role="tab" aria-selected={tab === item} aria-controls={"panel-" + item} id={item + "-tab"} className={tab === item ? "active" : ""} key={item} onClick={() => switchTab(item)} onKeyDown={(event) => handleTabKey(event, index)}>{item === "raw" ? "Raw" : item.toUpperCase()}</button>)}</nav><ReportActions loading={isLoading} onReinspect={() => { announce("Re-inspection started"); onReinspect(result.host); }} onInspectAnother={() => setInspectAnotherOpen((open) => !open)} onShare={() => void share()} onExportJson={exportJson} onExportMarkdown={exportMarkdown} /></div>
<p className="action-status" role="status" aria-live="polite">{statusMessage}</p>
{manualCopy && <div className="manual-copy-panel" role="region" aria-label={`Copy ${manualCopy.label} manually`}><div><strong>{manualCopy.label} ready to copy</strong><button type="button" onClick={() => setManualCopy(null)}>Close</button></div><p>Use Ctrl+C (or Copy on your device) to copy the selected text.</p><textarea ref={manualCopyRef} readOnly value={manualCopy.value} onFocus={(event) => event.currentTarget.select()} aria-label={`${manualCopy.label} text`} /></div>}
{tab === "overview" && <div id="panel-overview" role="tabpanel" aria-labelledby="overview-tab">
<section className="findings-panel" aria-labelledby="findings-title"><header><div><h2 id="findings-title">Findings</h2><span>{findings.length} total</span><p className="severity-counts" aria-label="Finding severity counts">{severityCounts.map(({ severity, count }) => <span className={"pill " + severity} key={severity}>{count} {severityLabel(severity).toLowerCase()}{count === 1 ? "" : "s"}</span>)}</p></div><button type="button" onClick={() => switchTab("raw")}>Open raw report&nbsp; →</button></header><div className="finding-filters" aria-label="Filter findings">{(["all", "critical", "warning", "info"] as const).map((item) => <button type="button" key={item} className={filter === item ? "active" : ""} aria-pressed={filter === item} onClick={() => { setFilter(item); setShowAllFindings(true); }}>{item === "all" ? "All" : severityLabel(item)}</button>)}</div>{displayFindings.length ? <div className="findings-grid">{displayFindings.map((finding) => <button type="button" className={"finding-card " + finding.severity} key={finding.id} onClick={(event) => { originatingFindingRef.current = event.currentTarget; setSelectedFindingId(finding.id); }} aria-label={"Open diagnostic details for " + finding.title}><div className={"finding-icon " + finding.severity}>{findingIcon(finding.severity)}</div><div className="finding-body"><div className="finding-title"><strong>{finding.title}</strong><span className={"pill " + finding.severity}>{severityLabel(finding.severity)}</span><b className="finding-impact">{finding.scoreImpact}</b></div><p>{finding.summary}</p><small>{finding.category.toUpperCase()} · View diagnostic and remediation</small></div><span className="finding-chevron" aria-hidden="true">›</span></button>)}</div> : <p className="empty-findings">No findings match this filter.</p>}{filteredFindings.length > 4 && <button type="button" className="show-all-button" onClick={() => setShowAllFindings((show) => !show)}>{showAllFindings ? "Show fewer findings" : "Show all " + filteredFindings.length + " findings"}</button>}</section>
<section className="detail-grid" aria-label="Evidence summary"><article className="detail-card dns-card"><header><h3>DNS summary</h3><span className="status-badge">{collectorLabel(collectorMeta(dns).status)}</span></header><dl className="summary-list">{[["A", dns.a], ["AAAA", dns.aaaa], ["MX", dns.mx], ["NS", dns.ns], ["SPF", dns.spf], ["DMARC", dns.dmarc]].map(([key, value]) => <div className="summary-row" key={String(key)}><dt>{String(key)}</dt><dd>{key === "MX" ? formatMxRecords(value) : compact(value)}</dd></div>)}</dl><button type="button" className="card-link" onClick={() => switchTab("dns")}>View DNS evidence →</button></article><article className="detail-card tls-card"><header><h3>TLS summary</h3><span className={"status-badge " + (tlsVerified ? "complete" : "partial")}>{tlsStatus}</span></header><dl className="summary-list"><div><dt>Protocol</dt><dd>{compact(tls.protocol)}</dd></div><div><dt>Endpoint</dt><dd>{compact(tls.address)}</dd></div><div><dt>Hostname match</dt><dd>{tls.hostnameValid === true ? "Yes" : tls.hostnameValid === false ? "No" : "Unavailable"}</dd></div><div><dt>Days remaining</dt><dd>{finiteNumber(tls.daysRemaining) ? tls.daysRemaining : "Unavailable"}</dd></div></dl><button type="button" className="card-link" onClick={() => switchTab("tls")}>View TLS evidence →</button></article><article className="detail-card http-card"><header><h3>HTTP summary</h3><span className={"status-badge " + httpStatus.tone}>{httpStatus.label}</span></header><dl className="summary-list"><div><dt>HTTPS endpoint</dt><dd>{compact(http.finalUrl)}</dd></div><div><dt>Redirects</dt><dd>{redirectCount} of {responses.length} responses</dd></div><div><dt>HSTS</dt><dd>{headers ? analyzeHeaders(headers).hsts.status : "Unavailable"}</dd></div><div><dt>Content-Security-Policy</dt><dd>{headers ? analyzeHeaders(headers).csp.status : "Unavailable"}</dd></div></dl><button type="button" className="card-link" onClick={() => switchTab("http")}>View HTTP evidence →</button></article></section>
</div>}
{tab === "dns" && <section id="panel-dns" role="tabpanel" aria-labelledby="dns-tab" className="single-detail"><header><div><h2>DNS evidence</h2><p>Structured record data and collector health.</p></div></header><DnsEvidence dns={dns} highlightedEvidence={highlightedEvidence} onCopy={copyText} /></section>}
{tab === "tls" && <section id="panel-tls" role="tabpanel" aria-labelledby="tls-tab" className="single-detail"><header><div><h2>TLS evidence</h2><p>Certificate identity, validity and validated connection attempts.</p></div></header><TlsEvidence tls={tls} highlightedEvidence={highlightedEvidence} /></section>}
{tab === "http" && <section id="panel-http" role="tabpanel" aria-labelledby="http-tab" className="single-detail"><header><div><h2>HTTP evidence</h2><p>Redirect behavior, response semantics and parsed security headers.</p></div></header><HttpEvidence http={http} highlightedEvidence={highlightedEvidence} /></section>}
{tab === "raw" && <section id="panel-raw" role="tabpanel" aria-labelledby="raw-tab" className="single-detail"><header><div><h2>Raw normalized report</h2><p>Complete JSON representation used by the diagnostics and exports.</p></div><div className="raw-actions"><button type="button" onClick={() => void copyText("Raw JSON", JSON.stringify(result, null, 2))}>Copy JSON</button><button type="button" onClick={exportJson}>Download JSON</button><button type="button" aria-pressed={wrapRaw} onClick={() => setWrapRaw((wrap) => !wrap)}>{wrapRaw ? "Disable line wrap" : "Wrap lines"}</button></div></header><pre id="raw-evidence" tabIndex={-1} className={(wrapRaw ? "wrap " : "") + (highlightedEvidence === "raw-evidence" ? "evidence-highlight" : "")}>{JSON.stringify(result, null, 2)}</pre></section>}
</section>
{selectedFinding && <div className="drawer-backdrop" role="presentation" onMouseDown={() => setSelectedFindingId(null)}><aside ref={dialogRef} className="diagnostic-drawer" role="dialog" aria-modal="true" aria-labelledby="diagnostic-title" aria-describedby="diagnostic-summary" tabIndex={-1} onMouseDown={(event) => event.stopPropagation()}><header><div><p className="drawer-kicker">Diagnostic detail</p><h2 id="diagnostic-title">{selectedFinding.title}</h2></div><button ref={closeRef} type="button" className="drawer-close" aria-label="Close diagnostic details" onClick={() => setSelectedFindingId(null)}><CloseIcon /></button></header><span className={"pill " + selectedFinding.severity}>{severityLabel(selectedFinding.severity)}</span><p id="diagnostic-summary" className="drawer-summary">{selectedFinding.summary}</p><dl className="drawer-facts"><div><dt>Category</dt><dd>{selectedFinding.category.toUpperCase()}</dd></div><div><dt>Score impact</dt><dd>{selectedFinding.scoreImpact > 0 ? "+" : ""}{selectedFinding.scoreImpact}</dd></div></dl><section><h3>Evidence pointers</h3><ul className="evidence-pointers">{selectedFinding.evidence.length ? selectedFinding.evidence.map((pointer) => <li key={pointer}><code>{pointer}</code></li>) : <li>No evidence pointer was recorded.</li>}</ul><p className="drawer-note">View evidence opens the structured location when a stable pointer is available, otherwise Raw.</p></section><section><h3>Recommended action</h3><div className="remedy-box">{selectedFinding.remediation ?? "No action required."}</div></section><div className="drawer-actions"><button type="button" className="drawer-evidence-button" onClick={() => viewEvidence(selectedFinding)}>View evidence</button><button type="button" className="drawer-raw-button" onClick={() => { switchTab("raw"); setSelectedFindingId(null); }}>Open raw evidence</button></div></aside></div>}
</>;
}