feat(rt-import): import Red Team engagement results as validated tests
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
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:
@@ -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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>} />
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
356
frontend/src/pages/ImportRTPage.tsx
Normal file
356
frontend/src/pages/ImportRTPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user