diff --git a/docker/_tests/README.md b/docker/_tests/README.md index e3cc526e..1dbac890 100644 --- a/docker/_tests/README.md +++ b/docker/_tests/README.md @@ -13,6 +13,7 @@ not touch production `data/` or `data_backup/` paths. ./docker/_tests/run.sh adbhoney ./docker/_tests/run.sh ciscoasa ./docker/_tests/run.sh citrixhoneypot +./docker/_tests/run.sh conpot ``` Common options: @@ -29,6 +30,7 @@ Individual tests can also be run directly: ./docker/_tests/tests/adbhoney.sh --image dtagdevsec/adbhoney:24.04 --host-port 15555 ./docker/_tests/tests/ciscoasa.sh --https-port 18443 --ike-port 15000 ./docker/_tests/tests/citrixhoneypot.sh --https-port 1443 +./docker/_tests/tests/conpot.sh --guardian-ast-port 11001 --ipmi-port 1623 ``` ## Conventions diff --git a/docker/_tests/tests/conpot.sh b/docker/_tests/tests/conpot.sh new file mode 100755 index 00000000..a8b97658 --- /dev/null +++ b/docker/_tests/tests/conpot.sh @@ -0,0 +1,677 @@ +#!/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="conpot" +DEFAULT_IMAGE="dtagdevsec/conpot:24.04" +IMAGE="" +IEC104_PORT="2404" +GUARDIAN_AST_PORT="10001" +IPMI_PORT="623" +KAMSTRUP_PORT="1025" +KAMSTRUP_MANAGEMENT_PORT="50100" +LOG_DIR="" + +CONPOT_CONTAINER_IEC104="" +CONPOT_CONTAINER_GUARDIAN_AST="" +CONPOT_CONTAINER_IPMI="" +CONPOT_CONTAINER_KAMSTRUP="" +CONPOT_CONTAINER_NAMES=() + +usage() { + cat <." + fi +} + +ensure_udp_port_free_for_docker() { + local port="$1" + local option="$2" + + if (( port < 1024 )); then + test_info "Skipping user-space preflight for privileged UDP port ${port}; Docker will validate the binding." + else + test_ensure_udp_port_free "${TEST_BIND_IP}" "${port}" || test_die "${TEST_BIND_IP}:${port}/udp is already in use. Try ${option} ." + fi +} + +prepare_conpot_harness() { + test_prepare_harness "${TEST_NAME}" + + LOG_DIR="${TEST_TMP_ROOT}/log" + TEST_ARTIFACT_LOG_DIR="${LOG_DIR}" + + CONPOT_CONTAINER_IEC104="${TEST_PROJECT_NAME}-iec104" + CONPOT_CONTAINER_GUARDIAN_AST="${TEST_PROJECT_NAME}-guardian-ast" + CONPOT_CONTAINER_IPMI="${TEST_PROJECT_NAME}-ipmi" + CONPOT_CONTAINER_KAMSTRUP="${TEST_PROJECT_NAME}-kamstrup-382" + CONPOT_CONTAINER_NAMES=( + "${CONPOT_CONTAINER_IEC104}" + "${CONPOT_CONTAINER_GUARDIAN_AST}" + "${CONPOT_CONTAINER_IPMI}" + "${CONPOT_CONTAINER_KAMSTRUP}" + ) + + mkdir -p "${LOG_DIR}" + chmod 0777 "${LOG_DIR}" + + cat > "${TEST_HARNESS_COMPOSE}" <&2 + if [[ ${#CONPOT_CONTAINER_NAMES[@]} -gt 0 ]]; then + for container in "${CONPOT_CONTAINER_NAMES[@]}"; do + printf '%s: ' "${container}" >&2 + docker inspect -f 'status={{.State.Status}} exit={{.State.ExitCode}} error={{.State.Error}}' "${container}" >&2 || true + done + else + printf 'No Conpot container names available.\n' >&2 + fi + + printf '\n[diagnostics] Docker logs\n' >&2 + if [[ -n "${TEST_HARNESS_COMPOSE}" && -f "${TEST_HARNESS_COMPOSE}" ]]; then + test_compose logs --no-color --tail=160 >&2 || true + else + printf 'No temporary compose file available.\n' >&2 + fi + + printf '\n[diagnostics] Test log artifacts\n' >&2 + if [[ -n "${LOG_DIR}" && -d "${LOG_DIR}" ]]; then + find "${LOG_DIR}" -maxdepth 1 -type f -print | sort >&2 || true + while IFS= read -r file; do + printf '\n--- %s ---\n' "${file}" >&2 + tail -n 120 "${file}" >&2 || true + done < <(find "${LOG_DIR}" -maxdepth 1 -type f -print | sort) + else + printf 'No temporary log directory available.\n' >&2 + fi +} + +wait_for_containers() { + local deadline=$((SECONDS + TEST_TIMEOUT)) + local container="" + local state="" + local all_running="" + + while (( SECONDS < deadline )); do + all_running="true" + for container in "${CONPOT_CONTAINER_NAMES[@]}"; do + state="$(docker inspect -f '{{.State.Status}}' "${container}" 2>/dev/null || true)" + case "${state}" in + running) + ;; + exited|dead) + return 1 + ;; + *) + all_running="false" + ;; + esac + done + [[ "${all_running}" == "true" ]] && return 0 + sleep 1 + done + + return 1 +} + +assert_containers_running() { + local container="" + local state="" + + for container in "${CONPOT_CONTAINER_NAMES[@]}"; do + state="$(docker inspect -f '{{.State.Status}}' "${container}" 2>/dev/null || true)" + [[ "${state}" == "running" ]] || test_die "${container} is not running; state=${state:-unknown}" + done +} + +wait_for_log_line() { + local log_file="$1" + local pattern="$2" + local deadline=$((SECONDS + TEST_TIMEOUT)) + + while (( SECONDS < deadline )); do + if [[ -f "${log_file}" ]] && grep -F -- "${pattern}" "${log_file}" >/dev/null 2>&1; then + return 0 + fi + sleep 1 + done + + return 1 +} + +wait_for_json_event() { + local json_file="$1" + local mode="$2" + + python3 - "${json_file}" "${mode}" "${TEST_TIMEOUT}" <<'PY' +import json +import sys +import time +from pathlib import Path + +path = Path(sys.argv[1]) +mode = sys.argv[2] +timeout = int(sys.argv[3]) +deadline = time.monotonic() + timeout +last_error = None + + +def matches(event): + data_type = str(event.get("data_type", "")) + event_type = str(event.get("event_type", "")) + request = str(event.get("request", "")) + response = str(event.get("response", "")) + dst_port = str(event.get("dst_port", "")) + + if mode == "iec104": + return data_type == "IEC104" + if mode == "guardian_ast": + return data_type == "guardian_ast" and ( + event_type == "AST I20100" or "I20100" in request or "I20100" in response + ) + if mode == "ipmi": + return data_type == "ipmi" and event_type == "GET_CHANNEL_AUTH_CAPABILITIES" and response not in ("", "None") + if mode == "kamstrup_meter": + return data_type == "kamstrup_protocol" and dst_port == "1025" + if mode == "kamstrup_management": + return data_type == "kamstrup_management_protocol" and dst_port == "50100" + raise RuntimeError(f"Unknown JSON event mode: {mode}") + + +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 + + found = False + 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 matches(event): + found = True + + if invalid is None and found: + print(f"JSON event found in {path}: {mode}") + sys.exit(0) + + last_error = invalid or f"No matching {mode} JSON event found in {path}" + time.sleep(1) + +if last_error: + print(last_error, file=sys.stderr) +sys.exit(1) +PY +} + +run_iec104_probe() { + python3 - "${TEST_BIND_IP}" "${IEC104_PORT}" "${TEST_TIMEOUT}" <<'PY' +import socket +import sys + +host = sys.argv[1] +port = int(sys.argv[2]) +timeout = int(sys.argv[3]) +payload = bytes.fromhex("68 04 07 00 00 00") + +try: + with socket.create_connection((host, port), timeout=timeout) as sock: + sock.settimeout(2) + sock.sendall(payload) + try: + response = sock.recv(64) + except socket.timeout: + response = b"" + print(f"IEC104 probe sent {len(payload)} bytes; received {len(response)} bytes") +except Exception as exc: + print(f"IEC104 probe failed: {exc}", file=sys.stderr) + sys.exit(1) +PY +} + +run_guardian_ast_probe() { + python3 - "${TEST_BIND_IP}" "${GUARDIAN_AST_PORT}" "${TEST_TIMEOUT}" <<'PY' +import socket +import sys +import time + +host = sys.argv[1] +port = int(sys.argv[2]) +timeout = int(sys.argv[3]) +deadline = time.monotonic() + timeout +payload = b"\x01I20100\n" + +try: + with socket.create_connection((host, port), timeout=timeout) as sock: + sock.settimeout(1) + sock.sendall(payload) + chunks = [] + while time.monotonic() < deadline: + try: + chunk = sock.recv(4096) + except socket.timeout: + continue + if not chunk: + break + chunks.append(chunk) + if b"I20100" in b"".join(chunks): + break + response = b"".join(chunks) + if b"I20100" not in response and b"IN-TANK" not in response: + raise RuntimeError(f"Expected Guardian AST inventory response, got {response[:120]!r}") + print(f"Guardian AST response: {response[:80]!r}") +except Exception as exc: + print(f"Guardian AST probe failed: {exc}", file=sys.stderr) + sys.exit(1) +PY +} + +run_ipmi_probe() { + python3 - "${TEST_BIND_IP}" "${IPMI_PORT}" "${TEST_TIMEOUT}" <<'PY' +import socket +import sys + +host = sys.argv[1] +port = int(sys.argv[2]) +timeout = int(sys.argv[3]) +# RMCP/IPMI v1.5 Get Channel Authentication Capabilities. +payload = bytes.fromhex( + "06 00 ff 07" + " 00 00 00 00 00 00 00 00 00 09" + " 20 18 c8 81 00 38 8e 04 b5" +) + +try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.settimeout(min(timeout, 3)) + sock.sendto(payload, (host, port)) + try: + response, _ = sock.recvfrom(4096) + except socket.timeout: + response = b"" + if not response: + raise RuntimeError("Expected RMCP/IPMI response, got no UDP response") + if not response.startswith(bytes.fromhex("06 00 ff 07")): + raise RuntimeError(f"Expected RMCP response, got {response[:16]!r}") + if b"\x38" not in response: + raise RuntimeError(f"Expected IPMI command 0x38 response, got {response[:32]!r}") + print(f"IPMI probe sent {len(payload)} bytes; received {len(response)} bytes") +except Exception as exc: + print(f"IPMI probe failed: {exc}", file=sys.stderr) + sys.exit(1) +PY +} + +run_kamstrup_meter_probe() { + python3 - "${TEST_BIND_IP}" "${KAMSTRUP_PORT}" "${TEST_TIMEOUT}" <<'PY' +import socket +import sys + +host = sys.argv[1] +port = int(sys.argv[2]) +timeout = int(sys.argv[3]) + +try: + with socket.create_connection((host, port), timeout=timeout): + pass + print("Kamstrup meter TCP connection accepted") +except Exception as exc: + print(f"Kamstrup meter probe failed: {exc}", file=sys.stderr) + sys.exit(1) +PY +} + +run_kamstrup_management_probe() { + python3 - "${TEST_BIND_IP}" "${KAMSTRUP_MANAGEMENT_PORT}" "${TEST_TIMEOUT}" <<'PY' +import socket +import sys + +host = sys.argv[1] +port = int(sys.argv[2]) +timeout = int(sys.argv[3]) + +try: + with socket.create_connection((host, port), timeout=timeout) as sock: + sock.settimeout(3) + banner = sock.recv(1024) + if b"Welcome" not in banner: + raise RuntimeError(f"Expected Kamstrup management banner, got {banner[:120]!r}") + sock.sendall(b"help\r\n") + try: + response = sock.recv(1024) + except socket.timeout: + response = b"" + print(f"Kamstrup management banner: {banner[:80]!r}; response bytes={len(response)}") +except Exception as exc: + print(f"Kamstrup management probe failed: {exc}", 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 "Conpot 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 "Conpot 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 conpot_default" + + ensure_tcp_port_free_for_docker "${IEC104_PORT}" "--iec104-port" + ensure_tcp_port_free_for_docker "${GUARDIAN_AST_PORT}" "--guardian-ast-port" + ensure_udp_port_free_for_docker "${IPMI_PORT}" "--ipmi-port" + ensure_tcp_port_free_for_docker "${KAMSTRUP_PORT}" "--kamstrup-port" + ensure_tcp_port_free_for_docker "${KAMSTRUP_MANAGEMENT_PORT}" "--kamstrup-management-port" + + prepare_conpot_harness + test_enable_cleanup + + test_info "Starting isolated Conpot containers" + test_compose up -d --no-build >/dev/null + + wait_for_containers || test_die "One or more Conpot containers did not stay running" + test_ok "All Conpot containers are running" + + test_info "Waiting for Conpot listener entries" + wait_for_log_line "${LOG_DIR}/conpot_IEC104.log" "IEC 60870-5-104 protocol server started on" || test_die "IEC104 listener entry was not found" + wait_for_log_line "${LOG_DIR}/conpot_guardian_ast.log" "GuardianAST server started on" || test_die "Guardian AST listener entry was not found" + wait_for_log_line "${LOG_DIR}/conpot_ipmi.log" "IPMI server started on" || test_die "IPMI listener entry was not found" + wait_for_log_line "${LOG_DIR}/conpot_kamstrup_382.log" "Kamstrup protocol server started on" || test_die "Kamstrup meter listener entry was not found" + wait_for_log_line "${LOG_DIR}/conpot_kamstrup_382.log" "Kamstrup management protocol server started on" || test_die "Kamstrup management listener entry was not found" + test_ok "All Conpot listener entries were found" + + assert_containers_running + + test_info "Running IEC104 probe" + run_iec104_probe + + test_info "Running Guardian AST probe" + run_guardian_ast_probe + + test_info "Running IPMI probe" + run_ipmi_probe + + test_info "Running Kamstrup meter probe" + run_kamstrup_meter_probe + + test_info "Running Kamstrup management probe" + run_kamstrup_management_probe + + assert_containers_running + assert_no_runtime_errors + test_ok "No Conpot runtime errors found in logs" + + test_info "Validating Conpot JSON events" + wait_for_json_event "${LOG_DIR}/conpot_IEC104.json" "iec104" || test_die "IEC104 JSON event was not found" + test_ok "IEC104 JSON event found" + + wait_for_json_event "${LOG_DIR}/conpot_guardian_ast.json" "guardian_ast" || test_die "Guardian AST JSON event was not found" + test_ok "Guardian AST JSON event found" + + wait_for_json_event "${LOG_DIR}/conpot_kamstrup_382.json" "kamstrup_meter" || test_die "Kamstrup meter JSON event was not found" + test_ok "Kamstrup meter JSON event found" + + wait_for_json_event "${LOG_DIR}/conpot_kamstrup_382.json" "kamstrup_management" || test_die "Kamstrup management JSON event was not found" + test_ok "Kamstrup management JSON event found" + + wait_for_log_line "${LOG_DIR}/conpot_ipmi.log" "Connection established with" || test_die "IPMI connection establishment was not found in conpot_ipmi.log" + wait_for_log_line "${LOG_DIR}/conpot_ipmi.log" "IPMI response sent to" || test_die "IPMI response was not found in conpot_ipmi.log" + test_ok "IPMI protocol exchange was written to conpot_ipmi.log" + + wait_for_json_event "${LOG_DIR}/conpot_ipmi.json" "ipmi" || test_die "IPMI JSON event was not found" + test_ok "IPMI JSON event found" + + test_ok "Conpot post-build smoke test completed successfully" +} + +main "$@" diff --git a/docker/conpot/Dockerfile b/docker/conpot/Dockerfile index 37ed51e4..0369391a 100644 --- a/docker/conpot/Dockerfile +++ b/docker/conpot/Dockerfile @@ -62,6 +62,7 @@ RUN apk --no-cache -U upgrade && \ sed -i 's/port="6969"/port="69"/' /opt/conpot/conpot/templates/default/tftp/tftp.xml && \ sed -i 's/port="16100"/port="161"/' /opt/conpot/conpot/templates/IEC104/snmp/snmp.xml && \ sed -i 's/port="6230"/port="623"/' /opt/conpot/conpot/templates/ipmi/ipmi/ipmi.xml && \ + patch -p1 < /root/dist/patches/ipmi-json-events.patch && \ cp /root/dist/requirements.txt . && \ pip3 install --break-system-packages --no-cache-dir . && \ cd / && \ diff --git a/docker/conpot/dist/patches/ipmi-json-events.patch b/docker/conpot/dist/patches/ipmi-json-events.patch new file mode 100644 index 00000000..bf2b1522 --- /dev/null +++ b/docker/conpot/dist/patches/ipmi-json-events.patch @@ -0,0 +1,61 @@ +--- a/conpot/protocols/ipmi/ipmi_server.py ++++ b/conpot/protocols/ipmi/ipmi_server.py +@@ -92,11 +92,25 @@ + csum &= 0xFF + return csum + ++ def _add_event(self, address, event_data): ++ session = conpot_core.get_session( ++ "ipmi", ++ address[0], ++ address[1], ++ self.sock.getsockname()[0], ++ self.port, ++ ) ++ session.add_event(event_data) ++ + def handle(self, data, address): + # make sure self.session exists + if not address[0] in self.sessions.keys() or not hasattr(self, "session"): + # new session for new source + logger.info("New IPMI traffic from %s", address) ++ self._add_event( ++ address, ++ {"type": "NEW_CONNECTION", "request": data, "response": None}, ++ ) + self.session = FakeSession(address[0], "", "", address[1]) + self.session.server = self + self.uuid = uuid.uuid4() +@@ -165,10 +179,10 @@ + (clientaddr, clientlun) = struct.unpack("BB", data[17:19]) + level &= 0b1111 + self.send_auth_cap( +- myaddr, mylun, clientaddr, clientlun, session.sockaddr ++ myaddr, mylun, clientaddr, clientlun, session.sockaddr, data + ) + +- def send_auth_cap(self, myaddr, mylun, clientaddr, clientlun, sockaddr): ++ def send_auth_cap(self, myaddr, mylun, clientaddr, clientlun, sockaddr, request=None): + header = b"\x06\x00\xff\x07\x00\x00\x00\x00\x00\x00\x00\x00\x00\x10" + + headerdata = (clientaddr, clientlun | (7 << 2)) +@@ -181,11 +195,19 @@ + header += chr_py3(self._checksum(*bodydata)) + self.session.stage += 1 + logger.info("Connection established with %s", sockaddr) ++ self._add_event( ++ sockaddr, ++ {"type": "GET_CHANNEL_AUTH_CAPABILITIES", "request": request, "response": header}, ++ ) + self.session.send_data(header, sockaddr) + + def close_server_session(self): + logger.info("IPMI Session closed %s", self.session.sockaddr[0]) + # cleanup session ++ self._add_event( ++ self.session.sockaddr, ++ {"type": "CONNECTION_LOST", "request": None, "response": None}, ++ ) + del self.sessions[self.session.sockaddr[0]] + del self.session +