Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
RT tests are created in 'in_review' state (not validated): - red_validation_status = 'approved' (RT confirmed execution) - blue_validation_status = null (pending Blue Lead review) - detection_result is pre-filled from the import JSON Blue Lead sees these in their normal validation queue and confirms or rejects the detection result. Only after Blue Lead approval does the technique coverage update to validated/not_covered/partial. This gives Blue Lead oversight over RT findings rather than auto- accepting external engagement results as ground truth.
358 lines
15 KiB
TypeScript
358 lines
15 KiB
TypeScript
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 creates a test in
|
|
<span className="text-blue-400"> In Review</span> state — Red side is pre-approved by the RT,
|
|
Blue Lead must still validate the detection result before it counts as coverage.
|
|
</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>
|
|
);
|
|
}
|