merge testing->tomerge/main #7
@@ -24,7 +24,7 @@ _RFC5424_RE = re.compile(
|
|||||||
r"(\S+) " # 1: TIMESTAMP
|
r"(\S+) " # 1: TIMESTAMP
|
||||||
r"(\S+) " # 2: HOSTNAME (decky name)
|
r"(\S+) " # 2: HOSTNAME (decky name)
|
||||||
r"(\S+) " # 3: APP-NAME (service)
|
r"(\S+) " # 3: APP-NAME (service)
|
||||||
r"\S+ " # PROCID (NILVALUE or PID)
|
r"- " # PROCID always NILVALUE
|
||||||
r"(\S+) " # 4: MSGID (event_type)
|
r"(\S+) " # 4: MSGID (event_type)
|
||||||
r"(.+)$", # 5: SD element + optional MSG
|
r"(.+)$", # 5: SD element + optional MSG
|
||||||
)
|
)
|
||||||
@@ -33,8 +33,6 @@ _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")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def parse_rfc5424(line: str) -> Optional[dict[str, Any]]:
|
def parse_rfc5424(line: str) -> Optional[dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Parse an RFC 5424 DECNET log line into a structured dict.
|
Parse an RFC 5424 DECNET log line into a structured dict.
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ _RFC5424_RE = re.compile(
|
|||||||
r"(\S+) " # 1: TIMESTAMP
|
r"(\S+) " # 1: TIMESTAMP
|
||||||
r"(\S+) " # 2: HOSTNAME (decky name)
|
r"(\S+) " # 2: HOSTNAME (decky name)
|
||||||
r"(\S+) " # 3: APP-NAME (service)
|
r"(\S+) " # 3: APP-NAME (service)
|
||||||
r"\S+ " # PROCID (NILVALUE or PID)
|
r"- " # PROCID always NILVALUE
|
||||||
r"(\S+) " # 4: MSGID (event_type)
|
r"(\S+) " # 4: MSGID (event_type)
|
||||||
r"(.+)$", # 5: SD element + optional MSG
|
r"(.+)$", # 5: SD element + optional MSG
|
||||||
)
|
)
|
||||||
@@ -41,7 +41,6 @@ _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")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class LogEvent:
|
class LogEvent:
|
||||||
"""A single parsed event from a DECNET syslog line."""
|
"""A single parsed event from a DECNET syslog line."""
|
||||||
@@ -100,7 +99,6 @@ def parse_line(line: str) -> LogEvent | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
fields = _parse_sd_params(sd_rest)
|
fields = _parse_sd_params(sd_rest)
|
||||||
|
|
||||||
attacker_ip = _extract_attacker_ip(fields)
|
attacker_ip = _extract_attacker_ip(fields)
|
||||||
|
|
||||||
return LogEvent(
|
return LogEvent(
|
||||||
|
|||||||
@@ -65,8 +65,6 @@ RUN mkdir -p /root/projects /root/backups /var/www/html && \
|
|||||||
printf 'DB_HOST=10.0.0.5\nDB_USER=admin\nDB_PASS=changeme123\nDB_NAME=prod_db\n' > /root/projects/.env && \
|
printf 'DB_HOST=10.0.0.5\nDB_USER=admin\nDB_PASS=changeme123\nDB_NAME=prod_db\n' > /root/projects/.env && \
|
||||||
printf '[Unit]\nDescription=App Server\n[Service]\nExecStart=/usr/bin/python3 /opt/app/server.py\n' > /root/projects/app.service
|
printf '[Unit]\nDescription=App Server\n[Service]\nExecStart=/usr/bin/python3 /opt/app/server.py\n' > /root/projects/app.service
|
||||||
|
|
||||||
COPY decnet_logging.py /opt/decnet_logging.py
|
|
||||||
COPY log_relay.py /opt/log_relay.py
|
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ fi
|
|||||||
# Logging pipeline: named pipe → rsyslogd (RFC 5424) → stdout → Docker log capture
|
# Logging pipeline: named pipe → rsyslogd (RFC 5424) → stdout → Docker log capture
|
||||||
mkfifo /var/run/decnet-logs
|
mkfifo /var/run/decnet-logs
|
||||||
|
|
||||||
# Relay pipe through Python log_relay — normalizes sshd/bash events to DECNET format
|
# Relay pipe to stdout so Docker captures all syslog events
|
||||||
python3 /opt/log_relay.py &
|
cat /var/run/decnet-logs &
|
||||||
|
|
||||||
# Start rsyslog (reads /etc/rsyslog.d/99-decnet.conf, writes to the pipe above)
|
# Start rsyslog (reads /etc/rsyslog.d/99-decnet.conf, writes to the pipe above)
|
||||||
rsyslogd
|
rsyslogd
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
SSH log relay — reads rsyslog output from the named pipe and re-emits
|
|
||||||
matched sshd/bash events as proper DECNET RFC 5424 syslog lines to stdout.
|
|
||||||
|
|
||||||
Matched events:
|
|
||||||
- Accepted password (login_success)
|
|
||||||
- Connection closed (connection_closed)
|
|
||||||
- Disconnected from user (disconnect)
|
|
||||||
- Session opened (session_opened)
|
|
||||||
- bash CMD (command)
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from decnet_logging import syslog_line, write_syslog_file, SEVERITY_INFO, SEVERITY_WARNING
|
|
||||||
|
|
||||||
NODE_NAME = os.environ.get("NODE_NAME", "ssh-decky")
|
|
||||||
SERVICE = "ssh"
|
|
||||||
|
|
||||||
# sshd patterns
|
|
||||||
_ACCEPTED_RE = re.compile(
|
|
||||||
r"Accepted (\S+) for (\S+) from (\S+) port (\d+)"
|
|
||||||
)
|
|
||||||
_SESSION_RE = re.compile(
|
|
||||||
r"session opened for user (\S+?)(?:\(uid=\d+\))? by"
|
|
||||||
)
|
|
||||||
_DISCONNECTED_RE = re.compile(
|
|
||||||
r"Disconnected from user (\S+) (\S+) port (\d+)"
|
|
||||||
)
|
|
||||||
_CONN_CLOSED_RE = re.compile(
|
|
||||||
r"Connection closed by (\S+) port (\d+)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# bash PROMPT_COMMAND pattern
|
|
||||||
_BASH_CMD_RE = re.compile(
|
|
||||||
r"CMD\s+uid=(\S+)\s+pwd=(\S+)\s+cmd=(.*)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _handle_line(line: str) -> None:
|
|
||||||
"""Parse a raw rsyslog line and emit a DECNET syslog line if it matches."""
|
|
||||||
|
|
||||||
# --- Accepted password ---
|
|
||||||
m = _ACCEPTED_RE.search(line)
|
|
||||||
if m:
|
|
||||||
method, user, src_ip, port = m.groups()
|
|
||||||
write_syslog_file(syslog_line(
|
|
||||||
SERVICE, NODE_NAME, "login_success", SEVERITY_WARNING,
|
|
||||||
src_ip=src_ip, username=user, auth_method=method, src_port=port,
|
|
||||||
))
|
|
||||||
return
|
|
||||||
|
|
||||||
# --- Session opened ---
|
|
||||||
m = _SESSION_RE.search(line)
|
|
||||||
if m:
|
|
||||||
user = m.group(1)
|
|
||||||
write_syslog_file(syslog_line(
|
|
||||||
SERVICE, NODE_NAME, "session_opened", SEVERITY_INFO,
|
|
||||||
username=user,
|
|
||||||
))
|
|
||||||
return
|
|
||||||
|
|
||||||
# --- Disconnected from user ---
|
|
||||||
m = _DISCONNECTED_RE.search(line)
|
|
||||||
if m:
|
|
||||||
user, src_ip, port = m.groups()
|
|
||||||
write_syslog_file(syslog_line(
|
|
||||||
SERVICE, NODE_NAME, "disconnect", SEVERITY_INFO,
|
|
||||||
src_ip=src_ip, username=user, src_port=port,
|
|
||||||
))
|
|
||||||
return
|
|
||||||
|
|
||||||
# --- Connection closed ---
|
|
||||||
m = _CONN_CLOSED_RE.search(line)
|
|
||||||
if m:
|
|
||||||
src_ip, port = m.groups()
|
|
||||||
write_syslog_file(syslog_line(
|
|
||||||
SERVICE, NODE_NAME, "connection_closed", SEVERITY_INFO,
|
|
||||||
src_ip=src_ip, src_port=port,
|
|
||||||
))
|
|
||||||
return
|
|
||||||
|
|
||||||
# --- bash CMD ---
|
|
||||||
m = _BASH_CMD_RE.search(line)
|
|
||||||
if m:
|
|
||||||
uid, pwd, cmd = m.groups()
|
|
||||||
write_syslog_file(syslog_line(
|
|
||||||
SERVICE, NODE_NAME, "command", SEVERITY_INFO,
|
|
||||||
uid=uid, pwd=pwd, command=cmd,
|
|
||||||
))
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
pipe_path = "/var/run/decnet-logs"
|
|
||||||
while True:
|
|
||||||
with open(pipe_path, "r") as pipe:
|
|
||||||
for line in pipe:
|
|
||||||
_handle_line(line.rstrip("\n"))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -131,14 +131,6 @@ class TestParseRfc5424:
|
|||||||
assert result["msg"] == "login attempt"
|
assert result["msg"] == "login attempt"
|
||||||
|
|
||||||
|
|
||||||
def test_non_nil_procid_accepted(self):
|
|
||||||
line = '<38>1 2026-04-14T05:48:12.611006+00:00 SRV-BRAVO-13 sshd 282 - - Accepted password for root from 192.168.1.5 port 50854 ssh2'
|
|
||||||
result = parse_rfc5424(line)
|
|
||||||
assert result is not None
|
|
||||||
assert result["service"] == "sshd"
|
|
||||||
assert result["decky"] == "SRV-BRAVO-13"
|
|
||||||
|
|
||||||
|
|
||||||
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,19 +155,6 @@ class TestParserAttackerIP:
|
|||||||
assert parse_line(line) is None
|
assert parse_line(line) is None
|
||||||
|
|
||||||
|
|
||||||
class TestParserProcidFlexibility:
|
|
||||||
def test_non_nil_procid_accepted(self):
|
|
||||||
line = '<38>1 2026-04-14T05:48:12.611006+00:00 SRV-BRAVO-13 sshd 282 - - Accepted password for root'
|
|
||||||
event = parse_line(line)
|
|
||||||
assert event is not None
|
|
||||||
assert event.service == "sshd"
|
|
||||||
assert event.decky == "SRV-BRAVO-13"
|
|
||||||
|
|
||||||
def test_nil_procid_still_works(self):
|
|
||||||
event = parse_line(_make_line())
|
|
||||||
assert event is not None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# graph.py — AttackerTraversal
|
# graph.py — AttackerTraversal
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
"""Tests for the SSH log relay that normalizes sshd/bash events."""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import types
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
_SSH_TPL = str(Path(__file__).resolve().parent.parent / "templates" / "ssh")
|
|
||||||
|
|
||||||
|
|
||||||
def _load_relay():
|
|
||||||
"""Import log_relay with a real decnet_logging from the SSH template dir."""
|
|
||||||
# Clear any stale stubs
|
|
||||||
for mod_name in ("decnet_logging", "log_relay"):
|
|
||||||
sys.modules.pop(mod_name, None)
|
|
||||||
|
|
||||||
if _SSH_TPL not in sys.path:
|
|
||||||
sys.path.insert(0, _SSH_TPL)
|
|
||||||
|
|
||||||
import log_relay
|
|
||||||
return log_relay
|
|
||||||
|
|
||||||
|
|
||||||
_relay = _load_relay()
|
|
||||||
|
|
||||||
|
|
||||||
def _capture(line: str) -> str | None:
|
|
||||||
"""Run _handle_line, collect output via monkey-patched write_syslog_file."""
|
|
||||||
collected: list[str] = []
|
|
||||||
original = _relay.write_syslog_file
|
|
||||||
_relay.write_syslog_file = lambda s: collected.append(s)
|
|
||||||
try:
|
|
||||||
_relay._handle_line(line)
|
|
||||||
finally:
|
|
||||||
_relay.write_syslog_file = original
|
|
||||||
return collected[0] if collected else None
|
|
||||||
|
|
||||||
|
|
||||||
class TestSshdAcceptedPassword:
|
|
||||||
def test_accepted_password_emits_login_success(self):
|
|
||||||
emitted = _capture(
|
|
||||||
'<38>1 2026-04-14T05:48:12.611006+00:00 SRV-BRAVO-13 sshd 282 - - Accepted password for root from 192.168.1.5 port 50854 ssh2'
|
|
||||||
)
|
|
||||||
assert emitted is not None
|
|
||||||
assert "login_success" in emitted
|
|
||||||
assert 'src_ip="192.168.1.5"' in emitted
|
|
||||||
assert 'username="root"' in emitted
|
|
||||||
assert 'auth_method="password"' in emitted
|
|
||||||
|
|
||||||
def test_accepted_publickey(self):
|
|
||||||
emitted = _capture(
|
|
||||||
'<38>1 2026-04-14T05:48:12.611006+00:00 SRV-BRAVO-13 sshd 282 - - Accepted publickey for admin from 10.0.0.1 port 12345 ssh2'
|
|
||||||
)
|
|
||||||
assert emitted is not None
|
|
||||||
assert 'auth_method="publickey"' in emitted
|
|
||||||
assert 'username="admin"' in emitted
|
|
||||||
|
|
||||||
|
|
||||||
class TestSshdSessionOpened:
|
|
||||||
def test_session_opened(self):
|
|
||||||
emitted = _capture(
|
|
||||||
'<86>1 2026-04-14T05:48:12.611880+00:00 SRV-BRAVO-13 sshd 282 - - pam_unix(sshd:session): session opened for user root(uid=0) by (uid=0)'
|
|
||||||
)
|
|
||||||
assert emitted is not None
|
|
||||||
assert "session_opened" in emitted
|
|
||||||
assert 'username="root"' in emitted
|
|
||||||
|
|
||||||
|
|
||||||
class TestSshdDisconnected:
|
|
||||||
def test_disconnected(self):
|
|
||||||
emitted = _capture(
|
|
||||||
'<38>1 2026-04-14T05:54:50.710536+00:00 SRV-BRAVO-13 sshd 282 - - Disconnected from user root 192.168.1.5 port 50854'
|
|
||||||
)
|
|
||||||
assert emitted is not None
|
|
||||||
assert "disconnect" in emitted
|
|
||||||
assert 'src_ip="192.168.1.5"' in emitted
|
|
||||||
assert 'username="root"' in emitted
|
|
||||||
|
|
||||||
|
|
||||||
class TestSshdConnectionClosed:
|
|
||||||
def test_connection_closed(self):
|
|
||||||
emitted = _capture(
|
|
||||||
'<38>1 2026-04-14T05:47:55.621236+00:00 SRV-BRAVO-13 sshd 280 - - Connection closed by 192.168.1.5 port 52900 [preauth]'
|
|
||||||
)
|
|
||||||
assert emitted is not None
|
|
||||||
assert "connection_closed" in emitted
|
|
||||||
assert 'src_ip="192.168.1.5"' in emitted
|
|
||||||
|
|
||||||
|
|
||||||
class TestBashCommand:
|
|
||||||
def test_bash_cmd(self):
|
|
||||||
emitted = _capture(
|
|
||||||
'<14>1 2026-04-14T05:48:12.628417+00:00 SRV-BRAVO-13 bash - - - CMD uid=0 pwd=/root cmd=ls /var/www/html'
|
|
||||||
)
|
|
||||||
assert emitted is not None
|
|
||||||
assert "command" in emitted
|
|
||||||
assert 'command="ls /var/www/html"' in emitted
|
|
||||||
|
|
||||||
def test_bash_cmd_with_pipes(self):
|
|
||||||
emitted = _capture(
|
|
||||||
'<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'
|
|
||||||
)
|
|
||||||
assert emitted is not None
|
|
||||||
assert "cat /etc/passwd | grep root" in emitted
|
|
||||||
|
|
||||||
|
|
||||||
class TestUnmatchedLines:
|
|
||||||
def test_pam_env_ignored(self):
|
|
||||||
assert _capture('<83>1 2026-04-14T05:48:12.615198+00:00 SRV-BRAVO-13 sshd 282 - - pam_env(sshd:session): Unable to open env file') is None
|
|
||||||
|
|
||||||
def test_session_closed_ignored(self):
|
|
||||||
assert _capture('<86>1 2026-04-14T05:54:50.710577+00:00 SRV-BRAVO-13 sshd 282 - - pam_unix(sshd:session): session closed for user root') is None
|
|
||||||
|
|
||||||
def test_syslogin_ignored(self):
|
|
||||||
assert _capture('<38>1 2026-04-14T05:54:50.710307+00:00 SRV-BRAVO-13 sshd 282 - - syslogin_perform_logout: logout() returned an error') is None
|
|
||||||
Reference in New Issue
Block a user