diff --git a/decnet/services/ssh.py b/decnet/services/ssh.py index 2377d57..427e92e 100644 --- a/decnet/services/ssh.py +++ b/decnet/services/ssh.py @@ -1,12 +1,26 @@ from pathlib import Path + 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): + """ + 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" - ports = [22, 2222] + ports = [22] default_image = "build" def compose_fragment( @@ -17,28 +31,10 @@ class SSHService(BaseService): ) -> dict: cfg = service_cfg or {} env: dict = { - "NODE_NAME": decky_name, - "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", + "SSH_ROOT_PASSWORD": cfg.get("password", "admin"), } - if log_target: - host, port = log_target.rsplit(":", 1) - 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"] + if "hostname" in cfg: + env["SSH_HOSTNAME"] = cfg["hostname"] return { "build": {"context": str(TEMPLATES_DIR)}, diff --git a/templates/real_ssh/Dockerfile b/templates/real_ssh/Dockerfile index 81052c9..230d429 100644 --- a/templates/real_ssh/Dockerfile +++ b/templates/real_ssh/Dockerfile @@ -4,6 +4,7 @@ FROM ${BASE_IMAGE} RUN apt-get update && apt-get install -y --no-install-recommends \ openssh-server \ sudo \ + rsyslog \ curl \ wget \ vim \ @@ -14,7 +15,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ git \ && 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 RUN sed -i \ @@ -23,6 +24,26 @@ RUN sed -i \ -e 's|^#\?ChallengeResponseAuthentication.*|ChallengeResponseAuthentication no|' \ /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 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 && \ @@ -32,29 +53,24 @@ RUN echo "Ubuntu 22.04.3 LTS" > /etc/issue.net && \ echo " * Support: https://ubuntu.com/advantage" >> /etc/motd RUN echo 'alias ll="ls -alF"' >> /root/.bashrc && \ - echo 'alias la="ls -A"' >> /root/.bashrc && \ - echo 'alias l="ls -CF"' >> /root/.bashrc && \ + echo 'alias la="ls -A"' >> /root/.bashrc && \ + echo 'alias l="ls -CF"' >> /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 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 && \ - echo "DB_HOST=10.0.0.5\nDB_USER=admin\nDB_PASS=changeme123\nDB_NAME=prod_db" > /root/projects/.env && \ - echo "[Unit]\nDescription=App Server\n[Service]\nExecStart=/usr/bin/python3 /opt/app/server.py" > /root/projects/app.service + printf '# TODO: migrate DB to new server\n# check cron jobs\n# update SSL cert\n' > /root/notes.txt && \ + 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 COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh 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 \ CMD kill -0 1 || exit 1 -USER decnet ENTRYPOINT ["/entrypoint.sh"] diff --git a/templates/real_ssh/entrypoint.sh b/templates/real_ssh/entrypoint.sh index 267886c..c5c8291 100644 --- a/templates/real_ssh/entrypoint.sh +++ b/templates/real_ssh/entrypoint.sh @@ -31,4 +31,14 @@ ls /var/www/html HIST 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 diff --git a/tests/test_real_ssh.py b/tests/test_real_ssh.py index 3ded9f5..7fd9a6d 100644 --- a/tests/test_real_ssh.py +++ b/tests/test_real_ssh.py @@ -2,6 +2,7 @@ Tests for the RealSSHService plugin and the deaddeck archetype. """ +from pathlib import Path from decnet.services.registry import all_services, get_service from decnet.archetypes import get_archetype @@ -126,3 +127,62 @@ def test_deaddeck_nmap_os(): def test_deaddeck_preferred_distros_not_empty(): arch = get_archetype("deaddeck") 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 diff --git a/tests/test_ssh.py b/tests/test_ssh.py new file mode 100644 index 0000000..13d8c2d --- /dev/null +++ b/tests/test_ssh.py @@ -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", {})