feat(rt-import): import Red Team engagement results as validated tests
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled

Backend — POST /tests/import-rt (red_lead + admin):
  Accepts engagement JSON with name/date/description/operator and
  a list of techniques each with mitre_id, result, attack_success,
  platform, notes. Creates one Test per technique directly in
  'validated' state (red + blue validation = approved) bypassing
  the normal workflow. Recalculates technique.status_global for
  all affected techniques. Returns created/skipped summary.

Frontend — /tests/import-rt (new dedicated page):
  - Format reference panel (collapsible) with field descriptions
  - Download template JSON button (generates a filled example)
  - Paste JSON textarea + file upload (.json)
  - Live validation + preview table showing what will be imported
  - Import button with spinner
  - Success / warning / error result display
  Accessible to admin and red_lead only.
  Added to sidebar under Tests > Import RT Results.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kitos
2026-05-29 16:15:35 +02:00
parent b39a4fec14
commit 2f1ef7545d
5 changed files with 542 additions and 2 deletions

View File

@@ -20,16 +20,20 @@ GET /tests/{id}/timeline — audit-log history for this test
""" """
import uuid 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 fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.dependencies.auth import get_current_user, require_any_role, require_role from app.dependencies.auth import get_current_user, require_any_role, require_role
from app.domain.enums import DataClassification from app.domain.enums import DataClassification
from app.limiter import limiter 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.models.user import User
from app.schemas.test import ( from app.schemas.test import (
TestCreate, TestCreate,
@@ -739,3 +743,140 @@ def sync_tempo(
}) })
return {"results": results} 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,
}

View File

@@ -20,6 +20,7 @@ const TestDetailPage = React.lazy(() => import("./pages/TestDetailPage"));
const TestCatalogPage = React.lazy(() => import("./pages/TestCatalogPage")); const TestCatalogPage = React.lazy(() => import("./pages/TestCatalogPage"));
const ValidatedTestsPage = React.lazy(() => import("./pages/ValidatedTestsPage")); const ValidatedTestsPage = React.lazy(() => import("./pages/ValidatedTestsPage"));
const ReviewQueuePage = React.lazy(() => import("./pages/ReviewQueuePage")); const ReviewQueuePage = React.lazy(() => import("./pages/ReviewQueuePage"));
const ImportRTPage = React.lazy(() => import("./pages/ImportRTPage"));
const ReportsPage = React.lazy(() => import("./pages/ReportsPage")); const ReportsPage = React.lazy(() => import("./pages/ReportsPage"));
const SystemPage = React.lazy(() => import("./pages/SystemPage")); const SystemPage = React.lazy(() => import("./pages/SystemPage"));
const UsersPage = React.lazy(() => import("./pages/UsersPage")); const UsersPage = React.lazy(() => import("./pages/UsersPage"));
@@ -76,6 +77,14 @@ export default function App() {
<Route path="/tests" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><TestsPage /></Suspense>} /> <Route path="/tests" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><TestsPage /></Suspense>} />
<Route path="/tests/new" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><TestCreatePage /></Suspense>} /> <Route path="/tests/new" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><TestCreatePage /></Suspense>} />
<Route path="/tests/validated" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><ValidatedTestsPage /></Suspense>} /> <Route path="/tests/validated" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><ValidatedTestsPage /></Suspense>} />
<Route
path="/tests/import-rt"
element={
<ProtectedRoute roles={["admin", "red_lead"]}>
<Suspense fallback={<LoadingSpinner text="Loading…" />}><ImportRTPage /></Suspense>
</ProtectedRoute>
}
/>
<Route path="/tests/:testId" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><TestDetailPage /></Suspense>} /> <Route path="/tests/:testId" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><TestDetailPage /></Suspense>} />
<Route path="/test-catalog" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><TestCatalogPage /></Suspense>} /> <Route path="/test-catalog" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><TestCatalogPage /></Suspense>} />
<Route path="/test-catalog/:templateId/use" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><TestCatalogPage /></Suspense>} /> <Route path="/test-catalog/:templateId/use" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><TestCatalogPage /></Suspense>} />

View File

