diff --git a/decnet/collector/worker.py b/decnet/collector/worker.py index da548d29..4e04f901 100644 --- a/decnet/collector/worker.py +++ b/decnet/collector/worker.py @@ -220,6 +220,12 @@ def parse_rfc5424(line: str) -> Optional[dict[str, Any]]: except ValueError: ts_formatted = ts_raw + # Free-form bash PROMPT_COMMAND lines (MSGID=NIL, body starts with + # "CMD ") get event_type rewritten to "command". `fields` stays empty + # so the frontend's msg-based pill rendering doesn't double up. + if event_type == "-" and msg.startswith("CMD "): + event_type = "command" + return { "timestamp": ts_formatted, "decky": decky, diff --git a/decnet/correlation/parser.py b/decnet/correlation/parser.py index 9740d490..de771a2f 100644 --- a/decnet/correlation/parser.py +++ b/decnet/correlation/parser.py @@ -137,6 +137,18 @@ def parse_line(line: str) -> LogEvent | None: msg = tail.group(1).strip() if tail else "" attacker_ip = _extract_attacker_ip(fields, msg) + # Free-form bash PROMPT_COMMAND lines arrive with MSGID=NIL and a body + # like `CMD uid=0 user=root src=… pwd=… cmd=`. Without + # this rewrite they're invisible to the behavioral profiler, which + # filters on event_type ∈ {command, exec, query, …}. + if event_type == "-" and msg.startswith("CMD "): + event_type = "command" + head, sep, cmd_rest = msg[4:].partition("cmd=") + for k, v in re.findall(r'(\w+)=(\S+)', head): + fields.setdefault(k, v) + if sep: + fields.setdefault("command", cmd_rest) + # Mutator-emitted transitions arrive on the same ingest stream but # belong in the substrate-state index, not the per-IP attacker one. kind: EventKind = ( diff --git a/tests/collector/test_collector.py b/tests/collector/test_collector.py index a1ad8fcd..17004268 100644 --- a/tests/collector/test_collector.py +++ b/tests/collector/test_collector.py @@ -209,6 +209,23 @@ class TestParseRfc5424: assert result["fields"]["src_ip"] == "1.2.3.4" assert result["msg"] == "login attempt" + def test_bash_prompt_command_normalized_to_command(self): + # SSH/telnet decky PROMPT_COMMAND emits free-form `logger -t bash + # "CMD …"` with MSGID=NIL. Normalize so the profiler picks it up. + # `fields` stays empty — the frontend renders kv pairs from msg. + line = ( + '<14>1 2026-04-28T22:35:58.021674+00:00 dmz-gateway bash - - - ' + 'CMD uid=0 user=root src=31.56.209.39 pwd=/root ' + 'cmd=echo "rm -rf *.sh" | sh' + ) + result = parse_rfc5424(line) + assert result is not None + assert result["event_type"] == "command" + assert result["attacker_ip"] == "31.56.209.39" + assert result["fields"] == {} + # cmd payload survives in msg for the dashboard renderer. + assert "cmd=echo" in result["msg"] + class TestIsServiceContainer: def test_known_container_returns_true(self): diff --git a/tests/correlation/test_correlation.py b/tests/correlation/test_correlation.py index 5601c6f8..8c4eb84f 100644 --- a/tests/correlation/test_correlation.py +++ b/tests/correlation/test_correlation.py @@ -103,6 +103,40 @@ class TestParserBasic: assert event.raw == line.strip() +class TestParserBashPromptCommand: + """ + Bash PROMPT_COMMAND lines from SSH/telnet decky containers arrive as + free-form `logger -t bash "CMD …"` syslog with MSGID=NIL. The parser + must rewrite them to event_type=command so the behavioral profiler + picks them up. + """ + + _RAW = ( + '<14>1 2026-04-28T22:35:58.021674+00:00 dmz-gateway bash - - - ' + 'CMD uid=0 user=root src=31.56.209.39 pwd=/root ' + 'cmd=echo "history -cw; rm -rf *.sh" | sh' + ) + + def test_event_type_normalized_to_command(self): + event = parse_line(self._RAW) + assert event is not None + assert event.event_type == "command" + + def test_attacker_ip_extracted(self): + event = parse_line(self._RAW) + assert event.attacker_ip == "31.56.209.39" + + def test_command_field_captures_full_cmd_with_spaces(self): + event = parse_line(self._RAW) + assert event.fields["command"] == 'echo "history -cw; rm -rf *.sh" | sh' + + def test_metadata_fields_populated(self): + event = parse_line(self._RAW) + assert event.fields["uid"] == "0" + assert event.fields["user"] == "root" + assert event.fields["pwd"] == "/root" + + class TestParserAttackerIP: def test_src_ip_field(self): event = parse_line(_make_line(src_ip="10.0.0.1"))