Files
Benjamin Admin 6f16507c5f
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m54s
CI / test-go (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 / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 17s
CI / loc-budget (push) Successful in 17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 43s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
feat(banner): P19 + P20 — Per-Category-Click-Test + Frontend-Drilldown
P19 (consent-tester):
- dp-cookieconsent (TYPO3, Safetykon-Pattern) als CMP-Profil hinzu —
  Selektoren #dp--cookie-statistics/marketing + a.cc-allow Save-Button
- Neues Signal provider_details_visible: nach Kategorie-Toggle prueft
  Playwright ob im Banner sichtbare Provider-/Cookie-Detail-Elemente
  erscheinen. Bei dp-cookieconsent (Banner ohne Listing) immer False
  -> HIGH-Violation "Kategorie zeigt keine Provider-/Cookie-Details —
  Nutzer kann nicht informiert einwilligen (Art. 7 Abs. 1 DSGVO)"
- main.py serialisiert provider_details_visible + cookies_set pro Kategorie

P20 (Frontend-Drilldown):
- Backend: check_payloads-Tabelle um Spalte 'banner' (JSON) — voller
  banner_result persistiert (vorher nur in-memory). ALTER TABLE
  Migration idempotent.
- Neuer Endpoint GET /api/compliance/agent/banner/<check_id> — liefert
  Quality-Score, Phases, Category-Tests, Banner-Checks, alle 46
  structured_checks.
- Frontend: BannerTab im /sdk/agent/audit/<id> mit Quality-Cards,
  3-Phasen-Cookie-Tabelle, Per-Category-Listing (mit P19-Signal
  rot/gruen), Banner-Verstoesse + Rechtsgrundlagen, 46-Check-Drilldown
  filterbar nach Severity.
- Tab-Switcher in page.tsx um "Cookie-Banner-Analyse" erweitert.
- Bonus: 2 alte route.ts auf Next.js 15 Promise-params umgestellt
  (Build-Fix).

Plus: Critical-Findings-Block nutzt provider_details_visible als
primaeres Signal statt nur tracking_services-Anzahl.

Smoke-Test Safetykon: 4 Critical Findings im Mail, banner-Endpoint
liefert 46 checks + 3 phases + 2 categories mit provider_details_visible=False.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:31:13 +02:00

120 lines
4.1 KiB
Python

"""
Voll-Audit Findings Router — unified view across all 4 finding sources.
Endpoint:
GET /api/compliance/agent/findings/{check_id}
?source=mc|pflichtangabe|vendor|redundanz|all
&severity=CRITICAL|HIGH|MEDIUM|LOW|INFO|all
&doc_type=impressum|dse|cookie|...|all
&status=failed|passed|skipped|na|info|all
&q=<freitext>
&limit=<int>
Liefert summary + filtered findings list. Frontend rendert daraus den
Voll-Audit-Tab unter /sdk/agent/audit/<check_id>.
"""
from __future__ import annotations
import logging
from urllib.parse import urlparse
from fastapi import APIRouter, HTTPException, Query
from compliance.services.unified_findings_store import (
findings_summary,
list_findings,
)
from compliance.services.compliance_audit_log import get_check_run, get_check_payload
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/compliance/agent", tags=["agent"])
def _normalize_domain(d: str) -> str:
if not d:
return ""
if "://" not in d:
d = "https://" + d
host = urlparse(d).netloc.lower()
return host[4:] if host.startswith("www.") else host
@router.get("/findings/{check_id}")
def get_findings(
check_id: str,
source: str | None = Query(None, description="mc|pflichtangabe|vendor|redundanz|all"),
severity: str | None = Query(None, description="CRITICAL|HIGH|MEDIUM|LOW|INFO|all"),
doc_type: str | None = Query(None),
status: str | None = Query(None, description="failed|passed|skipped|na|info|all"),
q: str | None = Query(None, description="freitext-suche label/vendor"),
limit: int = Query(1000, ge=1, le=5000),
expected_domain: str | None = Query(
None, description="Hard-Assertion: Run muss zu dieser Domain gehoeren (Cross-Tenant-Schutz)",
),
) -> dict:
"""Return aggregated findings + summary counters for a check run."""
# P7-Restpunkt: optionale Domain-Assertion. Verhindert dass ein Frontend
# einen check_id einer fremden Tenant-Domain anfragen kann.
if expected_domain:
run = get_check_run(check_id)
actual = _normalize_domain((run or {}).get("base_domain") or "")
if not run or actual != _normalize_domain(expected_domain):
raise HTTPException(
status_code=403,
detail=f"Cross-tenant access blocked: check_id {check_id} "
f"gehoert zu Domain '{actual or '?'}', angefragt: "
f"'{_normalize_domain(expected_domain)}'",
)
try:
summary = findings_summary(check_id)
findings = list_findings(
check_id=check_id,
source_type=source,
severity=severity,
doc_type=doc_type,
status=status,
q=q,
limit=limit,
)
return {
"found": summary.get("total", 0) > 0,
"check_id": check_id,
"summary": summary,
"filter": {
"source": source or "all",
"severity": severity or "all",
"doc_type": doc_type or "all",
"status": status or "all",
"q": q or "",
"limit": limit,
},
"count": len(findings),
"findings": findings,
}
except Exception as e:
logger.exception("get_findings failed for %s", check_id)
return {
"found": False,
"check_id": check_id,
"error": str(e)[:200],
"summary": {},
"count": 0,
"findings": [],
}
@router.get("/banner/{check_id}")
def get_banner_payload(check_id: str) -> dict:
"""P20: full banner_result (phases, structured_checks, category_tests,
banner_checks.violations) fuer das Voll-Audit-Frontend.
"""
try:
payload = get_check_payload(check_id) or {}
banner = payload.get("banner") or {}
return {"found": bool(banner), "check_id": check_id, "banner": banner}
except Exception as e:
logger.exception("get_banner_payload failed for %s", check_id)
return {"found": False, "check_id": check_id,
"error": str(e)[:200], "banner": {}}