From 45b13bccde0d7d5eebfc6b57a8e1c0cf64ae2c21 Mon Sep 17 00:00:00 2001 From: kitos Date: Thu, 28 May 2026 15:42:52 +0200 Subject: [PATCH] feat(dashboard): sort Critical Gaps by risk score instead of MITRE ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/src/api/risk.ts | 44 ++++++ frontend/src/pages/ExecutiveDashboardPage.tsx | 131 +++++++++++++----- 2 files changed, 139 insertions(+), 36 deletions(-) create mode 100644 frontend/src/api/risk.ts diff --git a/frontend/src/api/risk.ts b/frontend/src/api/risk.ts new file mode 100644 index 0000000..0ea5c14 --- /dev/null +++ b/frontend/src/api/risk.ts @@ -0,0 +1,44 @@ +import client from "./client"; + +// ── Types ─────────────────────────────────────────────────────────── + +export interface RiskProfile { + id: string; + technique_id: string; + risk_score: number; + likelihood: number; + impact: number; + risk_level: string; + detection_gap: number; + threat_actor_count: number; + osint_signal_count: number; + test_fail_count: number; + test_total_count: number; + test_failure_rate: number; + confidence_level: number; + scoring_breakdown: Record | null; + recommendations: string[] | null; + computed_at: string; + is_stale: boolean; +} + +// ── API Functions ─────────────────────────────────────────────────── + +/** List risk profiles sorted by risk_score DESC. */ +export async function getRiskProfiles(params?: { + risk_level?: string; + min_score?: number; + max_score?: number; + stale_only?: boolean; + limit?: number; + offset?: number; +}): Promise { + const { data } = await client.get("/risk/profiles", { params }); + return data; +} + +/** Get the risk profile for a single technique. */ +export async function getRiskProfile(techniqueId: string): Promise { + const { data } = await client.get(`/risk/profiles/${techniqueId}`); + return data; +} diff --git a/frontend/src/pages/ExecutiveDashboardPage.tsx b/frontend/src/pages/ExecutiveDashboardPage.tsx index 7b7a3c5..f712a55 100644 --- a/frontend/src/pages/ExecutiveDashboardPage.tsx +++ b/frontend/src/pages/ExecutiveDashboardPage.tsx @@ -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( + (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 */}

- Critical Gaps (Top 10 Uncovered Techniques) + Critical Gaps — Top 10 by Risk Priority

+ + @@ -413,42 +436,78 @@ export default function ExecutiveDashboardPage() { - {criticalGaps.map((tech) => ( - navigate(`/techniques/${tech.mitre_id}`)} - > - - - - - - ))} + {criticalGaps.map((tech, idx) => { + const profile = riskByTechniqueId.get(tech.id); + const riskScore = profile?.risk_score; + const riskLevel = profile?.risk_level; + const riskLevelColors: Record = { + 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 ( + navigate(`/techniques/${tech.mitre_id}`)} + > + + + + + + + + ); + })} {criticalGaps.length === 0 && ( -
#Risk MITRE ID Name Tactic
- {tech.mitre_id} - - {tech.name} - - {tech.tactic - ?.split(",")[0] - .trim() - .split("-") - .map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)) - .join(" ")} - - - {tech.status_global?.replace(/_/g, " ")} - -
{idx + 1} +
+ + {riskScore !== undefined ? Math.round(riskScore) : "—"} + + {riskLevel && ( + + {riskLevel} + + )} +
+
+ {tech.mitre_id} + + {tech.name} + + {tech.tactic + ?.split(",")[0] + .trim() + .split("-") + .map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" ")} + + + {tech.status_global?.replace(/_/g, " ")} + +
+ No critical gaps found