test(m4): pytest parser + endpoints + e2e tag picker
- backend/tests/test_mitre.py: 12 integration tests using a hand-crafted minimal STIX bundle (no network in tests). Covers parser (revoked/deprecated skip, sub-technique parent linkage), seed idempotence, persisted settings, checksum mismatch path, all four read endpoints, perm enforcement on /mitre/sync, ILIKE search. - e2e/tests/m4-mitre.spec.ts: 6 Playwright tests against the live stack. beforeAll calls POST /mitre/sync once (real bundle, ~50 MB, ~1.1 s) then the suite validates tactics ≥14, T1003 has ≥5 sub-techniques, the picker walks tactic→technique→subtechnique with chip multi-select, and non-admin sees /mitre but no Sync card. - tasks/testing-m4.md: manual + automated checklist, air-gapped operator notes, volume-permission caveat for pre-existing root-owned volumes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
360
backend/tests/test_mitre.py
Normal file
360
backend/tests/test_mitre.py
Normal file
@@ -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"]
|
||||||
162
e2e/tests/m4-mitre.spec.ts
Normal file
162
e2e/tests/m4-mitre.spec.ts
Normal file
@@ -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<string> {
|
||||||
|
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<string> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
111
tasks/testing-m4.md
Normal file
111
tasks/testing-m4.md
Normal file
@@ -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 <path>`) 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.
|
||||||
Reference in New Issue
Block a user