feat(dashboard): sort Critical Gaps by risk score instead of MITRE ID
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
- Create frontend/src/api/risk.ts with getRiskProfiles() API function - Executive Dashboard fetches risk profiles and builds a techniqueId→profile map - Critical Gaps sorted by risk_score DESC (highest risk shown first) - Ties resolved: not_covered before not_evaluated; unscored techniques last - Table now shows Risk Score (0-100, color-coded) and Risk Level badge per row - Column renamed to "Critical Gaps — Top 10 by Risk Priority" Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,7 @@ import {
|
||||
import { getCoverageByTactic } from "../api/metrics";
|
||||
import { getThreatActors } from "../api/threat-actors";
|
||||
import { getTechniques, type TechniqueSummary } from "../api/techniques";
|
||||
import { getRiskProfiles, type RiskProfile } from "../api/risk";
|
||||
|
||||
// ── Score Gauge Component ────────────────────────────────────────────
|
||||
|
||||
@@ -162,6 +163,11 @@ export default function ExecutiveDashboardPage() {
|
||||
queryFn: () => getTechniques(),
|
||||
});
|
||||
|
||||
const { data: riskProfiles } = useQuery({
|
||||
queryKey: ["risk-profiles-exec"],
|
||||
queryFn: () => getRiskProfiles({ limit: 500 }),
|
||||
});
|
||||
|
||||
const isLoading = loadingScore || loadingMetrics;
|
||||
|
||||
if (isLoading) {
|
||||
@@ -172,9 +178,24 @@ export default function ExecutiveDashboardPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Critical gaps: not_covered or not_evaluated techniques
|
||||
// Build a lookup: techniqueId → risk profile (already sorted by risk_score DESC server-side)
|
||||
const riskByTechniqueId = new Map<string, RiskProfile>(
|
||||
(riskProfiles || []).map((p) => [p.technique_id, p])
|
||||
);
|
||||
|
||||
// Critical gaps: not_covered or not_evaluated, sorted by risk_score DESC
|
||||
// Techniques with a risk profile rank higher; ties broken by status (not_covered first)
|
||||
const criticalGaps: TechniqueSummary[] = (allTechniques || [])
|
||||
.filter((t) => t.status_global === "not_covered" || t.status_global === "not_evaluated")
|
||||
.sort((a, b) => {
|
||||
const scoreA = riskByTechniqueId.get(a.id)?.risk_score ?? -1;
|
||||
const scoreB = riskByTechniqueId.get(b.id)?.risk_score ?? -1;
|
||||
if (scoreB !== scoreA) return scoreB - scoreA;
|
||||
// same score: not_covered before not_evaluated
|
||||
if (a.status_global === "not_covered" && b.status_global !== "not_covered") return -1;
|
||||
if (b.status_global === "not_covered" && a.status_global !== "not_covered") return 1;
|
||||
return 0;
|
||||
})
|
||||
.slice(0, 10);
|
||||
|
||||
// Coverage by tactic for bar chart
|
||||
@@ -400,12 +421,14 @@ export default function ExecutiveDashboardPage() {
|
||||
{/* Section 6: Critical Gaps */}
|
||||
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
|
||||
<h2 className="mb-3 text-sm font-semibold text-gray-300">
|
||||
Critical Gaps (Top 10 Uncovered Techniques)
|
||||
Critical Gaps — Top 10 by Risk Priority
|
||||
</h2>
|
||||
<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">
|
||||
<th className="pb-2 pr-3">#</th>
|
||||
<th className="pb-2 pr-4">Risk</th>
|
||||
<th className="pb-2 pr-4">MITRE ID</th>
|
||||
<th className="pb-2 pr-4">Name</th>
|
||||
<th className="pb-2 pr-4">Tactic</th>
|
||||
@@ -413,42 +436,78 @@ export default function ExecutiveDashboardPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{criticalGaps.map((tech) => (
|
||||
<tr
|
||||
key={tech.mitre_id}
|
||||
className="border-b border-gray-800/50 cursor-pointer hover:bg-gray-800/30"
|
||||
onClick={() => navigate(`/techniques/${tech.mitre_id}`)}
|
||||
>
|
||||
<td className="py-2 pr-4 font-mono text-xs text-cyan-400">
|
||||
{tech.mitre_id}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-gray-300 truncate max-w-[200px]">
|
||||
{tech.name}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-gray-500 text-xs">
|
||||
{tech.tactic
|
||||
?.split(",")[0]
|
||||
.trim()
|
||||
.split("-")
|
||||
.map((w: string) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(" ")}
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span
|
||||
className={`inline-block rounded-full px-2 py-0.5 text-[10px] font-medium ${
|
||||
tech.status_global === "not_covered"
|
||||
? "bg-red-500/10 text-red-400"
|
||||
: "bg-gray-500/10 text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{tech.status_global?.replace(/_/g, " ")}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{criticalGaps.map((tech, idx) => {
|
||||
const profile = riskByTechniqueId.get(tech.id);
|
||||
const riskScore = profile?.risk_score;
|
||||
const riskLevel = profile?.risk_level;
|
||||
const riskLevelColors: Record<string, string> = {
|
||||
critical: "bg-red-500/20 text-red-400 border border-red-500/30",
|
||||
high: "bg-orange-500/20 text-orange-400 border border-orange-500/30",
|
||||
medium: "bg-yellow-500/20 text-yellow-400 border border-yellow-500/30",
|
||||
low: "bg-blue-500/20 text-blue-400 border border-blue-500/30",
|
||||
info: "bg-gray-500/20 text-gray-400 border border-gray-500/30",
|
||||
};
|
||||
const scoreColor =
|
||||
riskScore === undefined ? "text-gray-600"
|
||||
: riskScore >= 75 ? "text-red-400"
|
||||
: riskScore >= 50 ? "text-orange-400"
|
||||
: riskScore >= 25 ? "text-yellow-400"
|
||||
: "text-blue-400";
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={tech.mitre_id}
|
||||
className="border-b border-gray-800/50 cursor-pointer hover:bg-gray-800/30"
|
||||
onClick={() => navigate(`/techniques/${tech.mitre_id}`)}
|
||||
>
|
||||
<td className="py-2 pr-3 text-xs text-gray-600 font-mono">{idx + 1}</td>
|
||||
<td className="py-2 pr-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-sm font-bold tabular-nums ${scoreColor}`}>
|
||||
{riskScore !== undefined ? Math.round(riskScore) : "—"}
|
||||
</span>
|
||||
{riskLevel && (
|
||||
<span
|
||||
className={`hidden sm:inline-block rounded-full px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide ${
|
||||
riskLevelColors[riskLevel] || riskLevelColors.info
|
||||
}`}
|
||||
>
|
||||
{riskLevel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs text-cyan-400">
|
||||
{tech.mitre_id}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-gray-300 truncate max-w-[180px]">
|
||||
{tech.name}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-gray-500 text-xs">
|
||||
{tech.tactic
|
||||
?.split(",")[0]
|
||||
.trim()
|
||||
.split("-")
|
||||
.map((w: string) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(" ")}
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span
|
||||
className={`inline-block rounded-full px-2 py-0.5 text-[10px] font-medium ${
|
||||
tech.status_global === "not_covered"
|
||||
? "bg-red-500/10 text-red-400"
|
||||
: "bg-gray-500/10 text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{tech.status_global?.replace(/_/g, " ")}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{criticalGaps.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="py-4 text-center text-gray-500">
|
||||
<td colSpan={6} className="py-4 text-center text-gray-500">
|
||||
No critical gaps found
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
Reference in New Issue
Block a user