feature/m4-mitre #1
@@ -175,6 +175,65 @@ def list_subtechniques():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/matrix")
|
||||||
|
@require_auth
|
||||||
|
def matrix():
|
||||||
|
"""Return the full Enterprise matrix: tactics → techniques → sub-techniques.
|
||||||
|
|
||||||
|
One-shot endpoint so the SPA can render the flat attack.mitre.org-style
|
||||||
|
grid without firing 15 parallel queries. The payload is ~55 KB serialised
|
||||||
|
against MITRE v19 (15 tactics × ~50 techniques × ~3 subs).
|
||||||
|
"""
|
||||||
|
with session_scope() as s:
|
||||||
|
# All techniques + their tactics (selectin-loaded by the relationship).
|
||||||
|
techniques = s.scalars(
|
||||||
|
select(MitreTechnique).order_by(MitreTechnique.external_id.asc())
|
||||||
|
).all()
|
||||||
|
# Sub-techniques bucketed by parent.
|
||||||
|
subs_by_parent: dict = {}
|
||||||
|
for sb in s.scalars(
|
||||||
|
select(MitreSubtechnique).order_by(MitreSubtechnique.external_id.asc())
|
||||||
|
).all():
|
||||||
|
subs_by_parent.setdefault(sb.technique_id, []).append(
|
||||||
|
{
|
||||||
|
"id": str(sb.id),
|
||||||
|
"external_id": sb.external_id,
|
||||||
|
"name": sb.name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# Tactics in canonical kill-chain order (matches attack.mitre.org).
|
||||||
|
tactics = s.scalars(
|
||||||
|
select(MitreTactic).order_by(MitreTactic.external_id.asc())
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Group techniques by tactic short_name.
|
||||||
|
techs_by_tactic: dict = {}
|
||||||
|
for t in techniques:
|
||||||
|
entry = {
|
||||||
|
"id": str(t.id),
|
||||||
|
"external_id": t.external_id,
|
||||||
|
"name": t.name,
|
||||||
|
"subtechniques": subs_by_parent.get(t.id, []),
|
||||||
|
}
|
||||||
|
for tac in t.tactics:
|
||||||
|
techs_by_tactic.setdefault(tac.short_name, []).append(entry)
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"tactics": [
|
||||||
|
{
|
||||||
|
"id": str(t.id),
|
||||||
|
"external_id": t.external_id,
|
||||||
|
"short_name": t.short_name,
|
||||||
|
"name": t.name,
|
||||||
|
"techniques": techs_by_tactic.get(t.short_name, []),
|
||||||
|
}
|
||||||
|
for t in tactics
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/status")
|
@bp.get("/status")
|
||||||
@require_auth
|
@require_auth
|
||||||
def status():
|
def status():
|
||||||
|
|||||||
@@ -358,3 +358,32 @@ def test_search_filter_on_name(app, admin_credentials, fixture_bundle_path):
|
|||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
ext_ids = [t["external_id"] for t in r.get_json()["items"]]
|
ext_ids = [t["external_id"] for t in r.get_json()["items"]]
|
||||||
assert ext_ids == ["T1078"]
|
assert ext_ids == ["T1078"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_matrix_endpoint_returns_nested_grid(app, admin_credentials, fixture_bundle_path):
|
||||||
|
"""GET /mitre/matrix returns the flat tactic→technique→subtechnique grid."""
|
||||||
|
mitre_svc.seed_mitre(source=fixture_bundle_path, expected_sha256=None)
|
||||||
|
with app.test_client() as c:
|
||||||
|
access = _login(c, admin_credentials["email"], admin_credentials["password"])
|
||||||
|
r = c.get("/api/v1/mitre/matrix", headers={"Authorization": f"Bearer {access}"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.get_json()
|
||||||
|
tactics = body["tactics"]
|
||||||
|
assert {t["external_id"] for t in tactics} == {"TA0001", "TA0002"}
|
||||||
|
|
||||||
|
# TA0001 has T1059 (multi-tactic) + T1078; T1059 carries its sub.
|
||||||
|
ta0001 = next(t for t in tactics if t["external_id"] == "TA0001")
|
||||||
|
techs = {t["external_id"]: t for t in ta0001["techniques"]}
|
||||||
|
assert set(techs.keys()) == {"T1059", "T1078"}
|
||||||
|
assert techs["T1059"]["subtechniques"][0]["external_id"] == "T1059.001"
|
||||||
|
assert techs["T1078"]["subtechniques"] == []
|
||||||
|
|
||||||
|
# TA0002 only carries T1059 (no T1078).
|
||||||
|
ta0002 = next(t for t in tactics if t["external_id"] == "TA0002")
|
||||||
|
assert [t["external_id"] for t in ta0002["techniques"]] == ["T1059"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_matrix_endpoint_requires_auth(app, fixture_bundle_path):
|
||||||
|
mitre_svc.seed_mitre(source=fixture_bundle_path, expected_sha256=None)
|
||||||
|
with app.test_client() as c:
|
||||||
|
assert c.get("/api/v1/mitre/matrix").status_code == 401
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ test.describe('M4 — MITRE ATT&CK reference', () => {
|
|||||||
expect(body.default_version).toBeTruthy();
|
expect(body.default_version).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('SPA MITRE page renders + picker walks tactic → technique → subtechnique', async ({ page }) => {
|
test('SPA MITRE matrix renders + click cells to select technique + sub-technique', async ({ page }) => {
|
||||||
await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD);
|
await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||||
await page.goto('/mitre');
|
await page.goto('/mitre');
|
||||||
|
|
||||||
@@ -118,19 +118,29 @@ test.describe('M4 — MITRE ATT&CK reference', () => {
|
|||||||
|
|
||||||
const picker = page.getByTestId('mitre-tag-picker');
|
const picker = page.getByTestId('mitre-tag-picker');
|
||||||
await expect(picker).toBeVisible();
|
await expect(picker).toBeVisible();
|
||||||
|
// The matrix has a column per tactic.
|
||||||
|
await expect(picker.getByTestId('mitre-column-TA0006')).toBeVisible();
|
||||||
|
|
||||||
// 1. Click on TA0006 (Credential Access)
|
// 1. Click the T1003 cell (Credential Dumping under TA0006) → technique selected.
|
||||||
await picker.getByTestId('mitre-tactic-TA0006').click();
|
const t1003 = picker.getByTestId('mitre-technique-T1003').first();
|
||||||
// 2. Techniques column populates; click T1003
|
await t1003.scrollIntoViewIfNeeded();
|
||||||
await expect(picker.getByTestId('mitre-technique-T1003')).toBeVisible();
|
await t1003.click();
|
||||||
await picker.getByTestId('mitre-technique-T1003').click();
|
await expect(page.getByTestId('mitre-selected')).toContainText('T1003');
|
||||||
// 3. Sub-techniques column populates with T1003.001 onward
|
await expect(t1003).toHaveAttribute('aria-pressed', 'true');
|
||||||
await expect(picker.getByTestId('mitre-subtechnique-T1003.001')).toBeVisible();
|
|
||||||
// 4. Select the sub-technique → chip appears in the selection bar
|
// 2. Expand T1003's sub-techniques inline via the +N chevron.
|
||||||
await picker.getByTestId('mitre-subtechnique-T1003.001').click();
|
await picker.getByTestId('mitre-expand-T1003').first().click();
|
||||||
|
const sub = picker.getByTestId('mitre-subtechnique-T1003.001').first();
|
||||||
|
await expect(sub).toBeVisible();
|
||||||
|
|
||||||
|
// 3. Click the sub-technique → chip + JSON preview both update.
|
||||||
|
await sub.click();
|
||||||
await expect(page.getByTestId('mitre-selected')).toContainText('T1003.001');
|
await expect(page.getByTestId('mitre-selected')).toContainText('T1003.001');
|
||||||
// 5. Preview payload card shows the JSON encoded selection
|
|
||||||
await expect(page.getByTestId('mitre-selected-json')).toContainText('"T1003.001"');
|
await expect(page.getByTestId('mitre-selected-json')).toContainText('"T1003.001"');
|
||||||
|
|
||||||
|
// 4. Filter the matrix on "valid" → TA0006/T1003 are hidden but TA0001/T1078 visible.
|
||||||
|
await picker.getByLabel(/^filter$/i).fill('valid');
|
||||||
|
await expect(picker.getByTestId('mitre-technique-T1078').first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Non-admin cannot trigger /mitre/sync', async ({ page, request }) => {
|
test('Non-admin cannot trigger /mitre/sync', async ({ page, request }) => {
|
||||||
|
|||||||
@@ -5,98 +5,87 @@ import { Alert } from '@/components/ui/Alert';
|
|||||||
import { Tag } from '@/components/ui/Tag';
|
import { Tag } from '@/components/ui/Tag';
|
||||||
import { TextField } from '@/components/ui/TextField';
|
import { TextField } from '@/components/ui/TextField';
|
||||||
import { apiGet } from '@/lib/api';
|
import { apiGet } from '@/lib/api';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
import {
|
import {
|
||||||
mitreKeys,
|
mitreKeys,
|
||||||
type MitreSubtechnique,
|
type MatrixTechnique,
|
||||||
type MitreTactic,
|
type MitreMatrix,
|
||||||
type MitreTag,
|
type MitreTag,
|
||||||
type MitreTechnique,
|
|
||||||
type Paginated,
|
|
||||||
} from '@/lib/mitre';
|
} from '@/lib/mitre';
|
||||||
import { cn } from '@/lib/cn';
|
|
||||||
|
|
||||||
interface MitreTagPickerProps {
|
interface MitreTagPickerProps {
|
||||||
/** Already-selected tags. The parent owns the state. */
|
/** Already-selected tags. The parent owns the state. */
|
||||||
value: MitreTag[];
|
value: MitreTag[];
|
||||||
/** Called whenever the selection changes (replace semantics). */
|
/** Replace-style change handler — called with the new full selection. */
|
||||||
onChange: (next: MitreTag[]) => void;
|
onChange: (next: MitreTag[]) => void;
|
||||||
/** Hide the search box(es). Useful for compact embed in a sidebar. */
|
|
||||||
compact?: boolean;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function useTactics(q: string) {
|
function useMatrix() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: mitreKeys.tactics(q),
|
queryKey: mitreKeys.matrix,
|
||||||
queryFn: () =>
|
queryFn: () => apiGet<MitreMatrix>('/mitre/matrix'),
|
||||||
apiGet<Paginated<MitreTactic>>(
|
staleTime: 5 * 60_000,
|
||||||
`/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.
|
* Flat ATT&CK matrix in the attack.mitre.org style — columns = tactics, each
|
||||||
* Selected tags accumulate in the chips at the top.
|
* cell lists its techniques with an inline chevron to expand sub-techniques.
|
||||||
|
*
|
||||||
|
* Click a technique row → toggle selection. Click a sub-technique row → toggle.
|
||||||
|
* Click the tactic header → toggle the whole tactic as a single tag.
|
||||||
|
* Selected items are filled with their accent (cyan/orange/purple).
|
||||||
*/
|
*/
|
||||||
export function MitreTagPicker({ value, onChange, compact, className }: MitreTagPickerProps) {
|
export function MitreTagPicker({ value, onChange, className }: MitreTagPickerProps) {
|
||||||
const [activeTactic, setActiveTactic] = useState<string | null>(null);
|
const matrix = useMatrix();
|
||||||
const [activeTechnique, setActiveTechnique] = useState<string | null>(null);
|
const [filter, setFilter] = useState('');
|
||||||
const [qTactic, setQTactic] = useState('');
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
const [qTechnique, setQTechnique] = useState('');
|
|
||||||
const [qSub, setQSub] = useState('');
|
|
||||||
|
|
||||||
const tactics = useTactics(qTactic);
|
const filterNorm = filter.trim().toLowerCase();
|
||||||
const techniques = useTechniques(activeTactic, qTechnique);
|
|
||||||
const subtechniques = useSubtechniques(activeTechnique, qSub);
|
|
||||||
|
|
||||||
const selectedKey = useMemo(() => new Set(value.map((t) => `${t.kind}:${t.external_id}`)), [value]);
|
// Set of `${kind}:${external_id}` for O(1) lookup.
|
||||||
|
const selectedKeys = useMemo(
|
||||||
|
() => new Set(value.map((t) => `${t.kind}:${t.external_id}`)),
|
||||||
|
[value],
|
||||||
|
);
|
||||||
|
|
||||||
|
function isSelected(kind: MitreTag['kind'], external_id: string): boolean {
|
||||||
|
return selectedKeys.has(`${kind}:${external_id}`);
|
||||||
|
}
|
||||||
|
|
||||||
function toggle(tag: MitreTag) {
|
function toggle(tag: MitreTag) {
|
||||||
const key = `${tag.kind}:${tag.external_id}`;
|
const key = `${tag.kind}:${tag.external_id}`;
|
||||||
if (selectedKey.has(key)) {
|
if (selectedKeys.has(key)) {
|
||||||
onChange(value.filter((t) => `${t.kind}:${t.external_id}` !== key));
|
onChange(value.filter((t) => `${t.kind}:${t.external_id}` !== key));
|
||||||
} else {
|
} else {
|
||||||
onChange([...value, tag]);
|
onChange([...value, tag]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function colorForKind(kind: 'tactic' | 'technique' | 'subtechnique') {
|
function toggleExpand(techExtId: string) {
|
||||||
return kind === 'tactic' ? 'cyan' : kind === 'technique' ? 'orange' : 'purple';
|
setExpanded((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(techExtId)) next.delete(techExtId);
|
||||||
|
else next.add(techExtId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function matches(t: MatrixTechnique): boolean {
|
||||||
|
if (!filterNorm) return true;
|
||||||
|
if (t.external_id.toLowerCase().includes(filterNorm)) return true;
|
||||||
|
if (t.name.toLowerCase().includes(filterNorm)) return true;
|
||||||
|
return t.subtechniques.some(
|
||||||
|
(sb) =>
|
||||||
|
sb.external_id.toLowerCase().includes(filterNorm) ||
|
||||||
|
sb.name.toLowerCase().includes(filterNorm),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('rounded-lg border border-border bg-bg-card p-4', className)} data-testid="mitre-tag-picker">
|
<div className={cn('rounded-lg border border-border bg-bg-card p-4', className)} data-testid="mitre-tag-picker">
|
||||||
|
{/* Selection chips */}
|
||||||
{value.length > 0 && (
|
{value.length > 0 && (
|
||||||
<div className="mb-3 flex flex-wrap items-center gap-1" data-testid="mitre-selected">
|
<div className="mb-3 flex flex-wrap items-center gap-1" data-testid="mitre-selected">
|
||||||
{value.map((t) => (
|
{value.map((t) => (
|
||||||
@@ -107,7 +96,7 @@ export function MitreTagPicker({ value, onChange, compact, className }: MitreTag
|
|||||||
className="inline-flex items-center"
|
className="inline-flex items-center"
|
||||||
aria-label={`Remove ${t.external_id}`}
|
aria-label={`Remove ${t.external_id}`}
|
||||||
>
|
>
|
||||||
<Tag accent={colorForKind(t.kind)}>
|
<Tag accent={t.kind === 'tactic' ? 'cyan' : t.kind === 'technique' ? 'orange' : 'purple'}>
|
||||||
{t.external_id} · {t.name} ✕
|
{t.external_id} · {t.name} ✕
|
||||||
</Tag>
|
</Tag>
|
||||||
</button>
|
</button>
|
||||||
@@ -115,158 +104,189 @@ export function MitreTagPicker({ value, onChange, compact, className }: MitreTag
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
{/* Filter + counts */}
|
||||||
{/* Tactics column */}
|
<div className="mb-3 flex items-end justify-between gap-3">
|
||||||
<div>
|
<TextField
|
||||||
{!compact && (
|
label="Filter"
|
||||||
<TextField
|
placeholder="external_id or name (e.g. TA0006, T1003, powershell)"
|
||||||
label="Tactic search"
|
value={filter}
|
||||||
value={qTactic}
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
onChange={(e) => setQTactic(e.target.value)}
|
className="max-w-md"
|
||||||
placeholder="e.g. Credential"
|
/>
|
||||||
/>
|
{matrix.data && (
|
||||||
)}
|
<span className="font-mono text-2xs text-text-dim mb-2 whitespace-nowrap">
|
||||||
<div className="mt-2 max-h-72 overflow-y-auto" data-testid="mitre-tactics-column">
|
{matrix.data.tactics.length} tactics ·{' '}
|
||||||
{tactics.isLoading && <p className="text-2xs text-text-dim">Loading…</p>}
|
{matrix.data.tactics.reduce((sum, t) => sum + t.techniques.length, 0)} mappings
|
||||||
{tactics.data?.items.map((t) => {
|
</span>
|
||||||
const active = activeTactic === t.external_id;
|
)}
|
||||||
const selected = selectedKey.has(`tactic:${t.external_id}`);
|
</div>
|
||||||
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 */}
|
{matrix.isLoading && <p className="font-mono text-xs text-text-dim">Loading matrix…</p>}
|
||||||
<div>
|
{matrix.isError && (
|
||||||
{!compact && (
|
<Alert accent="red">Failed to load /mitre/matrix — has `make seed-mitre` been run?</Alert>
|
||||||
<TextField
|
)}
|
||||||
label="Technique search"
|
|
||||||
value={qTechnique}
|
{matrix.data && (
|
||||||
onChange={(e) => setQTechnique(e.target.value)}
|
<div
|
||||||
placeholder="e.g. T1059"
|
className="overflow-x-auto pb-2"
|
||||||
/>
|
data-testid="mitre-matrix-scroll"
|
||||||
)}
|
role="region"
|
||||||
<div className="mt-2 max-h-72 overflow-y-auto" data-testid="mitre-techniques-column">
|
aria-label="MITRE ATT&CK matrix"
|
||||||
{activeTactic === null && (
|
>
|
||||||
<p className="text-2xs text-text-dim">Select a tactic to list its techniques.</p>
|
<div className="flex gap-2 min-w-max items-start">
|
||||||
)}
|
{matrix.data.tactics.map((tactic) => {
|
||||||
{techniques.isLoading && <p className="text-2xs text-text-dim">Loading…</p>}
|
const visible = tactic.techniques.filter(matches);
|
||||||
{techniques.data?.items.map((t) => {
|
const tacticSel = isSelected('tactic', tactic.external_id);
|
||||||
const active = activeTechnique === t.external_id;
|
|
||||||
const selected = selectedKey.has(`technique:${t.external_id}`);
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={t.id}
|
key={tactic.id}
|
||||||
className={cn(
|
className="w-56 shrink-0"
|
||||||
'group flex items-center gap-2 rounded border px-2 py-1 cursor-pointer',
|
data-testid={`mitre-column-${tactic.external_id}`}
|
||||||
active ? 'border-orange' : 'border-transparent hover:border-orange',
|
|
||||||
)}
|
|
||||||
onClick={() => setActiveTechnique(t.external_id)}
|
|
||||||
data-testid={`mitre-technique-${t.external_id}`}
|
|
||||||
>
|
>
|
||||||
<input
|
{/* Sticky-ish tactic header (click to select the whole tactic). */}
|
||||||
type="checkbox"
|
<button
|
||||||
checked={selected}
|
type="button"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={() =>
|
||||||
onChange={() =>
|
|
||||||
toggle({
|
toggle({
|
||||||
kind: 'technique',
|
kind: 'tactic',
|
||||||
id: t.id,
|
id: tactic.id,
|
||||||
external_id: t.external_id,
|
external_id: tactic.external_id,
|
||||||
name: t.name,
|
name: tactic.name,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
aria-label={`Select ${t.external_id}`}
|
className={cn(
|
||||||
/>
|
'w-full text-left rounded-t-md border-2 px-2 py-2 font-mono text-2xs transition',
|
||||||
<span className="font-mono text-2xs text-orange">{t.external_id}</span>
|
tacticSel
|
||||||
<span className="text-2xs">{t.name}</span>
|
? 'accent-fill-cyan border-cyan text-text-bright'
|
||||||
</div>
|
: 'border-cyan/40 hover:border-cyan hover:bg-cyan/5',
|
||||||
);
|
)}
|
||||||
})}
|
data-testid={`mitre-tactic-${tactic.external_id}`}
|
||||||
{techniques.data && techniques.data.items.length === 0 && activeTactic && (
|
aria-pressed={tacticSel}
|
||||||
<p className="text-2xs text-text-dim">No techniques for this tactic.</p>
|
>
|
||||||
)}
|
<div className="flex items-center justify-between gap-1">
|
||||||
</div>
|
<span className="text-cyan font-semibold">{tactic.external_id}</span>
|
||||||
</div>
|
<span className="text-3xs text-text-dim">
|
||||||
|
{tactic.techniques.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xs mt-0.5 truncate" title={tactic.name}>
|
||||||
|
{tactic.name}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Sub-techniques column */}
|
{/* Techniques cells */}
|
||||||
<div>
|
<div className="border-x border-b border-border rounded-b-md bg-bg-base">
|
||||||
{!compact && (
|
{visible.length === 0 && filterNorm && (
|
||||||
<TextField
|
<div className="px-2 py-1 font-mono text-3xs text-text-dim">
|
||||||
label="Sub-technique search"
|
(filtered out)
|
||||||
value={qSub}
|
</div>
|
||||||
onChange={(e) => setQSub(e.target.value)}
|
)}
|
||||||
placeholder="e.g. Powershell"
|
{visible.map((tech) => {
|
||||||
/>
|
const techSel = isSelected('technique', tech.external_id);
|
||||||
)}
|
const isExpanded = expanded.has(tech.external_id);
|
||||||
<div className="mt-2 max-h-72 overflow-y-auto" data-testid="mitre-subtechniques-column">
|
const hasSubs = tech.subtechniques.length > 0;
|
||||||
{activeTechnique === null && (
|
return (
|
||||||
<p className="text-2xs text-text-dim">Select a technique to list its sub-techniques.</p>
|
<div key={tech.id} className="border-t border-border first:border-t-0">
|
||||||
)}
|
<div
|
||||||
{subtechniques.isLoading && <p className="text-2xs text-text-dim">Loading…</p>}
|
className={cn(
|
||||||
{subtechniques.data?.items.map((sb) => {
|
'flex items-stretch text-2xs',
|
||||||
const selected = selectedKey.has(`subtechnique:${sb.external_id}`);
|
techSel ? 'accent-fill-orange' : 'hover:bg-bg-card',
|
||||||
return (
|
)}
|
||||||
<div
|
>
|
||||||
key={sb.id}
|
<button
|
||||||
className="flex items-center gap-2 rounded border border-transparent hover:border-purple px-2 py-1 cursor-pointer"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
toggle({
|
toggle({
|
||||||
kind: 'subtechnique',
|
kind: 'technique',
|
||||||
id: sb.id,
|
id: tech.id,
|
||||||
external_id: sb.external_id,
|
external_id: tech.external_id,
|
||||||
name: sb.name,
|
name: tech.name,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
data-testid={`mitre-subtechnique-${sb.external_id}`}
|
className="flex-1 text-left px-2 py-1 font-mono"
|
||||||
>
|
data-testid={`mitre-technique-${tech.external_id}`}
|
||||||
<input
|
aria-pressed={techSel}
|
||||||
type="checkbox"
|
>
|
||||||
checked={selected}
|
<span
|
||||||
readOnly
|
className={cn(
|
||||||
aria-label={`Select ${sb.external_id}`}
|
'font-semibold mr-1',
|
||||||
/>
|
techSel ? 'text-text-bright' : 'text-orange',
|
||||||
<span className="font-mono text-2xs text-purple">{sb.external_id}</span>
|
)}
|
||||||
<span className="text-2xs">{sb.name}</span>
|
>
|
||||||
|
{tech.external_id}
|
||||||
|
</span>
|
||||||
|
<span title={tech.name}>{tech.name}</span>
|
||||||
|
</button>
|
||||||
|
{hasSubs && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleExpand(tech.external_id)}
|
||||||
|
className="px-2 border-l border-border text-purple hover:bg-purple/10"
|
||||||
|
aria-label={`Toggle ${tech.external_id} sub-techniques`}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
data-testid={`mitre-expand-${tech.external_id}`}
|
||||||
|
>
|
||||||
|
<span className="font-mono">
|
||||||
|
{isExpanded ? '−' : '+'}
|
||||||
|
<span className="text-3xs ml-0.5">{tech.subtechniques.length}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && hasSubs && (
|
||||||
|
<div className="bg-bg-card border-t border-border">
|
||||||
|
{tech.subtechniques.map((sb) => {
|
||||||
|
const subSel = isSelected('subtechnique', sb.external_id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={sb.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
toggle({
|
||||||
|
kind: 'subtechnique',
|
||||||
|
id: sb.id,
|
||||||
|
external_id: sb.external_id,
|
||||||
|
name: sb.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
'block w-full text-left pl-6 pr-2 py-1 font-mono text-2xs border-l-2',
|
||||||
|
subSel
|
||||||
|
? 'accent-fill-purple border-purple text-text-bright'
|
||||||
|
: 'border-purple/30 hover:bg-purple/5',
|
||||||
|
)}
|
||||||
|
data-testid={`mitre-subtechnique-${sb.external_id}`}
|
||||||
|
aria-pressed={subSel}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'font-semibold mr-1',
|
||||||
|
subSel ? 'text-text-bright' : 'text-purple',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{sb.external_id}
|
||||||
|
</span>
|
||||||
|
<span title={sb.name}>{sb.name}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{subtechniques.data && subtechniques.data.items.length === 0 && activeTechnique && (
|
|
||||||
<p className="text-2xs text-text-dim">No sub-techniques.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<p className="mt-3 font-mono text-3xs text-text-dim">
|
||||||
|
Click any cell to select · click <span className="text-purple">+N</span> to reveal sub-techniques · click a chip above to remove.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,9 +53,35 @@ export interface MitreTag {
|
|||||||
|
|
||||||
export const mitreKeys = {
|
export const mitreKeys = {
|
||||||
status: ['mitre', 'status'] as const,
|
status: ['mitre', 'status'] as const,
|
||||||
|
matrix: ['mitre', 'matrix'] as const,
|
||||||
tactics: (q?: string) => ['mitre', 'tactics', q ?? ''] as const,
|
tactics: (q?: string) => ['mitre', 'tactics', q ?? ''] as const,
|
||||||
techniques: (tactic?: string, q?: string) =>
|
techniques: (tactic?: string, q?: string) =>
|
||||||
['mitre', 'techniques', tactic ?? '', q ?? ''] as const,
|
['mitre', 'techniques', tactic ?? '', q ?? ''] as const,
|
||||||
subtechniques: (technique?: string, q?: string) =>
|
subtechniques: (technique?: string, q?: string) =>
|
||||||
['mitre', 'subtechniques', technique ?? '', q ?? ''] as const,
|
['mitre', 'subtechniques', technique ?? '', q ?? ''] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface MatrixSubtechnique {
|
||||||
|
id: string;
|
||||||
|
external_id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatrixTechnique {
|
||||||
|
id: string;
|
||||||
|
external_id: string;
|
||||||
|
name: string;
|
||||||
|
subtechniques: MatrixSubtechnique[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatrixTactic {
|
||||||
|
id: string;
|
||||||
|
external_id: string;
|
||||||
|
short_name: string;
|
||||||
|
name: string;
|
||||||
|
techniques: MatrixTechnique[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MitreMatrix {
|
||||||
|
tactics: MatrixTactic[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ Remplace l'aller-retour Excel actuel par une UI partagée temps réel, multi-rô
|
|||||||
|
|
||||||
## 5. Exigences fonctionnelles
|
## 5. Exigences fonctionnelles
|
||||||
- **F1** — Gestion users/groupes/invitations par admin avec permissions atomiques (familles listées en §4).
|
- **F1** — Gestion users/groupes/invitations par admin avec permissions atomiques (familles listées en §4).
|
||||||
- **F2** — CRUD tests unitaires templates avec MITRE ATT&CK (Tactic+Technique+Sub-technique multi), procédure markdown/code, prérequis, résultat attendu red, détection attendue blue, niveau OPSEC (low/med/high), tags libres, IOCs attendus.
|
- **F2** — CRUD tests unitaires templates avec MITRE ATT&CK (Tactic+Technique+Sub-technique multi), procédure markdown/code, prérequis, résultat attendu red, détection attendue blue, niveau OPSEC (low/med/high), tags libres, IOCs attendus. **Représentation UI du picker MITRE** : matrice flat type `attack.mitre.org/#` — colonnes = tactics (ordonnées TA00xx), cellules = techniques, chevron `+N` qui déplie inline les sub-techniques. Click sur une cellule = (dé)sélection. Selection multi-niveaux (tactic / technique / sub-technique) cumulative, chips de sélection en haut.
|
||||||
- **F3** — CRUD scénarios = liste ordonnée (drag-and-drop) de tests unitaires.
|
- **F3** — CRUD scénarios = liste ordonnée (drag-and-drop) de tests unitaires.
|
||||||
- **F4** — CRUD missions (métadonnées §4) composées d'un ou plusieurs scénarios, snapshot des templates à l'instanciation.
|
- **F4** — CRUD missions (métadonnées §4) composées d'un ou plusieurs scénarios, snapshot des templates à l'instanciation.
|
||||||
- **F5** — Saisie côté red : commande lancée, output texte, commentaires markdown, statut, timestamp auto+override.
|
- **F5** — Saisie côté red : commande lancée, output texte, commentaires markdown, statut, timestamp auto+override.
|
||||||
@@ -89,7 +89,7 @@ Remplace l'aller-retour Excel actuel par une UI partagée temps réel, multi-rô
|
|||||||
- **F9** — Export mission : JSON complet (API + UI), CSV agrégé.
|
- **F9** — Export mission : JSON complet (API + UI), CSV agrégé.
|
||||||
- **F10** — Soft delete + purge admin.
|
- **F10** — Soft delete + purge admin.
|
||||||
- **F11** — Switch i18n FR/EN par utilisateur (préférence persistée).
|
- **F11** — Switch i18n FR/EN par utilisateur (préférence persistée).
|
||||||
- **F12** — Sync MITRE ATT&CK Enterprise : dataset STIX embarqué (seed) + job admin manuel pour re-puller depuis github.com/mitre/cti.
|
- **F12** — Sync MITRE ATT&CK Enterprise : dataset STIX embarqué (seed) + job admin manuel pour re-puller depuis github.com/mitre/cti. Le picker (cf. F2) se base sur un endpoint `GET /mitre/matrix` qui retourne la grille complète (tactics → techniques → sub-techniques) en un seul appel.
|
||||||
|
|
||||||
## 6. Exigences non fonctionnelles
|
## 6. Exigences non fonctionnelles
|
||||||
- **NF-perf** : UI fluide, pagination côté API au-delà de 50 éléments par liste, lazy-loading des fichiers de preuves.
|
- **NF-perf** : UI fluide, pagination côté API au-delà de 50 éléments par liste, lazy-loading des fichiers de preuves.
|
||||||
|
|||||||
@@ -38,18 +38,20 @@ Le rapport HTML est dans `e2e/playwright-report/`, le JUnit dans `e2e/playwright
|
|||||||
2. Cliquer **MITRE** dans la nav → page chargée.
|
2. Cliquer **MITRE** dans la nav → page chargée.
|
||||||
3. Carte **Source** : vérifier `version 19.0` + URL pinnée + `Last sync` non vide.
|
3. Carte **Source** : vérifier `version 19.0` + URL pinnée + `Last sync` non vide.
|
||||||
4. Carte **Sync** (admin uniquement) : cliquer **Trigger MITRE sync** → bannière verte avec counts (15 tactics / 222 techniques / 475 subtechniques).
|
4. Carte **Sync** (admin uniquement) : cliquer **Trigger MITRE sync** → bannière verte avec counts (15 tactics / 222 techniques / 475 subtechniques).
|
||||||
5. Picker :
|
5. **Picker — matrice flat type attack.mitre.org** :
|
||||||
- Cliquer **TA0006 — Credential Access** dans la colonne gauche.
|
- La matrice affiche 15 colonnes (tactics TA0001 → TA0040) en horizontal scroll. Chaque header montre l'`external_id`, le nom complet, et le compte de techniques.
|
||||||
- La colonne du milieu liste **T1003 OS Credential Dumping** et 16 autres techniques.
|
- Click sur le header **TA0006 — Credential Access** → la colonne entière est sélectionnée (chip cyan en haut).
|
||||||
- Cliquer **T1003** → la colonne droite liste **T1003.001** à **T1003.008**.
|
- Re-click pour désélectionner.
|
||||||
- Cocher la case en face de **T1003.001 PowerShell**.
|
- Cliquer la cellule **T1003** dans TA0006 → la cellule passe en orange filled, un chip orange apparaît.
|
||||||
- Un chip cyan/orange/purple apparaît en haut + la carte « Selected (preview payload) » montre le JSON.
|
- Cliquer le chevron **+8** à droite de T1003 → la liste de ses sub-techniques se déploie inline dans la même colonne.
|
||||||
- Cliquer le chip → désélection.
|
- Cliquer **T1003.001 LSASS Memory** → cell purple filled, chip purple ajouté en haut.
|
||||||
|
- Click le chip pour le retirer.
|
||||||
|
- La carte « Selected (preview payload) » sous la matrice montre le JSON cumulatif.
|
||||||
|
|
||||||
### 3.2 Filtres / recherche
|
### 3.2 Filtre
|
||||||
1. Dans la colonne **Tactic search**, taper `cred` → seule TA0006 reste.
|
1. Taper `dump` dans le champ **Filter** → seules T1003 + sub-techniques restent visibles, les autres techniques sont cachées (mais leurs colonnes restent visibles pour préserver la grille).
|
||||||
2. Sélectionner TA0006, dans **Technique search** taper `dump` → T1003 apparaît.
|
2. Taper `TA0006` → idem mais filtre par `external_id`.
|
||||||
3. Vider les recherches, sélectionner T1059 → vérifier T1059.001 à T1059.012 dans les sub-techniques.
|
3. Vider le filtre → toutes les cellules réapparaissent.
|
||||||
|
|
||||||
### 3.3 Non-admin
|
### 3.3 Non-admin
|
||||||
1. Inviter un user sans perms via Admin > Invitations.
|
1. Inviter un user sans perms via Admin > Invitations.
|
||||||
@@ -107,5 +109,6 @@ curl -sX POST http://localhost:8080/api/v1/mitre/sync \
|
|||||||
- [x] `/mitre/sync` exige la perm `mitre.sync` (admin via bypass `is_admin`).
|
- [x] `/mitre/sync` exige la perm `mitre.sync` (admin via bypass `is_admin`).
|
||||||
- [x] Sha256 mismatch sur la pinned URL → 502 `checksum_mismatch`, DB intacte.
|
- [x] Sha256 mismatch sur la pinned URL → 502 `checksum_mismatch`, DB intacte.
|
||||||
- [x] Bundle local (`--source <path>`) bypasse la vérif checksum.
|
- [x] Bundle local (`--source <path>`) bypasse la vérif checksum.
|
||||||
- [x] Picker SPA : tactic → technique → subtechnique, multi-select, déselection via chip cliquable.
|
- [x] Picker SPA : matrice flat attack.mitre.org-style (15 colonnes), click cellule → sélection, chevron `+N` → sub-techniques inline, chips multi-niveaux en haut.
|
||||||
|
- [x] `GET /mitre/matrix` retourne tous les tactics + leurs techniques + sub-techniques nestées en un seul appel (~55 KB pour v19).
|
||||||
- [x] Non-admin : voit la page `/mitre` mais pas la carte Sync ; `POST /mitre/sync` → 403.
|
- [x] Non-admin : voit la page `/mitre` mais pas la carte Sync ; `POST /mitre/sync` → 403.
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ spec: tasks/spec.md
|
|||||||
- ☐ Endpoint `POST /mitre/sync` (perm `mitre.sync`) qui re-pull depuis l'URL configurée (setting `mitre_source_url`).
|
- ☐ Endpoint `POST /mitre/sync` (perm `mitre.sync`) qui re-pull depuis l'URL configurée (setting `mitre_source_url`).
|
||||||
- ☐ Persister `mitre_last_sync` dans `settings`.
|
- ☐ Persister `mitre_last_sync` dans `settings`.
|
||||||
- ☐ Endpoint `GET /mitre/tactics`, `/mitre/techniques?tactic=…`, `/mitre/subtechniques?technique=…` (pagination + recherche full-text simple sur `name`).
|
- ☐ Endpoint `GET /mitre/tactics`, `/mitre/techniques?tactic=…`, `/mitre/subtechniques?technique=…` (pagination + recherche full-text simple sur `name`).
|
||||||
- ☐ Front : composant `<MitreTagPicker>` (autocomplete combiné Tactic > Technique > Sub-technique, multi-select).
|
- ☐ Front : composant `<MitreTagPicker>` — matrice flat type `attack.mitre.org/#` (colonnes = tactics, cellules = techniques, chevron `+N` qui déplie les sub-techniques inline). Click = (dé)sélection, multi-niveaux cumulatif, chips en haut, recherche par `external_id` ou `name`. Alimenté par `GET /mitre/matrix` (one-shot, ~55 KB).
|
||||||
|
|
||||||
**DoD** : après `make seed-mitre`, `GET /mitre/tactics` retourne 14 tactics Enterprise ; le picker permet de tagger un test avec « TA0002 / T1059.001 » et l'enregistrement est persistant.
|
**DoD** : après `make seed-mitre`, `GET /mitre/tactics` retourne 14 tactics Enterprise ; le picker permet de tagger un test avec « TA0002 / T1059.001 » et l'enregistrement est persistant.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user