feat(compliance-check): exec-summary + voll-audit + TDM-respect + cookie-KB-extended + saving-scan-funnel
CI / detect-changes (push) Successful in 10s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 14s
CI / loc-budget (push) Failing after 15s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m43s
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 37s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / detect-changes (push) Successful in 10s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 14s
CI / loc-budget (push) Failing after 15s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m43s
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 37s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
P1 — Exec-Summary oben im Email-Report (4 KPIs + 2 CTAs, dunkler Gradient)
P3 — no_direct_sales-Flag fuer OEM-Konfigurator-Sites; AGB/Widerruf/AGB als
"NICHT ANWENDBAR" (grau) statt "NICHT GEFUNDEN" (rot)
P5 — Voll-Audit Unification: alle Findings (MC + Pflichtangaben + Vendor +
Redundanz) in /data/compliance_audits.db.unified_findings; neuer
/api/compliance/agent/findings/<id> Endpoint + FindingsTab im Audit-UI
mit Filter + CSV-Export
P7 — Crawl-Hardening: TDM-Reservation-Check (robots.txt / ai.txt / Header /
Meta) vor jedem Run mit 24h-Cache; HeadlessChrome-UA (Firma noch nicht
gegruendet — Switch via BREAKPILOT_BRANDED_UA env); per-Domain
Rate-Limit 1 req/s + max 2 concurrent
P2 — Cookie-Knowledge-DB additiv erweitert (35 -> 74 Cookies): Adobe, Meta,
Microsoft, LinkedIn, TikTok, HubSpot, Marketo, Salesforce, Hotjar,
FullStory, Mouseflow, Intercom, Drift, Zendesk, Cloudflare, Stripe,
OneTrust/Cookiebot/Usercentrics, Matomo, Pinterest, Snapchat, X/Twitter,
YouTube, Vimeo, Klaviyo, Mailchimp, Mixpanel, Segment, Amplitude,
Optimizely, Datadog; Wire-in in cookie_function_classifier liefert
compliance_risk-Label (kritisch/hoch/mittel/gering) pro Vendor
A — k-Anonymitaets-Helper (benchmark_k_anonymity) fuer P6-Vorbereitung
B — Cross-Tenant-Domain-Assertion im /findings-Endpoint (expected_domain
Query-Param -> 403 bei Mismatch)
C — Saving-Scan-Funnel: /api/compliance/agent/saving-scan/start mit
Validierung + 24h-Rate-Limit pro Domain + Lead-Persistenz in
saving_scan_leads + Auto-Discovery via _run_compliance_check; 6 Tests
D — Risk-Badge im Email-Vendor-Row
Rechtliche Leitplanken (Memory feedback_oem_data_legal.md): nur eigene
Knapp-Bewertungen + Source-Pointer, keine 1:1-Kopien fremder CMP-Texte.
TDM-Opt-Out-Respect nach § 44b UrhG. KEINE Schema-Aenderungen — alles in
Sidecar-SQLite.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/agent/findings/<checkId>
|
||||
* -> backend GET /api/compliance/agent/findings/<checkId>
|
||||
*
|
||||
* Forwards all query params (source, severity, doc_type, status, q, limit).
|
||||
*/
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { checkId: string } },
|
||||
) {
|
||||
const checkId = params.checkId
|
||||
const qs = request.nextUrl.searchParams.toString()
|
||||
const url = `${BACKEND_URL}/api/compliance/agent/findings/${checkId}${qs ? `?${qs}` : ''}`
|
||||
try {
|
||||
const resp = await fetch(url, { signal: AbortSignal.timeout(20000) })
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data, { status: resp.status })
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Findings-Abfrage fehlgeschlagen' },
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -119,11 +119,9 @@ export function ComplianceCheckTab() {
|
||||
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
||||
return
|
||||
}
|
||||
if (data.status === 'failed' || data.status === 'not_found') {
|
||||
if (data.status === 'failed') setError(data.error || 'Pruefung fehlgeschlagen')
|
||||
setProgress(''); setProgressPct(0); setLoading(false)
|
||||
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
||||
return
|
||||
if (['failed', 'not_found', 'skipped_tdm'].includes(data.status)) {
|
||||
if (data.status !== 'not_found') setError(data.error || (data.status === 'skipped_tdm' ? 'TDM-Vorbehalt erkannt — Crawl uebersprungen' : 'Pruefung fehlgeschlagen'))
|
||||
setProgress(''); setProgressPct(0); setLoading(false); localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId(''); return
|
||||
}
|
||||
} catch { /* retry */ }
|
||||
}
|
||||
@@ -236,9 +234,9 @@ export function ComplianceCheckTab() {
|
||||
localStorage.setItem(STORAGE_KEY_HISTORY, JSON.stringify(updated))
|
||||
break
|
||||
}
|
||||
if (pollData.status === 'failed') {
|
||||
if (['failed', 'skipped_tdm'].includes(pollData.status)) {
|
||||
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
||||
throw new Error(pollData.error || 'Pruefung fehlgeschlagen')
|
||||
throw new Error(pollData.error || (pollData.status === 'skipped_tdm' ? 'TDM-Vorbehalt' : 'Pruefung fehlgeschlagen'))
|
||||
}
|
||||
attempts++
|
||||
}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
type Finding = {
|
||||
id: number
|
||||
source_type: string
|
||||
doc_type: string
|
||||
severity: string
|
||||
status: string
|
||||
regulation: string
|
||||
label: string
|
||||
hint: string
|
||||
action_recipe: Record<string, string>
|
||||
anchor_excerpt: string
|
||||
anchor_conf: number
|
||||
vendor_name: string
|
||||
category: string
|
||||
payload: Record<string, unknown>
|
||||
}
|
||||
|
||||
type Summary = {
|
||||
total: number
|
||||
by_source: Record<string, number>
|
||||
by_severity: Record<string, number>
|
||||
by_status: Record<string, number>
|
||||
by_doc_type: Record<string, number>
|
||||
}
|
||||
|
||||
type Resp = {
|
||||
found: boolean
|
||||
summary: Summary
|
||||
count: number
|
||||
findings: Finding[]
|
||||
}
|
||||
|
||||
const SOURCE_LABEL: Record<string, string> = {
|
||||
all: 'Alle Quellen',
|
||||
mc: 'Master-Controls',
|
||||
pflichtangabe: 'Pflichtangaben',
|
||||
vendor: 'Vendor-Findings',
|
||||
redundanz: 'Redundanzen',
|
||||
}
|
||||
|
||||
const SEVERITY_COLOR: Record<string, string> = {
|
||||
CRITICAL: 'bg-red-600 text-white',
|
||||
HIGH: 'bg-red-100 text-red-800',
|
||||
MEDIUM: 'bg-amber-100 text-amber-800',
|
||||
LOW: 'bg-blue-100 text-blue-800',
|
||||
INFO: 'bg-gray-100 text-gray-600',
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
failed: 'Fail',
|
||||
passed: 'Pass',
|
||||
skipped: 'Skip',
|
||||
na: 'N/A',
|
||||
info: 'Info',
|
||||
}
|
||||
|
||||
const SEVERITY_OPTS = ['all', 'CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO']
|
||||
const STATUS_OPTS = ['all', 'failed', 'passed', 'skipped', 'na', 'info']
|
||||
|
||||
export default function FindingsTab({ checkId }: { checkId: string }) {
|
||||
const [data, setData] = useState<Resp | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [source, setSource] = useState('all')
|
||||
const [severity, setSeverity] = useState('all')
|
||||
const [docType, setDocType] = useState('all')
|
||||
const [status, setStatus] = useState('failed')
|
||||
const [q, setQ] = useState('')
|
||||
const [expanded, setExpanded] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
const qs = new URLSearchParams({
|
||||
source, severity, doc_type: docType, status, q, limit: '1500',
|
||||
}).toString()
|
||||
fetch(`/api/sdk/v1/agent/findings/${checkId}?${qs}`)
|
||||
.then(r => r.json())
|
||||
.then(d => { if (!cancelled) setData(d) })
|
||||
.catch(e => { if (!cancelled) setError(String(e)) })
|
||||
.finally(() => { if (!cancelled) setLoading(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [checkId, source, severity, docType, status, q])
|
||||
|
||||
const docTypes = useMemo(
|
||||
() => Object.keys(data?.summary?.by_doc_type ?? {}).filter(d => d !== '-').sort(),
|
||||
[data],
|
||||
)
|
||||
|
||||
const csvExport = () => {
|
||||
const rows = data?.findings ?? []
|
||||
const head = ['Quelle', 'Doc', 'Severity', 'Status', 'Regulation', 'Label', 'Vendor', 'Hint']
|
||||
const lines = [head.join(',')]
|
||||
for (const r of rows) {
|
||||
const cells = [
|
||||
r.source_type, r.doc_type, r.severity, r.status,
|
||||
r.regulation, r.label, r.vendor_name, r.hint,
|
||||
].map(c => `"${String(c ?? '').replace(/"/g, '""').replace(/\n/g, ' ')}"`)
|
||||
lines.push(cells.join(','))
|
||||
}
|
||||
const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `findings-${checkId}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
if (loading && !data) return <div className="p-6 text-sm text-gray-500">Lade Voll-Audit…</div>
|
||||
if (error) return <div className="p-6 text-sm text-red-600">Fehler: {error}</div>
|
||||
if (!data?.found) {
|
||||
return (
|
||||
<div className="p-6 text-sm text-gray-500">
|
||||
Keine unified findings für diesen Run gespeichert (alter Run vor P5?).
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sum = data.summary
|
||||
const findings = data.findings
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
|
||||
{Object.entries(SOURCE_LABEL).filter(([k]) => k !== 'all').map(([k, label]) => {
|
||||
const count = sum.by_source?.[k] ?? 0
|
||||
return (
|
||||
<button key={k}
|
||||
onClick={() => setSource(source === k ? 'all' : k)}
|
||||
className={`text-left rounded-lg border px-3 py-2 transition ${
|
||||
source === k
|
||||
? 'border-blue-500 bg-blue-50 text-blue-900'
|
||||
: 'border-gray-200 hover:border-gray-300 bg-white'
|
||||
}`}>
|
||||
<div className="text-[10px] uppercase tracking-wide text-gray-500">{label}</div>
|
||||
<div className="text-lg font-semibold">{count}</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Filter row */}
|
||||
<div className="flex flex-wrap gap-2 items-center text-xs">
|
||||
<select value={severity} onChange={e => setSeverity(e.target.value)}
|
||||
className="border border-gray-200 rounded px-2 py-1">
|
||||
{SEVERITY_OPTS.map(s => (
|
||||
<option key={s} value={s}>
|
||||
{s === 'all' ? 'Alle Severities' : s}
|
||||
{s !== 'all' && sum.by_severity?.[s] != null ? ` (${sum.by_severity[s]})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={status} onChange={e => setStatus(e.target.value)}
|
||||
className="border border-gray-200 rounded px-2 py-1">
|
||||
{STATUS_OPTS.map(s => (
|
||||
<option key={s} value={s}>
|
||||
{s === 'all' ? 'Alle Status' : STATUS_LABEL[s] ?? s}
|
||||
{s !== 'all' && sum.by_status?.[s] != null ? ` (${sum.by_status[s]})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={docType} onChange={e => setDocType(e.target.value)}
|
||||
className="border border-gray-200 rounded px-2 py-1">
|
||||
<option value="all">Alle Doc-Types</option>
|
||||
{docTypes.map(d => (
|
||||
<option key={d} value={d}>{d} ({sum.by_doc_type?.[d] ?? 0})</option>
|
||||
))}
|
||||
</select>
|
||||
<input value={q} onChange={e => setQ(e.target.value)}
|
||||
placeholder="Suche Label / Anbieter…"
|
||||
className="border border-gray-200 rounded px-2 py-1 min-w-[180px]" />
|
||||
<button onClick={csvExport}
|
||||
className="ml-auto border border-gray-200 hover:border-gray-300 rounded px-2 py-1">
|
||||
CSV exportieren
|
||||
</button>
|
||||
<span className="text-gray-500">{data.count} Treffer</span>
|
||||
</div>
|
||||
|
||||
{/* Findings table */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-50 text-gray-600">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Quelle</th>
|
||||
<th className="px-3 py-2 text-left">Doc</th>
|
||||
<th className="px-3 py-2 text-left">Sev</th>
|
||||
<th className="px-3 py-2 text-left">Status</th>
|
||||
<th className="px-3 py-2 text-left">Finding</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{findings.map(f => (
|
||||
<React.Fragment key={f.id}>
|
||||
<tr className="border-t cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => setExpanded(expanded === f.id ? null : f.id)}>
|
||||
<td className="px-3 py-2 text-gray-500 capitalize">{f.source_type}</td>
|
||||
<td className="px-3 py-2 text-gray-700">{f.doc_type === '-' ? '—' : f.doc_type}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`px-2 py-0.5 rounded text-[10px] font-medium ${
|
||||
SEVERITY_COLOR[f.severity] || 'bg-gray-100'
|
||||
}`}>{f.severity}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-600">{STATUS_LABEL[f.status] ?? f.status}</td>
|
||||
<td className="px-3 py-2 text-gray-900">
|
||||
{f.label}
|
||||
{f.vendor_name && (
|
||||
<span className="ml-2 text-[10px] text-gray-400">
|
||||
· {f.vendor_name}
|
||||
</span>
|
||||
)}
|
||||
{f.payload?.risk_label && (
|
||||
<span className={`ml-2 px-1.5 py-0.5 rounded text-[10px] font-medium ${
|
||||
f.payload.risk_label === 'kritisch' ? 'bg-red-600 text-white' :
|
||||
f.payload.risk_label === 'hoch' ? 'bg-red-100 text-red-800' :
|
||||
f.payload.risk_label === 'mittel' ? 'bg-amber-100 text-amber-800' :
|
||||
f.payload.risk_label === 'gering' ? 'bg-green-50 text-green-700' :
|
||||
'bg-gray-100 text-gray-500'
|
||||
}`}>Risk: {String(f.payload.risk_label)}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{expanded === f.id && (
|
||||
<tr className="bg-gray-50/50">
|
||||
<td colSpan={5} className="px-3 py-3 text-xs space-y-2">
|
||||
{f.hint && (
|
||||
<div className="text-gray-700">{f.hint}</div>
|
||||
)}
|
||||
{f.action_recipe?.fix_text && (
|
||||
<div className="bg-amber-50 border-l-2 border-amber-300 pl-3 py-2">
|
||||
<div className="font-medium text-amber-800 mb-1">Empfehlung</div>
|
||||
<div className="whitespace-pre-line text-amber-900">
|
||||
{f.action_recipe.fix_text}
|
||||
</div>
|
||||
{f.action_recipe.where && (
|
||||
<div className="text-[10px] text-amber-700 mt-1">
|
||||
Einfuegen in: {f.action_recipe.where}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{f.anchor_excerpt && (
|
||||
<div className="bg-blue-50 border-l-2 border-blue-300 pl-3 py-2">
|
||||
<div className="font-medium text-blue-800 mb-1">
|
||||
Fundstelle im Dokument (Konfidenz {Math.round((f.anchor_conf || 0) * 100)}%)
|
||||
</div>
|
||||
<div className="italic text-blue-900">"{f.anchor_excerpt}"</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[10px] text-gray-400">
|
||||
Source: {f.source_type} · Regulation: {f.regulation || '—'}
|
||||
{f.category && ` · Kategorie: ${f.category}`}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{findings.length === 0 && (
|
||||
<tr><td colSpan={5} className="px-3 py-6 text-center text-gray-400">
|
||||
Keine Findings fuer die aktuellen Filter.
|
||||
</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useEffect, useState, useMemo } from 'react'
|
||||
import { use as useUnwrap } from 'react'
|
||||
import FindingsTab from './FindingsTab'
|
||||
|
||||
type MCRow = {
|
||||
id: number
|
||||
@@ -67,6 +68,7 @@ export default function AuditPage(
|
||||
const [filterReg, setFilterReg] = useState<string>('')
|
||||
const [filterDoc, setFilterDoc] = useState<string>('')
|
||||
const [expanded, setExpanded] = useState<number | null>(null)
|
||||
const [tab, setTab] = useState<'mc' | 'all'>('all')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -127,6 +129,25 @@ export default function AuditPage(
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tab switcher */}
|
||||
<div className="flex gap-2 border-b border-gray-200">
|
||||
{([
|
||||
{ key: 'all', label: 'Voll-Audit (alle Findings)' },
|
||||
{ key: 'mc', label: 'Nur MC-Scorecard' },
|
||||
] as const).map(t => (
|
||||
<button key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-4 py-2 text-sm border-b-2 -mb-px transition ${
|
||||
tab === t.key
|
||||
? 'border-blue-600 text-blue-700 font-medium'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}>{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'all' && <FindingsTab checkId={checkId} />}
|
||||
|
||||
{tab === 'mc' && <>
|
||||
{/* Scorecard */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-3 bg-blue-50 border-b border-blue-100">
|
||||
@@ -272,6 +293,7 @@ export default function AuditPage(
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user