From 8a1dd58c83be90194dedc603fda033c84e266351 Mon Sep 17 00:00:00 2001 From: Knacky Date: Tue, 12 May 2026 13:54:15 +0200 Subject: [PATCH] feat(m4): frontend MitreTagPicker + /mitre showcase page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/mitre.ts: shared types (MitreTactic, Technique, Subtechnique, MitreTag kind/id/external_id/name) + TanStack query keys. - components/MitreTagPicker.tsx: three-column controlled picker (tactic → technique → subtechnique), multi-select with chip-removal, autocomplete on each column, ARIA labels for screen readers. Returns MitreTag[] via value/onChange — drop-in for M5 template forms. - pages/MitrePage.tsx: status card (version, source URL, last_sync), admin- gated Trigger Sync button with success/error alerts, picker showcase, JSON preview of the current selection. - Layout adds MITRE nav link for any logged-in user; App.tsx adds the /mitre route under RequireAuth. HomePage roadmap bumped to next: M5 templates. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/App.tsx | 9 + frontend/src/components/Layout.tsx | 3 +- frontend/src/components/MitreTagPicker.tsx | 272 +++++++++++++++++++++ frontend/src/lib/mitre.ts | 61 +++++ frontend/src/pages/HomePage.tsx | 6 +- frontend/src/pages/MitrePage.tsx | 118 +++++++++ 6 files changed, 465 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/MitreTagPicker.tsx create mode 100644 frontend/src/lib/mitre.ts create mode 100644 frontend/src/pages/MitrePage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6245b25..db48353 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ import { AdminGroupsPage } from '@/pages/AdminGroupsPage'; import { AdminInvitationsPage } from '@/pages/AdminInvitationsPage'; import { AdminUsersPage } from '@/pages/AdminUsersPage'; import { HomePage } from '@/pages/HomePage'; +import { MitrePage } from '@/pages/MitrePage'; import { LoginPage } from '@/pages/LoginPage'; import { ProfilePage } from '@/pages/ProfilePage'; import { RegisterPage } from '@/pages/RegisterPage'; @@ -49,6 +50,14 @@ function App() { } /> + + + + } + /> {navItem('/', 'Home')} {navItem('/profile', 'Profile')} + {navItem('/mitre', 'MITRE')} {state.user.is_admin && ( <> {navItem('/admin/users', 'Users')} @@ -68,7 +69,7 @@ export function Layout() {
- 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
diff --git a/frontend/src/components/MitreTagPicker.tsx b/frontend/src/components/MitreTagPicker.tsx new file mode 100644 index 0000000..3691723 --- /dev/null +++ b/frontend/src/components/MitreTagPicker.tsx @@ -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>( + `/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>( + `/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>( + `/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(null); + const [activeTechnique, setActiveTechnique] = useState(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 ( +
+ {value.length > 0 && ( +
+ {value.map((t) => ( + + ))} +
+ )} + +
+ {/* Tactics column */} +
+ {!compact && ( + setQTactic(e.target.value)} + placeholder="e.g. Credential" + /> + )} +
+ {tactics.isLoading &&

Loading…

} + {tactics.data?.items.map((t) => { + const active = activeTactic === t.external_id; + const selected = selectedKey.has(`tactic:${t.external_id}`); + return ( +
{ + setActiveTactic(t.external_id); + setActiveTechnique(null); + }} + data-testid={`mitre-tactic-${t.external_id}`} + > + e.stopPropagation()} + onChange={() => + toggle({ kind: 'tactic', id: t.id, external_id: t.external_id, name: t.name }) + } + aria-label={`Select ${t.external_id}`} + /> + {t.external_id} + {t.name} +
+ ); + })} +
+
+ + {/* Techniques column */} +
+ {!compact && ( + setQTechnique(e.target.value)} + placeholder="e.g. T1059" + /> + )} +
+ {activeTactic === null && ( +

Select a tactic to list its techniques.

+ )} + {techniques.isLoading &&

Loading…

} + {techniques.data?.items.map((t) => { + const active = activeTechnique === t.external_id; + const selected = selectedKey.has(`technique:${t.external_id}`); + return ( +
setActiveTechnique(t.external_id)} + data-testid={`mitre-technique-${t.external_id}`} + > + e.stopPropagation()} + onChange={() => + toggle({ + kind: 'technique', + id: t.id, + external_id: t.external_id, + name: t.name, + }) + } + aria-label={`Select ${t.external_id}`} + /> + {t.external_id} + {t.name} +
+ ); + })} + {techniques.data && techniques.data.items.length === 0 && activeTactic && ( +

No techniques for this tactic.

+ )} +
+
+ + {/* Sub-techniques column */} +
+ {!compact && ( + setQSub(e.target.value)} + placeholder="e.g. Powershell" + /> + )} +
+ {activeTechnique === null && ( +

Select a technique to list its sub-techniques.

+ )} + {subtechniques.isLoading &&

Loading…

} + {subtechniques.data?.items.map((sb) => { + const selected = selectedKey.has(`subtechnique:${sb.external_id}`); + return ( +
+ toggle({ + kind: 'subtechnique', + id: sb.id, + external_id: sb.external_id, + name: sb.name, + }) + } + data-testid={`mitre-subtechnique-${sb.external_id}`} + > + + {sb.external_id} + {sb.name} +
+ ); + })} + {subtechniques.data && subtechniques.data.items.length === 0 && activeTechnique && ( +

No sub-techniques.

+ )} +
+
+
+ {(tactics.isError || techniques.isError || subtechniques.isError) && ( + + Failed to load MITRE data — has `make seed-mitre` been run? + + )} +
+ ); +} diff --git a/frontend/src/lib/mitre.ts b/frontend/src/lib/mitre.ts new file mode 100644 index 0000000..fc7d42f --- /dev/null +++ b/frontend/src/lib/mitre.ts @@ -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 { + 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, +}; diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index e494256..c9110da 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -61,7 +61,7 @@ export function HomePage() { Purple Team Platform

- Collaborative red & blue test orchestration — M3 milestone (RBAC) + Collaborative red & blue test orchestration — M4 milestone (MITRE ATT&CK)

- M0 + M1 + M2 + M3 done. Next:{' '} + M0 + M1 + M2 + M3 + M4 done. Next:{' '} - M4 — MITRE ATT&CK + M5 — Test & scenario templates .

diff --git a/frontend/src/pages/MitrePage.tsx b/frontend/src/pages/MitrePage.tsx new file mode 100644 index 0000000..113d603 --- /dev/null +++ b/frontend/src/pages/MitrePage.tsx @@ -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([]); + const [syncResult, setSyncResult] = useState(null); + const [syncError, setSyncError] = useState(null); + + const status = useQuery({ + queryKey: mitreKeys.status, + queryFn: () => apiGet('/mitre/status'), + }); + + const sync = useMutation({ + mutationFn: () => apiPost>('/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 ( + <> + + +
+ +

+ URL:{' '} + + {status.data?.source_url ?? status.data?.default_url ?? '—'} + +

+

+ Last sync:{' '} + + {status.data?.last_sync ?? 'never'} + +

+
+ + {state.user?.is_admin && ( + +

+ The seed CLI runs the same logic; trigger from here only if the URL is reachable from the api container. +

+ + {syncResult && {syncResult}} + {syncError && {syncError}} +
+ )} +
+ + + + + {selected.length > 0 && ( + +
+            {JSON.stringify(selected, null, 2)}
+          
+
+ )} + + {selected.length === 0 && ( +

+ Pick a tactic on the left, then a technique, then optionally a sub-technique. Selections accumulate. +

+ )} + +
+ Tactic + Technique + Sub-technique +
+ + ); +}