From 7aff04057963ba1f33ce1bc4e96a06da73edbe04 Mon Sep 17 00:00:00 2001 From: anti Date: Sat, 4 Apr 2026 13:42:19 -0300 Subject: [PATCH] Add deaddeck: real interactive SSH entry-point machine Introduces the 'real_ssh' service plugin backed by a genuine OpenSSH server (not cowrie), and the 'deaddeck' archetype that uses it. The container ships with a lived-in Linux environment and a deliberately weak root:admin credential to invite exploitation. - templates/real_ssh/: Dockerfile + entrypoint (configurable via env) - decnet/services/real_ssh.py: BaseService plugin, service_cfg supports password and hostname overrides - decnet/archetypes.py: deaddeck archetype added - tests/test_real_ssh.py: 17 tests covering registration, compose fragment structure, overrides, and archetype Co-Authored-By: Claude Sonnet 4.6 --- decnet/archetypes.py | 8 ++ decnet/services/real_ssh.py | 46 +++++++++++ templates/real_ssh/Dockerfile | 51 ++++++++++++ templates/real_ssh/entrypoint.sh | 34 ++++++++ tests/test_real_ssh.py | 130 +++++++++++++++++++++++++++++++ 5 files changed, 269 insertions(+) create mode 100644 decnet/services/real_ssh.py create mode 100644 templates/real_ssh/Dockerfile create mode 100644 templates/real_ssh/entrypoint.sh create mode 100644 tests/test_real_ssh.py diff --git a/decnet/archetypes.py b/decnet/archetypes.py index d43b837..e4145c8 100644 --- a/decnet/archetypes.py +++ b/decnet/archetypes.py @@ -144,6 +144,14 @@ ARCHETYPES: dict[str, Archetype] = { preferred_distros=["ubuntu22", "debian"], nmap_os="linux", ), + "deaddeck": Archetype( + slug="deaddeck", + display_name="Deaddeck (Entry Point)", + description="Internet-facing entry point with real interactive SSH — no honeypot emulation", + services=["real_ssh"], + preferred_distros=["debian", "ubuntu22"], + nmap_os="linux", + ), } diff --git a/decnet/services/real_ssh.py b/decnet/services/real_ssh.py new file mode 100644 index 0000000..328fb30 --- /dev/null +++ b/decnet/services/real_ssh.py @@ -0,0 +1,46 @@ +from pathlib import Path + +from decnet.services.base import BaseService + +TEMPLATES_DIR = Path(__file__).parent.parent.parent / "templates" / "real_ssh" + + +class RealSSHService(BaseService): + """ + Fully interactive OpenSSH server — no honeypot emulation. + + Used for the deaddeck (entry-point machine). Attackers get a real shell. + Credentials are intentionally weak to invite exploitation. + + service_cfg keys: + password Root password (default: "admin") + hostname Override container hostname + """ + + name = "real_ssh" + ports = [22] + default_image = "build" + + def compose_fragment( + self, + decky_name: str, + log_target: str | None = None, + service_cfg: dict | None = None, + ) -> dict: + cfg = service_cfg or {} + env: dict = { + "SSH_ROOT_PASSWORD": cfg.get("password", "admin"), + } + if "hostname" in cfg: + env["SSH_HOSTNAME"] = cfg["hostname"] + + return { + "build": {"context": str(TEMPLATES_DIR)}, + "container_name": f"{decky_name}-real-ssh", + "restart": "unless-stopped", + "cap_add": ["NET_BIND_SERVICE"], + "environment": env, + } + + def dockerfile_context(self) -> Path: + return TEMPLATES_DIR diff --git a/templates/real_ssh/Dockerfile b/templates/real_ssh/Dockerfile new file mode 100644 index 0000000..a0a0c22 --- /dev/null +++ b/templates/real_ssh/Dockerfile @@ -0,0 +1,51 @@ +ARG BASE_IMAGE=debian:bookworm-slim +FROM ${BASE_IMAGE} + +RUN apt-get update && apt-get install -y --no-install-recommends \ + openssh-server \ + sudo \ + curl \ + wget \ + vim \ + nano \ + net-tools \ + procps \ + htop \ + git \ + && rm -rf /var/lib/apt/lists/* + +RUN mkdir -p /var/run/sshd /root/.ssh + +# sshd_config: allow root + password auth +RUN sed -i \ + -e 's|^#\?PermitRootLogin.*|PermitRootLogin yes|' \ + -e 's|^#\?PasswordAuthentication.*|PasswordAuthentication yes|' \ + -e 's|^#\?ChallengeResponseAuthentication.*|ChallengeResponseAuthentication no|' \ + /etc/ssh/sshd_config + +# 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 && \ + echo "" >> /etc/motd && \ + echo " * Documentation: https://help.ubuntu.com" >> /etc/motd && \ + echo " * Management: https://landscape.canonical.com" >> /etc/motd && \ + 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 'export HISTSIZE=1000' >> /root/.bashrc && \ + echo 'export HISTFILESIZE=2000' >> /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 + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 22 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/templates/real_ssh/entrypoint.sh b/templates/real_ssh/entrypoint.sh new file mode 100644 index 0000000..267886c --- /dev/null +++ b/templates/real_ssh/entrypoint.sh @@ -0,0 +1,34 @@ +#!/bin/bash +set -e + +# Configure root password (default: admin) +ROOT_PASSWORD="${SSH_ROOT_PASSWORD:-admin}" +echo "root:${ROOT_PASSWORD}" | chpasswd + +# Optional: override hostname inside container +if [ -n "$SSH_HOSTNAME" ]; then + echo "$SSH_HOSTNAME" > /etc/hostname + hostname "$SSH_HOSTNAME" +fi + +# Generate host keys if missing (first boot) +ssh-keygen -A + +# Fake bash history so the box looks used +if [ ! -f /root/.bash_history ]; then + cat > /root/.bash_history <<'HIST' +apt update && apt upgrade -y +systemctl status nginx +tail -f /var/log/syslog +df -h +htop +ps aux | grep python +git pull origin main +cd /root/projects +vim notes.txt +crontab -e +ls /var/www/html +HIST +fi + +exec /usr/sbin/sshd -D -e diff --git a/tests/test_real_ssh.py b/tests/test_real_ssh.py new file mode 100644 index 0000000..8492832 --- /dev/null +++ b/tests/test_real_ssh.py @@ -0,0 +1,130 @@ +""" +Tests for the RealSSHService plugin and the deaddeck archetype. +""" + +import pytest +from pathlib import Path + +from decnet.services.registry import all_services, get_service +from decnet.archetypes import get_archetype + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _fragment(service_cfg: dict | None = None, log_target: str | None = None) -> dict: + return get_service("real_ssh").compose_fragment( + "test-decky", log_target=log_target, service_cfg=service_cfg + ) + + +# --------------------------------------------------------------------------- +# Registration +# --------------------------------------------------------------------------- + +def test_real_ssh_registered(): + assert "real_ssh" in all_services() + + +def test_real_ssh_ports(): + svc = get_service("real_ssh") + assert svc.ports == [22] + + +def test_real_ssh_is_build_service(): + svc = get_service("real_ssh") + assert svc.default_image == "build" + + +def test_real_ssh_dockerfile_context_exists(): + svc = get_service("real_ssh") + ctx = svc.dockerfile_context() + assert ctx is not None + assert ctx.is_dir(), f"Dockerfile context directory missing: {ctx}" + assert (ctx / "Dockerfile").exists(), "Dockerfile missing in real_ssh template dir" + assert (ctx / "entrypoint.sh").exists(), "entrypoint.sh missing in real_ssh template dir" + + +# --------------------------------------------------------------------------- +# compose_fragment structure +# --------------------------------------------------------------------------- + +def test_compose_fragment_has_build(): + frag = _fragment() + assert "build" in frag + assert "context" in frag["build"] + + +def test_compose_fragment_container_name(): + frag = _fragment() + assert frag["container_name"] == "test-decky-real-ssh" + + +def test_compose_fragment_restart_policy(): + frag = _fragment() + assert frag["restart"] == "unless-stopped" + + +def test_compose_fragment_cap_add(): + frag = _fragment() + assert "NET_BIND_SERVICE" in frag.get("cap_add", []) + + +def test_compose_fragment_default_password(): + frag = _fragment() + env = frag["environment"] + assert env["SSH_ROOT_PASSWORD"] == "admin" + + +# --------------------------------------------------------------------------- +# service_cfg overrides +# --------------------------------------------------------------------------- + +def test_custom_password(): + frag = _fragment(service_cfg={"password": "s3cr3t!"}) + assert frag["environment"]["SSH_ROOT_PASSWORD"] == "s3cr3t!" + + +def test_custom_hostname(): + frag = _fragment(service_cfg={"hostname": "srv-prod-01"}) + assert frag["environment"]["SSH_HOSTNAME"] == "srv-prod-01" + + +def test_no_hostname_by_default(): + frag = _fragment() + assert "SSH_HOSTNAME" not in frag["environment"] + + +# --------------------------------------------------------------------------- +# log_target: real_ssh does not forward logs via LOG_TARGET +# (no log aggregation on the entry-point — attacker shouldn't see it) +# --------------------------------------------------------------------------- + +def test_no_log_target_env_injected(): + frag = _fragment(log_target="10.0.0.1:5140") + assert "LOG_TARGET" not in frag.get("environment", {}) + + +# --------------------------------------------------------------------------- +# Deaddeck archetype +# --------------------------------------------------------------------------- + +def test_deaddeck_archetype_exists(): + arch = get_archetype("deaddeck") + assert arch.slug == "deaddeck" + + +def test_deaddeck_uses_real_ssh(): + arch = get_archetype("deaddeck") + assert "real_ssh" in arch.services + + +def test_deaddeck_nmap_os(): + arch = get_archetype("deaddeck") + assert arch.nmap_os == "linux" + + +def test_deaddeck_preferred_distros_not_empty(): + arch = get_archetype("deaddeck") + assert len(arch.preferred_distros) >= 1