148 lines
4.9 KiB
Python
148 lines
4.9 KiB
Python
"""
|
|
Traversal graph data types for the DECNET correlation engine.
|
|
|
|
An AttackerTraversal represents one attacker IP's movement across multiple
|
|
deckies. Hops are ordered chronologically; the traversal path is derived
|
|
by reading the unique decky sequence from the hop list.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
|
|
|
|
@dataclass
|
|
class MutationMarker:
|
|
"""A substrate transition that occurred during an attacker's traversal.
|
|
|
|
Emitted by the mutator (or deploy/teardown) and consumed by the
|
|
correlation engine so ``AttackerTraversal.to_dict()`` can interleave
|
|
substrate-change markers chronologically with attacker hops — an
|
|
interaction with ``decky-03@T5`` followed by a mutation at ``T6`` and
|
|
another interaction at ``T7`` is a substrate transition mid-session,
|
|
not a silent discontinuity.
|
|
"""
|
|
|
|
timestamp: datetime
|
|
decky: str
|
|
old_services: list[str]
|
|
new_services: list[str]
|
|
trigger: str # creation | retirement | scheduled | operator | …
|
|
|
|
|
|
@dataclass
|
|
class TraversalHop:
|
|
"""A single event in an attacker's traversal through the deception network."""
|
|
|
|
timestamp: datetime
|
|
decky: str # decky node name (e.g. "decky-01")
|
|
service: str # service that logged the event (e.g. "ssh", "http")
|
|
event_type: str # MSGID from the log line (e.g. "login_attempt")
|
|
|
|
|
|
@dataclass
|
|
class AttackerTraversal:
|
|
"""
|
|
All activity from a single attacker IP across two or more deckies,
|
|
sorted in chronological order.
|
|
"""
|
|
|
|
attacker_ip: str
|
|
hops: list[TraversalHop] # chronologically sorted
|
|
# Substrate-change markers on deckies this attacker touched, bounded
|
|
# by first_seen/last_seen. Empty for legacy attacker-only ingest;
|
|
# populated once mutation events flow through the engine.
|
|
mutations_during: list[MutationMarker] = field(default_factory=list)
|
|
|
|
@property
|
|
def first_seen(self) -> datetime:
|
|
return self.hops[0].timestamp
|
|
|
|
@property
|
|
def last_seen(self) -> datetime:
|
|
return self.hops[-1].timestamp
|
|
|
|
@property
|
|
def duration_seconds(self) -> float:
|
|
return (self.last_seen - self.first_seen).total_seconds()
|
|
|
|
@property
|
|
def deckies(self) -> list[str]:
|
|
"""Unique deckies touched, preserving first-contact order."""
|
|
seen: list[str] = []
|
|
for hop in self.hops:
|
|
if hop.decky not in seen:
|
|
seen.append(hop.decky)
|
|
return seen
|
|
|
|
@property
|
|
def decky_count(self) -> int:
|
|
return len(set(h.decky for h in self.hops))
|
|
|
|
@property
|
|
def path(self) -> str:
|
|
"""Human-readable traversal path: decky-01 → decky-03 → decky-07"""
|
|
return " → ".join(self.deckies)
|
|
|
|
def timeline(self) -> list[dict]:
|
|
"""Chronologically interleaved hops and mutation markers.
|
|
|
|
Each entry carries a ``kind`` discriminant (``hop`` | ``mutation``)
|
|
so JSON consumers can render them distinctly. Mutations of
|
|
deckies the attacker never touched are already filtered out at
|
|
the engine; here we just merge by timestamp.
|
|
"""
|
|
merged: list[tuple[datetime, dict]] = []
|
|
for h in self.hops:
|
|
merged.append((h.timestamp, {
|
|
"kind": "hop",
|
|
"timestamp": h.timestamp.isoformat(),
|
|
"decky": h.decky,
|
|
"service": h.service,
|
|
"event_type": h.event_type,
|
|
}))
|
|
for m in self.mutations_during:
|
|
merged.append((m.timestamp, {
|
|
"kind": "mutation",
|
|
"timestamp": m.timestamp.isoformat(),
|
|
"decky": m.decky,
|
|
"old_services": m.old_services,
|
|
"new_services": m.new_services,
|
|
"trigger": m.trigger,
|
|
}))
|
|
merged.sort(key=lambda kv: kv[0])
|
|
return [entry for _, entry in merged]
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"attacker_ip": self.attacker_ip,
|
|
"decky_count": self.decky_count,
|
|
"deckies": self.deckies,
|
|
"path": self.path,
|
|
"first_seen": self.first_seen.isoformat(),
|
|
"last_seen": self.last_seen.isoformat(),
|
|
"duration_seconds": self.duration_seconds,
|
|
"hop_count": len(self.hops),
|
|
"hops": [
|
|
{
|
|
"timestamp": h.timestamp.isoformat(),
|
|
"decky": h.decky,
|
|
"service": h.service,
|
|
"event_type": h.event_type,
|
|
}
|
|
for h in self.hops
|
|
],
|
|
"mutations_during": [
|
|
{
|
|
"timestamp": m.timestamp.isoformat(),
|
|
"decky": m.decky,
|
|
"old_services": m.old_services,
|
|
"new_services": m.new_services,
|
|
"trigger": m.trigger,
|
|
}
|
|
for m in self.mutations_during
|
|
],
|
|
"timeline": self.timeline(),
|
|
}
|