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.
+