From 7249db4b8da4f78aacce3227cf6bd5db5a12c864 Mon Sep 17 00:00:00 2001 From: i-OmSharma Date: Tue, 21 Jul 2026 12:40:03 +0530 Subject: [PATCH 1/4] 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 0d4e08f97..6fc4955b3 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 3eaa6c7b9..75fee8543 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 5b3ba5deb..d97143ad8 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 and cloud metadata are always blocked, and that this protection cannot be silently dropped via operator config.""" From dbe4d945853a4a9e4dcd2484f7f290b1d0a6aac4 Mon Sep 17 00:00:00 2001 From: i-OmSharma Date: Tue, 21 Jul 2026 12:48:29 +0530 Subject: [PATCH 2/4] 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 6fc4955b3..dc813684b 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 75fee8543..0570652b7 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 From 5f066dcf184f06a55b27ec72bba731497877c81b Mon Sep 17 00:00:00 2001 From: i-OmSharma Date: Sat, 25 Jul 2026 10:11:04 +0530 Subject: [PATCH 3/4] fix(network-policy): preserve explicit operator loopback deny rules over exemption --- backend/secuscan/network_policy.py | 74 ++++++++++++++------- testing/backend/unit/test_network_policy.py | 40 +++++++++++ 2 files changed, 89 insertions(+), 25 deletions(-) diff --git a/backend/secuscan/network_policy.py b/backend/secuscan/network_policy.py index 0570652b7..e6b94f978 100644 --- a/backend/secuscan/network_policy.py +++ b/backend/secuscan/network_policy.py @@ -33,6 +33,11 @@ class NetworkPolicy: reason: str # Why this rule exists created_at: datetime # When rule was added expires_at: Optional[datetime] = None # Optional expiration + # True only for the default/mandatory loopback deny rule. When + # SECUSCAN_ALLOW_LOOPBACK_SCANS is enabled, check_access() skips *this* + # rule for loopback scan targets. Operator-configured loopback deny rules + # leave this False and are therefore always enforced. + loopback_exemptible: bool = False def to_dict(self) -> Dict[str, Any]: """Convert to dictionary for logging""" @@ -136,7 +141,8 @@ def add_deny_rule( self, cidr: str, reason: str = "System blocked", - expires_at: Optional[datetime] = None + expires_at: Optional[datetime] = None, + loopback_exemptible: bool = False, ) -> None: """ Add a network to the denylist. @@ -145,6 +151,9 @@ def add_deny_rule( cidr: Network in CIDR notation reason: Human-readable reason for this rule expires_at: Optional expiration timestamp + loopback_exemptible: Mark this as the default loopback rule that + SECUSCAN_ALLOW_LOOPBACK_SCANS may bypass for scan targets. Must + stay False for operator-configured deny rules so they always win. """ try: net = ipaddress.ip_network(cidr, strict=False) @@ -154,6 +163,7 @@ def add_deny_rule( reason=reason, created_at=datetime.now(), expires_at=expires_at, + loopback_exemptible=loopback_exemptible, ) self.denylist.append((net, policy)) logger.info(f"Added deny rule for {cidr}: {reason}") @@ -242,33 +252,39 @@ def check_access( return False, reason, None # ═ 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. 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. + # When SECUSCAN_ALLOW_LOOPBACK_SCANS is enabled we exempt loopback scan + # targets, mirroring the exemption Safe Mode already applies in + # validation.py. The exemption is scoped to *only* the default/mandatory + # loopback deny rule (loopback_exemptible=True): an operator who + # explicitly adds a loopback deny rule still has it enforced, so the flag + # never silently overrides an intentional operator block. This applies to + # scan-target validation only (this method); validate_egress_target() + # below always blocks loopback for webhook/egress 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): + for net, policy in self.denylist: + if self._is_expired(policy): + continue + if ip in net: + # Skip only the default loopback rule when scans are allowed; + # operator-configured loopback deny rules keep precedence. + if loopback_exempt and policy.loopback_exemptible: 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 + 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: @@ -503,7 +519,15 @@ def _init_default_policies(engine: NetworkPolicyEngine) -> None: # things. for cidr in MANDATORY_DENYLIST: try: - engine.add_deny_rule(cidr, reason="Mandatory denylist (not operator-configurable)") + # Tag the default loopback ranges so SECUSCAN_ALLOW_LOOPBACK_SCANS can + # bypass only these for scan targets, without weakening the other + # mandatory ranges or any operator-added loopback deny rule. + is_loopback_rule = ipaddress.ip_network(cidr, strict=False).network_address.is_loopback + engine.add_deny_rule( + cidr, + reason="Mandatory denylist (not operator-configurable)", + loopback_exemptible=is_loopback_rule, + ) except ValueError: logger.warning(f"Skipping invalid mandatory denylist CIDR: {cidr}") diff --git a/testing/backend/unit/test_network_policy.py b/testing/backend/unit/test_network_policy.py index d97143ad8..ff2ec23b5 100644 --- a/testing/backend/unit/test_network_policy.py +++ b/testing/backend/unit/test_network_policy.py @@ -471,6 +471,39 @@ def test_non_loopback_denylist_entries_unaffected_by_flag(self, monkeypatch, tmp allowed, _, _ = engine.check_access("10.0.0.1", plugin_id="test") assert not allowed, "Private RFC1918 range must remain blocked" + def test_operator_explicit_loopback_deny_honored_despite_flag(self, monkeypatch, tmp_path): + """An operator who explicitly denies loopback must have it enforced even + when allow_loopback_scans=True. The flag exempts only the default loopback + rule, never an intentional operator deny (maintainer review on #2053).""" + 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) + # Operator explicitly re-blocks loopback (loopback_exemptible defaults to False) + engine.add_deny_rule("127.0.0.1/32", reason="Operator explicitly blocks loopback") + + allowed, reason, _ = engine.check_access("127.0.0.1", plugin_id="test") + assert not allowed, "explicit operator loopback deny must win over the flag" + assert "denylist" in reason.lower() + + def test_default_loopback_rule_still_exempt_with_operator_deny_on_other_range( + self, monkeypatch, tmp_path + ): + """Sanity: adding an unrelated operator deny rule must not accidentally + re-block loopback while the flag is on (only scan targets are exempted).""" + 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) + engine.add_deny_rule("203.0.113.0/24", reason="Operator unrelated block") + + allowed, reason, _ = engine.check_access("127.0.0.1", plugin_id="nmap") + assert allowed, f"loopback scan target should stay exempt, got: {reason}" + class TestDefaultDenylistSSRFProtection: """Test that private subnets and cloud metadata are always blocked, and @@ -496,6 +529,13 @@ def test_operator_denylist_override_does_not_drop_mandatory_ranges(self, monkeyp "backend.secuscan.config.settings.network_denylist", ["203.0.113.0/24"], # operator's own unrelated addition ) + # Disable the loopback scan exemption so 127.0.0.1 is expected to remain + # blocked here -- this test asserts operator config cannot drop the + # *mandatory* ranges; the loopback-flag exemption is covered separately + # in TestLoopbackExemption. + 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) From b56e03d97749793a7a704c33f5192cc768bcd0fc Mon Sep 17 00:00:00 2001 From: i-OmSharma Date: Sat, 25 Jul 2026 10:47:39 +0530 Subject: [PATCH 4/4] fix(frontend): bump postcss to ^8.5.23 to clear GHSA-r28c-9q8g-f849 Resolves the high-severity postcss advisory (CVSS 7.5, affects <=8.5.17) that was failing the CI npm-audit gate. postcss is a devDependency pulled in via vite/tailwind; no runtime API change. Typecheck, quality gate, unit tests (563), and build all pass locally. Co-Authored-By: Claude Opus 4.8 --- frontend/package-lock.json | 16 ++++++++-------- frontend/package.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 976fd4ce9..a52c12839 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -36,7 +36,7 @@ "autoprefixer": "^10.4.27", "cross-env": "^10.1.0", "jsdom": "^29.0.1", - "postcss": "^8.5.8", + "postcss": "^8.5.23", "tailwindcss": "^3.4.19", "typescript": "5.5.4", "vite": "^6.4.3", @@ -3355,9 +3355,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "funding": [ { "type": "github", @@ -3538,9 +3538,9 @@ } }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "funding": [ { "type": "opencollective", @@ -3557,7 +3557,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, diff --git a/frontend/package.json b/frontend/package.json index 8d0870432..a3d038844 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -44,7 +44,7 @@ "autoprefixer": "^10.4.27", "cross-env": "^10.1.0", "jsdom": "^29.0.1", - "postcss": "^8.5.8", + "postcss": "^8.5.23", "tailwindcss": "^3.4.19", "typescript": "5.5.4", "vite": "^6.4.3",