diff --git a/frontend/src/api/reports.ts b/frontend/src/api/reports.ts index 13c4aa5..8b15525 100644 --- a/frontend/src/api/reports.ts +++ b/frontend/src/api/reports.ts @@ -1,4 +1,5 @@ import client from "./client"; +import type { AttackSuccessResult } from "../types/models"; // ── Types ─────────────────────────────────────────────────────────── @@ -42,7 +43,7 @@ export interface TestResultsReport { technique_id: string; state: string; platform: string | null; - attack_success: boolean | null; + attack_success: AttackSuccessResult | null; detection_result: string | null; red_validation_status: string | null; blue_validation_status: string | null; diff --git a/frontend/src/api/tests.ts b/frontend/src/api/tests.ts index 5f12da4..b1a3f7f 100644 --- a/frontend/src/api/tests.ts +++ b/frontend/src/api/tests.ts @@ -3,6 +3,8 @@ import type { Test, TestResult, ContainmentResult, + AttackSuccessResult, + DataClassification, TestState, TestTimelineEntry, } from "../types/models"; @@ -32,7 +34,7 @@ export interface RedUpdatePayload { description?: string; procedure_text?: string; tool_used?: string; - attack_success?: boolean; + attack_success?: AttackSuccessResult; red_summary?: string; execution_start_time?: string; execution_end_time?: string; @@ -137,6 +139,17 @@ export async function updateTest( return data; } +/** Update the data classification label for a test (admin only). */ +export async function updateTestClassification( + testId: string, + dataClassification: DataClassification, +): Promise { + const { data } = await client.patch(`/tests/${testId}/classification`, { + data_classification: dataClassification, + }); + return data; +} + // ── Red Team ─────────────────────────────────────────────────────── /** Red Team updates their fields (draft, red_executing). */ @@ -365,7 +378,7 @@ export interface RTEvidenceEntry { export interface RTTechniqueEntry { mitre_id: string; result: "detected" | "not_detected" | "partially_detected"; - attack_success: boolean; + attack_success: AttackSuccessResult; platform?: string; notes?: string; evidence: RTEvidenceEntry[]; // required — at least 1 image @@ -382,7 +395,7 @@ export interface RTImportPayload { export interface RTImportResult { created: number; skipped: number; - items: { mitre_id: string; test_name: string; result: string; attack_success: boolean; evidence_attached: number }[]; + items: { mitre_id: string; test_name: string; result: string; attack_success: AttackSuccessResult; evidence_attached: number }[]; warnings: { mitre_id: string; reason: string }[]; engagement: string; } diff --git a/frontend/src/components/test-detail/DataClassificationBadge.tsx b/frontend/src/components/test-detail/DataClassificationBadge.tsx new file mode 100644 index 0000000..0a6cc54 --- /dev/null +++ b/frontend/src/components/test-detail/DataClassificationBadge.tsx @@ -0,0 +1,84 @@ +import { useState } from "react"; +import { ChevronDown, ChevronUp, Loader2, Lock } from "lucide-react"; +import type { DataClassification } from "../../types/models"; + +// ── Options ──────────────────────────────────────────────────────── + +const CLASSIFICATION_OPTIONS: { value: DataClassification; label: string; color: string }[] = [ + { value: "public", label: "Public", color: "border-gray-500/30 bg-gray-800/50 text-gray-400" }, + { value: "internal", label: "Internal", color: "border-blue-500/30 bg-blue-900/30 text-blue-400" }, + { value: "sensitive", label: "Sensitive", color: "border-amber-500/30 bg-amber-900/30 text-amber-400" }, + { value: "restricted", label: "Restricted", color: "border-red-500/30 bg-red-900/30 text-red-400" }, +]; + +// ── Props ────────────────────────────────────────────────────────── + +interface DataClassificationBadgeProps { + value: DataClassification; + canEdit: boolean; + isSaving: boolean; + onChange: (value: DataClassification) => void; +} + +// ── Component ────────────────────────────────────────────────────── + +export default function DataClassificationBadge({ + value, + canEdit, + isSaving, + onChange, +}: DataClassificationBadgeProps) { + const [expanded, setExpanded] = useState(false); + const current = CLASSIFICATION_OPTIONS.find((o) => o.value === value) ?? CLASSIFICATION_OPTIONS[1]; + + if (!canEdit) { + return ( + + + {current.label} + + ); + } + + return ( +
+ + + {expanded && ( +
+ {isSaving ? ( +
+ +
+ ) : ( + CLASSIFICATION_OPTIONS.map((opt) => ( + + )) + )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/test-detail/TeamTabs.tsx b/frontend/src/components/test-detail/TeamTabs.tsx index 81596ac..a62d950 100644 --- a/frontend/src/components/test-detail/TeamTabs.tsx +++ b/frontend/src/components/test-detail/TeamTabs.tsx @@ -18,6 +18,7 @@ import type { Test, TestResult, ContainmentResult, + AttackSuccessResult, TeamSide, Evidence, TestTimelineEntry, @@ -61,11 +62,29 @@ const DETECTION_RESULTS: { value: TestResult; label: string; color: string }[] = ]; // ── Containment result options ───────────────────────────────────── +// Order matches Detection's full/none/partial pattern for consistency. const CONTAINMENT_RESULTS: { value: ContainmentResult; label: string; color: string }[] = [ { value: "contained", label: "Contained", color: "border-green-500 bg-green-500/10 text-green-400" }, - { value: "partially_contained", label: "Partially Contained", color: "border-yellow-500 bg-yellow-500/10 text-yellow-400" }, { value: "not_contained", label: "Not Contained", color: "border-red-500 bg-red-500/10 text-red-400" }, + { + value: "partially_contained", + label: "Partially Contained", + color: "border-yellow-500 bg-yellow-500/10 text-yellow-400", + }, +]; + +// ── Attack success options ────────────────────────────────────────── +// Order matches Detection's full/none/partial pattern for consistency. + +const ATTACK_SUCCESS_RESULTS: { value: AttackSuccessResult; label: string; color: string }[] = [ + { value: "successful", label: "Yes", color: "border-green-500 bg-green-500/10 text-green-400" }, + { value: "not_successful", label: "No", color: "border-red-500 bg-red-500/10 text-red-400" }, + { + value: "partially_successful", + label: "Partial", + color: "border-yellow-500 bg-yellow-500/10 text-yellow-400", + }, ]; // ── Props ────────────────────────────────────────────────────────── @@ -81,7 +100,7 @@ interface TeamTabsProps { redDraft: { procedure_text: string; tool_used: string; - attack_success: boolean; + attack_success: AttackSuccessResult | ""; red_summary: string; execution_start_time: string; execution_end_time: string; @@ -233,28 +252,45 @@ export default function TeamTabs({ )} - {/* Attack Success toggle */} + {/* Attack Success */}
-
@@ -660,12 +696,20 @@ export default function TeamTabs({
Attack Success
- {test.attack_success === null ? ( - Not set - ) : test.attack_success ? ( - Yes + {test.attack_success ? ( + + {ATTACK_SUCCESS_RESULTS.find((o) => o.value === test.attack_success)?.label} + ) : ( - No + Not set )}
diff --git a/frontend/src/components/test-detail/TestDetailHeader.tsx b/frontend/src/components/test-detail/TestDetailHeader.tsx index 81d701f..da2c36f 100644 --- a/frontend/src/components/test-detail/TestDetailHeader.tsx +++ b/frontend/src/components/test-detail/TestDetailHeader.tsx @@ -18,8 +18,9 @@ import { } from "lucide-react"; import { useMutation } from "@tanstack/react-query"; import { requestDiscussion } from "../../api/tests"; -import type { Test, TestState, User } from "../../types/models"; +import type { Test, TestState, User, DataClassification } from "../../types/models"; import LiveTimer from "./LiveTimer"; +import DataClassificationBadge from "./DataClassificationBadge"; // ── Progress steps ───────────────────────────────────────────────── @@ -87,6 +88,8 @@ interface TestDetailHeaderProps { onHold: () => void; onResume: () => void; isTogglingHold: boolean; + onUpdateClassification: (value: DataClassification) => void; + isUpdatingClassification: boolean; } // ── Component ────────────────────────────────────────────────────── @@ -109,6 +112,8 @@ export default function TestDetailHeader({ onHold, onResume, isTogglingHold, + onUpdateClassification, + isUpdatingClassification, }: TestDetailHeaderProps) { const role = user?.role ?? ""; const currentIdx = STATE_INDEX[test.state]; @@ -524,6 +529,12 @@ export default function TestDetailHeader({ > {getStateLabel(test)} +

Created {formatDate(test.created_at)} diff --git a/frontend/src/pages/ImportRTPage.tsx b/frontend/src/pages/ImportRTPage.tsx index eb10fbc..43799d9 100644 --- a/frontend/src/pages/ImportRTPage.tsx +++ b/frontend/src/pages/ImportRTPage.tsx @@ -34,7 +34,7 @@ const TEMPLATE_JSON: RTImportPayload = { { mitre_id: "T1059.001", result: "detected", - attack_success: true, + attack_success: "successful", platform: "windows", notes: "PowerShell execution caught by Defender for Endpoint within 2 min", evidence: [ @@ -48,7 +48,7 @@ const TEMPLATE_JSON: RTImportPayload = { { mitre_id: "T1078", result: "not_detected", - attack_success: true, + attack_success: "successful", platform: "windows", notes: "Credential reuse via stolen credentials — undetected for 48h", evidence: [ @@ -67,7 +67,7 @@ const TEMPLATE_JSON: RTImportPayload = { { mitre_id: "T1486", result: "not_detected", - attack_success: false, + attack_success: "not_successful", platform: "windows", notes: "Ransomware blocked by AppLocker before execution", evidence: [ @@ -81,7 +81,7 @@ const TEMPLATE_JSON: RTImportPayload = { { mitre_id: "T1190", result: "partially_detected", - attack_success: true, + attack_success: "partially_successful", platform: "linux", notes: "Exploit worked but only a partial alert fired — no full incident created", evidence: [ @@ -231,7 +231,7 @@ export default function ImportRTPage() { ["name", "string", "Engagement name"], ["techniques[].mitre_id", "string", "e.g. T1059.001"], ["techniques[].result", "enum", "detected | not_detected | partially_detected"], - ["techniques[].attack_success", "boolean", "Was the attack successful?"], + ["techniques[].attack_success", "enum", "successful | not_successful | partially_successful"], ["techniques[].evidence", "array", "Min 1 image required per technique"], ["techniques[].evidence[].filename", "string", "e.g. screenshot_edr.png"], ["techniques[].evidence[].data", "string", "Base64-encoded image (PNG/JPG/GIF/WebP/BMP, max 10 MB)"], @@ -299,7 +299,7 @@ export default function ImportRTPage() {