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.
This commit is contained in:
@@ -32,6 +32,10 @@ _SD_BLOCK_RE = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL)
|
|||||||
_PARAM_RE = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
|
_PARAM_RE = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
|
||||||
_IP_FIELDS = ("src_ip", "src", "client_ip", "remote_ip", "ip")
|
_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]]:
|
def parse_rfc5424(line: str) -> Optional[dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
@@ -70,6 +74,16 @@ def parse_rfc5424(line: str) -> Optional[dict[str, Any]]:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
ts_formatted = ts_raw
|
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 {
|
return {
|
||||||
"timestamp": ts_formatted,
|
"timestamp": ts_formatted,
|
||||||
"decky": decky,
|
"decky": decky,
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ _PARAM_RE = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
|
|||||||
# Field names to probe for attacker IP, in priority order
|
# Field names to probe for attacker IP, in priority order
|
||||||
_IP_FIELDS = ("src_ip", "src", "client_ip", "remote_ip", "ip")
|
_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
|
@dataclass
|
||||||
class LogEvent:
|
class LogEvent:
|
||||||
@@ -99,6 +102,19 @@ def parse_line(line: str) -> LogEvent | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
fields = _parse_sd_params(sd_rest)
|
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)
|
attacker_ip = _extract_attacker_ip(fields)
|
||||||
|
|
||||||
return LogEvent(
|
return LogEvent(
|
||||||
|
|||||||
@@ -131,6 +131,32 @@ class TestParseRfc5424:
|
|||||||
assert result["msg"] == "login attempt"
|
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:
|
class TestIsServiceContainer:
|
||||||
def test_known_container_returns_true(self):
|
def test_known_container_returns_true(self):
|
||||||
with patch("decnet.collector.worker._load_service_container_names", return_value=_KNOWN_NAMES):
|
with patch("decnet.collector.worker._load_service_container_names", return_value=_KNOWN_NAMES):
|
||||||
|
|||||||
@@ -155,6 +155,39 @@ class TestParserAttackerIP:
|
|||||||
assert parse_line(line) is None
|
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
|
# graph.py — AttackerTraversal
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user