diff --git a/decnet/collector/worker.py b/decnet/collector/worker.py index 4dc7b8a..d96ed4f 100644 --- a/decnet/collector/worker.py +++ b/decnet/collector/worker.py @@ -24,7 +24,7 @@ _RFC5424_RE = re.compile( r"(\S+) " # 1: TIMESTAMP r"(\S+) " # 2: HOSTNAME (decky name) r"(\S+) " # 3: APP-NAME (service) - r"\S+ " # PROCID (NILVALUE or PID) + r"- " # PROCID always NILVALUE r"(\S+) " # 4: MSGID (event_type) r"(.+)$", # 5: SD element + optional MSG ) @@ -33,8 +33,6 @@ _PARAM_RE = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"') _IP_FIELDS = ("src_ip", "src", "client_ip", "remote_ip", "ip") - - def parse_rfc5424(line: str) -> Optional[dict[str, Any]]: """ Parse an RFC 5424 DECNET log line into a structured dict. diff --git a/decnet/correlation/parser.py b/decnet/correlation/parser.py index 9fa7420..e457254 100644 --- a/decnet/correlation/parser.py +++ b/decnet/correlation/parser.py @@ -26,7 +26,7 @@ _RFC5424_RE = re.compile( r"(\S+) " # 1: TIMESTAMP r"(\S+) " # 2: HOSTNAME (decky name) r"(\S+) " # 3: APP-NAME (service) - r"\S+ " # PROCID (NILVALUE or PID) + r"- " # PROCID always NILVALUE r"(\S+) " # 4: MSGID (event_type) r"(.+)$", # 5: SD element + optional MSG ) @@ -41,7 +41,6 @@ _PARAM_RE = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"') _IP_FIELDS = ("src_ip", "src", "client_ip", "remote_ip", "ip") - @dataclass class LogEvent: """A single parsed event from a DECNET syslog line.""" @@ -100,7 +99,6 @@ def parse_line(line: str) -> LogEvent | None: return None fields = _parse_sd_params(sd_rest) - attacker_ip = _extract_attacker_ip(fields) return LogEvent( diff --git a/templates/ssh/Dockerfile b/templates/ssh/Dockerfile index 5eeacc4..230d429 100644 --- a/templates/ssh/Dockerfile +++ b/templates/ssh/Dockerfile @@ -65,8 +65,6 @@ RUN mkdir -p /root/projects /root/backups /var/www/html && \ printf 'DB_HOST=10.0.0.5\nDB_USER=admin\nDB_PASS=changeme123\nDB_NAME=prod_db\n' > /root/projects/.env && \ printf '[Unit]\nDescription=App Server\n[Service]\nExecStart=/usr/bin/python3 /opt/app/server.py\n' > /root/projects/app.service -COPY decnet_logging.py /opt/decnet_logging.py -COPY log_relay.py /opt/log_relay.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/ssh/entrypoint.sh b/templates/ssh/entrypoint.sh index b9090fe..c5c8291 100644 --- a/templates/ssh/entrypoint.sh +++ b/templates/ssh/entrypoint.sh @@ -34,8 +34,8 @@ fi # Logging pipeline: named pipe → rsyslogd (RFC 5424) → stdout → Docker log capture mkfifo /var/run/decnet-logs -# Relay pipe through Python log_relay — normalizes sshd/bash events to DECNET format -python3 /opt/log_relay.py & +# Relay pipe to stdout so Docker captures all syslog events +cat /var/run/decnet-logs & # Start rsyslog (reads /etc/rsyslog.d/99-decnet.conf, writes to the pipe above) rsyslogd diff --git a/templates/ssh/log_relay.py b/templates/ssh/log_relay.py deleted file mode 100644 index 5fefb00..0000000 --- a/templates/ssh/log_relay.py +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env python3 -""" -SSH log relay — reads rsyslog output from the named pipe and re-emits -matched sshd/bash events as proper DECNET RFC 5424 syslog lines to stdout. - -Matched events: - - Accepted password (login_success) - - Connection closed (connection_closed) - - Disconnected from user (disconnect) - - Session opened (session_opened) - - bash CMD (command) -""" - -import os -import re -import sys - -from decnet_logging import syslog_line, write_syslog_file, SEVERITY_INFO, SEVERITY_WARNING - -NODE_NAME = os.environ.get("NODE_NAME", "ssh-decky") -SERVICE = "ssh" - -# sshd patterns -_ACCEPTED_RE = re.compile( - r"Accepted (\S+) for (\S+) from (\S+) port (\d+)" -) -_SESSION_RE = re.compile( - r"session opened for user (\S+?)(?:\(uid=\d+\))? by" -) -_DISCONNECTED_RE = re.compile( - r"Disconnected from user (\S+) (\S+) port (\d+)" -) -_CONN_CLOSED_RE = re.compile( - r"Connection closed by (\S+) port (\d+)" -) - -# bash PROMPT_COMMAND pattern -_BASH_CMD_RE = re.compile( - r"CMD\s+uid=(\S+)\s+pwd=(\S+)\s+cmd=(.*)" -) - - -def _handle_line(line: str) -> None: - """Parse a raw rsyslog line and emit a DECNET syslog line if it matches.""" - - # --- Accepted password --- - m = _ACCEPTED_RE.search(line) - if m: - method, user, src_ip, port = m.groups() - write_syslog_file(syslog_line( - SERVICE, NODE_NAME, "login_success", SEVERITY_WARNING, - src_ip=src_ip, username=user, auth_method=method, src_port=port, - )) - return - - # --- Session opened --- - m = _SESSION_RE.search(line) - if m: - user = m.group(1) - write_syslog_file(syslog_line( - SERVICE, NODE_NAME, "session_opened", SEVERITY_INFO, - username=user, - )) - return - - # --- Disconnected from user --- - m = _DISCONNECTED_RE.search(line) - if m: - user, src_ip, port = m.groups() - write_syslog_file(syslog_line( - SERVICE, NODE_NAME, "disconnect", SEVERITY_INFO, - src_ip=src_ip, username=user, src_port=port, - )) - return - - # --- Connection closed --- - m = _CONN_CLOSED_RE.search(line) - if m: - src_ip, port = m.groups() - write_syslog_file(syslog_line( - SERVICE, NODE_NAME, "connection_closed", SEVERITY_INFO, - src_ip=src_ip, src_port=port, - )) - return - - # --- bash CMD --- - m = _BASH_CMD_RE.search(line) - if m: - uid, pwd, cmd = m.groups() - write_syslog_file(syslog_line( - SERVICE, NODE_NAME, "command", SEVERITY_INFO, - uid=uid, pwd=pwd, command=cmd, - )) - return - - -def main() -> None: - pipe_path = "/var/run/decnet-logs" - while True: - with open(pipe_path, "r") as pipe: - for line in pipe: - _handle_line(line.rstrip("\n")) - - -if __name__ == "__main__": - main() diff --git a/tests/test_collector.py b/tests/test_collector.py index ca99e4c..d43f2e3 100644 --- a/tests/test_collector.py +++ b/tests/test_collector.py @@ -131,14 +131,6 @@ class TestParseRfc5424: assert result["msg"] == "login attempt" - def test_non_nil_procid_accepted(self): - line = '<38>1 2026-04-14T05:48:12.611006+00:00 SRV-BRAVO-13 sshd 282 - - Accepted password for root from 192.168.1.5 port 50854 ssh2' - result = parse_rfc5424(line) - assert result is not None - assert result["service"] == "sshd" - assert result["decky"] == "SRV-BRAVO-13" - - class TestIsServiceContainer: def test_known_container_returns_true(self): with patch("decnet.collector.worker._load_service_container_names", return_value=_KNOWN_NAMES): diff --git a/tests/test_correlation.py b/tests/test_correlation.py index cb186e1..7764ec8 100644 --- a/tests/test_correlation.py +++ b/tests/test_correlation.py @@ -155,19 +155,6 @@ class TestParserAttackerIP: assert parse_line(line) is None -class TestParserProcidFlexibility: - def test_non_nil_procid_accepted(self): - line = '<38>1 2026-04-14T05:48:12.611006+00:00 SRV-BRAVO-13 sshd 282 - - Accepted password for root' - event = parse_line(line) - assert event is not None - assert event.service == "sshd" - assert event.decky == "SRV-BRAVO-13" - - def test_nil_procid_still_works(self): - event = parse_line(_make_line()) - assert event is not None - - # --------------------------------------------------------------------------- # graph.py — AttackerTraversal # --------------------------------------------------------------------------- diff --git a/tests/test_ssh_log_relay.py b/tests/test_ssh_log_relay.py deleted file mode 100644 index 7745ab1..0000000 --- a/tests/test_ssh_log_relay.py +++ /dev/null @@ -1,117 +0,0 @@ -"""Tests for the SSH log relay that normalizes sshd/bash events.""" - -import os -import sys -import types -from pathlib import Path - -import pytest - -_SSH_TPL = str(Path(__file__).resolve().parent.parent / "templates" / "ssh") - - -def _load_relay(): - """Import log_relay with a real decnet_logging from the SSH template dir.""" - # Clear any stale stubs - for mod_name in ("decnet_logging", "log_relay"): - sys.modules.pop(mod_name, None) - - if _SSH_TPL not in sys.path: - sys.path.insert(0, _SSH_TPL) - - import log_relay - return log_relay - - -_relay = _load_relay() - - -def _capture(line: str) -> str | None: - """Run _handle_line, collect output via monkey-patched write_syslog_file.""" - collected: list[str] = [] - original = _relay.write_syslog_file - _relay.write_syslog_file = lambda s: collected.append(s) - try: - _relay._handle_line(line) - finally: - _relay.write_syslog_file = original - return collected[0] if collected else None - - -class TestSshdAcceptedPassword: - def test_accepted_password_emits_login_success(self): - emitted = _capture( - '<38>1 2026-04-14T05:48:12.611006+00:00 SRV-BRAVO-13 sshd 282 - - Accepted password for root from 192.168.1.5 port 50854 ssh2' - ) - assert emitted is not None - assert "login_success" in emitted - assert 'src_ip="192.168.1.5"' in emitted - assert 'username="root"' in emitted - assert 'auth_method="password"' in emitted - - def test_accepted_publickey(self): - emitted = _capture( - '<38>1 2026-04-14T05:48:12.611006+00:00 SRV-BRAVO-13 sshd 282 - - Accepted publickey for admin from 10.0.0.1 port 12345 ssh2' - ) - assert emitted is not None - assert 'auth_method="publickey"' in emitted - assert 'username="admin"' in emitted - - -class TestSshdSessionOpened: - def test_session_opened(self): - emitted = _capture( - '<86>1 2026-04-14T05:48:12.611880+00:00 SRV-BRAVO-13 sshd 282 - - pam_unix(sshd:session): session opened for user root(uid=0) by (uid=0)' - ) - assert emitted is not None - assert "session_opened" in emitted - assert 'username="root"' in emitted - - -class TestSshdDisconnected: - def test_disconnected(self): - emitted = _capture( - '<38>1 2026-04-14T05:54:50.710536+00:00 SRV-BRAVO-13 sshd 282 - - Disconnected from user root 192.168.1.5 port 50854' - ) - assert emitted is not None - assert "disconnect" in emitted - assert 'src_ip="192.168.1.5"' in emitted - assert 'username="root"' in emitted - - -class TestSshdConnectionClosed: - def test_connection_closed(self): - emitted = _capture( - '<38>1 2026-04-14T05:47:55.621236+00:00 SRV-BRAVO-13 sshd 280 - - Connection closed by 192.168.1.5 port 52900 [preauth]' - ) - assert emitted is not None - assert "connection_closed" in emitted - assert 'src_ip="192.168.1.5"' in emitted - - -class TestBashCommand: - def test_bash_cmd(self): - emitted = _capture( - '<14>1 2026-04-14T05:48:12.628417+00:00 SRV-BRAVO-13 bash - - - CMD uid=0 pwd=/root cmd=ls /var/www/html' - ) - assert emitted is not None - assert "command" in emitted - assert 'command="ls /var/www/html"' in emitted - - def test_bash_cmd_with_pipes(self): - emitted = _capture( - '<14>1 2026-04-14T05:48:32.006502+00:00 SRV-BRAVO-13 bash - - - CMD uid=0 pwd=/root cmd=cat /etc/passwd | grep root' - ) - assert emitted is not None - assert "cat /etc/passwd | grep root" in emitted - - -class TestUnmatchedLines: - def test_pam_env_ignored(self): - assert _capture('<83>1 2026-04-14T05:48:12.615198+00:00 SRV-BRAVO-13 sshd 282 - - pam_env(sshd:session): Unable to open env file') is None - - def test_session_closed_ignored(self): - assert _capture('<86>1 2026-04-14T05:54:50.710577+00:00 SRV-BRAVO-13 sshd 282 - - pam_unix(sshd:session): session closed for user root') is None - - def test_syslogin_ignored(self): - assert _capture('<38>1 2026-04-14T05:54:50.710307+00:00 SRV-BRAVO-13 sshd 282 - - syslogin_perform_logout: logout() returned an error') is None