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 # ---------------------------------------------------------------------------