fix(profiler): aggregate bash PROMPT_COMMAND lines into attacker profile
SSH/telnet decky containers emit shell commands via `logger -t bash "CMD …"`
which produces RFC 5424 lines with MSGID=NIL. Both parsers were leaving
event_type="-", so the behavioral profiler's `_COMMAND_EVENT_TYPES` filter
silently dropped them — the IP profile existed but no command transcripts
or artifacts. Confirmed in the wild: 44/48 events from one attacker were
event_type="-".
Rewrite event_type to "command" in both parsers when MSGID=NIL and the
msg starts with "CMD ". Correlation parser also extracts the cmd= payload
into fields["command"] so the profiler can build the transcript; collector
parser leaves fields={} to avoid duplicate pills in the dashboard.
This commit is contained in:
@@ -220,6 +220,12 @@ def parse_rfc5424(line: str) -> Optional[dict[str, Any]]:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
ts_formatted = ts_raw
|
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 {
|
return {
|
||||||
"timestamp": ts_formatted,
|
"timestamp": ts_formatted,
|
||||||
"decky": decky,
|
"decky": decky,
|
||||||
|
|||||||
@@ -137,6 +137,18 @@ def parse_line(line: str) -> LogEvent | None:
|
|||||||
msg = tail.group(1).strip() if tail else ""
|
msg = tail.group(1).strip() if tail else ""
|
||||||
attacker_ip = _extract_attacker_ip(fields, msg)
|
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=<rest of line>`. 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
|
# Mutator-emitted transitions arrive on the same ingest stream but
|
||||||
# belong in the substrate-state index, not the per-IP attacker one.
|
# belong in the substrate-state index, not the per-IP attacker one.
|
||||||
kind: EventKind = (
|
kind: EventKind = (
|
||||||
|
|||||||
@@ -209,6 +209,23 @@ class TestParseRfc5424:
|
|||||||
assert result["fields"]["src_ip"] == "1.2.3.4"
|
assert result["fields"]["src_ip"] == "1.2.3.4"
|
||||||
assert result["msg"] == "login attempt"
|
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:
|
class TestIsServiceContainer:
|
||||||
def test_known_container_returns_true(self):
|
def test_known_container_returns_true(self):
|
||||||
|
|||||||
@@ -103,6 +103,40 @@ class TestParserBasic:
|
|||||||
assert event.raw == line.strip()
|
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:
|
class TestParserAttackerIP:
|
||||||
def test_src_ip_field(self):
|
def test_src_ip_field(self):
|
||||||
event = parse_line(_make_line(src_ip="10.0.0.1"))
|
event = parse_line(_make_line(src_ip="10.0.0.1"))
|
||||||
|
|||||||
Reference in New Issue
Block a user