Initial curated CertScope source from fe2bbe3ca5c98bf64be26a09c6c58ee805a7f87e

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:30:09 +02:00
commit 7348faf6c0
65 files changed
+12509

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { GET } from "./route";
import { CERTSCOPE_VERSION } from "@/lib/version";
describe("GET /api/health", () => {
it("returns a cache-free versioned liveness response", async () => {
const response = GET();
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(await response.json()).toMatchObject({ status: "ok", version: CERTSCOPE_VERSION });
});
});
+12
View File
@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { CERTSCOPE_REVISION, CERTSCOPE_VERSION } from "@/lib/version";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export function GET() {
return NextResponse.json(
{ status: "ok", version: CERTSCOPE_VERSION, revision: CERTSCOPE_REVISION },
{ headers: { "Cache-Control": "no-store" } },
);
}
+39
View File
@@ -0,0 +1,39 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { InspectionInputError, InspectionResolutionError } from "@/lib/inspect/policy";
const { inspectMock } = vi.hoisted(() => ({ inspectMock: vi.fn() }));
vi.mock("@/lib/inspect/inspect", () => ({ inspectHost: inspectMock }));
import { classifyInspectionError, GET } from "./route";
function request(host = "example.com") {
return new NextRequest("http://localhost/api/inspect?host=" + encodeURIComponent(host));
}
describe("GET /api/inspect error classification", () => {
beforeEach(() => inspectMock.mockReset());
it("returns a report with no-store caching on success", async () => {
inspectMock.mockResolvedValue({ host: "example.com" });
const response = await GET(request());
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(await response.json()).toEqual({ host: "example.com" });
});
it("maps user input and resolution policy errors to client errors", async () => {
inspectMock.mockRejectedValueOnce(new InspectionInputError("invalid host"));
expect((await GET(request("bad input"))).status).toBe(400);
inspectMock.mockRejectedValueOnce(new InspectionResolutionError("not found"));
expect((await GET(request("missing.example"))).status).toBe(422);
});
it("classifies unexpected failures as a generic 500 without exposing details", () => {
expect(classifyInspectionError(new Error("private upstream detail"))).toEqual({
status: 500,
message: "Inspection service is temporarily unavailable. Try again shortly.",
});
});
});
+40
View File
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
import { checkRateLimit, clientKeyFromHeaders, tryAcquireInspection } from "@/lib/inspect/admission";
import { inspectHost } from "@/lib/inspect/inspect";
import { InspectionInputError, InspectionPolicyError, InspectionResolutionError } from "@/lib/inspect/policy";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
function jsonError(error: string, status: number, headers: Record<string, string> = {}) {
return NextResponse.json({ error }, { status, headers: { "Cache-Control": "no-store", ...headers } });
}
export function classifyInspectionError(error: unknown) {
if (error instanceof InspectionInputError || error instanceof InspectionPolicyError) return { status: 400, message: error.message };
if (error instanceof InspectionResolutionError) return { status: 422, message: error.message };
return { status: 500, message: "Inspection service is temporarily unavailable. Try again shortly." };
}
export async function GET(request: NextRequest) {
const host = request.nextUrl.searchParams.get("host") ?? "";
const rate = checkRateLimit(clientKeyFromHeaders(request.headers));
if (!rate.allowed) return jsonError("Request rate limit reached. Try again shortly.", 429, { "Retry-After": String(rate.retryAfterSeconds) });
const release = tryAcquireInspection();
if (!release) {
return jsonError("Inspection capacity is temporarily full. Try again shortly.", 429, { "Retry-After": "1" });
}
try {
return NextResponse.json(await inspectHost(host), { headers: { "Cache-Control": "no-store" } });
} catch (error) {
const classified = classifyInspectionError(error);
if (classified.status === 500) console.error("inspection_unexpected_error", {
name: error instanceof Error ? error.name : "UnknownError",
code: typeof error === "object" && error !== null && "code" in error ? String((error as { code?: unknown }).code ?? "") : "",
});
return jsonError(classified.message, classified.status);
} finally {
release();
}
}
+59
View File
@@ -0,0 +1,59 @@
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>;
}
+63
View File
@@ -0,0 +1,63 @@
"use client";
import { useEffect, useRef, useState } from "react";
function RefreshIcon() {
return <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 11a8 8 0 1 0 2 5.3" fill="none" stroke="currentColor" strokeWidth="2"/><path d="M20 5v6h-6" fill="none" stroke="currentColor" strokeWidth="2"/></svg>;
}
function ShareIcon() {
return <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 16V3m0 0L7 8m5-5 5 5M5 13v7h14v-7" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>;
}
type ReportActionsProps = {
loading: boolean;
onReinspect: () => void;
onInspectAnother: () => void;
onShare: () => void;
onExportJson: () => void;
onExportMarkdown: () => void;
};
export function ReportActions({ loading, onReinspect, onInspectAnother, onShare, onExportJson, onExportMarkdown }: ReportActionsProps) {
const [mobileOpen, setMobileOpen] = useState(false);
const [desktopExportOpen, setDesktopExportOpen] = useState(false);
const [mobileExportOpen, setMobileExportOpen] = useState(false);
const desktopRef = useRef<HTMLDivElement | null>(null);
const mobileRef = useRef<HTMLDivElement | null>(null);
const close = () => {
setMobileOpen(false);
setDesktopExportOpen(false);
setMobileExportOpen(false);
};
useEffect(() => {
if (!mobileOpen && !desktopExportOpen && !mobileExportOpen) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") close();
};
const onPointerDown = (event: PointerEvent) => {
if (![desktopRef.current, mobileRef.current].some((element) => element?.contains(event.target as Node))) close();
};
document.addEventListener("keydown", onKeyDown);
document.addEventListener("pointerdown", onPointerDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
document.removeEventListener("pointerdown", onPointerDown);
};
}, [mobileOpen, desktopExportOpen, mobileExportOpen]);
const controls = (variant: "desktop" | "mobile") => {
const exportOpen = variant === "desktop" ? desktopExportOpen : mobileExportOpen;
const setExportOpen = variant === "desktop" ? setDesktopExportOpen : setMobileExportOpen;
const exportId = variant + "-export-options";
return <>
<button type="button" className="action-reinspect" onClick={() => { close(); onReinspect(); }} disabled={loading}><RefreshIcon />{loading ? "Inspecting…" : "Re-inspect"}</button>
<button type="button" className="inspect-another-btn" onClick={() => { close(); onInspectAnother(); }}>Inspect another host</button>
<button type="button" className="share-btn" onClick={() => { close(); onShare(); }}><ShareIcon />Share</button>
<div className="export-control"><button type="button" className="export-btn" aria-expanded={exportOpen} aria-controls={exportId} onClick={() => setExportOpen((open) => !open)}>Export <span aria-hidden="true">⌄</span></button>{exportOpen && <div id={exportId} className="export-popover" aria-label="Export report"><button type="button" onClick={() => { onExportJson(); close(); }}>JSON</button><button type="button" onClick={() => { onExportMarkdown(); close(); }}>Markdown</button></div>}</div>
</>;
};
return <><div ref={desktopRef} className="report-actions">{controls("desktop")}</div><div ref={mobileRef} className="mobile-action-menu"><button type="button" className="actions-toggle" aria-expanded={mobileOpen} aria-controls="mobile-report-actions" onClick={() => setMobileOpen((open) => !open)}>Actions <span aria-hidden="true">⌄</span></button>{mobileOpen && <div id="mobile-report-actions" className="actions-popover" aria-label="Report actions">{controls("mobile")}</div>}</div></>;
}
+110
View File
@@ -0,0 +1,110 @@
import type { CSSProperties } from "react";
import type { Finding } from "@/lib/inspect/types";
export type Tab = "overview" | "dns" | "tls" | "http" | "raw";
export type FindingFilter = "all" | Finding["severity"];
export const tabs: Tab[] = ["overview", "dns", "tls", "http", "raw"];
export const headerNames = [
"strict-transport-security",
"content-security-policy",
"x-content-type-options",
"referrer-policy",
"x-frame-options",
];
export function downloadText(filename: string, content: string, type: string) {
const blob = new Blob([content], { type });
const href = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = href;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(href), 0);
}
export function CopyIcon() {
return <svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="7" width="10" height="12" rx="2" fill="none" stroke="currentColor" strokeWidth="1.6"/><path d="M6 16H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1" fill="none" stroke="currentColor" strokeWidth="1.6"/></svg>;
}
export function CloseIcon() {
return <svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/></svg>;
}
export function Gauge({ value, tone }: { value: number; tone: "green" | "amber" | "red" | "muted" }) {
const degrees = Math.max(0, Math.min(100, value)) * 3.6;
return <div className={`gauge ${tone}`} style={{ "--gauge": `${degrees}deg` } as CSSProperties}><div /></div>;
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export function finiteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
export function hasValue(value: unknown) {
return value !== null && value !== undefined && value !== "";
}
export function compact(value: unknown) {
if (Array.isArray(value)) return value.length ? value.map((item) => item && typeof item === "object" ? JSON.stringify(item) : String(item)).join("\n") : "—";
if (value && typeof value === "object") return JSON.stringify(value);
return value == null || value === "" ? "—" : String(value);
}
export function formatMxRecords(value: unknown) {
if (!Array.isArray(value)) return compact(value);
return value.length ? value.map((record) => {
if (!isRecord(record) || typeof record.exchange !== "string" || !finiteNumber(record.priority)) return compact(record);
return `${record.priority} · ${record.exchange}`;
}).join("\n") : "—";
}
export function formatCertificateName(value: unknown) {
if (!isRecord(value)) return compact(value);
const labels: Record<string, string> = { O: "Organization", CN: "Common name", C: "Country" };
return Object.entries(value).map(([key, entry]) => `${labels[key] ?? key}: ${compact(entry)}`).join("\n") || "—";
}
export function severityLabel(severity: Finding["severity"]) {
return severity === "critical" ? "Critical" : severity === "warning" ? "Warning" : severity === "good" ? "Good" : "Info";
}
export function findingIcon(severity: Finding["severity"]) {
return severity === "good" ? "✓" : severity === "info" ? "i" : "!";
}
export function toneForScore(value: number) {
return value >= 90 ? "green" as const : value >= 75 ? "amber" as const : "red" as const;
}
export function 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;
}
export function collectorMeta(value: Record<string, unknown>) {
const meta = isRecord(value._meta) ? value._meta : {};
const status = meta.status === "ok" || meta.status === "partial" || meta.status === "failed" ? meta.status : "failed";
const errors = Array.isArray(meta.errors) ? meta.errors : [];
return { status, errors };
}
export function collectorLabel(status: string) {
return status === "ok" ? "Complete" : status === "partial" ? "Partial" : "Failed";
}
export function triState(value: unknown, observed: boolean) {
return value === true ? "Yes" : value === false && observed ? "No" : "Unavailable";
}
export function formatInspectedAt(value: string) {
const date = new Date(value);
if (!Number.isFinite(date.getTime())) return value;
return `${date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "medium" })} local`;
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+491
View File
@@ -0,0 +1,491 @@
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--surface-0: #06111f;
--surface-1: #081a2a;
--surface-2: #0b2235;
--surface-3: #0d2a41;
--surface-elevated: #102f48;
--surface-report: rgba(7, 26, 40, .76);
--surface-evidence: rgba(5, 22, 35, .42);
--border-subtle: rgba(120, 179, 218, .16);
--border-strong: rgba(91, 173, 229, .38);
--text: #edf5fb;
--text-soft: #c2d5e4;
--muted: #8ca8bd;
--blue: #4db3ff;
--blue-strong: #218fe5;
--blue-soft: #9dd9ff;
--green: #54e8a1;
--amber: #ffca52;
--red: #ff778b;
--status-complete: var(--green);
--status-partial: var(--amber);
--status-error: var(--red);
--focus-ring: #9edcff;
--radius-sm: 10px;
--radius-md: 16px;
--radius-lg: 24px;
--shadow-1: 0 16px 44px rgba(0, 0, 0, .18);
--shadow-2: 0 22px 64px rgba(0, 0, 0, .35);
--ease: 160ms ease;
}
* { box-sizing: border-box; }
html, body { min-width: 320px; min-height: 100%; overflow-x: hidden; }
body {
margin: 0;
background:
radial-gradient(circle at 18% -10%, rgba(37, 122, 184, .27), transparent 34rem),
radial-gradient(circle at 88% 12%, rgba(23, 102, 155, .16), transparent 30rem),
var(--surface-0);
color: var(--text);
font-size: 14px;
}
body::before {
position: fixed;
z-index: -1;
inset: 0;
content: "";
opacity: .42;
pointer-events: none;
background-image:
linear-gradient(rgba(97, 164, 207, .025) 1px, transparent 1px),
linear-gradient(90deg, rgba(97, 164, 207, .025) 1px, transparent 1px);
background-size: 42px 42px;
mask-image: linear-gradient(to bottom, black, transparent 78%);
}
button, input { font: inherit; }
button { cursor: pointer; }
button:disabled { cursor: not-allowed; opacity: .58; }
a { color: inherit; }
button, a, input, .finding-card, .capability-card { transition: border-color var(--ease), color var(--ease), background var(--ease), box-shadow var(--ease), transform var(--ease); }
.cert-app-shell { min-height: 100vh; }
.app-topbar {
position: sticky;
top: 0;
z-index: 10;
min-height: 72px;
display: flex;
align-items: center;
gap: 28px;
padding: 0 max(24px, calc((100vw - 1360px) / 2));
border-bottom: 1px solid var(--border-subtle);
background: rgba(5, 15, 25, .78);
backdrop-filter: blur(18px);
}
.brand-lockup { display: flex; align-items: center; gap: 11px; min-width: 220px; }
.brand-lockup strong { display: block; letter-spacing: -.035em; font-size: 24px; line-height: 1; }
.brand-lockup small { display: block; margin-top: 4px; color: var(--text-soft); font-size: 8px; font-weight: 800; letter-spacing: .14em; }
.brand-mark { width: 33px; height: 37px; display: grid; place-items: center; color: var(--blue); }
.brand-mark svg { width: 100%; height: 100%; }
.product-context { display: flex; flex-direction: column; gap: 3px; color: var(--text-soft); font-size: 12px; }
.product-context strong { font-size: 12px; letter-spacing: .015em; }
.product-context span { color: var(--muted); font-size: 11px; }
.topbar-right { display: flex; align-items: center; gap: 13px; margin-left: auto; }
.version-chip { padding: 5px 9px; border: 1px solid rgba(87, 181, 239, .4); border-radius: 999px; background: rgba(18, 61, 91, .66); color: var(--blue-soft); font: 11px ui-monospace, SFMono-Regular, Consolas, monospace; }
.repo-link { color: var(--text-soft); font-size: 12px; text-decoration: none; }
.repo-link:hover, .repo-link:focus-visible { color: white; text-decoration: underline; }
.cert-main { width: min(1360px, 100%); margin: 0 auto; padding: 32px 24px 72px; }
.hero-workspace { display: grid; grid-template-columns: minmax(0, 1.06fr) minmax(380px, .94fr); gap: clamp(28px, 5vw, 74px); align-items: center; min-height: 346px; padding: clamp(30px, 5vw, 64px); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: linear-gradient(135deg, rgba(13, 43, 64, .78), rgba(6, 20, 33, .58) 64%, rgba(14, 53, 79, .55)); box-shadow: var(--shadow-1); overflow: hidden; }
.hero-workspace::after { position: absolute; content: ""; width: 420px; height: 420px; margin: -310px 0 0 -220px; border: 1px solid rgba(94, 190, 246, .15); border-radius: 50%; pointer-events: none; }
.hero-workspace { position: relative; }
.hero-copy, .inspect-cluster { position: relative; z-index: 1; }
.tagline { margin: 0 0 26px; color: var(--blue-soft); font-size: 11px; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; }
.hero-kicker { display: flex; align-items: center; gap: 8px; margin: 0 0 13px; color: var(--blue-soft); font-size: 12px; font-weight: 700; }
.hero-kicker span { color: var(--blue); font-size: 11px; }
.hero-copy h1 { margin: 0; font-size: clamp(48px, 6vw, 76px); line-height: .92; letter-spacing: -.065em; }
.hero-statement { max-width: 590px; margin: 17px 0 0; color: #d9eafb; font-size: clamp(22px, 2.3vw, 31px); line-height: 1.12; letter-spacing: -.035em; }
.hero-description { max-width: 590px; margin: 18px 0 0; color: var(--text-soft); font-size: 15px; line-height: 1.6; }
.hero-signals { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 24px; }
.hero-signals span { padding: 6px 10px; border: 1px solid rgba(90, 175, 229, .28); border-radius: 999px; background: rgba(12, 52, 78, .46); color: #c8e4f7; font-size: 11px; font-weight: 700; }
.inspect-cluster { display: grid; gap: 14px; padding: 22px; border: 1px solid rgba(109, 190, 240, .31); border-radius: var(--radius-md); background: linear-gradient(160deg, rgba(10, 42, 65, .98), rgba(7, 27, 43, .96)); box-shadow: 0 20px 46px rgba(0, 0, 0, .26), inset 0 1px 0 rgba(255, 255, 255, .04); }
.inspect-card-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.inspect-card-heading p { margin: 0 0 4px; color: var(--blue-soft); font-size: 10px; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; }
.inspect-card-heading strong { font-size: 17px; }
.inspect-card-status { padding: 5px 8px; border: 1px solid rgba(87, 213, 153, .35); border-radius: 999px; background: rgba(17, 76, 55, .68); color: var(--green); font-size: 10px; font-weight: 750; white-space: nowrap; }
.host-field-wrap label { display: block; margin-bottom: 7px; color: #d5e6f3; font-size: 12px; font-weight: 750; }
.host-field-wrap input { width: 100%; height: 50px; padding: 0 15px; border: 1px solid rgba(106, 177, 220, .42); border-radius: var(--radius-sm); outline: none; background: rgba(4, 18, 30, .72); color: var(--text); font-size: 16px; }
.host-field-wrap input::placeholder { color: #6f91aa; }
.host-field-wrap input:hover { border-color: rgba(113, 195, 243, .65); }
.host-field-wrap input:focus { border-color: var(--blue); box-shadow: 0 0 0 4px rgba(76, 178, 255, .16); }
.host-field-wrap small { display: block; margin-top: 7px; color: var(--muted); font-size: 11px; line-height: 1.4; }
.quick-hosts { display: flex; flex-wrap: wrap; gap: 7px; }
.quick-hosts button { padding: 5px 8px; border: 1px solid rgba(112, 177, 218, .25); border-radius: 7px; background: rgba(19, 58, 84, .48); color: #bbd8eb; font: 11px ui-monospace, SFMono-Regular, Consolas, monospace; }
.quick-hosts button:hover, .quick-hosts button:focus-visible { border-color: var(--blue); background: rgba(22, 86, 127, .65); color: white; }
.inspect-actions { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; }
.primary-btn, .secondary-btn, .preview-demo-button { min-height: 48px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; border-radius: 10px; font-weight: 780; }
.primary-btn { padding: 0 19px; border: 1px solid #4ab3ff; background: linear-gradient(180deg, #289cf3, #167cc7); box-shadow: 0 9px 25px rgba(23, 137, 223, .28); color: white; }
.primary-btn:hover:not(:disabled) { border-color: #8ed3ff; background: linear-gradient(180deg, #36a7fb, #218bd7); box-shadow: 0 12px 31px rgba(23, 137, 223, .38); transform: translateY(-1px); }
.primary-btn svg { width: 19px; }
.secondary-btn { padding: 0 14px; border: 1px solid rgba(114, 183, 224, .4); background: rgba(10, 45, 68, .72); color: #d6e8f5; }
.secondary-btn:hover:not(:disabled), .preview-demo-button:hover { border-color: var(--blue); background: rgba(22, 84, 124, .82); color: white; }
.secondary-btn svg, .preview-demo-button svg { width: 17px; }
.inspect-card-note { margin: 0; color: var(--muted); font-size: 11px; line-height: 1.45; }
.error-banner { display: grid; gap: 4px; padding: 12px; border: 1px solid rgba(255, 119, 139, .44); border-radius: var(--radius-sm); background: rgba(91, 27, 40, .48); color: #ffdce2; }
.error-banner strong { color: #ffb9c4; font-size: 12px; }
.error-banner span { font-size: 12px; line-height: 1.45; }
.error-banner small { color: #e7a8b3; font-size: 11px; }
.inspection-progress { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 17px; align-items: center; margin-top: 22px; padding: 17px 20px; border: 1px solid rgba(78, 176, 246, .32); border-radius: var(--radius-md); background: rgba(9, 37, 56, .8); box-shadow: var(--shadow-1); }
.progress-orb { width: 43px; height: 48px; color: var(--blue); animation: inspection-pulse 1.65s ease-in-out infinite; }
.progress-orb svg { width: 100%; height: 100%; }
.inspection-progress strong { font-size: 14px; }
.inspection-progress p { margin: 5px 0 0; color: var(--muted); font-size: 12px; line-height: 1.5; }
.inspection-progress code { color: var(--blue-soft); }
.progress-checks { display: flex; gap: 7px; }
.progress-checks span { padding: 5px 8px; border-radius: 999px; background: rgba(27, 79, 111, .76); color: #bbddf3; font-size: 10px; font-weight: 750; }
.capability-section { margin-top: 32px; }
.section-heading { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin: 0 2px 14px; }
.section-heading p, .demo-preview-copy > p, .security-boundary p { margin: 0 0 7px; color: var(--blue-soft); font-size: 10px; font-weight: 800; letter-spacing: .11em; text-transform: uppercase; }
.section-heading h2 { max-width: 700px; margin: 0; font-size: clamp(22px, 3vw, 30px); line-height: 1.16; letter-spacing: -.035em; }
.section-heading > span { padding-bottom: 3px; color: var(--muted); font-size: 12px; white-space: nowrap; }
.capability-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 13px; }
.capability-card { position: relative; min-height: 145px; padding: 20px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: linear-gradient(145deg, rgba(13, 39, 59, .84), rgba(7, 24, 38, .8)); overflow: hidden; }
.capability-card::after { position: absolute; inset: auto -25px -50px auto; width: 130px; height: 130px; border: 1px solid rgba(83, 180, 241, .12); border-radius: 50%; content: ""; }
.capability-card:hover { border-color: rgba(96, 186, 241, .44); background: linear-gradient(145deg, rgba(15, 51, 76, .94), rgba(7, 27, 42, .9)); transform: translateY(-2px); }
.capability-index { display: block; color: var(--blue); font: 11px ui-monospace, SFMono-Regular, Consolas, monospace; }
.capability-card h3 { margin: 17px 0 7px; font-size: 18px; }
.capability-card p { max-width: 235px; margin: 0; color: var(--text-soft); font-size: 13px; line-height: 1.5; }
.security-boundary { display: grid; grid-template-columns: minmax(200px, .8fr) minmax(0, 2.2fr); gap: 22px; align-items: center; margin-top: 14px; padding: 18px 20px; border-top: 1px solid var(--border-subtle); border-bottom: 1px solid var(--border-subtle); }
.security-boundary strong { font-size: 15px; }
.security-boundary ul { display: flex; flex-wrap: wrap; gap: 9px 16px; margin: 0; padding: 0; list-style: none; }
.security-boundary li { display: inline-flex; align-items: center; gap: 6px; color: var(--text-soft); font-size: 12px; }
.security-boundary svg { width: 15px; color: var(--green); }
.demo-preview { display: grid; grid-template-columns: minmax(0, .88fr) minmax(340px, 1.12fr); gap: 28px; align-items: center; margin-top: 34px; padding: clamp(22px, 4vw, 38px); border: 1px solid rgba(75, 159, 214, .29); border-radius: var(--radius-lg); background: radial-gradient(circle at 86% 20%, rgba(37, 125, 182, .2), transparent 23rem), linear-gradient(135deg, rgba(8, 29, 45, .92), rgba(10, 41, 62, .82)); box-shadow: var(--shadow-1); }
.demo-preview-copy h2 { max-width: 450px; margin: 0; font-size: clamp(25px, 3vw, 36px); line-height: 1.08; letter-spacing: -.045em; }
.demo-preview-copy > span { display: block; max-width: 450px; margin-top: 13px; color: var(--text-soft); line-height: 1.6; }
.preview-demo-button { margin-top: 20px; padding: 0 14px; border: 1px solid rgba(101, 192, 246, .44); background: rgba(14, 57, 85, .75); color: #d9ecf9; }
.demo-preview-report { padding: 18px; border: 1px solid rgba(113, 193, 239, .35); border-radius: var(--radius-md); background: rgba(4, 19, 31, .72); box-shadow: 0 14px 30px rgba(0,0,0,.16); }
.demo-preview-report header { display: flex; align-items: center; justify-content: space-between; gap: 14px; }
.demo-preview-report header > div { display: grid; gap: 4px; }
.demo-preview-report header span { color: var(--blue-soft); font-size: 10px; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; }
.demo-preview-report header strong { font: 13px ui-monospace, SFMono-Regular, Consolas, monospace; }
.demo-preview-report header > b { color: var(--green); font-size: 34px; letter-spacing: -.05em; }
.demo-preview-report header > b small { color: var(--muted); font-size: 12px; }
.preview-score-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 17px; }
.preview-score-grid div { padding: 11px; border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); background: rgba(12, 43, 63, .62); }
.preview-score-grid span { display: block; color: var(--muted); font-size: 10px; font-weight: 750; }
.preview-score-grid strong { display: block; margin-top: 4px; font-size: 22px; font-variant-numeric: tabular-nums; }
.preview-finding { display: grid; grid-template-columns: 30px minmax(0, 1fr); gap: 10px; margin-top: 12px; padding: 12px; border: 1px solid rgba(255, 202, 82, .28); border-radius: var(--radius-sm); background: rgba(73, 54, 16, .28); }
.preview-finding-icon { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 50%; background: rgba(190, 139, 24, .42); color: var(--amber); font-weight: 900; }
.preview-finding strong { font-size: 12px; }
.preview-finding p { margin: 4px 0 0; color: var(--text-soft); font-size: 11px; line-height: 1.45; }
.report-workspace { margin-top: 24px; min-width: 0; }
.report-context-bar { display: grid; grid-template-columns: minmax(180px, .72fr) minmax(0, 2fr); gap: 24px; align-items: center; padding: 18px 20px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: rgba(7, 27, 42, .72); }
.report-identity p { margin: 0 0 5px; color: var(--blue-soft); font-size: 10px; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; }
.report-identity h2 { margin: 0; overflow-wrap: anywhere; font: 700 22px/1.12 ui-monospace, SFMono-Regular, Consolas, monospace; letter-spacing: -.035em; }
.report-context-meta { display: grid; grid-template-columns: .9fr 1.5fr 1fr; gap: 16px; margin: 0; }
.report-context-meta div { min-width: 0; }
.report-context-meta dt { margin-bottom: 5px; color: var(--muted); font-size: 11px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
.report-context-meta dd { margin: 0; color: var(--text-soft); font-size: 12px; line-height: 1.4; overflow-wrap: anywhere; }
.address-list { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px !important; }
.workspace-host-form { grid-column: 1 / -1; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: end; padding-top: 14px; border-top: 1px solid var(--border-subtle); }
.workspace-host-form label { grid-column: 1 / -1; color: var(--muted); font-size: 10px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
.workspace-host-form input { min-width: 0; height: 37px; padding: 0 10px; border: 1px solid rgba(106, 177, 220, .42); border-radius: 8px; outline: none; background: rgba(4, 18, 30, .72); color: var(--text); }
.workspace-host-form button { min-height: 37px; padding: 0 12px; border: 1px solid var(--blue); border-radius: 8px; background: rgba(22, 84, 124, .82); color: white; font-weight: 750; }
.score-row { display: grid; grid-template-columns: minmax(280px, .8fr) minmax(0, 2.2fr); gap: 14px; margin-top: 14px; }
.category-score-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
.metric-card { min-width: 0; min-height: 138px; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 18px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: linear-gradient(160deg, rgba(12, 40, 60, .9), rgba(6, 23, 37, .92)); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .025); }
.metric-card.overall { min-height: 176px; padding: 23px; border-color: rgba(91, 189, 242, .4); background: radial-gradient(circle at 88% 18%, rgba(47, 158, 221, .17), transparent 11rem), linear-gradient(145deg, rgba(13, 49, 74, .96), rgba(6, 25, 39, .94)); }
.metric-card > div:first-child { display: flex; flex-direction: column; gap: 7px; min-width: 0; }
.metric-card span { color: var(--blue-soft); font-size: 12px; font-weight: 780; }
.metric-card strong { font-size: 31px; line-height: .95; letter-spacing: -.05em; font-variant-numeric: tabular-nums; }
.metric-card.overall strong { font-size: 52px; }
.metric-card strong small { color: var(--muted); font-size: 13px; font-weight: 600; letter-spacing: 0; }
.metric-card em { color: var(--text-soft); font-size: 11px; font-style: normal; line-height: 1.4; }
.metric-card.green em { color: var(--green); }
.metric-card.amber em { color: var(--amber); }
.metric-card.red em { color: var(--red); }
.metric-card.muted em { color: var(--muted); }
.gauge { position: relative; width: 62px; height: 62px; flex: 0 0 auto; border-radius: 50%; background: conic-gradient(var(--green) var(--gauge), rgba(72, 116, 140, .38) 0); }
.metric-card.overall .gauge { width: 86px; height: 86px; }
.gauge.amber { background: conic-gradient(var(--amber) var(--gauge), rgba(119, 92, 35, .5) 0); }
.gauge.red { background: conic-gradient(var(--red) var(--gauge), rgba(117, 53, 68, .56) 0); }
.gauge.muted { background: conic-gradient(#8ca8bd var(--gauge), rgba(72, 91, 109, .52) 0); }
.gauge > div { position: absolute; inset: 7px; border-radius: 50%; background: var(--surface-1); }
.report-status-row { min-height: 27px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 9px; color: var(--muted); font-size: 11px; }
.status-badge, .demo-badge { display: inline-flex; align-items: center; padding: 4px 8px; border: 1px solid rgba(119, 163, 191, .35); border-radius: 999px; background: rgba(39, 66, 83, .54); color: var(--text-soft); font-size: 11px; font-weight: 800; white-space: nowrap; }
.status-badge.complete, .status-badge.green { border-color: rgba(84, 232, 161, .35); background: rgba(18, 74, 54, .72); color: var(--green); }
.status-badge.partial, .status-badge.amber { border-color: rgba(255, 202, 82, .36); background: rgba(87, 66, 20, .6); color: var(--amber); }
.status-badge.failed, .status-badge.red { border-color: rgba(255, 119, 139, .38); background: rgba(91, 35, 47, .65); color: #ffbdc8; }
.status-badge.muted { color: var(--muted); }
.demo-badge { border-color: rgba(77, 179, 255, .38); background: rgba(18, 66, 96, .66); color: var(--blue-soft); }
.completeness-note { display: flex; gap: 8px; margin: 9px 0 0; padding: 10px 12px; border: 1px solid rgba(255, 202, 82, .3); border-radius: var(--radius-sm); background: rgba(80, 59, 18, .34); color: #ffe1a0; font-size: 12px; line-height: 1.45; }
.completeness-note.failed { border-color: rgba(255, 119, 139, .35); background: rgba(82, 30, 43, .36); color: #ffd2da; }
.report-tabs-bar { position: sticky; top: 72px; z-index: 9; min-height: 60px; display: flex; align-items: stretch; gap: 12px; margin-top: 14px; border-bottom: 1px solid var(--border-subtle); min-width: 0; background: rgba(6, 17, 31, .96); backdrop-filter: blur(14px); }
.report-tabs-bar nav { display: flex; flex: 0 1 auto; min-width: 0; overflow-x: auto; }
.report-tabs-bar nav button { min-width: 86px; padding: 0 14px; border: 0; border-bottom: 3px solid transparent; background: transparent; color: var(--muted); font-size: 12px; font-weight: 800; letter-spacing: .02em; }
.report-tabs-bar nav button:hover { color: var(--text); background: rgba(36, 88, 122, .28); }
.report-tabs-bar nav button.active { border-bottom-color: var(--blue); background: linear-gradient(180deg, rgba(31, 105, 151, .4), rgba(12, 40, 61, .5)); color: white; }
.report-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; white-space: nowrap; }
.report-actions button, .mobile-action-menu button { min-height: 35px; border: 1px solid rgba(96, 172, 217, .35); border-radius: 8px; background: rgba(13, 48, 72, .72); color: #d5e7f4; }
.report-actions button:hover, .mobile-action-menu button:hover { border-color: var(--blue); background: rgba(22, 79, 113, .9); }
.report-actions svg, .mobile-action-menu svg { width: 16px; }
.action-reinspect, .share-btn { display: flex; align-items: center; justify-content: center; gap: 7px; padding: 0 10px; }
.export-btn { padding: 0 9px; font-size: 11px; }
.mobile-action-menu { display: none; position: relative; margin-left: auto; }
.actions-toggle { min-width: 82px; padding: 0 11px; white-space: nowrap; }
.actions-popover { position: absolute; right: 0; z-index: 5; display: grid; gap: 6px; min-width: 180px; margin-top: 6px; padding: 8px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface-elevated); box-shadow: var(--shadow-2); }
.actions-popover button { justify-content: flex-start; width: 100%; }
.action-status { min-height: 18px; margin: 5px 0 0; color: var(--blue-soft); font-size: 11px; }
.manual-copy-panel { display: grid; gap: 9px; margin-top: 9px; padding: 14px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface-elevated); }
.manual-copy-panel > div { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.manual-copy-panel p { margin: 0; color: var(--text-soft); font-size: 13px; }
.manual-copy-panel button { min-height: 32px; padding: 0 10px; border: 1px solid var(--border-strong); border-radius: 7px; background: rgba(13, 48, 72, .72); color: var(--text); }
.manual-copy-panel textarea { width: 100%; min-height: 110px; padding: 10px; border: 1px solid var(--border-strong); border-radius: 7px; background: var(--surface-1); color: var(--text); font: 12px/1.5 ui-monospace, SFMono-Regular, Consolas, monospace; resize: vertical; }
.findings-panel { margin-top: 18px; padding: 17px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: rgba(7, 25, 39, .7); }
.findings-panel > header { display: flex; align-items: center; justify-content: space-between; gap: 14px; margin: 0 2px 12px; }
.findings-panel > header > div { display: flex; align-items: center; gap: 10px; }
.findings-panel h2 { margin: 0; font-size: 19px; letter-spacing: -.02em; }
.findings-panel header span { padding: 3px 9px; border-radius: 999px; background: rgba(35, 83, 112, .66); color: #c1ddeb; font-size: 10px; font-weight: 750; }
.findings-panel header button, .card-link, .show-all-button { border: 0; background: transparent; color: var(--blue-soft); font-size: 11px; font-weight: 700; }
.findings-panel header button:hover, .card-link:hover, .show-all-button:hover { color: white; text-decoration: underline; }
.finding-filters { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 2px 12px; }
.finding-filters button { padding: 5px 10px; border: 1px solid rgba(109, 174, 211, .26); border-radius: 999px; background: rgba(11, 43, 64, .66); color: var(--text-soft); font-size: 12px; }
.finding-filters button.active { border-color: var(--blue); background: rgba(27, 104, 151, .54); color: white; }
.findings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
.finding-card { width: 100%; min-height: 132px; display: grid; grid-template-columns: 40px minmax(0, 1fr) 16px; align-items: start; gap: 10px; padding: 14px; border: 1px solid rgba(103, 163, 197, .25); border-radius: var(--radius-sm); text-align: left; color: inherit; background: linear-gradient(145deg, rgba(10, 36, 54, .88), rgba(6, 23, 37, .84)); appearance: none; }
.finding-card:hover { border-color: rgba(111, 190, 234, .5); background: linear-gradient(145deg, rgba(13, 49, 73, .94), rgba(7, 29, 45, .92)); transform: translateY(-1px); }
.finding-card.critical { border-color: rgba(255, 119, 139, .45); }
.finding-card.warning { border-color: rgba(255, 202, 82, .42); }
.finding-card:focus-visible { outline: 2px solid #9edcff; outline-offset: 2px; }
.finding-icon { width: 30px; height: 30px; display: grid; place-items: center; margin-top: 2px; border-radius: 50%; font-size: 16px; font-weight: 900; }
.finding-icon.critical { background: rgba(150, 44, 65, .62); color: #ffd7de; }
.finding-icon.warning { background: rgba(126, 92, 21, .66); color: #ffe08b; }
.finding-icon.info { background: rgba(32, 105, 151, .62); color: #a5ddff; }
.finding-icon.good { background: rgba(24, 105, 74, .64); color: #91f4bd; }
.finding-title { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.finding-title strong { font-size: 14px; line-height: 1.28; }
.pill { display: inline-flex; align-items: center; padding: 3px 7px; border-radius: 999px; font-size: 10px; font-weight: 850; }
.pill.critical { background: rgba(101, 35, 49, .82); color: #ffc2cc; }
.pill.warning { background: rgba(95, 72, 18, .85); color: #ffdb70; }
.pill.info { background: rgba(18, 73, 108, .85); color: #7dd1ff; }
.pill.good { background: rgba(17, 79, 55, .85); color: #67edaa; }
.finding-body p { margin: 6px 0; color: var(--text-soft); font-size: 13px; line-height: 1.45; }
.remedy-box { padding: 8px 9px; border: 1px solid rgba(98, 159, 194, .22); border-radius: 7px; background: rgba(4, 18, 29, .45); color: #b7d3e5; font: 11px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; overflow-wrap: anywhere; }
.finding-chevron { align-self: center; color: var(--blue-soft); font-size: 24px; }
.show-all-button { display: block; margin: 12px auto 0; padding: 3px 8px; }
.empty-findings { margin: 0; padding: 18px 8px; color: var(--muted); }
.detail-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; min-width: 0; }
.detail-card, .evidence-card, .collector-state { min-width: 0; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: linear-gradient(155deg, rgba(10, 35, 53, .85), rgba(6, 22, 35, .84)); }
.detail-card { min-height: 278px; padding: 16px; }
.detail-card header, .single-detail > header, .evidence-card-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.detail-card h3, .evidence-card h3 { margin: 0; font-size: 15px; }
.evidence-card-heading p { margin: 0 0 5px; color: var(--blue-soft); font-size: 10px; font-weight: 800; letter-spacing: .09em; text-transform: uppercase; }
.summary-list { display: grid; gap: 0; margin: 13px 0 12px; }
.summary-list > div { display: grid; grid-template-columns: 112px minmax(0, 1fr); gap: 8px; padding: 7px 0; border-bottom: 1px solid var(--border-subtle); }
.summary-list b, .summary-list dt, .posture-list dt { color: var(--muted); font-size: 12px; font-weight: 650; }
.summary-list dd, .posture-list dd { margin: 0; color: #e1ebf3; font-size: 13px; overflow-wrap: anywhere; white-space: pre-line; }
.card-link { padding: 0; }
.value-good { color: var(--green) !important; }
.value-warning { color: var(--amber) !important; }
.value-bad { color: var(--red) !important; }
.value-muted, .value-neutral { color: var(--muted) !important; }
.single-detail { margin-top: 16px; padding: 20px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: rgba(7, 26, 40, .76); min-width: 0; }
.single-detail h2 { margin: 0; font-size: 22px; letter-spacing: -.03em; }
.single-detail > header p { margin: 5px 0 0; color: var(--muted); font-size: 12px; }
.evidence-layout { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-top: 17px; }
.http-evidence-layout { grid-template-columns: minmax(0, 1.2fr) minmax(320px, .8fr); }
.evidence-card { padding: 16px; }
.evidence-table { margin-top: 13px; border-top: 1px solid var(--border-subtle); }
.evidence-row { display: grid; grid-template-columns: 70px minmax(0, 1fr); gap: 10px; align-items: start; padding: 9px 0; border-bottom: 1px solid var(--border-subtle); }
.evidence-row > strong { color: #cfdfeb; font-size: 12px; }
.evidence-value { display: grid; grid-template-columns: minmax(0, 1fr) 28px; gap: 7px; min-width: 0; }
.evidence-value > span { color: #c8d6e2; font: 13px/1.5 ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre-line; overflow-wrap: anywhere; }
.evidence-value button, .dns-row button { width: 28px; height: 28px; padding: 5px; border: 0; border-radius: 6px; background: transparent; color: var(--blue-soft); }
.evidence-value button:hover, .dns-row button:hover { background: rgba(55, 126, 169, .38); color: white; }
.evidence-value svg { width: 16px; }
.posture-list { margin: 13px 0 0; }
.posture-list > div { display: grid; grid-template-columns: 130px minmax(0, 1fr); gap: 8px; padding: 8px 0; border-bottom: 1px solid var(--border-subtle); }
.collector-state { grid-column: 1 / -1; padding: 14px; }
.collector-state > div { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.collector-state strong { font-size: 13px; }
.collector-state ul { margin: 10px 0 0; padding-left: 18px; color: #ffcad3; font-size: 11px; line-height: 1.5; }
.collector-state p { margin: 8px 0 0; color: var(--muted); font-size: 12px; }
.status-good, .status-warning, .status-error, .status-neutral { display: inline-flex; padding: 4px 8px; border-radius: 999px; font-size: 10px; font-weight: 750; }
.status-good { color: var(--green); background: rgba(18, 74, 54, .78); }
.status-warning { color: #ffdc77; background: rgba(92, 70, 19, .7); }
.status-error { color: #ffc0cb; background: rgba(89, 35, 47, .78); }
.status-neutral { color: #d1dee9; background: rgba(66, 88, 106, .72); }
.empty-evidence { margin: 14px 0 0; color: var(--muted); font-size: 12px; line-height: 1.5; }
.attempt-list { display: grid; gap: 7px; margin: 14px 0; padding: 0; list-style: none; }
.attempt-list li { display: grid; gap: 4px; padding: 9px; border: 1px solid rgba(255, 119, 139, .32); border-radius: 8px; background: rgba(78, 24, 36, .4); }
.attempt-list strong { color: #ffced6; font: 12px ui-monospace, SFMono-Regular, Consolas, monospace; }
.attempt-list span { color: #deb3bc; font-size: 11px; }
.redirect-flow { display: grid; gap: 0; margin: 16px 0 0; padding: 0; list-style: none; }
.redirect-flow > li { list-style: none; }
.redirect-hop { display: grid; grid-template-columns: 30px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 11px; border: 1px solid rgba(96, 168, 208, .3); border-radius: var(--radius-sm); background: rgba(8, 36, 55, .75); }
.redirect-hop.red, .redirect-hop.error { border-color: rgba(255, 119, 139, .42); background: rgba(72, 27, 39, .56); }
.redirect-hop.amber { border-color: rgba(255, 202, 82, .38); }
.redirect-step { width: 27px; height: 27px; display: grid; place-items: center; border-radius: 50%; background: rgba(34, 108, 151, .7); color: #d8effe; font: 11px ui-monospace, SFMono-Regular, Consolas, monospace; }
.redirect-hop > div { display: grid; gap: 3px; min-width: 0; }
.redirect-protocol { color: var(--blue-soft); font-size: 10px; font-weight: 850; letter-spacing: .08em; }
.redirect-hop strong { color: #d9ebf7; font: 12px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; overflow-wrap: anywhere; }
.redirect-hop small { color: var(--muted); font: 11px ui-monospace, SFMono-Regular, Consolas, monospace; overflow-wrap: anywhere; }
.redirect-connector { height: 20px; display: grid; place-items: center; color: var(--blue); font-size: 16px; }
.status-chip { padding: 4px 7px; border-radius: 6px; font: 10px ui-monospace, SFMono-Regular, Consolas, monospace; }
.status-chip.green { color: var(--green); background: rgba(18, 86, 62, .84); }
.status-chip.amber { color: #ffe07f; background: rgba(89, 67, 18, .84); }
.status-chip.red, .status-chip.error { color: #ffc2cc; background: rgba(99, 37, 51, .86); }
.status-chip.muted { color: #d1dde6; background: rgba(60, 84, 101, .84); }
.redirect-facts { margin-top: 14px; }
.inline-alert { margin: 13px 0 0; padding: 9px 10px; border: 1px solid rgba(255, 119, 139, .38); border-radius: 8px; background: rgba(79, 27, 40, .46); font-size: 11px; }
.inline-alert.error { color: #ffd0d8; }
.header-matrix { display: grid; gap: 7px; margin-top: 16px; }
.header-matrix > div { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 10px; border: 1px solid var(--border-subtle); border-radius: 8px; background: rgba(5, 22, 35, .42); }
.header-matrix span { color: var(--text-soft); font-size: 12px; }
.header-matrix strong { font: 11px ui-monospace, SFMono-Regular, Consolas, monospace; text-align: right; overflow-wrap: anywhere; }
.header-details { margin-top: 14px; }
.header-details summary { color: var(--blue-soft); cursor: pointer; font-size: 11px; font-weight: 700; }
.header-details[open] summary { margin-bottom: 8px; }
.header-list { display: grid; gap: 5px; margin: 0; padding: 0; list-style: none; }
.header-list li { display: grid; grid-template-columns: 150px minmax(0, 1fr); gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--border-subtle); }
.header-list span { color: var(--muted); font-size: 11px; }
.header-list code { color: #cae4f4; font: 11px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; overflow-wrap: anywhere; }
.json-select { padding: 5px 9px; border: 1px solid rgba(95, 170, 215, .35); border-radius: 7px; background: rgba(12, 45, 67, .72); color: var(--text-soft); font: 10px ui-monospace, SFMono-Regular, Consolas, monospace; }
.single-detail > pre { max-width: 100%; max-height: 68vh; margin: 17px 0 0; padding: 16px; overflow: auto; border-radius: var(--radius-sm); white-space: pre-wrap; overflow-wrap: anywhere; background: #04121e; color: #b4daf4; font: 12px/1.55 ui-monospace, SFMono-Regular, Consolas, monospace; }
.http-status-line { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin: 13px 0 0; color: var(--text-soft); font-size: 12px; }
.status-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--muted); }
.status-dot.green { background: var(--green); }
.status-dot.amber { background: var(--amber); }
.status-dot.red { background: var(--red); }
.status-dot.muted { background: var(--muted); }
.drawer-backdrop { position: fixed; inset: 0; z-index: 20; display: flex; justify-content: flex-end; background: rgba(1, 7, 13, .76); backdrop-filter: blur(5px); }
.diagnostic-drawer { width: min(500px, 100%); height: 100%; overflow-y: auto; padding: 28px; border-left: 1px solid var(--border-strong); outline: none; background: linear-gradient(180deg, #0c2639, #071a2a); box-shadow: -24px 0 60px rgba(0,0,0,.42); }
.diagnostic-drawer > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; }
.drawer-kicker { margin: 0 0 7px; color: var(--blue-soft); font-size: 10px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
.diagnostic-drawer h2 { margin: 0; color: white; font-size: 26px; line-height: 1.14; letter-spacing: -.035em; }
.drawer-close { width: 38px; height: 38px; display: grid; place-items: center; flex: 0 0 auto; border: 1px solid rgba(109, 181, 220, .4); border-radius: 9px; background: rgba(13, 48, 72, .78); color: #c5deee; }
.drawer-close:hover { border-color: var(--blue); background: rgba(26, 79, 113, .92); }
.drawer-close svg { width: 18px; }
.drawer-summary { margin: 18px 0; color: var(--text-soft); line-height: 1.6; }
.drawer-facts { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; margin: 0 0 23px; }
.drawer-facts div { padding: 11px; border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); background: rgba(5, 22, 35, .55); }
.drawer-facts dt { color: var(--muted); font-size: 10px; letter-spacing: .08em; text-transform: uppercase; }
.drawer-facts dd { margin: 5px 0 0; color: var(--text); font-weight: 750; }
.diagnostic-drawer section { margin-top: 18px; padding-top: 18px; border-top: 1px solid var(--border-subtle); }
.diagnostic-drawer section h3 { margin: 0 0 10px; color: #dceaf4; font-size: 13px; }
.evidence-pointers { display: grid; gap: 7px; margin: 0; padding: 0; list-style: none; }
.evidence-pointers li { padding: 9px; border: 1px solid var(--border-subtle); border-radius: 7px; background: rgba(5, 22, 35, .54); }
.evidence-pointers code { color: var(--blue-soft); font: 12px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; overflow-wrap: anywhere; }
.drawer-note { margin: 12px 0 0; color: var(--muted); font-size: 11px; line-height: 1.45; }
.drawer-raw-button, .drawer-evidence-button { flex: 1; min-height: 43px; border: 1px solid #4db3ff; border-radius: 10px; background: linear-gradient(180deg, #2395e9, #167bc5); color: white; font-weight: 750; }
.drawer-raw-button { border-color: rgba(96, 172, 217, .35); background: rgba(13, 48, 72, .72); color: #d5e7f4; }
.drawer-actions { display: flex; gap: 9px; margin-top: 24px; }
/* Inspection workspace: dense evidence, one action surface and explicit state. */
.provenance-badge { display: inline-flex; align-items: center; padding: 4px 8px; border: 1px solid rgba(77, 179, 255, .38); border-radius: 999px; background: rgba(18, 66, 96, .66); color: var(--blue-soft); font-size: 10px; font-weight: 800; }
.previous-report-banner { margin-top: 10px; }
.inline-expand { margin-left: 7px; padding: 0; border: 0; background: transparent; color: var(--blue-soft); font-size: inherit; font-weight: 750; text-decoration: underline; }
.inspect-another-btn { padding: 0 10px; font-size: 11px; }
.export-control { position: relative; }
.export-popover { position: absolute; top: calc(100% + 6px); right: 0; z-index: 12; display: grid; gap: 5px; min-width: 132px; padding: 7px; border: 1px solid var(--border-strong); border-radius: 8px; background: var(--surface-elevated); box-shadow: var(--shadow-2); }
.export-popover button { width: 100%; padding: 7px 9px; text-align: left; }
.severity-counts { display: flex; flex-wrap: wrap; gap: 5px; margin: 0; }
.finding-impact { margin-left: auto; color: var(--amber); font: 700 12px ui-monospace, SFMono-Regular, Consolas, monospace; }
.finding-body small { color: var(--muted); font-size: 10px; font-weight: 700; letter-spacing: .04em; }
.finding-card { min-height: 98px; }
.finding-card .remedy-box { display: none; }
.evidence-card { border: 0; border-radius: 10px; background: var(--surface-evidence); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .025); }
.evidence-card:focus-visible, .evidence-highlight { outline: 2px solid var(--focus-ring); outline-offset: 3px; box-shadow: 0 0 0 5px rgba(77, 179, 255, .12); }
.header-matrix > div { display: grid; grid-template-columns: minmax(110px, .6fr) auto; align-items: start; }
.header-matrix small { grid-column: 1 / -1; color: var(--muted); font-size: 11px; line-height: 1.4; }
.raw-actions { display: flex; flex-wrap: wrap; gap: 7px; }
.raw-actions button { min-height: 33px; padding: 0 9px; border: 1px solid rgba(96, 172, 217, .35); border-radius: 8px; background: rgba(13, 48, 72, .72); color: #d5e7f4; font-size: 11px; }
.raw-actions button:hover { border-color: var(--blue); background: rgba(22, 79, 113, .9); }
.single-detail > pre { white-space: pre; overflow-wrap: normal; }
.single-detail > pre.wrap { white-space: pre-wrap; overflow-wrap: anywhere; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
button:focus-visible, input:focus-visible, a:focus-visible, [role="tab"]:focus-visible, summary:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
@keyframes inspection-pulse { 50% { filter: drop-shadow(0 0 10px rgba(77, 179, 255, .65)); transform: translateY(-1px); } }
@media (max-width: 1100px) {
.hero-workspace { grid-template-columns: 1fr; gap: 30px; }
.report-context-bar { grid-template-columns: 1fr; gap: 15px; }
.detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.detail-card:last-child { grid-column: 1 / -1; }
}
@media (max-width: 860px) {
.app-topbar { padding-inline: 22px; gap: 16px; }
.product-context { display: none; }
.cert-main { padding-inline: 22px; }
.report-context-meta { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.report-context-meta div:last-child { grid-column: 1 / -1; }
.score-row { grid-template-columns: 1fr; }
.category-score-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.findings-grid, .evidence-layout, .http-evidence-layout { grid-template-columns: 1fr; }
.collector-state { grid-column: auto; }
.report-actions { display: none; }
.mobile-action-menu { display: block; }
.security-boundary { grid-template-columns: 1fr; gap: 14px; }
}
@media (max-width: 620px) {
.app-topbar { min-height: 62px; padding-inline: 16px; }
.brand-lockup { min-width: 0; }
.brand-lockup strong { font-size: 21px; }
.brand-lockup small { font-size: 7px; }
.brand-mark { width: 29px; height: 33px; }
.repo-link { display: none; }
.cert-main { padding: 16px 16px 44px; }
.hero-workspace { min-height: 0; padding: 25px 18px; border-radius: 18px; }
.hero-copy h1 { font-size: 50px; }
.hero-statement { font-size: 23px; }
.hero-description { font-size: 14px; }
.inspect-cluster { padding: 17px; }
.inspect-actions { grid-template-columns: 1fr; }
.secondary-btn { width: 100%; }
.quick-hosts { gap: 6px; }
.inspection-progress { grid-template-columns: auto minmax(0, 1fr); padding: 14px; }
.progress-checks { grid-column: 1 / -1; }
.section-heading { align-items: flex-start; flex-direction: column; }
.capability-grid { grid-template-columns: 1fr; }
.capability-card { min-height: 126px; }
.demo-preview { grid-template-columns: 1fr; gap: 22px; padding: 22px 18px; border-radius: 18px; }
.report-context-bar { padding: 15px; }
.report-context-meta { grid-template-columns: 1fr; gap: 11px; }
.report-context-meta div:last-child { grid-column: auto; }
.workspace-host-form { grid-template-columns: 1fr; }
.category-score-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
.metric-card.overall { min-height: 122px; padding: 17px; }
.category-score-grid .metric-card { min-height: 98px; padding: 12px; }
.category-score-grid .metric-card strong { font-size: 25px; }
.category-score-grid .metric-card strong small { font-size: 10px; }
.category-score-grid .metric-card .gauge { display: none; }
.category-score-grid .metric-card em { font-size: 11px; }
.metric-card.overall strong { font-size: 44px; }
.metric-card.overall .gauge { width: 72px; height: 72px; }
.report-tabs-bar { top: 62px; gap: 7px; }
.report-tabs-bar nav button { min-width: 76px; padding-inline: 10px; }
.report-context-meta dd, .metric-card em, .summary-list dd, .posture-list dd { font-size: 13px; }
.summary-list dt, .posture-list dt, .finding-body small { font-size: 12px; }
.findings-panel { padding: 13px; }
.findings-panel > header { align-items: flex-start; flex-direction: column; }
.detail-grid { grid-template-columns: 1fr; }
.detail-card:last-child { grid-column: auto; }
.single-detail { padding: 15px; }
.single-detail > header { flex-direction: column; }
.summary-list > div, .posture-list > div { grid-template-columns: 98px minmax(0, 1fr); }
.evidence-row { grid-template-columns: 52px minmax(0, 1fr); }
.redirect-hop { grid-template-columns: 26px minmax(0, 1fr); }
.redirect-hop .status-chip { grid-column: 2; justify-self: start; }
.header-list li { grid-template-columns: 1fr; gap: 3px; }
.diagnostic-drawer { padding: 22px; border-left: 0; }
.diagnostic-drawer h2 { font-size: 22px; }
.drawer-actions { flex-direction: column; }
.header-matrix > div { grid-template-columns: 1fr auto; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
}
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 72" role="img" aria-label="CertScope shield">
<path fill="#42aaff" d="M32 3 58 12v20.4C58 48.8 47.4 61.8 32 69 16.6 61.8 6 48.8 6 32.4V12L32 3Z"/>
<path fill="none" stroke="#06111f" stroke-linecap="round" stroke-linejoin="round" stroke-width="7" d="m20.6 35.2 7.2 7.4 15.6-18"/>
</svg>

After

Width:  |  Height:  |  Size: 346 B

+22
View File
@@ -0,0 +1,22 @@
import "./globals.css";
import type { Metadata, Viewport } from "next";
export const metadata: Metadata = {
title: {
default: "CertScope — Domain posture inspector",
template: "%s · CertScope",
},
description: "Explainable DNS, TLS and HTTP posture inspection for one public hostname.",
applicationName: "CertScope",
icons: {
icon: [{ url: "/favicon.ico", sizes: "any" }, { url: "/icon.svg", type: "image/svg+xml" }],
},
};
export const viewport: Viewport = {
themeColor: "#07131f",
};
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return <html lang="en"><body>{children}</body></html>;
}
+124
View File
@@ -0,0 +1,124 @@
"use client";
import { FormEvent, useState } from "react";
import { ReportView } from "./report-view";
import { demoInspection } from "@/lib/inspect/demo";
import { isInspectionResult } from "@/lib/inspect/validation";
import type { InspectionResult } from "@/lib/inspect/types";
import { CERTSCOPE_VERSION } from "@/lib/version";
type ReportSource = "demo" | "live";
type ActiveReport = { result: InspectionResult; source: ReportSource };
type RequestState =
| { status: "idle" | "success" }
| { status: "loading"; requestedHost: string }
| { status: "error"; requestedHost: string; message: string };
function ShieldLogo() {
return <svg viewBox="0 0 32 36" aria-hidden="true"><path d="M16 1.5 29 6v10.2c0 8.2-5.3 14.7-13 18.3C8.3 30.9 3 24.4 3 16.2V6L16 1.5Z" fill="currentColor" opacity=".95"/><path d="m10.3 17.6 3.6 3.7 7.8-9" fill="none" stroke="#06111f" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"/></svg>;
}
function SearchIcon() {
return <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="6.5" fill="none" stroke="currentColor" strokeWidth="2"/><path d="m16 16 4 4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/></svg>;
}
function CheckIcon() {
return <svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12.5 4.2 4.2L19.5 6.5" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/></svg>;
}
function ArrowIcon() {
return <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h13m-5-5 5 5-5 5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/></svg>;
}
const capabilities = [
{ label: "DNS", detail: "A / AAAA, MX / NS, SPF and DMARC", icon: "01" },
{ label: "TLS", detail: "Trust, identity, expiry and connection", icon: "02" },
{ label: "HTTP", detail: "Redirect safety, HSTS, CSP and headers", icon: "03" },
];
const securityBoundaries = ["Public hosts only", "Ports 80 / 443", "Redirects revalidated", "Bounded deadlines", "No scan history"];
const unavailableMessage = "Inspection service is temporarily unavailable. Try again shortly.";
export default function Home() {
const [host, setHost] = useState("");
const [report, setReport] = useState<ActiveReport | null>(null);
const [request, setRequest] = useState<RequestState>({ status: "idle" });
const loading = request.status === "loading";
async function runInspection(rawHost: string) {
const requestedHost = rawHost.trim();
if (!requestedHost) return;
setRequest({ status: "loading", requestedHost });
try {
const response = await fetch(`/api/inspect?host=${encodeURIComponent(requestedHost)}`, { cache: "no-store" });
const body = await response.json().catch(() => null) as unknown;
if (!response.ok) {
const message = typeof body === "object" && body !== null && "error" in body && typeof body.error === "string"
? body.error
: unavailableMessage;
throw new Error(message);
}
if (!isInspectionResult(body)) throw new Error("Inspection returned an invalid response");
setReport({ result: body, source: "live" });
setHost(body.host);
setRequest({ status: "success" });
} catch (error) {
const message = error instanceof Error ? error.message : unavailableMessage;
setRequest({ status: "error", requestedHost, message });
}
}
function inspect(event: FormEvent) {
event.preventDefault();
void runInspection(host);
}
function loadDemo() {
setReport({ result: demoInspection, source: "demo" });
setRequest({ status: "idle" });
}
const demoWarning = demoInspection.findings.find((finding) => finding.severity === "warning") ?? demoInspection.findings[0];
const landingError = request.status === "error" ? request.message : "";
return <main className="cert-app-shell" aria-busy={loading}>
<header className="app-topbar">
<div className="brand-lockup"><span className="brand-mark"><ShieldLogo /></span><div><strong>CertScope</strong><small>DOMAIN POSTURE INSPECTOR</small></div></div>
<div className="product-context"><strong>DNS · TLS · HTTP</strong><span>Evidence-led checks for public infrastructure</span></div>
<div className="topbar-right"><span className="version-chip">v{CERTSCOPE_VERSION}</span><a className="repo-link" href="https://gitea.itworx.tech/Jens/CertScope-Public" target="_blank" rel="noreferrer">Repository</a></div>
</header>
<section className="cert-main">
{report ? <ReportView key={report.result.host + report.result.inspectedAt + report.source}
result={report.result}
source={report.source}
isLoading={loading}
requestedHost={request.status === "loading" ? request.requestedHost : null}
requestError={request.status === "error" ? request : null}
onReinspect={(currentHost) => void runInspection(currentHost)}
onInspectAnother={(nextHost) => void runInspection(nextHost)}
/> : <>
<section className="hero-workspace" aria-labelledby="certscope-title">
<div className="hero-copy">
<p className="tagline">Smaller risks. A more open internet.</p>
<p className="hero-kicker"><span aria-hidden="true">◆</span> Explainable domain inspection</p>
<h1 id="certscope-title">CertScope</h1>
<p className="hero-statement">Public infrastructure posture, made explainable.</p>
<p className="hero-description">Inspect the DNS, TLS and HTTP posture of any public hostname. Every score change points back to evidence, not a black box.</p>
<div className="hero-signals" aria-label="Inspection characteristics"><span>Single-host</span><span>Deterministic</span><span>Evidence-led</span></div>
</div>
<form onSubmit={inspect} className="inspect-cluster" aria-label="Inspect a hostname">
<div className="inspect-card-heading"><div><p>Start an inspection</p><strong>Inspect a hostname</strong></div><span className="inspect-card-status">Public targets only</span></div>
<div className="host-field-wrap"><label htmlFor="hostname">Public hostname</label><input id="hostname" value={host} onChange={(event) => setHost(event.target.value)} placeholder="example.com" autoComplete="off" spellCheck={false} aria-describedby="hostname-help" /><small id="hostname-help">Enter a hostname, not a URL or IP address.</small></div>
<div className="quick-hosts" aria-label="Example hostnames">{["example.com", "github.com", "cloudflare.com"].map((example) => <button type="button" key={example} onClick={() => setHost(example)} aria-label={"Use " + example}>{example}</button>)}</div>
<div className="inspect-actions"><button type="submit" className="primary-btn" disabled={loading || !host.trim()}><SearchIcon />{loading ? "Inspecting…" : "Inspect"}</button><button type="button" className="secondary-btn" onClick={loadDemo} disabled={loading}>Load demo <ArrowIcon /></button></div>
<p className="inspect-card-note">DNS, TLS and HTTP checks stay bounded; no scan history is stored.</p>
{landingError && <div className="error-banner" role="alert"><strong>Inspection unavailable</strong><span>{landingError}</span><small>Adjust the hostname and try again.</small></div>}
</form>
</section>
{loading && <section className="inspection-progress" role="status" aria-live="polite"><div className="progress-orb" aria-hidden="true"><ShieldLogo /></div><div><strong>Inspecting DNS, TLS and HTTP posture…</strong><p>Collecting bounded evidence for <code>{host.trim()}</code>. Existing evidence will appear as soon as the report is ready.</p></div><div className="progress-checks" aria-hidden="true"><span>Resolve</span><span>Verify</span><span>Explain</span></div></section>}
<section className="capability-section" aria-labelledby="capabilities-title"><div className="section-heading"><div><p>What CertScope checks</p><h2 id="capabilities-title">One clear report across the public-facing stack.</h2></div><span>Evidence, not guesswork</span></div><div className="capability-grid">{capabilities.map((capability) => <article className="capability-card" key={capability.label}><span className="capability-index" aria-hidden="true">{capability.icon}</span><h3>{capability.label}</h3><p>{capability.detail}</p></article>)}</div></section>
<section className="security-boundary" aria-label="Inspection security boundary"><div><p>Safe by design</p><strong>Strong boundaries around every connection.</strong></div><ul>{securityBoundaries.map((boundary) => <li key={boundary}><CheckIcon />{boundary}</li>)}</ul></section>
<section className="demo-preview" aria-labelledby="demo-preview-title"><div className="demo-preview-copy"><p>Deterministic demo</p><h2 id="demo-preview-title">See the report before you inspect.</h2><span>The built-in fixture is local, reproducible and ready to explore without a network request.</span><button type="button" className="preview-demo-button" onClick={loadDemo}>Open demo report <ArrowIcon /></button></div><div className="demo-preview-report" aria-label="Preview of the deterministic demo report"><header><div><span>Demo preview</span><strong>{demoInspection.host}</strong></div><b>{demoInspection.score.overall}<small>/100</small></b></header><div className="preview-score-grid">{(["dns", "tls", "http"] as const).map((category) => <div key={category}><span>{category.toUpperCase()}</span><strong>{demoInspection.score.categories[category]}</strong></div>)}</div><div className="preview-finding"><span className="preview-finding-icon">!</span><div><strong>{demoWarning.title}</strong><p>{demoWarning.summary}</p></div></div></div></section>
</>}
</section>
</main>;
}
+273
View File
@@ -0,0 +1,273 @@
"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>}
</>;
}