""" FastAPI routes for Evidence Checks — automated compliance verification. Endpoints: GET /evidence-checks — list checks (filter: check_type, is_active, frequency) POST /evidence-checks — create check definition GET /evidence-checks/{id} — single check with last 5 results PUT /evidence-checks/{id} — update check DELETE /evidence-checks/{id} — delete check (204) POST /evidence-checks/{id}/run — execute check now GET /evidence-checks/{id}/results — result history POST /evidence-checks/run-due — run all due checks POST /evidence-checks/seed — seed standard check definitions GET /evidence-checks/mappings — list evidence-control mappings POST /evidence-checks/mappings — create mapping DELETE /evidence-checks/mappings/{id} — delete mapping (204) GET /evidence-checks/mappings/by-control/{code} — evidence for a control GET /evidence-checks/mappings/report — coverage report """ import json import logging import ssl import socket import time from datetime import datetime, timedelta from typing import Optional, List, Any, Dict from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy import text from sqlalchemy.orm import Session from classroom_engine.database import get_db from .tenant_utils import get_tenant_id as _get_tenant_id from .db_utils import row_to_dict as _row_to_dict logger = logging.getLogger(__name__) router = APIRouter(prefix="/evidence-checks", tags=["evidence-checks"]) # ============================================================================= # Validation sets # ============================================================================= VALID_CHECK_TYPES = { "tls_scan", "header_check", "certificate_check", "config_scan", "api_scan", "dns_check", "port_scan", } VALID_FREQUENCIES = {"daily", "weekly", "monthly", "quarterly", "manual"} FREQUENCY_DELTAS = { "daily": timedelta(days=1), "weekly": timedelta(weeks=1), "monthly": timedelta(days=30), "quarterly": timedelta(days=90), "manual": None, } JSONB_FIELDS = {"target_config", "linked_control_ids"} # ============================================================================= # Pydantic Schemas # ============================================================================= class EvidenceCheckCreate(BaseModel): check_code: str title: str description: Optional[str] = None check_type: str target_url: Optional[str] = None target_config: Optional[dict] = {} linked_control_ids: Optional[List] = [] frequency: str = "monthly" is_active: bool = True class EvidenceCheckUpdate(BaseModel): check_code: Optional[str] = None title: Optional[str] = None description: Optional[str] = None check_type: Optional[str] = None target_url: Optional[str] = None target_config: Optional[dict] = None linked_control_ids: Optional[List] = None frequency: Optional[str] = None is_active: Optional[bool] = None class EvidenceControlMapCreate(BaseModel): evidence_id: str control_code: str mapping_type: str = "supports" notes: Optional[str] = None # ============================================================================= # Check execution helpers # ============================================================================= async def _run_tls_scan(target_url: str, config: dict) -> dict: """Check TLS version, cipher suite, certificate validity.""" try: from urllib.parse import urlparse parsed = urlparse(target_url) hostname = parsed.hostname or target_url port = parsed.port or 443 context = ssl.create_default_context() start = time.time() with socket.create_connection((hostname, port), timeout=10) as sock: with context.wrap_socket(sock, server_hostname=hostname) as ssock: cert = ssock.getpeercert() cipher = ssock.cipher() version = ssock.version() duration = int((time.time() - start) * 1000) # Check cert expiry not_after = datetime.strptime(cert['notAfter'], '%b %d %H:%M:%S %Y %Z') days_until_expiry = (not_after - datetime.utcnow()).days findings = [] critical = 0 if version in ('TLSv1', 'TLSv1.1'): findings.append({"severity": "critical", "finding": f"Veraltete TLS-Version: {version}"}) critical += 1 if days_until_expiry < 30: findings.append({"severity": "warning", "finding": f"Zertifikat laeuft in {days_until_expiry} Tagen ab"}) if days_until_expiry < 0: findings.append({"severity": "critical", "finding": "Zertifikat abgelaufen"}) critical += 1 status = "passed" if not findings else ("failed" if critical > 0 else "warning") return { "run_status": status, "result_data": { "tls_version": version, "cipher": cipher[0] if cipher else None, "cert_expiry": not_after.isoformat(), "days_until_expiry": days_until_expiry, "findings": findings, }, "summary": f"TLS {version}, Zertifikat gueltig bis {not_after.strftime('%d.%m.%Y')}", "findings_count": len(findings), "critical_findings": critical, "duration_ms": duration, } except Exception as e: return { "run_status": "error", "result_data": {"error": str(e)}, "summary": f"Fehler: {str(e)}", "findings_count": 1, "critical_findings": 1, "duration_ms": 0, } async def _run_header_check(target_url: str, config: dict) -> dict: """Check security headers on the target URL.""" try: import httpx required_headers = { "strict-transport-security": "HSTS", "x-frame-options": "X-Frame-Options", "x-content-type-options": "X-Content-Type-Options", "content-security-policy": "Content-Security-Policy", "referrer-policy": "Referrer-Policy", "permissions-policy": "Permissions-Policy", } start = time.time() async with httpx.AsyncClient(verify=False, timeout=10) as client: resp = await client.get(target_url) duration = int((time.time() - start) * 1000) findings = [] critical = 0 present = [] missing = [] for header_key, display_name in required_headers.items(): val = resp.headers.get(header_key) if val: present.append(display_name) else: missing.append(display_name) severity = "critical" if header_key == "strict-transport-security" else "warning" findings.append({"severity": severity, "finding": f"Fehlender Header: {display_name}"}) if severity == "critical": critical += 1 status = "passed" if not findings else ("failed" if critical > 0 else "warning") return { "run_status": status, "result_data": { "status_code": resp.status_code, "present_headers": present, "missing_headers": missing, "findings": findings, }, "summary": f"{len(present)}/{len(required_headers)} Security-Header vorhanden", "findings_count": len(findings), "critical_findings": critical, "duration_ms": duration, } except Exception as e: return { "run_status": "error", "result_data": {"error": str(e)}, "summary": f"Fehler: {str(e)}", "findings_count": 1, "critical_findings": 1, "duration_ms": 0, } async def _run_certificate_check(target_url: str, config: dict) -> dict: """Check certificate chain, key length, SAN.""" try: from urllib.parse import urlparse parsed = urlparse(target_url) hostname = parsed.hostname or target_url port = parsed.port or 443 context = ssl.create_default_context() start = time.time() with socket.create_connection((hostname, port), timeout=10) as sock: with context.wrap_socket(sock, server_hostname=hostname) as ssock: cert = ssock.getpeercert() der_cert = ssock.getpeercert(binary_form=True) duration = int((time.time() - start) * 1000) subject = dict(x[0] for x in cert.get("subject", ())) issuer = dict(x[0] for x in cert.get("issuer", ())) san_entries = [entry[1] for entry in cert.get("subjectAltName", ())] not_after = datetime.strptime(cert['notAfter'], '%b %d %H:%M:%S %Y %Z') not_before = datetime.strptime(cert['notBefore'], '%b %d %H:%M:%S %Y %Z') days_until_expiry = (not_after - datetime.utcnow()).days findings = [] critical = 0 if days_until_expiry < 0: findings.append({"severity": "critical", "finding": "Zertifikat abgelaufen"}) critical += 1 elif days_until_expiry < 14: findings.append({"severity": "critical", "finding": f"Zertifikat laeuft in {days_until_expiry} Tagen ab"}) critical += 1 elif days_until_expiry < 30: findings.append({"severity": "warning", "finding": f"Zertifikat laeuft in {days_until_expiry} Tagen ab"}) if hostname not in san_entries and f"*.{'.'.join(hostname.split('.')[1:])}" not in san_entries: findings.append({"severity": "warning", "finding": f"Hostname {hostname} nicht in SAN enthalten"}) # Key size from DER cert length as rough heuristic key_bits = len(der_cert) * 8 // 10 # rough estimate if key_bits < 2048: findings.append({"severity": "warning", "finding": f"Schluessellaenge moeglicherweise zu kurz"}) status = "passed" if not findings else ("failed" if critical > 0 else "warning") return { "run_status": status, "result_data": { "subject": subject, "issuer": issuer, "san": san_entries, "not_before": not_before.isoformat(), "not_after": not_after.isoformat(), "days_until_expiry": days_until_expiry, "serial_number": cert.get("serialNumber"), "findings": findings, }, "summary": f"Zertifikat von {issuer.get('organizationName', 'Unknown')}, gueltig bis {not_after.strftime('%d.%m.%Y')}", "findings_count": len(findings), "critical_findings": critical, "duration_ms": duration, } except Exception as e: return { "run_status": "error", "result_data": {"error": str(e)}, "summary": f"Fehler: {str(e)}", "findings_count": 1, "critical_findings": 1, "duration_ms": 0, } async def _run_dns_check(target_url: str, config: dict) -> dict: """Basic DNS resolution check.""" try: from urllib.parse import urlparse parsed = urlparse(target_url) hostname = parsed.hostname or target_url start = time.time() results = socket.getaddrinfo(hostname, None) duration = int((time.time() - start) * 1000) addresses = list(set(r[4][0] for r in results)) families = list(set(r[0].name for r in results)) findings = [] if not any("AF_INET6" in f for f in families): findings.append({"severity": "info", "finding": "Kein IPv6 (AAAA) Record gefunden"}) return { "run_status": "passed" if addresses else "failed", "result_data": { "hostname": hostname, "addresses": addresses, "address_families": families, "record_count": len(addresses), "findings": findings, }, "summary": f"DNS aufgeloest: {len(addresses)} Adresse(n) fuer {hostname}", "findings_count": len(findings), "critical_findings": 0, "duration_ms": duration, } except Exception as e: return { "run_status": "error", "result_data": {"error": str(e)}, "summary": f"DNS-Fehler: {str(e)}", "findings_count": 1, "critical_findings": 1, "duration_ms": 0, } async def _run_api_scan(target_url: str, config: dict) -> dict: """Check that health endpoints respond with 200.""" try: import httpx start = time.time() async with httpx.AsyncClient(verify=False, timeout=10) as client: resp = await client.get(target_url) duration = int((time.time() - start) * 1000) findings = [] critical = 0 if resp.status_code != 200: findings.append({"severity": "critical", "finding": f"HTTP {resp.status_code} statt 200"}) critical += 1 try: body = resp.json() except Exception: body = resp.text[:500] return { "run_status": "passed" if resp.status_code == 200 else "failed", "result_data": { "status_code": resp.status_code, "response_body": body, "response_time_ms": duration, "findings": findings, }, "summary": f"HTTP {resp.status_code}, Antwortzeit {duration}ms", "findings_count": len(findings), "critical_findings": critical, "duration_ms": duration, } except Exception as e: return { "run_status": "error", "result_data": {"error": str(e)}, "summary": f"Fehler: {str(e)}", "findings_count": 1, "critical_findings": 1, "duration_ms": 0, } async def _run_config_scan(target_url: str, config: dict) -> dict: """Check a config endpoint returns expected keys.""" try: import httpx expected_keys = config.get("expected_keys", []) start = time.time() async with httpx.AsyncClient(verify=False, timeout=10) as client: resp = await client.get(target_url) duration = int((time.time() - start) * 1000) findings = [] critical = 0 if resp.status_code != 200: findings.append({"severity": "critical", "finding": f"Endpunkt nicht erreichbar: HTTP {resp.status_code}"}) critical += 1 return { "run_status": "failed", "result_data": {"status_code": resp.status_code, "findings": findings}, "summary": f"Endpunkt nicht erreichbar: HTTP {resp.status_code}", "findings_count": len(findings), "critical_findings": critical, "duration_ms": duration, } try: body = resp.json() except Exception: findings.append({"severity": "critical", "finding": "Antwort ist kein gueltiges JSON"}) return { "run_status": "failed", "result_data": {"findings": findings}, "summary": "Ungueltige JSON-Antwort", "findings_count": 1, "critical_findings": 1, "duration_ms": duration, } present_keys = [] missing_keys = [] if expected_keys and isinstance(body, dict): for key in expected_keys: if key in body: present_keys.append(key) else: missing_keys.append(key) findings.append({"severity": "warning", "finding": f"Fehlender Config-Key: {key}"}) status = "passed" if not findings else ("failed" if critical > 0 else "warning") return { "run_status": status, "result_data": { "status_code": resp.status_code, "present_keys": present_keys, "missing_keys": missing_keys, "findings": findings, }, "summary": f"{len(present_keys)}/{len(expected_keys)} erwartete Keys vorhanden" if expected_keys else "Config erreichbar", "findings_count": len(findings), "critical_findings": critical, "duration_ms": duration, } except Exception as e: return { "run_status": "error", "result_data": {"error": str(e)}, "summary": f"Fehler: {str(e)}", "findings_count": 1, "critical_findings": 1, "duration_ms": 0, } async def _run_port_scan(target_url: str, config: dict) -> dict: """Check common ports — only open/closed status.""" try: from urllib.parse import urlparse parsed = urlparse(target_url) hostname = parsed.hostname or target_url common_ports = config.get("ports", [80, 443, 22, 3306, 5432, 6379, 8080]) start = time.time() port_results = [] open_ports = [] closed_ports = [] for port in common_ports: try: with socket.create_connection((hostname, port), timeout=3) as sock: port_results.append({"port": port, "status": "open"}) open_ports.append(port) except (socket.timeout, ConnectionRefusedError, OSError): port_results.append({"port": port, "status": "closed"}) closed_ports.append(port) duration = int((time.time() - start) * 1000) findings = [] # Flag unexpected open ports expected_open = config.get("expected_open", [80, 443]) unexpected = [p for p in open_ports if p not in expected_open] for p in unexpected: findings.append({"severity": "warning", "finding": f"Unerwarteter offener Port: {p}"}) status = "passed" if not findings else "warning" return { "run_status": status, "result_data": { "hostname": hostname, "ports": port_results, "open_ports": open_ports, "closed_ports": closed_ports, "findings": findings, }, "summary": f"{len(open_ports)} offene, {len(closed_ports)} geschlossene Ports", "findings_count": len(findings), "critical_findings": 0, "duration_ms": duration, } except Exception as e: return { "run_status": "error", "result_data": {"error": str(e)}, "summary": f"Fehler: {str(e)}", "findings_count": 1, "critical_findings": 1, "duration_ms": 0, } # Dispatcher CHECK_RUNNERS = { "tls_scan": _run_tls_scan, "header_check": _run_header_check, "certificate_check": _run_certificate_check, "dns_check": _run_dns_check, "api_scan": _run_api_scan, "config_scan": _run_config_scan, "port_scan": _run_port_scan, } # ============================================================================= # Routes — Check Definitions # ============================================================================= @router.get("") async def list_checks( check_type: Optional[str] = Query(None), is_active: Optional[bool] = Query(None), frequency: Optional[str] = Query(None), limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0), db: Session = Depends(get_db), tenant_id: str = Depends(_get_tenant_id), ): """List evidence checks with optional filters.""" where_clauses = ["tenant_id = :tenant_id"] params: Dict[str, Any] = {"tenant_id": tenant_id, "limit": limit, "offset": offset} if check_type: where_clauses.append("check_type = :check_type") params["check_type"] = check_type if is_active is not None: where_clauses.append("is_active = :is_active") params["is_active"] = is_active if frequency: where_clauses.append("frequency = :frequency") params["frequency"] = frequency where_sql = " AND ".join(where_clauses) total_row = db.execute( text(f"SELECT COUNT(*) FROM compliance_evidence_checks WHERE {where_sql}"), params, ).fetchone() total = total_row[0] if total_row else 0 rows = db.execute( text(f""" SELECT * FROM compliance_evidence_checks WHERE {where_sql} ORDER BY created_at DESC LIMIT :limit OFFSET :offset """), params, ).fetchall() return {"checks": [_row_to_dict(r) for r in rows], "total": total} @router.post("", status_code=201) async def create_check( body: EvidenceCheckCreate, db: Session = Depends(get_db), tenant_id: str = Depends(_get_tenant_id), ): """Create a new evidence check definition.""" if body.check_type not in VALID_CHECK_TYPES: raise HTTPException(400, f"Ungueltiger check_type: {body.check_type}. Erlaubt: {sorted(VALID_CHECK_TYPES)}") if body.frequency not in VALID_FREQUENCIES: raise HTTPException(400, f"Ungueltige frequency: {body.frequency}. Erlaubt: {sorted(VALID_FREQUENCIES)}") row = db.execute( text(""" INSERT INTO compliance_evidence_checks (tenant_id, check_code, title, description, check_type, target_url, target_config, linked_control_ids, frequency, is_active) VALUES (:tenant_id, :check_code, :title, :description, :check_type, :target_url, CAST(:target_config AS jsonb), CAST(:linked_control_ids AS jsonb), :frequency, :is_active) RETURNING * """), { "tenant_id": tenant_id, "check_code": body.check_code, "title": body.title, "description": body.description, "check_type": body.check_type, "target_url": body.target_url, "target_config": json.dumps(body.target_config or {}), "linked_control_ids": json.dumps(body.linked_control_ids or []), "frequency": body.frequency, "is_active": body.is_active, }, ).fetchone() db.commit() return _row_to_dict(row) @router.get("/mappings") async def list_mappings( control_code: Optional[str] = Query(None), evidence_id: Optional[str] = Query(None), limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0), db: Session = Depends(get_db), tenant_id: str = Depends(_get_tenant_id), ): """List evidence-control mappings.""" where_clauses = ["tenant_id = :tenant_id"] params: Dict[str, Any] = {"tenant_id": tenant_id, "limit": limit, "offset": offset} if control_code: where_clauses.append("control_code = :control_code") params["control_code"] = control_code if evidence_id: where_clauses.append("evidence_id = CAST(:evidence_id AS uuid)") params["evidence_id"] = evidence_id where_sql = " AND ".join(where_clauses) rows = db.execute( text(f""" SELECT * FROM compliance_evidence_control_map WHERE {where_sql} ORDER BY created_at DESC LIMIT :limit OFFSET :offset """), params, ).fetchall() return {"mappings": [_row_to_dict(r) for r in rows]} @router.post("/mappings", status_code=201) async def create_mapping( body: EvidenceControlMapCreate, db: Session = Depends(get_db), tenant_id: str = Depends(_get_tenant_id), ): """Create an evidence-control mapping.""" valid_mapping_types = {"supports", "partially_supports", "required"} if body.mapping_type not in valid_mapping_types: raise HTTPException(400, f"Ungueltiger mapping_type: {body.mapping_type}") row = db.execute( text(""" INSERT INTO compliance_evidence_control_map (tenant_id, evidence_id, control_code, mapping_type, notes) VALUES (:tenant_id, CAST(:evidence_id AS uuid), :control_code, :mapping_type, :notes) ON CONFLICT (tenant_id, evidence_id, control_code) DO NOTHING RETURNING * """), { "tenant_id": tenant_id, "evidence_id": body.evidence_id, "control_code": body.control_code, "mapping_type": body.mapping_type, "notes": body.notes, }, ).fetchone() db.commit() if not row: raise HTTPException(409, "Mapping existiert bereits") return _row_to_dict(row) @router.delete("/mappings/{mapping_id}", status_code=204) async def delete_mapping( mapping_id: str, db: Session = Depends(get_db), tenant_id: str = Depends(_get_tenant_id), ): """Delete an evidence-control mapping.""" result = db.execute( text(""" DELETE FROM compliance_evidence_control_map WHERE id = CAST(:id AS uuid) AND tenant_id = :tenant_id """), {"id": mapping_id, "tenant_id": tenant_id}, ) db.commit() if result.rowcount == 0: raise HTTPException(404, "Mapping nicht gefunden") return None @router.get("/mappings/by-control/{code}") async def mappings_by_control( code: str, db: Session = Depends(get_db), tenant_id: str = Depends(_get_tenant_id), ): """Get all evidence mapped to a specific control.""" rows = db.execute( text(""" SELECT * FROM compliance_evidence_control_map WHERE tenant_id = :tenant_id AND control_code = :code ORDER BY created_at DESC """), {"tenant_id": tenant_id, "code": code}, ).fetchall() return {"control_code": code, "mappings": [_row_to_dict(r) for r in rows]} @router.get("/mappings/report") async def mappings_report( db: Session = Depends(get_db), tenant_id: str = Depends(_get_tenant_id), ): """Coverage report: controls with/without evidence.""" # Get all distinct control codes from canonical controls all_controls = db.execute( text(""" SELECT DISTINCT control_code FROM compliance_canonical_controls WHERE tenant_id = :tenant_id """), {"tenant_id": tenant_id}, ).fetchall() total_controls = len(all_controls) all_codes = {r[0] for r in all_controls} # Get controls that have evidence covered = db.execute( text(""" SELECT DISTINCT control_code FROM compliance_evidence_control_map WHERE tenant_id = :tenant_id """), {"tenant_id": tenant_id}, ).fetchall() covered_codes = {r[0] for r in covered} controls_with_evidence = len(covered_codes & all_codes) controls_without = total_controls - controls_with_evidence coverage_pct = round((controls_with_evidence / total_controls * 100), 1) if total_controls > 0 else 0.0 return { "total_controls": total_controls, "controls_with_evidence": controls_with_evidence, "controls_without_evidence": controls_without, "coverage_percentage": coverage_pct, "uncovered_controls": sorted(all_codes - covered_codes), } @router.post("/seed") async def seed_checks( db: Session = Depends(get_db), tenant_id: str = Depends(_get_tenant_id), ): """Seed standard check definitions.""" seeds = [ ("TLS-SCAN-001", "TLS-Scan Hauptwebseite", "tls_scan", "monthly", "Prueft TLS-Version, Cipher-Suite und Zertifikatsgueltigkeit der Hauptwebseite"), ("TLS-SCAN-002", "TLS-Scan API-Server", "tls_scan", "monthly", "Prueft TLS-Version, Cipher-Suite und Zertifikatsgueltigkeit des API-Servers"), ("HEADER-001", "Security-Header-Check Webseite", "header_check", "monthly", "Prueft Sicherheitsheader (HSTS, CSP, X-Frame-Options) der Hauptwebseite"), ("HEADER-002", "Security-Header-Check API", "header_check", "monthly", "Prueft Sicherheitsheader des API-Servers"), ("CERT-001", "Zertifikat-Check Hauptdomain", "certificate_check", "weekly", "Prueft Zertifikatskette, Schluessellaenge und SAN der Hauptdomain"), ("CERT-002", "Zertifikat-Check API-Domain", "certificate_check", "weekly", "Prueft Zertifikatskette, Schluessellaenge und SAN der API-Domain"), ("DNS-001", "DNS-Check Hauptdomain", "dns_check", "monthly", "DNS-Aufloesung und Record-Pruefung der Hauptdomain"), ("API-HEALTH-001", "API Health-Check Backend", "api_scan", "daily", "Prueft ob der Backend-Health-Endpoint HTTP 200 liefert"), ("API-HEALTH-002", "API Health-Check SDK", "api_scan", "daily", "Prueft ob der SDK-Health-Endpoint HTTP 200 liefert"), ("API-HEALTH-003", "API Health-Check Frontend", "api_scan", "daily", "Prueft ob der Frontend-Health-Endpoint HTTP 200 liefert"), ("PORT-SCAN-001", "Port-Scan Webserver", "port_scan", "quarterly", "Prueft offene/geschlossene Ports des Webservers"), ("PORT-SCAN-002", "Port-Scan Datenbankserver", "port_scan", "quarterly", "Prueft offene/geschlossene Ports des Datenbankservers"), ("CONFIG-001", "Konfiguration-Check Backend", "config_scan", "monthly", "Prueft ob Backend-Konfiguration erwartete Keys enthaelt"), ("CONFIG-002", "Konfiguration-Check SDK", "config_scan", "monthly", "Prueft ob SDK-Konfiguration erwartete Keys enthaelt"), ("HEADER-003", "CORS-Header-Check API", "header_check", "quarterly", "Prueft CORS-Header-Konfiguration des API-Servers"), ] created = 0 for code, title, ctype, freq, desc in seeds: result = db.execute( text(""" INSERT INTO compliance_evidence_checks (tenant_id, check_code, title, description, check_type, frequency) VALUES (:tenant_id, :code, :title, :desc, :ctype, :freq) ON CONFLICT (tenant_id, project_id, check_code) DO NOTHING """), { "tenant_id": tenant_id, "code": code, "title": title, "desc": desc, "ctype": ctype, "freq": freq, }, ) created += result.rowcount db.commit() return {"seeded": created, "total_definitions": len(seeds)} @router.post("/run-due") async def run_due_checks( db: Session = Depends(get_db), tenant_id: str = Depends(_get_tenant_id), ): """Run all checks where next_run_at <= NOW() and is_active=true.""" rows = db.execute( text(""" SELECT * FROM compliance_evidence_checks WHERE tenant_id = :tenant_id AND is_active = true AND (next_run_at IS NULL OR next_run_at <= NOW()) ORDER BY created_at """), {"tenant_id": tenant_id}, ).fetchall() results_summary = {"total": len(rows), "passed": 0, "failed": 0, "warning": 0, "error": 0} for row in rows: check = _row_to_dict(row) check_id = check["id"] check_type = check["check_type"] target_url = check.get("target_url") or "" target_config = check.get("target_config") or {} runner = CHECK_RUNNERS.get(check_type) if not runner: continue # Insert running result result_row = db.execute( text(""" INSERT INTO compliance_evidence_check_results (check_id, tenant_id, run_status) VALUES (CAST(:check_id AS uuid), :tenant_id, 'running') RETURNING id """), {"check_id": check_id, "tenant_id": tenant_id}, ).fetchone() db.commit() result_id = result_row[0] if result_row else None # Run run_result = await runner(target_url, target_config) # Update result if result_id: db.execute( text(""" UPDATE compliance_evidence_check_results SET run_status = :run_status, result_data = CAST(:result_data AS jsonb), summary = :summary, findings_count = :findings_count, critical_findings = :critical_findings, duration_ms = :duration_ms WHERE id = CAST(:id AS uuid) """), { "id": str(result_id), "run_status": run_result["run_status"], "result_data": json.dumps(run_result["result_data"]), "summary": run_result["summary"], "findings_count": run_result["findings_count"], "critical_findings": run_result["critical_findings"], "duration_ms": run_result["duration_ms"], }, ) # Update check timestamps delta = FREQUENCY_DELTAS.get(check.get("frequency", "monthly")) next_run = (datetime.utcnow() + delta).isoformat() if delta else None db.execute( text(""" UPDATE compliance_evidence_checks SET last_run_at = NOW(), next_run_at = CAST(:next_run AS timestamptz), updated_at = NOW() WHERE id = CAST(:id AS uuid) """), {"id": check_id, "next_run": next_run}, ) db.commit() results_summary[run_result["run_status"]] = results_summary.get(run_result["run_status"], 0) + 1 return results_summary @router.get("/{check_id}") async def get_check( check_id: str, db: Session = Depends(get_db), tenant_id: str = Depends(_get_tenant_id), ): """Get single check with last 5 results.""" row = db.execute( text(""" SELECT * FROM compliance_evidence_checks WHERE id = CAST(:id AS uuid) AND tenant_id = :tenant_id """), {"id": check_id, "tenant_id": tenant_id}, ).fetchone() if not row: raise HTTPException(404, "Check nicht gefunden") check = _row_to_dict(row) # Last 5 results result_rows = db.execute( text(""" SELECT * FROM compliance_evidence_check_results WHERE check_id = CAST(:check_id AS uuid) ORDER BY run_at DESC LIMIT 5 """), {"check_id": check_id}, ).fetchall() check["recent_results"] = [_row_to_dict(r) for r in result_rows] return check @router.put("/{check_id}") async def update_check( check_id: str, body: EvidenceCheckUpdate, db: Session = Depends(get_db), tenant_id: str = Depends(_get_tenant_id), ): """Update a check definition.""" # Validate if provided if body.check_type and body.check_type not in VALID_CHECK_TYPES: raise HTTPException(400, f"Ungueltiger check_type: {body.check_type}") if body.frequency and body.frequency not in VALID_FREQUENCIES: raise HTTPException(400, f"Ungueltige frequency: {body.frequency}") # Build dynamic SET updates = body.model_dump(exclude_unset=True) if not updates: raise HTTPException(400, "Keine Felder zum Aktualisieren") set_parts = [] params: Dict[str, Any] = {"id": check_id, "tenant_id": tenant_id} for key, val in updates.items(): if key in JSONB_FIELDS: set_parts.append(f"{key} = CAST(:{key} AS jsonb)") params[key] = json.dumps(val) else: set_parts.append(f"{key} = :{key}") params[key] = val set_parts.append("updated_at = NOW()") set_sql = ", ".join(set_parts) row = db.execute( text(f""" UPDATE compliance_evidence_checks SET {set_sql} WHERE id = CAST(:id AS uuid) AND tenant_id = :tenant_id RETURNING * """), params, ).fetchone() db.commit() if not row: raise HTTPException(404, "Check nicht gefunden") return _row_to_dict(row) @router.delete("/{check_id}", status_code=204) async def delete_check( check_id: str, db: Session = Depends(get_db), tenant_id: str = Depends(_get_tenant_id), ): """Delete a check definition (cascades to results).""" result = db.execute( text(""" DELETE FROM compliance_evidence_checks WHERE id = CAST(:id AS uuid) AND tenant_id = :tenant_id """), {"id": check_id, "tenant_id": tenant_id}, ) db.commit() if result.rowcount == 0: raise HTTPException(404, "Check nicht gefunden") return None @router.post("/{check_id}/run") async def run_check( check_id: str, db: Session = Depends(get_db), tenant_id: str = Depends(_get_tenant_id), ): """Execute a specific check now.""" # Load check row = db.execute( text(""" SELECT * FROM compliance_evidence_checks WHERE id = CAST(:id AS uuid) AND tenant_id = :tenant_id """), {"id": check_id, "tenant_id": tenant_id}, ).fetchone() if not row: raise HTTPException(404, "Check nicht gefunden") check = _row_to_dict(row) check_type = check["check_type"] target_url = check.get("target_url") or "" target_config = check.get("target_config") or {} runner = CHECK_RUNNERS.get(check_type) if not runner: raise HTTPException(400, f"Kein Runner fuer check_type: {check_type}") # Insert running result result_row = db.execute( text(""" INSERT INTO compliance_evidence_check_results (check_id, tenant_id, run_status) VALUES (CAST(:check_id AS uuid), :tenant_id, 'running') RETURNING * """), {"check_id": check_id, "tenant_id": tenant_id}, ).fetchone() db.commit() result_id = str(result_row._mapping["id"]) if result_row else None # Execute run_result = await runner(target_url, target_config) # Update result if result_id: updated_row = db.execute( text(""" UPDATE compliance_evidence_check_results SET run_status = :run_status, result_data = CAST(:result_data AS jsonb), summary = :summary, findings_count = :findings_count, critical_findings = :critical_findings, duration_ms = :duration_ms WHERE id = CAST(:id AS uuid) RETURNING * """), { "id": result_id, "run_status": run_result["run_status"], "result_data": json.dumps(run_result["result_data"]), "summary": run_result["summary"], "findings_count": run_result["findings_count"], "critical_findings": run_result["critical_findings"], "duration_ms": run_result["duration_ms"], }, ).fetchone() db.commit() # Update check timestamps delta = FREQUENCY_DELTAS.get(check.get("frequency", "monthly")) next_run = (datetime.utcnow() + delta).isoformat() if delta else None db.execute( text(""" UPDATE compliance_evidence_checks SET last_run_at = NOW(), next_run_at = CAST(:next_run AS timestamptz), updated_at = NOW() WHERE id = CAST(:id AS uuid) """), {"id": check_id, "next_run": next_run}, ) db.commit() return _row_to_dict(updated_row) if updated_row else run_result @router.get("/{check_id}/results") async def list_results( check_id: str, limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), db: Session = Depends(get_db), tenant_id: str = Depends(_get_tenant_id), ): """Result history for a specific check.""" # Verify check exists exists = db.execute( text(""" SELECT 1 FROM compliance_evidence_checks WHERE id = CAST(:id AS uuid) AND tenant_id = :tenant_id """), {"id": check_id, "tenant_id": tenant_id}, ).fetchone() if not exists: raise HTTPException(404, "Check nicht gefunden") total_row = db.execute( text(""" SELECT COUNT(*) FROM compliance_evidence_check_results WHERE check_id = CAST(:check_id AS uuid) """), {"check_id": check_id}, ).fetchone() total = total_row[0] if total_row else 0 rows = db.execute( text(""" SELECT * FROM compliance_evidence_check_results WHERE check_id = CAST(:check_id AS uuid) ORDER BY run_at DESC LIMIT :limit OFFSET :offset """), {"check_id": check_id, "limit": limit, "offset": offset}, ).fetchall() return {"results": [_row_to_dict(r) for r in rows], "total": total}