fix(services): Resolve protocol realism gaps and update technical debt register
- Add dynamic challenge nonces to Postgres, VNC, and SIP. - Add basic keyspace lookup and mock data to Redis. - Correct MSSQL TDS pre-login offset bounds. - Support MongoDB OP_MSG handshake version checking. - Suppress Werkzeug HTTP server headers and normalize FTPAnonymousShell response. - Add tracking for Dynamic Bait Store (DEBT-027) via DEBT.md.
This commit is contained in:
10
DEBT.md
10
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
|
## 🟢 Low
|
||||||
|
|
||||||
### ~~DEBT-022 — Debug `print()` in correlation engine~~ ✅ CLOSED (false positive)
|
### ~~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-024~~ | ✅ | Infra | resolved |
|
||||||
| ~~DEBT-025~~ | ✅ | Build | resolved |
|
| ~~DEBT-025~~ | ✅ | Build | resolved |
|
||||||
| DEBT-026 | 🟡 Medium | Features | deferred (out of scope) |
|
| 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
|
**Estimated remaining effort:** ~10 hours
|
||||||
|
|||||||
@@ -7,18 +7,18 @@ forwards events as JSON to LOG_TARGET if set.
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from twisted.internet import defer, reactor
|
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 twisted.python import log as twisted_log
|
||||||
from decnet_logging import syslog_line, write_syslog_file, forward_syslog
|
from decnet_logging import syslog_line, write_syslog_file, forward_syslog
|
||||||
|
|
||||||
NODE_NAME = os.environ.get("NODE_NAME", "ftpserver")
|
NODE_NAME = os.environ.get("NODE_NAME", "ftpserver")
|
||||||
SERVICE_NAME = "ftp"
|
SERVICE_NAME = "ftp"
|
||||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
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:
|
def _log(event_type: str, severity: int = 6, **kwargs) -> None:
|
||||||
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
|
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)
|
write_syslog_file(line)
|
||||||
forward_syslog(line, LOG_TARGET)
|
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):
|
class ServerFTP(FTP):
|
||||||
def connectionMade(self):
|
def connectionMade(self):
|
||||||
@@ -40,25 +50,24 @@ class ServerFTP(FTP):
|
|||||||
|
|
||||||
def ftp_PASS(self, password):
|
def ftp_PASS(self, password):
|
||||||
_log("auth_attempt", username=getattr(self, "_server_user", "?"), password=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.state = self.AUTHED
|
||||||
self._user = getattr(self, "_server_user", "anonymous")
|
self._user = getattr(self, "_server_user", "anonymous")
|
||||||
|
self.shell = FTPAnonymousShell(FilePath(_setup_bait_fs()))
|
||||||
return defer.succeed((230, "Login successful."))
|
return defer.succeed((230, "Login successful."))
|
||||||
|
|
||||||
def ftp_RETR(self, path):
|
def ftp_RETR(self, path):
|
||||||
_log("download_attempt", path=path)
|
_log("download_attempt", path=path)
|
||||||
self.sendLine(b"550 File unavailable.")
|
return super().ftp_RETR(path)
|
||||||
return defer.succeed(None)
|
|
||||||
|
|
||||||
def connectionLost(self, reason):
|
def connectionLost(self, reason):
|
||||||
peer = self.transport.getPeer()
|
peer = self.transport.getPeer()
|
||||||
_log("disconnect", src_ip=peer.host, src_port=peer.port)
|
_log("disconnect", src_ip=peer.host, src_port=peer.port)
|
||||||
super().connectionLost(reason)
|
super().connectionLost(reason)
|
||||||
|
|
||||||
|
|
||||||
class ServerFTPFactory(FTPFactory):
|
class ServerFTPFactory(FTPFactory):
|
||||||
protocol = ServerFTP
|
protocol = ServerFTP
|
||||||
|
welcomeMessage = BANNER
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
twisted_log.startLogging(sys.stdout)
|
twisted_log.startLogging(sys.stdout)
|
||||||
|
|||||||
@@ -56,8 +56,10 @@ _FAKE_APP_BODIES: dict[str, str] = {
|
|||||||
|
|
||||||
app = Flask(__name__)
|
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:
|
def _log(event_type: str, severity: int = 6, **kwargs) -> None:
|
||||||
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
|
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:
|
elif FAKE_APP and FAKE_APP in _FAKE_APP_BODIES:
|
||||||
body = _FAKE_APP_BODIES[FAKE_APP]
|
body = _FAKE_APP_BODIES[FAKE_APP]
|
||||||
else:
|
else:
|
||||||
body = "<html><body><h1>403 Forbidden</h1></body></html>"
|
body = (
|
||||||
|
"<!DOCTYPE HTML PUBLIC \"-//IETF//DTD HTML 2.0//EN\">\n"
|
||||||
|
"<html><head>\n"
|
||||||
|
"<title>403 Forbidden</title>\n"
|
||||||
|
"</head><body>\n"
|
||||||
|
"<h1>Forbidden</h1>\n"
|
||||||
|
"<p>You don't have permission to access this resource.</p>\n"
|
||||||
|
"<hr>\n"
|
||||||
|
f"<address>{SERVER_HEADER} Server at {NODE_NAME} Port 80</address>\n"
|
||||||
|
"</body></html>\n"
|
||||||
|
)
|
||||||
|
|
||||||
headers = {"Server": SERVER_HEADER, "Content-Type": "text/html", **EXTRA_HEADERS}
|
headers = {"Content-Type": "text/html", **EXTRA_HEADERS}
|
||||||
return body, RESPONSE_CODE, headers
|
return body, RESPONSE_CODE, headers
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -47,8 +47,17 @@ def _op_reply(request_id: int, doc: bytes) -> bytes:
|
|||||||
)
|
)
|
||||||
return header + doc
|
return header + doc
|
||||||
|
|
||||||
|
def _op_msg(request_id: int, doc: bytes) -> bytes:
|
||||||
|
payload = b"\x00" + doc
|
||||||
|
flag_bits = struct.pack("<I", 0)
|
||||||
|
msg_body = flag_bits + payload
|
||||||
|
header = struct.pack("<iiii",
|
||||||
|
16 + len(msg_body),
|
||||||
|
1,
|
||||||
|
request_id,
|
||||||
|
2013,
|
||||||
|
)
|
||||||
|
return header + msg_body
|
||||||
|
|
||||||
def _log(event_type: str, severity: int = 6, **kwargs) -> None:
|
def _log(event_type: str, severity: int = 6, **kwargs) -> None:
|
||||||
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
|
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_str("version", "6.0.5"),
|
||||||
_bson_int32("ok", 1),
|
_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):
|
def connection_lost(self, exc):
|
||||||
_log("disconnect", src=self._peer[0] if self._peer else "?")
|
_log("disconnect", src=self._peer[0] if self._peer else "?")
|
||||||
|
|||||||
@@ -14,27 +14,30 @@ NODE_NAME = os.environ.get("NODE_NAME", "dbserver")
|
|||||||
SERVICE_NAME = "mssql"
|
SERVICE_NAME = "mssql"
|
||||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
||||||
|
|
||||||
# Minimal TDS pre-login response
|
|
||||||
_PRELOGIN_RESP = bytes([
|
_PRELOGIN_RESP = bytes([
|
||||||
0x04, 0x01, 0x00, 0x2b, 0x00, 0x00, 0x01, 0x00, # TDS header type=4, status=1, len=43
|
0x04, 0x01, 0x00, 0x2f, 0x00, 0x00, 0x01, 0x00, # TDS header type=4, status=1, len=47
|
||||||
# VERSION option
|
# 0. VERSION option
|
||||||
0x00, 0x00, 0x1a, 0x00, 0x06,
|
0x00, 0x00, 0x1a, 0x00, 0x06,
|
||||||
# ENCRYPTION option (not supported = 0x02)
|
# 1. ENCRYPTION option
|
||||||
0x01, 0x00, 0x20, 0x00, 0x01,
|
0x01, 0x00, 0x20, 0x00, 0x01,
|
||||||
# INSTOPT
|
# 2. INSTOPT
|
||||||
0x02, 0x00, 0x21, 0x00, 0x01,
|
0x02, 0x00, 0x21, 0x00, 0x01,
|
||||||
# THREADID
|
# 3. THREADID
|
||||||
0x03, 0x00, 0x22, 0x00, 0x04,
|
0x03, 0x00, 0x22, 0x00, 0x04,
|
||||||
|
# 4. MARS
|
||||||
|
0x04, 0x00, 0x26, 0x00, 0x01,
|
||||||
# TERMINATOR
|
# TERMINATOR
|
||||||
0xff,
|
0xff,
|
||||||
# version data: 16.00.1000
|
# version data: 14.0.2000
|
||||||
0x10, 0x00, 0x03, 0xe8, 0x00, 0x00,
|
0x0e, 0x00, 0x07, 0xd0, 0x00, 0x00,
|
||||||
# encryption: NOT_SUP
|
# encryption: NOT_SUP
|
||||||
0x02,
|
0x02,
|
||||||
# instance name NUL
|
# instopt
|
||||||
0x00,
|
0x00,
|
||||||
# thread id
|
# thread id
|
||||||
0x00, 0x00, 0x00, 0x01,
|
0x00, 0x00, 0x00, 0x00,
|
||||||
|
# mars
|
||||||
|
0x00,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,11 +14,6 @@ from decnet_logging import syslog_line, write_syslog_file, forward_syslog
|
|||||||
NODE_NAME = os.environ.get("NODE_NAME", "pgserver")
|
NODE_NAME = os.environ.get("NODE_NAME", "pgserver")
|
||||||
SERVICE_NAME = "postgres"
|
SERVICE_NAME = "postgres"
|
||||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
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:
|
def _error_response(message: str) -> bytes:
|
||||||
body = b"S" + b"FATAL\x00" + b"M" + message.encode() + b"\x00\x00"
|
body = b"S" + b"FATAL\x00" + b"M" + message.encode() + b"\x00\x00"
|
||||||
return b"E" + struct.pack(">I", len(body) + 4) + body
|
return b"E" + struct.pack(">I", len(body) + 4) + body
|
||||||
@@ -90,7 +85,9 @@ class PostgresProtocol(asyncio.Protocol):
|
|||||||
database = params.get("database", "")
|
database = params.get("database", "")
|
||||||
_log("startup", src=self._peer[0], username=username, database=database)
|
_log("startup", src=self._peer[0], username=username, database=database)
|
||||||
self._state = "auth"
|
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):
|
def _handle_password(self, payload: bytes):
|
||||||
pw_hash = payload.rstrip(b"\x00").decode(errors="replace")
|
pw_hash = payload.rstrip(b"\x00").decode(errors="replace")
|
||||||
|
|||||||
@@ -27,6 +27,19 @@ _INFO = (
|
|||||||
f"# Keyspace\n"
|
f"# Keyspace\n"
|
||||||
).encode()
|
).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":
|
elif verb == "CONFIG":
|
||||||
self._transport.write(b"*0\r\n")
|
self._transport.write(b"*0\r\n")
|
||||||
elif verb == "KEYS":
|
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":
|
elif verb == "QUIT":
|
||||||
self._transport.write(b"+OK\r\n")
|
self._transport.write(b"+OK\r\n")
|
||||||
self._transport.close()
|
self._transport.close()
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ _401 = (
|
|||||||
"To: {to}\r\n"
|
"To: {to}\r\n"
|
||||||
"Call-ID: {call_id}\r\n"
|
"Call-ID: {call_id}\r\n"
|
||||||
"CSeq: {cseq}\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"
|
"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"):
|
if method in ("REGISTER", "INVITE", "OPTIONS"):
|
||||||
|
nonce = os.urandom(8).hex()
|
||||||
response = _401.format(
|
response = _401.format(
|
||||||
via=headers.get("via", ""),
|
via=headers.get("via", ""),
|
||||||
from_=headers.get("from", ""),
|
from_=headers.get("from", ""),
|
||||||
@@ -78,6 +79,7 @@ def _handle_message(data: bytes, src_addr) -> bytes | None:
|
|||||||
call_id=headers.get("call-id", ""),
|
call_id=headers.get("call-id", ""),
|
||||||
cseq=headers.get("cseq", ""),
|
cseq=headers.get("cseq", ""),
|
||||||
host=NODE_NAME,
|
host=NODE_NAME,
|
||||||
|
nonce=nonce,
|
||||||
)
|
)
|
||||||
return response.encode()
|
return response.encode()
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ NODE_NAME = os.environ.get("NODE_NAME", "desktop")
|
|||||||
SERVICE_NAME = "vnc"
|
SERVICE_NAME = "vnc"
|
||||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
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:]
|
self._buf = self._buf[1:]
|
||||||
_log("security_choice", src=self._peer[0], type=chosen)
|
_log("security_choice", src=self._peer[0], type=chosen)
|
||||||
# Send 16-byte challenge
|
# Send 16-byte challenge
|
||||||
self._transport.write(_CHALLENGE[:16])
|
self._transport.write(os.urandom(16))
|
||||||
self._state = "auth_response"
|
self._state = "auth_response"
|
||||||
|
|
||||||
elif self._state == "auth_response":
|
elif self._state == "auth_response":
|
||||||
|
|||||||
104
tests/service_testing/test_redis.py
Normal file
104
tests/service_testing/test_redis.py
Normal file
@@ -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"
|
||||||
Reference in New Issue
Block a user