Files
Aegis/frontend/src/pages/ImportRTPage.tsx
T
kitos 5f54396cb6 feat(rt-import): require base64 evidence images per technique
Each technique in the RT import JSON now requires at least one evidence
image (PNG/JPG/GIF/WebP/BMP, max 10 MB decoded) embedded as base64.

Backend:
- RTEvidenceEntry model: filename, data (base64), caption (optional)
- RTTechniqueEntry.evidence is now required
- Pre-validation raises 422 if any technique is missing evidence
- After test creation, images are decoded and stored in MinIO as
  Evidence records (team=red) linked to the test

Frontend:
- RTEvidenceEntry type added to api/tests.ts
- parseJson() validates evidence presence and structure per technique
- Preview table shows base64 thumbnails (up to 3 + overflow count)
- Format reference updated: evidence fields moved to Required section
- Import result shows total evidence images attached
2026-06-05 12:57:22 +02:00

444 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, type RTEvidenceEntry } from "../api/tests";
/* ── Template JSON ─────────────────────────────────────────────────── */
// Tiny 1×1 pixel red PNG — used only as placeholder in the downloadable template.
// Operators must replace this with real base64-encoded screenshots.
const PLACEHOLDER_B64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADklEQVQI12P4z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg==";
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",
evidence: [
{
filename: "edr_alert_powershell.png",
data: PLACEHOLDER_B64,
caption: "EDR alert showing PowerShell execution blocked",
},
],
},
{
mitre_id: "T1078",
result: "not_detected",
attack_success: true,
platform: "windows",
notes: "Credential reuse via stolen credentials — undetected for 48h",
evidence: [
{
filename: "logon_event_4624.png",
data: PLACEHOLDER_B64,
caption: "Windows event log showing lateral movement logon",
},
{
filename: "timeline_activity.png",
data: PLACEHOLDER_B64,
caption: "48h activity timeline without any alert",
},
],
},
{
mitre_id: "T1486",
result: "not_detected",
attack_success: false,
platform: "windows",
notes: "Ransomware blocked by AppLocker before execution",
evidence: [
{
filename: "applocker_block.png",
data: PLACEHOLDER_B64,
caption: "AppLocker event log blocking ransomware binary",
},
],
},
{
mitre_id: "T1190",
result: "partially_detected",
attack_success: true,
platform: "linux",
notes: "Exploit worked but only a partial alert fired — no full incident created",
evidence: [
{
filename: "partial_alert.png",
data: PLACEHOLDER_B64,
caption: "SIEM alert showing partial detection — no 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` };
}
// Evidence is mandatory
if (!Array.isArray(t.evidence) || t.evidence.length === 0) {
return { payload: null, error: `Technique ${t.mitre_id} is missing evidence. At least one image is required per technique (evidence[].filename + evidence[].data in base64).` };
}
for (const ev of t.evidence as RTEvidenceEntry[]) {
if (!ev.filename || typeof ev.filename !== "string") {
return { payload: null, error: `Evidence in ${t.mitre_id} is missing filename.` };
}
if (!ev.data || typeof ev.data !== "string") {
return { payload: null, error: `Evidence '${ev.filename}' in ${t.mitre_id} is missing base64 data.` };
}
}
}
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?"],
["techniques[].evidence", "array", "Min 1 image required per technique"],
["techniques[].evidence[].filename", "string", "e.g. screenshot_edr.png"],
["techniques[].evidence[].data", "string", "Base64-encoded image (PNG/JPG/GIF/WebP/BMP, max 10 MB)"],
].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"],
["techniques[].evidence[].caption", "string", "Description of what the image shows"],
].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>
<th className="px-4 py-2.5 text-left">Evidence</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>
<td className="px-4 py-2.5">
{t.evidence && t.evidence.length > 0 ? (
<div className="flex items-center gap-1.5">
<div className="flex gap-1">
{t.evidence.slice(0, 3).map((ev: RTEvidenceEntry, ei: number) => {
const ext = ev.filename.split(".").pop()?.toLowerCase() ?? "png";
const mime = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : `image/${ext}`;
return (
<img
key={ei}
src={`data:${mime};base64,${ev.data}`}
title={ev.caption ?? ev.filename}
className="h-7 w-7 rounded object-cover border border-gray-700"
/>
);
})}
{t.evidence.length > 3 && (
<span className="flex h-7 w-7 items-center justify-center rounded border border-gray-700 bg-gray-800 text-xs text-gray-400">
+{t.evidence.length - 3}
</span>
)}
</div>
<span className="text-xs text-gray-500">{t.evidence.length} img</span>
</div>
) : (
<span className="text-xs text-red-400"> Missing</span>
)}
</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>
{" · "}Evidence: <span className="text-cyan-400">
{result.items.reduce((sum, it) => sum + (it.evidence_attached ?? 0), 0)} images attached
</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>
);
}