feat(ssh): replace Cowrie with real OpenSSH + rsyslog logging pipeline

Scraps the Cowrie emulation layer. The real_ssh template now runs a
genuine sshd backed by a three-layer logging stack forwarded to stdout
as RFC 5424 for the DECNET collector:

  auth,authpriv.*  → rsyslogd → named pipe → stdout  (logins/failures)
  user.*           → rsyslogd → named pipe → stdout  (PROMPT_COMMAND cmds)
  sudo syslog=auth → rsyslogd → named pipe → stdout  (privilege escalation)
  sudo logfile     → /var/log/sudo.log               (local backup with I/O)

The ssh.py service plugin now points to templates/real_ssh and drops all
COWRIE_* / NODE_NAME env vars, sharing the same compose fragment shape as
real_ssh.py.
This commit is contained in:
2026-04-11 19:12:54 -04:00
parent 9ca3b4691d
commit d4ac53c0c9
5 changed files with 209 additions and 37 deletions

View File

@@ -1,12 +1,26 @@
from pathlib import Path from pathlib import Path
from decnet.services.base import BaseService from decnet.services.base import BaseService
TEMPLATES_DIR = Path(__file__).parent.parent.parent / "templates" / "cowrie" TEMPLATES_DIR = Path(__file__).parent.parent.parent / "templates" / "real_ssh"
class SSHService(BaseService): class SSHService(BaseService):
"""
Interactive OpenSSH server for general-purpose deckies.
Replaced Cowrie emulation with a real sshd so fingerprinting tools and
experienced attackers cannot trivially identify the honeypot. Auth events,
sudo activity, and interactive commands are all forwarded to stdout as
RFC 5424 via the rsyslog bridge baked into the image.
service_cfg keys:
password Root password (default: "admin")
hostname Override container hostname
"""
name = "ssh" name = "ssh"
ports = [22, 2222] ports = [22]
default_image = "build" default_image = "build"
def compose_fragment( def compose_fragment(
@@ -17,28 +31,10 @@ class SSHService(BaseService):
) -> dict: ) -> dict:
cfg = service_cfg or {} cfg = service_cfg or {}
env: dict = { env: dict = {
"NODE_NAME": decky_name, "SSH_ROOT_PASSWORD": cfg.get("password", "admin"),
"COWRIE_HOSTNAME": decky_name,
"COWRIE_HONEYPOT_LISTEN_ENDPOINTS": "tcp:22:interface=0.0.0.0 tcp:2222:interface=0.0.0.0",
"COWRIE_SSH_LISTEN_ENDPOINTS": "tcp:22:interface=0.0.0.0 tcp:2222:interface=0.0.0.0",
} }
if log_target: if "hostname" in cfg:
host, port = log_target.rsplit(":", 1) env["SSH_HOSTNAME"] = cfg["hostname"]
env["COWRIE_OUTPUT_TCP_ENABLED"] = "true"
env["COWRIE_OUTPUT_TCP_HOST"] = host
env["COWRIE_OUTPUT_TCP_PORT"] = port
# Optional persona overrides
if "kernel_version" in cfg:
env["COWRIE_HONEYPOT_KERNEL_VERSION"] = cfg["kernel_version"]
if "kernel_build_string" in cfg:
env["COWRIE_HONEYPOT_KERNEL_BUILD_STRING"] = cfg["kernel_build_string"]
if "hardware_platform" in cfg:
env["COWRIE_HONEYPOT_HARDWARE_PLATFORM"] = cfg["hardware_platform"]
if "ssh_banner" in cfg:
env["COWRIE_SSH_VERSION"] = cfg["ssh_banner"]
if "users" in cfg:
env["COWRIE_USERDB_ENTRIES"] = cfg["users"]
return { return {
"build": {"context": str(TEMPLATES_DIR)}, "build": {"context": str(TEMPLATES_DIR)},

View File

@@ -4,6 +4,7 @@ FROM ${BASE_IMAGE}
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
openssh-server \ openssh-server \
sudo \ sudo \
rsyslog \
curl \ curl \
wget \ wget \
vim \ vim \
@@ -14,7 +15,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
git \ git \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN mkdir -p /var/run/sshd /root/.ssh RUN mkdir -p /var/run/sshd /root/.ssh /var/log/decnet
# sshd_config: allow root + password auth # sshd_config: allow root + password auth
RUN sed -i \ RUN sed -i \
@@ -23,6 +24,26 @@ RUN sed -i \
-e 's|^#\?ChallengeResponseAuthentication.*|ChallengeResponseAuthentication no|' \ -e 's|^#\?ChallengeResponseAuthentication.*|ChallengeResponseAuthentication no|' \
/etc/ssh/sshd_config /etc/ssh/sshd_config
# rsyslog: forward auth.* and user.* to named pipe in RFC 5424 format.
# The entrypoint relays the pipe to stdout for Docker log capture.
RUN printf '%s\n' \
'# DECNET log bridge — auth + user events → named pipe as RFC 5424' \
'$template RFC5424fmt,"<%PRI%>1 %TIMESTAMP:::date-rfc3339% %HOSTNAME% %APP-NAME% %PROCID% %MSGID% %STRUCTURED-DATA% %msg%\n"' \
'auth,authpriv.* |/var/run/decnet-logs;RFC5424fmt' \
'user.* |/var/run/decnet-logs;RFC5424fmt' \
> /etc/rsyslog.d/99-decnet.conf
# Silence default catch-all rules so we own auth/user routing exclusively
RUN sed -i \
-e 's|^\(\*\.\*;auth,authpriv\.none\)|#\1|' \
-e 's|^auth,authpriv\.\*|#auth,authpriv.*|' \
/etc/rsyslog.conf
# Sudo: log to syslog (auth facility) AND a local file with full I/O capture
RUN echo 'Defaults logfile="/var/log/sudo.log"' >> /etc/sudoers && \
echo 'Defaults syslog=auth' >> /etc/sudoers && \
echo 'Defaults log_input,log_output' >> /etc/sudoers
# Lived-in environment: motd, shell aliases, fake project files # Lived-in environment: motd, shell aliases, fake project files
RUN echo "Ubuntu 22.04.3 LTS" > /etc/issue.net && \ RUN echo "Ubuntu 22.04.3 LTS" > /etc/issue.net && \
echo "Welcome to Ubuntu 22.04.3 LTS (GNU/Linux 5.15.0-88-generic x86_64)" > /etc/motd && \ echo "Welcome to Ubuntu 22.04.3 LTS (GNU/Linux 5.15.0-88-generic x86_64)" > /etc/motd && \
@@ -32,29 +53,24 @@ RUN echo "Ubuntu 22.04.3 LTS" > /etc/issue.net && \
echo " * Support: https://ubuntu.com/advantage" >> /etc/motd echo " * Support: https://ubuntu.com/advantage" >> /etc/motd
RUN echo 'alias ll="ls -alF"' >> /root/.bashrc && \ RUN echo 'alias ll="ls -alF"' >> /root/.bashrc && \
echo 'alias la="ls -A"' >> /root/.bashrc && \ echo 'alias la="ls -A"' >> /root/.bashrc && \
echo 'alias l="ls -CF"' >> /root/.bashrc && \ echo 'alias l="ls -CF"' >> /root/.bashrc && \
echo 'export HISTSIZE=1000' >> /root/.bashrc && \ echo 'export HISTSIZE=1000' >> /root/.bashrc && \
echo 'export HISTFILESIZE=2000' >> /root/.bashrc echo 'export HISTFILESIZE=2000' >> /root/.bashrc && \
echo 'PROMPT_COMMAND='"'"'logger -p user.info -t bash "CMD uid=$UID pwd=$PWD cmd=$(history 1 | sed "s/^ *[0-9]* *//")";'"'" >> /root/.bashrc
# Fake project files to look lived-in # Fake project files to look lived-in
RUN mkdir -p /root/projects /root/backups /var/www/html && \ RUN mkdir -p /root/projects /root/backups /var/www/html && \
echo "# TODO: migrate DB to new server\n# check cron jobs\n# update SSL cert" > /root/notes.txt && \ printf '# TODO: migrate DB to new server\n# check cron jobs\n# update SSL cert\n' > /root/notes.txt && \
echo "DB_HOST=10.0.0.5\nDB_USER=admin\nDB_PASS=changeme123\nDB_NAME=prod_db" > /root/projects/.env && \ printf 'DB_HOST=10.0.0.5\nDB_USER=admin\nDB_PASS=changeme123\nDB_NAME=prod_db\n' > /root/projects/.env && \
echo "[Unit]\nDescription=App Server\n[Service]\nExecStart=/usr/bin/python3 /opt/app/server.py" > /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 entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
EXPOSE 22 EXPOSE 22
RUN useradd -r -s /bin/false -d /opt decnet \
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
&& rm -rf /var/lib/apt/lists/* \
&& (find /usr/bin/ -maxdepth 1 -name 'python3*' -type f -exec setcap 'cap_net_bind_service+eip' {} \; 2>/dev/null || true)
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD kill -0 1 || exit 1 CMD kill -0 1 || exit 1
USER decnet
ENTRYPOINT ["/entrypoint.sh"] ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -31,4 +31,14 @@ ls /var/www/html
HIST HIST
fi fi
exec /usr/sbin/sshd -D -e # Logging pipeline: named pipe → rsyslogd (RFC 5424) → stdout → Docker log capture
mkfifo /var/run/decnet-logs
# Relay pipe to stdout so Docker captures all syslog events
cat /var/run/decnet-logs &
# Start rsyslog (reads /etc/rsyslog.d/99-decnet.conf, writes to the pipe above)
rsyslogd
# sshd logs via syslog — no -e flag, so auth events flow through rsyslog → pipe → stdout
exec /usr/sbin/sshd -D

View File

@@ -2,6 +2,7 @@
Tests for the RealSSHService plugin and the deaddeck archetype. Tests for the RealSSHService plugin and the deaddeck archetype.
""" """
from pathlib import Path
from decnet.services.registry import all_services, get_service from decnet.services.registry import all_services, get_service
from decnet.archetypes import get_archetype from decnet.archetypes import get_archetype
@@ -126,3 +127,62 @@ def test_deaddeck_nmap_os():
def test_deaddeck_preferred_distros_not_empty(): def test_deaddeck_preferred_distros_not_empty():
arch = get_archetype("deaddeck") arch = get_archetype("deaddeck")
assert len(arch.preferred_distros) >= 1 assert len(arch.preferred_distros) >= 1
# ---------------------------------------------------------------------------
# Logging pipeline wiring (Dockerfile + entrypoint)
# ---------------------------------------------------------------------------
def _dockerfile_text() -> str:
svc = get_service("real_ssh")
return (svc.dockerfile_context() / "Dockerfile").read_text()
def _entrypoint_text() -> str:
svc = get_service("real_ssh")
return (svc.dockerfile_context() / "entrypoint.sh").read_text()
def test_dockerfile_has_rsyslog():
assert "rsyslog" in _dockerfile_text()
def test_dockerfile_runs_as_root():
"""sshd requires root — no USER directive should appear after setup."""
lines = [l.strip() for l in _dockerfile_text().splitlines()]
user_lines = [l for l in lines if l.startswith("USER ")]
assert user_lines == [], f"Unexpected USER directive(s): {user_lines}"
def test_dockerfile_rsyslog_conf_created():
df = _dockerfile_text()
assert "99-decnet.conf" in df
assert "RFC5424fmt" in df
def test_dockerfile_sudoers_syslog():
df = _dockerfile_text()
assert "syslog=auth" in df
assert "log_input" in df
assert "log_output" in df
def test_dockerfile_prompt_command_logger():
df = _dockerfile_text()
assert "PROMPT_COMMAND" in df
assert "logger" in df
def test_entrypoint_creates_named_pipe():
assert "mkfifo" in _entrypoint_text()
def test_entrypoint_starts_rsyslogd():
assert "rsyslogd" in _entrypoint_text()
def test_entrypoint_sshd_no_dash_e():
ep = _entrypoint_text()
assert "sshd -D" in ep
# -e flag would bypass syslog; must not be present
assert "sshd -D -e" not in ep

90
tests/test_ssh.py Normal file
View File

@@ -0,0 +1,90 @@
"""
Tests for the SSHService plugin (real OpenSSH, Cowrie removed).
"""
from decnet.services.registry import all_services, get_service
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _fragment(service_cfg: dict | None = None, log_target: str | None = None) -> dict:
return get_service("ssh").compose_fragment(
"test-decky", log_target=log_target, service_cfg=service_cfg
)
# ---------------------------------------------------------------------------
# Registration
# ---------------------------------------------------------------------------
def test_ssh_registered():
assert "ssh" in all_services()
def test_ssh_ports():
assert get_service("ssh").ports == [22]
def test_ssh_is_build_service():
assert get_service("ssh").default_image == "build"
def test_ssh_dockerfile_context_exists():
svc = get_service("ssh")
ctx = svc.dockerfile_context()
assert ctx.is_dir(), f"Dockerfile context missing: {ctx}"
assert (ctx / "Dockerfile").exists()
assert (ctx / "entrypoint.sh").exists()
# ---------------------------------------------------------------------------
# No Cowrie env vars
# ---------------------------------------------------------------------------
def test_no_cowrie_vars():
env = _fragment()["environment"]
cowrie_keys = [k for k in env if k.startswith("COWRIE_") or k == "NODE_NAME"]
assert cowrie_keys == [], f"Unexpected Cowrie vars: {cowrie_keys}"
# ---------------------------------------------------------------------------
# compose_fragment structure
# ---------------------------------------------------------------------------
def test_fragment_has_build():
frag = _fragment()
assert "build" in frag and "context" in frag["build"]
def test_fragment_container_name():
assert _fragment()["container_name"] == "test-decky-ssh"
def test_fragment_restart_policy():
assert _fragment()["restart"] == "unless-stopped"
def test_fragment_cap_add():
assert "NET_BIND_SERVICE" in _fragment().get("cap_add", [])
def test_default_password():
assert _fragment()["environment"]["SSH_ROOT_PASSWORD"] == "admin"
def test_custom_password():
assert _fragment(service_cfg={"password": "h4x!"})["environment"]["SSH_ROOT_PASSWORD"] == "h4x!"
def test_custom_hostname():
assert _fragment(service_cfg={"hostname": "prod-db-01"})["environment"]["SSH_HOSTNAME"] == "prod-db-01"
def test_no_hostname_by_default():
assert "SSH_HOSTNAME" not in _fragment()["environment"]
def test_no_log_target_in_env():
assert "LOG_TARGET" not in _fragment(log_target="10.0.0.1:5140").get("environment", {})