From a6c7cfdf66a6126c6313522e1a2d1ed3dedf03a0 Mon Sep 17 00:00:00 2001 From: anti Date: Tue, 14 Apr 2026 01:54:36 -0400 Subject: [PATCH] fix: normalize SSH bash CMD lines to service=ssh, event_type=command The SSH honeypot logs commands via PROMPT_COMMAND logger as: <14>1 ... bash - - - CMD uid=0 pwd=/root cmd=ls These lines had service=bash and event_type=-, so the attacker worker never recognized them as commands. Both the collector and correlation parsers now detect the CMD pattern and normalize to service=ssh, event_type=command, with uid/pwd/command in fields. --- decnet/collector/worker.py | 14 ++++++++++++++ decnet/correlation/parser.py | 16 ++++++++++++++++ tests/test_collector.py | 26 ++++++++++++++++++++++++++ tests/test_correlation.py | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+) diff --git a/decnet/collector/worker.py b/decnet/collector/worker.py index d96ed4f..b948bf1 100644 --- a/decnet/collector/worker.py +++ b/decnet/collector/worker.py @@ -32,6 +32,10 @@ _SD_BLOCK_RE = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL) _PARAM_RE = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"') _IP_FIELDS = ("src_ip", "src", "client_ip", "remote_ip", "ip") +# bash PROMPT_COMMAND logger output: "CMD uid=0 pwd=/root cmd=ls -lah" +_BASH_CMD_RE = re.compile(r"CMD\s+uid=(\S+)\s+pwd=(\S+)\s+cmd=(.*)") + + def parse_rfc5424(line: str) -> Optional[dict[str, Any]]: """ @@ -70,6 +74,16 @@ def parse_rfc5424(line: str) -> Optional[dict[str, Any]]: except ValueError: ts_formatted = ts_raw + # Normalize bash CMD lines from SSH honeypot PROMPT_COMMAND logger + if service == "bash" and msg: + cmd_match = _BASH_CMD_RE.match(msg) + if cmd_match: + service = "ssh" + event_type = "command" + fields["uid"] = cmd_match.group(1) + fields["pwd"] = cmd_match.group(2) + fields["command"] = cmd_match.group(3) + return { "timestamp": ts_formatted, "decky": decky, diff --git a/decnet/correlation/parser.py b/decnet/correlation/parser.py index e457254..5411d3e 100644 --- a/decnet/correlation/parser.py +++ b/decnet/correlation/parser.py @@ -40,6 +40,9 @@ _PARAM_RE = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"') # Field names to probe for attacker IP, in priority order _IP_FIELDS = ("src_ip", "src", "client_ip", "remote_ip", "ip") +# bash PROMPT_COMMAND logger output: "CMD uid=0 pwd=/root cmd=ls -lah" +_BASH_CMD_RE = re.compile(r"CMD\s+uid=(\S+)\s+pwd=(\S+)\s+cmd=(.*)") + @dataclass class LogEvent: @@ -99,6 +102,19 @@ def parse_line(line: str) -> LogEvent | None: return None fields = _parse_sd_params(sd_rest) + + # Normalize bash CMD lines from SSH honeypot PROMPT_COMMAND logger + if service == "bash": + # Free-text MSG follows the SD element (which is "-" for these lines) + msg = sd_rest.lstrip("- ").strip() + cmd_match = _BASH_CMD_RE.match(msg) + if cmd_match: + service = "ssh" + event_type = "command" + fields["uid"] = cmd_match.group(1) + fields["pwd"] = cmd_match.group(2) + fields["command"] = cmd_match.group(3) + attacker_ip = _extract_attacker_ip(fields) return LogEvent( diff --git a/tests/test_collector.py b/tests/test_collector.py index d43f2e3..2549788 100644 --- a/tests/test_collector.py +++ b/tests/test_collector.py @@ -131,6 +131,32 @@ class TestParseRfc5424: assert result["msg"] == "login attempt" + def test_bash_cmd_normalized_to_ssh_command(self): + line = '<14>1 2026-04-14T05:48:12.628417+00:00 SRV-BRAVO-13 bash - - - CMD uid=0 pwd=/root cmd=ls /var/www/html' + result = parse_rfc5424(line) + assert result is not None + assert result["service"] == "ssh" + assert result["event_type"] == "command" + assert result["fields"]["command"] == "ls /var/www/html" + assert result["fields"]["uid"] == "0" + assert result["fields"]["pwd"] == "/root" + + def test_bash_cmd_simple_command(self): + line = '<14>1 2026-04-14T05:48:13.332072+00:00 SRV-BRAVO-13 bash - - - CMD uid=0 pwd=/root cmd=ls' + result = parse_rfc5424(line) + assert result is not None + assert result["service"] == "ssh" + assert result["event_type"] == "command" + assert result["fields"]["command"] == "ls" + + def test_bash_non_cmd_not_normalized(self): + line = '<14>1 2026-04-14T05:48:12.628417+00:00 SRV-BRAVO-13 bash - - - some other bash message' + result = parse_rfc5424(line) + assert result is not None + assert result["service"] == "bash" + assert result["event_type"] == "-" + + 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 7764ec8..be4ca0e 100644 --- a/tests/test_correlation.py +++ b/tests/test_correlation.py @@ -155,6 +155,39 @@ class TestParserAttackerIP: assert parse_line(line) is None +class TestParserBashNormalization: + def test_bash_cmd_normalized_to_ssh_command(self): + line = '<14>1 2026-04-14T05:48:12.628417+00:00 SRV-BRAVO-13 bash - - - CMD uid=0 pwd=/root cmd=ls /var/www/html' + event = parse_line(line) + assert event is not None + assert event.service == "ssh" + assert event.event_type == "command" + assert event.fields["command"] == "ls /var/www/html" + assert event.fields["uid"] == "0" + assert event.fields["pwd"] == "/root" + + def test_bash_cmd_simple(self): + line = '<14>1 2026-04-14T05:48:13.332072+00:00 SRV-BRAVO-13 bash - - - CMD uid=0 pwd=/root cmd=ls' + event = parse_line(line) + assert event is not None + assert event.service == "ssh" + assert event.fields["command"] == "ls" + + def test_bash_non_cmd_stays_as_bash(self): + line = '<14>1 2026-04-14T05:48:12.628417+00:00 SRV-BRAVO-13 bash - - - some other bash message' + event = parse_line(line) + assert event is not None + assert event.service == "bash" + assert event.event_type == "-" + + def test_bash_cmd_with_complex_command(self): + line = '<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' + event = parse_line(line) + assert event is not None + assert event.service == "ssh" + assert event.fields["command"] == "cat /etc/passwd | grep root" + + # --------------------------------------------------------------------------- # graph.py — AttackerTraversal # ---------------------------------------------------------------------------