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:
@@ -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",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
46
decnet/services/real_ssh.py
Normal file
46
decnet/services/real_ssh.py
Normal 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
|
||||
51
templates/real_ssh/Dockerfile
Normal file
51
templates/real_ssh/Dockerfile
Normal 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"]
|
||||
34
templates/real_ssh/entrypoint.sh
Normal file
34
templates/real_ssh/entrypoint.sh
Normal 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
130
tests/test_real_ssh.py
Normal 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
|
||||
Reference in New Issue
Block a user