diff --git a/backend/app/routers/tests.py b/backend/app/routers/tests.py index 2da6958..0739791 100644 --- a/backend/app/routers/tests.py +++ b/backend/app/routers/tests.py @@ -20,16 +20,20 @@ GET /tests/{id}/timeline — audit-log history for this test """ import uuid -from typing import Optional +from datetime import datetime +from typing import Any, Optional from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from pydantic import BaseModel from sqlalchemy.orm import Session from app.database import get_db from app.dependencies.auth import get_current_user, require_any_role, require_role from app.domain.enums import DataClassification from app.limiter import limiter -from app.models.enums import TestState +from app.models.enums import TestState, TestResult +from app.models.technique import Technique +from app.models.test import Test from app.models.user import User from app.schemas.test import ( TestCreate, @@ -739,3 +743,140 @@ def sync_tempo( }) return {"results": results} + + +# --------------------------------------------------------------------------- +# POST /tests/import-rt — bulk import from a real Red Team engagement +# --------------------------------------------------------------------------- + + +class RTTechniqueEntry(BaseModel): + mitre_id: str + result: str # "detected" | "not_detected" | "partially_detected" + attack_success: bool = True + platform: Optional[str] = None + notes: Optional[str] = None + + +class RTImportPayload(BaseModel): + name: str # engagement name, e.g. "Red Team Q1 2024" + date: Optional[str] = None # ISO date string + description: Optional[str] = None + operator: Optional[str] = None # team / company that ran the RT + techniques: list[RTTechniqueEntry] + + +@router.post("/import-rt", status_code=status.HTTP_201_CREATED) +def import_rt( + payload: RTImportPayload, + db: Session = Depends(get_db), + current_user: User = Depends(require_any_role("red_lead")), +): + """Import results from a real Red Team engagement. + + Creates one Test record per technique in ``validated`` state (bypassing + the normal Red/Blue workflow) and immediately recalculates coverage metrics. + Requires ``red_lead`` or ``admin`` role. + """ + # Execution date from payload or now + exec_date_str = payload.date or datetime.utcnow().date().isoformat() + + # Result string → TestResult enum + _result_map = { + "detected": TestResult.detected, + "not_detected": TestResult.not_detected, + "partially_detected": TestResult.partially_detected, + } + + created: list[dict[str, Any]] = [] + skipped: list[dict[str, str]] = [] + affected_technique_ids: set = set() + + with UnitOfWork(db) as uow: + for entry in payload.techniques: + # Find technique + technique = ( + db.query(Technique) + .filter(Technique.mitre_id == entry.mitre_id.upper()) + .first() + ) + if technique is None: + skipped.append({"mitre_id": entry.mitre_id, "reason": "Technique not found"}) + continue + + detection_result = _result_map.get(entry.result) + if detection_result is None: + skipped.append({"mitre_id": entry.mitre_id, "reason": f"Unknown result value '{entry.result}'"}) + continue + + test_name = f"[RT] {payload.name} — {technique.name}" + + # Build red_summary from notes + engagement metadata + parts = [] + if payload.operator: + parts.append(f"Operator: {payload.operator}") + parts.append(f"Engagement date: {exec_date_str}") + if entry.notes: + parts.append(f"\n{entry.notes}") + red_summary_text = "\n".join(parts) + + # Create Test directly in validated state + test = Test( + technique_id=technique.id, + name=test_name, + description=payload.description, + platform=entry.platform, + procedure_text=entry.notes, + created_by=current_user.id, + state=TestState.validated, + # Red team fields + attack_success=entry.attack_success, + red_summary=red_summary_text, + red_validation_status="approved", + red_validated_by=current_user.id, + red_validated_at=datetime.utcnow(), + # Blue team fields + detection_result=detection_result, + blue_validation_status="approved", + blue_validated_by=current_user.id, + blue_validated_at=datetime.utcnow(), + # Timing + execution_date=exec_date_str, + created_at=datetime.utcnow(), + ) + db.add(test) + db.flush() + + affected_technique_ids.add(technique.id) + created.append({ + "mitre_id": entry.mitre_id, + "test_name": test_name, + "result": entry.result, + "attack_success": entry.attack_success, + }) + + log_action( + db, + user_id=current_user.id, + action="rt_import_test", + entity_type="test", + entity_id=test.id, + details={"engagement": payload.name, "mitre_id": entry.mitre_id}, + ) + + # Recalculate coverage for all affected techniques + for tech_id in affected_technique_ids: + tech = db.query(Technique).filter(Technique.id == tech_id).first() + if tech: + recalculate_technique_status(db, tech) + + uow.commit() + + return { + "created": len(created), + "skipped": len(skipped), + "items": created, + "warnings": skipped, + "engagement": payload.name, + } + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index af5ac4b..053bb33 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20,6 +20,7 @@ const TestDetailPage = React.lazy(() => import("./pages/TestDetailPage")); const TestCatalogPage = React.lazy(() => import("./pages/TestCatalogPage")); const ValidatedTestsPage = React.lazy(() => import("./pages/ValidatedTestsPage")); const ReviewQueuePage = React.lazy(() => import("./pages/ReviewQueuePage")); +const ImportRTPage = React.lazy(() => import("./pages/ImportRTPage")); const ReportsPage = React.lazy(() => import("./pages/ReportsPage")); const SystemPage = React.lazy(() => import("./pages/SystemPage")); const UsersPage = React.lazy(() => import("./pages/UsersPage")); @@ -76,6 +77,14 @@ export default function App() { }>} /> }>} /> }>} /> + + }> + + } + /> }>} /> }>} /> }>} /> diff --git a/frontend/src/api/tests.ts b/frontend/src/api/tests.ts index 0780e9d..9fcd6c1 100644 --- a/frontend/src/api/tests.ts +++ b/frontend/src/api/tests.ts @@ -282,6 +282,38 @@ export interface TempoSyncResult { detail?: string; } +// ── RT Import ────────────────────────────────────────────────────── + +export interface RTTechniqueEntry { + mitre_id: string; + result: "detected" | "not_detected" | "partially_detected"; + attack_success: boolean; + platform?: string; + notes?: string; +} + +export interface RTImportPayload { + name: string; + date?: string; + description?: string; + operator?: string; + techniques: RTTechniqueEntry[]; +} + +export interface RTImportResult { + created: number; + skipped: number; + items: { mitre_id: string; test_name: string; result: string; attack_success: boolean }[]; + warnings: { mitre_id: string; reason: string }[]; + engagement: string; +} + +/** Import results from a real Red Team engagement. */ +export async function importRT(payload: RTImportPayload): Promise { + const { data } = await client.post("/tests/import-rt", payload); + return data; +} + /** Manually push this test's red team execution worklog to Tempo. */ export async function syncTestToTempo( testId: string, diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 3c6c95f..95834d3 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -22,6 +22,7 @@ import { GitCompareArrows, ScrollText, ClipboardCheck, + Upload, } from "lucide-react"; import { useAuth } from "../context/AuthContext"; import { getTechniques } from "../api/techniques"; @@ -56,6 +57,7 @@ const mainLinks: NavItem[] = [ { to: "/tests", label: "All Tests", icon: ListChecks }, { to: "/tests/validated", label: "Validated Tests", icon: CheckCircle }, { to: "/test-catalog", label: "Test Catalog", icon: BookOpen }, + { to: "/tests/import-rt", label: "Import RT Results", icon: Upload, roles: ["admin", "red_lead"] }, ], }, { to: "/campaigns", label: "Campaigns", icon: Zap }, diff --git a/frontend/src/pages/ImportRTPage.tsx b/frontend/src/pages/ImportRTPage.tsx new file mode 100644 index 0000000..c16587c --- /dev/null +++ b/frontend/src/pages/ImportRTPage.tsx @@ -0,0 +1,356 @@ +import { useState, useRef } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { + Upload, + Download, + FileJson, + CheckCircle, + XCircle, + AlertTriangle, + Loader2, + ChevronDown, + ChevronUp, +} from "lucide-react"; +import { importRT, type RTImportPayload, type RTTechniqueEntry } from "../api/tests"; + +/* ── Template JSON ─────────────────────────────────────────────────── */ + +const TEMPLATE_JSON: RTImportPayload = { + name: "Red Team Q1 2024", + date: new Date().toISOString().slice(0, 10), + description: "External red team engagement — perimeter assessment", + operator: "SecTeam Red", + techniques: [ + { + mitre_id: "T1059.001", + result: "detected", + attack_success: true, + platform: "windows", + notes: "PowerShell execution caught by Defender for Endpoint within 2 min", + }, + { + mitre_id: "T1078", + result: "not_detected", + attack_success: true, + platform: "windows", + notes: "Credential reuse via stolen credentials — undetected for 48h", + }, + { + mitre_id: "T1486", + result: "not_detected", + attack_success: false, + platform: "windows", + notes: "Ransomware blocked by AppLocker before execution", + }, + { + mitre_id: "T1190", + result: "partially_detected", + attack_success: true, + platform: "linux", + notes: "Exploit worked but only a partial alert fired — no full incident created", + }, + ], +}; + +/* ── helpers ─────────────────────────────────────────────────────── */ + +const RESULT_CONFIG: Record = { + detected: { label: "Detected", badge: "bg-green-500/10 text-green-400 border-green-500/20" }, + not_detected: { label: "Not Detected", badge: "bg-red-500/10 text-red-400 border-red-500/20" }, + partially_detected: { label: "Partial", badge: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20" }, +}; + +function downloadTemplate() { + const blob = new Blob([JSON.stringify(TEMPLATE_JSON, null, 2)], { type: "application/json" }); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = "rt_import_template.json"; + a.click(); + URL.revokeObjectURL(a.href); +} + +function parseJson(raw: string): { payload: RTImportPayload | null; error: string | null } { + try { + const parsed = JSON.parse(raw); + if (!parsed.name) return { payload: null, error: "Missing required field: name" }; + if (!Array.isArray(parsed.techniques)) return { payload: null, error: "Missing required field: techniques (array)" }; + for (const t of parsed.techniques) { + if (!t.mitre_id) return { payload: null, error: `Technique missing mitre_id: ${JSON.stringify(t)}` }; + if (!["detected", "not_detected", "partially_detected"].includes(t.result)) { + return { payload: null, error: `Invalid result '${t.result}' for ${t.mitre_id}. Must be: detected | not_detected | partially_detected` }; + } + } + return { payload: parsed as RTImportPayload, error: null }; + } catch (e) { + return { payload: null, error: `Invalid JSON: ${(e as Error).message}` }; + } +} + +/* ── Component ────────────────────────────────────────────────────── */ + +export default function ImportRTPage() { + const fileRef = useRef(null); + const [jsonText, setJsonText] = useState(""); + const [showFormat, setShowFormat] = useState(false); + const [parseError, setParseError] = useState(null); + const [preview, setPreview] = useState(null); + + const importMutation = useMutation({ + mutationFn: (payload: RTImportPayload) => importRT(payload), + }); + + const handleTextChange = (value: string) => { + setJsonText(value); + setParseError(null); + importMutation.reset(); + if (!value.trim()) { + setPreview(null); + return; + } + const { payload, error } = parseJson(value); + if (error) { + setParseError(error); + setPreview(null); + } else { + setPreview(payload); + } + }; + + const handleFile = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (ev) => { + const text = ev.target?.result as string; + setJsonText(text); + handleTextChange(text); + }; + reader.readAsText(file); + e.target.value = ""; + }; + + const handleImport = () => { + if (!preview) return; + importMutation.mutate(preview); + }; + + const result = importMutation.data; + + return ( +
+ {/* Header */} +
+

+ + Import Red Team Results +

+

+ Upload findings from a real Red Team engagement. Each technique becomes a validated test + with its detection result, maintaining full coverage history. +

+
+ + {/* Format toggle */} +
+ + + {showFormat && ( +
+
+ {/* Required fields */} +
+

Required fields

+ + + {[ + ["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?"], + ].map(([field, type, desc]) => ( + + + + + + ))} + +
{field}{type}{desc}
+
+ {/* Optional fields */} +
+

Optional fields

+ + + {[ + ["date", "string", "ISO date (YYYY-MM-DD)"], + ["description", "string", "Engagement description"], + ["operator", "string", "Team or company"], + ["techniques[].platform", "string", "windows | linux | macos"], + ["techniques[].notes", "string", "What happened, how it was used"], + ].map(([field, type, desc]) => ( + + + + + + ))} + +
{field}{type}{desc}
+
+
+ + +
+ )} +
+ + {/* Input area */} +
+
+

Paste or upload your JSON

+
+ + +
+
+ +