Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 27 additions & 18 deletions backend/secuscan/network_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
61 changes: 59 additions & 2 deletions testing/backend/unit/test_network_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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"""

Expand Down
Loading