Ein Dashboard-Aufruf feuert acht Requests. Im Log kamen alle in derselben Millisekunde zurueck, /auth/me mit 1116ms — ein Request, der eine Zeile liest. Nichts davon war fuer sich langsam: einzeln gemessen kosten die acht zusammen 355ms, die Seite brauchte trotzdem so lange wie ihre Summe statt so lange wie ihr teuerster Request. Die Handler waren `async def`, obwohl jede Zeile darin eine blockierende SQLAlchemy-Session benutzt. FastAPI fuehrt `async def` auf dem Event-Loop selbst aus, also hielt jeder Handler den einzigen Loop fuer die Dauer seines kompletten Query-Batches fest — und `get_current_user` haengt vor jedem authentifizierten Request, deshalb war auch der Ein-Zeilen-Request betroffen. 111 solcher Handler, keiner davon mit einem einzigen `await`, sind jetzt plain `def` und laufen im Worker-Threadpool, wo Blockieren vorgesehen ist. Damit sie dort auch eine Verbindung finden, deckt der Connection-Pool jetzt die 40 Threads ab, die FastAPI vergibt, statt 30: die 31. gleichzeitige Anfrage haette nicht auf eine langsame Query gewartet, sondern auf eine Connection, und das ist ein Timeout, keine Langsamkeit. Zweiter Posten auf derselben Seite: kev-recent baute den zusammengefuehrten KEV-Katalog bei jedem Request neu — 2 MB JSON aus `settings` parsen und mergen, zweimal pro Seitenaufruf, fuer einen Katalog der sich einmal am Tag aendert. Jetzt memoisiert, und zwar auf den `_updated_at`-Werten der Quell-Caches statt auf einer Uhr, damit "jetzt aktualisieren, Seite neu laden" weiter den neuen Katalog zeigt. Das Datumsparsen laeuft ueber fromisoformat statt strptime; mit 9100 Aufrufen pro Request war es der Hotspot im Merge. Gemessen mit acht gleichzeitigen Requests gegen 36.000 Findings auf 300 Assets: Seitenaufruf 420ms -> 190ms, Event-Loop blockiert 250ms -> 50ms, /auth/me unter Last 304ms -> 91ms. Assets-/Scans-Seite 146ms -> 108ms. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
386 lines
13 KiB
Python
386 lines
13 KiB
Python
"""
|
|
VulnManager - FastAPI Application
|
|
|
|
Sichere Web-Applikation zur Verwaltung von Sicherheitslücken nach OWASP-Prinzipien.
|
|
"""
|
|
import os
|
|
import time
|
|
os.environ['TZ'] = 'Europe/Zurich'
|
|
if hasattr(time, 'tzset'):
|
|
time.tzset()
|
|
import logging
|
|
from dotenv import load_dotenv
|
|
|
|
load_dotenv()
|
|
|
|
from contextlib import asynccontextmanager
|
|
from fastapi import FastAPI, Request, status, Depends, HTTPException
|
|
from fastapi.exceptions import RequestValidationError
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.middleware.trustedhost import TrustedHostMiddleware
|
|
from fastapi.middleware.gzip import GZipMiddleware
|
|
try:
|
|
from starlette.middleware.proxy_headers import ProxyHeadersMiddleware
|
|
except ModuleNotFoundError:
|
|
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
|
from fastapi.responses import JSONResponse
|
|
from slowapi import _rate_limit_exceeded_handler
|
|
from slowapi.errors import RateLimitExceeded
|
|
|
|
|
|
|
|
from app.routers import auth, auth_admin, vulnerabilities, assets, policies, scans, settings, notifications, groups, reports, audit, nessus, compliance, intune, vcenter, igel, netdisco, advisories
|
|
try:
|
|
from app.routers import auth_oidc
|
|
_HAS_OIDC = True
|
|
except Exception as _e: # authlib missing or misconfigured
|
|
auth_oidc = None # type: ignore
|
|
_HAS_OIDC = False
|
|
try:
|
|
from app.routers import auth_saml
|
|
_HAS_SAML = True
|
|
except Exception:
|
|
auth_saml = None # type: ignore
|
|
_HAS_SAML = False
|
|
from app.database import engine, SessionLocal
|
|
from app.models.base import Base
|
|
from app.models.user import User, UserRole
|
|
from app.auth.jwt_handler import hash_password
|
|
from app.scheduler import start_scheduler, stop_scheduler
|
|
from app.db_init import create_initial_data
|
|
from app.rate_limiter import limiter, TRUST_PROXY_HEADERS
|
|
|
|
# Logging Configuration
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Environment
|
|
ENV = os.getenv("ENV", "production")
|
|
DEBUG = ENV == "development"
|
|
# Opt-in: expose Swagger/ReDoc on a production instance (for ITSM/CMDB
|
|
# integrators) without full debug mode. Default off.
|
|
API_DOCS = DEBUG or os.getenv("ENABLE_API_DOCS", "false").lower() in ("1", "true", "yes")
|
|
|
|
# Rate Limiter (OWASP A04: Insecure Design)
|
|
|
|
|
|
# Lifecycle Events
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
"""
|
|
Startup und Shutdown Events
|
|
|
|
Startup:
|
|
- Datenbank-Initialisierung
|
|
- Admin-User Check
|
|
- Health-Check-Logging
|
|
|
|
Shutdown:
|
|
- Cleanup von Ressourcen
|
|
"""
|
|
logger.info("🚀 VulnManager startet...")
|
|
logger.info(f"Environment: {ENV}")
|
|
|
|
# Erstelle DB-Tabellen (nur Development - in Production: Alembic)
|
|
if DEBUG:
|
|
try:
|
|
logger.warning("Development mode: Creating database tables...")
|
|
Base.metadata.create_all(bind=engine)
|
|
except Exception as e:
|
|
logger.error(f"Database unreachable: {e}")
|
|
logger.warning("App starting without DB connection - API calls will fail until DB is available")
|
|
|
|
# Initiale Daten (Default Admin User) erstellen
|
|
try:
|
|
db = SessionLocal()
|
|
create_initial_data(db)
|
|
db.close()
|
|
except Exception as e:
|
|
logger.error(f"Error creating initial data: {e}")
|
|
|
|
# Background-Scheduler für automatische Scans starten
|
|
try:
|
|
start_scheduler()
|
|
logger.info("Background scheduler for automatic scans started")
|
|
except Exception as e:
|
|
logger.warning(f"Scheduler could not be started: {e}")
|
|
|
|
# Audit-log → syslog forwarder (SIEM). No-op until enabled in settings.
|
|
try:
|
|
from app.services.syslog_service import register_audit_listener
|
|
register_audit_listener()
|
|
except Exception as e:
|
|
logger.warning(f"Syslog forwarder could not be registered: {e}")
|
|
|
|
yield
|
|
|
|
stop_scheduler()
|
|
logger.info("🛑 VulnManager wird heruntergefahren...")
|
|
|
|
|
|
# FastAPI App
|
|
app = FastAPI(
|
|
title="TrueVuln API",
|
|
description="TrueVuln — Vulnerability Management Dashboard mit Wazuh & KI-Integration",
|
|
version="1.0.0",
|
|
docs_url="/docs" if API_DOCS else None, # Swagger UI: Dev, oder ENABLE_API_DOCS=true
|
|
redoc_url="/redoc" if API_DOCS else None,
|
|
lifespan=lifespan
|
|
)
|
|
|
|
# Rate Limiter State
|
|
app.state.limiter = limiter
|
|
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
|
|
|
@app.exception_handler(RequestValidationError)
|
|
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
|
import logging
|
|
logger = logging.getLogger("app.main")
|
|
# Log only the error structure, not the offending body — Pydantic
|
|
# echoes back submitted values which may include passwords / tokens.
|
|
logger.error(
|
|
"Validation Error at %s: %d field error(s)",
|
|
request.url.path,
|
|
len(exc.errors()),
|
|
)
|
|
return JSONResponse(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
content={"detail": exc.errors()},
|
|
)
|
|
|
|
|
|
# CORS Origins configuration
|
|
allowed_origins = os.getenv("CORS_ORIGINS", "http://localhost:3000,http://localhost:5173").split(",")
|
|
|
|
# ============================================
|
|
# Security Middleware (OWASP A05: Security Misconfiguration)
|
|
# ============================================
|
|
|
|
if TRUST_PROXY_HEADERS:
|
|
# Narrow the set of peers allowed to set forwarded-for headers. Wildcard
|
|
# ("*") lets any upstream rewrite request.client.host, which defeats
|
|
# rate-limit IP keying. Operators must list reverse-proxy IPs via
|
|
# FORWARDED_ALLOW_IPS (uvicorn convention).
|
|
_trusted_hosts = os.getenv("FORWARDED_ALLOW_IPS", "127.0.0.1").split(",")
|
|
_trusted_hosts = [h.strip() for h in _trusted_hosts if h.strip()]
|
|
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=_trusted_hosts)
|
|
|
|
# Query parameters whose value must be redacted before being logged.
|
|
# Add new sensitive param names here as they emerge.
|
|
_SENSITIVE_QUERY_PARAMS = {
|
|
"token", "access_token", "refresh_token", "password", "api_key",
|
|
"key", "secret", "code", "mfa_token",
|
|
}
|
|
|
|
|
|
def _safe_query_string(query: str) -> str:
|
|
"""Redact values for sensitive query params; return the rest verbatim."""
|
|
if not query:
|
|
return ""
|
|
parts = []
|
|
for pair in query.split("&"):
|
|
if "=" in pair:
|
|
name, _, _ = pair.partition("=")
|
|
if name.lower() in _SENSITIVE_QUERY_PARAMS:
|
|
parts.append(f"{name}=***")
|
|
continue
|
|
parts.append(pair)
|
|
return "?" + "&".join(parts)
|
|
|
|
|
|
# Anything slower than this is worth finding in a log by eye. The dashboard
|
|
# fires thirteen requests at once, so "the page is slow" is only actionable
|
|
# once a single line says WHICH of them was slow.
|
|
SLOW_REQUEST_MS = 1000
|
|
|
|
|
|
@app.middleware("http")
|
|
async def log_requests(request: Request, call_next):
|
|
safe_target = f"{request.url.path}{_safe_query_string(request.url.query)}"
|
|
logger.info(f"➡️ Incoming Request: {request.method} {safe_target}")
|
|
started = time.perf_counter()
|
|
try:
|
|
response = await call_next(request)
|
|
# The path and the duration belong on the RESPONSE line, not just the
|
|
# request one. Concurrent requests interleave — the dashboard alone
|
|
# issues thirteen — so a bare "⬅️ Response: 200" cannot be tied back to
|
|
# the request it answers, and a report of "the page loads slowly" had
|
|
# no line to point at.
|
|
ms = (time.perf_counter() - started) * 1000
|
|
line = f"⬅️ Response: {response.status_code} {request.method} {safe_target} in {ms:.0f}ms"
|
|
logger.warning(f"🐢 SLOW {line}") if ms >= SLOW_REQUEST_MS else logger.info(line)
|
|
return response
|
|
except Exception as e:
|
|
ms = (time.perf_counter() - started) * 1000
|
|
logger.error(f"❌ Request Failed after {ms:.0f}ms: {request.method} {safe_target}: {e}")
|
|
raise
|
|
|
|
@app.middleware("http")
|
|
async def add_security_headers(request: Request, call_next):
|
|
"""
|
|
Fügt Security-Headers zu allen Responses hinzu
|
|
|
|
OWASP-Best-Practices:
|
|
- X-Content-Type-Options: nosniff (Verhindert MIME-Type-Sniffing)
|
|
- X-Frame-Options: DENY (Verhindert Clickjacking)
|
|
- X-XSS-Protection: 1; mode=block (XSS-Filter)
|
|
- Strict-Transport-Security: HSTS (HTTPS-Only)
|
|
- Content-Security-Policy: CSP (XSS-Mitigation)
|
|
"""
|
|
response = await call_next(request)
|
|
|
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
response.headers["X-Frame-Options"] = "DENY"
|
|
response.headers["X-XSS-Protection"] = "1; mode=block"
|
|
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
|
|
|
# HSTS (nur in Production mit HTTPS)
|
|
if not DEBUG:
|
|
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
|
|
|
# Content-Security-Policy
|
|
connect_src = " ".join(["'self'"] + allowed_origins)
|
|
csp = (
|
|
f"default-src 'self'; "
|
|
f"script-src 'self'; "
|
|
f"style-src 'self' 'unsafe-inline'; "
|
|
f"img-src 'self' data: https:; "
|
|
f"font-src 'self' data:; "
|
|
f"connect-src {connect_src}"
|
|
)
|
|
response.headers["Content-Security-Policy"] = csp
|
|
|
|
return response
|
|
|
|
|
|
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=allowed_origins,
|
|
allow_credentials=True,
|
|
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
|
|
allow_headers=["Authorization", "Content-Type", "Accept"],
|
|
expose_headers=["X-Total-Count", "Content-Disposition"] # Pagination + CSV-Download-Dateiname
|
|
)
|
|
|
|
# Trusted Host Middleware (Host-Header-Validation)
|
|
if not DEBUG:
|
|
# Include 'backend' for Docker internal network communication
|
|
allowed_hosts = os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1,backend").split(",")
|
|
app.add_middleware(TrustedHostMiddleware, allowed_hosts=allowed_hosts)
|
|
|
|
# GZip Compression
|
|
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
|
|
|
|
|
# ============================================
|
|
# Exception Handlers
|
|
# ============================================
|
|
|
|
@app.exception_handler(Exception)
|
|
async def global_exception_handler(request: Request, exc: Exception):
|
|
"""
|
|
Global Exception Handler (verhindert Information Disclosure)
|
|
|
|
OWASP A09: Security Logging and Monitoring Failures
|
|
"""
|
|
logger.error(f"Unhandled error: {exc}", exc_info=True)
|
|
|
|
# In Production: Keine detaillierten Fehlermeldungen (Information Disclosure)
|
|
if DEBUG:
|
|
return JSONResponse(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
content={"detail": str(exc)}
|
|
)
|
|
else:
|
|
return JSONResponse(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
content={"detail": "Internal Server Error"}
|
|
)
|
|
|
|
|
|
# ============================================
|
|
# Health Check & Monitoring
|
|
# ============================================
|
|
|
|
@app.get("/health", tags=["Health"])
|
|
def health_check():
|
|
"""
|
|
Health-Check-Endpoint für Load-Balancer & Monitoring.
|
|
|
|
Returns only liveness — version, environment, and other build/runtime
|
|
info are not exposed here to avoid handing reconnaissance data to
|
|
unauthenticated callers.
|
|
"""
|
|
return {"status": "healthy"}
|
|
|
|
|
|
@app.get("/", tags=["Root"])
|
|
@limiter.limit("10/minute")
|
|
def root(request: Request):
|
|
"""
|
|
Root-Endpoint mit API-Info
|
|
"""
|
|
return {
|
|
"name": "VulnManager API",
|
|
"version": "1.0.0",
|
|
"docs": "/docs" if DEBUG else "Disabled in Production",
|
|
"health": "/health"
|
|
}
|
|
|
|
|
|
# ============================================
|
|
# Routers
|
|
# ============================================
|
|
|
|
app.include_router(auth.router)
|
|
app.include_router(auth_admin.router)
|
|
if _HAS_OIDC and auth_oidc is not None:
|
|
app.include_router(auth_oidc.router)
|
|
if _HAS_SAML and auth_saml is not None:
|
|
app.include_router(auth_saml.router)
|
|
app.include_router(vulnerabilities.router)
|
|
app.include_router(nessus.router)
|
|
app.include_router(intune.router)
|
|
app.include_router(vcenter.router)
|
|
app.include_router(igel.router)
|
|
app.include_router(netdisco.router)
|
|
app.include_router(advisories.router)
|
|
app.include_router(assets.router)
|
|
app.include_router(policies.router)
|
|
app.include_router(scans.router)
|
|
app.include_router(settings.router)
|
|
app.include_router(notifications.router)
|
|
app.include_router(groups.router)
|
|
app.include_router(reports.router)
|
|
app.include_router(audit.router)
|
|
app.include_router(compliance.router)
|
|
|
|
|
|
# ============================================
|
|
# Startup-Banner
|
|
# ============================================
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
|
|
logger.info("""
|
|
╔══════════════════════════════════════════════════════╗
|
|
║ VulnManager v1.0.0 ║
|
|
║ Vulnerability Management Dashboard ║
|
|
║ 🔒 OWASP-Compliant | 🚀 FastAPI | 🤖 AI-Powered ║
|
|
╚══════════════════════════════════════════════════════╝
|
|
""")
|
|
|
|
uvicorn.run(
|
|
"app.main:app",
|
|
host="0.0.0.0",
|
|
port=8000,
|
|
reload=DEBUG,
|
|
log_level="info"
|
|
)
|