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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user