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,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>
)
}