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:
Benjamin Admin
2026-05-18 22:08:49 +02:00
parent 0a64da74bb
commit cc80e59e5e
8 changed files with 1360 additions and 25 deletions
@@ -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">
&larr; 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">
&larr; 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>
)
}
@@ -1073,3 +1073,642 @@ async def run_check(
raise
finally:
db.close()
# =============================================================================
# PHASE 4: Vulnerability Disclosure + Post-Market Monitoring
# =============================================================================
VULN_STATUS_WHITELIST = {"reported", "triaged", "patched", "disclosed", "withdrawn"}
class CreateVulnRequest(BaseModel):
title: str
description: str = ""
cve_id: Optional[str] = None
severity: Optional[str] = None # LOW | MEDIUM | HIGH | CRITICAL
cvss_score: Optional[float] = None
affected_components: list[str] = []
reporter_source: str = "internal"
reporter_contact: Optional[str] = None
notes: str = ""
class UpdateVulnRequest(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
cve_id: Optional[str] = None
severity: Optional[str] = None
cvss_score: Optional[float] = None
affected_components: Optional[list[str]] = None
reporter_source: Optional[str] = None
reporter_contact: Optional[str] = None
notes: Optional[str] = None
status: Optional[str] = None
embargo_until: Optional[str] = None # ISO datetime
# Lifecycle timestamps — clients can set these explicitly or via status transition
triaged_at: Optional[str] = None
patched_at: Optional[str] = None
disclosed_at: Optional[str] = None
reported_to_enisa_at: Optional[str] = None
detailed_report_at: Optional[str] = None
def _vuln_to_dict(row) -> dict:
def _iso(v):
return v.isoformat() if v else None
components = row.affected_components
if isinstance(components, str):
try:
components = json.loads(components)
except Exception:
components = []
return {
"id": str(row.id),
"cra_project_id": str(row.cra_project_id),
"cve_id": row.cve_id,
"title": row.title,
"description": row.description or "",
"severity": row.severity,
"cvss_score": float(row.cvss_score) if row.cvss_score is not None else None,
"affected_components": components or [],
"reporter_source": row.reporter_source or "internal",
"reporter_contact": row.reporter_contact,
"discovered_at": _iso(row.discovered_at),
"triaged_at": _iso(row.triaged_at),
"patched_at": _iso(row.patched_at),
"disclosed_at": _iso(row.disclosed_at),
"embargo_until": _iso(row.embargo_until),
"reported_to_enisa_at": _iso(row.reported_to_enisa_at),
"detailed_report_at": _iso(row.detailed_report_at),
"status": row.status,
"notes": row.notes or "",
"created_at": _iso(row.created_at),
"updated_at": _iso(row.updated_at),
}
@router.post("/{project_id}/vulnerabilities", status_code=201)
async def create_vulnerability(
project_id: str,
body: CreateVulnRequest,
tenant_id: str = Depends(get_tenant_id),
):
"""Create a vulnerability record. discovered_at = now()."""
db = SessionLocal()
try:
if not _cra_project_exists(db, project_id, tenant_id):
raise HTTPException(status_code=404, detail="CRA project not found")
if body.severity and body.severity not in ("LOW", "MEDIUM", "HIGH", "CRITICAL"):
raise HTTPException(status_code=400, detail="Invalid severity")
if body.cvss_score is not None and not (0.0 <= body.cvss_score <= 10.0):
raise HTTPException(status_code=400, detail="cvss_score must be 0.0-10.0")
row = db.execute(
text("""
INSERT INTO compliance_cra_vulnerabilities
(cra_project_id, tenant_id, cve_id, title, description,
severity, cvss_score, affected_components,
reporter_source, reporter_contact, notes)
VALUES
(:pid, :tid, :cve, :title, :desc,
:sev, :cvss, CAST(:comp AS jsonb),
:src, :rcontact, :notes)
RETURNING *
"""),
{
"pid": project_id, "tid": tenant_id,
"cve": body.cve_id, "title": body.title, "desc": body.description,
"sev": body.severity, "cvss": body.cvss_score,
"comp": json.dumps(body.affected_components),
"src": body.reporter_source, "rcontact": body.reporter_contact,
"notes": body.notes,
},
).fetchone()
db.commit()
return _vuln_to_dict(row)
except HTTPException:
raise
except Exception:
db.rollback()
raise
finally:
db.close()
@router.get("/{project_id}/vulnerabilities")
async def list_vulnerabilities(
project_id: str,
tenant_id: str = Depends(get_tenant_id),
):
"""List all vulnerabilities for this project, newest first."""
db = SessionLocal()
try:
if not _cra_project_exists(db, project_id, tenant_id):
raise HTTPException(status_code=404, detail="CRA project not found")
rows = db.execute(
text("""
SELECT * FROM compliance_cra_vulnerabilities
WHERE cra_project_id = :pid AND tenant_id = :tid
ORDER BY discovered_at DESC
"""),
{"pid": project_id, "tid": tenant_id},
).fetchall()
items = [_vuln_to_dict(r) for r in rows]
# Compliance summary
critical_open = sum(1 for v in items if v["severity"] == "CRITICAL" and v["status"] in ("reported", "triaged"))
breached_24h = 0
breached_72h = 0
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
for v in items:
if not v["discovered_at"]:
continue
disc = datetime.fromisoformat(v["discovered_at"])
age_hours = (now - disc).total_seconds() / 3600
if age_hours > 24 and not v["reported_to_enisa_at"]:
breached_24h += 1
if age_hours > 72 and not v["detailed_report_at"]:
breached_72h += 1
return {
"project_id": project_id,
"total": len(items),
"summary": {
"critical_open": critical_open,
"breached_24h_reporting": breached_24h,
"breached_72h_reporting": breached_72h,
"by_status": {s: sum(1 for v in items if v["status"] == s) for s in VULN_STATUS_WHITELIST},
},
"items": items,
}
finally:
db.close()
@router.patch("/vulnerabilities/{vuln_id}")
async def update_vulnerability(
vuln_id: str,
body: UpdateVulnRequest,
tenant_id: str = Depends(get_tenant_id),
):
"""Update vuln fields incl. status transition + lifecycle timestamps."""
from datetime import datetime
db = SessionLocal()
try:
updates: dict = {"vid": vuln_id, "tid": tenant_id}
set_parts = ["updated_at = NOW()"]
for field in (
"title", "description", "cve_id", "severity", "cvss_score",
"reporter_source", "reporter_contact", "notes", "status",
):
val = getattr(body, field)
if val is None:
continue
if field == "status" and val not in VULN_STATUS_WHITELIST:
raise HTTPException(status_code=400, detail=f"Invalid status. Allowed: {sorted(VULN_STATUS_WHITELIST)}")
if field == "severity" and val not in ("LOW", "MEDIUM", "HIGH", "CRITICAL"):
raise HTTPException(status_code=400, detail="Invalid severity")
if field == "cvss_score" and not (0.0 <= float(val) <= 10.0):
raise HTTPException(status_code=400, detail="cvss_score must be 0.0-10.0")
set_parts.append(f"{field} = :{field}")
updates[field] = val
if body.affected_components is not None:
set_parts.append("affected_components = CAST(:comp AS jsonb)")
updates["comp"] = json.dumps(body.affected_components)
# Auto-set timestamps on status transitions
# If client passes status='triaged' and triaged_at is None, set to NOW()
if body.status == "triaged" and not body.triaged_at:
set_parts.append("triaged_at = COALESCE(triaged_at, NOW())")
if body.status == "patched" and not body.patched_at:
set_parts.append("patched_at = COALESCE(patched_at, NOW())")
if body.status == "disclosed" and not body.disclosed_at:
set_parts.append("disclosed_at = COALESCE(disclosed_at, NOW())")
for ts_field in ("triaged_at", "patched_at", "disclosed_at",
"reported_to_enisa_at", "detailed_report_at", "embargo_until"):
val = getattr(body, ts_field)
if val is None:
continue
try:
datetime.fromisoformat(val.replace("Z", "+00:00"))
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid ISO datetime for {ts_field}")
set_parts.append(f"{ts_field} = :{ts_field}")
updates[ts_field] = val
if len(set_parts) == 1:
raise HTTPException(status_code=400, detail="No fields to update")
row = db.execute(
text(f"""
UPDATE compliance_cra_vulnerabilities
SET {', '.join(set_parts)}
WHERE id = :vid AND tenant_id = :tid
RETURNING *
"""),
updates,
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Vulnerability not found")
db.commit()
return _vuln_to_dict(row)
except HTTPException:
raise
except Exception:
db.rollback()
raise
finally:
db.close()
@router.delete("/vulnerabilities/{vuln_id}")
async def delete_vulnerability(
vuln_id: str,
tenant_id: str = Depends(get_tenant_id),
):
"""Mark vulnerability as withdrawn (soft delete)."""
db = SessionLocal()
try:
row = db.execute(
text("""
UPDATE compliance_cra_vulnerabilities
SET status = 'withdrawn', updated_at = NOW()
WHERE id = :vid AND tenant_id = :tid AND status != 'withdrawn'
RETURNING id
"""),
{"vid": vuln_id, "tid": tenant_id},
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Vulnerability not found or already withdrawn")
db.commit()
return {"success": True, "id": str(row.id), "status": "withdrawn"}
except HTTPException:
raise
except Exception:
db.rollback()
raise
finally:
db.close()
@router.get("/{project_id}/monitoring")
async def post_market_monitoring(
project_id: str,
tenant_id: str = Depends(get_tenant_id),
):
"""Combined Post-Market view: CRA timeline + vuln summary + checklist progress."""
from datetime import datetime, timezone
db = SessionLocal()
try:
if not _cra_project_exists(db, project_id, tenant_id):
raise HTTPException(status_code=404, detail="CRA project not found")
vulns = db.execute(
text("""
SELECT id, status, severity, discovered_at,
reported_to_enisa_at, detailed_report_at
FROM compliance_cra_vulnerabilities
WHERE cra_project_id = :pid AND tenant_id = :tid AND status != 'withdrawn'
"""),
{"pid": project_id, "tid": tenant_id},
).fetchall()
sbom_count = db.execute(
text("SELECT count(*) FROM compliance_cra_sboms WHERE cra_project_id = :pid AND tenant_id = :tid"),
{"pid": project_id, "tid": tenant_id},
).scalar()
checks_count = db.execute(
text("""
SELECT count(*) FROM compliance_evidence_checks
WHERE tenant_id = CAST(:tid AS uuid) AND project_id = :pid AND check_code LIKE 'cra_%'
"""),
{"tid": tenant_id, "pid": project_id},
).scalar()
now = datetime.now(timezone.utc)
breached_24h = 0
breached_72h = 0
for v in vulns:
if not v.discovered_at:
continue
age = (now - v.discovered_at).total_seconds() / 3600
if age > 24 and not v.reported_to_enisa_at:
breached_24h += 1
if age > 72 and not v.detailed_report_at:
breached_72h += 1
checklist = [
{"item": "SBOM hochgeladen", "done": (sbom_count or 0) > 0,
"href_suffix": "sbom"},
{"item": "Automatisierte Checks konfiguriert", "done": (checks_count or 0) > 0,
"href_suffix": "checks"},
{"item": "Vulnerability-Tracking aktiv", "done": len(vulns) > 0,
"href_suffix": "vuln"},
{"item": "Keine 24h-Reporting-Pflichten ueberzogen", "done": breached_24h == 0,
"href_suffix": "vuln"},
{"item": "Keine 72h-Reporting-Pflichten ueberzogen", "done": breached_72h == 0,
"href_suffix": "vuln"},
]
return {
"project_id": project_id,
"deadlines": DEADLINES,
"summary": {
"active_vulns": len(vulns),
"critical_vulns": sum(1 for v in vulns if v.severity == "CRITICAL"),
"high_vulns": sum(1 for v in vulns if v.severity == "HIGH"),
"breached_24h_reporting": breached_24h,
"breached_72h_reporting": breached_72h,
"sbom_versions": sbom_count or 0,
"configured_checks": checks_count or 0,
},
"post_market_checklist": checklist,
}
finally:
db.close()
# =============================================================================
# PHASE 5: Document Generation (DoC, Technical Doc, CVD Policy, Update Policy, SBOM Report)
# =============================================================================
from .cra_doc_templates import DOC_GENERATORS, DOC_TYPE_LABELS # noqa: E402
DOC_STATUS_WHITELIST = {"draft", "reviewed", "approved", "superseded"}
class GenerateDocRequest(BaseModel):
doc_type: str
manufacturer: Optional[str] = None
notified_body: Optional[str] = None
security_contact: Optional[str] = None
class ApproveDocRequest(BaseModel):
signed_by: str
status: str = "approved"
def _doc_row_to_dict(row) -> dict:
return {
"id": str(row.id),
"cra_project_id": str(row.cra_project_id),
"doc_type": row.doc_type,
"doc_type_label": DOC_TYPE_LABELS.get(row.doc_type, row.doc_type),
"title": row.title,
"content_md": row.content_md,
"version": row.version,
"requirements_coverage": (
row.requirements_coverage
if isinstance(row.requirements_coverage, dict)
else json.loads(row.requirements_coverage or "{}")
),
"status": row.status,
"signed_by": row.signed_by,
"signed_at": row.signed_at.isoformat() if row.signed_at else None,
"generated_at": row.generated_at.isoformat() if row.generated_at else None,
"superseded_at": row.superseded_at.isoformat() if row.superseded_at else None,
}
@router.post("/{project_id}/documents/generate", status_code=201)
async def generate_document(
project_id: str,
body: GenerateDocRequest,
tenant_id: str = Depends(get_tenant_id),
):
"""Generate a document of the given type from current project state."""
if body.doc_type not in DOC_GENERATORS:
raise HTTPException(
status_code=400,
detail=f"Unknown doc_type. Allowed: {sorted(DOC_GENERATORS.keys())}",
)
db = SessionLocal()
try:
proj_row = db.execute(
text("""
SELECT * FROM compliance_cra_projects
WHERE id = :pid AND tenant_id = :tid
"""),
{"pid": project_id, "tid": tenant_id},
).fetchone()
if not proj_row:
raise HTTPException(status_code=404, detail="CRA project not found")
project = _row_to_response(proj_row)
# For SBOM report: fetch latest SBOM
latest_sbom = None
if body.doc_type == "doc_sbom_report":
sbom_row = db.execute(
text("""
SELECT id, filename, format, spec_version, component_count,
summary, scan_status, uploaded_at
FROM compliance_cra_sboms
WHERE cra_project_id = :pid AND tenant_id = :tid
ORDER BY uploaded_at DESC LIMIT 1
"""),
{"pid": project_id, "tid": tenant_id},
).fetchone()
if sbom_row:
latest_sbom = {
"filename": sbom_row.filename,
"format": sbom_row.format,
"spec_version": sbom_row.spec_version,
"component_count": sbom_row.component_count,
"summary": sbom_row.summary if isinstance(sbom_row.summary, dict) else json.loads(sbom_row.summary or "{}"),
"scan_status": sbom_row.scan_status,
"uploaded_at": sbom_row.uploaded_at.isoformat() if sbom_row.uploaded_at else None,
}
# Invoke generator
gen = DOC_GENERATORS[body.doc_type]
kwargs: dict = {}
if body.doc_type == "doc_eu_conformity":
kwargs = {"manufacturer": body.manufacturer, "notified_body": body.notified_body}
elif body.doc_type == "doc_cvd_policy":
kwargs = {"security_contact": body.security_contact}
elif body.doc_type == "doc_sbom_report":
kwargs = {"latest_sbom": latest_sbom}
title, content, coverage = gen(project, **kwargs)
# Supersede previous versions of this doc_type
db.execute(
text("""
UPDATE compliance_cra_documents
SET status = 'superseded', superseded_at = NOW()
WHERE cra_project_id = :pid AND tenant_id = :tid
AND doc_type = :dtype AND status != 'superseded'
"""),
{"pid": project_id, "tid": tenant_id, "dtype": body.doc_type},
)
# Next version number
next_ver = db.execute(
text("""
SELECT COALESCE(MAX(version), 0) + 1 FROM compliance_cra_documents
WHERE cra_project_id = :pid AND doc_type = :dtype
"""),
{"pid": project_id, "dtype": body.doc_type},
).scalar()
# Snapshot project context for audit
gen_context = {
"project_status": project.get("status"),
"classification": project.get("cra_classification"),
"conformity_path": project.get("conformity_path"),
"generated_for_version": next_ver,
}
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
(:pid, :tid, :dtype, :title, :content,
:ver, CAST(:cov AS jsonb), CAST(:ctx AS jsonb), 'draft')
RETURNING *
"""),
{
"pid": project_id, "tid": tenant_id,
"dtype": body.doc_type, "title": title, "content": content,
"ver": next_ver,
"cov": json.dumps(coverage),
"ctx": json.dumps(gen_context),
},
).fetchone()
db.commit()
return _doc_row_to_dict(row)
except HTTPException:
raise
except Exception:
db.rollback()
raise
finally:
db.close()
@router.get("/{project_id}/documents")
async def list_documents(
project_id: str,
tenant_id: str = Depends(get_tenant_id),
include_superseded: bool = False,
):
"""List documents for a project. By default only latest/active version per type."""
db = SessionLocal()
try:
if not _cra_project_exists(db, project_id, tenant_id):
raise HTTPException(status_code=404, detail="CRA project not found")
if include_superseded:
rows = db.execute(
text("""
SELECT * FROM compliance_cra_documents
WHERE cra_project_id = :pid AND tenant_id = :tid
ORDER BY doc_type, version DESC
"""),
{"pid": project_id, "tid": tenant_id},
).fetchall()
else:
rows = db.execute(
text("""
SELECT DISTINCT ON (doc_type) *
FROM compliance_cra_documents
WHERE cra_project_id = :pid AND tenant_id = :tid
AND status != 'superseded'
ORDER BY doc_type, version DESC
"""),
{"pid": project_id, "tid": tenant_id},
).fetchall()
# Show all doc types — even if not yet generated
existing_types = {r.doc_type for r in rows}
items = [_doc_row_to_dict(r) for r in rows]
for doc_type, label in DOC_TYPE_LABELS.items():
if doc_type not in existing_types:
items.append({
"id": None,
"cra_project_id": project_id,
"doc_type": doc_type,
"doc_type_label": label,
"title": label,
"content_md": None,
"version": 0,
"requirements_coverage": {},
"status": "not_generated",
"signed_by": None,
"signed_at": None,
"generated_at": None,
"superseded_at": None,
})
items.sort(key=lambda x: x["doc_type"])
return {"project_id": project_id, "total": len(items), "items": items}
finally:
db.close()
@router.get("/documents/{doc_id}")
async def get_document(
doc_id: str,
tenant_id: str = Depends(get_tenant_id),
):
"""Get full document content (incl. content_md)."""
db = SessionLocal()
try:
row = db.execute(
text("""
SELECT * FROM compliance_cra_documents
WHERE id = :did AND tenant_id = :tid
"""),
{"did": doc_id, "tid": tenant_id},
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Document not found")
return _doc_row_to_dict(row)
finally:
db.close()
@router.post("/documents/{doc_id}/approve")
async def approve_document(
doc_id: str,
body: ApproveDocRequest,
tenant_id: str = Depends(get_tenant_id),
):
"""Set status to reviewed/approved + signature."""
if body.status not in DOC_STATUS_WHITELIST:
raise HTTPException(
status_code=400,
detail=f"Invalid status. Allowed: {sorted(DOC_STATUS_WHITELIST)}",
)
if not body.signed_by.strip():
raise HTTPException(status_code=400, detail="signed_by required")
db = SessionLocal()
try:
row = db.execute(
text("""
UPDATE compliance_cra_documents
SET status = :status, signed_by = :signer, signed_at = NOW()
WHERE id = :did AND tenant_id = :tid AND status != 'superseded'
RETURNING *
"""),
{"did": doc_id, "tid": tenant_id, "status": body.status, "signer": body.signed_by},
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Document not found or already superseded")
db.commit()
return _doc_row_to_dict(row)
except HTTPException:
raise
except Exception:
db.rollback()
raise
finally:
db.close()
@@ -0,0 +1,55 @@
-- Migration 121: CRA Vulnerability Disclosure + Lifecycle
-- Tracks vulnerabilities reported against a CRA project + CRA-mandated deadlines:
-- - 24h: Early warning to ENISA / national CSIRT (CRA Art. 14(2)(a))
-- - 72h: Detailed report (CRA Art. 14(2)(b))
-- - Patch -> Disclosure (typically with embargo)
--
-- Status state machine (whitelist):
-- reported -> triaged -> patched -> disclosed
-- (or withdrawn at any time)
CREATE TABLE IF NOT EXISTS compliance_cra_vulnerabilities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
cra_project_id UUID NOT NULL,
tenant_id VARCHAR(255) NOT NULL,
-- Identification
cve_id VARCHAR(50), -- CVE-YYYY-NNNN (optional)
title VARCHAR(500) NOT NULL,
description TEXT DEFAULT '',
severity VARCHAR(20), -- LOW | MEDIUM | HIGH | CRITICAL
cvss_score NUMERIC(3,1), -- 0.0 - 10.0
-- Affected components (e.g. ["lodash@4.17.20", "axios@0.21.0"])
affected_components JSONB NOT NULL DEFAULT '[]'::jsonb,
-- Reporter
reporter_source VARCHAR(50) DEFAULT 'internal', -- internal | external | researcher | scanner
reporter_contact VARCHAR(500),
-- Lifecycle timestamps
discovered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
triaged_at TIMESTAMPTZ,
patched_at TIMESTAMPTZ,
disclosed_at TIMESTAMPTZ,
embargo_until TIMESTAMPTZ,
-- CRA-Mandated reports (Art. 14(2))
reported_to_enisa_at TIMESTAMPTZ, -- 24h deadline
detailed_report_at TIMESTAMPTZ, -- 72h deadline
-- Status (whitelist)
status VARCHAR(30) NOT NULL DEFAULT 'reported',
-- Free-text notes (triage rationale, decision log)
notes TEXT DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_cra_vuln_project ON compliance_cra_vulnerabilities(cra_project_id);
CREATE INDEX IF NOT EXISTS idx_cra_vuln_tenant ON compliance_cra_vulnerabilities(tenant_id);
CREATE INDEX IF NOT EXISTS idx_cra_vuln_status ON compliance_cra_vulnerabilities(cra_project_id, status);
CREATE INDEX IF NOT EXISTS idx_cra_vuln_cve ON compliance_cra_vulnerabilities(cve_id) WHERE cve_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_cra_vuln_discovered ON compliance_cra_vulnerabilities(cra_project_id, discovered_at DESC);