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 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 13:42:19 -03:00
parent 9219bf432b
commit 7aff040579
5 changed files with 269 additions and 0 deletions

View File

@@ -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",
),
}

View File

@@ -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

View File

@@ -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"]

View File

@@ -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

130
tests/test_real_ssh.py Normal file
View File

@@ -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