diff --git a/backend/tests/test_mitre.py b/backend/tests/test_mitre.py new file mode 100644 index 0000000..cd297cb --- /dev/null +++ b/backend/tests/test_mitre.py @@ -0,0 +1,360 @@ +"""Integration tests for M4: STIX parser + seed + /mitre/* endpoints. + +Uses a minimal hand-crafted STIX bundle (no network) so the parser logic and +the upsert semantics can be exercised deterministically. +""" + +from __future__ import annotations + +import json +import secrets +import uuid +from pathlib import Path + +import pytest +from sqlalchemy import text + +from app.core.install_token import regenerate_install_token +from app.main import create_app +from app.services import mitre_seed as mitre_svc + + +def _truncate_all(engine): + with engine.begin() as conn: + conn.execute( + text( + "TRUNCATE users, refresh_tokens, invitations, invitation_groups, " + "user_groups, group_permissions, permissions, settings, groups, " + "mitre_subtechniques, mitre_technique_tactics, mitre_techniques, " + "mitre_tactics RESTART IDENTITY CASCADE" + ) + ) + + +@pytest.fixture(scope="module") +def app(db_engine_or_skip): + _truncate_all(db_engine_or_skip) + flask_app = create_app() + flask_app.config.update(TESTING=True) + return flask_app + + +@pytest.fixture() +def client(app): + return app.test_client() + + +def _unique_email(prefix: str) -> str: + return f"{prefix}-{secrets.token_hex(4)}@metamorph.local" + + +@pytest.fixture(scope="module") +def admin_credentials(app, db_engine_or_skip): + """Bootstrap a fresh admin once for the whole module.""" + token = regenerate_install_token() + email = _unique_email("admin") + password = "AdminPass1234!" + with app.test_client() as c: + r = c.post( + "/api/v1/setup", + json={"install_token": token, "email": email, "password": password}, + ) + assert r.status_code == 201, r.get_data(as_text=True) + return {"email": email, "password": password, "user_id": r.get_json()["user_id"]} + + +def _login(client, email: str, password: str) -> str: + r = client.post("/api/v1/auth/login", json={"email": email, "password": password}) + assert r.status_code == 200, r.get_data(as_text=True) + return r.get_json()["access_token"] + + +# === Fixture STIX bundle ===================================================== + + +MINIMAL_BUNDLE = { + "type": "bundle", + "id": "bundle--00000000-0000-0000-0000-000000000001", + "spec_version": "2.1", + "objects": [ + # Tactic 1 — kept + { + "type": "x-mitre-tactic", + "id": "x-mitre-tactic--ta0001", + "name": "Initial Access", + "description": "Get a foothold.", + "x_mitre_shortname": "initial-access", + "external_references": [ + { + "source_name": "mitre-attack", + "external_id": "TA0001", + "url": "https://attack.mitre.org/tactics/TA0001/", + } + ], + }, + # Tactic 2 — kept + { + "type": "x-mitre-tactic", + "id": "x-mitre-tactic--ta0002", + "name": "Execution", + "description": "Run code.", + "x_mitre_shortname": "execution", + "external_references": [ + { + "source_name": "mitre-attack", + "external_id": "TA0002", + "url": "https://attack.mitre.org/tactics/TA0002/", + } + ], + }, + # Revoked tactic — must be skipped + { + "type": "x-mitre-tactic", + "id": "x-mitre-tactic--ta0099", + "name": "Doomed", + "x_mitre_shortname": "doomed", + "revoked": True, + "external_references": [ + {"source_name": "mitre-attack", "external_id": "TA0099"} + ], + }, + # Technique T1059 covers both tactics + { + "type": "attack-pattern", + "id": "attack-pattern--t1059", + "name": "Command and Scripting Interpreter", + "description": "Use shells.", + "kill_chain_phases": [ + {"kill_chain_name": "mitre-attack", "phase_name": "initial-access"}, + {"kill_chain_name": "mitre-attack", "phase_name": "execution"}, + ], + "external_references": [ + { + "source_name": "mitre-attack", + "external_id": "T1059", + "url": "https://attack.mitre.org/techniques/T1059/", + } + ], + }, + # Technique T1078 only initial-access + { + "type": "attack-pattern", + "id": "attack-pattern--t1078", + "name": "Valid Accounts", + "description": "Use legit creds.", + "kill_chain_phases": [ + {"kill_chain_name": "mitre-attack", "phase_name": "initial-access"}, + ], + "external_references": [ + { + "source_name": "mitre-attack", + "external_id": "T1078", + "url": "https://attack.mitre.org/techniques/T1078/", + } + ], + }, + # Deprecated technique — skipped + { + "type": "attack-pattern", + "id": "attack-pattern--t1190", + "name": "Exploit Public-Facing Application", + "x_mitre_deprecated": True, + "external_references": [ + {"source_name": "mitre-attack", "external_id": "T1190"} + ], + }, + # Sub-technique of T1059 + { + "type": "attack-pattern", + "id": "attack-pattern--t1059-001", + "name": "PowerShell", + "description": "Windows shell.", + "x_mitre_is_subtechnique": True, + "external_references": [ + { + "source_name": "mitre-attack", + "external_id": "T1059.001", + "url": "https://attack.mitre.org/techniques/T1059/001/", + } + ], + }, + # Relationship attaching the sub to its parent + { + "type": "relationship", + "id": "relationship--rel1", + "relationship_type": "subtechnique-of", + "source_ref": "attack-pattern--t1059-001", + "target_ref": "attack-pattern--t1059", + }, + ], +} + + +@pytest.fixture() +def fixture_bundle_path(tmp_path: Path) -> Path: + path = tmp_path / "minimal-stix.json" + path.write_text(json.dumps(MINIMAL_BUNDLE)) + return path + + +# === Parser unit tests ======================================================= + + +def test_parser_extracts_active_objects(fixture_bundle_path): + parsed = mitre_svc.parse_bundle(fixture_bundle_path) + assert len(parsed.tactics) == 2 # TA0001 + TA0002 (TA0099 revoked) + assert {t["external_id"] for t in parsed.tactics} == {"TA0001", "TA0002"} + assert len(parsed.techniques) == 2 # T1059 + T1078 (T1190 deprecated) + assert {t["external_id"] for t in parsed.techniques} == {"T1059", "T1078"} + assert len(parsed.subtechniques) == 1 + sb = parsed.subtechniques[0] + assert sb["external_id"] == "T1059.001" + assert sb["parent_stix_id"] == "attack-pattern--t1059" + + +# === Seed integration tests ================================================== + + +def test_seed_against_fixture(app, fixture_bundle_path): + result = mitre_svc.seed_mitre(source=fixture_bundle_path, expected_sha256=None) + assert result.tactics_upserted == 2 + assert result.techniques_upserted == 2 + assert result.subtechniques_upserted == 1 + assert result.subtechniques_skipped_orphan == 0 + assert result.technique_tactic_links == 3 # T1059→TA0001, T1059→TA0002, T1078→TA0001 + + +def test_seed_is_idempotent(app, fixture_bundle_path): + """Running twice yields the same row counts and no SQL errors.""" + first = mitre_svc.seed_mitre(source=fixture_bundle_path, expected_sha256=None) + second = mitre_svc.seed_mitre(source=fixture_bundle_path, expected_sha256=None) + assert (first.tactics_upserted, first.techniques_upserted, first.subtechniques_upserted) == ( + second.tactics_upserted, + second.techniques_upserted, + second.subtechniques_upserted, + ) + + +def test_seed_persists_setting(app, fixture_bundle_path): + """settings table records the last sync timestamp + source URL.""" + mitre_svc.seed_mitre(source=fixture_bundle_path, expected_sha256=None) + status = mitre_svc.read_status() + assert status["last_sync"] is not None + # We seeded from a local path so version is None and source_url is the path string. + assert status["source_url"] == str(fixture_bundle_path) + assert status["version"] is None # only set when source == MITRE_DEFAULT_URL + + +def test_checksum_mismatch_aborts(tmp_path): + """A wrong sha256 triggers MitreChecksumMismatch and skips DB writes.""" + path = tmp_path / "tiny.json" + path.write_text(json.dumps(MINIMAL_BUNDLE)) + # Force the URL path so download() is invoked. We mock by passing a file:// URL. + # Simpler: call _download() directly with a bogus hash. + bogus = "0" * 64 + with pytest.raises(mitre_svc.MitreChecksumMismatch): + mitre_svc._download( + f"file://{path}", tmp_path / "out.json", expected_sha256=bogus + ) + + +# === API endpoint tests ====================================================== + + +def test_list_tactics_requires_auth(app, fixture_bundle_path): + mitre_svc.seed_mitre(source=fixture_bundle_path, expected_sha256=None) + with app.test_client() as c: + r = c.get("/api/v1/mitre/tactics") + assert r.status_code == 401 + + +def test_list_tactics_returns_seeded(app, admin_credentials, fixture_bundle_path): + 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/tactics", headers={"Authorization": f"Bearer {access}"} + ) + assert r.status_code == 200 + body = r.get_json() + assert body["total"] == 2 + ids = [t["external_id"] for t in body["items"]] + assert "TA0001" in ids and "TA0002" in ids + + +def test_filter_techniques_by_tactic(app, admin_credentials, fixture_bundle_path): + 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/techniques?tactic=TA0002", + headers={"Authorization": f"Bearer {access}"}, + ) + assert r.status_code == 200 + body = r.get_json() + # Only T1059 covers TA0002 (execution); T1078 covers initial-access only. + ext_ids = [t["external_id"] for t in body["items"]] + assert ext_ids == ["T1059"] + + +def test_subtechniques_listed_under_parent(app, admin_credentials, fixture_bundle_path): + 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/subtechniques?technique=T1059", + headers={"Authorization": f"Bearer {access}"}, + ) + assert r.status_code == 200 + body = r.get_json() + ext_ids = [t["external_id"] for t in body["items"]] + assert ext_ids == ["T1059.001"] + + +def test_status_endpoint(app, admin_credentials, fixture_bundle_path): + 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/status", headers={"Authorization": f"Bearer {access}"}) + assert r.status_code == 200 + body = r.get_json() + assert body["last_sync"] is not None + assert body["default_url"].startswith("https://") + assert body["default_version"] + + +def test_sync_endpoint_requires_perm(app, admin_credentials, fixture_bundle_path): + """A non-admin without mitre.sync gets 403.""" + mitre_svc.seed_mitre(source=fixture_bundle_path, expected_sha256=None) + with app.test_client() as c: + # Bootstrap a no-perm user via invitation. + admin_access = _login(c, admin_credentials["email"], admin_credentials["password"]) + eve_email = _unique_email("eve") + inv = c.post( + "/api/v1/invitations", + headers={"Authorization": f"Bearer {admin_access}"}, + json={"email_hint": eve_email}, + ) + token = inv.get_json()["token"] + c.post( + f"/api/v1/invitations/accept/{token}", + json={"email": eve_email, "password": "EvePass1234!"}, + ) + eve_access = _login(c, eve_email, "EvePass1234!") + r = c.post( + "/api/v1/mitre/sync", headers={"Authorization": f"Bearer {eve_access}"} + ) + assert r.status_code == 403 + + +def test_search_filter_on_name(app, admin_credentials, fixture_bundle_path): + 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/techniques?q=valid", + headers={"Authorization": f"Bearer {access}"}, + ) + assert r.status_code == 200 + ext_ids = [t["external_id"] for t in r.get_json()["items"]] + assert ext_ids == ["T1078"] diff --git a/e2e/tests/m4-mitre.spec.ts b/e2e/tests/m4-mitre.spec.ts new file mode 100644 index 0000000..a9e4cc4 --- /dev/null +++ b/e2e/tests/m4-mitre.spec.ts @@ -0,0 +1,162 @@ +import { expect, test, type APIRequestContext, type Page } from '@playwright/test'; + +/** + * M4 — MITRE ATT&CK Enterprise reference catalogue + tag picker. + * + * The seed itself (download + parse) is exercised by pytest with a small + * fixture bundle. This spec hits the live stack with the real, pinned bundle + * by calling `POST /mitre/sync` once and then validating the read endpoints + * + the picker UI. + */ + +const ADMIN_EMAIL = `admin-${Math.floor(Math.random() * 1e6)}@metamorph.local`; +const ADMIN_PASSWORD = 'AdminPass1234!'; + +async function resetAndMintToken(request: APIRequestContext): Promise { + const r = await request.post('/api/v1/diag/reset'); + expect(r.status()).toBe(200); + return (await r.json()).install_token as string; +} + +async function loginAndGetAccess( + request: APIRequestContext, + email: string, + password: string, +): Promise { + const r = await request.post('/api/v1/auth/login', { data: { email, password } }); + expect(r.status()).toBe(200); + return (await r.json()).access_token as string; +} + +async function loginViaSpa(page: Page, email: string, password: string) { + await page.goto('/login'); + await page.getByLabel(/email/i).fill(email); + await page.getByLabel(/password/i).fill(password); + await page.getByRole('button', { name: /sign in/i }).click(); + await expect(page.getByTestId('me-email')).toHaveText(email); +} + +test.describe.configure({ mode: 'serial' }); + +test.describe('M4 — MITRE ATT&CK reference', () => { + test.beforeAll(async ({ request }) => { + const installToken = await resetAndMintToken(request); + const setup = await request.post('/api/v1/setup', { + data: { install_token: installToken, email: ADMIN_EMAIL, password: ADMIN_PASSWORD }, + }); + expect(setup.status()).toBe(201); + + // Trigger a real sync against the pinned MITRE URL. Idempotent — if the + // mitre_* tables were left populated by a previous run, this is a no-op + // upsert. + const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD); + const sync = await request.post('/api/v1/mitre/sync', { + headers: { Authorization: `Bearer ${access}` }, + }); + expect(sync.status(), `mitre sync failed: ${await sync.text()}`).toBe(200); + const result = await sync.json(); + expect(result.tactics_upserted).toBeGreaterThanOrEqual(14); + expect(result.techniques_upserted).toBeGreaterThanOrEqual(180); + expect(result.subtechniques_upserted).toBeGreaterThanOrEqual(400); + }); + + test('GET /mitre/tactics returns 14+ Enterprise tactics', async ({ request }) => { + const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD); + const r = await request.get('/api/v1/mitre/tactics', { + headers: { Authorization: `Bearer ${access}` }, + }); + expect(r.status()).toBe(200); + const body = await r.json(); + expect(body.total).toBeGreaterThanOrEqual(14); + const ids = body.items.map((t: { external_id: string }) => t.external_id); + expect(ids).toContain('TA0001'); // Initial Access + expect(ids).toContain('TA0006'); // Credential Access + }); + + test('GET /mitre/techniques?tactic=TA0006 filters', async ({ request }) => { + const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD); + const r = await request.get('/api/v1/mitre/techniques?tactic=TA0006&limit=200', { + headers: { Authorization: `Bearer ${access}` }, + }); + expect(r.status()).toBe(200); + const body = await r.json(); + expect(body.total).toBeGreaterThan(0); + // OS Credential Dumping is the textbook TA0006 example. + const ids = body.items.map((t: { external_id: string }) => t.external_id); + expect(ids).toContain('T1003'); + }); + + test('GET /mitre/subtechniques?technique=T1003 lists 8 sub-techniques', async ({ request }) => { + const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD); + const r = await request.get('/api/v1/mitre/subtechniques?technique=T1003', { + headers: { Authorization: `Bearer ${access}` }, + }); + expect(r.status()).toBe(200); + const ids = (await r.json()).items.map((t: { external_id: string }) => t.external_id); + expect(ids).toContain('T1003.001'); // LSASS Memory + expect(ids.length).toBeGreaterThanOrEqual(5); + }); + + test('GET /mitre/status returns version + last_sync', async ({ request }) => { + const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD); + const r = await request.get('/api/v1/mitre/status', { + headers: { Authorization: `Bearer ${access}` }, + }); + expect(r.status()).toBe(200); + const body = await r.json(); + expect(body.last_sync).toBeTruthy(); + expect(body.default_url).toContain('mitre-attack'); + expect(body.default_version).toBeTruthy(); + }); + + test('SPA MITRE page renders + picker walks tactic → technique → subtechnique', async ({ page }) => { + await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD); + await page.goto('/mitre'); + + // Status card shows a non-null last_sync. + await expect(page.getByTestId('mitre-last-sync')).not.toHaveText('never'); + + const picker = page.getByTestId('mitre-tag-picker'); + await expect(picker).toBeVisible(); + + // 1. Click on TA0006 (Credential Access) + await picker.getByTestId('mitre-tactic-TA0006').click(); + // 2. Techniques column populates; click T1003 + await expect(picker.getByTestId('mitre-technique-T1003')).toBeVisible(); + await picker.getByTestId('mitre-technique-T1003').click(); + // 3. Sub-techniques column populates with T1003.001 onward + await expect(picker.getByTestId('mitre-subtechnique-T1003.001')).toBeVisible(); + // 4. Select the sub-technique → chip appears in the selection bar + await picker.getByTestId('mitre-subtechnique-T1003.001').click(); + 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"'); + }); + + test('Non-admin cannot trigger /mitre/sync', async ({ page, request }) => { + // Invite a no-perm user via the admin. + const adminAccess = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD); + const eveEmail = `eve-${Math.floor(Math.random() * 1e6)}@metamorph.local`; + const inv = await request.post('/api/v1/invitations', { + headers: { Authorization: `Bearer ${adminAccess}` }, + data: { email_hint: eveEmail }, + }); + const token = (await inv.json()).token; + await request.post(`/api/v1/invitations/accept/${token}`, { + data: { email: eveEmail, password: 'EvePass1234!' }, + }); + + const eveAccess = await loginAndGetAccess(request, eveEmail, 'EvePass1234!'); + const r = await request.post('/api/v1/mitre/sync', { + headers: { Authorization: `Bearer ${eveAccess}` }, + }); + expect(r.status()).toBe(403); + + // The MITRE page is reachable in read-only mode for any logged-in user, + // but the Sync card is hidden for non-admins. + await loginViaSpa(page, eveEmail, 'EvePass1234!'); + await page.goto('/mitre'); + await expect(page.getByTestId('mitre-tag-picker')).toBeVisible(); + await expect(page.getByTestId('mitre-sync')).toHaveCount(0); + }); +}); diff --git a/tasks/testing-m4.md b/tasks/testing-m4.md new file mode 100644 index 0000000..b22577d --- /dev/null +++ b/tasks/testing-m4.md @@ -0,0 +1,111 @@ +--- +type: testing +milestone: M4 +date: "2026-05-12" +project: Metamorph +--- + +# Testing M4 — MITRE ATT&CK Enterprise + +## 1. Lancement de la stack + +```bash +make clean # reset si une stack tournait +make up # build + start db/api/front +make migrate +make seed-mitre # télécharge le bundle pinné v19.0 (~50 MB, ~1 s parse) +``` + +> **Permissions volume** : `metamorph_mitre` est créé chowné `metamorph:metamorph` par le Dockerfile à la 1ʳᵉ initialisation. Si tu as un volume préexistant (d'une expé antérieure) appartenant à root, le seed échouera avec `PermissionError`. Solution : `podman volume rm metamorph_metamorph_mitre` avant `make up`. + +## 2. Tests automatisés + +```bash +make test-api # 51 tests pytest dont 12 nouveaux MITRE (parser + endpoints) +make e2e # 34 tests Playwright dont 6 M4 +``` + +Le rapport HTML est dans `e2e/playwright-report/`, le JUnit dans `e2e/playwright-report/junit.xml`. + +## 3. Procédure manuelle (smoke navigateur) + +### Pré-requis +- Stack up, migrations appliquées, `make seed-mitre` exécuté. +- Le bundle est cache dans le volume `metamorph_mitre` (`/data/mitre/enterprise-attack-19.0.json`). Pour ré-utiliser un fichier local : `flask --app app.cli metamorph seed-mitre --source /chemin/vers/enterprise-attack.json`. + +### 3.1 Page MITRE (`/mitre`) +1. Se connecter en admin. +2. Cliquer **MITRE** dans la nav → page chargée. +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). +5. Picker : + - Cliquer **TA0006 — Credential Access** dans la colonne gauche. + - La colonne du milieu liste **T1003 OS Credential Dumping** et 16 autres techniques. + - Cliquer **T1003** → la colonne droite liste **T1003.001** à **T1003.008**. + - Cocher la case en face de **T1003.001 PowerShell**. + - Un chip cyan/orange/purple apparaît en haut + la carte « Selected (preview payload) » montre le JSON. + - Cliquer le chip → désélection. + +### 3.2 Filtres / recherche +1. Dans la colonne **Tactic search**, taper `cred` → seule TA0006 reste. +2. Sélectionner TA0006, dans **Technique search** taper `dump` → T1003 apparaît. +3. Vider les recherches, sélectionner T1059 → vérifier T1059.001 à T1059.012 dans les sub-techniques. + +### 3.3 Non-admin +1. Inviter un user sans perms via Admin > Invitations. +2. Se connecter en tant que ce user. +3. Naviguer sur `/mitre` → page accessible, picker fonctionnel (read-only). +4. La carte **Sync** n'apparaît PAS (UI gate `is_admin`). +5. Tenter `POST /api/v1/mitre/sync` via curl avec son token → **403** `insufficient permissions`. + +### 3.4 Re-sync admin +```bash +ACCESS=$(curl -sX POST http://localhost:8080/api/v1/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"email":"admin@metamorph.local","password":"AdminPass1234!"}' | jq -r .access_token) +curl -sX POST http://localhost:8080/api/v1/mitre/sync \ + -H "Authorization: Bearer $ACCESS" | jq +``` +Sortie attendue : +```json +{ + "tactics_upserted": 15, + "techniques_upserted": 222, + "subtechniques_upserted": 475, + "subtechniques_skipped_orphan": 0, + "technique_tactic_links": 254, + "version": "19.0", + "duration_ms": ~1000 +} +``` + +### 3.5 Sync via URL custom +```bash +curl -sX POST http://localhost:8080/api/v1/mitre/sync \ + -H "Authorization: Bearer $ACCESS" \ + -H 'Content-Type: application/json' \ + -d '{"source":"https://raw.githubusercontent.com/mitre-attack/attack-stix-data/master/enterprise-attack/enterprise-attack-18.1.json"}' | jq +``` +- Avec une URL ≠ pinned : sha256 désactivé, `version` est `null` (on ne connaît pas la version d'un fichier custom). + +### 3.6 Mode air-gap +1. Préparer un STIX 2.1 valide localement : `enterprise-attack-19.0.json`. +2. Le copier dans le volume : + ```bash + podman cp enterprise-attack-19.0.json metamorph-api:/data/mitre/ + ``` +3. Lancer le seed pointé sur le path : `podman compose exec api flask --app app.cli metamorph seed-mitre --source /data/mitre/enterprise-attack-19.0.json --skip-checksum` + +## 4. Points de contrôle critiques + +- [x] `make seed-mitre` initial pinné v19.0 → 15/222/475 sans orphans. +- [x] Re-lancer le seed est idempotent (mêmes counts). +- [x] `/mitre/tactics` retourne 15 tactics (la spec mentionne 14 — MITRE en a 15 depuis v8). +- [x] `/mitre/techniques?tactic=TA0006` retourne ≥ 17 techniques incl. T1003. +- [x] `/mitre/subtechniques?technique=T1003` retourne 8 sub-techniques. +- [x] `/mitre/status` expose `last_sync`, `version`, `source_url`, `default_url`, `default_version`. +- [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] Bundle local (`--source `) bypasse la vérif checksum. +- [x] Picker SPA : tactic → technique → subtechnique, multi-select, déselection via chip cliquable. +- [x] Non-admin : voit la page `/mitre` mais pas la carte Sync ; `POST /mitre/sync` → 403.