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,40 @@
import { NextRequest, NextResponse } from 'next/server'
// Proxy for the CRA Art. 14 incident-reporting (Meldewesen) endpoints.
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
function tenant(request: NextRequest): string {
return request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
}
async function forward(request: NextRequest, path: string[], method: string) {
const sub = path.join('/')
const { searchParams } = new URL(request.url)
const qs = searchParams.toString()
const url = `${BACKEND_URL}/api/v1/cra/incidents${sub ? `/${sub}` : ''}${qs ? `?${qs}` : ''}`
const init: RequestInit = {
method,
headers: { 'X-Tenant-ID': tenant(request), 'Content-Type': 'application/json' },
}
if (method !== 'GET') init.body = await request.text()
try {
const resp = await fetch(url, init)
const body = await resp.text()
return new NextResponse(body, {
status: resp.status,
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
})
} catch (err) {
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
}
}
export async function GET(request: NextRequest, { params }: { params: Promise<{ path?: string[] }> }) {
return forward(request, (await params).path || [], 'GET')
}
export async function POST(request: NextRequest, { params }: { params: Promise<{ path?: string[] }> }) {
return forward(request, (await params).path || [], 'POST')
}
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ path?: string[] }> }) {
return forward(request, (await params).path || [], 'PATCH')
}
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from 'next/server'
// Customer-facing proxy to the legal-documents API. The customer "Dokumente"
// page only ever reads PUBLISHED documents (GET /public). Templates, drafts and
// the generator stay behind the internal API and are never proxied here.
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
function tenantHeader(request: NextRequest): string {
return request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> },
) {
const { path = [] } = await params
const sub = path.join('/')
// Hard allow-list: customers may only read the public (published) views.
if (sub !== 'public' && !sub.startsWith('public/')) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
const { searchParams } = new URL(request.url)
const qs = searchParams.toString()
try {
const resp = await fetch(
`${BACKEND_URL}/api/compliance/legal-documents/${sub}${qs ? `?${qs}` : ''}`,
{ headers: { 'X-Tenant-ID': tenantHeader(request) } },
)
const body = await resp.text()
return new NextResponse(body, {
status: resp.status,
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
})
} catch (err) {
return NextResponse.json(
{ error: 'Backend unreachable', details: String(err) },
{ status: 502 },
)
}
}
@@ -0,0 +1,88 @@
'use client'
import { Incident, Deadline, downloadStageExport } from '../_hooks/useMeldewesen'
const STATUS_STYLE: Record<string, string> = {
submitted: 'bg-green-100 text-green-800 border-green-300',
overdue: 'bg-red-100 text-red-800 border-red-300',
due_soon: 'bg-amber-100 text-amber-800 border-amber-300',
pending: 'bg-gray-100 text-gray-700 border-gray-300',
}
const STATUS_LABEL: Record<string, string> = {
submitted: 'gemeldet', overdue: 'überfällig', due_soon: 'bald fällig', pending: 'offen',
}
const SEV_STYLE: Record<string, string> = {
critical: 'bg-red-600', high: 'bg-orange-500', medium: 'bg-amber-500', low: 'bg-gray-400',
}
function remaining(sec: number | null): string {
if (sec === null) return ''
const past = sec < 0
const a = Math.abs(sec)
const h = Math.floor(a / 3600)
const txt = h >= 48 ? `${Math.floor(h / 24)} Tage` : `${h} h`
return past ? `seit ${txt} überfällig` : `noch ${txt}`
}
function fmt(iso: string | null): string {
if (!iso) return '—'
try { return new Date(iso).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' }) } catch { return iso }
}
function StageRow({ d, incidentId, summary, onSubmit }: {
d: Deadline; incidentId: string; summary: string; onSubmit: (stage: string) => void
}) {
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2 border-t border-gray-100 dark:border-gray-700">
<span className={`text-xs font-medium px-2 py-0.5 rounded border ${STATUS_STYLE[d.status]}`}>
{STATUS_LABEL[d.status]}
</span>
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">{d.label}</span>
<span className="text-xs text-gray-400">{d.article}</span>
<span className="text-xs text-gray-500">
Frist: {fmt(d.due_at)}{d.status !== 'submitted' && d.remaining_seconds !== null ? ` · ${remaining(d.remaining_seconds)}` : ''}
{d.status === 'submitted' ? ` · übermittelt ${fmt(d.submitted_at)}` : ''}
</span>
<div className="ml-auto flex items-center gap-2">
<button onClick={() => downloadStageExport(incidentId, d.key, summary)}
className="text-xs rounded border border-gray-300 dark:border-gray-600 px-2 py-1 hover:bg-gray-50 dark:hover:bg-gray-700">
ENISA-Entwurf
</button>
{d.status !== 'submitted' && (
<button onClick={() => onSubmit(d.key)}
className="text-xs rounded bg-indigo-600 hover:bg-indigo-700 text-white px-2 py-1">
Als gemeldet markieren
</button>
)}
</div>
</div>
)
}
export function IncidentCard({ inc, onSubmit }: { inc: Incident; onSubmit: (id: string, stage: string) => void }) {
return (
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className={`w-2.5 h-2.5 rounded-full ${SEV_STYLE[inc.severity || 'low']}`} title={inc.severity} />
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100 truncate">{inc.summary || 'Vorfall'}</h3>
</div>
<p className="text-xs text-gray-500 mt-0.5">
{inc.product_name} {inc.product_version} · {inc.kind === 'exploited_vulnerability' ? 'ausgenutzte Schwachstelle' : 'schwerer Vorfall'} · bekannt seit {fmt(inc.aware_at || null)}
</p>
</div>
<span className="text-xs text-gray-400 shrink-0">Status: {inc.status}</span>
</div>
<div className="mt-3">
{inc.deadlines.map((d) => (
<StageRow key={d.key} d={d} incidentId={inc.id} summary={inc.summary}
onSubmit={(stage) => onSubmit(inc.id, stage)} />
))}
</div>
<p className="text-[11px] text-gray-400 italic mt-2">
Übermittlung an die ENISA Single Reporting Platform erfolgt manuell mit dem Entwurf keine automatische Übertragung.
</p>
</div>
)
}
@@ -0,0 +1,66 @@
'use client'
import { useState } from 'react'
import { Meta } from '../_hooks/useMeldewesen'
const KIND_LABEL: Record<string, string> = {
exploited_vulnerability: 'Aktiv ausgenutzte Schwachstelle',
severe_incident: 'Schwerwiegender Sicherheitsvorfall',
}
const SEV_LABEL: Record<string, string> = {
critical: 'kritisch', high: 'hoch', medium: 'mittel', low: 'niedrig',
}
export function NewIncidentForm({ meta, onCreate, onCancel }: {
meta: Meta | null
onCreate: (body: Record<string, unknown>) => Promise<boolean>
onCancel: () => void
}) {
const [f, setF] = useState<Record<string, string>>({
summary: '', product_name: '', product_version: '', manufacturer: '',
kind: 'exploited_vulnerability', severity: 'high', contact: '', impact: '',
})
const [busy, setBusy] = useState(false)
const set = (k: string, v: string) => setF((p) => ({ ...p, [k]: v }))
const field = 'w-full text-sm rounded border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-700 p-2'
const submit = async () => {
setBusy(true)
try { if (await onCreate(f)) onCancel() } finally { setBusy(false) }
}
return (
<div className="rounded-xl border border-indigo-200 dark:border-indigo-800 bg-indigo-50/40 dark:bg-indigo-900/20 p-4 space-y-3">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Neue CRA-Meldung erfassen</h3>
<p className="text-xs text-gray-600 dark:text-gray-300">
Die 24h/72h/14-Tage-Fristen laufen ab dem Zeitpunkt, an dem Sie Kenntnis erlangt haben.
</p>
<input className={field} placeholder="Kurzbeschreibung des Vorfalls *"
value={f.summary} onChange={(e) => set('summary', e.target.value)} />
<div className="grid grid-cols-2 gap-2">
<input className={field} placeholder="Produkt" value={f.product_name} onChange={(e) => set('product_name', e.target.value)} />
<input className={field} placeholder="Version" value={f.product_version} onChange={(e) => set('product_version', e.target.value)} />
<input className={field} placeholder="Hersteller" value={f.manufacturer} onChange={(e) => set('manufacturer', e.target.value)} />
<input className={field} placeholder="Kontakt (PSIRT-E-Mail)" value={f.contact} onChange={(e) => set('contact', e.target.value)} />
<select className={field} value={f.kind} onChange={(e) => set('kind', e.target.value)}>
{(meta?.kinds || ['exploited_vulnerability', 'severe_incident']).map((k) => (
<option key={k} value={k}>{KIND_LABEL[k] || k}</option>
))}
</select>
<select className={field} value={f.severity} onChange={(e) => set('severity', e.target.value)}>
{(meta?.severities || ['low', 'medium', 'high', 'critical']).map((s) => (
<option key={s} value={s}>{SEV_LABEL[s] || s}</option>
))}
</select>
</div>
<textarea className={field} rows={2} placeholder="Auswirkung (kurz)" value={f.impact} onChange={(e) => set('impact', e.target.value)} />
<div className="flex items-center gap-2">
<button onClick={submit} disabled={busy || f.summary.trim().length < 3}
className="rounded bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white text-sm px-4 py-2">
{busy ? 'Lege an …' : 'Meldung anlegen'}
</button>
<button onClick={onCancel} className="text-sm text-gray-500 hover:underline">Abbrechen</button>
</div>
</div>
)
}
@@ -0,0 +1,126 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
// CRA Art. 14 Meldewesen: incident reporting cascade (24h/72h/14d) to ENISA SRP.
// Incidents are scoped to a CRA project; results of the cascade are exported as a
// structured draft (no live ENISA API yet).
export interface Deadline {
key: string
label: string
article: string
due_at: string | null
submitted_at: string | null
status: 'submitted' | 'overdue' | 'due_soon' | 'pending'
remaining_seconds: number | null
}
export interface Incident {
id: string
status: string
summary: string
product_name?: string
product_version?: string
manufacturer?: string
kind?: string
severity?: string
aware_at?: string
contact?: string
impact?: string
root_cause?: string
corrective_measures?: string
patch_available?: boolean
personal_data_affected?: boolean
deadlines: Deadline[]
next_stage: string | null
submissions?: Record<string, { submitted_at: string; report: Record<string, unknown> }>
}
export interface Meta {
stages: { key: string; label: string; article: string; hours: number }[]
severities: string[]
kinds: string[]
reporting_active_from: string
}
interface Project { id: string; name: string }
const j = (r: Response) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))
export function useMeldewesen() {
const [meta, setMeta] = useState<Meta | null>(null)
const [projects, setProjects] = useState<Project[]>([])
const [projectId, setProjectId] = useState('')
const [incidents, setIncidents] = useState<Incident[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetch('/api/sdk/v1/cra/incidents/meta').then(j).then(setMeta).catch(() => {})
fetch('/api/sdk/v1/cra/projects')
.then(j)
.then((d) => {
const list: Project[] = (d.projects || d || []).map((p: any) => ({ id: p.id, name: p.name || p.machine_name || p.id }))
setProjects(list)
if (list.length && !projectId) setProjectId(list[0].id)
})
.catch(() => {})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const loadIncidents = useCallback((pid: string) => {
if (!pid) { setIncidents([]); return }
setLoading(true)
setError(null)
fetch(`/api/sdk/v1/cra/incidents?cra_project_id=${encodeURIComponent(pid)}`)
.then(j)
.then((d) => setIncidents(d.incidents || []))
.catch((e) => setError(String(e?.message || e)))
.finally(() => setLoading(false))
}, [])
useEffect(() => { loadIncidents(projectId) }, [projectId, loadIncidents])
const createIncident = useCallback(async (body: Record<string, unknown>) => {
const r = await fetch('/api/sdk/v1/cra/incidents', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...body, cra_project_id: projectId }),
})
if (r.ok) loadIncidents(projectId)
return r.ok
}, [projectId, loadIncidents])
const submitStage = useCallback(async (incidentId: string, stage: string) => {
const r = await fetch(`/api/sdk/v1/cra/incidents/${incidentId}/submit/${stage}`, { method: 'POST' })
if (r.ok) loadIncidents(projectId)
return r.ok
}, [projectId, loadIncidents])
const patchIncident = useCallback(async (incidentId: string, patch: Record<string, unknown>) => {
const r = await fetch(`/api/sdk/v1/cra/incidents/${incidentId}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch),
})
if (r.ok) loadIncidents(projectId)
return r.ok
}, [projectId, loadIncidents])
return {
meta, projects, projectId, setProjectId, incidents, loading, error,
createIncident, submitStage, patchIncident, reload: () => loadIncidents(projectId),
}
}
// Download the ENISA export draft for a stage as JSON.
export async function downloadStageExport(incidentId: string, stage: string, summary: string): Promise<void> {
const r = await fetch(`/api/sdk/v1/cra/incidents/${incidentId}/export/${stage}`)
if (!r.ok) return
const data = await r.json()
const safe = (summary || 'meldung').replace(/[^\w\-äöüÄÖÜß ]/g, '').trim().replace(/\s+/g, '_').slice(0, 40)
const blob = new Blob([JSON.stringify(data.report, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `ENISA_${stage}_${safe || 'meldung'}.json`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
@@ -0,0 +1,85 @@
'use client'
import { useState } from 'react'
import { useMeldewesen } from './_hooks/useMeldewesen'
import { IncidentCard } from './_components/IncidentCard'
import { NewIncidentForm } from './_components/NewIncidentForm'
// CRA Article 14 Meldewesen: the 24h/72h/14d incident-reporting cascade to ENISA.
// Customer-facing; deadlines + report drafts. No live ENISA API (manual export).
export default function MeldewesenPage() {
const m = useMeldewesen()
const [showForm, setShowForm] = useState(false)
return (
<div className="space-y-6">
<header className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">CRA-Meldewesen</h1>
<p className="text-sm text-gray-600 dark:text-gray-300 mt-1 max-w-2xl">
Meldepflichten nach CRA Artikel 14: Frühwarnung (24 h), Meldung (72 h) und Abschlussbericht
(14 Tage) an die ENISA Single Reporting Platform. Wir behalten die Fristen im Blick und
erstellen die Berichtsentwürfe die Übermittlung bestätigen Sie selbst.
</p>
</div>
{m.meta?.reporting_active_from && (
<span className="text-xs rounded-full bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 px-3 py-1">
Meldepflicht aktiv ab {new Date(m.meta.reporting_active_from).toLocaleDateString('de-DE')}
</span>
)}
</header>
<div className="flex flex-wrap items-center gap-3">
<label className="text-sm text-gray-600 dark:text-gray-300">Projekt:</label>
<select
value={m.projectId}
onChange={(e) => m.setProjectId(e.target.value)}
className="text-sm rounded border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-700 p-2 min-w-[16rem]"
>
{m.projects.length === 0 && <option value=""> kein CRA-Projekt </option>}
{m.projects.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
{m.projectId && !showForm && (
<button onClick={() => setShowForm(true)}
className="ml-auto rounded bg-indigo-600 hover:bg-indigo-700 text-white text-sm px-4 py-2">
+ Neue Meldung
</button>
)}
</div>
{showForm && m.projectId && (
<NewIncidentForm meta={m.meta} onCreate={m.createIncident} onCancel={() => setShowForm(false)} />
)}
{!m.projectId && (
<div className="rounded-xl border border-dashed border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 p-8 text-center text-sm text-gray-600 dark:text-gray-300">
Legen Sie zuerst ein CRA-Projekt an, um Vorfälle zu erfassen.
</div>
)}
{m.loading && <div className="text-sm text-gray-500">Lade Meldungen </div>}
{m.error && !m.loading && (
<div className="rounded-xl border border-amber-300 bg-amber-50 dark:bg-amber-900/20 text-amber-900 dark:text-amber-200 p-4 text-sm">
Meldungen konnten nicht geladen werden ({m.error}).{' '}
<button onClick={m.reload} className="underline font-medium">Erneut versuchen</button>
</div>
)}
{m.projectId && !m.loading && !m.error && m.incidents.length === 0 && (
<div className="rounded-xl border border-dashed border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 p-8 text-center">
<p className="text-sm text-gray-700 dark:text-gray-200 font-medium">Keine offenen Meldungen</p>
<p className="text-xs text-gray-500 mt-1">Im Ernstfall erfassen Sie hier den Vorfall die Fristen laufen dann automatisch mit.</p>
</div>
)}
{m.incidents.length > 0 && (
<div className="grid gap-3">
{m.incidents.map((inc) => (
<IncidentCard key={inc.id} inc={inc} onSubmit={m.submitStage} />
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,75 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
// Customer "Dokumente" view: lists ONLY published legal documents (the
// ready-to-use output), never templates or drafts. Backed by
// GET /api/sdk/v1/legal-documents/public (published-only, tenant-scoped).
export interface PublishedDoc {
id: string
type: string
name: string
version: number
title: string
content: string
language: string
published_at: string | null
}
// Human-readable German labels for the known document types. Internal type keys
// are never shown to the customer — only this Klartext.
const TYPE_LABEL: Record<string, string> = {
impressum: 'Impressum',
privacy_policy: 'Datenschutzerklärung',
datenschutz: 'Datenschutzerklärung',
dse: 'Datenschutzerklärung',
agb: 'AGB',
terms_of_service: 'Nutzungsbedingungen',
widerruf: 'Widerrufsbelehrung',
cookie_policy: 'Cookie-Richtlinie',
cookie_banner: 'Cookie-Banner-Text',
dpa: 'Auftragsverarbeitungsvertrag (AVV)',
nda: 'Geheimhaltungsvereinbarung (NDA)',
sla: 'Service-Level-Agreement (SLA)',
legal_notice: 'Rechtlicher Hinweis',
}
export function docLabel(type: string): string {
return TYPE_LABEL[type] || type.replace(/_/g, ' ')
}
export function useDokumente() {
const [docs, setDocs] = useState<PublishedDoc[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const load = useCallback(() => {
setLoading(true)
setError(null)
fetch('/api/sdk/v1/legal-documents/public')
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
.then((data: PublishedDoc[]) => setDocs(Array.isArray(data) ? data : []))
.catch((e) => setError(String(e?.message || e)))
.finally(() => setLoading(false))
}, [])
useEffect(() => { load() }, [load])
return { docs, loading, error, reload: load }
}
// Trigger a client-side download of a document's content as a .md file.
export function downloadDoc(doc: PublishedDoc): void {
const safe = (doc.title || docLabel(doc.type) || 'dokument')
.replace(/[^\w\-äöüÄÖÜß ]/g, '').trim().replace(/\s+/g, '_')
const blob = new Blob([doc.content || ''], { type: 'text/markdown;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${safe || 'dokument'}_v${doc.version}.md`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
+104
View File
@@ -0,0 +1,104 @@
'use client'
import { useState } from 'react'
import { useDokumente, docLabel, downloadDoc, PublishedDoc } from './_hooks/useDokumente'
// Customer-facing "Dokumente": the finished, published legal documents the
// customer can read and download. Deliberately shows NO templates, NO drafts and
// NO generator — only what has been approved and published.
function fmtDate(iso: string | null): string {
if (!iso) return '—'
try {
return new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' })
} catch {
return iso
}
}
function DocCard({ doc }: { doc: PublishedDoc }) {
const [open, setOpen] = useState(false)
return (
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="rounded bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300 text-xs font-medium px-2 py-0.5">
{docLabel(doc.type)}
</span>
<span className="text-xs text-gray-400">v{doc.version} · {doc.language?.toUpperCase()}</span>
</div>
<h3 className="mt-1.5 text-base font-semibold text-gray-900 dark:text-gray-100 truncate">
{doc.title || docLabel(doc.type)}
</h3>
<p className="text-xs text-gray-500 mt-0.5">Veröffentlicht am {fmtDate(doc.published_at)}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => setOpen((v) => !v)}
className="rounded border border-gray-300 dark:border-gray-600 text-sm px-3 py-1.5 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
>
{open ? 'Schließen' : 'Ansehen'}
</button>
<button
onClick={() => downloadDoc(doc)}
className="rounded bg-indigo-600 hover:bg-indigo-700 text-white text-sm px-3 py-1.5"
>
Herunterladen
</button>
</div>
</div>
{open && (
<article className="mt-4 max-h-[28rem] overflow-auto rounded-lg border border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40 p-4 text-sm leading-relaxed text-gray-800 dark:text-gray-200 whitespace-pre-wrap">
{doc.content || 'Kein Inhalt hinterlegt.'}
</article>
)}
</div>
)
}
export default function DokumentePage() {
const { docs, loading, error, reload } = useDokumente()
return (
<div className="space-y-6">
<header>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Dokumente</h1>
<p className="text-sm text-gray-600 dark:text-gray-300 mt-1 max-w-2xl">
Ihre freigegebenen Rechtsdokumente fertig zum Ansehen und Herunterladen. Hier erscheinen
ausschließlich <span className="font-medium">veröffentlichte</span> Dokumente; Entwürfe und
interne Vorlagen sind bewusst nicht enthalten.
</p>
</header>
{loading && (
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-8 text-center text-sm text-gray-500">
Lade Dokumente
</div>
)}
{error && !loading && (
<div className="rounded-xl border border-amber-300 bg-amber-50 dark:bg-amber-900/20 text-amber-900 dark:text-amber-200 p-4 text-sm">
Dokumente konnten gerade nicht geladen werden ({error}).{' '}
<button onClick={reload} className="underline font-medium">Erneut versuchen</button>
</div>
)}
{!loading && !error && docs.length === 0 && (
<div className="rounded-xl border border-dashed border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 p-8 text-center">
<p className="text-sm text-gray-700 dark:text-gray-200 font-medium">Noch keine veröffentlichten Dokumente</p>
<p className="text-xs text-gray-500 mt-1 max-w-md mx-auto">
Sobald ein Dokument intern und von Ihnen freigegeben und veröffentlicht wurde, erscheint es
hier automatisch zum Download.
</p>
</div>
)}
{!loading && !error && docs.length > 0 && (
<div className="grid gap-3">
{docs.map((d) => <DocCard key={`${d.id}-${d.version}`} doc={d} />)}
</div>
)}
</div>
)
}
+2 -10
View File
@@ -94,19 +94,11 @@ export default function ImportPage() {
{/* Continue Button */} {/* Continue Button */}
{analysisResult && ( {analysisResult && (
<div className="flex justify-between items-center pt-4 border-t border-gray-200"> <div className="pt-4 border-t border-gray-200">
{/* "Weiter zum Screening" entfernt: Repo-Scanning läuft extern im Compliance-Scanner. */}
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
Die Gap-Analyse wurde gespeichert. Sie koennen jetzt mit dem Compliance-Assessment fortfahren. Die Gap-Analyse wurde gespeichert. Sie koennen jetzt mit dem Compliance-Assessment fortfahren.
</p> </p>
<button
onClick={() => router.push('/sdk/screening')}
className="px-6 py-2.5 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 transition-colors flex items-center gap-2"
>
Weiter zum Screening
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</button>
</div> </div>
)} )}
+2 -3
View File
@@ -267,9 +267,8 @@ export default function SDKDashboard() {
<QuickActionCard title="Neuen Use Case erstellen" description="Starten Sie den 5-Schritte-Wizard" <QuickActionCard title="Neuen Use Case erstellen" description="Starten Sie den 5-Schritte-Wizard"
icon={<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" /></svg>} icon={<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" /></svg>}
href="/sdk/advisory-board" color="bg-purple-50" projectId={projectId} /> href="/sdk/advisory-board" color="bg-purple-50" projectId={projectId} />
<QuickActionCard title="Security Screening" description="SBOM generieren und Schwachstellen scannen" {/* "Security Screening" entfernt: Repo-Scanning (SBOM/SAST/DAST/Vuln) läuft
icon={<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>} extern im Compliance-Scanner (CERTifAI); das Ergebnis wird dort angezeigt. */}
href="/sdk/screening" color="bg-red-50" projectId={projectId} />
<QuickActionCard title="DSFA generieren" description="Datenschutz-Folgenabschaetzung erstellen" <QuickActionCard title="DSFA generieren" description="Datenschutz-Folgenabschaetzung erstellen"
icon={<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>} icon={<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>}
href="/sdk/dsfa" color="bg-blue-50" projectId={projectId} /> href="/sdk/dsfa" color="bg-blue-50" projectId={projectId} />
@@ -54,6 +54,7 @@ const I = {
portfolio: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10', portfolio: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10',
roadmap: 'M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2', roadmap: 'M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2',
code: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4', code: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4',
doc: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z',
} }
function SectionHeader({ label, collapsed, tone = 'gray' }: { label: string; collapsed: boolean; tone?: 'gray' | 'indigo' | 'purple' | 'slate' }) { function SectionHeader({ label, collapsed, tone = 'gray' }: { label: string; collapsed: boolean; tone?: 'gray' | 'indigo' | 'purple' | 'slate' }) {
@@ -86,6 +87,7 @@ export function SidebarModuleList({ collapsed, projectId, pendingCRCount }: Side
{item('/sdk/gap-analysis', I.barChart, 'Gap-Analyse', true)} {item('/sdk/gap-analysis', I.barChart, 'Gap-Analyse', true)}
{item('/sdk/iace', I.iace, 'Maschinensicherheit (CE)', true)} {item('/sdk/iace', I.iace, 'Maschinensicherheit (CE)', true)}
{item('/sdk/cra', I.shieldCheck, 'Cyber Resilience (CRA)', true)} {item('/sdk/cra', I.shieldCheck, 'Cyber Resilience (CRA)', true)}
{item('/sdk/cra-meldewesen', I.warning, 'CRA-Meldewesen', true)}
</div> </div>
{/* KI-Compliance */} {/* KI-Compliance */}
@@ -105,9 +107,10 @@ export function SidebarModuleList({ collapsed, projectId, pendingCRCount }: Side
{item('/sdk/cookie-banner/preview', I.eyeCircle, 'Cookie Live-Vorschau')} {item('/sdk/cookie-banner/preview', I.eyeCircle, 'Cookie Live-Vorschau')}
</div> </div>
{/* Verträge & Audit */} {/* Dokumente & Verträge */}
<div className="border-t border-gray-100 py-2"> <div className="border-t border-gray-100 py-2">
<SectionHeader label="Verträge & Audit" collapsed={collapsed} /> <SectionHeader label="Dokumente & Verträge" collapsed={collapsed} />
{item('/sdk/dokumente', I.doc, 'Dokumente', true)}
{item('/sdk/vendor-assessment', I.clipboardCheck, 'Vertragspruefung', true)} {item('/sdk/vendor-assessment', I.clipboardCheck, 'Vertragspruefung', true)}
{item('/sdk/audit-timeline', I.clock, 'Audit Timeline', true)} {item('/sdk/audit-timeline', I.clock, 'Audit Timeline', true)}
</div> </div>
+6 -2
View File
@@ -78,11 +78,15 @@ export const SDK_STEPS: SDKStep[] = [
order: 5, order: 5,
name: 'System Screening', name: 'System Screening',
nameShort: 'Screening', nameShort: 'Screening',
description: 'SBOM + Vulnerability Scan (OSV.dev)', description: 'SBOM + Security-Scan — erfolgt extern im Compliance-Scanner (CERTifAI)',
url: '/sdk/screening', url: '/sdk/screening',
checkpointId: 'CP-SCAN', checkpointId: 'CP-SCAN',
prerequisiteSteps: ['use-case-assessment'], prerequisiteSteps: ['use-case-assessment'],
isOptional: true }, isOptional: true,
// Aus der Navigation genommen: Repo-Scanning (SBOM/SAST/DAST/Vuln) läuft in
// Sharangs Compliance-Scanner; das Ergebnis wird DORT angezeigt. Unser SDK
// konsumiert die Findings nur für Cyber-trifft-Safety (CRA/CE/IACE).
visibleWhen: () => false },
// Modules entfernt — Regulierungen werden im Scope-Decision-Tab + Dashboard angezeigt // Modules entfernt — Regulierungen werden im Scope-Decision-Tab + Dashboard angezeigt
{ {
id: 'source-policy', id: 'source-policy',
@@ -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_routes import router as cra_router
from compliance.api.cra_assess_routes import router as cra_assess_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_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.cra_link_routes import router as cra_link_router
from compliance.api.quaidal_routes import router as quaidal_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") app.include_router(import_router, prefix="/api")
# System Screening (SBOM generation, vulnerability scan) # 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") app.include_router(screening_router, prefix="/api")
# Company Profile (CRUD with audit logging) # 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_router, prefix="/api")
app.include_router(cra_assess_router, prefix="/api") app.include_router(cra_assess_router, prefix="/api")
app.include_router(cra_progress_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(cra_link_router, prefix="/api")
app.include_router(quaidal_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"]