From e7776cbaccc39b094076740dabb7b06c369ac283 Mon Sep 17 00:00:00 2001 From: i-OmSharma Date: Tue, 21 Jul 2026 12:40:03 +0530 Subject: [PATCH 1/2] fix(network-policy): respect SECUSCAN_ALLOW_LOOPBACK_SCANS for loopback targets Fixes #2047 by skipping denylist match for loopback IP addresses (127.0.0.1 and ::1) when settings.allow_loopback_scans is enabled in Network Policy. This brings network_policy.py in alignment with validation.py and fixes the default Quick Start experience for fresh installs. - Added TestLoopbackExemption test suite in testing/backend/unit/test_network_policy.py. - Preserved strict validation for webhook egress targets to prevent SSRF vulnerabilities. Signed-off-by: i-OmSharma --- .env.example | 2 + backend/secuscan/network_policy.py | 45 +++++++++------ testing/backend/unit/test_network_policy.py | 61 ++++++++++++++++++++- 3 files changed, 88 insertions(+), 20 deletions(-) diff --git a/.env.example b/.env.example index 0e16e2c1a..adcdf8a1e 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,8 @@ SECUSCAN_DOCKER_NETWORK=restricted # Security Defaults SECUSCAN_SAFE_MODE_DEFAULT=true SECUSCAN_REQUIRE_CONSENT=true +# Applies to both Safe Mode (validation.py) and Network Policy (network_policy.py) — +# both gates must independently permit loopback, and both key off this one setting. 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..6c401dba4 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. Safe + # Mode and Network Policy are independent gates; both must agree that + # loopback is permitted for a loopback scan to succeed, and both key off + # the same setting so they don't drift out of sync again. + 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""" From 0539eda0f64eea94a05ed4db2f835040086ff13e Mon Sep 17 00:00:00 2001 From: i-OmSharma Date: Tue, 21 Jul 2026 12:48:29 +0530 Subject: [PATCH 2/2] docs: scope loopback exemption comments to scan targets only Clarified in .env.example and network_policy.py that SECUSCAN_ALLOW_LOOPBACK_SCANS applies strictly to scan-target validation (check_access) and does not relax webhook egress validation (validate_egress_target). --- .env.example | 7 +++++-- backend/secuscan/network_policy.py | 8 ++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index adcdf8a1e..b0240ac71 100644 --- a/.env.example +++ b/.env.example @@ -28,8 +28,11 @@ SECUSCAN_DOCKER_NETWORK=restricted # Security Defaults SECUSCAN_SAFE_MODE_DEFAULT=true SECUSCAN_REQUIRE_CONSENT=true -# Applies to both Safe Mode (validation.py) and Network Policy (network_policy.py) — -# both gates must independently permit loopback, and both key off this one setting. +# 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 6c401dba4..1ddff72d3 100644 --- a/backend/secuscan/network_policy.py +++ b/backend/secuscan/network_policy.py @@ -243,10 +243,10 @@ def check_access( # ═ Step 1: Check denylist (highest priority) ═ # Loopback is exempted here when SECUSCAN_ALLOW_LOOPBACK_SCANS is enabled, - # mirroring the exemption Safe Mode already applies in validation.py. Safe - # Mode and Network Policy are independent gates; both must agree that - # loopback is permitted for a loopback scan to succeed, and both key off - # the same setting so they don't drift out of sync again. + # 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