diff --git a/docker/_tests/README.md b/docker/_tests/README.md index c947eeea..ce38ccd4 100644 --- a/docker/_tests/README.md +++ b/docker/_tests/README.md @@ -15,6 +15,7 @@ not touch production `data/` or `data_backup/` paths. ./docker/_tests/run.sh citrixhoneypot ./docker/_tests/run.sh conpot ./docker/_tests/run.sh cowrie +./docker/_tests/run.sh ddospot ``` Common options: @@ -36,6 +37,8 @@ Individual tests can also be run directly: ./docker/_tests/tests/cowrie.sh --ssh-port 2222 --telnet-port 2323 ./docker/_tests/tests/cowrie.sh --persona debian-bookworm-vuln ./docker/_tests/tests/cowrie.sh --persona openwrt-1806 +./docker/_tests/tests/ddospot.sh +./docker/_tests/tests/ddospot.sh --dns-port 1053 --ntp-port 1123 --ssdp-port 19000 ``` ## Conventions diff --git a/docker/_tests/tests/ddospot.sh b/docker/_tests/tests/ddospot.sh new file mode 100755 index 00000000..308e51f2 --- /dev/null +++ b/docker/_tests/tests/ddospot.sh @@ -0,0 +1,511 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../lib/common.sh +source "${SCRIPT_DIR}/../lib/common.sh" + +TEST_NAME="ddospot" +DEFAULT_IMAGE="dtagdevsec/ddospot:24.04.1" +IMAGE="" +CHARGEN_PORT="" +DNS_PORT="" +NTP_PORT="" +SSDP_PORT="" +LOG_DIR="" +BL_DIR="" +DB_DIR="" +CHARGEN_LOG_FILE="" +DNS_LOG_FILE="" +NTP_LOG_FILE="" +SSDP_LOG_FILE="" +MAPPED_CHARGEN_PORT="" +MAPPED_DNS_PORT="" +MAPPED_NTP_PORT="" +MAPPED_SSDP_PORT="" + +usage() { + cat <." + fi +} + +prepare_ddospot_harness() { + test_prepare_harness "${TEST_NAME}" + + LOG_DIR="${TEST_TMP_ROOT}/log" + BL_DIR="${TEST_TMP_ROOT}/bl" + DB_DIR="${TEST_TMP_ROOT}/db" + CHARGEN_LOG_FILE="${LOG_DIR}/chargenpot.log" + DNS_LOG_FILE="${LOG_DIR}/dnspot.log" + NTP_LOG_FILE="${LOG_DIR}/ntpot.log" + SSDP_LOG_FILE="${LOG_DIR}/ssdpot.log" + TEST_ARTIFACT_LOG_DIR="${LOG_DIR}" + + mkdir -p "${LOG_DIR}" "${BL_DIR}" "${DB_DIR}" + chmod 0777 "${LOG_DIR}" "${BL_DIR}" "${DB_DIR}" + + cat > "${TEST_HARNESS_COMPOSE}" <> 3) & 0x07 + if mode != 4: + raise RuntimeError(f"Expected NTP server mode 4, got mode {mode}") + if version < 1: + raise RuntimeError(f"Expected sane NTP version, got {version}") + + print(f"NTP client-mode probe received {len(response)} bytes") +except Exception as exc: + print(f"NTP probe failed: {exc}", file=sys.stderr) + sys.exit(1) +PY +} + +run_ssdp_probe() { + local token="$1" + + python3 - "${TEST_BIND_IP}" "${MAPPED_SSDP_PORT}" "${token}" "${TEST_TIMEOUT}" <<'PY' +import socket +import sys + +host = sys.argv[1] +port = int(sys.argv[2]) +token = sys.argv[3] +timeout = int(sys.argv[4]) +request = ( + "M-SEARCH * HTTP/1.1\r\n" + "HOST: 239.255.255.250:1900\r\n" + "MAN: \"ssdp:discover\"\r\n" + "MX: 1\r\n" + "ST: ssdp:all\r\n" + f"USER-AGENT: tpot-ddospot-smoke/{token}\r\n" + "\r\n" +).encode("ascii") + +try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.settimeout(min(timeout, 3)) + sock.sendto(request, (host, port)) + try: + response, _ = sock.recvfrom(4096) + except socket.timeout: + response = b"" + + if not response: + raise RuntimeError("Expected SSDP response, got no UDP response") + + upper = response.upper() + if not (upper.startswith(b"HTTP/") or b"SSDP" in upper or b"UPNP" in upper): + raise RuntimeError(f"Expected SSDP-like HTTP response, got {response[:120]!r}") + + status = response.splitlines()[0].decode("iso-8859-1", errors="replace") if response.splitlines() else "" + print(f"SSDP probe response: {status}") +except Exception as exc: + print(f"SSDP probe failed: {exc}", file=sys.stderr) + sys.exit(1) +PY +} + +run_probe_with_retries() { + local description="$1" + shift + local deadline=$((SECONDS + TEST_TIMEOUT)) + local output="" + + while (( SECONDS < deadline )); do + if output="$("$@" 2>&1)"; then + printf '%s\n' "${output}" + return 0 + fi + sleep 1 + done + + printf '%s\n' "${output}" >&2 + printf '%s probe did not succeed before timeout\n' "${description}" >&2 + return 1 +} + +wait_for_json_event() { + local json_file="$1" + local service="$2" + + python3 - "${json_file}" "${service}" "${TEST_TIMEOUT}" <<'PY' +import json +import sys +import time +from pathlib import Path + +path = Path(sys.argv[1]) +service = sys.argv[2] +timeout = int(sys.argv[3]) +deadline = time.monotonic() + timeout +last_error = None + +while time.monotonic() < deadline: + if not path.exists(): + last_error = f"{path} does not exist yet" + time.sleep(1) + continue + + raw = path.read_text(encoding="utf-8", errors="replace") + if not raw.strip(): + last_error = f"{path} is empty" + time.sleep(1) + continue + + invalid = None + for line_number, line in enumerate(raw.splitlines(), 1): + if not line.strip(): + continue + try: + event = json.loads(line) + except json.JSONDecodeError as exc: + invalid = f"{path}:{line_number}: invalid JSON: {exc}" + continue + if isinstance(event, dict): + print(f"{service} JSON event found in {path}:{line_number}") + sys.exit(0) + invalid = f"{path}:{line_number}: JSON event is not an object" + + last_error = invalid or f"No JSON event found in {path}" + time.sleep(1) + +if last_error: + print(last_error, file=sys.stderr) +sys.exit(1) +PY +} + +assert_no_runtime_errors() { + if grep -R -E "Traceback|NameError|Exception" "${LOG_DIR}" >/dev/null 2>&1; then + test_die "DDoSPot runtime error found in log files" + fi + + if test_compose logs --no-color 2>/dev/null | grep -E "Traceback|NameError|Exception" >/dev/null 2>&1; then + test_die "DDoSPot runtime error found in Docker logs" + fi +} + +main() { + parse_args "$@" + validate_args + test_check_dependencies + + if [[ -z "${IMAGE}" ]]; then + IMAGE="$(test_read_compose_image "${TEST_NAME}" "${DEFAULT_IMAGE}")" + fi + + test_info "Using image: ${IMAGE}" + test_require_image "${IMAGE}" "docker compose -f docker/${TEST_NAME}/docker-compose.yml build ${TEST_NAME}" + + ensure_udp_port_free_for_docker "${CHARGEN_PORT}" "--chargen-port" + ensure_udp_port_free_for_docker "${DNS_PORT}" "--dns-port" + ensure_udp_port_free_for_docker "${NTP_PORT}" "--ntp-port" + ensure_udp_port_free_for_docker "${SSDP_PORT}" "--ssdp-port" + + prepare_ddospot_harness + test_enable_cleanup + + test_info "Starting isolated DDoSPot container" + test_compose up -d --no-build >/dev/null + + test_wait_for_container || test_die "DDoSPot container did not stay running" + test_ok "Container is running" + + MAPPED_CHARGEN_PORT="$(test_get_mapped_port "${TEST_NAME}" "19/udp")" || test_die "Could not resolve mapped host port for 19/udp" + test_ok "Port ${TEST_BIND_IP}:${MAPPED_CHARGEN_PORT} maps to container port 19/udp" + + MAPPED_DNS_PORT="$(test_get_mapped_port "${TEST_NAME}" "53/udp")" || test_die "Could not resolve mapped host port for 53/udp" + test_ok "Port ${TEST_BIND_IP}:${MAPPED_DNS_PORT} maps to container port 53/udp" + + MAPPED_NTP_PORT="$(test_get_mapped_port "${TEST_NAME}" "123/udp")" || test_die "Could not resolve mapped host port for 123/udp" + test_ok "Port ${TEST_BIND_IP}:${MAPPED_NTP_PORT} maps to container port 123/udp" + + MAPPED_SSDP_PORT="$(test_get_mapped_port "${TEST_NAME}" "1900/udp")" || test_die "Could not resolve mapped host port for 1900/udp" + test_ok "Port ${TEST_BIND_IP}:${MAPPED_SSDP_PORT} maps to container port 1900/udp" + + local token="ddospot-test-$(date +%s)-$$" + + test_info "Running CHARGEN probe with token: ${token}" + run_probe_with_retries "CHARGEN" run_chargen_probe "${token}" || test_die "CHARGEN probe failed on ${TEST_BIND_IP}:${MAPPED_CHARGEN_PORT}" + test_wait_for_container || test_die "DDoSPot container stopped after CHARGEN probe" + + test_info "Running DNS CHAOS/TXT probe with token: ${token}" + run_probe_with_retries "DNS" run_dns_probe "${token}" || test_die "DNS probe failed on ${TEST_BIND_IP}:${MAPPED_DNS_PORT}" + test_wait_for_container || test_die "DDoSPot container stopped after DNS probe" + + test_info "Running NTP client-mode probe" + run_probe_with_retries "NTP" run_ntp_probe || test_die "NTP probe failed on ${TEST_BIND_IP}:${MAPPED_NTP_PORT}" + test_wait_for_container || test_die "DDoSPot container stopped after NTP probe" + + test_info "Running SSDP M-SEARCH probe with token: ${token}" + run_probe_with_retries "SSDP" run_ssdp_probe "${token}" || test_die "SSDP probe failed on ${TEST_BIND_IP}:${MAPPED_SSDP_PORT}" + test_wait_for_container || test_die "DDoSPot container stopped after SSDP probe" + + test_info "Waiting for DDoSPot JSON log events" + wait_for_json_event "${CHARGEN_LOG_FILE}" "CHARGEN" || test_die "CHARGEN JSON event was not found in chargenpot.log" + wait_for_json_event "${DNS_LOG_FILE}" "DNS" || test_die "DNS JSON event was not found in dnspot.log" + wait_for_json_event "${NTP_LOG_FILE}" "NTP" || test_die "NTP JSON event was not found in ntpot.log" + wait_for_json_event "${SSDP_LOG_FILE}" "SSDP" || test_die "SSDP JSON event was not found in ssdpot.log" + test_ok "All DDoSPot JSON events were written" + + assert_no_runtime_errors + test_ok "No DDoSPot runtime errors found in logs" + + test_ok "DDoSPot post-build smoke test completed successfully" +} + +main "$@" diff --git a/docker/ddospot/Dockerfile b/docker/ddospot/Dockerfile index d3935008..b2015f79 100644 --- a/docker/ddospot/Dockerfile +++ b/docker/ddospot/Dockerfile @@ -1,4 +1,4 @@ -FROM alpine:3.20 AS builder +FROM alpine:3.23 AS builder # # Include dist COPY dist/ /root/dist/ @@ -52,7 +52,7 @@ RUN pyinstaller ddospot.py \ --hidden-import OpenSSL.crypto \ --hidden-import OpenSSL.SSL # -FROM alpine:3.20 +FROM alpine:3.23 RUN apk --no-cache -U upgrade COPY --from=builder /opt/ddospot/ddospot/dist/ddospot/ /opt/ddospot/ddospot COPY --from=builder /opt/ddospot/ddospot/global.conf /opt/ddospot/ddospot/ diff --git a/docker/ddospot/docker-compose.yml b/docker/ddospot/docker-compose.yml index c10744ea..55ca263a 100644 --- a/docker/ddospot/docker-compose.yml +++ b/docker/ddospot/docker-compose.yml @@ -18,7 +18,7 @@ services: - "123:123/udp" # - "161:161/udp" - "1900:1900/udp" - image: "dtagdevsec/ddospot:24.04" + image: "dtagdevsec/ddospot:24.04.1" read_only: true volumes: - $HOME/tpotce/data/ddospot/log:/opt/ddospot/ddospot/logs