CSP violation reports: ingest endpoint, ClickHouse storage, UI view
- POST /v1/csp collects browser CSP reports in both wire formats (legacy application/csp-report and Reporting API application/reports+json), with CORS limited to the operator's domains (any subdomain of k6n.net, tornberg.me, knatofs.se) and preflight handling. - Reports are stored in a dedicated typed ClickHouse table (csp_reports, 90-day TTL) with a JSONL file fallback (OTEL_CSP_PATH). - New authenticated view endpoints /api/csp/reports and /api/csp/summary plus a 'CSP reports' screen in the UI (tab 12) with time window, site and directive filters and report drill-down. - Documented in instrumentation.md; unit tests for parsing/handlers/file store and an env-gated ClickHouse integration test.
This commit is contained in:
1 parent
9685de64da
commit
36f00d49b9
16 files changed
+1424
-1
No files matched your search
@@ -0,0 +1,249 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { RefreshCw, ShieldAlert } from "lucide-react"
|
||||
import { useCspReports, useCspSummary } from "../hooks/useCspReports"
|
||||
import { useObservabilityData } from "../hooks/useObservabilityData"
|
||||
import { formatRelativeTime } from "../lib/timeUtils"
|
||||
import { URLLowerHost } from "../lib/csp-url"
|
||||
import { Segmented } from "./common/Segmented"
|
||||
import { DetailField, SectionLabel } from "./common/DetailField"
|
||||
import { FilterBar, type FilterChip } from "./FilterBar"
|
||||
import { MasterDetail } from "./MasterDetail"
|
||||
import type { CspReport } from "../lib/types"
|
||||
|
||||
type Window = "24h" | "7d" | "30d" | "all"
|
||||
|
||||
const WINDOW_HOURS: Record<Window, number | null> = { "24h": 24, "7d": 24 * 7, "30d": 24 * 30, all: null }
|
||||
|
||||
const dateTime = (iso: string) =>
|
||||
new Date(iso).toLocaleString([], {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
})
|
||||
|
||||
/** Neutral directive badge; tone only escalates for enforced blocks. */
|
||||
const DirectiveBadge = ({ report }: { report: CspReport }) => {
|
||||
const directive = report.effectiveDirective || report.violatedDirective || "unknown"
|
||||
const enforced = report.disposition === "enforce"
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex shrink-0 items-center gap-1 whitespace-nowrap rounded-[6px] border px-1.5 py-0.5 font-[family-name:var(--font-data)] text-[10.5px] ${
|
||||
enforced
|
||||
? "border-ds-warn/40 bg-ds-warn-bg text-ds-warn"
|
||||
: "border-ds-line-strong bg-ds-surface-2 text-ds-text-2"
|
||||
}`}
|
||||
>
|
||||
{directive}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const StatCard = ({ label, value }: { label: string; value: string | number }) => (
|
||||
<div className="flex min-w-0 flex-col gap-1 rounded-[14px] border border-ds-line bg-ds-surface px-4 py-3">
|
||||
<span className="truncate font-[family-name:var(--font-data)] text-[22px] leading-tight text-ds-text">{value}</span>
|
||||
<span className="text-[11px] uppercase tracking-[0.08em] text-ds-text-3">{label}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
const CspListItem = ({ report }: { report: CspReport }) => (
|
||||
<div className="flex w-full flex-col gap-1 px-3 py-2 text-left">
|
||||
<div className="flex items-center gap-2">
|
||||
<DirectiveBadge report={report} />
|
||||
<span className="min-w-0 flex-1 truncate font-[family-name:var(--font-data)] text-[12px] text-ds-text">
|
||||
{URLLowerHost(report.blockedUrl ?? "") || report.blockedUrl || "(no blocked URL)"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[11px] text-ds-text-3">
|
||||
<span className="truncate">{report.site}</span>
|
||||
<span className="shrink-0 font-[family-name:var(--font-data)]">{formatRelativeTime(Date.parse(report.timestamp))}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const CspReportDetail = ({ report }: { report: CspReport }) => (
|
||||
<div className="flex flex-col gap-3 overflow-y-auto p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<DirectiveBadge report={report} />
|
||||
{report.disposition && (
|
||||
<span className="rounded-[6px] border border-ds-line-strong bg-ds-surface-2 px-1.5 py-0.5 font-[family-name:var(--font-data)] text-[10.5px] text-ds-text-2">
|
||||
{report.disposition}
|
||||
</span>
|
||||
)}
|
||||
{report.reportType && (
|
||||
<span className="rounded-[6px] border border-ds-line-strong bg-ds-surface-2 px-1.5 py-0.5 font-[family-name:var(--font-data)] text-[10.5px] text-ds-text-2">
|
||||
{report.reportType}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto font-[family-name:var(--font-data)] text-[11px] text-ds-text-3">{dateTime(report.timestamp)}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<SectionLabel>Violation</SectionLabel>
|
||||
<dl className="mt-1.5 grid grid-cols-1 gap-2 rounded-[9px] border border-ds-fill bg-ds-surface-2 p-3 text-[12px]">
|
||||
<DetailField label="Blocked URL" value={report.blockedUrl} />
|
||||
<DetailField label="Document" value={report.documentUrl} />
|
||||
<DetailField label="Referrer" value={report.referrer} />
|
||||
{report.violatedDirective && report.violatedDirective !== report.effectiveDirective && (
|
||||
<DetailField label="Violated directive" value={report.violatedDirective} />
|
||||
)}
|
||||
{report.statusCode ? <DetailField label="Document status" value={String(report.statusCode)} /> : null}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{report.sample && (
|
||||
<div>
|
||||
<SectionLabel>Script sample</SectionLabel>
|
||||
<pre className="mt-1.5 overflow-x-auto whitespace-pre-wrap break-all rounded-[8px] border border-ds-line bg-ds-surface-2 p-2 font-[family-name:var(--font-data)] text-[11.5px] text-ds-text-2">
|
||||
{report.sample}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report.originalPolicy && (
|
||||
<div>
|
||||
<SectionLabel>Policy</SectionLabel>
|
||||
<pre className="mt-1.5 max-h-48 overflow-auto whitespace-pre-wrap break-all rounded-[8px] border border-ds-line bg-ds-surface-2 p-2 font-[family-name:var(--font-data)] text-[11.5px] text-ds-text-2">
|
||||
{report.originalPolicy}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<SectionLabel>Context</SectionLabel>
|
||||
<dl className="mt-1.5 grid grid-cols-1 gap-2 rounded-[9px] border border-ds-fill bg-ds-surface-2 p-3 text-[12px]">
|
||||
<DetailField label="Site" value={report.site} />
|
||||
<DetailField label="User agent" value={report.userAgent} />
|
||||
<DetailField label="Source IP" value={report.sourceIP} />
|
||||
<DetailField label="Report id" value={report.id} />
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export function CspReportsVisualizer() {
|
||||
const [window, setWindow] = useState<Window>("7d")
|
||||
const [site, setSite] = useState<string | null>(null)
|
||||
const [directive, setDirective] = useState<string | null>(null)
|
||||
const { searchQuery, setSearchQuery } = useObservabilityData()
|
||||
|
||||
const hours = WINDOW_HOURS[window]
|
||||
const { reports, loading, error, refresh } = useCspReports({ site: site ?? undefined, directive: directive ?? undefined, hours: hours ?? undefined })
|
||||
const { summary } = useCspSummary(hours ?? 24 * 365)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = searchQuery.trim().toLowerCase()
|
||||
if (!q) return reports
|
||||
return reports.filter((r) =>
|
||||
`${r.site} ${r.documentUrl} ${r.blockedUrl ?? ""} ${r.referrer ?? ""} ${r.sample ?? ""} ${r.effectiveDirective ?? ""}`
|
||||
.toLowerCase()
|
||||
.includes(q),
|
||||
)
|
||||
}, [reports, searchQuery])
|
||||
|
||||
const selected = filtered[0] ?? null
|
||||
|
||||
const chip = (id: string, filterKey: string, value: string, onRemove: () => void): FilterChip => ({ id, filterKey, value, onRemove })
|
||||
const chips: FilterChip[] = [
|
||||
...(searchQuery ? [chip("query", "query", searchQuery, () => setSearchQuery(""))] : []),
|
||||
...(site ? [chip("site", "site", site, () => setSite(null))] : []),
|
||||
...(directive ? [chip("directive", "directive", directive, () => setDirective(null))] : []),
|
||||
]
|
||||
const addFilterOptions = [
|
||||
...(summary?.bySite ?? []).filter((s) => s.name !== site).slice(0, 6).map((s) => ({ value: `site:${s.name}`, label: `site=${s.name} (${s.count})` })),
|
||||
...(summary?.byDirective ?? []).filter((d) => d.name && d.name !== directive).slice(0, 8).map((d) => ({ value: `dir:${d.name}`, label: `directive=${d.name} (${d.count})` })),
|
||||
]
|
||||
const onAddFilter = (value: string) => {
|
||||
if (value.startsWith("site:")) setSite(value.slice(5))
|
||||
else if (value.startsWith("dir:")) setDirective(value.slice(4))
|
||||
}
|
||||
const clearAll = () => {
|
||||
setSearchQuery("")
|
||||
setSite(null)
|
||||
setDirective(null)
|
||||
}
|
||||
const matchText = `Matching ${filtered.length}${summary ? ` of ${summary.total}` : ""} reports · ${chips.length} filter${chips.length === 1 ? "" : "s"}`
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-[14px] border border-dashed border-ds-line py-20 text-center">
|
||||
<ShieldAlert className="h-8 w-8 text-ds-text-3" aria-hidden="true" />
|
||||
<p className="text-[14px] font-medium text-ds-text">CSP reports unavailable</p>
|
||||
<p className="max-w-sm text-[12.5px] text-ds-text-3">Reports storage is not configured on the collector.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (loading && reports.length === 0) {
|
||||
return <div className="py-16 text-center text-sm text-ds-text-3">Loading CSP reports…</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<StatCard label="Reports in window" value={summary ? summary.total.toLocaleString() : "—"} />
|
||||
<StatCard label="Sites reporting" value={summary ? summary.sites.toLocaleString() : "—"} />
|
||||
<StatCard label="Top directive" value={summary?.byDirective[0]?.name ?? "—"} />
|
||||
<StatCard label="Top blocked host" value={summary?.byBlockedHost[0]?.name ?? "—"} />
|
||||
</div>
|
||||
|
||||
<FilterBar
|
||||
chips={chips}
|
||||
addFilterOptions={addFilterOptions.length > 0 ? addFilterOptions : undefined}
|
||||
onAddFilter={addFilterOptions.length > 0 ? onAddFilter : undefined}
|
||||
matchText={matchText}
|
||||
onClearAll={chips.length > 0 ? clearAll : undefined}
|
||||
/>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-[14px] border border-ds-line bg-ds-surface py-16 text-center">
|
||||
<ShieldAlert className="h-6 w-6 text-ds-text-3" aria-hidden="true" />
|
||||
<p className="text-[13px] text-ds-text-3">
|
||||
{reports.length === 0
|
||||
? "No CSP reports recorded yet. Point report-uri at /v1/csp on the collector."
|
||||
: "No reports match your filters."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<MasterDetail
|
||||
items={filtered}
|
||||
getKey={(r) => r.id}
|
||||
selectedKey={selected?.id ?? null}
|
||||
onSelect={() => {}}
|
||||
label="CSP reports"
|
||||
storageKey="csp-reports"
|
||||
listSize={{ default: 380, min: 300, max: 560 }}
|
||||
listHeader={
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-[11px] font-bold uppercase tracking-[0.1em] text-ds-text-3">CSP violations</h2>
|
||||
<Segmented
|
||||
label="Report time window"
|
||||
value={window}
|
||||
onChange={setWindow}
|
||||
options={[
|
||||
{ value: "24h", label: "24h" },
|
||||
{ value: "7d", label: "7d" },
|
||||
{ value: "30d", label: "30d" },
|
||||
{ value: "all", label: "All" },
|
||||
]}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refresh()}
|
||||
aria-label="Refresh CSP reports"
|
||||
title="Refresh CSP reports"
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-[7px] text-ds-text-3 hover:bg-ds-fill hover:text-ds-text-2"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
renderItem={(report) => <CspListItem report={report} />}
|
||||
renderDetail={(report) => <CspReportDetail report={report} />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CspReportsVisualizer
|
||||
@@ -29,6 +29,7 @@ import { TelemetryFilters } from "./TelemetryFilters"
|
||||
import { PatternsVisualizer } from "./PatternsVisualizer"
|
||||
import { RolloutsVisualizer } from "./RolloutsVisualizer"
|
||||
import { SourceIpsVisualizer } from "./SourceIpsVisualizer"
|
||||
import { CspReportsVisualizer } from "./CspReportsVisualizer"
|
||||
import { TodosVisualizer } from "./TodosVisualizer"
|
||||
|
||||
|
||||
@@ -354,6 +355,9 @@ export const TabsComponent = () => {
|
||||
<Tabs.Content className="outline-none py-2" value="tab10">
|
||||
{activeTab === "tab10" && <ViewErrorBoundary><SourceIpsVisualizer /></ViewErrorBoundary>}
|
||||
</Tabs.Content>
|
||||
<Tabs.Content className="outline-none py-2" value="tab12">
|
||||
{activeTab === "tab12" && <ViewErrorBoundary><CspReportsVisualizer /></ViewErrorBoundary>}
|
||||
</Tabs.Content>
|
||||
<Tabs.Content className="outline-none py-2" value="tab11">
|
||||
{activeTab === "tab11" && <ViewErrorBoundary><TodosVisualizer /></ViewErrorBoundary>}
|
||||
</Tabs.Content>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ListTodo,
|
||||
Radar,
|
||||
ScrollText,
|
||||
Shield,
|
||||
TrendingUp,
|
||||
Waypoints,
|
||||
Wrench,
|
||||
@@ -65,6 +66,7 @@ export const IconRail: React.FC = () => {
|
||||
{ tab: "tab2", label: "Metrics & APM", Icon: LineChart },
|
||||
{ tab: "tab7", label: "RUM sessions", Icon: Globe },
|
||||
{ tab: "tab10", label: "Source IPs", Icon: Fingerprint },
|
||||
{ tab: "tab12", label: "CSP reports", Icon: Shield },
|
||||
{ tab: "tab11", label: "Todos from the site", Icon: ListTodo },
|
||||
...(statsAvailable ? [{ tab: "tab5", label: "Historical trends", Icon: TrendingUp }] : []),
|
||||
{ tab: "tab6", label: "Archive & trap", Icon: Radar },
|
||||
|
||||
Reference in new issue
Block a user