diff --git a/DEBT.md b/DEBT.md
index ea7369b..a3da844 100644
--- a/DEBT.md
+++ b/DEBT.md
@@ -108,6 +108,13 @@ Bait emails are hardcoded strings. A modular framework to dynamically inject per
---
+### DEBT-027 — Dynamic Bait Store
+**Files:** `templates/redis/server.py`, `templates/ftp/server.py`
+The bait store and honeypot files are hardcoded. A dynamic injection framework should be created to populate this payload across different honeypots.
+**Status:** Deferred — out of current scope.
+
+---
+
## 🟢 Low
### ~~DEBT-022 — Debug `print()` in correlation engine~~ ✅ CLOSED (false positive)
@@ -158,6 +165,7 @@ Bait emails are hardcoded strings. A modular framework to dynamically inject per
| ~~DEBT-024~~ | ✅ | Infra | resolved |
| ~~DEBT-025~~ | ✅ | Build | resolved |
| DEBT-026 | 🟡 Medium | Features | deferred (out of scope) |
+| DEBT-027 | 🟡 Medium | Features | deferred (out of scope) |
-**Remaining open:** DEBT-011 (Alembic migrations), DEBT-023 (image digest pinning), DEBT-026 (modular mailboxes)
+**Remaining open:** DEBT-011 (Alembic), DEBT-023 (image pinning), DEBT-026 (modular mailboxes), DEBT-027 (Dynamic bait store)
**Estimated remaining effort:** ~10 hours
diff --git a/templates/ftp/server.py b/templates/ftp/server.py
index c01a080..9f0ed6f 100644
--- a/templates/ftp/server.py
+++ b/templates/ftp/server.py
@@ -7,18 +7,18 @@ forwards events as JSON to LOG_TARGET if set.
import os
import sys
+from pathlib import Path
from twisted.internet import defer, reactor
-from twisted.protocols.ftp import FTP, FTPFactory
+from twisted.protocols.ftp import FTP, FTPFactory, FTPAnonymousShell
+from twisted.python.filepath import FilePath
from twisted.python import log as twisted_log
from decnet_logging import syslog_line, write_syslog_file, forward_syslog
NODE_NAME = os.environ.get("NODE_NAME", "ftpserver")
SERVICE_NAME = "ftp"
LOG_TARGET = os.environ.get("LOG_TARGET", "")
-
-
-
+BANNER = os.environ.get("FTP_BANNER", "220 (vsFTPd 3.0.3)")
def _log(event_type: str, severity: int = 6, **kwargs) -> None:
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
@@ -26,6 +26,16 @@ def _log(event_type: str, severity: int = 6, **kwargs) -> None:
write_syslog_file(line)
forward_syslog(line, LOG_TARGET)
+def _setup_bait_fs() -> str:
+ bait_dir = Path("/tmp/ftp_bait")
+ bait_dir.mkdir(parents=True, exist_ok=True)
+
+ (bait_dir / "backup.tar.gz").write_bytes(b"\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\x03\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00")
+ (bait_dir / "db_dump.sql").write_text("CREATE TABLE users (id INT, username VARCHAR(50), password VARCHAR(50));\nINSERT INTO users VALUES (1, 'admin', 'pbkdf2:sha256:5000$...');\n")
+ (bait_dir / "config.ini").write_text("[database]\nuser = dbadmin\npassword = db_super_admin_pass_!\nhost = localhost\n")
+ (bait_dir / "credentials.txt").write_text("admin:super_secret_admin_pw\nroot:toor\nalice:wonderland\n")
+
+ return str(bait_dir)
class ServerFTP(FTP):
def connectionMade(self):
@@ -40,25 +50,24 @@ class ServerFTP(FTP):
def ftp_PASS(self, password):
_log("auth_attempt", username=getattr(self, "_server_user", "?"), password=password)
- # Accept everything — we're a server
+ # Accept everything — we're a honeypot server
self.state = self.AUTHED
self._user = getattr(self, "_server_user", "anonymous")
+ self.shell = FTPAnonymousShell(FilePath(_setup_bait_fs()))
return defer.succeed((230, "Login successful."))
def ftp_RETR(self, path):
_log("download_attempt", path=path)
- self.sendLine(b"550 File unavailable.")
- return defer.succeed(None)
+ return super().ftp_RETR(path)
def connectionLost(self, reason):
peer = self.transport.getPeer()
_log("disconnect", src_ip=peer.host, src_port=peer.port)
super().connectionLost(reason)
-
class ServerFTPFactory(FTPFactory):
protocol = ServerFTP
-
+ welcomeMessage = BANNER
if __name__ == "__main__":
twisted_log.startLogging(sys.stdout)
diff --git a/templates/http/server.py b/templates/http/server.py
index 87410cb..3d5bd82 100644
--- a/templates/http/server.py
+++ b/templates/http/server.py
@@ -56,8 +56,10 @@ _FAKE_APP_BODIES: dict[str, str] = {
app = Flask(__name__)
-
-
+@app.after_request
+def _fix_server_header(response):
+ response.headers["Server"] = SERVER_HEADER
+ return response
def _log(event_type: str, severity: int = 6, **kwargs) -> None:
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
@@ -93,9 +95,19 @@ def catch_all(path):
elif FAKE_APP and FAKE_APP in _FAKE_APP_BODIES:
body = _FAKE_APP_BODIES[FAKE_APP]
else:
- body = "
403 Forbidden
"
+ body = (
+ "\n"
+ "\n"
+ "403 Forbidden\n"
+ "\n"
+ "Forbidden
\n"
+ "You don't have permission to access this resource.
\n"
+ "
\n"
+ f"{SERVER_HEADER} Server at {NODE_NAME} Port 80\n"
+ "\n"
+ )
- headers = {"Server": SERVER_HEADER, "Content-Type": "text/html", **EXTRA_HEADERS}
+ headers = {"Content-Type": "text/html", **EXTRA_HEADERS}
return body, RESPONSE_CODE, headers
diff --git a/templates/mongodb/server.py b/templates/mongodb/server.py
index 62b6b96..7ce68be 100644
--- a/templates/mongodb/server.py
+++ b/templates/mongodb/server.py
@@ -47,8 +47,17 @@ def _op_reply(request_id: int, doc: bytes) -> bytes:
)
return header + doc
-
-
+def _op_msg(request_id: int, doc: bytes) -> bytes:
+ payload = b"\x00" + doc
+ flag_bits = struct.pack(" None:
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
@@ -93,7 +102,10 @@ class MongoDBProtocol(asyncio.Protocol):
_bson_str("version", "6.0.5"),
_bson_int32("ok", 1),
)
- self._transport.write(_op_reply(request_id, reply_doc))
+ if opcode == 2013: # OP_MSG
+ self._transport.write(_op_msg(request_id, reply_doc))
+ else:
+ self._transport.write(_op_reply(request_id, reply_doc))
def connection_lost(self, exc):
_log("disconnect", src=self._peer[0] if self._peer else "?")
diff --git a/templates/mssql/server.py b/templates/mssql/server.py
index 0a42f4c..3502878 100644
--- a/templates/mssql/server.py
+++ b/templates/mssql/server.py
@@ -14,27 +14,30 @@ NODE_NAME = os.environ.get("NODE_NAME", "dbserver")
SERVICE_NAME = "mssql"
LOG_TARGET = os.environ.get("LOG_TARGET", "")
-# Minimal TDS pre-login response
_PRELOGIN_RESP = bytes([
- 0x04, 0x01, 0x00, 0x2b, 0x00, 0x00, 0x01, 0x00, # TDS header type=4, status=1, len=43
- # VERSION option
+ 0x04, 0x01, 0x00, 0x2f, 0x00, 0x00, 0x01, 0x00, # TDS header type=4, status=1, len=47
+ # 0. VERSION option
0x00, 0x00, 0x1a, 0x00, 0x06,
- # ENCRYPTION option (not supported = 0x02)
+ # 1. ENCRYPTION option
0x01, 0x00, 0x20, 0x00, 0x01,
- # INSTOPT
+ # 2. INSTOPT
0x02, 0x00, 0x21, 0x00, 0x01,
- # THREADID
+ # 3. THREADID
0x03, 0x00, 0x22, 0x00, 0x04,
+ # 4. MARS
+ 0x04, 0x00, 0x26, 0x00, 0x01,
# TERMINATOR
0xff,
- # version data: 16.00.1000
- 0x10, 0x00, 0x03, 0xe8, 0x00, 0x00,
+ # version data: 14.0.2000
+ 0x0e, 0x00, 0x07, 0xd0, 0x00, 0x00,
# encryption: NOT_SUP
0x02,
- # instance name NUL
+ # instopt
0x00,
# thread id
- 0x00, 0x00, 0x00, 0x01,
+ 0x00, 0x00, 0x00, 0x00,
+ # mars
+ 0x00,
])
diff --git a/templates/postgres/server.py b/templates/postgres/server.py
index 05d5e3d..c012624 100644
--- a/templates/postgres/server.py
+++ b/templates/postgres/server.py
@@ -14,11 +14,6 @@ from decnet_logging import syslog_line, write_syslog_file, forward_syslog
NODE_NAME = os.environ.get("NODE_NAME", "pgserver")
SERVICE_NAME = "postgres"
LOG_TARGET = os.environ.get("LOG_TARGET", "")
-SALT = b"\xde\xad\xbe\xef"
-
-# AuthenticationMD5Password: 'R' + length(12) + auth_type(5) + salt(4)
-_AUTH_MD5 = b"R" + struct.pack(">I", 12) + struct.pack(">I", 5) + SALT
-
def _error_response(message: str) -> bytes:
body = b"S" + b"FATAL\x00" + b"M" + message.encode() + b"\x00\x00"
return b"E" + struct.pack(">I", len(body) + 4) + body
@@ -90,7 +85,9 @@ class PostgresProtocol(asyncio.Protocol):
database = params.get("database", "")
_log("startup", src=self._peer[0], username=username, database=database)
self._state = "auth"
- self._transport.write(_AUTH_MD5)
+ salt = os.urandom(4)
+ auth_md5 = b"R" + struct.pack(">I", 12) + struct.pack(">I", 5) + salt
+ self._transport.write(auth_md5)
def _handle_password(self, payload: bytes):
pw_hash = payload.rstrip(b"\x00").decode(errors="replace")
diff --git a/templates/redis/server.py b/templates/redis/server.py
index 196cb42..756803c 100644
--- a/templates/redis/server.py
+++ b/templates/redis/server.py
@@ -27,6 +27,19 @@ _INFO = (
f"# Keyspace\n"
).encode()
+_FAKE_STORE = {
+ b"sessions:user:1234": b'{"id":1234,"user":"admin","token":"eyJhbGciOiJIUzI1NiJ9..."}',
+ b"sessions:user:5678": b'{"id":5678,"user":"alice","token":"eyJhbGciOiJIUzI1NiJ9..."}',
+ b"cache:api_key": b"sk_live_9mK3xF2aP7qR1bN8cT4dW6vE0yU5hJ",
+ b"jwt:secret": b"super_secret_jwt_signing_key_do_not_share_2024",
+ b"user:admin": b'{"username":"admin","password":"$2b$12$LQv3c1yqBWVHxkd0LHAkC.","role":"superadmin"}',
+ b"user:alice": b'{"username":"alice","password":"$2b$12$XKLDm3vT8nPqR4sY2hE6fO","role":"user"}',
+ b"config:db_password": b"Pr0dDB!2024#Secure",
+ b"config:aws_access_key": b"AKIAIOSFODNN7EXAMPLE",
+ b"config:aws_secret_key": b"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
+ b"rate_limit:192.168.1.1": b"42",
+}
+
@@ -134,7 +147,31 @@ class RedisProtocol(asyncio.Protocol):
elif verb == "CONFIG":
self._transport.write(b"*0\r\n")
elif verb == "KEYS":
- self._transport.write(b"*0\r\n")
+ pattern = args[0] if args else "*"
+ keys = list(_FAKE_STORE.keys())
+ if pattern.endswith('*') and pattern != '*':
+ prefix = pattern[:-1].encode()
+ keys = [k for k in keys if k.startswith(prefix)]
+ elif pattern != '*':
+ pat = pattern.encode()
+ keys = [k for k in keys if k == pat]
+
+ resp = f"*{len(keys)}\r\n".encode() + b"".join(_bulk(k.decode()) for k in keys)
+ self._transport.write(resp)
+ elif verb == "GET":
+ key = args[0].encode() if args else b""
+ if key in _FAKE_STORE:
+ self._transport.write(_bulk(_FAKE_STORE[key].decode()))
+ else:
+ self._transport.write(b"$-1\r\n")
+ elif verb == "SCAN":
+ keys = list(_FAKE_STORE.keys())
+ resp = b"*2\r\n$1\r\n0\r\n" + f"*{len(keys)}\r\n".encode() + b"".join(_bulk(k.decode()) for k in keys)
+ self._transport.write(resp)
+ elif verb == "TYPE":
+ self._transport.write(b"+string\r\n")
+ elif verb == "TTL":
+ self._transport.write(b":-1\r\n")
elif verb == "QUIT":
self._transport.write(b"+OK\r\n")
self._transport.close()
diff --git a/templates/sip/server.py b/templates/sip/server.py
index 414aff7..a84c0c7 100644
--- a/templates/sip/server.py
+++ b/templates/sip/server.py
@@ -21,7 +21,7 @@ _401 = (
"To: {to}\r\n"
"Call-ID: {call_id}\r\n"
"CSeq: {cseq}\r\n"
- 'WWW-Authenticate: Digest realm="{host}", nonce="decnet0000", algorithm=MD5\r\n'
+ 'WWW-Authenticate: Digest realm="{host}", nonce="{nonce}", algorithm=MD5\r\n'
"Content-Length: 0\r\n\r\n"
)
@@ -71,6 +71,7 @@ def _handle_message(data: bytes, src_addr) -> bytes | None:
)
if method in ("REGISTER", "INVITE", "OPTIONS"):
+ nonce = os.urandom(8).hex()
response = _401.format(
via=headers.get("via", ""),
from_=headers.get("from", ""),
@@ -78,6 +79,7 @@ def _handle_message(data: bytes, src_addr) -> bytes | None:
call_id=headers.get("call-id", ""),
cseq=headers.get("cseq", ""),
host=NODE_NAME,
+ nonce=nonce,
)
return response.encode()
return None
diff --git a/templates/vnc/server.py b/templates/vnc/server.py
index fcb1c88..7f8637f 100644
--- a/templates/vnc/server.py
+++ b/templates/vnc/server.py
@@ -14,8 +14,6 @@ NODE_NAME = os.environ.get("NODE_NAME", "desktop")
SERVICE_NAME = "vnc"
LOG_TARGET = os.environ.get("LOG_TARGET", "")
-# RFB challenge — fixed so captured responses are reproducible
-_CHALLENGE = bytes(range(16)) * 1 + b"\x10\x11\x12\x13\x14\x15\x16\x17" # 24 bytes
@@ -63,7 +61,7 @@ class VNCProtocol(asyncio.Protocol):
self._buf = self._buf[1:]
_log("security_choice", src=self._peer[0], type=chosen)
# Send 16-byte challenge
- self._transport.write(_CHALLENGE[:16])
+ self._transport.write(os.urandom(16))
self._state = "auth_response"
elif self._state == "auth_response":
diff --git a/tests/service_testing/test_redis.py b/tests/service_testing/test_redis.py
new file mode 100644
index 0000000..4ad6f33
--- /dev/null
+++ b/tests/service_testing/test_redis.py
@@ -0,0 +1,104 @@
+import importlib.util
+import sys
+from types import ModuleType
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+
+def _make_fake_decnet_logging() -> ModuleType:
+ mod = ModuleType("decnet_logging")
+ mod.syslog_line = MagicMock(return_value="")
+ mod.write_syslog_file = MagicMock()
+ mod.forward_syslog = MagicMock()
+ mod.SEVERITY_WARNING = 4
+ mod.SEVERITY_INFO = 6
+ return mod
+
+
+def _load_redis():
+ env = {"NODE_NAME": "testredis"}
+ for key in list(sys.modules):
+ if key in ("redis_server", "decnet_logging"):
+ del sys.modules[key]
+
+ sys.modules["decnet_logging"] = _make_fake_decnet_logging()
+
+ spec = importlib.util.spec_from_file_location("redis_server", "templates/redis/server.py")
+ mod = importlib.util.module_from_spec(spec)
+ with patch.dict("os.environ", env, clear=False):
+ spec.loader.exec_module(mod)
+ return mod
+
+
+@pytest.fixture
+def redis_mod():
+ return _load_redis()
+
+
+def _make_protocol(mod):
+ proto = mod.RedisProtocol()
+ transport = MagicMock()
+ written: list[bytes] = []
+ transport.write.side_effect = written.append
+ proto.connection_made(transport)
+ written.clear()
+ return proto, transport, written
+
+
+def _send(proto, *lines: bytes) -> None:
+ for line in lines:
+ proto.data_received(line)
+
+
+def test_auth_accepted(redis_mod):
+ proto, _, written = _make_protocol(redis_mod)
+ _send(proto, b"AUTH password\r\n")
+ assert b"".join(written) == b"+OK\r\n"
+
+
+def test_keys_wildcard(redis_mod):
+ proto, _, written = _make_protocol(redis_mod)
+ _send(proto, b"*2\r\n$4\r\nKEYS\r\n$1\r\n*\r\n")
+ response = b"".join(written)
+ assert response.startswith(b"*10\r\n")
+ assert b"config:aws_access_key" in response
+
+
+def test_keys_prefix(redis_mod):
+ proto, _, written = _make_protocol(redis_mod)
+ _send(proto, b"*2\r\n$4\r\nKEYS\r\n$6\r\nuser:*\r\n")
+ response = b"".join(written)
+ assert response.startswith(b"*2\r\n")
+ assert b"user:admin" in response
+
+
+def test_get_valid_key(redis_mod):
+ proto, _, written = _make_protocol(redis_mod)
+ _send(proto, b"*2\r\n$3\r\nGET\r\n$13\r\ncache:api_key\r\n")
+ response = b"".join(written)
+ assert response == b"$38\r\nsk_live_9mK3xF2aP7qR1bN8cT4dW6vE0yU5hJ\r\n"
+
+
+def test_get_invalid_key(redis_mod):
+ proto, _, written = _make_protocol(redis_mod)
+ _send(proto, b"*2\r\n$3\r\nGET\r\n$7\r\nunknown\r\n")
+ response = b"".join(written)
+ assert response == b"$-1\r\n"
+
+
+def test_scan(redis_mod):
+ proto, _, written = _make_protocol(redis_mod)
+ _send(proto, b"*1\r\n$4\r\nSCAN\r\n")
+ response = b"".join(written)
+ assert response.startswith(b"*2\r\n$1\r\n0\r\n*10\r\n")
+
+
+def test_type_and_ttl(redis_mod):
+ proto, _, written = _make_protocol(redis_mod)
+ _send(proto, b"TYPE somekey\r\n")
+ assert b"".join(written) == b"+string\r\n"
+ written.clear()
+
+ _send(proto, b"TTL somekey\r\n")
+ assert b"".join(written) == b":-1\r\n"