feat: seed 10 canonical controls + CRUD endpoints + frontend editor
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 39s
CI/CD / test-python-backend-compliance (push) Successful in 39s
CI/CD / test-python-document-crawler (push) Successful in 30s
CI/CD / test-python-dsms-gateway (push) Successful in 20s
CI/CD / validate-canonical-controls (push) Successful in 12s
CI/CD / deploy-hetzner (push) Successful in 1m37s

- Migration 045: Seed 10 controls (AUTH, NET, SUP, LOG, WEB, DATA, CRYP, REL)
  with 39 open-source anchors into the database
- Backend: POST/PUT/DELETE endpoints for canonical controls CRUD
- Frontend proxy: PUT and DELETE methods added to canonical route
- Frontend: Control Library with create/edit/delete UI, full form with
  open anchor management, scope, requirements, evidence, test procedures

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-13 00:28:21 +01:00
parent 453eec9ed8
commit c87f07c99a
4 changed files with 878 additions and 47 deletions

View File

@@ -80,25 +80,73 @@ export async function GET(request: NextRequest) {
}
/**
* Proxy: POST /api/sdk/v1/canonical?endpoint=similarity-check&id=...
* Proxy: POST /api/sdk/v1/canonical?endpoint=...
*
* Routes to: POST /api/compliance/v1/canonical/controls/{id}/similarity-check
* endpoint=create-control → POST /api/compliance/v1/canonical/controls
* endpoint=similarity-check&id= → POST /api/compliance/v1/canonical/controls/{id}/similarity-check
*/
export async function POST(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const endpoint = searchParams.get('endpoint')
const controlId = searchParams.get('id')
const body = await request.json()
if (endpoint !== 'similarity-check' || !controlId) {
return NextResponse.json({ error: 'Invalid endpoint or missing id' }, { status: 400 })
let backendPath: string
if (endpoint === 'create-control') {
backendPath = '/api/compliance/v1/canonical/controls'
} else if (endpoint === 'similarity-check') {
const controlId = searchParams.get('id')
if (!controlId) {
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
}
backendPath = `/api/compliance/v1/canonical/controls/${encodeURIComponent(controlId)}/similarity-check`
} else {
return NextResponse.json({ error: `Unknown POST endpoint: ${endpoint}` }, { status: 400 })
}
const response = await fetch(`${BACKEND_URL}${backendPath}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json(
{ error: 'Backend error', details: errorText },
{ status: response.status }
)
}
return NextResponse.json(await response.json(), { status: response.status })
} catch (error) {
console.error('Canonical control POST proxy error:', error)
return NextResponse.json(
{ error: 'Failed to connect to backend' },
{ status: 503 }
)
}
}
/**
* Proxy: PUT /api/sdk/v1/canonical?endpoint=update-control&id=AUTH-001
*
* Routes to: PUT /api/compliance/v1/canonical/controls/{id}
*/
export async function PUT(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const controlId = searchParams.get('id')
if (!controlId) {
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
}
const body = await request.json()
const response = await fetch(
`${BACKEND_URL}/api/compliance/v1/canonical/controls/${encodeURIComponent(controlId)}/similarity-check`,
`${BACKEND_URL}/api/compliance/v1/canonical/controls/${encodeURIComponent(controlId)}`,
{
method: 'POST',
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}
@@ -114,7 +162,43 @@ export async function POST(request: NextRequest) {
return NextResponse.json(await response.json())
} catch (error) {
console.error('Similarity check proxy error:', error)
console.error('Canonical control PUT proxy error:', error)
return NextResponse.json(
{ error: 'Failed to connect to backend' },
{ status: 503 }
)
}
}
/**
* Proxy: DELETE /api/sdk/v1/canonical?id=AUTH-001
*
* Routes to: DELETE /api/compliance/v1/canonical/controls/{id}
*/
export async function DELETE(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const controlId = searchParams.get('id')
if (!controlId) {
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
}
const response = await fetch(
`${BACKEND_URL}/api/compliance/v1/canonical/controls/${encodeURIComponent(controlId)}`,
{ method: 'DELETE' }
)
if (!response.ok && response.status !== 204) {
const errorText = await response.text()
return NextResponse.json(
{ error: 'Backend error', details: errorText },
{ status: response.status }
)
}
return new NextResponse(null, { status: 204 })
} catch (error) {
console.error('Canonical control DELETE proxy error:', error)
return NextResponse.json(
{ error: 'Failed to connect to backend' },
{ status: 503 }

View File

@@ -4,7 +4,7 @@ import { useState, useEffect, useMemo, useCallback } from 'react'
import {
Shield, Search, ChevronRight, ArrowLeft, ExternalLink,
Filter, AlertTriangle, CheckCircle2, Info, Lock,
FileText, BookOpen, Scale,
FileText, BookOpen, Scale, Plus, Pencil, Trash2, Save, X,
} from 'lucide-react'
// =============================================================================
@@ -77,6 +77,24 @@ const EFFORT_LABELS: Record<string, string> = {
const BACKEND_URL = '/api/sdk/v1/canonical'
const EMPTY_CONTROL = {
framework_id: 'bp_security_v1',
control_id: '',
title: '',
objective: '',
rationale: '',
scope: { platforms: [] as string[], components: [] as string[], data_classes: [] as string[] },
requirements: [''],
test_procedure: [''],
evidence: [{ type: '', description: '' }],
severity: 'medium',
risk_score: null as number | null,
implementation_effort: 'm' as string | null,
open_anchors: [{ framework: '', ref: '', url: '' }],
release_state: 'draft',
tags: [] as string[],
}
// =============================================================================
// HELPERS
// =============================================================================
@@ -110,6 +128,277 @@ function getDomain(controlId: string): string {
return controlId.split('-')[0] || ''
}
// =============================================================================
// CONTROL FORM COMPONENT
// =============================================================================
function ControlForm({
initial,
onSave,
onCancel,
saving,
}: {
initial: typeof EMPTY_CONTROL
onSave: (data: typeof EMPTY_CONTROL) => void
onCancel: () => void
saving: boolean
}) {
const [form, setForm] = useState(initial)
const [tagInput, setTagInput] = useState(initial.tags.join(', '))
const [platformInput, setPlatformInput] = useState((initial.scope.platforms || []).join(', '))
const [componentInput, setComponentInput] = useState((initial.scope.components || []).join(', '))
const [dataClassInput, setDataClassInput] = useState((initial.scope.data_classes || []).join(', '))
const handleSave = () => {
const data = {
...form,
tags: tagInput.split(',').map(t => t.trim()).filter(Boolean),
scope: {
platforms: platformInput.split(',').map(t => t.trim()).filter(Boolean),
components: componentInput.split(',').map(t => t.trim()).filter(Boolean),
data_classes: dataClassInput.split(',').map(t => t.trim()).filter(Boolean),
},
requirements: form.requirements.filter(r => r.trim()),
test_procedure: form.test_procedure.filter(r => r.trim()),
evidence: form.evidence.filter(e => e.type.trim() || e.description.trim()),
open_anchors: form.open_anchors.filter(a => a.framework.trim() || a.ref.trim()),
}
onSave(data)
}
return (
<div className="max-w-4xl mx-auto p-6 space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">
{initial.control_id ? `Control ${initial.control_id} bearbeiten` : 'Neues Control erstellen'}
</h2>
<div className="flex items-center gap-2">
<button onClick={onCancel} className="px-3 py-1.5 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50">
<X className="w-4 h-4 inline mr-1" />Abbrechen
</button>
<button onClick={handleSave} disabled={saving} className="px-3 py-1.5 text-sm text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50">
<Save className="w-4 h-4 inline mr-1" />{saving ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
{/* Basic fields */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Control-ID *</label>
<input
value={form.control_id}
onChange={e => setForm({ ...form, control_id: e.target.value.toUpperCase() })}
placeholder="AUTH-003"
disabled={!!initial.control_id}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:outline-none disabled:bg-gray-100"
/>
<p className="text-xs text-gray-400 mt-1">Format: DOMAIN-NNN (z.B. AUTH-003, NET-005)</p>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Titel *</label>
<input
value={form.title}
onChange={e => setForm({ ...form, title: e.target.value })}
placeholder="Control-Titel"
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:outline-none"
/>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Schweregrad</label>
<select value={form.severity} onChange={e => setForm({ ...form, severity: e.target.value })} className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg">
<option value="low">Niedrig</option>
<option value="medium">Mittel</option>
<option value="high">Hoch</option>
<option value="critical">Kritisch</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Risiko-Score (0-10)</label>
<input
type="number" min="0" max="10" step="0.5"
value={form.risk_score ?? ''}
onChange={e => setForm({ ...form, risk_score: e.target.value ? parseFloat(e.target.value) : null })}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Aufwand</label>
<select value={form.implementation_effort || ''} onChange={e => setForm({ ...form, implementation_effort: e.target.value || null })} className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg">
<option value="">-</option>
<option value="s">Klein (S)</option>
<option value="m">Mittel (M)</option>
<option value="l">Gross (L)</option>
<option value="xl">Sehr gross (XL)</option>
</select>
</div>
</div>
{/* Objective & Rationale */}
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Ziel *</label>
<textarea
value={form.objective}
onChange={e => setForm({ ...form, objective: e.target.value })}
rows={3}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Begruendung *</label>
<textarea
value={form.rationale}
onChange={e => setForm({ ...form, rationale: e.target.value })}
rows={3}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:outline-none"
/>
</div>
{/* Scope */}
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Plattformen (komma-getrennt)</label>
<input value={platformInput} onChange={e => setPlatformInput(e.target.value)} placeholder="web, mobile, api" className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" />
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Komponenten (komma-getrennt)</label>
<input value={componentInput} onChange={e => setComponentInput(e.target.value)} placeholder="auth-service, gateway" className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" />
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Datenklassen (komma-getrennt)</label>
<input value={dataClassInput} onChange={e => setDataClassInput(e.target.value)} placeholder="credentials, tokens" className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" />
</div>
</div>
{/* Requirements */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-xs font-medium text-gray-600">Anforderungen</label>
<button onClick={() => setForm({ ...form, requirements: [...form.requirements, ''] })} className="text-xs text-purple-600 hover:text-purple-800">+ Hinzufuegen</button>
</div>
{form.requirements.map((req, i) => (
<div key={i} className="flex gap-2 mb-2">
<span className="text-xs text-gray-400 mt-2.5">{i + 1}.</span>
<input
value={req}
onChange={e => { const r = [...form.requirements]; r[i] = e.target.value; setForm({ ...form, requirements: r }) }}
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg"
/>
<button onClick={() => setForm({ ...form, requirements: form.requirements.filter((_, j) => j !== i) })} className="text-red-400 hover:text-red-600">
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
{/* Test Procedure */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-xs font-medium text-gray-600">Pruefverfahren</label>
<button onClick={() => setForm({ ...form, test_procedure: [...form.test_procedure, ''] })} className="text-xs text-purple-600 hover:text-purple-800">+ Hinzufuegen</button>
</div>
{form.test_procedure.map((step, i) => (
<div key={i} className="flex gap-2 mb-2">
<span className="text-xs text-gray-400 mt-2.5">{i + 1}.</span>
<input
value={step}
onChange={e => { const t = [...form.test_procedure]; t[i] = e.target.value; setForm({ ...form, test_procedure: t }) }}
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg"
/>
<button onClick={() => setForm({ ...form, test_procedure: form.test_procedure.filter((_, j) => j !== i) })} className="text-red-400 hover:text-red-600">
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
{/* Evidence */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-xs font-medium text-gray-600">Nachweisanforderungen</label>
<button onClick={() => setForm({ ...form, evidence: [...form.evidence, { type: '', description: '' }] })} className="text-xs text-purple-600 hover:text-purple-800">+ Hinzufuegen</button>
</div>
{form.evidence.map((ev, i) => (
<div key={i} className="flex gap-2 mb-2">
<input
value={ev.type}
onChange={e => { const evs = [...form.evidence]; evs[i] = { ...evs[i], type: e.target.value }; setForm({ ...form, evidence: evs }) }}
placeholder="Typ (z.B. config, test_result)"
className="w-32 px-3 py-2 text-sm border border-gray-300 rounded-lg"
/>
<input
value={ev.description}
onChange={e => { const evs = [...form.evidence]; evs[i] = { ...evs[i], description: e.target.value }; setForm({ ...form, evidence: evs }) }}
placeholder="Beschreibung"
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg"
/>
<button onClick={() => setForm({ ...form, evidence: form.evidence.filter((_, j) => j !== i) })} className="text-red-400 hover:text-red-600">
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
{/* Open Anchors */}
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<BookOpen className="w-4 h-4 text-green-700" />
<label className="text-xs font-semibold text-green-900">Open-Source-Referenzen *</label>
</div>
<button onClick={() => setForm({ ...form, open_anchors: [...form.open_anchors, { framework: '', ref: '', url: '' }] })} className="text-xs text-green-700 hover:text-green-900">+ Hinzufuegen</button>
</div>
<p className="text-xs text-green-600 mb-3">Jedes Control braucht mindestens eine offene Referenz (OWASP, NIST, ENISA, etc.)</p>
{form.open_anchors.map((anchor, i) => (
<div key={i} className="flex gap-2 mb-2">
<input
value={anchor.framework}
onChange={e => { const a = [...form.open_anchors]; a[i] = { ...a[i], framework: e.target.value }; setForm({ ...form, open_anchors: a }) }}
placeholder="Framework (z.B. OWASP ASVS)"
className="w-40 px-3 py-2 text-sm border border-green-200 rounded-lg bg-white"
/>
<input
value={anchor.ref}
onChange={e => { const a = [...form.open_anchors]; a[i] = { ...a[i], ref: e.target.value }; setForm({ ...form, open_anchors: a }) }}
placeholder="Referenz (z.B. V2.8)"
className="w-48 px-3 py-2 text-sm border border-green-200 rounded-lg bg-white"
/>
<input
value={anchor.url}
onChange={e => { const a = [...form.open_anchors]; a[i] = { ...a[i], url: e.target.value }; setForm({ ...form, open_anchors: a }) }}
placeholder="https://..."
className="flex-1 px-3 py-2 text-sm border border-green-200 rounded-lg bg-white"
/>
<button onClick={() => setForm({ ...form, open_anchors: form.open_anchors.filter((_, j) => j !== i) })} className="text-red-400 hover:text-red-600">
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
{/* Tags & State */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Tags (komma-getrennt)</label>
<input value={tagInput} onChange={e => setTagInput(e.target.value)} placeholder="mfa, auth, iam" className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" />
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Status</label>
<select value={form.release_state} onChange={e => setForm({ ...form, release_state: e.target.value })} className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg">
<option value="draft">Draft</option>
<option value="review">Review</option>
<option value="approved">Approved</option>
<option value="deprecated">Deprecated</option>
</select>
</div>
</div>
</div>
)
}
// =============================================================================
// CONTROL LIBRARY PAGE
// =============================================================================
@@ -126,30 +415,30 @@ export default function ControlLibraryPage() {
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`),
])
// CRUD state
const [mode, setMode] = useState<'list' | 'detail' | 'create' | 'edit'>('list')
const [saving, setSaving] = useState(false)
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 data
const loadData = useCallback(async () => {
try {
setLoading(true)
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()
}, [])
useEffect(() => { loadData() }, [loadData])
// Derived: unique domains
const domains = useMemo(() => {
const set = new Set(controls.map(c => getDomain(c.control_id)))
@@ -174,7 +463,68 @@ export default function ControlLibraryPage() {
})
}, [controls, severityFilter, domainFilter, searchQuery])
const handleBack = useCallback(() => setSelectedControl(null), [])
// CRUD handlers
const handleCreate = async (data: typeof EMPTY_CONTROL) => {
setSaving(true)
try {
const res = await fetch(`${BACKEND_URL}?endpoint=create-control`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) {
const err = await res.json()
alert(`Fehler: ${err.error || err.details || 'Unbekannt'}`)
return
}
await loadData()
setMode('list')
} catch {
alert('Netzwerkfehler')
} finally {
setSaving(false)
}
}
const handleUpdate = async (data: typeof EMPTY_CONTROL) => {
if (!selectedControl) return
setSaving(true)
try {
const res = await fetch(`${BACKEND_URL}?endpoint=update-control&id=${selectedControl.control_id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) {
const err = await res.json()
alert(`Fehler: ${err.error || err.details || 'Unbekannt'}`)
return
}
await loadData()
setSelectedControl(null)
setMode('list')
} catch {
alert('Netzwerkfehler')
} finally {
setSaving(false)
}
}
const handleDelete = async (controlId: string) => {
if (!confirm(`Control ${controlId} wirklich loeschen?`)) return
try {
const res = await fetch(`${BACKEND_URL}?id=${controlId}`, { method: 'DELETE' })
if (!res.ok && res.status !== 204) {
alert('Fehler beim Loeschen')
return
}
await loadData()
setSelectedControl(null)
setMode('list')
} catch {
alert('Netzwerkfehler')
}
}
if (loading) {
return (
@@ -192,17 +542,58 @@ export default function ControlLibraryPage() {
)
}
// =========================================================================
// CREATE MODE
// =========================================================================
if (mode === 'create') {
return <ControlForm initial={EMPTY_CONTROL} onSave={handleCreate} onCancel={() => setMode('list')} saving={saving} />
}
// =========================================================================
// EDIT MODE
// =========================================================================
if (mode === 'edit' && selectedControl) {
const editData = {
framework_id: frameworks[0]?.framework_id || 'bp_security_v1',
control_id: selectedControl.control_id,
title: selectedControl.title,
objective: selectedControl.objective,
rationale: selectedControl.rationale,
scope: selectedControl.scope || { platforms: [], components: [], data_classes: [] },
requirements: selectedControl.requirements.length ? selectedControl.requirements : [''],
test_procedure: selectedControl.test_procedure.length ? selectedControl.test_procedure : [''],
evidence: selectedControl.evidence.length ? selectedControl.evidence : [{ type: '', description: '' }],
severity: selectedControl.severity,
risk_score: selectedControl.risk_score,
implementation_effort: selectedControl.implementation_effort,
open_anchors: selectedControl.open_anchors.length ? selectedControl.open_anchors : [{ framework: '', ref: '', url: '' }],
release_state: selectedControl.release_state,
tags: selectedControl.tags,
}
return <ControlForm initial={editData} onSave={handleUpdate} onCancel={() => { setMode('detail') }} saving={saving} />
}
// =========================================================================
// DETAIL VIEW
// =========================================================================
if (selectedControl) {
if (mode === 'detail' && 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>
<div className="flex items-center justify-between mb-6">
<button onClick={() => { setSelectedControl(null); setMode('list') }} className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700">
<ArrowLeft className="w-4 h-4" /> Zurueck zur Uebersicht
</button>
<div className="flex items-center gap-2">
<button onClick={() => setMode('edit')} className="flex items-center gap-1 px-3 py-1.5 text-sm text-purple-600 border border-purple-300 rounded-lg hover:bg-purple-50">
<Pencil className="w-3.5 h-3.5" /> Bearbeiten
</button>
<button onClick={() => handleDelete(ctrl.control_id)} className="flex items-center gap-1 px-3 py-1.5 text-sm text-red-600 border border-red-300 rounded-lg hover:bg-red-50">
<Trash2 className="w-3.5 h-3.5" /> Loeschen
</button>
</div>
</div>
{/* Header */}
<div className="flex items-start gap-4 mb-6">
@@ -381,6 +772,13 @@ export default function ControlLibraryPage() {
</p>
</div>
</div>
<button
onClick={() => setMode('create')}
className="flex items-center gap-1.5 px-3 py-2 text-sm text-white bg-purple-600 rounded-lg hover:bg-purple-700"
>
<Plus className="w-4 h-4" />
Neues Control
</button>
</div>
{/* Frameworks */}
@@ -440,7 +838,7 @@ export default function ControlLibraryPage() {
{filteredControls.map(ctrl => (
<button
key={ctrl.control_id}
onClick={() => setSelectedControl(ctrl)}
onClick={() => { setSelectedControl(ctrl); setMode('detail') }}
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">
@@ -474,7 +872,9 @@ export default function ControlLibraryPage() {
{filteredControls.length === 0 && (
<div className="text-center py-12 text-gray-400 text-sm">
Keine Controls gefunden.
{controls.length === 0
? 'Noch keine Controls vorhanden. Klicke auf "Neues Control" um zu starten.'
: 'Keine Controls gefunden.'}
</div>
)}
</div>

View File

@@ -1,19 +1,21 @@
"""
FastAPI routes for the Canonical Control Library.
Provides read-only access to independently authored security controls.
All controls are formulated without proprietary nomenclature and anchored
in open-source frameworks (OWASP, NIST, ENISA).
Independently authored security controls anchored in open-source frameworks
(OWASP, NIST, ENISA). No proprietary nomenclature.
Endpoints:
GET /v1/canonical/frameworks — All frameworks
GET /v1/canonical/frameworks/{framework_id} — Framework details
GET /v1/canonical/frameworks/{framework_id}/controls — Controls of a framework
GET /v1/canonical/controls — All controls (filterable)
GET /v1/canonical/controls/{control_id} — Single control by control_id
GET /v1/canonical/sourcesSource registry
GET /v1/canonical/licenses — License matrix
POST /v1/canonical/controls/{control_id}/similarity-check — Too-close check
GET /v1/canonical/frameworks — All frameworks
GET /v1/canonical/frameworks/{framework_id} — Framework details
GET /v1/canonical/frameworks/{framework_id}/controls — Controls of a framework
GET /v1/canonical/controls — All controls (filterable)
GET /v1/canonical/controls/{control_id} — Single control
POST /v1/canonical/controls Create a control
PUT /v1/canonical/controls/{control_id} — Update a control
DELETE /v1/canonical/controls/{control_id} — Delete a control
GET /v1/canonical/sources — Source registry
GET /v1/canonical/licenses — License matrix
POST /v1/canonical/controls/{control_id}/similarity-check — Too-close check
"""
from __future__ import annotations
@@ -72,6 +74,42 @@ class ControlResponse(BaseModel):
updated_at: str
class ControlCreateRequest(BaseModel):
framework_id: str # e.g. 'bp_security_v1'
control_id: str # e.g. 'AUTH-003'
title: str
objective: str
rationale: str
scope: dict = {}
requirements: list = []
test_procedure: list = []
evidence: list = []
severity: str = "medium"
risk_score: Optional[float] = None
implementation_effort: Optional[str] = None
evidence_confidence: Optional[float] = None
open_anchors: list = []
release_state: str = "draft"
tags: list = []
class ControlUpdateRequest(BaseModel):
title: Optional[str] = None
objective: Optional[str] = None
rationale: Optional[str] = None
scope: Optional[dict] = None
requirements: Optional[list] = None
test_procedure: Optional[list] = None
evidence: Optional[list] = None
severity: Optional[str] = None
risk_score: Optional[float] = None
implementation_effort: Optional[str] = None
evidence_confidence: Optional[float] = None
open_anchors: Optional[list] = None
release_state: Optional[str] = None
tags: Optional[list] = None
class SimilarityCheckRequest(BaseModel):
source_text: str
candidate_text: str
@@ -266,6 +304,150 @@ async def get_control(control_id: str):
return _control_row(row)
# =============================================================================
# CONTROL CRUD (CREATE / UPDATE / DELETE)
# =============================================================================
@router.post("/controls", status_code=201)
async def create_control(body: ControlCreateRequest):
"""Create a new canonical control."""
import json as _json
import re
# Validate control_id format
if not re.match(r"^[A-Z]{2,6}-[0-9]{3}$", body.control_id):
raise HTTPException(status_code=400, detail="control_id must match DOMAIN-NNN (e.g. AUTH-001)")
if body.severity not in ("low", "medium", "high", "critical"):
raise HTTPException(status_code=400, detail="severity must be low/medium/high/critical")
if body.risk_score is not None and not (0 <= body.risk_score <= 10):
raise HTTPException(status_code=400, detail="risk_score must be 0..10")
with SessionLocal() as db:
# Resolve framework
fw = db.execute(
text("SELECT id FROM canonical_control_frameworks WHERE framework_id = :fid"),
{"fid": body.framework_id},
).fetchone()
if not fw:
raise HTTPException(status_code=404, detail=f"Framework '{body.framework_id}' not found")
# Check duplicate
existing = db.execute(
text("SELECT id FROM canonical_controls WHERE framework_id = :fid AND control_id = :cid"),
{"fid": str(fw.id), "cid": body.control_id},
).fetchone()
if existing:
raise HTTPException(status_code=409, detail=f"Control '{body.control_id}' already exists")
row = db.execute(
text("""
INSERT INTO canonical_controls (
framework_id, control_id, title, objective, rationale,
scope, requirements, test_procedure, evidence,
severity, risk_score, implementation_effort, evidence_confidence,
open_anchors, release_state, tags
) VALUES (
:fw_id, :cid, :title, :objective, :rationale,
:scope::jsonb, :requirements::jsonb, :test_procedure::jsonb, :evidence::jsonb,
:severity, :risk_score, :effort, :confidence,
:anchors::jsonb, :release_state, :tags::jsonb
)
RETURNING id, framework_id, control_id, title, objective, rationale,
scope, requirements, test_procedure, evidence,
severity, risk_score, implementation_effort,
evidence_confidence, open_anchors, release_state, tags,
created_at, updated_at
"""),
{
"fw_id": str(fw.id),
"cid": body.control_id,
"title": body.title,
"objective": body.objective,
"rationale": body.rationale,
"scope": _json.dumps(body.scope),
"requirements": _json.dumps(body.requirements),
"test_procedure": _json.dumps(body.test_procedure),
"evidence": _json.dumps(body.evidence),
"severity": body.severity,
"risk_score": body.risk_score,
"effort": body.implementation_effort,
"confidence": body.evidence_confidence,
"anchors": _json.dumps(body.open_anchors),
"release_state": body.release_state,
"tags": _json.dumps(body.tags),
},
).fetchone()
db.commit()
return _control_row(row)
@router.put("/controls/{control_id}")
async def update_control(control_id: str, body: ControlUpdateRequest):
"""Update an existing canonical control (partial update)."""
import json as _json
updates = body.dict(exclude_none=True)
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
if "severity" in updates and updates["severity"] not in ("low", "medium", "high", "critical"):
raise HTTPException(status_code=400, detail="severity must be low/medium/high/critical")
if "risk_score" in updates and updates["risk_score"] is not None and not (0 <= updates["risk_score"] <= 10):
raise HTTPException(status_code=400, detail="risk_score must be 0..10")
# Build dynamic SET clause
set_parts = []
params: dict[str, Any] = {"cid": control_id.upper()}
json_fields = {"scope", "requirements", "test_procedure", "evidence", "open_anchors", "tags"}
for key, val in updates.items():
col = "implementation_effort" if key == "implementation_effort" else key
col = "evidence_confidence" if key == "evidence_confidence" else col
if key in json_fields:
set_parts.append(f"{col} = :{key}::jsonb")
params[key] = _json.dumps(val)
else:
set_parts.append(f"{col} = :{key}")
params[key] = val
set_parts.append("updated_at = NOW()")
with SessionLocal() as db:
row = db.execute(
text(f"""
UPDATE canonical_controls
SET {', '.join(set_parts)}
WHERE control_id = :cid
RETURNING id, framework_id, control_id, title, objective, rationale,
scope, requirements, test_procedure, evidence,
severity, risk_score, implementation_effort,
evidence_confidence, open_anchors, release_state, tags,
created_at, updated_at
"""),
params,
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Control not found")
db.commit()
return _control_row(row)
@router.delete("/controls/{control_id}", status_code=204)
async def delete_control(control_id: str):
"""Delete a canonical control."""
with SessionLocal() as db:
result = db.execute(
text("DELETE FROM canonical_controls WHERE control_id = :cid"),
{"cid": control_id.upper()},
)
if result.rowcount == 0:
raise HTTPException(status_code=404, detail="Control not found")
db.commit()
return None
# =============================================================================
# SIMILARITY CHECK
# =============================================================================

View File

@@ -0,0 +1,165 @@
-- Migration 045: Seed 10 canonical controls into the database
-- These controls are independently authored with open-source anchoring (OWASP, NIST, ENISA).
-- No proprietary nomenclature — independent taxonomy with DOMAIN-NNN IDs.
BEGIN;
INSERT INTO canonical_controls (
framework_id, control_id, title, objective, rationale,
scope, requirements, test_procedure, evidence,
severity, risk_score, implementation_effort, open_anchors,
release_state, tags
)
SELECT
f.id,
v.control_id, v.title, v.objective, v.rationale,
v.scope::jsonb, v.requirements::jsonb, v.test_procedure::jsonb, v.evidence::jsonb,
v.severity, v.risk_score, v.implementation_effort, v.open_anchors::jsonb,
'draft', v.tags::jsonb
FROM canonical_control_frameworks f
CROSS JOIN (VALUES
-- AUTH-001
(
'AUTH-001',
'Multi-Factor Authentication for Privileged Access',
'Privilegierte Konten und administrative Zugaenge muessen durch mindestens zwei unabhaengige Authentisierungsfaktoren geschuetzt werden, um Credential-Diebstahl zu mitigieren.',
'Passwort-basierte Authentisierung allein bietet ungenuegenden Schutz gegen Phishing, Credential Stuffing und Brute-Force-Angriffe. NIST und OWASP empfehlen uebereinstimmend MFA fuer jeden Zugang mit erhoehten Rechten. ENISA listet fehlende MFA als Top-Risiko fuer Cloud- und Mobile-Anwendungen.',
'{"platforms": ["web", "mobile", "api"], "components": ["authentication-service", "identity-provider", "admin-panel"], "data_classes": ["credentials", "session-tokens"]}',
'["Mindestens zwei Faktoren aus unterschiedlichen Kategorien (Wissen, Besitz, Biometrie) fuer privilegierte Konten", "Time-based One-Time Passwords (TOTP) oder FIDO2/WebAuthn als zweiter Faktor unterstuetzt", "Fallback-Mechanismen (Recovery Codes) sicher generiert und verschluesselt gespeichert", "MFA-Bypass nur mit dokumentierter Ausnahme und zeitlicher Begrenzung"]',
'["Pruefe, ob Admin-Login ohne zweiten Faktor abgelehnt wird", "Pruefe, ob TOTP-Codes mit falschem Shared Secret abgelehnt werden", "Pruefe, ob Recovery Codes nach einmaliger Nutzung invalidiert werden", "Pruefe, ob MFA-Enrollment bei Erstanmeldung erzwungen wird"]',
'[{"type": "config", "description": "MFA-Policy-Konfiguration des Identity Providers"}, {"type": "test_result", "description": "Automatisierte Login-Tests mit/ohne zweiten Faktor"}, {"type": "audit_log", "description": "MFA-Enrollment- und Challenge-Logs"}]',
'high', 8.5, 'm',
'[{"framework": "OWASP ASVS", "ref": "V2.8 — One-Time Verifier", "url": "https://owasp.org/www-project-application-security-verification-standard/"}, {"framework": "NIST SP 800-63B", "ref": "Section 4 — Authenticator Assurance Levels", "url": "https://pages.nist.gov/800-63-3/sp800-63b.html"}, {"framework": "ENISA", "ref": "Good Practices for IoT/Cloud — Authentication", "url": "https://www.enisa.europa.eu/publications/good-practices-for-security-of-iot-1"}, {"framework": "OWASP Top 10", "ref": "A07:2021 — Identification and Authentication Failures", "url": "https://owasp.org/Top10/A07_2021-Identification_and_Authentication_Failures/"}]',
'["authentication", "mfa", "iam", "privileged-access"]'
),
-- AUTH-002
(
'AUTH-002',
'Secure Token Lifecycle Management',
'Authentisierungs- und Autorisierungs-Tokens muessen sicher generiert, gespeichert, uebertragen und invalidiert werden, um Session-Hijacking und Token-Leakage zu verhindern.',
'Unsicheres Token-Handling ist ein wiederkehrender Angriffsvektor. OWASP ASVS und NIST definieren klare Anforderungen an Token-Entropie, Speicherung und Invalidierung. Tokens in Local Storage oder unverschluesselt auf dem Geraet sind besonders anfaellig.',
'{"platforms": ["web", "mobile", "api"], "components": ["session-manager", "oauth-server", "api-gateway"], "data_classes": ["session-tokens", "refresh-tokens", "api-keys"]}',
'["Tokens mit mindestens 128 Bit Entropie aus kryptographisch sicherem PRNG generieren", "Access Tokens kurzlebig (max. 15 Minuten), Refresh Tokens mit Rotation bei Nutzung", "Tokens auf Clientseite in sicherem Speicher (Keychain/Keystore, HttpOnly Cookies) — nicht in Local Storage", "Serverseitige Token-Invalidierung bei Logout, Passwortaenderung und Verdacht auf Kompromittierung", "Token-Binding an Client-Kontext (IP-Range, Device Fingerprint) wo moeglich"]',
'["Pruefe Token-Entropie (min. 128 Bit) durch Analyse generierter Tokens", "Pruefe, ob abgelaufene Tokens serverseitig abgelehnt werden", "Pruefe, ob Refresh Token Rotation nach Nutzung den alten Token invalidiert", "Pruefe, ob Tokens nach Logout serverseitig nicht mehr akzeptiert werden"]',
'[{"type": "code_review", "description": "Token-Generierung und -Speicherung im Quellcode"}, {"type": "test_result", "description": "Automatisierte Token-Lifecycle-Tests"}, {"type": "config", "description": "Token-TTL- und Rotations-Konfiguration"}]',
'high', 8.0, 'm',
'[{"framework": "OWASP ASVS", "ref": "V3.5 — Token-based Session Management", "url": "https://owasp.org/www-project-application-security-verification-standard/"}, {"framework": "NIST SP 800-63B", "ref": "Section 7 — Session Management", "url": "https://pages.nist.gov/800-63-3/sp800-63b.html"}, {"framework": "OWASP Top 10", "ref": "A07:2021 — Identification and Authentication Failures", "url": "https://owasp.org/Top10/A07_2021-Identification_and_Authentication_Failures/"}, {"framework": "OWASP MASVS", "ref": "MASVS-AUTH — Authentication and Session Management", "url": "https://mas.owasp.org/MASVS/05-MASVS-AUTH/"}]',
'["tokens", "session", "oauth", "iam"]'
),
-- NET-001
(
'NET-001',
'Mandatory Transport Encryption',
'Jede Netzwerkkommunikation zwischen Client und Server sowie zwischen Services muss mit TLS 1.2+ verschluesselt sein. Unsichere Protokolle und Cipher Suites muessen deaktiviert werden.',
'Unverschluesselte Kommunikation erlaubt Abhoeren und Man-in-the-Middle-Angriffe. Alle relevanten Sicherheitsstandards (NIST, OWASP, ENISA) fordern TLS als Baseline. TLS 1.0 und 1.1 gelten als unsicher und muessen deaktiviert werden.',
'{"platforms": ["web", "mobile", "api", "backend"], "components": ["reverse-proxy", "api-gateway", "service-mesh", "database-connections"], "data_classes": ["all-in-transit"]}',
'["TLS 1.2 als Minimum, TLS 1.3 bevorzugt fuer alle externen und internen Verbindungen", "SSLv3, TLS 1.0, TLS 1.1 vollstaendig deaktiviert", "Nur starke Cipher Suites: AEAD-basiert (AES-GCM, ChaCha20-Poly1305), kein CBC, kein RC4", "HSTS-Header mit includeSubDomains und preload fuer Webanwendungen", "Keine Mixed-Content-Ausnahmen in Produktionsumgebungen"]',
'["TLS-Scan aller oeffentlichen Endpunkte (z.B. mit testssl.sh oder ssllabs)", "Pruefe, ob Verbindungen mit TLS < 1.2 abgelehnt werden", "Pruefe HSTS-Header auf korrekte Konfiguration (max-age >= 31536000)", "Pruefe interne Service-zu-Service-Kommunikation auf TLS-Nutzung"]',
'[{"type": "scan_result", "description": "TLS-Scan-Report (testssl.sh oder SSLLabs)"}, {"type": "config", "description": "Nginx/Reverse-Proxy TLS-Konfiguration"}, {"type": "test_result", "description": "Automatisierte Cipher-Suite-Pruefung"}]',
'high', 9.0, 's',
'[{"framework": "OWASP ASVS", "ref": "V9.1 — Communication Security", "url": "https://owasp.org/www-project-application-security-verification-standard/"}, {"framework": "NIST SP 800-52", "ref": "Guidelines for TLS Implementations", "url": "https://csrc.nist.gov/publications/detail/sp/800-52/rev-2/final"}, {"framework": "OWASP Top 10", "ref": "A02:2021 — Cryptographic Failures", "url": "https://owasp.org/Top10/A02_2021-Cryptographic_Failures/"}, {"framework": "ENISA", "ref": "Algorithms, Key Sizes and Parameters Report", "url": "https://www.enisa.europa.eu/publications/algorithms-key-size-and-parameters-report-2014"}]',
'["tls", "encryption", "transport", "network"]'
),
-- NET-002
(
'NET-002',
'Certificate Trust Store Hardening',
'Anwendungen muessen den Zertifikats-Trust-Store einschraenken und Certificate Pinning oder Certificate Transparency nutzen, um Man-in-the-Middle-Angriffe durch kompromittierte CAs zu erschweren.',
'Das Standard-CA-System vertraut hunderten CAs weltweit. Eine kompromittierte CA kann gueltige Zertifikate fuer beliebige Domains ausstellen. OWASP MASVS empfiehlt Certificate Pinning fuer mobile Apps, NIST und Certificate Transparency Logs bieten ergaenzende Massnahmen.',
'{"platforms": ["mobile", "api"], "components": ["http-client", "tls-config", "trust-store"], "data_classes": ["certificates", "tls-metadata"]}',
'["Mobile Apps: Certificate Pinning gegen den Server-Public-Key oder Intermediate-CA", "Pin-Rotation: Backup-Pins fuer geplanten Zertifikatswechsel vorhalten", "Certificate Transparency: Expect-CT Header oder CT-Log-Monitoring fuer Webdienste", "Regelmaessige Pruefung der Trust-Store-Eintraege auf abgelaufene oder zurueckgerufene CAs"]',
'["Pruefe, ob Mobile App Verbindungen mit selbstsigniertem Zertifikat ablehnt", "Pruefe, ob Backup-Pins konfiguriert sind fuer nahtlose Rotation", "Pruefe CT-Log-Monitoring auf unerwartete Zertifikatsausstellungen", "Pruefe, ob CRL/OCSP-Stapling aktiviert ist"]',
'[{"type": "config", "description": "Certificate Pinning Konfiguration im Mobile-Client"}, {"type": "test_result", "description": "MITM-Proxy-Test mit falschem Zertifikat"}, {"type": "monitoring", "description": "CT-Log-Monitoring-Dashboard"}]',
'medium', 6.5, 'm',
'[{"framework": "OWASP MASVS", "ref": "MASVS-NETWORK — Network Communication", "url": "https://mas.owasp.org/MASVS/06-MASVS-NETWORK/"}, {"framework": "NIST SP 800-52", "ref": "Section 3.5 — Server Certificate Validation", "url": "https://csrc.nist.gov/publications/detail/sp/800-52/rev-2/final"}, {"framework": "OWASP", "ref": "Certificate and Public Key Pinning Cheat Sheet", "url": "https://cheatsheetseries.owasp.org/cheatsheets/Pinning_Cheat_Sheet.html"}]',
'["certificates", "pinning", "trust-store", "network"]'
),
-- SUP-001
(
'SUP-001',
'Software Distribution Integrity & Update Verification',
'Software-Updates und -Pakete muessen kryptographisch signiert und vor der Installation verifiziert werden, um Manipulation in der Lieferkette zu erkennen.',
'Supply-Chain-Angriffe (z.B. SolarWinds, Codecov) zeigen, dass unsignierte oder ungepruefte Updates ein kritisches Einfallstor sind. NIST SSDF, OWASP und SLSA definieren Mindestanforderungen an Build-Provenance und Signaturpruefung.',
'{"platforms": ["mobile", "web", "backend"], "components": ["ci-cd-pipeline", "package-registry", "auto-updater", "app-store"], "data_classes": ["binaries", "packages", "container-images"]}',
'["Alle Release-Artefakte kryptographisch signiert (z.B. GPG, Sigstore/Cosign fuer Container)", "Signaturpruefung vor jeder Installation oder Deployment — unsignierte Artefakte ablehnen", "SBOM (Software Bill of Materials) fuer jedes Release generieren und archivieren", "Dependency-Scanning (SCA) in CI/CD-Pipeline integriert, bekannte CVEs blockieren", "Reproduzierbare Builds wo technisch moeglich, Build-Provenance dokumentieren"]',
'["Pruefe, ob unsignierte Artefakte vom Deployment abgelehnt werden", "Pruefe, ob SBOM fuer das letzte Release vorhanden und vollstaendig ist", "Pruefe, ob Dependency-Scanner in CI/CD aktiv ist und bei Critical CVEs blockiert", "Pruefe, ob Container-Images mit Cosign/Notary signiert sind"]',
'[{"type": "config", "description": "CI/CD-Pipeline mit Signatur- und Scan-Steps"}, {"type": "artifact", "description": "Signiertes SBOM des letzten Release"}, {"type": "scan_result", "description": "SCA-Report der letzten Pipeline-Ausfuehrung"}]',
'high', 8.0, 'l',
'[{"framework": "NIST SSDF", "ref": "PW.4 — Reusable Software Integrity Verification", "url": "https://csrc.nist.gov/publications/detail/sp/800-218/final"}, {"framework": "OWASP Top 10", "ref": "A08:2021 — Software and Data Integrity Failures", "url": "https://owasp.org/Top10/A08_2021-Software_and_Data_Integrity_Failures/"}, {"framework": "SLSA", "ref": "Supply-chain Levels for Software Artifacts", "url": "https://slsa.dev/spec/v1.0/levels"}, {"framework": "NIST SP 800-53", "ref": "SA-12 — Supply Chain Protection", "url": "https://csrc.nist.gov/publications/detail/sp/800-53/rev-5/final"}]',
'["supply-chain", "signing", "sbom", "sdlc"]'
),
-- LOG-001
(
'LOG-001',
'Privacy-Aware Security Logging',
'Sicherheitsrelevante Ereignisse vollstaendig protokollieren, dabei personenbezogene Daten konsequent reduzieren (Redaction-First-Prinzip).',
'Effektive Incident Response erfordert vollstaendige Security Logs. Gleichzeitig verlangt DSGVO Art. 5(1)(c) Datenminimierung — Logs duerfen keine ueberfluessigen personenbezogenen Daten enthalten. OWASP Logging Cheat Sheet und NIST SP 800-92 definieren Best Practices fuer sicheres Logging.',
'{"platforms": ["web", "mobile", "api", "backend"], "components": ["logging-framework", "siem", "log-aggregator"], "data_classes": ["security-events", "access-logs", "error-logs"]}',
'["Alle sicherheitsrelevanten Events loggen: Login/Logout, Rechteaenderungen, Fehlgeschlagene Zugriffe, Konfigurationsaenderungen", "PII-Redaction: Passwoerter, Tokens, Kreditkarten, IP-Adressen wo moeglich pseudonymisieren", "Strukturiertes Logging (JSON) mit einheitlichem Schema: timestamp, event_type, actor_id, resource, outcome", "Log-Integritaet: Tamper-Protection durch Signaturen oder Write-Once-Storage", "Retention Policy: Security Logs mindestens 90 Tage, maximal nach Compliance-Anforderung"]',
'["Pruefe, ob fehlgeschlagene Login-Versuche geloggt werden (mit pseudonymisierter IP)", "Pruefe, ob Passwoerter und Tokens in keinem Log-Eintrag im Klartext erscheinen", "Pruefe, ob Log-Eintraege das definierte JSON-Schema einhalten", "Pruefe, ob Logs aelter als Retention-Periode automatisch geloescht werden"]',
'[{"type": "config", "description": "Logging-Framework-Konfiguration mit Redaction-Regeln"}, {"type": "test_result", "description": "Log-Analyse auf PII-Leaks"}, {"type": "policy", "description": "Log-Retention-Policy-Dokument"}]',
'medium', 6.0, 'm',
'[{"framework": "OWASP", "ref": "Logging Cheat Sheet", "url": "https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html"}, {"framework": "NIST SP 800-92", "ref": "Guide to Computer Security Log Management", "url": "https://csrc.nist.gov/publications/detail/sp/800-92/final"}, {"framework": "OWASP Top 10", "ref": "A09:2021 — Security Logging and Monitoring Failures", "url": "https://owasp.org/Top10/A09_2021-Security_Logging_and_Monitoring_Failures/"}, {"framework": "OWASP ASVS", "ref": "V7 — Error Handling and Logging", "url": "https://owasp.org/www-project-application-security-verification-standard/"}]',
'["logging", "monitoring", "privacy", "redaction"]'
),
-- WEB-001
(
'WEB-001',
'Hardened Administrative and Account Recovery Flows',
'Administrative Zugaenge und Account-Recovery-Prozesse (Passwort-Reset, E-Mail-Aenderung) muessen gegen Enumeration, Brute-Force und Social Engineering gehaertet werden.',
'Account-Recovery-Flows sind haeufig schwaecher geschuetzt als der regulaere Login und werden gezielt angegriffen. OWASP identifiziert unsichere Recovery-Mechanismen als verbreitetes Problem. Rate Limiting, sichere Token-Generierung und Vermeidung von User-Enumeration sind essentiell.',
'{"platforms": ["web", "mobile"], "components": ["password-reset", "email-change", "admin-panel", "account-recovery"], "data_classes": ["credentials", "recovery-tokens", "email-addresses"]}',
'["Passwort-Reset-Tokens kryptographisch sicher (min. 128 Bit), zeitlich begrenzt (max. 1 Stunde), einmalig nutzbar", "Keine User-Enumeration: Identische Antwort unabhaengig ob Account existiert", "Rate Limiting auf Reset- und Recovery-Endpunkte (max. 5 Versuche / 15 Minuten pro IP)", "Admin-Panels nicht oeffentlich erreichbar oder zusaetzlich durch IP-Whitelist/VPN geschuetzt", "E-Mail-Aenderung erfordert Bestaetigung an alte UND neue Adresse"]',
'["Pruefe, ob Reset-Endpunkt bei existierendem und nicht-existierendem Account identisch antwortet", "Pruefe, ob Reset-Token nach einmaliger Nutzung invalidiert wird", "Pruefe Rate Limiting: 6. Versuch innerhalb von 15 Minuten wird blockiert", "Pruefe, ob Admin-Panel von externen IPs nicht erreichbar ist"]',
'[{"type": "test_result", "description": "Automatisierte Enumeration- und Rate-Limit-Tests"}, {"type": "config", "description": "Rate-Limiting-Konfiguration"}, {"type": "network_config", "description": "Admin-Panel-Zugriffsbeschraenkung (IP/VPN)"}]',
'high', 7.5, 'm',
'[{"framework": "OWASP ASVS", "ref": "V2.5 — Credential Recovery", "url": "https://owasp.org/www-project-application-security-verification-standard/"}, {"framework": "OWASP", "ref": "Forgot Password Cheat Sheet", "url": "https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html"}, {"framework": "NIST SP 800-63B", "ref": "Section 6.1.2 — Memorized Secret Recovery", "url": "https://pages.nist.gov/800-63-3/sp800-63b.html"}, {"framework": "OWASP Top 10", "ref": "A07:2021 — Identification and Authentication Failures", "url": "https://owasp.org/Top10/A07_2021-Identification_and_Authentication_Failures/"}]',
'["account-recovery", "admin", "rate-limiting", "web"]'
),
-- DATA-001
(
'DATA-001',
'Data-Classification-Driven Security Measures',
'Schutzmassnahmen muessen automatisch an die Klassifikation der verarbeiteten Daten gekoppelt sein. Hoehere Datenklassen erfordern staerkere Controls.',
'Ein einheitliches Schutzniveau fuer alle Daten ist entweder uebermaessig teuer oder unzureichend fuer sensible Daten. NIST SP 800-53, ISO 27001 und DSGVO Art. 32 fordern risikoadaequate Massnahmen. Die Datenklassifikation bildet die Grundlage fuer die Auswahl geeigneter Controls.',
'{"platforms": ["web", "mobile", "api", "backend"], "components": ["data-catalog", "access-control", "encryption-service", "backup-system"], "data_classes": ["public", "internal", "confidential", "restricted"]}',
'["Datenklassifikationsschema definiert: Public, Internal, Confidential, Restricted (mit Beispielen je Klasse)", "Jede Datenverarbeitung mit Klassifikation versehen — Default ist Internal (nicht Public)", "Confidential/Restricted: Verschluesselung at Rest und in Transit obligatorisch", "Restricted: Zusaetzlich Zugriffsprotokollierung, Need-to-Know-Prinzip, Vier-Augen fuer Export", "Automatische Policy-Enforcement: Datenklasse bestimmt verfuegbare Operationen (Export, Sharing, Retention)"]',
'["Pruefe, ob jede Tabelle/Collection eine Datenklassifikation hat", "Pruefe, ob Confidential-Daten at Rest verschluesselt sind", "Pruefe, ob Restricted-Daten nur mit Zugriffsprotokollierung abrufbar sind", "Pruefe, ob Export von Restricted-Daten Vier-Augen-Freigabe erfordert"]',
'[{"type": "policy", "description": "Datenklassifikationsschema mit Beispielen"}, {"type": "config", "description": "Encryption-at-Rest-Konfiguration pro Datenklasse"}, {"type": "audit_log", "description": "Zugriffsprotokolle fuer Restricted-Daten"}]',
'critical', 9.5, 'l',
'[{"framework": "NIST SP 800-53", "ref": "RA-2 — Security Categorization", "url": "https://csrc.nist.gov/publications/detail/sp/800-53/rev-5/final"}, {"framework": "NIST SP 800-60", "ref": "Guide for Mapping Types of Information to Security Categories", "url": "https://csrc.nist.gov/publications/detail/sp/800-60/vol-1-rev-1/final"}, {"framework": "OWASP ASVS", "ref": "V6.1 — Data Classification", "url": "https://owasp.org/www-project-application-security-verification-standard/"}, {"framework": "ENISA", "ref": "Data Protection Engineering — Data Classification", "url": "https://www.enisa.europa.eu/publications/data-protection-engineering"}]',
'["data-classification", "governance", "encryption", "access-control"]'
),
-- CRYP-001
(
'CRYP-001',
'Cryptographic Key Lifecycle Management',
'Kryptographische Schluessel muessen sicher erzeugt, gespeichert, rotiert und vernichtet werden. Der gesamte Lebenszyklus muss dokumentiert und auditierbar sein.',
'Selbst starke Algorithmen bieten keinen Schutz, wenn Schluessel unsicher gespeichert oder nie rotiert werden. NIST SP 800-57 definiert den Key-Lifecycle. OWASP warnt explizit vor Hard-coded Keys und fehlender Rotation.',
'{"platforms": ["backend", "api"], "components": ["key-management-service", "vault", "encryption-service", "certificate-manager"], "data_classes": ["encryption-keys", "signing-keys", "api-keys"]}',
'["Schluessel in Hardware Security Module (HSM) oder Software-Vault (z.B. HashiCorp Vault) — nie im Quellcode oder Konfigurationsdateien", "Schluesselgenerierung mit kryptographisch sicherem PRNG (CSPRNG), Mindestlaenge nach Algorithmus (AES-256, RSA-3072+, Ed25519)", "Rotation: Symmetrische Schluessel mindestens jaehrlich, asymmetrische nach Algorithmus-Empfehlung", "Sichere Vernichtung: Alte Schluessel nach Ablauf der Aufbewahrungsfrist kryptographisch ueberschrieben", "Trennung: Unterschiedliche Schluessel fuer unterschiedliche Zwecke (Signing vs. Encryption)"]',
'["Pruefe, ob keine Schluessel im Quellcode oder .env-Dateien hartcodiert sind (Secret Scanner)", "Pruefe, ob Vault/HSM fuer Schluesseloperationen genutzt wird", "Pruefe, ob Schluessel-Rotation-Logs vorhanden sind", "Pruefe, ob unterschiedliche Schluessel fuer Signing und Encryption verwendet werden"]',
'[{"type": "config", "description": "Vault/HSM-Konfiguration und Zugriffsrichtlinien"}, {"type": "scan_result", "description": "Secret-Scanner-Report (kein Leak im Repo)"}, {"type": "audit_log", "description": "Key-Rotation-Historie aus Vault"}]',
'high', 8.5, 'l',
'[{"framework": "NIST SP 800-57", "ref": "Key Management — Part 1: General", "url": "https://csrc.nist.gov/publications/detail/sp/800-57-part-1/rev-5/final"}, {"framework": "OWASP", "ref": "Key Management Cheat Sheet", "url": "https://cheatsheetseries.owasp.org/cheatsheets/Key_Management_Cheat_Sheet.html"}, {"framework": "OWASP Top 10", "ref": "A02:2021 — Cryptographic Failures", "url": "https://owasp.org/Top10/A02_2021-Cryptographic_Failures/"}, {"framework": "ENISA", "ref": "Algorithms, Key Sizes and Parameters Report", "url": "https://www.enisa.europa.eu/publications/algorithms-key-size-and-parameters-report-2014"}]',
'["key-management", "crypto", "vault", "rotation"]'
),
-- REL-001
(
'REL-001',
'Security Change Impact Assessment',
'Jede Aenderung an sicherheitsrelevanten Komponenten muss vor dem Deployment eine strukturierte Impact-Bewertung durchlaufen.',
'Ungepruefte Aenderungen an Security-Controls koennen den gesamten Schutzlevel untergraben. NIST SP 800-53 (CM-4) und OWASP fordern Change Impact Assessments. Ein formalisierter Prozess verhindert, dass Security-Regressionen unbemerkt in Produktion gelangen.',
'{"platforms": ["all"], "components": ["ci-cd-pipeline", "change-management", "code-review"], "data_classes": ["source-code", "infrastructure-config", "security-policies"]}',
'["Aenderungen an auth, crypto, access-control, logging als security-relevant getaggt", "Security-relevante Changes erfordern Review durch Security-qualifizierten Reviewer", "Automatisierte Security-Regression-Tests in CI/CD fuer alle security-relevanten Pfade", "Rollback-Plan dokumentiert fuer jedes security-relevante Deployment", "Post-Deployment-Monitoring: Erhoehte Alerting-Schwelle fuer 24h nach Security-Change"]',
'["Pruefe, ob MRs/PRs mit Security-relevanten Dateien automatisch getaggt werden", "Pruefe, ob Security-getaggte MRs einen zweiten Reviewer erfordern", "Pruefe, ob Security-Regression-Tests in CI/CD vorhanden und aktiv sind", "Pruefe, ob Rollback-Dokumentation fuer letzte 3 Security-Changes existiert"]',
'[{"type": "process", "description": "Change-Management-Prozessbeschreibung"}, {"type": "config", "description": "CI/CD-Konfiguration mit Security-Review-Gate"}, {"type": "audit_log", "description": "Merge-Request-Historie mit Security-Tags"}]',
'medium', 5.5, 'm',
'[{"framework": "NIST SP 800-53", "ref": "CM-4 — Impact Analyses", "url": "https://csrc.nist.gov/publications/detail/sp/800-53/rev-5/final"}, {"framework": "OWASP SAMM", "ref": "Implementation — Secure Deployment", "url": "https://owaspsamm.org/model/implementation/secure-deployment/"}, {"framework": "NIST SSDF", "ref": "PO.1 — Security Requirements for Software Development", "url": "https://csrc.nist.gov/publications/detail/sp/800-218/final"}, {"framework": "ENISA", "ref": "Secure Development Lifecycle", "url": "https://www.enisa.europa.eu/publications/standards-and-tools-for-secure-software-development"}]',
'["change-management", "ci-cd", "review", "governance"]'
)
) AS v(control_id, title, objective, rationale, scope, requirements, test_procedure, evidence, severity, risk_score, implementation_effort, open_anchors, tags)
WHERE f.framework_id = 'bp_security_v1'
ON CONFLICT (framework_id, control_id) DO NOTHING;
COMMIT;