|
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
|
|
ToggleRight,
|
|
|
|
|
BarChart3,
|
|
|
|
|
X,
|
|
|
|
|
Pencil,
|
|
|
|
|
} from "lucide-react";
|
|
|
|
|
import {
|
|
|
|
|
triggerMitreSync,
|
|
|
|
@@ -30,6 +31,7 @@ import {
|
|
|
|
|
getTemplateStats,
|
|
|
|
|
getAllTemplates,
|
|
|
|
|
createTemplate,
|
|
|
|
|
updateTemplate,
|
|
|
|
|
toggleTemplateActive,
|
|
|
|
|
bulkActivateTemplates,
|
|
|
|
|
type TemplateStats,
|
|
|
|
@@ -43,6 +45,7 @@ export default function SystemPage() {
|
|
|
|
|
const [intelResult, setIntelResult] = useState<IntelScanResponse | null>(null);
|
|
|
|
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
|
|
|
|
const [bulkConfirm, setBulkConfirm] = useState<"activate" | "deactivate" | null>(null);
|
|
|
|
|
const [selectedTemplate, setSelectedTemplate] = useState<TestTemplate | null>(null);
|
|
|
|
|
|
|
|
|
|
// ── Existing queries ─────────────────────────────────────────────
|
|
|
|
|
const {
|
|
|
|
@@ -119,6 +122,16 @@ export default function SystemPage() {
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const updateTemplateMutation = useMutation({
|
|
|
|
|
mutationFn: ({ id, payload }: { id: string; payload: Partial<CreateTemplatePayload> }) =>
|
|
|
|
|
updateTemplate(id, payload),
|
|
|
|
|
onSuccess: (updated) => {
|
|
|
|
|
setSelectedTemplate(updated);
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["templates-admin"] });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["test-templates"] });
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const formatNextRun = (dateStr: string | null) => {
|
|
|
|
|
if (!dateStr) return "Not scheduled";
|
|
|
|
|
const date = new Date(dateStr);
|
|
|
|
@@ -168,18 +181,11 @@ export default function SystemPage() {
|
|
|
|
|
<div className="mt-4 rounded-lg border border-green-500/30 bg-green-900/20 p-3">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<CheckCircle className="h-4 w-4 text-green-400" />
|
|
|
|
|
<span className="text-sm font-medium text-green-400">Sync Complete</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
|
|
|
|
|
<div>
|
|
|
|
|
<span className="text-gray-400">New techniques:</span>
|
|
|
|
|
<span className="ml-2 font-medium text-white">{syncResult.new}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<span className="text-gray-400">Updated:</span>
|
|
|
|
|
<span className="ml-2 font-medium text-white">{syncResult.updated}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<span className="text-sm font-medium text-green-400">
|
|
|
|
|
{syncResult.status === "started" ? "Sync Started" : "Sync Complete"}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<p className="mt-1 text-sm text-gray-400">{syncResult.message}</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
@@ -474,9 +480,14 @@ export default function SystemPage() {
|
|
|
|
|
className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<td className="py-3 pr-4">
|
|
|
|
|
<span className="font-medium text-gray-200 truncate block max-w-[200px]">
|
|
|
|
|
{tpl.name}
|
|
|
|
|
</span>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setSelectedTemplate(tpl)}
|
|
|
|
|
className="flex items-center gap-1.5 text-left font-medium text-cyan-400 hover:text-cyan-300 truncate max-w-[200px] transition-colors"
|
|
|
|
|
title="Click to view/edit"
|
|
|
|
|
>
|
|
|
|
|
<Pencil className="h-3 w-3 shrink-0 opacity-60" />
|
|
|
|
|
<span className="truncate">{tpl.name}</span>
|
|
|
|
|
</button>
|
|
|
|
|
</td>
|
|
|
|
|
<td className="py-3 px-4">
|
|
|
|
|
<span className="font-mono text-xs text-cyan-400">
|
|
|
|
@@ -691,6 +702,18 @@ export default function SystemPage() {
|
|
|
|
|
</div>
|
|
|
|
|
</dl>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Template Detail Modal */}
|
|
|
|
|
{selectedTemplate && (
|
|
|
|
|
<TemplateDetailModal
|
|
|
|
|
template={selectedTemplate}
|
|
|
|
|
onClose={() => setSelectedTemplate(null)}
|
|
|
|
|
onSave={(id, payload) => updateTemplateMutation.mutate({ id, payload })}
|
|
|
|
|
onToggleActive={(id) => toggleActiveMutation.mutate(id)}
|
|
|
|
|
isSaving={updateTemplateMutation.isPending}
|
|
|
|
|
isToggling={toggleActiveMutation.isPending}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
@@ -901,3 +924,211 @@ function CreateTemplateForm({
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ── Template Detail / Edit Modal ────────────────────────────────── */
|
|
|
|
|
|
|
|
|
|
function TemplateDetailModal({
|
|
|
|
|
template,
|
|
|
|
|
onClose,
|
|
|
|
|
onSave,
|
|
|
|
|
onToggleActive,
|
|
|
|
|
isSaving,
|
|
|
|
|
isToggling,
|
|
|
|
|
}: {
|
|
|
|
|
template: TestTemplate;
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
onSave: (id: string, payload: Partial<CreateTemplatePayload>) => void;
|
|
|
|
|
onToggleActive: (id: string) => void;
|
|
|
|
|
isSaving: boolean;
|
|
|
|
|
isToggling: boolean;
|
|
|
|
|
}) {
|
|
|
|
|
const [form, setForm] = useState<Partial<CreateTemplatePayload>>({
|
|
|
|
|
name: template.name,
|
|
|
|
|
description: template.description ?? "",
|
|
|
|
|
attack_procedure: template.attack_procedure ?? "",
|
|
|
|
|
expected_detection: template.expected_detection ?? "",
|
|
|
|
|
platform: template.platform ?? "",
|
|
|
|
|
tool_suggested: template.tool_suggested ?? "",
|
|
|
|
|
severity: template.severity ?? "",
|
|
|
|
|
mitre_technique_id: template.mitre_technique_id,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
onSave(template.id, form);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
|
|
|
|
<div className="w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-xl border border-gray-700 bg-gray-900 shadow-2xl">
|
|
|
|
|
{/* Header */}
|
|
|
|
|
<div className="flex items-center justify-between border-b border-gray-800 px-6 py-4">
|
|
|
|
|
<div>
|
|
|
|
|
<h2 className="text-lg font-semibold text-white">Edit Template</h2>
|
|
|
|
|
<p className="mt-0.5 text-xs text-gray-400 font-mono">{template.mitre_technique_id}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<button onClick={onClose} className="text-gray-400 hover:text-white transition-colors">
|
|
|
|
|
<X className="h-5 w-5" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Meta badges */}
|
|
|
|
|
<div className="flex flex-wrap gap-2 px-6 py-3 border-b border-gray-800">
|
|
|
|
|
<span
|
|
|
|
|
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
|
|
|
|
|
template.source === "atomic_red_team"
|
|
|
|
|
? "bg-red-900/50 text-red-400 border-red-500/30"
|
|
|
|
|
: template.source === "mitre"
|
|
|
|
|
? "bg-blue-900/50 text-blue-400 border-blue-500/30"
|
|
|
|
|
: "bg-gray-800/50 text-gray-400 border-gray-600/30"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{template.source.replace(/_/g, " ")}
|
|
|
|
|
</span>
|
|
|
|
|
<span
|
|
|
|
|
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
|
|
|
|
|
template.is_active
|
|
|
|
|
? "bg-green-900/50 text-green-400 border-green-500/30"
|
|
|
|
|
: "bg-gray-800/50 text-gray-500 border-gray-600/30"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{template.is_active ? "Active" : "Inactive"}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Form */}
|
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-4 px-6 py-4">
|
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-gray-300 mb-1">Template Name</label>
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={form.name ?? ""}
|
|
|
|
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
|
|
|
|
required
|
|
|
|
|
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>
|
|
|
|
|
<label className="block text-sm font-medium text-gray-300 mb-1">MITRE Technique ID</label>
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={form.mitre_technique_id ?? ""}
|
|
|
|
|
onChange={(e) => setForm({ ...form, mitre_technique_id: e.target.value })}
|
|
|
|
|
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>
|
|
|
|
|
<label className="block text-sm font-medium text-gray-300 mb-1">Platform</label>
|
|
|
|
|
<select
|
|
|
|
|
value={form.platform ?? ""}
|
|
|
|
|
onChange={(e) => setForm({ ...form, platform: e.target.value || undefined })}
|
|
|
|
|
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"
|
|
|
|
|
>
|
|
|
|
|
<option value="">None</option>
|
|
|
|
|
<option value="windows">Windows</option>
|
|
|
|
|
<option value="linux">Linux</option>
|
|
|
|
|
<option value="macos">macOS</option>
|
|
|
|
|
<option value="cloud">Cloud</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-gray-300 mb-1">Severity</label>
|
|
|
|
|
<select
|
|
|
|
|
value={form.severity ?? ""}
|
|
|
|
|
onChange={(e) => setForm({ ...form, severity: e.target.value || undefined })}
|
|
|
|
|
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"
|
|
|
|
|
>
|
|
|
|
|
<option value="">None</option>
|
|
|
|
|
<option value="low">Low</option>
|
|
|
|
|
<option value="medium">Medium</option>
|
|
|
|
|
<option value="high">High</option>
|
|
|
|
|
<option value="critical">Critical</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-gray-300 mb-1">Description</label>
|
|
|
|
|
<textarea
|
|
|
|
|
value={form.description ?? ""}
|
|
|
|
|
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
|
|
|
|
rows={2}
|
|
|
|
|
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>
|
|
|
|
|
<label className="block text-sm font-medium text-gray-300 mb-1">Attack Procedure</label>
|
|
|
|
|
<textarea
|
|
|
|
|
value={form.attack_procedure ?? ""}
|
|
|
|
|
onChange={(e) => setForm({ ...form, attack_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>
|
|
|
|
|
<label className="block text-sm font-medium text-gray-300 mb-1">Expected Detection</label>
|
|
|
|
|
<textarea
|
|
|
|
|
value={form.expected_detection ?? ""}
|
|
|
|
|
onChange={(e) => setForm({ ...form, expected_detection: e.target.value })}
|
|
|
|
|
rows={2}
|
|
|
|
|
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>
|
|
|
|
|
<label className="block text-sm font-medium text-gray-300 mb-1">Suggested Tool</label>
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={form.tool_suggested ?? ""}
|
|
|
|
|
onChange={(e) => setForm({ ...form, tool_suggested: e.target.value })}
|
|
|
|
|
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>
|
|
|
|
|
|
|
|
|
|
{/* Action buttons */}
|
|
|
|
|
<div className="flex items-center gap-3 pt-2 border-t border-gray-800">
|
|
|
|
|
<button
|
|
|
|
|
type="submit"
|
|
|
|
|
disabled={isSaving}
|
|
|
|
|
className="flex items-center gap-2 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 disabled:opacity-50 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <CheckCircle className="h-4 w-4" />}
|
|
|
|
|
{isSaving ? "Saving..." : "Save Changes"}
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => onToggleActive(template.id)}
|
|
|
|
|
disabled={isToggling}
|
|
|
|
|
className={`flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50 ${
|
|
|
|
|
template.is_active
|
|
|
|
|
? "border-red-500/30 bg-red-900/20 text-red-400 hover:bg-red-900/40"
|
|
|
|
|
: "border-green-500/30 bg-green-900/20 text-green-400 hover:bg-green-900/40"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{isToggling ? (
|
|
|
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
|
|
|
) : template.is_active ? (
|
|
|
|
|
<ToggleRight className="h-4 w-4" />
|
|
|
|
|
) : (
|
|
|
|
|
<ToggleLeft className="h-4 w-4" />
|
|
|
|
|
)}
|
|
|
|
|
{template.is_active ? "Deactivate" : "Activate"}
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
className="ml-auto rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-sm font-medium text-gray-300 hover:border-gray-600 hover:text-white transition-colors"
|
|
|
|
|
>
|
|
|
|
|
Close
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|