Native sshd and pam_unix lines route through rsyslog without the relay@55555 SD wrapper and without key=value pairs, so attacker_ip fell through to "Unknown". Add a prose-IP fallback to both parsers: anchored patterns (from/rhost/client/src) win first so we never pick the local listener in "Connection from X port Y on Z port 22", with a bare-IPv4 scan as the last resort.
607 lines
23 KiB
Python
607 lines
23 KiB
Python
"""
|
|
Tests for the DECNET cross-decky correlation engine.
|
|
|
|
Covers:
|
|
- RFC 5424 line parsing (parser.py)
|
|
- Traversal graph data types (graph.py)
|
|
- CorrelationEngine ingestion, querying, and reporting (engine.py)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from datetime import datetime
|
|
|
|
|
|
from decnet.correlation.parser import LogEvent, parse_line
|
|
from decnet.correlation.graph import AttackerTraversal, TraversalHop
|
|
from decnet.correlation.engine import CorrelationEngine, _fmt_duration
|
|
from decnet.logging.syslog_formatter import format_rfc5424, SEVERITY_INFO, SEVERITY_WARNING
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures & helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_TS = "2026-04-04T10:00:00+00:00"
|
|
_TS2 = "2026-04-04T10:05:00+00:00"
|
|
_TS3 = "2026-04-04T10:10:00+00:00"
|
|
|
|
|
|
def _make_line(
|
|
service: str = "http",
|
|
hostname: str = "decky-01",
|
|
event_type: str = "connection",
|
|
src_ip: str = "1.2.3.4",
|
|
timestamp: str = _TS,
|
|
extra_fields: dict | None = None,
|
|
) -> str:
|
|
"""Build a real RFC 5424 DECNET syslog line via the formatter."""
|
|
fields = {}
|
|
if src_ip:
|
|
fields["src_ip"] = src_ip
|
|
if extra_fields:
|
|
fields.update(extra_fields)
|
|
return format_rfc5424(
|
|
service=service,
|
|
hostname=hostname,
|
|
event_type=event_type,
|
|
severity=SEVERITY_INFO,
|
|
timestamp=datetime.fromisoformat(timestamp),
|
|
**fields,
|
|
)
|
|
|
|
|
|
def _make_line_src(hostname: str, src: str, timestamp: str = _TS) -> str:
|
|
"""Build a line that uses `src` instead of `src_ip` (mssql style)."""
|
|
return format_rfc5424(
|
|
service="mssql",
|
|
hostname=hostname,
|
|
event_type="unknown_packet",
|
|
severity=SEVERITY_INFO,
|
|
timestamp=datetime.fromisoformat(timestamp),
|
|
src=src,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# parser.py — parse_line
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestParserBasic:
|
|
def test_returns_none_for_blank(self):
|
|
assert parse_line("") is None
|
|
assert parse_line(" ") is None
|
|
|
|
def test_returns_none_for_non_rfc5424(self):
|
|
assert parse_line("this is not a syslog line") is None
|
|
assert parse_line("Jan 1 00:00:00 host sshd: blah") is None
|
|
|
|
def test_returns_log_event(self):
|
|
event = parse_line(_make_line())
|
|
assert isinstance(event, LogEvent)
|
|
|
|
def test_hostname_extracted(self):
|
|
event = parse_line(_make_line(hostname="decky-07"))
|
|
assert event.decky == "decky-07"
|
|
|
|
def test_service_extracted(self):
|
|
event = parse_line(_make_line(service="ftp"))
|
|
assert event.service == "ftp"
|
|
|
|
def test_event_type_extracted(self):
|
|
event = parse_line(_make_line(event_type="login_attempt"))
|
|
assert event.event_type == "login_attempt"
|
|
|
|
def test_timestamp_parsed(self):
|
|
event = parse_line(_make_line(timestamp=_TS))
|
|
assert event.timestamp == datetime.fromisoformat(_TS)
|
|
|
|
def test_raw_line_preserved(self):
|
|
line = _make_line()
|
|
event = parse_line(line)
|
|
assert event.raw == line.strip()
|
|
|
|
|
|
class TestParserAttackerIP:
|
|
def test_src_ip_field(self):
|
|
event = parse_line(_make_line(src_ip="10.0.0.1"))
|
|
assert event.attacker_ip == "10.0.0.1"
|
|
|
|
def test_src_field_fallback(self):
|
|
"""mssql logs use `src` instead of `src_ip`."""
|
|
event = parse_line(_make_line_src("decky-win", "192.168.1.5"))
|
|
assert event.attacker_ip == "192.168.1.5"
|
|
|
|
def test_no_ip_field_gives_none(self):
|
|
line = format_rfc5424("http", "decky-01", "startup", SEVERITY_INFO)
|
|
event = parse_line(line)
|
|
assert event is not None
|
|
assert event.attacker_ip is None
|
|
|
|
def test_extra_fields_in_dict(self):
|
|
event = parse_line(_make_line(extra_fields={"username": "root", "password": "admin"}))
|
|
assert event.fields["username"] == "root"
|
|
assert event.fields["password"] == "admin"
|
|
|
|
def test_src_ip_priority_over_src(self):
|
|
"""src_ip should win when both are present."""
|
|
line = format_rfc5424(
|
|
"mssql", "decky-01", "evt", SEVERITY_INFO,
|
|
timestamp=datetime.fromisoformat(_TS),
|
|
src_ip="1.1.1.1",
|
|
src="2.2.2.2",
|
|
)
|
|
event = parse_line(line)
|
|
assert event.attacker_ip == "1.1.1.1"
|
|
|
|
def test_sd_escape_chars_decoded(self):
|
|
"""Escaped characters in SD values should be unescaped."""
|
|
line = format_rfc5424(
|
|
"http", "decky-01", "evt", SEVERITY_INFO,
|
|
timestamp=datetime.fromisoformat(_TS),
|
|
src_ip="1.2.3.4",
|
|
path='/search?q=a"b',
|
|
)
|
|
event = parse_line(line)
|
|
assert '"' in event.fields["path"]
|
|
|
|
def test_nilvalue_hostname_skipped(self):
|
|
line = format_rfc5424("-", "decky-01", "evt", SEVERITY_INFO)
|
|
assert parse_line(line) is None
|
|
|
|
def test_nilvalue_service_skipped(self):
|
|
line = format_rfc5424("http", "-", "evt", SEVERITY_INFO)
|
|
assert parse_line(line) is None
|
|
|
|
def test_attacker_ip_from_sshd_prose(self):
|
|
"""sshd routed via rsyslog has no SD block — IP lives in free prose.
|
|
Anchored "from <ip>" must beat the local listener in
|
|
"Connection from X port Y on Z port 22"."""
|
|
cases = [
|
|
(
|
|
"<38>1 2026-04-27T03:08:48+00:00 dmz-gateway sshd - - - "
|
|
"Failed password for root from 157.66.144.16 port 42772 ssh2",
|
|
"157.66.144.16",
|
|
),
|
|
(
|
|
"<38>1 2026-04-27T03:08:45+00:00 dmz-gateway sshd - - - "
|
|
"Connection from 157.66.144.16 port 42772 on 10.0.0.2 port 22",
|
|
"157.66.144.16",
|
|
),
|
|
(
|
|
"<38>1 2026-04-27T03:08:46+00:00 dmz-gateway sshd - - - "
|
|
"pam_unix(sshd:auth): authentication failure; rhost=157.66.144.16 user=root",
|
|
"157.66.144.16",
|
|
),
|
|
]
|
|
for line, expected in cases:
|
|
event = parse_line(line)
|
|
assert event is not None, line
|
|
assert event.attacker_ip == expected, (line, event.attacker_ip)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# graph.py — AttackerTraversal
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _make_traversal(ip: str, hops_spec: list[tuple]) -> AttackerTraversal:
|
|
"""hops_spec: list of (ts_str, decky, service, event_type)"""
|
|
hops = [
|
|
TraversalHop(
|
|
timestamp=datetime.fromisoformat(ts),
|
|
decky=decky,
|
|
service=svc,
|
|
event_type=evt,
|
|
)
|
|
for ts, decky, svc, evt in hops_spec
|
|
]
|
|
return AttackerTraversal(attacker_ip=ip, hops=hops)
|
|
|
|
|
|
class TestTraversalGraph:
|
|
def setup_method(self):
|
|
self.t = _make_traversal("5.6.7.8", [
|
|
(_TS, "decky-01", "ssh", "login_attempt"),
|
|
(_TS2, "decky-03", "http", "request"),
|
|
(_TS3, "decky-05", "ftp", "auth_attempt"),
|
|
])
|
|
|
|
def test_first_seen(self):
|
|
assert self.t.first_seen == datetime.fromisoformat(_TS)
|
|
|
|
def test_last_seen(self):
|
|
assert self.t.last_seen == datetime.fromisoformat(_TS3)
|
|
|
|
def test_duration_seconds(self):
|
|
assert self.t.duration_seconds == 600.0
|
|
|
|
def test_deckies_ordered(self):
|
|
assert self.t.deckies == ["decky-01", "decky-03", "decky-05"]
|
|
|
|
def test_decky_count(self):
|
|
assert self.t.decky_count == 3
|
|
|
|
def test_path_string(self):
|
|
assert self.t.path == "decky-01 → decky-03 → decky-05"
|
|
|
|
def test_to_dict_keys(self):
|
|
d = self.t.to_dict()
|
|
assert d["attacker_ip"] == "5.6.7.8"
|
|
assert d["decky_count"] == 3
|
|
assert d["hop_count"] == 3
|
|
assert len(d["hops"]) == 3
|
|
assert d["path"] == "decky-01 → decky-03 → decky-05"
|
|
|
|
def test_to_dict_hops_structure(self):
|
|
hop = self.t.to_dict()["hops"][0]
|
|
assert set(hop.keys()) == {"timestamp", "decky", "service", "event_type"}
|
|
|
|
def test_repeated_decky_not_double_counted_in_path(self):
|
|
t = _make_traversal("1.1.1.1", [
|
|
(_TS, "decky-01", "ssh", "conn"),
|
|
(_TS2, "decky-02", "ftp", "conn"),
|
|
(_TS3, "decky-01", "ssh", "conn"), # revisit
|
|
])
|
|
assert t.deckies == ["decky-01", "decky-02"]
|
|
assert t.decky_count == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# engine.py — CorrelationEngine
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestEngineIngestion:
|
|
def test_ingest_returns_event(self):
|
|
engine = CorrelationEngine()
|
|
evt = engine.ingest(_make_line())
|
|
assert evt is not None
|
|
|
|
def test_ingest_blank_returns_none(self):
|
|
engine = CorrelationEngine()
|
|
assert engine.ingest("") is None
|
|
|
|
def test_lines_parsed_counter(self):
|
|
engine = CorrelationEngine()
|
|
engine.ingest(_make_line())
|
|
engine.ingest("garbage")
|
|
assert engine.lines_parsed == 2
|
|
|
|
def test_events_indexed_counter(self):
|
|
engine = CorrelationEngine()
|
|
engine.ingest(_make_line(src_ip="1.2.3.4"))
|
|
engine.ingest(_make_line(src_ip="")) # no IP
|
|
assert engine.events_indexed == 1
|
|
|
|
def test_ingest_file(self, tmp_path):
|
|
log = tmp_path / "decnet.log"
|
|
lines = [
|
|
_make_line("ssh", "decky-01", "conn", "10.0.0.1", _TS),
|
|
_make_line("http", "decky-02", "req", "10.0.0.1", _TS2),
|
|
_make_line("ftp", "decky-03", "auth", "10.0.0.1", _TS3),
|
|
]
|
|
log.write_text("\n".join(lines))
|
|
engine = CorrelationEngine()
|
|
count = engine.ingest_file(log)
|
|
assert count == 3
|
|
|
|
|
|
class TestEngineTraversals:
|
|
def _engine_with(self, specs: list[tuple]) -> CorrelationEngine:
|
|
"""specs: (service, decky, event_type, src_ip, timestamp)"""
|
|
engine = CorrelationEngine()
|
|
for svc, decky, evt, ip, ts in specs:
|
|
engine.ingest(_make_line(svc, decky, evt, ip, ts))
|
|
return engine
|
|
|
|
def test_single_decky_not_a_traversal(self):
|
|
engine = self._engine_with([
|
|
("ssh", "decky-01", "conn", "1.1.1.1", _TS),
|
|
("ssh", "decky-01", "conn", "1.1.1.1", _TS2),
|
|
])
|
|
assert engine.traversals() == []
|
|
|
|
def test_two_deckies_is_traversal(self):
|
|
engine = self._engine_with([
|
|
("ssh", "decky-01", "conn", "1.1.1.1", _TS),
|
|
("http", "decky-02", "req", "1.1.1.1", _TS2),
|
|
])
|
|
t = engine.traversals()
|
|
assert len(t) == 1
|
|
assert t[0].attacker_ip == "1.1.1.1"
|
|
assert t[0].decky_count == 2
|
|
|
|
def test_prober_event_does_not_count_as_traversal(self):
|
|
"""Hit live on first VPS deploy: every fingerprinted attacker
|
|
showed up as a 2-decky traversal because the prober's outbound
|
|
fingerprint events (decky=decnet-prober, target_ip=<attacker>)
|
|
got co-indexed with the attacker's actual decoy hops. The
|
|
prober is internal infrastructure, not a hop — its events
|
|
must not bump the distinct-decky count."""
|
|
engine = self._engine_with([
|
|
("ssh", "dmz-gateway", "conn", "1.1.1.1", _TS),
|
|
("ssh", "decnet-prober", "hassh_fingerprint", "1.1.1.1", _TS2),
|
|
])
|
|
# Only one *real* decky touched — no traversal.
|
|
assert engine.traversals() == []
|
|
|
|
def test_prober_excluded_from_traversal_path(self):
|
|
"""When a real traversal exists, the prober's hops must not
|
|
appear in the path or inflate the decky count."""
|
|
engine = self._engine_with([
|
|
("ssh", "dmz-gateway", "conn", "1.1.1.1", _TS),
|
|
("ssh", "decnet-prober", "hassh_fingerprint", "1.1.1.1", _TS2),
|
|
("http", "decky-internal", "req", "1.1.1.1", _TS3),
|
|
])
|
|
traversals = engine.traversals()
|
|
assert len(traversals) == 1
|
|
t = traversals[0]
|
|
assert t.decky_count == 2, (
|
|
f"prober should not inflate decky_count; got {t.decky_count}"
|
|
)
|
|
assert "decnet-prober" not in t.path, (
|
|
f"prober should not appear in traversal path; got {t.path!r}"
|
|
)
|
|
|
|
def test_min_deckies_filter(self):
|
|
engine = self._engine_with([
|
|
("ssh", "decky-01", "conn", "1.1.1.1", _TS),
|
|
("http", "decky-02", "req", "1.1.1.1", _TS2),
|
|
("ftp", "decky-03", "auth", "1.1.1.1", _TS3),
|
|
])
|
|
assert len(engine.traversals(min_deckies=3)) == 1
|
|
assert len(engine.traversals(min_deckies=4)) == 0
|
|
|
|
def test_multiple_attackers_separate_traversals(self):
|
|
engine = self._engine_with([
|
|
("ssh", "decky-01", "conn", "1.1.1.1", _TS),
|
|
("http", "decky-02", "req", "1.1.1.1", _TS2),
|
|
("ssh", "decky-03", "conn", "9.9.9.9", _TS),
|
|
("ftp", "decky-04", "auth", "9.9.9.9", _TS2),
|
|
])
|
|
traversals = engine.traversals()
|
|
assert len(traversals) == 2
|
|
ips = {t.attacker_ip for t in traversals}
|
|
assert ips == {"1.1.1.1", "9.9.9.9"}
|
|
|
|
def test_traversals_sorted_by_first_seen(self):
|
|
engine = self._engine_with([
|
|
("ssh", "decky-01", "conn", "9.9.9.9", _TS2), # later
|
|
("ftp", "decky-02", "auth", "9.9.9.9", _TS3),
|
|
("http", "decky-03", "req", "1.1.1.1", _TS), # earlier
|
|
("smb", "decky-04", "auth", "1.1.1.1", _TS2),
|
|
])
|
|
traversals = engine.traversals()
|
|
assert traversals[0].attacker_ip == "1.1.1.1"
|
|
assert traversals[1].attacker_ip == "9.9.9.9"
|
|
|
|
def test_hops_ordered_chronologically(self):
|
|
engine = self._engine_with([
|
|
("ftp", "decky-02", "auth", "5.5.5.5", _TS2), # ingested first but later ts
|
|
("ssh", "decky-01", "conn", "5.5.5.5", _TS),
|
|
])
|
|
t = engine.traversals()[0]
|
|
assert t.hops[0].decky == "decky-01"
|
|
assert t.hops[1].decky == "decky-02"
|
|
|
|
def test_all_attackers(self):
|
|
engine = self._engine_with([
|
|
("ssh", "decky-01", "conn", "1.1.1.1", _TS),
|
|
("ssh", "decky-01", "conn", "1.1.1.1", _TS2),
|
|
("ssh", "decky-01", "conn", "2.2.2.2", _TS),
|
|
])
|
|
attackers = engine.all_attackers()
|
|
assert attackers["1.1.1.1"] == 2
|
|
assert attackers["2.2.2.2"] == 1
|
|
|
|
def test_mssql_src_field_correlated(self):
|
|
"""Verify that `src=` (mssql style) is picked up for cross-decky correlation."""
|
|
engine = CorrelationEngine()
|
|
engine.ingest(_make_line_src("decky-win1", "10.10.10.5", _TS))
|
|
engine.ingest(_make_line_src("decky-win2", "10.10.10.5", _TS2))
|
|
t = engine.traversals()
|
|
assert len(t) == 1
|
|
assert t[0].decky_count == 2
|
|
|
|
|
|
class TestEngineReporting:
|
|
def _two_decky_engine(self) -> CorrelationEngine:
|
|
engine = CorrelationEngine()
|
|
engine.ingest(_make_line("ssh", "decky-01", "conn", "3.3.3.3", _TS))
|
|
engine.ingest(_make_line("http", "decky-02", "req", "3.3.3.3", _TS2))
|
|
return engine
|
|
|
|
def test_report_json_structure(self):
|
|
engine = self._two_decky_engine()
|
|
report = engine.report_json()
|
|
assert "stats" in report
|
|
assert "traversals" in report
|
|
assert report["stats"]["traversals"] == 1
|
|
t = report["traversals"][0]
|
|
assert t["attacker_ip"] == "3.3.3.3"
|
|
assert t["decky_count"] == 2
|
|
|
|
def test_report_json_serialisable(self):
|
|
engine = self._two_decky_engine()
|
|
# Should not raise
|
|
json.dumps(engine.report_json())
|
|
|
|
def test_report_table_returns_rich_table(self):
|
|
from rich.table import Table
|
|
engine = self._two_decky_engine()
|
|
table = engine.report_table()
|
|
assert isinstance(table, Table)
|
|
|
|
def test_traversal_syslog_lines_count(self):
|
|
engine = self._two_decky_engine()
|
|
lines = engine.traversal_syslog_lines()
|
|
assert len(lines) == 1
|
|
|
|
def test_traversal_syslog_line_is_rfc5424(self):
|
|
engine = self._two_decky_engine()
|
|
line = engine.traversal_syslog_lines()[0]
|
|
# Must match RFC 5424 header
|
|
assert re.match(r"^<\d+>1 \S+ \S+ correlator - traversal_detected", line)
|
|
|
|
def test_traversal_syslog_contains_attacker_ip(self):
|
|
engine = self._two_decky_engine()
|
|
line = engine.traversal_syslog_lines()[0]
|
|
assert "3.3.3.3" in line
|
|
|
|
def test_traversal_syslog_severity_is_warning(self):
|
|
engine = self._two_decky_engine()
|
|
line = engine.traversal_syslog_lines()[0]
|
|
pri = int(re.match(r"^<(\d+)>", line).group(1))
|
|
assert pri == 16 * 8 + SEVERITY_WARNING # local0 + warning
|
|
|
|
def test_no_traversals_empty_json(self):
|
|
engine = CorrelationEngine()
|
|
engine.ingest(_make_line()) # single decky, no traversal
|
|
assert engine.report_json()["stats"]["traversals"] == 0
|
|
assert engine.traversal_syslog_lines() == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _fmt_duration helper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestFmtDuration:
|
|
def test_seconds(self):
|
|
assert _fmt_duration(45) == "45s"
|
|
|
|
def test_minutes(self):
|
|
assert _fmt_duration(90) == "1.5m"
|
|
|
|
def test_hours(self):
|
|
assert _fmt_duration(7200) == "2.0h"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Mutation-event stream (parser kind + engine index + graph markers)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _mutation_line(
|
|
decky: str,
|
|
*,
|
|
old: str = "",
|
|
new: str = "ssh",
|
|
trigger: str = "scheduled",
|
|
timestamp: str = _TS,
|
|
) -> str:
|
|
return format_rfc5424(
|
|
service="mutator",
|
|
hostname=decky,
|
|
event_type="decky_mutated",
|
|
severity=SEVERITY_INFO,
|
|
timestamp=datetime.fromisoformat(timestamp),
|
|
decky=decky,
|
|
old_services=old,
|
|
new_services=new,
|
|
trigger=trigger,
|
|
)
|
|
|
|
|
|
class TestParserMutationKind:
|
|
def test_mutator_line_kind_is_mutation(self):
|
|
ev = parse_line(_mutation_line("decky-01", old="ssh", new="rdp",
|
|
trigger="scheduled"))
|
|
assert ev is not None
|
|
assert ev.kind == "mutation"
|
|
|
|
def test_default_kind_is_attacker(self):
|
|
ev = parse_line(_make_line())
|
|
assert ev is not None
|
|
assert ev.kind == "attacker"
|
|
|
|
def test_non_mutator_service_stays_attacker(self):
|
|
# Same event_type but different service ⇒ not a mutation
|
|
line = format_rfc5424(
|
|
service="ssh",
|
|
hostname="decky-01",
|
|
event_type="decky_mutated",
|
|
severity=SEVERITY_INFO,
|
|
timestamp=datetime.fromisoformat(_TS),
|
|
src_ip="1.1.1.1",
|
|
)
|
|
ev = parse_line(line)
|
|
assert ev is not None
|
|
assert ev.kind == "attacker"
|
|
|
|
|
|
class TestEngineMutationIndex:
|
|
def test_mutation_indexed_separately(self):
|
|
engine = CorrelationEngine()
|
|
engine.ingest(_mutation_line("decky-01", old="ssh", new="rdp"))
|
|
assert engine.mutations_indexed == 1
|
|
assert engine.events_indexed == 0
|
|
assert "decky-01" in engine._mutations
|
|
assert "decky-01" not in engine._events
|
|
|
|
def test_mutations_interleaved_into_traversal(self):
|
|
engine = CorrelationEngine()
|
|
# Attacker hits decky-01 and decky-02; decky-01 mutates in between
|
|
engine.ingest(_make_line(hostname="decky-01", src_ip="9.9.9.9",
|
|
timestamp=_TS))
|
|
engine.ingest(_mutation_line("decky-01", old="ssh", new="rdp",
|
|
trigger="scheduled", timestamp=_TS2))
|
|
engine.ingest(_make_line(hostname="decky-02", src_ip="9.9.9.9",
|
|
timestamp=_TS3))
|
|
traversals = engine.traversals()
|
|
assert len(traversals) == 1
|
|
t = traversals[0]
|
|
assert len(t.mutations_during) == 1
|
|
m = t.mutations_during[0]
|
|
assert m.decky == "decky-01"
|
|
assert m.old_services == ["ssh"]
|
|
assert m.new_services == ["rdp"]
|
|
assert m.trigger == "scheduled"
|
|
|
|
def test_mutation_outside_window_excluded(self):
|
|
engine = CorrelationEngine()
|
|
# Mutation at _TS — before attacker first_seen at _TS2
|
|
engine.ingest(_mutation_line("decky-01", old="", new="ssh",
|
|
trigger="creation", timestamp=_TS))
|
|
engine.ingest(_make_line(hostname="decky-01", src_ip="9.9.9.9",
|
|
timestamp=_TS2))
|
|
engine.ingest(_make_line(hostname="decky-02", src_ip="9.9.9.9",
|
|
timestamp=_TS3))
|
|
t = engine.traversals()[0]
|
|
# The creation happened BEFORE first contact, so it's not "during"
|
|
assert t.mutations_during == []
|
|
|
|
def test_mutation_on_untouched_decky_excluded(self):
|
|
engine = CorrelationEngine()
|
|
engine.ingest(_make_line(hostname="decky-01", src_ip="9.9.9.9",
|
|
timestamp=_TS))
|
|
engine.ingest(_make_line(hostname="decky-02", src_ip="9.9.9.9",
|
|
timestamp=_TS3))
|
|
# decky-03 mutates mid-window but the attacker never touched it
|
|
engine.ingest(_mutation_line("decky-03", old="ftp", new="smtp",
|
|
trigger="operator", timestamp=_TS2))
|
|
t = engine.traversals()[0]
|
|
assert t.mutations_during == []
|
|
|
|
def test_to_dict_includes_timeline_with_markers(self):
|
|
engine = CorrelationEngine()
|
|
engine.ingest(_make_line(hostname="decky-01", src_ip="9.9.9.9",
|
|
timestamp=_TS))
|
|
engine.ingest(_mutation_line("decky-01", old="ssh", new="rdp",
|
|
trigger="scheduled", timestamp=_TS2))
|
|
engine.ingest(_make_line(hostname="decky-02", src_ip="9.9.9.9",
|
|
timestamp=_TS3))
|
|
d = engine.traversals()[0].to_dict()
|
|
assert len(d["mutations_during"]) == 1
|
|
assert d["mutations_during"][0]["trigger"] == "scheduled"
|
|
kinds = [entry["kind"] for entry in d["timeline"]]
|
|
assert kinds == ["hop", "mutation", "hop"]
|
|
|
|
def test_report_json_serialisable_with_mutations(self):
|
|
engine = CorrelationEngine()
|
|
engine.ingest(_make_line(hostname="decky-01", src_ip="9.9.9.9",
|
|
timestamp=_TS))
|
|
engine.ingest(_mutation_line("decky-01", old="ssh", new="rdp",
|
|
trigger="scheduled", timestamp=_TS2))
|
|
engine.ingest(_make_line(hostname="decky-02", src_ip="9.9.9.9",
|
|
timestamp=_TS3))
|
|
json.dumps(engine.report_json()) # must not raise
|