feat(matrix): unify Techniques and Coverage Matrix into one page
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
Techniques (/techniques) and Coverage Matrix (/matrix) showed the same
underlying data (Technique.status_global) with completely different
visual styles — discrete Tailwind status colors + ID+name cells on
Techniques, vs. a continuous score-gradient hex color + ID-only cells
(name via tooltip) on the heatmap. Coverage Matrix also has 3 layers
(Threat Actor, Detection Rules, Campaign) with no Techniques equivalent,
so Techniques was the strict subset — merged into Coverage Matrix rather
than the other way around.
- HeatmapCell now uses the same discrete status-color palette as the old
TechniqueCell (cellColors.ts), shows the technique name inline, and
keeps zoom/virtualization/hover-tooltip untouched.
- HeatmapLegend restyled to match (swatch + label + description per
tier, review_required as an overlay badge) instead of the old
hex-gradient bar.
- MatrixPage gains a Matrix/List view toggle and a status filter
(coverage/threat-actor layers only) — both ported from the old
Techniques page — so nothing it offered is lost.
- /techniques now redirects to /matrix; removed TechniquesPage,
AttackMatrix, TechniqueCell (fully superseded). /techniques/:mitreId
(detail) and /techniques/review-queue are untouched.
Known pre-existing limitation carried over, not introduced or fixed
here: the heatmap only places a multi-tactic technique under its first
tactic column (_format_tactic takes tactic_str.split(",")[0]), while the
old Techniques matrix placed it under every tactic it belongs to.
This commit is contained in:
@@ -9,7 +9,6 @@ import LoginPage from "./pages/LoginPage";
|
|||||||
import DashboardPage from "./pages/DashboardPage";
|
import DashboardPage from "./pages/DashboardPage";
|
||||||
|
|
||||||
/* ── Lazy loaded (V1-V3 pages) ────────────────────────────────────── */
|
/* ── Lazy loaded (V1-V3 pages) ────────────────────────────────────── */
|
||||||
const TechniquesPage = React.lazy(() => import("./pages/TechniquesPage"));
|
|
||||||
const MatrixPage = React.lazy(() => import("./pages/MatrixPage"));
|
const MatrixPage = React.lazy(() => import("./pages/MatrixPage"));
|
||||||
const ExecutiveDashboardPage = React.lazy(() => import("./pages/ExecutiveDashboardPage"));
|
const ExecutiveDashboardPage = React.lazy(() => import("./pages/ExecutiveDashboardPage"));
|
||||||
const CompliancePage = React.lazy(() => import("./pages/CompliancePage"));
|
const CompliancePage = React.lazy(() => import("./pages/CompliancePage"));
|
||||||
@@ -50,7 +49,8 @@ export default function App() {
|
|||||||
{/* ── Core ─────────────────────────────────────────────── */}
|
{/* ── Core ─────────────────────────────────────────────── */}
|
||||||
<Route path="/dashboard" element={<DashboardPage />} />
|
<Route path="/dashboard" element={<DashboardPage />} />
|
||||||
|
|
||||||
<Route path="/techniques" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><TechniquesPage /></Suspense>} />
|
{/* Techniques was merged into the Coverage Matrix's "Coverage" layer + List view */}
|
||||||
|
<Route path="/techniques" element={<Navigate to="/matrix" replace />} />
|
||||||
<Route path="/techniques/:mitreId" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><TechniqueDetailPage /></Suspense>} />
|
<Route path="/techniques/:mitreId" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><TechniqueDetailPage /></Suspense>} />
|
||||||
|
|
||||||
<Route path="/matrix" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><MatrixPage /></Suspense>} />
|
<Route path="/matrix" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><MatrixPage /></Suspense>} />
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ export interface HeatmapMetadata {
|
|||||||
|
|
||||||
export interface HeatmapTechnique {
|
export interface HeatmapTechnique {
|
||||||
techniqueID: string;
|
techniqueID: string;
|
||||||
|
/** Technique display name — always present now, shown inline on cells (matches the Techniques page's style). */
|
||||||
|
name?: string;
|
||||||
|
/** Real TechniqueStatus, when the layer has one (coverage, threat-actor) — used to color cells the same way the Techniques page does. Absent for detection-rules/campaign layers. */
|
||||||
|
status?: string;
|
||||||
tactic: string;
|
tactic: string;
|
||||||
color: string;
|
color: string;
|
||||||
score: number;
|
score: number;
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import TechniqueCell from "./TechniqueCell";
|
|
||||||
import type { TechniqueSummary } from "../api/techniques";
|
|
||||||
|
|
||||||
interface AttackMatrixProps {
|
|
||||||
techniques: TechniqueSummary[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// MITRE ATT&CK Enterprise Tactics in order
|
|
||||||
const TACTIC_ORDER = [
|
|
||||||
"reconnaissance",
|
|
||||||
"resource-development",
|
|
||||||
"initial-access",
|
|
||||||
"execution",
|
|
||||||
"persistence",
|
|
||||||
"privilege-escalation",
|
|
||||||
"defense-evasion",
|
|
||||||
"credential-access",
|
|
||||||
"discovery",
|
|
||||||
"lateral-movement",
|
|
||||||
"collection",
|
|
||||||
"command-and-control",
|
|
||||||
"exfiltration",
|
|
||||||
"impact",
|
|
||||||
];
|
|
||||||
|
|
||||||
const formatTacticName = (tactic: string): string => {
|
|
||||||
return tactic
|
|
||||||
.split("-")
|
|
||||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
||||||
.join(" ");
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function AttackMatrix({ techniques }: AttackMatrixProps) {
|
|
||||||
// Group techniques by tactic
|
|
||||||
const groupedByTactic = useMemo(() => {
|
|
||||||
const groups: Record<string, TechniqueSummary[]> = {};
|
|
||||||
|
|
||||||
for (const tech of techniques) {
|
|
||||||
// A technique can belong to multiple tactics (comma-separated)
|
|
||||||
const tactics = tech.tactic
|
|
||||||
? tech.tactic.split(",").map((t) => t.trim().toLowerCase())
|
|
||||||
: ["unknown"];
|
|
||||||
|
|
||||||
for (const tactic of tactics) {
|
|
||||||
if (!groups[tactic]) {
|
|
||||||
groups[tactic] = [];
|
|
||||||
}
|
|
||||||
groups[tactic].push(tech);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort techniques within each tactic by mitre_id
|
|
||||||
for (const tactic of Object.keys(groups)) {
|
|
||||||
groups[tactic].sort((a, b) => a.mitre_id.localeCompare(b.mitre_id));
|
|
||||||
}
|
|
||||||
|
|
||||||
return groups;
|
|
||||||
}, [techniques]);
|
|
||||||
|
|
||||||
// Get ordered tactics that have techniques
|
|
||||||
const orderedTactics = useMemo(() => {
|
|
||||||
const tacticSet = new Set(Object.keys(groupedByTactic));
|
|
||||||
const ordered = TACTIC_ORDER.filter((t) => tacticSet.has(t));
|
|
||||||
// Add any unknown tactics at the end
|
|
||||||
const remaining = Array.from(tacticSet).filter((t) => !TACTIC_ORDER.includes(t));
|
|
||||||
return [...ordered, ...remaining];
|
|
||||||
}, [groupedByTactic]);
|
|
||||||
|
|
||||||
if (techniques.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border border-gray-800 bg-gray-900 p-8 text-center">
|
|
||||||
<p className="text-gray-400">No techniques found matching your filters</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="overflow-x-auto rounded-xl border border-gray-800 bg-gray-900">
|
|
||||||
<div className="min-w-max p-4">
|
|
||||||
<div className="flex gap-3">
|
|
||||||
{orderedTactics.map((tactic) => (
|
|
||||||
<div key={tactic} className="w-48 flex-shrink-0">
|
|
||||||
{/* Tactic header */}
|
|
||||||
<div className="mb-3 rounded-lg bg-gray-800 px-3 py-2">
|
|
||||||
<h3 className="text-center text-sm font-semibold text-cyan-400">
|
|
||||||
{formatTacticName(tactic)}
|
|
||||||
</h3>
|
|
||||||
<p className="mt-0.5 text-center text-xs text-gray-500">
|
|
||||||
{groupedByTactic[tactic]?.length || 0} techniques
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Technique cells */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
{groupedByTactic[tactic]?.map((tech) => (
|
|
||||||
<TechniqueCell
|
|
||||||
key={`${tactic}-${tech.mitre_id}`}
|
|
||||||
mitreId={tech.mitre_id}
|
|
||||||
name={tech.name}
|
|
||||||
status={tech.status_global}
|
|
||||||
reviewRequired={tech.review_required}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
Crosshair,
|
Crosshair,
|
||||||
Zap,
|
Zap,
|
||||||
Grid3X3,
|
Grid3X3,
|
||||||
List,
|
|
||||||
Gauge,
|
Gauge,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
GitCompareArrows,
|
GitCompareArrows,
|
||||||
@@ -44,7 +43,6 @@ const mainLinks: NavItem[] = [
|
|||||||
label: "ATT&CK",
|
label: "ATT&CK",
|
||||||
icon: Grid3X3,
|
icon: Grid3X3,
|
||||||
children: [
|
children: [
|
||||||
{ to: "/techniques", label: "Techniques", icon: List },
|
|
||||||
{ to: "/matrix", label: "Coverage Matrix", icon: Grid3X3 },
|
{ to: "/matrix", label: "Coverage Matrix", icon: Grid3X3 },
|
||||||
{ to: "/techniques/review-queue", label: "Review Queue", icon: ClipboardCheck, roles: ["admin", "red_lead", "blue_lead"] },
|
{ to: "/techniques/review-queue", label: "Review Queue", icon: ClipboardCheck, roles: ["admin", "red_lead", "blue_lead"] },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
import { Link } from "react-router-dom";
|
|
||||||
import { AlertTriangle } from "lucide-react";
|
|
||||||
import type { TechniqueStatus } from "../types/models";
|
|
||||||
|
|
||||||
interface TechniqueCellProps {
|
|
||||||
mitreId: string;
|
|
||||||
name: string;
|
|
||||||
status: TechniqueStatus;
|
|
||||||
reviewRequired?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusColors: Record<TechniqueStatus, { bg: string; border: string; text: string }> = {
|
|
||||||
validated: {
|
|
||||||
bg: "bg-green-900/40",
|
|
||||||
border: "border-green-500/50",
|
|
||||||
text: "text-green-400",
|
|
||||||
},
|
|
||||||
partial: {
|
|
||||||
bg: "bg-yellow-900/40",
|
|
||||||
border: "border-yellow-500/50",
|
|
||||||
text: "text-yellow-400",
|
|
||||||
},
|
|
||||||
in_progress: {
|
|
||||||
bg: "bg-blue-900/40",
|
|
||||||
border: "border-blue-500/50",
|
|
||||||
text: "text-blue-400",
|
|
||||||
},
|
|
||||||
not_covered: {
|
|
||||||
bg: "bg-red-900/40",
|
|
||||||
border: "border-red-500/50",
|
|
||||||
text: "text-red-400",
|
|
||||||
},
|
|
||||||
not_evaluated: {
|
|
||||||
bg: "bg-gray-800/40",
|
|
||||||
border: "border-gray-600/50",
|
|
||||||
text: "text-gray-400",
|
|
||||||
},
|
|
||||||
review_required: {
|
|
||||||
bg: "bg-yellow-900/40",
|
|
||||||
border: "border-yellow-500/50",
|
|
||||||
text: "text-yellow-400",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function TechniqueCell({
|
|
||||||
mitreId,
|
|
||||||
name,
|
|
||||||
status,
|
|
||||||
reviewRequired = false,
|
|
||||||
}: TechniqueCellProps) {
|
|
||||||
const colors = statusColors[status] || statusColors.not_evaluated;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
to={`/techniques/${mitreId}`}
|
|
||||||
className={`
|
|
||||||
relative block w-full rounded-md border p-2 text-left transition-all
|
|
||||||
hover:scale-[1.02] hover:shadow-lg hover:z-10
|
|
||||||
${colors.bg} ${colors.border}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{reviewRequired && (
|
|
||||||
<div className="absolute -right-1 -top-1 rounded-full bg-orange-500 p-0.5">
|
|
||||||
<AlertTriangle className="h-3 w-3 text-white" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className={`text-xs font-semibold ${colors.text}`}>{mitreId}</p>
|
|
||||||
<p className="mt-0.5 truncate text-xs text-gray-300" title={name}>
|
|
||||||
{name}
|
|
||||||
</p>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -47,7 +47,9 @@ function TacticColumn({
|
|||||||
}) {
|
}) {
|
||||||
const parentRef = useRef<HTMLDivElement>(null);
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const rowHeight = zoom === "compact" ? 28 : zoom === "normal" ? 40 : 60;
|
// normal/expanded now show the technique name under the ID (matching the
|
||||||
|
// Techniques page's cell style), so rows need enough height for 2 lines.
|
||||||
|
const rowHeight = zoom === "compact" ? 28 : zoom === "normal" ? 48 : 64;
|
||||||
|
|
||||||
const rowVirtualizer = useVirtualizer({
|
const rowVirtualizer = useVirtualizer({
|
||||||
count: techniques.length,
|
count: techniques.length,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import React, { useState, useMemo, useCallback } from "react";
|
import React, { useState, useMemo, useCallback } from "react";
|
||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
import type { HeatmapTechnique } from "../../api/heatmap";
|
import type { HeatmapTechnique } from "../../api/heatmap";
|
||||||
|
import { cellColorsFor } from "./cellColors";
|
||||||
import HeatmapTooltip from "./HeatmapTooltip";
|
import HeatmapTooltip from "./HeatmapTooltip";
|
||||||
|
|
||||||
interface HeatmapCellProps {
|
interface HeatmapCellProps {
|
||||||
@@ -12,45 +14,24 @@ interface HeatmapCellProps {
|
|||||||
* Memoized heatmap cell — this component renders 3000+ times in the
|
* Memoized heatmap cell — this component renders 3000+ times in the
|
||||||
* full ATT&CK matrix, so React.memo prevents unnecessary re-renders
|
* full ATT&CK matrix, so React.memo prevents unnecessary re-renders
|
||||||
* when only sibling cells change.
|
* when only sibling cells change.
|
||||||
|
*
|
||||||
|
* Styled to match TechniqueCell (the old standalone Techniques page's
|
||||||
|
* cell): discrete status-colored Tailwind classes instead of an inline
|
||||||
|
* hex background, and the technique name shown inline instead of only
|
||||||
|
* via hover tooltip.
|
||||||
*/
|
*/
|
||||||
const HeatmapCell = React.memo(function HeatmapCell({ technique, size, onClick }: HeatmapCellProps) {
|
const HeatmapCell = React.memo(function HeatmapCell({ technique, size, onClick }: HeatmapCellProps) {
|
||||||
const [showTooltip, setShowTooltip] = useState(false);
|
const [showTooltip, setShowTooltip] = useState(false);
|
||||||
|
|
||||||
const sizeClasses = {
|
|
||||||
compact: "h-6 text-[9px] px-1",
|
|
||||||
normal: "h-9 text-[11px] px-1.5",
|
|
||||||
expanded: "h-14 text-xs px-2",
|
|
||||||
};
|
|
||||||
|
|
||||||
const bgColor = technique.enabled ? technique.color : "transparent";
|
|
||||||
const isDisabled = !technique.enabled;
|
const isDisabled = !technique.enabled;
|
||||||
|
const colors = useMemo(() => cellColorsFor(technique), [technique]);
|
||||||
// Memoize text color (derived from background hex)
|
const reviewRequired = technique.status === "review_required";
|
||||||
const textColor = useMemo(() => {
|
|
||||||
const hex = bgColor;
|
|
||||||
if (!hex || hex === "transparent" || hex === "") return "text-gray-600";
|
|
||||||
const r = parseInt(hex.slice(1, 3), 16);
|
|
||||||
const g = parseInt(hex.slice(3, 5), 16);
|
|
||||||
const b = parseInt(hex.slice(5, 7), 16);
|
|
||||||
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
|
|
||||||
return brightness > 128 ? "text-gray-900" : "text-white";
|
|
||||||
}, [bgColor]);
|
|
||||||
|
|
||||||
// Status indicators — memoized
|
|
||||||
const { testsCount, reviewRequired, isValidated } = useMemo(() => {
|
|
||||||
const hasTests = technique.metadata.find((m) => m.name === "tests_count");
|
|
||||||
return {
|
|
||||||
testsCount: hasTests ? parseInt(hasTests.value, 10) : 0,
|
|
||||||
reviewRequired: technique.comment?.toLowerCase().includes("review") ?? false,
|
|
||||||
isValidated: technique.score >= 100,
|
|
||||||
};
|
|
||||||
}, [technique.metadata, technique.comment, technique.score]);
|
|
||||||
|
|
||||||
const handleClick = useCallback(() => onClick(technique.techniqueID), [onClick, technique.techniqueID]);
|
const handleClick = useCallback(() => onClick(technique.techniqueID), [onClick, technique.techniqueID]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative"
|
className="relative h-full"
|
||||||
onMouseEnter={() => setShowTooltip(true)}
|
onMouseEnter={() => setShowTooltip(true)}
|
||||||
onMouseLeave={() => setShowTooltip(false)}
|
onMouseLeave={() => setShowTooltip(false)}
|
||||||
>
|
>
|
||||||
@@ -58,28 +39,25 @@ const HeatmapCell = React.memo(function HeatmapCell({ technique, size, onClick }
|
|||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
className={`
|
className={`
|
||||||
w-full rounded border transition-all duration-150
|
relative block h-full w-full rounded-md border p-1.5 text-left transition-all overflow-hidden
|
||||||
${sizeClasses[size]}
|
|
||||||
${isDisabled
|
${isDisabled
|
||||||
? "cursor-default border-gray-800/30 bg-gray-900/20 opacity-30"
|
? "cursor-default border-gray-800/30 bg-gray-900/20 opacity-30"
|
||||||
: "cursor-pointer border-gray-700/50 hover:brightness-110 hover:ring-1 hover:ring-cyan-400/40"
|
: `cursor-pointer hover:scale-[1.02] hover:shadow-lg hover:z-10 ${colors.bg} ${colors.border}`
|
||||||
}
|
}
|
||||||
${reviewRequired && !isDisabled ? "ring-1 ring-amber-400/60" : ""}
|
|
||||||
flex items-center gap-1 overflow-hidden
|
|
||||||
`}
|
`}
|
||||||
style={{
|
|
||||||
backgroundColor: isDisabled ? undefined : bgColor,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span className={`truncate font-mono font-medium leading-tight ${textColor}`}>
|
{reviewRequired && !isDisabled && (
|
||||||
|
<div className="absolute -right-1 -top-1 rounded-full bg-orange-500 p-0.5">
|
||||||
|
<AlertTriangle className="h-3 w-3 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className={`truncate text-xs font-semibold ${isDisabled ? "text-gray-600" : colors.text}`}>
|
||||||
{technique.techniqueID}
|
{technique.techniqueID}
|
||||||
</span>
|
</p>
|
||||||
{size !== "compact" && !isDisabled && (
|
{size !== "compact" && (
|
||||||
<span className="ml-auto flex items-center gap-0.5 flex-shrink-0">
|
<p className="mt-0.5 truncate text-xs text-gray-300" title={technique.name}>
|
||||||
{testsCount === 0 && <span className="text-[8px]" title="No tests">🔴</span>}
|
{technique.name}
|
||||||
{reviewRequired && <span className="text-[8px]" title="Review required">⚠️</span>}
|
</p>
|
||||||
{isValidated && <span className="text-[8px]" title="Validated">✅</span>}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
import type { TechniqueStatus } from "../../types/models";
|
import type { TechniqueStatus } from "../../types/models";
|
||||||
import { TOOLTIPS } from "../StatusBadge";
|
import { TOOLTIPS } from "../StatusBadge";
|
||||||
|
import { STATUS_CELL_COLORS, type CellColors } from "./cellColors";
|
||||||
|
|
||||||
interface HeatmapLegendProps {
|
interface HeatmapLegendProps {
|
||||||
layerType: "coverage" | "threat-actor" | "detection-rules" | "campaign";
|
layerType: "coverage" | "threat-actor" | "detection-rules" | "campaign";
|
||||||
@@ -10,47 +12,61 @@ function tooltipText(status: TechniqueStatus): string {
|
|||||||
return `${t.heading} — ${t.lines.map((l) => `${l.label}: ${l.text}`).join(" ")}`;
|
return `${t.heading} — ${t.lines.map((l) => `${l.label}: ${l.text}`).join(" ")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LEGENDS: Record<
|
interface LegendItem {
|
||||||
string,
|
colors: CellColors;
|
||||||
{ label: string; colors: { color: string; label: string; status?: TechniqueStatus }[] }
|
label: string;
|
||||||
> = {
|
description: string;
|
||||||
|
status?: TechniqueStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same 5-tier score buckets the cells fall back to for layers with no real
|
||||||
|
// TechniqueStatus (detection-rules, campaign) — see cellColors.ts.
|
||||||
|
const BUCKET_COLORS: CellColors[] = [
|
||||||
|
{ bg: "bg-gray-800/40", border: "border-gray-600/50", text: "text-gray-400" },
|
||||||
|
{ bg: "bg-red-900/40", border: "border-red-500/50", text: "text-red-400" },
|
||||||
|
{ bg: "bg-orange-900/40", border: "border-orange-500/50", text: "text-orange-400" },
|
||||||
|
{ bg: "bg-yellow-900/40", border: "border-yellow-500/50", text: "text-yellow-400" },
|
||||||
|
{ bg: "bg-green-900/40", border: "border-green-500/50", text: "text-green-400" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const LEGENDS: Record<string, { label: string; items: LegendItem[] }> = {
|
||||||
coverage: {
|
coverage: {
|
||||||
label: "Coverage Status",
|
label: "Coverage legend",
|
||||||
colors: [
|
items: [
|
||||||
{ color: "#d3d3d3", label: "Not Evaluated (0)", status: "not_evaluated" },
|
{ colors: STATUS_CELL_COLORS.validated, label: "Validated", description: "Detected in ≥1 approved test", status: "validated" },
|
||||||
{ color: "#ff6666", label: "Not Covered (10)", status: "not_covered" },
|
{ colors: STATUS_CELL_COLORS.partial, label: "Partial", description: "Some tests validated, coverage incomplete", status: "partial" },
|
||||||
{ color: "#ff9933", label: "In Progress (30)", status: "in_progress" },
|
{ colors: STATUS_CELL_COLORS.in_progress, label: "In Progress", description: "Tests active, awaiting validation", status: "in_progress" },
|
||||||
{ color: "#ffff66", label: "Partial (60)", status: "partial" },
|
{ colors: STATUS_CELL_COLORS.not_covered, label: "Not Covered", description: "Tested but not detected — confirmed gap", status: "not_covered" },
|
||||||
{ color: "#66ff66", label: "Validated (100)", status: "validated" },
|
{ colors: STATUS_CELL_COLORS.not_evaluated, label: "Not Evaluated", description: "No tests created yet — unknown risk", status: "not_evaluated" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"threat-actor": {
|
"threat-actor": {
|
||||||
label: "Threat Actor Coverage",
|
label: "Threat actor coverage legend",
|
||||||
colors: [
|
items: [
|
||||||
{ color: "#d3d3d3", label: "Not Used by Actor" },
|
{ colors: STATUS_CELL_COLORS.validated, label: "Covered", description: "Detected in ≥1 approved test", status: "validated" },
|
||||||
{ color: "#ff6666", label: "Not Covered (10)" },
|
{ colors: STATUS_CELL_COLORS.partial, label: "Partial", description: "Some tests validated, coverage incomplete", status: "partial" },
|
||||||
{ color: "#ff9933", label: "In Progress (30)" },
|
{ colors: STATUS_CELL_COLORS.in_progress, label: "In Progress", description: "Tests active, awaiting validation", status: "in_progress" },
|
||||||
{ color: "#ffff66", label: "Partial (60)" },
|
{ colors: STATUS_CELL_COLORS.not_covered, label: "Not Covered", description: "Used by this actor but not detected", status: "not_covered" },
|
||||||
{ color: "#66ff66", label: "Covered (100)" },
|
{ colors: STATUS_CELL_COLORS.not_evaluated, label: "Not Evaluated", description: "Used by this actor, no tests yet", status: "not_evaluated" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"detection-rules": {
|
"detection-rules": {
|
||||||
label: "Detection Rules Coverage",
|
label: "Detection rules legend",
|
||||||
colors: [
|
items: [
|
||||||
{ color: "#d3d3d3", label: "0 rules" },
|
{ colors: BUCKET_COLORS[0], label: "0 rules", description: "No detection rules cataloged" },
|
||||||
{ color: "#ff6666", label: "1 rule" },
|
{ colors: BUCKET_COLORS[1], label: "1 rule", description: "Minimal detection coverage" },
|
||||||
{ color: "#ff9933", label: "2 rules" },
|
{ colors: BUCKET_COLORS[2], label: "2 rules", description: "Partial detection coverage" },
|
||||||
{ color: "#ffff66", label: "3 rules" },
|
{ colors: BUCKET_COLORS[3], label: "3 rules", description: "Good detection coverage" },
|
||||||
{ color: "#66ff66", label: "4+ rules" },
|
{ colors: BUCKET_COLORS[4], label: "4+ rules", description: "Strong detection coverage" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
campaign: {
|
campaign: {
|
||||||
label: "Campaign Progress",
|
label: "Campaign progress legend",
|
||||||
colors: [
|
items: [
|
||||||
{ color: "#ff6666", label: "Draft / Rejected" },
|
{ colors: BUCKET_COLORS[1], label: "Draft / Rejected", description: "Not yet started or sent back" },
|
||||||
{ color: "#ff9933", label: "Red Executing (30)" },
|
{ colors: BUCKET_COLORS[2], label: "Red Executing", description: "Red Team actively working" },
|
||||||
{ color: "#ffff66", label: "Blue Evaluating (50)" },
|
{ colors: BUCKET_COLORS[3], label: "Blue Evaluating", description: "Blue Team actively working" },
|
||||||
{ color: "#66ff66", label: "Validated (100)" },
|
{ colors: BUCKET_COLORS[4], label: "Validated", description: "Completed and validated" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -59,42 +75,40 @@ export default function HeatmapLegend({ layerType }: HeatmapLegendProps) {
|
|||||||
const legend = LEGENDS[layerType] || LEGENDS.coverage;
|
const legend = LEGENDS[layerType] || LEGENDS.coverage;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-4 rounded-xl border border-gray-800 bg-gray-900 p-4">
|
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
|
||||||
<span className="text-sm font-medium text-gray-400">{legend.label}:</span>
|
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-gray-500">{legend.label}</p>
|
||||||
|
<div className="flex flex-wrap gap-x-6 gap-y-3">
|
||||||
{/* Gradient bar */}
|
{legend.items.map((item) => (
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<div
|
|
||||||
className="h-3 w-40 rounded"
|
|
||||||
style={{
|
|
||||||
background: `linear-gradient(to right, ${legend.colors.map((c) => c.color).join(", ")})`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Individual labels */}
|
|
||||||
{legend.colors.map((item) => (
|
|
||||||
<div
|
<div
|
||||||
key={item.label}
|
key={item.label}
|
||||||
className="flex items-center gap-1.5"
|
className="flex items-start gap-2.5"
|
||||||
title={item.status ? tooltipText(item.status) : undefined}
|
title={item.status ? tooltipText(item.status) : undefined}
|
||||||
>
|
>
|
||||||
<div
|
<div className={`mt-0.5 h-8 w-8 shrink-0 rounded-md border ${item.colors.border} ${item.colors.bg}`} />
|
||||||
className="h-3 w-3 rounded border border-gray-700"
|
<div>
|
||||||
style={{ backgroundColor: item.color }}
|
<p className={`text-xs font-semibold ${item.colors.text}`}>{item.label}</p>
|
||||||
/>
|
<p className="text-xs text-gray-500">{item.description}</p>
|
||||||
<span className="text-xs text-gray-400">{item.label}</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Review Required — an overlay indicator (amber ring + ⚠️), not a
|
{/* Review Required — an overlay indicator (⚠️ badge), not a
|
||||||
distinct score tier, so it's shown separately from the gradient. */}
|
distinct tier, so it's shown separately for layers that carry
|
||||||
{layerType === "coverage" && (
|
a real technique status. */}
|
||||||
<div className="flex items-center gap-1.5" title={tooltipText("review_required")}>
|
{(layerType === "coverage" || layerType === "threat-actor") && (
|
||||||
<div className="h-3 w-3 rounded border border-gray-700 ring-1 ring-amber-400/60" />
|
<div className="flex items-start gap-2.5" title={tooltipText("review_required")}>
|
||||||
<span className="text-xs text-gray-400">⚠️ Review Required (any status)</span>
|
<div className="relative mt-0.5 h-8 w-8 shrink-0 rounded-md border border-yellow-500/50 bg-yellow-900/40">
|
||||||
|
<div className="absolute -right-1 -top-1 rounded-full bg-orange-500 p-0.5">
|
||||||
|
<AlertTriangle className="h-2.5 w-2.5 text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-yellow-400">Review Required</p>
|
||||||
|
<p className="text-xs text-gray-500">Pending lead validation or re-evaluation</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import type { HeatmapTechnique } from "../../api/heatmap";
|
||||||
|
import type { TechniqueStatus } from "../../types/models";
|
||||||
|
import StatusBadge from "../StatusBadge";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
techniques: HeatmapTechnique[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flat table alternative to the matrix grid — mirrors the old Techniques page's list view. */
|
||||||
|
export default function HeatmapTechniqueTable({ techniques }: Props) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto rounded-xl border border-gray-800 bg-gray-900">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-800 text-xs text-gray-400">
|
||||||
|
<th className="px-4 py-3 font-medium">MITRE ID</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Name</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Tactic</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{techniques.map((tech) => (
|
||||||
|
<tr
|
||||||
|
key={tech.techniqueID}
|
||||||
|
onClick={() => navigate(`/techniques/${tech.techniqueID}`)}
|
||||||
|
className="cursor-pointer border-b border-gray-800/50 transition-colors hover:bg-gray-800/30"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-cyan-400">{tech.techniqueID}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-200">{tech.name || "—"}</td>
|
||||||
|
<td className="px-4 py-3 text-xs capitalize text-gray-400">
|
||||||
|
{(tech.tactic || "—").replace(/-/g, " ")}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{tech.status ? (
|
||||||
|
<StatusBadge status={tech.status as TechniqueStatus} size="sm" />
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-400">{tech.comment}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{techniques.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="py-16 text-center text-sm text-gray-500">
|
||||||
|
No techniques match the current filters.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { TechniqueStatus } from "../../types/models";
|
||||||
|
|
||||||
|
export interface CellColors {
|
||||||
|
bg: string;
|
||||||
|
border: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discrete status palette — matches the Techniques page's cell colors
|
||||||
|
* exactly, so a technique looks the same whether viewed via the Coverage
|
||||||
|
* Matrix or (previously) the standalone Techniques page.
|
||||||
|
*/
|
||||||
|
export const STATUS_CELL_COLORS: Record<TechniqueStatus, CellColors> = {
|
||||||
|
validated: { bg: "bg-green-900/40", border: "border-green-500/50", text: "text-green-400" },
|
||||||
|
partial: { bg: "bg-yellow-900/40", border: "border-yellow-500/50", text: "text-yellow-400" },
|
||||||
|
in_progress: { bg: "bg-blue-900/40", border: "border-blue-500/50", text: "text-blue-400" },
|
||||||
|
not_covered: { bg: "bg-red-900/40", border: "border-red-500/50", text: "text-red-400" },
|
||||||
|
not_evaluated: { bg: "bg-gray-800/40", border: "border-gray-600/50", text: "text-gray-400" },
|
||||||
|
review_required: { bg: "bg-yellow-900/40", border: "border-yellow-500/50", text: "text-yellow-400" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 5-tier fallback palette for heatmap layers with no real TechniqueStatus
|
||||||
|
* concept (detection-rules, campaign) — same score boundaries the backend
|
||||||
|
* uses for its hex gradient (_score_to_color), but as discrete Tailwind
|
||||||
|
* classes instead of an inline hex color.
|
||||||
|
*/
|
||||||
|
const SCORE_BUCKET_COLORS: CellColors[] = [
|
||||||
|
{ bg: "bg-gray-800/40", border: "border-gray-600/50", text: "text-gray-400" },
|
||||||
|
{ bg: "bg-red-900/40", border: "border-red-500/50", text: "text-red-400" },
|
||||||
|
{ bg: "bg-orange-900/40", border: "border-orange-500/50", text: "text-orange-400" },
|
||||||
|
{ bg: "bg-yellow-900/40", border: "border-yellow-500/50", text: "text-yellow-400" },
|
||||||
|
{ bg: "bg-green-900/40", border: "border-green-500/50", text: "text-green-400" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function scoreToBucketColors(score: number): CellColors {
|
||||||
|
if (score <= 0) return SCORE_BUCKET_COLORS[0];
|
||||||
|
if (score <= 25) return SCORE_BUCKET_COLORS[1];
|
||||||
|
if (score <= 50) return SCORE_BUCKET_COLORS[2];
|
||||||
|
if (score <= 75) return SCORE_BUCKET_COLORS[3];
|
||||||
|
return SCORE_BUCKET_COLORS[4];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pick cell colors for a heatmap technique: real status when the layer provides one, else a score-bucket fallback. */
|
||||||
|
export function cellColorsFor(technique: { status?: string; score: number }): CellColors {
|
||||||
|
const status = technique.status as TechniqueStatus | undefined;
|
||||||
|
if (status && status in STATUS_CELL_COLORS) {
|
||||||
|
return STATUS_CELL_COLORS[status];
|
||||||
|
}
|
||||||
|
return scoreToBucketColors(technique.score);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useMemo, useCallback } from "react";
|
import { useState, useMemo, useCallback } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Loader2, AlertCircle, Download, ZoomIn, ZoomOut } from "lucide-react";
|
import { Loader2, AlertCircle, Download, ZoomIn, ZoomOut, Grid3X3, List } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
getHeatmapCoverage,
|
getHeatmapCoverage,
|
||||||
getHeatmapThreatActor,
|
getHeatmapThreatActor,
|
||||||
@@ -12,11 +12,13 @@ import {
|
|||||||
type HeatmapFilters as HeatmapFilterParams,
|
type HeatmapFilters as HeatmapFilterParams,
|
||||||
} from "../api/heatmap";
|
} from "../api/heatmap";
|
||||||
import AdvancedHeatmap from "../components/heatmap/AdvancedHeatmap";
|
import AdvancedHeatmap from "../components/heatmap/AdvancedHeatmap";
|
||||||
|
import HeatmapTechniqueTable from "../components/heatmap/HeatmapTechniqueTable";
|
||||||
import HeatmapLayerSelector, {
|
import HeatmapLayerSelector, {
|
||||||
type LayerType,
|
type LayerType,
|
||||||
} from "../components/heatmap/HeatmapLayerSelector";
|
} from "../components/heatmap/HeatmapLayerSelector";
|
||||||
import HeatmapFiltersComponent from "../components/heatmap/HeatmapFilters";
|
import HeatmapFiltersComponent from "../components/heatmap/HeatmapFilters";
|
||||||
import HeatmapLegend from "../components/heatmap/HeatmapLegend";
|
import HeatmapLegend from "../components/heatmap/HeatmapLegend";
|
||||||
|
import type { TechniqueStatus } from "../types/models";
|
||||||
|
|
||||||
const TACTIC_ORDER = [
|
const TACTIC_ORDER = [
|
||||||
"reconnaissance",
|
"reconnaissance",
|
||||||
@@ -49,6 +51,14 @@ export default function MatrixPage() {
|
|||||||
const [platforms, setPlatforms] = useState<string[]>([]);
|
const [platforms, setPlatforms] = useState<string[]>([]);
|
||||||
const [selectedTactics, setSelectedTactics] = useState<string[]>([]);
|
const [selectedTactics, setSelectedTactics] = useState<string[]>([]);
|
||||||
const [minScore, setMinScore] = useState(0);
|
const [minScore, setMinScore] = useState(0);
|
||||||
|
// Only meaningful for layers that carry a real TechniqueStatus
|
||||||
|
// (coverage, threat-actor) — applied client-side since it's not a score
|
||||||
|
// threshold (review_required and not_covered share the same score).
|
||||||
|
const [statusFilter, setStatusFilter] = useState<TechniqueStatus | "all">("all");
|
||||||
|
|
||||||
|
// Matrix (tactic-columned grid) vs List (flat sortable table) — ported
|
||||||
|
// from the old standalone Techniques page.
|
||||||
|
const [viewMode, setViewMode] = useState<"matrix" | "list">("matrix");
|
||||||
|
|
||||||
// Zoom
|
// Zoom
|
||||||
const [zoom, setZoom] = useState<ZoomLevel>("normal");
|
const [zoom, setZoom] = useState<ZoomLevel>("normal");
|
||||||
@@ -104,7 +114,14 @@ export default function MatrixPage() {
|
|||||||
(activeLayer === "campaign" && !!selectedCampaignId),
|
(activeLayer === "campaign" && !!selectedCampaignId),
|
||||||
});
|
});
|
||||||
|
|
||||||
const techniques = layerData?.techniques || [];
|
const allTechniques = layerData?.techniques || [];
|
||||||
|
|
||||||
|
const layerHasStatus = activeLayer === "coverage" || activeLayer === "threat-actor";
|
||||||
|
|
||||||
|
const techniques = useMemo(() => {
|
||||||
|
if (statusFilter === "all" || !layerHasStatus) return allTechniques;
|
||||||
|
return allTechniques.filter((t) => t.status === statusFilter);
|
||||||
|
}, [allTechniques, statusFilter, layerHasStatus]);
|
||||||
|
|
||||||
// Handle cell click - navigate to technique detail
|
// Handle cell click - navigate to technique detail
|
||||||
const handleCellClick = useCallback(
|
const handleCellClick = useCallback(
|
||||||
@@ -182,8 +199,34 @@ export default function MatrixPage() {
|
|||||||
onCampaignChange={setSelectedCampaignId}
|
onCampaignChange={setSelectedCampaignId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Right side: Export + Zoom */}
|
{/* Right side: View toggle + Export + Zoom */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Matrix / List view toggle */}
|
||||||
|
<div className="flex items-center gap-1 rounded-lg border border-gray-700 bg-gray-800 p-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode("matrix")}
|
||||||
|
className={`flex items-center gap-1.5 rounded px-2.5 py-1 text-xs transition-colors ${
|
||||||
|
viewMode === "matrix"
|
||||||
|
? "bg-cyan-500/20 text-cyan-400"
|
||||||
|
: "text-gray-400 hover:text-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Grid3X3 className="h-3.5 w-3.5" />
|
||||||
|
Matrix
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode("list")}
|
||||||
|
className={`flex items-center gap-1.5 rounded px-2.5 py-1 text-xs transition-colors ${
|
||||||
|
viewMode === "list"
|
||||||
|
? "bg-cyan-500/20 text-cyan-400"
|
||||||
|
: "text-gray-400 hover:text-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<List className="h-3.5 w-3.5" />
|
||||||
|
List
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Export dropdown */}
|
{/* Export dropdown */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
@@ -211,7 +254,8 @@ export default function MatrixPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Zoom controls */}
|
{/* Zoom controls — matrix view only */}
|
||||||
|
{viewMode === "matrix" && (
|
||||||
<div className="flex items-center rounded-lg border border-gray-700 bg-gray-800">
|
<div className="flex items-center rounded-lg border border-gray-700 bg-gray-800">
|
||||||
<button
|
<button
|
||||||
onClick={zoomOut}
|
onClick={zoomOut}
|
||||||
@@ -231,10 +275,28 @@ export default function MatrixPage() {
|
|||||||
<ZoomIn className="h-3.5 w-3.5" />
|
<ZoomIn className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
{layerHasStatus && (
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value as TechniqueStatus | "all")}
|
||||||
|
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="validated">Validated</option>
|
||||||
|
<option value="partial">Partial</option>
|
||||||
|
<option value="in_progress">In Progress</option>
|
||||||
|
<option value="not_covered">Not Covered</option>
|
||||||
|
<option value="not_evaluated">Not Evaluated</option>
|
||||||
|
<option value="review_required">Review Required</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<HeatmapFiltersComponent
|
<HeatmapFiltersComponent
|
||||||
platforms={platforms}
|
platforms={platforms}
|
||||||
onPlatformsChange={setPlatforms}
|
onPlatformsChange={setPlatforms}
|
||||||
@@ -272,6 +334,8 @@ export default function MatrixPage() {
|
|||||||
<AlertCircle className="h-10 w-10 text-red-400" />
|
<AlertCircle className="h-10 w-10 text-red-400" />
|
||||||
<p className="text-red-400">Failed to load heatmap data</p>
|
<p className="text-red-400">Failed to load heatmap data</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : viewMode === "list" ? (
|
||||||
|
<HeatmapTechniqueTable techniques={techniques} />
|
||||||
) : (
|
) : (
|
||||||
<AdvancedHeatmap
|
<AdvancedHeatmap
|
||||||
techniques={techniques}
|
techniques={techniques}
|
||||||
|
|||||||
@@ -212,11 +212,11 @@ export default function TechniqueDetailPage() {
|
|||||||
<AlertCircle className="h-10 w-10 text-red-400" />
|
<AlertCircle className="h-10 w-10 text-red-400" />
|
||||||
<p className="text-red-400">Failed to load technique</p>
|
<p className="text-red-400">Failed to load technique</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate("/techniques")}
|
onClick={() => navigate("/matrix")}
|
||||||
className="mt-2 flex items-center gap-1 text-sm text-cyan-400 hover:underline"
|
className="mt-2 flex items-center gap-1 text-sm text-cyan-400 hover:underline"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
Back to techniques
|
Back to matrix
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -237,11 +237,11 @@ export default function TechniqueDetailPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Back button */}
|
{/* Back button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate("/techniques")}
|
onClick={() => navigate("/matrix")}
|
||||||
className="flex items-center gap-1 text-sm text-gray-400 hover:text-cyan-400 transition-colors"
|
className="flex items-center gap-1 text-sm text-gray-400 hover:text-cyan-400 transition-colors"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
Back to techniques
|
Back to matrix
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|||||||
@@ -1,326 +0,0 @@
|
|||||||
import { useState, useMemo } from "react";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Loader2, AlertCircle, Filter, X, Grid3X3, List, AlertTriangle } from "lucide-react";
|
|
||||||
import { getTechniques, type TechniqueSummary } from "../api/techniques";
|
|
||||||
import AttackMatrix from "../components/AttackMatrix";
|
|
||||||
import type { TechniqueStatus } from "../types/models";
|
|
||||||
import { useNavigate, Link } from "react-router-dom";
|
|
||||||
|
|
||||||
const STATUS_OPTIONS: { value: TechniqueStatus | "all"; label: string; color: string }[] = [
|
|
||||||
{ value: "all", label: "All Statuses", color: "text-gray-400" },
|
|
||||||
{ value: "validated", label: "Validated", color: "text-green-400" },
|
|
||||||
{ value: "partial", label: "Partial", color: "text-yellow-400" },
|
|
||||||
{ value: "in_progress", label: "In Progress", color: "text-blue-400" },
|
|
||||||
{ value: "not_covered", label: "Not Covered", color: "text-red-400" },
|
|
||||||
{ value: "not_evaluated", label: "Not Evaluated", color: "text-gray-400" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const PLATFORM_OPTIONS = ["all", "windows", "linux", "macos", "cloud", "network"] as const;
|
|
||||||
|
|
||||||
import StatusBadge from "../components/StatusBadge";
|
|
||||||
|
|
||||||
const statusBadgeColors: Record<TechniqueStatus, string> = {
|
|
||||||
validated: "bg-green-900/50 text-green-400 border-green-500/30",
|
|
||||||
partial: "bg-yellow-900/50 text-yellow-400 border-yellow-500/30",
|
|
||||||
in_progress: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
|
||||||
not_covered: "bg-red-900/50 text-red-400 border-red-500/30",
|
|
||||||
not_evaluated: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
|
||||||
review_required: "bg-orange-900/50 text-orange-400 border-orange-500/30",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function TechniquesPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [viewMode, setViewMode] = useState<"matrix" | "list">("matrix");
|
|
||||||
const [statusFilter, setStatusFilter] = useState<TechniqueStatus | "all">("all");
|
|
||||||
const [platformFilter, setPlatformFilter] = useState<string>("all");
|
|
||||||
const [tacticFilter, setTacticFilter] = useState<string>("all");
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: techniques,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["techniques"],
|
|
||||||
queryFn: () => getTechniques(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Extract unique tactics from techniques
|
|
||||||
const availableTactics = useMemo(() => {
|
|
||||||
if (!techniques) return [];
|
|
||||||
const tactics = new Set<string>();
|
|
||||||
for (const tech of techniques) {
|
|
||||||
if (tech.tactic) {
|
|
||||||
tech.tactic.split(",").forEach((t) => tactics.add(t.trim().toLowerCase()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Array.from(tactics).sort();
|
|
||||||
}, [techniques]);
|
|
||||||
|
|
||||||
// Apply filters
|
|
||||||
const filteredTechniques = useMemo(() => {
|
|
||||||
if (!techniques) return [];
|
|
||||||
|
|
||||||
return techniques.filter((tech: TechniqueSummary) => {
|
|
||||||
// Status filter
|
|
||||||
if (statusFilter !== "all" && tech.status_global !== statusFilter) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tactic filter
|
|
||||||
if (tacticFilter !== "all") {
|
|
||||||
const techTactics = tech.tactic?.split(",").map((t) => t.trim().toLowerCase()) || [];
|
|
||||||
if (!techTactics.includes(tacticFilter)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}, [techniques, statusFilter, tacticFilter]);
|
|
||||||
|
|
||||||
const hasActiveFilters = statusFilter !== "all" || tacticFilter !== "all" || platformFilter !== "all";
|
|
||||||
|
|
||||||
const clearFilters = () => {
|
|
||||||
setStatusFilter("all");
|
|
||||||
setPlatformFilter("all");
|
|
||||||
setTacticFilter("all");
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-64 items-center justify-center">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-cyan-400" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-64 flex-col items-center justify-center gap-2">
|
|
||||||
<AlertCircle className="h-10 w-10 text-red-400" />
|
|
||||||
<p className="text-red-400">Failed to load techniques</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-white">ATT&CK Techniques</h1>
|
|
||||||
<p className="mt-1 text-sm text-gray-400">
|
|
||||||
MITRE ATT&CK coverage matrix — click any technique for details
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 rounded-lg border border-gray-700 bg-gray-800 p-1">
|
|
||||||
<button
|
|
||||||
onClick={() => setViewMode("matrix")}
|
|
||||||
className={`flex items-center gap-1.5 rounded px-3 py-1.5 text-sm transition-colors ${
|
|
||||||
viewMode === "matrix"
|
|
||||||
? "bg-cyan-500/20 text-cyan-400"
|
|
||||||
: "text-gray-400 hover:text-gray-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Grid3X3 className="h-4 w-4" />
|
|
||||||
Matrix
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setViewMode("list")}
|
|
||||||
className={`flex items-center gap-1.5 rounded px-3 py-1.5 text-sm transition-colors ${
|
|
||||||
viewMode === "list"
|
|
||||||
? "bg-cyan-500/20 text-cyan-400"
|
|
||||||
: "text-gray-400 hover:text-gray-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<List className="h-4 w-4" />
|
|
||||||
List
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Legend */}
|
|
||||||
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
|
|
||||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-gray-500">Coverage legend</p>
|
|
||||||
<div className="flex flex-wrap gap-x-6 gap-y-3">
|
|
||||||
|
|
||||||
{/* Validated */}
|
|
||||||
<div className="flex items-start gap-2.5">
|
|
||||||
<div className="mt-0.5 h-8 w-8 shrink-0 rounded-md border border-green-500/50 bg-green-900/40" />
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold text-green-400">Validated</p>
|
|
||||||
<p className="text-xs text-gray-500">Detected in ≥2 approved tests</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Partial */}
|
|
||||||
<div className="flex items-start gap-2.5">
|
|
||||||
<div className="mt-0.5 h-8 w-8 shrink-0 rounded-md border border-yellow-500/50 bg-yellow-900/40" />
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold text-yellow-400">Partial</p>
|
|
||||||
<p className="text-xs text-gray-500">Some tests validated, coverage incomplete</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* In Progress */}
|
|
||||||
<div className="flex items-start gap-2.5">
|
|
||||||
<div className="mt-0.5 h-8 w-8 shrink-0 rounded-md border border-blue-500/50 bg-blue-900/40" />
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold text-blue-400">In Progress</p>
|
|
||||||
<p className="text-xs text-gray-500">Tests active, awaiting validation</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Not Covered */}
|
|
||||||
<div className="flex items-start gap-2.5">
|
|
||||||
<div className="mt-0.5 h-8 w-8 shrink-0 rounded-md border border-red-500/50 bg-red-900/40" />
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold text-red-400">Not Covered</p>
|
|
||||||
<p className="text-xs text-gray-500">Tested but not detected — confirmed gap</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Not Evaluated */}
|
|
||||||
<div className="flex items-start gap-2.5">
|
|
||||||
<div className="mt-0.5 h-8 w-8 shrink-0 rounded-md border border-gray-600/50 bg-gray-800/40" />
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold text-gray-400">Not Evaluated</p>
|
|
||||||
<p className="text-xs text-gray-500">No tests created yet — unknown risk</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Review Required */}
|
|
||||||
<div className="flex items-start gap-2.5">
|
|
||||||
<div className="relative mt-0.5 h-8 w-8 shrink-0 rounded-md border border-yellow-500/50 bg-yellow-900/40">
|
|
||||||
<div className="absolute -right-1 -top-1 rounded-full bg-orange-500 p-0.5">
|
|
||||||
<AlertTriangle className="h-2.5 w-2.5 text-white" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold text-yellow-400">Review Required</p>
|
|
||||||
<p className="text-xs text-gray-500">Pending lead validation or re-evaluation</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<div className="flex flex-wrap items-center gap-4 rounded-xl border border-gray-800 bg-gray-900 p-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Filter className="h-4 w-4 text-gray-400" />
|
|
||||||
<span className="text-sm font-medium text-gray-400">Filters:</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status filter */}
|
|
||||||
<select
|
|
||||||
value={statusFilter}
|
|
||||||
onChange={(e) => setStatusFilter(e.target.value as TechniqueStatus | "all")}
|
|
||||||
className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
|
|
||||||
>
|
|
||||||
{STATUS_OPTIONS.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{/* Tactic filter */}
|
|
||||||
<select
|
|
||||||
value={tacticFilter}
|
|
||||||
onChange={(e) => setTacticFilter(e.target.value)}
|
|
||||||
className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
|
|
||||||
>
|
|
||||||
<option value="all">All Tactics</option>
|
|
||||||
{availableTactics.map((tactic) => (
|
|
||||||
<option key={tactic} value={tactic}>
|
|
||||||
{tactic
|
|
||||||
.split("-")
|
|
||||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
||||||
.join(" ")}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{/* Platform filter */}
|
|
||||||
<select
|
|
||||||
value={platformFilter}
|
|
||||||
onChange={(e) => setPlatformFilter(e.target.value)}
|
|
||||||
className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
|
|
||||||
>
|
|
||||||
{PLATFORM_OPTIONS.map((platform) => (
|
|
||||||
<option key={platform} value={platform}>
|
|
||||||
{platform === "all" ? "All Platforms" : platform.charAt(0).toUpperCase() + platform.slice(1)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<button
|
|
||||||
onClick={clearFilters}
|
|
||||||
className="flex items-center gap-1 rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-400 hover:border-red-500/50 hover:text-red-400"
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="ml-auto text-sm text-gray-500">
|
|
||||||
Showing {filteredTechniques.length} of {techniques?.length || 0} techniques
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Matrix or List View */}
|
|
||||||
{viewMode === "matrix" ? (
|
|
||||||
<AttackMatrix techniques={filteredTechniques} />
|
|
||||||
) : (
|
|
||||||
<div className="rounded-xl border border-gray-800 bg-gray-900">
|
|
||||||
<table className="w-full text-left text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-gray-800">
|
|
||||||
<th className="px-4 py-3 font-medium text-gray-400">MITRE ID</th>
|
|
||||||
<th className="px-4 py-3 font-medium text-gray-400">Name</th>
|
|
||||||
<th className="px-4 py-3 font-medium text-gray-400">Tactic</th>
|
|
||||||
<th className="px-4 py-3 font-medium text-gray-400">Status</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{filteredTechniques.map((tech) => (
|
|
||||||
<tr
|
|
||||||
key={tech.id}
|
|
||||||
onClick={() => navigate(`/techniques/${tech.mitre_id}`)}
|
|
||||||
className="cursor-pointer border-b border-gray-800/50 hover:bg-gray-800/50 transition-colors"
|
|
||||||
>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<Link
|
|
||||||
to={`/techniques/${tech.mitre_id}`}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
className="font-mono text-cyan-400 hover:underline"
|
|
||||||
>
|
|
||||||
{tech.mitre_id}
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-gray-200">{tech.name}</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<span className="text-gray-400 capitalize">
|
|
||||||
{tech.tactic?.replace(/-/g, " ") || "—"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 overflow-visible">
|
|
||||||
<StatusBadge status={tech.status_global} />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{filteredTechniques.length === 0 && (
|
|
||||||
<div className="p-8 text-center text-gray-400">
|
|
||||||
No techniques found matching your filters
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user