feat: Anti-Fake-Evidence System (Phase 1-4b)

Implement full evidence integrity pipeline to prevent compliance theater:
- Confidence levels (E0-E4), truth status tracking, assertion engine
- Four-Eyes approval workflow, audit trail, reject endpoint
- Evidence distribution dashboard, LLM audit routes
- Traceability matrix (backend endpoint + Compliance Hub UI tab)
- Anti-fake badges, control status machine, normative patterns
- 2 migrations, 4 test suites, MkDocs documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-23 17:15:45 +01:00
parent 48ca0a6bef
commit e6201d5239
36 changed files with 5627 additions and 189 deletions

View File

@@ -591,12 +591,43 @@ async function handleV2Draft(body: Record<string, unknown>): Promise<NextRespons
cacheStats: proseCache.getStats(),
}
// Anti-Fake-Evidence: Truth label for all LLM-generated content
const truthLabel = {
generation_mode: 'draft_assistance',
truth_status: 'generated',
may_be_used_as_evidence: false,
generated_by: 'system',
}
// Fire-and-forget: persist LLM audit trail to backend
try {
const BACKEND_URL = process.env.BACKEND_COMPLIANCE_URL || 'http://backend-compliance:8002'
fetch(`${BACKEND_URL}/api/compliance/llm-audit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
entity_type: 'document',
entity_id: null,
generation_mode: 'draft_assistance',
truth_status: 'generated',
may_be_used_as_evidence: false,
llm_model: LLM_MODEL,
llm_provider: 'ollama',
input_summary: `${documentType} draft generation`,
output_summary: draft?.sections?.length ? `${draft.sections.length} sections generated` : 'draft generated',
}),
}).catch(() => {/* fire-and-forget */})
} catch {
// LLM audit persistence failure should not block the response
}
return NextResponse.json({
draft,
constraintCheck,
tokensUsed: Math.round(totalTokens),
pipelineVersion: 'v2',
auditTrail,
truthLabel,
})
}

View File

@@ -14,6 +14,76 @@ import { buildCrossCheckPrompt } from '@/lib/sdk/drafting-engine/prompts/validat
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434'
const LLM_MODEL = process.env.COMPLIANCE_LLM_MODEL || 'qwen2.5vl:32b'
/**
* Anti-Fake-Evidence: Verbotene Formulierungen
*
* Flags formulations that falsely claim compliance without evidence.
* Only allowed when: control_status=pass AND confidence >= E2 AND
* truth_status in (validated_internal, accepted_by_auditor).
*/
interface EvidenceContext {
controlStatus?: string
confidenceLevel?: string
truthStatus?: string
}
const FORBIDDEN_PATTERNS: Array<{
pattern: RegExp
label: string
safeAlternative: string
}> = [
{ pattern: /ist\s+compliant/gi, label: 'ist compliant', safeAlternative: 'soll compliant sein' },
{ pattern: /erfüllt\s+vollständig/gi, label: 'erfüllt vollständig', safeAlternative: 'soll vollständig erfüllt werden' },
{ pattern: /wurde\s+geprüft/gi, label: 'wurde geprüft', safeAlternative: 'soll geprüft werden' },
{ pattern: /wurde\s+umgesetzt/gi, label: 'wurde umgesetzt', safeAlternative: 'ist zur Umsetzung vorgesehen' },
{ pattern: /ist\s+auditiert/gi, label: 'ist auditiert', safeAlternative: 'soll auditiert werden' },
{ pattern: /vollständig\s+implementiert/gi, label: 'vollständig implementiert', safeAlternative: 'Implementierung ist vorgesehen' },
{ pattern: /nachweislich\s+konform/gi, label: 'nachweislich konform', safeAlternative: 'Konformität ist nachzuweisen' },
]
const CONFIDENCE_ORDER: Record<string, number> = { E0: 0, E1: 1, E2: 2, E3: 3, E4: 4 }
const VALID_TRUTH_STATUSES = new Set(['validated_internal', 'accepted_by_auditor'])
function checkForbiddenFormulations(
content: string,
evidenceContext?: EvidenceContext,
): ValidationFinding[] {
const findings: ValidationFinding[] = []
if (!content) return findings
// If evidence context shows sufficient proof, allow the formulations
if (evidenceContext) {
const { controlStatus, confidenceLevel, truthStatus } = evidenceContext
const confLevel = CONFIDENCE_ORDER[confidenceLevel ?? 'E0'] ?? 0
if (
controlStatus === 'pass' &&
confLevel >= CONFIDENCE_ORDER.E2 &&
VALID_TRUTH_STATUSES.has(truthStatus ?? '')
) {
return findings // Formulations are backed by real evidence
}
}
for (const { pattern, label, safeAlternative } of FORBIDDEN_PATTERNS) {
// Reset regex state for global patterns
pattern.lastIndex = 0
if (pattern.test(content)) {
findings.push({
id: `AFE-FORBIDDEN-${label.replace(/\s+/g, '_').toUpperCase()}`,
severity: 'error',
category: 'forbidden_formulation' as ValidationFinding['category'],
title: `Verbotene Formulierung: "${label}"`,
description: `Die Formulierung "${label}" impliziert eine nachgewiesene Compliance, die ohne ausreichenden Nachweis (Evidence >= E2, validiert) nicht verwendet werden darf.`,
documentType: 'vvt' as ScopeDocumentType,
suggestion: `Verwende stattdessen: "${safeAlternative}"`,
})
}
}
return findings
}
/**
* Stufe 1: Deterministische Pruefung
*/
@@ -221,10 +291,18 @@ export async function POST(request: NextRequest) {
// LLM unavailable, continue with deterministic results only
}
// ---------------------------------------------------------------
// Stufe 1b: Verbotene Formulierungen (Anti-Fake-Evidence)
// ---------------------------------------------------------------
const forbiddenFindings = checkForbiddenFormulations(
draftContent || '',
validationContext.evidenceContext,
)
// ---------------------------------------------------------------
// Combine results
// ---------------------------------------------------------------
const allFindings = [...deterministicFindings, ...llmFindings]
const allFindings = [...deterministicFindings, ...forbiddenFindings, ...llmFindings]
const errors = allFindings.filter(f => f.severity === 'error')
const warnings = allFindings.filter(f => f.severity === 'warning')
const suggestions = allFindings.filter(f => f.severity === 'suggestion')

View File

@@ -0,0 +1,468 @@
'use client'
import React, { useState, useEffect } from 'react'
// =============================================================================
// TYPES
// =============================================================================
interface Assertion {
id: string
tenant_id: string | null
entity_type: string
entity_id: string
sentence_text: string
sentence_index: number
assertion_type: string // 'assertion' | 'fact' | 'rationale'
evidence_ids: string[]
confidence: number
normative_tier: string | null // 'pflicht' | 'empfehlung' | 'kann'
verified_by: string | null
verified_at: string | null
created_at: string | null
updated_at: string | null
}
interface AssertionSummary {
total_assertions: number
total_facts: number
total_rationale: number
unverified_count: number
}
// =============================================================================
// CONSTANTS
// =============================================================================
const TIER_COLORS: Record<string, string> = {
pflicht: 'bg-red-100 text-red-700',
empfehlung: 'bg-yellow-100 text-yellow-700',
kann: 'bg-blue-100 text-blue-700',
}
const TIER_LABELS: Record<string, string> = {
pflicht: 'Pflicht',
empfehlung: 'Empfehlung',
kann: 'Kann',
}
const TYPE_COLORS: Record<string, string> = {
assertion: 'bg-orange-100 text-orange-700',
fact: 'bg-green-100 text-green-700',
rationale: 'bg-purple-100 text-purple-700',
}
const TYPE_LABELS: Record<string, string> = {
assertion: 'Behauptung',
fact: 'Fakt',
rationale: 'Begruendung',
}
const API_BASE = '/api/sdk/v1/compliance'
type TabKey = 'overview' | 'list' | 'extract'
// =============================================================================
// ASSERTION CARD
// =============================================================================
function AssertionCard({
assertion,
onVerify,
}: {
assertion: Assertion
onVerify: (id: string) => void
}) {
const tierColor = assertion.normative_tier ? TIER_COLORS[assertion.normative_tier] || 'bg-gray-100 text-gray-600' : 'bg-gray-100 text-gray-600'
const tierLabel = assertion.normative_tier ? TIER_LABELS[assertion.normative_tier] || assertion.normative_tier : '—'
const typeColor = TYPE_COLORS[assertion.assertion_type] || 'bg-gray-100 text-gray-600'
const typeLabel = TYPE_LABELS[assertion.assertion_type] || assertion.assertion_type
return (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className={`px-2 py-0.5 text-xs rounded font-medium ${tierColor}`}>
{tierLabel}
</span>
<span className={`px-2 py-0.5 text-xs rounded ${typeColor}`}>
{typeLabel}
</span>
{assertion.entity_type && (
<span className="px-2 py-0.5 text-xs bg-gray-100 text-gray-500 rounded">
{assertion.entity_type}: {assertion.entity_id?.slice(0, 8) || '—'}
</span>
)}
{assertion.confidence > 0 && (
<span className="text-xs text-gray-400">
Konfidenz: {(assertion.confidence * 100).toFixed(0)}%
</span>
)}
</div>
<p className="text-sm text-gray-900 leading-relaxed">
&ldquo;{assertion.sentence_text}&rdquo;
</p>
<div className="mt-2 flex items-center gap-3 text-xs text-gray-400">
{assertion.verified_by && (
<span className="text-green-600">
Verifiziert von {assertion.verified_by} am {assertion.verified_at ? new Date(assertion.verified_at).toLocaleDateString('de-DE') : '—'}
</span>
)}
{assertion.evidence_ids.length > 0 && (
<span>
{assertion.evidence_ids.length} Evidence verknuepft
</span>
)}
</div>
</div>
<div className="flex flex-col gap-1">
{assertion.assertion_type !== 'fact' && (
<button
onClick={() => onVerify(assertion.id)}
className="px-3 py-1.5 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors whitespace-nowrap"
>
Als Fakt pruefen
</button>
)}
</div>
</div>
</div>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
export default function AssertionsPage() {
const [activeTab, setActiveTab] = useState<TabKey>('overview')
const [summary, setSummary] = useState<AssertionSummary | null>(null)
const [assertions, setAssertions] = useState<Assertion[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Filters
const [filterEntityType, setFilterEntityType] = useState('')
const [filterAssertionType, setFilterAssertionType] = useState('')
// Extract tab
const [extractText, setExtractText] = useState('')
const [extractEntityType, setExtractEntityType] = useState('control')
const [extractEntityId, setExtractEntityId] = useState('')
const [extracting, setExtracting] = useState(false)
const [extractedAssertions, setExtractedAssertions] = useState<Assertion[]>([])
// Verify dialog
const [verifyingId, setVerifyingId] = useState<string | null>(null)
const [verifyEmail, setVerifyEmail] = useState('')
useEffect(() => {
loadSummary()
}, [])
useEffect(() => {
if (activeTab === 'list') loadAssertions()
}, [activeTab, filterEntityType, filterAssertionType]) // eslint-disable-line react-hooks/exhaustive-deps
const loadSummary = async () => {
try {
const res = await fetch(`${API_BASE}/assertions/summary`)
if (res.ok) setSummary(await res.json())
} catch { /* silent */ }
finally { setLoading(false) }
}
const loadAssertions = async () => {
setLoading(true)
try {
const params = new URLSearchParams()
if (filterEntityType) params.set('entity_type', filterEntityType)
if (filterAssertionType) params.set('assertion_type', filterAssertionType)
params.set('limit', '200')
const res = await fetch(`${API_BASE}/assertions?${params}`)
if (res.ok) {
const data = await res.json()
setAssertions(data.assertions || [])
}
} catch {
setError('Assertions konnten nicht geladen werden')
} finally {
setLoading(false)
}
}
const handleExtract = async () => {
if (!extractText.trim()) { setError('Bitte Text eingeben'); return }
setExtracting(true)
setError(null)
setExtractedAssertions([])
try {
const res = await fetch(`${API_BASE}/assertions/extract`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: extractText,
entity_type: extractEntityType || 'control',
entity_id: extractEntityId || undefined,
}),
})
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Extraktion fehlgeschlagen' }))
throw new Error(typeof err.detail === 'string' ? err.detail : JSON.stringify(err.detail))
}
const data = await res.json()
setExtractedAssertions(data.assertions || [])
// Refresh summary
loadSummary()
} catch (err) {
setError(err instanceof Error ? err.message : 'Extraktion fehlgeschlagen')
} finally {
setExtracting(false)
}
}
const handleVerify = async (assertionId: string) => {
setVerifyingId(assertionId)
}
const submitVerify = async () => {
if (!verifyingId || !verifyEmail.trim()) return
try {
const res = await fetch(`${API_BASE}/assertions/${verifyingId}/verify?verified_by=${encodeURIComponent(verifyEmail)}`, {
method: 'POST',
})
if (res.ok) {
setVerifyingId(null)
setVerifyEmail('')
loadAssertions()
loadSummary()
} else {
const err = await res.json().catch(() => ({ detail: 'Verifizierung fehlgeschlagen' }))
setError(typeof err.detail === 'string' ? err.detail : 'Verifizierung fehlgeschlagen')
}
} catch {
setError('Netzwerkfehler')
}
}
const tabs: { key: TabKey; label: string }[] = [
{ key: 'overview', label: 'Uebersicht' },
{ key: 'list', label: 'Assertion-Liste' },
{ key: 'extract', label: 'Extraktion' },
]
return (
<div className="space-y-6">
{/* Header */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h1 className="text-2xl font-bold text-slate-900">Assertions</h1>
<p className="text-slate-500 mt-1">
Behauptungen vs. Fakten in Compliance-Texten trennen und verifizieren.
</p>
</div>
{/* Tabs */}
<div className="bg-white rounded-xl shadow-sm border">
<div className="flex border-b">
{tabs.map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-6 py-3 text-sm font-medium transition-colors ${
activeTab === tab.key
? 'text-purple-600 border-b-2 border-purple-600'
: 'text-slate-500 hover:text-slate-700'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
{/* Error */}
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">&times;</button>
</div>
)}
{/* ============================================================ */}
{/* TAB: Uebersicht */}
{/* ============================================================ */}
{activeTab === 'overview' && (
<>
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
) : summary ? (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="text-sm text-gray-500">Gesamt Assertions</div>
<div className="text-3xl font-bold text-gray-900">{summary.total_assertions}</div>
</div>
<div className="bg-white rounded-xl border border-green-200 p-6">
<div className="text-sm text-green-600">Verifizierte Fakten</div>
<div className="text-3xl font-bold text-green-600">{summary.total_facts}</div>
</div>
<div className="bg-white rounded-xl border border-purple-200 p-6">
<div className="text-sm text-purple-600">Begruendungen</div>
<div className="text-3xl font-bold text-purple-600">{summary.total_rationale}</div>
</div>
<div className="bg-white rounded-xl border border-orange-200 p-6">
<div className="text-sm text-orange-600">Unverifizizt</div>
<div className="text-3xl font-bold text-orange-600">{summary.unverified_count}</div>
</div>
</div>
) : (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<p className="text-gray-500">Keine Assertions vorhanden. Nutzen Sie die Extraktion, um Behauptungen aus Texten zu identifizieren.</p>
</div>
)}
</>
)}
{/* ============================================================ */}
{/* TAB: Assertion-Liste */}
{/* ============================================================ */}
{activeTab === 'list' && (
<>
{/* Filters */}
<div className="flex items-center gap-4 flex-wrap">
<div>
<label className="block text-xs text-gray-500 mb-1">Entity-Typ</label>
<select value={filterEntityType} onChange={e => setFilterEntityType(e.target.value)}
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent">
<option value="">Alle</option>
<option value="control">Control</option>
<option value="evidence">Evidence</option>
<option value="requirement">Requirement</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Assertion-Typ</label>
<select value={filterAssertionType} onChange={e => setFilterAssertionType(e.target.value)}
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent">
<option value="">Alle</option>
<option value="assertion">Behauptung</option>
<option value="fact">Fakt</option>
<option value="rationale">Begruendung</option>
</select>
</div>
</div>
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
) : assertions.length === 0 ? (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<p className="text-gray-500">Keine Assertions gefunden.</p>
</div>
) : (
<div className="space-y-3">
<p className="text-sm text-gray-500">{assertions.length} Assertions</p>
{assertions.map(a => (
<AssertionCard key={a.id} assertion={a} onVerify={handleVerify} />
))}
</div>
)}
</>
)}
{/* ============================================================ */}
{/* TAB: Extraktion */}
{/* ============================================================ */}
{activeTab === 'extract' && (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Assertions aus Text extrahieren</h3>
<p className="text-sm text-gray-500 mb-4">
Geben Sie einen Compliance-Text ein. Das System identifiziert automatisch Behauptungen, Fakten und Begruendungen.
</p>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Entity-Typ</label>
<select value={extractEntityType} onChange={e => setExtractEntityType(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent">
<option value="control">Control</option>
<option value="evidence">Evidence</option>
<option value="requirement">Requirement</option>
<option value="policy">Policy</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Entity-ID (optional)</label>
<input type="text" value={extractEntityId} onChange={e => setExtractEntityId(e.target.value)}
placeholder="z.B. GOV-001 oder UUID"
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
</div>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">Text</label>
<textarea
value={extractText}
onChange={e => setExtractText(e.target.value)}
placeholder="Die Organisation muss ein ISMS gemaess ISO 27001 implementieren. Es sollte regelmaessig ein internes Audit durchgefuehrt werden. Optional kann ein externer Auditor hinzugezogen werden."
rows={6}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent resize-none"
/>
</div>
<button
onClick={handleExtract}
disabled={extracting || !extractText.trim()}
className={`px-5 py-2 rounded-lg font-medium transition-colors ${
extracting || !extractText.trim()
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
: 'bg-purple-600 text-white hover:bg-purple-700'
}`}
>
{extracting ? 'Extrahiere...' : 'Extrahieren'}
</button>
{/* Extracted results */}
{extractedAssertions.length > 0 && (
<div className="mt-6">
<h4 className="text-sm font-semibold text-gray-800 mb-3">{extractedAssertions.length} Assertions extrahiert:</h4>
<div className="space-y-3">
{extractedAssertions.map(a => (
<AssertionCard key={a.id} assertion={a} onVerify={handleVerify} />
))}
</div>
</div>
)}
</div>
)}
{/* Verify Dialog */}
{verifyingId && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setVerifyingId(null)}>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md mx-4 p-6" onClick={e => e.stopPropagation()}>
<h2 className="text-lg font-bold text-gray-900 mb-4">Als Fakt verifizieren</h2>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">Verifiziert von (E-Mail)</label>
<input type="email" value={verifyEmail} onChange={e => setVerifyEmail(e.target.value)}
placeholder="auditor@unternehmen.de"
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
</div>
<div className="flex justify-end gap-3">
<button onClick={() => setVerifyingId(null)} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Abbrechen
</button>
<button onClick={submitVerify} disabled={!verifyEmail.trim()}
className="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50">
Verifizieren
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -12,6 +12,7 @@
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { ConfidenceLevelBadge } from '../evidence/components/anti-fake-badges'
// Types
interface DashboardData {
@@ -25,6 +26,15 @@ interface DashboardData {
evidence_by_status: Record<string, number>
total_risks: number
risks_by_level: Record<string, number>
multi_score?: {
requirement_coverage: number
evidence_strength: number
validation_quality: number
evidence_freshness: number
control_effectiveness: number
overall_readiness: number
hard_blocks: string[]
} | null
}
interface Regulation {
@@ -106,7 +116,46 @@ interface ScoreSnapshot {
created_at: string
}
type TabKey = 'overview' | 'roadmap' | 'modules' | 'trend'
interface TraceabilityAssertion {
id: string
sentence_text: string
assertion_type: string
confidence: number
verified: boolean
}
interface TraceabilityEvidence {
id: string
title: string
evidence_type: string
confidence_level: string
status: string
assertions: TraceabilityAssertion[]
}
interface TraceabilityCoverage {
has_evidence: boolean
has_assertions: boolean
all_assertions_verified: boolean
min_confidence_level: string | null
}
interface TraceabilityControl {
id: string
control_id: string
title: string
status: string
domain: string
evidence: TraceabilityEvidence[]
coverage: TraceabilityCoverage
}
interface TraceabilityMatrixData {
controls: TraceabilityControl[]
summary: Record<string, number>
}
type TabKey = 'overview' | 'roadmap' | 'modules' | 'trend' | 'traceability'
const DOMAIN_LABELS: Record<string, string> = {
gov: 'Governance',
@@ -148,6 +197,17 @@ export default function ComplianceHubPage() {
const [error, setError] = useState<string | null>(null)
const [seeding, setSeeding] = useState(false)
const [savingSnapshot, setSavingSnapshot] = useState(false)
const [evidenceDistribution, setEvidenceDistribution] = useState<{
by_confidence: Record<string, number>
four_eyes_pending: number
total: number
} | null>(null)
const [traceabilityMatrix, setTraceabilityMatrix] = useState<TraceabilityMatrixData | null>(null)
const [traceabilityLoading, setTraceabilityLoading] = useState(false)
const [traceabilityFilter, setTraceabilityFilter] = useState<'all' | 'covered' | 'uncovered' | 'fully_verified'>('all')
const [traceabilityDomainFilter, setTraceabilityDomainFilter] = useState<string>('all')
const [expandedControls, setExpandedControls] = useState<Set<string>>(new Set())
const [expandedEvidence, setExpandedEvidence] = useState<Set<string>>(new Set())
useEffect(() => {
loadData()
@@ -157,6 +217,7 @@ export default function ComplianceHubPage() {
if (activeTab === 'roadmap' && !roadmap) loadRoadmap()
if (activeTab === 'modules' && !moduleStatus) loadModuleStatus()
if (activeTab === 'trend' && scoreHistory.length === 0) loadScoreHistory()
if (activeTab === 'traceability' && !traceabilityMatrix) loadTraceabilityMatrix()
}, [activeTab]) // eslint-disable-line react-hooks/exhaustive-deps
const loadData = async () => {
@@ -182,6 +243,12 @@ export default function ComplianceHubPage() {
const data = await actionsRes.json()
setNextActions(data.actions || [])
}
// Evidence distribution (Anti-Fake-Evidence Phase 3)
try {
const evidenceDistRes = await fetch('/api/sdk/v1/compliance/dashboard/evidence-distribution')
if (evidenceDistRes.ok) setEvidenceDistribution(await evidenceDistRes.json())
} catch { /* silent */ }
} catch (err) {
console.error('Failed to load compliance data:', err)
setError('Verbindung zum Backend fehlgeschlagen')
@@ -214,6 +281,31 @@ export default function ComplianceHubPage() {
} catch { /* silent */ }
}
const loadTraceabilityMatrix = async () => {
setTraceabilityLoading(true)
try {
const res = await fetch('/api/sdk/v1/compliance/dashboard/traceability-matrix')
if (res.ok) setTraceabilityMatrix(await res.json())
} catch { /* silent */ }
finally { setTraceabilityLoading(false) }
}
const toggleControlExpanded = (id: string) => {
setExpandedControls(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
}
const toggleEvidenceExpanded = (id: string) => {
setExpandedEvidence(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
}
const saveSnapshot = async () => {
setSavingSnapshot(true)
try {
@@ -259,6 +351,7 @@ export default function ComplianceHubPage() {
{ key: 'roadmap', label: 'Roadmap' },
{ key: 'modules', label: 'Module' },
{ key: 'trend', label: 'Trend' },
{ key: 'traceability', label: 'Traceability' },
]
return (
@@ -411,6 +504,115 @@ export default function ComplianceHubPage() {
))}
</div>
{/* Anti-Fake-Evidence Section (Phase 3) */}
{dashboard && (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Anti-Fake-Evidence Status</h3>
{/* Confidence Distribution Bar */}
{evidenceDistribution && evidenceDistribution.total > 0 && (
<div className="mb-6">
<p className="text-sm text-slate-500 mb-2">Confidence-Verteilung ({evidenceDistribution.total} Nachweise)</p>
<div className="flex h-6 rounded-full overflow-hidden">
{(['E0', 'E1', 'E2', 'E3', 'E4'] as const).map(level => {
const count = evidenceDistribution.by_confidence[level] || 0
const pct = (count / evidenceDistribution.total) * 100
if (pct === 0) return null
const colors: Record<string, string> = {
E0: 'bg-red-400', E1: 'bg-yellow-400', E2: 'bg-blue-400', E3: 'bg-green-400', E4: 'bg-emerald-400'
}
return (
<div key={level} className={`${colors[level]} flex items-center justify-center text-xs text-white font-medium`}
style={{ width: `${pct}%` }} title={`${level}: ${count}`}>
{pct >= 10 ? `${level} (${count})` : ''}
</div>
)
})}
</div>
<div className="flex items-center gap-4 mt-2 text-xs text-slate-500">
{(['E0', 'E1', 'E2', 'E3', 'E4'] as const).map(level => {
const count = evidenceDistribution.by_confidence[level] || 0
const dotColors: Record<string, string> = {
E0: 'bg-red-400', E1: 'bg-yellow-400', E2: 'bg-blue-400', E3: 'bg-green-400', E4: 'bg-emerald-400'
}
return (
<span key={level} className="flex items-center gap-1">
<span className={`w-2 h-2 rounded-full ${dotColors[level]}`} />
{level}: {count}
</span>
)
})}
</div>
</div>
)}
{/* Multi-Score Dimensions */}
{dashboard.multi_score && (
<div className="mb-6">
<p className="text-sm text-slate-500 mb-2">Multi-dimensionaler Score</p>
<div className="space-y-2">
{([
{ key: 'requirement_coverage', label: 'Anforderungsabdeckung', color: 'bg-blue-500' },
{ key: 'evidence_strength', label: 'Evidence-Staerke', color: 'bg-green-500' },
{ key: 'validation_quality', label: 'Validierungsqualitaet', color: 'bg-purple-500' },
{ key: 'evidence_freshness', label: 'Aktualitaet', color: 'bg-yellow-500' },
{ key: 'control_effectiveness', label: 'Control-Wirksamkeit', color: 'bg-indigo-500' },
] as const).map(dim => {
const value = (dashboard.multi_score as Record<string, number>)[dim.key] || 0
return (
<div key={dim.key} className="flex items-center gap-3">
<span className="text-xs text-slate-600 w-44 truncate">{dim.label}</span>
<div className="flex-1 h-2 bg-slate-200 rounded-full overflow-hidden">
<div className={`h-full ${dim.color} rounded-full transition-all`} style={{ width: `${value}%` }} />
</div>
<span className="text-xs text-slate-600 w-12 text-right">{typeof value === 'number' ? value.toFixed(0) : value}%</span>
</div>
)
})}
<div className="flex items-center gap-3 pt-2 border-t border-slate-100">
<span className="text-xs font-semibold text-slate-700 w-44">Audit-Readiness</span>
<div className="flex-1 h-3 bg-slate-200 rounded-full overflow-hidden">
<div className={`h-full rounded-full transition-all ${
(dashboard.multi_score.overall_readiness || 0) >= 80 ? 'bg-green-500' :
(dashboard.multi_score.overall_readiness || 0) >= 60 ? 'bg-yellow-500' : 'bg-red-500'
}`} style={{ width: `${dashboard.multi_score.overall_readiness || 0}%` }} />
</div>
<span className="text-xs font-semibold text-slate-700 w-12 text-right">
{typeof dashboard.multi_score.overall_readiness === 'number' ? dashboard.multi_score.overall_readiness.toFixed(0) : 0}%
</span>
</div>
</div>
</div>
)}
{/* Bottom row: Four-Eyes + Hard Blocks */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="text-center p-3 rounded-lg bg-yellow-50">
<div className="text-2xl font-bold text-yellow-700">{evidenceDistribution?.four_eyes_pending || 0}</div>
<div className="text-xs text-yellow-600 mt-1">Four-Eyes Reviews ausstehend</div>
</div>
{dashboard.multi_score?.hard_blocks && dashboard.multi_score.hard_blocks.length > 0 ? (
<div className="p-3 rounded-lg bg-red-50">
<div className="text-xs font-medium text-red-700 mb-1">Hard Blocks ({dashboard.multi_score.hard_blocks.length})</div>
<ul className="space-y-1">
{dashboard.multi_score.hard_blocks.slice(0, 3).map((block: string, i: number) => (
<li key={i} className="text-xs text-red-600 flex items-start gap-1">
<span className="text-red-400 mt-0.5">&#8226;</span>
<span>{block}</span>
</li>
))}
</ul>
</div>
) : (
<div className="text-center p-3 rounded-lg bg-green-50">
<div className="text-2xl font-bold text-green-700">0</div>
<div className="text-xs text-green-600 mt-1">Keine Hard Blocks</div>
</div>
)}
</div>
</div>
)}
{/* Next Actions + Findings */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Next Actions */}
@@ -805,6 +1007,232 @@ export default function ComplianceHubPage() {
)}
</div>
)}
{/* Traceability Tab */}
{activeTab === 'traceability' && (
<div className="p-6 space-y-6">
{traceabilityLoading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
<span className="ml-3 text-slate-500">Traceability Matrix wird geladen...</span>
</div>
) : !traceabilityMatrix ? (
<div className="text-center py-12 text-slate-500">
Keine Daten verfuegbar. Stellen Sie sicher, dass Controls und Evidence vorhanden sind.
</div>
) : (() => {
const summary = traceabilityMatrix.summary
const totalControls = summary.total_controls || 0
const covered = summary.covered || 0
const fullyVerified = summary.fully_verified || 0
const uncovered = summary.uncovered || 0
const filteredControls = (traceabilityMatrix.controls || []).filter(ctrl => {
if (traceabilityFilter === 'covered' && !ctrl.coverage.has_evidence) return false
if (traceabilityFilter === 'uncovered' && ctrl.coverage.has_evidence) return false
if (traceabilityFilter === 'fully_verified' && !ctrl.coverage.all_assertions_verified) return false
if (traceabilityDomainFilter !== 'all' && ctrl.domain !== traceabilityDomainFilter) return false
return true
})
const domains = [...new Set(traceabilityMatrix.controls.map(c => c.domain))].sort()
return (
<>
{/* Summary Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
<div className="text-2xl font-bold text-purple-700">{totalControls}</div>
<div className="text-sm text-purple-600">Total Controls</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="text-2xl font-bold text-blue-700">{covered}</div>
<div className="text-sm text-blue-600">Abgedeckt</div>
</div>
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="text-2xl font-bold text-green-700">{fullyVerified}</div>
<div className="text-sm text-green-600">Vollst. verifiziert</div>
</div>
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="text-2xl font-bold text-red-700">{uncovered}</div>
<div className="text-sm text-red-600">Unabgedeckt</div>
</div>
</div>
{/* Filter Bar */}
<div className="flex flex-wrap gap-4 items-center">
<div className="flex gap-1">
{([
{ key: 'all', label: 'Alle' },
{ key: 'covered', label: 'Abgedeckt' },
{ key: 'uncovered', label: 'Nicht abgedeckt' },
{ key: 'fully_verified', label: 'Vollst. verifiziert' },
] as const).map(f => (
<button
key={f.key}
onClick={() => setTraceabilityFilter(f.key)}
className={`px-3 py-1 text-xs rounded-full transition-colors ${
traceabilityFilter === f.key
? 'bg-purple-600 text-white'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{f.label}
</button>
))}
</div>
<div className="h-4 w-px bg-slate-300" />
<div className="flex gap-1 flex-wrap">
<button
onClick={() => setTraceabilityDomainFilter('all')}
className={`px-3 py-1 text-xs rounded-full transition-colors ${
traceabilityDomainFilter === 'all'
? 'bg-purple-600 text-white'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
Alle Domains
</button>
{domains.map(d => (
<button
key={d}
onClick={() => setTraceabilityDomainFilter(d)}
className={`px-3 py-1 text-xs rounded-full transition-colors ${
traceabilityDomainFilter === d
? 'bg-purple-600 text-white'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{DOMAIN_LABELS[d] || d}
</button>
))}
</div>
</div>
{/* Controls List */}
<div className="space-y-2">
{filteredControls.length === 0 ? (
<div className="text-center py-8 text-slate-400">
Keine Controls fuer diesen Filter gefunden.
</div>
) : filteredControls.map(ctrl => {
const isExpanded = expandedControls.has(ctrl.id)
const coverageIcon = ctrl.coverage.all_assertions_verified
? { symbol: '\u2713', color: 'text-green-600 bg-green-50' }
: ctrl.coverage.has_evidence
? { symbol: '\u25D0', color: 'text-yellow-600 bg-yellow-50' }
: { symbol: '\u2717', color: 'text-red-600 bg-red-50' }
return (
<div key={ctrl.id} className="border rounded-lg overflow-hidden">
{/* Control Row */}
<button
onClick={() => toggleControlExpanded(ctrl.id)}
className="w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-slate-50 transition-colors"
>
<span className="text-slate-400 text-xs">{isExpanded ? '\u25BC' : '\u25B6'}</span>
<span className={`w-7 h-7 flex items-center justify-center rounded-full text-sm font-medium ${coverageIcon.color}`}>
{coverageIcon.symbol}
</span>
<code className="text-xs bg-slate-100 px-2 py-0.5 rounded text-slate-600 font-mono">{ctrl.control_id}</code>
<span className="text-sm text-slate-800 flex-1 truncate">{ctrl.title}</span>
<span className="text-xs bg-slate-100 text-slate-500 px-2 py-0.5 rounded">{DOMAIN_LABELS[ctrl.domain] || ctrl.domain}</span>
<span className={`text-xs px-2 py-0.5 rounded ${
ctrl.status === 'implemented' ? 'bg-green-100 text-green-700'
: ctrl.status === 'in_progress' ? 'bg-blue-100 text-blue-700'
: 'bg-slate-100 text-slate-600'
}`}>
{ctrl.status}
</span>
<ConfidenceLevelBadge level={ctrl.coverage.min_confidence_level} />
<span className="text-xs text-slate-400 min-w-[3rem] text-right">
{ctrl.evidence.length} Ev.
</span>
</button>
{/* Expanded: Evidence list */}
{isExpanded && (
<div className="border-t bg-slate-50">
{ctrl.evidence.length === 0 ? (
<div className="px-8 py-3 text-xs text-slate-400 italic">
Kein Evidence verknuepft.
</div>
) : ctrl.evidence.map(ev => {
const evExpanded = expandedEvidence.has(ev.id)
return (
<div key={ev.id} className="border-b last:border-b-0">
<button
onClick={() => toggleEvidenceExpanded(ev.id)}
className="w-full flex items-center gap-3 px-8 py-2 text-left hover:bg-slate-100 transition-colors"
>
<span className="text-slate-400 text-xs">{evExpanded ? '\u25BC' : '\u25B6'}</span>
<span className="text-sm text-slate-700 flex-1 truncate">{ev.title}</span>
<span className="text-xs bg-white border px-2 py-0.5 rounded text-slate-500">{ev.evidence_type}</span>
<ConfidenceLevelBadge level={ev.confidence_level} />
<span className={`text-xs px-2 py-0.5 rounded ${
ev.status === 'valid' ? 'bg-green-100 text-green-700'
: ev.status === 'expired' ? 'bg-red-100 text-red-700'
: 'bg-slate-100 text-slate-600'
}`}>
{ev.status}
</span>
<span className="text-xs text-slate-400 min-w-[3rem] text-right">
{ev.assertions.length} Ass.
</span>
</button>
{/* Expanded: Assertions list */}
{evExpanded && ev.assertions.length > 0 && (
<div className="bg-white border-t">
<table className="w-full text-xs">
<thead className="bg-slate-50">
<tr>
<th className="px-12 py-1.5 text-left text-slate-500 font-medium">Aussage</th>
<th className="px-3 py-1.5 text-center text-slate-500 font-medium w-20">Typ</th>
<th className="px-3 py-1.5 text-center text-slate-500 font-medium w-24">Konfidenz</th>
<th className="px-3 py-1.5 text-center text-slate-500 font-medium w-16">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{ev.assertions.map(a => (
<tr key={a.id} className="hover:bg-slate-50">
<td className="px-12 py-1.5 text-slate-700">{a.sentence_text}</td>
<td className="px-3 py-1.5 text-center text-slate-500">{a.assertion_type}</td>
<td className="px-3 py-1.5 text-center">
<span className={`font-medium ${
a.confidence >= 0.8 ? 'text-green-600'
: a.confidence >= 0.5 ? 'text-yellow-600'
: 'text-red-600'
}`}>
{(a.confidence * 100).toFixed(0)}%
</span>
</td>
<td className="px-3 py-1.5 text-center">
{a.verified
? <span className="text-green-600 font-medium">{'\u2713'}</span>
: <span className="text-slate-400">{'\u2717'}</span>
}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
</>
)
})()}
</div>
)}
</>
)}
</div>

View File

@@ -196,7 +196,15 @@ function ControlCard({
{/* Linked Evidence */}
{control.linkedEvidence.length > 0 && (
<div className="mt-3 pt-3 border-t border-gray-100">
<span className="text-xs text-gray-500 mb-1 block">Nachweise:</span>
<span className="text-xs text-gray-500 mb-1 block">
Nachweise: {control.linkedEvidence.length}
{(() => {
const e2plus = control.linkedEvidence.filter((ev: { confidenceLevel?: string }) =>
ev.confidenceLevel && ['E2', 'E3', 'E4'].includes(ev.confidenceLevel)
).length
return e2plus > 0 ? ` (${e2plus} E2+)` : ''
})()}
</span>
<div className="flex items-center gap-1 flex-wrap">
{control.linkedEvidence.map(ev => (
<span key={ev.id} className={`px-2 py-0.5 text-xs rounded ${
@@ -205,6 +213,9 @@ function ControlCard({
'bg-yellow-50 text-yellow-700'
}`}>
{ev.title}
{(ev as { confidenceLevel?: string }).confidenceLevel && (
<span className="ml-1 opacity-70">({(ev as { confidenceLevel?: string }).confidenceLevel})</span>
)}
</span>
))}
</div>
@@ -359,6 +370,49 @@ interface RAGControlSuggestion {
// MAIN PAGE
// =============================================================================
function TransitionErrorBanner({
controlId,
violations,
onDismiss,
}: {
controlId: string
violations: string[]
onDismiss: () => void
}) {
return (
<div className="p-4 bg-orange-50 border border-orange-200 rounded-lg">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-orange-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<h4 className="font-medium text-orange-800">
Status-Transition blockiert ({controlId})
</h4>
<ul className="mt-2 space-y-1">
{violations.map((v, i) => (
<li key={i} className="text-sm text-orange-700 flex items-start gap-2">
<span className="text-orange-400 mt-0.5"></span>
<span>{v}</span>
</li>
))}
</ul>
<a href="/sdk/evidence" className="mt-2 inline-block text-sm text-purple-600 hover:text-purple-700 font-medium">
Evidence hinzufuegen
</a>
</div>
</div>
<button onClick={onDismiss} className="text-orange-400 hover:text-orange-600 ml-4">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
)
}
export default function ControlsPage() {
const { state, dispatch } = useSDK()
const router = useRouter()
@@ -373,6 +427,9 @@ export default function ControlsPage() {
const [showRagPanel, setShowRagPanel] = useState(false)
const [selectedRequirementId, setSelectedRequirementId] = useState<string>('')
// Transition error from Anti-Fake-Evidence state machine (409 Conflict)
const [transitionError, setTransitionError] = useState<{ controlId: string; violations: string[] } | null>(null)
// Track effectiveness locally as it's not in the SDK state type
const [effectivenessMap, setEffectivenessMap] = useState<Record<string, number>>({})
// Track linked evidence per control
@@ -385,7 +442,7 @@ export default function ControlsPage() {
const data = await res.json()
const allEvidence = data.evidence || data
if (Array.isArray(allEvidence)) {
const map: Record<string, { id: string; title: string; status: string }[]> = {}
const map: Record<string, { id: string; title: string; status: string; confidenceLevel?: string }[]> = {}
for (const ev of allEvidence) {
const ctrlId = ev.control_id || ''
if (!map[ctrlId]) map[ctrlId] = []
@@ -393,6 +450,7 @@ export default function ControlsPage() {
id: ev.id,
title: ev.title || ev.name || 'Nachweis',
status: ev.status || 'pending',
confidenceLevel: ev.confidence_level || undefined,
})
}
setEvidenceMap(map)
@@ -483,20 +541,56 @@ export default function ControlsPage() {
: 0
const partialCount = displayControls.filter(c => c.displayStatus === 'partial').length
const handleStatusChange = async (controlId: string, status: ImplementationStatus) => {
const handleStatusChange = async (controlId: string, newStatus: ImplementationStatus) => {
// Remember old status for rollback
const oldControl = state.controls.find(c => c.id === controlId)
const oldStatus = oldControl?.implementationStatus
// Optimistic update
dispatch({
type: 'UPDATE_CONTROL',
payload: { id: controlId, data: { implementationStatus: status } },
payload: { id: controlId, data: { implementationStatus: newStatus } },
})
try {
await fetch(`/api/sdk/v1/compliance/controls/${controlId}`, {
const res = await fetch(`/api/sdk/v1/compliance/controls/${controlId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ implementation_status: status }),
body: JSON.stringify({ implementation_status: newStatus }),
})
if (!res.ok) {
// Rollback optimistic update
if (oldStatus) {
dispatch({
type: 'UPDATE_CONTROL',
payload: { id: controlId, data: { implementationStatus: oldStatus } },
})
}
const err = await res.json().catch(() => ({ detail: 'Status-Aenderung fehlgeschlagen' }))
if (res.status === 409 && err.detail?.violations) {
setTransitionError({ controlId, violations: err.detail.violations })
} else {
const msg = typeof err.detail === 'string' ? err.detail : err.detail?.error || 'Status-Aenderung fehlgeschlagen'
setError(msg)
}
} else {
// Clear any previous transition error for this control
if (transitionError?.controlId === controlId) {
setTransitionError(null)
}
}
} catch {
// Silently fail — SDK state is already updated
// Network error — rollback
if (oldStatus) {
dispatch({
type: 'UPDATE_CONTROL',
payload: { id: controlId, data: { implementationStatus: oldStatus } },
})
}
setError('Netzwerkfehler bei Status-Aenderung')
}
}
@@ -745,6 +839,15 @@ export default function ControlsPage() {
</div>
)}
{/* Transition Error Banner (Anti-Fake-Evidence 409 violations) */}
{transitionError && (
<TransitionErrorBanner
controlId={transitionError.controlId}
violations={transitionError.violations}
onDismiss={() => setTransitionError(null)}
/>
)}
{/* Requirements Alert */}
{state.requirements.length === 0 && !loading && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">

View File

@@ -0,0 +1,111 @@
"use client"
import React from "react"
const badgeBase = "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
// ---------------------------------------------------------------------------
// Confidence Level Badge (E0E4)
// ---------------------------------------------------------------------------
const confidenceColors: Record<string, string> = {
E0: "bg-red-100 text-red-800",
E1: "bg-yellow-100 text-yellow-800",
E2: "bg-blue-100 text-blue-800",
E3: "bg-green-100 text-green-800",
E4: "bg-emerald-100 text-emerald-800",
}
const confidenceLabels: Record<string, string> = {
E0: "E0 — Generiert",
E1: "E1 — Manuell",
E2: "E2 — Intern validiert",
E3: "E3 — System-beobachtet",
E4: "E4 — Extern auditiert",
}
export function ConfidenceLevelBadge({ level }: { level?: string | null }) {
if (!level) return null
const color = confidenceColors[level] || "bg-gray-100 text-gray-800"
const label = confidenceLabels[level] || level
return <span className={`${badgeBase} ${color}`}>{label}</span>
}
// ---------------------------------------------------------------------------
// Truth Status Badge
// ---------------------------------------------------------------------------
const truthColors: Record<string, string> = {
generated: "bg-violet-100 text-violet-800",
uploaded: "bg-gray-100 text-gray-800",
observed: "bg-blue-100 text-blue-800",
validated: "bg-green-100 text-green-800",
rejected: "bg-red-100 text-red-800",
audited: "bg-emerald-100 text-emerald-800",
}
const truthLabels: Record<string, string> = {
generated: "Generiert",
uploaded: "Hochgeladen",
observed: "Beobachtet",
validated: "Validiert",
rejected: "Abgelehnt",
audited: "Auditiert",
}
export function TruthStatusBadge({ status }: { status?: string | null }) {
if (!status) return null
const color = truthColors[status] || "bg-gray-100 text-gray-800"
const label = truthLabels[status] || status
return <span className={`${badgeBase} ${color}`}>{label}</span>
}
// ---------------------------------------------------------------------------
// Generation Mode Badge (sparkles icon)
// ---------------------------------------------------------------------------
export function GenerationModeBadge({ mode }: { mode?: string | null }) {
if (!mode) return null
return (
<span className={`${badgeBase} bg-violet-100 text-violet-800`}>
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0v-1H3a1 1 0 010-2h1v-1a1 1 0 011-1zm7-10a1 1 0 01.967.744L14.146 7.2 17.5 7.512a1 1 0 010 1.976l-3.354.313-1.18 4.456a1 1 0 01-1.932 0l-1.18-4.456-3.354-.313a1 1 0 010-1.976l3.354-.313 1.18-4.456A1 1 0 0112 2z" />
</svg>
KI-generiert
</span>
)
}
// ---------------------------------------------------------------------------
// Approval Status Badge (Four-Eyes)
// ---------------------------------------------------------------------------
const approvalColors: Record<string, string> = {
none: "bg-gray-100 text-gray-600",
pending_first: "bg-yellow-100 text-yellow-800",
first_approved: "bg-blue-100 text-blue-800",
approved: "bg-green-100 text-green-800",
rejected: "bg-red-100 text-red-800",
}
const approvalLabels: Record<string, string> = {
none: "Kein Review",
pending_first: "Warte auf 1. Review",
first_approved: "1. Review OK",
approved: "Genehmigt (4-Augen)",
rejected: "Abgelehnt",
}
export function ApprovalStatusBadge({
status,
requiresFourEyes,
}: {
status?: string | null
requiresFourEyes?: boolean | null
}) {
if (!requiresFourEyes) return null
const s = status || "none"
const color = approvalColors[s] || "bg-gray-100 text-gray-600"
const label = approvalLabels[s] || s
return <span className={`${badgeBase} ${color}`}>{label}</span>
}

View File

@@ -3,6 +3,12 @@
import React, { useState, useEffect, useRef } from 'react'
import { useSDK, Evidence as SDKEvidence, EvidenceType } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
import {
ConfidenceLevelBadge,
TruthStatusBadge,
GenerationModeBadge,
ApprovalStatusBadge,
} from './components/anti-fake-badges'
// =============================================================================
// TYPES
@@ -28,6 +34,12 @@ interface DisplayEvidence {
status: DisplayStatus
fileSize: string
fileUrl: string | null
// Anti-Fake-Evidence Phase 2
confidenceLevel: string | null
truthStatus: string | null
generationMode: string | null
approvalStatus: string | null
requiresFourEyes: boolean
}
// =============================================================================
@@ -162,7 +174,327 @@ const evidenceTemplates: EvidenceTemplate[] = [
// COMPONENTS
// =============================================================================
function EvidenceCard({ evidence, onDelete, onView, onDownload }: { evidence: DisplayEvidence; onDelete: () => void; onView: () => void; onDownload: () => void }) {
// =============================================================================
// CONFIDENCE FILTER COLORS (matching anti-fake-badges)
// =============================================================================
const confidenceFilterColors: Record<string, string> = {
E0: 'bg-red-200 text-red-800',
E1: 'bg-yellow-200 text-yellow-800',
E2: 'bg-blue-200 text-blue-800',
E3: 'bg-green-200 text-green-800',
E4: 'bg-emerald-200 text-emerald-800',
}
// =============================================================================
// REVIEW MODAL
// =============================================================================
function ReviewModal({ evidence, onClose, onSuccess }: { evidence: DisplayEvidence; onClose: () => void; onSuccess: () => void }) {
const [confidenceLevel, setConfidenceLevel] = useState(evidence.confidenceLevel || 'E1')
const [truthStatus, setTruthStatus] = useState(evidence.truthStatus || 'uploaded')
const [reviewedBy, setReviewedBy] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async () => {
if (!reviewedBy.trim()) { setError('Bitte E-Mail-Adresse angeben'); return }
setSubmitting(true)
setError(null)
try {
const res = await fetch(`/api/sdk/v1/compliance/evidence/${evidence.id}/review`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ confidence_level: confidenceLevel, truth_status: truthStatus, reviewed_by: reviewedBy }),
})
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Review fehlgeschlagen' }))
throw new Error(typeof err.detail === 'string' ? err.detail : JSON.stringify(err.detail))
}
onSuccess()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setSubmitting(false)
}
}
const confidenceLevels = [
{ value: 'E0', label: 'E0 — Generiert' },
{ value: 'E1', label: 'E1 — Manuell' },
{ value: 'E2', label: 'E2 — Intern validiert' },
{ value: 'E3', label: 'E3 — System-beobachtet' },
{ value: 'E4', label: 'E4 — Extern auditiert' },
]
const truthStatuses = [
{ value: 'generated', label: 'Generiert' },
{ value: 'uploaded', label: 'Hochgeladen' },
{ value: 'observed', label: 'Beobachtet' },
{ value: 'validated', label: 'Validiert' },
{ value: 'audited', label: 'Auditiert' },
]
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 p-6" onClick={e => e.stopPropagation()}>
<h2 className="text-xl font-bold text-gray-900 mb-4">Evidence Reviewen</h2>
<p className="text-sm text-gray-500 mb-4">{evidence.name}</p>
{/* Current values */}
<div className="mb-4 p-3 bg-gray-50 rounded-lg text-sm space-y-1">
<div className="flex justify-between">
<span className="text-gray-500">Aktuelles Confidence-Level:</span>
<span className="font-medium">{evidence.confidenceLevel || '—'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Aktueller Truth-Status:</span>
<span className="font-medium">{evidence.truthStatus || '—'}</span>
</div>
</div>
{/* New confidence level */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">Neues Confidence-Level</label>
<select value={confidenceLevel} onChange={e => setConfidenceLevel(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent">
{confidenceLevels.map(l => <option key={l.value} value={l.value}>{l.label}</option>)}
</select>
</div>
{/* New truth status */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">Neuer Truth-Status</label>
<select value={truthStatus} onChange={e => setTruthStatus(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent">
{truthStatuses.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
</div>
{/* Reviewed by */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">Reviewer (E-Mail)</label>
<input type="email" value={reviewedBy} onChange={e => setReviewedBy(e.target.value)}
placeholder="reviewer@unternehmen.de"
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
</div>
{/* Four-eyes warning */}
{evidence.requiresFourEyes && evidence.approvalStatus !== 'approved' && (
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="flex items-start gap-2">
<svg className="w-5 h-5 text-yellow-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div className="text-sm text-yellow-800">
<p className="font-medium">4-Augen-Prinzip aktiv</p>
<p>Dieser Nachweis erfordert eine zusaetzliche Freigabe durch einen zweiten Reviewer.</p>
</div>
</div>
</div>
)}
{/* Error */}
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</div>
)}
{/* Actions */}
<div className="flex justify-end gap-3">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Abbrechen
</button>
<button onClick={handleSubmit} disabled={submitting}
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50">
{submitting ? 'Wird gespeichert...' : 'Review abschliessen'}
</button>
</div>
</div>
</div>
)
}
// =============================================================================
// REJECT MODAL
// =============================================================================
function RejectModal({ evidence, onClose, onSuccess }: { evidence: DisplayEvidence; onClose: () => void; onSuccess: () => void }) {
const [reviewedBy, setReviewedBy] = useState('')
const [rejectionReason, setRejectionReason] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async () => {
if (!reviewedBy.trim()) { setError('Bitte E-Mail-Adresse angeben'); return }
if (!rejectionReason.trim()) { setError('Bitte Ablehnungsgrund angeben'); return }
setSubmitting(true)
setError(null)
try {
const res = await fetch(`/api/sdk/v1/compliance/evidence/${evidence.id}/reject`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reviewed_by: reviewedBy, rejection_reason: rejectionReason }),
})
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Ablehnung fehlgeschlagen' }))
throw new Error(typeof err.detail === 'string' ? err.detail : JSON.stringify(err.detail))
}
onSuccess()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setSubmitting(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 p-6" onClick={e => e.stopPropagation()}>
<h2 className="text-xl font-bold text-gray-900 mb-4">Evidence Ablehnen</h2>
<p className="text-sm text-gray-500 mb-4">{evidence.name}</p>
{/* Reviewed by */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">Reviewer (E-Mail)</label>
<input type="email" value={reviewedBy} onChange={e => setReviewedBy(e.target.value)}
placeholder="reviewer@unternehmen.de"
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-red-500 focus:border-transparent" />
</div>
{/* Rejection reason */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">Ablehnungsgrund</label>
<textarea value={rejectionReason} onChange={e => setRejectionReason(e.target.value)}
placeholder="Bitte beschreiben Sie den Grund fuer die Ablehnung..."
rows={4}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-red-500 focus:border-transparent resize-none" />
</div>
{/* Error */}
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</div>
)}
{/* Actions */}
<div className="flex justify-end gap-3">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Abbrechen
</button>
<button onClick={handleSubmit} disabled={submitting}
className="px-4 py-2 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50">
{submitting ? 'Wird abgelehnt...' : 'Ablehnen'}
</button>
</div>
</div>
</div>
)
}
// =============================================================================
// AUDIT TRAIL PANEL
// =============================================================================
function AuditTrailPanel({ evidenceId, onClose }: { evidenceId: string; onClose: () => void }) {
const [entries, setEntries] = useState<{ id: string; action: string; actor: string; timestamp: string; details: Record<string, unknown> | null }[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch(`/api/sdk/v1/compliance/audit-trail?entity_type=evidence&entity_id=${evidenceId}`)
.then(res => res.json())
.then(data => {
const mapped = (data.entries || []).map((e: Record<string, unknown>) => ({
id: e.id as string,
action: e.action as string,
actor: (e.performed_by || 'System') as string,
timestamp: (e.performed_at || '') as string,
details: {
...(e.field_changed ? { field: e.field_changed } : {}),
...(e.old_value ? { old: e.old_value } : {}),
...(e.new_value ? { new: e.new_value } : {}),
...(e.change_summary ? { summary: e.change_summary } : {}),
} as Record<string, unknown>,
}))
setEntries(mapped)
})
.catch(() => {})
.finally(() => setLoading(false))
}, [evidenceId])
const actionLabels: Record<string, { label: string; color: string }> = {
created: { label: 'Erstellt', color: 'bg-blue-100 text-blue-700' },
uploaded: { label: 'Hochgeladen', color: 'bg-purple-100 text-purple-700' },
reviewed: { label: 'Reviewed', color: 'bg-green-100 text-green-700' },
rejected: { label: 'Abgelehnt', color: 'bg-red-100 text-red-700' },
updated: { label: 'Aktualisiert', color: 'bg-yellow-100 text-yellow-700' },
deleted: { label: 'Geloescht', color: 'bg-gray-100 text-gray-700' },
approved: { label: 'Genehmigt', color: 'bg-emerald-100 text-emerald-700' },
four_eyes_first: { label: '1. Review (4-Augen)', color: 'bg-blue-100 text-blue-700' },
four_eyes_final: { label: 'Finale Freigabe (4-Augen)', color: 'bg-emerald-100 text-emerald-700' },
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-2xl mx-4 p-6 max-h-[80vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold text-gray-900">Audit-Trail</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl">&times;</button>
</div>
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
) : entries.length === 0 ? (
<div className="py-12 text-center text-gray-500">
<p>Keine Audit-Trail-Eintraege vorhanden.</p>
</div>
) : (
<div className="relative">
{/* Timeline line */}
<div className="absolute left-4 top-0 bottom-0 w-0.5 bg-gray-200" />
<div className="space-y-4">
{entries.map((entry, idx) => {
const meta = actionLabels[entry.action] || { label: entry.action, color: 'bg-gray-100 text-gray-700' }
return (
<div key={entry.id || idx} className="relative flex items-start gap-4 pl-10">
{/* Timeline dot */}
<div className="absolute left-2.5 top-1.5 w-3 h-3 rounded-full bg-white border-2 border-purple-400" />
<div className="flex-1 bg-gray-50 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<span className={`px-2 py-0.5 text-xs rounded ${meta.color}`}>{meta.label}</span>
<span className="text-xs text-gray-400">
{entry.timestamp ? new Date(entry.timestamp).toLocaleString('de-DE') : '—'}
</span>
</div>
<div className="text-sm text-gray-600">
<span className="font-medium">{entry.actor || 'System'}</span>
</div>
{entry.details && Object.keys(entry.details).length > 0 && (
<div className="mt-2 text-xs text-gray-500 font-mono bg-white rounded p-2 border">
{Object.entries(entry.details).map(([k, v]) => (
<div key={k}><span className="text-gray-400">{k}:</span> {String(v)}</div>
))}
</div>
)}
</div>
</div>
)
})}
</div>
</div>
)}
</div>
</div>
)
}
// =============================================================================
// EVIDENCE CARD
// =============================================================================
function EvidenceCard({ evidence, onDelete, onView, onDownload, onReview, onReject, onShowHistory }: { evidence: DisplayEvidence; onDelete: () => void; onView: () => void; onDownload: () => void; onReview: () => void; onReject: () => void; onShowHistory: () => void }) {
const typeIcons = {
document: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -221,9 +553,15 @@ function EvidenceCard({ evidence, onDelete, onView, onDownload }: { evidence: Di
<div className="flex-1">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">{evidence.name}</h3>
<div className="flex items-center gap-1.5 flex-wrap">
<span className={`px-3 py-1 text-xs rounded-full ${statusColors[evidence.status]}`}>
{statusLabels[evidence.status]}
</span>
<ConfidenceLevelBadge level={evidence.confidenceLevel} />
<TruthStatusBadge status={evidence.truthStatus} />
<GenerationModeBadge mode={evidence.generationMode} />
<ApprovalStatusBadge status={evidence.approvalStatus} requiresFourEyes={evidence.requiresFourEyes} />
</div>
</div>
<p className="text-sm text-gray-500 mt-1">{evidence.description}</p>
@@ -275,6 +613,31 @@ function EvidenceCard({ evidence, onDelete, onView, onDownload }: { evidence: Di
>
Loeschen
</button>
{/* Review button — visible when review is possible */}
{(evidence.approvalStatus === 'none' || evidence.approvalStatus === 'pending_first' || evidence.approvalStatus === 'first_approved' || !evidence.approvalStatus) && evidence.approvalStatus !== 'approved' && evidence.approvalStatus !== 'rejected' && (
<button
onClick={onReview}
className="px-3 py-1 text-sm text-green-600 hover:bg-green-50 rounded-lg transition-colors font-medium"
>
Reviewen
</button>
)}
{/* Reject button — visible for four-eyes evidence that's not yet resolved */}
{evidence.requiresFourEyes && evidence.approvalStatus !== 'rejected' && evidence.approvalStatus !== 'approved' && (
<button
onClick={onReject}
className="px-3 py-1 text-sm text-orange-600 hover:bg-orange-50 rounded-lg transition-colors"
>
Ablehnen
</button>
)}
{/* History button */}
<button
onClick={onShowHistory}
className="px-3 py-1 text-sm text-gray-500 hover:bg-gray-100 rounded-lg transition-colors"
>
Historie
</button>
</div>
</div>
</div>
@@ -382,6 +745,15 @@ export default function EvidencePage() {
const [pageSize] = useState(20)
const [total, setTotal] = useState(0)
// Anti-Fake-Evidence metadata (keyed by evidence ID)
const [antiFakeMeta, setAntiFakeMeta] = useState<Record<string, {
confidenceLevel: string | null
truthStatus: string | null
generationMode: string | null
approvalStatus: string | null
requiresFourEyes: boolean
}>>({})
// Evidence Checks state
const [checks, setChecks] = useState<EvidenceCheck[]>([])
const [checksLoading, setChecksLoading] = useState(false)
@@ -393,6 +765,13 @@ export default function EvidencePage() {
const [coverageReport, setCoverageReport] = useState<CoverageReport | null>(null)
const [seedingChecks, setSeedingChecks] = useState(false)
// Phase 3: Review/Reject/AuditTrail state
const [reviewEvidence, setReviewEvidence] = useState<DisplayEvidence | null>(null)
const [rejectEvidence, setRejectEvidence] = useState<DisplayEvidence | null>(null)
const [auditTrailId, setAuditTrailId] = useState<string | null>(null)
const [confidenceFilter, setConfidenceFilter] = useState<string | null>(null)
const [refreshKey, setRefreshKey] = useState(0)
// Fetch evidence from backend on mount and when page changes
useEffect(() => {
const fetchEvidence = async () => {
@@ -404,8 +783,18 @@ export default function EvidencePage() {
if (data.total !== undefined) setTotal(data.total)
const backendEvidence = data.evidence || data
if (Array.isArray(backendEvidence) && backendEvidence.length > 0) {
const mapped: SDKEvidence[] = backendEvidence.map((e: Record<string, unknown>) => ({
id: (e.id || '') as string,
const metaMap: typeof antiFakeMeta = {}
const mapped: SDKEvidence[] = backendEvidence.map((e: Record<string, unknown>) => {
const id = (e.id || '') as string
metaMap[id] = {
confidenceLevel: (e.confidence_level || null) as string | null,
truthStatus: (e.truth_status || null) as string | null,
generationMode: (e.generation_mode || null) as string | null,
approvalStatus: (e.approval_status || null) as string | null,
requiresFourEyes: !!e.requires_four_eyes,
}
return {
id,
controlId: (e.control_id || '') as string,
type: ((e.evidence_type || 'DOCUMENT') as string).toUpperCase() as EvidenceType,
name: (e.title || e.name || '') as string,
@@ -415,7 +804,9 @@ export default function EvidencePage() {
validUntil: e.valid_until ? new Date(e.valid_until as string) : null,
uploadedBy: (e.uploaded_by || 'System') as string,
uploadedAt: e.created_at ? new Date(e.created_at as string) : new Date(),
}))
}
})
setAntiFakeMeta(metaMap)
dispatch({ type: 'SET_STATE', payload: { evidence: mapped } })
setError(null)
return
@@ -463,12 +854,13 @@ export default function EvidencePage() {
}
fetchEvidence()
}, [page, pageSize]) // eslint-disable-line react-hooks/exhaustive-deps
}, [page, pageSize, refreshKey]) // eslint-disable-line react-hooks/exhaustive-deps
// Convert SDK evidence to display evidence
const displayEvidence: DisplayEvidence[] = state.evidence.map(ev => {
const template = evidenceTemplates.find(t => t.id === ev.id)
const meta = antiFakeMeta[ev.id]
return {
id: ev.id,
name: ev.name,
@@ -485,12 +877,18 @@ export default function EvidencePage() {
status: getEvidenceStatus(ev.validUntil),
fileSize: template?.fileSize || 'Unbekannt',
fileUrl: ev.fileUrl,
confidenceLevel: meta?.confidenceLevel || null,
truthStatus: meta?.truthStatus || null,
generationMode: meta?.generationMode || null,
approvalStatus: meta?.approvalStatus || null,
requiresFourEyes: meta?.requiresFourEyes || false,
}
})
const filteredEvidence = filter === 'all'
const filteredEvidence = (filter === 'all'
? displayEvidence
: displayEvidence.filter(e => e.status === filter || e.displayType === filter)
).filter(e => !confidenceFilter || e.confidenceLevel === confidenceFilter)
const validCount = displayEvidence.filter(e => e.status === 'valid').length
const expiredCount = displayEvidence.filter(e => e.status === 'expired').length
@@ -803,6 +1201,20 @@ export default function EvidencePage() {
f === 'certificate' ? 'Zertifikate' : 'Audit-Berichte'}
</button>
))}
<span className="text-gray-300 mx-1">|</span>
{['E0', 'E1', 'E2', 'E3', 'E4'].map(level => (
<button
key={level}
onClick={() => setConfidenceFilter(confidenceFilter === level ? null : level)}
className={`px-3 py-1 text-sm rounded-full transition-colors ${
confidenceFilter === level
? confidenceFilterColors[level]
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{level}
</button>
))}
</div>
{/* Loading State */}
@@ -818,6 +1230,9 @@ export default function EvidencePage() {
onDelete={() => handleDelete(ev.id)}
onView={() => handleView(ev)}
onDownload={() => handleDownload(ev)}
onReview={() => setReviewEvidence(ev)}
onReject={() => setRejectEvidence(ev)}
onShowHistory={() => setAuditTrailId(ev.id)}
/>
))}
</div>
@@ -1106,6 +1521,28 @@ export default function EvidencePage() {
)}
</div>
)}
{/* Phase 3 Modals */}
{reviewEvidence && (
<ReviewModal
evidence={reviewEvidence}
onClose={() => setReviewEvidence(null)}
onSuccess={() => { setReviewEvidence(null); setRefreshKey(k => k + 1) }}
/>
)}
{rejectEvidence && (
<RejectModal
evidence={rejectEvidence}
onClose={() => setRejectEvidence(null)}
onSuccess={() => { setRejectEvidence(null); setRefreshKey(k => k + 1) }}
/>
)}
{auditTrailId && (
<AuditTrailPanel
evidenceId={auditTrailId}
onClose={() => setAuditTrailId(null)}
/>
)}
</div>
)
}

View File

@@ -643,6 +643,19 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/assertions"
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 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
}
label="Assertions"
isActive={pathname === '/sdk/assertions'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/dsms"
icon={

View File

@@ -61,6 +61,8 @@ _ROUTER_MODULES = [
"evidence_check_routes",
"vvt_library_routes",
"tom_mapping_routes",
"llm_audit_routes",
"assertion_routes",
]
_loaded_count = 0

View File

@@ -0,0 +1,227 @@
"""
API routes for Assertion Engine (Anti-Fake-Evidence Phase 2).
Endpoints:
- /assertions: CRUD for assertions
- /assertions/extract: Automatic extraction from entity text
- /assertions/summary: Stats (total assertions, facts, unverified)
"""
import logging
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from classroom_engine.database import get_db
from ..db.models import AssertionDB
from ..services.assertion_engine import extract_assertions
from .schemas import (
AssertionCreate,
AssertionUpdate,
AssertionResponse,
AssertionListResponse,
AssertionSummaryResponse,
AssertionExtractRequest,
)
from .audit_trail_utils import log_audit_trail, generate_id
logger = logging.getLogger(__name__)
router = APIRouter(tags=["compliance-assertions"])
def _build_assertion_response(a: AssertionDB) -> AssertionResponse:
return AssertionResponse(
id=a.id,
tenant_id=a.tenant_id,
entity_type=a.entity_type,
entity_id=a.entity_id,
sentence_text=a.sentence_text,
sentence_index=a.sentence_index,
assertion_type=a.assertion_type,
evidence_ids=a.evidence_ids or [],
confidence=a.confidence or 0.0,
normative_tier=a.normative_tier,
verified_by=a.verified_by,
verified_at=a.verified_at,
created_at=a.created_at,
updated_at=a.updated_at,
)
@router.post("/assertions", response_model=AssertionResponse)
async def create_assertion(
data: AssertionCreate,
tenant_id: Optional[str] = Query(None),
db: Session = Depends(get_db),
):
"""Create a single assertion manually."""
a = AssertionDB(
id=generate_id(),
tenant_id=tenant_id,
entity_type=data.entity_type,
entity_id=data.entity_id,
sentence_text=data.sentence_text,
assertion_type=data.assertion_type or "assertion",
evidence_ids=data.evidence_ids or [],
normative_tier=data.normative_tier,
)
db.add(a)
db.commit()
db.refresh(a)
return _build_assertion_response(a)
@router.get("/assertions", response_model=AssertionListResponse)
async def list_assertions(
entity_type: Optional[str] = Query(None),
entity_id: Optional[str] = Query(None),
assertion_type: Optional[str] = Query(None),
tenant_id: Optional[str] = Query(None),
limit: int = Query(100, ge=1, le=500),
db: Session = Depends(get_db),
):
"""List assertions with optional filters."""
query = db.query(AssertionDB)
if entity_type:
query = query.filter(AssertionDB.entity_type == entity_type)
if entity_id:
query = query.filter(AssertionDB.entity_id == entity_id)
if assertion_type:
query = query.filter(AssertionDB.assertion_type == assertion_type)
if tenant_id:
query = query.filter(AssertionDB.tenant_id == tenant_id)
total = query.count()
records = query.order_by(AssertionDB.sentence_index.asc()).limit(limit).all()
return AssertionListResponse(
assertions=[_build_assertion_response(a) for a in records],
total=total,
)
@router.get("/assertions/summary", response_model=AssertionSummaryResponse)
async def assertion_summary(
tenant_id: Optional[str] = Query(None),
entity_type: Optional[str] = Query(None),
entity_id: Optional[str] = Query(None),
db: Session = Depends(get_db),
):
"""Summary stats: total assertions, facts, rationale, unverified."""
query = db.query(AssertionDB)
if tenant_id:
query = query.filter(AssertionDB.tenant_id == tenant_id)
if entity_type:
query = query.filter(AssertionDB.entity_type == entity_type)
if entity_id:
query = query.filter(AssertionDB.entity_id == entity_id)
all_records = query.all()
total = len(all_records)
facts = sum(1 for a in all_records if a.assertion_type == "fact")
rationale = sum(1 for a in all_records if a.assertion_type == "rationale")
unverified = sum(1 for a in all_records if a.assertion_type == "assertion" and not a.verified_by)
return AssertionSummaryResponse(
total_assertions=total,
total_facts=facts,
total_rationale=rationale,
unverified_count=unverified,
)
@router.get("/assertions/{assertion_id}", response_model=AssertionResponse)
async def get_assertion(
assertion_id: str,
db: Session = Depends(get_db),
):
"""Get a single assertion by ID."""
a = db.query(AssertionDB).filter(AssertionDB.id == assertion_id).first()
if not a:
raise HTTPException(status_code=404, detail=f"Assertion {assertion_id} not found")
return _build_assertion_response(a)
@router.put("/assertions/{assertion_id}", response_model=AssertionResponse)
async def update_assertion(
assertion_id: str,
data: AssertionUpdate,
db: Session = Depends(get_db),
):
"""Update an assertion (e.g. link evidence, change type)."""
a = db.query(AssertionDB).filter(AssertionDB.id == assertion_id).first()
if not a:
raise HTTPException(status_code=404, detail=f"Assertion {assertion_id} not found")
update_fields = data.model_dump(exclude_unset=True)
for key, value in update_fields.items():
setattr(a, key, value)
a.updated_at = datetime.utcnow()
db.commit()
db.refresh(a)
return _build_assertion_response(a)
@router.post("/assertions/{assertion_id}/verify", response_model=AssertionResponse)
async def verify_assertion(
assertion_id: str,
verified_by: str = Query(...),
db: Session = Depends(get_db),
):
"""Mark an assertion as verified fact."""
a = db.query(AssertionDB).filter(AssertionDB.id == assertion_id).first()
if not a:
raise HTTPException(status_code=404, detail=f"Assertion {assertion_id} not found")
a.assertion_type = "fact"
a.verified_by = verified_by
a.verified_at = datetime.utcnow()
a.updated_at = datetime.utcnow()
db.commit()
db.refresh(a)
return _build_assertion_response(a)
@router.post("/assertions/extract", response_model=AssertionListResponse)
async def extract_assertions_endpoint(
data: AssertionExtractRequest,
tenant_id: Optional[str] = Query(None),
db: Session = Depends(get_db),
):
"""Extract assertions from free text and persist them."""
extracted = extract_assertions(
text=data.text,
entity_type=data.entity_type,
entity_id=data.entity_id,
tenant_id=tenant_id,
)
created = []
for item in extracted:
a = AssertionDB(
id=generate_id(),
tenant_id=item["tenant_id"],
entity_type=item["entity_type"],
entity_id=item["entity_id"],
sentence_text=item["sentence_text"],
sentence_index=item["sentence_index"],
assertion_type=item["assertion_type"],
evidence_ids=item["evidence_ids"],
normative_tier=item.get("normative_tier"),
confidence=item.get("confidence", 0.0),
)
db.add(a)
created.append(a)
db.commit()
for a in created:
db.refresh(a)
return AssertionListResponse(
assertions=[_build_assertion_response(a) for a in created],
total=len(created),
)

View File

@@ -0,0 +1,53 @@
"""Shared audit trail utilities.
Extracted from isms_routes.py for reuse across evidence, control,
and assertion routes.
"""
import hashlib
import uuid
from datetime import datetime
from sqlalchemy.orm import Session
from ..db.models import AuditTrailDB
def generate_id() -> str:
"""Generate a UUID string."""
return str(uuid.uuid4())
def create_signature(data: str) -> str:
"""Create SHA-256 signature."""
return hashlib.sha256(data.encode()).hexdigest()
def log_audit_trail(
db: Session,
entity_type: str,
entity_id: str,
entity_name: str,
action: str,
performed_by: str,
field_changed: str = None,
old_value: str = None,
new_value: str = None,
change_summary: str = None,
):
"""Log an entry to the audit trail."""
trail = AuditTrailDB(
id=generate_id(),
entity_type=entity_type,
entity_id=entity_id,
entity_name=entity_name,
action=action,
field_changed=field_changed,
old_value=old_value,
new_value=new_value,
change_summary=change_summary,
performed_by=performed_by,
performed_at=datetime.utcnow(),
checksum=create_signature(f"{entity_type}|{entity_id}|{action}|{performed_by}"),
)
db.add(trail)

View File

@@ -32,14 +32,21 @@ from ..db import (
ControlRepository,
EvidenceRepository,
RiskRepository,
AssertionDB,
)
from .schemas import (
DashboardResponse,
MultiDimensionalScore,
ExecutiveDashboardResponse,
TrendDataPoint,
RiskSummary,
DeadlineItem,
TeamWorkloadItem,
TraceabilityAssertion,
TraceabilityEvidence,
TraceabilityCoverage,
TraceabilityControl,
TraceabilityMatrixResponse,
)
from .tenant_utils import get_tenant_id as _get_tenant_id
from .db_utils import row_to_dict as _row_to_dict
@@ -95,6 +102,14 @@ async def get_dashboard(db: Session = Depends(get_db)):
# or compute from by_status dict
score = ctrl_stats.get("compliance_score", 0.0)
# Multi-dimensional score (Anti-Fake-Evidence)
try:
ms = ctrl_repo.get_multi_dimensional_score()
multi_score = MultiDimensionalScore(**ms)
except Exception as e:
logger.warning(f"Failed to compute multi-dimensional score: {e}")
multi_score = None
return DashboardResponse(
compliance_score=round(score, 1),
total_regulations=len(regulations),
@@ -107,6 +122,7 @@ async def get_dashboard(db: Session = Depends(get_db)):
total_risks=len(risks),
risks_by_level=risks_by_level,
recent_activity=[],
multi_score=multi_score,
)
@@ -125,11 +141,18 @@ async def get_compliance_score(db: Session = Depends(get_db)):
else:
score = 0
# Multi-dimensional score (Anti-Fake-Evidence)
try:
multi_score = ctrl_repo.get_multi_dimensional_score()
except Exception:
multi_score = None
return {
"score": round(score, 1),
"total_controls": total,
"passing_controls": passing,
"partial_controls": partial,
"multi_score": multi_score,
}
@@ -597,6 +620,158 @@ async def get_score_history(
}
# ============================================================================
# Evidence Distribution (Anti-Fake-Evidence Phase 3)
# ============================================================================
@router.get("/dashboard/evidence-distribution")
async def get_evidence_distribution(
db: Session = Depends(get_db),
tenant_id: str = Depends(_get_tenant_id),
):
"""Evidence counts by confidence level and four-eyes status."""
evidence_repo = EvidenceRepository(db)
all_evidence = evidence_repo.get_all()
by_confidence = {"E0": 0, "E1": 0, "E2": 0, "E3": 0, "E4": 0}
four_eyes_pending = 0
for e in all_evidence:
level = e.confidence_level.value if e.confidence_level else "E1"
if level in by_confidence:
by_confidence[level] += 1
if e.requires_four_eyes and e.approval_status not in ("approved", "rejected"):
four_eyes_pending += 1
return {
"by_confidence": by_confidence,
"four_eyes_pending": four_eyes_pending,
"total": len(all_evidence),
}
# ============================================================================
# Traceability Matrix (Anti-Fake-Evidence Phase 4a)
# ============================================================================
@router.get("/dashboard/traceability-matrix", response_model=TraceabilityMatrixResponse)
async def get_traceability_matrix(
db: Session = Depends(get_db),
tenant_id: str = Depends(_get_tenant_id),
):
"""
Full traceability chain: Control → Evidence → Assertions.
Loads each entity set once, builds in-memory indices, and nests
the result so the frontend can render a matrix view.
"""
ctrl_repo = ControlRepository(db)
evidence_repo = EvidenceRepository(db)
# 1. Load all three entity sets
controls = ctrl_repo.get_all()
all_evidence = evidence_repo.get_all()
all_assertions = db.query(AssertionDB).filter(
AssertionDB.entity_type == "evidence",
).all()
# 2. Index assertions by evidence_id (entity_id)
assertions_by_evidence: Dict[str, list] = {}
for a in all_assertions:
assertions_by_evidence.setdefault(a.entity_id, []).append(a)
# 3. Index evidence by control_id
evidence_by_control: Dict[str, list] = {}
for e in all_evidence:
evidence_by_control.setdefault(str(e.control_id), []).append(e)
# 4. Build nested response
result_controls: list = []
total_controls = 0
covered_controls = 0
fully_verified = 0
for ctrl in controls:
total_controls += 1
ctrl_id = str(ctrl.id)
ctrl_evidence = evidence_by_control.get(ctrl_id, [])
nested_evidence: list = []
has_evidence = len(ctrl_evidence) > 0
has_assertions = False
all_verified = True
min_conf: Optional[str] = None
conf_order = {"E0": 0, "E1": 1, "E2": 2, "E3": 3, "E4": 4}
for e in ctrl_evidence:
ev_id = str(e.id)
ev_assertions = assertions_by_evidence.get(ev_id, [])
nested_assertions = [
TraceabilityAssertion(
id=str(a.id),
sentence_text=a.sentence_text,
assertion_type=a.assertion_type or "assertion",
confidence=a.confidence or 0.0,
verified=a.verified_by is not None,
)
for a in ev_assertions
]
if nested_assertions:
has_assertions = True
for na in nested_assertions:
if not na.verified:
all_verified = False
conf = e.confidence_level.value if e.confidence_level else "E1"
if min_conf is None or conf_order.get(conf, 1) < conf_order.get(min_conf, 1):
min_conf = conf
nested_evidence.append(TraceabilityEvidence(
id=ev_id,
title=e.title,
evidence_type=e.evidence_type,
confidence_level=conf,
status=e.status.value if e.status else "valid",
assertions=nested_assertions,
))
if not has_assertions:
all_verified = False
if has_evidence:
covered_controls += 1
if has_evidence and has_assertions and all_verified:
fully_verified += 1
coverage = TraceabilityCoverage(
has_evidence=has_evidence,
has_assertions=has_assertions,
all_assertions_verified=all_verified,
min_confidence_level=min_conf,
)
result_controls.append(TraceabilityControl(
id=ctrl_id,
control_id=ctrl.control_id,
title=ctrl.title,
status=ctrl.status.value if ctrl.status else "planned",
domain=ctrl.domain.value if ctrl.domain else "unknown",
evidence=nested_evidence,
coverage=coverage,
))
summary = {
"total_controls": total_controls,
"covered_controls": covered_controls,
"fully_verified": fully_verified,
"uncovered_controls": total_controls - covered_controls,
}
return TraceabilityMatrixResponse(controls=result_controls, summary=summary)
# ============================================================================
# Reports
# ============================================================================

View File

@@ -26,17 +26,102 @@ from ..db import (
ControlRepository,
EvidenceRepository,
EvidenceStatusEnum,
EvidenceConfidenceEnum,
EvidenceTruthStatusEnum,
)
from ..db.models import EvidenceDB, ControlDB
from ..db.models import EvidenceDB, ControlDB, AuditTrailDB
from ..services.auto_risk_updater import AutoRiskUpdater
from .schemas import (
EvidenceCreate, EvidenceResponse, EvidenceListResponse,
EvidenceRejectRequest,
)
from .audit_trail_utils import log_audit_trail
logger = logging.getLogger(__name__)
router = APIRouter(tags=["compliance-evidence"])
# ============================================================================
# Anti-Fake-Evidence: Four-Eyes Domain Check
# ============================================================================
FOUR_EYES_DOMAINS = {"gov", "priv"}
def _requires_four_eyes(control_domain: str) -> bool:
"""Controls in governance/privacy domains require two independent reviewers."""
return control_domain in FOUR_EYES_DOMAINS
# ============================================================================
# Anti-Fake-Evidence: Auto-Classification Helpers
# ============================================================================
def _classify_confidence(source: Optional[str], evidence_type: Optional[str] = None, artifact_hash: Optional[str] = None) -> EvidenceConfidenceEnum:
"""Classify evidence confidence level based on source and metadata."""
if source == "ci_pipeline":
return EvidenceConfidenceEnum.E3
if source == "api" and artifact_hash:
return EvidenceConfidenceEnum.E3
if source == "api":
return EvidenceConfidenceEnum.E3
if source in ("manual", "upload"):
return EvidenceConfidenceEnum.E1
if source == "generated":
return EvidenceConfidenceEnum.E0
# Default for unknown sources
return EvidenceConfidenceEnum.E1
def _classify_truth_status(source: Optional[str]) -> EvidenceTruthStatusEnum:
"""Classify evidence truth status based on source."""
if source == "ci_pipeline":
return EvidenceTruthStatusEnum.OBSERVED
if source in ("manual", "upload"):
return EvidenceTruthStatusEnum.UPLOADED
if source == "generated":
return EvidenceTruthStatusEnum.GENERATED
if source == "api":
return EvidenceTruthStatusEnum.OBSERVED
return EvidenceTruthStatusEnum.UPLOADED
def _build_evidence_response(e: EvidenceDB) -> EvidenceResponse:
"""Build an EvidenceResponse from an EvidenceDB, including anti-fake fields."""
return EvidenceResponse(
id=e.id,
control_id=e.control_id,
evidence_type=e.evidence_type,
title=e.title,
description=e.description,
artifact_path=e.artifact_path,
artifact_url=e.artifact_url,
artifact_hash=e.artifact_hash,
file_size_bytes=e.file_size_bytes,
mime_type=e.mime_type,
valid_from=e.valid_from,
valid_until=e.valid_until,
status=e.status.value if e.status else None,
source=e.source,
ci_job_id=e.ci_job_id,
uploaded_by=e.uploaded_by,
collected_at=e.collected_at,
created_at=e.created_at,
confidence_level=e.confidence_level.value if e.confidence_level else None,
truth_status=e.truth_status.value if e.truth_status else None,
generation_mode=e.generation_mode,
may_be_used_as_evidence=e.may_be_used_as_evidence,
reviewed_by=e.reviewed_by,
reviewed_at=e.reviewed_at,
approval_status=e.approval_status,
first_reviewer=e.first_reviewer,
first_reviewed_at=e.first_reviewed_at,
second_reviewer=e.second_reviewer,
second_reviewed_at=e.second_reviewed_at,
requires_four_eyes=e.requires_four_eyes,
)
# ============================================================================
# Evidence
# ============================================================================
@@ -80,29 +165,7 @@ async def list_evidence(
offset = (page - 1) * limit
evidence = evidence[offset:offset + limit]
results = [
EvidenceResponse(
id=e.id,
control_id=e.control_id,
evidence_type=e.evidence_type,
title=e.title,
description=e.description,
artifact_path=e.artifact_path,
artifact_url=e.artifact_url,
artifact_hash=e.artifact_hash,
file_size_bytes=e.file_size_bytes,
mime_type=e.mime_type,
valid_from=e.valid_from,
valid_until=e.valid_until,
status=e.status.value if e.status else None,
source=e.source,
ci_job_id=e.ci_job_id,
uploaded_by=e.uploaded_by,
collected_at=e.collected_at,
created_at=e.created_at,
)
for e in evidence
]
results = [_build_evidence_response(e) for e in evidence]
return EvidenceListResponse(evidence=results, total=total)
@@ -121,6 +184,22 @@ async def create_evidence(
if not control:
raise HTTPException(status_code=404, detail=f"Control {evidence_data.control_id} not found")
source = evidence_data.source or "api"
confidence = _classify_confidence(source, evidence_data.evidence_type)
truth = _classify_truth_status(source)
# Allow explicit override from request
if evidence_data.confidence_level:
try:
confidence = EvidenceConfidenceEnum(evidence_data.confidence_level)
except ValueError:
pass
if evidence_data.truth_status:
try:
truth = EvidenceTruthStatusEnum(evidence_data.truth_status)
except ValueError:
pass
evidence = repo.create(
control_id=control.id,
evidence_type=evidence_data.evidence_type,
@@ -129,31 +208,34 @@ async def create_evidence(
artifact_url=evidence_data.artifact_url,
valid_from=evidence_data.valid_from,
valid_until=evidence_data.valid_until,
source=evidence_data.source or "api",
source=source,
ci_job_id=evidence_data.ci_job_id,
)
# Set anti-fake-evidence fields
evidence.confidence_level = confidence
evidence.truth_status = truth
# Generated evidence should not be used as evidence by default
if truth == EvidenceTruthStatusEnum.GENERATED:
evidence.may_be_used_as_evidence = False
# Four-Eyes: check if the linked control's domain requires it
control_domain = control.domain.value if control.domain else ""
if _requires_four_eyes(control_domain):
evidence.requires_four_eyes = True
evidence.approval_status = "pending_first"
db.commit()
# Audit trail
log_audit_trail(
db, "evidence", evidence.id, evidence.title, "create",
performed_by=evidence_data.source or "api",
change_summary=f"Evidence created with confidence={confidence.value}, truth={truth.value}",
)
db.commit()
return EvidenceResponse(
id=evidence.id,
control_id=evidence.control_id,
evidence_type=evidence.evidence_type,
title=evidence.title,
description=evidence.description,
artifact_path=evidence.artifact_path,
artifact_url=evidence.artifact_url,
artifact_hash=evidence.artifact_hash,
file_size_bytes=evidence.file_size_bytes,
mime_type=evidence.mime_type,
valid_from=evidence.valid_from,
valid_until=evidence.valid_until,
status=evidence.status.value if evidence.status else None,
source=evidence.source,
ci_job_id=evidence.ci_job_id,
uploaded_by=evidence.uploaded_by,
collected_at=evidence.collected_at,
created_at=evidence.created_at,
)
return _build_evidence_response(evidence)
@router.delete("/evidence/{evidence_id}")
@@ -223,28 +305,20 @@ async def upload_evidence(
mime_type=file.content_type,
source="upload",
)
# Upload evidence → E1 + uploaded
evidence.confidence_level = EvidenceConfidenceEnum.E1
evidence.truth_status = EvidenceTruthStatusEnum.UPLOADED
# Four-Eyes: check if the linked control's domain requires it
control_domain = control.domain.value if control.domain else ""
if _requires_four_eyes(control_domain):
evidence.requires_four_eyes = True
evidence.approval_status = "pending_first"
db.commit()
return EvidenceResponse(
id=evidence.id,
control_id=evidence.control_id,
evidence_type=evidence.evidence_type,
title=evidence.title,
description=evidence.description,
artifact_path=evidence.artifact_path,
artifact_url=evidence.artifact_url,
artifact_hash=evidence.artifact_hash,
file_size_bytes=evidence.file_size_bytes,
mime_type=evidence.mime_type,
valid_from=evidence.valid_from,
valid_until=evidence.valid_until,
status=evidence.status.value if evidence.status else None,
source=evidence.source,
ci_job_id=evidence.ci_job_id,
uploaded_by=evidence.uploaded_by,
collected_at=evidence.collected_at,
created_at=evidence.created_at,
)
return _build_evidence_response(evidence)
# ============================================================================
@@ -357,7 +431,7 @@ def _store_evidence(
with open(file_path, "w") as f:
json.dump(report_data or {}, f, indent=2)
# Create evidence record
# Create evidence record with anti-fake-evidence classification
evidence = EvidenceDB(
id=str(uuid_module.uuid4()),
control_id=control_db_id,
@@ -373,6 +447,10 @@ def _store_evidence(
valid_from=datetime.utcnow(),
valid_until=datetime.utcnow() + timedelta(days=90),
status=EvidenceStatusEnum(parsed["evidence_status"]),
# CI pipeline evidence → E3 observed (system-observed, hash-verified)
confidence_level=EvidenceConfidenceEnum.E3,
truth_status=EvidenceTruthStatusEnum.OBSERVED,
may_be_used_as_evidence=True,
)
db.add(evidence)
db.commit()
@@ -639,3 +717,169 @@ async def get_ci_evidence_status(
"total_evidence": len(evidence_list),
"controls": result,
}
# ============================================================================
# Evidence Review (Anti-Fake-Evidence)
# ============================================================================
from pydantic import BaseModel as _BaseModel
class _EvidenceReviewRequest(_BaseModel):
confidence_level: Optional[str] = None
truth_status: Optional[str] = None
reviewed_by: str
@router.patch("/evidence/{evidence_id}/review", response_model=EvidenceResponse)
async def review_evidence(
evidence_id: str,
review: _EvidenceReviewRequest,
db: Session = Depends(get_db),
):
"""
Review evidence: upgrade confidence level and/or change truth status.
For Four-Eyes evidence, the first reviewer sets first_reviewer and
approval_status='first_approved'. A second (different) reviewer then
sets second_reviewer and approval_status='approved'.
"""
evidence = db.query(EvidenceDB).filter(EvidenceDB.id == evidence_id).first()
if not evidence:
raise HTTPException(status_code=404, detail=f"Evidence {evidence_id} not found")
old_confidence = evidence.confidence_level.value if evidence.confidence_level else None
old_truth = evidence.truth_status.value if evidence.truth_status else None
if review.confidence_level:
try:
evidence.confidence_level = EvidenceConfidenceEnum(review.confidence_level)
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid confidence_level: {review.confidence_level}")
if review.truth_status:
try:
evidence.truth_status = EvidenceTruthStatusEnum(review.truth_status)
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid truth_status: {review.truth_status}")
# Four-Eyes branching
if evidence.requires_four_eyes:
status = evidence.approval_status or "none"
if status in ("none", "pending_first"):
evidence.first_reviewer = review.reviewed_by
evidence.first_reviewed_at = datetime.utcnow()
evidence.approval_status = "first_approved"
elif status == "first_approved":
if review.reviewed_by == evidence.first_reviewer:
raise HTTPException(
status_code=400,
detail="Four-Eyes: second reviewer must be different from first reviewer",
)
evidence.second_reviewer = review.reviewed_by
evidence.second_reviewed_at = datetime.utcnow()
evidence.approval_status = "approved"
elif status == "approved":
raise HTTPException(status_code=400, detail="Evidence already approved")
elif status == "rejected":
raise HTTPException(status_code=400, detail="Evidence was rejected — create new evidence instead")
evidence.reviewed_by = review.reviewed_by
evidence.reviewed_at = datetime.utcnow()
db.commit()
# Audit trail
new_confidence = evidence.confidence_level.value if evidence.confidence_level else None
if old_confidence != new_confidence:
log_audit_trail(
db, "evidence", evidence_id, evidence.title, "review",
performed_by=review.reviewed_by,
field_changed="confidence_level",
old_value=old_confidence,
new_value=new_confidence,
)
new_truth = evidence.truth_status.value if evidence.truth_status else None
if old_truth != new_truth:
log_audit_trail(
db, "evidence", evidence_id, evidence.title, "review",
performed_by=review.reviewed_by,
field_changed="truth_status",
old_value=old_truth,
new_value=new_truth,
)
db.commit()
db.refresh(evidence)
return _build_evidence_response(evidence)
@router.patch("/evidence/{evidence_id}/reject", response_model=EvidenceResponse)
async def reject_evidence(
evidence_id: str,
body: EvidenceRejectRequest,
db: Session = Depends(get_db),
):
"""Reject evidence (sets approval_status='rejected')."""
evidence = db.query(EvidenceDB).filter(EvidenceDB.id == evidence_id).first()
if not evidence:
raise HTTPException(status_code=404, detail=f"Evidence {evidence_id} not found")
evidence.approval_status = "rejected"
evidence.reviewed_by = body.reviewed_by
evidence.reviewed_at = datetime.utcnow()
db.commit()
log_audit_trail(
db, "evidence", evidence_id, evidence.title, "reject",
performed_by=body.reviewed_by,
change_summary=body.rejection_reason or "Evidence rejected",
)
db.commit()
db.refresh(evidence)
return _build_evidence_response(evidence)
# ============================================================================
# Audit Trail Query
# ============================================================================
@router.get("/audit-trail")
async def get_audit_trail(
entity_type: Optional[str] = Query(None),
entity_id: Optional[str] = Query(None),
action: Optional[str] = Query(None),
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
):
"""Query audit trail entries for an entity."""
query = db.query(AuditTrailDB)
if entity_type:
query = query.filter(AuditTrailDB.entity_type == entity_type)
if entity_id:
query = query.filter(AuditTrailDB.entity_id == entity_id)
if action:
query = query.filter(AuditTrailDB.action == action)
records = query.order_by(AuditTrailDB.performed_at.desc()).limit(limit).all()
return {
"entries": [
{
"id": r.id,
"entity_type": r.entity_type,
"entity_id": r.entity_id,
"entity_name": r.entity_name,
"action": r.action,
"field_changed": r.field_changed,
"old_value": r.old_value,
"new_value": r.new_value,
"change_summary": r.change_summary,
"performed_by": r.performed_by,
"performed_at": r.performed_at.isoformat() if r.performed_at else None,
"checksum": r.checksum,
}
for r in records
],
"total": len(records),
}

View File

@@ -73,39 +73,8 @@ def generate_id() -> str:
return str(uuid.uuid4())
def create_signature(data: str) -> str:
"""Create SHA-256 signature."""
return hashlib.sha256(data.encode()).hexdigest()
def log_audit_trail(
db: Session,
entity_type: str,
entity_id: str,
entity_name: str,
action: str,
performed_by: str,
field_changed: str = None,
old_value: str = None,
new_value: str = None,
change_summary: str = None
):
"""Log an entry to the audit trail."""
trail = AuditTrailDB(
id=generate_id(),
entity_type=entity_type,
entity_id=entity_id,
entity_name=entity_name,
action=action,
field_changed=field_changed,
old_value=old_value,
new_value=new_value,
change_summary=change_summary,
performed_by=performed_by,
performed_at=datetime.utcnow(),
checksum=create_signature(f"{entity_type}|{entity_id}|{action}|{performed_by}")
)
db.add(trail)
# Shared audit trail utilities — canonical implementation in audit_trail_utils.py
from .audit_trail_utils import log_audit_trail, create_signature # noqa: E402
# =============================================================================

View File

@@ -0,0 +1,162 @@
"""
FastAPI routes for LLM Generation Audit Trail.
Endpoints:
- POST /llm-audit: Record an LLM generation event
- GET /llm-audit: List audit records with filters
"""
import logging
import uuid as uuid_module
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from classroom_engine.database import get_db
from ..db.models import LLMGenerationAuditDB
logger = logging.getLogger(__name__)
router = APIRouter(tags=["compliance-llm-audit"])
# ============================================================================
# Schemas
# ============================================================================
class LLMAuditCreate(BaseModel):
entity_type: str
entity_id: Optional[str] = None
generation_mode: str
truth_status: str = "generated"
may_be_used_as_evidence: bool = False
llm_model: Optional[str] = None
llm_provider: Optional[str] = None
prompt_hash: Optional[str] = None
input_summary: Optional[str] = None
output_summary: Optional[str] = None
metadata: Optional[dict] = None
tenant_id: Optional[str] = None
class LLMAuditResponse(BaseModel):
id: str
tenant_id: Optional[str] = None
entity_type: str
entity_id: Optional[str] = None
generation_mode: str
truth_status: str
may_be_used_as_evidence: bool
llm_model: Optional[str] = None
llm_provider: Optional[str] = None
prompt_hash: Optional[str] = None
input_summary: Optional[str] = None
output_summary: Optional[str] = None
metadata: Optional[dict] = None
created_at: datetime
class Config:
from_attributes = True
# ============================================================================
# Routes
# ============================================================================
@router.post("/llm-audit", response_model=LLMAuditResponse)
async def create_llm_audit(
data: LLMAuditCreate,
db: Session = Depends(get_db),
):
"""Record an LLM generation event for audit trail."""
from ..db.models import EvidenceTruthStatusEnum
# Validate truth_status
try:
truth_enum = EvidenceTruthStatusEnum(data.truth_status)
except ValueError:
truth_enum = EvidenceTruthStatusEnum.GENERATED
record = LLMGenerationAuditDB(
id=str(uuid_module.uuid4()),
tenant_id=data.tenant_id,
entity_type=data.entity_type,
entity_id=data.entity_id,
generation_mode=data.generation_mode,
truth_status=truth_enum,
may_be_used_as_evidence=data.may_be_used_as_evidence,
llm_model=data.llm_model,
llm_provider=data.llm_provider,
prompt_hash=data.prompt_hash,
input_summary=data.input_summary[:500] if data.input_summary else None,
output_summary=data.output_summary[:500] if data.output_summary else None,
extra_metadata=data.metadata or {},
)
db.add(record)
db.commit()
db.refresh(record)
return LLMAuditResponse(
id=record.id,
tenant_id=record.tenant_id,
entity_type=record.entity_type,
entity_id=record.entity_id,
generation_mode=record.generation_mode,
truth_status=record.truth_status.value if record.truth_status else "generated",
may_be_used_as_evidence=record.may_be_used_as_evidence,
llm_model=record.llm_model,
llm_provider=record.llm_provider,
prompt_hash=record.prompt_hash,
input_summary=record.input_summary,
output_summary=record.output_summary,
metadata=record.extra_metadata,
created_at=record.created_at,
)
@router.get("/llm-audit")
async def list_llm_audit(
entity_type: Optional[str] = Query(None),
entity_id: Optional[str] = Query(None),
page: int = Query(1, ge=1),
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
):
"""List LLM generation audit records with optional filters."""
query = db.query(LLMGenerationAuditDB)
if entity_type:
query = query.filter(LLMGenerationAuditDB.entity_type == entity_type)
if entity_id:
query = query.filter(LLMGenerationAuditDB.entity_id == entity_id)
total = query.count()
offset = (page - 1) * limit
records = query.order_by(LLMGenerationAuditDB.created_at.desc()).offset(offset).limit(limit).all()
return {
"records": [
LLMAuditResponse(
id=r.id,
tenant_id=r.tenant_id,
entity_type=r.entity_type,
entity_id=r.entity_id,
generation_mode=r.generation_mode,
truth_status=r.truth_status.value if r.truth_status else "generated",
may_be_used_as_evidence=r.may_be_used_as_evidence,
llm_model=r.llm_model,
llm_provider=r.llm_provider,
prompt_hash=r.prompt_hash,
input_summary=r.input_summary,
output_summary=r.output_summary,
metadata=r.extra_metadata,
created_at=r.created_at,
)
for r in records
],
"total": total,
"page": page,
"limit": limit,
}

View File

@@ -25,6 +25,7 @@ from sqlalchemy.orm import Session
from classroom_engine.database import get_db
from .audit_trail_utils import log_audit_trail
from ..db import (
RegulationRepository,
RequirementRepository,
@@ -595,6 +596,7 @@ async def get_control(control_id: str, db: Session = Depends(get_db)):
review_frequency_days=control.review_frequency_days,
status=control.status.value if control.status else None,
status_notes=control.status_notes,
status_justification=control.status_justification,
last_reviewed_at=control.last_reviewed_at,
next_review_at=control.next_review_at,
created_at=control.created_at,
@@ -617,16 +619,52 @@ async def update_control(
update_data = update.model_dump(exclude_unset=True)
# Convert status string to enum
# Convert status string to enum and validate transition
if "status" in update_data:
try:
update_data["status"] = ControlStatusEnum(update_data["status"])
new_status_enum = ControlStatusEnum(update_data["status"])
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid status: {update_data['status']}")
# Validate status transition (Anti-Fake-Evidence)
from ..services.control_status_machine import validate_transition
current_status = control.status.value if control.status else "planned"
evidence_list = db.query(EvidenceDB).filter(EvidenceDB.control_id == control.id).all()
allowed, violations = validate_transition(
current_status=current_status,
new_status=update_data["status"],
evidence_list=evidence_list,
status_justification=update_data.get("status_justification") or update_data.get("status_notes"),
)
if not allowed:
raise HTTPException(
status_code=409,
detail={
"error": "Status transition not allowed",
"current_status": current_status,
"requested_status": update_data["status"],
"violations": violations,
}
)
update_data["status"] = new_status_enum
updated = repo.update(control.id, **update_data)
db.commit()
# Audit trail for status changes
new_status = updated.status.value if updated.status else None
if "status" in update.model_dump(exclude_unset=True) and current_status != new_status:
log_audit_trail(
db, "control", control.id, updated.control_id or updated.title,
"status_change",
performed_by=update.owner or "system",
field_changed="status",
old_value=current_status,
new_value=new_status,
)
db.commit()
return ControlResponse(
id=updated.id,
control_id=updated.control_id,
@@ -645,6 +683,7 @@ async def update_control(
review_frequency_days=updated.review_frequency_days,
status=updated.status.value if updated.status else None,
status_notes=updated.status_notes,
status_justification=updated.status_justification,
last_reviewed_at=updated.last_reviewed_at,
next_review_at=updated.next_review_at,
created_at=updated.created_at,
@@ -690,6 +729,7 @@ async def review_control(
review_frequency_days=updated.review_frequency_days,
status=updated.status.value if updated.status else None,
status_notes=updated.status_notes,
status_justification=updated.status_justification,
last_reviewed_at=updated.last_reviewed_at,
next_review_at=updated.next_review_at,
created_at=updated.created_at,

View File

@@ -43,6 +43,7 @@ class ControlStatus(str):
FAIL = "fail"
NOT_APPLICABLE = "n/a"
PLANNED = "planned"
IN_PROGRESS = "in_progress"
class RiskLevel(str):
@@ -209,12 +210,14 @@ class ControlUpdate(BaseModel):
owner: Optional[str] = None
status: Optional[str] = None
status_notes: Optional[str] = None
status_justification: Optional[str] = None
class ControlResponse(ControlBase):
id: str
status: str
status_notes: Optional[str] = None
status_justification: Optional[str] = None
last_reviewed_at: Optional[datetime] = None
next_review_at: Optional[datetime] = None
created_at: datetime
@@ -291,7 +294,8 @@ class EvidenceBase(BaseModel):
class EvidenceCreate(EvidenceBase):
pass
confidence_level: Optional[str] = None
truth_status: Optional[str] = None
class EvidenceResponse(EvidenceBase):
@@ -304,6 +308,20 @@ class EvidenceResponse(EvidenceBase):
uploaded_by: Optional[str] = None
collected_at: datetime
created_at: datetime
# Anti-Fake-Evidence fields
confidence_level: Optional[str] = None
truth_status: Optional[str] = None
generation_mode: Optional[str] = None
may_be_used_as_evidence: Optional[bool] = None
reviewed_by: Optional[str] = None
reviewed_at: Optional[datetime] = None
# Anti-Fake-Evidence Phase 2: Four-Eyes
approval_status: Optional[str] = None
first_reviewer: Optional[str] = None
first_reviewed_at: Optional[datetime] = None
second_reviewer: Optional[str] = None
second_reviewed_at: Optional[datetime] = None
requires_four_eyes: Optional[bool] = None
class Config:
from_attributes = True
@@ -435,6 +453,25 @@ class AISystemListResponse(BaseModel):
# Dashboard & Export Schemas
# ============================================================================
class MultiDimensionalScore(BaseModel):
"""Multi-dimensional compliance score (Anti-Fake-Evidence)."""
requirement_coverage: float = 0.0 # % requirements with linked control
evidence_strength: float = 0.0 # Weighted avg of evidence confidence (E0=0..E4=1)
validation_quality: float = 0.0 # % evidence with truth_status >= validated_internal
evidence_freshness: float = 0.0 # % evidence not expired + reviewed < 90 days
control_effectiveness: float = 0.0 # Existing formula (pass + partial*0.5)
overall_readiness: float = 0.0 # Weighted composite
hard_blocks: List[str] = [] # Blocking issues preventing audit-readiness
class StatusTransitionError(BaseModel):
"""Error detail for forbidden control status transitions."""
allowed: bool = False
current_status: str
requested_status: str
violations: List[str] = []
class DashboardResponse(BaseModel):
compliance_score: float
total_regulations: int
@@ -447,6 +484,7 @@ class DashboardResponse(BaseModel):
total_risks: int
risks_by_level: Dict[str, int]
recent_activity: List[Dict[str, Any]]
multi_score: Optional[MultiDimensionalScore] = None
class ExportRequest(BaseModel):
@@ -1939,3 +1977,111 @@ class TOMStatsResponse(BaseModel):
implemented: int = 0
partial: int = 0
not_implemented: int = 0
# ============================================================================
# Assertion Schemas (Anti-Fake-Evidence Phase 2)
# ============================================================================
class AssertionCreate(BaseModel):
entity_type: str
entity_id: str
sentence_text: str
assertion_type: Optional[str] = "assertion"
evidence_ids: Optional[List[str]] = []
normative_tier: Optional[str] = None
class AssertionUpdate(BaseModel):
sentence_text: Optional[str] = None
assertion_type: Optional[str] = None
evidence_ids: Optional[List[str]] = None
normative_tier: Optional[str] = None
confidence: Optional[float] = None
class AssertionResponse(BaseModel):
id: str
tenant_id: Optional[str] = None
entity_type: str
entity_id: str
sentence_text: str
sentence_index: int = 0
assertion_type: str = "assertion"
evidence_ids: Optional[List[str]] = []
confidence: float = 0.0
normative_tier: Optional[str] = None
verified_by: Optional[str] = None
verified_at: Optional[datetime] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
class AssertionListResponse(BaseModel):
assertions: List[AssertionResponse]
total: int
class AssertionSummaryResponse(BaseModel):
total_assertions: int = 0
total_facts: int = 0
total_rationale: int = 0
unverified_count: int = 0
class AssertionExtractRequest(BaseModel):
entity_type: str
entity_id: str
text: str
class EvidenceRejectRequest(BaseModel):
reviewed_by: str
rejection_reason: Optional[str] = None
# ============================================================================
# Traceability Matrix (Anti-Fake-Evidence Phase 4a)
# ============================================================================
class TraceabilityAssertion(BaseModel):
"""Single assertion linked to an evidence item."""
id: str
sentence_text: str
assertion_type: str = "assertion"
confidence: float = 0.0
verified: bool = False
class TraceabilityEvidence(BaseModel):
"""Evidence item with nested assertions."""
id: str
title: str
evidence_type: str
confidence_level: str = "E1"
status: str = "valid"
assertions: List[TraceabilityAssertion] = []
class TraceabilityCoverage(BaseModel):
"""Coverage flags for a single control."""
has_evidence: bool = False
has_assertions: bool = False
all_assertions_verified: bool = False
min_confidence_level: Optional[str] = None
class TraceabilityControl(BaseModel):
"""Control with nested evidence and coverage info."""
id: str
control_id: str
title: str
status: str = "planned"
domain: str = "unknown"
evidence: List[TraceabilityEvidence] = []
coverage: TraceabilityCoverage = TraceabilityCoverage()
class TraceabilityMatrixResponse(BaseModel):
"""Full traceability matrix: Controls → Evidence → Assertions."""
controls: List[TraceabilityControl]
summary: Dict[str, int]

View File

@@ -8,12 +8,16 @@ from .models import (
EvidenceDB,
RiskDB,
AuditExportDB,
LLMGenerationAuditDB,
AssertionDB,
RegulationTypeEnum,
ControlTypeEnum,
ControlDomainEnum,
RiskLevelEnum,
EvidenceStatusEnum,
ControlStatusEnum,
EvidenceConfidenceEnum,
EvidenceTruthStatusEnum,
)
from .repository import (
RegulationRepository,
@@ -33,6 +37,8 @@ __all__ = [
"EvidenceDB",
"RiskDB",
"AuditExportDB",
"LLMGenerationAuditDB",
"AssertionDB",
# Enums
"RegulationTypeEnum",
"ControlTypeEnum",
@@ -40,6 +46,8 @@ __all__ = [
"RiskLevelEnum",
"EvidenceStatusEnum",
"ControlStatusEnum",
"EvidenceConfidenceEnum",
"EvidenceTruthStatusEnum",
# Repositories
"RegulationRepository",
"RequirementRepository",

View File

@@ -65,6 +65,7 @@ class ControlStatusEnum(str, enum.Enum):
FAIL = "fail" # Not passing
NOT_APPLICABLE = "n/a" # Not applicable
PLANNED = "planned" # Planned for implementation
IN_PROGRESS = "in_progress" # Implementation in progress
class RiskLevelEnum(str, enum.Enum):
@@ -83,6 +84,26 @@ class EvidenceStatusEnum(str, enum.Enum):
FAILED = "failed" # Failed validation
class EvidenceConfidenceEnum(str, enum.Enum):
"""Confidence level of evidence (Anti-Fake-Evidence)."""
E0 = "E0" # Generated / no real evidence (LLM output, placeholder)
E1 = "E1" # Uploaded but unreviewed (manual upload, no hash, no reviewer)
E2 = "E2" # Reviewed internally (human reviewed, hash verified)
E3 = "E3" # Observed by system (CI/CD pipeline, API with hash)
E4 = "E4" # Validated by external auditor
class EvidenceTruthStatusEnum(str, enum.Enum):
"""Truth status lifecycle for evidence (Anti-Fake-Evidence)."""
GENERATED = "generated"
UPLOADED = "uploaded"
OBSERVED = "observed"
VALIDATED_INTERNAL = "validated_internal"
REJECTED = "rejected"
PROVIDED_TO_AUDITOR = "provided_to_auditor"
ACCEPTED_BY_AUDITOR = "accepted_by_auditor"
class ExportStatusEnum(str, enum.Enum):
"""Status of audit export."""
PENDING = "pending"
@@ -239,6 +260,7 @@ class ControlDB(Base):
# Status
status = Column(Enum(ControlStatusEnum), default=ControlStatusEnum.PLANNED)
status_notes = Column(Text)
status_justification = Column(Text) # Required for n/a transitions
# Ownership & Review
owner = Column(String(100)) # Responsible person/team
@@ -321,6 +343,22 @@ class EvidenceDB(Base):
ci_job_id = Column(String(100)) # CI/CD job reference
uploaded_by = Column(String(100)) # User who uploaded
# Anti-Fake-Evidence: Confidence & Truth tracking
confidence_level = Column(Enum(EvidenceConfidenceEnum), default=EvidenceConfidenceEnum.E1)
truth_status = Column(Enum(EvidenceTruthStatusEnum), default=EvidenceTruthStatusEnum.UPLOADED)
generation_mode = Column(String(100)) # e.g. "draft_assistance", "auto_generation"
may_be_used_as_evidence = Column(Boolean, default=True)
reviewed_by = Column(String(200))
reviewed_at = Column(DateTime)
# Anti-Fake-Evidence Phase 2: Four-Eyes review
approval_status = Column(String(30), default="none")
first_reviewer = Column(String(200))
first_reviewed_at = Column(DateTime)
second_reviewer = Column(String(200))
second_reviewed_at = Column(DateTime)
requires_four_eyes = Column(Boolean, default=False)
# Timestamps
collected_at = Column(DateTime, default=datetime.utcnow)
created_at = Column(DateTime, default=datetime.utcnow)
@@ -332,6 +370,7 @@ class EvidenceDB(Base):
__table_args__ = (
Index('ix_evidence_control_type', 'control_id', 'evidence_type'),
Index('ix_evidence_status', 'status'),
Index('ix_evidence_approval_status', 'approval_status'),
)
def __repr__(self):
@@ -1464,3 +1503,77 @@ class ISMSReadinessCheckDB(Base):
def __repr__(self):
return f"<ISMSReadiness {self.check_date}: {self.overall_status}>"
class LLMGenerationAuditDB(Base):
"""
Audit trail for LLM-generated content.
Every piece of content generated by an LLM is recorded here with its
truth_status and may_be_used_as_evidence flag, ensuring transparency
about what is real evidence vs. generated assistance.
"""
__tablename__ = 'compliance_llm_generation_audit'
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
tenant_id = Column(String(36), index=True)
entity_type = Column(String(50), nullable=False) # 'evidence', 'control', 'document'
entity_id = Column(String(36)) # FK to generated entity
generation_mode = Column(String(100), nullable=False) # 'draft_assistance', 'auto_generation'
truth_status = Column(Enum(EvidenceTruthStatusEnum), nullable=False, default=EvidenceTruthStatusEnum.GENERATED)
may_be_used_as_evidence = Column(Boolean, nullable=False, default=False)
llm_model = Column(String(100))
llm_provider = Column(String(50)) # 'ollama', 'anthropic'
prompt_hash = Column(String(64)) # SHA-256 of prompt
input_summary = Column(Text)
output_summary = Column(Text)
extra_metadata = Column("metadata", JSON, default=dict)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
__table_args__ = (
Index('ix_llm_audit_entity', 'entity_type', 'entity_id'),
)
def __repr__(self):
return f"<LLMGenerationAudit {self.entity_type}:{self.entity_id} mode={self.generation_mode}>"
class AssertionDB(Base):
"""
Assertion tracking — separates claims from verified facts.
Each sentence from a control/evidence/document is stored here with its
classification (assertion vs. fact vs. rationale) and optional evidence linkage.
"""
__tablename__ = 'compliance_assertions'
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
tenant_id = Column(String(36), index=True)
entity_type = Column(String(50), nullable=False) # 'control', 'evidence', 'document', 'obligation'
entity_id = Column(String(36), nullable=False)
sentence_text = Column(Text, nullable=False)
sentence_index = Column(Integer, nullable=False, default=0)
assertion_type = Column(String(20), nullable=False, default='assertion') # 'assertion' | 'fact' | 'rationale'
evidence_ids = Column(JSON, default=list)
confidence = Column(Float, default=0.0)
normative_tier = Column(String(20)) # 'pflicht' | 'empfehlung' | 'kann'
verified_by = Column(String(200))
verified_at = Column(DateTime)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
__table_args__ = (
Index('ix_assertion_entity', 'entity_type', 'entity_id'),
Index('ix_assertion_type', 'assertion_type'),
)
def __repr__(self):
return f"<Assertion {self.assertion_type}: {self.sentence_text[:50]}>"

View File

@@ -487,6 +487,137 @@ class ControlRepository:
"compliance_score": round(score, 1),
}
def get_multi_dimensional_score(self) -> Dict[str, Any]:
"""
Calculate multi-dimensional compliance score (Anti-Fake-Evidence).
Returns 6 dimensions + hard_blocks + overall_readiness.
"""
from .models import (
EvidenceDB, RequirementDB, ControlMappingDB,
EvidenceConfidenceEnum, EvidenceTruthStatusEnum,
)
# Weight map for confidence levels
conf_weights = {"E0": 0.0, "E1": 0.25, "E2": 0.5, "E3": 0.75, "E4": 1.0}
validated_statuses = {"validated_internal", "accepted_by_auditor", "provided_to_auditor"}
controls = self.get_all()
total_controls = len(controls)
if total_controls == 0:
return {
"requirement_coverage": 0.0,
"evidence_strength": 0.0,
"validation_quality": 0.0,
"evidence_freshness": 0.0,
"control_effectiveness": 0.0,
"overall_readiness": 0.0,
"hard_blocks": ["Keine Controls vorhanden"],
}
# 1. requirement_coverage: % requirements linked to at least one control
total_reqs = self.db.query(func.count(RequirementDB.id)).scalar() or 0
linked_reqs = (
self.db.query(func.count(func.distinct(ControlMappingDB.requirement_id)))
.scalar() or 0
)
requirement_coverage = (linked_reqs / total_reqs * 100) if total_reqs > 0 else 0.0
# 2. evidence_strength: weighted average of evidence confidence
all_evidence = self.db.query(EvidenceDB).all()
if all_evidence:
total_weight = 0.0
for e in all_evidence:
conf_val = e.confidence_level.value if e.confidence_level else "E1"
total_weight += conf_weights.get(conf_val, 0.25)
evidence_strength = (total_weight / len(all_evidence)) * 100
else:
evidence_strength = 0.0
# 3. validation_quality: % evidence with truth_status >= validated_internal
if all_evidence:
validated_count = sum(
1 for e in all_evidence
if (e.truth_status.value if e.truth_status else "uploaded") in validated_statuses
)
validation_quality = (validated_count / len(all_evidence)) * 100
else:
validation_quality = 0.0
# 4. evidence_freshness: % evidence not expired and reviewed < 90 days
now = datetime.now()
if all_evidence:
fresh_count = 0
for e in all_evidence:
is_expired = e.valid_until and e.valid_until < now
is_stale = e.reviewed_at and (now - e.reviewed_at).days > 90 if hasattr(e, 'reviewed_at') and e.reviewed_at else False
if not is_expired and not is_stale:
fresh_count += 1
evidence_freshness = (fresh_count / len(all_evidence)) * 100
else:
evidence_freshness = 0.0
# 5. control_effectiveness: existing formula
passed = sum(1 for c in controls if c.status == ControlStatusEnum.PASS)
partial = sum(1 for c in controls if c.status == ControlStatusEnum.PARTIAL)
control_effectiveness = ((passed + partial * 0.5) / total_controls) * 100
# 6. overall_readiness: weighted composite
overall_readiness = (
0.20 * requirement_coverage +
0.25 * evidence_strength +
0.20 * validation_quality +
0.10 * evidence_freshness +
0.25 * control_effectiveness
)
# Hard blocks
hard_blocks = []
# Critical controls without any evidence
critical_no_evidence = []
for c in controls:
if c.status in (ControlStatusEnum.PASS, ControlStatusEnum.PARTIAL):
evidence_for_ctrl = [e for e in all_evidence if e.control_id == c.id]
if not evidence_for_ctrl:
critical_no_evidence.append(c.control_id)
if critical_no_evidence:
hard_blocks.append(
f"{len(critical_no_evidence)} Controls mit Status pass/partial haben keine Evidence: "
f"{', '.join(critical_no_evidence[:5])}"
)
# Controls with only E0/E1 evidence claiming pass
weak_evidence_pass = []
for c in controls:
if c.status == ControlStatusEnum.PASS:
evidence_for_ctrl = [e for e in all_evidence if e.control_id == c.id]
if evidence_for_ctrl:
max_conf = max(
conf_weights.get(
e.confidence_level.value if e.confidence_level else "E1", 0.25
)
for e in evidence_for_ctrl
)
if max_conf < 0.5: # Only E0 or E1
weak_evidence_pass.append(c.control_id)
if weak_evidence_pass:
hard_blocks.append(
f"{len(weak_evidence_pass)} Controls auf 'pass' haben nur E0/E1-Evidence: "
f"{', '.join(weak_evidence_pass[:5])}"
)
return {
"requirement_coverage": round(requirement_coverage, 1),
"evidence_strength": round(evidence_strength, 1),
"validation_quality": round(validation_quality, 1),
"evidence_freshness": round(evidence_freshness, 1),
"control_effectiveness": round(control_effectiveness, 1),
"overall_readiness": round(overall_readiness, 1),
"hard_blocks": hard_blocks,
}
class ControlMappingRepository:
"""Repository for requirement-control mappings."""

View File

@@ -0,0 +1,80 @@
"""Assertion Engine — splits text into sentences and classifies each.
Each sentence is tagged as:
- assertion: normative statement (pflicht / empfehlung / kann)
- fact: references concrete evidence artifacts
- rationale: explains why something is required
"""
import re
from typing import Optional
from .normative_patterns import (
PFLICHT_RE, EMPFEHLUNG_RE, KANN_RE, RATIONALE_RE, EVIDENCE_RE,
)
# Sentence splitter: period/excl/question followed by space+uppercase, or newlines
_SENTENCE_SPLIT = re.compile(r'(?<=[.!?])\s+(?=[A-ZÄÖÜ])|(?:\n\s*\n)')
def extract_assertions(
text: str,
entity_type: str,
entity_id: str,
tenant_id: Optional[str] = None,
) -> list[dict]:
"""Split *text* into sentences and classify each one.
Returns a list of dicts ready for AssertionDB creation.
"""
if not text or not text.strip():
return []
sentences = _SENTENCE_SPLIT.split(text.strip())
results: list[dict] = []
for idx, raw in enumerate(sentences):
sentence = raw.strip()
if not sentence or len(sentence) < 5:
continue
assertion_type, normative_tier = _classify_sentence(sentence)
results.append({
"tenant_id": tenant_id,
"entity_type": entity_type,
"entity_id": entity_id,
"sentence_text": sentence,
"sentence_index": idx,
"assertion_type": assertion_type,
"normative_tier": normative_tier,
"evidence_ids": [],
"confidence": 0.0,
})
return results
def _classify_sentence(sentence: str) -> tuple[str, Optional[str]]:
"""Return (assertion_type, normative_tier) for a single sentence."""
# 1. Check for evidence/fact keywords first
if EVIDENCE_RE.search(sentence):
return ("fact", None)
# 2. Check for rationale
normative_count = len(PFLICHT_RE.findall(sentence)) + len(EMPFEHLUNG_RE.findall(sentence)) + len(KANN_RE.findall(sentence))
rationale_count = len(RATIONALE_RE.findall(sentence))
if rationale_count > 0 and rationale_count >= normative_count:
return ("rationale", None)
# 3. Normative classification
if PFLICHT_RE.search(sentence):
return ("assertion", "pflicht")
if EMPFEHLUNG_RE.search(sentence):
return ("assertion", "empfehlung")
if KANN_RE.search(sentence):
return ("assertion", "kann")
# 4. Default: unclassified assertion
return ("assertion", None)

View File

@@ -493,6 +493,9 @@ class GeneratedControl:
applicable_industries: Optional[list] = None # e.g. ["all"] or ["Telekommunikation", "Energie"]
applicable_company_size: Optional[list] = None # e.g. ["all"] or ["medium", "large", "enterprise"]
scope_conditions: Optional[dict] = None # e.g. {"requires_any": ["uses_ai"], "description": "..."}
# Anti-Fake-Evidence: truth tracking for generated controls
truth_status: str = "generated"
may_be_used_as_evidence: bool = False
@dataclass
@@ -781,10 +784,23 @@ REFORM_SYSTEM_PROMPT = """Du bist ein Security-Compliance-Experte. Deine Aufgabe
Security Controls zu formulieren. Du formulierst IMMER in eigenen Worten.
KOPIERE KEINE Sätze aus dem Quelltext. Verwende eigene Begriffe und Struktur.
NENNE NICHT die Quelle. Keine proprietären Bezeichner.
WICHTIG — Truthfulness-Guardrail:
Deine Ausgabe ist ein ENTWURF. Formuliere NIEMALS Behauptungen über bereits erfolgte Umsetzung.
Verwende NICHT: "ist compliant", "erfüllt vollständig", "wurde geprüft", "wurde umgesetzt",
"ist auditiert", "vollständig implementiert", "nachweislich konform".
Verwende stattdessen: "soll umsetzen", "ist vorgesehen", "muss implementiert werden".
Antworte NUR mit validem JSON. Bei mehreren Controls antworte mit einem JSON-Array."""
STRUCTURE_SYSTEM_PROMPT = """Du bist ein Security-Compliance-Experte. Strukturiere den gegebenen Text
als praxisorientiertes Security Control. Erstelle eine verständliche, umsetzbare Formulierung.
WICHTIG — Truthfulness-Guardrail:
Deine Ausgabe ist ein ENTWURF. Formuliere NIEMALS Behauptungen über bereits erfolgte Umsetzung.
Verwende NICHT: "ist compliant", "erfüllt vollständig", "wurde geprüft", "wurde umgesetzt".
Verwende stattdessen: "soll umsetzen", "ist vorgesehen", "muss implementiert werden".
Antworte NUR mit validem JSON. Bei mehreren Controls antworte mit einem JSON-Array."""
# Shared applicability prompt block — appended to all generation prompts (v3)
@@ -1877,7 +1893,38 @@ Kategorien: {CATEGORY_LIST_STR}"""
)
self.db.commit()
row = result.fetchone()
return str(row[0]) if row else None
control_uuid = str(row[0]) if row else None
# Anti-Fake-Evidence: Record LLM audit trail for generated control
if control_uuid:
try:
self.db.execute(
text("""
INSERT INTO compliance_llm_generation_audit (
entity_type, entity_id, generation_mode,
truth_status, may_be_used_as_evidence,
llm_model, llm_provider,
input_summary, output_summary
) VALUES (
'control', :entity_id, 'auto_generation',
'generated', FALSE,
:llm_model, :llm_provider,
:input_summary, :output_summary
)
"""),
{
"entity_id": control_uuid,
"llm_model": ANTHROPIC_MODEL if ANTHROPIC_API_KEY else OLLAMA_MODEL,
"llm_provider": "anthropic" if ANTHROPIC_API_KEY else "ollama",
"input_summary": f"Control generation for {control.control_id}",
"output_summary": control.title[:500] if control.title else None,
},
)
self.db.commit()
except Exception as audit_err:
logger.warning("Failed to create LLM audit record: %s", audit_err)
return control_uuid
except Exception as e:
logger.error("Failed to store control %s: %s", control.control_id, e)
self.db.rollback()

View File

@@ -0,0 +1,152 @@
"""
Control Status Transition State Machine.
Enforces that controls cannot be set to "pass" without sufficient evidence.
Prevents Compliance-Theater where controls claim compliance without real proof.
Transition rules:
planned → in_progress : always allowed
in_progress → pass : requires ≥1 evidence with confidence ≥ E2 and
truth_status in (uploaded, observed, validated_internal)
in_progress → partial : requires ≥1 evidence (any level)
pass → fail : always allowed (degradation)
any → n/a : requires status_justification
any → planned : always allowed (reset)
"""
from typing import List, Optional, Tuple
from ..db.models import EvidenceDB
# Confidence level ordering for comparisons
CONFIDENCE_ORDER = {"E0": 0, "E1": 1, "E2": 2, "E3": 3, "E4": 4}
# Truth statuses that qualify as "real" evidence for pass transitions
VALID_TRUTH_STATUSES = {"uploaded", "observed", "validated_internal", "accepted_by_auditor", "provided_to_auditor"}
def validate_transition(
current_status: str,
new_status: str,
evidence_list: Optional[List[EvidenceDB]] = None,
status_justification: Optional[str] = None,
bypass_for_auto_updater: bool = False,
) -> Tuple[bool, List[str]]:
"""
Validate whether a control status transition is allowed.
Args:
current_status: Current control status value (e.g. "planned", "pass")
new_status: Requested new status
evidence_list: List of EvidenceDB objects linked to this control
status_justification: Text justification (required for n/a transitions)
bypass_for_auto_updater: If True, skip evidence checks (used by CI/CD auto-updater
which creates evidence atomically with status change)
Returns:
Tuple of (allowed: bool, violations: list[str])
"""
violations: List[str] = []
evidence_list = evidence_list or []
# Same status → no-op, always allowed
if current_status == new_status:
return True, []
# Reset to planned is always allowed
if new_status == "planned":
return True, []
# n/a requires justification
if new_status == "n/a":
if not status_justification or not status_justification.strip():
violations.append("Transition to 'n/a' requires a status_justification explaining why this control is not applicable.")
return len(violations) == 0, violations
# Degradation: pass → fail is always allowed
if current_status == "pass" and new_status == "fail":
return True, []
# planned → in_progress: always allowed
if current_status == "planned" and new_status == "in_progress":
return True, []
# in_progress → partial: needs at least 1 evidence
if new_status == "partial":
if not bypass_for_auto_updater and len(evidence_list) == 0:
violations.append("Transition to 'partial' requires at least 1 evidence record.")
return len(violations) == 0, violations
# in_progress → pass: strict requirements
if new_status == "pass":
if bypass_for_auto_updater:
return True, []
if len(evidence_list) == 0:
violations.append("Transition to 'pass' requires at least 1 evidence record.")
return False, violations
# Check for at least one qualifying evidence
has_qualifying = False
for e in evidence_list:
conf = getattr(e, "confidence_level", None)
truth = getattr(e, "truth_status", None)
# Get string values from enum or string
conf_val = conf.value if hasattr(conf, "value") else str(conf) if conf else "E1"
truth_val = truth.value if hasattr(truth, "value") else str(truth) if truth else "uploaded"
if CONFIDENCE_ORDER.get(conf_val, 1) >= CONFIDENCE_ORDER["E2"] and truth_val in VALID_TRUTH_STATUSES:
has_qualifying = True
break
if not has_qualifying:
violations.append(
"Transition to 'pass' requires at least 1 evidence with confidence >= E2 "
"and truth_status in (uploaded, observed, validated_internal, accepted_by_auditor). "
"Current evidence does not meet this threshold."
)
return len(violations) == 0, violations
# in_progress → fail: always allowed
if new_status == "fail":
return True, []
# Any other transition from planned/fail to pass requires going through in_progress
if current_status in ("planned", "fail") and new_status == "pass":
if bypass_for_auto_updater:
return True, []
violations.append(
f"Direct transition from '{current_status}' to 'pass' is not allowed. "
f"Move to 'in_progress' first, then to 'pass' with qualifying evidence."
)
return False, violations
# Default: allow other transitions (e.g. fail → partial, partial → pass)
# For partial → pass, apply the same evidence checks
if current_status == "partial" and new_status == "pass":
if bypass_for_auto_updater:
return True, []
has_qualifying = False
for e in evidence_list:
conf = getattr(e, "confidence_level", None)
truth = getattr(e, "truth_status", None)
conf_val = conf.value if hasattr(conf, "value") else str(conf) if conf else "E1"
truth_val = truth.value if hasattr(truth, "value") else str(truth) if truth else "uploaded"
if CONFIDENCE_ORDER.get(conf_val, 1) >= CONFIDENCE_ORDER["E2"] and truth_val in VALID_TRUTH_STATUSES:
has_qualifying = True
break
if not has_qualifying:
violations.append(
"Transition from 'partial' to 'pass' requires at least 1 evidence with confidence >= E2 "
"and truth_status in (uploaded, observed, validated_internal, accepted_by_auditor)."
)
return len(violations) == 0, violations
# All other transitions allowed
return True, []

View File

@@ -52,64 +52,18 @@ ANTHROPIC_API_URL = "https://api.anthropic.com/v1"
# Tier 2: Empfehlung (recommendation) — weaker normative signals
# Tier 3: Kann (optional/permissive) — permissive signals
# Nothing is rejected — everything is classified.
#
# Patterns are defined in normative_patterns.py and imported here
# with local aliases for backward compatibility.
_PFLICHT_SIGNALS = [
# Deutsche modale Pflichtformulierungen
r"\bmüssen\b", r"\bmuss\b", r"\bhat\s+sicherzustellen\b",
r"\bhaben\s+sicherzustellen\b", r"\bsind\s+verpflichtet\b",
r"\bist\s+verpflichtet\b",
# "ist zu prüfen", "sind zu dokumentieren" (direkt)
r"\bist\s+zu\s+\w+en\b", r"\bsind\s+zu\s+\w+en\b",
r"\bhat\s+zu\s+\w+en\b", r"\bhaben\s+zu\s+\w+en\b",
# "ist festzustellen", "sind vorzunehmen" (Compound-Verben, eingebettetes zu)
r"\bist\s+\w+zu\w+en\b", r"\bsind\s+\w+zu\w+en\b",
# "ist zusätzlich zu prüfen", "sind regelmäßig zu überwachen" (Adverb dazwischen)
r"\bist\s+\w+\s+zu\s+\w+en\b", r"\bsind\s+\w+\s+zu\s+\w+en\b",
r"\bhat\s+\w+\s+zu\s+\w+en\b", r"\bhaben\s+\w+\s+zu\s+\w+en\b",
# Englische Pflicht-Signale
r"\bshall\b", r"\bmust\b", r"\brequired\b",
# Compound-Infinitive (Gerundivum): mitzuteilen, anzuwenden, bereitzustellen
r"\b\w+zuteilen\b", r"\b\w+zuwenden\b", r"\b\w+zustellen\b", r"\b\w+zulegen\b",
r"\b\w+zunehmen\b", r"\b\w+zuführen\b", r"\b\w+zuhalten\b", r"\b\w+zusetzen\b",
r"\b\w+zuweisen\b", r"\b\w+zuordnen\b", r"\b\w+zufügen\b", r"\b\w+zugeben\b",
# Breites Pattern: "ist ... [bis 80 Zeichen] ... zu + Infinitiv"
r"\bist\b.{1,80}\bzu\s+\w+en\b", r"\bsind\b.{1,80}\bzu\s+\w+en\b",
]
_PFLICHT_RE = re.compile("|".join(_PFLICHT_SIGNALS), re.IGNORECASE)
_EMPFEHLUNG_SIGNALS = [
# Modale Verben (schwaecher als "muss")
r"\bsoll\b", r"\bsollen\b", r"\bsollte\b", r"\bsollten\b",
r"\bgewährleisten\b", r"\bsicherstellen\b",
# Englische Empfehlungs-Signale
r"\bshould\b", r"\bensure\b", r"\brecommend\w*\b",
# Haeufige normative Infinitive (ohne Hilfsverb, als Empfehlung)
r"\bnachweisen\b", r"\beinhalten\b", r"\bunterlassen\b", r"\bwahren\b",
r"\bdokumentieren\b", r"\bimplementieren\b", r"\büberprüfen\b", r"\büberwachen\b",
# Pruefanweisungen als normative Aussage
r"\bprüfen,\s+ob\b", r"\bkontrollieren,\s+ob\b",
]
_EMPFEHLUNG_RE = re.compile("|".join(_EMPFEHLUNG_SIGNALS), re.IGNORECASE)
_KANN_SIGNALS = [
r"\bkann\b", r"\bkönnen\b", r"\bdarf\b", r"\bdürfen\b",
r"\bmay\b", r"\boptional\b",
]
_KANN_RE = re.compile("|".join(_KANN_SIGNALS), re.IGNORECASE)
# Union of all normative signals (for backward-compatible has_normative_signal flag)
_NORMATIVE_RE = re.compile(
"|".join(_PFLICHT_SIGNALS + _EMPFEHLUNG_SIGNALS + _KANN_SIGNALS),
re.IGNORECASE,
from .normative_patterns import (
PFLICHT_RE as _PFLICHT_RE,
EMPFEHLUNG_RE as _EMPFEHLUNG_RE,
KANN_RE as _KANN_RE,
NORMATIVE_RE as _NORMATIVE_RE,
RATIONALE_RE as _RATIONALE_RE,
)
_RATIONALE_SIGNALS = [
r"\bda\s+", r"\bweil\b", r"\bgrund\b", r"\berwägung",
r"\bbecause\b", r"\breason\b", r"\brationale\b",
r"\bkönnen\s+.*\s+verursachen\b", r"\bführt\s+zu\b",
]
_RATIONALE_RE = re.compile("|".join(_RATIONALE_SIGNALS), re.IGNORECASE)
_TEST_SIGNALS = [
r"\btesten\b", r"\btest\b", r"\bprüfung\b", r"\bprüfen\b",
r"\bgetestet\b", r"\bwirksamkeit\b", r"\baudit\b",

View File

@@ -0,0 +1,59 @@
"""Shared normative language patterns for assertion classification.
Extracted from decomposition_pass.py for reuse in the assertion engine.
"""
import re
_PFLICHT_SIGNALS = [
r"\bmüssen\b", r"\bmuss\b", r"\bhat\s+sicherzustellen\b",
r"\bhaben\s+sicherzustellen\b", r"\bsind\s+verpflichtet\b",
r"\bist\s+verpflichtet\b",
r"\bist\s+zu\s+\w+en\b", r"\bsind\s+zu\s+\w+en\b",
r"\bhat\s+zu\s+\w+en\b", r"\bhaben\s+zu\s+\w+en\b",
r"\bist\s+\w+zu\w+en\b", r"\bsind\s+\w+zu\w+en\b",
r"\bist\s+\w+\s+zu\s+\w+en\b", r"\bsind\s+\w+\s+zu\s+\w+en\b",
r"\bhat\s+\w+\s+zu\s+\w+en\b", r"\bhaben\s+\w+\s+zu\s+\w+en\b",
r"\bshall\b", r"\bmust\b", r"\brequired\b",
r"\b\w+zuteilen\b", r"\b\w+zuwenden\b", r"\b\w+zustellen\b", r"\b\w+zulegen\b",
r"\b\w+zunehmen\b", r"\b\w+zuführen\b", r"\b\w+zuhalten\b", r"\b\w+zusetzen\b",
r"\b\w+zuweisen\b", r"\b\w+zuordnen\b", r"\b\w+zufügen\b", r"\b\w+zugeben\b",
r"\bist\b.{1,80}\bzu\s+\w+en\b", r"\bsind\b.{1,80}\bzu\s+\w+en\b",
]
PFLICHT_RE = re.compile("|".join(_PFLICHT_SIGNALS), re.IGNORECASE)
_EMPFEHLUNG_SIGNALS = [
r"\bsoll\b", r"\bsollen\b", r"\bsollte\b", r"\bsollten\b",
r"\bgewährleisten\b", r"\bsicherstellen\b",
r"\bshould\b", r"\bensure\b", r"\brecommend\w*\b",
r"\bnachweisen\b", r"\beinhalten\b", r"\bunterlassen\b", r"\bwahren\b",
r"\bdokumentieren\b", r"\bimplementieren\b", r"\büberprüfen\b", r"\büberwachen\b",
r"\bprüfen,\s+ob\b", r"\bkontrollieren,\s+ob\b",
]
EMPFEHLUNG_RE = re.compile("|".join(_EMPFEHLUNG_SIGNALS), re.IGNORECASE)
_KANN_SIGNALS = [
r"\bkann\b", r"\bkönnen\b", r"\bdarf\b", r"\bdürfen\b",
r"\bmay\b", r"\boptional\b",
]
KANN_RE = re.compile("|".join(_KANN_SIGNALS), re.IGNORECASE)
NORMATIVE_RE = re.compile(
"|".join(_PFLICHT_SIGNALS + _EMPFEHLUNG_SIGNALS + _KANN_SIGNALS),
re.IGNORECASE,
)
_RATIONALE_SIGNALS = [
r"\bda\s+", r"\bweil\b", r"\bgrund\b", r"\berwägung",
r"\bbecause\b", r"\breason\b", r"\brationale\b",
r"\bkönnen\s+.*\s+verursachen\b", r"\bführt\s+zu\b",
]
RATIONALE_RE = re.compile("|".join(_RATIONALE_SIGNALS), re.IGNORECASE)
# Evidence-related keywords (for fact detection)
_EVIDENCE_KEYWORDS = [
r"\bnachweis\b", r"\bzertifikat\b", r"\baudit.report\b",
r"\bprotokoll\b", r"\bdokumentation\b", r"\bbericht\b",
r"\bcertificate\b", r"\bevidence\b", r"\bproof\b",
]
EVIDENCE_RE = re.compile("|".join(_EVIDENCE_KEYWORDS), re.IGNORECASE)

View File

@@ -0,0 +1,125 @@
-- Migration 076: Anti-Fake-Evidence Guardrails (Phase 1)
--
-- Prevents "Compliance-Theater": generated content passed off as real evidence,
-- controls without evidence marked as "pass", unvalidated 100% compliance claims.
--
-- Changes:
-- 1. New ENUM types for evidence confidence + truth status
-- 2. New columns on compliance_evidence (confidence, truth, review tracking)
-- 3. New value 'in_progress' for controlstatusenum
-- 4. status_justification column on compliance_controls
-- 5. New table compliance_llm_generation_audit
-- 6. Backfill existing evidence based on source
-- 7. Indexes on new columns
-- ============================================================================
-- 1. New ENUM types
-- ============================================================================
-- NOTE: CREATE TYPE cannot run inside a transaction block when combined with
-- ALTER TYPE ... ADD VALUE. Each statement here is auto-committed separately
-- when executed outside a transaction (which is the default for psql scripts).
CREATE TYPE evidence_confidence_level AS ENUM (
'E0', -- Generated / no real evidence (LLM output, placeholder)
'E1', -- Uploaded but unreviewed (manual upload, no hash, no reviewer)
'E2', -- Reviewed internally (human reviewed, hash verified)
'E3', -- Observed by system (CI/CD pipeline, API with hash)
'E4' -- Validated by external auditor
);
CREATE TYPE evidence_truth_status AS ENUM (
'generated', -- Created by LLM / system generation
'uploaded', -- Manually uploaded by user
'observed', -- Automatically observed (CI/CD, monitoring)
'validated_internal', -- Reviewed + approved by internal reviewer
'rejected', -- Reviewed and rejected
'provided_to_auditor', -- Shared with external auditor
'accepted_by_auditor' -- Accepted by external auditor
);
-- ============================================================================
-- 2. Add 'in_progress' to controlstatusenum
-- ============================================================================
-- ALTER TYPE ... ADD VALUE cannot run inside a transaction.
ALTER TYPE controlstatusenum ADD VALUE IF NOT EXISTS 'in_progress';
-- ============================================================================
-- 3. New columns on compliance_evidence
-- ============================================================================
ALTER TABLE compliance_evidence
ADD COLUMN IF NOT EXISTS confidence_level evidence_confidence_level DEFAULT 'E1',
ADD COLUMN IF NOT EXISTS truth_status evidence_truth_status DEFAULT 'uploaded',
ADD COLUMN IF NOT EXISTS generation_mode VARCHAR(100),
ADD COLUMN IF NOT EXISTS may_be_used_as_evidence BOOLEAN DEFAULT TRUE,
ADD COLUMN IF NOT EXISTS reviewed_by VARCHAR(200),
ADD COLUMN IF NOT EXISTS reviewed_at TIMESTAMPTZ;
-- ============================================================================
-- 4. status_justification on compliance_controls
-- ============================================================================
ALTER TABLE compliance_controls
ADD COLUMN IF NOT EXISTS status_justification TEXT;
-- ============================================================================
-- 5. LLM Generation Audit table
-- ============================================================================
CREATE TABLE IF NOT EXISTS compliance_llm_generation_audit (
id VARCHAR(36) PRIMARY KEY DEFAULT gen_random_uuid()::text,
tenant_id VARCHAR(36),
entity_type VARCHAR(50) NOT NULL, -- 'evidence', 'control', 'document', ...
entity_id VARCHAR(36), -- FK to the generated entity
generation_mode VARCHAR(100) NOT NULL, -- 'draft_assistance', 'auto_generation', ...
truth_status evidence_truth_status NOT NULL DEFAULT 'generated',
may_be_used_as_evidence BOOLEAN NOT NULL DEFAULT FALSE,
llm_model VARCHAR(100),
llm_provider VARCHAR(50), -- 'ollama', 'anthropic', ...
prompt_hash VARCHAR(64), -- SHA-256 of the prompt
input_summary TEXT, -- Truncated input for auditability
output_summary TEXT, -- Truncated output for auditability
metadata JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================================
-- 6. Backfill existing evidence based on source
-- ============================================================================
-- CI pipeline evidence → E3 + observed
UPDATE compliance_evidence
SET confidence_level = 'E3',
truth_status = 'observed'
WHERE source = 'ci_pipeline'
AND confidence_level = 'E1';
-- API evidence → E3 + observed
UPDATE compliance_evidence
SET confidence_level = 'E3',
truth_status = 'observed'
WHERE source = 'api'
AND confidence_level = 'E1';
-- Manual/upload evidence stays at E1 + uploaded (default)
-- Generated evidence → E0 + generated
UPDATE compliance_evidence
SET confidence_level = 'E0',
truth_status = 'generated',
may_be_used_as_evidence = FALSE
WHERE source = 'generated'
AND confidence_level = 'E1';
-- ============================================================================
-- 7. Indexes
-- ============================================================================
CREATE INDEX IF NOT EXISTS ix_evidence_confidence ON compliance_evidence (confidence_level);
CREATE INDEX IF NOT EXISTS ix_evidence_truth_status ON compliance_evidence (truth_status);
CREATE INDEX IF NOT EXISTS ix_evidence_may_be_used ON compliance_evidence (may_be_used_as_evidence);
CREATE INDEX IF NOT EXISTS ix_llm_audit_entity ON compliance_llm_generation_audit (entity_type, entity_id);
CREATE INDEX IF NOT EXISTS ix_llm_audit_tenant ON compliance_llm_generation_audit (tenant_id);

View File

@@ -0,0 +1,37 @@
-- Migration 077: Anti-Fake-Evidence Phase 2
-- Assertions table, Four-Eyes columns on Evidence, Audit-Trail performance index
-- 1A. Assertions table
CREATE TABLE IF NOT EXISTS compliance_assertions (
id VARCHAR(36) PRIMARY KEY DEFAULT gen_random_uuid()::text,
tenant_id VARCHAR(36),
entity_type VARCHAR(50) NOT NULL,
entity_id VARCHAR(36) NOT NULL,
sentence_text TEXT NOT NULL,
sentence_index INTEGER NOT NULL DEFAULT 0,
assertion_type VARCHAR(20) NOT NULL DEFAULT 'assertion',
evidence_ids JSONB DEFAULT '[]'::jsonb,
confidence FLOAT DEFAULT 0.0,
normative_tier VARCHAR(20),
verified_by VARCHAR(200),
verified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS ix_assertion_entity ON compliance_assertions (entity_type, entity_id);
CREATE INDEX IF NOT EXISTS ix_assertion_type ON compliance_assertions (assertion_type);
CREATE INDEX IF NOT EXISTS ix_assertion_tenant ON compliance_assertions (tenant_id);
-- 1B. Four-Eyes columns on Evidence
ALTER TABLE compliance_evidence
ADD COLUMN IF NOT EXISTS approval_status VARCHAR(30) DEFAULT 'none',
ADD COLUMN IF NOT EXISTS first_reviewer VARCHAR(200),
ADD COLUMN IF NOT EXISTS first_reviewed_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS second_reviewer VARCHAR(200),
ADD COLUMN IF NOT EXISTS second_reviewed_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS requires_four_eyes BOOLEAN DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS ix_evidence_approval_status ON compliance_evidence (approval_status);
-- 1C. Audit-Trail performance index
CREATE INDEX IF NOT EXISTS ix_audit_trail_entity_action
ON compliance_audit_trail (entity_type, action, performed_at);

View File

@@ -0,0 +1,562 @@
"""Tests for Anti-Fake-Evidence Phase 1 guardrails.
~45 tests covering:
- Evidence confidence classification
- Evidence truth status classification
- Control status transition state machine
- Multi-dimensional compliance score
- LLM generation audit
- Evidence review endpoint
"""
from datetime import datetime, timedelta
from unittest.mock import MagicMock, patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from compliance.api.evidence_routes import router as evidence_router
from compliance.api.llm_audit_routes import router as llm_audit_router
from compliance.api.evidence_routes import _classify_confidence, _classify_truth_status
from compliance.services.control_status_machine import validate_transition
from compliance.db.models import (
EvidenceConfidenceEnum,
EvidenceTruthStatusEnum,
ControlStatusEnum,
)
from classroom_engine.database import get_db
# ---------------------------------------------------------------------------
# App setup with mocked DB dependency
# ---------------------------------------------------------------------------
app = FastAPI()
app.include_router(evidence_router)
app.include_router(llm_audit_router, prefix="/compliance")
mock_db = MagicMock()
def override_get_db():
yield mock_db
app.dependency_overrides[get_db] = override_get_db
client = TestClient(app)
EVIDENCE_UUID = "eeeeeeee-aaaa-bbbb-cccc-ffffffffffff"
CONTROL_UUID = "cccccccc-aaaa-bbbb-cccc-dddddddddddd"
NOW = datetime(2026, 3, 23, 12, 0, 0)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def make_evidence(overrides=None):
e = MagicMock()
e.id = EVIDENCE_UUID
e.control_id = CONTROL_UUID
e.evidence_type = "test_results"
e.title = "Pytest Test Report"
e.description = "All tests passing"
e.artifact_url = "https://ci.example.com/job/123/artifact"
e.artifact_path = None
e.artifact_hash = "abc123def456"
e.file_size_bytes = None
e.mime_type = None
e.status = MagicMock()
e.status.value = "valid"
e.uploaded_by = None
e.source = "ci_pipeline"
e.ci_job_id = "job-123"
e.valid_from = NOW
e.valid_until = NOW + timedelta(days=90)
e.collected_at = NOW
e.created_at = NOW
# Anti-fake-evidence fields
e.confidence_level = EvidenceConfidenceEnum.E3
e.truth_status = EvidenceTruthStatusEnum.OBSERVED
e.generation_mode = None
e.may_be_used_as_evidence = True
e.reviewed_by = None
e.reviewed_at = None
# Phase 2 fields
e.approval_status = "none"
e.first_reviewer = None
e.first_reviewed_at = None
e.second_reviewer = None
e.second_reviewed_at = None
e.requires_four_eyes = False
if overrides:
for k, v in overrides.items():
setattr(e, k, v)
return e
def make_control(overrides=None):
c = MagicMock()
c.id = CONTROL_UUID
c.control_id = "GOV-001"
c.title = "Access Control"
c.status = ControlStatusEnum.PLANNED
if overrides:
for k, v in overrides.items():
setattr(c, k, v)
return c
# ===========================================================================
# 1. TestEvidenceConfidenceClassification
# ===========================================================================
class TestEvidenceConfidenceClassification:
"""Test automatic confidence level classification."""
def test_ci_pipeline_returns_e3(self):
assert _classify_confidence("ci_pipeline") == EvidenceConfidenceEnum.E3
def test_api_with_hash_returns_e3(self):
assert _classify_confidence("api", artifact_hash="sha256:abc") == EvidenceConfidenceEnum.E3
def test_api_without_hash_returns_e3(self):
assert _classify_confidence("api") == EvidenceConfidenceEnum.E3
def test_manual_returns_e1(self):
assert _classify_confidence("manual") == EvidenceConfidenceEnum.E1
def test_upload_returns_e1(self):
assert _classify_confidence("upload") == EvidenceConfidenceEnum.E1
def test_generated_returns_e0(self):
assert _classify_confidence("generated") == EvidenceConfidenceEnum.E0
def test_unknown_source_returns_e1(self):
assert _classify_confidence("some_random_source") == EvidenceConfidenceEnum.E1
def test_none_source_returns_e1(self):
assert _classify_confidence(None) == EvidenceConfidenceEnum.E1
# ===========================================================================
# 2. TestEvidenceTruthStatus
# ===========================================================================
class TestEvidenceTruthStatus:
"""Test automatic truth status classification."""
def test_ci_pipeline_returns_observed(self):
assert _classify_truth_status("ci_pipeline") == EvidenceTruthStatusEnum.OBSERVED
def test_manual_returns_uploaded(self):
assert _classify_truth_status("manual") == EvidenceTruthStatusEnum.UPLOADED
def test_upload_returns_uploaded(self):
assert _classify_truth_status("upload") == EvidenceTruthStatusEnum.UPLOADED
def test_generated_returns_generated(self):
assert _classify_truth_status("generated") == EvidenceTruthStatusEnum.GENERATED
def test_api_returns_observed(self):
assert _classify_truth_status("api") == EvidenceTruthStatusEnum.OBSERVED
def test_none_returns_uploaded(self):
assert _classify_truth_status(None) == EvidenceTruthStatusEnum.UPLOADED
# ===========================================================================
# 3. TestControlStatusTransitions
# ===========================================================================
class TestControlStatusTransitions:
"""Test the control status transition state machine."""
def test_planned_to_in_progress_allowed(self):
allowed, violations = validate_transition("planned", "in_progress")
assert allowed is True
assert violations == []
def test_in_progress_to_pass_without_evidence_blocked(self):
allowed, violations = validate_transition("in_progress", "pass", evidence_list=[])
assert allowed is False
assert len(violations) > 0
assert "pass" in violations[0].lower()
def test_in_progress_to_pass_with_e2_evidence_allowed(self):
e = make_evidence({
"confidence_level": EvidenceConfidenceEnum.E2,
"truth_status": EvidenceTruthStatusEnum.VALIDATED_INTERNAL,
})
allowed, violations = validate_transition("in_progress", "pass", evidence_list=[e])
assert allowed is True
assert violations == []
def test_in_progress_to_pass_with_e1_evidence_blocked(self):
e = make_evidence({
"confidence_level": EvidenceConfidenceEnum.E1,
"truth_status": EvidenceTruthStatusEnum.UPLOADED,
})
allowed, violations = validate_transition("in_progress", "pass", evidence_list=[e])
assert allowed is False
assert "E2" in violations[0]
def test_in_progress_to_partial_with_evidence_allowed(self):
e = make_evidence({"confidence_level": EvidenceConfidenceEnum.E0})
allowed, violations = validate_transition("in_progress", "partial", evidence_list=[e])
assert allowed is True
def test_in_progress_to_partial_without_evidence_blocked(self):
allowed, violations = validate_transition("in_progress", "partial", evidence_list=[])
assert allowed is False
def test_pass_to_fail_always_allowed(self):
allowed, violations = validate_transition("pass", "fail")
assert allowed is True
def test_any_to_na_requires_justification(self):
allowed, violations = validate_transition("in_progress", "n/a", status_justification=None)
assert allowed is False
assert "justification" in violations[0].lower()
def test_any_to_na_with_justification_allowed(self):
allowed, violations = validate_transition("in_progress", "n/a", status_justification="Not applicable for this project")
assert allowed is True
def test_any_to_planned_always_allowed(self):
allowed, violations = validate_transition("pass", "planned")
assert allowed is True
def test_same_status_noop_allowed(self):
allowed, violations = validate_transition("pass", "pass")
assert allowed is True
def test_bypass_for_auto_updater(self):
allowed, violations = validate_transition("in_progress", "pass", evidence_list=[], bypass_for_auto_updater=True)
assert allowed is True
def test_partial_to_pass_needs_e2(self):
e = make_evidence({
"confidence_level": EvidenceConfidenceEnum.E1,
"truth_status": EvidenceTruthStatusEnum.UPLOADED,
})
allowed, violations = validate_transition("partial", "pass", evidence_list=[e])
assert allowed is False
def test_partial_to_pass_with_e3_allowed(self):
e = make_evidence({
"confidence_level": EvidenceConfidenceEnum.E3,
"truth_status": EvidenceTruthStatusEnum.OBSERVED,
})
allowed, violations = validate_transition("partial", "pass", evidence_list=[e])
assert allowed is True
def test_in_progress_to_fail_allowed(self):
allowed, violations = validate_transition("in_progress", "fail")
assert allowed is True
# ===========================================================================
# 4. TestMultiDimensionalScore
# ===========================================================================
class TestMultiDimensionalScore:
"""Test multi-dimensional score calculation."""
def test_score_structure(self):
"""Score result should have all required keys."""
from compliance.db.repository import ControlRepository
repo = ControlRepository(mock_db)
with patch.object(repo, 'get_all', return_value=[]):
result = repo.get_multi_dimensional_score()
assert "requirement_coverage" in result
assert "evidence_strength" in result
assert "validation_quality" in result
assert "evidence_freshness" in result
assert "control_effectiveness" in result
assert "overall_readiness" in result
assert "hard_blocks" in result
def test_empty_controls_returns_zeros(self):
from compliance.db.repository import ControlRepository
repo = ControlRepository(mock_db)
with patch.object(repo, 'get_all', return_value=[]):
result = repo.get_multi_dimensional_score()
assert result["overall_readiness"] == 0.0
assert "Keine Controls" in result["hard_blocks"][0]
def test_hard_blocks_pass_without_evidence(self):
"""Controls on 'pass' without evidence should trigger hard block."""
from compliance.db.repository import ControlRepository
repo = ControlRepository(mock_db)
ctrl = make_control({"status": ControlStatusEnum.PASS})
mock_db.query.return_value.all.return_value = [] # no evidence
mock_db.query.return_value.scalar.return_value = 0
with patch.object(repo, 'get_all', return_value=[ctrl]):
result = repo.get_multi_dimensional_score()
assert any("Evidence" in b or "evidence" in b.lower() for b in result["hard_blocks"])
def test_all_dimensions_are_floats(self):
from compliance.db.repository import ControlRepository
repo = ControlRepository(mock_db)
with patch.object(repo, 'get_all', return_value=[]):
result = repo.get_multi_dimensional_score()
for key in ["requirement_coverage", "evidence_strength", "validation_quality",
"evidence_freshness", "control_effectiveness", "overall_readiness"]:
assert isinstance(result[key], float), f"{key} should be float"
def test_hard_blocks_is_list(self):
from compliance.db.repository import ControlRepository
repo = ControlRepository(mock_db)
with patch.object(repo, 'get_all', return_value=[]):
result = repo.get_multi_dimensional_score()
assert isinstance(result["hard_blocks"], list)
def test_backwards_compatibility_with_old_score(self):
"""get_statistics should still work and return compliance_score."""
from compliance.db.repository import ControlRepository
repo = ControlRepository(mock_db)
mock_db.query.return_value.scalar.return_value = 0
mock_db.query.return_value.group_by.return_value.all.return_value = []
result = repo.get_statistics()
assert "compliance_score" in result
assert "total" in result
# ===========================================================================
# 5. TestForbiddenFormulations
# ===========================================================================
class TestForbiddenFormulations:
"""Test forbidden formulation detection (tested via the validate endpoint context)."""
def test_import_works(self):
"""Verify forbidden pattern check function is importable and callable."""
# This tests the Python-side schema, the actual check is in TypeScript
from compliance.api.schemas import MultiDimensionalScore, StatusTransitionError
score = MultiDimensionalScore()
assert score.overall_readiness == 0.0
err = StatusTransitionError(current_status="planned", requested_status="pass")
assert err.allowed is False
def test_status_transition_error_schema(self):
from compliance.api.schemas import StatusTransitionError
err = StatusTransitionError(
allowed=False,
current_status="in_progress",
requested_status="pass",
violations=["Need E2 evidence"],
)
assert err.violations == ["Need E2 evidence"]
def test_multi_dimensional_score_defaults(self):
from compliance.api.schemas import MultiDimensionalScore
score = MultiDimensionalScore()
assert score.requirement_coverage == 0.0
assert score.hard_blocks == []
def test_multi_dimensional_score_with_data(self):
from compliance.api.schemas import MultiDimensionalScore
score = MultiDimensionalScore(
requirement_coverage=80.0,
evidence_strength=60.0,
validation_quality=40.0,
evidence_freshness=90.0,
control_effectiveness=70.0,
overall_readiness=65.0,
hard_blocks=["3 Controls ohne Evidence"],
)
assert score.overall_readiness == 65.0
assert len(score.hard_blocks) == 1
def test_evidence_response_has_anti_fake_fields(self):
from compliance.api.schemas import EvidenceResponse
fields = EvidenceResponse.model_fields
assert "confidence_level" in fields
assert "truth_status" in fields
assert "generation_mode" in fields
assert "may_be_used_as_evidence" in fields
assert "reviewed_by" in fields
assert "reviewed_at" in fields
# ===========================================================================
# 6. TestLLMGenerationAudit
# ===========================================================================
class TestLLMGenerationAudit:
"""Test LLM generation audit trail."""
def test_create_audit_record(self):
"""POST /compliance/llm-audit should create a record."""
mock_record = MagicMock()
mock_record.id = "audit-001"
mock_record.tenant_id = None
mock_record.entity_type = "document"
mock_record.entity_id = None
mock_record.generation_mode = "draft_assistance"
mock_record.truth_status = EvidenceTruthStatusEnum.GENERATED
mock_record.may_be_used_as_evidence = False
mock_record.llm_model = "qwen2.5vl:32b"
mock_record.llm_provider = "ollama"
mock_record.prompt_hash = None
mock_record.input_summary = "Test input"
mock_record.output_summary = "Test output"
mock_record.extra_metadata = {}
mock_record.created_at = NOW
mock_db.add = MagicMock()
mock_db.commit = MagicMock()
mock_db.refresh = MagicMock(side_effect=lambda r: setattr(r, 'id', 'audit-001'))
# We need to patch the LLMGenerationAuditDB constructor
with patch('compliance.api.llm_audit_routes.LLMGenerationAuditDB', return_value=mock_record):
resp = client.post("/compliance/llm-audit", json={
"entity_type": "document",
"generation_mode": "draft_assistance",
"truth_status": "generated",
"may_be_used_as_evidence": False,
"llm_model": "qwen2.5vl:32b",
"llm_provider": "ollama",
})
assert resp.status_code == 200
data = resp.json()
assert data["entity_type"] == "document"
assert data["truth_status"] == "generated"
assert data["may_be_used_as_evidence"] is False
def test_truth_status_always_generated_for_llm(self):
"""LLM-generated content should always start with truth_status=generated."""
from compliance.db.models import LLMGenerationAuditDB, EvidenceTruthStatusEnum
audit = LLMGenerationAuditDB()
# Default should be GENERATED
assert audit.truth_status is None or audit.truth_status == EvidenceTruthStatusEnum.GENERATED
def test_may_be_used_as_evidence_defaults_false(self):
"""Generated content should NOT be usable as evidence by default."""
from compliance.db.models import LLMGenerationAuditDB
audit = LLMGenerationAuditDB()
assert audit.may_be_used_as_evidence is False or audit.may_be_used_as_evidence is None
def test_list_audit_records(self):
"""GET /compliance/llm-audit should return records."""
mock_query = MagicMock()
mock_query.count.return_value = 0
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.offset.return_value = mock_query
mock_query.limit.return_value = mock_query
mock_query.all.return_value = []
mock_db.query.return_value = mock_query
resp = client.get("/compliance/llm-audit")
assert resp.status_code == 200
data = resp.json()
assert "records" in data
assert "total" in data
assert data["total"] == 0
# ===========================================================================
# 7. TestEvidenceReview
# ===========================================================================
class TestEvidenceReview:
"""Test evidence review endpoint."""
def test_review_upgrades_confidence(self):
"""PATCH /evidence/{id}/review should update confidence and set reviewer."""
evidence = make_evidence({
"confidence_level": EvidenceConfidenceEnum.E1,
"truth_status": EvidenceTruthStatusEnum.UPLOADED,
})
mock_db.query.return_value.filter.return_value.first.return_value = evidence
mock_db.commit = MagicMock()
mock_db.refresh = MagicMock()
resp = client.patch(f"/evidence/{EVIDENCE_UUID}/review", json={
"confidence_level": "E2",
"truth_status": "validated_internal",
"reviewed_by": "auditor@example.com",
})
assert resp.status_code == 200
# Verify the evidence was updated
assert evidence.confidence_level == EvidenceConfidenceEnum.E2
assert evidence.truth_status == EvidenceTruthStatusEnum.VALIDATED_INTERNAL
assert evidence.reviewed_by == "auditor@example.com"
assert evidence.reviewed_at is not None
def test_review_nonexistent_evidence_returns_404(self):
mock_db.query.return_value.filter.return_value.first.return_value = None
resp = client.patch("/evidence/nonexistent-id/review", json={
"reviewed_by": "someone",
})
assert resp.status_code == 404
def test_review_invalid_confidence_returns_400(self):
evidence = make_evidence()
mock_db.query.return_value.filter.return_value.first.return_value = evidence
resp = client.patch(f"/evidence/{EVIDENCE_UUID}/review", json={
"confidence_level": "INVALID",
"reviewed_by": "someone",
})
assert resp.status_code == 400
# ===========================================================================
# 8. TestControlUpdateIntegration
# ===========================================================================
class TestControlUpdateIntegration:
"""Test that ControlUpdate schema includes status_justification."""
def test_control_update_has_status_justification(self):
from compliance.api.schemas import ControlUpdate
fields = ControlUpdate.model_fields
assert "status_justification" in fields
def test_control_response_has_status_justification(self):
from compliance.api.schemas import ControlResponse
fields = ControlResponse.model_fields
assert "status_justification" in fields
def test_control_status_enum_has_in_progress(self):
assert ControlStatusEnum.IN_PROGRESS.value == "in_progress"
# ===========================================================================
# 9. TestEvidenceEnums
# ===========================================================================
class TestEvidenceEnums:
"""Test the new evidence enums."""
def test_confidence_enum_values(self):
assert EvidenceConfidenceEnum.E0.value == "E0"
assert EvidenceConfidenceEnum.E1.value == "E1"
assert EvidenceConfidenceEnum.E2.value == "E2"
assert EvidenceConfidenceEnum.E3.value == "E3"
assert EvidenceConfidenceEnum.E4.value == "E4"
def test_truth_status_enum_values(self):
assert EvidenceTruthStatusEnum.GENERATED.value == "generated"
assert EvidenceTruthStatusEnum.UPLOADED.value == "uploaded"
assert EvidenceTruthStatusEnum.OBSERVED.value == "observed"
assert EvidenceTruthStatusEnum.VALIDATED_INTERNAL.value == "validated_internal"
assert EvidenceTruthStatusEnum.REJECTED.value == "rejected"
assert EvidenceTruthStatusEnum.PROVIDED_TO_AUDITOR.value == "provided_to_auditor"
assert EvidenceTruthStatusEnum.ACCEPTED_BY_AUDITOR.value == "accepted_by_auditor"

View File

@@ -0,0 +1,528 @@
"""Tests for Anti-Fake-Evidence Phase 2.
~35 tests covering:
- Audit trail extension (evidence review/create logging)
- Assertion engine (extraction, CRUD, verify, summary)
- Four-Eyes review (domain check, first/second review, same-person reject)
- UI badge data (response schema includes new fields)
"""
from datetime import datetime, timedelta
from unittest.mock import MagicMock, patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from compliance.api.evidence_routes import (
router as evidence_router,
_requires_four_eyes,
_classify_confidence,
_classify_truth_status,
)
from compliance.api.assertion_routes import router as assertion_router
from compliance.services.assertion_engine import extract_assertions, _classify_sentence
from compliance.db.models import (
EvidenceConfidenceEnum,
EvidenceTruthStatusEnum,
ControlStatusEnum,
AssertionDB,
)
from classroom_engine.database import get_db
# ---------------------------------------------------------------------------
# App setup with mocked DB dependency
# ---------------------------------------------------------------------------
app = FastAPI()
app.include_router(evidence_router)
app.include_router(assertion_router)
mock_db = MagicMock()
def override_get_db():
yield mock_db
app.dependency_overrides[get_db] = override_get_db
client = TestClient(app)
EVIDENCE_UUID = "eeee0002-aaaa-bbbb-cccc-ffffffffffff"
CONTROL_UUID = "cccc0002-aaaa-bbbb-cccc-dddddddddddd"
ASSERTION_UUID = "aaaa0002-bbbb-cccc-dddd-eeeeeeeeeeee"
NOW = datetime(2026, 3, 23, 14, 0, 0)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def make_evidence(overrides=None):
e = MagicMock()
e.id = EVIDENCE_UUID
e.control_id = CONTROL_UUID
e.evidence_type = "test_results"
e.title = "Phase 2 Test Evidence"
e.description = "Testing four-eyes"
e.artifact_url = "https://ci.example.com/artifact"
e.artifact_path = None
e.artifact_hash = "abc123"
e.file_size_bytes = None
e.mime_type = None
e.status = MagicMock()
e.status.value = "valid"
e.uploaded_by = None
e.source = "api"
e.ci_job_id = None
e.valid_from = NOW
e.valid_until = NOW + timedelta(days=90)
e.collected_at = NOW
e.created_at = NOW
e.confidence_level = EvidenceConfidenceEnum.E1
e.truth_status = EvidenceTruthStatusEnum.UPLOADED
e.generation_mode = None
e.may_be_used_as_evidence = True
e.reviewed_by = None
e.reviewed_at = None
# Phase 2 fields
e.approval_status = "none"
e.first_reviewer = None
e.first_reviewed_at = None
e.second_reviewer = None
e.second_reviewed_at = None
e.requires_four_eyes = False
if overrides:
for k, v in overrides.items():
setattr(e, k, v)
return e
def make_assertion(overrides=None):
a = MagicMock()
a.id = ASSERTION_UUID
a.tenant_id = "tenant-001"
a.entity_type = "control"
a.entity_id = CONTROL_UUID
a.sentence_text = "Test assertion sentence"
a.sentence_index = 0
a.assertion_type = "assertion"
a.evidence_ids = []
a.confidence = 0.0
a.normative_tier = "pflicht"
a.verified_by = None
a.verified_at = None
a.created_at = NOW
a.updated_at = NOW
if overrides:
for k, v in overrides.items():
setattr(a, k, v)
return a
# ===========================================================================
# 1. TestAuditTrailExtension
# ===========================================================================
class TestAuditTrailExtension:
"""Test that evidence review and create log audit trail entries."""
def test_review_evidence_logs_audit_trail(self):
evidence = make_evidence()
mock_db.reset_mock()
mock_db.query.return_value.filter.return_value.first.return_value = evidence
mock_db.refresh.return_value = None
resp = client.patch(
f"/evidence/{EVIDENCE_UUID}/review",
json={"confidence_level": "E2", "reviewed_by": "auditor@test.com"},
)
assert resp.status_code == 200
# db.add should be called for audit trail entries
assert mock_db.add.called
def test_review_evidence_records_old_and_new_confidence(self):
evidence = make_evidence({"confidence_level": EvidenceConfidenceEnum.E1})
mock_db.reset_mock()
mock_db.query.return_value.filter.return_value.first.return_value = evidence
mock_db.refresh.return_value = None
resp = client.patch(
f"/evidence/{EVIDENCE_UUID}/review",
json={"confidence_level": "E3", "reviewed_by": "reviewer@test.com"},
)
assert resp.status_code == 200
def test_review_evidence_records_truth_status_change(self):
evidence = make_evidence({"truth_status": EvidenceTruthStatusEnum.UPLOADED})
mock_db.reset_mock()
mock_db.query.return_value.filter.return_value.first.return_value = evidence
mock_db.refresh.return_value = None
resp = client.patch(
f"/evidence/{EVIDENCE_UUID}/review",
json={"truth_status": "validated_internal", "reviewed_by": "reviewer@test.com"},
)
assert resp.status_code == 200
def test_review_nonexistent_evidence_returns_404(self):
mock_db.reset_mock()
mock_db.query.return_value.filter.return_value.first.return_value = None
resp = client.patch(
"/evidence/nonexistent/review",
json={"reviewed_by": "someone"},
)
assert resp.status_code == 404
def test_reject_evidence_logs_audit_trail(self):
evidence = make_evidence()
mock_db.reset_mock()
mock_db.query.return_value.filter.return_value.first.return_value = evidence
mock_db.refresh.return_value = None
resp = client.patch(
f"/evidence/{EVIDENCE_UUID}/reject",
json={"reviewed_by": "auditor@test.com", "rejection_reason": "Fake evidence"},
)
assert resp.status_code == 200
data = resp.json()
assert data["approval_status"] == "rejected"
def test_reject_nonexistent_evidence_returns_404(self):
mock_db.reset_mock()
mock_db.query.return_value.filter.return_value.first.return_value = None
resp = client.patch(
"/evidence/nonexistent/reject",
json={"reviewed_by": "someone"},
)
assert resp.status_code == 404
def test_audit_trail_query_endpoint(self):
mock_db.reset_mock()
trail_entry = MagicMock()
trail_entry.id = "trail-001"
trail_entry.entity_type = "evidence"
trail_entry.entity_id = EVIDENCE_UUID
trail_entry.entity_name = "Test"
trail_entry.action = "review"
trail_entry.field_changed = "confidence_level"
trail_entry.old_value = "E1"
trail_entry.new_value = "E2"
trail_entry.change_summary = None
trail_entry.performed_by = "auditor"
trail_entry.performed_at = NOW
trail_entry.checksum = "abc"
mock_db.query.return_value.filter.return_value.filter.return_value.order_by.return_value.limit.return_value.all.return_value = [trail_entry]
resp = client.get(f"/audit-trail?entity_type=evidence&entity_id={EVIDENCE_UUID}")
assert resp.status_code == 200
data = resp.json()
assert data["total"] >= 1
def test_audit_trail_checksum_present(self):
"""Audit trail entries should have a checksum for integrity."""
from compliance.api.audit_trail_utils import create_signature
sig = create_signature("evidence|123|review|user@test.com")
assert len(sig) == 64 # SHA-256 hex digest
# ===========================================================================
# 2. TestAssertionEngine
# ===========================================================================
class TestAssertionEngine:
"""Test assertion extraction and classification."""
def test_pflicht_sentence_classified_as_assertion(self):
result = _classify_sentence("Die Organisation muss ein ISMS implementieren.")
assert result == ("assertion", "pflicht")
def test_empfehlung_sentence_classified(self):
result = _classify_sentence("Die Organisation sollte regelmäßige Audits durchführen.")
assert result == ("assertion", "empfehlung")
def test_kann_sentence_classified(self):
result = _classify_sentence("Optional kann ein externes Audit durchgeführt werden.")
assert result == ("assertion", "kann")
def test_rationale_sentence_classified(self):
result = _classify_sentence("Dies ist erforderlich, weil Datenverlust schwere Folgen hat.")
assert result == ("rationale", None)
def test_fact_sentence_with_evidence_keyword(self):
result = _classify_sentence("Das Zertifikat wurde am 15.03.2026 ausgestellt.")
assert result == ("fact", None)
def test_extract_assertions_splits_sentences(self):
text = "Die Organisation muss Daten schützen. Sie sollte regelmäßig prüfen."
results = extract_assertions(text, "control", "ctrl-001")
assert len(results) == 2
assert results[0]["assertion_type"] == "assertion"
assert results[0]["normative_tier"] == "pflicht"
assert results[1]["normative_tier"] == "empfehlung"
def test_extract_assertions_empty_text(self):
results = extract_assertions("", "control", "ctrl-001")
assert results == []
def test_extract_assertions_single_sentence(self):
results = extract_assertions("Der Betreiber muss ein Audit durchführen.", "control", "ctrl-001")
assert len(results) == 1
assert results[0]["normative_tier"] == "pflicht"
def test_mixed_text_with_rationale(self):
text = "Die Organisation muss ein ISMS implementieren. Dies ist notwendig, weil Compliance gefordert ist."
results = extract_assertions(text, "control", "ctrl-001")
assert len(results) == 2
types = [r["assertion_type"] for r in results]
assert "assertion" in types
assert "rationale" in types
def test_assertion_crud_create(self):
mock_db.reset_mock()
mock_db.refresh.return_value = None
# Mock the added object to return proper values
def side_effect_add(obj):
obj.id = ASSERTION_UUID
obj.created_at = NOW
obj.updated_at = NOW
obj.sentence_index = 0
obj.confidence = 0.0
mock_db.add.side_effect = side_effect_add
resp = client.post(
"/assertions?tenant_id=tenant-001",
json={
"entity_type": "control",
"entity_id": CONTROL_UUID,
"sentence_text": "Die Organisation muss ein ISMS implementieren.",
"assertion_type": "assertion",
"normative_tier": "pflicht",
},
)
assert resp.status_code == 200
def test_assertion_verify_endpoint(self):
a = make_assertion()
mock_db.reset_mock()
mock_db.query.return_value.filter.return_value.first.return_value = a
mock_db.refresh.return_value = None
resp = client.post(f"/assertions/{ASSERTION_UUID}/verify?verified_by=auditor@test.com")
assert resp.status_code == 200
assert a.assertion_type == "fact"
assert a.verified_by == "auditor@test.com"
def test_assertion_summary(self):
mock_db.reset_mock()
a1 = make_assertion({"assertion_type": "assertion", "verified_by": None})
a2 = make_assertion({"assertion_type": "fact", "verified_by": "user"})
a3 = make_assertion({"assertion_type": "rationale", "verified_by": None})
mock_db.query.return_value.filter.return_value.filter.return_value.filter.return_value.all.return_value = [a1, a2, a3]
# Direct .all() for no-filter case
mock_db.query.return_value.all.return_value = [a1, a2, a3]
resp = client.get("/assertions/summary")
assert resp.status_code == 200
data = resp.json()
assert data["total_assertions"] == 3
assert data["total_facts"] == 1
assert data["total_rationale"] == 1
assert data["unverified_count"] == 1
# ===========================================================================
# 3. TestFourEyesReview
# ===========================================================================
class TestFourEyesReview:
"""Test Four-Eyes review process."""
def test_gov_domain_requires_four_eyes(self):
assert _requires_four_eyes("gov") is True
def test_priv_domain_requires_four_eyes(self):
assert _requires_four_eyes("priv") is True
def test_ops_domain_does_not_require_four_eyes(self):
assert _requires_four_eyes("ops") is False
def test_sdlc_domain_does_not_require_four_eyes(self):
assert _requires_four_eyes("sdlc") is False
def test_first_review_sets_first_approved(self):
evidence = make_evidence({
"requires_four_eyes": True,
"approval_status": "pending_first",
})
mock_db.reset_mock()
mock_db.query.return_value.filter.return_value.first.return_value = evidence
mock_db.refresh.return_value = None
resp = client.patch(
f"/evidence/{EVIDENCE_UUID}/review",
json={"reviewed_by": "reviewer1@test.com"},
)
assert resp.status_code == 200
assert evidence.first_reviewer == "reviewer1@test.com"
assert evidence.approval_status == "first_approved"
def test_second_review_different_person_approves(self):
evidence = make_evidence({
"requires_four_eyes": True,
"approval_status": "first_approved",
"first_reviewer": "reviewer1@test.com",
})
mock_db.reset_mock()
mock_db.query.return_value.filter.return_value.first.return_value = evidence
mock_db.refresh.return_value = None
resp = client.patch(
f"/evidence/{EVIDENCE_UUID}/review",
json={"reviewed_by": "reviewer2@test.com"},
)
assert resp.status_code == 200
assert evidence.second_reviewer == "reviewer2@test.com"
assert evidence.approval_status == "approved"
def test_same_person_second_review_rejected(self):
evidence = make_evidence({
"requires_four_eyes": True,
"approval_status": "first_approved",
"first_reviewer": "reviewer1@test.com",
})
mock_db.reset_mock()
mock_db.query.return_value.filter.return_value.first.return_value = evidence
resp = client.patch(
f"/evidence/{EVIDENCE_UUID}/review",
json={"reviewed_by": "reviewer1@test.com"},
)
assert resp.status_code == 400
assert "different" in resp.json()["detail"].lower()
def test_already_approved_blocked(self):
evidence = make_evidence({
"requires_four_eyes": True,
"approval_status": "approved",
})
mock_db.reset_mock()
mock_db.query.return_value.filter.return_value.first.return_value = evidence
resp = client.patch(
f"/evidence/{EVIDENCE_UUID}/review",
json={"reviewed_by": "reviewer3@test.com"},
)
assert resp.status_code == 400
assert "already" in resp.json()["detail"].lower()
def test_rejected_evidence_cannot_be_reviewed(self):
evidence = make_evidence({
"requires_four_eyes": True,
"approval_status": "rejected",
})
mock_db.reset_mock()
mock_db.query.return_value.filter.return_value.first.return_value = evidence
resp = client.patch(
f"/evidence/{EVIDENCE_UUID}/review",
json={"reviewed_by": "reviewer@test.com"},
)
assert resp.status_code == 400
def test_reject_endpoint(self):
evidence = make_evidence({"requires_four_eyes": True})
mock_db.reset_mock()
mock_db.query.return_value.filter.return_value.first.return_value = evidence
mock_db.refresh.return_value = None
resp = client.patch(
f"/evidence/{EVIDENCE_UUID}/reject",
json={"reviewed_by": "auditor@test.com", "rejection_reason": "Not authentic"},
)
assert resp.status_code == 200
assert evidence.approval_status == "rejected"
# ===========================================================================
# 4. TestUIBadgeData
# ===========================================================================
class TestUIBadgeData:
"""Test that evidence response includes all Phase 2 fields."""
def test_evidence_response_includes_approval_status(self):
evidence = make_evidence({
"approval_status": "first_approved",
"first_reviewer": "reviewer1@test.com",
"first_reviewed_at": NOW,
"requires_four_eyes": True,
})
mock_db.reset_mock()
mock_db.query.return_value.filter.return_value.first.return_value = evidence
mock_db.refresh.return_value = None
resp = client.patch(
f"/evidence/{EVIDENCE_UUID}/review",
json={"reviewed_by": "reviewer2@test.com"},
)
assert resp.status_code == 200
data = resp.json()
assert "approval_status" in data
assert "requires_four_eyes" in data
assert data["requires_four_eyes"] is True
def test_evidence_response_includes_four_eyes_fields(self):
evidence = make_evidence({
"requires_four_eyes": True,
"approval_status": "approved",
"first_reviewer": "r1@test.com",
"first_reviewed_at": NOW,
"second_reviewer": "r2@test.com",
"second_reviewed_at": NOW,
})
mock_db.reset_mock()
mock_db.query.return_value.filter.return_value.first.return_value = evidence
# Use list endpoint
mock_db.query.return_value.filter.return_value.all.return_value = [evidence]
mock_db.query.return_value.all.return_value = [evidence]
# Direct test via _build_evidence_response
from compliance.api.evidence_routes import _build_evidence_response
resp = _build_evidence_response(evidence)
assert resp.approval_status == "approved"
assert resp.first_reviewer == "r1@test.com"
assert resp.second_reviewer == "r2@test.com"
assert resp.requires_four_eyes is True
def test_assertion_response_schema(self):
a = make_assertion()
mock_db.reset_mock()
mock_db.query.return_value.filter.return_value.first.return_value = a
resp = client.get(f"/assertions/{ASSERTION_UUID}")
assert resp.status_code == 200
data = resp.json()
assert "assertion_type" in data
assert "normative_tier" in data
assert "evidence_ids" in data
assert "verified_by" in data
def test_evidence_response_includes_confidence_and_truth(self):
evidence = make_evidence({
"confidence_level": EvidenceConfidenceEnum.E3,
"truth_status": EvidenceTruthStatusEnum.OBSERVED,
})
from compliance.api.evidence_routes import _build_evidence_response
resp = _build_evidence_response(evidence)
assert resp.confidence_level == "E3"
assert resp.truth_status == "observed"
def test_evidence_response_none_four_eyes_fields_default(self):
evidence = make_evidence()
from compliance.api.evidence_routes import _build_evidence_response
resp = _build_evidence_response(evidence)
assert resp.approval_status == "none"
assert resp.requires_four_eyes is False
assert resp.first_reviewer is None

View File

@@ -0,0 +1,191 @@
"""Tests for Anti-Fake-Evidence Phase 3: Enforcement.
~8 tests covering:
- Evidence distribution endpoint (confidence counts, four-eyes pending)
- Dashboard multi-score presence
"""
from datetime import datetime, timedelta
from unittest.mock import MagicMock, patch, PropertyMock
from fastapi import FastAPI
from fastapi.testclient import TestClient
from compliance.api.dashboard_routes import router as dashboard_router
from compliance.db.models import EvidenceConfidenceEnum, EvidenceTruthStatusEnum
from classroom_engine.database import get_db
# ---------------------------------------------------------------------------
# App setup with mocked DB dependency
# ---------------------------------------------------------------------------
app = FastAPI()
app.include_router(dashboard_router)
mock_db = MagicMock()
def override_get_db():
yield mock_db
app.dependency_overrides[get_db] = override_get_db
client = TestClient(app)
NOW = datetime(2026, 3, 23, 14, 0, 0)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def make_evidence(confidence="E1", requires_four_eyes=False, approval_status="none"):
e = MagicMock()
e.confidence_level = MagicMock()
e.confidence_level.value = confidence
e.requires_four_eyes = requires_four_eyes
e.approval_status = approval_status
return e
# ===========================================================================
# 1. TestEvidenceDistributionEndpoint
# ===========================================================================
class TestEvidenceDistributionEndpoint:
"""Test GET /dashboard/evidence-distribution endpoint."""
def _setup_evidence(self, evidence_list):
"""Configure mock DB to return evidence list via EvidenceRepository."""
mock_db.reset_mock()
# EvidenceRepository(db).get_all() internally does db.query(...).all()
# We patch the EvidenceRepository class to return our list
return evidence_list
@patch("compliance.api.dashboard_routes.EvidenceRepository")
def test_empty_db_returns_zero_counts(self, mock_repo_cls):
mock_repo = MagicMock()
mock_repo.get_all.return_value = []
mock_repo_cls.return_value = mock_repo
resp = client.get("/dashboard/evidence-distribution")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 0
assert data["four_eyes_pending"] == 0
assert data["by_confidence"] == {"E0": 0, "E1": 0, "E2": 0, "E3": 0, "E4": 0}
@patch("compliance.api.dashboard_routes.EvidenceRepository")
def test_counts_by_confidence_level(self, mock_repo_cls):
evidence = [
make_evidence("E0"),
make_evidence("E1"),
make_evidence("E1"),
make_evidence("E2"),
make_evidence("E3"),
make_evidence("E3"),
make_evidence("E3"),
make_evidence("E4"),
]
mock_repo = MagicMock()
mock_repo.get_all.return_value = evidence
mock_repo_cls.return_value = mock_repo
resp = client.get("/dashboard/evidence-distribution")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 8
assert data["by_confidence"]["E0"] == 1
assert data["by_confidence"]["E1"] == 2
assert data["by_confidence"]["E2"] == 1
assert data["by_confidence"]["E3"] == 3
assert data["by_confidence"]["E4"] == 1
@patch("compliance.api.dashboard_routes.EvidenceRepository")
def test_four_eyes_pending_count(self, mock_repo_cls):
evidence = [
make_evidence("E1", requires_four_eyes=True, approval_status="pending_first"),
make_evidence("E2", requires_four_eyes=True, approval_status="first_approved"),
make_evidence("E2", requires_four_eyes=True, approval_status="approved"),
make_evidence("E1", requires_four_eyes=True, approval_status="rejected"),
make_evidence("E1", requires_four_eyes=False, approval_status="none"),
]
mock_repo = MagicMock()
mock_repo.get_all.return_value = evidence
mock_repo_cls.return_value = mock_repo
resp = client.get("/dashboard/evidence-distribution")
assert resp.status_code == 200
data = resp.json()
# pending_first and first_approved are pending; approved and rejected are not
assert data["four_eyes_pending"] == 2
assert data["total"] == 5
@patch("compliance.api.dashboard_routes.EvidenceRepository")
def test_null_confidence_defaults_to_e1(self, mock_repo_cls):
e = MagicMock()
e.confidence_level = None
e.requires_four_eyes = False
e.approval_status = "none"
mock_repo = MagicMock()
mock_repo.get_all.return_value = [e]
mock_repo_cls.return_value = mock_repo
resp = client.get("/dashboard/evidence-distribution")
assert resp.status_code == 200
data = resp.json()
assert data["by_confidence"]["E1"] == 1
assert data["total"] == 1
@patch("compliance.api.dashboard_routes.EvidenceRepository")
def test_all_four_eyes_approved_zero_pending(self, mock_repo_cls):
evidence = [
make_evidence("E2", requires_four_eyes=True, approval_status="approved"),
make_evidence("E3", requires_four_eyes=True, approval_status="approved"),
]
mock_repo = MagicMock()
mock_repo.get_all.return_value = evidence
mock_repo_cls.return_value = mock_repo
resp = client.get("/dashboard/evidence-distribution")
assert resp.status_code == 200
data = resp.json()
assert data["four_eyes_pending"] == 0
# ===========================================================================
# 2. TestDashboardMultiScore
# ===========================================================================
class TestDashboardMultiScore:
"""Test that dashboard response includes multi_score."""
def test_dashboard_response_schema_includes_multi_score(self):
"""DashboardResponse schema must include the multi_score field."""
from compliance.api.schemas import DashboardResponse
fields = DashboardResponse.model_fields
assert "multi_score" in fields, "DashboardResponse must have multi_score field"
def test_multi_score_schema_has_required_fields(self):
"""MultiDimensionalScore schema should have all 7 fields."""
from compliance.api.schemas import MultiDimensionalScore
fields = MultiDimensionalScore.model_fields
required = [
"requirement_coverage",
"evidence_strength",
"validation_quality",
"evidence_freshness",
"control_effectiveness",
"overall_readiness",
"hard_blocks",
]
for field in required:
assert field in fields, f"Missing field: {field}"
def test_multi_score_default_values(self):
"""MultiDimensionalScore defaults should be sensible."""
from compliance.api.schemas import MultiDimensionalScore
score = MultiDimensionalScore()
assert score.overall_readiness == 0.0
assert score.hard_blocks == []
assert score.requirement_coverage == 0.0

View File

@@ -0,0 +1,277 @@
"""Tests for Anti-Fake-Evidence Phase 4a: Traceability Matrix.
6 tests covering:
- Empty DB returns empty controls + zero summary
- Nested structure: Control → Evidence → Assertions
- Assertions appear under correct evidence
- Coverage flags computed correctly
- Control without evidence has correct coverage
- Summary counts match
"""
from datetime import datetime
from unittest.mock import MagicMock, patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from compliance.api.dashboard_routes import router as dashboard_router
from classroom_engine.database import get_db
# ---------------------------------------------------------------------------
# App setup with mocked DB dependency
# ---------------------------------------------------------------------------
app = FastAPI()
app.include_router(dashboard_router)
mock_db = MagicMock()
def override_get_db():
yield mock_db
app.dependency_overrides[get_db] = override_get_db
client = TestClient(app)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def make_control(id="c1", control_id="CTRL-001", title="Test Control", status="pass", domain="gov"):
ctrl = MagicMock()
ctrl.id = id
ctrl.control_id = control_id
ctrl.title = title
ctrl.status = MagicMock()
ctrl.status.value = status
ctrl.domain = MagicMock()
ctrl.domain.value = domain
return ctrl
def make_evidence(id="e1", control_id="c1", title="Evidence 1", evidence_type="scan_report",
confidence="E2", status="valid"):
e = MagicMock()
e.id = id
e.control_id = control_id
e.title = title
e.evidence_type = evidence_type
e.confidence_level = MagicMock()
e.confidence_level.value = confidence
e.status = MagicMock()
e.status.value = status
return e
def make_assertion(id="a1", entity_id="e1", sentence_text="System encrypts data at rest.",
assertion_type="assertion", confidence=0.85, verified_by=None):
a = MagicMock()
a.id = id
a.entity_id = entity_id
a.sentence_text = sentence_text
a.assertion_type = assertion_type
a.confidence = confidence
a.verified_by = verified_by
return a
# ===========================================================================
# Tests
# ===========================================================================
class TestTraceabilityMatrix:
"""Test GET /dashboard/traceability-matrix endpoint."""
@patch("compliance.api.dashboard_routes.EvidenceRepository")
@patch("compliance.api.dashboard_routes.ControlRepository")
def test_empty_db_returns_empty_matrix(self, mock_ctrl_cls, mock_ev_cls):
"""Empty DB should return zero controls and zero summary counts."""
mock_ctrl = MagicMock()
mock_ctrl.get_all.return_value = []
mock_ctrl_cls.return_value = mock_ctrl
mock_ev = MagicMock()
mock_ev.get_all.return_value = []
mock_ev_cls.return_value = mock_ev
# Mock db.query(AssertionDB).filter(...).all()
mock_db.reset_mock()
mock_query = MagicMock()
mock_query.filter.return_value.all.return_value = []
mock_db.query.return_value = mock_query
resp = client.get("/dashboard/traceability-matrix")
assert resp.status_code == 200
data = resp.json()
assert data["controls"] == []
assert data["summary"]["total_controls"] == 0
assert data["summary"]["covered_controls"] == 0
assert data["summary"]["fully_verified"] == 0
assert data["summary"]["uncovered_controls"] == 0
@patch("compliance.api.dashboard_routes.EvidenceRepository")
@patch("compliance.api.dashboard_routes.ControlRepository")
def test_nested_structure(self, mock_ctrl_cls, mock_ev_cls):
"""Control with evidence and assertions should return nested structure."""
ctrl = make_control(id="c1", control_id="PRIV-001", title="Privacy Control")
ev = make_evidence(id="e1", control_id="c1", confidence="E3")
assertion = make_assertion(id="a1", entity_id="e1", verified_by="auditor@example.com")
mock_ctrl = MagicMock()
mock_ctrl.get_all.return_value = [ctrl]
mock_ctrl_cls.return_value = mock_ctrl
mock_ev = MagicMock()
mock_ev.get_all.return_value = [ev]
mock_ev_cls.return_value = mock_ev
mock_db.reset_mock()
mock_query = MagicMock()
mock_query.filter.return_value.all.return_value = [assertion]
mock_db.query.return_value = mock_query
resp = client.get("/dashboard/traceability-matrix")
assert resp.status_code == 200
data = resp.json()
assert len(data["controls"]) == 1
c = data["controls"][0]
assert c["control_id"] == "PRIV-001"
assert len(c["evidence"]) == 1
assert c["evidence"][0]["confidence_level"] == "E3"
assert len(c["evidence"][0]["assertions"]) == 1
assert c["evidence"][0]["assertions"][0]["verified"] is True
@patch("compliance.api.dashboard_routes.EvidenceRepository")
@patch("compliance.api.dashboard_routes.ControlRepository")
def test_assertions_grouped_under_correct_evidence(self, mock_ctrl_cls, mock_ev_cls):
"""Assertions should only appear under the evidence they reference."""
ctrl = make_control(id="c1")
ev1 = make_evidence(id="e1", control_id="c1", title="Evidence A")
ev2 = make_evidence(id="e2", control_id="c1", title="Evidence B")
a1 = make_assertion(id="a1", entity_id="e1", sentence_text="Assertion for E1")
a2 = make_assertion(id="a2", entity_id="e2", sentence_text="Assertion for E2")
a3 = make_assertion(id="a3", entity_id="e2", sentence_text="Second assertion for E2")
mock_ctrl = MagicMock()
mock_ctrl.get_all.return_value = [ctrl]
mock_ctrl_cls.return_value = mock_ctrl
mock_ev = MagicMock()
mock_ev.get_all.return_value = [ev1, ev2]
mock_ev_cls.return_value = mock_ev
mock_db.reset_mock()
mock_query = MagicMock()
mock_query.filter.return_value.all.return_value = [a1, a2, a3]
mock_db.query.return_value = mock_query
resp = client.get("/dashboard/traceability-matrix")
assert resp.status_code == 200
data = resp.json()
c = data["controls"][0]
ev1_data = next(e for e in c["evidence"] if e["id"] == "e1")
ev2_data = next(e for e in c["evidence"] if e["id"] == "e2")
assert len(ev1_data["assertions"]) == 1
assert len(ev2_data["assertions"]) == 2
@patch("compliance.api.dashboard_routes.EvidenceRepository")
@patch("compliance.api.dashboard_routes.ControlRepository")
def test_coverage_flags_correct(self, mock_ctrl_cls, mock_ev_cls):
"""Coverage flags should reflect evidence, assertions, and verification state."""
ctrl = make_control(id="c1")
ev = make_evidence(id="e1", control_id="c1", confidence="E2")
# One verified, one not
a1 = make_assertion(id="a1", entity_id="e1", verified_by="alice")
a2 = make_assertion(id="a2", entity_id="e1", verified_by=None)
mock_ctrl = MagicMock()
mock_ctrl.get_all.return_value = [ctrl]
mock_ctrl_cls.return_value = mock_ctrl
mock_ev = MagicMock()
mock_ev.get_all.return_value = [ev]
mock_ev_cls.return_value = mock_ev
mock_db.reset_mock()
mock_query = MagicMock()
mock_query.filter.return_value.all.return_value = [a1, a2]
mock_db.query.return_value = mock_query
resp = client.get("/dashboard/traceability-matrix")
assert resp.status_code == 200
cov = resp.json()["controls"][0]["coverage"]
assert cov["has_evidence"] is True
assert cov["has_assertions"] is True
assert cov["all_assertions_verified"] is False # a2 not verified
assert cov["min_confidence_level"] == "E2"
@patch("compliance.api.dashboard_routes.EvidenceRepository")
@patch("compliance.api.dashboard_routes.ControlRepository")
def test_coverage_without_evidence(self, mock_ctrl_cls, mock_ev_cls):
"""Control with no evidence should have all coverage flags False/None."""
ctrl = make_control(id="c1")
mock_ctrl = MagicMock()
mock_ctrl.get_all.return_value = [ctrl]
mock_ctrl_cls.return_value = mock_ctrl
mock_ev = MagicMock()
mock_ev.get_all.return_value = []
mock_ev_cls.return_value = mock_ev
mock_db.reset_mock()
mock_query = MagicMock()
mock_query.filter.return_value.all.return_value = []
mock_db.query.return_value = mock_query
resp = client.get("/dashboard/traceability-matrix")
assert resp.status_code == 200
cov = resp.json()["controls"][0]["coverage"]
assert cov["has_evidence"] is False
assert cov["has_assertions"] is False
assert cov["all_assertions_verified"] is False
assert cov["min_confidence_level"] is None
@patch("compliance.api.dashboard_routes.EvidenceRepository")
@patch("compliance.api.dashboard_routes.ControlRepository")
def test_summary_counts(self, mock_ctrl_cls, mock_ev_cls):
"""Summary should count total, covered, fully verified, and uncovered controls."""
# c1: has evidence + verified assertions → fully verified
# c2: has evidence but no assertions → covered, not fully verified
# c3: no evidence → uncovered
c1 = make_control(id="c1", control_id="C-001")
c2 = make_control(id="c2", control_id="C-002")
c3 = make_control(id="c3", control_id="C-003")
ev1 = make_evidence(id="e1", control_id="c1", confidence="E3")
ev2 = make_evidence(id="e2", control_id="c2", confidence="E1")
a1 = make_assertion(id="a1", entity_id="e1", verified_by="auditor")
mock_ctrl = MagicMock()
mock_ctrl.get_all.return_value = [c1, c2, c3]
mock_ctrl_cls.return_value = mock_ctrl
mock_ev = MagicMock()
mock_ev.get_all.return_value = [ev1, ev2]
mock_ev_cls.return_value = mock_ev
mock_db.reset_mock()
mock_query = MagicMock()
mock_query.filter.return_value.all.return_value = [a1]
mock_db.query.return_value = mock_query
resp = client.get("/dashboard/traceability-matrix")
assert resp.status_code == 200
summary = resp.json()["summary"]
assert summary["total_controls"] == 3
assert summary["covered_controls"] == 2
assert summary["fully_verified"] == 1
assert summary["uncovered_controls"] == 1

View File

@@ -61,6 +61,7 @@ def make_control(overrides=None):
c.status = MagicMock()
c.status.value = "planned"
c.status_notes = None
c.status_justification = None
c.last_reviewed_at = None
c.next_review_at = None
c.created_at = NOW
@@ -249,15 +250,15 @@ class TestUpdateControl:
assert response.status_code == 404
def test_update_status_with_valid_enum(self):
"""Status must be a valid ControlStatusEnum value."""
"""Status must be a valid ControlStatusEnum value (planned → in_progress is always allowed)."""
updated = make_control()
updated.status.value = "pass"
updated.status.value = "in_progress"
with patch("compliance.api.routes.ControlRepository") as MockRepo:
MockRepo.return_value.get_by_control_id.return_value = make_control()
MockRepo.return_value.update.return_value = updated
response = client.put(
"/compliance/controls/GOV-001",
json={"status": "pass"},
json={"status": "in_progress"},
)
assert response.status_code == 200

View File

@@ -56,6 +56,22 @@ def make_evidence(overrides=None):
e.valid_until = None
e.collected_at = NOW
e.created_at = NOW
# Anti-Fake-Evidence fields
e.confidence_level = MagicMock()
e.confidence_level.value = "E1"
e.truth_status = MagicMock()
e.truth_status.value = "uploaded"
e.generation_mode = None
e.may_be_used_as_evidence = True
e.reviewed_by = None
e.reviewed_at = None
# Phase 2 fields
e.approval_status = "none"
e.first_reviewer = None
e.first_reviewed_at = None
e.second_reviewer = None
e.second_reviewed_at = None
e.requires_four_eyes = False
if overrides:
for k, v in overrides.items():
setattr(e, k, v)

View File

@@ -0,0 +1,460 @@
# Anti-Fake-Evidence Architektur
**Status:** Phase 2 (aktiv seit 2026-03-23)
**Prefix:** CP-AFE
**Motivation:** Delve-Vorfall (Maerz 2026) — Compliance-Theater verhindern
## Motivation
Der Delve-Vorfall zeigte, wie Compliance-Automation zur Haftungsfalle wird:
- LLM-generierte Inhalte wurden als echte Nachweise behandelt
- Controls ohne Evidence standen auf "pass"
- 100%-Compliance-Claims ohne Validierung
Die Anti-Fake-Evidence Architektur implementiert 6 Guardrails, die sicherstellen, dass nur **nachgewiesene** Compliance als solche dargestellt wird.
---
## Evidence Confidence Levels (E0E4)
| Level | Bezeichnung | Beschreibung | Beispiel |
|-------|-------------|--------------|----------|
| **E0** | Generated | LLM-Output, Platzhalter | KI-generierter Nachweis-Entwurf |
| **E1** | Uploaded | Manuell hochgeladen, ungeprüft | PDF ohne Reviewer |
| **E2** | Reviewed | Intern geprüft, Hash verifiziert | Dokument von Compliance-Beauftragtem bestätigt |
| **E3** | Observed | System-beobachtet (CI/CD, API) | Automatischer SAST-Report mit SHA-256 |
| **E4** | Auditor-validated | Extern validiert | Wirtschaftsprüfer hat akzeptiert |
### Auto-Klassifikation
| Source | Confidence | Truth Status |
|--------|-----------|--------------|
| `ci_pipeline` | E3 | observed |
| `api` (mit Hash) | E3 | observed |
| `manual` / `upload` | E1 | uploaded |
| `generated` | E0 | generated |
---
## Evidence Truth-Status Lifecycle
```mermaid
stateDiagram-v2
[*] --> generated : LLM erzeugt
[*] --> uploaded : Manuell hochgeladen
[*] --> observed : CI/CD Pipeline
generated --> rejected : Review abgelehnt
uploaded --> validated_internal : Intern geprueft
uploaded --> rejected : Review abgelehnt
observed --> validated_internal : Intern bestaetigt
validated_internal --> provided_to_auditor : An Auditor uebergeben
provided_to_auditor --> accepted_by_auditor : Auditor akzeptiert
provided_to_auditor --> rejected : Auditor lehnt ab
```
---
## Control Status-Transition State Machine
```mermaid
stateDiagram-v2
planned --> in_progress : immer erlaubt
in_progress --> pass : Evidence >= E2 + truth_status valid
in_progress --> partial : min 1 Evidence (beliebig)
in_progress --> fail : immer erlaubt
pass --> fail : Degradation (immer)
partial --> pass : Evidence >= E2 + truth_status valid
note right of pass
Voraussetzung: min 1 Evidence
mit confidence >= E2 UND
truth_status in (uploaded,
observed, validated_internal,
accepted_by_auditor)
end note
```
### Transition-Regeln
| Von | Nach | Voraussetzung |
|-----|------|---------------|
| planned | in_progress | keine |
| in_progress | pass | min 1 Evidence mit confidence >= E2, truth_status valide |
| in_progress | partial | min 1 Evidence (beliebig) |
| in_progress | fail | immer erlaubt |
| pass | fail | immer erlaubt (Degradation) |
| * | n/a | erfordert `status_justification` |
| * | planned | immer erlaubt (Reset) |
Bei Verstoß: **HTTP 409 Conflict** mit Liste der Violations.
---
## LLM Truth-Labels
Jeder LLM-generierte Inhalt wird mit einem Truth-Label versehen:
```json
{
"generation_mode": "draft_assistance",
"truth_status": "generated",
"may_be_used_as_evidence": false,
"generated_by": "system"
}
```
### Audit-Trail
Tabelle `compliance_llm_generation_audit`:
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| entity_type | VARCHAR(50) | 'evidence', 'control', 'document' |
| entity_id | VARCHAR(36) | FK zur generierten Entitaet |
| generation_mode | VARCHAR(100) | 'draft_assistance', 'auto_generation' |
| truth_status | ENUM | generated, uploaded, ... |
| may_be_used_as_evidence | BOOLEAN | Default: FALSE |
| llm_model | VARCHAR(100) | z.B. 'qwen2.5vl:32b' |
| llm_provider | VARCHAR(50) | 'ollama', 'anthropic' |
| prompt_hash | VARCHAR(64) | SHA-256 des Prompts |
---
## Multi-dimensionaler Compliance-Score
Statt einer einzelnen Prozentzahl zeigt der Score 6 Dimensionen:
| Dimension | Gewicht | Beschreibung |
|-----------|---------|--------------|
| requirement_coverage | 20% | % Requirements mit verlinktem Control |
| evidence_strength | 25% | Gewichteter Durchschnitt der Evidence-Confidence |
| validation_quality | 20% | % Evidence mit truth_status >= validated_internal |
| evidence_freshness | 10% | % Evidence nicht expired + reviewed < 90 Tage |
| control_effectiveness | 25% | Bestehende Formel (pass + partial*0.5) |
| **overall_readiness** | — | Gewichteter Composite der 5 Dimensionen |
### Hard Blocks
Zusaetzlich werden **Sperrgründe** angezeigt, die eine Audit-Readiness verhindern:
- Controls auf 'pass' ohne jegliche Evidence
- Controls auf 'pass' mit nur E0/E1-Evidence (keine Validierung)
---
## Verbotene Formulierungen
Der Drafting-Engine Validator prueft auf Formulierungen, die **ohne ausreichenden Nachweis** nicht verwendet werden duerfen:
| Verboten | Sicher stattdessen |
|----------|--------------------|
| "ist compliant" | "soll compliant sein" |
| "erfuellt vollstaendig" | "soll vollstaendig erfuellt werden" |
| "wurde geprueft" | "soll geprueft werden" |
| "wurde umgesetzt" | "ist zur Umsetzung vorgesehen" |
| "ist auditiert" | "soll auditiert werden" |
| "vollstaendig implementiert" | "Implementierung ist vorgesehen" |
| "nachweislich konform" | "Konformitaet ist nachzuweisen" |
**Erlaubt nur wenn:** control_status = pass AND confidence >= E2 AND truth_status in (validated_internal, accepted_by_auditor).
---
## API-Aenderungen
### Neue Endpoints
| Methode | Pfad | Beschreibung |
|---------|------|--------------|
| PATCH | `/evidence/{id}/review` | Evidence reviewen (Confidence upgraden) |
| POST | `/llm-audit` | LLM-Generierungs-Audit erstellen |
| GET | `/llm-audit` | LLM-Audit-Eintraege auflisten |
### Erweiterte Responses
**EvidenceResponse** — 6 neue Felder:
```json
{
"confidence_level": "E3",
"truth_status": "observed",
"generation_mode": null,
"may_be_used_as_evidence": true,
"reviewed_by": null,
"reviewed_at": null
}
```
**DashboardResponse** — neues Feld `multi_score`:
```json
{
"multi_score": {
"requirement_coverage": 85.0,
"evidence_strength": 60.0,
"validation_quality": 40.0,
"evidence_freshness": 90.0,
"control_effectiveness": 70.0,
"overall_readiness": 65.0,
"hard_blocks": ["3 Controls auf 'pass' haben nur E0/E1-Evidence"]
}
}
```
**ControlResponse** — neues Feld `status_justification`.
**ControlUpdate** — neues Feld `status_justification` (Pflicht fuer n/a-Transitions).
### Status-Transition Fehler (409)
```json
{
"detail": {
"error": "Status transition not allowed",
"current_status": "in_progress",
"requested_status": "pass",
"violations": [
"Transition to 'pass' requires at least 1 evidence with confidence >= E2..."
]
}
}
```
---
## Migration
### Phase 1
**Datei:** `backend-compliance/migrations/076_anti_fake_evidence.sql`
- Neue ENUM-Typen: `evidence_confidence_level`, `evidence_truth_status`
- 6 neue Spalten auf `compliance_evidence`
- `in_progress` Wert fuer `controlstatusenum`
- `status_justification` auf `compliance_controls`
- Neue Tabelle `compliance_llm_generation_audit`
- Backfill bestehender Evidence nach Source
- Indizes auf neue Spalten
### Phase 2
**Datei:** `backend-compliance/migrations/077_anti_fake_evidence_phase2.sql`
- Neue Tabelle `compliance_assertions` (Assertion Engine)
- 6 neue Spalten auf `compliance_evidence` (Four-Eyes: approval_status, first_reviewer, etc.)
- Performance-Index auf `compliance_audit_trail (entity_type, action, performed_at)`
---
## Phase 2: UI-Badges
Badges werden auf Evidence-Cards angezeigt und zeigen den Vertrauensstatus auf einen Blick:
| Badge | Farben | Anzeige |
|-------|--------|---------|
| **ConfidenceLevelBadge** | E0=rot, E1=gelb, E2=blau, E3=gruen, E4=emerald | Immer |
| **TruthStatusBadge** | generated=violet, uploaded=grau, observed=blau, validated=gruen, rejected=rot | Immer |
| **GenerationModeBadge** | violet + Sparkles-Icon | Wenn LLM-generiert |
| **ApprovalStatusBadge** | pending=gelb, first_approved=blau, approved=gruen, rejected=rot | Nur bei Four-Eyes |
---
## Phase 2: Assertion Engine
Die Assertion Engine trennt **Behauptungen** von **Fakten** in Compliance-Texten.
```mermaid
flowchart LR
Text[Freitext] --> Split[Satz-Splitting]
Split --> Classify{Normativ?}
Classify -->|Pflicht| A[Assertion pflicht]
Classify -->|Empfehlung| B[Assertion empfehlung]
Classify -->|Kann| C[Assertion kann]
Classify -->|Begruendung| D[Rationale]
Classify -->|Evidence-Keywords| E[Fact tentativ]
E --> Verify[Manuell verifizieren]
Verify --> Fact[Verified Fact]
```
### Assertion-Typen
| Typ | Bedeutung | Beispiel |
|-----|-----------|----------|
| **assertion** | Normative Aussage (unbewiesen) | "Die Organisation muss ein ISMS implementieren" |
| **fact** | Verifizierte Tatsache | "ISO-Zertifikat Nr. 12345 liegt vor" |
| **rationale** | Begruendung | "Dies ist notwendig, weil..." |
### Normative Tiers
| Tier | Signal-Woerter |
|------|---------------|
| **pflicht** | muss, hat sicherzustellen, ist verpflichtet, shall, must, required |
| **empfehlung** | soll, sollte, gewaehrleisten, should, ensure |
| **kann** | kann, darf, may, optional |
---
## Phase 2: Four-Eyes-Prinzip
```mermaid
stateDiagram-v2
[*] --> pending_first : Evidence erstellt (Gov/Priv Domain)
pending_first --> first_approved : 1. Reviewer OK
first_approved --> approved : 2. Reviewer OK (andere Person!)
first_approved --> rejected : 2. Reviewer lehnt ab
pending_first --> rejected : 1. Reviewer lehnt ab
note right of first_approved
Zweiter Reviewer MUSS
eine andere Person sein
als der erste Reviewer
end note
```
### Domains mit Four-Eyes-Pflicht
| Domain | Four-Eyes? | Begruendung |
|--------|-----------|-------------|
| `gov` | Ja | Governance-Controls sind audit-kritisch |
| `priv` | Ja | Datenschutz erfordert unabhaengige Pruefung |
| `ops`, `sdlc`, `ai`, ... | Nein | Operationale Controls mit Single-Review |
---
## Phase 2: Audit-Trail-Erweiterung
Neue Audit-Trail-Eintraege:
| Entity | Action | Wann |
|--------|--------|------|
| evidence | create | Bei Evidence-Erstellung |
| evidence | review | Bei Confidence/Truth-Status-Aenderung |
| evidence | reject | Bei Evidence-Ablehnung |
| control | status_change | Bei Control-Status-Aenderung |
Jeder Eintrag enthaelt `old_value`, `new_value` und einen SHA-256 `checksum`.
Neuer Query-Endpoint: `GET /audit-trail?entity_type=evidence&entity_id={id}`
---
## Phase 2: Neue API-Endpoints
| Methode | Pfad | Beschreibung |
|---------|------|--------------|
| PATCH | `/evidence/{id}/reject` | Evidence ablehnen |
| GET | `/audit-trail` | Audit-Trail abfragen (Filter: entity_type, entity_id, action) |
| POST | `/assertions` | Assertion manuell erstellen |
| GET | `/assertions` | Assertions auflisten (Filter: entity_type, entity_id, assertion_type) |
| GET | `/assertions/{id}` | Assertion Detail |
| PUT | `/assertions/{id}` | Assertion aktualisieren |
| POST | `/assertions/{id}/verify` | Als Fakt markieren |
| POST | `/assertions/extract` | Automatische Extraktion aus Freitext |
| GET | `/assertions/summary` | Stats (total, facts, rationale, unverified) |
### Erweiterte EvidenceResponse (Phase 2)
```json
{
"approval_status": "first_approved",
"first_reviewer": "reviewer1@example.com",
"first_reviewed_at": "2026-03-23T14:00:00Z",
"second_reviewer": null,
"second_reviewed_at": null,
"requires_four_eyes": true
}
```
---
## Phase 3: Durchsetzung (UI + Dashboard)
Phase 3 macht das Anti-Fake-Evidence-System **benutzbar und durchsetzbar** im Frontend.
### Evidence Review/Reject UI
Die Evidence-Seite bietet jetzt direkte Buttons fuer Review und Ablehnung:
- **Reviewen-Button**: Sichtbar wenn `approvalStatus` nicht `approved` oder `rejected`
- **Ablehnen-Button**: Sichtbar bei Four-Eyes-Evidence die noch nicht abgeschlossen ist
**ReviewModal** erlaubt:
- Confidence-Level aendern (E0-E4 Dropdown)
- Truth-Status aendern (Dropdown)
- Reviewer E-Mail angeben
- Four-Eyes-Warnung wenn noch ein weiterer Review noetig ist
**RejectModal** erlaubt:
- Ablehnungsgrund als Freitext
- Reviewer E-Mail angeben
Bei Four-Eyes Same-Person-Fehler (HTTP 400) wird eine Fehlermeldung angezeigt.
### Control Status-Transition Fehlerbehandlung
Die Controls-Seite zeigt jetzt detaillierte Fehler bei blockierten Status-Transitionen:
- **Optimistic Update mit Rollback**: UI aktualisiert sofort, rollt bei Fehler zurueck
- **TransitionErrorBanner**: Zeigt Violations-Liste bei HTTP 409 Conflict
- z.B. "Transition to 'pass' requires at least 1 evidence with confidence >= E2"
- **Link zur Evidence-Seite**: "Evidence hinzufuegen" direkt im Fehler-Banner
### Evidence Audit-Trail Anzeige
Neuer "Historie"-Button auf jeder Evidence-Card zeigt den vollstaendigen Audit-Trail:
- Timeline mit Zeitstempel, Aktion und Akteur
- Details zu Feldaenderungen (old_value → new_value)
- Lazy-Loading: Erst beim Aufklappen wird `GET /audit-trail` abgerufen
### Evidence Confidence-Filter
Neue Filter-Pills auf der Evidence-Seite:
```
[Alle] [Gueltig] [Abgelaufen] [Ausstehend] | [E0] [E1] [E2] [E3] [E4]
```
Farbcodierung: E0=rot, E1=gelb, E2=blau, E3=gruen, E4=emerald (passend zu den Badges)
### Evidence Confidence-Verteilung (Dashboard)
Neuer Endpoint und Dashboard-Bereich:
**Endpoint:** `GET /dashboard/evidence-distribution`
```json
{
"by_confidence": {"E0": 2, "E1": 5, "E2": 3, "E3": 8, "E4": 1},
"four_eyes_pending": 3,
"total": 19
}
```
**Compliance Hub** zeigt:
- Horizontal gestapelter Balken der Confidence-Verteilung (E0 rot → E4 emerald)
- Multi-Score Dimensionen als Fortschrittsbalken (5 Dimensionen + Audit-Readiness)
- Four-Eyes-Warteschlange (Anzahl pending)
- Hard-Blocks-Liste oder "Keine Hard Blocks" Status
### Assertions-Seite
Neue Seite unter `/sdk/assertions` mit 3 Tabs:
| Tab | Inhalt |
|-----|--------|
| **Uebersicht** | Summary-Stats (Assertions, Facts, Rationale, Unverified) |
| **Assertion-Liste** | Filterbarer Tabelle (entity_type, assertion_type) mit AssertionCards |
| **Extraktion** | Textfeld + Button → `POST /assertions/extract` |
**AssertionCard** zeigt:
- Normative-Tier als farbiger Badge (Pflicht=rot, Empfehlung=gelb, Kann=blau)
- Typ-Badge (Assertion/Fact/Rationale)
- "Als Fakt pruefen"-Button → `POST /assertions/{id}/verify`
### Phase 3: Neue API-Endpoints
| Methode | Pfad | Beschreibung |
|---------|------|--------------|
| GET | `/dashboard/evidence-distribution` | Evidence-Verteilung nach Confidence + Four-Eyes-Status |

View File

@@ -88,12 +88,21 @@ compliance_evidence (
---
## Anti-Fake-Evidence
Seit Phase 1 (2026-03-23) werden Nachweise automatisch mit **Confidence Levels** (E0E4) und **Truth Status** klassifiziert. Details: [Anti-Fake-Evidence Architektur](anti-fake-evidence.md)
---
## Tests
**Testdatei:** `backend-compliance/tests/test_evidence_routes.py`
**Anzahl Tests:** 11 · **Status:** ✅ alle bestanden (Stand 2026-03-05)
**Anti-Fake-Evidence Tests:** `backend-compliance/tests/test_anti_fake_evidence.py`
**Anzahl Tests:** ~45 · Confidence-Klassifikation, State Machine, Multi-Score, LLM Audit
```bash
cd backend-compliance
python3 -m pytest tests/test_evidence_routes.py -v
python3 -m pytest tests/test_evidence_routes.py tests/test_anti_fake_evidence.py -v
```

View File

@@ -109,6 +109,7 @@ nav:
- Control Generator Pipeline: services/sdk-modules/control-generator-pipeline.md
- Deduplizierungs-Engine: services/sdk-modules/dedup-engine.md
- Control Provenance Wiki: services/sdk-modules/control-provenance.md
- Anti-Fake-Evidence Architektur: services/sdk-modules/anti-fake-evidence.md
- Strategie:
- Wettbewerbsanalyse & Roadmap: strategy/wettbewerbsanalyse.md
- Entwicklung: