From 20847831bb671fd828bb824507de4c4272f622b4 Mon Sep 17 00:00:00 2001 From: jsg-claude Date: Wed, 22 Apr 2026 22:34:45 +0200 Subject: [PATCH 1/2] Harden screening API and deployment defaults --- .env.example | 14 +- .env.prod.example | 13 + Dockerfile | 5 + README.md | 6 + docker-compose.prod.yml | 16 +- docker-compose.yml | 28 +- sanctions/README.md | 1 + sanctions/src/main.py | 706 ++++++++++++++++++++++++++++------- sanctions/tests/test_main.py | 58 ++- shared/llm/gateway.py | 17 +- 10 files changed, 709 insertions(+), 155 deletions(-) diff --git a/.env.example b/.env.example index 828a6bf..0ab073a 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,18 @@ # Copy to .env and adjust values # ── Neo4j ── -NEO4J_PASSWORD=beyond-ai-2026 +NEO4J_PASSWORD= + +# ── yente / Elasticsearch ── +# Use a strong URL-safe password because it is embedded into YENTE_INDEX_URL. +YENTE_ES_PASSWORD= + +# ── Beyond AI access protection ── +BEYOND_AI_BASIC_AUTH_USER= +BEYOND_AI_BASIC_AUTH_PASSWORD= +BEYOND_AI_AUTH_REQUIRED=true +BEYOND_AI_RATE_LIMIT_REQUESTS=30 +BEYOND_AI_RATE_LIMIT_WINDOW_SECONDS=60 # ── Ollama (Primary LLM) ── OLLAMA_URL=http://localhost:11434 @@ -10,6 +21,7 @@ OLLAMA_MODEL=kimi-k2.5 # ── Claude API (Fallback LLM) ── ANTHROPIC_API_KEY= +BEYOND_AI_ALLOW_EXTERNAL_LLM_FALLBACK=false # ── OpenSanctions ── # Nur für kommerzielle Nutzung: diff --git a/.env.prod.example b/.env.prod.example index 5050fe4..9adbc00 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -6,9 +6,22 @@ NEO4J_CONTAINER_NAME=neo4j NEO4J_PASSWORD=DEIN_NEO4J_PASSWORT_HIER +# ─── yente / Elasticsearch ───────────────────────────────────── +# URL-safe Passwort verwenden, da es in YENTE_INDEX_URL eingebettet wird. +YENTE_ES_PASSWORD=SETZE_EIN_LANGES_URL_SICHERES_PASSWORT + +# ─── Beyond AI Zugriffsschutz ────────────────────────────────── +BEYOND_AI_BASIC_AUTH_USER=screening +BEYOND_AI_BASIC_AUTH_PASSWORD=SETZE_EIN_LANGES_PASSWORT +BEYOND_AI_AUTH_REQUIRED=true +BEYOND_AI_RATE_LIMIT_REQUESTS=30 +BEYOND_AI_RATE_LIMIT_WINDOW_SECONDS=60 +BEYOND_AI_TRUST_PROXY_HEADERS=true + # ─── Ollama (bestehende ollama-eksw-Instanz) ────────────────── # Container-Namen prüfen: docker ps --format '{{.Names}}' | grep ollama OLLAMA_CONTAINER_NAME=ollama-eksw-ollama-1 # ─── Optional: Claude API als LLM-Fallback ──────────────────── ANTHROPIC_API_KEY= +BEYOND_AI_ALLOW_EXTERNAL_LLM_FALLBACK=false diff --git a/Dockerfile b/Dockerfile index acad3ac..ad03ff6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,11 @@ RUN pip install --no-cache-dir -r requirements.txt COPY shared/ ./shared/ COPY sanctions/ ./sanctions/ +RUN addgroup --system beyondai && adduser --system --ingroup beyondai beyondai \ + && chown -R beyondai:beyondai /app + +USER beyondai + EXPOSE 8000 CMD ["uvicorn", "sanctions.src.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index 07e727b..e514f43 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,11 @@ git clone https://github.com/endvater/beyond-ai.git cd beyond-ai cp .env.example .env +# Pflichtfelder in .env setzen: +# - NEO4J_PASSWORD +# - YENTE_ES_PASSWORD +# - BEYOND_AI_BASIC_AUTH_USER +# - BEYOND_AI_BASIC_AUTH_PASSWORD docker compose up -d ``` @@ -140,6 +145,7 @@ Danach verfügbar: ```bash curl -X POST http://localhost:8000/api/screen \ + -u "$BEYOND_AI_BASIC_AUTH_USER:$BEYOND_AI_BASIC_AUTH_PASSWORD" \ -H "Content-Type: application/json" \ -d '{"name": "Wladimir Putin"}' ``` diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 6f92e9b..eff760e 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -23,12 +23,15 @@ services: - "9200" environment: discovery.type: single-node - xpack.security.enabled: "false" + xpack.security.enabled: "true" + xpack.security.autoconfiguration.enabled: "false" + xpack.security.http.ssl.enabled: "false" + ELASTIC_PASSWORD: ${YENTE_ES_PASSWORD:?Set YENTE_ES_PASSWORD in .env} ES_JAVA_OPTS: "-Xms1g -Xmx1g" # KVM 2 (bei Upgrade auf KVM 4 → -Xms2g -Xmx2g) volumes: - yente_es_data:/usr/share/elasticsearch/data healthcheck: - test: ["CMD", "curl", "-sf", "http://localhost:9200/_cluster/health"] + test: ["CMD-SHELL", "curl -sf -u elastic:$$ELASTIC_PASSWORD http://localhost:9200/_cluster/health >/dev/null"] interval: 10s timeout: 5s retries: 10 @@ -48,7 +51,7 @@ services: yente-es: condition: service_healthy environment: - YENTE_INDEX_URL: http://yente-es:9200 + YENTE_INDEX_URL: http://elastic:${YENTE_ES_PASSWORD:?Set YENTE_ES_PASSWORD in .env}@yente-es:9200 YENTE_MANIFEST: /data/manifest.yml volumes: - ./data/yente:/data:ro @@ -80,6 +83,13 @@ services: OLLAMA_MODEL: kimi-k2.5:cloud # Optional: Claude API als Fallback ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + BEYOND_AI_AUTH_REQUIRED: ${BEYOND_AI_AUTH_REQUIRED:-true} + BEYOND_AI_BASIC_AUTH_USER: ${BEYOND_AI_BASIC_AUTH_USER:?Set BEYOND_AI_BASIC_AUTH_USER in .env} + BEYOND_AI_BASIC_AUTH_PASSWORD: ${BEYOND_AI_BASIC_AUTH_PASSWORD:?Set BEYOND_AI_BASIC_AUTH_PASSWORD in .env} + BEYOND_AI_RATE_LIMIT_REQUESTS: ${BEYOND_AI_RATE_LIMIT_REQUESTS:-30} + BEYOND_AI_RATE_LIMIT_WINDOW_SECONDS: ${BEYOND_AI_RATE_LIMIT_WINDOW_SECONDS:-60} + BEYOND_AI_TRUST_PROXY_HEADERS: ${BEYOND_AI_TRUST_PROXY_HEADERS:-true} + BEYOND_AI_ALLOW_EXTERNAL_LLM_FALLBACK: ${BEYOND_AI_ALLOW_EXTERNAL_LLM_FALLBACK:-false} volumes: - ./shared:/app/shared - ./sanctions:/app/sanctions diff --git a/docker-compose.yml b/docker-compose.yml index acf9d1f..af0e94e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,14 +11,14 @@ services: image: neo4j:5-community container_name: beyond-ai-neo4j ports: - - "7474:7474" # Browser - - "7687:7687" # Bolt + - "127.0.0.1:7474:7474" # Browser + - "127.0.0.1:7687:7687" # Bolt volumes: - neo4j_data:/data - neo4j_logs:/logs - ./data/neo4j-import:/var/lib/neo4j/import environment: - NEO4J_AUTH: neo4j/${NEO4J_PASSWORD:-beyond-ai-2026} + NEO4J_AUTH: neo4j/${NEO4J_PASSWORD:?Set NEO4J_PASSWORD in .env} NEO4J_PLUGINS: '["apoc"]' NEO4J_server_memory_heap_initial__size: 1G NEO4J_server_memory_heap_max__size: 2G @@ -31,12 +31,12 @@ services: image: ghcr.io/opensanctions/yente:latest container_name: beyond-ai-yente ports: - - "8100:8000" + - "127.0.0.1:8100:8000" depends_on: yente-es: condition: service_healthy environment: - YENTE_INDEX_URL: http://yente-es:9200 + YENTE_INDEX_URL: http://elastic:${YENTE_ES_PASSWORD:?Set YENTE_ES_PASSWORD in .env}@yente-es:9200 YENTE_MANIFEST: /data/manifest.yml # Für kommerzielle Nutzung: YENTE_DATA_TOKEN setzen # YENTE_DATA_TOKEN: ${OPENSANCTIONS_TOKEN:-} @@ -49,12 +49,15 @@ services: container_name: beyond-ai-yente-es environment: discovery.type: single-node - xpack.security.enabled: "false" + xpack.security.enabled: "true" + xpack.security.autoconfiguration.enabled: "false" + xpack.security.http.ssl.enabled: "false" + ELASTIC_PASSWORD: ${YENTE_ES_PASSWORD:?Set YENTE_ES_PASSWORD in .env} ES_JAVA_OPTS: "-Xms1g -Xmx1g" volumes: - yente_es_data:/usr/share/elasticsearch/data healthcheck: - test: ["CMD", "curl", "-sf", "http://localhost:9200/_cluster/health"] + test: ["CMD-SHELL", "curl -sf -u elastic:$$ELASTIC_PASSWORD http://localhost:9200/_cluster/health >/dev/null"] interval: 10s timeout: 5s retries: 10 @@ -69,18 +72,25 @@ services: dockerfile: Dockerfile container_name: beyond-ai-api ports: - - "8000:8000" + - "127.0.0.1:8000:8000" depends_on: - neo4j - yente environment: NEO4J_URI: bolt://neo4j:7687 NEO4J_USER: neo4j - NEO4J_PASSWORD: ${NEO4J_PASSWORD:-beyond-ai-2026} + NEO4J_PASSWORD: ${NEO4J_PASSWORD:?Set NEO4J_PASSWORD in .env} YENTE_URL: http://yente:8000 OLLAMA_URL: ${OLLAMA_URL:-http://host.docker.internal:11434} OLLAMA_MODEL: ${OLLAMA_MODEL:-kimi-k2.5} ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + BEYOND_AI_AUTH_REQUIRED: ${BEYOND_AI_AUTH_REQUIRED:-true} + BEYOND_AI_BASIC_AUTH_USER: ${BEYOND_AI_BASIC_AUTH_USER:?Set BEYOND_AI_BASIC_AUTH_USER in .env} + BEYOND_AI_BASIC_AUTH_PASSWORD: ${BEYOND_AI_BASIC_AUTH_PASSWORD:?Set BEYOND_AI_BASIC_AUTH_PASSWORD in .env} + BEYOND_AI_RATE_LIMIT_REQUESTS: ${BEYOND_AI_RATE_LIMIT_REQUESTS:-30} + BEYOND_AI_RATE_LIMIT_WINDOW_SECONDS: ${BEYOND_AI_RATE_LIMIT_WINDOW_SECONDS:-60} + BEYOND_AI_TRUST_PROXY_HEADERS: "false" + BEYOND_AI_ALLOW_EXTERNAL_LLM_FALLBACK: ${BEYOND_AI_ALLOW_EXTERNAL_LLM_FALLBACK:-false} volumes: - ./shared:/app/shared - ./sanctions:/app/sanctions diff --git a/sanctions/README.md b/sanctions/README.md index dfc3013..e65efc3 100644 --- a/sanctions/README.md +++ b/sanctions/README.md @@ -71,6 +71,7 @@ python src/api.py # Einzelnen Namen screenen curl -X POST http://localhost:8000/api/screen \ + -u "$BEYOND_AI_BASIC_AUTH_USER:$BEYOND_AI_BASIC_AUTH_PASSWORD" \ -H "Content-Type: application/json" \ -d '{"name": "Wladimir Wladimirowitsch Putin", "threshold": 0.7}' diff --git a/sanctions/src/main.py b/sanctions/src/main.py index 8a61fec..cb44bd9 100644 --- a/sanctions/src/main.py +++ b/sanctions/src/main.py @@ -4,17 +4,25 @@ Sprint 1: Name screening against OpenSanctions (yente) with LLM enhancement. """ +import base64 +import binascii import html as html_lib import json +import logging import os +import secrets +import time +from collections import defaultdict, deque +from threading import Lock from typing import Annotated import httpx -from fastapi import FastAPI, HTTPException, Request -from fastapi.responses import HTMLResponse -from pydantic import BaseModel, StringConstraints +from fastapi import FastAPI, Form, HTTPException, Request +from fastapi.responses import HTMLResponse, JSONResponse +from pydantic import BaseModel, Field, StringConstraints APP_VERSION = os.getenv("BEYOND_AI_VERSION", "0.1.0-alpha.1") +logger = logging.getLogger(__name__) app = FastAPI( title="Beyond AI — Sanctions Screener", @@ -28,6 +36,15 @@ "person": "Person", "organization": "Organization", } +CACHE_CONTROL_NO_STORE = "no-store, max-age=0, private" +PROTECTED_DOC_PATHS = frozenset({ + "/docs", + "/docs/oauth2-redirect", + "/openapi.json", + "/redoc", +}) +_RATE_LIMIT_BUCKETS: dict[str, deque[float]] = defaultdict(deque) +_RATE_LIMIT_LOCK = Lock() ScreenName = Annotated[ str, @@ -37,7 +54,7 @@ class ScreenRequest(BaseModel): name: ScreenName - threshold: float = 0.7 + threshold: float = Field(default=0.7, ge=0.0, le=1.0) class ScreenMatch(BaseModel): @@ -54,6 +71,171 @@ class ScreenResponse(BaseModel): total: int +def _env_flag(name: str, default: bool) -> bool: + value = os.getenv(name) + if value is None: + return default + return value.strip().lower() not in {"0", "false", "no", "off"} + + +def _is_protected_path(path: str) -> bool: + return path.startswith("/search") or path in { + "/api/screen", + "/api/status", + *PROTECTED_DOC_PATHS, + } + + +def _auth_required() -> bool: + return _env_flag("BEYOND_AI_AUTH_REQUIRED", True) + + +def _trusted_proxy_headers() -> bool: + return _env_flag("BEYOND_AI_TRUST_PROXY_HEADERS", False) + + +def _basic_auth_config() -> tuple[str, str] | None: + username = os.getenv("BEYOND_AI_BASIC_AUTH_USER", "").strip() + password = os.getenv("BEYOND_AI_BASIC_AUTH_PASSWORD", "") + if not username or not password: + return None + return username, password + + +def _parse_basic_auth(header_value: str | None) -> tuple[str, str] | None: + if not header_value: + return None + + scheme, _, token = header_value.partition(" ") + if scheme.lower() != "basic" or not token: + return None + + try: + decoded = base64.b64decode(token, validate=True).decode("utf-8") + except (binascii.Error, UnicodeDecodeError): + return None + + username, separator, password = decoded.partition(":") + if not separator: + return None + return username, password + + +def _authenticated(request: Request) -> bool: + expected = _basic_auth_config() + if expected is None: + return False + + provided = _parse_basic_auth(request.headers.get("Authorization")) + if provided is None: + return False + + expected_user, expected_password = expected + provided_user, provided_password = provided + return secrets.compare_digest(provided_user, expected_user) and secrets.compare_digest( + provided_password, expected_password + ) + + +def _rate_limit_window_seconds() -> int: + return max(1, int(os.getenv("BEYOND_AI_RATE_LIMIT_WINDOW_SECONDS", "60"))) + + +def _rate_limit_requests() -> int: + return max(1, int(os.getenv("BEYOND_AI_RATE_LIMIT_REQUESTS", "30"))) + + +def _client_identifier(request: Request) -> str: + forwarded_for = request.headers.get("X-Forwarded-For") + if _trusted_proxy_headers() and forwarded_for: + return forwarded_for.split(",")[0].strip() + if request.client is not None: + return request.client.host + return "unknown" + + +async def _rate_limit_exceeded(request: Request) -> tuple[bool, int]: + window_seconds = _rate_limit_window_seconds() + max_requests = _rate_limit_requests() + now = time.monotonic() + bucket_key = f"{request.url.path}:{_client_identifier(request)}" + window_start = now - window_seconds + + with _RATE_LIMIT_LOCK: + bucket = _RATE_LIMIT_BUCKETS[bucket_key] + while bucket and bucket[0] <= window_start: + bucket.popleft() + + if len(bucket) >= max_requests: + retry_after = max(1, int(bucket[0] + window_seconds - now)) + return True, retry_after + + bucket.append(now) + + return False, 0 + + +def _apply_security_headers(response, path: str): + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Referrer-Policy"] = "no-referrer" + response.headers["X-Frame-Options"] = "DENY" + + if _is_protected_path(path): + response.headers["Cache-Control"] = CACHE_CONTROL_NO_STORE + response.headers["Pragma"] = "no-cache" + response.headers["Vary"] = "Authorization" + + if path.startswith("/search"): + response.headers["Content-Security-Policy"] = ( + "default-src 'self'; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data:; " + "base-uri 'none'; " + "form-action 'self'; " + "frame-ancestors 'none'" + ) + + return response + + +@app.middleware("http") +async def protect_sensitive_endpoints(request: Request, call_next): + path = request.url.path + + if _is_protected_path(path): + if _auth_required(): + auth_config = _basic_auth_config() + if auth_config is None: + logger.error( + "Protected endpoint requested without configured auth credentials." + ) + response = JSONResponse( + status_code=503, + content={"detail": "service is not securely configured"}, + ) + return _apply_security_headers(response, path) + + if not _authenticated(request): + response = JSONResponse( + status_code=401, + content={"detail": "authentication required"}, + headers={"WWW-Authenticate": 'Basic realm="Beyond AI"'}, + ) + return _apply_security_headers(response, path) + + limited, retry_after = await _rate_limit_exceeded(request) + if limited: + response = JSONResponse( + status_code=429, + content={"detail": "rate limit exceeded"}, + headers={"Retry-After": str(retry_after)}, + ) + return _apply_security_headers(response, path) + + response = await call_next(request) + return _apply_security_headers(response, path) + + async def _query_yente(name: str, threshold: float = 0.7) -> list[dict]: query_name = name.strip() queries = { @@ -97,199 +279,456 @@ async def health(): return {"status": "ok", "service": "beyond-ai-api", "version": APP_VERSION} -@app.get("/search", response_class=HTMLResponse) -async def search_ui(request: Request, q: str = "", threshold: float = 0.7): - """Browser-Suche: HTML + JSON side-by-side.""" - matches = [] - error = None - raw_json = "" - normalized_query = q.strip() - +def _build_search_page( + q: str = "", + threshold: float = 0.7, + matches: list[dict] | None = None, + error: str | None = None, + raw_json: str = "", +) -> str: def escape_text(value: object) -> str: return html_lib.escape(str(value), quote=True) - if q and not normalized_query: - error = "Bitte einen Namen eingeben." - elif normalized_query: - try: - results = await _query_yente(normalized_query, threshold) - matches = [ - { - "id": r.get("id", ""), - "name": r.get("caption", r.get("name", "")), - "score": r.get("score", 0.0), - "datasets": r.get("datasets", []), - "properties": r.get("properties", {}), - } - for r in results - ] - raw_json = json.dumps( - { - "query": normalized_query, - "total": len(matches), - "matches": matches, - }, - indent=2, - ensure_ascii=False, - ) - except httpx.ConnectError: - error = "yente nicht erreichbar — läuft der Container?" - except Exception as e: - error = str(e) + normalized_query = q.strip() + matches = matches or [] def score_color(score: float) -> str: if score >= 0.9: - return "#dc2626" # rot — hohe Übereinstimmung + return "#dc2626" if score >= 0.8: - return "#d97706" # orange - return "#ca8a04" # gelb + return "#d97706" + return "#ca8a04" def dataset_badge(ds: str) -> str: colors = { - "sanctions": ("bg-red-100 text-red-800", "Sanctions"), - "peps": ("bg-orange-100 text-orange-800", "PEP"), + "sanctions": ("badge badge-danger", "Sanctions"), + "peps": ("badge badge-warning", "PEP"), } - cls, label = colors.get(ds, ("bg-gray-100 text-gray-700", ds.upper())) - return ( - f'' - f"{escape_text(label)}" - ) + cls, label = colors.get(ds, ("badge badge-neutral", ds.upper())) + return f'{escape_text(label)}' def prop_row(key: str, vals: list) -> str: if not vals: return "" joined = " · ".join(escape_text(v) for v in vals[:5]) if len(vals) > 5: - joined += f" +{len(vals)-5} weitere" + joined += f" +{len(vals)-5} weitere" return f""" - - {escape_text(key)} - {joined} + + {escape_text(key)} + {joined} """ - SHOW_PROPS = ["birthDate", "birthPlace", "nationality", "position", "topics", - "programId", "address", "notes"] + show_props = [ + "birthDate", + "birthPlace", + "nationality", + "position", + "topics", + "programId", + "address", + "notes", + ] match_cards = "" - for m in matches: - score_pct = int(m["score"] * 100) - badges = " ".join(dataset_badge(ds) for ds in m["datasets"]) - color = score_color(m["score"]) - props = m.get("properties", {}) - rows = "".join(prop_row(k, props.get(k, [])) for k in SHOW_PROPS if props.get(k)) - safe_name = escape_text(m["name"]) - safe_id = escape_text(m["id"]) + for match in matches: + score_pct = int(match["score"] * 100) + badges = " ".join(dataset_badge(ds) for ds in match["datasets"]) + color = score_color(match["score"]) + props = match.get("properties", {}) + rows = "".join(prop_row(key, props.get(key, [])) for key in show_props if props.get(key)) + safe_name = escape_text(match["name"]) + safe_id = escape_text(match["id"]) match_cards += f""" -
-
+
+
-

