""" Authenticated Scanner — tests post-login functionality. Checks §312k BGB (cancellation), Art. 17 (deletion), Art. 20 (export), Art. 7(3) (consent withdrawal), Art. 15 (data access). Credentials are NEVER stored, logged, or transmitted beyond the browser context. """ import logging from dataclasses import dataclass, field from playwright.async_api import async_playwright, Page logger = logging.getLogger(__name__) USER_AGENT = ( "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" ) @dataclass class CheckResult: found: bool = False selector: str = "" text: str = "" clicks_needed: int = 0 screenshot: bytes = b"" @dataclass class AuthTestResult: authenticated: bool = False login_error: str = "" cancel_subscription: CheckResult = field(default_factory=CheckResult) delete_account: CheckResult = field(default_factory=CheckResult) export_data: CheckResult = field(default_factory=CheckResult) consent_settings: CheckResult = field(default_factory=CheckResult) profile_visible: CheckResult = field(default_factory=CheckResult) # Search patterns for each check (DE + EN) CANCEL_PATTERNS = [ "kündigen", "kuendigen", "vertrag beenden", "abo beenden", "mitgliedschaft kündigen", "cancel subscription", "unsubscribe", "cancel membership", "vertrag kündigen", ] DELETE_PATTERNS = [ "konto löschen", "konto loeschen", "account löschen", "delete account", "account deaktivieren", "profil löschen", "remove account", ] EXPORT_PATTERNS = [ "daten exportieren", "daten herunterladen", "export data", "download data", "meine daten", "datenauskunft", "data download", "daten anfordern", ] CONSENT_PATTERNS = [ "einwilligung", "einstellungen", "datenschutz-einstellungen", "consent", "privacy settings", "cookie-einstellungen", "werbeeinstellungen", "marketing preferences", ] PROFILE_PATTERNS = [ "profil", "mein konto", "kontodaten", "persönliche daten", "profile", "my account", "account settings", "personal data", ] async def run_authenticated_test( url: str, username: str, password: str, username_selector: str = "", password_selector: str = "", submit_selector: str = "", ) -> AuthTestResult: """Run authenticated area test. Credentials are destroyed after test.""" result = AuthTestResult() async with async_playwright() as p: browser = await p.chromium.launch( headless=True, args=["--no-sandbox", "--disable-dev-shm-usage"], ) context = await browser.new_context(user_agent=USER_AGENT) page = await context.new_page() try: # Step 1: Login await page.goto(url, wait_until="networkidle", timeout=30000) await page.wait_for_timeout(2000) login_ok = await _try_login( page, username, password, username_selector, password_selector, submit_selector, ) if not login_ok: result.login_error = "Login fehlgeschlagen — Formular nicht gefunden oder Credentials falsch" await context.close() await browser.close() return result result.authenticated = True await page.wait_for_timeout(3000) # Step 2: Check cancellation (§312k BGB) result.cancel_subscription = await _check_patterns(page, CANCEL_PATTERNS, "cancel") logger.info("Cancel check: found=%s", result.cancel_subscription.found) # Step 3: Check delete account (Art. 17 DSGVO) result.delete_account = await _check_patterns(page, DELETE_PATTERNS, "delete") # Step 4: Check data export (Art. 20 DSGVO) result.export_data = await _check_patterns(page, EXPORT_PATTERNS, "export") # Step 5: Check consent settings (Art. 7(3) DSGVO) result.consent_settings = await _check_patterns(page, CONSENT_PATTERNS, "consent") # Step 6: Check profile visibility (Art. 15 DSGVO) result.profile_visible = await _check_patterns(page, PROFILE_PATTERNS, "profile") except Exception as e: logger.error("Authenticated test failed: %s", e) result.login_error = str(e) finally: # CRITICAL: Destroy context — wipes all credentials, cookies, session await context.close() await browser.close() return result async def _try_login( page: Page, username: str, password: str, user_sel: str, pass_sel: str, submit_sel: str, ) -> bool: """Attempt to fill and submit login form.""" try: # Auto-detect selectors if not provided if not user_sel: for sel in ['input[type="email"]', 'input[name="email"]', 'input[name="username"]', 'input[name="login"]', 'input[id="email"]', 'input[id="username"]']: if await page.locator(sel).count() > 0: user_sel = sel break if not pass_sel: for sel in ['input[type="password"]', 'input[name="password"]', 'input[id="password"]']: if await page.locator(sel).count() > 0: pass_sel = sel break if not submit_sel: for sel in ['button[type="submit"]', 'input[type="submit"]', 'button:has-text("Anmelden")', 'button:has-text("Login")', 'button:has-text("Sign in")', 'button:has-text("Einloggen")']: if await page.locator(sel).count() > 0: submit_sel = sel break if not user_sel or not pass_sel: return False await page.fill(user_sel, username) await page.fill(pass_sel, password) if submit_sel: await page.click(submit_sel) else: await page.press(pass_sel, "Enter") await page.wait_for_timeout(5000) # Check if login succeeded (URL changed or login form disappeared) still_on_login = await page.locator('input[type="password"]').count() > 0 return not still_on_login except Exception as e: logger.warning("Login attempt failed: %s", e) return False async def _check_patterns(page: Page, patterns: list[str], check_name: str) -> CheckResult: """Search current page and navigation for patterns.""" result = CheckResult() # Check current page text for pattern in patterns: try: locator = page.get_by_text(pattern, exact=False) count = await locator.count() if count > 0: text = await locator.first.text_content() result.found = True result.text = (text or "").strip()[:100] return result except Exception: continue # Check links/buttons for pattern in patterns: try: for sel in [f'a:has-text("{pattern}")', f'button:has-text("{pattern}")', f'[href*="{pattern.replace(" ", "-")}"]']: locator = page.locator(sel) if await locator.count() > 0: result.found = True result.selector = sel result.text = pattern return result except Exception: continue # Check navigation menus (common locations for account management) for nav_sel in ['nav', '[role="navigation"]', '.sidebar', '.account-menu', '#account']: try: nav = page.locator(nav_sel) if await nav.count() > 0: nav_text = (await nav.first.text_content() or "").lower() for pattern in patterns: if pattern.lower() in nav_text: result.found = True result.text = f"In Navigation: {pattern}" return result except Exception: continue return result