feat(phase-29): add compliance framework mapping, reports and UI (T-227 to T-229)

This commit is contained in:
2026-02-09 18:41:24 +01:00
parent 12f33307fd
commit 2ac8e7f4a5
12 changed files with 1516 additions and 0 deletions

View File

@@ -4,6 +4,7 @@ import DashboardPage from "./pages/DashboardPage";
import TechniquesPage from "./pages/TechniquesPage";
import MatrixPage from "./pages/MatrixPage";
import ExecutiveDashboardPage from "./pages/ExecutiveDashboardPage";
import CompliancePage from "./pages/CompliancePage";
import TechniqueDetailPage from "./pages/TechniqueDetailPage";
import TestsPage from "./pages/TestsPage";
import TestCreatePage from "./pages/TestCreatePage";
@@ -57,6 +58,7 @@ export default function App() {
<Route path="/threat-actors/:actorId" element={<ThreatActorDetailPage />} />
<Route path="/campaigns" element={<CampaignsPage />} />
<Route path="/campaigns/:campaignId" element={<CampaignDetailPage />} />
<Route path="/compliance" element={<CompliancePage />} />
<Route
path="/system"
element={

View File

@@ -0,0 +1,116 @@
import client from "./client";
// ── Types ────────────────────────────────────────────────────────────
export interface ComplianceFrameworkSummary {
id: string;
name: string;
version: string | null;
description: string | null;
url: string | null;
is_active: boolean;
controls_count: number;
}
export interface ComplianceTechniqueInfo {
mitre_id: string;
name: string;
score: number;
status: string;
}
export interface ComplianceControlStatus {
control_id: string;
title: string;
category: string | null;
status: "covered" | "partially_covered" | "not_covered" | "not_evaluated";
score: number;
techniques_count: number;
techniques_covered: number;
techniques: ComplianceTechniqueInfo[];
}
export interface ComplianceSummary {
total_controls: number;
covered: number;
partially_covered: number;
not_covered: number;
not_evaluated: number;
compliance_percentage: number;
}
export interface ComplianceFrameworkStatus {
framework: { id: string; name: string };
summary: ComplianceSummary;
controls: ComplianceControlStatus[];
}
export interface ComplianceGapTechnique extends ComplianceTechniqueInfo {
templates_available: number;
threat_actors_using: number;
}
export interface ComplianceGap {
control_id: string;
title: string;
category: string | null;
status: string;
score: number;
uncovered_techniques: ComplianceGapTechnique[];
}
export interface ComplianceGapsResponse {
framework: { id: string; name: string };
total_gaps: number;
gaps: ComplianceGap[];
}
// ── API Functions ────────────────────────────────────────────────────
/** List all available compliance frameworks. */
export async function getComplianceFrameworks(): Promise<ComplianceFrameworkSummary[]> {
const { data } = await client.get<ComplianceFrameworkSummary[]>("/compliance/frameworks");
return data;
}
/** Get compliance status for a framework. */
export async function getFrameworkStatus(
frameworkId: string,
): Promise<ComplianceFrameworkStatus> {
const { data } = await client.get<ComplianceFrameworkStatus>(
`/compliance/frameworks/${frameworkId}/status`,
);
return data;
}
/** Get compliance gaps for a framework. */
export async function getFrameworkGaps(
frameworkId: string,
): Promise<ComplianceGapsResponse> {
const { data } = await client.get<ComplianceGapsResponse>(
`/compliance/frameworks/${frameworkId}/gaps`,
);
return data;
}
/** Download CSV report for a framework. */
export async function downloadComplianceCSV(frameworkId: string): Promise<void> {
const { data } = await client.get(`/compliance/frameworks/${frameworkId}/report/csv`, {
responseType: "blob",
});
const blob = new Blob([data], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "compliance_report.csv";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/** Import NIST 800-53 mappings (admin). */
export async function importNistMappings(): Promise<Record<string, unknown>> {
const { data } = await client.post("/compliance/import/nist-800-53");
return data;
}

View File

@@ -17,6 +17,7 @@ import {
Zap,
Grid3X3,
Gauge,
ShieldCheck,
} from "lucide-react";
import { useAuth } from "../context/AuthContext";
@@ -45,6 +46,7 @@ const mainLinks: NavItem[] = [
{ to: "/reports", label: "Reports", icon: BarChart3 },
{ to: "/threat-actors", label: "Threat Actors", icon: Crosshair },
{ to: "/campaigns", label: "Campaigns", icon: Zap },
{ to: "/compliance", label: "Compliance", icon: ShieldCheck },
];
const adminLinks: NavItem[] = [

View File

@@ -0,0 +1,62 @@
interface ComplianceGaugeProps {
percentage: number;
size?: "sm" | "md" | "lg";
}
export default function ComplianceGauge({
percentage,
size = "md",
}: ComplianceGaugeProps) {
const getColor = (p: number) => {
if (p < 30) return "#ef4444";
if (p < 50) return "#f97316";
if (p < 70) return "#eab308";
return "#22c55e";
};
const color = getColor(percentage);
const sizes = {
sm: { outer: 64, radius: 26, stroke: 5, text: "text-lg", label: "text-[8px]" },
md: { outer: 96, radius: 40, stroke: 6, text: "text-2xl", label: "text-[10px]" },
lg: { outer: 128, radius: 54, stroke: 8, text: "text-3xl", label: "text-xs" },
};
const s = sizes[size];
const circumference = 2 * Math.PI * s.radius;
const strokeDasharray = `${(percentage / 100) * circumference} ${circumference}`;
const viewBox = `0 0 ${s.outer + 4} ${s.outer + 4}`;
const center = (s.outer + 4) / 2;
return (
<div className="relative inline-flex items-center justify-center" style={{ width: s.outer + 4, height: s.outer + 4 }}>
<svg className="-rotate-90" viewBox={viewBox} width={s.outer + 4} height={s.outer + 4}>
<circle
cx={center}
cy={center}
r={s.radius}
fill="none"
stroke="#1f2937"
strokeWidth={s.stroke}
/>
<circle
cx={center}
cy={center}
r={s.radius}
fill="none"
stroke={color}
strokeWidth={s.stroke}
strokeLinecap="round"
strokeDasharray={strokeDasharray}
className="transition-all duration-700"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className={`font-bold text-white ${s.text}`}>
{Math.round(percentage)}
</span>
<span className={`text-gray-500 ${s.label}`}>%</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,216 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { ChevronDown, ChevronRight, Search, Filter } from "lucide-react";
import type { ComplianceControlStatus } from "../../api/compliance";
interface ControlsTableProps {
controls: ComplianceControlStatus[];
}
const STATUS_COLORS: Record<string, { bg: string; text: string; dot: string }> = {
covered: { bg: "bg-green-500/10", text: "text-green-400", dot: "bg-green-500" },
partially_covered: { bg: "bg-yellow-500/10", text: "text-yellow-400", dot: "bg-yellow-500" },
not_covered: { bg: "bg-red-500/10", text: "text-red-400", dot: "bg-red-500" },
not_evaluated: { bg: "bg-gray-500/10", text: "text-gray-400", dot: "bg-gray-500" },
};
const STATUS_LABELS: Record<string, string> = {
covered: "Covered",
partially_covered: "Partial",
not_covered: "Not Covered",
not_evaluated: "Not Evaluated",
};
export default function ControlsTable({ controls }: ControlsTableProps) {
const navigate = useNavigate();
const [expandedId, setExpandedId] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<string>("all");
const [categoryFilter, setCategoryFilter] = useState<string>("all");
const [search, setSearch] = useState("");
// Extract unique categories
const categories = [...new Set(controls.map((c) => c.category).filter(Boolean))] as string[];
// Apply filters
const filteredControls = controls.filter((c) => {
if (statusFilter !== "all" && c.status !== statusFilter) return false;
if (categoryFilter !== "all" && c.category !== categoryFilter) return false;
if (search) {
const q = search.toLowerCase();
return (
c.control_id.toLowerCase().includes(q) ||
c.title.toLowerCase().includes(q)
);
}
return true;
});
const toggleExpand = (controlId: string) => {
setExpandedId(expandedId === controlId ? null : controlId);
};
return (
<div>
{/* Filters row */}
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-gray-400" />
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-xs text-gray-200 focus:border-cyan-500 focus:outline-none"
>
<option value="all">All Statuses</option>
<option value="covered">Covered</option>
<option value="partially_covered">Partial</option>
<option value="not_covered">Not Covered</option>
<option value="not_evaluated">Not Evaluated</option>
</select>
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-xs text-gray-200 focus:border-cyan-500 focus:outline-none"
>
<option value="all">All Categories</option>
{categories.map((cat) => (
<option key={cat} value={cat}>
{cat}
</option>
))}
</select>
<div className="relative flex-1 max-w-xs">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-500" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by ID or title..."
className="w-full rounded-lg border border-gray-700 bg-gray-800 py-1.5 pl-8 pr-3 text-xs text-gray-200 placeholder-gray-500 focus:border-cyan-500 focus:outline-none"
/>
</div>
<span className="ml-auto text-xs text-gray-500">
{filteredControls.length} of {controls.length} controls
</span>
</div>
{/* Table */}
<div className="overflow-hidden rounded-xl border border-gray-800">
<table className="w-full">
<thead>
<tr className="bg-gray-800/50 text-left text-xs text-gray-500 uppercase tracking-wider">
<th className="w-8 px-3 py-2.5" />
<th className="px-3 py-2.5">Control</th>
<th className="px-3 py-2.5">Title</th>
<th className="px-3 py-2.5 hidden lg:table-cell">Category</th>
<th className="px-3 py-2.5">Status</th>
<th className="px-3 py-2.5">Score</th>
<th className="px-3 py-2.5">Techniques</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800/50">
{filteredControls.map((control) => {
const isExpanded = expandedId === control.control_id;
const statusStyle = STATUS_COLORS[control.status] || STATUS_COLORS.not_evaluated;
return (
<tbody key={control.control_id}>
<tr
className="cursor-pointer transition-colors hover:bg-gray-800/30"
onClick={() => toggleExpand(control.control_id)}
>
<td className="px-3 py-2.5">
{isExpanded ? (
<ChevronDown className="h-3.5 w-3.5 text-gray-400" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-gray-400" />
)}
</td>
<td className="px-3 py-2.5 font-mono text-xs font-medium text-cyan-400">
{control.control_id}
</td>
<td className="px-3 py-2.5 text-sm text-gray-200 truncate max-w-[200px]">
{control.title}
</td>
<td className="px-3 py-2.5 text-xs text-gray-500 hidden lg:table-cell">
{control.category}
</td>
<td className="px-3 py-2.5">
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[10px] font-medium ${statusStyle.bg} ${statusStyle.text}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${statusStyle.dot}`} />
{STATUS_LABELS[control.status]}
</span>
</td>
<td className="px-3 py-2.5 text-sm font-medium text-gray-300">
{control.score.toFixed(1)}
</td>
<td className="px-3 py-2.5 text-xs text-gray-400">
{control.techniques_covered}/{control.techniques_count}
</td>
</tr>
{/* Expanded row: technique details */}
{isExpanded && control.techniques.length > 0 && (
<tr>
<td colSpan={7} className="bg-gray-800/20 px-6 py-3">
<div className="space-y-1">
<p className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-gray-500">
Mapped Techniques
</p>
{control.techniques.map((tech) => {
const techStatusColor =
tech.score >= 70
? "text-green-400"
: tech.score >= 30
? "text-yellow-400"
: tech.score > 0
? "text-red-400"
: "text-gray-500";
return (
<div
key={tech.mitre_id}
className="flex items-center justify-between rounded-lg bg-gray-900/50 px-3 py-1.5 cursor-pointer hover:bg-gray-900"
onClick={(e) => {
e.stopPropagation();
navigate(`/techniques/${tech.mitre_id}`);
}}
>
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-cyan-400">
{tech.mitre_id}
</span>
<span className="text-xs text-gray-300">
{tech.name}
</span>
</div>
<div className="flex items-center gap-3">
<span className="text-[10px] text-gray-500">
{tech.status.replace(/_/g, " ")}
</span>
<span className={`text-xs font-medium ${techStatusColor}`}>
{tech.score.toFixed(1)}
</span>
</div>
</div>
);
})}
</div>
</td>
</tr>
)}
</tbody>
);
})}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,189 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Loader2, AlertCircle, Download, FileText } from "lucide-react";
import {
getComplianceFrameworks,
getFrameworkStatus,
downloadComplianceCSV,
type ComplianceFrameworkSummary,
} from "../api/compliance";
import ComplianceGauge from "../components/compliance/ComplianceGauge";
import ControlsTable from "../components/compliance/ControlsTable";
export default function CompliancePage() {
const [selectedFrameworkId, setSelectedFrameworkId] = useState<string | null>(null);
// Fetch available frameworks
const {
data: frameworks,
isLoading: loadingFrameworks,
} = useQuery({
queryKey: ["compliance-frameworks"],
queryFn: getComplianceFrameworks,
});
// Auto-select first framework
const activeFrameworkId = selectedFrameworkId || frameworks?.[0]?.id || null;
// Fetch framework status
const {
data: frameworkStatus,
isLoading: loadingStatus,
} = useQuery({
queryKey: ["compliance-status", activeFrameworkId],
queryFn: () => getFrameworkStatus(activeFrameworkId!),
enabled: !!activeFrameworkId,
});
const isLoading = loadingFrameworks || loadingStatus;
const summary = frameworkStatus?.summary;
const controls = frameworkStatus?.controls || [];
const handleExportCSV = async () => {
if (activeFrameworkId) {
await downloadComplianceCSV(activeFrameworkId);
}
};
const handleExportJSON = async () => {
if (!frameworkStatus) return;
const json = JSON.stringify(frameworkStatus, null, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `compliance_${frameworkStatus.framework.name.replace(/\s+/g, "_")}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
if (isLoading && !frameworkStatus) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-cyan-400" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Compliance</h1>
<p className="mt-1 text-sm text-gray-400">
Map ATT&CK coverage to compliance framework controls
</p>
</div>
<div className="flex items-center gap-2">
{/* Framework selector */}
<select
value={activeFrameworkId || ""}
onChange={(e) => setSelectedFrameworkId(e.target.value)}
className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
>
{(frameworks || []).map((fw) => (
<option key={fw.id} value={fw.id}>
{fw.name}
</option>
))}
</select>
{/* Export buttons */}
<button
onClick={handleExportCSV}
className="flex items-center gap-1.5 rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-xs font-medium text-gray-300 hover:border-cyan-500/50 hover:text-white"
>
<Download className="h-3.5 w-3.5" />
Export CSV
</button>
<button
onClick={handleExportJSON}
className="flex items-center gap-1.5 rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-xs font-medium text-gray-300 hover:border-cyan-500/50 hover:text-white"
>
<FileText className="h-3.5 w-3.5" />
Export JSON
</button>
</div>
</div>
{/* Summary cards */}
{summary && (
<div className="grid grid-cols-2 gap-4 lg:grid-cols-5">
{/* Gauge */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4 flex flex-col items-center justify-center">
<ComplianceGauge percentage={summary.compliance_percentage} size="md" />
<p className="mt-2 text-xs text-gray-500">Overall Compliance</p>
</div>
{/* Covered */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
<p className="text-xs text-gray-500 uppercase tracking-wider">Covered</p>
<p className="mt-2 text-3xl font-bold text-green-400">{summary.covered}</p>
<div className="mt-2 h-1.5 w-full rounded-full bg-gray-800">
<div
className="h-full rounded-full bg-green-500"
style={{ width: `${summary.total_controls > 0 ? (summary.covered / summary.total_controls) * 100 : 0}%` }}
/>
</div>
</div>
{/* Partial */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
<p className="text-xs text-gray-500 uppercase tracking-wider">Partial</p>
<p className="mt-2 text-3xl font-bold text-yellow-400">
{summary.partially_covered}
</p>
<div className="mt-2 h-1.5 w-full rounded-full bg-gray-800">
<div
className="h-full rounded-full bg-yellow-500"
style={{ width: `${summary.total_controls > 0 ? (summary.partially_covered / summary.total_controls) * 100 : 0}%` }}
/>
</div>
</div>
{/* Not Covered */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
<p className="text-xs text-gray-500 uppercase tracking-wider">Not Covered</p>
<p className="mt-2 text-3xl font-bold text-red-400">{summary.not_covered}</p>
<div className="mt-2 h-1.5 w-full rounded-full bg-gray-800">
<div
className="h-full rounded-full bg-red-500"
style={{ width: `${summary.total_controls > 0 ? (summary.not_covered / summary.total_controls) * 100 : 0}%` }}
/>
</div>
</div>
{/* Not Evaluated */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
<p className="text-xs text-gray-500 uppercase tracking-wider">Not Evaluated</p>
<p className="mt-2 text-3xl font-bold text-gray-400">{summary.not_evaluated}</p>
<div className="mt-2 h-1.5 w-full rounded-full bg-gray-800">
<div
className="h-full rounded-full bg-gray-500"
style={{ width: `${summary.total_controls > 0 ? (summary.not_evaluated / summary.total_controls) * 100 : 0}%` }}
/>
</div>
</div>
</div>
)}
{/* Controls table */}
{controls.length > 0 ? (
<ControlsTable controls={controls} />
) : (
!isLoading && (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-8 text-center">
<AlertCircle className="mx-auto h-10 w-10 text-gray-600" />
<p className="mt-3 text-gray-400">
No compliance data available. Import a compliance framework from the System page.
</p>
</div>
)
)}
</div>
);
}