{safe_name}

-

ID: {safe_id}

+

{safe_name}

+

ID: {safe_id}

-
-
{score_pct}%
-
Match-Score
+
+
{score_pct}%
+
Match-Score
-
{badges}
- {"" + rows + "
" if rows else ""} -
""" +
{badges}
+ {"" + rows + "
" if rows else ""} +
""" result_section = "" if normalized_query and not error: - status_color = "text-red-600 font-semibold" if matches else "text-green-600 font-semibold" - status_text = ( + status_class = "result-status result-status-alert" if matches else "result-status result-status-clear" + status_text = ( f"⚠️ {len(matches)} Treffer gefunden" if matches else "✅ Keine Treffer — Entität nicht gelistet" ) result_section = f""" -
{status_text}
+
{status_text}
{match_cards if matches else ""}""" if error: - result_section = ( - '
' - f"⚠️ {escape_text(error)}
" - ) + result_section = f'
⚠️ {escape_text(error)}
' json_section = "" if raw_json: safe_raw_json = escape_text(raw_json) json_section = f""" -
-

JSON Output

-
{safe_raw_json}
+
+

JSON Output

+
{safe_raw_json}
""" - safe_query = escape_text(q) + safe_query = escape_text(normalized_query) threshold_options = "".join( - f'' - for v in [0.5, 0.6, 0.7, 0.8, 0.9] + f'' + for value in [0.5, 0.6, 0.7, 0.8, 0.9] ) - page_html = f""" + return f""" Beyond AI — Sanctions Screener - + - - -
-
B
+ +
+
B
- Beyond AI - Sanctions & PEP Screener + Beyond AI + Sanctions & PEP Screener
-
-
- -
+
+ - {threshold_options} - + +
Anfragen werden absichtlich nicht in die URL geschrieben.
-
- -
-

Ergebnisse

- {result_section if result_section else '

Gib einen Namen ein und klicke auf „Screenen".

'} -
- -
- {json_section if json_section else '
JSON erscheint hier nach der Suche.
'} -
+
+
+

Ergebnisse

+ {result_section if result_section else '

Gib einen Namen ein und klicke auf „Screenen".

'} +
+
+ {json_section if json_section else '
JSON erscheint hier nach der Suche.
'} +
- """ - return HTMLResponse(content=page_html) + +@app.get("/search", response_class=HTMLResponse) +async def search_ui(): + """Render the blank search page without writing sensitive queries into URLs.""" + return HTMLResponse(content=_build_search_page()) + + +@app.post("/search", response_class=HTMLResponse) +async def search_ui_submit( + q: Annotated[str, Form()] = "", + threshold: Annotated[float, Form(ge=0.0, le=1.0)] = 0.7, +): + matches: list[dict] = [] + error = None + raw_json = "" + normalized_query = q.strip() + + if q and not normalized_query: + error = "Bitte einen Namen eingeben." + elif normalized_query: + try: + results = await _query_yente(normalized_query, threshold) + matches = [ + { + "id": result.get("id", ""), + "name": result.get("caption", result.get("name", "")), + "score": result.get("score", 0.0), + "datasets": result.get("datasets", []), + "properties": result.get("properties", {}), + } + for result in results + ] + raw_json = json.dumps( + { + "query": normalized_query, + "total": len(matches), + "matches": matches, + }, + indent=2, + ensure_ascii=False, + ) + except httpx.ConnectError: + logger.warning("Search UI could not reach yente.") + error = "Screening-Service aktuell nicht erreichbar." + except Exception: + logger.exception("Unexpected error while rendering search results.") + error = "Suche aktuell nicht verfuegbar." + + return HTMLResponse( + content=_build_search_page( + q=normalized_query, + threshold=threshold, + matches=matches, + error=error, + raw_json=raw_json, + ) + ) @app.post("/api/screen", response_model=ScreenResponse) @@ -298,19 +737,23 @@ async def screen(req: ScreenRequest): try: results = await _query_yente(req.name, req.threshold) except httpx.ConnectError as e: + logger.warning("yente connectivity failure during screening request.") raise HTTPException( status_code=503, - detail=f"yente service not reachable at {YENTE_URL}.", + detail="screening backend unavailable", ) from e except httpx.TimeoutException as e: + logger.warning("yente timeout during screening request.") raise HTTPException( status_code=504, - detail=f"yente request timed out at {YENTE_URL}.", + detail="screening backend timed out", ) from e except httpx.RequestError as e: - raise HTTPException(status_code=503, detail=f"yente request failed: {e}") from e + logger.warning("yente request error during screening request.") + raise HTTPException(status_code=503, detail="screening backend unavailable") from e except httpx.HTTPStatusError as e: - raise HTTPException(status_code=502, detail=f"yente error: {e}") from e + logger.warning("yente returned an invalid response to screening request.") + raise HTTPException(status_code=502, detail="screening backend error") from e matches = [ ScreenMatch( @@ -334,15 +777,18 @@ async def status(): try: async with httpx.AsyncClient(timeout=5.0) as client: r = await client.get(f"{YENTE_URL}/healthz") - services["yente"] = "ok" if r.status_code == 200 else f"HTTP {r.status_code}" - except Exception as e: - services["yente"] = f"error: {e}" + services["yente"] = "ok" if r.status_code == 200 else "degraded" + except Exception: + logger.warning("Status check for yente failed.", exc_info=True) + services["yente"] = "degraded" try: from shared.neo4j.connector import Neo4jConnector with Neo4jConnector() as conn: services["neo4j"] = "ok" if conn.verify_connectivity() else "unreachable" - except Exception as e: - services["neo4j"] = f"error: {e}" + except Exception: + logger.warning("Status check for neo4j failed.", exc_info=True) + services["neo4j"] = "degraded" - return {"services": services} + overall_status = "ok" if all(state == "ok" for state in services.values()) else "degraded" + return {"status": overall_status, "services": services} diff --git a/sanctions/tests/test_main.py b/sanctions/tests/test_main.py index 73ae440..56107bd 100644 --- a/sanctions/tests/test_main.py +++ b/sanctions/tests/test_main.py @@ -3,7 +3,9 @@ Sprint 1: Grundlegende Unit-Tests ohne externe Services. """ +import base64 import html +import os from unittest.mock import AsyncMock, Mock, patch import httpx @@ -12,9 +14,17 @@ from sanctions.src.main import APP_VERSION, _query_yente, app +os.environ["BEYOND_AI_BASIC_AUTH_USER"] = "tester" +os.environ["BEYOND_AI_BASIC_AUTH_PASSWORD"] = "topsecret" + client = TestClient(app) +def auth_headers() -> dict[str, str]: + token = base64.b64encode(b"tester:topsecret").decode("ascii") + return {"Authorization": f"Basic {token}"} + + def test_health(): response = client.get("/health") assert response.status_code == 200 @@ -27,18 +37,20 @@ def test_health(): def test_search_ui_empty(): """Leere Suche liefert HTML mit Suchfeld.""" - response = client.get("/search") + response = client.get("/search", headers=auth_headers()) assert response.status_code == 200 assert "text/html" in response.headers["content-type"] assert "Beyond AI" in response.text assert "Sanctions" in response.text + assert response.headers["cache-control"].startswith("no-store") -def test_search_ui_with_query(): - """Suche mit Query — yente nicht erreichbar → Fehlermeldung in HTML.""" - response = client.get("/search?q=TestPerson") +def test_search_ui_get_does_not_process_query_string(): + """GET rendert nur die Seite und verarbeitet keine sensiblen Query-Strings.""" + response = client.get("/search?q=TestPerson", headers=auth_headers()) assert response.status_code == 200 assert "text/html" in response.headers["content-type"] + assert "TestPerson" not in response.text def test_search_ui_escapes_query_and_result_fields(): @@ -55,7 +67,7 @@ def test_search_ui_escapes_query_and_result_fields(): ] with patch("sanctions.src.main._query_yente", new=AsyncMock(return_value=mock_results)): - response = client.get("/search", params={"q": payload}) + response = client.post("/search", data={"q": payload}, headers=auth_headers()) assert response.status_code == 200 assert payload not in response.text @@ -69,7 +81,7 @@ def test_search_ui_escapes_query_and_result_fields(): def test_search_ui_whitespace_query_shows_validation_error(): """Whitespace-only Queries duerfen nicht als cleanes Screening erscheinen.""" with patch("sanctions.src.main._query_yente", new=AsyncMock()) as query_mock: - response = client.get("/search", params={"q": " "}) + response = client.post("/search", data={"q": " "}, headers=auth_headers()) assert response.status_code == 200 query_mock.assert_not_called() @@ -77,6 +89,12 @@ def test_search_ui_whitespace_query_shows_validation_error(): assert "Keine Treffer" not in response.text +def test_search_requires_authentication(): + response = client.get("/search") + assert response.status_code == 401 + assert response.json() == {"detail": "authentication required"} + + @pytest.mark.asyncio async def test_query_yente_screens_persons_and_organizations(): """Organisationen werden neben Personen gegen yente abgefragt.""" @@ -125,6 +143,7 @@ async def test_screen_endpoint_mocked(): response = client.post( "/api/screen", json={"name": "Wladimir Putin"}, + headers=auth_headers(), ) assert response.status_code == 200 @@ -142,6 +161,7 @@ async def test_screen_no_matches(): response = client.post( "/api/screen", json={"name": "Max Mustermann"}, + headers=auth_headers(), ) assert response.status_code == 200 @@ -159,21 +179,37 @@ def test_screen_timeout_returns_gateway_timeout(): response = client.post( "/api/screen", json={"name": "Max Mustermann"}, + headers=auth_headers(), ) assert response.status_code == 504 - assert response.json() == { - "detail": "yente request timed out at http://localhost:8100." - } + assert response.json() == {"detail": "screening backend timed out"} def test_screen_blank_name_rejected(): """Leere oder whitespace-only Namen sind ungueltig.""" - response = client.post("/api/screen", json={"name": " "}) + response = client.post("/api/screen", json={"name": " "}, headers=auth_headers()) assert response.status_code == 422 def test_screen_missing_body(): """Fehlender Body → 422 Validation Error.""" - response = client.post("/api/screen", json={}) + response = client.post("/api/screen", json={}, headers=auth_headers()) + assert response.status_code == 422 + + +def test_screen_threshold_bounds_enforced(): + response = client.post( + "/api/screen", + json={"name": "Max Mustermann", "threshold": 1.5}, + headers=auth_headers(), + ) assert response.status_code == 422 + + +def test_status_endpoint_sanitizes_errors(): + response = client.get("/api/status", headers=auth_headers()) + assert response.status_code == 200 + payload = response.json() + assert payload["status"] in {"ok", "degraded"} + assert "error:" not in response.text diff --git a/shared/llm/gateway.py b/shared/llm/gateway.py index 34da9d2..a764847 100644 --- a/shared/llm/gateway.py +++ b/shared/llm/gateway.py @@ -5,11 +5,14 @@ Follows FinRegAgents confidence-aware pattern. """ +import logging import os from dataclasses import dataclass import httpx +logger = logging.getLogger(__name__) + @dataclass class LLMResponse: @@ -31,6 +34,7 @@ def __init__( claude_api_key: str | None = None, claude_model: str = "claude-sonnet-4-20250514", timeout: float = 60.0, + allow_external_fallback: bool | None = None, ): self.ollama_url = ollama_url or os.getenv( "OLLAMA_URL", "http://localhost:11434" @@ -41,6 +45,11 @@ def __init__( self.claude_api_key = claude_api_key or os.getenv("ANTHROPIC_API_KEY") self.claude_model = claude_model self.timeout = timeout + if allow_external_fallback is None: + allow_external_fallback = os.getenv( + "BEYOND_AI_ALLOW_EXTERNAL_LLM_FALLBACK", "" + ).strip().lower() in {"1", "true", "yes", "on"} + self.allow_external_fallback = allow_external_fallback self._client = httpx.Client(timeout=timeout) async def complete( @@ -56,10 +65,16 @@ async def complete( prompt, system, temperature, max_tokens ) except Exception: - if self.claude_api_key: + if self.allow_external_fallback and self.claude_api_key: + logger.warning( + "Falling back to external Claude provider after Ollama failure." + ) return await self._claude_complete( prompt, system, temperature, max_tokens ) + logger.warning( + "Ollama request failed and external fallback is disabled or unconfigured." + ) raise async def _ollama_complete( From 21fdaa2bad10dc25c0b7b0208d6795eaeabdaa8e Mon Sep 17 00:00:00 2001 From: jsg-claude Date: Wed, 22 Apr 2026 22:49:32 +0200 Subject: [PATCH 2/2] Add python-multipart for search form parsing --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 69954f2..1bd4248 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ httpx>=0.27.0 neo4j>=5.0.0 pydantic>=2.0.0 python-dotenv>=1.0.0 +python-multipart>=0.0.20