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
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:
@@ -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}
|
||||
Reference in New Issue
Block a user