"""Tests for the canonical cassette sanitization registry. The registry lives in :mod:`tests.cassette_patterns` and exports a single :func:`scrub_string` sanitizer plus an :func:`is_clean` validator. These tests assert: - Every cookie shape we know about scrubs cleanly (positive) - Scrubbing is idempotent on already-scrubbed input (no double-scrub) - Every placeholder in ``SCRUB_PLACEHOLDERS`` is recognised as clean - A real cookie value starting with ``S`` IS still flagged as a leak — the legacy bash guard used a ``[^S"]`` character class that exempted any real secret whose first character was ``S``; the consolidated registry uses exact-match placeholder membership to close that hole. - Registry stays in sync with :mod:`tests.vcr_config` - Bad-cassette regressions (the shape-lint inputs) are caught by :func:`is_clean`. Inline payloads are used so this test does not depend on filesystem fixtures. """ from __future__ import annotations from pathlib import Path import pytest from tests import vcr_config from tests.cassette_patterns import ( DISPLAY_NAME_FALSE_POSITIVES, EMAIL_PROVIDERS, HOST_COOKIES, OPTIONAL_COOKIES, SCRUB_PLACEHOLDERS, SECURE_COOKIES, SESSION_COOKIES, find_credential_leaks, find_high_entropy_leaks, is_clean, scrub_string, ) REPO_ROOT = Path(__file__).resolve().parents[2] # A synthetic Google API key whose *shape* (``AIza`` + 35 ``[A-Za-z0-9_-]`` # chars) matches the canonical Google API-key pattern the registry scrubs, but # which is built by concatenation at runtime so no contiguous 39-char key # literal ever appears in this source file — embedding a real-looking key here # would itself trip GitHub secret scanning. The value is obviously fake. FAKE_GOOGLE_API_KEY = "AIza" + "FAKE0" * 7 assert len(FAKE_GOOGLE_API_KEY) == 39 # AIza + 35 # --------------------------------------------------------------------------- # Exports # --------------------------------------------------------------------------- def test_registry_exports_required_constants() -> None: """Every constant called out in the sanitization-registry spec is exported.""" assert isinstance(SESSION_COOKIES, list) and SESSION_COOKIES assert isinstance(SECURE_COOKIES, list) and SECURE_COOKIES assert isinstance(HOST_COOKIES, list) and HOST_COOKIES assert isinstance(OPTIONAL_COOKIES, list) # allowed empty in theory assert isinstance(EMAIL_PROVIDERS, list) and EMAIL_PROVIDERS assert isinstance(SCRUB_PLACEHOLDERS, frozenset) and SCRUB_PLACEHOLDERS def test_session_cookies_contains_expected_names() -> None: """Lock the canonical SID-family cookie names.""" for name in ("SID", "HSID", "SSID", "APISID", "SAPISID", "SIDCC", "OSID", "NID"): assert name in SESSION_COOKIES # --------------------------------------------------------------------------- # scrub_string — positive: every cookie shape we redact # --------------------------------------------------------------------------- @pytest.mark.parametrize( "name", ["SID", "HSID", "SSID", "APISID", "SAPISID", "SIDCC", "OSID", "NID"] ) def test_session_cookie_header_form_is_scrubbed(name: str) -> None: """``Cookie: NAME=secret; ...`` → ``Cookie: NAME=SCRUBBED; ...``""" header = f"Cookie: foo=bar; {name}=ABCDEF1234567890; baz=qux" scrubbed = scrub_string(header) assert "ABCDEF1234567890" not in scrubbed assert f"{name}=SCRUBBED" in scrubbed @pytest.mark.parametrize( "name", [ "__Secure-1PSID", "__Secure-3PSID", "__Secure-1PSIDCC", "__Secure-3PSIDTS", "__Host-GAPS", ], ) def test_secure_and_host_cookies_are_scrubbed(name: str) -> None: """``__Secure-*`` / ``__Host-*`` umbrella scrubs the value, keeps the name.""" header = f"Cookie: {name}=REAL_SECRET_HERE; other=keep" scrubbed = scrub_string(header) assert "REAL_SECRET_HERE" not in scrubbed assert f"{name}=SCRUBBED" in scrubbed @pytest.mark.parametrize("name", ["SID", "SAPISID", "__Secure-1PSID"]) def test_storage_state_name_first_is_scrubbed(name: str) -> None: """Playwright storage_state ``{"name":..., "value":...}`` shape is scrubbed.""" text = f'{{"name":"{name}","value":"REAL_VALUE_HERE","domain":".google.com"}}' scrubbed = scrub_string(text) assert "REAL_VALUE_HERE" not in scrubbed assert '"value":"SCRUBBED"' in scrubbed @pytest.mark.parametrize("name", ["SID", "SAPISID", "__Secure-1PSID"]) def test_storage_state_value_first_is_scrubbed(name: str) -> None: """Defensive ordering: ``"value":..., "name":...`` is also scrubbed.""" text = f'{{"value":"REAL_VALUE_HERE","name":"{name}","domain":".google.com"}}' scrubbed = scrub_string(text) assert "REAL_VALUE_HERE" not in scrubbed assert '"value":"SCRUBBED"' in scrubbed def test_url_session_id_is_scrubbed() -> None: """``f.sid=...`` in URL query params is scrubbed.""" url = "https://notebooklm.google.com/_/?f.sid=ABCDE12345&f.cv=1" assert "ABCDE12345" not in scrub_string(url) assert "f.sid=SCRUBBED" in scrub_string(url) def test_at_csrf_token_is_scrubbed_in_body() -> None: """``at=...`` form parameter is scrubbed to ``at=SCRUBBED_CSRF``.""" body = "f.req=..&at=AABBCCDD-EEFF-GGHH&other=keep" scrubbed = scrub_string(body) assert "AABBCCDD-EEFF-GGHH" not in scrubbed assert "at=SCRUBBED_CSRF" in scrubbed @pytest.mark.parametrize("provider", EMAIL_PROVIDERS) def test_email_is_scrubbed_quoted(provider: str) -> None: """JSON-quoted emails at any supported provider are scrubbed.""" text = f'{{"email":"alice.example+tag@{provider}.com"}}' scrubbed = scrub_string(text) assert provider not in scrubbed assert '"SCRUBBED_EMAIL@example.com"' in scrubbed @pytest.mark.parametrize("provider", EMAIL_PROVIDERS) def test_email_is_scrubbed_unquoted(provider: str) -> None: """Bare ``user@provider.com`` in HTML / JS contexts is scrubbed.""" text = f'mail me' scrubbed = scrub_string(text) assert provider not in scrubbed assert "SCRUBBED_EMAIL@example.com" in scrubbed # --------------------------------------------------------------------------- # scrub_string — negative: legitimate content survives unchanged # --------------------------------------------------------------------------- def test_bsid_cookie_substring_is_not_scrubbed() -> None: """A benign cookie named ``BSID`` containing the ``SID`` suffix survives. The negative lookbehind on each cookie-header pattern anchors at a cookie-name boundary; without it the regex would eat the ``SID=...`` tail of ``BSID=...``. """ header = "Cookie: BSID=PUBLIC_VALUE_HERE; other=keep" assert scrub_string(header) == header def test_legitimate_two_word_source_title_not_scrubbed() -> None: """A non-displayName JSON key with a two-Capitalized-word value survives.""" text = '{"title": "Source Title"}' assert scrub_string(text) == text def test_unknown_email_provider_not_scrubbed() -> None: """An email at a provider we do NOT cover (``@corp.internal``) is preserved.""" text = '{"contact":"bob@corp.internal"}' assert scrub_string(text) == text @pytest.mark.parametrize("param", ["flat", "rate", "format", "stat"]) def test_at_lookbehind_protects_param_names_ending_in_at(param: str) -> None: """Params whose names *end* in ``at`` are not eaten by the ``at=`` scrubber. Without the negative-lookbehind anchor, ``at=[A-Za-z0-9_-]+`` would match the substring ``at=VALUE`` inside ``flat=VALUE`` / ``rate=VALUE`` and corrupt the URL or form body. """ body = f"foo=1&{param}=PUBLIC_VALUE&bar=2" assert scrub_string(body) == body # --------------------------------------------------------------------------- # scrub_string — idempotence # --------------------------------------------------------------------------- def test_scrub_is_idempotent_on_already_scrubbed_cookie_header() -> None: text = "Cookie: SID=SCRUBBED; __Secure-1PSID=SCRUBBED" once = scrub_string(text) twice = scrub_string(once) assert once == twice assert once == text # nothing changed on the first pass either def test_scrub_is_idempotent_on_already_scrubbed_email() -> None: """``SCRUBBED_EMAIL@example.com`` survives a second scrub pass unchanged.""" once = scrub_string('{"email":"alice@gmail.com"}') twice = scrub_string(once) assert once == twice assert '"SCRUBBED_EMAIL@example.com"' in twice def test_scrub_is_idempotent_on_already_scrubbed_storage_state() -> None: text = '{"name":"SID","value":"SCRUBBED","domain":".google.com"}' once = scrub_string(text) twice = scrub_string(once) assert once == twice == text @pytest.mark.parametrize( "field,placeholder", [ ("SNlM0e", "SCRUBBED_CSRF"), ("FdrFJe", "SCRUBBED_SESSION"), ("oPEP7c", "SCRUBBED_EMAIL"), ("S06Grb", "SCRUBBED_USER_ID"), ("B8SWKb", "SCRUBBED_API_KEY"), ("JrWMbf", "SCRUBBED_API_KEY"), ("at", "SCRUBBED_CSRF"), ], ) def test_token_field_scrubs_value_with_escaped_quote(field: str, placeholder: str) -> None: """JSON token values containing ``\\"`` are scrubbed in full, not truncated. Regression test for the naive ``[^"]+`` value match: without the escape- aware idiom, the scrub stops at the first ``\\"`` and leaves the suffix of the secret in the cassette while ``is_clean`` is fooled by the leading placeholder. The new ``(?:[^"\\\\]|\\\\.)*`` idiom matches across escape sequences so the entire JSON string value is replaced. """ text = f'{{"{field}":"REAL_PREFIX\\"REAL_SUFFIX"}}' scrubbed = scrub_string(text) assert "REAL_PREFIX" not in scrubbed, scrubbed assert "REAL_SUFFIX" not in scrubbed, scrubbed assert f'"{field}":"{placeholder}"' in scrubbed # --------------------------------------------------------------------------- # is_clean — positive: every known placeholder is accepted # --------------------------------------------------------------------------- @pytest.mark.parametrize("placeholder", sorted(SCRUB_PLACEHOLDERS)) def test_is_clean_accepts_known_placeholder_in_cookie_header(placeholder: str) -> None: """Every member of ``SCRUB_PLACEHOLDERS`` is recognised as a clean value.""" # Single-name placeholders are valid cookie values; the email placeholder # contains '@' which we test via the JSON-key shape (cookies in JSON-key # shape are explicitly enumerated below). if "@" in placeholder: pytest.skip("header form doesn't apply to email-shaped placeholder") header = f"Cookie: SID={placeholder}" ok, leaks = is_clean(header) assert ok, leaks def test_is_clean_accepts_scrubbed_storage_state_value() -> None: text = '{"name":"SID","value":"SCRUBBED","domain":".google.com"}' ok, leaks = is_clean(text) assert ok, leaks def test_is_clean_accepts_scrubbed_cookie_json_key() -> None: text = '{"SID":"SCRUBBED","__Secure-1PSID":"SCRUBBED"}' ok, leaks = is_clean(text) assert ok, leaks def test_is_clean_accepts_scrubbed_email_placeholder() -> None: text = '{"email":"SCRUBBED_EMAIL@example.com"}' ok, leaks = is_clean(text) assert ok, leaks # --------------------------------------------------------------------------- # is_clean — negative: real leaks are detected # --------------------------------------------------------------------------- def test_is_clean_flags_real_email() -> None: """A real ``@gmail.com`` address survives a missing-scrub pass.""" ok, leaks = is_clean('{"email":"realname@gmail.com"}') assert not ok assert any("realname" in leak or "email" in leak.lower() for leak in leaks) def test_is_clean_flags_sid_starting_with_S_in_header_form() -> None: """Cookie-leak heuristic: a real SID value starting with ``S`` is detected. The legacy bash guard used a ``[^S"]`` character class on the cookie value, which exempted any real secret whose first character was ``S``. The new registry uses an exact-match :data:`SCRUB_PLACEHOLDERS` allowlist so the starting character is irrelevant — anything not in the allowlist is a leak. """ text = "Set-Cookie: SID=S_REAL_LEAKED_TOKEN; Path=/" ok, leaks = is_clean(text) assert not ok, "S-prefixed real cookie value should be flagged" assert any("SID" in leak for leak in leaks) def test_is_clean_flags_sid_starting_with_S_in_storage_state() -> None: """Same cookie-leak heuristic in Playwright ``storage_state.json`` shape.""" text = '{"name":"SID","value":"S_REAL_VALUE","domain":".google.com"}' ok, leaks = is_clean(text) assert not ok assert any("SID" in leak for leak in leaks) def test_is_clean_flags_sid_starting_with_S_in_json_key() -> None: """Same cookie-leak heuristic in the JSON-dict-with-cookie-name-as-key shape.""" text = '{"SAPISID": "S_real_leaked_token_here"}' ok, _ = is_clean(text) assert not ok def test_is_clean_flags_short_one_char_cookie_value() -> None: """A single-character non-scrubbed leak (``"SID": "x"``) is detected.""" text = '{"SID": "x"}' ok, _ = is_clean(text) assert not ok def test_is_clean_flags_wiz_global_data_unscrubbed_csrf() -> None: """``SNlM0e`` left at its real value is flagged as a leak.""" text = '{"SNlM0e":"AB-some-real-CSRF-token-value-12345"}' ok, leaks = is_clean(text) assert not ok assert any("SNlM0e" in leak or "CSRF" in leak.upper() for leak in leaks) def test_is_clean_flags_jrwmbf_unscrubbed_api_key() -> None: """``JrWMbf`` left at its real Google API key value is flagged as a leak. Regression for the ``generate_mind_map_interactive`` / ``mind_maps_interactive`` cassette leak: the NotebookLM web API key rode in the ``JrWMbf`` WIZ field, which had no scrubber, so the key round-tripped into committed cassettes unredacted. """ text = f'{{"JrWMbf":"{FAKE_GOOGLE_API_KEY}"}}' ok, leaks = is_clean(text) assert not ok assert any("JrWMbf" in leak or "Google API key" in leak for leak in leaks) def test_is_clean_flags_bare_google_api_key_in_unknown_field() -> None: """A Google API-key shape is flagged even outside any known WIZ field. The field-name-agnostic catch-all detector is the backstop that closes the ``JrWMbf`` gap class: a key in any future/unknown field is still caught. """ text = f'{{"SomeUnknownField":"{FAKE_GOOGLE_API_KEY}"}}' ok, leaks = is_clean(text) assert not ok assert any("Google API key" in leak for leak in leaks) def test_scrub_removes_google_api_key_in_unknown_field() -> None: """The catch-all scrubber collapses an ``AIza`` key in any field.""" text = f'{{"SomeUnknownField":"{FAKE_GOOGLE_API_KEY}"}}' scrubbed = scrub_string(text) assert FAKE_GOOGLE_API_KEY not in scrubbed assert "SCRUBBED_API_KEY" in scrubbed def test_longer_than_canonical_api_key_is_fully_scrubbed_no_partial_leak() -> None: """A key with MORE than 35 tail chars is scrubbed in full (no trailing leak). Regression for the exact-``{35}``-quantifier partial-leak class: with an exact quantifier, ``AIza`` + 36 chars in an unknown field would scrub only the first 39 chars and leave a trailing fragment that ``SCRUBBED_API_KEY`` no longer re-matches — silently leaking the tail. The greedy ``{35,}`` tail consumes the whole contiguous key-char run. """ # Tail char ``Z`` is absent from the ``SCRUBBED_API_KEY`` sentinel, so its # presence in the output can only mean an un-scrubbed key fragment survived. long_key = "AIza" + "Z" * 40 # 4 + 40 = 44 chars, well over the canonical 39 text = f'{{"SomeUnknownField":"{long_key}"}}' scrubbed = scrub_string(text) # No fragment of the original key survives (not even a trailing remainder). assert "Z" not in scrubbed, scrubbed assert "AIza" not in scrubbed, scrubbed assert "SCRUBBED_API_KEY" in scrubbed # And the validator agrees the scrubbed output is clean. assert is_clean(scrubbed)[0] # The raw long key is itself flagged before scrubbing. assert find_credential_leaks(text) # --------------------------------------------------------------------------- # find_credential_leaks — high-severity-only subset (for fixture scanning) # --------------------------------------------------------------------------- def test_find_credential_leaks_flags_google_api_key() -> None: """A Google API-key shape is reported by the credential-only scanner.""" leaks = find_credential_leaks(f'{{"JrWMbf":"{FAKE_GOOGLE_API_KEY}"}}') assert any("Google API key" in leak for leak in leaks) def test_find_credential_leaks_flags_auth_token() -> None: """A raw ``g.a000-`` auth token is reported by the credential-only scanner.""" leaks = find_credential_leaks("Cookie: SID=g.a000-abcdefghijklmnop") assert any("auth token" in leak for leak in leaks) def test_find_credential_leaks_ignores_placeholder_fixture_content() -> None: """Placeholder content that trips ``is_clean`` is NOT flagged here. This is the property that makes ``--secrets-only`` safe to run over ``tests/fixtures/`` — escaped display names, test emails, and scrubbed cookie sentinels are all ignored; only real credential shapes match. """ fixture_like = ( '[\\"Scrubbed Note Title\\",\\"alice@gmail.com\\"]' ' {"SID":"SCRUBBED"} {"oPEP7c":"SCRUBBED_EMAIL"}' ) # is_clean WOULD flag the escaped display name / email here ... assert not is_clean(fixture_like)[0] # ... but the credential-only scanner stays silent. assert find_credential_leaks(fixture_like) == [] # --------------------------------------------------------------------------- # Generic high-entropy / unknown-field credential scan (issue #1382) # --------------------------------------------------------------------------- # # The targeted detectors above are name-anchored or known-shape, making the # guard necessary-but-not-sufficient: a NOVEL token shape, or a known secret in # an un-targeted field, passes silently (the LSID / JrWMbf leaks). These tests # pin the field-agnostic backstop: it WOULD catch a planted novel token, yet # produces ZERO false positives on every committed cassette. # Synthetic novel credentials assembled at runtime from shorter chunks so this # source file carries NO contiguous static credential-shaped literal — a 64-char # hex or 40+ char base64 string written inline would itself trip secret scanning # (Betterleaks flags it as a generic API key). The shapes are also distinct from # the known g.a000-/sidts-/ya29./AIza prefixes, and the deterministic mixes keep # the base64 entropy well above the 4.0 bits/char floor. NOVEL_BASE64_TOKEN = "".join( ("kJ8sLm2NpQr5TvWx", "Yz0AbCdEfGhIjKlM", "nOpQrStUvWxYz123", "45678AbCdEf") ) NOVEL_HEX_TOKEN = "".join( ("9f8e7d6c5b4a3928", "17065fe4d3c2b1a0", "9f8e7d6c5b4a3928", "17065fe4d3c2b1a0") ) assert len(NOVEL_BASE64_TOKEN) >= 40 assert len(NOVEL_HEX_TOKEN) >= 64 def test_entropy_scan_flags_novel_base64_token_in_unknown_field() -> None: """A long high-entropy base64 token in an UNKNOWN field is flagged. This is the residual-risk shape the name-anchored detectors miss: a credential family the registry does not yet know about, riding in a field that is not on any allowlist. The generic entropy scan catches it by shape alone. """ payload = f'{{"sessionBlob":"{NOVEL_BASE64_TOKEN}"}}' leaks = find_high_entropy_leaks(payload) assert any("high-entropy token" in leak for leak in leaks), leaks # It also surfaces through both public entry points. assert any("high-entropy token" in leak for leak in find_credential_leaks(payload)) assert not is_clean(payload)[0] def test_entropy_scan_flags_novel_hex_token_in_unknown_field() -> None: """A long pure-hex token (64+ chars) in an unknown field is flagged. Hex entropy caps at log2(16) == 4.0 bits/char, so the base64 entropy bar can never reliably fire on it — the scan routes pure-hex tokens onto a dedicated 64-char length floor instead. """ payload = f'{{"legacyToken":"{NOVEL_HEX_TOKEN}"}}' assert any("high-entropy token" in leak for leak in find_high_entropy_leaks(payload)) assert not is_clean(payload)[0] def test_entropy_scan_ignores_internal_uuid() -> None: """A 36-char UUID (artifact / source / conversation ID) is NOT flagged. UUIDs are NotebookLM-internal identifiers the scrubbers deliberately preserve. They fall under both the 40-char base64 length floor and the explicit UUID-shape allowlist, so the scan must stay silent. """ payload = '{"artifactId":"06f0c5bd-108f-4c8b-8911-34b2acc656de"}' assert find_high_entropy_leaks(payload) == [] assert is_clean(payload)[0] def test_entropy_scan_ignores_scrub_placeholders() -> None: """Canonical scrub placeholders are never flagged (idempotent validation).""" for placeholder in SCRUB_PLACEHOLDERS: payload = f'{{"field":"{placeholder}"}}' assert find_high_entropy_leaks(payload) == [], placeholder def test_entropy_scan_ignores_short_token() -> None: """A token below the 40-char base64 length floor is not flagged.""" payload = '{"x":"abcdef1234567890ABCDEF"}' # 22 chars, high entropy assert find_high_entropy_leaks(payload) == [] def test_entropy_scan_ignores_unquoted_high_entropy_content() -> None: """High-entropy content that is NOT a quoted scalar is out of scope. The scan deliberately anchors on a quoted JSON string scalar — the shape a leaked credential takes in an unknown field — so the legitimate high-entropy text that pervades real cassettes (CSS class runs, base64 query blobs split by ``=``/``&``, hex mind-map data) stays out of scope. This documents that blind spot as a deliberate decision, not an accident. """ # Mirrors a real cassette ``context=eJw...`` zlib-base64 query param: the # token is preceded by ``context=`` and is NOT inside quotes. blob = "kJ8sLm2NpQr5TvWxYz0AbCdEfGhIjKlMnOpQrStUvWxYz12345678AbCdEf" assert find_high_entropy_leaks(f"context={blob}&next=1") == [] def test_all_committed_cassettes_pass_entropy_scan() -> None: """ZERO false positives: every committed cassette passes the entropy scan. Calibration guard. The thresholds are tuned against the real corpus; if a future cassette legitimately carries a quoted high-entropy scalar this test fails loudly so the threshold/allowlist is revisited deliberately rather than the scan silently regressing into noise. """ cassette_dir = REPO_ROOT / "tests" / "cassettes" offenders: list[str] = [] for cassette in sorted(cassette_dir.rglob("*.yaml")): # ``examples/`` holds illustrative fixtures with hand-edited placeholder # content, excluded from the real-recording guard for the same reason # the checker excludes them. if "examples" in cassette.parts: continue text = cassette.read_text(encoding="utf-8", errors="replace") leaks = find_high_entropy_leaks(text) if leaks: offenders.append(f"{cassette.name}: {leaks[:3]}") assert not offenders, "entropy scan false-positives on committed cassettes:\n" + "\n".join( offenders ) # --------------------------------------------------------------------------- # Round-trip: scrub_string(x) is always is_clean # --------------------------------------------------------------------------- @pytest.mark.parametrize( "leak_input", [ "Cookie: SID=ABCDEF1234567890", "Cookie: __Secure-1PSID=REAL_VALUE; Path=/", "Set-Cookie: SAPISID=S_REAL_TOKEN; Path=/", '{"name":"SID","value":"S_REAL_VALUE"}', '{"value":"S_REAL_VALUE","name":"__Secure-1PSID"}', # Direct JSON-key cookie shape (round-trip via the rule added to # close the validator/sanitizer asymmetry). '{"SID": "S_REAL_LEAKED_TOKEN"}', '{"SAPISID":"S_real_leaked_token_here"}', '{"__Secure-1PSID": "S_REAL_VALUE"}', '{"email":"alice@gmail.com"}', '{"SNlM0e":"real-csrf-here"}', '{"FdrFJe":"real-session-here"}', '{"oPEP7c":"alice@gmail.com"}', '{"S06Grb":"123456789012345678901"}', '{"W3Yyqf":"123456789012345678901"}', '{"qDCSke":"123456789012345678901"}', '{"B8SWKb":"AIzaSyAREAL_API_KEY_HERE"}', '{"VqImj":"AIzaSyAREAL_API_KEY_HERE"}', f'{{"JrWMbf":"{FAKE_GOOGLE_API_KEY}"}}', # Bare Google API-key shape outside any known WIZ field — the # field-name-agnostic catch-all must still scrub it. f'{{"SomeUnknownField":"{FAKE_GOOGLE_API_KEY}"}}', '{"QGcrse":"real-client-id"}', '{"iQJtYd":"real-project-id"}', "f.sid=REAL_SESSION_TOKEN", "at=REAL_CSRF_TOKEN", ], ) def test_scrub_then_is_clean_round_trip(leak_input: str) -> None: """Anything :func:`scrub_string` produces must satisfy :func:`is_clean`.""" scrubbed = scrub_string(leak_input) ok, leaks = is_clean(scrubbed) assert ok, f"scrubbed output still leaks: {leaks}" # --------------------------------------------------------------------------- # Registry ↔ vcr_config sync # --------------------------------------------------------------------------- def test_vcr_config_uses_registry_scrub_string() -> None: """``vcr_config.scrub_string`` is sourced from ``cassette_patterns``. ``vcr_config`` loads ``cassette_patterns`` via ``importlib.util.spec_from_ file_location`` (a separate module identity from the ``sys.path``-import used by the test harness), so we cannot use ``is`` identity. Instead we pin both the source-file location and the byte-for-byte source of the bound function — if a future refactor reintroduces an inline pattern list in ``vcr_config`` and reassigns ``scrub_string`` to a local definition, either check fails. """ import inspect assert inspect.getfile(vcr_config.scrub_string).endswith("cassette_patterns.py") assert inspect.getsource(vcr_config.scrub_string) == inspect.getsource(scrub_string) def test_vcr_config_has_no_inline_sensitive_patterns() -> None: """``vcr_config`` MUST NOT define its own ``SENSITIVE_PATTERNS`` list. Guards against drift between recorder and guard. The registry is the single source of truth; this test fails if vcr_config recreates a local copy. """ assert not hasattr(vcr_config, "SENSITIVE_PATTERNS") def test_registry_session_cookies_all_scrubbed_by_vcr_config() -> None: """Each :data:`SESSION_COOKIES` name has a working scrubber. This is the registry-sync invariant: if either the registry's cookie list or ``vcr_config``'s scrubber pipeline drifts so that a declared cookie name no longer gets its value scrubbed, this test fails. """ for name in SESSION_COOKIES: header = f"Cookie: {name}=REAL_SECRET_TOKEN_HERE" scrubbed = vcr_config.scrub_string(header) assert "REAL_SECRET_TOKEN_HERE" not in scrubbed, ( f"{name} declared in SESSION_COOKIES but not scrubbed by vcr_config" ) assert f"{name}=SCRUBBED" in scrubbed def test_registry_secure_cookies_all_scrubbed_by_vcr_config() -> None: """Each :data:`SECURE_COOKIES` name is caught by the umbrella scrubber.""" for name in SECURE_COOKIES: header = f"Cookie: {name}=REAL_SECRET" scrubbed = vcr_config.scrub_string(header) assert "REAL_SECRET" not in scrubbed assert f"{name}=SCRUBBED" in scrubbed def test_filter_headers_disjoint_from_cookies() -> None: """VCR ``filter_headers`` covers HTTP-header-only entries — not cookies. Cookies are scrubbed via :func:`scrub_string`, not dropped via ``filter_headers``. If a future change moves an SID-family name into ``filter_headers`` (which would silently drop the entire ``Cookie`` header from every cassette and break replay), this assertion catches it. """ cookie_names = set(SESSION_COOKIES) | set(SECURE_COOKIES) | set(HOST_COOKIES) filter_headers = set(vcr_config.notebooklm_vcr.filter_headers) overlap = cookie_names & filter_headers assert not overlap, ( f"cookie names found in vcr filter_headers (should be scrubbed, not dropped): {overlap}" ) # --------------------------------------------------------------------------- # Bad-cassette regression sanity check (inline payloads so this test does # not depend on filesystem fixtures) # --------------------------------------------------------------------------- def test_bad_cassette_byte_count_payload_with_email_is_flagged() -> None: """A synthetic bad-cassette body with a leaked email is flagged.""" body = '12\n{"u":"alice@gmail.com"}\n' ok, _ = is_clean(body) assert not ok def test_bad_cassette_cookie_header_payload_is_flagged() -> None: """A synthetic bad-cassette body with a leaked cookie value is flagged.""" body = "Set-Cookie: SID=S_REAL_LEAK; Path=/\n" ok, _ = is_clean(body) assert not ok # --------------------------------------------------------------------------- # upload + Drive token scrubbing # # These tests pin down two invariants: # # 1. Upload tokens (X-GUploader-UploadID headers, upload_id query params, # full upload URLs) and Drive tokens (AONS permission tokens, Drive # file IDs inside ``"file_id":`` JSON or ``/drive/v3/files/`` URLs) # scrub to their canonical placeholders. # # 2. The DRIVE_FILE_ID regex is context-anchored so bare 36-char UUIDs # elsewhere (artifact IDs, source IDs, conversation IDs) are NOT # scrubbed — those are NotebookLM-internal identifiers and matching # them would corrupt cassette replay. The negative regression block # is the load-bearing test here. # --------------------------------------------------------------------------- def test_upload_id_header_scrubbed() -> None: header = "X-GUploader-UploadID: ABPj22qXYZ_-abc123def456" scrubbed = scrub_string(header) assert scrubbed == "X-GUploader-UploadID: SCRUBBED_UPLOAD_ID" assert "ABPj22qXYZ" not in scrubbed def test_upload_url_full_scrubbed() -> None: """A full notebooklm upload URL preserves host/path and scrubs the token.""" url = ( "https://notebooklm.google.com/upload/_/?authuser=0&upload_id=AJRbA5XZXPNXlxYzAbcdef_-12345" ) scrubbed = scrub_string(url) assert scrubbed == "https://notebooklm.google.com/upload/_/?upload_id=SCRUBBED_UPLOAD_ID" assert "AJRbA5XZXPNXlx" not in scrubbed def test_upload_id_query_param_scrubbed() -> None: """A bare ``upload_id=...`` query param outside the full URL is scrubbed.""" text = "POST /resume?upload_id=AJRbA5XZ_-1234567890 HTTP/1.1" scrubbed = scrub_string(text) assert "AJRbA5XZ_-1234567890" not in scrubbed assert "upload_id=SCRUBBED_UPLOAD_ID" in scrubbed def test_drive_aons_token_scrubbed() -> None: """A real-world Drive AONS token (50+ chars) is scrubbed.""" aons = "AONSffzuRealTokenValueWithLotsOfCharsAndDigits1234567890" scrubbed = scrub_string(aons) assert scrubbed == "SCRUBBED_AONS" def test_drive_aons_short_prefix_not_matched() -> None: """The literal string ``AONS`` (and short tails) is NOT scrubbed. The 20-char tail threshold avoids matching incidental ``AONS`` mentions in code, comments, or short identifiers. """ assert scrub_string("AONS") == "AONS" assert scrub_string("AONSshort") == "AONSshort" def test_drive_file_id_in_json_key_scrubbed() -> None: """File ID inside a ``"file_id": "..."`` JSON key is scrubbed.""" text = '{"file_id": "1NFoP9ORcSIk_dTXwMhZWRHfX6JvqYdRqmzYp8LqLi-s"}' scrubbed = scrub_string(text) assert "1NFoP9ORcSIk" not in scrubbed assert '"file_id": "SCRUBBED_DRIVE_FILE_ID"' in scrubbed def test_drive_file_id_in_drive_url_scrubbed() -> None: """File ID inside a ``/drive/v3/files/`` URL is scrubbed.""" url = "https://www.googleapis.com/drive/v3/files/1A2B3C4D5E6F7G8H9I0J1K2L3M4N5O6P7Q8R" scrubbed = scrub_string(url) assert "1A2B3C4D5E6F7G8H9I" not in scrubbed assert "/drive/v3/files/SCRUBBED_DRIVE_FILE_ID" in scrubbed def test_drive_file_id_negative_regression_non_drive_ids_not_scrubbed() -> None: """Bare 36-char UUIDs outside Drive contexts must NOT be scrubbed. This is the load-bearing test for Drive-file-ID scrubbing. The codebase emits many ``[A-Za-z0-9-]`` identifiers that LOOK like Drive file IDs but are NotebookLM-internal (artifact IDs, conversation IDs, source IDs). A naive ``[A-Za-z0-9_-]{33,44}`` pattern would corrupt cassette replay by mangling them; DRIVE_FILE_ID is intentionally anchored to ``"file_id":`` JSON keys and ``/drive/v3/files/`` URLs only. """ non_drive_ids = [ "71669a91-d5f0-4298-913e-9193178ec62c", # notebook ID "62e5c8db-3dd2-407c-8d19-32ae4ae799db", # artifact ID "f66923f0-1df4-4ffe-9822-3ed63c558b1c", # conversation ID "953b658a-579b-4b3c-b280-43b3781babf3", # source ID "c3f6285f-1709-44c4-9cd6-e95cf0ea4f5e", # share ID "167481cd-23a3-4331-9a45-c8948900bf91", # another notebook ID ] for non_drive_id in non_drive_ids: wrappers = [ f'{{"artifact_id": "{non_drive_id}"}}', f'{{"conversation_id": "{non_drive_id}"}}', f'{{"source_id": "{non_drive_id}"}}', f"/v1/notebooks/{non_drive_id}/sources", f"/v1/artifacts/{non_drive_id}", ] for wrapped in wrappers: assert scrub_string(wrapped) == wrapped, ( f"DRIVE_FILE_ID false-positive on non-Drive identifier: {wrapped!r}" ) def test_upload_drive_scrubbing_is_idempotent() -> None: """Scrubbing an already-scrubbed string is a no-op for upload/Drive patterns.""" inputs = [ "X-GUploader-UploadID: ABPj22qXYZ_-abc123", "https://notebooklm.google.com/upload/_/?upload_id=AJRbA5XZ_-12345", "POST /resume?upload_id=AJRbA5XZ_-1234567890 HTTP/1.1", "AONSffzuTokenWithEnoughCharsToMatch12345", '{"file_id": "1NFoP9ORcSIk_dTXwMhZWRHfX6JvqYdRqmzYp8LqLi-s"}', "/drive/v3/files/1A2B3C4D5E6F7G8H9I0J1K2L3M4N5O6P7Q8R", ] for raw in inputs: once = scrub_string(raw) twice = scrub_string(once) assert once == twice, f"Scrubbing not idempotent for: {raw!r}" def test_is_clean_flags_unscrubbed_upload_id() -> None: """is_clean must catch an unscrubbed upload token.""" dirty = "X-GUploader-UploadID: ABPj22qXYZ_-abc123def456" ok, leaks = is_clean(dirty) assert not ok assert any("ABPj22qXYZ" in leak for leak in leaks) def test_is_clean_flags_unscrubbed_aons() -> None: """is_clean must catch an unscrubbed Drive AONS token.""" dirty = "value=AONSffzuRealTokenValueWithLotsOfChars1234567890" ok, leaks = is_clean(dirty) assert not ok assert any("AONS" in leak for leak in leaks) def test_is_clean_flags_unscrubbed_drive_file_id_in_json() -> None: """is_clean must catch an unscrubbed Drive file ID inside a JSON key.""" dirty = '{"file_id": "1NFoP9ORcSIk_dTXwMhZWRHfX6JvqYdRqmzYp8LqLi-s"}' ok, leaks = is_clean(dirty) assert not ok assert any("1NFoP9ORcSIk" in leak for leak in leaks) def test_is_clean_flags_unscrubbed_upload_url() -> None: """is_clean must catch an unscrubbed full upload URL.""" dirty = "https://notebooklm.google.com/upload/_/?upload_id=AJRbA5XZ_-12345" ok, leaks = is_clean(dirty) assert not ok def test_is_clean_recognizes_upload_drive_placeholders() -> None: """The four upload + Drive placeholders register as clean.""" for placeholder in [ "X-GUploader-UploadID: SCRUBBED_UPLOAD_ID", "upload_id=SCRUBBED_UPLOAD_ID", "https://notebooklm.google.com/upload/_/?upload_id=SCRUBBED_UPLOAD_ID", "SCRUBBED_UPLOAD_URL", "SCRUBBED_AONS", '{"file_id": "SCRUBBED_DRIVE_FILE_ID"}', "/drive/v3/files/SCRUBBED_DRIVE_FILE_ID", ]: ok, leaks = is_clean(placeholder) assert ok, f"Placeholder form wrongly flagged as leak: {placeholder!r} -> {leaks}" # --------------------------------------------------------------------------- # display-name + avatar scrub patterns # # These tests pin down four invariants: # # 1. Escaped JSON display-name literals (``\"First Last\"`` inside a double- # encoded WRB payload) scrub to ``\"SCRUBBED_NAME\"``. # 2. ``lh3.googleusercontent.com/(?:a|ogw)/`` avatar URLs scrub to # ``SCRUBBED_AVATAR_URL`` for BOTH path forms and BOTH sizing suffix # variants (``=s512`` and ``=s32-c-mo``). # 3. Legitimate two-Capitalized-word strings (font families like Google Sans, # UI titles like Account Information, test-corpus artifact / notebook # titles, AND bare-quoted source titles like ``"Source Title"``) are NEVER # scrubbed. This is the LOAD-BEARING test: a broad # ``>[A-Z][a-z]+\\s[A-Z][a-z]+<`` regex without the false-positive # allowlist + escape anchoring would corrupt cassette replay. The audit # explicitly flagged this as the failure mode to prevent. # 4. Scrubbing is idempotent on already-scrubbed input; ``is_clean`` flags # raw leaks and accepts the canonical placeholders as clean. # --------------------------------------------------------------------------- # Helper: a literal backslash-quote pair as it would appear inside a JSON- # stringified WRB payload. Using a tiny helper keeps the test bodies readable # (without it every assertion would be a forest of ``\\\\"``). def _esc(s: str) -> str: """Wrap ``s`` in the cassette's escaped-quote form ``\\"...\\"``.""" return f'\\"{s}\\"' def test_display_name_escaped_literal_scrubbed() -> None: """A real escaped display-name literal scrubs to the SCRUBBED_NAME sentinel.""" raw = _esc("Alice Smith") scrubbed = scrub_string(raw) assert scrubbed == _esc("SCRUBBED_NAME") assert "Alice Smith" not in scrubbed def test_display_name_three_word_escaped_literal_scrubbed() -> None: """Three-word names (e.g. middle name present) are also scrubbed.""" raw = _esc("Mary Ann Smith") scrubbed = scrub_string(raw) assert scrubbed == _esc("SCRUBBED_NAME") assert "Mary Ann Smith" not in scrubbed def test_display_name_inside_wrb_payload_scrubbed() -> None: """The exact shape from sharing_get_status.yaml is scrubbed correctly. This is the realistic payload form: a stringified JSON list inside a WRB envelope, with the display name as a positional list element rather than under a structured ``"displayName": "..."`` key. The A4 patterns do NOT fire on this shape — that's the gap A6a closes. """ payload = ( '[[[\\"SCRUBBED_EMAIL@example.com\\",1,[],' '[\\"People Conf\\",\\"https://lh3.googleusercontent.com/a/ACg8ocXYZabc=s512\\"]]]' ) scrubbed = scrub_string(payload) assert "People Conf" not in scrubbed assert "ACg8ocXYZabc" not in scrubbed assert _esc("SCRUBBED_NAME") in scrubbed assert "SCRUBBED_AVATAR_URL" in scrubbed def test_avatar_url_a_path_scrubbed() -> None: """The ``/a/`` avatar URL form is scrubbed to SCRUBBED_AVATAR_URL.""" url = "https://lh3.googleusercontent.com/a/ACg8ocImrMoQR5mQnUHZzuc6Tat88aWfSwMre0nCoCanft5bLuZ3dTV0=s512" scrubbed = scrub_string(url) assert scrubbed == "SCRUBBED_AVATAR_URL" assert "ACg8oc" not in scrubbed def test_avatar_url_ogw_path_scrubbed() -> None: """The ``/ogw/`` avatar URL form is scrubbed to SCRUBBED_AVATAR_URL.""" url = "https://lh3.googleusercontent.com/ogw/AF2bZyi16LQ_0jOcB_3NwTmyCfSFpN74FaCfwF0mWwtxF--cwSQ=s192-c-mo" scrubbed = scrub_string(url) assert scrubbed == "SCRUBBED_AVATAR_URL" assert "AF2bZyi" not in scrubbed def test_avatar_url_sizing_suffix_variants_scrubbed() -> None: """Both ``=s512`` and ``=s32-c-mo`` style sizing suffixes are captured.""" for tail in ("=s32", "=s64-c-mo", "=s83-c-mo", "=s512"): url = f"https://lh3.googleusercontent.com/ogw/AF2bZyABC123_-xyz{tail}" scrubbed = scrub_string(url) assert scrubbed == "SCRUBBED_AVATAR_URL", ( f"Sizing suffix {tail!r} not fully scrubbed: {scrubbed!r}" ) def test_display_name_false_positive_allowlist_preserved() -> None: """Every entry in DISPLAY_NAME_FALSE_POSITIVES is preserved verbatim. This is part of the load-bearing negative regression: font family strings (``Google Sans``), UI page titles (``Account Information``), and artifact / notebook titles from the test corpus must never be scrubbed even though they match the two-Capitalized-word shape. """ for fp in DISPLAY_NAME_FALSE_POSITIVES: raw = _esc(fp) scrubbed = scrub_string(raw) assert scrubbed == raw, ( f"False-positive allowlist breach: {fp!r} got scrubbed to {scrubbed!r}" ) def test_display_name_negative_regression_bare_source_titles_preserved() -> None: """Bare-quoted two-Capitalized-word source titles must NOT be scrubbed. THIS IS THE LOAD-BEARING TEST. The audit explicitly warned that a broad ``>[A-Z][a-z]+\\s[A-Z][a-z]+<`` regex would corrupt source-rename and artifact-list cassettes. The A6a scrubber anchors on the escape-quote form ``\\"...\\"``, so bare-quoted ``"Source Title"`` (which is how artifact / notebook / source titles actually appear in cassettes) is safe — both the escape anchoring AND the false-positive allowlist cooperate to prevent corruption. """ bare_titles = [ '"Source Title"', '"Apple Pie"', '"Recipe Book"', '"Quantum Physics"', '"Machine Learning"', '"Web Development"', '"Data Science"', '"Climate Change"', ] for bare in bare_titles: scrubbed = scrub_string(bare) assert scrubbed == bare, ( f"Bare-quoted source title wrongly scrubbed: {bare!r} -> {scrubbed!r}" ) def test_display_name_negative_regression_escaped_titles_in_allowlist_preserved() -> None: """Escaped artifact/notebook titles from the test corpus are preserved. A cassette body containing ``\\"Agent Quiz\\"`` (e.g. an artifact title) must NOT be scrubbed — the false-positive allowlist protects it. Without this protection, the escape-anchored regex would clobber the title and cassette replay would fail signature matching against the recorded request bodies. """ titles = [ "Agent Quiz", "Agent Flashcards", "Agent Development Tutorials", "Slide Deck", "Tool Use Loop", "Claude Code", ] for title in titles: raw = _esc(title) scrubbed = scrub_string(raw) assert scrubbed == raw, ( f"Test-corpus title wrongly scrubbed: {title!r} ({raw!r}) -> {scrubbed!r}" ) def test_display_name_and_avatar_scrubbing_is_idempotent() -> None: """Scrubbing an already-scrubbed string is a no-op for display-name/avatar patterns.""" inputs = [ _esc("Alice Smith"), _esc("People Conf"), "https://lh3.googleusercontent.com/a/ACg8ocXYZ=s512", "https://lh3.googleusercontent.com/ogw/AF2bZy_-cwSQ=s32-c-mo", # Realistic combined payload. f'[[[\\"SCRUBBED_EMAIL@example.com\\",1,[],[{_esc("People Conf")},' f'\\"https://lh3.googleusercontent.com/a/ACg8ocXYZ=s512\\"]]]', ] for raw in inputs: once = scrub_string(raw) twice = scrub_string(once) assert once == twice, f"Scrubbing not idempotent for: {raw!r}" def test_is_clean_flags_unscrubbed_escaped_display_name() -> None: """is_clean must catch an unscrubbed escaped display-name literal.""" dirty = _esc("Alice Smith") ok, leaks = is_clean(dirty) assert not ok assert any("Alice Smith" in leak for leak in leaks) def test_is_clean_ignores_false_positive_escaped_literals() -> None: """is_clean must NOT flag false-positive allowlist entries. The detector consults DISPLAY_NAME_FALSE_POSITIVES at the call site, so escaped ``\\"Google Sans\\"`` and friends don't surface as leaks even though they match the two-Capitalized-word regex shape. """ for fp in DISPLAY_NAME_FALSE_POSITIVES: ok, leaks = is_clean(_esc(fp)) # Filter to only display-name leaks; the email/cookie detectors # don't fire on these inputs but be explicit anyway. display_leaks = [leak for leak in leaks if "escaped display name" in leak] assert not display_leaks, ( f"False-positive {fp!r} wrongly flagged as display-name leak: {display_leaks}" ) def test_is_clean_flags_unscrubbed_avatar_url() -> None: """is_clean must catch an unscrubbed avatar URL (both /a/ and /ogw/).""" for url in ( "https://lh3.googleusercontent.com/a/ACg8ocXYZabc=s512", "https://lh3.googleusercontent.com/ogw/AF2bZy_-cwSQ=s32-c-mo", ): ok, leaks = is_clean(url) assert not ok assert any("avatar URL" in leak for leak in leaks) def test_is_clean_recognizes_display_name_avatar_placeholders() -> None: """The display-name + avatar placeholders register as clean.""" placeholders = [ "SCRUBBED_AVATAR_URL", _esc("SCRUBBED_NAME"), # Realistic combined: an already-scrubbed sharing payload. f'[[[\\"SCRUBBED_EMAIL@example.com\\",1,[],[{_esc("SCRUBBED_NAME")},' f'\\"SCRUBBED_AVATAR_URL\\"]]]', ] for placeholder in placeholders: ok, leaks = is_clean(placeholder) assert ok, f"Placeholder form wrongly flagged as leak: {placeholder!r} -> {leaks}" def test_display_name_false_positives_mirror_shape_lint() -> None: """The scrub-registry allowlist must stay in sync with the shape-lint allowlist. The shape lint carries the same set under the same name (``DISPLAY_NAME_FALSE_POSITIVES``) with each entry wrapped in the cassette's escape-quote form. If the two drift, a real cassette could pass shape-lint but trip the scrub detector (or vice versa) — this test forces both lists to be updated together. """ # The shape-lint allowlist now lives in the shared non-test helper # ``_guardrails._cassette_shape_lint`` (issue #1431); importing it from # that ``_``-prefixed module avoids a test-imports-test dependency on # ``test_cassette_shapes``. ``tests/`` is on ``sys.path`` so the # ``_guardrails`` package resolves. from tests._guardrails._cassette_shape_lint import ( DISPLAY_NAME_FALSE_POSITIVES as SHAPE_LINT_FPS, ) # Shape-lint stores entries as ``\"Name\"``; the scrub registry stores # bare names. Strip the escape wrapping to compare apples-to-apples. shape_lint_bare = frozenset( entry.removeprefix('\\"').removesuffix('\\"') for entry in SHAPE_LINT_FPS ) assert shape_lint_bare == DISPLAY_NAME_FALSE_POSITIVES, ( "Display-name false-positive allowlist drifted from shape-lint allowlist; " "update BOTH tests/cassette_patterns.py and " "tests/_guardrails/_cassette_shape_lint.py" )