80991b2f59
Backend: expose description in control status response, add rich business-language descriptions to all curated controls (ISO 27001, ISO 42001, CIS v8, DORA) explaining requirements and ATT&CK mapping rationale. ISO 42001 includes infrastructure-mapping note. Frontend: description field in type, info panel in ControlsTable expanded rows, framework info banner with description and official standard link in CompliancePage.
266 lines
12 KiB
TypeScript
266 lines
12 KiB
TypeScript
import { useState } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { ChevronDown, ChevronRight, Search, Filter, ExternalLink, Info, ShieldAlert } from "lucide-react";
|
|
import type { ComplianceControlStatus } from "../../api/compliance";
|
|
|
|
interface ControlsTableProps {
|
|
controls: ComplianceControlStatus[];
|
|
}
|
|
|
|
const STATUS_COLORS: Record<string, { bg: string; text: string; dot: string; border: string }> = {
|
|
covered: { bg: "bg-green-500/10", text: "text-green-400", dot: "bg-green-500", border: "border-green-500/20" },
|
|
partially_covered:{ bg: "bg-yellow-500/10", text: "text-yellow-400", dot: "bg-yellow-500", border: "border-yellow-500/20" },
|
|
not_covered: { bg: "bg-red-500/10", text: "text-red-400", dot: "bg-red-500", border: "border-red-500/20" },
|
|
not_evaluated: { bg: "bg-gray-500/10", text: "text-gray-400", dot: "bg-gray-500", border: "border-gray-600/20" },
|
|
};
|
|
|
|
const STATUS_LABELS: Record<string, string> = {
|
|
covered: "Covered",
|
|
partially_covered: "Partial",
|
|
not_covered: "Not Covered",
|
|
not_evaluated: "Not Evaluated",
|
|
};
|
|
|
|
function ScoreBar({ score }: { score: number }) {
|
|
const color =
|
|
score >= 70 ? "bg-green-500"
|
|
: score >= 30 ? "bg-yellow-500"
|
|
: score > 0 ? "bg-red-500"
|
|
: "bg-gray-700";
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<div className="h-1.5 w-16 rounded-full bg-gray-800 overflow-hidden">
|
|
<div className={`h-full rounded-full ${color} transition-all`} style={{ width: `${score}%` }} />
|
|
</div>
|
|
<span className="text-xs font-medium text-gray-300 tabular-nums w-8">{score.toFixed(0)}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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("");
|
|
|
|
const categories = [...new Set(controls.map((c) => c.category).filter(Boolean))] as string[];
|
|
|
|
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) ||
|
|
(c.category || "").toLowerCase().includes(q)
|
|
);
|
|
}
|
|
return true;
|
|
});
|
|
|
|
const toggleExpand = (id: string) => setExpandedId(expandedId === id ? null : id);
|
|
|
|
return (
|
|
<div className="rounded-xl border border-gray-800 bg-gray-900">
|
|
{/* Filters */}
|
|
<div className="flex flex-wrap items-center gap-3 border-b border-gray-800 px-4 py-3">
|
|
<Filter className="h-3.5 w-3.5 shrink-0 text-gray-500" />
|
|
|
|
<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>
|
|
|
|
{categories.length > 0 && (
|
|
<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 max-w-xs flex-1">
|
|
<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 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} / {controls.length}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-gray-800 text-left text-xs text-gray-500 uppercase tracking-wider">
|
|
<th className="w-8 px-4 py-3" />
|
|
<th className="px-4 py-3 whitespace-nowrap">Control ID</th>
|
|
<th className="px-4 py-3">Title</th>
|
|
<th className="hidden px-4 py-3 lg:table-cell whitespace-nowrap">Category</th>
|
|
<th className="px-4 py-3">Status</th>
|
|
<th className="px-4 py-3 whitespace-nowrap">Score</th>
|
|
<th className="px-4 py-3 whitespace-nowrap">Techniques</th>
|
|
</tr>
|
|
</thead>
|
|
|
|
<tbody className="divide-y divide-gray-800/50">
|
|
{filteredControls.length === 0 && (
|
|
<tr>
|
|
<td colSpan={7} className="px-4 py-10 text-center text-sm text-gray-500">
|
|
No controls match the current filters.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
|
|
{filteredControls.map((control) => {
|
|
const isExpanded = expandedId === control.control_id;
|
|
const statusStyle = STATUS_COLORS[control.status] || STATUS_COLORS.not_evaluated;
|
|
|
|
return (
|
|
<>
|
|
{/* Main row */}
|
|
<tr
|
|
key={control.control_id}
|
|
className={`cursor-pointer transition-colors hover:bg-gray-800/40 ${
|
|
isExpanded ? "bg-gray-800/20" : ""
|
|
}`}
|
|
onClick={() => toggleExpand(control.control_id)}
|
|
>
|
|
<td className="px-4 py-3">
|
|
{isExpanded
|
|
? <ChevronDown className="h-4 w-4 text-cyan-400" />
|
|
: <ChevronRight className="h-4 w-4 text-gray-500" />
|
|
}
|
|
</td>
|
|
<td className="px-4 py-3 font-mono text-xs font-semibold text-cyan-400 whitespace-nowrap">
|
|
{control.control_id}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-gray-200 max-w-[260px]">
|
|
<span className="line-clamp-2">{control.title}</span>
|
|
</td>
|
|
<td className="hidden px-4 py-3 text-xs text-gray-400 lg:table-cell">
|
|
{control.category || "—"}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span
|
|
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[10px] font-medium ${statusStyle.bg} ${statusStyle.text} ${statusStyle.border}`}
|
|
>
|
|
<span className={`h-1.5 w-1.5 rounded-full ${statusStyle.dot}`} />
|
|
{STATUS_LABELS[control.status] ?? control.status}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<ScoreBar score={control.score} />
|
|
</td>
|
|
<td className="px-4 py-3 text-xs text-gray-400 whitespace-nowrap">
|
|
<span className={control.techniques_covered > 0 ? "text-gray-200" : "text-gray-600"}>
|
|
{control.techniques_covered}
|
|
</span>
|
|
<span className="text-gray-600"> / {control.techniques_count}</span>
|
|
</td>
|
|
</tr>
|
|
|
|
{/* Expanded detail row */}
|
|
{isExpanded && (
|
|
<tr key={`${control.control_id}-expanded`} className="bg-gray-800/10">
|
|
<td colSpan={7} className="px-6 pb-5 pt-3">
|
|
<div className="space-y-4">
|
|
|
|
{/* Executive description */}
|
|
{control.description && (
|
|
<div className="flex gap-3 rounded-xl border border-blue-500/15 bg-blue-500/5 p-4">
|
|
<Info className="mt-0.5 h-4 w-4 shrink-0 text-blue-400" />
|
|
<div>
|
|
<p className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-blue-400">
|
|
What this control requires — and why it matters
|
|
</p>
|
|
<p className="text-xs leading-relaxed text-gray-300">
|
|
{control.description}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Techniques grid */}
|
|
{control.techniques.length === 0 ? (
|
|
<div className="flex items-center gap-2 text-xs text-gray-500 italic">
|
|
<ShieldAlert className="h-4 w-4" />
|
|
No ATT&CK techniques mapped to this control yet.
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<p className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-gray-500">
|
|
ATT&CK techniques covered ({control.techniques.length}) — sorted by coverage score
|
|
</p>
|
|
<div className="grid gap-1.5 sm:grid-cols-2 lg:grid-cols-3">
|
|
{control.techniques.map((tech) => {
|
|
const techStyle =
|
|
tech.score >= 70 ? "border-green-500/20 bg-green-500/5 text-green-400"
|
|
: tech.score >= 30 ? "border-yellow-500/20 bg-yellow-500/5 text-yellow-400"
|
|
: tech.score > 0 ? "border-red-500/20 bg-red-500/5 text-red-400"
|
|
: "border-gray-700 bg-gray-800/30 text-gray-500";
|
|
|
|
return (
|
|
<div
|
|
key={tech.mitre_id}
|
|
className={`flex items-center justify-between rounded-lg border px-3 py-2 cursor-pointer hover:brightness-125 transition-all ${techStyle}`}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
navigate(`/techniques/${tech.mitre_id}`);
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<span className="shrink-0 font-mono text-xs font-semibold text-cyan-400">
|
|
{tech.mitre_id}
|
|
</span>
|
|
<span className="truncate text-xs text-gray-300">
|
|
{tech.name}
|
|
</span>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-2 pl-2">
|
|
<span className="text-[10px] font-medium tabular-nums">
|
|
{tech.score.toFixed(0)}
|
|
</span>
|
|
<ExternalLink className="h-3 w-3 text-gray-600" />
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|