feat(dashboard): sort Critical Gaps by risk score instead of MITRE ID
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:
kitos
2026-05-28 15:42:52 +02:00
parent 2e5b47a4a2
commit 45b13bccde
2 changed files with 139 additions and 36 deletions

44
frontend/src/api/risk.ts Normal file
View File

@@ -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<string, unknown> | 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<RiskProfile[]> {
const { data } = await client.get<RiskProfile[]>("/risk/profiles", { params });
return data;
}
/** Get the risk profile for a single technique. */
export async function getRiskProfile(techniqueId: string): Promise<RiskProfile> {
const { data } = await client.get<RiskProfile>(`/risk/profiles/${techniqueId}`);
return data;
}

View File

@@ -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>