feat(tests): add Detect Procedure UI, template suggested-procedure editing, lead review panel
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
Blue Team gets a Detect Procedure textarea in the test detail view, mirroring Red's Attack Procedure field. Template create/edit forms gain a Detect Suggested Procedure field for leads to curate directly. Leads also get a Procedure Suggestions panel on the Tests page, scoped to their own team by the backend, to approve or reject suggestions extracted from submitted rounds.
This commit is contained in:
@@ -0,0 +1,20 @@
|
|||||||
|
import client from "./client";
|
||||||
|
import type { ProcedureSuggestion } from "../types/models";
|
||||||
|
|
||||||
|
/** List pending procedure suggestions, scoped to the caller's team (admins see both). */
|
||||||
|
export async function getProcedureSuggestions(): Promise<ProcedureSuggestion[]> {
|
||||||
|
const { data } = await client.get<ProcedureSuggestion[]>("/procedure-suggestions");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Approve a suggestion — writes it into the template's suggested-procedure field. */
|
||||||
|
export async function approveProcedureSuggestion(id: string): Promise<ProcedureSuggestion> {
|
||||||
|
const { data } = await client.post<ProcedureSuggestion>(`/procedure-suggestions/${id}/approve`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reject a suggestion — the template is left untouched. */
|
||||||
|
export async function rejectProcedureSuggestion(id: string): Promise<ProcedureSuggestion> {
|
||||||
|
const { data } = await client.post<ProcedureSuggestion>(`/procedure-suggestions/${id}/reject`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ export interface CreateTemplatePayload {
|
|||||||
source_url?: string;
|
source_url?: string;
|
||||||
attack_procedure?: string;
|
attack_procedure?: string;
|
||||||
expected_detection?: string;
|
expected_detection?: string;
|
||||||
|
detect_suggested_procedure?: string;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
tool_suggested?: string;
|
tool_suggested?: string;
|
||||||
severity?: string;
|
severity?: string;
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export interface BlueUpdatePayload {
|
|||||||
detection_time?: string;
|
detection_time?: string;
|
||||||
containment_time?: string;
|
containment_time?: string;
|
||||||
blue_summary?: string;
|
blue_summary?: string;
|
||||||
|
detect_procedure?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RedValidationPayload {
|
export interface RedValidationPayload {
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ interface TeamTabsProps {
|
|||||||
detection_time: string;
|
detection_time: string;
|
||||||
containment_time: string;
|
containment_time: string;
|
||||||
blue_summary: string;
|
blue_summary: string;
|
||||||
|
detect_procedure: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Evidence
|
// Evidence
|
||||||
@@ -411,6 +412,26 @@ export default function TeamTabs({
|
|||||||
{blueLockedHint}
|
{blueLockedHint}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Detect Procedure */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-300">
|
||||||
|
Detect Procedure
|
||||||
|
</label>
|
||||||
|
{canEditBlue ? (
|
||||||
|
<textarea
|
||||||
|
value={blueDraft.detect_procedure}
|
||||||
|
onChange={(e) => onBlueFieldChange("detect_procedure", e.target.value)}
|
||||||
|
rows={6}
|
||||||
|
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 font-mono"
|
||||||
|
placeholder="Describe how you detected the attack..."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<pre className="whitespace-pre-wrap rounded-lg bg-gray-800 p-4 font-mono text-sm text-gray-300">
|
||||||
|
{test.detect_procedure || "No detection procedure documented."}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Detection Result */}
|
{/* Detection Result */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-gray-300">
|
<label className="mb-2 block text-sm font-medium text-gray-300">
|
||||||
|
|||||||
@@ -1355,6 +1355,7 @@ function CreateTemplateForm({
|
|||||||
source: "custom",
|
source: "custom",
|
||||||
attack_procedure: "",
|
attack_procedure: "",
|
||||||
expected_detection: "",
|
expected_detection: "",
|
||||||
|
detect_suggested_procedure: "",
|
||||||
platform: "",
|
platform: "",
|
||||||
tool_suggested: "",
|
tool_suggested: "",
|
||||||
severity: "",
|
severity: "",
|
||||||
@@ -1491,6 +1492,20 @@ function CreateTemplateForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Detect Suggested Procedure */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-1">
|
||||||
|
Detect Suggested Procedure
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={form.detect_suggested_procedure || ""}
|
||||||
|
onChange={(e) => setForm({ ...form, detect_suggested_procedure: e.target.value })}
|
||||||
|
placeholder="Steps for the blue team to detect this technique..."
|
||||||
|
rows={3}
|
||||||
|
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 placeholder-gray-500 focus:border-cyan-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Tool Suggested */}
|
{/* Tool Suggested */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-gray-300 mb-1">
|
||||||
@@ -1565,6 +1580,7 @@ function TemplateDetailModal({
|
|||||||
description: template.description ?? "",
|
description: template.description ?? "",
|
||||||
attack_procedure: template.attack_procedure ?? "",
|
attack_procedure: template.attack_procedure ?? "",
|
||||||
expected_detection: template.expected_detection ?? "",
|
expected_detection: template.expected_detection ?? "",
|
||||||
|
detect_suggested_procedure: template.detect_suggested_procedure ?? "",
|
||||||
platform: template.platform ?? "",
|
platform: template.platform ?? "",
|
||||||
tool_suggested: template.tool_suggested ?? "",
|
tool_suggested: template.tool_suggested ?? "",
|
||||||
severity: template.severity ?? "",
|
severity: template.severity ?? "",
|
||||||
@@ -1696,6 +1712,16 @@ function TemplateDetailModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-1">Detect Suggested Procedure</label>
|
||||||
|
<textarea
|
||||||
|
value={form.detect_suggested_procedure ?? ""}
|
||||||
|
onChange={(e) => setForm({ ...form, detect_suggested_procedure: e.target.value })}
|
||||||
|
rows={3}
|
||||||
|
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 focus:border-cyan-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-1">Suggested Tool</label>
|
<label className="block text-sm font-medium text-gray-300 mb-1">Suggested Tool</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -116,12 +116,14 @@ export default function TestDetailPage() {
|
|||||||
detection_time: string;
|
detection_time: string;
|
||||||
containment_time: string;
|
containment_time: string;
|
||||||
blue_summary: string;
|
blue_summary: string;
|
||||||
|
detect_procedure: string;
|
||||||
}>({
|
}>({
|
||||||
detection_result: "",
|
detection_result: "",
|
||||||
containment_result: "",
|
containment_result: "",
|
||||||
detection_time: "",
|
detection_time: "",
|
||||||
containment_time: "",
|
containment_time: "",
|
||||||
blue_summary: "",
|
blue_summary: "",
|
||||||
|
detect_procedure: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
||||||
@@ -185,6 +187,7 @@ export default function TestDetailPage() {
|
|||||||
detection_time: isoToDatetimeLocal(test.detection_time),
|
detection_time: isoToDatetimeLocal(test.detection_time),
|
||||||
containment_time: isoToDatetimeLocal(test.containment_time),
|
containment_time: isoToDatetimeLocal(test.containment_time),
|
||||||
blue_summary: test.blue_summary || "",
|
blue_summary: test.blue_summary || "",
|
||||||
|
detect_procedure: test.detect_procedure || "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [test]);
|
}, [test]);
|
||||||
@@ -242,6 +245,7 @@ export default function TestDetailPage() {
|
|||||||
detection_time: datetimeLocalToIso(blueDraft.detection_time),
|
detection_time: datetimeLocalToIso(blueDraft.detection_time),
|
||||||
containment_time: datetimeLocalToIso(blueDraft.containment_time),
|
containment_time: datetimeLocalToIso(blueDraft.containment_time),
|
||||||
blue_summary: blueDraft.blue_summary || undefined,
|
blue_summary: blueDraft.blue_summary || undefined,
|
||||||
|
detect_procedure: blueDraft.detect_procedure || undefined,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
invalidateAll();
|
invalidateAll();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -19,8 +19,14 @@ import {
|
|||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
Timer,
|
Timer,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
Lightbulb,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { getTests, type TestListFilters } from "../api/tests";
|
import { getTests, type TestListFilters } from "../api/tests";
|
||||||
|
import {
|
||||||
|
getProcedureSuggestions,
|
||||||
|
approveProcedureSuggestion,
|
||||||
|
rejectProcedureSuggestion,
|
||||||
|
} from "../api/procedure-suggestions";
|
||||||
import type { Test, TestState } from "../types/models";
|
import type { Test, TestState } from "../types/models";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
|
||||||
@@ -707,6 +713,12 @@ export default function TestsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Procedure suggestions for leads ───────────────────────────
|
||||||
|
GET /procedure-suggestions is already scoped server-side to the
|
||||||
|
caller's own team (red_lead sees only red, blue_lead only blue),
|
||||||
|
so no client-side filtering is needed here. */}
|
||||||
|
{isReviewLead && <ProcedureSuggestionsPanel />}
|
||||||
|
|
||||||
{/* ── Team queue overview for leads ─────────────────────────────
|
{/* ── Team queue overview for leads ─────────────────────────────
|
||||||
red_lead/blue_lead never hit the plain "All Tests" branch above —
|
red_lead/blue_lead never hit the plain "All Tests" branch above —
|
||||||
they're always routed into the review two-queue view or their
|
they're always routed into the review two-queue view or their
|
||||||
@@ -742,6 +754,76 @@ export default function TestsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Procedure suggestions panel (leads only) ─────────────────────── */
|
||||||
|
|
||||||
|
function ProcedureSuggestionsPanel() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: suggestions = [] } = useQuery({
|
||||||
|
queryKey: ["procedure-suggestions"],
|
||||||
|
queryFn: getProcedureSuggestions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["procedure-suggestions"] });
|
||||||
|
const approveMutation = useMutation({ mutationFn: approveProcedureSuggestion, onSuccess: invalidate });
|
||||||
|
const rejectMutation = useMutation({ mutationFn: rejectProcedureSuggestion, onSuccess: invalidate });
|
||||||
|
const isBusy = approveMutation.isPending || rejectMutation.isPending;
|
||||||
|
|
||||||
|
if (suggestions.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
||||||
|
<div className="mb-4 flex items-center gap-2">
|
||||||
|
<Lightbulb className="h-5 w-5 text-yellow-400" />
|
||||||
|
<h2 className="text-lg font-semibold text-white">Procedure Suggestions</h2>
|
||||||
|
<span className="rounded-full border border-gray-600 bg-gray-800 px-2 py-0.5 text-xs font-medium text-gray-300">
|
||||||
|
{suggestions.length}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
Commands extracted from submitted rounds, proposed as template updates
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{suggestions.map((s) => (
|
||||||
|
<div key={s.id} className="rounded-lg border border-gray-700 bg-gray-800/50 p-4">
|
||||||
|
<p className="text-sm font-medium text-gray-200">{s.template_name || "Unknown template"}</p>
|
||||||
|
{s.template_current_text && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-xs font-medium uppercase text-gray-500">Current</p>
|
||||||
|
<pre className="mt-1 whitespace-pre-wrap rounded bg-gray-900 p-2 font-mono text-xs text-gray-400">
|
||||||
|
{s.template_current_text}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-xs font-medium uppercase text-gray-500">Suggested</p>
|
||||||
|
<pre className="mt-1 whitespace-pre-wrap rounded bg-gray-900 p-2 font-mono text-xs text-green-400">
|
||||||
|
{s.suggested_text}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => approveMutation.mutate(s.id)}
|
||||||
|
disabled={isBusy}
|
||||||
|
className="flex items-center gap-1 rounded-lg border border-green-500/30 bg-green-900/20 px-3 py-1.5 text-xs font-medium text-green-400 hover:bg-green-900/40 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<CheckCircle className="h-3.5 w-3.5" /> Approve
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => rejectMutation.mutate(s.id)}
|
||||||
|
disabled={isBusy}
|
||||||
|
className="flex items-center gap-1 rounded-lg border border-red-500/30 bg-red-900/20 px-3 py-1.5 text-xs font-medium text-red-400 hover:bg-red-900/40 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<XCircle className="h-3.5 w-3.5" /> Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Shared test table component ────────────────────────────────────── */
|
/* ── Shared test table component ────────────────────────────────────── */
|
||||||
|
|
||||||
function TestTable({
|
function TestTable({
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export interface Test {
|
|||||||
tool_used: string | null;
|
tool_used: string | null;
|
||||||
execution_date: string | null;
|
execution_date: string | null;
|
||||||
created_by: string | null;
|
created_by: string | null;
|
||||||
|
source_template_id: string | null;
|
||||||
result: TestResult | null;
|
result: TestResult | null;
|
||||||
state: TestState;
|
state: TestState;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -107,6 +108,7 @@ export interface Test {
|
|||||||
|
|
||||||
// Blue Team fields
|
// Blue Team fields
|
||||||
blue_summary: string | null;
|
blue_summary: string | null;
|
||||||
|
detect_procedure: string | null;
|
||||||
detection_result: TestResult | null;
|
detection_result: TestResult | null;
|
||||||
containment_result: ContainmentResult | null;
|
containment_result: ContainmentResult | null;
|
||||||
detection_time: string | null;
|
detection_time: string | null;
|
||||||
@@ -196,6 +198,7 @@ export interface TestRoundHistory {
|
|||||||
detection_time: string | null;
|
detection_time: string | null;
|
||||||
containment_time: string | null;
|
containment_time: string | null;
|
||||||
blue_summary: string | null;
|
blue_summary: string | null;
|
||||||
|
detect_procedure: string | null;
|
||||||
review_notes: string | null;
|
review_notes: string | null;
|
||||||
reviewed_by: string | null;
|
reviewed_by: string | null;
|
||||||
archived_at: string | null;
|
archived_at: string | null;
|
||||||
@@ -227,6 +230,7 @@ export interface TestTemplate {
|
|||||||
source_url: string | null;
|
source_url: string | null;
|
||||||
attack_procedure: string | null;
|
attack_procedure: string | null;
|
||||||
expected_detection: string | null;
|
expected_detection: string | null;
|
||||||
|
detect_suggested_procedure: string | null;
|
||||||
platform: string | null;
|
platform: string | null;
|
||||||
tool_suggested: string | null;
|
tool_suggested: string | null;
|
||||||
severity: string | null;
|
severity: string | null;
|
||||||
@@ -236,6 +240,23 @@ export interface TestTemplate {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Procedure suggestions (review queue) ────────────────────────────
|
||||||
|
|
||||||
|
export interface ProcedureSuggestion {
|
||||||
|
id: string;
|
||||||
|
template_id: string;
|
||||||
|
team: TeamSide;
|
||||||
|
suggested_text: string;
|
||||||
|
source_test_id: string | null;
|
||||||
|
submitted_by: string | null;
|
||||||
|
status: "pending" | "approved" | "rejected";
|
||||||
|
reviewed_by: string | null;
|
||||||
|
reviewed_at: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
template_name: string | null;
|
||||||
|
template_current_text: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TestTemplateSummary {
|
export interface TestTemplateSummary {
|
||||||
id: string;
|
id: string;
|
||||||
mitre_technique_id: string;
|
mitre_technique_id: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user