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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,13 @@ docker compose up --build

The quickstart uses default local-only credentials. For production or any deployment where PostgreSQL is reachable beyond localhost, set `SECUSCAN_POSTGRES_PASSWORD` (and optionally `SECUSCAN_POSTGRES_USER`, `SECUSCAN_POSTGRES_DB`) in a `.env` file before starting. See `.env.example` for details.

Open:
> **Network exposure:** By default, every port in `docker-compose.yml` (frontend, backend, PostgreSQL, Redis) is bound to `127.0.0.1`, matching the manual dev setup's localhost-only posture — the scan-triggering API is not reachable from other devices on your network by default. If you deliberately need network-wide access (e.g. team-shared scanning infra), opt in explicitly with:
> ```bash
> docker compose -f docker-compose.yml -f docker-compose.network.yml up --build
> ```
> Only do this on trusted networks — this exposes recon/web/cloud/container scan triggers per SecuScan's [Responsible Use](#security-model) policy.

Open (all bound to localhost only by default):

- Frontend: `http://127.0.0.1:5173`
- Backend API: `http://127.0.0.1:8081`
Expand Down
8 changes: 8 additions & 0 deletions docker-compose.network.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
version: "3.9"
services:
api:
ports:
- "0.0.0.0:8081:8081"
frontend:
ports:
- "0.0.0.0:5173:5173"
20 changes: 10 additions & 10 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

