feat(rt-import): require base64 evidence images per technique
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
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 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -284,12 +284,19 @@ export interface TempoSyncResult {
|
||||
|
||||
// ── RT Import ──────────────────────────────────────────────────────
|
||||
|
||||
export interface RTEvidenceEntry {
|
||||
filename: string; // e.g. "screenshot_edr.png"
|
||||
data: string; // base64-encoded image (PNG / JPG / GIF / WebP / BMP)
|
||||
caption?: string; // optional description
|
||||
}
|
||||
|
||||
export interface RTTechniqueEntry {
|
||||
mitre_id: string;
|
||||
result: "detected" | "not_detected" | "partially_detected";
|
||||
attack_success: boolean;
|
||||
platform?: string;
|
||||
notes?: string;
|
||||
evidence: RTEvidenceEntry[]; // required — at least 1 image
|
||||
}
|
||||
|
||||
export interface RTImportPayload {
|
||||
@@ -303,7 +310,7 @@ export interface RTImportPayload {
|
||||
export interface RTImportResult {
|
||||
created: number;
|
||||
skipped: number;
|
||||
items: { mitre_id: string; test_name: string; result: string; attack_success: boolean }[];
|
||||
items: { mitre_id: string; test_name: string; result: string; attack_success: boolean; evidence_attached: number }[];
|
||||
warnings: { mitre_id: string; reason: string }[];
|
||||
engagement: string;
|
||||
}
|
||||
|
||||
@@ -11,10 +11,15 @@ import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from "lucide-react";
|
||||
import { importRT, type RTImportPayload, type RTTechniqueEntry } from "../api/tests";
|
||||
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),
|
||||
@@ -27,6 +32,13 @@ const TEMPLATE_JSON: RTImportPayload = {
|
||||
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",
|
||||
@@ -34,6 +46,18 @@ const TEMPLATE_JSON: RTImportPayload = {
|
||||
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",
|
||||
@@ -41,6 +65,13 @@ const TEMPLATE_JSON: RTImportPayload = {
|
||||
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",
|
||||
@@ -48,6 +79,13 @@ const TEMPLATE_JSON: RTImportPayload = {
|
||||
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",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -79,6 +117,18 @@ function parseJson(raw: string): { payload: RTImportPayload | null; error: strin
|
||||
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) {
|
||||
@@ -177,6 +227,9 @@ export default function ImportRTPage() {
|
||||
["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>
|
||||
@@ -198,6 +251,7 @@ export default function ImportRTPage() {
|
||||
["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>
|
||||
@@ -289,6 +343,7 @@ export default function ImportRTPage() {
|
||||
<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">
|
||||
@@ -308,6 +363,34 @@ export default function ImportRTPage() {
|
||||
</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>
|
||||
@@ -328,6 +411,9 @@ export default function ImportRTPage() {
|
||||
<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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user