Compare commits
2 Commits
0a64da74bb
...
27384aea09
| Author | SHA1 | Date | |
|---|---|---|---|
| 27384aea09 | |||
| cc80e59e5e |
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function POST(request: NextRequest, ctx: { params: Promise<{ docId: string }> }) {
|
||||
const { docId } = await ctx.params
|
||||
const tenantId = request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
|
||||
const body = await request.text()
|
||||
try {
|
||||
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/documents/${docId}/approve`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Tenant-ID': tenantId, '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,23 @@
|
||||
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<{ docId: string }> }) {
|
||||
const { docId } = await ctx.params
|
||||
try {
|
||||
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/documents/${docId}`, {
|
||||
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,23 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function POST(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'
|
||||
const body = await request.text()
|
||||
try {
|
||||
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/${id}/documents/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Tenant-ID': tenantId, '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,26 @@
|
||||
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
|
||||
const { searchParams } = new URL(request.url)
|
||||
const qs = searchParams.toString()
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${BACKEND_URL}/api/v1/cra/projects/${id}/documents${qs ? `?${qs}` : ''}`,
|
||||
{ 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,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,309 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState, useCallback, use } from 'react'
|
||||
|
||||
interface DocItem {
|
||||
id: string | null
|
||||
doc_type: string
|
||||
doc_type_label: string
|
||||
title: string
|
||||
content_md: string | null
|
||||
version: number
|
||||
requirements_coverage: Record<string, unknown>
|
||||
status: string
|
||||
signed_by: string | null
|
||||
signed_at: string | null
|
||||
generated_at: string | null
|
||||
superseded_at: string | null
|
||||
}
|
||||
|
||||
interface DocListResponse {
|
||||
project_id: string
|
||||
total: number
|
||||
items: DocItem[]
|
||||
}
|
||||
|
||||
const STATUS_STYLE: Record<string, string> = {
|
||||
draft: 'bg-yellow-100 text-yellow-800',
|
||||
reviewed: 'bg-blue-100 text-blue-800',
|
||||
approved: 'bg-green-100 text-green-800',
|
||||
superseded: 'bg-gray-200 text-gray-600',
|
||||
not_generated: 'bg-gray-100 text-gray-400',
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
draft: 'Entwurf',
|
||||
reviewed: 'Geprueft',
|
||||
approved: 'Freigegeben',
|
||||
superseded: 'Veraltet',
|
||||
not_generated: 'Nicht erzeugt',
|
||||
}
|
||||
|
||||
export default function DocumentsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ projectId: string }>
|
||||
}) {
|
||||
const { projectId } = use(params)
|
||||
const [data, setData] = useState<DocListResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [generating, setGenerating] = useState<string | null>(null)
|
||||
const [expanded, setExpanded] = useState<string | null>(null)
|
||||
const [docContent, setDocContent] = useState<Record<string, string>>({})
|
||||
|
||||
// Generation params per doc type
|
||||
const [manufacturer, setManufacturer] = useState('')
|
||||
const [notifiedBody, setNotifiedBody] = useState('')
|
||||
const [securityContact, setSecurityContact] = useState('')
|
||||
|
||||
// Approval form
|
||||
const [approving, setApproving] = useState<string | null>(null)
|
||||
const [signedBy, setSignedBy] = useState('')
|
||||
|
||||
const tenant = '00000000-0000-0000-0000-000000000001'
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}/documents`, {
|
||||
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 generate = async (docType: string) => {
|
||||
setGenerating(docType)
|
||||
setError('')
|
||||
try {
|
||||
const body: Record<string, string> = { doc_type: docType }
|
||||
if (docType === 'doc_eu_conformity') {
|
||||
if (manufacturer) body.manufacturer = manufacturer
|
||||
if (notifiedBody) body.notified_body = notifiedBody
|
||||
}
|
||||
if (docType === 'doc_cvd_policy' && securityContact) {
|
||||
body.security_contact = securityContact
|
||||
}
|
||||
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}/documents/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenant },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
const doc = await res.json()
|
||||
setDocContent(prev => ({ ...prev, [doc.id]: doc.content_md }))
|
||||
await load()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Generierung fehlgeschlagen')
|
||||
} finally {
|
||||
setGenerating(null)
|
||||
}
|
||||
}
|
||||
|
||||
const loadContent = async (docId: string) => {
|
||||
if (docContent[docId]) {
|
||||
setExpanded(expanded === docId ? null : docId)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/cra/documents/${docId}`, {
|
||||
headers: { 'X-Tenant-ID': tenant },
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
const doc = await res.json()
|
||||
setDocContent(prev => ({ ...prev, [docId]: doc.content_md }))
|
||||
setExpanded(docId)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
const approve = async (docId: string, status: string) => {
|
||||
if (!signedBy.trim()) {
|
||||
setError('Bitte Namen zur Freigabe eintragen.')
|
||||
return
|
||||
}
|
||||
setApproving(docId)
|
||||
setError('')
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/cra/documents/${docId}/approve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenant },
|
||||
body: JSON.stringify({ signed_by: signedBy, status }),
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
await load()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Freigabe fehlgeschlagen')
|
||||
} finally {
|
||||
setApproving(null)
|
||||
}
|
||||
}
|
||||
|
||||
const download = (doc: DocItem) => {
|
||||
const content = docContent[doc.id || ''] || doc.content_md || ''
|
||||
if (!content) return
|
||||
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${doc.doc_type}_v${doc.version}_${doc.id?.slice(0, 8)}.md`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
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-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">CRA-Dokumente</h1>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
DoC (Annex VII), Technische Doku (Annex V), CVD-Policy, Update-Policy, SBOM-Bericht — generiert aus aktuellem Projektstand.
|
||||
</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-xs underline mt-1">Schliessen</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generation params */}
|
||||
<details className="bg-white rounded-xl border border-gray-200 p-4 mb-4">
|
||||
<summary className="cursor-pointer text-sm font-medium text-gray-700">
|
||||
Optionale Parameter fuer Generierung (Hersteller, NoBo, Security-Contact)
|
||||
</summary>
|
||||
<div className="mt-3 space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Hersteller (fuer DoC)</label>
|
||||
<input value={manufacturer} onChange={e => setManufacturer(e.target.value)} placeholder="Acme GmbH, Musterstr. 1, 80331 Muenchen" className="w-full px-3 py-2 border rounded text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Notified Body (falls Modul C)</label>
|
||||
<input value={notifiedBody} onChange={e => setNotifiedBody(e.target.value)} placeholder="TUEV Nord (NB-0044)" className="w-full px-3 py-2 border rounded text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Security-Contact (fuer CVD-Policy)</label>
|
||||
<input type="email" value={securityContact} onChange={e => setSecurityContact(e.target.value)} placeholder="security@example.com" className="w-full px-3 py-2 border rounded text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div className="space-y-3">
|
||||
{data?.items.map(doc => (
|
||||
<div key={doc.doc_type} className="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||
<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">{doc.doc_type_label}</h3>
|
||||
{doc.version > 0 && (
|
||||
<span className="text-xs text-gray-500">v{doc.version}</span>
|
||||
)}
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${STATUS_STYLE[doc.status]}`}>
|
||||
{STATUS_LABEL[doc.status]}
|
||||
</span>
|
||||
</div>
|
||||
{doc.generated_at && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Generiert: {new Date(doc.generated_at).toLocaleString('de-DE')}
|
||||
{doc.signed_by && doc.signed_at && (
|
||||
<> · Freigegeben von <span className="font-medium">{doc.signed_by}</span> am {new Date(doc.signed_at).toLocaleString('de-DE')}</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{doc.requirements_coverage && Object.keys(doc.requirements_coverage).length > 0 && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Coverage: {String(doc.requirements_coverage.fields_filled || 0)} / {String(doc.requirements_coverage.fields_required || 0)} Pflichtfelder · {String(doc.requirements_coverage.annex_anchor || '')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => generate(doc.doc_type)}
|
||||
disabled={generating === doc.doc_type}
|
||||
className="px-3 py-1.5 bg-red-600 text-white text-sm rounded hover:bg-red-700 disabled:bg-gray-300"
|
||||
>
|
||||
{generating === doc.doc_type ? 'Generiere...' : (doc.version === 0 ? 'Generieren' : 'Neu generieren')}
|
||||
</button>
|
||||
{doc.id && (
|
||||
<button
|
||||
onClick={() => loadContent(doc.id!)}
|
||||
className="px-3 py-1.5 bg-gray-100 text-gray-700 text-sm rounded hover:bg-gray-200"
|
||||
>
|
||||
{expanded === doc.id ? 'Einklappen' : 'Inhalt'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded === doc.id && doc.id && docContent[doc.id] && (
|
||||
<div className="mt-3 border-t border-gray-200 pt-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-xs text-gray-500 font-mono">Markdown-Vorschau</p>
|
||||
<button
|
||||
onClick={() => download(doc)}
|
||||
className="text-xs px-2 py-1 bg-blue-100 text-blue-700 rounded hover:bg-blue-200"
|
||||
>
|
||||
⬇ Download (.md)
|
||||
</button>
|
||||
</div>
|
||||
<pre className="bg-gray-50 rounded p-3 text-xs overflow-x-auto max-h-96 whitespace-pre-wrap font-mono">
|
||||
{docContent[doc.id]}
|
||||
</pre>
|
||||
|
||||
{doc.status === 'draft' && (
|
||||
<div className="mt-3 p-3 bg-yellow-50 border border-yellow-200 rounded">
|
||||
<p className="text-xs text-yellow-800 mb-2">
|
||||
Vor Freigabe pruefen ob alle <code>[zu ergaenzen]</code>-Stellen gefuellt sind.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={signedBy}
|
||||
onChange={e => setSignedBy(e.target.value)}
|
||||
placeholder="Name + Rolle des Freigebenden"
|
||||
className="flex-1 px-2 py-1 border rounded text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={() => approve(doc.id!, 'reviewed')}
|
||||
disabled={approving === doc.id || !signedBy.trim()}
|
||||
className="px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 disabled:bg-gray-300"
|
||||
>
|
||||
Als geprueft markieren
|
||||
</button>
|
||||
<button
|
||||
onClick={() => approve(doc.id!, 'approved')}
|
||||
disabled={approving === doc.id || !signedBy.trim()}
|
||||
className="px-3 py-1 bg-green-600 text-white text-xs rounded hover:bg-green-700 disabled:bg-gray-300"
|
||||
>
|
||||
Freigeben
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 bg-blue-50 border border-blue-200 rounded-xl p-4 text-sm text-blue-900">
|
||||
<strong>Hinweis:</strong> Diese Dokumente sind <em>Skelette</em> aus dem aktuellen Projektstand. Markdown-Format, manuelles Editieren + Unterzeichnung erforderlich vor Inverkehrbringen. PDF-Export folgt in Phase 5.5.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
"""
|
||||
CRA Document Templates — generates Markdown documents from project state.
|
||||
|
||||
Each template takes a project dict + optional context (sboms, vulnerabilities,
|
||||
checks) and returns (title, markdown_content). Pure functions, no DB access.
|
||||
|
||||
Templates implement:
|
||||
- doc_eu_conformity — EU Declaration of Conformity (CRA Annex VII)
|
||||
- doc_technical — Technische Dokumentation (CRA Annex V)
|
||||
- doc_cvd_policy — Coordinated Vulnerability Disclosure Policy
|
||||
- doc_update_policy — Update / Patch Policy
|
||||
- doc_sbom_report — SBOM-Zusammenfassung
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
PATH_LABELS = {
|
||||
"self_assessment": "Modul A — Self-Assessment durch Hersteller",
|
||||
"harmonized_standard": "Modul B — Harmonisierte Norm",
|
||||
"eucc": "Modul H — EUCC-Zertifizierung",
|
||||
"notified_body": "Modul C — Konformitaetsbewertung durch Notified Body",
|
||||
}
|
||||
|
||||
CLASS_LABELS = {
|
||||
"NOT_IN_SCOPE": "Nicht im CRA-Scope",
|
||||
"STANDARD": "Standard-Produkt mit digitalen Elementen",
|
||||
"IMPORTANT_I": "Wichtiges Produkt — Annex III Klasse I",
|
||||
"IMPORTANT_II": "Wichtiges Produkt — Annex III Klasse II",
|
||||
"CRITICAL": "Kritisches Produkt — Annex IV",
|
||||
}
|
||||
|
||||
|
||||
def _today() -> str:
|
||||
return datetime.utcnow().strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _fmt_or_placeholder(v: Optional[str], placeholder: str = "[zu ergaenzen]") -> str:
|
||||
return v if v else placeholder
|
||||
|
||||
|
||||
def doc_eu_conformity(project: dict, *, manufacturer: Optional[str] = None,
|
||||
notified_body: Optional[str] = None) -> tuple[str, str]:
|
||||
"""EU Declaration of Conformity nach CRA Annex VII.
|
||||
|
||||
Pflichtfelder gemaess CRA Art. 28 + Annex VII:
|
||||
1. Produkt + Modell + eindeutige Kennung
|
||||
2. Hersteller + Anschrift
|
||||
3. (Optional) Bevollmaechtigter
|
||||
4. Konformitaetserklaerung mit Bezug auf CRA
|
||||
5. Verweis auf angewandte harmonisierte Normen
|
||||
6. Notified Body (falls Modul C)
|
||||
7. Datum + Ort + Unterschrift
|
||||
"""
|
||||
name = project.get("name", "[zu ergaenzen]")
|
||||
classification = project.get("cra_classification", "unbewertet")
|
||||
path = project.get("conformity_path", "self_assessment")
|
||||
path_label = PATH_LABELS.get(path, path)
|
||||
class_label = CLASS_LABELS.get(classification, classification)
|
||||
desc = project.get("description") or project.get("intended_use") or ""
|
||||
|
||||
title = f"EU-Konformitaetserklaerung — {name}"
|
||||
|
||||
content = f"""# EU-Konformitaetserklaerung (DoC)
|
||||
**Gemaess Verordnung (EU) 2024/2847 — Cyber Resilience Act, Anhang VII**
|
||||
|
||||
---
|
||||
|
||||
## 1. Eindeutige Kennung des Produkts
|
||||
|
||||
**Produktname:** {name}
|
||||
**Modell / Typ:** {_fmt_or_placeholder(project.get('primary_language'), 'Software-Produkt')}
|
||||
**Repository / Source:** {_fmt_or_placeholder(project.get('repo_url'), '[Repo-URL ergaenzen]')}
|
||||
**Beschreibung:** {desc or '[Produktbeschreibung ergaenzen]'}
|
||||
|
||||
## 2. Hersteller
|
||||
|
||||
**Name:** {_fmt_or_placeholder(manufacturer, '[Hersteller-Name + Adresse ergaenzen]')}
|
||||
**Anschrift:** [zu ergaenzen]
|
||||
**Kontakt:** [zu ergaenzen]
|
||||
|
||||
## 3. Bevollmaechtigter (Authorised Representative)
|
||||
|
||||
[Falls Hersteller nicht in EU ansaessig — sonst entfallen]
|
||||
|
||||
## 4. Konformitaetserklaerung
|
||||
|
||||
Die alleinige Verantwortung fuer die Ausstellung dieser Konformitaetserklaerung
|
||||
traegt der Hersteller. Der Gegenstand der oben beschriebenen Erklaerung
|
||||
**erfuellt die einschlaegigen Harmonisierungsrechtsvorschriften der Union**:
|
||||
|
||||
- **Verordnung (EU) 2024/2847** (Cyber Resilience Act) — vollstaendig
|
||||
einschliesslich der **wesentlichen Cybersicherheitsanforderungen nach
|
||||
Anhang I**.
|
||||
|
||||
**CRA-Klassifizierung:** {class_label}
|
||||
**Angewandtes Konformitaetsbewertungsverfahren:** {path_label}
|
||||
|
||||
## 5. Verweise auf angewandte harmonisierte Normen und technische Spezifikationen
|
||||
|
||||
- Harmonisierte Norm: DIN EN 40000-1-2 (Entwurf, Stand 11/2025)
|
||||
- ETSI EN 303 645 (Consumer-IoT-Cybersicherheit)
|
||||
- ISO/IEC 29147 (Vulnerability Disclosure)
|
||||
- ISO/IEC 30111 (Vulnerability Handling Processes)
|
||||
- ENISA Technical Implementation Guidance (NIS2-Bezug)
|
||||
|
||||
## 6. Notified Body
|
||||
|
||||
{'_' if not notified_body else notified_body}
|
||||
{'Identifikationsnummer: [zu ergaenzen]' if notified_body else '(keine — Self-Assessment durch Hersteller)'}
|
||||
|
||||
## 7. Zusatzangaben
|
||||
|
||||
Diese Konformitaetserklaerung wurde unterzeichnet im Namen von:
|
||||
|
||||
**Ort, Datum:** [zu ergaenzen], {_today()}
|
||||
**Name, Funktion:** [zu ergaenzen]
|
||||
**Unterschrift:** ___________________________
|
||||
|
||||
---
|
||||
|
||||
*Diese Erklaerung wurde automatisch aus dem BreakPilot CRA-Modul generiert.
|
||||
Pflichtangaben sind als `[zu ergaenzen]` markiert und muessen vor Inverkehrbringen
|
||||
ausgefuellt + manuell unterzeichnet werden.*
|
||||
"""
|
||||
coverage = {
|
||||
"doc_type": "doc_eu_conformity",
|
||||
"annex_anchor": "CRA Anhang VII",
|
||||
"fields_required": 7,
|
||||
"fields_filled": sum(1 for v in [name, classification, path, desc] if v and not str(v).startswith("[")),
|
||||
}
|
||||
return title, content, coverage
|
||||
|
||||
|
||||
def doc_technical(project: dict) -> tuple[str, str, dict]:
|
||||
"""Technische Dokumentation nach CRA Annex V."""
|
||||
name = project.get("name", "[Produktname]")
|
||||
desc = project.get("description") or project.get("intended_use") or ""
|
||||
|
||||
title = f"Technische Dokumentation — {name}"
|
||||
content = f"""# Technische Dokumentation
|
||||
**{name}**
|
||||
Gemaess Verordnung (EU) 2024/2847 (CRA), Anhang V
|
||||
|
||||
Generiert am: {_today()}
|
||||
|
||||
---
|
||||
|
||||
## 1. Allgemeine Beschreibung des Produkts
|
||||
|
||||
**Produktname:** {name}
|
||||
**Verwendungszweck:** {_fmt_or_placeholder(project.get('intended_use'))}
|
||||
**Hauptfunktionen:** {desc or '[ergaenzen]'}
|
||||
|
||||
### 1.1 Technische Eigenschaften
|
||||
|
||||
- **Primaere Programmiersprache:** {_fmt_or_placeholder(project.get('primary_language'))}
|
||||
- **Enthaelt Firmware:** {'Ja' if project.get('has_firmware') else 'Nein'}
|
||||
- **Internet-Konnektivitaet:** {'Ja' if project.get('connected_to_internet') else 'Nein'}
|
||||
- **Software-Updates:** {'Ja' if project.get('has_software_updates') else 'Nein'}
|
||||
- **Verarbeitet personenbezogene Daten:** {'Ja' if project.get('processes_personal_data') else 'Nein'}
|
||||
- **Kritische Infrastruktur:** {'Ja' if project.get('is_critical_infra_supplier') else 'Nein'}
|
||||
|
||||
## 2. Konzeption, Entwicklung und Produktion
|
||||
|
||||
### 2.1 Cybersicherheits-Risikobewertung (CRA Art. 13(2))
|
||||
|
||||
Risikobewertung wurde durchgefuehrt fuer die Klassifikation
|
||||
**{CLASS_LABELS.get(project.get('cra_classification', ''), 'unbewertet')}**.
|
||||
|
||||
### 2.2 Secure-by-Design-Massnahmen
|
||||
|
||||
Siehe `doc_eu_conformity` Abschnitt 4 fuer die Einhaltung von Anhang I.
|
||||
Detail-Mapping (40 atomare Annex-I-Anforderungen) ist im BreakPilot
|
||||
CRA-Modul unter `/sdk/cra/{{projectId}}/requirements` einsehbar.
|
||||
|
||||
## 3. Bewertung der Konformitaet
|
||||
|
||||
### 3.1 Verfahren
|
||||
{PATH_LABELS.get(project.get('conformity_path', ''), 'Nicht festgelegt')}
|
||||
|
||||
### 3.2 Klassifikation
|
||||
|
||||
**{CLASS_LABELS.get(project.get('cra_classification', ''), 'unbewertet')}**
|
||||
|
||||
Begruendung:
|
||||
{chr(10).join(f'- {r}' for r in project.get('classification_rationale', []) or ['(keine Rationale erfasst)'])}
|
||||
|
||||
## 4. Kontaktangaben
|
||||
|
||||
**Hersteller:** [zu ergaenzen]
|
||||
**Anschrift:** [zu ergaenzen]
|
||||
**Cybersicherheits-Kontakt:** [zu ergaenzen, siehe CVD-Policy]
|
||||
|
||||
---
|
||||
|
||||
*Diese Datei wurde automatisch aus dem BreakPilot CRA-Modul generiert.
|
||||
Sie ist ein Skelett — Hersteller muss alle `[ergaenzen]`-Stellen fuellen,
|
||||
zusaetzliche Anhaenge (Architekturzeichnungen, Test-Berichte, SBOM) beilegen.*
|
||||
"""
|
||||
coverage = {
|
||||
"doc_type": "doc_technical",
|
||||
"annex_anchor": "CRA Anhang V",
|
||||
"fields_required": 8,
|
||||
"fields_filled": sum(1 for v in [
|
||||
project.get("name"), project.get("intended_use"),
|
||||
project.get("cra_classification"), project.get("conformity_path"),
|
||||
] if v),
|
||||
}
|
||||
return title, content, coverage
|
||||
|
||||
|
||||
def doc_cvd_policy(project: dict, *, security_contact: Optional[str] = None) -> tuple[str, str, dict]:
|
||||
"""Coordinated Vulnerability Disclosure Policy."""
|
||||
name = project.get("name", "[Produkt]")
|
||||
contact = _fmt_or_placeholder(security_contact, "security@[your-domain]")
|
||||
title = f"Coordinated Vulnerability Disclosure Policy — {name}"
|
||||
content = f"""# Coordinated Vulnerability Disclosure (CVD) Policy
|
||||
**{name}**
|
||||
Generiert am: {_today()}
|
||||
|
||||
Diese Policy beschreibt, wie Sicherheitsforscher und Nutzer Schwachstellen
|
||||
verantwortungsvoll an uns melden koennen.
|
||||
|
||||
---
|
||||
|
||||
## Wir freuen uns ueber Schwachstellen-Meldungen
|
||||
|
||||
Wenn Sie eine Sicherheitsluecke in **{name}** entdeckt haben, melden Sie sie
|
||||
bitte vertraulich an unsere Sicherheitsstelle. Wir verpflichten uns:
|
||||
|
||||
- **Eingangsbestaetigung innerhalb von 5 Werktagen**
|
||||
- Kommunikation auf Augenhoehe waehrend der Triage
|
||||
- Koordinierte Veroeffentlichung nach Patch-Verfuegbarkeit
|
||||
- Public Acknowledgement (auf Wunsch)
|
||||
- Kein juristisches Vorgehen gegen Forscher, die diese Policy befolgen
|
||||
|
||||
## Meldekanal
|
||||
|
||||
- **E-Mail:** {contact}
|
||||
- **PGP-Key:** [Fingerprint einfuegen]
|
||||
- **Web:** https://[your-domain]/.well-known/security.txt
|
||||
- **Sprachen:** Deutsch, English
|
||||
|
||||
## Was wir brauchen
|
||||
|
||||
- Eindeutige Produkt-Identifikation: {name} + Version
|
||||
- Reproduktionsschritte (PoC bevorzugt)
|
||||
- Auswirkungsbeschreibung (Confidentiality / Integrity / Availability)
|
||||
- (Optional) Vorschlag fuer Behebung
|
||||
|
||||
## Reaktionszeit-Ziele (SLAs)
|
||||
|
||||
| Severity (CVSS) | Triage | Patch verfuegbar |
|
||||
|---|---|---|
|
||||
| Kritisch (9.0+) | 24h | 30 Tage |
|
||||
| Hoch (7.0-8.9) | 72h | 90 Tage |
|
||||
| Mittel (4.0-6.9) | 7 Tage | 180 Tage |
|
||||
| Niedrig (<4.0) | 14 Tage | naechster Release-Zyklus |
|
||||
|
||||
## Embargo + Disclosure
|
||||
|
||||
Wir bitten Sicherheitsforscher um **Embargo bis Patch ausgeliefert** (typ. 90 Tage).
|
||||
Nach Patch koordinieren wir die Veroeffentlichung gemeinsam (Forscher +
|
||||
Hersteller). CVE-Beantragung erfolgt durch uns.
|
||||
|
||||
## CRA-Bezug
|
||||
|
||||
Diese Policy ist Pflicht gemaess **CRA Anhang I Teil 2(5)** sowie
|
||||
**ISO/IEC 29147**. Bei aktiv ausgenutzten Schwachstellen melden wir nach
|
||||
**CRA Art. 14(2)**:
|
||||
|
||||
- **24h:** Fruehwarnung an ENISA / nationale CSIRT
|
||||
- **72h:** Detaillierter Bericht
|
||||
- **14 Tage:** Abschlussbericht nach Behebung
|
||||
|
||||
---
|
||||
|
||||
*Diese CVD-Policy wurde automatisch generiert. Hersteller-spezifische Angaben
|
||||
(Kontakt, PGP, Domain) muessen ergaenzt werden vor Veroeffentlichung.*
|
||||
"""
|
||||
coverage = {
|
||||
"doc_type": "doc_cvd_policy",
|
||||
"annex_anchor": "CRA Anhang I Teil 2(5), Art. 14",
|
||||
"fields_required": 3,
|
||||
"fields_filled": 1 if security_contact else 0,
|
||||
}
|
||||
return title, content, coverage
|
||||
|
||||
|
||||
def doc_update_policy(project: dict) -> tuple[str, str, dict]:
|
||||
"""Update / Patch Policy."""
|
||||
name = project.get("name", "[Produkt]")
|
||||
title = f"Update- und Patch-Policy — {name}"
|
||||
content = f"""# Update- und Patch-Policy
|
||||
**{name}**
|
||||
Generiert am: {_today()}
|
||||
|
||||
---
|
||||
|
||||
## Lifecycle-Support
|
||||
|
||||
Wir verpflichten uns, **Sicherheits-Updates fuer {name}** waehrend der
|
||||
gesamten erwarteten Nutzungsdauer bereitzustellen, jedoch **mindestens 5 Jahre**
|
||||
ab Inverkehrbringen — gemaess CRA Anhang I, 1(4).
|
||||
|
||||
## Patch-SLA (Severity-basiert)
|
||||
|
||||
| CVSS Score | Severity | Max. Patch-Zeit |
|
||||
|---|---|---|
|
||||
| 9.0–10.0 | Kritisch | **30 Tage** |
|
||||
| 7.0–8.9 | Hoch | 90 Tage |
|
||||
| 4.0–6.9 | Mittel | 180 Tage |
|
||||
| <4.0 | Niedrig | naechster Release |
|
||||
|
||||
## Update-Mechanismus
|
||||
|
||||
- **Distribution:** Ueber sichere HTTPS-Kanaele
|
||||
- **Authentizitaet:** Alle Updates sind digital signiert (X.509)
|
||||
- **Integritaet:** SHA-256-Hash vor Installation geprueft
|
||||
- **Rollback:** Manuelles Rollback auf vorherige Version moeglich
|
||||
- **Manipulationsschutz:** Downgrade auf verwundbare Versionen blockiert
|
||||
|
||||
## Benachrichtigung
|
||||
|
||||
Bei kritischen Updates: aktive Benachrichtigung der Endnutzer ueber
|
||||
{_fmt_or_placeholder(project.get('repo_url'), '[Kommunikationskanal ergaenzen]')}
|
||||
binnen 24h nach Verfuegbarkeit.
|
||||
|
||||
## Security Advisories
|
||||
|
||||
Veroeffentlicht im CSAF-Format (Common Security Advisory Framework) unter:
|
||||
- https://[your-domain]/security/advisories
|
||||
- Maschinenlesbar fuer SCA-Tools
|
||||
|
||||
## End-of-Life
|
||||
|
||||
Mindestens **6 Monate vor EOL** wird der Termin bekannt gegeben. Letztes
|
||||
Sicherheits-Update wird mit der EOL-Ankuendigung ausgeliefert.
|
||||
|
||||
## CRA-Bezug
|
||||
|
||||
Diese Policy erfuellt CRA Anhang I, 1(4) (sichere Updates) und
|
||||
2(3) (Patch-Bereitstellung), sowie Art. 13 (Lifecycle-Support).
|
||||
|
||||
---
|
||||
|
||||
*Hersteller-spezifische Angaben muessen ergaenzt werden.*
|
||||
"""
|
||||
coverage = {
|
||||
"doc_type": "doc_update_policy",
|
||||
"annex_anchor": "CRA Anhang I 1(4) + 2(3), Art. 13",
|
||||
"fields_required": 4,
|
||||
"fields_filled": 1 if project.get("has_software_updates") else 0,
|
||||
}
|
||||
return title, content, coverage
|
||||
|
||||
|
||||
def doc_sbom_report(project: dict, latest_sbom: Optional[dict] = None) -> tuple[str, str, dict]:
|
||||
"""SBOM-Zusammenfassung des aktuellsten Uploads."""
|
||||
name = project.get("name", "[Produkt]")
|
||||
title = f"SBOM-Bericht — {name}"
|
||||
|
||||
if not latest_sbom:
|
||||
body = """## Kein SBOM hochgeladen
|
||||
|
||||
Bitte SBOM (CycloneDX oder SPDX) hochladen unter:
|
||||
`/sdk/cra/{projectId}/sbom`
|
||||
|
||||
CRA Anhang I, Anforderung 23 verlangt ein maschinenlesbares
|
||||
Software Bill of Materials fuer jedes Produkt mit digitalen Elementen.
|
||||
"""
|
||||
fields_filled = 0
|
||||
else:
|
||||
summary = latest_sbom.get("summary") or {}
|
||||
sample = summary.get("sample_components") or summary.get("sample_packages") or []
|
||||
sample_list = "\n".join(f"- `{c.get('name', '?')}` v{c.get('version', '?')}" for c in sample[:10])
|
||||
body = f"""## Aktuelle Version
|
||||
|
||||
- **Datei:** {latest_sbom.get('filename', 'unknown')}
|
||||
- **Format:** {latest_sbom.get('format', '?')} v{latest_sbom.get('spec_version', '?')}
|
||||
- **Hochgeladen:** {latest_sbom.get('uploaded_at', '?')}
|
||||
- **Komponenten:** {latest_sbom.get('component_count', 0)}
|
||||
- **Scan-Status:** {latest_sbom.get('scan_status', '?')}
|
||||
|
||||
## Beispielkomponenten (Top 10)
|
||||
|
||||
{sample_list or '_(keine extrahiert)_'}
|
||||
|
||||
## Schwachstellen-Status
|
||||
|
||||
{'Scan ausstehend — wird durch separates Tool durchgefuehrt.' if latest_sbom.get('scan_status') == 'pending' else 'Siehe `/sdk/cra/{projectId}/vuln` fuer aktive Vulnerabilities.'}
|
||||
"""
|
||||
fields_filled = 4
|
||||
|
||||
content = f"""# SBOM-Bericht
|
||||
**{name}**
|
||||
Generiert am: {_today()}
|
||||
|
||||
{body}
|
||||
|
||||
---
|
||||
|
||||
## CRA-Bezug
|
||||
|
||||
Anhang I, Anforderung 23: maschinenlesbares SBOM (CycloneDX oder SPDX)
|
||||
fuer jedes Produkt mit digitalen Elementen. Pflicht bei jedem Release zu
|
||||
aktualisieren.
|
||||
|
||||
## Naechste Schritte
|
||||
|
||||
1. SBOM bei jedem Release-Update neu hochladen
|
||||
2. osv.dev-Scan pruefen (Phase 3.5)
|
||||
3. CVEs in `affected_components` der `/vuln`-Seite tracken
|
||||
"""
|
||||
coverage = {
|
||||
"doc_type": "doc_sbom_report",
|
||||
"annex_anchor": "CRA Anhang I, Req. 23",
|
||||
"fields_required": 4,
|
||||
"fields_filled": fields_filled,
|
||||
}
|
||||
return title, content, coverage
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Registry — for the /documents/generate endpoint
|
||||
# =============================================================================
|
||||
|
||||
DOC_GENERATORS = {
|
||||
"doc_eu_conformity": doc_eu_conformity,
|
||||
"doc_technical": doc_technical,
|
||||
"doc_cvd_policy": doc_cvd_policy,
|
||||
"doc_update_policy": doc_update_policy,
|
||||
"doc_sbom_report": doc_sbom_report,
|
||||
}
|
||||
|
||||
DOC_TYPE_LABELS = {
|
||||
"doc_eu_conformity": "EU-Konformitaetserklaerung (Annex VII)",
|
||||
"doc_technical": "Technische Dokumentation (Annex V)",
|
||||
"doc_cvd_policy": "CVD Policy",
|
||||
"doc_update_policy": "Update- und Patch-Policy",
|
||||
"doc_sbom_report": "SBOM-Bericht",
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Migration 122: CRA Generated Documents
|
||||
-- Tracks all auto-generated CRA documents (DoC, Technical Doc, CVD Policy, etc.)
|
||||
-- with versioning so customers can audit-trail changes over time.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS compliance_cra_documents (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
cra_project_id UUID NOT NULL,
|
||||
tenant_id VARCHAR(255) NOT NULL,
|
||||
|
||||
-- Document classification
|
||||
doc_type VARCHAR(50) NOT NULL,
|
||||
-- doc_eu_conformity -- EU DoC (Annex VII)
|
||||
-- doc_technical -- Technische Doku (Annex V)
|
||||
-- doc_cvd_policy -- Coordinated Vulnerability Disclosure Policy
|
||||
-- doc_update_policy -- Update / Patch Policy
|
||||
-- doc_sbom_report -- SBOM-Zusammenfassung
|
||||
|
||||
-- Content (Markdown, can be converted to PDF later)
|
||||
title VARCHAR(500) NOT NULL,
|
||||
content_md TEXT NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
|
||||
-- Compliance metadata
|
||||
requirements_coverage JSONB DEFAULT '{}'::jsonb, -- e.g. {covered: ["CRA-AI-23", ...], total: 40}
|
||||
generation_context JSONB DEFAULT '{}'::jsonb, -- snapshot of project state at generation
|
||||
|
||||
-- Signature/Approval (Phase 5 minimum: just status)
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft | reviewed | approved | superseded
|
||||
signed_by VARCHAR(255),
|
||||
signed_at TIMESTAMPTZ,
|
||||
|
||||
generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
superseded_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cra_docs_project ON compliance_cra_documents(cra_project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_cra_docs_tenant ON compliance_cra_documents(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_cra_docs_type ON compliance_cra_documents(cra_project_id, doc_type, generated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_cra_docs_status ON compliance_cra_documents(cra_project_id, status);
|
||||
Reference in New Issue
Block a user