cc80e59e5e
Migration 121: compliance_cra_vulnerabilities table with full lifecycle tracking
- Status state machine: reported → triaged → patched → disclosed (+ withdrawn)
- CRA Art. 14(2) deadlines tracked: reported_to_enisa_at (24h), detailed_report_at (72h)
- CVE-ID, severity, CVSS, affected_components (JSONB), embargo_until
Backend endpoints in cra_routes.py:
- POST /vulnerabilities — create with validation (severity, CVSS range)
- GET /vulnerabilities — list with deadline-breach summary (24h/72h counters)
- PATCH /vulnerabilities/{id} — update fields + auto-set lifecycle timestamps
- DELETE /vulnerabilities/{id} — soft-delete (withdrawn)
- GET /monitoring — combined view: CRA deadlines + vuln summary + post-market checklist
Frontend:
- /vuln page: intake form, vuln cards with 24h/72h-countdown buttons,
status-transition flow with auto-timestamps
- /monitoring page: CRA deadlines (11.06.26 / 11.09.26 / 11.12.27), breach banner
if 24h/72h obligations missed, post-market checklist with deep-links
- Dashboard: +2 buttons (Vulns, Monitoring)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1715 lines
62 KiB
Python
1715 lines
62 KiB
Python
"""
|
|
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()
|