From c210a56fc8b29d8b1587260aa688d3e38346df13 Mon Sep 17 00:00:00 2001 From: anti Date: Sat, 9 May 2026 07:37:41 -0400 Subject: [PATCH] =?UTF-8?q?feat(ttp/stix):=20fleet-wide=20STIX=202.1=20exp?= =?UTF-8?q?ort=20=E2=80=94=20GET=20/api/v1/attackers/export/stix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- decnet/ttp/stix_export.py | 44 ++++++ decnet/web/db/repository.py | 4 + decnet/web/db/sqlmodel_repo/ttp.py | 35 +++++ decnet/web/router/__init__.py | 2 + .../attackers/api_export_attackers_stix.py | 65 ++++++++ tests/db/test_base_repo.py | 4 + tests/web/test_api_export_attackers_stix.py | 145 ++++++++++++++++++ 7 files changed, 299 insertions(+) create mode 100644 decnet/web/router/attackers/api_export_attackers_stix.py create mode 100644 tests/web/test_api_export_attackers_stix.py diff --git a/decnet/ttp/stix_export.py b/decnet/ttp/stix_export.py index 07d52bf3..b6749f17 100644 --- a/decnet/ttp/stix_export.py +++ b/decnet/ttp/stix_export.py @@ -300,3 +300,47 @@ def build_attacker_bundle( objs.append(note) return stix2.Bundle(objects=objs, allow_custom=True) + + +def build_fleet_bundle( + rows: list[dict[str, Any]], + ttp_by_attacker: dict[str, list[dict[str, Any]]], +) -> stix2.Bundle: + """Assemble a STIX 2.1 Bundle covering all attackers in *rows*. + + Deduplicates by STIX ID — attack-pattern SDOs with the same canonical + MITRE UUID appear once regardless of how many attackers used the technique. + Per-tag Sightings, Artifacts, and SMTP targets are omitted in fleet mode + (too verbose; use the per-attacker endpoint for full fidelity). + """ + objs_by_id: dict[str, Any] = {} + + for row in rows: + raw_cmds = row.get("commands") or [] + if isinstance(raw_cmds, str): + try: + raw_cmds = json.loads(raw_cmds) + except Exception: + raw_cmds = [] + cmds = [ + str(e.get("command_text", "")).strip() + for e in raw_cmds + if isinstance(e, dict) and e.get("command_text") + ] + + intel = row.get("threat_intel") + bundle = build_attacker_bundle( + attacker=row, + behavior=None, + identity=None, + intel=intel, + technique_rollup=ttp_by_attacker.get(row["uuid"], []), + raw_tags=[], + artifacts=[], + smtp_targets=[], + commands=cmds, + ) + for obj in bundle.objects: + objs_by_id[obj.id] = obj + + return stix2.Bundle(objects=list(objs_by_id.values()), allow_custom=True) diff --git a/decnet/web/db/repository.py b/decnet/web/db/repository.py index 7f892c9c..addd2b75 100644 --- a/decnet/web/db/repository.py +++ b/decnet/web/db/repository.py @@ -1496,6 +1496,10 @@ class BaseRepository(ABC): """Deduplicated ``command_text`` strings for one attacker, order-preserved.""" raise NotImplementedError + async def get_all_ttp_rollups_for_export(self) -> dict[str, list[dict[str, Any]]]: + """Return ``{attacker_uuid: [rollup_dict, ...]}`` for all attackers.""" + raise NotImplementedError + async def list_ttp_decky_phases( self, identity_uuid: str, ) -> list[dict[str, Any]]: diff --git a/decnet/web/db/sqlmodel_repo/ttp.py b/decnet/web/db/sqlmodel_repo/ttp.py index 9282ccac..20255ed5 100644 --- a/decnet/web/db/sqlmodel_repo/ttp.py +++ b/decnet/web/db/sqlmodel_repo/ttp.py @@ -361,6 +361,41 @@ class TTPMixin(_MixinBase): res = await session.execute(stmt) return [r.model_dump(mode="json") for r in res.scalars().all()] + async def get_all_ttp_rollups_for_export(self) -> dict[str, list[dict[str, Any]]]: + """Return ``{attacker_uuid: [rollup_dict, ...]}`` for all attackers. + + Single query; used by the fleet STIX export so it doesn't fan out + N × list_techniques_by_attacker calls. + """ + async with self._session() as session: + stmt: Any = ( + select( + col(TTPTag.attacker_uuid), + col(TTPTag.technique_id), + col(TTPTag.sub_technique_id), + func.max(col(TTPTag.tactic)).label("tactic"), + func.count().label("count"), + func.max(col(TTPTag.confidence)).label("confidence_max"), + ) + .where(col(TTPTag.attacker_uuid).is_not(None)) + .group_by( + TTPTag.attacker_uuid, + TTPTag.technique_id, + TTPTag.sub_technique_id, + ) + ) + res = await session.execute(stmt) + out: dict[str, list[dict[str, Any]]] = {} + for r in res.all(): + out.setdefault(r.attacker_uuid, []).append({ + "technique_id": r.technique_id, + "sub_technique_id": r.sub_technique_id, + "tactic": r.tactic, + "count": r.count, + "confidence_max": r.confidence_max, + }) + return out + # ── Backfill iterators (E.4) ──────────────────────────────────── # # Read-only iterators consumed by ``decnet ttp backfill`` to replay diff --git a/decnet/web/router/__init__.py b/decnet/web/router/__init__.py index 22874a23..221f3002 100644 --- a/decnet/web/router/__init__.py +++ b/decnet/web/router/__init__.py @@ -16,6 +16,7 @@ from .stream.api_stream_events import router as stream_router from .attackers.api_get_attackers import router as attackers_router from .attackers.api_export_attackers import router as attackers_export_router from .attackers.api_export_attacker_stix import router as attacker_export_stix_router +from .attackers.api_export_attackers_stix import router as attackers_export_stix_router from .attackers.api_events import router as attacker_events_router from .attackers.api_get_attacker_detail import router as attacker_detail_router from .attackers.api_get_attacker_commands import router as attacker_commands_router @@ -106,6 +107,7 @@ api_router.include_router(deploy_deckies_router) # Attacker Profiles api_router.include_router(attackers_router) api_router.include_router(attackers_export_router) +api_router.include_router(attackers_export_stix_router) api_router.include_router(attacker_export_stix_router) api_router.include_router(attacker_detail_router) api_router.include_router(attacker_events_router) diff --git a/decnet/web/router/attackers/api_export_attackers_stix.py b/decnet/web/router/attackers/api_export_attackers_stix.py new file mode 100644 index 00000000..2660b3bb --- /dev/null +++ b/decnet/web/router/attackers/api_export_attackers_stix.py @@ -0,0 +1,65 @@ +"""GET /api/v1/attackers/export/stix — fleet-wide STIX 2.1 bundle. + +Returns a self-contained STIX 2.1 Bundle covering every attacker the +instance has observed. Attack-pattern SDOs carry canonical MITRE STIX IDs +and are deduplicated across attackers — consumers who already have the +public ATT&CK bundle won't accumulate duplicates. + +Per-tag Sightings, captured Artifacts, and SMTP targets are omitted in +fleet mode. Use GET /api/v1/attackers/{uuid}/export/stix for full fidelity +on a single attacker. +""" +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +from fastapi import APIRouter, Depends +from fastapi.responses import Response + +from decnet.telemetry import traced as _traced +from decnet.ttp.stix_export import build_fleet_bundle +from decnet.web.dependencies import require_viewer, repo + +router = APIRouter() + + +@router.get( + "/attackers/export/stix", + tags=["Attacker Profiles"], + response_class=Response, + responses={ + 200: { + "content": {"application/json": {}}, + "description": "STIX 2.1 bundle for all attackers", + }, + 401: {"description": "Could not validate credentials"}, + 403: {"description": "Insufficient permissions"}, + }, +) +@_traced("api.attackers.export_stix_fleet") +async def api_export_attackers_stix( + user: dict[str, Any] = Depends(require_viewer), +) -> Response: + """Download a STIX 2.1 bundle covering every observed attacker.""" + rows, ttp_by_attacker = await _gather_fleet_data() + bundle = build_fleet_bundle(rows=rows, ttp_by_attacker=ttp_by_attacker) + ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + return Response( + content=bundle.serialize(pretty=True, indent=2), + media_type="application/json", + headers={ + "Content-Disposition": ( + f'attachment; filename="decnet-fleet-{ts}.stix.json"' + ), + }, + ) + + +async def _gather_fleet_data() -> tuple[list[dict[str, Any]], dict[str, list[dict[str, Any]]]]: + import asyncio + rows, ttp_by_attacker = await asyncio.gather( + repo.get_all_attackers_for_export(), + repo.get_all_ttp_rollups_for_export(), + ) + return rows, ttp_by_attacker diff --git a/tests/db/test_base_repo.py b/tests/db/test_base_repo.py index 57f0fab1..68bc8133 100644 --- a/tests/db/test_base_repo.py +++ b/tests/db/test_base_repo.py @@ -131,6 +131,8 @@ class DummyRepo(BaseRepository): return [] async def list_attacker_commands_deduped(self, uuid): return [] + async def get_all_ttp_rollups_for_export(self): + return {} # Iter helpers — async generators, can't `await super()` on them # because the base raises in the body before any yield. Just yield # nothing so the consumer's ``async for`` exits cleanly. @@ -274,6 +276,8 @@ async def test_base_repo_coverage(): await BaseRepository.list_ttp_tags_by_attacker(dr, "a") with pytest.raises(NotImplementedError): await BaseRepository.list_attacker_commands_deduped(dr, "a") + with pytest.raises(NotImplementedError): + await BaseRepository.get_all_ttp_rollups_for_export(dr) # Iter helpers: just consume the empty generator. now = datetime.now(timezone.utc) async for _ in dr.iter_attacker_commands_since(now): diff --git a/tests/web/test_api_export_attackers_stix.py b/tests/web/test_api_export_attackers_stix.py new file mode 100644 index 00000000..d3befee9 --- /dev/null +++ b/tests/web/test_api_export_attackers_stix.py @@ -0,0 +1,145 @@ +"""Tests for GET /api/v1/attackers/export/stix — fleet-wide STIX bundle.""" +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import AsyncMock, patch + +import pytest +import stix2 + +from decnet.ttp import attack_stix +from decnet.web.router.attackers.api_export_attackers_stix import ( + api_export_attackers_stix, +) + +_REPO_BUNDLE = Path(__file__).resolve().parents[2] / "enterprise-attack-19.0.json" +_FAKE_USER: dict = {"uuid": "test-user", "role": "viewer"} + + +@pytest.fixture(autouse=True) +def _pin_bundle(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + license_path = tmp_path / "LICENSE.txt" + license_path.write_text("placeholder", encoding="utf-8") + monkeypatch.setenv("DECNET_ATTACK_BUNDLE", str(_REPO_BUNDLE)) + monkeypatch.setenv("DECNET_ATTACK_LICENSE", str(license_path)) + attack_stix._data = None + attack_stix._loaded_path = None + attack_stix._attack_pattern_by_id.cache_clear() + attack_stix._tactic_by_id.cache_clear() + attack_stix._tactic_by_short_name.cache_clear() + attack_stix.groups_using_technique.cache_clear() + + +def _attacker(uuid: str = "att-aaaa", ip: str = "1.2.3.4") -> dict: + return { + "uuid": uuid, + "ip": ip, + "first_seen": datetime(2026, 1, 1, tzinfo=timezone.utc), + "last_seen": datetime(2026, 1, 31, tzinfo=timezone.utc), + "event_count": 10, + "identity_id": None, + "country_code": "US", + "asn": 15169, + "as_name": "GOOGLE", + "commands": [], + "threat_intel": None, + } + + +def _mock_repo(*, rows=None, ttp_by_attacker=None): + m = type("M", (), {})() + m.get_all_attackers_for_export = AsyncMock(return_value=rows or []) + m.get_all_ttp_rollups_for_export = AsyncMock(return_value=ttp_by_attacker or {}) + return m + + +@pytest.mark.asyncio +async def test_empty_fleet_returns_bundle(): + """Zero attackers → valid bundle with just the DECNET org identity.""" + m = _mock_repo() + with patch("decnet.web.router.attackers.api_export_attackers_stix.repo", m): + resp = await api_export_attackers_stix(user=_FAKE_USER) + bundle = json.loads(resp.body) + assert bundle["type"] == "bundle" + + +@pytest.mark.asyncio +async def test_single_attacker_baseline_objects(): + """One attacker with no TTPs → 4 objects (org, ip, observed-data, threat-actor).""" + m = _mock_repo(rows=[_attacker()]) + with patch("decnet.web.router.attackers.api_export_attackers_stix.repo", m): + resp = await api_export_attackers_stix(user=_FAKE_USER) + objs = json.loads(resp.body)["objects"] + types = [o["type"] for o in objs] + assert types.count("identity") == 1 + assert types.count("ipv4-addr") == 1 + assert types.count("observed-data") == 1 + assert types.count("threat-actor") == 1 + assert len(objs) == 4 + + +@pytest.mark.asyncio +async def test_attack_patterns_deduplicated_across_attackers(): + """Two attackers sharing T1059 → only one attack-pattern SDO in bundle.""" + rows = [_attacker("att-1111", "1.1.1.1"), _attacker("att-2222", "2.2.2.2")] + rollup = {"technique_id": "T1059", "sub_technique_id": None, "tactic": "TA0002", + "count": 1, "confidence_max": 0.9} + ttp_by_attacker = {"att-1111": [rollup], "att-2222": [rollup]} + m = _mock_repo(rows=rows, ttp_by_attacker=ttp_by_attacker) + with patch("decnet.web.router.attackers.api_export_attackers_stix.repo", m): + resp = await api_export_attackers_stix(user=_FAKE_USER) + objs = json.loads(resp.body)["objects"] + assert [o for o in objs if o["type"] == "attack-pattern"].__len__() == 1 + + +@pytest.mark.asyncio +async def test_two_attackers_two_threat_actors(): + """Two distinct attacker rows → two distinct threat-actor SDOs.""" + rows = [_attacker("att-1111", "1.1.1.1"), _attacker("att-2222", "2.2.2.2")] + m = _mock_repo(rows=rows) + with patch("decnet.web.router.attackers.api_export_attackers_stix.repo", m): + resp = await api_export_attackers_stix(user=_FAKE_USER) + objs = json.loads(resp.body)["objects"] + assert len([o for o in objs if o["type"] == "threat-actor"]) == 2 + + +@pytest.mark.asyncio +async def test_commands_included_in_fleet(): + """Commands from the inline commands field are emitted as process SCOs.""" + row = _attacker() + row["commands"] = [ + {"command_text": "whoami", "ts": "2026-01-10T00:00:00"}, + {"command_text": "id", "ts": "2026-01-10T00:01:00"}, + ] + m = _mock_repo(rows=[row]) + with patch("decnet.web.router.attackers.api_export_attackers_stix.repo", m): + resp = await api_export_attackers_stix(user=_FAKE_USER) + objs = json.loads(resp.body)["objects"] + processes = [o for o in objs if o["type"] == "process"] + assert len(processes) == 2 + assert {p["command_line"] for p in processes} == {"whoami", "id"} + + +@pytest.mark.asyncio +async def test_stix2_round_trip_validation(): + """Fleet bundle must parse cleanly with stix2.""" + rows = [_attacker("att-1111", "1.1.1.1"), _attacker("att-2222", "2.2.2.2")] + rollup = {"technique_id": "T1059", "sub_technique_id": None, "tactic": "TA0002", + "count": 2, "confidence_max": 0.85} + m = _mock_repo(rows=rows, ttp_by_attacker={"att-1111": [rollup]}) + with patch("decnet.web.router.attackers.api_export_attackers_stix.repo", m): + resp = await api_export_attackers_stix(user=_FAKE_USER) + parsed = stix2.parse(resp.body, allow_custom=True) + assert parsed.type == "bundle" + + +@pytest.mark.asyncio +async def test_response_headers(): + m = _mock_repo() + with patch("decnet.web.router.attackers.api_export_attackers_stix.repo", m): + resp = await api_export_attackers_stix(user=_FAKE_USER) + assert resp.media_type == "application/json" + assert "decnet-fleet-" in resp.headers["content-disposition"] + assert ".stix.json" in resp.headers["content-disposition"]