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:
@@ -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)},
|
||||||
|
|||||||
@@ -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 && \
|
||||||
@@ -35,26 +56,21 @@ 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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
90
tests/test_ssh.py
Normal 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", {})
|
||||||
Reference in New Issue
Block a user