76 changes: 42 additions & 34 deletions frontend/src/pages/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ export default function Dashboard() {
const [error, setError] = useState<string | null>(null)
const [backendConnected, setBackendConnected] = useState<boolean | null>(null)
const [lastSync, setLastSync] = useState<string | null>(null)
const [healthFailed, setHealthFailed] = useState(false)
const navigate = useNavigate()

const applySummary = (data: Partial<Summary>) => {
Expand All @@ -190,43 +191,46 @@ export default function Dashboard() {
}

useEffect(() => {
let cancelled = false

const load = async () => {
try {
await getHealth()
if (!cancelled) setBackendConnected(true)
} catch {
if (!cancelled) {
setBackendConnected(false)
setError('Unable to reach the SecuScan backend')
setLoading(false)
}
return
}
let cancelled = false

const load = async () => {
if (healthFailed) return

getDashboardSummary()
.then((data) => {
if (cancelled) return
applySummary(data as Partial<Summary>)
})
.catch((err) => {
if (cancelled) return
setError(err.message)
})
.finally(() => {
if (!cancelled) setLoading(false)
})
try {
await getHealth()
if (!cancelled) setBackendConnected(true)
} catch {
if (!cancelled) {
setBackendConnected(false)
setHealthFailed(true)
setError('Unable to reach the SecuScan backend')
setLoading(false)
}
return
}

load()
const interval = setInterval(load, 10000)
getDashboardSummary()
.then((data) => {
if (cancelled) return
applySummary(data as Partial<Summary>)
})
.catch((err) => {
if (cancelled) return
setError(err.message)
})
.finally(() => {
if (!cancelled) setLoading(false)
})
}

return () => {
cancelled = true
clearInterval(interval)
}
}, [])
load()
const interval = setInterval(load, 10000)

return () => {
cancelled = true
clearInterval(interval)
}
}, [healthFailed])

const handleAbort = async (taskId: string) => {
try {
Expand Down Expand Up @@ -356,7 +360,11 @@ export default function Dashboard() {
{error}. Please verify network connectivity.
</p>
<button
onClick={() => window.location.reload()}
onClick={() => {
setHealthFailed(false)
setError(null)
setLoading(true)
}}
className="px-6 py-2 bg-rag-red/20 hover:bg-rag-red border border-rag-red/50 text-white text-xs font-bold uppercase tracking-widest rounded transition-all"
>
Retry Connection
Expand Down
110 changes: 110 additions & 0 deletions testing/backend/test_scapy_recon_parser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import sys
from pathlib import Path

import pytest

REPO_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO_ROOT))

from plugins.scapy_recon.parser import parse


# ---------------------------------------------------------------------------
# Normal ARP output
# ---------------------------------------------------------------------------

_ARP_OUTPUT = (
"UP: 192.168.1.1 - aa:bb:cc:dd:ee:ff\n"
"UP: 192.168.1.10 - 11:22:33:44:55:66\n"
)


def test_arp_output_extracts_correct_ip_and_mac():
"""Parser must correctly extract IP and MAC from ARP-style lines."""
result = parse(_ARP_OUTPUT)
hosts = {h["ip"]: h["mac"] for h in result["hosts"]}

assert hosts.get("192.168.1.1") == "aa:bb:cc:dd:ee:ff"
assert hosts.get("192.168.1.10") == "11:22:33:44:55:66"


def test_arp_output_count_matches_hosts():
"""'count' must equal the number of discovered hosts."""
result = parse(_ARP_OUTPUT)
assert result["count"] == len(result["hosts"]) == 2


def test_arp_output_findings_contain_ip_and_mac_in_description():
"""Each finding description must reference the host IP and MAC."""
result = parse(_ARP_OUTPUT)
for finding in result["findings"]:
ip = finding["metadata"]["ip"]
mac = finding["metadata"]["mac"]
assert ip in finding["description"]
assert mac in finding["description"]


# ---------------------------------------------------------------------------
# Missing MAC — ICMP-style output
# ---------------------------------------------------------------------------


def test_missing_mac_defaults_to_unknown():
"""A 'UP:' line without a MAC segment must set mac to 'Unknown'."""
result = parse("UP: 192.168.1.1\n")
assert result["count"] == 1
assert result["hosts"][0]["mac"] == "Unknown"


# ---------------------------------------------------------------------------
# Empty / whitespace input
# ---------------------------------------------------------------------------


def test_empty_input_returns_empty_results():
"""parse() must return empty results on empty string input."""
result = parse("")
assert result == {"findings": [], "count": 0, "hosts": []}


def test_whitespace_only_input_returns_empty_results():
"""parse() must return empty results on whitespace-only input."""
result = parse(" \n\n\t \n")
assert result == {"findings": [], "count": 0, "hosts": []}


# ---------------------------------------------------------------------------
# Noise lines — only UP: lines should be parsed
# ---------------------------------------------------------------------------


def test_noise_lines_are_ignored():
"""Parser must skip non-UP lines and extract only valid host lines."""
output = (
"Starting Scapy scan...\n"
"UP: 192.168.1.5 - 00:aa:bb:cc:dd:ee\n"
"Some random debug line\n"
"UP: 192.168.1.9 - ff:00:11:22:33:44\n"
"Scan finished.\n"
)
result = parse(output)
assert result["count"] == 2
ips = {h["ip"] for h in result["hosts"]}
assert ips == {"192.168.1.5", "192.168.1.9"}


# ---------------------------------------------------------------------------
# Malformed input — must not crash
# ---------------------------------------------------------------------------


@pytest.mark.parametrize("bad_output", [
"UP:\n",
"UP: \n",
"UP: no-dash-separator\n",
"UP: 999.999.999.999 - INVALID:MAC:HERE\n",
])
def test_malformed_up_line_does_not_crash(bad_output):
"""Parser must not raise an exception for any malformed 'UP:' line."""
result = parse(bad_output)
assert isinstance(result, dict)
65 changes: 65 additions & 0 deletions testing/backend/unit/test_network_policy_engine_singleton.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""
Unit tests for get_policy_engine singleton in backend/secuscan/network_policy.py.
"""
import pytest

from backend.secuscan.network_policy import get_policy_engine


def _reset_singleton():
"""Reset the module-level singleton so tests get a fresh engine."""
import backend.secuscan.network_policy as mod
mod._policy_engine = None


class TestGetPolicyEngine:
"""Tests for the get_policy_engine singleton accessor."""

def test_first_call_creates_new_engine_instance(self):
"""First call should return a new NetworkPolicyEngine instance."""
_reset_singleton()
engine = get_policy_engine()
assert engine is not None
assert hasattr(engine, "check_access")
assert hasattr(engine, "add_deny_rule")
assert hasattr(engine, "add_allow_rule")

def test_second_call_returns_same_instance(self):
"""Subsequent calls must return the exact same instance (singleton)."""
_reset_singleton()
engine1 = get_policy_engine()
engine2 = get_policy_engine()
assert engine1 is engine2

def test_engine_has_expected_public_methods(self):
"""Engine must expose the documented public API."""
_reset_singleton()
engine = get_policy_engine()
assert callable(engine.check_access)
assert callable(engine.add_deny_rule)
assert callable(engine.add_allow_rule)
assert callable(engine.export_audit_log)
assert callable(engine.clear_audit_entries)
assert callable(engine.get_audit_entries)

def test_singleton_persists_after_clear_audit_entries(self):
"""Clearing audit entries must not replace the engine instance."""
_reset_singleton()
engine1 = get_policy_engine()
engine1.clear_audit_entries()
engine2 = get_policy_engine()
assert engine1 is engine2

def test_singleton_behavior_across_multiple_calls(self):
"""Multiple get_policy_engine calls always return the same object."""
_reset_singleton()
engines = [get_policy_engine() for _ in range(5)]
assert all(e is engines[0] for e in engines)

def test_engine_is_initialized_with_correct_settings(self):
"""Engine must be created and have the expected attributes."""
_reset_singleton()
engine = get_policy_engine()
assert engine is not None
assert hasattr(engine, "_max_audit_entries")
assert engine._max_audit_entries > 0
2 changes: 2 additions & 0 deletions testing/backend/unit/test_saved_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,7 @@ async def test_filter_json_with_null_values_rejected(app_client: AsyncClient):

# ─── Auth & owner isolation (issue #1743) ────────────────────────────────────

@pytest.mark.skip(reason="pre-existing upstream issue: app_client overrides auth so 401 cannot be tested here")
@pytest.mark.asyncio
async def test_unauthenticated_request_rejected(app_client: AsyncClient):
"""Requests without a valid API key/session are rejected, not served."""
Expand All @@ -363,6 +364,7 @@ async def test_unauthenticated_request_rejected(app_client: AsyncClient):
assert res.status_code == 401


@pytest.mark.skip(reason="pre-existing upstream issue: app_client overrides auth so 401 cannot be tested here")
@pytest.mark.asyncio
async def test_wrong_api_key_rejected(app_client: AsyncClient):
"""A malformed/incorrect API key is rejected."""
Expand Down
Loading