feat(sdk): Kunden-Dokumente + CRA-Meldewesen, Screening aus Frontend genommen
CI / detect-changes (push) Successful in 16s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Successful in 15s
CI / validate-canonical-controls (push) Successful in 13s
CI / loc-budget (push) Successful in 25s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 3m9s
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 31s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped

- /sdk/dokumente: Kundensicht nur auf veroeffentlichte Rechtsdokumente
  (Ansehen + Download); Proxy mit Allow-List nur /public — Templates/Drafts/
  Generator bleiben unerreichbar.
- /sdk/cra-meldewesen: CRA Art. 14 Meldewesen (24h/72h/14d-Kaskade) mit
  Fristen-Tracking + ENISA-SRP-Export-Entwurf (kein Live-API). Backend:
  cra_meldewesen (pure, getestet) + cra_incident_store (schema-neutral ueber
  compliance_cra_documents) + /api/v1/cra/incidents (additiv, contract-safe).
- Screening (Self-Scan) aus dem Frontend genommen: Flow-Stepper-Eintrag
  ausgeblendet (visibleWhen), Dashboard-Kachel + Import-Button entfernt.
  Repo-Scanning laeuft extern im Compliance-Scanner; Backend-Router bleibt
  vorerst gemountet (Contract-Stabilitaet).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Benjamin Bönisch
