diff --git a/backend/secuscan/main.py b/backend/secuscan/main.py index e03e9598..e5d5a589 100644 --- a/backend/secuscan/main.py +++ b/backend/secuscan/main.py @@ -28,8 +28,8 @@ logging.StreamHandler(sys.stdout), logging.FileHandler(settings.log_file) if Path(settings.log_file).parent.exists() - else logging.NullHandler() - ] + else logging.NullHandler(), + ], ) from .logging_utils import RequestIDFilter, JSONFormatter @@ -40,27 +40,30 @@ logger = logging.getLogger(__name__) + @asynccontextmanager async def lifespan(app: FastAPI): """Application lifespan manager""" # Startup logger.info("🚀 Starting SecuScan backend...") - + # Ensure directories exist settings.ensure_directories() logger.info("✓ Directories initialized") # Initialize API key authentication api_key = init_api_key(settings.data_dir) - logger.info("✓ API key authentication ready (key file: %s/.api_key)", settings.data_dir) - + logger.info( + "✓ API key authentication ready (key file: %s/.api_key)", settings.data_dir + ) + # Initialize database await init_db(settings.database_path) logger.info("✓ SQLite connected") await init_cache() logger.info("✓ In-memory cache initialized") - + # Load plugins await init_plugins(settings.plugins_dir) logger.info("✓ Plugins loaded") @@ -68,50 +71,78 @@ async def lifespan(app: FastAPI): # If docker is enabled, verify and auto-create the restricted docker network if settings.docker_enabled: if shutil.which("docker"): - logger.info(f"Docker is enabled. Verifying network '{settings.docker_network}'...") + logger.info( + f"Docker is enabled. Verifying network '{settings.docker_network}'..." + ) try: import subprocess + res = subprocess.run( ["docker", "network", "inspect", settings.docker_network], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) if res.returncode != 0: - logger.info(f"Docker network '{settings.docker_network}' not found. Creating isolated bridge network (ICC disabled)...") + logger.info( + f"Docker network '{settings.docker_network}' not found. Creating isolated bridge network (ICC disabled)..." + ) creation_res = subprocess.run( [ - "docker", "network", "create", - "--driver", "bridge", - "--opt", "com.docker.network.bridge.enable_icc=false", - settings.docker_network + "docker", + "network", + "create", + "--driver", + "bridge", + "--opt", + "com.docker.network.bridge.enable_icc=false", + settings.docker_network, ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) if creation_res.returncode != 0: - logger.warning("Failed to create isolated bridge network with ICC disabled. Falling back to standard bridge...") + logger.warning( + "Failed to create isolated bridge network with ICC disabled. Falling back to standard bridge..." + ) subprocess.run( - ["docker", "network", "create", "--driver", "bridge", settings.docker_network], + [ + "docker", + "network", + "create", + "--driver", + "bridge", + settings.docker_network, + ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) - logger.info(f"✓ Docker network '{settings.docker_network}' created (fallback)") + logger.info( + f"✓ Docker network '{settings.docker_network}' created (fallback)" + ) else: - logger.info(f"✓ Docker network '{settings.docker_network}' created with ICC disabled") + logger.info( + f"✓ Docker network '{settings.docker_network}' created with ICC disabled" + ) else: - logger.info(f"✓ Docker network '{settings.docker_network}' verified") + logger.info( + f"✓ Docker network '{settings.docker_network}' verified" + ) except Exception as e: - logger.warning(f"Failed to check/create Docker network '{settings.docker_network}': {e}") + logger.warning( + f"Failed to check/create Docker network '{settings.docker_network}': {e}" + ) else: - logger.warning("Docker sandboxing is enabled but 'docker' executable is not in PATH.") + logger.warning( + "Docker sandboxing is enabled but 'docker' executable is not in PATH." + ) await scheduler.start() logger.info("✓ Workflow scheduler started") - + logger.info("✓ Ready to serve on %s:%d", settings.bind_address, settings.bind_port) - + yield - + # Shutdown logger.info("🛑 Shutting down SecuScan backend...") if global_db: @@ -121,6 +152,7 @@ async def lifespan(app: FastAPI): await scheduler.stop() logger.info("✓ Shutdown complete") + # Create FastAPI application app = FastAPI( title="SecuScan API", @@ -129,24 +161,31 @@ async def lifespan(app: FastAPI): docs_url="/docs", redoc_url="/redoc", openapi_url="/openapi.json", - lifespan=lifespan + lifespan=lifespan, ) + @app.get("/api/docs", include_in_schema=False) async def redirect_api_docs(): from fastapi.responses import RedirectResponse + return RedirectResponse(url="/docs") + @app.get("/api/redoc", include_in_schema=False) async def redirect_api_redoc(): from fastapi.responses import RedirectResponse + return RedirectResponse(url="/redoc") + @app.get("/api/openapi.json", include_in_schema=False) async def redirect_api_openapi(): from fastapi.responses import RedirectResponse + return RedirectResponse(url="/openapi.json") + # CORS middleware cors_allow_all = "*" in settings.cors_allowed_origins if cors_allow_all and settings.cors_allow_credentials: @@ -175,7 +214,8 @@ async def health_check(): """Health check endpoint""" import platform import sys - + + logger.info("Health check endpoint accessed") return { "status": "operational", "version": "0.1.0-alpha", @@ -183,9 +223,10 @@ async def health_check(): "platform": platform.system(), "python_version": sys.version.split()[0], "docker_available": shutil.which("docker") is not None, - } + }, } + # Root endpoint @app.get("/") async def root(): @@ -195,31 +236,33 @@ async def root(): "version": "0.1.0-alpha", "status": "under development", "api_docs": f"{settings.base_url}/api/docs" if settings.debug else None, - "legal_notice": "For authorized testing only. Unauthorized scanning may be illegal." + "legal_notice": "For authorized testing only. Unauthorized scanning may be illegal.", } + def main(): """Main entry point""" import uvicorn - + logger.info(""" - ╔═══════════════════════════════════════════════════════╗ - ║ ║ - ║ SecuScan v0.1.0-alpha ║ - ║ Local-First Pentesting Toolkit ║ - ║ ║ - ║ ⚠️ For authorized testing only ║ - ║ ║ - ╚═══════════════════════════════════════════════════════╝ + ╔═══════════════════════════════════════════════════╗ + ║ ║ + ║ SecuScan v0.1.0-alpha ║ + ║ Local-First Pentesting Toolkit ║ + ║ ║ + ║ For authorized testing only ║ + ║ ║ + ╚═══════════════════════════════════════════════════╝ """) - + uvicorn.run( "backend.secuscan.main:app", host=settings.bind_address, port=settings.bind_port, reload=settings.debug, - log_level=settings.log_level.lower() + log_level=settings.log_level.lower(), ) + if __name__ == "__main__": main() diff --git a/backend/secuscan/workflows.py b/backend/secuscan/workflows.py index eb98c598..2a448d63 100644 --- a/backend/secuscan/workflows.py +++ b/backend/secuscan/workflows.py @@ -1,4 +1,5 @@ """Workflow automation and scheduling.""" + from __future__ import annotations from .request_context import get_request_id, set_request_id import asyncio @@ -11,7 +12,10 @@ from .executor import executor from .execution_context import normalize_execution_context from .platform_resources import get_target_policy + logger = logging.getLogger(__name__) + + class WorkflowScheduler: def __init__(self): self._task: asyncio.Task | None = None @@ -23,6 +27,7 @@ async def start(self): self._running = True self._task = asyncio.create_task(self._run_loop()) logger.info("Workflow scheduler started") + async def stop(self): self._running = False if self._task: @@ -33,6 +38,7 @@ async def stop(self): pass self._task = None logger.info("Workflow scheduler stopped") + async def _run_loop(self): while self._running: try: @@ -40,6 +46,7 @@ async def _run_loop(self): except Exception as exc: logger.error("Workflow scheduler tick failed: %s", exc) await asyncio.sleep(5) + async def tick(self): db = await get_db() rows = await db.fetchall( @@ -51,14 +58,21 @@ async def tick(self): ) now = datetime.now(timezone.utc) for row in rows: - if not self._should_run(now, row.get("last_run_at"), int(row["schedule_seconds"])): + if not self._should_run( + now, row.get("last_run_at"), int(row["schedule_seconds"]) + ): continue - await self._run_workflow(row["id"], json.loads(row.get("steps_json") or "[]")) + await self._run_workflow( + row["id"], json.loads(row.get("steps_json") or "[]") + ) await db.execute( "UPDATE workflows SET last_run_at = datetime('now') WHERE id = ?", (row["id"],), ) - def _should_run(self, now: datetime, last_run_at: str | None, schedule_seconds: int) -> bool: + + def _should_run( + self, now: datetime, last_run_at: str | None, schedule_seconds: int + ) -> bool: if not last_run_at: return True last = datetime.fromisoformat(last_run_at.replace("Z", "+00:00")) @@ -70,16 +84,22 @@ def _should_run(self, now: datetime, last_run_at: str | None, schedule_seconds: last = last.replace(tzinfo=timezone.utc) elapsed = (now - last).total_seconds() return elapsed >= schedule_seconds + async def _run_workflow(self, workflow_id: str, steps: List[Dict[str, Any]]): logger.info("Running workflow %s with %d step(s)", workflow_id, len(steps)) + db = await get_db() for step in steps: plugin_id = step.get("plugin_id") inputs = step.get("inputs") or {} if not plugin_id: continue request_id = get_request_id() - execution_context = normalize_execution_context(step.get("execution_context") or {}) - target_policy = await get_target_policy(db, "default", execution_context.get("target_policy_id")) + execution_context = normalize_execution_context( + step.get("execution_context") or {} + ) + target_policy = await get_target_policy( + db, "default", execution_context.get("target_policy_id") + ) safe_mode = bool( settings.safe_mode_default and not (target_policy and target_policy.get("allow_public_targets")) diff --git a/frontend/testing/unit/pages/ToolConfigDynamic.test.tsx b/frontend/testing/unit/pages/ToolConfigDynamic.test.tsx index 6cd5a1da..03bfb13c 100644 --- a/frontend/testing/unit/pages/ToolConfigDynamic.test.tsx +++ b/frontend/testing/unit/pages/ToolConfigDynamic.test.tsx @@ -108,6 +108,11 @@ describe('ToolConfig dynamic schema flow', () => { }), true, 'quick', + expect.objectContaining({ + scan_profile: 'standard', + validation_mode: 'proof', + evidence_level: 'standard', + }), ) }) }) diff --git a/frontend/testing/unit/pages/ToolConfigTimeout.test.tsx b/frontend/testing/unit/pages/ToolConfigTimeout.test.tsx index 4c0b2213..02dd3671 100644 --- a/frontend/testing/unit/pages/ToolConfigTimeout.test.tsx +++ b/frontend/testing/unit/pages/ToolConfigTimeout.test.tsx @@ -16,6 +16,9 @@ vi.mock('../../../src/api', () => ({ getPluginSchema: vi.fn(), startTask: vi.fn(), getSettings: vi.fn(), + listTargetPolicies: vi.fn().mockResolvedValue({ items: [] }), + listCredentialProfiles: vi.fn().mockResolvedValue({ items: [] }), + listSessionProfiles: vi.fn().mockResolvedValue({ items: [] }), })) describe('ToolConfig timeout control', () => { diff --git a/frontend/testing/unit/pages/Workflows.test.tsx b/frontend/testing/unit/pages/Workflows.test.tsx index 7c304da8..54628a33 100644 --- a/frontend/testing/unit/pages/Workflows.test.tsx +++ b/frontend/testing/unit/pages/Workflows.test.tsx @@ -130,7 +130,15 @@ describe('Workflows — create action', () => { name: 'Nightly Scan', schedule_seconds: 7200, enabled: true, - steps: [{ plugin_id: '', inputs: {} }], + steps: [{ + plugin_id: '', + inputs: {}, + execution_context: { + scan_profile: 'standard', + validation_mode: 'proof', + evidence_level: 'standard', + }, + }], }) }) }) diff --git a/testing/backend/integration/test_request_logging_smoke.py b/testing/backend/integration/test_request_logging_smoke.py new file mode 100644 index 00000000..7b2e4f5f --- /dev/null +++ b/testing/backend/integration/test_request_logging_smoke.py @@ -0,0 +1,61 @@ +# testing/backend/integration/test_request_logging_smoke.py + +import io +import json +import logging +import re + +import pytest +from backend.secuscan.logging_utils import JSONFormatter, RequestIDFilter + +UUID4_RE = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" +) + +@pytest.fixture +def log_capture(): + buf = io.StringIO() + handler = logging.StreamHandler(buf) + handler.addFilter(RequestIDFilter()) + handler.setFormatter(JSONFormatter()) + handler.setLevel(logging.DEBUG) + root = logging.getLogger() + root.addHandler(handler) + try: + yield buf + finally: + root.removeHandler(handler) + handler.close() + +def _parse_log_lines(buf): + return [json.loads(line) for line in buf.getvalue().splitlines() if line.strip()] + +def test_request_id_appears_in_json_logs(test_client, log_capture): + response = test_client.get("/api/v1/health") + assert response.status_code == 200 + + request_id = response.headers.get("X-Request-ID") + assert request_id, "Middleware must echo X-Request-ID header" + assert UUID4_RE.match(request_id), "Request ID must be a valid UUID4" + + entries = _parse_log_lines(log_capture) + assert entries, "At least one JSON log line must be emitted" + + for entry in entries: + for key in ("timestamp", "level", "request_id", "logger", "message"): + assert key in entry, f"Log entry missing key '{key}': {entry}" + + correlated = [e for e in entries if e["request_id"] == request_id] + assert correlated, ( + f"No log line carries request_id={request_id!r}. " + f"Seen IDs: {[e['request_id'] for e in entries]}" + ) + +def test_passthrough_request_id(test_client, log_capture): + custom_id = "smoke-test-trace-abc123" + response = test_client.get("/api/v1/health", headers={"X-Request-ID": custom_id}) + assert response.headers.get("X-Request-ID") == custom_id + + entries = _parse_log_lines(log_capture) + correlated = [e for e in entries if e["request_id"] == custom_id] + assert correlated, "Passthrough request ID must appear in logs"