feature/m4-mitre #1
@@ -8,6 +8,7 @@ import { AdminGroupsPage } from '@/pages/AdminGroupsPage';
|
|||||||
import { AdminInvitationsPage } from '@/pages/AdminInvitationsPage';
|
import { AdminInvitationsPage } from '@/pages/AdminInvitationsPage';
|
||||||
import { AdminUsersPage } from '@/pages/AdminUsersPage';
|
import { AdminUsersPage } from '@/pages/AdminUsersPage';
|
||||||
import { HomePage } from '@/pages/HomePage';
|
import { HomePage } from '@/pages/HomePage';
|
||||||
|
import { MitrePage } from '@/pages/MitrePage';
|
||||||
import { LoginPage } from '@/pages/LoginPage';
|
import { LoginPage } from '@/pages/LoginPage';
|
||||||
import { ProfilePage } from '@/pages/ProfilePage';
|
import { ProfilePage } from '@/pages/ProfilePage';
|
||||||
import { RegisterPage } from '@/pages/RegisterPage';
|
import { RegisterPage } from '@/pages/RegisterPage';
|
||||||
@@ -49,6 +50,14 @@ function App() {
|
|||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/mitre"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<MitrePage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/admin/users"
|
path="/admin/users"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export function Layout() {
|
|||||||
<>
|
<>
|
||||||
{navItem('/', 'Home')}
|
{navItem('/', 'Home')}
|
||||||
{navItem('/profile', 'Profile')}
|
{navItem('/profile', 'Profile')}
|
||||||
|
{navItem('/mitre', 'MITRE')}
|
||||||
{state.user.is_admin && (
|
{state.user.is_admin && (
|
||||||
<>
|
<>
|
||||||
{navItem('/admin/users', 'Users')}
|
{navItem('/admin/users', 'Users')}
|
||||||
@@ -68,7 +69,7 @@ export function Layout() {
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
|
|
||||||
<footer className="mt-[60px] py-8 border-t border-border text-center font-mono text-xs text-text-dim">
|
<footer className="mt-[60px] py-8 border-t border-border text-center font-mono text-xs text-text-dim">
|
||||||
metamorph · M0 bootstrap · M1 db schema · M2 auth · M3 rbac · design system from tasks/design.md
|
metamorph · M0 bootstrap · M1 db schema · M2 auth · M3 rbac · M4 mitre · design system from tasks/design.md
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
272
frontend/src/components/MitreTagPicker.tsx
Normal file
272
frontend/src/components/MitreTagPicker.tsx
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { Alert } from '@/components/ui/Alert';
|
||||||
|
import { Tag } from '@/components/ui/Tag';
|
||||||
|
import { TextField } from '@/components/ui/TextField';
|
||||||
|
import { apiGet } from '@/lib/api';
|
||||||
|
import {
|
||||||
|
mitreKeys,
|
||||||
|
type MitreSubtechnique,
|
||||||
|
type MitreTactic,
|
||||||
|
type MitreTag,
|
||||||
|
type MitreTechnique,
|
||||||
|
type Paginated,
|
||||||
|
} from '@/lib/mitre';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
|
interface MitreTagPickerProps {
|
||||||
|
/** Already-selected tags. The parent owns the state. */
|
||||||
|
value: MitreTag[];
|
||||||
|
/** Called whenever the selection changes (replace semantics). */
|
||||||
|
onChange: (next: MitreTag[]) => void;
|
||||||
|
/** Hide the search box(es). Useful for compact embed in a sidebar. */
|
||||||
|
compact?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useTactics(q: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: mitreKeys.tactics(q),
|
||||||
|
queryFn: () =>
|
||||||
|
apiGet<Paginated<MitreTactic>>(
|
||||||
|
`/mitre/tactics${q ? `?q=${encodeURIComponent(q)}` : ''}`,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useTechniques(tactic: string | null, q: string) {
|
||||||
|
return useQuery({
|
||||||
|
enabled: tactic !== null,
|
||||||
|
queryKey: mitreKeys.techniques(tactic ?? '', q),
|
||||||
|
queryFn: () => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (tactic) params.set('tactic', tactic);
|
||||||
|
if (q) params.set('q', q);
|
||||||
|
return apiGet<Paginated<MitreTechnique>>(
|
||||||
|
`/mitre/techniques${params.toString() ? `?${params}` : ''}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSubtechniques(technique: string | null, q: string) {
|
||||||
|
return useQuery({
|
||||||
|
enabled: technique !== null,
|
||||||
|
queryKey: mitreKeys.subtechniques(technique ?? '', q),
|
||||||
|
queryFn: () => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (technique) params.set('technique', technique);
|
||||||
|
if (q) params.set('q', q);
|
||||||
|
return apiGet<Paginated<MitreSubtechnique>>(
|
||||||
|
`/mitre/subtechniques${params.toString() ? `?${params}` : ''}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Three-column picker — Tactic > Technique > Sub-technique — with multi-select.
|
||||||
|
* Selected tags accumulate in the chips at the top.
|
||||||
|
*/
|
||||||
|
export function MitreTagPicker({ value, onChange, compact, className }: MitreTagPickerProps) {
|
||||||
|
const [activeTactic, setActiveTactic] = useState<string | null>(null);
|
||||||
|
const [activeTechnique, setActiveTechnique] = useState<string | null>(null);
|
||||||
|
const [qTactic, setQTactic] = useState('');
|
||||||
|
const [qTechnique, setQTechnique] = useState('');
|
||||||
|
const [qSub, setQSub] = useState('');
|
||||||
|
|
||||||
|
const tactics = useTactics(qTactic);
|
||||||
|
const techniques = useTechniques(activeTactic, qTechnique);
|
||||||
|
const subtechniques = useSubtechniques(activeTechnique, qSub);
|
||||||
|
|
||||||
|
const selectedKey = useMemo(() => new Set(value.map((t) => `${t.kind}:${t.external_id}`)), [value]);
|
||||||
|
|
||||||
|
function toggle(tag: MitreTag) {
|
||||||
|
const key = `${tag.kind}:${tag.external_id}`;
|
||||||
|
if (selectedKey.has(key)) {
|
||||||
|
onChange(value.filter((t) => `${t.kind}:${t.external_id}` !== key));
|
||||||
|
} else {
|
||||||
|
onChange([...value, tag]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorForKind(kind: 'tactic' | 'technique' | 'subtechnique') {
|
||||||
|
return kind === 'tactic' ? 'cyan' : kind === 'technique' ? 'orange' : 'purple';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('rounded-lg border border-border bg-bg-card p-4', className)} data-testid="mitre-tag-picker">
|
||||||
|
{value.length > 0 && (
|
||||||
|
<div className="mb-3 flex flex-wrap items-center gap-1" data-testid="mitre-selected">
|
||||||
|
{value.map((t) => (
|
||||||
|
<button
|
||||||
|
key={`${t.kind}:${t.external_id}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggle(t)}
|
||||||
|
className="inline-flex items-center"
|
||||||
|
aria-label={`Remove ${t.external_id}`}
|
||||||
|
>
|
||||||
|
<Tag accent={colorForKind(t.kind)}>
|
||||||
|
{t.external_id} · {t.name} ✕
|
||||||
|
</Tag>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
{/* Tactics column */}
|
||||||
|
<div>
|
||||||
|
{!compact && (
|
||||||
|
<TextField
|
||||||
|
label="Tactic search"
|
||||||
|
value={qTactic}
|
||||||
|
onChange={(e) => setQTactic(e.target.value)}
|
||||||
|
placeholder="e.g. Credential"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="mt-2 max-h-72 overflow-y-auto" data-testid="mitre-tactics-column">
|
||||||
|
{tactics.isLoading && <p className="text-2xs text-text-dim">Loading…</p>}
|
||||||
|
{tactics.data?.items.map((t) => {
|
||||||
|
const active = activeTactic === t.external_id;
|
||||||
|
const selected = selectedKey.has(`tactic:${t.external_id}`);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
className={cn(
|
||||||
|
'group flex items-center gap-2 rounded border px-2 py-1 cursor-pointer',
|
||||||
|
active ? 'border-cyan' : 'border-transparent hover:border-cyan',
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTactic(t.external_id);
|
||||||
|
setActiveTechnique(null);
|
||||||
|
}}
|
||||||
|
data-testid={`mitre-tactic-${t.external_id}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onChange={() =>
|
||||||
|
toggle({ kind: 'tactic', id: t.id, external_id: t.external_id, name: t.name })
|
||||||
|
}
|
||||||
|
aria-label={`Select ${t.external_id}`}
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-2xs text-cyan">{t.external_id}</span>
|
||||||
|
<span className="text-2xs">{t.name}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Techniques column */}
|
||||||
|
<div>
|
||||||
|
{!compact && (
|
||||||
|
<TextField
|
||||||
|
label="Technique search"
|
||||||
|
value={qTechnique}
|
||||||
|
onChange={(e) => setQTechnique(e.target.value)}
|
||||||
|
placeholder="e.g. T1059"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="mt-2 max-h-72 overflow-y-auto" data-testid="mitre-techniques-column">
|
||||||
|
{activeTactic === null && (
|
||||||
|
<p className="text-2xs text-text-dim">Select a tactic to list its techniques.</p>
|
||||||
|
)}
|
||||||
|
{techniques.isLoading && <p className="text-2xs text-text-dim">Loading…</p>}
|
||||||
|
{techniques.data?.items.map((t) => {
|
||||||
|
const active = activeTechnique === t.external_id;
|
||||||
|
const selected = selectedKey.has(`technique:${t.external_id}`);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
className={cn(
|
||||||
|
'group flex items-center gap-2 rounded border px-2 py-1 cursor-pointer',
|
||||||
|
active ? 'border-orange' : 'border-transparent hover:border-orange',
|
||||||
|
)}
|
||||||
|
onClick={() => setActiveTechnique(t.external_id)}
|
||||||
|
data-testid={`mitre-technique-${t.external_id}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onChange={() =>
|
||||||
|
toggle({
|
||||||
|
kind: 'technique',
|
||||||
|
id: t.id,
|
||||||
|
external_id: t.external_id,
|
||||||
|
name: t.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
aria-label={`Select ${t.external_id}`}
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-2xs text-orange">{t.external_id}</span>
|
||||||
|
<span className="text-2xs">{t.name}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{techniques.data && techniques.data.items.length === 0 && activeTactic && (
|
||||||
|
<p className="text-2xs text-text-dim">No techniques for this tactic.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sub-techniques column */}
|
||||||
|
<div>
|
||||||
|
{!compact && (
|
||||||
|
<TextField
|
||||||
|
label="Sub-technique search"
|
||||||
|
value={qSub}
|
||||||
|
onChange={(e) => setQSub(e.target.value)}
|
||||||
|
placeholder="e.g. Powershell"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="mt-2 max-h-72 overflow-y-auto" data-testid="mitre-subtechniques-column">
|
||||||
|
{activeTechnique === null && (
|
||||||
|
<p className="text-2xs text-text-dim">Select a technique to list its sub-techniques.</p>
|
||||||
|
)}
|
||||||
|
{subtechniques.isLoading && <p className="text-2xs text-text-dim">Loading…</p>}
|
||||||
|
{subtechniques.data?.items.map((sb) => {
|
||||||
|
const selected = selectedKey.has(`subtechnique:${sb.external_id}`);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={sb.id}
|
||||||
|
className="flex items-center gap-2 rounded border border-transparent hover:border-purple px-2 py-1 cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
toggle({
|
||||||
|
kind: 'subtechnique',
|
||||||
|
id: sb.id,
|
||||||
|
external_id: sb.external_id,
|
||||||
|
name: sb.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
data-testid={`mitre-subtechnique-${sb.external_id}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected}
|
||||||
|
readOnly
|
||||||
|
aria-label={`Select ${sb.external_id}`}
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-2xs text-purple">{sb.external_id}</span>
|
||||||
|
<span className="text-2xs">{sb.name}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{subtechniques.data && subtechniques.data.items.length === 0 && activeTechnique && (
|
||||||
|
<p className="text-2xs text-text-dim">No sub-techniques.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(tactics.isError || techniques.isError || subtechniques.isError) && (
|
||||||
|
<Alert accent="red" className="mt-3">
|
||||||
|
Failed to load MITRE data — has `make seed-mitre` been run?
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
frontend/src/lib/mitre.ts
Normal file
61
frontend/src/lib/mitre.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/** Shared types + query keys for MITRE ATT&CK browsing. */
|
||||||
|
|
||||||
|
export interface MitreTactic {
|
||||||
|
id: string;
|
||||||
|
external_id: string;
|
||||||
|
short_name: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MitreTechnique {
|
||||||
|
id: string;
|
||||||
|
external_id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
url: string | null;
|
||||||
|
tactics: Array<{ external_id: string; name: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MitreSubtechnique {
|
||||||
|
id: string;
|
||||||
|
external_id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
url: string | null;
|
||||||
|
technique_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Paginated<T> {
|
||||||
|
items: T[];
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MitreStatus {
|
||||||
|
last_sync: string | null;
|
||||||
|
version: string | null;
|
||||||
|
source_url: string | null;
|
||||||
|
default_url: string;
|
||||||
|
default_version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MitreTagKind = 'tactic' | 'technique' | 'subtechnique';
|
||||||
|
|
||||||
|
export interface MitreTag {
|
||||||
|
kind: MitreTagKind;
|
||||||
|
id: string;
|
||||||
|
external_id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mitreKeys = {
|
||||||
|
status: ['mitre', 'status'] as const,
|
||||||
|
tactics: (q?: string) => ['mitre', 'tactics', q ?? ''] as const,
|
||||||
|
techniques: (tactic?: string, q?: string) =>
|
||||||
|
['mitre', 'techniques', tactic ?? '', q ?? ''] as const,
|
||||||
|
subtechniques: (technique?: string, q?: string) =>
|
||||||
|
['mitre', 'subtechniques', technique ?? '', q ?? ''] as const,
|
||||||
|
};
|
||||||
@@ -61,7 +61,7 @@ export function HomePage() {
|
|||||||
<span className="text-purple">Purple Team Platform</span>
|
<span className="text-purple">Purple Team Platform</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="font-mono text-sm font-light text-text-dim mt-2">
|
<p className="font-mono text-sm font-light text-text-dim mt-2">
|
||||||
Collaborative red & blue test orchestration — M3 milestone (RBAC)
|
Collaborative red & blue test orchestration — M4 milestone (MITRE ATT&CK)
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
@@ -141,9 +141,9 @@ export function HomePage() {
|
|||||||
|
|
||||||
<Card accent="purple" title="Roadmap" sub="14 milestones">
|
<Card accent="purple" title="Roadmap" sub="14 milestones">
|
||||||
<p>
|
<p>
|
||||||
M0 + M1 + M2 + M3 done. Next:{' '}
|
M0 + M1 + M2 + M3 + M4 done. Next:{' '}
|
||||||
<code className="accent-fill-cyan px-2 py-[2px] rounded-sm font-mono text-4xs">
|
<code className="accent-fill-cyan px-2 py-[2px] rounded-sm font-mono text-4xs">
|
||||||
M4 — MITRE ATT&CK
|
M5 — Test & scenario templates
|
||||||
</code>
|
</code>
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
118
frontend/src/pages/MitrePage.tsx
Normal file
118
frontend/src/pages/MitrePage.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { MitreTagPicker } from '@/components/MitreTagPicker';
|
||||||
|
import { Alert } from '@/components/ui/Alert';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { SectionHeader } from '@/components/ui/SectionHeader';
|
||||||
|
import { Tag } from '@/components/ui/Tag';
|
||||||
|
import { ApiError, apiGet, apiPost } from '@/lib/api';
|
||||||
|
import { useAuth } from '@/lib/auth';
|
||||||
|
import { mitreKeys, type MitreStatus, type MitreTag } from '@/lib/mitre';
|
||||||
|
|
||||||
|
export function MitrePage() {
|
||||||
|
const { state } = useAuth();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [selected, setSelected] = useState<MitreTag[]>([]);
|
||||||
|
const [syncResult, setSyncResult] = useState<string | null>(null);
|
||||||
|
const [syncError, setSyncError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const status = useQuery({
|
||||||
|
queryKey: mitreKeys.status,
|
||||||
|
queryFn: () => apiGet<MitreStatus>('/mitre/status'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const sync = useMutation({
|
||||||
|
mutationFn: () => apiPost<Record<string, unknown>>('/mitre/sync'),
|
||||||
|
onMutate: () => {
|
||||||
|
setSyncResult(null);
|
||||||
|
setSyncError(null);
|
||||||
|
},
|
||||||
|
onSuccess: async (res) => {
|
||||||
|
const counts = `${res.tactics_upserted} tactics, ${res.techniques_upserted} techniques, ${res.subtechniques_upserted} subtechniques`;
|
||||||
|
setSyncResult(`Sync completed in ${(res as { duration_ms: number }).duration_ms / 1000}s — ${counts}.`);
|
||||||
|
await qc.invalidateQueries({ queryKey: ['mitre'] });
|
||||||
|
},
|
||||||
|
onError: (e) => {
|
||||||
|
if (e instanceof ApiError) {
|
||||||
|
const p = e.payload as { error?: string; message?: string } | null;
|
||||||
|
setSyncError(p?.message ?? p?.error ?? `HTTP ${e.status}`);
|
||||||
|
} else {
|
||||||
|
setSyncError(e instanceof Error ? e.message : 'Sync failed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeader
|
||||||
|
prefix="MITRE"
|
||||||
|
highlight="ATT&CK"
|
||||||
|
accent="cyan"
|
||||||
|
description="Reference catalogue: tactics, techniques, sub-techniques. Tag picker reusable by tests and missions."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-4 mb-6 [grid-template-columns:repeat(auto-fill,minmax(360px,1fr))]">
|
||||||
|
<Card accent="cyan" title="Source" sub={status.data?.version ? `version ${status.data.version}` : '—'}>
|
||||||
|
<p>
|
||||||
|
URL:{' '}
|
||||||
|
<code className="accent-fill-cyan px-2 py-[2px] rounded-sm font-mono text-4xs break-all">
|
||||||
|
{status.data?.source_url ?? status.data?.default_url ?? '—'}
|
||||||
|
</code>
|
||||||
|
</p>
|
||||||
|
<p className="mt-2">
|
||||||
|
Last sync:{' '}
|
||||||
|
<code
|
||||||
|
className="accent-fill-cyan px-2 py-[2px] rounded-sm font-mono text-4xs"
|
||||||
|
data-testid="mitre-last-sync"
|
||||||
|
>
|
||||||
|
{status.data?.last_sync ?? 'never'}
|
||||||
|
</code>
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{state.user?.is_admin && (
|
||||||
|
<Card accent="orange" title="Sync" sub="re-pull the bundle from the configured source">
|
||||||
|
<p className="text-2xs text-text-dim mb-3">
|
||||||
|
The seed CLI runs the same logic; trigger from here only if the URL is reachable from the api container.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
accent="orange"
|
||||||
|
onClick={() => sync.mutate()}
|
||||||
|
disabled={sync.isPending}
|
||||||
|
data-testid="mitre-sync"
|
||||||
|
>
|
||||||
|
{sync.isPending ? 'Syncing…' : 'Trigger MITRE sync'}
|
||||||
|
</Button>
|
||||||
|
{syncResult && <Alert accent="green" className="mt-3">{syncResult}</Alert>}
|
||||||
|
{syncError && <Alert accent="red" className="mt-3">{syncError}</Alert>}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionHeader prefix="Tag" highlight="Picker" accent="orange" />
|
||||||
|
<MitreTagPicker value={selected} onChange={setSelected} />
|
||||||
|
|
||||||
|
{selected.length > 0 && (
|
||||||
|
<Card accent="purple" className="mt-6" title="Selected (preview payload)">
|
||||||
|
<pre className="text-2xs text-text-bright whitespace-pre-wrap" data-testid="mitre-selected-json">
|
||||||
|
{JSON.stringify(selected, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selected.length === 0 && (
|
||||||
|
<p className="mt-3 font-mono text-2xs text-text-dim">
|
||||||
|
Pick a tactic on the left, then a technique, then optionally a sub-technique. Selections accumulate.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<Tag accent="cyan">Tactic</Tag>
|
||||||
|
<Tag accent="orange">Technique</Tag>
|
||||||
|
<Tag accent="purple">Sub-technique</Tag>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user