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
+6 -2
View File
@@ -431,15 +431,19 @@ def update_campaign(
# Entry: db # Entry: db
db: Session = Depends(get_db), db: Session = Depends(get_db),
# Entry: current_user # Entry: current_user
current_user: User = Depends(require_any_role("red_lead", "blue_lead")), current_user: User = Depends(require_any_role("red_lead", "blue_lead", "manager")),
) -> dict: ) -> dict:
"""Update a campaign. Only allowed in draft or active state. """Update a campaign. Only allowed in draft or active state.
A manager may only edit a campaign that's sitting in draft with a
rejection_reason set (i.e. one they previously rejected) — see
``update_campaign()``'s ownership check.
Args: Args:
campaign_id (str): UUID string of the campaign to update. campaign_id (str): UUID string of the campaign to update.
payload (CampaignUpdate): Partial update payload; only set fields are applied. payload (CampaignUpdate): Partial update payload; only set fields are applied.
db (Session): SQLAlchemy database session. db (Session): SQLAlchemy database session.
current_user (User): Authenticated red_lead or blue_lead performing the update. current_user (User): Authenticated red_lead, blue_lead, or manager performing the update.
Returns: Returns:
dict: Serialised representation of the updated campaign. dict: Serialised representation of the updated campaign.
+18 -6
View File
@@ -367,6 +367,11 @@ def approve_campaign(
) -> Campaign: ) -> Campaign:
"""Approve a pending campaign: fix its start date and activate it. """Approve a pending campaign: fix its start date and activate it.
Also allows approving a previously-rejected campaign directly (status
``draft`` with a ``rejection_reason`` set) — a manager can edit it via
``update_campaign()`` and then approve it themselves, without needing
the original lead to resubmit it through the normal queue.
Raises EntityNotFoundError, BusinessRuleViolation. Raises EntityNotFoundError, BusinessRuleViolation.
Does not commit; caller commits. Does not commit; caller commits.
""" """
@@ -374,8 +379,9 @@ def approve_campaign(
if not campaign: if not campaign:
raise EntityNotFoundError("Campaign", campaign_id) raise EntityNotFoundError("Campaign", campaign_id)
if campaign.status != "pending_approval": is_previously_rejected_draft = campaign.status == "draft" and campaign.rejection_reason
raise BusinessRuleViolation("Only campaigns pending approval can be approved") if campaign.status != "pending_approval" and not is_previously_rejected_draft:
raise BusinessRuleViolation("Only campaigns pending approval (or previously rejected) can be approved")
if not start_date: if not start_date:
raise BusinessRuleViolation("start_date is required to approve a campaign") raise BusinessRuleViolation("start_date is required to approve a campaign")
@@ -454,7 +460,7 @@ def update_campaign(
Does not commit; caller commits. Does not commit; caller commits.
""" """
# Assign campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first() # Assign campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first() campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first()
# Check: not campaign # Check: not campaign
if not campaign: if not campaign:
# Raise EntityNotFoundError # Raise EntityNotFoundError
@@ -465,10 +471,16 @@ def update_campaign(
# Raise BusinessRuleViolation # Raise BusinessRuleViolation
raise BusinessRuleViolation("Can only update draft or active campaigns") raise BusinessRuleViolation("Can only update draft or active campaigns")
# Check: str(campaign.created_by) != str(updater_id) and updater_role != "ad... # A manager may edit a campaign they (or another manager) previously
if str(campaign.created_by) != str(updater_id) and updater_role != "admin": # rejected — this is what lets them fix it up and self-approve it,
# instead of bouncing it back to the original lead to resubmit.
is_manager_editing_own_rejection = (
updater_role == "manager" and campaign.status == "draft" and campaign.rejection_reason
)
is_owner_or_admin = str(campaign.created_by) == str(updater_id) or updater_role == "admin"
if not is_owner_or_admin and not is_manager_editing_own_rejection:
# Raise PermissionViolation # Raise PermissionViolation
raise PermissionViolation("Only the creator or admin can update this campaign") raise PermissionViolation("Only the creator, admin, or a manager reviewing a rejected campaign can update this campaign")
# Check: "scheduled_at" in fields and fields["scheduled_at"] # Check: "scheduled_at" in fields and fields["scheduled_at"]
if "scheduled_at" in fields and fields["scheduled_at"]: if "scheduled_at" in fields and fields["scheduled_at"]:
@@ -19,7 +19,7 @@ from app.models.template_suggestion import TemplateSuggestion
from app.models.test_template import TestTemplate from app.models.test_template import TestTemplate
from app.models.user import User from app.models.user import User
from app.services.notification_service import notify_role from app.services.notification_service import notify_role
from app.services.test_template_service import create_template from app.services.test_template_service import create_template, validate_mitre_technique_id
_TEAM_BY_ROLE = {"red_tech": "red", "blue_tech": "blue"} _TEAM_BY_ROLE = {"red_tech": "red", "blue_tech": "blue"}
_LEAD_ROLE = {"red": "red_lead", "blue": "blue_lead"} _LEAD_ROLE = {"red": "red_lead", "blue": "blue_lead"}
@@ -45,6 +45,7 @@ def create_template_suggestion(db: Session, submitter: User, **fields: object) -
Does not commit; caller uses UnitOfWork. Does not commit; caller uses UnitOfWork.
""" """
team = team_for_submitter(submitter) team = team_for_submitter(submitter)
validate_mitre_technique_id(db, fields["mitre_technique_id"])
suggestion = TemplateSuggestion(team=team, submitted_by=submitter.id, **fields) suggestion = TemplateSuggestion(team=team, submitted_by=submitter.id, **fields)
db.add(suggestion) db.add(suggestion)
db.flush() db.flush()
+16 -1
View File
@@ -12,7 +12,7 @@ from sqlalchemy import func, or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
# Import EntityNotFoundError from app.domain.errors # Import EntityNotFoundError from app.domain.errors
from app.domain.errors import EntityNotFoundError from app.domain.errors import BusinessRuleViolation, EntityNotFoundError
# Import TestTemplate from app.models.test_template # Import TestTemplate from app.models.test_template
from app.models.test_template import TestTemplate from app.models.test_template import TestTemplate
@@ -235,9 +235,22 @@ def get_template_or_raise(db: Session, template_id: uuid.UUID) -> TestTemplate:
return template return template
def validate_mitre_technique_id(db: Session, mitre_technique_id: str) -> None:
"""Raise BusinessRuleViolation unless *mitre_technique_id* matches a real,
already-synced MITRE ATT&CK technique — free text like "made up" or a
typo'd ID would otherwise silently create an orphaned template that can
never be found via technique lookups or coverage reporting."""
exists = db.query(Technique).filter(Technique.mitre_id == mitre_technique_id).first()
if not exists:
raise BusinessRuleViolation(
f"'{mitre_technique_id}' is not a known MITRE ATT&CK technique ID"
)
# Define function create_template # Define function create_template
def create_template(db: Session, **fields: object) -> TestTemplate: def create_template(db: Session, **fields: object) -> TestTemplate:
"""Create a test template from keyword args (e.g. payload.model_dump()). Does NOT commit.""" """Create a test template from keyword args (e.g. payload.model_dump()). Does NOT commit."""
validate_mitre_technique_id(db, fields["mitre_technique_id"])
# Assign template = TestTemplate(**fields) # Assign template = TestTemplate(**fields)
template = TestTemplate(**fields) template = TestTemplate(**fields)
# Stage new record(s) for database insertion # Stage new record(s) for database insertion
@@ -249,6 +262,8 @@ def create_template(db: Session, **fields: object) -> TestTemplate:
# Define function update_template # Define function update_template
def update_template(db: Session, template_id: uuid.UUID, **fields: object) -> TestTemplate: def update_template(db: Session, template_id: uuid.UUID, **fields: object) -> TestTemplate:
"""Update an existing template. Raises EntityNotFoundError if not found. Does NOT commit.""" """Update an existing template. Raises EntityNotFoundError if not found. Does NOT commit."""
if fields.get("mitre_technique_id"):
validate_mitre_technique_id(db, fields["mitre_technique_id"])
# Assign template = get_template_or_raise(db, template_id) # Assign template = get_template_or_raise(db, template_id)
template = get_template_or_raise(db, template_id) template = get_template_or_raise(db, template_id)
# Iterate over fields.items() # Iterate over fields.items()
@@ -417,3 +417,56 @@ def test_lead_created_campaign_is_still_draft_not_auto_approved(api, db, red_lea
) )
assert resp.status_code == 201, resp.text assert resp.status_code == 201, resp.text
assert resp.json()["status"] == "draft" assert resp.json()["status"] == "draft"
def test_manager_can_edit_a_campaign_they_rejected(api, db, red_lead_user, red_lead_headers, manager_headers):
campaign = _make_draft_campaign(db, red_lead_user.id)
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
reject = api(
"post", f"/api/v1/campaigns/{campaign.id}/reject", manager_headers,
json={"reason": "Needs a clearer scope"},
)
assert reject.status_code == 200, reject.text
assert reject.json()["status"] == "draft"
resp = api(
"patch", f"/api/v1/campaigns/{campaign.id}", manager_headers,
json={"name": "Edited by manager after rejection"},
)
assert resp.status_code == 200, resp.text
assert resp.json()["name"] == "Edited by manager after rejection"
def test_manager_cannot_edit_a_draft_campaign_that_was_never_rejected(
api, db, red_lead_user, red_lead_headers, manager_headers,
):
campaign = _make_draft_campaign(db, red_lead_user.id)
resp = api(
"patch", f"/api/v1/campaigns/{campaign.id}", manager_headers,
json={"name": "Manager should not be able to do this"},
)
assert resp.status_code == 403
def test_manager_can_approve_a_campaign_they_previously_rejected(
api, db, red_lead_user, red_lead_headers, manager_headers,
):
campaign = _make_draft_campaign(db, red_lead_user.id)
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
api(
"post", f"/api/v1/campaigns/{campaign.id}/reject", manager_headers,
json={"reason": "Needs a clearer scope"},
)
api(
"patch", f"/api/v1/campaigns/{campaign.id}", manager_headers,
json={"description": "Scope clarified after rejection"},
)
resp = api(
"post", f"/api/v1/campaigns/{campaign.id}/approve", manager_headers,
json={"start_date": "2020-01-01T00:00:00"},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["status"] == "active"
assert body["rejection_reason"] is None
@@ -0,0 +1,78 @@
"""A TestTemplate/TemplateSuggestion's mitre_technique_id must correspond
to a real, already-synced MITRE ATT&CK technique — free text or a typo'd
ID would otherwise create an orphaned template invisible to technique
lookups and coverage reporting.
"""
import pytest
@pytest.fixture
def technique(api, auth_headers):
resp = api(
"post", "/api/v1/techniques", auth_headers,
json={"mitre_id": "T1059.500", "name": "Command Line MITRE Validation"},
)
assert resp.status_code == 201, resp.text
return resp.json()["id"]
class TestTemplateCreateValidation:
def test_create_template_rejects_unknown_mitre_id(self, api, red_lead_headers, technique):
resp = api(
"post", "/api/v1/test-templates", red_lead_headers,
json={"mitre_technique_id": "T9999.999", "name": "Bogus template"},
)
assert resp.status_code == 400
assert "T9999.999" in resp.text
def test_create_template_accepts_known_mitre_id(self, api, red_lead_headers, technique):
resp = api(
"post", "/api/v1/test-templates", red_lead_headers,
json={"mitre_technique_id": "T1059.500", "name": "Valid template"},
)
assert resp.status_code == 201, resp.text
def test_update_template_rejects_unknown_mitre_id(self, api, red_lead_headers, technique):
created = api(
"post", "/api/v1/test-templates", red_lead_headers,
json={"mitre_technique_id": "T1059.500", "name": "Will be edited"},
)
template_id = created.json()["id"]
resp = api(
"patch", f"/api/v1/test-templates/{template_id}", red_lead_headers,
json={"mitre_technique_id": "T0000.000", "name": "Will be edited"},
)
assert resp.status_code == 400
class TestTemplateSuggestionValidation:
def test_propose_template_rejects_unknown_mitre_id(self, api, red_tech_headers, technique):
resp = api(
"post", "/api/v1/template-suggestions", red_tech_headers,
json={"mitre_technique_id": "T8888.888", "name": "Bogus proposal"},
)
assert resp.status_code == 400
def test_propose_template_accepts_known_mitre_id(self, api, red_tech_headers, technique):
resp = api(
"post", "/api/v1/template-suggestions", red_tech_headers,
json={"mitre_technique_id": "T1059.500", "name": "Valid proposal"},
)
assert resp.status_code == 201, resp.text
def test_approve_suggestion_with_edited_unknown_mitre_id_rejected(
self, api, red_tech_headers, red_lead_headers, technique,
):
created = api(
"post", "/api/v1/template-suggestions", red_tech_headers,
json={"mitre_technique_id": "T1059.500", "name": "Valid proposal"},
)
suggestion_id = created.json()["id"]
resp = api(
"post", f"/api/v1/template-suggestions/{suggestion_id}/approve", red_lead_headers,
json={"mitre_technique_id": "T7777.777"},
)
assert resp.status_code == 400
@@ -1,8 +1,9 @@
import { useState } from "react"; 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 { X, Loader2 } from "lucide-react";
import { createTemplate, type CreateTemplatePayload } from "../api/test-templates"; import { createTemplate, type CreateTemplatePayload } from "../api/test-templates";
import { proposeTemplate } from "../api/template-suggestions"; import { proposeTemplate } from "../api/template-suggestions";
import { getTechniques, type TechniqueSummary } from "../api/techniques";
import { useToast } from "./Toast"; import { useToast } from "./Toast";
const SEVERITY_OPTIONS = ["low", "medium", "high", "critical"]; const SEVERITY_OPTIONS = ["low", "medium", "high", "critical"];
@@ -22,6 +23,11 @@ export default function CreateTemplateModal({
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { showToast } = useToast(); const { showToast } = useToast();
const { data: techniques, isLoading: techniquesLoading } = useQuery({
queryKey: ["techniques"],
queryFn: () => getTechniques(),
});
const [form, setForm] = useState<CreateTemplatePayload>({ const [form, setForm] = useState<CreateTemplatePayload>({
mitre_technique_id: "", mitre_technique_id: "",
name: "", name: "",
@@ -91,15 +97,22 @@ export default function CreateTemplateModal({
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="mb-1 block text-xs font-medium text-gray-500"> <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> </label>
<input <select
required required
disabled={techniquesLoading}
value={form.mitre_technique_id} value={form.mitre_technique_id}
onChange={(e) => set("mitre_technique_id", e.target.value)} 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" 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>
<div> <div>
<label className="mb-1 block text-xs font-medium text-gray-500"> <label className="mb-1 block text-xs font-medium text-gray-500">
+81
View File
@@ -27,6 +27,7 @@ import {
submitCampaignForApproval, submitCampaignForApproval,
approveCampaign, approveCampaign,
rejectCampaign, rejectCampaign,
updateCampaign,
listCampaignModificationRequests, listCampaignModificationRequests,
approveModificationRequest, approveModificationRequest,
rejectModificationRequest, rejectModificationRequest,
@@ -84,6 +85,8 @@ export default function CampaignDetailPage() {
const [approveStartDate, setApproveStartDate] = useState(""); const [approveStartDate, setApproveStartDate] = useState("");
const [rejectModalOpen, setRejectModalOpen] = useState(false); const [rejectModalOpen, setRejectModalOpen] = useState(false);
const [rejectReason, setRejectReason] = useState(""); const [rejectReason, setRejectReason] = useState("");
const [editModalOpen, setEditModalOpen] = useState(false);
const [editForm, setEditForm] = useState({ name: "", description: "" });
const showToast = (message: string, type: "success" | "error") => { const showToast = (message: string, type: "success" | "error") => {
setToast({ message, type }); setToast({ message, type });
@@ -144,6 +147,16 @@ export default function CampaignDetailPage() {
onError: (err: Error) => showToast(err.message, "error"), 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({ const completeMutation = useMutation({
mutationFn: () => completeCampaign(campaignId!), mutationFn: () => completeCampaign(campaignId!),
onSuccess: () => { onSuccess: () => {
@@ -287,6 +300,10 @@ export default function CampaignDetailPage() {
} }
const progress = campaign.progress; 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -414,6 +431,26 @@ export default function CampaignDetailPage() {
</button> </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" && ( {canComplete && campaign.status === "active" && (
<button <button
onClick={() => completeMutation.mutate()} onClick={() => completeMutation.mutate()}
@@ -852,6 +889,50 @@ export default function CampaignDetailPage() {
onSuccess={() => showToast("Modification request submitted", "success")} 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 */} {/* Approve modal */}
{approveModalOpen && ( {approveModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"> <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>
</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 */} {/* Filters bar */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4"> <div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
<div className="flex flex-wrap items-end gap-3"> <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 */} {/* Template instantiation modal */}
{templateId && ( {templateId && (
<TestFromTemplateForm <TestFromTemplateForm
@@ -441,7 +443,46 @@ function TemplateSuggestionsPanel() {
{s.submitter_name && ( {s.submitter_name && (
<p className="mt-0.5 text-xs text-gray-500">Proposed by {s.submitter_name}</p> <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.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 && ( {s.attack_procedure && (
<div className="mt-2"> <div className="mt-2">
<p className="text-xs font-medium uppercase text-gray-500">Suggested Attack Procedure</p> <p className="text-xs font-medium uppercase text-gray-500">Suggested Attack Procedure</p>
@@ -458,6 +499,14 @@ function TemplateSuggestionsPanel() {
</pre> </pre>
</div> </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"> <div className="mt-3 flex gap-2">
<button <button
onClick={() => approveMutation.mutate(s.id)} onClick={() => approveMutation.mutate(s.id)}