diff --git a/.env.example b/.env.example index 0e16e2c1a..b0240ac71 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,11 @@ SECUSCAN_DOCKER_NETWORK=restricted # Security Defaults SECUSCAN_SAFE_MODE_DEFAULT=true SECUSCAN_REQUIRE_CONSENT=true +# Governs scan-target validation only: Safe Mode (validation.py) and Network +# Policy's check_access() (network_policy.py) both key off this setting and +# must independently permit loopback for a loopback scan to succeed. It does +# NOT affect webhook/egress validation (validate_egress_target()), which +# always blocks loopback destinations to prevent SSRF. SECUSCAN_ALLOW_LOOPBACK_SCANS=true # SECUSCAN_ALLOWED_NETWORKS=127.0.0.1,192.168.*.*,10.*.*.*,172.16.*.* # SECUSCAN_CORS_ALLOWED_ORIGINS=http://127.0.0.1:5173,http://localhost:5173 diff --git a/backend/secuscan/network_policy.py b/backend/secuscan/network_policy.py index 6b012272e..1ddff72d3 100644 --- a/backend/secuscan/network_policy.py +++ b/backend/secuscan/network_policy.py @@ -242,24 +242,33 @@ def check_access( return False, reason, None # ═ Step 1: Check denylist (highest priority) ═ - for net, policy in self.denylist: - if self._is_expired(policy): - continue - if ip in net: - reason = f"Blocked by denylist rule: {policy.reason} (matched: {policy.cidr})" - entry = AuditLogEntry( - timestamp=datetime.now(), - plugin_id=plugin_id, - task_id=task_id, - action=PolicyAction.DENY, - dest_ip=dest_ip, - dest_port=dest_port, - dest_hostname=dest_hostname, - policy_matched=policy.cidr, - reason=reason, - ) - self._log_audit_entry(entry) - return False, reason, policy + # Loopback is exempted here when SECUSCAN_ALLOW_LOOPBACK_SCANS is enabled, + # mirroring the exemption Safe Mode already applies in validation.py. This + # applies to scan-target validation only (this method). It intentionally + # does not apply to validate_egress_target() below, which always blocks + # loopback for webhook/egress destinations to prevent SSRF. + from .config import settings + loopback_exempt = ip.is_loopback and settings.allow_loopback_scans + + if not loopback_exempt: + for net, policy in self.denylist: + if self._is_expired(policy): + continue + if ip in net: + reason = f"Blocked by denylist rule: {policy.reason} (matched: {policy.cidr})" + entry = AuditLogEntry( + timestamp=datetime.now(), + plugin_id=plugin_id, + task_id=task_id, + action=PolicyAction.DENY, + dest_ip=dest_ip, + dest_port=dest_port, + dest_hostname=dest_hostname, + policy_matched=policy.cidr, + reason=reason, + ) + self._log_audit_entry(entry) + return False, reason, policy # ═ Step 2: Check allowlist ═ for net, policy in self.allowlist: diff --git a/testing/backend/unit/test_network_policy.py b/testing/backend/unit/test_network_policy.py index 9144776e0..5085c77bd 100644 --- a/testing/backend/unit/test_network_policy.py +++ b/testing/backend/unit/test_network_policy.py @@ -61,9 +61,11 @@ def test_empty_allowlist_blocks_private_ranges(self, tmp_path): audit_log = tmp_path / "audit.log" engine = NetworkPolicyEngine(audit_log_path=str(audit_log)) _init_default_policies(engine) - # Private/metadata IPs should still be blocked by denylist + # Private/metadata IPs should still be blocked by denylist. + # 127.0.0.1 is excluded here: with the default SECUSCAN_ALLOW_LOOPBACK_SCANS=true + # it is intentionally exempted from the denylist (see TestLoopbackExemption). for blocked_ip in ["10.0.0.1", "192.168.1.1", "172.16.0.1", - "169.254.169.254", "127.0.0.1", "100.64.0.1"]: + "169.254.169.254", "100.64.0.1"]: allowed, _, _ = engine.check_access(blocked_ip, plugin_id="test") assert not allowed, f"{blocked_ip} should be blocked by denylist" @@ -415,6 +417,61 @@ def test_resolve_and_pin_returns_none_on_unresolvable(self, mock_gethostbyname, assert "Unresolvable" in reason +class TestLoopbackExemption: + """Test that Network Policy respects SECUSCAN_ALLOW_LOOPBACK_SCANS for loopback, + consistent with Safe Mode's handling in validation.py (GH #2047)""" + + def test_loopback_allowed_when_flag_enabled(self, monkeypatch, tmp_path): + """127.0.0.1 must pass Network Policy when allow_loopback_scans=True (the default), + matching the README Quick Start nmap/127.0.0.1 example""" + monkeypatch.setattr( + "backend.secuscan.config.settings.allow_loopback_scans", True + ) + audit_log = tmp_path / "audit.log" + engine = NetworkPolicyEngine(audit_log_path=str(audit_log)) + _init_default_policies(engine) + + allowed, reason, _ = engine.check_access("127.0.0.1", plugin_id="nmap") + assert allowed, f"127.0.0.1 should be allowed, got: {reason}" + + def test_loopback_ipv6_allowed_when_flag_enabled(self, monkeypatch, tmp_path): + """::1 must also be exempted when allow_loopback_scans=True""" + monkeypatch.setattr( + "backend.secuscan.config.settings.allow_loopback_scans", True + ) + audit_log = tmp_path / "audit.log" + engine = NetworkPolicyEngine(audit_log_path=str(audit_log)) + _init_default_policies(engine) + + allowed, reason, _ = engine.check_access("::1", plugin_id="nmap") + assert allowed, f"::1 should be allowed, got: {reason}" + + def test_loopback_blocked_when_flag_disabled(self, monkeypatch, tmp_path): + """127.0.0.1 must still be blocked when allow_loopback_scans=False""" + monkeypatch.setattr( + "backend.secuscan.config.settings.allow_loopback_scans", False + ) + audit_log = tmp_path / "audit.log" + engine = NetworkPolicyEngine(audit_log_path=str(audit_log)) + _init_default_policies(engine) + + allowed, reason, _ = engine.check_access("127.0.0.1", plugin_id="nmap") + assert not allowed + assert "denylist" in reason.lower() + + def test_non_loopback_denylist_entries_unaffected_by_flag(self, monkeypatch, tmp_path): + """Enabling loopback exemption must not weaken unrelated denylist rules""" + monkeypatch.setattr( + "backend.secuscan.config.settings.allow_loopback_scans", True + ) + audit_log = tmp_path / "audit.log" + engine = NetworkPolicyEngine(audit_log_path=str(audit_log)) + _init_default_policies(engine) + + allowed, _, _ = engine.check_access("10.0.0.1", plugin_id="test") + assert not allowed, "Private RFC1918 range must remain blocked" + + class TestDefaultDenylistSSRFProtection: """Test that private subnets are blocked by default in settings"""