feat(templates,campaigns): validate MITRE technique IDs, surface template suggestions first, manager can re-approve rejected campaigns
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

- TestTemplate/TemplateSuggestion creation and updates now reject any
  mitre_technique_id that doesn't match a real, already-synced MITRE
  ATT&CK technique. Frontend swaps the free-text ID input for a
  technique picker.
- Test Catalog now shows pending template suggestions before the
  catalog grid, with full field detail (platform, severity, tool,
  atomic ID, source URL, remediation) instead of just name/procedure.
- A manager can edit and directly re-approve a campaign they previously
  rejected, instead of needing the original lead to resubmit it.
This commit is contained in:
kitos
2026-07-16 14:29:01 +02:00
parent 4809c4a662
commit 82ac9c7014
9 changed files with 327 additions and 21 deletions
@@ -1,8 +1,9 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { X, Loader2 } from "lucide-react";
import { createTemplate, type CreateTemplatePayload } from "../api/test-templates";
import { proposeTemplate } from "../api/template-suggestions";
import { getTechniques, type TechniqueSummary } from "../api/techniques";
import { useToast } from "./Toast";
const SEVERITY_OPTIONS = ["low", "medium", "high", "critical"];
@@ -22,6 +23,11 @@ export default function CreateTemplateModal({
const queryClient = useQueryClient();
const { showToast } = useToast();
const { data: techniques, isLoading: techniquesLoading } = useQuery({
queryKey: ["techniques"],
queryFn: () => getTechniques(),
});
const [form, setForm] = useState<CreateTemplatePayload>({
mitre_technique_id: "",
name: "",
@@ -91,15 +97,22 @@ export default function CreateTemplateModal({
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-xs font-medium text-gray-500">
MITRE Technique ID <span className="text-red-400">*</span>
MITRE Technique <span className="text-red-400">*</span>
</label>
<input
<select
required
disabled={techniquesLoading}
value={form.mitre_technique_id}
onChange={(e) => set("mitre_technique_id", e.target.value)}
placeholder="T1059.001"
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
/>
>
<option value="">Select a technique</option>
{techniques?.map((tech: TechniqueSummary) => (
<option key={tech.id} value={tech.mitre_id}>
{tech.mitre_id} - {tech.name}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-500">
+81
View File
@@ -27,6 +27,7 @@ import {
submitCampaignForApproval,
approveCampaign,
rejectCampaign,
updateCampaign,
listCampaignModificationRequests,
approveModificationRequest,
rejectModificationRequest,
@@ -84,6 +85,8 @@ export default function CampaignDetailPage() {
const [approveStartDate, setApproveStartDate] = useState("");
const [rejectModalOpen, setRejectModalOpen] = useState(false);
const [rejectReason, setRejectReason] = useState("");
const [editModalOpen, setEditModalOpen] = useState(false);
const [editForm, setEditForm] = useState({ name: "", description: "" });
const showToast = (message: string, type: "success" | "error") => {
setToast({ message, type });
@@ -144,6 +147,16 @@ export default function CampaignDetailPage() {
onError: (err: Error) => showToast(err.message, "error"),
});
const editMutation = useMutation({
mutationFn: () => updateCampaign(campaignId!, editForm),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] });
setEditModalOpen(false);
showToast("Campaign updated", "success");
},
onError: (err: Error) => showToast(err.message, "error"),
});
const completeMutation = useMutation({
mutationFn: () => completeCampaign(campaignId!),
onSuccess: () => {
@@ -287,6 +300,10 @@ export default function CampaignDetailPage() {
}
const progress = campaign.progress;
// A manager can edit and directly re-approve a campaign they (or another
// manager) previously rejected — no need to bounce it back to the
// original lead to resubmit it through the normal queue.
const isRejectedDraft = campaign.status === "draft" && !!campaign.rejection_reason;
return (
<div className="space-y-6">
@@ -414,6 +431,26 @@ export default function CampaignDetailPage() {
</button>
</>
)}
{isManager && isRejectedDraft && (
<>
<button
onClick={() => {
setEditForm({ name: campaign.name, description: campaign.description || "" });
setEditModalOpen(true);
}}
className="flex items-center gap-1.5 rounded-lg border border-gray-700 px-4 py-2 text-sm font-medium text-gray-300 hover:bg-gray-800 transition-colors"
>
Edit
</button>
<button
onClick={() => setApproveModalOpen(true)}
className="flex items-center gap-1.5 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-500 transition-colors"
>
<CheckCircle className="h-4 w-4" />
Approve
</button>
</>
)}
{canComplete && campaign.status === "active" && (
<button
onClick={() => completeMutation.mutate()}
@@ -852,6 +889,50 @@ export default function CampaignDetailPage() {
onSuccess={() => showToast("Modification request submitted", "success")}
/>
{/* Edit modal (manager fixing up a campaign they rejected) */}
{editModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
<div className="mx-4 w-full max-w-md rounded-xl border border-gray-700 bg-gray-900 p-6 shadow-2xl">
<h3 className="mb-4 text-lg font-semibold text-white">Edit Campaign</h3>
<div className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-300">Name</label>
<input
value={editForm.name}
onChange={(e) => setEditForm((f) => ({ ...f, name: e.target.value }))}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-300">Description</label>
<textarea
value={editForm.description}
onChange={(e) => setEditForm((f) => ({ ...f, description: 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-200 focus:border-cyan-500 focus:outline-none"
/>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<button
onClick={() => setEditModalOpen(false)}
className="rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-400 hover:bg-gray-800 transition-colors"
>
Cancel
</button>
<button
onClick={() => editMutation.mutate()}
disabled={!editForm.name.trim() || editMutation.isPending}
className="flex items-center gap-1.5 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 disabled:opacity-50 transition-colors"
>
{editMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Save
</button>
</div>
</div>
</div>
)}
{/* Approve modal */}
{approveModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
+55 -6
View File
@@ -201,6 +201,14 @@ export default function TestCatalogPage() {
</div>
</div>
{/* ── Template suggestions for leads ────────────────────────────
Shown first, before the catalog itself — a lead's top priority
on this page is clearing the review queue, not browsing.
GET /template-suggestions is already scoped server-side to the
caller's own team (red_lead sees only red, blue_lead only blue;
admin sees both), so no client-side filtering is needed here. */}
{isReviewLead && <TemplateSuggestionsPanel />}
{/* Filters bar */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
<div className="flex flex-wrap items-end gap-3">
@@ -367,12 +375,6 @@ export default function TestCatalogPage() {
</>
)}
{/* ── Template suggestions for leads ────────────────────────────
GET /template-suggestions is already scoped server-side to the
caller's own team (red_lead sees only red, blue_lead only blue;
admin sees both), so no client-side filtering is needed here. */}
{isReviewLead && <TemplateSuggestionsPanel />}
{/* Template instantiation modal */}
{templateId && (
<TestFromTemplateForm
@@ -441,7 +443,46 @@ function TemplateSuggestionsPanel() {
{s.submitter_name && (
<p className="mt-0.5 text-xs text-gray-500">Proposed by {s.submitter_name}</p>
)}
{/* Metadata badges — everything a lead needs to judge the
proposal at a glance, not just the procedure text. */}
<div className="mt-2 flex flex-wrap gap-1.5">
<span className="inline-flex rounded-full border border-gray-600/30 bg-gray-900 px-2 py-0.5 text-xs capitalize text-gray-400">
{s.source}
</span>
{s.platform && (
<span className="inline-flex rounded-full border border-gray-600/30 bg-gray-900 px-2 py-0.5 text-xs capitalize text-gray-400">
{s.platform}
</span>
)}
{s.severity && (
<span className="inline-flex rounded-full border border-gray-600/30 bg-gray-900 px-2 py-0.5 text-xs capitalize text-gray-400">
{s.severity}
</span>
)}
{s.tool_suggested && (
<span className="inline-flex rounded-full border border-gray-600/30 bg-gray-900 px-2 py-0.5 text-xs text-gray-400">
Tool: {s.tool_suggested}
</span>
)}
{s.atomic_test_id && (
<span className="inline-flex rounded-full border border-gray-600/30 bg-gray-900 px-2 py-0.5 text-xs text-gray-400">
Atomic ID: {s.atomic_test_id}
</span>
)}
</div>
{s.description && <p className="mt-2 text-sm text-gray-400">{s.description}</p>}
{s.source_url && (
<a
href={s.source_url}
target="_blank"
rel="noreferrer"
className="mt-1 inline-block text-xs text-cyan-400 hover:underline"
>
{s.source_url}
</a>
)}
{s.attack_procedure && (
<div className="mt-2">
<p className="text-xs font-medium uppercase text-gray-500">Suggested Attack Procedure</p>
@@ -458,6 +499,14 @@ function TemplateSuggestionsPanel() {
</pre>
</div>
)}
{s.suggested_remediation && (
<div className="mt-2">
<p className="text-xs font-medium uppercase text-gray-500">Suggested Remediation</p>
<pre className="mt-1 whitespace-pre-wrap rounded bg-gray-900 p-2 font-mono text-xs text-gray-300">
{s.suggested_remediation}
</pre>
</div>
)}
<div className="mt-3 flex gap-2">
<button
onClick={() => approveMutation.mutate(s.id)}