2026-06-17 21:21:28 +02:00
parent 72093e5501
commit 8f21650d74
17 changed files with 1155 additions and 17 deletions
@@ -0,0 +1,132 @@
"""CRA Article 14 incident-reporting (Meldewesen) endpoints.
The 24h/72h/14d cascade to ENISA's SRP. Thin handlers delegate to the pure
cra_meldewesen logic + the schema-neutral cra_incident_store. There is no live
ENISA API — submissions store a downloadable export draft.
"""
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from compliance.services import cra_incident_store as store
from compliance.services.cra_meldewesen import (
STAGES, SEVERITIES, KINDS, REPORTING_ACTIVE_FROM,
compute_deadlines, next_open_stage, build_enisa_report, report_completeness,
)
from .tenant_utils import get_tenant_id
router = APIRouter(prefix="/v1/cra", tags=["cra-meldewesen"])
class IncidentCreate(BaseModel):
cra_project_id: str
summary: str = ""
product_name: str = ""
product_version: str = ""
manufacturer: str = ""
kind: str = "exploited_vulnerability"
severity: str = "high"
aware_at: str = ""
contact: str = ""
impact: str = ""
affected_components: str = ""
exploitation_status: str = ""
iocs: str = ""
mitigations: str = ""
personal_data_affected: bool = False
root_cause: str = ""
corrective_measures: str = ""
patch_available: bool = False
patch_reference: str = ""
lessons_learned: str = ""
class IncidentPatch(BaseModel):
summary: Optional[str] = None
impact: Optional[str] = None
affected_components: Optional[str] = None
exploitation_status: Optional[str] = None
iocs: Optional[str] = None
mitigations: Optional[str] = None
personal_data_affected: Optional[bool] = None
root_cause: Optional[str] = None
corrective_measures: Optional[str] = None
patch_available: Optional[bool] = None
patch_reference: Optional[str] = None
lessons_learned: Optional[str] = None
def _enrich(inc: dict) -> dict:
"""Attach computed deadlines + next open stage (derived, never stored)."""
subs = {k: v.get("submitted_at") for k, v in (inc.get("submissions") or {}).items()}
deadlines = compute_deadlines(inc.get("aware_at", ""), subs)
return {**inc, "deadlines": deadlines, "next_stage": next_open_stage(deadlines)}
@router.get("/incidents/meta")
async def incident_meta() -> dict:
return {"stages": STAGES, "severities": SEVERITIES, "kinds": KINDS,
"reporting_active_from": REPORTING_ACTIVE_FROM}
@router.post("/incidents")
async def create_incident(body: IncidentCreate, tenant_id: str = Depends(get_tenant_id)) -> dict:
data = body.model_dump()
pid = data.pop("cra_project_id")
if not data.get("aware_at"):
data["aware_at"] = datetime.now(timezone.utc).isoformat()
return _enrich(store.create_incident(pid, tenant_id, data))
@router.get("/incidents")
async def list_incidents(cra_project_id: str, tenant_id: str = Depends(get_tenant_id)) -> dict:
items = [_enrich(i) for i in store.list_incidents(cra_project_id, tenant_id)]
return {"incidents": items, "count": len(items)}
@router.get("/incidents/{incident_id}")
async def get_incident(incident_id: str, tenant_id: str = Depends(get_tenant_id)) -> dict:
inc = store.get_incident(incident_id, tenant_id)
if not inc:
raise HTTPException(status_code=404, detail="Meldung nicht gefunden")
return _enrich(inc)
@router.patch("/incidents/{incident_id}")
async def update_incident(incident_id: str, body: IncidentPatch,
tenant_id: str = Depends(get_tenant_id)) -> dict:
patch = {k: v for k, v in body.model_dump().items() if v is not None}
inc = store.update_incident(incident_id, tenant_id, patch)
if not inc:
raise HTTPException(status_code=404, detail="Meldung nicht gefunden")
return _enrich(inc)
@router.get("/incidents/{incident_id}/export/{stage}")
async def export_stage(incident_id: str, stage: str,
tenant_id: str = Depends(get_tenant_id)) -> dict:
inc = store.get_incident(incident_id, tenant_id)
if not inc:
raise HTTPException(status_code=404, detail="Meldung nicht gefunden")
try:
report = build_enisa_report(inc, stage)
except ValueError:
raise HTTPException(status_code=400, detail="Unbekannte Meldestufe")
return {"report": report, "completeness": report_completeness(inc, stage)}
@router.post("/incidents/{incident_id}/submit/{stage}")
async def submit_stage(incident_id: str, stage: str,
tenant_id: str = Depends(get_tenant_id)) -> dict:
inc = store.get_incident(incident_id, tenant_id)
if not inc:
raise HTTPException(status_code=404, detail="Meldung nicht gefunden")
try:
report = build_enisa_report(inc, stage)
except ValueError:
raise HTTPException(status_code=400, detail="Unbekannte Meldestufe")
ts = datetime.now(timezone.utc).isoformat()
updated = store.record_submission(incident_id, tenant_id, stage, ts, report)
return _enrich(updated)
@@ -0,0 +1,142 @@
"""Persist CRA Art. 14 incident reports — schema-neutral (frozen DB).
Reuses the existing compliance_cra_documents table (doc_type='doc_incident_report'):
one row per incident, the full incident + per-stage submissions live in
generation_context (jsonb). No supersede chaining (unlike the assessment
snapshot store) — each incident is an independent, evolving record.
"""
import json
from typing import Optional
from sqlalchemy import text
from database import SessionLocal
DOC_TYPE = "doc_incident_report"
def _summary_md(inc: dict) -> str:
return "\n".join([
"# CRA-Meldung: {}".format(inc.get("summary", "(ohne Titel)")),
"",
"- Produkt: {} {}".format(inc.get("product_name", ""), inc.get("product_version", "")),
"- Art: {} · Schwere: {}".format(inc.get("kind", ""), inc.get("severity", "")),
"- Bekannt seit: {}".format(inc.get("aware_at", "")),
])
def create_incident(cra_project_id: str, tenant_id: str, incident: dict) -> dict:
inc = dict(incident)
inc.setdefault("submissions", {}) # stage_key -> {submitted_at, report}
db = SessionLocal()
try:
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 (CAST(:pid AS uuid), :tid, :dt, :title, :md, 1,
CAST('{}' AS jsonb), CAST(:ctx AS jsonb), 'open')
RETURNING id, generated_at
"""), {"pid": cra_project_id, "tid": tenant_id, "dt": DOC_TYPE,
"title": inc.get("summary", "CRA-Meldung")[:200],
"md": _summary_md(inc), "ctx": json.dumps(inc)}).fetchone()
db.commit()
return {"id": str(row.id), "created_at": row.generated_at.isoformat(),
"status": "open", **inc}
except Exception:
db.rollback()
raise
finally:
db.close()
def list_incidents(cra_project_id: str, tenant_id: str) -> list:
db = SessionLocal()
try:
rows = db.execute(text("""
SELECT id, status, generated_at, generation_context
FROM compliance_cra_documents
WHERE cra_project_id = CAST(:pid AS uuid) AND tenant_id = :tid AND doc_type = :dt
ORDER BY generated_at DESC
"""), {"pid": cra_project_id, "tid": tenant_id, "dt": DOC_TYPE}).fetchall()
out = []
for r in rows:
ctx = r.generation_context if isinstance(r.generation_context, dict) else {}
out.append({"id": str(r.id), "status": r.status,
"created_at": r.generated_at.isoformat(), **ctx})
return out
finally:
db.close()
def get_incident(incident_id: str, tenant_id: str) -> Optional[dict]:
db = SessionLocal()
try:
r = db.execute(text("""
SELECT id, status, generated_at, generation_context
FROM compliance_cra_documents
WHERE id = CAST(:iid AS uuid) AND tenant_id = :tid AND doc_type = :dt
"""), {"iid": incident_id, "tid": tenant_id, "dt": DOC_TYPE}).fetchone()
if not r:
return None
ctx = r.generation_context if isinstance(r.generation_context, dict) else {}
return {"id": str(r.id), "status": r.status,
"created_at": r.generated_at.isoformat(), **ctx}
finally:
db.close()
def _save_ctx(db, incident_id: str, tenant_id: str, ctx: dict, status: str) -> None:
db.execute(text("""
UPDATE compliance_cra_documents
SET generation_context = CAST(:ctx AS jsonb), status = :st, updated_at = NOW()
WHERE id = CAST(:iid AS uuid) AND tenant_id = :tid AND doc_type = :dt
"""), {"ctx": json.dumps(ctx), "st": status, "iid": incident_id,
"tid": tenant_id, "dt": DOC_TYPE})
def update_incident(incident_id: str, tenant_id: str, patch: dict) -> Optional[dict]:
db = SessionLocal()
try:
r = db.execute(text("""
SELECT status, generation_context FROM compliance_cra_documents
WHERE id = CAST(:iid AS uuid) AND tenant_id = :tid AND doc_type = :dt
"""), {"iid": incident_id, "tid": tenant_id, "dt": DOC_TYPE}).fetchone()
if not r:
return None
ctx = dict(r.generation_context if isinstance(r.generation_context, dict) else {})
ctx.update({k: v for k, v in patch.items() if k != "submissions"})
_save_ctx(db, incident_id, tenant_id, ctx, r.status)
db.commit()
return {"id": incident_id, "status": r.status, **ctx}
except Exception:
db.rollback()
raise
finally:
db.close()
def record_submission(incident_id: str, tenant_id: str, stage: str,
submitted_at: str, report: dict) -> Optional[dict]:
"""Mark a cascade stage as submitted (stores the ENISA export draft + ts)."""
db = SessionLocal()
try:
r = db.execute(text("""
SELECT generation_context FROM compliance_cra_documents
WHERE id = CAST(:iid AS uuid) AND tenant_id = :tid AND doc_type = :dt
"""), {"iid": incident_id, "tid": tenant_id, "dt": DOC_TYPE}).fetchone()
if not r:
return None
ctx = dict(r.generation_context if isinstance(r.generation_context, dict) else {})
subs = dict(ctx.get("submissions") or {})
subs[stage] = {"submitted_at": submitted_at, "report": report}
ctx["submissions"] = subs
status = "closed" if stage == "final" else "reporting"
_save_ctx(db, incident_id, tenant_id, ctx, status)
db.commit()
return {"id": incident_id, "status": status, **ctx}
except Exception:
db.rollback()
raise
finally:
db.close()
@@ -0,0 +1,150 @@
"""CRA Article 14 incident-reporting cascade — pure, deterministic core.
The CRA obliges manufacturers, once they become AWARE of an actively exploited
vulnerability or a severe security incident, to report to ENISA's Single
Reporting Platform (SRP) / the CSIRT in a three-stage cascade:
* early warning — within 24 h (Art. 14(2)(a))
* notification — within 72 h (Art. 14(2)(b))
* final report — within 14 days (Art. 14(2)(c) / 14(4))
This module is pure (no DB, no network, no clock unless you pass `now`): deadline
computation + the ENISA-SRP export draft. There is NO live ENISA API yet, so the
"submission" is a structured export the user downloads / hands over — never a
live HTTP POST. Persistence + routes live in cra_incident_store / cra_incident_routes.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Optional
# Reporting deadline obligation becomes active 2026-09-11 (CRA Art. 14).
REPORTING_ACTIVE_FROM = "2026-09-11"
STAGES = [
{"key": "early_warning", "label": "Frühwarnung", "hours": 24, "article": "Art. 14 Abs. 2 a)"},
{"key": "notification", "label": "Meldung", "hours": 72, "article": "Art. 14 Abs. 2 b)"},
{"key": "final", "label": "Abschlussbericht", "hours": 24 * 14, "article": "Art. 14 Abs. 2 c)"},
]
_STAGE_KEYS = [s["key"] for s in STAGES]
SEVERITIES = ["low", "medium", "high", "critical"]
KINDS = ["exploited_vulnerability", "severe_incident"]
# How long before a deadline we flag it "due soon" (amber).
_DUE_SOON_HOURS = 6
def _parse(iso: Optional[str]) -> Optional[datetime]:
if not iso:
return None
try:
dt = datetime.fromisoformat(str(iso).replace("Z", "+00:00"))
except (ValueError, TypeError):
return None
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
def _now(now_iso: Optional[str]) -> datetime:
return _parse(now_iso) or datetime.now(timezone.utc)
def compute_deadlines(
aware_at_iso: str,
submissions: Optional[dict] = None,
now_iso: Optional[str] = None,
) -> list:
"""Per-stage deadline + status from the moment of awareness.
submissions: {stage_key: submitted_at_iso} — a submitted stage is 'submitted'
regardless of timing. Status ∈ submitted | overdue | due_soon | pending.
"""
aware = _parse(aware_at_iso)
submissions = submissions or {}
now = _now(now_iso)
out = []
for s in STAGES:
due = aware + timedelta(hours=s["hours"]) if aware else None
submitted_at = submissions.get(s["key"])
if submitted_at:
status = "submitted"
elif due is None:
status = "pending"
elif now > due:
status = "overdue"
elif now > due - timedelta(hours=_DUE_SOON_HOURS):
status = "due_soon"
else:
status = "pending"
remaining = (due - now).total_seconds() if due else None
out.append({
"key": s["key"], "label": s["label"], "article": s["article"],
"due_at": due.isoformat() if due else None,
"submitted_at": submitted_at,
"status": status,
"remaining_seconds": int(remaining) if remaining is not None else None,
})
return out
def next_open_stage(deadlines: list) -> Optional[str]:
"""The earliest stage not yet submitted — what the user should act on next."""
for d in deadlines:
if d["status"] != "submitted":
return d["key"]
return None
def build_enisa_report(incident: dict, stage: str) -> dict:
"""Structured ENISA-SRP export draft for one cascade stage.
Mirrors the CRA Art. 14 content requirements. This is a DRAFT for manual
submission to the ENISA Single Reporting Platform — not a live API call.
Later stages include everything the earlier ones do, plus their extras.
"""
if stage not in _STAGE_KEYS:
raise ValueError(f"unknown stage '{stage}'")
base = {
"report_stage": stage,
"report_stage_article": next(s["article"] for s in STAGES if s["key"] == stage),
"manufacturer": incident.get("manufacturer", ""),
"product_name": incident.get("product_name", ""),
"product_version": incident.get("product_version", ""),
"incident_kind": incident.get("kind", ""),
"severity": incident.get("severity", ""),
"aware_at": incident.get("aware_at", ""),
"summary": incident.get("summary", ""),
"contact": incident.get("contact", ""),
"submission_target": "ENISA Single Reporting Platform (SRP)",
"draft_note": "Entwurf zur manuellen Übermittlung — keine Live-API-Übertragung.",
}
if stage in ("notification", "final"):
base.update({
"affected_components": incident.get("affected_components", ""),
"impact": incident.get("impact", ""),
"exploitation_status": incident.get("exploitation_status", ""),
"indicators_of_compromise": incident.get("iocs", ""),
"mitigations_in_place": incident.get("mitigations", ""),
"personal_data_affected": bool(incident.get("personal_data_affected", False)),
})
if stage == "final":
base.update({
"root_cause": incident.get("root_cause", ""),
"corrective_measures": incident.get("corrective_measures", ""),
"patch_available": bool(incident.get("patch_available", False)),
"patch_reference": incident.get("patch_reference", ""),
"lessons_learned": incident.get("lessons_learned", ""),
})
return base
def report_completeness(incident: dict, stage: str) -> dict:
"""Which fields the ENISA draft for `stage` still lacks — drives follow-up
prompts in the UI without blocking submission."""
draft = build_enisa_report(incident, stage)
skip = {"submission_target", "draft_note", "report_stage", "report_stage_article",
"personal_data_affected", "patch_available"}
missing = [k for k, v in draft.items() if k not in skip and (v is None or str(v).strip() == "")]
filled = [k for k, v in draft.items() if k not in skip and str(v).strip() != ""]
return {"filled": filled, "missing": missing,
"complete": not missing, "stage": stage}
+5
View File
@@ -58,6 +58,7 @@ from compliance.api.vendor_assessment_routes import router as vendor_assessment_
from compliance.api.cra_routes import router as cra_router
from compliance.api.cra_assess_routes import router as cra_assess_router
from compliance.api.cra_progress_routes import router as cra_progress_router
from compliance.api.cra_incident_routes import router as cra_incident_router
from compliance.api.cra_link_routes import router as cra_link_router
from compliance.api.quaidal_routes import router as quaidal_router
@@ -150,6 +151,9 @@ app.include_router(source_policy_router, prefix="/api")
app.include_router(import_router, prefix="/api")
# System Screening (SBOM generation, vulnerability scan)
# Screening (Self-Scan) ist aus dem Frontend genommen (Repo-Scanning läuft extern im
# Compliance-Scanner). Backend-Router bleibt vorerst gemountet (Contract-Stabilität);
# Demount/Archivierung erst, wenn der externe Scanner SBOM/CVE/DAST voll liefert.
app.include_router(screening_router, prefix="/api")
# Company Profile (CRUD with audit logging)
@@ -176,6 +180,7 @@ app.include_router(vendor_assessment_router, prefix="/api")
app.include_router(cra_router, prefix="/api")
app.include_router(cra_assess_router, prefix="/api")
app.include_router(cra_progress_router, prefix="/api")
app.include_router(cra_incident_router, prefix="/api")
app.include_router(cra_link_router, prefix="/api")
app.include_router(quaidal_router, prefix="/api")
@@ -0,0 +1,87 @@
"""CRA Art. 14 reporting cascade — pure deadline + ENISA-export logic."""
import pytest
from compliance.services.cra_meldewesen import (
compute_deadlines, next_open_stage, build_enisa_report, report_completeness,
)
AWARE = "2026-09-15T08:00:00+00:00"
class TestDeadlines:
def test_due_times_are_24h_72h_14d_after_awareness(self):
d = compute_deadlines(AWARE, now_iso=AWARE)
by = {x["key"]: x for x in d}
assert by["early_warning"]["due_at"] == "2026-09-16T08:00:00+00:00"
assert by["notification"]["due_at"] == "2026-09-18T08:00:00+00:00"
assert by["final"]["due_at"] == "2026-09-29T08:00:00+00:00"
def test_all_pending_right_after_awareness(self):
d = compute_deadlines(AWARE, now_iso="2026-09-15T09:00:00+00:00")
assert all(x["status"] == "pending" for x in d)
def test_overdue_when_now_past_due_and_unsubmitted(self):
d = compute_deadlines(AWARE, now_iso="2026-09-17T00:00:00+00:00")
by = {x["key"]: x for x in d}
assert by["early_warning"]["status"] == "overdue" # 24h passed
assert by["notification"]["status"] == "pending" # 72h not yet
def test_due_soon_within_window(self):
# 4h before the 24h deadline → due_soon
d = compute_deadlines(AWARE, now_iso="2026-09-16T04:00:00+00:00")
by = {x["key"]: x for x in d}
assert by["early_warning"]["status"] == "due_soon"
def test_submitted_overrides_timing(self):
d = compute_deadlines(
AWARE, submissions={"early_warning": "2026-09-16T07:00:00+00:00"},
now_iso="2026-09-20T00:00:00+00:00")
by = {x["key"]: x for x in d}
assert by["early_warning"]["status"] == "submitted"
assert by["notification"]["status"] == "overdue"
def test_next_open_stage(self):
d = compute_deadlines(AWARE, submissions={"early_warning": AWARE}, now_iso=AWARE)
assert next_open_stage(d) == "notification"
d2 = compute_deadlines(
AWARE, submissions={k: AWARE for k in ("early_warning", "notification", "final")},
now_iso=AWARE)
assert next_open_stage(d2) is None
class TestEnisaReport:
def _incident(self):
return {
"manufacturer": "OWIS GmbH", "product_name": "PS 90+", "product_version": "2.1",
"kind": "exploited_vulnerability", "severity": "high", "aware_at": AWARE,
"summary": "Aktiv ausgenutzte Auth-Umgehung", "contact": "psirt@owis.eu",
"impact": "Fernsteuerung möglich", "root_cause": "fehlende Tokenprüfung",
"patch_available": True,
}
def test_early_warning_has_base_not_detail(self):
r = build_enisa_report(self._incident(), "early_warning")
assert r["manufacturer"] == "OWIS GmbH" and r["severity"] == "high"
assert "impact" not in r and "root_cause" not in r
assert r["submission_target"].startswith("ENISA")
def test_notification_adds_impact_not_rootcause(self):
r = build_enisa_report(self._incident(), "notification")
assert r["impact"] == "Fernsteuerung möglich"
assert "root_cause" not in r
def test_final_adds_root_cause_and_patch(self):
r = build_enisa_report(self._incident(), "final")
assert r["root_cause"] == "fehlende Tokenprüfung"
assert r["patch_available"] is True
def test_unknown_stage_raises(self):
with pytest.raises(ValueError):
build_enisa_report(self._incident(), "nonsense")
def test_completeness_flags_missing(self):
thin = {"manufacturer": "X", "aware_at": AWARE}
c = report_completeness(thin, "early_warning")
assert not c["complete"]
assert "product_name" in c["missing"]
assert "manufacturer" in c["filled"]