fix: D3FEND expandable cards, System page cleanup, and multi-source improvements
- Make D3FEND defense cards clickable with expandable details and external link - Fix D3FEND URLs to use PascalCase technique names matching the ontology - Remove duplicate Import Atomic Red Team from System page (use Data Sources) - Add bulk Activate All / Deactivate All buttons with confirmation modal - Fix template admin list to show both active and inactive templates - Add PATCH /test-templates/bulk-activate backend endpoint - Auto-seed data sources on container startup via entrypoint.sh - Fix SigmaHQ, CALDERA, GTFOBins import issues - Register D3FEND sync handler in data sources router - Add CIS Controls v8 compliance framework import - Expand Test Catalog source filters (CALDERA, LOLBAS, GTFOBins) - Campaign Generate from Threat Actor now opens actor selector modal - Add coverage snapshot creation button to Comparison page - Update README with accurate data source and feature documentation
This commit is contained in:
@@ -114,3 +114,9 @@ export async function importNistMappings(): Promise<Record<string, unknown>> {
|
||||
const { data } = await client.post("/compliance/import/nist-800-53");
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Import CIS Controls v8 mappings (admin). */
|
||||
export async function importCisMappings(): Promise<Record<string, unknown>> {
|
||||
const { data } = await client.post("/compliance/import/cis-controls-v8");
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface TemplateFilters {
|
||||
severity?: string;
|
||||
mitre_technique_id?: string;
|
||||
search?: string;
|
||||
is_active?: boolean;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
}
|
||||
@@ -51,6 +52,8 @@ export async function getTemplates(
|
||||
if (filters?.mitre_technique_id)
|
||||
params.append("mitre_technique_id", filters.mitre_technique_id);
|
||||
if (filters?.search) params.append("search", filters.search);
|
||||
// Default to active-only for catalog; admin uses getAllTemplates without this filter
|
||||
params.append("is_active", filters?.is_active !== undefined ? String(filters.is_active) : "true");
|
||||
if (filters?.offset !== undefined)
|
||||
params.append("offset", String(filters.offset));
|
||||
if (filters?.limit !== undefined)
|
||||
@@ -125,7 +128,8 @@ export async function toggleTemplateActive(
|
||||
|
||||
// ── All templates (include inactive, for admin) ────────────────────
|
||||
|
||||
/** Fetch all templates including inactive ones (for admin management). */
|
||||
/** Fetch all templates including inactive ones (for admin management).
|
||||
* Does NOT filter by is_active so the backend returns all templates. */
|
||||
export async function getAllTemplates(
|
||||
filters?: TemplateFilters,
|
||||
): Promise<TestTemplate[]> {
|
||||
@@ -135,6 +139,7 @@ export async function getAllTemplates(
|
||||
if (filters?.search) params.append("search", filters.search);
|
||||
if (filters?.offset !== undefined) params.append("offset", String(filters.offset));
|
||||
if (filters?.limit !== undefined) params.append("limit", String(filters.limit));
|
||||
// Explicitly don't pass is_active so backend returns ALL templates
|
||||
|
||||
const { data } = await client.get<TestTemplate[]>(
|
||||
`/test-templates${params.toString() ? `?${params}` : ""}`,
|
||||
@@ -142,6 +147,24 @@ export async function getAllTemplates(
|
||||
return data;
|
||||
}
|
||||
|
||||
// ── Bulk activate/deactivate (admin) ──────────────────────────────
|
||||
|
||||
export interface BulkActivateResponse {
|
||||
detail: string;
|
||||
affected: number;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
/** Activate or deactivate all templates. Admin only. */
|
||||
export async function bulkActivateTemplates(
|
||||
activate: boolean,
|
||||
): Promise<BulkActivateResponse> {
|
||||
const { data } = await client.patch<BulkActivateResponse>(
|
||||
`/test-templates/bulk-activate?activate=${activate}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ── Import Atomic Red Team ─────────────────────────────────────────
|
||||
|
||||
/** Trigger Atomic Red Team import. Admin only. */
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
Filter,
|
||||
Target,
|
||||
} from "lucide-react";
|
||||
import { listCampaigns, createCampaign, type CampaignSummary } from "../api/campaigns";
|
||||
import { listCampaigns, createCampaign, generateCampaignFromThreatActor, type CampaignSummary } from "../api/campaigns";
|
||||
import { getThreatActors, type ThreatActorSummary } from "../api/threat-actors";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
@@ -46,6 +47,8 @@ export default function CampaignsPage() {
|
||||
search: "",
|
||||
});
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [showActorSelector, setShowActorSelector] = useState(false);
|
||||
const [actorSearch, setActorSearch] = useState("");
|
||||
const [newCampaign, setNewCampaign] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
@@ -75,6 +78,22 @@ export default function CampaignsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
// Threat actor selector data
|
||||
const { data: actorsData, isLoading: isLoadingActors } = useQuery({
|
||||
queryKey: ["threat-actors-for-campaign", actorSearch],
|
||||
queryFn: () => getThreatActors({ search: actorSearch || undefined, limit: 50 }),
|
||||
enabled: showActorSelector,
|
||||
});
|
||||
|
||||
const generateMutation = useMutation({
|
||||
mutationFn: (actorId: string) => generateCampaignFromThreatActor(actorId),
|
||||
onSuccess: (campaign) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["campaigns"] });
|
||||
setShowActorSelector(false);
|
||||
navigate(`/campaigns/${campaign.id}`);
|
||||
},
|
||||
});
|
||||
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return "";
|
||||
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||
@@ -98,7 +117,7 @@ export default function CampaignsPage() {
|
||||
{canCreate && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => navigate("/threat-actors")}
|
||||
onClick={() => setShowActorSelector(true)}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-2 text-sm font-medium text-red-400 hover:bg-red-500/20 transition-colors"
|
||||
>
|
||||
<Crosshair className="h-4 w-4" />
|
||||
@@ -226,6 +245,93 @@ export default function CampaignsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Threat Actor Selector Modal */}
|
||||
{showActorSelector && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="w-full max-w-lg rounded-xl border border-gray-800 bg-gray-900 p-6">
|
||||
<h2 className="mb-1 text-lg font-semibold text-white">Generate Campaign from Threat Actor</h2>
|
||||
<p className="mb-4 text-sm text-gray-400">
|
||||
Select a threat actor to auto-generate a campaign with tests for their uncovered techniques.
|
||||
</p>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative mb-4">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
|
||||
<input
|
||||
value={actorSearch}
|
||||
onChange={(e) => setActorSearch(e.target.value)}
|
||||
placeholder="Search threat actors..."
|
||||
className="w-full rounded-lg border border-gray-700 bg-gray-800 pl-10 pr-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-cyan-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actor list */}
|
||||
<div className="max-h-72 overflow-y-auto rounded-lg border border-gray-800">
|
||||
{isLoadingActors ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-cyan-400" />
|
||||
</div>
|
||||
) : actorsData && actorsData.items.length > 0 ? (
|
||||
actorsData.items.map((actor) => (
|
||||
<button
|
||||
key={actor.id}
|
||||
onClick={() => generateMutation.mutate(actor.id)}
|
||||
disabled={generateMutation.isPending}
|
||||
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-gray-800/50 transition-colors border-b border-gray-800/50 last:border-b-0 disabled:opacity-50"
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Crosshair className="h-3.5 w-3.5 text-red-400" />
|
||||
<span className="text-sm font-medium text-white">{actor.name}</span>
|
||||
{actor.country && (
|
||||
<span className="text-xs text-gray-500">{actor.country}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-3 text-xs text-gray-500">
|
||||
<span>{actor.technique_count} techniques</span>
|
||||
<span>Coverage: {actor.coverage_pct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<Target className="h-4 w-4 text-gray-600" />
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="py-8 text-center text-sm text-gray-500">
|
||||
No threat actors found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{generateMutation.isError && (
|
||||
<div className="mt-3 flex items-center gap-2 rounded-lg border border-red-500/30 bg-red-900/20 px-3 py-2 text-xs text-red-400">
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
{(generateMutation.error as Error)?.message || "Failed to generate campaign"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading indicator */}
|
||||
{generateMutation.isPending && (
|
||||
<div className="mt-3 flex items-center gap-2 text-sm text-cyan-400">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Generating campaign...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cancel */}
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button
|
||||
onClick={() => { setShowActorSelector(false); setActorSearch(""); }}
|
||||
disabled={generateMutation.isPending}
|
||||
className="rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-400 hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Campaign grid */}
|
||||
{isLoading ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
@@ -9,12 +9,14 @@ import {
|
||||
Minus,
|
||||
GitCompareArrows,
|
||||
Camera,
|
||||
Plus,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
listSnapshots,
|
||||
compareSnapshots,
|
||||
createSnapshot,
|
||||
type SnapshotSummary,
|
||||
type SnapshotComparison,
|
||||
} from "../api/snapshots";
|
||||
@@ -91,9 +93,12 @@ function MetricCard({
|
||||
|
||||
export default function ComparisonPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [snapA, setSnapA] = useState<string>("");
|
||||
const [snapB, setSnapB] = useState<string>("");
|
||||
const [activeTab, setActiveTab] = useState<Tab>("improved");
|
||||
const [showNameInput, setShowNameInput] = useState(false);
|
||||
const [snapshotName, setSnapshotName] = useState("");
|
||||
|
||||
// Fetch all snapshots for the dropdowns
|
||||
const { data: snapshotsData, isLoading: isLoadingSnapshots } = useQuery({
|
||||
@@ -101,6 +106,18 @@ export default function ComparisonPage() {
|
||||
queryFn: () => listSnapshots({ limit: 200 }),
|
||||
});
|
||||
|
||||
// Create snapshot mutation
|
||||
const createSnapshotMutation = useMutation({
|
||||
mutationFn: (name?: string) => createSnapshot(name),
|
||||
onSuccess: (newSnapshot) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["snapshots"] });
|
||||
setShowNameInput(false);
|
||||
setSnapshotName("");
|
||||
// Auto-select the new snapshot as Snapshot B
|
||||
setSnapB(newSnapshot.id);
|
||||
},
|
||||
});
|
||||
|
||||
const snapshots = snapshotsData?.items || [];
|
||||
|
||||
// Comparison query
|
||||
@@ -149,6 +166,51 @@ export default function ComparisonPage() {
|
||||
<p className="text-sm text-gray-400">Compare coverage snapshots over time</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Snapshot */}
|
||||
<div className="flex items-center gap-2">
|
||||
{showNameInput ? (
|
||||
<>
|
||||
<input
|
||||
value={snapshotName}
|
||||
onChange={(e) => setSnapshotName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") createSnapshotMutation.mutate(snapshotName || undefined);
|
||||
if (e.key === "Escape") { setShowNameInput(false); setSnapshotName(""); }
|
||||
}}
|
||||
placeholder="Snapshot name (optional)"
|
||||
autoFocus
|
||||
className="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 w-56"
|
||||
/>
|
||||
<button
|
||||
onClick={() => createSnapshotMutation.mutate(snapshotName || undefined)}
|
||||
disabled={createSnapshotMutation.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 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{createSnapshotMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Camera className="h-4 w-4" />
|
||||
)}
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowNameInput(false); setSnapshotName(""); }}
|
||||
className="rounded-lg border border-gray-700 px-3 py-2 text-sm text-gray-400 hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowNameInput(true)}
|
||||
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"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Snapshot
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Snapshot selectors */}
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2, AlertCircle, Download, FileText } from "lucide-react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, AlertCircle, Download, FileText, Plus } from "lucide-react";
|
||||
import {
|
||||
getComplianceFrameworks,
|
||||
getFrameworkStatus,
|
||||
downloadComplianceCSV,
|
||||
importNistMappings,
|
||||
importCisMappings,
|
||||
type ComplianceFrameworkSummary,
|
||||
} from "../api/compliance";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import ComplianceGauge from "../components/compliance/ComplianceGauge";
|
||||
import ControlsTable from "../components/compliance/ControlsTable";
|
||||
|
||||
export default function CompliancePage() {
|
||||
const [selectedFrameworkId, setSelectedFrameworkId] = useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.role === "admin";
|
||||
|
||||
// Fetch available frameworks
|
||||
const {
|
||||
@@ -59,6 +65,24 @@ export default function CompliancePage() {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const importNist = useMutation({
|
||||
mutationFn: importNistMappings,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["compliance-frameworks"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["compliance-status"] });
|
||||
},
|
||||
});
|
||||
|
||||
const importCis = useMutation({
|
||||
mutationFn: importCisMappings,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["compliance-frameworks"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["compliance-status"] });
|
||||
},
|
||||
});
|
||||
|
||||
const isImporting = importNist.isPending || importCis.isPending;
|
||||
|
||||
if (isLoading && !frameworkStatus) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
@@ -171,6 +195,35 @@ export default function CompliancePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import buttons for admin */}
|
||||
{isAdmin && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-gray-500">Import frameworks:</span>
|
||||
<button
|
||||
onClick={() => importNist.mutate()}
|
||||
disabled={isImporting}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-xs font-medium text-gray-300 hover:border-cyan-500/50 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{importNist.isPending ? <Loader2 className="h-3 w-3 animate-spin" /> : <Plus className="h-3 w-3" />}
|
||||
NIST 800-53
|
||||
</button>
|
||||
<button
|
||||
onClick={() => importCis.mutate()}
|
||||
disabled={isImporting}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-xs font-medium text-gray-300 hover:border-cyan-500/50 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{importCis.isPending ? <Loader2 className="h-3 w-3 animate-spin" /> : <Plus className="h-3 w-3" />}
|
||||
CIS Controls v8
|
||||
</button>
|
||||
{(importNist.isSuccess || importCis.isSuccess) && (
|
||||
<span className="text-xs text-green-400">Import complete</span>
|
||||
)}
|
||||
{(importNist.isError || importCis.isError) && (
|
||||
<span className="text-xs text-red-400">Import failed</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls table */}
|
||||
{controls.length > 0 ? (
|
||||
<ControlsTable controls={controls} />
|
||||
@@ -179,7 +232,7 @@ export default function CompliancePage() {
|
||||
<div className="rounded-xl border border-gray-800 bg-gray-900 p-8 text-center">
|
||||
<AlertCircle className="mx-auto h-10 w-10 text-gray-600" />
|
||||
<p className="mt-3 text-gray-400">
|
||||
No compliance data available. Import a compliance framework from the System page.
|
||||
No compliance data available. Use the import buttons above to load a compliance framework.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
Shield,
|
||||
Search,
|
||||
FlaskConical,
|
||||
Download,
|
||||
Plus,
|
||||
ToggleLeft,
|
||||
ToggleRight,
|
||||
@@ -28,12 +27,11 @@ import {
|
||||
type IntelScanResponse,
|
||||
} from "../api/system";
|
||||
import {
|
||||
importAtomicTests,
|
||||
getTemplateStats,
|
||||
getAllTemplates,
|
||||
createTemplate,
|
||||
toggleTemplateActive,
|
||||
type ImportAtomicResponse,
|
||||
bulkActivateTemplates,
|
||||
type TemplateStats,
|
||||
type CreateTemplatePayload,
|
||||
} from "../api/test-templates";
|
||||
@@ -43,8 +41,8 @@ export default function SystemPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [syncResult, setSyncResult] = useState<SyncMitreResponse | null>(null);
|
||||
const [intelResult, setIntelResult] = useState<IntelScanResponse | null>(null);
|
||||
const [importResult, setImportResult] = useState<ImportAtomicResponse | null>(null);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [bulkConfirm, setBulkConfirm] = useState<"activate" | "deactivate" | null>(null);
|
||||
|
||||
// ── Existing queries ─────────────────────────────────────────────
|
||||
const {
|
||||
@@ -71,7 +69,7 @@ export default function SystemPage() {
|
||||
isLoading: templatesLoading,
|
||||
} = useQuery({
|
||||
queryKey: ["templates-admin"],
|
||||
queryFn: () => getAllTemplates({ limit: 100 }),
|
||||
queryFn: () => getAllTemplates({ limit: 200 }),
|
||||
});
|
||||
|
||||
// ── Mutations ────────────────────────────────────────────────────
|
||||
@@ -92,12 +90,12 @@ export default function SystemPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const importAtomicMutation = useMutation({
|
||||
mutationFn: importAtomicTests,
|
||||
onSuccess: (data) => {
|
||||
setImportResult(data);
|
||||
queryClient.invalidateQueries({ queryKey: ["template-stats"] });
|
||||
const bulkActivateMutation = useMutation({
|
||||
mutationFn: (activate: boolean) => bulkActivateTemplates(activate),
|
||||
onSuccess: () => {
|
||||
setBulkConfirm(null);
|
||||
queryClient.invalidateQueries({ queryKey: ["templates-admin"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["template-stats"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["test-templates"] });
|
||||
},
|
||||
});
|
||||
@@ -281,70 +279,8 @@ export default function SystemPage() {
|
||||
TEMPLATE ADMINISTRATION (T-124)
|
||||
──────────────────────────────────────────────────────────────── */}
|
||||
|
||||
{/* Import Atomic Red Team + Stats */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Import Atomic Red Team */}
|
||||
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="rounded-lg bg-red-500/10 p-3">
|
||||
<Download className="h-6 w-6 text-red-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-lg font-semibold text-white">Import Atomic Red Team</h2>
|
||||
<p className="mt-1 text-sm text-gray-400">
|
||||
Import test templates from the Atomic Red Team repository by Red Canary, mapped to MITRE ATT&CK techniques.
|
||||
</p>
|
||||
|
||||
{importResult && (
|
||||
<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">Import Complete</span>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-3 gap-2 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-400">Imported:</span>
|
||||
<span className="ml-1 font-medium text-white">{importResult.imported}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-400">Skipped:</span>
|
||||
<span className="ml-1 font-medium text-white">{importResult.skipped}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-400">Parsed:</span>
|
||||
<span className="ml-1 font-medium text-white">{importResult.total_parsed}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{importAtomicMutation.isError && (
|
||||
<div className="mt-4 rounded-lg border border-red-500/30 bg-red-900/20 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<XCircle className="h-4 w-4 text-red-400" />
|
||||
<span className="text-sm text-red-400">
|
||||
Import failed: {(importAtomicMutation.error as Error)?.message}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => importAtomicMutation.mutate()}
|
||||
disabled={importAtomicMutation.isPending}
|
||||
className="mt-4 flex items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-500 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{importAtomicMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
{importAtomicMutation.isPending ? "Importing..." : "Import Now"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Template Catalog Stats */}
|
||||
<div className="grid gap-6 lg:grid-cols-1">
|
||||
{/* Template Catalog Stats */}
|
||||
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
@@ -433,20 +369,85 @@ export default function SystemPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Bulk Activate Confirmation Modal */}
|
||||
{bulkConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md rounded-xl border border-gray-700 bg-gray-900 p-6 shadow-xl">
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
{bulkConfirm === "activate" ? "Activate All Templates" : "Deactivate All Templates"}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-gray-400">
|
||||
{bulkConfirm === "activate"
|
||||
? "This will activate ALL templates in the catalog, including previously deactivated ones. All templates will become available for test creation."
|
||||
: "This will deactivate ALL templates in the catalog. No templates will be available for test creation until reactivated."}
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-medium text-yellow-400">
|
||||
This action affects all {templateStats?.total || 0} templates.
|
||||
</p>
|
||||
<div className="mt-4 flex items-center justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setBulkConfirm(null)}
|
||||
className="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"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => bulkActivateMutation.mutate(bulkConfirm === "activate")}
|
||||
disabled={bulkActivateMutation.isPending}
|
||||
className={`flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors disabled:opacity-50 ${
|
||||
bulkConfirm === "activate"
|
||||
? "bg-green-600 hover:bg-green-500"
|
||||
: "bg-red-600 hover:bg-red-500"
|
||||
}`}
|
||||
>
|
||||
{bulkActivateMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : bulkConfirm === "activate" ? (
|
||||
<ToggleRight className="h-4 w-4" />
|
||||
) : (
|
||||
<ToggleLeft className="h-4 w-4" />
|
||||
)}
|
||||
{bulkActivateMutation.isPending
|
||||
? "Processing..."
|
||||
: bulkConfirm === "activate"
|
||||
? "Activate All"
|
||||
: "Deactivate All"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Templates Management Table */}
|
||||
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<FlaskConical className="h-5 w-5 text-cyan-400" />
|
||||
Manage Templates
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowCreateForm(!showCreateForm)}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-cyan-600 px-3 py-2 text-sm font-medium text-white hover:bg-cyan-500 transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Custom Template
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setBulkConfirm("activate")}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-green-500/30 bg-green-900/20 px-3 py-2 text-sm font-medium text-green-400 hover:bg-green-900/40 transition-colors"
|
||||
>
|
||||
<ToggleRight className="h-4 w-4" />
|
||||
Activate All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBulkConfirm("deactivate")}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-red-500/30 bg-red-900/20 px-3 py-2 text-sm font-medium text-red-400 hover:bg-red-900/40 transition-colors"
|
||||
>
|
||||
<ToggleLeft className="h-4 w-4" />
|
||||
Deactivate All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCreateForm(!showCreateForm)}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-cyan-600 px-3 py-2 text-sm font-medium text-white hover:bg-cyan-500 transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Custom
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{templatesLoading ? (
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
AlertTriangle,
|
||||
BookOpen,
|
||||
FlaskConical,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from "lucide-react";
|
||||
import { getTechniqueByMitreId, markTechniqueReviewed } from "../api/techniques";
|
||||
import { getTemplatesByTechnique } from "../api/test-templates";
|
||||
@@ -410,76 +412,7 @@ export default function TechniqueDetailPage() {
|
||||
|
||||
{/* Recommended Defenses (D3FEND) */}
|
||||
{technique.d3fend_defenses && technique.d3fend_defenses.length > 0 && (
|
||||
<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 flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-emerald-400" />
|
||||
Recommended Defenses (D3FEND)
|
||||
</h2>
|
||||
<span className="rounded-full bg-emerald-900/50 border border-emerald-500/30 px-2.5 py-0.5 text-xs font-medium text-emerald-400">
|
||||
{technique.d3fend_defenses.length} countermeasure{technique.d3fend_defenses.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Group by tactic */}
|
||||
{(() => {
|
||||
const grouped: Record<string, typeof technique.d3fend_defenses> = {};
|
||||
for (const def of technique.d3fend_defenses!) {
|
||||
const tactic = def.tactic || "Other";
|
||||
if (!grouped[tactic]) grouped[tactic] = [];
|
||||
grouped[tactic].push(def);
|
||||
}
|
||||
const tacticColors: Record<string, string> = {
|
||||
Detect: "border-blue-500/30 bg-blue-900/20 text-blue-400",
|
||||
Harden: "border-emerald-500/30 bg-emerald-900/20 text-emerald-400",
|
||||
Isolate: "border-purple-500/30 bg-purple-900/20 text-purple-400",
|
||||
Deceive: "border-amber-500/30 bg-amber-900/20 text-amber-400",
|
||||
Evict: "border-red-500/30 bg-red-900/20 text-red-400",
|
||||
Model: "border-cyan-500/30 bg-cyan-900/20 text-cyan-400",
|
||||
};
|
||||
|
||||
return Object.entries(grouped).map(([tactic, defenses]) => (
|
||||
<div key={tactic} className="mb-4 last:mb-0">
|
||||
<h3 className="mb-2 text-sm font-medium text-gray-400 uppercase tracking-wide">
|
||||
{tactic}
|
||||
</h3>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{defenses!.map((def) => (
|
||||
<div
|
||||
key={def.id}
|
||||
className={`rounded-lg border p-3 transition-colors hover:border-gray-600 ${
|
||||
tacticColors[tactic] || "border-gray-700 bg-gray-800/30 text-gray-300"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-gray-200">
|
||||
<span className="font-mono text-xs text-gray-500 mr-1.5">{def.d3fend_id}</span>
|
||||
{def.name}
|
||||
</p>
|
||||
{def.description && (
|
||||
<p className="mt-1 text-xs text-gray-400 line-clamp-2">{def.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{def.d3fend_url && (
|
||||
<a
|
||||
href={def.d3fend_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-2 shrink-0 text-gray-500 hover:text-cyan-400"
|
||||
title="View in D3FEND"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
<D3FENDSection defenses={technique.d3fend_defenses} />
|
||||
)}
|
||||
|
||||
{/* Intel Items Section */}
|
||||
@@ -525,3 +458,124 @@ export default function TechniqueDetailPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── D3FEND Section ────────────────────────────────────────────────────
|
||||
|
||||
function D3FENDSection({ defenses }: { defenses: Array<{
|
||||
id: string;
|
||||
d3fend_id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
tactic?: string | null;
|
||||
d3fend_url?: string | null;
|
||||
}> }) {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
const grouped: Record<string, typeof defenses> = {};
|
||||
for (const def of defenses) {
|
||||
const tactic = def.tactic || "Other";
|
||||
if (!grouped[tactic]) grouped[tactic] = [];
|
||||
grouped[tactic].push(def);
|
||||
}
|
||||
|
||||
const tacticColors: Record<string, string> = {
|
||||
Detect: "border-blue-500/30 bg-blue-900/20 text-blue-400",
|
||||
Harden: "border-emerald-500/30 bg-emerald-900/20 text-emerald-400",
|
||||
Isolate: "border-purple-500/30 bg-purple-900/20 text-purple-400",
|
||||
Deceive: "border-amber-500/30 bg-amber-900/20 text-amber-400",
|
||||
Evict: "border-red-500/30 bg-red-900/20 text-red-400",
|
||||
Model: "border-cyan-500/30 bg-cyan-900/20 text-cyan-400",
|
||||
};
|
||||
|
||||
const tacticDescriptions: Record<string, string> = {
|
||||
Detect: "Techniques for identifying adversary activity through monitoring and analysis.",
|
||||
Harden: "Techniques for strengthening systems to reduce the attack surface.",
|
||||
Isolate: "Techniques for containing threats by limiting communication and access.",
|
||||
Deceive: "Techniques that use deception to mislead adversaries.",
|
||||
Evict: "Techniques for removing adversary presence from systems.",
|
||||
Model: "Techniques for understanding and mapping the environment.",
|
||||
};
|
||||
|
||||
return (
|
||||
<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 flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-emerald-400" />
|
||||
Recommended Defenses (D3FEND)
|
||||
</h2>
|
||||
<span className="rounded-full bg-emerald-900/50 border border-emerald-500/30 px-2.5 py-0.5 text-xs font-medium text-emerald-400">
|
||||
{defenses.length} countermeasure{defenses.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{Object.entries(grouped).map(([tactic, defs]) => (
|
||||
<div key={tactic} className="mb-4 last:mb-0">
|
||||
<h3 className="mb-1 text-sm font-medium text-gray-400 uppercase tracking-wide">
|
||||
{tactic}
|
||||
</h3>
|
||||
{tacticDescriptions[tactic] && (
|
||||
<p className="mb-2 text-xs text-gray-500">{tacticDescriptions[tactic]}</p>
|
||||
)}
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{defs.map((def) => {
|
||||
const isExpanded = expandedId === def.id;
|
||||
return (
|
||||
<div
|
||||
key={def.id}
|
||||
className={`rounded-lg border p-3 transition-all cursor-pointer ${
|
||||
isExpanded ? "ring-1 ring-gray-600" : ""
|
||||
} ${tacticColors[tactic] || "border-gray-700 bg-gray-800/30 text-gray-300"}`}
|
||||
onClick={() => setExpandedId(isExpanded ? null : def.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-gray-200 flex items-center gap-1.5">
|
||||
<span className="font-mono text-xs text-gray-500">{def.d3fend_id}</span>
|
||||
{def.name}
|
||||
</p>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="ml-2 h-4 w-4 shrink-0 text-gray-500" />
|
||||
) : (
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 text-gray-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-3 space-y-2 border-t border-gray-700/50 pt-3">
|
||||
{def.description ? (
|
||||
<p className="text-xs text-gray-300 leading-relaxed">{def.description}</p>
|
||||
) : (
|
||||
<p className="text-xs text-gray-500 italic">No description available.</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<span className="rounded bg-gray-800 px-2 py-0.5 text-[10px] font-medium text-gray-400 border border-gray-700">
|
||||
Tactic: {def.tactic || "Unknown"}
|
||||
</span>
|
||||
<span className="rounded bg-gray-800 px-2 py-0.5 text-[10px] font-medium text-gray-400 border border-gray-700">
|
||||
ID: {def.d3fend_id}
|
||||
</span>
|
||||
</div>
|
||||
{def.d3fend_url && (
|
||||
<a
|
||||
href={def.d3fend_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center gap-1.5 text-xs text-cyan-400 hover:text-cyan-300 hover:underline mt-1"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
View on MITRE D3FEND
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ const PAGE_SIZE = 12;
|
||||
const SOURCE_OPTIONS = [
|
||||
{ value: "", label: "All Sources" },
|
||||
{ value: "atomic_red_team", label: "Atomic Red Team" },
|
||||
{ value: "caldera", label: "MITRE CALDERA" },
|
||||
{ value: "lolbas", label: "LOLBAS (Windows)" },
|
||||
{ value: "gtfobins", label: "GTFOBins (Linux)" },
|
||||
{ value: "custom", label: "Custom" },
|
||||
];
|
||||
|
||||
@@ -45,9 +48,20 @@ const PLATFORM_OPTIONS = [
|
||||
|
||||
const SOURCE_BADGE: Record<string, string> = {
|
||||
atomic_red_team: "bg-red-900/50 text-red-400 border-red-500/30",
|
||||
caldera: "bg-purple-900/50 text-purple-400 border-purple-500/30",
|
||||
lolbas: "bg-amber-900/50 text-amber-400 border-amber-500/30",
|
||||
gtfobins: "bg-green-900/50 text-green-400 border-green-500/30",
|
||||
custom: "bg-cyan-900/50 text-cyan-400 border-cyan-500/30",
|
||||
};
|
||||
|
||||
const SOURCE_LABEL: Record<string, string> = {
|
||||
atomic_red_team: "Atomic",
|
||||
caldera: "CALDERA",
|
||||
lolbas: "LOLBAS",
|
||||
gtfobins: "GTFOBins",
|
||||
custom: "Custom",
|
||||
};
|
||||
|
||||
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",
|
||||
@@ -302,7 +316,7 @@ function TemplateCard({
|
||||
SOURCE_BADGE[template.source] || "bg-gray-800/50 text-gray-400 border-gray-600/30"
|
||||
}`}
|
||||
>
|
||||
{template.source === "atomic_red_team" ? "Atomic" : template.source}
|
||||
{SOURCE_LABEL[template.source] || template.source}
|
||||
</span>
|
||||
|
||||
{/* Platform */}
|
||||
|
||||
Reference in New Issue
Block a user