diff --git a/testing/backend/unit/test_validation.py b/testing/backend/unit/test_validation.py index bbf13016..3e0d37cd 100644 --- a/testing/backend/unit/test_validation.py +++ b/testing/backend/unit/test_validation.py @@ -1,7 +1,5 @@ import pytest import socket -import ipaddress -from backend.secuscan import validation as validation_module from backend.secuscan.config import settings from backend.secuscan.validation import ( validate_target, validate_port, validate_port_range, validate_url, @@ -19,17 +17,58 @@ def test_validate_target(): # Safe mode restrictions assert validate_target("8.8.8.8", safe_mode=True)[0] is False # Public IP blocked in safe mode assert validate_target("military.mil", safe_mode=True)[0] is False # Blocked TLD + assert validate_target("example.gov", safe_mode=True)[0] is False # Invalid targets assert validate_target("10.0.0.0/24")[0] is True # Private CIDR ranges are allowed in safe mode assert validate_target("not!a!valid!hostname")[0] is False -def test_validate_target_safe_mode_blocks_public_hostname(monkeypatch): - def fake_getaddrinfo(_host, *_args, **_kwargs): - return [(socket.AF_INET, None, None, None, ("8.8.8.8", 0))] +def test_validate_target_public_ip_allowed_without_safe_mode(): + ok, msg = validate_target( + "8.8.8.8", + safe_mode=False + ) + + assert ok is True + assert msg == "" + +def test_validate_target_safe_mode_rejects_unresolved_hostname(monkeypatch): + """ + Mutation guard: + Safe mode must fail closed if DNS returns no addresses. + """ + + def fake_getaddrinfo(*args, **kwargs): + return [] monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo) - assert validate_target("example.com", safe_mode=True)[0] is False + + ok, msg = validate_target( + "unknown.example", + safe_mode=True + ) + + assert ok is False + assert "did not resolve" in msg.lower() + +def test_validate_target_safe_mode_blocks_link_local(): + ok, msg = validate_target( + "169.254.1.10", + safe_mode=True + ) + + assert ok is False + assert "blocked network" in msg.lower() + + +def test_validate_target_safe_mode_blocks_multicast(): + ok, msg = validate_target( + "224.0.0.1", + safe_mode=True + ) + + assert ok is False + assert "blocked network" in msg.lower() def test_validate_target_safe_mode_blocks_multi_record_when_any_public(monkeypatch): """If any A/AAAA record is public, safe-mode must fail closed.""" @@ -42,55 +81,132 @@ def fake_getaddrinfo(_host, *_args, **_kwargs): monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo) assert validate_target("multirecord.example", safe_mode=True)[0] is False -def test_validate_target_safe_mode_blocks_dns_rebinding_union(monkeypatch): - """Rebinding/round-robin: validate_target resolves twice and validates the union.""" - calls = {"n": 0} +def test_validate_target_blocks_loopback_when_disabled(monkeypatch): + monkeypatch.setattr(settings, "allow_loopback_scans", False) - def fake_getaddrinfo(_host, *_args, **_kwargs): - calls["n"] += 1 - if calls["n"] == 1: - return [(socket.AF_INET, None, None, None, ("192.168.1.10", 0))] - return [(socket.AF_INET, None, None, None, ("8.8.8.8", 0))] + ok, msg = validate_target( + "127.0.0.1", + safe_mode=False + ) - monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo) - ok, _msg = validate_target("rebind.example", safe_mode=True) assert ok is False - assert calls["n"] >= 2 + assert "loopback" in msg.lower() -def test_validate_target_safe_mode_blocks_url_ip_literal(): - assert validate_target("http://8.8.8.8", safe_mode=True)[0] is False +def test_validate_target_safe_mode_allows_private_hostname(monkeypatch): -def test_validate_target_ipv4_with_ipv6_allowed_network_does_not_crash(monkeypatch): - monkeypatch.setattr(settings, "allowed_networks", ["fc00::/7"]) - ok, msg = validate_target("127.0.0.1", safe_mode=True) + def fake_getaddrinfo(*args, **kwargs): + return [ + ( + socket.AF_INET, + None, + None, + None, + ("192.168.1.20", 0) + ) + ] + + monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo) + + ok, msg = validate_target( + "internal.example", + safe_mode=True + ) + + assert ok is True + assert msg == "" + +def test_validate_target_safe_mode_blocks_public_hostname(monkeypatch): + + def fake_getaddrinfo(*args, **kwargs): + return [ + ( + socket.AF_INET, + None, + None, + None, + ("8.8.8.8", 0) + ) + ] + + monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo) + + ok, msg = validate_target( + "public.example", + safe_mode=True + ) assert ok is False - assert msg == "Target not within allowed networks in safe mode (SecuScan Guardrail)" + assert "public" in msg.lower() +def test_validate_target_safe_mode_blocks_link_local_hostname(monkeypatch): -def test_validate_target_ipv6_with_ipv4_allowed_network_does_not_crash(monkeypatch): - monkeypatch.setattr(settings, "allowed_networks", ["127.0.0.0/8"]) - ok, msg = validate_target("::1", safe_mode=True) + def fake_getaddrinfo(*args, **kwargs): + return [ + ( + socket.AF_INET, + None, + None, + None, + ("169.254.10.10", 0) + ) + ] + + monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo) + + ok, msg = validate_target( + "linklocal.example", + safe_mode=True + ) assert ok is False - assert msg == "Public IPs/networks not allowed in safe mode (SecuScan Guardrail)" + assert "blocked network" in msg.lower() +def test_validate_target_hostname_respects_allowed_networks(monkeypatch): -def test_validate_target_mixed_allowed_networks_uses_later_same_version_entry(monkeypatch): - monkeypatch.setattr(settings, "allowed_networks", ["fc00::/7", "127.0.0.0/8"]) - ok, msg = validate_target("127.0.0.1", safe_mode=True) + monkeypatch.setattr( + settings, + "allowed_networks", + ["192.168.1.0/24"] + ) - assert ok is True - assert msg == "" + def fake_getaddrinfo(*args, **kwargs): + return [ + ( + socket.AF_INET, + None, + None, + None, + ("10.0.0.5", 0) + ) + ] -def test_validate_target_mixed_allowed_networks_uses_later_same_version_ipv6_entry(monkeypatch): - monkeypatch.setattr(validation_module, "ALLOWED_PRIVATE", [ipaddress.ip_network("fc00::/7")]) - monkeypatch.setattr(settings, "allowed_networks", ["127.0.0.0/8", "fc00::/7"]) + monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo) - ok, msg = validate_target("fd00::1", safe_mode=True) + ok, msg = validate_target( + "internal.example", + safe_mode=True + ) - assert ok is True - assert msg == "" + assert ok is False + assert "allowed networks" in msg.lower() + +def test_validate_target_safe_mode_blocks_url_ip_literal(): + assert validate_target("http://8.8.8.8", safe_mode=True)[0] is False + +def test_validate_target_safe_mode_rejects_outside_allowed_network(monkeypatch): + monkeypatch.setattr( + settings, + "allowed_networks", + ["192.168.1.0/24"] + ) + + ok, msg = validate_target( + "10.0.0.5", + safe_mode=True + ) + + assert ok is False + assert "allowed networks" in msg.lower() def test_validate_port(): assert validate_port(80) == (True, "") @@ -314,7 +430,6 @@ def test_validate_command_network_egress_log_only(monkeypatch): assert ok is False assert "network policy" in err.lower() - def test_validate_command_network_egress_ignores_malformed_urls(): """Malformed URLs without a valid hostname should be ignored."""