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:
@@ -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">
|
||||
← 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">
|
||||
← 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">
|
||||
← 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. "Firewall", "Betriebssystem") 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">← 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">
|
||||
← 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">
|
||||
← 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 "unbewertet" 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">
|
||||
← 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: "CycloneDX"</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">
|
||||
← 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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
Reference in New Issue
Block a user