From d660bceeb4b41bb63ba34bb0e0716b3c985e6b26 Mon Sep 17 00:00:00 2001 From: Kitos Date: Mon, 9 Feb 2026 10:57:48 +0100 Subject: [PATCH] feat(phase-13): update frontend types and API clients for Red/Blue workflow (T-113, T-114) T-113: Rewrite models.ts with v2 types - TestState now includes red_executing/blue_evaluating, add TeamSide, ValidationStatus, TestTemplate, TestTemplateSummary, TestTimelineEntry types, RED_EDITABLE_STATES/BLUE_EDITABLE_STATES constants, and dual validation fields on Test interface. Remove old validated_by/validated_at references from TestDetailPage and techniques API. T-114: Rewrite tests.ts API client with 16 functions covering full Red/Blue workflow (createTestFromTemplate, updateTestRed/Blue, startExecution, submitRed/Blue, validateAsRedLead/BlueLead, reopenTest, getTestTimeline). Rewrite evidence.ts with team parameter on upload/list and new deleteEvidence. Create test-templates.ts with getTemplates, getTemplateById, getTemplatesByTechnique, createTemplate, importAtomicTests. --- frontend/src/api/evidence.ts | 74 ++++++++-- frontend/src/api/techniques.ts | 29 +--- frontend/src/api/test-templates.ts | 106 ++++++++++++++ frontend/src/api/tests.ts | 190 +++++++++++++++++++++++--- frontend/src/pages/TestDetailPage.tsx | 20 ++- frontend/src/types/models.ts | 96 ++++++++++++- 6 files changed, 447 insertions(+), 68 deletions(-) create mode 100644 frontend/src/api/test-templates.ts diff --git a/frontend/src/api/evidence.ts b/frontend/src/api/evidence.ts index ad64bdc..c4c75ce 100644 --- a/frontend/src/api/evidence.ts +++ b/frontend/src/api/evidence.ts @@ -1,30 +1,76 @@ import client from "./client"; +import type { Evidence, TeamSide } from "../types/models"; -export interface EvidenceOut { - id: string; - test_id: string; - file_name: string; - sha256_hash: string; - uploaded_by: string | null; - uploaded_at: string; +// ── Response type (with download URL) ────────────────────────────── + +export interface EvidenceOut extends Evidence { download_url: string; } -/** Upload evidence file for a test. */ -export async function uploadEvidence(testId: string, file: File): Promise { +// ── Upload ───────────────────────────────────────────────────────── + +/** Upload an evidence file for the given test. + * + * The ``team`` field is sent as form data alongside the file so the + * backend can enforce Red/Blue access control. + */ +export async function uploadEvidence( + testId: string, + file: File, + team: TeamSide, + notes?: string, +): Promise { const formData = new FormData(); formData.append("file", file); + formData.append("team", team); + if (notes) { + formData.append("notes", notes); + } - const { data } = await client.post(`/tests/${testId}/evidence`, formData, { - headers: { - "Content-Type": "multipart/form-data", + const { data } = await client.post( + `/tests/${testId}/evidence`, + formData, + { + headers: { + "Content-Type": "multipart/form-data", + }, }, - }); + ); return data; } -/** Get evidence metadata with download URL. */ +// ── List ─────────────────────────────────────────────────────────── + +/** List evidences for a test, optionally filtered by team. */ +export async function getTestEvidences( + testId: string, + team?: TeamSide, +): Promise { + const params = new URLSearchParams(); + if (team) params.append("team", team); + + const { data } = await client.get( + `/tests/${testId}/evidence${params.toString() ? `?${params}` : ""}`, + ); + return data; +} + +// ── Detail ───────────────────────────────────────────────────────── + +/** Get evidence metadata with presigned download URL. */ export async function getEvidence(evidenceId: string): Promise { const { data } = await client.get(`/evidence/${evidenceId}`); return data; } + +// ── Delete ───────────────────────────────────────────────────────── + +/** Delete an evidence record (only in editable states). */ +export async function deleteEvidence( + evidenceId: string, +): Promise<{ detail: string }> { + const { data } = await client.delete<{ detail: string }>( + `/evidence/${evidenceId}`, + ); + return data; +} diff --git a/frontend/src/api/techniques.ts b/frontend/src/api/techniques.ts index dba8fce..cd28678 100644 --- a/frontend/src/api/techniques.ts +++ b/frontend/src/api/techniques.ts @@ -1,5 +1,5 @@ import client from "./client"; -import type { Technique, TechniqueStatus } from "../types/models"; +import type { Technique, TechniqueStatus, Test, IntelItem } from "../types/models"; /** Summary representation used in list endpoints. */ export interface TechniqueSummary { @@ -13,31 +13,8 @@ export interface TechniqueSummary { /** Extended technique with tests for detail view. */ export interface TechniqueWithTests extends Technique { - tests?: Array<{ - id: string; - technique_id: string; - name: string; - description: string | null; - platform: string | null; - procedure_text: string | null; - tool_used: string | null; - execution_date: string | null; - created_by: string | null; - result: "detected" | "not_detected" | "partially_detected" | null; - state: "draft" | "in_review" | "validated" | "rejected"; - validated_by: string | null; - validated_at: string | null; - created_at: string; - }>; - intel_items?: Array<{ - id: string; - technique_id: string | null; - url: string; - title: string | null; - source: string | null; - detected_at: string; - reviewed: boolean; - }>; + tests?: Test[]; + intel_items?: IntelItem[]; } export interface TechniqueFilters { diff --git a/frontend/src/api/test-templates.ts b/frontend/src/api/test-templates.ts new file mode 100644 index 0000000..1e02b2f --- /dev/null +++ b/frontend/src/api/test-templates.ts @@ -0,0 +1,106 @@ +import client from "./client"; +import type { TestTemplate, TestTemplateSummary } from "../types/models"; + +// ── Filters ──────────────────────────────────────────────────────── + +export interface TemplateFilters { + source?: string; + platform?: string; + severity?: string; + mitre_technique_id?: string; + search?: string; + offset?: number; + limit?: number; +} + +// ── Create payload ───────────────────────────────────────────────── + +export interface CreateTemplatePayload { + mitre_technique_id: string; + name: string; + description?: string; + source?: string; + source_url?: string; + attack_procedure?: string; + expected_detection?: string; + platform?: string; + tool_suggested?: string; + severity?: string; + atomic_test_id?: string; +} + +// ── Import response ──────────────────────────────────────────────── + +export interface ImportAtomicResponse { + message: string; + imported: number; + skipped: number; + total_parsed: number; +} + +// ── List (paginated, filtered) ───────────────────────────────────── + +/** Fetch a paginated, filtered list of test templates. */ +export async function getTemplates( + filters?: TemplateFilters, +): Promise { + const params = new URLSearchParams(); + if (filters?.source) params.append("source", filters.source); + if (filters?.platform) params.append("platform", filters.platform); + if (filters?.severity) params.append("severity", filters.severity); + if (filters?.mitre_technique_id) + params.append("mitre_technique_id", filters.mitre_technique_id); + if (filters?.search) params.append("search", filters.search); + if (filters?.offset !== undefined) + params.append("offset", String(filters.offset)); + if (filters?.limit !== undefined) + params.append("limit", String(filters.limit)); + + const { data } = await client.get( + `/test-templates${params.toString() ? `?${params}` : ""}`, + ); + return data; +} + +// ── Detail ───────────────────────────────────────────────────────── + +/** Fetch a single test template by ID. */ +export async function getTemplateById(id: string): Promise { + const { data } = await client.get(`/test-templates/${id}`); + return data; +} + +// ── By technique ─────────────────────────────────────────────────── + +/** Fetch all templates mapped to a specific MITRE technique. */ +export async function getTemplatesByTechnique( + mitreId: string, +): Promise { + const { data } = await client.get( + `/test-templates/by-technique/${mitreId}`, + ); + return data; +} + +// ── Create (admin) ───────────────────────────────────────────────── + +/** Create a custom test template. Admin only. */ +export async function createTemplate( + payload: CreateTemplatePayload, +): Promise { + const { data } = await client.post( + "/test-templates", + payload, + ); + return data; +} + +// ── Import Atomic Red Team ───────────────────────────────────────── + +/** Trigger Atomic Red Team import. Admin only. */ +export async function importAtomicTests(): Promise { + const { data } = await client.post( + "/system/import-atomic-tests", + ); + return data; +} diff --git a/frontend/src/api/tests.ts b/frontend/src/api/tests.ts index f8184b2..ca020fd 100644 --- a/frontend/src/api/tests.ts +++ b/frontend/src/api/tests.ts @@ -1,5 +1,12 @@ import client from "./client"; -import type { Test, TestResult } from "../types/models"; +import type { + Test, + TestResult, + TestState, + TestTimelineEntry, +} from "../types/models"; + +// ── Payloads ─────────────────────────────────────────────────────── export interface TestCreatePayload { technique_id: string; @@ -19,21 +26,57 @@ export interface TestUpdatePayload { result?: TestResult; } +export interface RedUpdatePayload { + name?: string; + description?: string; + procedure_text?: string; + tool_used?: string; + attack_success?: boolean; + red_summary?: string; +} + +export interface BlueUpdatePayload { + detection_result?: TestResult; + blue_summary?: string; +} + +export interface RedValidationPayload { + red_validation_status: "approved" | "rejected"; + red_validation_notes?: string; +} + +export interface BlueValidationPayload { + blue_validation_status: "approved" | "rejected"; + blue_validation_notes?: string; +} + +/** Legacy payload — kept for backwards compat. */ export interface TestValidatePayload { result: TestResult; comments?: string; } -export interface TestWithEvidences extends Test { - evidences?: Array<{ - id: string; - test_id: string; - file_name: string; - sha256_hash: string; - uploaded_by: string | null; - uploaded_at: string; - download_url?: string; - }>; +export interface TestListFilters { + state?: TestState; + technique_id?: string; + offset?: number; + limit?: number; +} + +// ── CRUD ─────────────────────────────────────────────────────────── + +/** List tests with optional filters. */ +export async function getTests(filters?: TestListFilters): Promise { + const params = new URLSearchParams(); + if (filters?.state) params.append("state", filters.state); + if (filters?.technique_id) params.append("technique_id", filters.technique_id); + if (filters?.offset !== undefined) params.append("offset", String(filters.offset)); + if (filters?.limit !== undefined) params.append("limit", String(filters.limit)); + + const { data } = await client.get( + `/tests${params.toString() ? `?${params}` : ""}`, + ); + return data; } /** Create a new test. */ @@ -42,25 +85,134 @@ export async function createTest(payload: TestCreatePayload): Promise { return data; } -/** Get test by ID with evidences. */ -export async function getTestById(testId: string): Promise { - const { data } = await client.get(`/tests/${testId}`); +/** Create a test from an existing template. */ +export async function createTestFromTemplate( + templateId: string, + techniqueId: string, +): Promise { + const { data } = await client.post("/tests/from-template", { + template_id: templateId, + technique_id: techniqueId, + }); + return data; +} + +/** Get test by ID (with evidences). */ +export async function getTestById(testId: string): Promise { + const { data } = await client.get(`/tests/${testId}`); return data; } /** Update a test (only draft/rejected). */ -export async function updateTest(testId: string, payload: TestUpdatePayload): Promise { +export async function updateTest( + testId: string, + payload: TestUpdatePayload, +): Promise { const { data } = await client.patch(`/tests/${testId}`, payload); return data; } -/** Validate a test. */ -export async function validateTest(testId: string, payload: TestValidatePayload): Promise { - const { data } = await client.post(`/tests/${testId}/validate`, payload); +// ── Red Team ─────────────────────────────────────────────────────── + +/** Red Team updates their fields (draft, red_executing). */ +export async function updateTestRed( + testId: string, + payload: RedUpdatePayload, +): Promise { + const { data } = await client.patch(`/tests/${testId}/red`, payload); return data; } -/** Reject a test. */ +/** Move test from draft → red_executing. */ +export async function startExecution(testId: string): Promise { + const { data } = await client.post(`/tests/${testId}/start-execution`); + return data; +} + +/** Red Team finalises — red_executing → blue_evaluating. */ +export async function submitRedEvidence(testId: string): Promise { + const { data } = await client.post(`/tests/${testId}/submit-red`); + return data; +} + +// ── Blue Team ────────────────────────────────────────────────────── + +/** Blue Team updates their fields (blue_evaluating only). */ +export async function updateTestBlue( + testId: string, + payload: BlueUpdatePayload, +): Promise { + const { data } = await client.patch(`/tests/${testId}/blue`, payload); + return data; +} + +/** Blue Team finalises — blue_evaluating → in_review. */ +export async function submitBlueEvidence(testId: string): Promise { + const { data } = await client.post(`/tests/${testId}/submit-blue`); + return data; +} + +// ── Lead Validation ──────────────────────────────────────────────── + +/** Red Lead approves/rejects the red side. */ +export async function validateAsRedLead( + testId: string, + payload: RedValidationPayload, +): Promise { + const { data } = await client.post( + `/tests/${testId}/validate-red`, + payload, + ); + return data; +} + +/** Blue Lead approves/rejects the blue side. */ +export async function validateAsBlueLead( + testId: string, + payload: BlueValidationPayload, +): Promise { + const { data } = await client.post( + `/tests/${testId}/validate-blue`, + payload, + ); + return data; +} + +// ── Reopen ───────────────────────────────────────────────────────── + +/** Reopen a rejected test — moves back to draft. */ +export async function reopenTest(testId: string): Promise { + const { data } = await client.post(`/tests/${testId}/reopen`); + return data; +} + +// ── Timeline ─────────────────────────────────────────────────────── + +/** Get the audit-log timeline for a test. */ +export async function getTestTimeline( + testId: string, +): Promise { + const { data } = await client.get( + `/tests/${testId}/timeline`, + ); + return data; +} + +// ── Legacy (kept for backwards compat) ───────────────────────────── + +/** Validate a test (legacy endpoint). */ +export async function validateTest( + testId: string, + payload: TestValidatePayload, +): Promise { + const { data } = await client.post( + `/tests/${testId}/validate`, + payload, + ); + return data; +} + +/** Reject a test (legacy endpoint). */ export async function rejectTest(testId: string): Promise { const { data } = await client.post(`/tests/${testId}/reject`); return data; diff --git a/frontend/src/pages/TestDetailPage.tsx b/frontend/src/pages/TestDetailPage.tsx index 4113900..e4f87e0 100644 --- a/frontend/src/pages/TestDetailPage.tsx +++ b/frontend/src/pages/TestDetailPage.tsx @@ -20,6 +20,8 @@ import { useState } from "react"; const testStateBadgeColors: Record = { draft: "bg-gray-800/50 text-gray-400 border-gray-600/30", + red_executing: "bg-orange-900/50 text-orange-400 border-orange-500/30", + blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30", in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30", validated: "bg-green-900/50 text-green-400 border-green-500/30", rejected: "bg-red-900/50 text-red-400 border-red-500/30", @@ -60,7 +62,7 @@ export default function TestDetailPage() { }); const uploadMutation = useMutation({ - mutationFn: (file: File) => uploadEvidence(testId!, file), + mutationFn: (file: File) => uploadEvidence(testId!, file, "red"), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["test", testId] }); }, @@ -248,7 +250,7 @@ export default function TestDetailPage() { {/* Evidence List */}
@@ -277,11 +279,19 @@ export default function TestDetailPage() { {formatDate(test.execution_date)} - {test.validated_at && ( + {test.red_validated_at && (
-
Validated
+
Red Validated
- {formatDate(test.validated_at)} + {formatDate(test.red_validated_at)} +
+
+ )} + {test.blue_validated_at && ( +
+
Blue Validated
+
+ {formatDate(test.blue_validated_at)}
)} diff --git a/frontend/src/types/models.ts b/frontend/src/types/models.ts index 16ff6df..31cacee 100644 --- a/frontend/src/types/models.ts +++ b/frontend/src/types/models.ts @@ -1,11 +1,15 @@ /* ── Shared TypeScript types matching the backend Pydantic schemas ── */ +// ── Users ────────────────────────────────────────────────────────── + export interface User { id: string; username: string; role: string; } +// ── Techniques ───────────────────────────────────────────────────── + export interface Technique { id: string; mitre_id: string; @@ -30,6 +34,28 @@ export type TechniqueStatus = | "not_covered" | "review_required"; +// ── Tests (v2 — Red/Blue dual validation) ────────────────────────── + +export type TestState = + | "draft" + | "red_executing" + | "blue_evaluating" + | "in_review" + | "validated" + | "rejected"; + +export type TestResult = "detected" | "not_detected" | "partially_detected"; + +export type ValidationStatus = "pending" | "approved" | "rejected"; + +export type TeamSide = "red" | "blue"; + +/** States in which Red Team can edit / upload evidence. */ +export const RED_EDITABLE_STATES: TestState[] = ["draft", "red_executing"]; + +/** States in which Blue Team can edit / upload evidence. */ +export const BLUE_EDITABLE_STATES: TestState[] = ["blue_evaluating"]; + export interface Test { id: string; technique_id: string; @@ -42,13 +68,30 @@ export interface Test { created_by: string | null; result: TestResult | null; state: TestState; - validated_by: string | null; - validated_at: string | null; created_at: string; + + // Red Team fields + red_summary: string | null; + attack_success: boolean | null; + red_validated_by: string | null; + red_validated_at: string | null; + red_validation_status: ValidationStatus | null; + red_validation_notes: string | null; + + // Blue Team fields + blue_summary: string | null; + detection_result: TestResult | null; + blue_validated_by: string | null; + blue_validated_at: string | null; + blue_validation_status: ValidationStatus | null; + blue_validation_notes: string | null; + + // Separated evidences + red_evidences: Evidence[]; + blue_evidences: Evidence[]; } -export type TestState = "draft" | "in_review" | "validated" | "rejected"; -export type TestResult = "detected" | "not_detected" | "partially_detected"; +// ── Evidence (v2 — with team) ────────────────────────────────────── export interface Evidence { id: string; @@ -58,8 +101,51 @@ export interface Evidence { sha256_hash: string; uploaded_by: string | null; uploaded_at: string; + team: TeamSide; + notes: string | null; + download_url?: string; } +// ── Test Templates ───────────────────────────────────────────────── + +export interface TestTemplate { + id: string; + mitre_technique_id: string; + name: string; + description: string | null; + source: string; + source_url: string | null; + attack_procedure: string | null; + expected_detection: string | null; + platform: string | null; + tool_suggested: string | null; + severity: string | null; + atomic_test_id: string | null; + is_active: boolean; + created_at: string; +} + +export interface TestTemplateSummary { + id: string; + mitre_technique_id: string; + name: string; + source: string; + platform: string | null; + severity: string | null; +} + +// ── Timeline ─────────────────────────────────────────────────────── + +export interface TestTimelineEntry { + id: string; + action: string; + user_id: string | null; + timestamp: string; + details: Record; +} + +// ── Intel ────────────────────────────────────────────────────────── + export interface IntelItem { id: string; technique_id: string | null; @@ -70,6 +156,8 @@ export interface IntelItem { reviewed: boolean; } +// ── Metrics ──────────────────────────────────────────────────────── + export interface CoverageSummary { total_techniques: number; validated: number;