""" FastAPI routes for CRA (Cyber Resilience Act) Compliance Projects. Endpoints: - GET /v1/cra/projects List all CRA projects for a tenant - POST /v1/cra/projects Create a new CRA project - GET /v1/cra/projects/{id} Get a single CRA project - PATCH /v1/cra/projects/{id} Update intake fields / status - DELETE /v1/cra/projects/{id} Archive project - POST /v1/cra/projects/{id}/scope-check Run deterministic Annex III/IV match - POST /v1/cra/projects/{id}/path-select Set conformity path (validated against classification) Tenant scoping via X-Tenant-ID header (see tenant_utils.get_tenant_id). """ import json import logging import socket import ssl from datetime import date from typing import Optional from uuid import UUID import httpx from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from pydantic import BaseModel from sqlalchemy import text from database import SessionLocal from .cra_annex_i_data import ( ANNEX_I_REQUIREMENTS, MEASURES, DEADLINES, SEVERITY_WEIGHT, ) from .tenant_utils import get_tenant_id logger = logging.getLogger(__name__) router = APIRouter(prefix="/v1/cra/projects", tags=["cra"]) # ============================================================================= # CONSTANTS — Annex III/IV mapping (source: migration 059_wiki_cra_annex_i_detail.sql) # ============================================================================= ANNEX_IV_CRITICAL = [ "hardware-security-module", "hardware security module", "hsm", "smartcard-chip", "smartcard chip", "smart-meter-gateway", "smart meter gateway", "secure-element", "secure element", ] ANNEX_III_CLASS_II = [ "betriebssystem", "operating system", "hypervisor", "container-runtime", "container runtime", "public-key-infrastruktur", "public key infrastructure", "pki", "industrial-control", "industrial control", "ics", "scada", ] ANNEX_III_CLASS_I = [ "passwort-manager", "password manager", "passwort manager", "vpn-software", "vpn software", "firewall", "router", "smart-home", "smart home", "iot-sensor", "iot sensor", "iot-geraet", "siem", "siem-system", ] CLASSIFICATIONS = {"NOT_IN_SCOPE", "STANDARD", "IMPORTANT_I", "IMPORTANT_II", "CRITICAL"} CONFORMITY_PATHS = {"self_assessment", "harmonized_standard", "eucc", "notified_body"} # Allowed paths per classification (CRITICAL must use notified_body) ALLOWED_PATHS = { "NOT_IN_SCOPE": set(), "STANDARD": {"self_assessment", "harmonized_standard", "eucc", "notified_body"}, "IMPORTANT_I": {"self_assessment", "harmonized_standard", "eucc", "notified_body"}, "IMPORTANT_II": {"harmonized_standard", "eucc", "notified_body"}, "CRITICAL": {"notified_body"}, } STATUS_WHITELIST = { "draft", "scoped", "classified", "path_selected", "requirements_mapped", "evidence_pending", "gaps_open", "remediation", "ready_for_review", "declaration_ready", "post_market", "archived", } # ============================================================================= # REQUEST / RESPONSE MODELS # ============================================================================= class CreateProjectRequest(BaseModel): name: str description: str = "" gap_project_id: Optional[str] = None class UpdateProjectRequest(BaseModel): name: Optional[str] = None description: Optional[str] = None repo_url: Optional[str] = None primary_language: Optional[str] = None has_firmware: Optional[bool] = None connected_to_internet: Optional[bool] = None has_software_updates: Optional[bool] = None processes_personal_data: Optional[bool] = None is_critical_infra_supplier: Optional[bool] = None intended_use: Optional[str] = None status: Optional[str] = None class PathSelectRequest(BaseModel): conformity_path: str # ============================================================================= # HELPERS # ============================================================================= def _row_to_response(row) -> dict: """Convert a DB row to dict.""" return { "id": str(row.id), "tenant_id": row.tenant_id, "name": row.name, "description": row.description or "", "gap_project_id": str(row.gap_project_id) if row.gap_project_id else None, "repo_url": row.repo_url, "primary_language": row.primary_language, "has_firmware": bool(row.has_firmware), "connected_to_internet": bool(row.connected_to_internet), "has_software_updates": bool(row.has_software_updates), "processes_personal_data": bool(row.processes_personal_data), "is_critical_infra_supplier": bool(row.is_critical_infra_supplier), "intended_use": row.intended_use or "", "cra_classification": row.cra_classification, "classification_rationale": ( row.classification_rationale if isinstance(row.classification_rationale, list) else json.loads(row.classification_rationale or "[]") ), "conformity_path": row.conformity_path, "status": row.status, "created_at": row.created_at.isoformat() if row.created_at else "", "updated_at": row.updated_at.isoformat() if row.updated_at else "", } def _classify(intake: dict) -> tuple[str, list[str]]: """Deterministic Annex III/IV match. Returns (classification, rationale_list).""" haystack = " ".join( str(intake.get(f) or "").lower() for f in ("intended_use", "primary_language", "name", "description") ) def _match(buckets: list[str]) -> Optional[str]: for token in buckets: if token.lower() in haystack: return token return None rationale: list[str] = [] hit = _match(ANNEX_IV_CRITICAL) if hit: rationale.append(f"Annex IV (Critical): Treffer auf '{hit}'") return "CRITICAL", rationale hit = _match(ANNEX_III_CLASS_II) if hit: rationale.append(f"Annex III Klasse II (Important_II): Treffer auf '{hit}'") return "IMPORTANT_II", rationale hit = _match(ANNEX_III_CLASS_I) if hit: rationale.append(f"Annex III Klasse I (Important_I): Treffer auf '{hit}'") return "IMPORTANT_I", rationale flags = [ ("connected_to_internet", "Produkt ist mit dem Internet verbunden"), ("has_software_updates", "Produkt hat Software-Updates (digitales Element)"), ("processes_personal_data", "Produkt verarbeitet personenbezogene Daten"), ("is_critical_infra_supplier", "Produkt wird in kritischer Infrastruktur eingesetzt"), ] for field, msg in flags: if intake.get(field): rationale.append(msg) if rationale: return "STANDARD", rationale rationale.append("Kein digitales Element und keine Annex III/IV-Kategorie erkannt") return "NOT_IN_SCOPE", rationale # ============================================================================= # ENDPOINTS # ============================================================================= @router.get("") async def list_projects( tenant_id: str = Depends(get_tenant_id), include_archived: bool = False, ): """List all CRA projects for the tenant.""" db = SessionLocal() try: if include_archived: sql = """ SELECT * FROM compliance_cra_projects WHERE tenant_id = :tenant_id ORDER BY created_at DESC """ else: sql = """ SELECT * FROM compliance_cra_projects WHERE tenant_id = :tenant_id AND status != 'archived' ORDER BY created_at DESC """ rows = db.execute(text(sql), {"tenant_id": tenant_id}).fetchall() return { "projects": [_row_to_response(row) for row in rows], "total": len(rows), } finally: db.close() @router.post("", status_code=201) async def create_project( body: CreateProjectRequest, tenant_id: str = Depends(get_tenant_id), ): """Create a new CRA project. Optionally pre-populate intake from a gap_project.""" db = SessionLocal() try: gap_intake: dict = {} if body.gap_project_id: try: gap_uuid = UUID(body.gap_project_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid gap_project_id (must be UUID)") gap_row = db.execute( text(""" SELECT connected_to_internet, has_software_updates, processes_personal_data, is_critical_infra_supplier, description FROM gap_projects WHERE id = :gid AND tenant_id = CAST(:tid AS uuid) """), {"gid": str(gap_uuid), "tid": tenant_id}, ).fetchone() if gap_row: gap_intake = { "connected_to_internet": bool(gap_row.connected_to_internet), "has_software_updates": bool(gap_row.has_software_updates), "processes_personal_data": bool(gap_row.processes_personal_data), "is_critical_infra_supplier": bool(gap_row.is_critical_infra_supplier), "intended_use": gap_row.description or "", } result = db.execute( text(""" INSERT INTO compliance_cra_projects (tenant_id, name, description, gap_project_id, connected_to_internet, has_software_updates, processes_personal_data, is_critical_infra_supplier, intended_use, status) VALUES (:tenant_id, :name, :description, :gap_project_id, :connected_to_internet, :has_software_updates, :processes_personal_data, :is_critical_infra_supplier, :intended_use, 'draft') RETURNING * """), { "tenant_id": tenant_id, "name": body.name, "description": body.description, "gap_project_id": body.gap_project_id, "connected_to_internet": gap_intake.get("connected_to_internet", False), "has_software_updates": gap_intake.get("has_software_updates", False), "processes_personal_data": gap_intake.get("processes_personal_data", False), "is_critical_infra_supplier": gap_intake.get("is_critical_infra_supplier", False), "intended_use": gap_intake.get("intended_use", ""), }, ) row = result.fetchone() db.commit() logger.info("Created CRA project %s for tenant %s", row.id, tenant_id) return _row_to_response(row) except HTTPException: raise except Exception: db.rollback() raise finally: db.close() @router.get("/{project_id}") async def get_project( project_id: str, tenant_id: str = Depends(get_tenant_id), ): """Get a single CRA project (tenant-scoped).""" db = SessionLocal() try: row = db.execute( text(""" SELECT * FROM compliance_cra_projects WHERE id = :pid AND tenant_id = :tid """), {"pid": project_id, "tid": tenant_id}, ).fetchone() if not row: raise HTTPException(status_code=404, detail="CRA project not found") return _row_to_response(row) finally: db.close() @router.patch("/{project_id}") async def update_project( project_id: str, body: UpdateProjectRequest, tenant_id: str = Depends(get_tenant_id), ): """Update intake / status fields. Status is whitelist-validated (no transitions).""" db = SessionLocal() try: updates: dict = {"pid": project_id, "tid": tenant_id} set_parts = ["updated_at = NOW()"] for field in ( "name", "description", "repo_url", "primary_language", "has_firmware", "connected_to_internet", "has_software_updates", "processes_personal_data", "is_critical_infra_supplier", "intended_use", "status", ): val = getattr(body, field) if val is None: continue if field == "status" and val not in STATUS_WHITELIST: raise HTTPException( status_code=400, detail=f"Invalid status '{val}'. Allowed: {sorted(STATUS_WHITELIST)}", ) set_parts.append(f"{field} = :{field}") updates[field] = val if len(set_parts) == 1: raise HTTPException(status_code=400, detail="No fields to update") result = db.execute( text(f""" UPDATE compliance_cra_projects SET {', '.join(set_parts)} WHERE id = :pid AND tenant_id = :tid RETURNING * """), updates, ) row = result.fetchone() if not row: raise HTTPException(status_code=404, detail="CRA project not found") db.commit() return _row_to_response(row) except HTTPException: raise except Exception: db.rollback() raise finally: db.close() @router.delete("/{project_id}") async def archive_project( project_id: str, tenant_id: str = Depends(get_tenant_id), ): """Soft-archive a CRA project.""" db = SessionLocal() try: result = db.execute( text(""" UPDATE compliance_cra_projects SET status = 'archived', updated_at = NOW() WHERE id = :pid AND tenant_id = :tid AND status != 'archived' RETURNING id """), {"pid": project_id, "tid": tenant_id}, ) row = result.fetchone() if not row: raise HTTPException(status_code=404, detail="Project not found or already archived") db.commit() return {"success": True, "id": str(row.id), "status": "archived"} except HTTPException: raise except Exception: db.rollback() raise finally: db.close() @router.post("/{project_id}/scope-check") async def scope_check( project_id: str, tenant_id: str = Depends(get_tenant_id), ): """Run deterministic Annex III/IV classification on the project's intake fields. Writes cra_classification + classification_rationale + status update. """ db = SessionLocal() try: row = db.execute( text(""" SELECT * FROM compliance_cra_projects WHERE id = :pid AND tenant_id = :tid """), {"pid": project_id, "tid": tenant_id}, ).fetchone() if not row: raise HTTPException(status_code=404, detail="CRA project not found") intake = { "name": row.name, "description": row.description, "intended_use": row.intended_use, "primary_language": row.primary_language, "connected_to_internet": row.connected_to_internet, "has_software_updates": row.has_software_updates, "processes_personal_data": row.processes_personal_data, "is_critical_infra_supplier": row.is_critical_infra_supplier, } classification, rationale = _classify(intake) new_status = "scoped" if classification == "NOT_IN_SCOPE" else "classified" updated = db.execute( text(""" UPDATE compliance_cra_projects SET cra_classification = :cls, classification_rationale = CAST(:rat AS jsonb), status = :status, updated_at = NOW() WHERE id = :pid AND tenant_id = :tid RETURNING * """), { "pid": project_id, "tid": tenant_id, "cls": classification, "rat": json.dumps(rationale, ensure_ascii=False), "status": new_status, }, ).fetchone() db.commit() logger.info( "Scope-check %s for tenant %s -> %s", project_id, tenant_id, classification, ) return _row_to_response(updated) except HTTPException: raise except Exception: db.rollback() raise finally: db.close() @router.get("/{project_id}/requirements") async def list_requirements( project_id: str, tenant_id: str = Depends(get_tenant_id), ): """Liste der 40 Annex-I-Requirements mit per-Projekt-Status. Phase-2-Logik: Status default 'unbewertet' (kein evidence_check linked). Phase-3 wird per-req Status aus compliance_evidence_check_results berechnen. """ db = SessionLocal() try: row = db.execute( text(""" SELECT id, cra_classification FROM compliance_cra_projects WHERE id = :pid AND tenant_id = :tid """), {"pid": project_id, "tid": tenant_id}, ).fetchone() if not row: raise HTTPException(status_code=404, detail="CRA project not found") items = [] for req in ANNEX_I_REQUIREMENTS: items.append({ **req, "status": "unbewertet", "mapped_measure_names": [ {"id": m, "name": MEASURES.get(m, m)} for m in req["mapped_measures"] ], }) return { "project_id": project_id, "classification": row.cra_classification, "total": len(items), "items": items, } finally: db.close() @router.get("/{project_id}/backlog") async def get_backlog( project_id: str, tenant_id: str = Depends(get_tenant_id), ): """Priorisierte TODO-Liste — gleiche Daten wie /requirements, aber sortiert. Sortierung: severity_weight (desc), effort_days (asc), n (asc). Deadline-Druck: Tage bis 11.12.2027 (CRA-CE-Pflicht). """ db = SessionLocal() try: row = db.execute( text(""" SELECT id, cra_classification FROM compliance_cra_projects WHERE id = :pid AND tenant_id = :tid """), {"pid": project_id, "tid": tenant_id}, ).fetchone() if not row: raise HTTPException(status_code=404, detail="CRA project not found") ce_deadline = date(2027, 12, 11) today = date.today() days_to_ce = max(0, (ce_deadline - today).days) max_days = 365 * 3 # 3 Jahre als Skalierungsbasis items = [] for req in ANNEX_I_REQUIREMENTS: severity_w = SEVERITY_WEIGHT.get(req["severity"], 0) deadline_pressure = 1.0 - min(days_to_ce / max_days, 1.0) # 0..1 effort_factor = 1.0 / max(req["effort_days"], 1) priority_score = ( severity_w * (1.0 + 0.5 * deadline_pressure) * (1.0 + 0.1 * effort_factor) ) items.append({ "req_id": req["req_id"], "title": req["title"], "category": req["category"], "severity": req["severity"], "annex_anchor": req["annex_anchor"], "description": req["description"], "effort_days": req["effort_days"], "mapped_measure_names": [ {"id": m, "name": MEASURES.get(m, m)} for m in req["mapped_measures"] ], "status": "unbewertet", "priority_score": round(priority_score, 1), }) items.sort(key=lambda x: (-x["priority_score"], x["effort_days"])) for idx, it in enumerate(items, 1): it["rank"] = idx return { "project_id": project_id, "classification": row.cra_classification, "days_to_ce_deadline": days_to_ce, "deadlines": DEADLINES, "total": len(items), "items": items, } finally: db.close() @router.post("/{project_id}/path-select") async def path_select( project_id: str, body: PathSelectRequest, tenant_id: str = Depends(get_tenant_id), ): """Set conformity_path. Validates path is allowed for the project's classification.""" if body.conformity_path not in CONFORMITY_PATHS: raise HTTPException( status_code=400, detail=f"Invalid conformity_path. Allowed: {sorted(CONFORMITY_PATHS)}", ) db = SessionLocal() try: row = db.execute( text(""" SELECT cra_classification FROM compliance_cra_projects WHERE id = :pid AND tenant_id = :tid """), {"pid": project_id, "tid": tenant_id}, ).fetchone() if not row: raise HTTPException(status_code=404, detail="CRA project not found") if not row.cra_classification: raise HTTPException( status_code=400, detail="Run scope-check before selecting a conformity path", ) allowed = ALLOWED_PATHS.get(row.cra_classification, set()) if body.conformity_path not in allowed: raise HTTPException( status_code=400, detail=( f"Path '{body.conformity_path}' not allowed for classification " f"'{row.cra_classification}'. Allowed: {sorted(allowed)}" ), ) updated = db.execute( text(""" UPDATE compliance_cra_projects SET conformity_path = :path, status = 'path_selected', updated_at = NOW() WHERE id = :pid AND tenant_id = :tid RETURNING * """), {"pid": project_id, "tid": tenant_id, "path": body.conformity_path}, ).fetchone() db.commit() return _row_to_response(updated) except HTTPException: raise except Exception: db.rollback() raise finally: db.close() # ============================================================================= # PHASE 3: SBOM Upload + Automated Checks # ============================================================================= DEFAULT_CHECKS = [ { "check_code": "cra_security_txt", "title": "security.txt (RFC 9116)", "description": "Pruefung ob /.well-known/security.txt existiert und Contact-Eintrag enthaelt", "check_type": "url_probe", "linked_req_ids": ["CRA-AI-35"], # CVD }, { "check_code": "cra_vuln_disclosure_url", "title": "Vulnerability-Disclosure-Policy-URL", "description": "Manuelle Pflege: URL zur CVD-Policy. Wird in DoC referenziert.", "check_type": "manual_review", "linked_req_ids": ["CRA-AI-35"], }, { "check_code": "cra_update_policy_url", "title": "Update-Policy-URL", "description": "URL zur dokumentierten Patch-/Update-Policy (Lifecycle, SLAs).", "check_type": "manual_review", "linked_req_ids": ["CRA-AI-28", "CRA-AI-31", "CRA-AI-34"], }, { "check_code": "cra_signed_updates", "title": "Signierte Updates", "description": "Bestaetigung dass Updates digital signiert ausgeliefert werden.", "check_type": "manual_review", "linked_req_ids": ["CRA-AI-29", "CRA-AI-30"], }, { "check_code": "cra_default_password_check", "title": "Keine Default-Passwoerter", "description": "Bestaetigung dass das Produkt keine Default-Passwoerter ausliefert.", "check_type": "manual_review", "linked_req_ids": ["CRA-AI-8"], }, { "check_code": "cra_tls_cert_check", "title": "TLS-Zertifikat des Produkt-Endpoints", "description": "Pruefung ob TLS 1.2+ und gueltiges Zertifikat.", "check_type": "tls_probe", "linked_req_ids": ["CRA-AI-15"], }, ] class CheckRunRequest(BaseModel): target_url: Optional[str] = None def _cra_project_exists(db, project_id: str, tenant_id: str) -> bool: row = db.execute( text(""" SELECT 1 FROM compliance_cra_projects WHERE id = :pid AND tenant_id = :tid """), {"pid": project_id, "tid": tenant_id}, ).fetchone() return bool(row) @router.post("/{project_id}/sbom/upload") async def upload_sbom( project_id: str, file: UploadFile = File(...), tenant_id: str = Depends(get_tenant_id), ): """Upload CycloneDX or SPDX SBOM (JSON). Persists in compliance_cra_sboms.""" db = SessionLocal() try: if not _cra_project_exists(db, project_id, tenant_id): raise HTTPException(status_code=404, detail="CRA project not found") raw = await file.read() try: data = json.loads(raw) except json.JSONDecodeError as e: raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}") # Format detection fmt = None spec_version = None component_count = 0 summary: dict = {} if isinstance(data, dict) and data.get("bomFormat") == "CycloneDX": fmt = "cyclonedx" spec_version = data.get("specVersion") or data.get("version") components = data.get("components") or [] component_count = len(components) summary = { "bomFormat": "CycloneDX", "specVersion": spec_version, "serialNumber": data.get("serialNumber"), "metadata_component": (data.get("metadata") or {}).get("component", {}), "sample_components": components[:5], } elif isinstance(data, dict) and data.get("spdxVersion"): fmt = "spdx" spec_version = data["spdxVersion"] packages = data.get("packages") or [] component_count = len(packages) summary = { "spdxVersion": spec_version, "name": data.get("name"), "sample_packages": [{"name": p.get("name"), "version": p.get("versionInfo")} for p in packages[:5]], } else: raise HTTPException( status_code=400, detail="Unrecognized SBOM format. Expected CycloneDX (bomFormat='CycloneDX') or SPDX (spdxVersion present).", ) result = db.execute( text(""" INSERT INTO compliance_cra_sboms (cra_project_id, tenant_id, filename, format, spec_version, component_count, raw_content, summary, scan_status) VALUES (:pid, :tid, :fn, :fmt, :ver, :cnt, CAST(:raw AS jsonb), CAST(:sum AS jsonb), 'pending') RETURNING id, uploaded_at """), { "pid": project_id, "tid": tenant_id, "fn": file.filename or "unknown.json", "fmt": fmt, "ver": spec_version, "cnt": component_count, "raw": json.dumps(data), "sum": json.dumps(summary), }, ).fetchone() db.commit() return { "id": str(result.id), "uploaded_at": result.uploaded_at.isoformat(), "filename": file.filename, "format": fmt, "spec_version": spec_version, "component_count": component_count, "summary": summary, "scan_status": "pending", "note": "osv.dev scan wird durch separates Tool im Team durchgefuehrt.", } except HTTPException: raise except Exception: db.rollback() raise finally: db.close() @router.get("/{project_id}/sboms") async def list_sboms( project_id: str, tenant_id: str = Depends(get_tenant_id), ): """List all SBOM uploads for this project, newest first.""" db = SessionLocal() try: if not _cra_project_exists(db, project_id, tenant_id): raise HTTPException(status_code=404, detail="CRA project not found") rows = db.execute( text(""" SELECT id, filename, format, spec_version, component_count, summary, scan_status, scan_summary, uploaded_at, scanned_at FROM compliance_cra_sboms WHERE cra_project_id = :pid AND tenant_id = :tid ORDER BY uploaded_at DESC """), {"pid": project_id, "tid": tenant_id}, ).fetchall() items = [] for r in rows: items.append({ "id": str(r.id), "filename": r.filename, "format": r.format, "spec_version": r.spec_version, "component_count": r.component_count, "summary": r.summary if isinstance(r.summary, dict) else json.loads(r.summary or "{}"), "scan_status": r.scan_status, "scan_summary": r.scan_summary if isinstance(r.scan_summary, dict) else json.loads(r.scan_summary or "{}"), "uploaded_at": r.uploaded_at.isoformat() if r.uploaded_at else None, "scanned_at": r.scanned_at.isoformat() if r.scanned_at else None, }) return {"project_id": project_id, "total": len(items), "items": items} finally: db.close() @router.get("/{project_id}/checks") async def list_checks( project_id: str, tenant_id: str = Depends(get_tenant_id), ): """List configured checks + their latest run result.""" db = SessionLocal() try: if not _cra_project_exists(db, project_id, tenant_id): raise HTTPException(status_code=404, detail="CRA project not found") rows = db.execute( text(""" SELECT id, check_code, title, description, check_type, target_url, linked_control_ids, last_run_at, is_active FROM compliance_evidence_checks WHERE tenant_id = CAST(:tid AS uuid) AND project_id = :pid_uuid AND check_code LIKE 'cra_%' ORDER BY check_code """), {"tid": tenant_id, "pid_uuid": project_id}, ).fetchall() # Latest result per check results: dict = {} if rows: ids = [str(r.id) for r in rows] for r_row in db.execute( text(""" SELECT check_id, status, message, ran_at FROM compliance_evidence_check_results WHERE check_id::text = ANY(:ids) ORDER BY ran_at DESC """), {"ids": ids}, ).fetchall(): cid = str(r_row.check_id) if cid not in results: results[cid] = { "status": r_row.status, "message": r_row.message, "ran_at": r_row.ran_at.isoformat() if r_row.ran_at else None, } items = [] for r in rows: linked = r.linked_control_ids if isinstance(linked, str): try: linked = json.loads(linked) except Exception: linked = [] items.append({ "id": str(r.id), "check_code": r.check_code, "title": r.title, "description": r.description, "check_type": r.check_type, "target_url": r.target_url, "linked_req_ids": linked or [], "last_run_at": r.last_run_at.isoformat() if r.last_run_at else None, "is_active": bool(r.is_active), "latest_result": results.get(str(r.id)), }) return {"project_id": project_id, "total": len(items), "items": items} finally: db.close() @router.post("/{project_id}/checks/init") async def init_checks( project_id: str, tenant_id: str = Depends(get_tenant_id), ): """Create the default set of CRA checks for this project (idempotent).""" db = SessionLocal() try: if not _cra_project_exists(db, project_id, tenant_id): raise HTTPException(status_code=404, detail="CRA project not found") existing = db.execute( text(""" SELECT check_code FROM compliance_evidence_checks WHERE tenant_id = CAST(:tid AS uuid) AND project_id = :pid AND check_code LIKE 'cra_%' """), {"tid": tenant_id, "pid": project_id}, ).fetchall() existing_codes = {r.check_code for r in existing} created = 0 for c in DEFAULT_CHECKS: if c["check_code"] in existing_codes: continue db.execute( text(""" INSERT INTO compliance_evidence_checks (tenant_id, project_id, check_code, title, description, check_type, linked_control_ids, frequency, is_active) VALUES (CAST(:tid AS uuid), :pid, :code, :title, :desc, :ctype, CAST(:linked AS jsonb), 'monthly', true) """), { "tid": tenant_id, "pid": project_id, "code": c["check_code"], "title": c["title"], "desc": c["description"], "ctype": c["check_type"], "linked": json.dumps(c["linked_req_ids"]), }, ) created += 1 db.commit() return {"created": created, "already_present": len(existing_codes)} except HTTPException: raise except Exception: db.rollback() raise finally: db.close() @router.post("/checks/{check_id}/run") async def run_check( check_id: str, body: CheckRunRequest, tenant_id: str = Depends(get_tenant_id), ): """Manually run a single check. Currently implements: - cra_security_txt: HTTP GET, looks for 'Contact:' field - cra_tls_cert_check: TLS handshake + cert info - others: returns 'manual_review_required' status """ db = SessionLocal() try: check = db.execute( text(""" SELECT id, check_code, target_url FROM compliance_evidence_checks WHERE id = CAST(:cid AS uuid) AND tenant_id = CAST(:tid AS uuid) """), {"cid": check_id, "tid": tenant_id}, ).fetchone() if not check: raise HTTPException(status_code=404, detail="Check not found") url = body.target_url or check.target_url if url and url != check.target_url: db.execute( text("UPDATE compliance_evidence_checks SET target_url = :u WHERE id = CAST(:cid AS uuid)"), {"u": url, "cid": check_id}, ) status = "manual_review_required" message = "Dieser Check-Typ erfordert manuelle Pruefung." if check.check_code == "cra_security_txt" and url: base = url.rstrip("/") probe_url = f"{base}/.well-known/security.txt" try: async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client: resp = await client.get(probe_url) if resp.status_code != 200: status = "fail" message = f"GET {probe_url} -> HTTP {resp.status_code}" else: has_contact = any( line.strip().lower().startswith("contact:") for line in resp.text.splitlines() ) if has_contact: status = "pass" message = f"security.txt vorhanden mit Contact-Eintrag ({len(resp.text)} bytes)" else: status = "fail" message = "security.txt vorhanden, aber KEIN 'Contact:'-Eintrag" except Exception as e: status = "fail" message = f"Fetch error: {e}" elif check.check_code == "cra_tls_cert_check" and url: try: host = url.replace("https://", "").replace("http://", "").split("/")[0].split(":")[0] ctx = ssl.create_default_context() with socket.create_connection((host, 443), timeout=10) as sock: with ctx.wrap_socket(sock, server_hostname=host) as ssock: cert = ssock.getpeercert() proto = ssock.version() if proto and proto >= "TLSv1.2": status = "pass" message = f"TLS {proto} ok. Issuer: {dict(x[0] for x in cert.get('issuer', [])).get('organizationName', 'unknown')}" else: status = "fail" message = f"TLS-Version zu alt: {proto}" except Exception as e: status = "fail" message = f"TLS error: {e}" # Persist result db.execute( text(""" INSERT INTO compliance_evidence_check_results (check_id, tenant_id, project_id, status, message, ran_at) VALUES (CAST(:cid AS uuid), CAST(:tid AS uuid), (SELECT project_id FROM compliance_evidence_checks WHERE id = CAST(:cid AS uuid)), :status, :msg, NOW()) """), {"cid": check_id, "tid": tenant_id, "status": status, "msg": message}, ) db.execute( text(""" UPDATE compliance_evidence_checks SET last_run_at = NOW() WHERE id = CAST(:cid AS uuid) """), {"cid": check_id}, ) db.commit() return { "check_id": check_id, "check_code": check.check_code, "status": status, "message": message, "target_url": url, } except HTTPException: raise except Exception: db.rollback() raise finally: db.close() # ============================================================================= # PHASE 4: Vulnerability Disclosure + Post-Market Monitoring # ============================================================================= VULN_STATUS_WHITELIST = {"reported", "triaged", "patched", "disclosed", "withdrawn"} class CreateVulnRequest(BaseModel): title: str description: str = "" cve_id: Optional[str] = None severity: Optional[str] = None # LOW | MEDIUM | HIGH | CRITICAL cvss_score: Optional[float] = None affected_components: list[str] = [] reporter_source: str = "internal" reporter_contact: Optional[str] = None notes: str = "" class UpdateVulnRequest(BaseModel): title: Optional[str] = None description: Optional[str] = None cve_id: Optional[str] = None severity: Optional[str] = None cvss_score: Optional[float] = None affected_components: Optional[list[str]] = None reporter_source: Optional[str] = None reporter_contact: Optional[str] = None notes: Optional[str] = None status: Optional[str] = None embargo_until: Optional[str] = None # ISO datetime # Lifecycle timestamps — clients can set these explicitly or via status transition triaged_at: Optional[str] = None patched_at: Optional[str] = None disclosed_at: Optional[str] = None reported_to_enisa_at: Optional[str] = None detailed_report_at: Optional[str] = None def _vuln_to_dict(row) -> dict: def _iso(v): return v.isoformat() if v else None components = row.affected_components if isinstance(components, str): try: components = json.loads(components) except Exception: components = [] return { "id": str(row.id), "cra_project_id": str(row.cra_project_id), "cve_id": row.cve_id, "title": row.title, "description": row.description or "", "severity": row.severity, "cvss_score": float(row.cvss_score) if row.cvss_score is not None else None, "affected_components": components or [], "reporter_source": row.reporter_source or "internal", "reporter_contact": row.reporter_contact, "discovered_at": _iso(row.discovered_at), "triaged_at": _iso(row.triaged_at), "patched_at": _iso(row.patched_at), "disclosed_at": _iso(row.disclosed_at), "embargo_until": _iso(row.embargo_until), "reported_to_enisa_at": _iso(row.reported_to_enisa_at), "detailed_report_at": _iso(row.detailed_report_at), "status": row.status, "notes": row.notes or "", "created_at": _iso(row.created_at), "updated_at": _iso(row.updated_at), } @router.post("/{project_id}/vulnerabilities", status_code=201) async def create_vulnerability( project_id: str, body: CreateVulnRequest, tenant_id: str = Depends(get_tenant_id), ): """Create a vulnerability record. discovered_at = now().""" db = SessionLocal() try: if not _cra_project_exists(db, project_id, tenant_id): raise HTTPException(status_code=404, detail="CRA project not found") if body.severity and body.severity not in ("LOW", "MEDIUM", "HIGH", "CRITICAL"): raise HTTPException(status_code=400, detail="Invalid severity") if body.cvss_score is not None and not (0.0 <= body.cvss_score <= 10.0): raise HTTPException(status_code=400, detail="cvss_score must be 0.0-10.0") row = db.execute( text(""" INSERT INTO compliance_cra_vulnerabilities (cra_project_id, tenant_id, cve_id, title, description, severity, cvss_score, affected_components, reporter_source, reporter_contact, notes) VALUES (:pid, :tid, :cve, :title, :desc, :sev, :cvss, CAST(:comp AS jsonb), :src, :rcontact, :notes) RETURNING * """), { "pid": project_id, "tid": tenant_id, "cve": body.cve_id, "title": body.title, "desc": body.description, "sev": body.severity, "cvss": body.cvss_score, "comp": json.dumps(body.affected_components), "src": body.reporter_source, "rcontact": body.reporter_contact, "notes": body.notes, }, ).fetchone() db.commit() return _vuln_to_dict(row) except HTTPException: raise except Exception: db.rollback() raise finally: db.close() @router.get("/{project_id}/vulnerabilities") async def list_vulnerabilities( project_id: str, tenant_id: str = Depends(get_tenant_id), ): """List all vulnerabilities for this project, newest first.""" db = SessionLocal() try: if not _cra_project_exists(db, project_id, tenant_id): raise HTTPException(status_code=404, detail="CRA project not found") rows = db.execute( text(""" SELECT * FROM compliance_cra_vulnerabilities WHERE cra_project_id = :pid AND tenant_id = :tid ORDER BY discovered_at DESC """), {"pid": project_id, "tid": tenant_id}, ).fetchall() items = [_vuln_to_dict(r) for r in rows] # Compliance summary critical_open = sum(1 for v in items if v["severity"] == "CRITICAL" and v["status"] in ("reported", "triaged")) breached_24h = 0 breached_72h = 0 from datetime import datetime, timezone now = datetime.now(timezone.utc) for v in items: if not v["discovered_at"]: continue disc = datetime.fromisoformat(v["discovered_at"]) age_hours = (now - disc).total_seconds() / 3600 if age_hours > 24 and not v["reported_to_enisa_at"]: breached_24h += 1 if age_hours > 72 and not v["detailed_report_at"]: breached_72h += 1 return { "project_id": project_id, "total": len(items), "summary": { "critical_open": critical_open, "breached_24h_reporting": breached_24h, "breached_72h_reporting": breached_72h, "by_status": {s: sum(1 for v in items if v["status"] == s) for s in VULN_STATUS_WHITELIST}, }, "items": items, } finally: db.close() @router.patch("/vulnerabilities/{vuln_id}") async def update_vulnerability( vuln_id: str, body: UpdateVulnRequest, tenant_id: str = Depends(get_tenant_id), ): """Update vuln fields incl. status transition + lifecycle timestamps.""" from datetime import datetime db = SessionLocal() try: updates: dict = {"vid": vuln_id, "tid": tenant_id} set_parts = ["updated_at = NOW()"] for field in ( "title", "description", "cve_id", "severity", "cvss_score", "reporter_source", "reporter_contact", "notes", "status", ): val = getattr(body, field) if val is None: continue if field == "status" and val not in VULN_STATUS_WHITELIST: raise HTTPException(status_code=400, detail=f"Invalid status. Allowed: {sorted(VULN_STATUS_WHITELIST)}") if field == "severity" and val not in ("LOW", "MEDIUM", "HIGH", "CRITICAL"): raise HTTPException(status_code=400, detail="Invalid severity") if field == "cvss_score" and not (0.0 <= float(val) <= 10.0): raise HTTPException(status_code=400, detail="cvss_score must be 0.0-10.0") set_parts.append(f"{field} = :{field}") updates[field] = val if body.affected_components is not None: set_parts.append("affected_components = CAST(:comp AS jsonb)") updates["comp"] = json.dumps(body.affected_components) # Auto-set timestamps on status transitions # If client passes status='triaged' and triaged_at is None, set to NOW() if body.status == "triaged" and not body.triaged_at: set_parts.append("triaged_at = COALESCE(triaged_at, NOW())") if body.status == "patched" and not body.patched_at: set_parts.append("patched_at = COALESCE(patched_at, NOW())") if body.status == "disclosed" and not body.disclosed_at: set_parts.append("disclosed_at = COALESCE(disclosed_at, NOW())") for ts_field in ("triaged_at", "patched_at", "disclosed_at", "reported_to_enisa_at", "detailed_report_at", "embargo_until"): val = getattr(body, ts_field) if val is None: continue try: datetime.fromisoformat(val.replace("Z", "+00:00")) except ValueError: raise HTTPException(status_code=400, detail=f"Invalid ISO datetime for {ts_field}") set_parts.append(f"{ts_field} = :{ts_field}") updates[ts_field] = val if len(set_parts) == 1: raise HTTPException(status_code=400, detail="No fields to update") row = db.execute( text(f""" UPDATE compliance_cra_vulnerabilities SET {', '.join(set_parts)} WHERE id = :vid AND tenant_id = :tid RETURNING * """), updates, ).fetchone() if not row: raise HTTPException(status_code=404, detail="Vulnerability not found") db.commit() return _vuln_to_dict(row) except HTTPException: raise except Exception: db.rollback() raise finally: db.close() @router.delete("/vulnerabilities/{vuln_id}") async def delete_vulnerability( vuln_id: str, tenant_id: str = Depends(get_tenant_id), ): """Mark vulnerability as withdrawn (soft delete).""" db = SessionLocal() try: row = db.execute( text(""" UPDATE compliance_cra_vulnerabilities SET status = 'withdrawn', updated_at = NOW() WHERE id = :vid AND tenant_id = :tid AND status != 'withdrawn' RETURNING id """), {"vid": vuln_id, "tid": tenant_id}, ).fetchone() if not row: raise HTTPException(status_code=404, detail="Vulnerability not found or already withdrawn") db.commit() return {"success": True, "id": str(row.id), "status": "withdrawn"} except HTTPException: raise except Exception: db.rollback() raise finally: db.close() @router.get("/{project_id}/monitoring") async def post_market_monitoring( project_id: str, tenant_id: str = Depends(get_tenant_id), ): """Combined Post-Market view: CRA timeline + vuln summary + checklist progress.""" from datetime import datetime, timezone db = SessionLocal() try: if not _cra_project_exists(db, project_id, tenant_id): raise HTTPException(status_code=404, detail="CRA project not found") vulns = db.execute( text(""" SELECT id, status, severity, discovered_at, reported_to_enisa_at, detailed_report_at FROM compliance_cra_vulnerabilities WHERE cra_project_id = :pid AND tenant_id = :tid AND status != 'withdrawn' """), {"pid": project_id, "tid": tenant_id}, ).fetchall() sbom_count = db.execute( text("SELECT count(*) FROM compliance_cra_sboms WHERE cra_project_id = :pid AND tenant_id = :tid"), {"pid": project_id, "tid": tenant_id}, ).scalar() checks_count = db.execute( text(""" SELECT count(*) FROM compliance_evidence_checks WHERE tenant_id = CAST(:tid AS uuid) AND project_id = :pid AND check_code LIKE 'cra_%' """), {"tid": tenant_id, "pid": project_id}, ).scalar() now = datetime.now(timezone.utc) breached_24h = 0 breached_72h = 0 for v in vulns: if not v.discovered_at: continue age = (now - v.discovered_at).total_seconds() / 3600 if age > 24 and not v.reported_to_enisa_at: breached_24h += 1 if age > 72 and not v.detailed_report_at: breached_72h += 1 checklist = [ {"item": "SBOM hochgeladen", "done": (sbom_count or 0) > 0, "href_suffix": "sbom"}, {"item": "Automatisierte Checks konfiguriert", "done": (checks_count or 0) > 0, "href_suffix": "checks"}, {"item": "Vulnerability-Tracking aktiv", "done": len(vulns) > 0, "href_suffix": "vuln"}, {"item": "Keine 24h-Reporting-Pflichten ueberzogen", "done": breached_24h == 0, "href_suffix": "vuln"}, {"item": "Keine 72h-Reporting-Pflichten ueberzogen", "done": breached_72h == 0, "href_suffix": "vuln"}, ] return { "project_id": project_id, "deadlines": DEADLINES, "summary": { "active_vulns": len(vulns), "critical_vulns": sum(1 for v in vulns if v.severity == "CRITICAL"), "high_vulns": sum(1 for v in vulns if v.severity == "HIGH"), "breached_24h_reporting": breached_24h, "breached_72h_reporting": breached_72h, "sbom_versions": sbom_count or 0, "configured_checks": checks_count or 0, }, "post_market_checklist": checklist, } finally: db.close() # ============================================================================= # PHASE 5: Document Generation (DoC, Technical Doc, CVD Policy, Update Policy, SBOM Report) # ============================================================================= from .cra_doc_templates import DOC_GENERATORS, DOC_TYPE_LABELS # noqa: E402 DOC_STATUS_WHITELIST = {"draft", "reviewed", "approved", "superseded"} class GenerateDocRequest(BaseModel): doc_type: str manufacturer: Optional[str] = None notified_body: Optional[str] = None security_contact: Optional[str] = None class ApproveDocRequest(BaseModel): signed_by: str status: str = "approved" def _doc_row_to_dict(row) -> dict: return { "id": str(row.id), "cra_project_id": str(row.cra_project_id), "doc_type": row.doc_type, "doc_type_label": DOC_TYPE_LABELS.get(row.doc_type, row.doc_type), "title": row.title, "content_md": row.content_md, "version": row.version, "requirements_coverage": ( row.requirements_coverage if isinstance(row.requirements_coverage, dict) else json.loads(row.requirements_coverage or "{}") ), "status": row.status, "signed_by": row.signed_by, "signed_at": row.signed_at.isoformat() if row.signed_at else None, "generated_at": row.generated_at.isoformat() if row.generated_at else None, "superseded_at": row.superseded_at.isoformat() if row.superseded_at else None, } @router.post("/{project_id}/documents/generate", status_code=201) async def generate_document( project_id: str, body: GenerateDocRequest, tenant_id: str = Depends(get_tenant_id), ): """Generate a document of the given type from current project state.""" if body.doc_type not in DOC_GENERATORS: raise HTTPException( status_code=400, detail=f"Unknown doc_type. Allowed: {sorted(DOC_GENERATORS.keys())}", ) db = SessionLocal() try: proj_row = db.execute( text(""" SELECT * FROM compliance_cra_projects WHERE id = :pid AND tenant_id = :tid """), {"pid": project_id, "tid": tenant_id}, ).fetchone() if not proj_row: raise HTTPException(status_code=404, detail="CRA project not found") project = _row_to_response(proj_row) # For SBOM report: fetch latest SBOM latest_sbom = None if body.doc_type == "doc_sbom_report": sbom_row = db.execute( text(""" SELECT id, filename, format, spec_version, component_count, summary, scan_status, uploaded_at FROM compliance_cra_sboms WHERE cra_project_id = :pid AND tenant_id = :tid ORDER BY uploaded_at DESC LIMIT 1 """), {"pid": project_id, "tid": tenant_id}, ).fetchone() if sbom_row: latest_sbom = { "filename": sbom_row.filename, "format": sbom_row.format, "spec_version": sbom_row.spec_version, "component_count": sbom_row.component_count, "summary": sbom_row.summary if isinstance(sbom_row.summary, dict) else json.loads(sbom_row.summary or "{}"), "scan_status": sbom_row.scan_status, "uploaded_at": sbom_row.uploaded_at.isoformat() if sbom_row.uploaded_at else None, } # Invoke generator gen = DOC_GENERATORS[body.doc_type] kwargs: dict = {} if body.doc_type == "doc_eu_conformity": kwargs = {"manufacturer": body.manufacturer, "notified_body": body.notified_body} elif body.doc_type == "doc_cvd_policy": kwargs = {"security_contact": body.security_contact} elif body.doc_type == "doc_sbom_report": kwargs = {"latest_sbom": latest_sbom} title, content, coverage = gen(project, **kwargs) # Supersede previous versions of this doc_type db.execute( text(""" UPDATE compliance_cra_documents SET status = 'superseded', superseded_at = NOW() WHERE cra_project_id = :pid AND tenant_id = :tid AND doc_type = :dtype AND status != 'superseded' """), {"pid": project_id, "tid": tenant_id, "dtype": body.doc_type}, ) # Next version number next_ver = db.execute( text(""" SELECT COALESCE(MAX(version), 0) + 1 FROM compliance_cra_documents WHERE cra_project_id = :pid AND doc_type = :dtype """), {"pid": project_id, "dtype": body.doc_type}, ).scalar() # Snapshot project context for audit gen_context = { "project_status": project.get("status"), "classification": project.get("cra_classification"), "conformity_path": project.get("conformity_path"), "generated_for_version": next_ver, } row = db.execute( text(""" INSERT INTO compliance_cra_documents (cra_project_id, tenant_id, doc_type, title, content_md, version, requirements_coverage, generation_context, status) VALUES (:pid, :tid, :dtype, :title, :content, :ver, CAST(:cov AS jsonb), CAST(:ctx AS jsonb), 'draft') RETURNING * """), { "pid": project_id, "tid": tenant_id, "dtype": body.doc_type, "title": title, "content": content, "ver": next_ver, "cov": json.dumps(coverage), "ctx": json.dumps(gen_context), }, ).fetchone() db.commit() return _doc_row_to_dict(row) except HTTPException: raise except Exception: db.rollback() raise finally: db.close() @router.get("/{project_id}/documents") async def list_documents( project_id: str, tenant_id: str = Depends(get_tenant_id), include_superseded: bool = False, ): """List documents for a project. By default only latest/active version per type.""" db = SessionLocal() try: if not _cra_project_exists(db, project_id, tenant_id): raise HTTPException(status_code=404, detail="CRA project not found") if include_superseded: rows = db.execute( text(""" SELECT * FROM compliance_cra_documents WHERE cra_project_id = :pid AND tenant_id = :tid ORDER BY doc_type, version DESC """), {"pid": project_id, "tid": tenant_id}, ).fetchall() else: rows = db.execute( text(""" SELECT DISTINCT ON (doc_type) * FROM compliance_cra_documents WHERE cra_project_id = :pid AND tenant_id = :tid AND status != 'superseded' ORDER BY doc_type, version DESC """), {"pid": project_id, "tid": tenant_id}, ).fetchall() # Show all doc types — even if not yet generated existing_types = {r.doc_type for r in rows} items = [_doc_row_to_dict(r) for r in rows] for doc_type, label in DOC_TYPE_LABELS.items(): if doc_type not in existing_types: items.append({ "id": None, "cra_project_id": project_id, "doc_type": doc_type, "doc_type_label": label, "title": label, "content_md": None, "version": 0, "requirements_coverage": {}, "status": "not_generated", "signed_by": None, "signed_at": None, "generated_at": None, "superseded_at": None, }) items.sort(key=lambda x: x["doc_type"]) return {"project_id": project_id, "total": len(items), "items": items} finally: db.close() @router.get("/documents/{doc_id}") async def get_document( doc_id: str, tenant_id: str = Depends(get_tenant_id), ): """Get full document content (incl. content_md).""" db = SessionLocal() try: row = db.execute( text(""" SELECT * FROM compliance_cra_documents WHERE id = :did AND tenant_id = :tid """), {"did": doc_id, "tid": tenant_id}, ).fetchone() if not row: raise HTTPException(status_code=404, detail="Document not found") return _doc_row_to_dict(row) finally: db.close() @router.post("/documents/{doc_id}/approve") async def approve_document( doc_id: str, body: ApproveDocRequest, tenant_id: str = Depends(get_tenant_id), ): """Set status to reviewed/approved + signature.""" if body.status not in DOC_STATUS_WHITELIST: raise HTTPException( status_code=400, detail=f"Invalid status. Allowed: {sorted(DOC_STATUS_WHITELIST)}", ) if not body.signed_by.strip(): raise HTTPException(status_code=400, detail="signed_by required") db = SessionLocal() try: row = db.execute( text(""" UPDATE compliance_cra_documents SET status = :status, signed_by = :signer, signed_at = NOW() WHERE id = :did AND tenant_id = :tid AND status != 'superseded' RETURNING * """), {"did": doc_id, "tid": tenant_id, "status": body.status, "signer": body.signed_by}, ).fetchone() if not row: raise HTTPException(status_code=404, detail="Document not found or already superseded") db.commit() return _doc_row_to_dict(row) except HTTPException: raise except Exception: db.rollback() raise finally: db.close()