diff --git a/backend/app/analysis/checks.py b/backend/app/analysis/checks.py index 8921f2b..4c80b66 100644 --- a/backend/app/analysis/checks.py +++ b/backend/app/analysis/checks.py @@ -20,6 +20,15 @@ check_mqtt_open — port 1883 open (no TLS) medium check_open_dns_resolver — port 53 open medium check_modbus_open — port 502 open high + check_snmp_exposed — port 161/udp open high + check_redis_exposed — port 6379 open critical + check_docker_daemon_tcp — port 2375 open (no TLS) critical + check_docker_daemon_tls — port 2376 open (TLS) high + check_elasticsearch_open — port 9200 open high + check_portainer_exposed — ports 9000/9443 open medium + check_home_assistant_exposed — port 8123 open medium + check_tftp_open — port 69/udp open medium + check_wireguard_vpn — port 51820/udp open low """ from __future__ import annotations @@ -419,6 +428,186 @@ def check_modbus_open(device: Device) -> list[RiskData]: ] +def check_snmp_exposed(device: Device) -> list[RiskData]: + """HIGH — SNMP (port 161/udp) open; default community string leaks device info.""" + ports = _has_port(device, 161, protocol="udp") + if not ports: + return [] + return [ + RiskData( + check_id="snmp_exposed", + severity="high", + title="SNMP port open", + description=( + f"Device {device.ip_address} has SNMP (port 161/udp) open. " + "Devices using the default 'public' community string expose full device " + "information to anyone on the network. Change the community string or " + "switch to SNMPv3 with authentication and encryption." + ), + ) + ] + + +def check_redis_exposed(device: Device) -> list[RiskData]: + """CRITICAL — Redis (port 6379) open; no authentication by default.""" + ports = _has_port(device, 6379) + if not ports: + return [] + return [ + RiskData( + check_id="redis_exposed", + severity="critical", + title="Redis database port open", + description=( + f"Device {device.ip_address} has Redis (port 6379/tcp) open. " + "Redis has no authentication enabled by default, and the CONFIG SET " + "command can be used to write arbitrary files and gain remote code " + "execution. Bind Redis to 127.0.0.1 and require a strong password." + ), + ) + ] + + +def check_docker_daemon_tcp(device: Device) -> list[RiskData]: + """CRITICAL — Docker TCP daemon (port 2375) open; unauthenticated API.""" + ports = _has_port(device, 2375) + if not ports: + return [] + return [ + RiskData( + check_id="docker_daemon_tcp", + severity="critical", + title="Docker daemon exposed without TLS", + description=( + f"Device {device.ip_address} has the Docker daemon API (port 2375/tcp) " + "open without TLS. This allows anyone on the network to run containers, " + "mount the host filesystem, and gain full root access to the host. " + "Disable the TCP listener or enable TLS client authentication immediately." + ), + ) + ] + + +def check_docker_daemon_tls(device: Device) -> list[RiskData]: + """HIGH — Docker TLS daemon (port 2376) open; verify TLS is properly configured.""" + ports = _has_port(device, 2376) + if not ports: + return [] + return [ + RiskData( + check_id="docker_daemon_tls", + severity="high", + title="Docker daemon TLS port open", + description=( + f"Device {device.ip_address} has the Docker daemon TLS port (2376/tcp) " + "open. While TLS is required on this port, it still exposes the Docker " + "API remotely. Verify that client certificate authentication is enforced " + "and restrict access to trusted hosts only." + ), + ) + ] + + +def check_elasticsearch_open(device: Device) -> list[RiskData]: + """HIGH — Elasticsearch HTTP (port 9200) open; historically unauthenticated.""" + ports = _has_port(device, 9200) + if not ports: + return [] + return [ + RiskData( + check_id="elasticsearch_open", + severity="high", + title="Elasticsearch HTTP port open", + description=( + f"Device {device.ip_address} has Elasticsearch (port 9200/tcp) open. " + "Older versions of Elasticsearch had no authentication, leading to " + "widespread data breaches. Ensure X-Pack security is enabled, require " + "authentication, and restrict network access to trusted hosts." + ), + ) + ] + + +def check_portainer_exposed(device: Device) -> list[RiskData]: + """MEDIUM — Portainer admin UI (ports 9000/9443) open.""" + ports = _has_port(device, 9000) + _has_port(device, 9443) + if not ports: + return [] + port_list = ", ".join(str(p.port_number) for p in ports) + return [ + RiskData( + check_id="portainer_exposed", + severity="medium", + title="Portainer Docker management UI exposed", + description=( + f"Device {device.ip_address} has Portainer (port(s) {port_list}) open. " + "Portainer provides a web UI for managing the entire Docker stack. " + "Ensure a strong admin password is set, enable HTTPS only, and restrict " + "access to trusted hosts with a firewall rule." + ), + ) + ] + + +def check_home_assistant_exposed(device: Device) -> list[RiskData]: + """MEDIUM — Home Assistant (port 8123) open on the LAN.""" + ports = _has_port(device, 8123) + if not ports: + return [] + return [ + RiskData( + check_id="home_assistant_exposed", + severity="medium", + title="Home Assistant web interface exposed", + description=( + f"Device {device.ip_address} has Home Assistant (port 8123/tcp) open. " + "Home Assistant controls smart home devices and may have access to " + "locks, cameras, and alarms. Ensure a strong password is set, enable " + "two-factor authentication, and avoid exposing this port to the internet." + ), + ) + ] + + +def check_tftp_open(device: Device) -> list[RiskData]: + """MEDIUM — TFTP (port 69/udp) open; no authentication protocol.""" + ports = _has_port(device, 69, protocol="udp") + if not ports: + return [] + return [ + RiskData( + check_id="tftp_open", + severity="medium", + title="TFTP port open", + description=( + f"Device {device.ip_address} has TFTP (port 69/udp) open. " + "TFTP (Trivial File Transfer Protocol) has no authentication mechanism " + "and transfers files in plaintext. It is commonly used for network " + "booting and router firmware — disable it if not actively needed." + ), + ) + ] + + +def check_wireguard_vpn(device: Device) -> list[RiskData]: + """LOW — WireGuard VPN (port 51820/udp) detected; informational.""" + ports = _has_port(device, 51820, protocol="udp") + if not ports: + return [] + return [ + RiskData( + check_id="wireguard_vpn", + severity="low", + title="WireGuard VPN port open", + description=( + f"Device {device.ip_address} has WireGuard VPN (port 51820/udp) open. " + "This is informational — WireGuard is a modern, secure VPN protocol. " + "Ensure only authorised peers are configured and keep WireGuard updated." + ), + ) + ] + + # ── Master list of all checks ───────────────────────────────────────────────── ALL_CHECKS = [ @@ -435,4 +624,13 @@ def check_modbus_open(device: Device) -> list[RiskData]: check_mqtt_open, check_open_dns_resolver, check_modbus_open, + check_snmp_exposed, + check_redis_exposed, + check_docker_daemon_tcp, + check_docker_daemon_tls, + check_elasticsearch_open, + check_portainer_exposed, + check_home_assistant_exposed, + check_tftp_open, + check_wireguard_vpn, ] diff --git a/backend/app/recommendations/__init__.py b/backend/app/recommendations/__init__.py index e47eac4..90fb54a 100644 --- a/backend/app/recommendations/__init__.py +++ b/backend/app/recommendations/__init__.py @@ -253,6 +253,155 @@ class _Advice: effort="medium", impact="high", ), + "snmp_exposed": _Advice( + title="Secure or disable SNMP", + description=( + "SNMP with the default 'public' community string leaks full device information " + "to anyone on the network. Upgrade to SNMPv3 or disable SNMP entirely." + ), + steps=[ + "Log in to the device management interface.", + "Locate the SNMP configuration section.", + "If SNMP is not required, disable it completely.", + "If SNMP is needed, change the community string from 'public' " + "to a strong random value.", + "Upgrade to SNMPv3 with AuthPriv mode (authentication + encryption) where supported.", + "Restrict SNMP access to specific management hosts using an ACL.", + ], + effort="low", + impact="high", + ), + "redis_exposed": _Advice( + title="Bind Redis to localhost and require authentication", + description=( + "An unauthenticated Redis instance accessible from the network is a critical " + "vulnerability enabling data theft and remote code execution via CONFIG commands." + ), + steps=[ + "Edit redis.conf and set 'bind 127.0.0.1' to restrict listening to localhost only.", + "Set a strong password: 'requirepass '.", + "If Redis must be network-accessible, use TLS (Redis 6+) and require authentication.", + "Apply a firewall rule to block port 6379 from all but authorised hosts.", + "Restart Redis and verify the configuration with 'redis-cli ping' from a remote host.", + ], + effort="low", + impact="critical", + ), + "docker_daemon_tcp": _Advice( + title="Disable the unauthenticated Docker TCP socket", + description=( + "The Docker TCP daemon without TLS allows anyone to run containers, mount the " + "host filesystem, and gain root access. This must be remediated immediately." + ), + steps=[ + "Edit /etc/docker/daemon.json and remove or comment out the 'hosts' entry for tcp://.", + "If remote Docker access is required, configure TLS with client " + "certificates instead " + "(dockerd --tlsverify --tlscacert=ca.pem " + "--tlscert=server-cert.pem --tlskey=server-key.pem).", + "Restart Docker: 'sudo systemctl restart docker'.", + "Apply a firewall rule to block port 2375 immediately as a temporary measure.", + "Verify the TCP socket is no longer listening with 'ss -tlnp | grep 2375'.", + ], + effort="low", + impact="critical", + ), + "docker_daemon_tls": _Advice( + title="Restrict Docker TLS daemon access", + description=( + "The Docker TLS daemon port exposes the full Docker API remotely. " + "Verify that mutual TLS client authentication is enforced and access is restricted." + ), + steps=[ + "Confirm TLS is configured with --tlsverify and a CA, server cert, and server key.", + "Ensure --tlsverify is set so only clients with a valid certificate can connect.", + "Apply a firewall rule to allow port 2376 only from trusted management hosts.", + "Rotate certificates periodically and revoke access for unused clients.", + ], + effort="medium", + impact="high", + ), + "elasticsearch_open": _Advice( + title="Enable Elasticsearch authentication and restrict network access", + description=( + "Elasticsearch without authentication has led to numerous data breaches. " + "Enable X-Pack security and restrict network access." + ), + steps=[ + "Enable X-Pack security in elasticsearch.yml: 'xpack.security.enabled: true'.", + "Set passwords for built-in users: 'bin/elasticsearch-setup-passwords interactive'.", + "Configure TLS for inter-node and client communication.", + "Apply a firewall rule to allow port 9200 only from authorised application hosts.", + "Review cluster settings to confirm no anonymous access is permitted.", + ], + effort="medium", + impact="high", + ), + "portainer_exposed": _Advice( + title="Secure Portainer and restrict network access", + description=( + "Portainer controls your entire Docker environment. A compromised Portainer " + "instance gives full access to all running containers and the host." + ), + steps=[ + "Set a strong admin password if not already done (Portainer will prompt on first run).", + "Enable HTTPS-only access — disable port 9000 (HTTP) and use 9443 (HTTPS) only.", + "Apply a firewall rule to allow Portainer ports only from trusted management hosts.", + "Consider placing Portainer behind a VPN or SSH tunnel.", + "Enable two-factor authentication in Portainer settings if available.", + ], + effort="low", + impact="medium", + ), + "home_assistant_exposed": _Advice( + title="Secure Home Assistant and enable 2FA", + description=( + "Home Assistant controls smart home devices. Ensure it is properly secured " + "against unauthorised access from the local network." + ), + steps=[ + "Enable two-factor authentication (TOTP) in your Home Assistant profile settings.", + "Set a strong password for all user accounts.", + "Apply a firewall rule or network policy to restrict port 8123 to trusted devices.", + "If remote access is needed, use the Nabu Casa cloud service or a VPN rather than " + "direct port forwarding.", + "Keep Home Assistant updated to receive security patches.", + ], + effort="low", + impact="medium", + ), + "tftp_open": _Advice( + title="Disable TFTP if not actively required", + description=( + "TFTP has no authentication and transfers files in plaintext. " + "Disable it unless it is actively used for network booting or firmware updates." + ), + steps=[ + "Identify the service using TFTP (port 69/udp) — " + "common culprits: tftpd, dnsmasq, routers.", + "If TFTP is not required, disable or stop the service.", + "If TFTP is needed (e.g., PXE booting), restrict access to specific client IPs.", + "Apply a firewall rule to block port 69/udp from untrusted network segments.", + ], + effort="low", + impact="medium", + ), + "wireguard_vpn": _Advice( + title="Review WireGuard peer configuration", + description=( + "WireGuard is a secure VPN protocol. This entry is informational — " + "verify the peer list and keep WireGuard updated." + ), + steps=[ + "Review the WireGuard configuration (/etc/wireguard/*.conf) for authorised peers only.", + "Remove any stale or unused peer entries.", + "Ensure the WireGuard package is kept up to date.", + "Consider using a non-default port if the VPN endpoint " + "should not be easily discoverable.", + ], + effort="low", + impact="low", + ), } diff --git a/backend/tests/test_analysis.py b/backend/tests/test_analysis.py index 65625fc..24dc2aa 100644 --- a/backend/tests/test_analysis.py +++ b/backend/tests/test_analysis.py @@ -439,6 +439,166 @@ def test_check_modbus_open_returns_risk(): assert results[0].severity == "high" +# ── check_snmp_exposed ──────────────────────────────────────────────────────── + + +@pytest.mark.unit +def test_check_snmp_exposed_fires_on_port_161_udp(): + from app.analysis.checks import check_snmp_exposed + + device = _make_device(ports=[_make_port(161, protocol="udp")]) + results = check_snmp_exposed(device) + assert len(results) == 1 + assert results[0].check_id == "snmp_exposed" + assert results[0].severity == "high" + + +@pytest.mark.unit +def test_check_snmp_exposed_no_false_positive_tcp(): + from app.analysis.checks import check_snmp_exposed + + device = _make_device(ports=[_make_port(161, protocol="tcp")]) + assert check_snmp_exposed(device) == [] + + +# ── check_redis_exposed ─────────────────────────────────────────────────────── + + +@pytest.mark.unit +def test_check_redis_exposed_fires_on_port_6379(): + from app.analysis.checks import check_redis_exposed + + device = _make_device(ports=[_make_port(6379)]) + results = check_redis_exposed(device) + assert len(results) == 1 + assert results[0].check_id == "redis_exposed" + assert results[0].severity == "critical" + + +# ── check_docker_daemon_tcp ─────────────────────────────────────────────────── + + +@pytest.mark.unit +def test_check_docker_daemon_tcp_fires_on_port_2375(): + from app.analysis.checks import check_docker_daemon_tcp + + device = _make_device(ports=[_make_port(2375)]) + results = check_docker_daemon_tcp(device) + assert len(results) == 1 + assert results[0].check_id == "docker_daemon_tcp" + assert results[0].severity == "critical" + + +# ── check_docker_daemon_tls ─────────────────────────────────────────────────── + + +@pytest.mark.unit +def test_check_docker_daemon_tls_fires_on_port_2376(): + from app.analysis.checks import check_docker_daemon_tls + + device = _make_device(ports=[_make_port(2376)]) + results = check_docker_daemon_tls(device) + assert len(results) == 1 + assert results[0].check_id == "docker_daemon_tls" + assert results[0].severity == "high" + + +# ── check_elasticsearch_open ────────────────────────────────────────────────── + + +@pytest.mark.unit +def test_check_elasticsearch_open_fires_on_port_9200(): + from app.analysis.checks import check_elasticsearch_open + + device = _make_device(ports=[_make_port(9200)]) + results = check_elasticsearch_open(device) + assert len(results) == 1 + assert results[0].check_id == "elasticsearch_open" + assert results[0].severity == "high" + + +# ── check_portainer_exposed ─────────────────────────────────────────────────── + + +@pytest.mark.unit +def test_check_portainer_exposed_fires_on_port_9000(): + from app.analysis.checks import check_portainer_exposed + + device = _make_device(ports=[_make_port(9000)]) + results = check_portainer_exposed(device) + assert len(results) == 1 + assert results[0].check_id == "portainer_exposed" + assert results[0].severity == "medium" + + +@pytest.mark.unit +def test_check_portainer_exposed_fires_on_port_9443(): + from app.analysis.checks import check_portainer_exposed + + device = _make_device(ports=[_make_port(9443)]) + results = check_portainer_exposed(device) + assert len(results) == 1 + assert results[0].check_id == "portainer_exposed" + + +# ── check_home_assistant_exposed ────────────────────────────────────────────── + + +@pytest.mark.unit +def test_check_home_assistant_exposed_fires_on_port_8123(): + from app.analysis.checks import check_home_assistant_exposed + + device = _make_device(ports=[_make_port(8123)]) + results = check_home_assistant_exposed(device) + assert len(results) == 1 + assert results[0].check_id == "home_assistant_exposed" + assert results[0].severity == "medium" + + +# ── check_tftp_open ─────────────────────────────────────────────────────────── + + +@pytest.mark.unit +def test_check_tftp_open_fires_on_port_69_udp(): + from app.analysis.checks import check_tftp_open + + device = _make_device(ports=[_make_port(69, protocol="udp")]) + results = check_tftp_open(device) + assert len(results) == 1 + assert results[0].check_id == "tftp_open" + assert results[0].severity == "medium" + + +@pytest.mark.unit +def test_check_tftp_open_no_false_positive_tcp(): + from app.analysis.checks import check_tftp_open + + device = _make_device(ports=[_make_port(69, protocol="tcp")]) + assert check_tftp_open(device) == [] + + +# ── check_wireguard_vpn ─────────────────────────────────────────────────────── + + +@pytest.mark.unit +def test_check_wireguard_vpn_fires_on_port_51820_udp(): + from app.analysis.checks import check_wireguard_vpn + + device = _make_device(ports=[_make_port(51820, protocol="udp")]) + results = check_wireguard_vpn(device) + assert len(results) == 1 + assert results[0].check_id == "wireguard_vpn" + assert results[0].severity == "low" + + +@pytest.mark.unit +def test_check_wireguard_vpn_no_false_positive_tcp(): + from app.analysis.checks import check_wireguard_vpn + + device = _make_device(ports=[_make_port(51820, protocol="tcp")]) + assert check_wireguard_vpn(device) == [] + + # ── Integration: run_checks with real DB ─────────────────────────────────────