feat(cra): CRA Compliance module Phase 1+2+3 (intake, scope, path, requirements, backlog, sbom, checks)

Phase 1 — Intake + Scope + Path:
- Migration 119: compliance_cra_projects table (intake + classification + path + status state machine)
- Backend service cra_routes.py: CRUD + scope-check + path-select
- Deterministic Annex III/IV classifier (verbatim mapping from migration 059 wiki)
- Path validation per classification (CRITICAL → notified_body mandatory)
- Frontend: project list, dashboard, 3-step wizard (intake/scope/path)
- Sidebar entry under "CRA Compliance" (red)

Phase 2 — Annex I Requirements + Priorisierungs-Backlog:
- cra_annex_i_data.py: 40 Annex-I requirements (8 categories), 9 measures (M540-M548), 3 CRA deadlines
- Endpoints: /requirements (40 items), /backlog (priority-sorted with deadline pressure)
- Frontend: requirements table with filters + expandable details, backlog with deadline banner + score-ranked table
- Dashboard KPI cards (Critical count, days to CE deadline, etc.) + top-10 backlog snippet

Phase 3 — SBOM Upload + Automated Checks:
- Migration 120: compliance_cra_sboms (versioned uploads, CycloneDX + SPDX)
- SBOM endpoints: POST /sbom/upload (format detection, summary extraction), GET /sboms
- Checks reuse compliance_evidence_checks: init creates 6 default CRA checks, run executes
- Real implementations: cra_security_txt (HTTP + Contact: line) and cra_tls_cert_check (TLS handshake)
- Frontend: SBOM file upload + version list, Checks page with per-check URL input + Run button

