Initial curated CertScope source from fe2bbe3ca5c98bf64be26a09c6c58ee805a7f87e
This commit is contained in:
commit
7348faf6c0
65 files changed
+12509
No files matched your search
@@ -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>;
|
||||
}
|
||||
@@ -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></>;
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
Reference in new issue
Block a user