@@ -282,6 +282,38 @@ export interface TempoSyncResult {
detail?: string; 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<RTImportResult> {
const { data } = await client.post<RTImportResult>("/tests/import-rt", payload);
return data;
}
/** Manually push this test's red team execution worklog to Tempo. */ /** Manually push this test's red team execution worklog to Tempo. */
export async function syncTestToTempo( export async function syncTestToTempo(
testId: string, testId: string,

View File

@@ -22,6 +22,7 @@ import {
GitCompareArrows, GitCompareArrows,
ScrollText, ScrollText,
ClipboardCheck, ClipboardCheck,
Upload,
} from "lucide-react"; } from "lucide-react";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { getTechniques } from "../api/techniques"; import { getTechniques } from "../api/techniques";
@@ -56,6 +57,7 @@ const mainLinks: NavItem[] = [
{ to: "/tests", label: "All Tests", icon: ListChecks }, { to: "/tests", label: "All Tests", icon: ListChecks },
{ to: "/tests/validated", label: "Validated Tests", icon: CheckCircle }, { to: "/tests/validated", label: "Validated Tests", icon: CheckCircle },
{ to: "/test-catalog", label: "Test Catalog", icon: BookOpen }, { 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 }, { to: "/campaigns", label: "Campaigns", icon: Zap },

View File

@@ -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<string, { label: string; badge: string }> = {
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<HTMLInputElement>(null);
const [jsonText, setJsonText] = useState("");
const [showFormat, setShowFormat] = useState(false);
const [parseError, setParseError] = useState<string | null>(null);
const [preview, setPreview] = useState<RTImportPayload | null>(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<HTMLInputElement>) => {
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 (
<div className="space-y-6 max-w-4xl">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
<Upload className="h-7 w-7 text-orange-400" />
Import Red Team Results
</h1>
<p className="mt-1 text-sm text-gray-400">
Upload findings from a real Red Team engagement. Each technique becomes a validated test
with its detection result, maintaining full coverage history.
</p>
</div>
{/* Format toggle */}
<div className="rounded-xl border border-gray-800 bg-gray-900">
<button
onClick={() => setShowFormat(!showFormat)}
className="flex w-full items-center justify-between px-5 py-4 text-left"
>
<div className="flex items-center gap-2">
<FileJson className="h-4 w-4 text-cyan-400" />
<span className="text-sm font-medium text-gray-300">JSON Format Reference</span>
</div>
{showFormat ? <ChevronUp className="h-4 w-4 text-gray-500" /> : <ChevronDown className="h-4 w-4 text-gray-500" />}
</button>
{showFormat && (
<div className="border-t border-gray-800 px-5 pb-5 pt-4 space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
{/* Required fields */}
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500">Required fields</p>
<table className="w-full text-xs">
<tbody className="divide-y divide-gray-800">
{[
["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]) => (
<tr key={field}>
<td className="py-1.5 pr-3 font-mono text-cyan-400">{field}</td>
<td className="py-1.5 pr-3 text-gray-500 italic">{type}</td>
<td className="py-1.5 text-gray-400">{desc}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Optional fields */}
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500">Optional fields</p>
<table className="w-full text-xs">
<tbody className="divide-y divide-gray-800">
{[
["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]) => (
<tr key={field}>
<td className="py-1.5 pr-3 font-mono text-gray-400">{field}</td>
<td className="py-1.5 pr-3 text-gray-500 italic">{type}</td>
<td className="py-1.5 text-gray-400">{desc}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<button
onClick={downloadTemplate}
className="flex items-center gap-2 rounded-lg border border-cyan-500/30 bg-cyan-500/10 px-4 py-2 text-sm font-medium text-cyan-400 hover:bg-cyan-500/20 transition-colors"
>
<Download className="h-4 w-4" />
Download template JSON
</button>
</div>
)}
</div>
{/* Input area */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-5 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-white">Paste or upload your JSON</h2>
<div className="flex items-center gap-2">
<input ref={fileRef} type="file" accept=".json" onChange={handleFile} className="hidden" />
<button
onClick={() => fileRef.current?.click()}
className="flex items-center gap-1.5 rounded-lg border border-gray-700 px-3 py-1.5 text-xs text-gray-400 hover:bg-gray-800 hover:text-white transition-colors"
>
<Upload className="h-3.5 w-3.5" />
Upload file
</button>
</div>
</div>
<textarea
value={jsonText}
onChange={(e) => handleTextChange(e.target.value)}
placeholder={`Paste your JSON here or use the template…\n\n{\n "name": "Red Team Q1 2024",\n "techniques": [\n {\n "mitre_id": "T1059.001",\n "result": "detected",\n "attack_success": true\n }\n ]\n}`}
rows={14}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2.5 font-mono text-xs text-gray-200 placeholder-gray-600 focus:border-cyan-500 focus:outline-none resize-none"
/>
{parseError && (
<div className="flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-900/20 p-3 text-sm text-red-400">
<XCircle className="h-4 w-4 mt-0.5 shrink-0" />
{parseError}
</div>
)}
</div>
{/* Preview */}
{preview && !parseError && (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-5 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-semibold text-white">{preview.name}</h2>
<p className="mt-0.5 text-xs text-gray-500">
{preview.date && <span>{preview.date} · </span>}
{preview.operator && <span>{preview.operator} · </span>}
<span className="text-amber-400">{preview.techniques.length} techniques</span>
</p>
{preview.description && (
<p className="mt-1 text-xs text-gray-400">{preview.description}</p>
)}
</div>
<button
onClick={handleImport}
disabled={importMutation.isPending}
className="flex items-center gap-2 rounded-lg bg-orange-600 px-4 py-2 text-sm font-medium text-white hover:bg-orange-500 disabled:opacity-50 transition-colors"
>
{importMutation.isPending
? <><Loader2 className="h-4 w-4 animate-spin" /> Importing</>
: <><Upload className="h-4 w-4" /> Import {preview.techniques.length} Techniques</>
}
</button>
</div>
<div className="overflow-x-auto rounded-lg border border-gray-800">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-gray-800 text-gray-500 uppercase tracking-wider">
<th className="px-4 py-2.5 text-left">MITRE ID</th>
<th className="px-4 py-2.5 text-left">Result</th>
<th className="px-4 py-2.5 text-left">Attack</th>
<th className="px-4 py-2.5 text-left">Platform</th>
<th className="px-4 py-2.5 text-left">Notes</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800/50">
{preview.techniques.map((t: RTTechniqueEntry, i) => (
<tr key={i} className="hover:bg-gray-800/20">
<td className="px-4 py-2.5 font-mono text-cyan-400 font-semibold">{t.mitre_id}</td>
<td className="px-4 py-2.5">
<span className={`inline-flex rounded-full border px-2 py-0.5 font-medium ${RESULT_CONFIG[t.result]?.badge ?? ""}`}>
{RESULT_CONFIG[t.result]?.label ?? t.result}
</span>
</td>
<td className="px-4 py-2.5">
{t.attack_success
? <span className="text-orange-400">Success</span>
: <span className="text-gray-500">Blocked</span>
}
</td>
<td className="px-4 py-2.5 text-gray-400 capitalize">{t.platform ?? "—"}</td>
<td className="px-4 py-2.5 text-gray-500 max-w-xs truncate">{t.notes ?? "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Import result */}
{result && (
<div className="rounded-xl border border-green-500/30 bg-green-500/5 p-5 space-y-3">
<div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-400" />
<h2 className="text-sm font-semibold text-green-400">
Import complete {result.created} tests created
</h2>
</div>
<p className="text-xs text-gray-400">
Engagement: <span className="text-white">{result.engagement}</span>
{" · "}Created: <span className="text-green-400">{result.created}</span>
{result.skipped > 0 && <>{" · "}<span className="text-yellow-400">{result.skipped} skipped</span></>}
</p>
{result.warnings.length > 0 && (
<div className="space-y-1">
<p className="text-xs font-medium text-yellow-400 flex items-center gap-1">
<AlertTriangle className="h-3.5 w-3.5" /> Skipped entries
</p>
{result.warnings.map((w, i) => (
<p key={i} className="text-xs text-gray-500 ml-5">
<span className="font-mono text-gray-400">{w.mitre_id}</span>: {w.reason}
</p>
))}
</div>
)}
</div>
)}
{importMutation.isError && (
<div className="flex items-start gap-2 rounded-xl border border-red-500/30 bg-red-900/20 p-4 text-sm text-red-400">
<XCircle className="h-4 w-4 mt-0.5 shrink-0" />
{(importMutation.error as Error)?.message || "Import failed"}
</div>
)}
</div>
);
}