Backend-Reuse: gap_projects (intake pre-population), compliance_evidence_checks/_check_results.
Tenant scoping via existing X-Tenant-ID header pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-18 17:56:52 +02:00
parent 3faa312b31
commit 1cf5de1d45
27 changed files with 3714 additions and 0 deletions
@@ -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<{ checkId: string }> }) {
const { checkId } = 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/checks/${checkId}/run`, {
method: 'POST',
headers: { 'X-Tenant-ID': tenantId, 'Content-Type': 'application/json' },
body: 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,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}/backlog`, {
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,41 @@
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}/checks`, {
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 })
}
}
/** POST /checks (no body) -> backend /checks/init creates default checks */
export async function POST(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params
try {
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/${id}/checks/init`, {
method: 'POST',
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,29 @@
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}/path-select`, {
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,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}/requirements`, {
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,45 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
function tenantHeader(request: NextRequest): string {
return request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
}
async function proxy(request: NextRequest, method: string, id: string, body?: string) {
const tenantId = tenantHeader(request)
const init: RequestInit = {
method,
headers: { 'X-Tenant-ID': tenantId, 'Content-Type': 'application/json' },
}
if (body !== undefined) init.body = body
try {
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/${id}`, init)
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 GET(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params
return proxy(request, 'GET', id)
}
export async function PATCH(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params
const body = await request.text()
return proxy(request, 'PATCH', id, body)
}
export async function DELETE(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params
return proxy(request, 'DELETE', id)
}
@@ -0,0 +1,48 @@
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'
}
/** GET /sbom -> List uploads. We map this to the backend /sboms endpoint. */
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}/sboms`, {
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 })
}
}
/** POST /sbom -> multipart upload to backend /sbom/upload */
export async function POST(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params
try {
const formData = await request.formData()
const upstreamForm = new FormData()
for (const [key, value] of formData.entries()) {
upstreamForm.append(key, value)
}
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/${id}/sbom/upload`, {
method: 'POST',
headers: { 'X-Tenant-ID': tenant(request) },
body: upstreamForm as unknown as BodyInit,
})
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,24 @@
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'
try {
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/${id}/scope-check`, {
method: 'POST',
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,56 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
function tenantHeader(request: NextRequest): string {
return request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
}
/** GET /api/sdk/v1/cra/projects -> Backend list */
export async function GET(request: NextRequest) {
const tenantId = tenantHeader(request)
const { searchParams } = new URL(request.url)
const qs = searchParams.toString()
try {
const resp = await fetch(
`${BACKEND_URL}/api/v1/cra/projects${qs ? `?${qs}` : ''}`,
{ headers: { 'X-Tenant-ID': tenantId } }
)
const body = await resp.text()
return new NextResponse(body, {
status: resp.status,
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
})
} catch (err) {
return NextResponse.json(
{ error: 'Backend unreachable', details: String(err) },
{ status: 502 }
)
}
}
/** POST /api/sdk/v1/cra/projects -> Backend create */
export async function POST(request: NextRequest) {
const tenantId = tenantHeader(request)
const body = await request.text()
try {
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects`, {
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,155 @@
'use client'
import React, { useEffect, useState, useCallback, use } from 'react'
import { SeverityBadge } from '../../_components/SeverityBadge'
interface BacklogItem {
rank: number
req_id: string
title: string
category: string
severity: string
annex_anchor: string
description: string
effort_days: number
mapped_measure_names: { id: string; name: string }[]
status: string
priority_score: number
}
interface BacklogResponse {
project_id: string
classification: string | null
days_to_ce_deadline: number
deadlines: { date: string; label: string }[]
total: number
items: BacklogItem[]
}
export default function BacklogPage({
params,
}: {
params: Promise<{ projectId: string }>
}) {
const { projectId } = use(params)
const [data, setData] = useState<BacklogResponse | 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}/backlog`, {
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
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-6xl mx-auto px-4">
<div className="mb-6">
<a href={`/sdk/cra/${projectId}`} className="text-sm text-blue-600 hover:underline">
&larr; Zurueck zum Projekt
</a>
<h1 className="text-2xl font-bold text-gray-900 mt-2">Prioritaeten-Backlog</h1>
<p className="text-sm text-gray-600 mt-1">
Sortiert nach Severity × Deadline-Druck × Effort. Was du heute tust, was naechsten Sprint, was vor 11.12.2027.
</p>
</div>
{/* Deadline-Banner */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mb-6">
{data.deadlines.map(d => {
const days = Math.max(0, Math.round((new Date(d.date).getTime() - Date.now()) / 86400000))
const isPast = new Date(d.date).getTime() < Date.now()
return (
<div
key={d.date}
className={`rounded-xl border p-4 ${
isPast ? 'bg-gray-100 border-gray-200' :
days < 90 ? 'bg-red-50 border-red-200' :
days < 365 ? 'bg-orange-50 border-orange-200' :
'bg-blue-50 border-blue-200'
}`}
>
<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 ${isPast ? 'text-gray-500' : 'text-gray-700'}`}>
{isPast ? 'bereits abgelaufen' : `noch ${days} Tage`}
</div>
</div>
)
})}
</div>
{/* Backlog */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Rang</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Anforderung</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Severity</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Score</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Aufwand</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Massnahme</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Aktion</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{data.items.map(item => (
<tr key={item.req_id} className="hover:bg-gray-50">
<td className="px-3 py-3 text-sm font-bold text-gray-700">{item.rank}</td>
<td className="px-3 py-3">
<div className="text-sm font-medium text-gray-900">{item.title}</div>
<div className="text-xs text-gray-500">{item.category} · {item.annex_anchor}</div>
</td>
<td className="px-3 py-3"><SeverityBadge value={item.severity} /></td>
<td className="px-3 py-3 text-sm font-mono text-gray-700">{item.priority_score}</td>
<td className="px-3 py-3 text-sm text-gray-600">{item.effort_days} PT</td>
<td className="px-3 py-3 text-xs text-gray-600">
{item.mapped_measure_names.length > 0 ? (
<div className="space-y-0.5">
{item.mapped_measure_names.map(m => (
<div key={m.id} title={m.name}>
<span className="font-mono text-gray-400">{m.id}:</span> {m.name.length > 50 ? m.name.slice(0, 50) + '...' : m.name}
</div>
))}
</div>
) : (
<span className="text-gray-400"></span>
)}
</td>
<td className="px-3 py-3">
<button
className="px-2 py-1 text-xs bg-purple-100 text-purple-800 rounded hover:bg-purple-200"
onClick={() => alert(`Jira-Export fuer ${item.req_id} — Phase-4-Feature`)}
>
Jira
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-xs text-gray-500 mt-4 text-center">
Tage bis CE-Marking-Pflicht (11.12.2027): <span className="font-semibold">{data.days_to_ce_deadline}</span>
</p>
</div>
</div>
)
}
@@ -0,0 +1,195 @@
'use client'
import React, { useEffect, useState, useCallback, use } from 'react'
interface CheckItem {
id: string
check_code: string
title: string
description: string
check_type: string
target_url: string | null
linked_req_ids: string[]
last_run_at: string | null
is_active: boolean
latest_result: { status: string; message: string; ran_at: string } | null
}
interface ChecksResponse {
project_id: string
total: number
items: CheckItem[]
}
const STATUS_STYLE: Record<string, string> = {
pass: 'bg-green-100 text-green-800',
fail: 'bg-red-100 text-red-800',
manual_review_required: 'bg-yellow-100 text-yellow-800',
}
export default function ChecksPage({
params,
}: {
params: Promise<{ projectId: string }>
}) {
const { projectId } = use(params)
const [data, setData] = useState<ChecksResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [running, setRunning] = useState<string | null>(null)
const [urlInputs, setUrlInputs] = useState<Record<string, string>>({})
const tenant = '00000000-0000-0000-0000-000000000001'
const load = useCallback(async () => {
try {
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}/checks`, {
headers: { 'X-Tenant-ID': tenant },
})
if (!res.ok) throw new Error(await res.text())
const json: ChecksResponse = await res.json()
setData(json)
const u: Record<string, string> = {}
for (const c of json.items) u[c.id] = c.target_url || ''
setUrlInputs(u)
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}, [projectId])
useEffect(() => { load() }, [load])
const initChecks = async () => {
setError('')
try {
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}/checks`, {
method: 'POST',
headers: { 'X-Tenant-ID': tenant },
})
if (!res.ok) throw new Error(await res.text())
await load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Init fehlgeschlagen')
}
}
const runCheck = async (checkId: string) => {
setRunning(checkId)
setError('')
try {
const res = await fetch(`/api/sdk/v1/cra/checks/${checkId}/run`, {
method: 'POST',
headers: { 'X-Tenant-ID': tenant, 'Content-Type': 'application/json' },
body: JSON.stringify({ target_url: urlInputs[checkId] || null }),
})
if (!res.ok) throw new Error(await res.text())
await load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Run fehlgeschlagen')
} finally {
setRunning(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-5xl mx-auto px-4">
<div className="mb-6">
<a href={`/sdk/cra/${projectId}`} className="text-sm text-blue-600 hover:underline">
&larr; Zurueck zum Projekt
</a>
<h1 className="text-2xl font-bold text-gray-900 mt-2">Automatisierte Checks</h1>
<p className="text-sm text-gray-600 mt-1">
CRA-typische Online-Pruefungen: security.txt, Update-Policy, TLS-Konfiguration, Vuln-Disclosure.
</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>
)}
{data && data.items.length === 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center">
<p className="text-gray-600 mb-3">Noch keine Checks fuer dieses Projekt konfiguriert.</p>
<button
onClick={initChecks}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm font-medium"
>
Standard-CRA-Checks erstellen (6 Stueck)
</button>
</div>
)}
{data && data.items.length > 0 && (
<div className="space-y-3">
{data.items.map(c => (
<div key={c.id} 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">
<h3 className="font-semibold text-gray-900">{c.title}</h3>
<span className="text-xs text-gray-400">{c.check_code}</span>
</div>
<p className="text-sm text-gray-600 mt-1">{c.description}</p>
{c.linked_req_ids.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{c.linked_req_ids.map(r => (
<span key={r} className="px-2 py-0.5 text-xs rounded bg-blue-100 text-blue-700">{r}</span>
))}
</div>
)}
</div>
{c.latest_result && (
<span className={`px-2 py-1 text-xs rounded-full font-medium ${STATUS_STYLE[c.latest_result.status] || 'bg-gray-100 text-gray-600'}`}>
{c.latest_result.status}
</span>
)}
</div>
{(c.check_type === 'url_probe' || c.check_type === 'tls_probe' || c.check_type === 'manual_review') && (
<div className="flex items-center gap-2 mb-2">
<input
type="url"
placeholder={c.check_type === 'tls_probe' ? 'https://product.example.com' : 'https://your-product.com'}
value={urlInputs[c.id] ?? ''}
onChange={e => setUrlInputs({ ...urlInputs, [c.id]: e.target.value })}
className="flex-1 px-3 py-1.5 border border-gray-300 rounded text-sm focus:ring-2 focus:ring-red-500"
/>
<button
onClick={() => runCheck(c.id)}
disabled={running === c.id}
className="px-3 py-1.5 bg-red-600 text-white text-sm rounded hover:bg-red-700 disabled:bg-gray-300"
>
{running === c.id ? 'Laeuft...' : 'Run'}
</button>
</div>
)}
{c.latest_result && (
<div className="mt-2 text-xs text-gray-600 bg-gray-50 rounded p-2 font-mono">
{c.latest_result.message}
<div className="text-gray-400 mt-1 text-[10px]">
Geprueft: {new Date(c.latest_result.ran_at).toLocaleString('de-DE')}
</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> Aktuell implementiert: <code>cra_security_txt</code> (HTTP) und <code>cra_tls_cert_check</code> (TLS-Handshake).
Andere Check-Typen sind als <code>manual_review_required</code> markiert der Pruefer beantwortet sie manuell.
</div>
</div>
</div>
)
}
@@ -0,0 +1,240 @@
'use client'
import React, { useEffect, useState, useCallback, use } from 'react'
import { useRouter } from 'next/navigation'
const LANGUAGES = [
{ value: '', label: '— bitte waehlen —' },
{ value: 'js', label: 'JavaScript / TypeScript' },
{ value: 'python', label: 'Python' },
{ value: 'go', label: 'Go' },
{ value: 'rust', label: 'Rust' },
{ value: 'java', label: 'Java / Kotlin' },
{ value: 'csharp', label: 'C# / .NET' },
{ value: 'cpp', label: 'C / C++' },
{ value: 'swift', label: 'Swift' },
{ value: 'mixed', label: 'Mehrere Sprachen' },
{ value: 'other', label: 'Andere' },
]
interface CRAProject {
id: string
name: string
description: string
repo_url: string | null
primary_language: string | null
has_firmware: boolean
connected_to_internet: boolean
has_software_updates: boolean
processes_personal_data: boolean
is_critical_infra_supplier: boolean
intended_use: string
}
export default function IntakePage({
params,
}: {
params: Promise<{ projectId: string }>
}) {
const { projectId } = use(params)
const router = useRouter()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [repoUrl, setRepoUrl] = useState('')
const [primaryLanguage, setPrimaryLanguage] = useState('')
const [hasFirmware, setHasFirmware] = useState(false)
const [connectedInternet, setConnectedInternet] = useState(false)
const [hasUpdates, setHasUpdates] = useState(false)
const [processesPersonal, setProcessesPersonal] = useState(false)
const [isCriticalInfra, setIsCriticalInfra] = useState(false)
const [intendedUse, setIntendedUse] = useState('')
const tenant = '00000000-0000-0000-0000-000000000001'
const load = useCallback(async () => {
try {
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}`, {
headers: { 'X-Tenant-ID': tenant },
})
if (!res.ok) throw new Error(await res.text())
const p: CRAProject = await res.json()
setName(p.name)
setDescription(p.description || '')
setRepoUrl(p.repo_url || '')
setPrimaryLanguage(p.primary_language || '')
setHasFirmware(p.has_firmware)
setConnectedInternet(p.connected_to_internet)
setHasUpdates(p.has_software_updates)
setProcessesPersonal(p.processes_personal_data)
setIsCriticalInfra(p.is_critical_infra_supplier)
setIntendedUse(p.intended_use || '')
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}, [projectId])
useEffect(() => { load() }, [load])
const save = async () => {
setSaving(true)
setError('')
try {
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenant },
body: JSON.stringify({
name,
description,
repo_url: repoUrl || null,
primary_language: primaryLanguage || null,
has_firmware: hasFirmware,
connected_to_internet: connectedInternet,
has_software_updates: hasUpdates,
processes_personal_data: processesPersonal,
is_critical_infra_supplier: isCriticalInfra,
intended_use: intendedUse,
status: 'scoped',
}),
})
if (!res.ok) throw new Error(await res.text())
router.push(`/sdk/cra/${projectId}/scope`)
} catch (e) {
setError(e instanceof Error ? e.message : 'Speichern fehlgeschlagen')
} finally {
setSaving(false)
}
}
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-3xl mx-auto px-4">
<div className="mb-6">
<a href={`/sdk/cra/${projectId}`} className="text-sm text-blue-600 hover:underline">
&larr; Zurueck zum Projekt
</a>
<h1 className="text-2xl font-bold text-gray-900 mt-2">Intake Software-Profil</h1>
<p className="text-sm text-gray-600 mt-1">
Schritt 1 von 3 Beschreibe Software, Firmware und Connectivity. Daraus leiten wir die CRA-Klassifikation ab.
</p>
</div>
{error && (
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">{error}</div>
)}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Produktname *</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500"
placeholder="z.B. SmartHome Gateway v3"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Kurzbeschreibung</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
rows={2}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Intended Use Zweck und Anwendungsbereich
</label>
<textarea
value={intendedUse}
onChange={e => setIntendedUse(e.target.value)}
rows={3}
placeholder="z.B. Mobile App fuer Industrieanlagen-Monitoring, oder: Password Manager fuer KMU, oder: VPN-Software fuer Mitarbeiter-Geraete"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500"
/>
<p className="text-xs text-gray-500 mt-1">
Wichtig fuer die Klassifikation. Erwaehne konkrete Funktionen (z.B. &quot;Firewall&quot;, &quot;Betriebssystem&quot;) wenn zutreffend.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Repo-URL (optional)</label>
<input
type="url"
value={repoUrl}
onChange={e => setRepoUrl(e.target.value)}
placeholder="https://github.com/..."
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Primaere Programmiersprache</label>
<select
value={primaryLanguage}
onChange={e => setPrimaryLanguage(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500"
>
{LANGUAGES.map(l => (
<option key={l.value} value={l.value}>{l.label}</option>
))}
</select>
</div>
</div>
<div className="border-t border-gray-200 pt-5">
<h3 className="text-sm font-medium text-gray-700 mb-3">Eigenschaften des Produkts</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{[
['hasFirmware', 'Enthaelt Firmware (Embedded/IoT)', hasFirmware, setHasFirmware],
['connectedInternet', 'Mit dem Internet verbunden', connectedInternet, setConnectedInternet],
['hasUpdates', 'Hat Software-/Firmware-Updates', hasUpdates, setHasUpdates],
['processesPersonal', 'Verarbeitet personenbezogene Daten', processesPersonal, setProcessesPersonal],
['isCriticalInfra', 'Zulieferer fuer kritische Infrastruktur', isCriticalInfra, setIsCriticalInfra],
].map(([key, label, value, setter]) => (
<label key={key as string} className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={value as boolean}
onChange={e => (setter as (b: boolean) => void)(e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-red-600 focus:ring-red-500"
/>
<span className="text-sm text-gray-700">{label as string}</span>
</label>
))}
</div>
</div>
<div className="flex gap-3 pt-3">
<button
onClick={() => router.push(`/sdk/cra/${projectId}`)}
disabled={saving}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
Abbrechen
</button>
<button
onClick={save}
disabled={saving || !name.trim()}
className="flex-1 py-2 bg-red-600 text-white font-medium rounded-lg hover:bg-red-700 disabled:bg-gray-300"
>
{saving ? 'Speichert...' : 'Weiter zum Scope-Check →'}
</button>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,332 @@
'use client'
import React, { useEffect, useState, useCallback, use } from 'react'
import { useRouter } from 'next/navigation'
import { ClassificationBadge } from '../_components/ClassificationBadge'
import { StatusStepper } from '../_components/StatusStepper'
import { SeverityBadge } from '../_components/SeverityBadge'
interface CRAProject {
id: string
name: string
description: string
cra_classification: string | null
classification_rationale: string[]
conformity_path: string | null
status: string
intended_use: string
repo_url: string | null
primary_language: string | null
created_at: string
updated_at: string
}
interface BacklogItem {
rank: number
req_id: string
title: string
category: string
severity: string
effort_days: number
priority_score: number
}
interface BacklogData {
days_to_ce_deadline: number
deadlines: { date: string; label: string }[]
total: number
items: BacklogItem[]
}
const PATH_LABEL: Record<string, string> = {
self_assessment: 'Modul A — Self-Assessment',
harmonized_standard: 'Modul B — Harmonized Standard',
eucc: 'Modul H — EUCC',
notified_body: 'Modul C — Notified Body',
}
export default function CRAProjectDashboard({
params,
}: {
params: Promise<{ projectId: string }>
}) {
const { projectId } = use(params)
const router = useRouter()
const [project, setProject] = useState<CRAProject | null>(null)
const [backlog, setBacklog] = useState<BacklogData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const load = useCallback(async () => {
try {
const headers = { 'X-Tenant-ID': '00000000-0000-0000-0000-000000000001' }
const [projRes, backlogRes] = await Promise.all([
fetch(`/api/sdk/v1/cra/projects/${projectId}`, { headers }),
fetch(`/api/sdk/v1/cra/projects/${projectId}/backlog`, { headers }),
])
if (!projRes.ok) throw new Error(await projRes.text())
setProject(await projRes.json())
if (backlogRes.ok) {
setBacklog(await backlogRes.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 (!project) return null
const nextStep =
project.status === 'draft' ? { href: `/sdk/cra/${projectId}/intake`, label: 'Intake starten' } :
project.status === 'scoped' ? { href: `/sdk/cra/${projectId}/scope`, label: 'Scope-Check ausfuehren' } :
project.status === 'classified' ? { href: `/sdk/cra/${projectId}/path`, label: 'Konformitaetspfad waehlen' } :
project.status === 'path_selected' ? { href: null, label: 'Phase 2 (Requirements) folgt' } :
{ href: null, label: '' }
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-5xl mx-auto px-4">
<div className="mb-4">
<a href="/sdk/cra" className="text-sm text-blue-600 hover:underline">&larr; Alle CRA-Projekte</a>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
<div className="flex items-start justify-between gap-4 mb-4">
<div className="flex-1 min-w-0">
<h1 className="text-2xl font-bold text-gray-900">{project.name}</h1>
{project.description && (
<p className="text-gray-600 mt-1">{project.description}</p>
)}
</div>
<ClassificationBadge value={project.cra_classification} size="lg" />
</div>
<StatusStepper current={project.status} />
</div>
{/* KPI Cards */}
{backlog && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
<KPICard
label="Annex-I Requirements"
value={backlog.total}
hint="aus Migration 059"
color="blue"
/>
<KPICard
label="Critical-Anforderungen"
value={backlog.items.filter(i => i.severity === 'CRITICAL').length}
hint={`+ ${backlog.items.filter(i => i.severity === 'HIGH').length} High`}
color="red"
/>
<KPICard
label="Tage bis CE-Pflicht"
value={backlog.days_to_ce_deadline}
hint="11.12.2027"
color={backlog.days_to_ce_deadline < 365 ? 'orange' : 'green'}
/>
<KPICard
label="Compliance"
value="0%"
hint="Evidence in Phase 3"
color="gray"
/>
</div>
)}
{/* Top-10 Backlog-Snippet */}
{backlog && backlog.items.length > 0 && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5 mb-6">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">Top-10 Prioritaeten</h3>
<a href={`/sdk/cra/${projectId}/backlog`} className="text-xs text-blue-600 hover:underline">
Vollstaendiges Backlog
</a>
</div>
<table className="w-full text-sm">
<thead className="text-xs text-gray-500 uppercase">
<tr>
<th className="text-left py-1">#</th>
<th className="text-left py-1">Anforderung</th>
<th className="text-left py-1">Severity</th>
<th className="text-left py-1">Aufwand</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{backlog.items.slice(0, 10).map(item => (
<tr key={item.req_id} className="hover:bg-gray-50">
<td className="py-2 text-gray-500">{item.rank}</td>
<td className="py-2">
<div className="font-medium text-gray-900">{item.title}</div>
<div className="text-xs text-gray-500">{item.category}</div>
</td>
<td className="py-2"><SeverityBadge value={item.severity} /></td>
<td className="py-2 text-gray-600">{item.effort_days} PT</td>
</tr>
))}
</tbody>
</table>
</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>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<InfoCard
title="Intake"
content={
project.status === 'draft'
? <span className="text-gray-400">Noch nicht erfasst</span>
: (
<div className="space-y-1 text-sm">
{project.intended_use && <div><span className="text-gray-500">Use:</span> {project.intended_use}</div>}
{project.primary_language && <div><span className="text-gray-500">Sprache:</span> {project.primary_language}</div>}
{project.repo_url && <div><span className="text-gray-500">Repo:</span> {project.repo_url}</div>}
</div>
)
}
actionHref={`/sdk/cra/${projectId}/intake`}
actionLabel={project.status === 'draft' ? 'Erfassen' : 'Bearbeiten'}
/>
<InfoCard
title="Klassifikation"
content={
project.cra_classification ? (
<div>
<ClassificationBadge value={project.cra_classification} size="md" />
{project.classification_rationale?.length > 0 && (
<ul className="mt-2 text-xs text-gray-600 list-disc list-inside space-y-0.5">
{project.classification_rationale.map((r, i) => <li key={i}>{r}</li>)}
</ul>
)}
</div>
) : <span className="text-gray-400">Scope-Check ausstehend</span>
}
actionHref={`/sdk/cra/${projectId}/scope`}
actionLabel={project.cra_classification ? 'Neu pruefen' : 'Pruefen'}
/>
<InfoCard
title="Konformitaetspfad"
content={
project.conformity_path
? <span className="font-medium text-purple-700">{PATH_LABEL[project.conformity_path] || project.conformity_path}</span>
: <span className="text-gray-400">Noch nicht gewaehlt</span>
}
actionHref={project.cra_classification ? `/sdk/cra/${projectId}/path` : null}
actionLabel={project.conformity_path ? 'Aendern' : 'Waehlen'}
/>
<InfoCard
title="Status"
content={
<div className="space-y-1 text-sm">
<div><span className="text-gray-500">Aktuell:</span> {project.status}</div>
<div className="text-xs text-gray-400">Aktualisiert: {new Date(project.updated_at).toLocaleString('de-DE')}</div>
</div>
}
actionHref={null}
actionLabel=""
/>
</div>
{nextStep.href && (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-5 flex items-center justify-between">
<div>
<h3 className="font-semibold text-blue-900">Naechster Schritt</h3>
<p className="text-sm text-blue-700 mt-1">{nextStep.label}</p>
</div>
<button
onClick={() => router.push(nextStep.href!)}
className="px-5 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"
>
Weiter
</button>
</div>
)}
{!nextStep.href && nextStep.label && (
<div className="bg-gray-100 border border-gray-200 rounded-xl p-5 text-center text-gray-600">
{nextStep.label}
</div>
)}
</div>
</div>
)
}
function InfoCard({
title, content, actionHref, actionLabel,
}: {
title: string
content: React.ReactNode
actionHref: string | null
actionLabel: string
}) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">{title}</h3>
{actionHref && actionLabel && (
<a href={actionHref} className="text-xs text-blue-600 hover:underline">{actionLabel}</a>
)}
</div>
<div>{content}</div>
</div>
)
}
function KPICard({
label, value, hint, color,
}: {
label: string
value: string | number
hint: string
color: 'blue' | 'red' | 'orange' | 'green' | 'gray'
}) {
const colors = {
blue: 'bg-blue-50 border-blue-200 text-blue-700',
red: 'bg-red-50 border-red-200 text-red-700',
orange: 'bg-orange-50 border-orange-200 text-orange-700',
green: 'bg-green-50 border-green-200 text-green-700',
gray: 'bg-gray-50 border-gray-200 text-gray-700',
}
return (
<div className={`rounded-xl border p-4 ${colors[color]}`}>
<p className="text-xs text-gray-600 uppercase tracking-wide">{label}</p>
<p className="text-2xl font-bold mt-1">{value}</p>
<p className="text-xs mt-1 opacity-80">{hint}</p>
</div>
)
}
@@ -0,0 +1,256 @@
'use client'
import React, { useEffect, useState, useCallback, use } from 'react'
import { useRouter } from 'next/navigation'
import { ClassificationBadge } from '../../_components/ClassificationBadge'
interface CRAProject {
id: string
name: string
cra_classification: string | null
conformity_path: string | null
status: string
}
type PathId = 'self_assessment' | 'harmonized_standard' | 'eucc' | 'notified_body'
interface PathOption {
id: PathId
modul: string
title: string
short: string
details: string[]
}
const PATHS: PathOption[] = [
{
id: 'self_assessment',
modul: 'Modul A',
title: 'Self-Assessment',
short: 'Konformitaetsbewertung durch interne Pruefung',
details: [
'Hersteller fuehrt Konformitaetsbewertung selbst durch',
'Geringster externer Aufwand, schnelle Umsetzung',
'Default fuer Standard-Produkte',
'Technische Dokumentation + DoC bleibt Pflicht',
],
},
{
id: 'harmonized_standard',
modul: 'Modul B',
title: 'Harmonized Standard',
short: 'Konformitaetsvermutung durch harmonisierte Norm',
details: [
'Anwendung einer harmonisierten EU-Norm (z.B. DIN EN 40000-1-2 Entwurf)',
'Konformitaetsvermutung gemaess EU-Recht',
'Geringeres Audit-Risiko',
'Empfohlen bei verfuegbarer harmonisierter Norm',
],
},
{
id: 'eucc',
modul: 'Modul H',
title: 'EUCC Zertifizierung',
short: 'European Cybersecurity Certification Scheme',
details: [
'ENISA-EUCC-Zertifizierung (Common Criteria-basiert)',
'Hoechste Anerkennung in EU + Drittstaaten',
'Hoher Aufwand, ITSEF-Pruefung erforderlich',
'Pflicht bei einigen Important Class II-Produkten',
],
},
{
id: 'notified_body',
modul: 'Modul C',
title: 'Notified Body Assessment',
short: 'Drittprueforganisation pruefn die Konformitaet',
details: [
'Externe Bewertung durch akkreditierte Stelle',
'PFLICHT fuer Critical-Produkte (Annex IV)',
'Hoechste Auditierbarkeit + Vertrauen',
'Laufzeit + Kosten am hoechsten',
],
},
]
const ALLOWED: Record<string, PathId[]> = {
STANDARD: ['self_assessment', 'harmonized_standard', 'eucc', 'notified_body'],
IMPORTANT_I: ['self_assessment', 'harmonized_standard', 'eucc', 'notified_body'],
IMPORTANT_II: ['harmonized_standard', 'eucc', 'notified_body'],
CRITICAL: ['notified_body'],
}
const DEFAULT_FOR: Record<string, PathId> = {
STANDARD: 'self_assessment',
IMPORTANT_I: 'self_assessment',
IMPORTANT_II: 'harmonized_standard',
CRITICAL: 'notified_body',
}
export default function PathSelectPage({
params,
}: {
params: Promise<{ projectId: string }>
}) {
const { projectId } = use(params)
const router = useRouter()
const [project, setProject] = useState<CRAProject | null>(null)
const [selected, setSelected] = useState<PathId | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const tenant = '00000000-0000-0000-0000-000000000001'
const load = useCallback(async () => {
try {
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}`, {
headers: { 'X-Tenant-ID': tenant },
})
if (!res.ok) throw new Error(await res.text())
const p: CRAProject = await res.json()
setProject(p)
if (p.conformity_path) {
setSelected(p.conformity_path as PathId)
} else if (p.cra_classification && DEFAULT_FOR[p.cra_classification]) {
setSelected(DEFAULT_FOR[p.cra_classification])
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}, [projectId])
useEffect(() => { load() }, [load])
const submit = async () => {
if (!selected) return
setSaving(true)
setError('')
try {
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}/path-select`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenant },
body: JSON.stringify({ conformity_path: selected }),
})
if (!res.ok) throw new Error(await res.text())
router.push(`/sdk/cra/${projectId}`)
} catch (e) {
setError(e instanceof Error ? e.message : 'Speichern fehlgeschlagen')
} finally {
setSaving(false)
}
}
if (loading) return <div className="min-h-screen bg-gray-50 p-8"><p className="text-gray-500">Laedt...</p></div>
if (!project) return null
if (!project.cra_classification) {
return (
<div className="min-h-screen bg-gray-50 p-8">
<div className="max-w-2xl mx-auto bg-yellow-50 border border-yellow-200 rounded-lg p-6">
<p className="text-yellow-800">
Bitte erst den Scope-Check ausfuehren.
<a href={`/sdk/cra/${projectId}/scope`} className="ml-2 underline"> Zum Scope-Check</a>
</p>
</div>
</div>
)
}
const allowedPaths = ALLOWED[project.cra_classification] || []
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-4xl mx-auto px-4">
<div className="mb-6">
<a href={`/sdk/cra/${projectId}`} className="text-sm text-blue-600 hover:underline">
&larr; Zurueck zum Projekt
</a>
<h1 className="text-2xl font-bold text-gray-900 mt-2">Konformitaetspfad waehlen</h1>
<p className="text-sm text-gray-600 mt-1">
Schritt 3 von 3 basierend auf der Klassifikation siehst du die zulaessigen Pfade.
</p>
<div className="mt-3 flex items-center gap-2">
<span className="text-sm text-gray-600">Klassifikation:</span>
<ClassificationBadge value={project.cra_classification} size="md" />
</div>
</div>
{error && (
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">{error}</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
{PATHS.map(path => {
const allowed = allowedPaths.includes(path.id)
const isSelected = selected === path.id
return (
<button
key={path.id}
onClick={() => allowed && setSelected(path.id)}
disabled={!allowed}
className={`text-left p-5 rounded-xl border-2 transition-all ${
isSelected ? 'border-red-500 bg-red-50' :
allowed ? 'border-gray-200 bg-white hover:border-red-300 hover:shadow-md' :
'border-gray-200 bg-gray-50 opacity-50 cursor-not-allowed'
}`}
>
<div className="flex items-start justify-between mb-2">
<div>
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">{path.modul}</span>
<h3 className="text-lg font-semibold text-gray-900">{path.title}</h3>
</div>
{isSelected && (
<span className="px-2 py-0.5 text-xs bg-red-600 text-white rounded">Gewaehlt</span>
)}
{!allowed && (
<span className="px-2 py-0.5 text-xs bg-gray-200 text-gray-600 rounded">Nicht zulaessig</span>
)}
</div>
<p className="text-sm text-gray-600 mb-3">{path.short}</p>
<ul className="text-xs text-gray-600 space-y-1">
{path.details.map((d, i) => (
<li key={i} className="flex items-start gap-1.5">
<span className="text-gray-400 mt-0.5"></span>
<span>{d}</span>
</li>
))}
</ul>
</button>
)
})}
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4 flex items-center justify-between">
<div className="text-sm text-gray-600">
{selected ? (
<>Ausgewaehlt: <span className="font-medium text-gray-900">
{PATHS.find(p => p.id === selected)?.title}
</span></>
) : (
'Keine Auswahl getroffen'
)}
</div>
<div className="flex gap-3">
<button
onClick={() => router.push(`/sdk/cra/${projectId}/scope`)}
disabled={saving}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
Zurueck
</button>
<button
onClick={submit}
disabled={saving || !selected}
className="px-6 py-2 bg-red-600 text-white font-medium rounded-lg hover:bg-red-700 disabled:bg-gray-300"
>
{saving ? 'Speichert...' : 'Pfad festlegen'}
</button>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,182 @@
'use client'
import React, { useEffect, useState, useCallback, use } from 'react'
import { SeverityBadge } from '../../_components/SeverityBadge'
interface Requirement {
req_id: string
n: number
category: string
title: string
annex_anchor: string
iso27001_ref: string[]
description: string
severity: string
mapped_measures: string[]
mapped_measure_names: { id: string; name: string }[]
evidence_type: string
effort_days: number
status: string
}
interface RequirementsResponse {
project_id: string
classification: string | null
total: number
items: Requirement[]
}
export default function RequirementsPage({
params,
}: {
params: Promise<{ projectId: string }>
}) {
const { projectId } = use(params)
const [data, setData] = useState<RequirementsResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [filterCategory, setFilterCategory] = useState<string>('all')
const [filterSeverity, setFilterSeverity] = useState<string>('all')
const [expanded, setExpanded] = useState<string | null>(null)
const load = useCallback(async () => {
try {
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}/requirements`, {
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 categories = Array.from(new Set(data.items.map(i => i.category)))
const filtered = data.items.filter(r =>
(filterCategory === 'all' || r.category === filterCategory) &&
(filterSeverity === 'all' || r.severity === filterSeverity)
)
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-7xl mx-auto px-4">
<div className="mb-6">
<a href={`/sdk/cra/${projectId}`} className="text-sm text-blue-600 hover:underline">
&larr; Zurueck zum Projekt
</a>
<h1 className="text-2xl font-bold text-gray-900 mt-2">CRA Annex I Requirements</h1>
<p className="text-sm text-gray-600 mt-1">
Alle {data.total} Essential Cybersecurity Requirements aus Annex I. Status bleibt &quot;unbewertet&quot; bis Evidence-Checks in Phase 3 verknuepft sind.
</p>
</div>
<div className="flex gap-3 mb-4 flex-wrap">
<select
value={filterCategory}
onChange={e => setFilterCategory(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
<option value="all">Alle Kategorien</option>
{categories.map(c => <option key={c} value={c}>{c}</option>)}
</select>
<select
value={filterSeverity}
onChange={e => setFilterSeverity(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
<option value="all">Alle Severities</option>
<option value="CRITICAL">Kritisch</option>
<option value="HIGH">Hoch</option>
<option value="MEDIUM">Mittel</option>
<option value="LOW">Niedrig</option>
</select>
<span className="text-sm text-gray-500 self-center">
{filtered.length} von {data.total}
</span>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">#</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Anforderung</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Kategorie</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Severity</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Aufwand</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filtered.map(req => (
<React.Fragment key={req.req_id}>
<tr
className="hover:bg-gray-50 cursor-pointer"
onClick={() => setExpanded(expanded === req.req_id ? null : req.req_id)}
>
<td className="px-3 py-2 text-sm text-gray-500">{req.n}</td>
<td className="px-3 py-2">
<div className="text-sm font-medium text-gray-900">{req.title}</div>
<div className="text-xs text-gray-500">{req.annex_anchor} · {req.req_id}</div>
</td>
<td className="px-3 py-2 text-sm text-gray-600">{req.category}</td>
<td className="px-3 py-2"><SeverityBadge value={req.severity} /></td>
<td className="px-3 py-2 text-sm text-gray-600">{req.effort_days} PT</td>
<td className="px-3 py-2">
<span className="px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600">{req.status}</span>
</td>
</tr>
{expanded === req.req_id && (
<tr>
<td colSpan={6} className="px-4 py-4 bg-blue-50">
<div className="space-y-3">
<div>
<p className="text-xs font-semibold text-gray-600 uppercase">Beschreibung</p>
<p className="text-sm text-gray-700 mt-1">{req.description}</p>
</div>
{req.iso27001_ref.length > 0 && (
<div>
<p className="text-xs font-semibold text-gray-600 uppercase">ISO 27001:2022 Mapping</p>
<p className="text-sm text-gray-700 mt-1">
{req.iso27001_ref.map(r => (
<span key={r} className="inline-block mr-2 mb-1 px-2 py-0.5 bg-white rounded text-xs">{r}</span>
))}
</p>
</div>
)}
{req.mapped_measure_names.length > 0 && (
<div>
<p className="text-xs font-semibold text-gray-600 uppercase">Empfohlene Massnahmen</p>
<ul className="text-sm text-gray-700 mt-1 space-y-0.5">
{req.mapped_measure_names.map(m => (
<li key={m.id}>
<span className="font-mono text-xs text-gray-500">{m.id}</span> {m.name}
</li>
))}
</ul>
</div>
)}
<div className="text-xs text-gray-500 pt-1">
Evidence-Typ: <span className="font-medium">{req.evidence_type}</span>
</div>
</div>
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}
@@ -0,0 +1,171 @@
'use client'
import React, { useEffect, useState, useCallback, use, useRef } from 'react'
interface SBOMItem {
id: string
filename: string
format: string
spec_version: string | null
component_count: number
summary: Record<string, unknown>
scan_status: string
scan_summary: Record<string, unknown>
uploaded_at: string
scanned_at: string | null
}
interface SBOMListResponse {
project_id: string
total: number
items: SBOMItem[]
}
export default function SBOMPage({
params,
}: {
params: Promise<{ projectId: string }>
}) {
const { projectId } = use(params)
const [data, setData] = useState<SBOMListResponse | null>(null)
const [loading, setLoading] = useState(true)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState('')
const fileRef = useRef<HTMLInputElement>(null)
const tenant = '00000000-0000-0000-0000-000000000001'
const load = useCallback(async () => {
try {
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}/sbom`, {
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 onUpload = async () => {
const f = fileRef.current?.files?.[0]
if (!f) return
setUploading(true)
setError('')
try {
const fd = new FormData()
fd.append('file', f)
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}/sbom`, {
method: 'POST',
headers: { 'X-Tenant-ID': tenant },
body: fd,
})
if (!res.ok) throw new Error(await res.text())
if (fileRef.current) fileRef.current.value = ''
await load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Upload fehlgeschlagen')
} finally {
setUploading(false)
}
}
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">
&larr; Zurueck zum Projekt
</a>
<h1 className="text-2xl font-bold text-gray-900 mt-2">SBOM Software Bill of Materials</h1>
<p className="text-sm text-gray-600 mt-1">
CycloneDX oder SPDX hochladen. Verknuepft mit Annex-I Requirement 23 (SBOM-Pflicht).
</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 mt-1 underline text-xs">Schliessen</button>
</div>
)}
<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 mb-3">Neue Version hochladen</h3>
<div className="flex items-center gap-3">
<input
ref={fileRef}
type="file"
accept=".json,application/json"
className="flex-1 text-sm file:mr-3 file:py-1.5 file:px-3 file:rounded file:border-0 file:text-sm file:bg-blue-100 file:text-blue-700 hover:file:bg-blue-200"
/>
<button
onClick={onUpload}
disabled={uploading}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:bg-gray-300 text-sm font-medium"
>
{uploading ? 'Laedt hoch...' : 'Upload'}
</button>
</div>
<p className="text-xs text-gray-500 mt-2">
Format: CycloneDX-JSON (mit <code>bomFormat: &quot;CycloneDX&quot;</code>) oder SPDX-JSON (mit <code>spdxVersion</code>).
Generieren z.B. via <code>npx @cyclonedx/cyclonedx-npm</code> oder <code>cyclonedx-py</code>.
</p>
</div>
{data && data.items.length === 0 && (
<div className="bg-gray-100 rounded-xl p-8 text-center text-gray-500">
Noch kein SBOM hochgeladen.
</div>
)}
{data && data.items.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-gray-700">Versionen ({data.total})</h3>
{data.items.map(s => (
<div key={s.id} className="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<div className="flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-semibold text-gray-900">{s.filename}</span>
<span className="px-2 py-0.5 text-xs rounded bg-blue-100 text-blue-700 uppercase">{s.format}</span>
{s.spec_version && (
<span className="text-xs text-gray-500">v{s.spec_version}</span>
)}
</div>
<div className="text-xs text-gray-500 mt-1">
{s.component_count} Komponenten · hochgeladen {new Date(s.uploaded_at).toLocaleString('de-DE')}
</div>
</div>
<span className={`px-2 py-1 text-xs rounded-full ${
s.scan_status === 'scanned' ? 'bg-green-100 text-green-700' :
s.scan_status === 'failed' ? 'bg-red-100 text-red-700' :
'bg-gray-100 text-gray-600'
}`}>
Scan: {s.scan_status}
</span>
</div>
{s.summary && Object.keys(s.summary).length > 0 && (
<details className="mt-3 text-xs">
<summary className="cursor-pointer text-gray-600 hover:text-gray-900">Summary-Details</summary>
<pre className="mt-2 p-2 bg-gray-50 rounded overflow-x-auto text-xs">{JSON.stringify(s.summary, null, 2)}</pre>
</details>
)}
</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> Der osv.dev-Vulnerability-Scan wird durch ein separates Tool im Team durchgefuehrt.
Diese Seite akzeptiert SBOM-Uploads und persistiert sie versioniert.
</div>
</div>
</div>
)
}
@@ -0,0 +1,172 @@
'use client'
import React, { useEffect, useState, useCallback, use } from 'react'
import { useRouter } from 'next/navigation'
import { ClassificationBadge } from '../../_components/ClassificationBadge'
interface CRAProject {
id: string
name: string
intended_use: string
primary_language: string | null
connected_to_internet: boolean
has_software_updates: boolean
processes_personal_data: boolean
is_critical_infra_supplier: boolean
cra_classification: string | null
classification_rationale: string[]
status: string
}
const CLASSIFICATION_DESC: Record<string, string> = {
NOT_IN_SCOPE: 'Dein Produkt enthaelt keine digitalen Elemente nach CRA-Definition. Es ist nicht vom CRA betroffen.',
STANDARD: 'Default-Kategorie fuer Produkte mit digitalen Elementen. Self-Assessment (Modul A) ist der typische Pfad.',
IMPORTANT_I: 'Annex III Klasse I — Wichtige Produkte mit erhoehten Anforderungen. Self-Assessment OR Harmonized Standard moeglich.',
IMPORTANT_II: 'Annex III Klasse II — Wichtige Produkte mit hohem Sicherheitsbedarf. Harmonized Standard ODER EUCC ODER Notified Body.',
CRITICAL: 'Annex IV — Kritische Produkte (z.B. HSM, Smart-Meter-Gateways). Notified-Body-Assessment Pflicht.',
}
export default function ScopeCheckPage({
params,
}: {
params: Promise<{ projectId: string }>
}) {
const { projectId } = use(params)
const router = useRouter()
const [project, setProject] = useState<CRAProject | null>(null)
const [loading, setLoading] = useState(true)
const [checking, setChecking] = useState(false)
const [error, setError] = useState('')
const tenant = '00000000-0000-0000-0000-000000000001'
const load = useCallback(async () => {
try {
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}`, {
headers: { 'X-Tenant-ID': tenant },
})
if (!res.ok) throw new Error(await res.text())
setProject(await res.json())
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}, [projectId])
useEffect(() => { load() }, [load])
const runScopeCheck = async () => {
setChecking(true)
setError('')
try {
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}/scope-check`, {
method: 'POST',
headers: { 'X-Tenant-ID': tenant },
})
if (!res.ok) throw new Error(await res.text())
setProject(await res.json())
} catch (e) {
setError(e instanceof Error ? e.message : 'Klassifikation fehlgeschlagen')
} finally {
setChecking(false)
}
}
if (loading) return <div className="min-h-screen bg-gray-50 p-8"><p className="text-gray-500">Laedt...</p></div>
if (!project) return null
const hasResult = !!project.cra_classification
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-3xl mx-auto px-4">
<div className="mb-6">
<a href={`/sdk/cra/${projectId}`} className="text-sm text-blue-600 hover:underline">
&larr; Zurueck zum Projekt
</a>
<h1 className="text-2xl font-bold text-gray-900 mt-2">Scope-Check & Klassifikation</h1>
<p className="text-sm text-gray-600 mt-1">
Schritt 2 von 3 Wir matchen dein Intake gegen Annex III/IV des CRA.
</p>
</div>
{error && (
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">{error}</div>
)}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
<h3 className="text-sm font-semibold text-gray-700">Aktuelle Intake-Daten</h3>
<dl className="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm">
<Field label="Produkt" value={project.name} />
<Field label="Sprache" value={project.primary_language || '—'} />
<Field label="Intended Use" value={project.intended_use || '—'} fullWidth />
<Field label="Internet" value={project.connected_to_internet ? 'Ja' : 'Nein'} />
<Field label="Software-Updates" value={project.has_software_updates ? 'Ja' : 'Nein'} />
<Field label="Personenbezogene Daten" value={project.processes_personal_data ? 'Ja' : 'Nein'} />
<Field label="Kritische Infra" value={project.is_critical_infra_supplier ? 'Ja' : 'Nein'} />
</dl>
<div className="border-t border-gray-200 pt-4">
<button
onClick={runScopeCheck}
disabled={checking}
className="w-full py-3 bg-red-600 text-white font-medium rounded-lg hover:bg-red-700 disabled:bg-gray-300"
>
{checking ? 'Pruefe...' : hasResult ? 'Klassifikation neu berechnen' : 'Klassifikation berechnen'}
</button>
</div>
</div>
{hasResult && (
<div className="mt-6 bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Ergebnis</h3>
<div className="flex items-center gap-4 mb-4">
<ClassificationBadge value={project.cra_classification} size="lg" />
<p className="text-sm text-gray-700">
{CLASSIFICATION_DESC[project.cra_classification!]}
</p>
</div>
{project.classification_rationale?.length > 0 && (
<div className="bg-gray-50 rounded-lg p-4 mb-4">
<p className="text-xs font-semibold text-gray-600 uppercase tracking-wide mb-2">Begruendung</p>
<ul className="list-disc list-inside space-y-1 text-sm text-gray-700">
{project.classification_rationale.map((r, i) => <li key={i}>{r}</li>)}
</ul>
</div>
)}
<div className="flex gap-3">
<button
onClick={() => router.push(`/sdk/cra/${projectId}/intake`)}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
Intake anpassen
</button>
<button
onClick={() => router.push(`/sdk/cra/${projectId}/path`)}
disabled={project.cra_classification === 'NOT_IN_SCOPE'}
className="flex-1 py-2 bg-red-600 text-white font-medium rounded-lg hover:bg-red-700 disabled:bg-gray-300"
>
Weiter zum Konformitaetspfad
</button>
</div>
{project.cra_classification === 'NOT_IN_SCOPE' && (
<p className="text-xs text-gray-500 mt-2 text-center">
Produkt ist nicht im CRA-Scope. Keine weiteren Schritte noetig.
</p>
)}
</div>
)}
</div>
</div>
)
}
function Field({ label, value, fullWidth }: { label: string; value: string; fullWidth?: boolean }) {
return (
<div className={fullWidth ? 'md:col-span-2' : ''}>
<dt className="text-xs text-gray-500">{label}</dt>
<dd className="text-gray-900 mt-0.5">{value}</dd>
</div>
)
}
@@ -0,0 +1,24 @@
'use client'
type Classification = 'NOT_IN_SCOPE' | 'STANDARD' | 'IMPORTANT_I' | 'IMPORTANT_II' | 'CRITICAL'
const STYLES: Record<string, { bg: string; label: string }> = {
NOT_IN_SCOPE: { bg: 'bg-gray-200 text-gray-700', label: 'Nicht im Scope' },
STANDARD: { bg: 'bg-blue-100 text-blue-800', label: 'Standard' },
IMPORTANT_I: { bg: 'bg-yellow-100 text-yellow-800', label: 'Important Class I' },
IMPORTANT_II: { bg: 'bg-orange-100 text-orange-800', label: 'Important Class II' },
CRITICAL: { bg: 'bg-red-100 text-red-800', label: 'Critical' },
}
export function ClassificationBadge({ value, size = 'md' }: { value: string | null; size?: 'sm' | 'md' | 'lg' }) {
if (!value) {
return <span className="px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-500">Unbewertet</span>
}
const style = STYLES[value] || { bg: 'bg-gray-100 text-gray-700', label: value }
const sizeClasses = {
sm: 'px-2 py-0.5 text-xs',
md: 'px-3 py-1 text-sm font-medium',
lg: 'px-4 py-2 text-base font-semibold',
}[size]
return <span className={`rounded-full ${sizeClasses} ${style.bg}`}>{style.label}</span>
}
@@ -0,0 +1,13 @@
'use client'
const STYLES: Record<string, { bg: string; label: string }> = {
CRITICAL: { bg: 'bg-red-600 text-white', label: 'Kritisch' },
HIGH: { bg: 'bg-orange-500 text-white', label: 'Hoch' },
MEDIUM: { bg: 'bg-yellow-400 text-gray-900', label: 'Mittel' },
LOW: { bg: 'bg-blue-100 text-blue-800', label: 'Niedrig' },
}
export function SeverityBadge({ value }: { value: string }) {
const s = STYLES[value] || { bg: 'bg-gray-200 text-gray-700', label: value }
return <span className={`px-2 py-0.5 text-xs font-bold rounded ${s.bg}`}>{s.label}</span>
}
@@ -0,0 +1,40 @@
'use client'
const STEPS = [
{ id: 'draft', label: 'Entwurf' },
{ id: 'scoped', label: 'Intake' },
{ id: 'classified', label: 'Klassifiziert' },
{ id: 'path_selected', label: 'Pfad' },
{ id: 'requirements_mapped', label: 'Requirements' },
{ id: 'evidence_pending', label: 'Evidence' },
{ id: 'ready_for_review', label: 'Review' },
{ id: 'declaration_ready', label: 'DoC' },
{ id: 'post_market', label: 'Post-Market' },
]
export function StatusStepper({ current }: { current: string }) {
const currentIdx = STEPS.findIndex(s => s.id === current)
return (
<div className="flex items-center gap-1 overflow-x-auto py-2">
{STEPS.map((step, idx) => {
const isPast = idx < currentIdx
const isCurrent = idx === currentIdx
return (
<div key={step.id} className="flex items-center gap-1 flex-shrink-0">
<div className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-medium ${
isCurrent ? 'bg-blue-600 text-white' :
isPast ? 'bg-green-500 text-white' :
'bg-gray-200 text-gray-500'
}`}>{idx + 1}</div>
<span className={`text-xs ${isCurrent ? 'font-semibold text-blue-700' : isPast ? 'text-gray-700' : 'text-gray-400'}`}>
{step.label}
</span>
{idx < STEPS.length - 1 && (
<span className={`mx-1 ${isPast ? 'text-green-500' : 'text-gray-300'}`}></span>
)}
</div>
)
})}
</div>
)
}
+200
View File
@@ -0,0 +1,200 @@
'use client'
import React, { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { ClassificationBadge } from './_components/ClassificationBadge'
interface CRAProject {
id: string
name: string
description: string
cra_classification: string | null
conformity_path: string | null
status: string
created_at: string
}
const PATH_LABEL: Record<string, string> = {
self_assessment: 'Modul A (Self-Assessment)',
harmonized_standard: 'Modul B (Harmonized)',
eucc: 'Modul H (EUCC)',
notified_body: 'Modul C (Notified Body)',
}
const STATUS_LABEL: Record<string, string> = {
draft: 'Entwurf',
scoped: 'Intake erfasst',
classified: 'Klassifiziert',
path_selected: 'Pfad gewaehlt',
requirements_mapped: 'Requirements',
evidence_pending: 'Evidence',
gaps_open: 'Gaps offen',
remediation: 'Remediation',
ready_for_review: 'In Pruefung',
declaration_ready: 'DoC bereit',
post_market: 'Post-Market',
archived: 'Archiviert',
}
export default function CRAProjectsPage() {
const router = useRouter()
const [projects, setProjects] = useState<CRAProject[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [showModal, setShowModal] = useState(false)
const [newName, setNewName] = useState('')
const [newDescription, setNewDescription] = useState('')
const [creating, setCreating] = useState(false)
const tenantHeader = '00000000-0000-0000-0000-000000000001'
const loadProjects = useCallback(async () => {
try {
const res = await fetch('/api/sdk/v1/cra/projects', {
headers: { 'X-Tenant-ID': tenantHeader },
})
if (!res.ok) throw new Error(await res.text())
const data = await res.json()
setProjects(data.projects || [])
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { loadProjects() }, [loadProjects])
const createProject = async () => {
if (!newName.trim()) return
setCreating(true)
setError('')
try {
const res = await fetch('/api/sdk/v1/cra/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenantHeader },
body: JSON.stringify({ name: newName, description: newDescription }),
})
if (!res.ok) throw new Error(await res.text())
const project = await res.json()
router.push(`/sdk/cra/${project.id}/intake`)
} catch (e) {
setError(e instanceof Error ? e.message : 'Anlegen fehlgeschlagen')
} finally {
setCreating(false)
}
}
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-6xl mx-auto px-4">
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900">CRA Compliance</h1>
<p className="text-gray-600 mt-2">
Cyber Resilience Act Konformitaets-Workflow fuer Produkte mit digitalen Elementen.
</p>
<p className="text-sm text-gray-500 mt-1">
Fuer Entwickler / Tech-Experten. Hardware-CE-Risikobeurteilung siehe{' '}
<a href="/sdk/iace" className="text-blue-600 hover:underline">iACE</a>.
</p>
</div>
{error && (
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">
{error}
<button onClick={() => setError('')} className="ml-3 underline">Schliessen</button>
</div>
)}
<button
onClick={() => setShowModal(true)}
className="mb-6 w-full py-4 border-2 border-dashed border-red-300 rounded-xl text-red-600 hover:bg-red-50 hover:border-red-400 transition-colors font-medium"
>
+ Neues CRA-Projekt
</button>
{loading ? (
<div className="text-center text-gray-500 py-12">Laedt...</div>
) : projects.length === 0 ? (
<p className="text-center text-gray-500 mt-8">
Noch keine Projekte. Starten Sie Ihre erste CRA-Konformitaetsanalyse.
</p>
) : (
<div className="space-y-3">
<h2 className="text-lg font-semibold text-gray-800">Projekte</h2>
{projects.map(p => (
<a
key={p.id}
href={`/sdk/cra/${p.id}`}
className="block bg-white rounded-xl shadow-sm border border-gray-200 p-5 hover:shadow-md hover:border-red-300 transition-all"
>
<div className="flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 truncate">{p.name}</h3>
{p.description && (
<p className="text-sm text-gray-500 mt-1 truncate">{p.description}</p>
)}
</div>
<div className="flex items-center gap-3 flex-shrink-0">
<ClassificationBadge value={p.cra_classification} size="sm" />
{p.conformity_path && (
<span className="px-2 py-0.5 text-xs rounded-full bg-purple-100 text-purple-800">
{PATH_LABEL[p.conformity_path] || p.conformity_path}
</span>
)}
<span className="px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-700">
{STATUS_LABEL[p.status] || p.status}
</span>
<span className="text-xs text-gray-400">
{new Date(p.created_at).toLocaleDateString('de-DE')}
</span>
</div>
</div>
</a>
))}
</div>
)}
{showModal && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl max-w-md w-full mx-4 p-6">
<h3 className="text-lg font-semibold mb-4">Neues CRA-Projekt anlegen</h3>
<div className="space-y-3">
<input
type="text"
placeholder="Projektname (z.B. SmartHome Gateway v3)"
value={newName}
onChange={e => setNewName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500"
/>
<textarea
placeholder="Kurzbeschreibung (optional)"
value={newDescription}
onChange={e => setNewDescription(e.target.value)}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500"
/>
</div>
<div className="flex gap-3 mt-5">
<button
onClick={() => { setShowModal(false); setNewName(''); setNewDescription('') }}
disabled={creating}
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
Abbrechen
</button>
<button
onClick={createProject}
disabled={creating || !newName.trim()}
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:bg-gray-300"
>
{creating ? 'Erstelle...' : 'Anlegen'}
</button>
</div>
</div>
</div>
)}
</div>
</div>
)
}
@@ -75,6 +75,28 @@ export function SidebarModuleList({ collapsed, projectId, pendingCRCount }: Side
<AdditionalModuleItem href="/sdk/agent" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>} label="Compliance Agent" isActive={pathname?.startsWith('/sdk/agent') ?? false} collapsed={collapsed} projectId={projectId} />
</div>
{/* CRA Compliance */}
<div className="border-t-2 border-red-200 py-2 bg-red-50/30">
{!collapsed && (
<div className="px-4 py-2 text-xs font-semibold text-red-600 uppercase tracking-wider">
CRA Compliance
</div>
)}
<AdditionalModuleItem
href="/sdk/cra"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
}
label="CRA Compliance"
isActive={pathname?.startsWith('/sdk/cra') ?? false}
collapsed={collapsed}
projectId={projectId}
/>
</div>
{/* Regulatory Gap-Analyse */}
<div className="border-t-2 border-orange-200 py-2 bg-orange-50/30">
{!collapsed && (
@@ -0,0 +1,260 @@
"""
CRA Annex I Essential Cybersecurity Requirements (40 Controls)
Quelle: Migration 059_wiki_cra_annex_i_detail.sql (Wiki-Artikel) +
ai-compliance-sdk/internal/iace/measures_library_cra.go (M540-M548).
Statische Daten eine deterministische Quelle fuer die /requirements und
/backlog Endpoints. KEINE LLM-Calls.
Schluesselfelder:
- req_id eindeutige Stable-ID (CRA-AI-1 .. CRA-AI-40)
- category eine der 8 Annex-I-Kategorien
- annex_anchor Verweis auf CRA Annex I Punkt (z.B. "Annex I, 1(3)(d)")
- severity CRITICAL | HIGH | MEDIUM | LOW wie kritisch die Luecke ist
- iso27001_ref Annex A Mapping zur ISO 27001:2022
- mapped_measures Liste von M-IDs aus measures_library_cra.go
- evidence_type code | process | hybrid | document wie pruefbar
- effort_days Schaetzung in Personentagen fuer typische Umsetzung
"""
ANNEX_I_REQUIREMENTS = [
# Part 1 — Produktsicherheit
# Kategorie 1: Secure-by-Design
{"req_id": "CRA-AI-1", "n": 1, "category": "Secure-by-Design",
"title": "Secure-by-Default-Konfiguration",
"annex_anchor": "Annex I, 1(1)", "iso27001_ref": ["A.8.9"],
"description": "Produkte muessen mit sicheren Standardeinstellungen ausgeliefert werden. Keine offenen Ports, keine aktivierten Debug-Schnittstellen, keine unnoetig laufenden Dienste.",
"severity": "HIGH", "mapped_measures": ["M545"], "evidence_type": "hybrid", "effort_days": 5},
{"req_id": "CRA-AI-2", "n": 2, "category": "Secure-by-Design",
"title": "Minimale Angriffsflaeche",
"annex_anchor": "Annex I, 1(2)", "iso27001_ref": ["A.8.9", "A.8.20"],
"description": "Nur notwendige Schnittstellen, Dienste und Protokolle aktivieren.",
"severity": "HIGH", "mapped_measures": [], "evidence_type": "code", "effort_days": 4},
{"req_id": "CRA-AI-3", "n": 3, "category": "Secure-by-Design",
"title": "Sichere Systemarchitektur",
"annex_anchor": "Annex I, 1(3)", "iso27001_ref": ["A.8.27"],
"description": "Sicherheitskritische Komponenten muessen isoliert werden (Sandboxing, Containerisierung, Privilege Separation).",
"severity": "HIGH", "mapped_measures": [], "evidence_type": "code", "effort_days": 10},
{"req_id": "CRA-AI-4", "n": 4, "category": "Secure-by-Design",
"title": "Least-Privilege-Prinzip",
"annex_anchor": "Annex I, 1(3)(d)", "iso27001_ref": ["A.8.2", "A.8.3"],
"description": "Jede Komponente, jeder Prozess und jeder Benutzer erhaelt nur die minimal notwendigen Berechtigungen.",
"severity": "HIGH", "mapped_measures": [], "evidence_type": "code", "effort_days": 5},
{"req_id": "CRA-AI-5", "n": 5, "category": "Secure-by-Design",
"title": "Manipulationsschutz",
"annex_anchor": "Annex I, 1(3)(c)", "iso27001_ref": ["A.8.24"],
"description": "Schutz vor unautorisierter Aenderung von Software und Konfiguration (Code Signing, Secure Boot, TPM).",
"severity": "HIGH", "mapped_measures": ["M541"], "evidence_type": "code", "effort_days": 8},
{"req_id": "CRA-AI-6", "n": 6, "category": "Secure-by-Design",
"title": "Integritaetspruefung",
"annex_anchor": "Annex I, 1(3)(c)", "iso27001_ref": ["A.8.24"],
"description": "Automatische Ueberpruefung der Integritaet von Software, Firmware und Konfigurationsdaten bei Start und Laufzeit.",
"severity": "HIGH", "mapped_measures": ["M547"], "evidence_type": "code", "effort_days": 4},
# Kategorie 2: Auth
{"req_id": "CRA-AI-7", "n": 7, "category": "Authentifizierung",
"title": "Starke Authentifizierung",
"annex_anchor": "Annex I, 1(3)(d)", "iso27001_ref": ["A.8.5"],
"description": "Sichere Authentifizierungsmechanismen, MFA fuer administrative Zugriffe, FIDO2/WebAuthn.",
"severity": "HIGH", "mapped_measures": [], "evidence_type": "code", "effort_days": 6},
{"req_id": "CRA-AI-8", "n": 8, "category": "Authentifizierung",
"title": "Keine Default-Passwoerter",
"annex_anchor": "Annex I, 1(3)(d)", "iso27001_ref": ["A.8.5"],
"description": "Produkte duerfen keine universellen Standardpasswoerter verwenden. Aenderung bei Ersteinrichtung erzwingen.",
"severity": "CRITICAL", "mapped_measures": ["M542"], "evidence_type": "code", "effort_days": 2},
{"req_id": "CRA-AI-9", "n": 9, "category": "Authentifizierung",
"title": "Sicheres Credential-Management",
"annex_anchor": "Annex I, 1(3)(d)", "iso27001_ref": ["A.8.5"],
"description": "Zugangsdaten verschluesselt speichern (bcrypt, Argon2id). Keine Klartextspeicherung. Tokens rotieren.",
"severity": "HIGH", "mapped_measures": [], "evidence_type": "code", "effort_days": 3},
{"req_id": "CRA-AI-10", "n": 10, "category": "Authentifizierung",
"title": "Sitzungsmanagement",
"annex_anchor": "Annex I, 1(3)(d)", "iso27001_ref": ["A.8.5"],
"description": "Session-Verwaltung mit Timeout, Token-Binding, Invalidierung bei Logout. CSRF-Schutz.",
"severity": "MEDIUM", "mapped_measures": [], "evidence_type": "code", "effort_days": 3},
{"req_id": "CRA-AI-11", "n": 11, "category": "Authentifizierung",
"title": "Brute-Force-Schutz",
"annex_anchor": "Annex I, 1(3)(d)", "iso27001_ref": ["A.8.5", "A.8.16"],
"description": "Schutz vor Brute-Force und Credential-Stuffing via Rate Limiting, Account Lockout, CAPTCHA.",
"severity": "MEDIUM", "mapped_measures": [], "evidence_type": "code", "effort_days": 2},
{"req_id": "CRA-AI-12", "n": 12, "category": "Authentifizierung",
"title": "Rollenbasierte Autorisierung",
"annex_anchor": "Annex I, 1(3)(d)", "iso27001_ref": ["A.8.2", "A.8.3"],
"description": "RBAC implementieren. Trennung administrativ vs Nutzer. Least-Privilege durchsetzen.",
"severity": "HIGH", "mapped_measures": [], "evidence_type": "code", "effort_days": 4},
# Kategorie 3: Krypto
{"req_id": "CRA-AI-13", "n": 13, "category": "Kryptografie",
"title": "Verschluesselung sensibler Daten",
"annex_anchor": "Annex I, 1(3)(e)", "iso27001_ref": ["A.8.24"],
"description": "Sensible Daten at rest (AES-256) und in transit (TLS 1.2+) verschluesseln.",
"severity": "HIGH", "mapped_measures": [], "evidence_type": "code", "effort_days": 4},
{"req_id": "CRA-AI-14", "n": 14, "category": "Kryptografie",
"title": "Speicher-Schutz (Data at Rest)",
"annex_anchor": "Annex I, 1(3)(e)", "iso27001_ref": ["A.8.24"],
"description": "Verschluesselung von Festplatten, Datenbanken, Backups. Schluessel getrennt von Daten.",
"severity": "HIGH", "mapped_measures": [], "evidence_type": "code", "effort_days": 3},
{"req_id": "CRA-AI-15", "n": 15, "category": "Kryptografie",
"title": "Transport-Schutz (Data in Transit)",
"annex_anchor": "Annex I, 1(3)(e)", "iso27001_ref": ["A.8.24"],
"description": "TLS 1.2+ fuer alle Netzwerkkommunikation. SSL/TLS 1.0/1.1 deaktivieren. Certificate Pinning.",
"severity": "HIGH", "mapped_measures": [], "evidence_type": "code", "effort_days": 2},
{"req_id": "CRA-AI-16", "n": 16, "category": "Kryptografie",
"title": "Sicheres Schluesselmanagement",
"annex_anchor": "Annex I, 1(3)(e)", "iso27001_ref": ["A.8.24"],
"description": "Schluessel in HSM/Vault. Mind. jaehrliche Rotation. Dokumentation der Lebenszyklen.",
"severity": "HIGH", "mapped_measures": [], "evidence_type": "hybrid", "effort_days": 8},
{"req_id": "CRA-AI-17", "n": 17, "category": "Kryptografie",
"title": "Datenminimierung",
"annex_anchor": "Annex I, 1(3)(f)", "iso27001_ref": ["A.8.10", "A.8.11"],
"description": "Nur Daten erfassen, die fuer die Produktfunktion erforderlich sind. DSGVO-Grundsaetze beachten.",
"severity": "MEDIUM", "mapped_measures": [], "evidence_type": "process", "effort_days": 3},
# Kategorie 4: SSDLC
{"req_id": "CRA-AI-18", "n": 18, "category": "SSDLC",
"title": "Strukturierter SSDLC",
"annex_anchor": "Annex I, 1(1)", "iso27001_ref": ["A.8.25", "A.8.26"],
"description": "Formaler Secure Software Development Lifecycle mit Security Gates in jeder Phase.",
"severity": "MEDIUM", "mapped_measures": [], "evidence_type": "process", "effort_days": 15},
{"req_id": "CRA-AI-19", "n": 19, "category": "SSDLC",
"title": "Systematische Code Reviews",
"annex_anchor": "Annex I, 1(1)", "iso27001_ref": ["A.8.25"],
"description": "Peer Reviews mit Security-Fokus fuer jeden Commit. OWASP Top 10 + CWE Top 25 Checklisten.",
"severity": "MEDIUM", "mapped_measures": [], "evidence_type": "process", "effort_days": 5},
{"req_id": "CRA-AI-20", "n": 20, "category": "SSDLC",
"title": "Automatisierte Sicherheitstests",
"annex_anchor": "Annex I, 1(1)", "iso27001_ref": ["A.8.25"],
"description": "SAST, DAST, SCA und Secrets Detection in der CI/CD-Pipeline.",
"severity": "HIGH", "mapped_measures": ["M548"], "evidence_type": "code", "effort_days": 8},
# Kategorie 5: Supply Chain & SBOM
{"req_id": "CRA-AI-21", "n": 21, "category": "Supply Chain",
"title": "Supply-Chain-Security",
"annex_anchor": "Annex I, 1(5)", "iso27001_ref": ["A.5.19", "A.5.21"],
"description": "Drittanbieter-Komponenten systematisch auf Schwachstellen und Lizenz-Compliance pruefen.",
"severity": "HIGH", "mapped_measures": [], "evidence_type": "process", "effort_days": 5},
{"req_id": "CRA-AI-22", "n": 22, "category": "Supply Chain",
"title": "Dependency-Monitoring",
"annex_anchor": "Annex I, 1(5)", "iso27001_ref": ["A.8.8", "A.8.25"],
"description": "Kontinuierliche CVE-Ueberwachung aller Abhaengigkeiten. Automatische Benachrichtigungen.",
"severity": "HIGH", "mapped_measures": [], "evidence_type": "code", "effort_days": 3},
{"req_id": "CRA-AI-23", "n": 23, "category": "Supply Chain",
"title": "Software Bill of Materials (SBOM)",
"annex_anchor": "Annex I, 1(5)", "iso27001_ref": ["A.8.25"],
"description": "Maschinenlesbares SBOM (CycloneDX oder SPDX). Top-Level-Abhaengigkeiten mit Name, Version, Lizenz. Bei jedem Release aktualisieren.",
"severity": "CRITICAL", "mapped_measures": ["M540"], "evidence_type": "code", "effort_days": 2},
# Kategorie 6: Logging & Monitoring
{"req_id": "CRA-AI-24", "n": 24, "category": "Logging",
"title": "Security-Logging",
"annex_anchor": "Annex I, 1(3)(g)", "iso27001_ref": ["A.8.15"],
"description": "Logs aller sicherheitsrelevanten Ereignisse: Login, Berechtigungen, Admin-Aktionen, APIs, Fehler.",
"severity": "MEDIUM", "mapped_measures": [], "evidence_type": "code", "effort_days": 4},
{"req_id": "CRA-AI-25", "n": 25, "category": "Logging",
"title": "Ereignis-Monitoring",
"annex_anchor": "Annex I, 1(3)(g)", "iso27001_ref": ["A.8.16"],
"description": "Zentrale Sammlung und Echtzeit-Ueberwachung. SIEM oder vergleichbares. Event-Korrelation.",
"severity": "MEDIUM", "mapped_measures": [], "evidence_type": "process", "effort_days": 10},
{"req_id": "CRA-AI-26", "n": 26, "category": "Logging",
"title": "Anomalie-Erkennung",
"annex_anchor": "Annex I, 1(3)(g)", "iso27001_ref": ["A.8.16"],
"description": "Automatische Erkennung von Angriffsmustern. Alarmierung bei Baseline-Abweichungen. Threat Intel.",
"severity": "MEDIUM", "mapped_measures": [], "evidence_type": "process", "effort_days": 8},
{"req_id": "CRA-AI-27", "n": 27, "category": "Logging",
"title": "Log-Integritaet und -Aufbewahrung",
"annex_anchor": "Annex I, 1(3)(g)", "iso27001_ref": ["A.8.15"],
"description": "Manipulationssichere Logs (append-only, signiert oder WORM). Mind. 12 Monate Aufbewahrung.",
"severity": "MEDIUM", "mapped_measures": [], "evidence_type": "code", "effort_days": 4},
# Kategorie 7: Updates
{"req_id": "CRA-AI-28", "n": 28, "category": "Updates",
"title": "Sichere Update-Mechanismen",
"annex_anchor": "Annex I, 1(4)", "iso27001_ref": ["A.8.8", "A.8.19"],
"description": "Updates ueber sichere Kanaele (HTTPS, signiert). Automatische oder einfach zugaengliche Update-Moeglichkeit. Rollback-Faehigkeit.",
"severity": "HIGH", "mapped_measures": ["M541", "M547"], "evidence_type": "code", "effort_days": 8},
{"req_id": "CRA-AI-29", "n": 29, "category": "Updates",
"title": "Update-Authentizitaet",
"annex_anchor": "Annex I, 1(4)", "iso27001_ref": ["A.8.24"],
"description": "Updates digital signiert. Signaturpruefung vor Installation. Dokumentierte Key Ceremony.",
"severity": "CRITICAL", "mapped_measures": ["M541"], "evidence_type": "code", "effort_days": 3},
{"req_id": "CRA-AI-30", "n": 30, "category": "Updates",
"title": "Update-Integritaet",
"annex_anchor": "Annex I, 1(4)", "iso27001_ref": ["A.8.24"],
"description": "Integritaetspruefung jedes Update-Pakets (Hash, Signatur). Manipulationen waehrend Uebertragung erkennen.",
"severity": "HIGH", "mapped_measures": ["M547"], "evidence_type": "code", "effort_days": 2},
{"req_id": "CRA-AI-31", "n": 31, "category": "Updates",
"title": "Lifecycle-Support",
"annex_anchor": "Annex I, 1(4)", "iso27001_ref": ["A.8.8"],
"description": "Security-Updates fuer mind. 5 Jahre ab Inverkehrbringen oder erwartete Nutzungsdauer. End-of-Life klar kommunizieren.",
"severity": "HIGH", "mapped_measures": ["M544"], "evidence_type": "process", "effort_days": 3},
# Part 2 — Vulnerability Handling
{"req_id": "CRA-AI-32", "n": 32, "category": "Vulnerability Handling",
"title": "Schwachstellen-Identifikation",
"annex_anchor": "Annex I, 2(1)", "iso27001_ref": ["A.8.8"],
"description": "Kontinuierliches CVE-Monitoring aller eingesetzten Komponenten. Bug Bounty oder Responsible Disclosure.",
"severity": "HIGH", "mapped_measures": [], "evidence_type": "process", "effort_days": 4},
{"req_id": "CRA-AI-33", "n": 33, "category": "Vulnerability Handling",
"title": "SBOM-Pflege und Analyse",
"annex_anchor": "Annex I, 2(1)", "iso27001_ref": ["A.8.8", "A.8.25"],
"description": "SBOM aktuell halten und kontinuierlich gegen CVE-Datenbanken pruefen. Auto-Alarmierung bei neuen CVEs.",
"severity": "HIGH", "mapped_measures": ["M540"], "evidence_type": "code", "effort_days": 3},
{"req_id": "CRA-AI-34", "n": 34, "category": "Vulnerability Handling",
"title": "Risikobasierte Priorisierung",
"annex_anchor": "Annex I, 2(2)", "iso27001_ref": ["A.8.8"],
"description": "CVSS-basierte Priorisierung. SLAs: Kritisch 24-72h, Hoch 7 Tage, Mittel 30 Tage, Niedrig naechster Zyklus.",
"severity": "HIGH", "mapped_measures": ["M544"], "evidence_type": "process", "effort_days": 2},
{"req_id": "CRA-AI-35", "n": 35, "category": "Vulnerability Handling",
"title": "Coordinated Vulnerability Disclosure",
"annex_anchor": "Annex I, 2(5)", "iso27001_ref": ["A.5.5", "A.5.6"],
"description": "CVD-Policy mit Meldeprozess. Kontaktadresse fuer Forscher. Eingangsbestaetigung innerhalb 5 Werktagen.",
"severity": "CRITICAL", "mapped_measures": ["M543"], "evidence_type": "document", "effort_days": 2},
{"req_id": "CRA-AI-36", "n": 36, "category": "Vulnerability Handling",
"title": "Incident-Response-Prozess",
"annex_anchor": "Annex I, 2(5)", "iso27001_ref": ["A.5.24", "A.5.25", "A.5.26"],
"description": "Dokumentierter Prozess: Detection -> Classification -> Containment -> Investigation -> Recovery -> Reporting -> Lessons Learned.",
"severity": "HIGH", "mapped_measures": ["M546"], "evidence_type": "process", "effort_days": 10},
{"req_id": "CRA-AI-37", "n": 37, "category": "Vulnerability Handling",
"title": "Fruehwarnung (24h)",
"annex_anchor": "Annex I, 2(7) + Art. 14(2)(a)", "iso27001_ref": ["A.5.24", "A.5.26"],
"description": "Bei aktiv ausgenutzten Schwachstellen oder schweren Vorfaellen: Fruehwarnung an ENISA/CSIRT innerhalb 24 Stunden.",
"severity": "CRITICAL", "mapped_measures": ["M546"], "evidence_type": "process", "effort_days": 3},
{"req_id": "CRA-AI-38", "n": 38, "category": "Vulnerability Handling",
"title": "Detaillierter Vorfallsbericht (72h)",
"annex_anchor": "Annex I, 2(7) + Art. 14(2)(b)", "iso27001_ref": ["A.5.24", "A.5.26"],
"description": "72h: Detaillierter Bericht mit Umfang, Auswirkung, Ursachenanalyse, Gegenmassnahmen. Bei personenbezogenen Daten zusaetzlich DSGVO Art. 33/34.",
"severity": "CRITICAL", "mapped_measures": ["M546"], "evidence_type": "process", "effort_days": 2},
{"req_id": "CRA-AI-39", "n": 39, "category": "Vulnerability Handling",
"title": "Patch-Bereitstellung",
"annex_anchor": "Annex I, 2(3)", "iso27001_ref": ["A.8.8"],
"description": "Patches fuer gemeldete Schwachstellen so schnell wie moeglich. Security Advisories (CSAF-Format empfohlen).",
"severity": "HIGH", "mapped_measures": ["M544"], "evidence_type": "process", "effort_days": 5},
{"req_id": "CRA-AI-40", "n": 40, "category": "Vulnerability Handling",
"title": "Dokumentation und Nachbereitung",
"annex_anchor": "Annex I, 2(6)", "iso27001_ref": ["A.5.27"],
"description": "Lueckenlose Dokumentation aller Schwachstellen + Vorfaelle, mind. 10 Jahre Aufbewahrung. Lessons-Learned-Prozess.",
"severity": "MEDIUM", "mapped_measures": [], "evidence_type": "document", "effort_days": 3},
]
# Measure descriptions (from measures_library_cra.go)
MEASURES = {
"M540": "Software Bill of Materials (SBOM) erstellen und mit der Maschine ausliefern",
"M541": "Signierte Software- und Firmware-Updates mit Rollback-Schutz",
"M542": "Initiale Default-Passwoerter beim ersten Start erzwungen aendern",
"M543": "CVD-Policy (Coordinated Vulnerability Disclosure) veroeffentlichen",
"M544": "Patch-SLA mit Severity-Tiers dokumentieren",
"M545": "Cybersecurity-Hardening-Guide fuer den Anwender beilegen",
"M546": "Incident-Meldeprozess an ENISA / nationale CSIRT definieren",
"M547": "Updates ueber authentisierten Kanal mit Integritaetspruefung",
"M548": "Sicherheitsbewertung / Penetrationstest vor Inverkehrbringen",
}
# CRA-Deadlines (deterministisch, kein DB-Lookup)
DEADLINES = [
{"date": "2026-06-11", "label": "Conformity Bodies benannt"},
{"date": "2026-09-11", "label": "Vulnerability-Reporting-Pflicht aktiv (24h/72h)"},
{"date": "2027-12-11", "label": "CE-Marking nach CRA verpflichtend"},
]
# Severity-Gewichtung fuer Priority-Score
SEVERITY_WEIGHT = {
"CRITICAL": 100,
"HIGH": 60,
"MEDIUM": 30,
"LOW": 10,
}
File diff suppressed because it is too large Load Diff
+4
View File
@@ -52,6 +52,7 @@ from compliance.api.agent_doc_check_routes import router as agent_doc_check_rout
from compliance.api.agent_compliance_check_routes import router as agent_compliance_check_router
from compliance.api.agent_migration_routes import router as agent_migration_router
from compliance.api.vendor_assessment_routes import router as vendor_assessment_router
from compliance.api.cra_routes import router as cra_router
# Middleware
from middleware import (
@@ -161,6 +162,9 @@ app.include_router(agent_migration_router, prefix="/api")
# Vendor Contract Assessment
app.include_router(vendor_assessment_router, prefix="/api")
# CRA (Cyber Resilience Act) Compliance
app.include_router(cra_router, prefix="/api")
if __name__ == "__main__":
import uvicorn
@@ -0,0 +1,44 @@
-- Migration 119: CRA Compliance Projects
-- Tracks per-product CRA conformity assessment lifecycle.
-- Status state machine (validated as whitelist, no transition enforcement):
-- draft -> scoped -> classified -> path_selected -> requirements_mapped ->
-- evidence_pending -> gaps_open -> remediation -> ready_for_review ->
-- declaration_ready -> post_market
-- Tenant scoping via X-Tenant-ID header (validated UUID).
CREATE TABLE IF NOT EXISTS compliance_cra_projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id VARCHAR(255) NOT NULL,
name VARCHAR(500) NOT NULL,
description TEXT DEFAULT '',
gap_project_id UUID,
-- Intake (Software-fokussiert, NICHT Hardware)
repo_url VARCHAR(1000),
primary_language VARCHAR(50),
has_firmware BOOLEAN DEFAULT false,
connected_to_internet BOOLEAN DEFAULT false,
has_software_updates BOOLEAN DEFAULT false,
processes_personal_data BOOLEAN DEFAULT false,
is_critical_infra_supplier BOOLEAN DEFAULT false,
intended_use TEXT DEFAULT '',
-- Scope
cra_classification VARCHAR(20),
classification_rationale JSONB DEFAULT '[]'::jsonb,
-- Path
conformity_path VARCHAR(30),
-- Status (whitelist)
status VARCHAR(40) NOT NULL DEFAULT 'draft',
-- Audit
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_cra_projects_tenant ON compliance_cra_projects(tenant_id);
CREATE INDEX IF NOT EXISTS idx_cra_projects_status ON compliance_cra_projects(tenant_id, status);
CREATE INDEX IF NOT EXISTS idx_cra_projects_class ON compliance_cra_projects(cra_classification);
CREATE INDEX IF NOT EXISTS idx_cra_projects_gap_link ON compliance_cra_projects(gap_project_id) WHERE gap_project_id IS NOT NULL;
@@ -0,0 +1,23 @@
-- Migration 120: CRA Project SBOMs + reuse existing compliance_evidence_checks
-- For SBOM uploads (CycloneDX/SPDX), we add a dedicated table to track versions.
-- For automated checks (security.txt etc.), we reuse compliance_evidence_checks.
CREATE TABLE IF NOT EXISTS compliance_cra_sboms (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
cra_project_id UUID NOT NULL,
tenant_id VARCHAR(255) NOT NULL,
filename VARCHAR(500) NOT NULL,
format VARCHAR(20) NOT NULL, -- 'cyclonedx' | 'spdx'
spec_version VARCHAR(20),
component_count INTEGER DEFAULT 0,
raw_content JSONB NOT NULL DEFAULT '{}'::jsonb,
summary JSONB DEFAULT '{}'::jsonb, -- top-level metadata extracted
scan_status VARCHAR(20) DEFAULT 'pending', -- pending | scanned | failed
scan_summary JSONB DEFAULT '{}'::jsonb, -- osv.dev results (Phase 3.5)
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
scanned_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_cra_sboms_project ON compliance_cra_sboms(cra_project_id);
CREATE INDEX IF NOT EXISTS idx_cra_sboms_tenant ON compliance_cra_sboms(tenant_id);
CREATE INDEX IF NOT EXISTS idx_cra_sboms_uploaded ON compliance_cra_sboms(cra_project_id, uploaded_at DESC);