diff --git a/backend/secuscan/config.py b/backend/secuscan/config.py index e37be854..757ca3ac 100644 --- a/backend/secuscan/config.py +++ b/backend/secuscan/config.py @@ -19,7 +19,7 @@ class Settings(BaseSettings): # Server Configuration bind_address: str = "127.0.0.1" bind_port: int = 8000 - debug: bool = True + debug: bool = False # Primary data store database_path: str = str(PROJECT_ROOT / "data" / "secuscan.db") diff --git a/backend/secuscan/main.py b/backend/secuscan/main.py index 66282c41..b2c04193 100644 --- a/backend/secuscan/main.py +++ b/backend/secuscan/main.py @@ -2,6 +2,7 @@ SecuScan Backend - Main application entry point """ +import html as html_mod import logging import sys import shutil @@ -162,30 +163,39 @@ async def lifespan(app: FastAPI): await scheduler.stop() logger.info("✓ Shutdown complete") -# Create FastAPI application +# Create FastAPI application — docs/redoc/openapi only exposed when debug=True app = FastAPI( title="SecuScan API", description="Backend for SecuScan Pentesting Toolkit", version="1.0.0", - docs_url="/docs", - redoc_url="/redoc", - openapi_url="/openapi.json", + docs_url="/docs" if settings.debug else None, + redoc_url="/redoc" if settings.debug else None, + openapi_url="/openapi.json" if settings.debug else None, lifespan=lifespan ) @app.get("/api/docs", include_in_schema=False) async def redirect_api_docs(): from fastapi.responses import RedirectResponse + if not settings.debug: + from fastapi import HTTPException + raise HTTPException(status_code=404, detail="Not found") return RedirectResponse(url="/docs") @app.get("/api/redoc", include_in_schema=False) async def redirect_api_redoc(): from fastapi.responses import RedirectResponse + if not settings.debug: + from fastapi import HTTPException + raise HTTPException(status_code=404, detail="Not found") return RedirectResponse(url="/redoc") @app.get("/api/openapi.json", include_in_schema=False) async def redirect_api_openapi(): from fastapi.responses import RedirectResponse + if not settings.debug: + from fastapi import HTTPException + raise HTTPException(status_code=404, detail="Not found") return RedirectResponse(url="/openapi.json") # CORS middleware @@ -280,8 +290,9 @@ async def custom_unhandled_exception_handler(request: Request, exc: Exception): if settings.debug: import traceback - html = f"
{traceback.format_exc()}"
- response = HTMLResponse(html, status_code=500)
+ safe_traceback = html_mod.escape(traceback.format_exc())
+ html_content = f"{safe_traceback}"
+ response = HTMLResponse(html_content, status_code=500)
else:
response = PlainTextResponse("Internal Server Error", status_code=500)
diff --git a/start.sh b/start.sh
index f582f951..638c1f2b 100755
--- a/start.sh
+++ b/start.sh
@@ -86,6 +86,9 @@ pip install -q -r backend/requirements.txt
mkdir -p "$ROOT_DIR/data" "$ROOT_DIR/logs"
echo "🚀 Starting backend on http://127.0.0.1:8000"
+# Enable debug mode for development — exposes /docs, /redoc, /openapi.json
+# and serves detailed error pages. Production defaults to debug=False.
+export SECUSCAN_DEBUG=true
python -m uvicorn backend.secuscan.main:app \
--host 127.0.0.1 \
--port 8000 \
diff --git a/testing/backend/unit/test_config_settings.py b/testing/backend/unit/test_config_settings.py
index e167664f..5b60cd1a 100644
--- a/testing/backend/unit/test_config_settings.py
+++ b/testing/backend/unit/test_config_settings.py
@@ -54,9 +54,9 @@ def test_settings_default_bind_address():
def test_settings_default_debug():
- """Debug defaults to True."""
+ """Debug defaults to False (secure-by-default)."""
s = Settings()
- assert s.debug is True
+ assert s.debug is False
def test_settings_default_sandbox_disabled():
@@ -286,3 +286,89 @@ def test_sandbox_settings_env_override():
assert s.sandbox_timeout == 30
assert s.sandbox_memory_mb == 128
assert s.sandbox_allow_network is False
+
+
+# ── debug mode docs gating (Issue #2022) ──────────────────────────────────────
+
+
+def test_debug_mode_true_enables_docs():
+ """When debug=True, docs_url/redoc_url/openapi_url are set."""
+ s = Settings(debug=True)
+ assert s.debug is True
+
+
+def test_debug_mode_false_disables_docs():
+ """When debug=False (default), docs should not be exposed."""
+ s = Settings(debug=False)
+ assert s.debug is False
+
+
+# ── FastAPI docs gating via TestClient ────────────────────────────────────────
+
+
+def test_docs_disabled_when_debug_false(tmp_path, monkeypatch):
+ """/docs returns 404 when the app was created with debug=False."""
+ from backend.secuscan.main import app as _app
+ from fastapi.testclient import TestClient
+
+ # The app is created with debug=False by default, so docs_url=None
+ assert _app.docs_url is None
+
+ with TestClient(_app, raise_server_exceptions=False) as client:
+ resp = client.get("/docs")
+ assert resp.status_code == 404
+
+
+def test_openapi_disabled_when_debug_false(tmp_path, monkeypatch):
+ """/openapi.json returns 404 when the app was created with debug=False."""
+ from backend.secuscan.main import app as _app
+ from fastapi.testclient import TestClient
+
+ # The app is created with debug=False by default, so openapi_url=None
+ assert _app.openapi_url is None
+
+ with TestClient(_app, raise_server_exceptions=False) as client:
+ resp = client.get("/openapi.json")
+ assert resp.status_code == 404
+
+
+def test_docs_enabled_when_debug_true(tmp_path):
+ """/docs returns 200 when a fresh app is created with debug=True."""
+ from backend.secuscan.config import Settings
+ from fastapi import FastAPI
+ from fastapi.testclient import TestClient
+
+ app_debug = FastAPI(
+ title="Test",
+ docs_url="/docs",
+ redoc_url="/redoc",
+ openapi_url="/openapi.json",
+ )
+
+ with TestClient(app_debug, raise_server_exceptions=False) as client:
+ resp = client.get("/docs")
+ assert resp.status_code == 200
+ resp = client.get("/openapi.json")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "openapi" in data
+
+
+def test_traceback_not_leaked_in_production(tmp_path, monkeypatch):
+ """In production (debug=False), unhandled exceptions return plain text, not HTML tracebacks."""
+ from backend.secuscan.config import settings as real_settings
+ from backend.secuscan.main import app as _app
+ from fastapi.testclient import TestClient
+
+ monkeypatch.setattr(real_settings, "debug", False)
+
+ @_app.get("/test-exception-prod")
+ async def raise_exception_prod():
+ raise RuntimeError("secret-sauce-error-12345")
+
+ with TestClient(_app, raise_server_exceptions=False) as client:
+ resp = client.get("/test-exception-prod")
+ assert resp.status_code == 500
+ body = resp.text
+ assert "secret-sauce-error-12345" not in body
+ assert "" not in body