feat(phase-15): add Test Catalog page, template instantiation, and auto-migration entrypoint (T-119, T-120, T-121)
T-119: TestCatalogPage with search, filters (source/platform/severity), template cards grid, and pagination T-120: TestFromTemplateForm modal with pre-filled fields from template, required field validation, and redirect on creation T-121: Integrate Available Test Templates section in TechniqueDetailPage with Run This Test buttons; fix missing testStateBadgeColors for new states Also: add backend entrypoint.sh for automatic Alembic migrations + seed on container startup, add curl to Dockerfile for healthcheck
This commit is contained in:
@@ -6,6 +6,7 @@ WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first for better caching
|
||||
@@ -15,8 +16,11 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Make entrypoint executable
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Default command
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
# Default command (migrations + seed + uvicorn)
|
||||
CMD ["sh", "/app/entrypoint.sh"]
|
||||
|
||||
11
backend/entrypoint.sh
Normal file
11
backend/entrypoint.sh
Normal file
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "=== Running Alembic migrations ==="
|
||||
alembic upgrade head
|
||||
|
||||
echo "=== Seeding admin user ==="
|
||||
python -m app.seed
|
||||
|
||||
echo "=== Starting uvicorn ==="
|
||||
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
@@ -84,7 +84,7 @@ services:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
command: sh /app/entrypoint.sh
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 10s
|
||||
|
||||
@@ -6,6 +6,7 @@ import TechniqueDetailPage from "./pages/TechniqueDetailPage";
|
||||
import TestsPage from "./pages/TestsPage";
|
||||
import TestCreatePage from "./pages/TestCreatePage";
|
||||
import TestDetailPage from "./pages/TestDetailPage";
|
||||
import TestCatalogPage from "./pages/TestCatalogPage";
|
||||
import SystemPage from "./pages/SystemPage";
|
||||
import UsersPage from "./pages/UsersPage";
|
||||
import AuditLogPage from "./pages/AuditLogPage";
|
||||
@@ -32,6 +33,8 @@ export default function App() {
|
||||
<Route path="/tests" element={<TestsPage />} />
|
||||
<Route path="/tests/new" element={<TestCreatePage />} />
|
||||
<Route path="/tests/:testId" element={<TestDetailPage />} />
|
||||
<Route path="/test-catalog" element={<TestCatalogPage />} />
|
||||
<Route path="/test-catalog/:templateId/use" element={<TestCatalogPage />} />
|
||||
<Route
|
||||
path="/system"
|
||||
element={
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
LayoutDashboard,
|
||||
Shield,
|
||||
FlaskConical,
|
||||
BookOpen,
|
||||
Settings,
|
||||
Users,
|
||||
FileText,
|
||||
@@ -13,6 +14,7 @@ const baseLinks = [
|
||||
{ to: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ to: "/techniques", label: "Techniques", icon: Shield },
|
||||
{ to: "/tests", label: "Tests", icon: FlaskConical },
|
||||
{ to: "/test-catalog", label: "Test Catalog", icon: BookOpen },
|
||||
];
|
||||
|
||||
const adminLinks = [
|
||||
|
||||
250
frontend/src/components/TestFromTemplateForm.tsx
Normal file
250
frontend/src/components/TestFromTemplateForm.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
Loader2,
|
||||
FlaskConical,
|
||||
X,
|
||||
AlertCircle,
|
||||
BookOpen,
|
||||
} from "lucide-react";
|
||||
import { getTemplateById } from "../api/test-templates";
|
||||
import { createTestFromTemplate } from "../api/tests";
|
||||
import type { TestTemplate } from "../types/models";
|
||||
|
||||
// ── Props ──────────────────────────────────────────────────────────
|
||||
|
||||
interface TestFromTemplateFormProps {
|
||||
templateId: string;
|
||||
/** If provided, pre-selects the technique (from TechniqueDetailPage). */
|
||||
techniqueId?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────
|
||||
|
||||
export default function TestFromTemplateForm({
|
||||
templateId,
|
||||
techniqueId: propTechniqueId,
|
||||
onClose,
|
||||
}: TestFromTemplateFormProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// ── Load template details ──────────────────────────────────────
|
||||
|
||||
const {
|
||||
data: template,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["test-template", templateId],
|
||||
queryFn: () => getTemplateById(templateId),
|
||||
enabled: !!templateId,
|
||||
});
|
||||
|
||||
// ── Form state (pre-filled from template) ──────────────────────
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [technique, setTechnique] = useState(propTechniqueId || "");
|
||||
const [platform, setPlatform] = useState("");
|
||||
const [procedureText, setProcedureText] = useState("");
|
||||
const [toolUsed, setToolUsed] = useState("");
|
||||
const [expectedDetection, setExpectedDetection] = useState("");
|
||||
|
||||
// Hydrate form when template loads
|
||||
useEffect(() => {
|
||||
if (template) {
|
||||
setName(template.name || "");
|
||||
setDescription(template.description || "");
|
||||
setTechnique(propTechniqueId || template.mitre_technique_id || "");
|
||||
setPlatform(template.platform || "");
|
||||
setProcedureText(template.attack_procedure || "");
|
||||
setToolUsed(template.tool_suggested || "");
|
||||
setExpectedDetection(template.expected_detection || "");
|
||||
}
|
||||
}, [template, propTechniqueId]);
|
||||
|
||||
// ── Submit ─────────────────────────────────────────────────────
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createTestFromTemplate(templateId, technique),
|
||||
onSuccess: (test) => {
|
||||
navigate(`/tests/${test.id}`);
|
||||
},
|
||||
});
|
||||
|
||||
const canSubmit = name.trim().length > 0 && technique.trim().length > 0;
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-xl border border-gray-800 bg-gray-900 shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-gray-800 bg-gray-900 px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="h-5 w-5 text-cyan-400" />
|
||||
<h3 className="text-lg font-semibold text-white">Create Test from Template</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-gray-400 hover:bg-gray-800 hover:text-white"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-cyan-400" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
<AlertCircle className="h-10 w-10 text-red-400" />
|
||||
<p className="mt-2 text-sm text-red-400">Failed to load template</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5 px-6 py-5">
|
||||
{/* Template info banner */}
|
||||
<div className="rounded-lg border border-cyan-500/20 bg-cyan-900/10 p-3">
|
||||
<p className="text-xs text-cyan-400">
|
||||
Pre-filled from template: <strong>{template?.name}</strong>
|
||||
{template?.source && (
|
||||
<span className="ml-2 rounded-full border border-gray-600/30 bg-gray-800/50 px-2 py-0.5 text-[10px] text-gray-400">
|
||||
{template.source}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-300">
|
||||
Test Name <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
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-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
|
||||
placeholder="Test name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-300">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(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 placeholder-gray-500 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
|
||||
placeholder="Test description..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Technique ID */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-300">
|
||||
MITRE Technique ID <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
value={technique}
|
||||
onChange={(e) => setTechnique(e.target.value)}
|
||||
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-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
|
||||
placeholder="e.g. T1059.001"
|
||||
readOnly={!!propTechniqueId}
|
||||
/>
|
||||
{propTechniqueId && (
|
||||
<p className="mt-1 text-xs text-gray-500">Pre-selected from technique page</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Platform */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-300">Platform</label>
|
||||
<input
|
||||
value={platform}
|
||||
onChange={(e) => setPlatform(e.target.value)}
|
||||
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-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
|
||||
placeholder="e.g. windows, linux, macos"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Attack Procedure */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-300">
|
||||
Suggested Attack Procedure
|
||||
</label>
|
||||
<textarea
|
||||
value={procedureText}
|
||||
onChange={(e) => setProcedureText(e.target.value)}
|
||||
rows={5}
|
||||
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 font-mono text-sm text-gray-200 placeholder-gray-500 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
|
||||
placeholder="Attack procedure..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tool Suggested */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-300">
|
||||
Suggested Tool
|
||||
</label>
|
||||
<input
|
||||
value={toolUsed}
|
||||
onChange={(e) => setToolUsed(e.target.value)}
|
||||
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-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
|
||||
placeholder="e.g. Atomic Red Team, Cobalt Strike"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Expected Detection (read-only reference for Blue Team) */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-300">
|
||||
Expected Detection
|
||||
<span className="ml-2 text-xs text-gray-500">(read-only reference for Blue Team)</span>
|
||||
</label>
|
||||
<div className="rounded-lg border border-gray-700 bg-gray-800/50 p-3">
|
||||
<p className="whitespace-pre-wrap font-mono text-sm text-gray-400">
|
||||
{expectedDetection || "No detection guidance provided in template."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{createMutation.isError && (
|
||||
<div className="rounded-lg border border-red-500/30 bg-red-900/20 p-3 text-sm text-red-400">
|
||||
{(createMutation.error as Error)?.message || "Failed to create test"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="sticky bottom-0 flex justify-end gap-3 border-t border-gray-800 bg-gray-900 px-6 py-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-400 hover:bg-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={!canSubmit || createMutation.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"
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<FlaskConical className="h-4 w-4" />
|
||||
)}
|
||||
Create Test
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
@@ -13,9 +14,13 @@ import {
|
||||
Check,
|
||||
X,
|
||||
AlertTriangle,
|
||||
BookOpen,
|
||||
FlaskConical,
|
||||
} from "lucide-react";
|
||||
import { getTechniqueByMitreId, markTechniqueReviewed } from "../api/techniques";
|
||||
import { getTemplatesByTechnique } from "../api/test-templates";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import TestFromTemplateForm from "../components/TestFromTemplateForm";
|
||||
import type { TechniqueStatus, TestState, TestResult } from "../types/models";
|
||||
|
||||
const statusBadgeColors: Record<TechniqueStatus, string> = {
|
||||
@@ -29,6 +34,8 @@ const statusBadgeColors: Record<TechniqueStatus, string> = {
|
||||
|
||||
const testStateBadgeColors: Record<TestState, string> = {
|
||||
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
||||
red_executing: "bg-orange-900/50 text-orange-400 border-orange-500/30",
|
||||
blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30",
|
||||
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
||||
validated: "bg-green-900/50 text-green-400 border-green-500/30",
|
||||
rejected: "bg-red-900/50 text-red-400 border-red-500/30",
|
||||
@@ -46,6 +53,8 @@ export default function TechniqueDetailPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [templateFormId, setTemplateFormId] = useState<string | null>(null);
|
||||
|
||||
const canReview =
|
||||
user?.role === "admin" || user?.role === "red_lead" || user?.role === "blue_lead";
|
||||
|
||||
@@ -59,6 +68,12 @@ export default function TechniqueDetailPage() {
|
||||
enabled: !!mitreId,
|
||||
});
|
||||
|
||||
const { data: templates = [], isLoading: isTemplatesLoading } = useQuery({
|
||||
queryKey: ["templates-for-technique", mitreId],
|
||||
queryFn: () => getTemplatesByTechnique(mitreId!),
|
||||
enabled: !!mitreId,
|
||||
});
|
||||
|
||||
const reviewMutation = useMutation({
|
||||
mutationFn: () => markTechniqueReviewed(mitreId!),
|
||||
onSuccess: () => {
|
||||
@@ -327,6 +342,72 @@ export default function TechniqueDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Available Test Templates Section */}
|
||||
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-white">Available Test Templates</h2>
|
||||
<button
|
||||
onClick={() => navigate("/test-catalog")}
|
||||
className="flex items-center gap-1.5 text-sm text-gray-400 hover:text-cyan-400 transition-colors"
|
||||
>
|
||||
<BookOpen className="h-4 w-4" />
|
||||
Browse Full Catalog
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isTemplatesLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-gray-500" />
|
||||
</div>
|
||||
) : templates.length > 0 ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{templates.map((tpl) => (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className="flex items-center justify-between rounded-lg border border-gray-800 bg-gray-800/30 p-4 transition-colors hover:border-gray-700"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-gray-200">{tpl.name}</p>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
<span className="inline-flex rounded-full border border-gray-600/30 bg-gray-800/50 px-2 py-0.5 text-[10px] text-gray-400">
|
||||
{tpl.source === "atomic_red_team" ? "Atomic" : tpl.source}
|
||||
</span>
|
||||
{tpl.platform && (
|
||||
<span className="inline-flex rounded-full border border-gray-600/30 bg-gray-800/50 px-2 py-0.5 text-[10px] capitalize text-gray-400">
|
||||
{tpl.platform}
|
||||
</span>
|
||||
)}
|
||||
{tpl.severity && (
|
||||
<span className="inline-flex rounded-full border border-gray-600/30 bg-gray-800/50 px-2 py-0.5 text-[10px] capitalize text-gray-400">
|
||||
{tpl.severity}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setTemplateFormId(tpl.id)}
|
||||
className="ml-3 flex shrink-0 items-center gap-1 rounded-lg border border-cyan-500/30 bg-cyan-500/10 px-3 py-1.5 text-xs font-medium text-cyan-400 hover:bg-cyan-500/20 transition-colors"
|
||||
>
|
||||
<FlaskConical className="h-3.5 w-3.5" />
|
||||
Run This Test
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-gray-400">
|
||||
<BookOpen className="mb-2 h-8 w-8 text-gray-600" />
|
||||
<p className="text-sm">No test templates available for this technique.</p>
|
||||
<button
|
||||
onClick={() => navigate("/test-catalog")}
|
||||
className="mt-3 flex items-center gap-1 text-sm text-cyan-400 hover:underline"
|
||||
>
|
||||
Browse the full test catalog
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Intel Items Section */}
|
||||
{technique.intel_items && technique.intel_items.length > 0 && (
|
||||
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
||||
@@ -358,6 +439,15 @@ export default function TechniqueDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Template instantiation modal */}
|
||||
{templateFormId && technique && (
|
||||
<TestFromTemplateForm
|
||||
templateId={templateFormId}
|
||||
techniqueId={technique.id}
|
||||
onClose={() => setTemplateFormId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
340
frontend/src/pages/TestCatalogPage.tsx
Normal file
340
frontend/src/pages/TestCatalogPage.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate, useSearchParams, useParams } from "react-router-dom";
|
||||
import {
|
||||
Loader2,
|
||||
Search,
|
||||
BookOpen,
|
||||
Filter,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
FlaskConical,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { getTemplates } from "../api/test-templates";
|
||||
import TestFromTemplateForm from "../components/TestFromTemplateForm";
|
||||
import type { TestTemplateSummary } from "../types/models";
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────
|
||||
|
||||
const PAGE_SIZE = 12;
|
||||
|
||||
const SOURCE_OPTIONS = [
|
||||
{ value: "", label: "All Sources" },
|
||||
{ value: "atomic_red_team", label: "Atomic Red Team" },
|
||||
{ value: "custom", label: "Custom" },
|
||||
];
|
||||
|
||||
const SEVERITY_OPTIONS = [
|
||||
{ value: "", label: "All Severities" },
|
||||
{ value: "low", label: "Low" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
{ value: "high", label: "High" },
|
||||
{ value: "critical", label: "Critical" },
|
||||
];
|
||||
|
||||
const PLATFORM_OPTIONS = [
|
||||
{ value: "", label: "All Platforms" },
|
||||
{ value: "windows", label: "Windows" },
|
||||
{ value: "linux", label: "Linux" },
|
||||
{ value: "macos", label: "macOS" },
|
||||
{ value: "azure-ad", label: "Azure AD" },
|
||||
{ value: "office-365", label: "Office 365" },
|
||||
{ value: "containers", label: "Containers" },
|
||||
];
|
||||
|
||||
const SOURCE_BADGE: Record<string, string> = {
|
||||
atomic_red_team: "bg-red-900/50 text-red-400 border-red-500/30",
|
||||
custom: "bg-cyan-900/50 text-cyan-400 border-cyan-500/30",
|
||||
};
|
||||
|
||||
const SEVERITY_BADGE: Record<string, string> = {
|
||||
low: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
||||
medium: "bg-yellow-900/50 text-yellow-400 border-yellow-500/30",
|
||||
high: "bg-orange-900/50 text-orange-400 border-orange-500/30",
|
||||
critical: "bg-red-900/50 text-red-400 border-red-500/30",
|
||||
};
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────
|
||||
|
||||
export default function TestCatalogPage() {
|
||||
const navigate = useNavigate();
|
||||
const { templateId } = useParams<{ templateId: string }>();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [search, setSearch] = useState(searchParams.get("search") || "");
|
||||
const [source, setSource] = useState(searchParams.get("source") || "");
|
||||
const [platform, setPlatform] = useState(searchParams.get("platform") || "");
|
||||
const [severity, setSeverity] = useState(searchParams.get("severity") || "");
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
// Build filters
|
||||
const filters = {
|
||||
search: search || undefined,
|
||||
source: source || undefined,
|
||||
platform: platform || undefined,
|
||||
severity: severity || undefined,
|
||||
offset: page * PAGE_SIZE,
|
||||
limit: PAGE_SIZE,
|
||||
};
|
||||
|
||||
const { data: templates = [], isLoading } = useQuery({
|
||||
queryKey: ["test-templates", filters],
|
||||
queryFn: () => getTemplates(filters),
|
||||
});
|
||||
|
||||
// ── Filter handlers ──────────────────────────────────────────────
|
||||
|
||||
const applyFilters = () => {
|
||||
setPage(0);
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.set("search", search);
|
||||
if (source) params.set("source", source);
|
||||
if (platform) params.set("platform", platform);
|
||||
if (severity) params.set("severity", severity);
|
||||
setSearchParams(params);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearch("");
|
||||
setSource("");
|
||||
setPlatform("");
|
||||
setSeverity("");
|
||||
setPage(0);
|
||||
setSearchParams({});
|
||||
};
|
||||
|
||||
const hasActiveFilters = search || source || platform || severity;
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Test Catalog</h1>
|
||||
<p className="mt-1 text-sm text-gray-400">
|
||||
Browse available test templates. Use a template to quickly create a security test.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters bar */}
|
||||
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
{/* Search */}
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="mb-1 block text-xs font-medium text-gray-500">Search</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && applyFilters()}
|
||||
placeholder="Search templates..."
|
||||
className="w-full rounded-lg border border-gray-700 bg-gray-800 py-2 pl-9 pr-3 text-sm text-gray-200 placeholder-gray-500 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source */}
|
||||
<div className="min-w-[150px]">
|
||||
<label className="mb-1 block text-xs font-medium text-gray-500">Source</label>
|
||||
<select
|
||||
value={source}
|
||||
onChange={(e) => { setSource(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"
|
||||
>
|
||||
{SOURCE_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Platform */}
|
||||
<div className="min-w-[150px]">
|
||||
<label className="mb-1 block text-xs font-medium text-gray-500">Platform</label>
|
||||
<select
|
||||
value={platform}
|
||||
onChange={(e) => { setPlatform(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"
|
||||
>
|
||||
{PLATFORM_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Severity */}
|
||||
<div className="min-w-[140px]">
|
||||
<label className="mb-1 block text-xs font-medium text-gray-500">Severity</label>
|
||||
<select
|
||||
value={severity}
|
||||
onChange={(e) => { setSeverity(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"
|
||||
>
|
||||
{SEVERITY_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<button
|
||||
onClick={applyFilters}
|
||||
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 transition-colors"
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
Apply
|
||||
</button>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-gray-700 px-3 py-2 text-sm text-gray-400 hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{isLoading ? (
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-cyan-400" />
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-gray-800 bg-gray-900 py-16">
|
||||
<BookOpen className="h-12 w-12 text-gray-600" />
|
||||
<p className="mt-3 text-gray-400">No templates found</p>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{hasActiveFilters
|
||||
? "Try adjusting your filters or search term."
|
||||
: "Import Atomic Red Team tests from the System page to populate the catalog."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Template Grid */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{templates.map((tpl) => (
|
||||
<TemplateCard
|
||||
key={tpl.id}
|
||||
template={tpl}
|
||||
onUse={() => navigate(`/test-catalog/${tpl.id}/use`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-500">
|
||||
Showing {page * PAGE_SIZE + 1}
|
||||
{" - "}
|
||||
{page * PAGE_SIZE + templates.length}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="flex items-center gap-1 rounded-lg border border-gray-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-gray-800 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Prev
|
||||
</button>
|
||||
<span className="text-sm text-gray-400">Page {page + 1}</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={templates.length < PAGE_SIZE}
|
||||
className="flex items-center gap-1 rounded-lg border border-gray-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-gray-800 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Template instantiation modal */}
|
||||
{templateId && (
|
||||
<TestFromTemplateForm
|
||||
templateId={templateId}
|
||||
onClose={() => navigate("/test-catalog")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Template Card ──────────────────────────────────────────────────
|
||||
|
||||
function TemplateCard({
|
||||
template,
|
||||
onUse,
|
||||
}: {
|
||||
template: TestTemplateSummary;
|
||||
onUse: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col rounded-xl border border-gray-800 bg-gray-900 p-5 transition-colors hover:border-gray-700">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<FlaskConical className="h-5 w-5 shrink-0 text-cyan-400" />
|
||||
<h3 className="line-clamp-2 text-sm font-semibold text-white">
|
||||
{template.name}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Badges */}
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{/* Technique */}
|
||||
<span className="inline-flex rounded-full border border-gray-600/30 bg-gray-800/50 px-2 py-0.5 text-xs text-gray-400">
|
||||
{template.mitre_technique_id}
|
||||
</span>
|
||||
|
||||
{/* Source */}
|
||||
<span
|
||||
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
|
||||
SOURCE_BADGE[template.source] || "bg-gray-800/50 text-gray-400 border-gray-600/30"
|
||||
}`}
|
||||
>
|
||||
{template.source === "atomic_red_team" ? "Atomic" : template.source}
|
||||
</span>
|
||||
|
||||
{/* Platform */}
|
||||
{template.platform && (
|
||||
<span className="inline-flex rounded-full border border-gray-600/30 bg-gray-800/50 px-2 py-0.5 text-xs capitalize text-gray-400">
|
||||
{template.platform}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Severity */}
|
||||
{template.severity && (
|
||||
<span
|
||||
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium capitalize ${
|
||||
SEVERITY_BADGE[template.severity] || "bg-gray-800/50 text-gray-400 border-gray-600/30"
|
||||
}`}
|
||||
>
|
||||
{template.severity}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Action */}
|
||||
<button
|
||||
onClick={onUse}
|
||||
className="mt-4 flex w-full items-center justify-center gap-1.5 rounded-lg border border-cyan-500/30 bg-cyan-500/10 py-2 text-sm font-medium text-cyan-400 hover:bg-cyan-500/20 transition-colors"
|
||||
>
|
||||
<FlaskConical className="h-4 w-4" />
|
||||
Use Template
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user