feat(canonical-controls): Canonical Control Library — rechtssichere Security Controls
All checks were successful
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 40s
CI/CD / test-python-backend-compliance (push) Successful in 41s
CI/CD / test-python-document-crawler (push) Successful in 26s
CI/CD / test-python-dsms-gateway (push) Successful in 23s
CI/CD / validate-canonical-controls (push) Successful in 18s
CI/CD / deploy-hetzner (push) Successful in 2m26s
All checks were successful
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 40s
CI/CD / test-python-backend-compliance (push) Successful in 41s
CI/CD / test-python-document-crawler (push) Successful in 26s
CI/CD / test-python-dsms-gateway (push) Successful in 23s
CI/CD / validate-canonical-controls (push) Successful in 18s
CI/CD / deploy-hetzner (push) Successful in 2m26s
Eigenstaendig formulierte Security Controls mit unabhaengiger Taxonomie und Open-Source-Verankerung (OWASP, NIST, ENISA). Keine BSI-Nomenklatur. - Migration 044: 5 DB-Tabellen (frameworks, controls, sources, licenses, mappings) - 10 Seed Controls mit 39 Open-Source-Referenzen - License Gate: Quellen-Berechtigungspruefung (analysis/excerpt/embeddings/product) - Too-Close-Detektor: 5 Metriken (exact-phrase, token-overlap, ngram, embedding, LCS) - REST API: 8 Endpoints unter /v1/canonical/ - Go Loader mit Multi-Index (ID, domain, severity, framework) - Frontend: Control Library Browser + Provenance Wiki - CI/CD: validate-controls.py Job (schema, no-leak, open-anchors) - 67 Tests (8 Go + 59 Python), alle PASS - MkDocs Dokumentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
484
admin-compliance/app/sdk/control-library/page.tsx
Normal file
484
admin-compliance/app/sdk/control-library/page.tsx
Normal file
@@ -0,0 +1,484 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import {
|
||||
Shield, Search, ChevronRight, ArrowLeft, ExternalLink,
|
||||
Filter, AlertTriangle, CheckCircle2, Info, Lock,
|
||||
FileText, BookOpen, Scale,
|
||||
} from 'lucide-react'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface OpenAnchor {
|
||||
framework: string
|
||||
ref: string
|
||||
url: string
|
||||
}
|
||||
|
||||
interface EvidenceItem {
|
||||
type: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface CanonicalControl {
|
||||
id: string
|
||||
framework_id: string
|
||||
control_id: string
|
||||
title: string
|
||||
objective: string
|
||||
rationale: string
|
||||
scope: {
|
||||
platforms?: string[]
|
||||
components?: string[]
|
||||
data_classes?: string[]
|
||||
}
|
||||
requirements: string[]
|
||||
test_procedure: string[]
|
||||
evidence: EvidenceItem[]
|
||||
severity: string
|
||||
risk_score: number | null
|
||||
implementation_effort: string | null
|
||||
evidence_confidence: number | null
|
||||
open_anchors: OpenAnchor[]
|
||||
release_state: string
|
||||
tags: string[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface Framework {
|
||||
id: string
|
||||
framework_id: string
|
||||
name: string
|
||||
version: string
|
||||
description: string
|
||||
release_state: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONSTANTS
|
||||
// =============================================================================
|
||||
|
||||
const SEVERITY_CONFIG: Record<string, { bg: string; label: string; icon: React.ComponentType<{ className?: string }> }> = {
|
||||
critical: { bg: 'bg-red-100 text-red-800', label: 'Kritisch', icon: AlertTriangle },
|
||||
high: { bg: 'bg-orange-100 text-orange-800', label: 'Hoch', icon: AlertTriangle },
|
||||
medium: { bg: 'bg-yellow-100 text-yellow-800', label: 'Mittel', icon: Info },
|
||||
low: { bg: 'bg-green-100 text-green-800', label: 'Niedrig', icon: CheckCircle2 },
|
||||
}
|
||||
|
||||
const EFFORT_LABELS: Record<string, string> = {
|
||||
s: 'Klein (S)',
|
||||
m: 'Mittel (M)',
|
||||
l: 'Gross (L)',
|
||||
xl: 'Sehr gross (XL)',
|
||||
}
|
||||
|
||||
const BACKEND_URL = '/api/sdk/v1/canonical'
|
||||
|
||||
// =============================================================================
|
||||
// HELPERS
|
||||
// =============================================================================
|
||||
|
||||
function SeverityBadge({ severity }: { severity: string }) {
|
||||
const config = SEVERITY_CONFIG[severity] || SEVERITY_CONFIG.medium
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${config.bg}`}>
|
||||
<Icon className="w-3 h-3" />
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function StateBadge({ state }: { state: string }) {
|
||||
const config: Record<string, string> = {
|
||||
draft: 'bg-gray-100 text-gray-600',
|
||||
review: 'bg-blue-100 text-blue-700',
|
||||
approved: 'bg-green-100 text-green-700',
|
||||
deprecated: 'bg-red-100 text-red-600',
|
||||
}
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${config[state] || config.draft}`}>
|
||||
{state}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function getDomain(controlId: string): string {
|
||||
return controlId.split('-')[0] || ''
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONTROL LIBRARY PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function ControlLibraryPage() {
|
||||
const [frameworks, setFrameworks] = useState<Framework[]>([])
|
||||
const [controls, setControls] = useState<CanonicalControl[]>([])
|
||||
const [selectedControl, setSelectedControl] = useState<CanonicalControl | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Filters
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [severityFilter, setSeverityFilter] = useState<string>('')
|
||||
const [domainFilter, setDomainFilter] = useState<string>('')
|
||||
|
||||
// Load data
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const [fwRes, ctrlRes] = await Promise.all([
|
||||
fetch(`${BACKEND_URL}?endpoint=frameworks`),
|
||||
fetch(`${BACKEND_URL}?endpoint=controls`),
|
||||
])
|
||||
|
||||
if (fwRes.ok) {
|
||||
setFrameworks(await fwRes.json())
|
||||
}
|
||||
if (ctrlRes.ok) {
|
||||
setControls(await ctrlRes.json())
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
|
||||
// Derived: unique domains
|
||||
const domains = useMemo(() => {
|
||||
const set = new Set(controls.map(c => getDomain(c.control_id)))
|
||||
return Array.from(set).sort()
|
||||
}, [controls])
|
||||
|
||||
// Filtered controls
|
||||
const filteredControls = useMemo(() => {
|
||||
return controls.filter(c => {
|
||||
if (severityFilter && c.severity !== severityFilter) return false
|
||||
if (domainFilter && getDomain(c.control_id) !== domainFilter) return false
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase()
|
||||
return (
|
||||
c.control_id.toLowerCase().includes(q) ||
|
||||
c.title.toLowerCase().includes(q) ||
|
||||
c.objective.toLowerCase().includes(q) ||
|
||||
c.tags.some(t => t.toLowerCase().includes(q))
|
||||
)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [controls, severityFilter, domainFilter, searchQuery])
|
||||
|
||||
const handleBack = useCallback(() => setSelectedControl(null), [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-purple-600 border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-800 text-sm">{error}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DETAIL VIEW
|
||||
// =========================================================================
|
||||
|
||||
if (selectedControl) {
|
||||
const ctrl = selectedControl
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<button onClick={handleBack} className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6">
|
||||
<ArrowLeft className="w-4 h-4" /> Zurueck zur Uebersicht
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-4 mb-6">
|
||||
<div className="flex-shrink-0 w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<Shield className="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-mono text-purple-600">{ctrl.control_id}</span>
|
||||
<SeverityBadge severity={ctrl.severity} />
|
||||
<StateBadge state={ctrl.release_state} />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-gray-900">{ctrl.title}</h1>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-gray-500">
|
||||
{ctrl.risk_score !== null && <span>Risiko-Score: {ctrl.risk_score}/10</span>}
|
||||
{ctrl.implementation_effort && <span>Aufwand: {EFFORT_LABELS[ctrl.implementation_effort] || ctrl.implementation_effort}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Objective & Rationale */}
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Ziel</h3>
|
||||
<p className="text-sm text-gray-700 bg-gray-50 rounded-lg p-4">{ctrl.objective}</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Begruendung</h3>
|
||||
<p className="text-sm text-gray-700 bg-gray-50 rounded-lg p-4">{ctrl.rationale}</p>
|
||||
</section>
|
||||
|
||||
{/* Scope */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Geltungsbereich</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{ctrl.scope.platforms && ctrl.scope.platforms.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 mb-1">Plattformen</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ctrl.scope.platforms.map(p => (
|
||||
<span key={p} className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-xs">{p}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{ctrl.scope.components && ctrl.scope.components.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 mb-1">Komponenten</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ctrl.scope.components.map(c => (
|
||||
<span key={c} className="px-2 py-0.5 bg-purple-50 text-purple-700 rounded text-xs">{c}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{ctrl.scope.data_classes && ctrl.scope.data_classes.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 mb-1">Datenklassen</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ctrl.scope.data_classes.map(d => (
|
||||
<span key={d} className="px-2 py-0.5 bg-amber-50 text-amber-700 rounded text-xs">{d}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Requirements */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Anforderungen</h3>
|
||||
<ol className="space-y-2">
|
||||
{ctrl.requirements.map((req, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-gray-700">
|
||||
<span className="flex-shrink-0 w-5 h-5 bg-purple-100 text-purple-700 rounded-full flex items-center justify-center text-xs font-medium mt-0.5">{i + 1}</span>
|
||||
{req}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{/* Test Procedure */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Pruefverfahren</h3>
|
||||
<ol className="space-y-2">
|
||||
{ctrl.test_procedure.map((step, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-gray-700">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500 flex-shrink-0 mt-0.5" />
|
||||
{step}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{/* Evidence */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Nachweisanforderungen</h3>
|
||||
<div className="space-y-2">
|
||||
{ctrl.evidence.map((ev, i) => (
|
||||
<div key={i} className="flex items-start gap-2 p-3 bg-gray-50 rounded-lg">
|
||||
<FileText className="w-4 h-4 text-gray-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<span className="text-xs font-medium text-gray-500 uppercase">{ev.type}</span>
|
||||
<p className="text-sm text-gray-700">{ev.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Open Anchors — THE KEY SECTION */}
|
||||
<section className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<BookOpen className="w-4 h-4 text-green-700" />
|
||||
<h3 className="text-sm font-semibold text-green-900">Open-Source-Referenzen</h3>
|
||||
<span className="text-xs text-green-600">({ctrl.open_anchors.length} Quellen)</span>
|
||||
</div>
|
||||
<p className="text-xs text-green-700 mb-3">
|
||||
Dieses Control basiert auf frei verfuegbarem Wissen. Alle Referenzen sind offen und oeffentlich zugaenglich.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{ctrl.open_anchors.map((anchor, i) => (
|
||||
<div key={i} className="flex items-start gap-3 p-2 bg-white rounded border border-green-100">
|
||||
<Scale className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs font-semibold text-green-800">{anchor.framework}</span>
|
||||
<p className="text-sm text-gray-700">{anchor.ref}</p>
|
||||
</div>
|
||||
<a
|
||||
href={anchor.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-green-600 hover:text-green-800 flex-shrink-0"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
Quelle
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tags */}
|
||||
{ctrl.tags.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Tags</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ctrl.tags.map(tag => (
|
||||
<span key={tag} className="px-2 py-1 bg-gray-100 text-gray-600 rounded text-xs">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// LIST VIEW
|
||||
// =========================================================================
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="border-b border-gray-200 bg-white px-6 py-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="w-6 h-6 text-purple-600" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-gray-900">Canonical Control Library</h1>
|
||||
<p className="text-xs text-gray-500">
|
||||
{controls.length} unabhaengig formulierte Security Controls —{' '}
|
||||
{controls.reduce((sum, c) => sum + c.open_anchors.length, 0)} Open-Source-Referenzen
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Frameworks */}
|
||||
{frameworks.length > 0 && (
|
||||
<div className="mb-4 p-3 bg-purple-50 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-xs text-purple-700">
|
||||
<Lock className="w-3 h-3" />
|
||||
<span className="font-medium">{frameworks[0]?.name} v{frameworks[0]?.version}</span>
|
||||
<span className="text-purple-500">—</span>
|
||||
<span>{frameworks[0]?.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Controls durchsuchen..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Filter className="w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
<select
|
||||
value={severityFilter}
|
||||
onChange={e => setSeverityFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="">Alle Schweregrade</option>
|
||||
<option value="critical">Kritisch</option>
|
||||
<option value="high">Hoch</option>
|
||||
<option value="medium">Mittel</option>
|
||||
<option value="low">Niedrig</option>
|
||||
</select>
|
||||
<select
|
||||
value={domainFilter}
|
||||
onChange={e => setDomainFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="">Alle Domains</option>
|
||||
{domains.map(d => (
|
||||
<option key={d} value={d}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Control List */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="space-y-3">
|
||||
{filteredControls.map(ctrl => (
|
||||
<button
|
||||
key={ctrl.control_id}
|
||||
onClick={() => setSelectedControl(ctrl)}
|
||||
className="w-full text-left bg-white border border-gray-200 rounded-lg p-4 hover:border-purple-300 hover:shadow-sm transition-all group"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-mono text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{ctrl.control_id}</span>
|
||||
<SeverityBadge severity={ctrl.severity} />
|
||||
<StateBadge state={ctrl.release_state} />
|
||||
{ctrl.risk_score !== null && (
|
||||
<span className="text-xs text-gray-400">Score: {ctrl.risk_score}</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-gray-900 group-hover:text-purple-700">{ctrl.title}</h3>
|
||||
<p className="text-xs text-gray-500 mt-1 line-clamp-2">{ctrl.objective}</p>
|
||||
|
||||
{/* Open anchors summary */}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<BookOpen className="w-3 h-3 text-green-600" />
|
||||
<span className="text-xs text-green-700">
|
||||
{ctrl.open_anchors.length} Open-Source-Referenzen:
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{ctrl.open_anchors.map(a => a.framework).filter((v, i, arr) => arr.indexOf(v) === i).join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-gray-300 group-hover:text-purple-500 flex-shrink-0 mt-1 ml-4" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{filteredControls.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-400 text-sm">
|
||||
Keine Controls gefunden.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user