Files
vulncheck/app/main.py
T
vulncheckandClaude Opus 5 8362369ad3 perf(api): die Requests liefen alle auf dem Event-Loop, also nacheinander
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>
2026-09-10 10:53:23 +02:00

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"
)