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
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:
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user