feat(ttp/stix): add deduped process SCOs for attacker commands
This commit is contained in:
@@ -156,6 +156,7 @@ def build_attacker_bundle(
|
|||||||
raw_tags: list[dict[str, Any]],
|
raw_tags: list[dict[str, Any]],
|
||||||
artifacts: list[dict[str, Any]],
|
artifacts: list[dict[str, Any]],
|
||||||
smtp_targets: list[dict[str, Any]],
|
smtp_targets: list[dict[str, Any]],
|
||||||
|
commands: list[str] | None = None,
|
||||||
) -> stix2.Bundle:
|
) -> stix2.Bundle:
|
||||||
"""Assemble a STIX 2.1 Bundle for *attacker*.
|
"""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 ───────────────────────────────────────────────────
|
# ── Intel note ───────────────────────────────────────────────────
|
||||||
if intel:
|
if intel:
|
||||||
note = _intel_note(intel, ta.id, org.id)
|
note = _intel_note(intel, ta.id, org.id)
|
||||||
|
|||||||
@@ -1492,6 +1492,10 @@ class BaseRepository(ABC):
|
|||||||
"""Raw ``ttp_tag`` rows for one attacker (for STIX export + similar)."""
|
"""Raw ``ttp_tag`` rows for one attacker (for STIX export + similar)."""
|
||||||
raise NotImplementedError
|
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(
|
async def list_ttp_decky_phases(
|
||||||
self, identity_uuid: str,
|
self, identity_uuid: str,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
|
|||||||
@@ -41,6 +41,24 @@ class AttackerActivityMixin(_MixinBase):
|
|||||||
page = commands[offset: offset + limit]
|
page = commands[offset: offset + limit]
|
||||||
return {"total": total, "data": page}
|
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(
|
async def get_attacker_service_activity(
|
||||||
self, attacker_uuid: str
|
self, attacker_uuid: str
|
||||||
) -> list[tuple[str, str]]:
|
) -> list[tuple[str, str]]:
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ async def api_export_attacker_stix(
|
|||||||
repo.list_ttp_tags_by_attacker(uuid),
|
repo.list_ttp_tags_by_attacker(uuid),
|
||||||
repo.get_attacker_artifacts(uuid),
|
repo.get_attacker_artifacts(uuid),
|
||||||
repo.list_smtp_targets(uuid),
|
repo.list_smtp_targets(uuid),
|
||||||
|
repo.list_attacker_commands_deduped(uuid),
|
||||||
)
|
)
|
||||||
behavior = cast(dict[str, Any] | None, results[0])
|
behavior = cast(dict[str, Any] | None, results[0])
|
||||||
identity = cast(dict[str, Any] | None, results[1])
|
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])
|
raw_tags = cast(list[dict[str, Any]], results[4])
|
||||||
artifacts = cast(list[dict[str, Any]], results[5])
|
artifacts = cast(list[dict[str, Any]], results[5])
|
||||||
smtp_targets = cast(list[dict[str, Any]], results[6])
|
smtp_targets = cast(list[dict[str, Any]], results[6])
|
||||||
|
commands = cast(list[str], results[7])
|
||||||
|
|
||||||
bundle = build_attacker_bundle(
|
bundle = build_attacker_bundle(
|
||||||
attacker=attacker,
|
attacker=attacker,
|
||||||
@@ -89,6 +91,7 @@ async def api_export_attacker_stix(
|
|||||||
raw_tags=raw_tags,
|
raw_tags=raw_tags,
|
||||||
artifacts=artifacts,
|
artifacts=artifacts,
|
||||||
smtp_targets=smtp_targets,
|
smtp_targets=smtp_targets,
|
||||||
|
commands=commands,
|
||||||
)
|
)
|
||||||
return Response(
|
return Response(
|
||||||
content=bundle.serialize(pretty=True, indent=2),
|
content=bundle.serialize(pretty=True, indent=2),
|
||||||
|
|||||||
@@ -128,7 +128,9 @@ class DummyRepo(BaseRepository):
|
|||||||
async def list_distinct_techniques(self):
|
async def list_distinct_techniques(self):
|
||||||
await super().list_distinct_techniques(); return []
|
await super().list_distinct_techniques(); return []
|
||||||
async def list_ttp_tags_by_attacker(self, uuid, limit=2000):
|
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
|
# Iter helpers — async generators, can't `await super()` on them
|
||||||
# because the base raises in the body before any yield. Just yield
|
# because the base raises in the body before any yield. Just yield
|
||||||
# nothing so the consumer's ``async for`` exits cleanly.
|
# nothing so the consumer's ``async for`` exits cleanly.
|
||||||
@@ -267,6 +269,11 @@ async def test_base_repo_coverage():
|
|||||||
)
|
)
|
||||||
with pytest.raises(NotImplementedError):
|
with pytest.raises(NotImplementedError):
|
||||||
await dr.list_distinct_techniques()
|
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.
|
# Iter helpers: just consume the empty generator.
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
async for _ in dr.iter_attacker_commands_since(now):
|
async for _ in dr.iter_attacker_commands_since(now):
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ def _intel() -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def _mock_repo(*, attacker=None, identity=None, intel=None,
|
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 = type("M", (), {})()
|
||||||
m.get_attacker_by_uuid = AsyncMock(return_value=attacker or _attacker())
|
m.get_attacker_by_uuid = AsyncMock(return_value=attacker or _attacker())
|
||||||
m.get_attacker_behavior = AsyncMock(return_value={})
|
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.list_ttp_tags_by_attacker = AsyncMock(return_value=tags or [])
|
||||||
m.get_attacker_artifacts = AsyncMock(return_value=artifacts or [])
|
m.get_attacker_artifacts = AsyncMock(return_value=artifacts or [])
|
||||||
m.list_smtp_targets = AsyncMock(return_value=smtp or [])
|
m.list_smtp_targets = AsyncMock(return_value=smtp or [])
|
||||||
|
m.list_attacker_commands_deduped = AsyncMock(return_value=commands or [])
|
||||||
return m
|
return m
|
||||||
|
|
||||||
|
|
||||||
@@ -232,6 +233,20 @@ async def test_sighting_count_equals_tag_count():
|
|||||||
assert all(s["count"] == 1 for s in sightings)
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_response_headers():
|
async def test_response_headers():
|
||||||
m = _mock_repo()
|
m = _mock_repo()
|
||||||
|
|||||||
Reference in New Issue
Block a user