feat(cra): Phase 4 — Vulnerability Disclosure + Post-Market Monitoring
Migration 121: compliance_cra_vulnerabilities table with full lifecycle tracking
- Status state machine: reported → triaged → patched → disclosed (+ withdrawn)
- CRA Art. 14(2) deadlines tracked: reported_to_enisa_at (24h), detailed_report_at (72h)
- CVE-ID, severity, CVSS, affected_components (JSONB), embargo_until
Backend endpoints in cra_routes.py:
- POST /vulnerabilities — create with validation (severity, CVSS range)
- GET /vulnerabilities — list with deadline-breach summary (24h/72h counters)
- PATCH /vulnerabilities/{id} — update fields + auto-set lifecycle timestamps
- DELETE /vulnerabilities/{id} — soft-delete (withdrawn)
- GET /monitoring — combined view: CRA deadlines + vuln summary + post-market checklist
Frontend:
- /vuln page: intake form, vuln cards with 24h/72h-countdown buttons,
status-transition flow with auto-timestamps
- /monitoring page: CRA deadlines (11.06.26 / 11.09.26 / 11.12.27), breach banner
if 24h/72h obligations missed, post-market checklist with deep-links
- Dashboard: +2 buttons (Vulns, Monitoring)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function GET(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await ctx.params
|
||||
const tenantId = request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
|
||||
try {
|
||||
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/${id}/monitoring`, {
|
||||
headers: { 'X-Tenant-ID': tenantId },
|
||||
})
|
||||
const text = await resp.text()
|
||||
return new NextResponse(text, {
|
||||
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,42 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
function tenant(req: NextRequest) {
|
||||
return req.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await ctx.params
|
||||
try {
|
||||
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/${id}/vulnerabilities`, {
|
||||
headers: { 'X-Tenant-ID': tenant(request) },
|
||||
})
|
||||
const text = await resp.text()
|
||||
return new NextResponse(text, {
|
||||
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 POST(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await ctx.params
|
||||
const body = await request.text()
|
||||
try {
|
||||
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/${id}/vulnerabilities`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Tenant-ID': tenant(request), 'Content-Type': 'application/json' },
|
||||
body,
|
||||
})
|
||||
const text = await resp.text()
|
||||
return new NextResponse(text, {
|
||||
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,43 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
function tenant(req: NextRequest) {
|
||||
return req.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, ctx: { params: Promise<{ vulnId: string }> }) {
|
||||
const { vulnId } = await ctx.params
|
||||
const body = await request.text()
|
||||
try {
|
||||
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/vulnerabilities/${vulnId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'X-Tenant-ID': tenant(request), 'Content-Type': 'application/json' },
|
||||
body,
|
||||
})
|
||||
const text = await resp.text()
|
||||
return new NextResponse(text, {
|
||||
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 DELETE(request: NextRequest, ctx: { params: Promise<{ vulnId: string }> }) {
|
||||
const { vulnId } = await ctx.params
|
||||
try {
|
||||
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/vulnerabilities/${vulnId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-Tenant-ID': tenant(request) },
|
||||
})
|
||||
const text = await resp.text()
|
||||
return new NextResponse(text, {
|
||||
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,168 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState, useCallback, use } from 'react'
|
||||
|
||||
interface MonitoringData {
|
||||
project_id: string
|
||||
deadlines: { date: string; label: string }[]
|
||||
summary: {
|
||||
active_vulns: number
|
||||
critical_vulns: number
|
||||
high_vulns: number
|
||||
breached_24h_reporting: number
|
||||
breached_72h_reporting: number
|
||||
sbom_versions: number
|
||||
configured_checks: number
|
||||
}
|
||||
post_market_checklist: { item: string; done: boolean; href_suffix: string }[]
|
||||
}
|
||||
|
||||
export default function MonitoringPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ projectId: string }>
|
||||
}) {
|
||||
const { projectId } = use(params)
|
||||
const [data, setData] = useState<MonitoringData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}/monitoring`, {
|
||||
headers: { 'X-Tenant-ID': '00000000-0000-0000-0000-000000000001' },
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
setData(await res.json())
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
if (loading) return <div className="min-h-screen bg-gray-50 p-8"><p className="text-gray-500">Laedt...</p></div>
|
||||
if (error) return <div className="min-h-screen bg-gray-50 p-8"><p className="text-red-600">{error}</p></div>
|
||||
if (!data) return null
|
||||
|
||||
const completeness = data.post_market_checklist.filter(c => c.done).length
|
||||
const totalChecks = data.post_market_checklist.length
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-5xl mx-auto px-4">
|
||||
<div className="mb-6">
|
||||
<a href={`/sdk/cra/${projectId}`} className="text-sm text-blue-600 hover:underline">
|
||||
← Zurueck zum Projekt
|
||||
</a>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mt-2">Post-Market Monitoring</h1>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
CRA-Stichtage + Vuln-Reporting-Compliance + Post-Market-Pflichten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* CRA-Stichtage */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5 mb-6">
|
||||
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">CRA-Stichtage</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{data.deadlines.map(d => {
|
||||
const target = new Date(d.date).getTime()
|
||||
const days = Math.round((target - Date.now()) / 86400000)
|
||||
const isPast = days < 0
|
||||
const isSoon = days >= 0 && days < 90
|
||||
const styles = isPast ? 'bg-gray-100 border-gray-200' :
|
||||
isSoon ? 'bg-red-50 border-red-200' :
|
||||
days < 365 ? 'bg-orange-50 border-orange-200' :
|
||||
'bg-blue-50 border-blue-200'
|
||||
return (
|
||||
<div key={d.date} className={`rounded-lg border p-4 ${styles}`}>
|
||||
<div className="text-xs text-gray-500">{d.date}</div>
|
||||
<div className="font-semibold text-gray-900 text-sm mt-0.5">{d.label}</div>
|
||||
<div className="text-xs mt-1 text-gray-700">
|
||||
{isPast ? `vor ${-days} Tagen` : `noch ${days} Tage`}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vuln-Reporting Compliance Banner */}
|
||||
{(data.summary.breached_24h_reporting > 0 || data.summary.breached_72h_reporting > 0) && (
|
||||
<div className="bg-red-50 border-2 border-red-300 rounded-xl p-5 mb-6">
|
||||
<h3 className="text-sm font-bold text-red-900 uppercase tracking-wide mb-2">⚠ CRA-Pflichten verletzt</h3>
|
||||
{data.summary.breached_24h_reporting > 0 && (
|
||||
<p className="text-sm text-red-800">
|
||||
<span className="font-semibold">{data.summary.breached_24h_reporting}</span> Schwachstelle(n) ohne 24h-Fruehwarnung an ENISA — Art. 14(2)(a) CRA.
|
||||
</p>
|
||||
)}
|
||||
{data.summary.breached_72h_reporting > 0 && (
|
||||
<p className="text-sm text-red-800 mt-1">
|
||||
<span className="font-semibold">{data.summary.breached_72h_reporting}</span> Schwachstelle(n) ohne 72h-Detailbericht — Art. 14(2)(b) CRA.
|
||||
</p>
|
||||
)}
|
||||
<a href={`/sdk/cra/${projectId}/vuln`} className="inline-block mt-2 text-sm text-red-700 underline font-medium">
|
||||
Zu den Schwachstellen →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
||||
<SummaryCard label="Aktive Vulns" value={data.summary.active_vulns} subtitle={`${data.summary.critical_vulns} Critical · ${data.summary.high_vulns} High`} color="blue" />
|
||||
<SummaryCard label="SBOM-Versionen" value={data.summary.sbom_versions} subtitle={data.summary.sbom_versions === 0 ? 'noch keine' : 'hochgeladen'} color={data.summary.sbom_versions > 0 ? 'green' : 'gray'} />
|
||||
<SummaryCard label="Aktive Checks" value={data.summary.configured_checks} subtitle={data.summary.configured_checks === 0 ? 'init noetig' : 'konfiguriert'} color={data.summary.configured_checks > 0 ? 'green' : 'gray'} />
|
||||
<SummaryCard label="Post-Market" value={`${completeness}/${totalChecks}`} subtitle="erfuellt" color={completeness === totalChecks ? 'green' : 'orange'} />
|
||||
</div>
|
||||
|
||||
{/* Post-Market Checklist */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5 mb-6">
|
||||
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Post-Market-Pflichten</h3>
|
||||
<ul className="space-y-2">
|
||||
{data.post_market_checklist.map((c, i) => (
|
||||
<li key={i} className="flex items-center gap-3">
|
||||
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${
|
||||
c.done ? 'bg-green-500 text-white' : 'bg-gray-200 text-gray-400'
|
||||
}`}>
|
||||
{c.done ? '✓' : '○'}
|
||||
</span>
|
||||
<span className={`text-sm ${c.done ? 'text-gray-700' : 'text-gray-900 font-medium'}`}>{c.item}</span>
|
||||
{!c.done && (
|
||||
<a
|
||||
href={`/sdk/cra/${projectId}/${c.href_suffix}`}
|
||||
className="ml-auto text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
Erledigen →
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 text-sm text-blue-900">
|
||||
<strong>Hinweis:</strong> Diese Seite aggregiert CRA-Pflichten aus SBOM, Checks und Vulnerability-Tracker. Die Reporting-Pflichten 24h/72h gelten ab CRA Art. 14(2) — verletzte Fristen erscheinen als rotes Banner.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SummaryCard({ label, value, subtitle, color }: { label: string; value: number | string; subtitle: string; color: 'blue' | 'red' | 'green' | 'orange' | 'gray' }) {
|
||||
const bg = {
|
||||
blue: 'bg-blue-50 border-blue-200 text-blue-700',
|
||||
red: 'bg-red-50 border-red-200 text-red-700',
|
||||
green: 'bg-green-50 border-green-200 text-green-700',
|
||||
orange: 'bg-orange-50 border-orange-200 text-orange-700',
|
||||
gray: 'bg-gray-50 border-gray-200 text-gray-600',
|
||||
}[color]
|
||||
return (
|
||||
<div className={`rounded-xl border p-3 ${bg}`}>
|
||||
<p className="text-xs uppercase tracking-wide">{label}</p>
|
||||
<p className="text-2xl font-bold mt-1">{value}</p>
|
||||
<p className="text-xs mt-0.5 opacity-80">{subtitle}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -175,31 +175,14 @@ export default function CRAProjectDashboard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
||||
<a
|
||||
href={`/sdk/cra/${projectId}/requirements`}
|
||||
className="text-center py-2 bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 text-sm font-medium"
|
||||
>
|
||||
→ Requirements (40)
|
||||
</a>
|
||||
<a
|
||||
href={`/sdk/cra/${projectId}/backlog`}
|
||||
className="text-center py-2 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 text-sm font-medium"
|
||||
>
|
||||
→ Backlog
|
||||
</a>
|
||||
<a
|
||||
href={`/sdk/cra/${projectId}/sbom`}
|
||||
className="text-center py-2 bg-green-100 text-green-700 rounded-lg hover:bg-green-200 text-sm font-medium"
|
||||
>
|
||||
→ SBOM
|
||||
</a>
|
||||
<a
|
||||
href={`/sdk/cra/${projectId}/checks`}
|
||||
className="text-center py-2 bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 text-sm font-medium"
|
||||
>
|
||||
→ Checks
|
||||
</a>
|
||||
<div className="grid grid-cols-2 md:grid-cols-7 gap-2 mb-6">
|
||||
<a href={`/sdk/cra/${projectId}/requirements`} className="text-center py-2 bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 text-xs font-medium">Requirements</a>
|
||||
<a href={`/sdk/cra/${projectId}/backlog`} className="text-center py-2 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 text-xs font-medium">Backlog</a>
|
||||
<a href={`/sdk/cra/${projectId}/sbom`} className="text-center py-2 bg-green-100 text-green-700 rounded-lg hover:bg-green-200 text-xs font-medium">SBOM</a>
|
||||
<a href={`/sdk/cra/${projectId}/checks`} className="text-center py-2 bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 text-xs font-medium">Checks</a>
|
||||
<a href={`/sdk/cra/${projectId}/vuln`} className="text-center py-2 bg-orange-100 text-orange-700 rounded-lg hover:bg-orange-200 text-xs font-medium">Vulns (CVD)</a>
|
||||
<a href={`/sdk/cra/${projectId}/monitoring`} className="text-center py-2 bg-yellow-100 text-yellow-700 rounded-lg hover:bg-yellow-200 text-xs font-medium">Monitoring</a>
|
||||
<a href={`/sdk/cra/${projectId}/documents`} className="text-center py-2 bg-teal-100 text-teal-700 rounded-lg hover:bg-teal-200 text-xs font-medium">Dokumente</a>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState, useCallback, use } from 'react'
|
||||
import { SeverityBadge } from '../../_components/SeverityBadge'
|
||||
|
||||
interface Vuln {
|
||||
id: string
|
||||
cve_id: string | null
|
||||
title: string
|
||||
description: string
|
||||
severity: string | null
|
||||
cvss_score: number | null
|
||||
affected_components: string[]
|
||||
reporter_source: string
|
||||
reporter_contact: string | null
|
||||
discovered_at: string
|
||||
triaged_at: string | null
|
||||
patched_at: string | null
|
||||
disclosed_at: string | null
|
||||
embargo_until: string | null
|
||||
reported_to_enisa_at: string | null
|
||||
detailed_report_at: string | null
|
||||
status: string
|
||||
notes: string
|
||||
}
|
||||
|
||||
interface VulnListResponse {
|
||||
project_id: string
|
||||
total: number
|
||||
summary: {
|
||||
critical_open: number
|
||||
breached_24h_reporting: number
|
||||
breached_72h_reporting: number
|
||||
by_status: Record<string, number>
|
||||
}
|
||||
items: Vuln[]
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
reported: 'Gemeldet',
|
||||
triaged: 'Triagiert',
|
||||
patched: 'Gepatcht',
|
||||
disclosed: 'Offengelegt',
|
||||
withdrawn: 'Zurueckgezogen',
|
||||
}
|
||||
|
||||
const STATUS_NEXT: Record<string, { status: string; label: string } | null> = {
|
||||
reported: { status: 'triaged', label: 'Triagieren' },
|
||||
triaged: { status: 'patched', label: 'Patch verfuegbar' },
|
||||
patched: { status: 'disclosed', label: 'Offenlegen' },
|
||||
disclosed: null,
|
||||
withdrawn: null,
|
||||
}
|
||||
|
||||
function ageHours(iso: string | null): number {
|
||||
if (!iso) return 0
|
||||
return (Date.now() - new Date(iso).getTime()) / 3600000
|
||||
}
|
||||
|
||||
function fmtRemaining(iso: string | null, hours: number): { label: string; color: string } {
|
||||
if (!iso) return { label: '—', color: 'text-gray-400' }
|
||||
const age = ageHours(iso)
|
||||
const remaining = hours - age
|
||||
if (remaining < 0) return { label: `+${Math.round(-remaining)}h ueber Frist`, color: 'text-red-600 font-semibold' }
|
||||
if (remaining < 4) return { label: `noch ${remaining.toFixed(1)}h`, color: 'text-orange-600 font-semibold' }
|
||||
return { label: `noch ${Math.round(remaining)}h`, color: 'text-gray-600' }
|
||||
}
|
||||
|
||||
export default function VulnPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ projectId: string }>
|
||||
}) {
|
||||
const { projectId } = use(params)
|
||||
const [data, setData] = useState<VulnListResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [transitioning, setTransitioning] = useState<string | null>(null)
|
||||
|
||||
// New vuln form state
|
||||
const [title, setTitle] = useState('')
|
||||
const [cveId, setCveId] = useState('')
|
||||
const [severity, setSeverity] = useState('')
|
||||
const [cvssScore, setCvssScore] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [components, setComponents] = useState('')
|
||||
const [reporterSource, setReporterSource] = useState('internal')
|
||||
const [reporterContact, setReporterContact] = useState('')
|
||||
|
||||
const tenant = '00000000-0000-0000-0000-000000000001'
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}/vulnerabilities`, {
|
||||
headers: { 'X-Tenant-ID': tenant },
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
setData(await res.json())
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const create = async () => {
|
||||
if (!title.trim()) return
|
||||
setCreating(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}/vulnerabilities`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenant },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
cve_id: cveId || null,
|
||||
description,
|
||||
severity: severity || null,
|
||||
cvss_score: cvssScore ? parseFloat(cvssScore) : null,
|
||||
affected_components: components.split(',').map(s => s.trim()).filter(Boolean),
|
||||
reporter_source: reporterSource,
|
||||
reporter_contact: reporterContact || null,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
setShowForm(false)
|
||||
setTitle(''); setCveId(''); setSeverity(''); setCvssScore('')
|
||||
setDescription(''); setComponents(''); setReporterContact('')
|
||||
await load()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Anlegen fehlgeschlagen')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const transition = async (vulnId: string, nextStatus: string) => {
|
||||
setTransitioning(vulnId)
|
||||
setError('')
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/cra/vulnerabilities/${vulnId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenant },
|
||||
body: JSON.stringify({ status: nextStatus }),
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
await load()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Statuswechsel fehlgeschlagen')
|
||||
} finally {
|
||||
setTransitioning(null)
|
||||
}
|
||||
}
|
||||
|
||||
const markReported = async (vulnId: string, field: 'reported_to_enisa_at' | 'detailed_report_at') => {
|
||||
setTransitioning(vulnId)
|
||||
setError('')
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/cra/vulnerabilities/${vulnId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenant },
|
||||
body: JSON.stringify({ [field]: new Date().toISOString() }),
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
await load()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Reporting fehlgeschlagen')
|
||||
} finally {
|
||||
setTransitioning(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="min-h-screen bg-gray-50 p-8"><p className="text-gray-500">Laedt...</p></div>
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-6xl mx-auto px-4">
|
||||
<div className="mb-6">
|
||||
<a href={`/sdk/cra/${projectId}`} className="text-sm text-blue-600 hover:underline">
|
||||
← Zurueck zum Projekt
|
||||
</a>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mt-2">Vulnerability Disclosure (CVD)</h1>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Schwachstellen tracken. CRA-Pflichten: 24h Fruehwarnung an ENISA, 72h Detailbericht.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">
|
||||
<pre className="whitespace-pre-wrap">{error}</pre>
|
||||
<button onClick={() => setError('')} className="text-red-500 underline text-xs">Schliessen</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary KPIs */}
|
||||
{data && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
||||
<SummaryCard label="Aktive Vulns" value={data.total - (data.summary.by_status.withdrawn || 0)} color="blue" />
|
||||
<SummaryCard label="Critical offen" value={data.summary.critical_open} color={data.summary.critical_open > 0 ? 'red' : 'green'} />
|
||||
<SummaryCard label="24h-Reporting versaeumt" value={data.summary.breached_24h_reporting} color={data.summary.breached_24h_reporting > 0 ? 'red' : 'green'} />
|
||||
<SummaryCard label="72h-Reporting versaeumt" value={data.summary.breached_72h_reporting} color={data.summary.breached_72h_reporting > 0 ? 'red' : 'green'} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
className="mb-4 w-full py-3 border-2 border-dashed border-red-300 rounded-xl text-red-600 hover:bg-red-50 font-medium"
|
||||
>
|
||||
{showForm ? 'Abbrechen' : '+ Neue Schwachstelle melden'}
|
||||
</button>
|
||||
|
||||
{showForm && (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5 mb-6">
|
||||
<h3 className="text-sm font-semibold mb-3">Neue Schwachstelle</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-xs text-gray-600 mb-1">Titel *</label>
|
||||
<input value={title} onChange={e => setTitle(e.target.value)} className="w-full px-3 py-2 border rounded text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">CVE-ID (optional)</label>
|
||||
<input value={cveId} onChange={e => setCveId(e.target.value)} placeholder="CVE-2026-12345" className="w-full px-3 py-2 border rounded text-sm font-mono" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Severity</label>
|
||||
<select value={severity} onChange={e => setSeverity(e.target.value)} className="w-full px-3 py-2 border rounded text-sm">
|
||||
<option value="">— waehlen —</option>
|
||||
<option value="LOW">LOW</option>
|
||||
<option value="MEDIUM">MEDIUM</option>
|
||||
<option value="HIGH">HIGH</option>
|
||||
<option value="CRITICAL">CRITICAL</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">CVSS Score (0-10)</label>
|
||||
<input type="number" min="0" max="10" step="0.1" value={cvssScore} onChange={e => setCvssScore(e.target.value)} className="w-full px-3 py-2 border rounded text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Reporter</label>
|
||||
<select value={reporterSource} onChange={e => setReporterSource(e.target.value)} className="w-full px-3 py-2 border rounded text-sm">
|
||||
<option value="internal">Intern</option>
|
||||
<option value="external">Extern (Kunde/Partner)</option>
|
||||
<option value="researcher">Security Researcher</option>
|
||||
<option value="scanner">Automatisierter Scanner</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-xs text-gray-600 mb-1">Reporter-Kontakt</label>
|
||||
<input value={reporterContact} onChange={e => setReporterContact(e.target.value)} placeholder="email@..." className="w-full px-3 py-2 border rounded text-sm" />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-xs text-gray-600 mb-1">Betroffene Komponenten (Komma-getrennt)</label>
|
||||
<input value={components} onChange={e => setComponents(e.target.value)} placeholder="lodash@4.17.20, axios@0.21.0" className="w-full px-3 py-2 border rounded text-sm font-mono" />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-xs text-gray-600 mb-1">Beschreibung</label>
|
||||
<textarea value={description} onChange={e => setDescription(e.target.value)} rows={3} className="w-full px-3 py-2 border rounded text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={create}
|
||||
disabled={creating || !title.trim()}
|
||||
className="mt-4 w-full py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:bg-gray-300 font-medium"
|
||||
>
|
||||
{creating ? 'Erstelle...' : 'Schwachstelle erfassen'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && data.items.length === 0 && !showForm && (
|
||||
<div className="bg-gray-100 rounded-xl p-8 text-center text-gray-500">
|
||||
Noch keine Schwachstellen erfasst.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && data.items.map(v => {
|
||||
const tx = STATUS_NEXT[v.status]
|
||||
const rep24 = fmtRemaining(v.discovered_at, 24)
|
||||
const rep72 = fmtRemaining(v.discovered_at, 72)
|
||||
return (
|
||||
<div key={v.id} className="bg-white rounded-xl shadow-sm border border-gray-200 p-5 mb-3">
|
||||
<div className="flex items-start justify-between gap-4 mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-semibold text-gray-900">{v.title}</h3>
|
||||
{v.cve_id && <span className="font-mono text-xs px-1.5 py-0.5 bg-gray-100 rounded">{v.cve_id}</span>}
|
||||
{v.severity && <SeverityBadge value={v.severity} />}
|
||||
{v.cvss_score !== null && <span className="text-xs text-gray-500">CVSS {v.cvss_score}</span>}
|
||||
</div>
|
||||
{v.description && <p className="text-sm text-gray-600 mt-1">{v.description}</p>}
|
||||
{v.affected_components.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{v.affected_components.map((c, i) => (
|
||||
<span key={i} className="font-mono text-xs px-1.5 py-0.5 bg-yellow-50 text-yellow-800 rounded">{c}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-700 flex-shrink-0">
|
||||
{STATUS_LABEL[v.status] || v.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* CRA Reporting Compliance */}
|
||||
{v.status !== 'withdrawn' && (
|
||||
<div className="grid grid-cols-2 gap-3 mb-3 text-xs">
|
||||
<div className={`p-2 rounded ${v.reported_to_enisa_at ? 'bg-green-50' : 'bg-orange-50'}`}>
|
||||
<div className="font-semibold text-gray-700">24h: ENISA-Fruehwarnung</div>
|
||||
{v.reported_to_enisa_at ? (
|
||||
<div className="text-green-700">✓ {new Date(v.reported_to_enisa_at).toLocaleString('de-DE')}</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span className={rep24.color}>{rep24.label}</span>
|
||||
<button
|
||||
onClick={() => markReported(v.id, 'reported_to_enisa_at')}
|
||||
disabled={transitioning === v.id}
|
||||
className="px-2 py-0.5 bg-orange-600 text-white rounded text-xs"
|
||||
>
|
||||
Jetzt melden
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`p-2 rounded ${v.detailed_report_at ? 'bg-green-50' : 'bg-orange-50'}`}>
|
||||
<div className="font-semibold text-gray-700">72h: Detailbericht</div>
|
||||
{v.detailed_report_at ? (
|
||||
<div className="text-green-700">✓ {new Date(v.detailed_report_at).toLocaleString('de-DE')}</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span className={rep72.color}>{rep72.label}</span>
|
||||
<button
|
||||
onClick={() => markReported(v.id, 'detailed_report_at')}
|
||||
disabled={transitioning === v.id}
|
||||
className="px-2 py-0.5 bg-orange-600 text-white rounded text-xs"
|
||||
>
|
||||
Jetzt melden
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||
<div>
|
||||
Entdeckt: {new Date(v.discovered_at).toLocaleString('de-DE')}
|
||||
{v.patched_at && <> · Gepatcht: {new Date(v.patched_at).toLocaleString('de-DE')}</>}
|
||||
{v.disclosed_at && <> · Offengelegt: {new Date(v.disclosed_at).toLocaleString('de-DE')}</>}
|
||||
</div>
|
||||
{tx && (
|
||||
<button
|
||||
onClick={() => transition(v.id, tx.status)}
|
||||
disabled={transitioning === v.id}
|
||||
className="px-3 py-1 bg-blue-600 text-white rounded text-xs hover:bg-blue-700 disabled:bg-gray-300"
|
||||
>
|
||||
→ {tx.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SummaryCard({ label, value, color }: { label: string; value: number; color: 'blue' | 'red' | 'green' | 'orange' }) {
|
||||
const bg = {
|
||||
blue: 'bg-blue-50 border-blue-200 text-blue-700',
|
||||
red: 'bg-red-50 border-red-200 text-red-700',
|
||||
green: 'bg-green-50 border-green-200 text-green-700',
|
||||
orange: 'bg-orange-50 border-orange-200 text-orange-700',
|
||||
}[color]
|
||||
return (
|
||||
<div className={`rounded-xl border p-3 ${bg}`}>
|
||||
<p className="text-xs uppercase tracking-wide">{label}</p>
|
||||
<p className="text-2xl font-bold mt-1">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user