diff --git a/decnet/ttp/stix_export.py b/decnet/ttp/stix_export.py index 36a71e01..07d52bf3 100644 --- a/decnet/ttp/stix_export.py +++ b/decnet/ttp/stix_export.py @@ -156,6 +156,7 @@ def build_attacker_bundle( raw_tags: list[dict[str, Any]], artifacts: list[dict[str, Any]], smtp_targets: list[dict[str, Any]], + commands: list[str] | None = None, ) -> stix2.Bundle: """Assemble a STIX 2.1 Bundle for *attacker*. @@ -275,6 +276,24 @@ def build_attacker_bundle( ) ) + # ── Shell commands (process SCOs + observed-data) ──────────────── + seen_cmds: set[str] = set() + for cmd_line in commands or []: + if not cmd_line or cmd_line in seen_cmds: + continue + seen_cmds.add(cmd_line) + proc = stix2.Process(command_line=cmd_line, is_hidden=False) + objs.append(proc) + objs.append( + stix2.ObservedData( + first_observed=fs or now, + last_observed=ls or now, + number_observed=1, + object_refs=[proc.id], + created_by_ref=org.id, + ) + ) + # ── Intel note ─────────────────────────────────────────────────── if intel: note = _intel_note(intel, ta.id, org.id) diff --git a/decnet/web/db/repository.py b/decnet/web/db/repository.py index 6b7536e1..7f892c9c 100644 --- a/decnet/web/db/repository.py +++ b/decnet/web/db/repository.py @@ -1492,6 +1492,10 @@ class BaseRepository(ABC): """Raw ``ttp_tag`` rows for one attacker (for STIX export + similar).""" raise NotImplementedError + async def list_attacker_commands_deduped(self, uuid: str) -> list[str]: + """Deduplicated ``command_text`` strings for one attacker, order-preserved.""" + raise NotImplementedError + async def list_ttp_decky_phases( self, identity_uuid: str, ) -> list[dict[str, Any]]: diff --git a/decnet/web/db/sqlmodel_repo/attackers/activity.py b/decnet/web/db/sqlmodel_repo/attackers/activity.py index 328458ac..a59d28da 100644 --- a/decnet/web/db/sqlmodel_repo/attackers/activity.py +++ b/decnet/web/db/sqlmodel_repo/attackers/activity.py @@ -41,6 +41,24 @@ class AttackerActivityMixin(_MixinBase): page = commands[offset: offset + limit] return {"total": total, "data": page} + async def list_attacker_commands_deduped(self, uuid: str) -> list[str]: + async with self._session() as session: + result = await session.execute( + select(col(Attacker.commands)).where(Attacker.uuid == uuid) + ) + raw = result.scalar_one_or_none() + if raw is None: + return [] + commands: list = json.loads(raw) if isinstance(raw, str) else raw + seen: set[str] = set() + out: list[str] = [] + for entry in commands: + text = str(entry.get("command_text", "")).strip() + if text and text not in seen: + seen.add(text) + out.append(text) + return out + async def get_attacker_service_activity( self, attacker_uuid: str ) -> list[tuple[str, str]]: diff --git a/decnet/web/router/attackers/api_export_attacker_stix.py b/decnet/web/router/attackers/api_export_attacker_stix.py index ee6021ce..c4792633 100644 --- a/decnet/web/router/attackers/api_export_attacker_stix.py +++ b/decnet/web/router/attackers/api_export_attacker_stix.py @@ -68,6 +68,7 @@ async def api_export_attacker_stix( repo.list_ttp_tags_by_attacker(uuid), repo.get_attacker_artifacts(uuid), repo.list_smtp_targets(uuid), + repo.list_attacker_commands_deduped(uuid), ) behavior = cast(dict[str, Any] | None, results[0]) identity = cast(dict[str, Any] | None, results[1]) @@ -76,6 +77,7 @@ async def api_export_attacker_stix( raw_tags = cast(list[dict[str, Any]], results[4]) artifacts = cast(list[dict[str, Any]], results[5]) smtp_targets = cast(list[dict[str, Any]], results[6]) + commands = cast(list[str], results[7]) bundle = build_attacker_bundle( attacker=attacker, @@ -89,6 +91,7 @@ async def api_export_attacker_stix( raw_tags=raw_tags, artifacts=artifacts, smtp_targets=smtp_targets, + commands=commands, ) return Response( content=bundle.serialize(pretty=True, indent=2), diff --git a/tests/db/test_base_repo.py b/tests/db/test_base_repo.py index 27d3bd35..57f0fab1 100644 --- a/tests/db/test_base_repo.py +++ b/tests/db/test_base_repo.py @@ -128,7 +128,9 @@ class DummyRepo(BaseRepository): async def list_distinct_techniques(self): await super().list_distinct_techniques(); return [] async def list_ttp_tags_by_attacker(self, uuid, limit=2000): - await super().list_ttp_tags_by_attacker(uuid, limit); return [] + return [] + async def list_attacker_commands_deduped(self, uuid): + 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. @@ -267,6 +269,11 @@ async def test_base_repo_coverage(): ) with pytest.raises(NotImplementedError): await dr.list_distinct_techniques() + with pytest.raises(NotImplementedError): + from decnet.web.db.repository import BaseRepository + await BaseRepository.list_ttp_tags_by_attacker(dr, "a") + with pytest.raises(NotImplementedError): + await BaseRepository.list_attacker_commands_deduped(dr, "a") # 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_attacker_stix.py b/tests/web/test_api_export_attacker_stix.py index ed52a5fc..4e79c97a 100644 --- a/tests/web/test_api_export_attacker_stix.py +++ b/tests/web/test_api_export_attacker_stix.py @@ -104,7 +104,7 @@ def _intel() -> dict: def _mock_repo(*, attacker=None, identity=None, intel=None, - rollup=None, tags=None, artifacts=None, smtp=None): + rollup=None, tags=None, artifacts=None, smtp=None, commands=None): m = type("M", (), {})() m.get_attacker_by_uuid = AsyncMock(return_value=attacker or _attacker()) m.get_attacker_behavior = AsyncMock(return_value={}) @@ -127,6 +127,7 @@ def _mock_repo(*, attacker=None, identity=None, intel=None, m.list_ttp_tags_by_attacker = AsyncMock(return_value=tags or []) m.get_attacker_artifacts = AsyncMock(return_value=artifacts or []) m.list_smtp_targets = AsyncMock(return_value=smtp or []) + m.list_attacker_commands_deduped = AsyncMock(return_value=commands or []) return m @@ -232,6 +233,20 @@ async def test_sighting_count_equals_tag_count(): assert all(s["count"] == 1 for s in sightings) +@pytest.mark.asyncio +async def test_commands_emit_process_scos(): + """Deduped commands produce one process SCO + observed-data pair each.""" + cmds = ["whoami", "cat /etc/passwd", "whoami"] # duplicate → 2 unique + m = _mock_repo(commands=cmds) + with patch("decnet.web.router.attackers.api_export_attacker_stix.repo", m): + resp = await api_export_attacker_stix("att-aaaabbbbccccdddd", user=_FAKE_USER) + objs = json.loads(resp.body)["objects"] + processes = [o for o in objs if o["type"] == "process"] + assert len(processes) == 2 + cmd_lines = {p["command_line"] for p in processes} + assert cmd_lines == {"whoami", "cat /etc/passwd"} + + @pytest.mark.asyncio async def test_response_headers(): m = _mock_repo()