"""Tests for UNWIND Security Self-Defence — attacking the security code itself.

This test suite targets the attack surface of UNWIND's own security
infrastructure. Automated scanners and malicious MCP servers can probe
these weaknesses without any human expertise.

Attack classes tested:
1. ReDoS — regex denial of service against credential/DLP patterns
2. Malformed JSON — fuzzing the sidecar server's JSON parsing
3. URL parser confusion — egress policy bypasses via URL tricks
4. Input size bombs — oversized payloads that could OOM or hang
5. Parameter depth bombs — deeply nested dicts that could stack overflow
6. Unicode/encoding tricks — confusing string matching

These are automated mass-attack vectors, NOT state-level threats.
"""

import json
import re
import time
import unittest
from pathlib import Path
from unittest.mock import MagicMock

from unwind.config import UnwindConfig
from unwind.enforcement.credential_exposure import (
    CredentialExposureCheck,
    CREDENTIAL_PATTERNS,
)
from unwind.enforcement.dlp_lite import DLPLiteCheck, SECRET_PATTERNS
from unwind.enforcement.egress_policy import EgressPolicyCheck


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _make_config() -> UnwindConfig:
    """Build a minimal config for testing."""
    import tempfile
    tmp = tempfile.mkdtemp()
    config = UnwindConfig(
        unwind_home=Path(tmp) / ".unwind",
        workspace_root=Path(tmp) / "workspace",
    )
    config.ensure_dirs()
    (Path(tmp) / "workspace").mkdir(exist_ok=True)
    return config


# Max time in seconds any single regex match should take.
# If a pattern takes longer than this on adversarial input, it's vulnerable.
REGEX_TIME_LIMIT = 0.1  # 100ms — generous; most should be <1ms


# =========================================================================
# 1. ReDoS — Regex Denial of Service
# =========================================================================

class TestReDoSCredentialPatterns(unittest.TestCase):
    """Verify all 14 credential patterns resist catastrophic backtracking.

    Attack: An adversary crafts a tool parameter string designed to make
    a regex pattern backtrack exponentially. This hangs the enforcement
    pipeline, causing a denial of service that lets subsequent tool calls
    through unscanned (if the sidecar times out and the adapter falls
    back to allow).

    These inputs are generated by standard ReDoS fuzzing tools (e.g.,
    regexploit, rxxr2) and circulate freely on GitHub.
    """

    def _time_pattern(self, pattern: re.Pattern, evil_input: str) -> float:
        """Time how long a pattern takes to match against input."""
        start = time.monotonic()
        pattern.findall(evil_input)
        elapsed = time.monotonic() - start
        return elapsed

    def test_aws_key_pattern_no_redos(self):
        """AKIA pattern: fixed-length, no backtracking risk."""
        pattern = CREDENTIAL_PATTERNS[0]  # AKIA[0-9A-Z]{16}
        # Near-miss: AKIA followed by 15 valid chars + 1 invalid, repeated
        evil = "AKIA" + "A" * 15 + "!" * 1000
        elapsed = self._time_pattern(pattern, evil)
        self.assertLess(elapsed, REGEX_TIME_LIMIT,
                        f"AWS key pattern took {elapsed:.3f}s on adversarial input")

    def test_aws_secret_pattern_no_redos(self):
        """AWS secret pattern: \\S{20,} could backtrack on near-misses."""
        pattern = CREDENTIAL_PATTERNS[1]
        evil = "aws_secret_access_key=" + "a" * 10000
        elapsed = self._time_pattern(pattern, evil)
        self.assertLess(elapsed, REGEX_TIME_LIMIT,
                        f"AWS secret pattern took {elapsed:.3f}s")

    def test_generic_api_key_pattern_no_redos(self):
        """Generic api_key pattern has optional groups — test for backtracking."""
        pattern = CREDENTIAL_PATTERNS[8]  # api[_-]?key...
        # Adversarial: api_key= followed by long string of chars that
        # partially match [A-Za-z0-9_\\-]{20,} then fail
        evil = "api_key=" + "a" * 5000 + "\x00"
        elapsed = self._time_pattern(pattern, evil)
        self.assertLess(elapsed, REGEX_TIME_LIMIT,
                        f"Generic API key pattern took {elapsed:.3f}s")

    def test_jwt_pattern_no_redos(self):
        """JWT pattern has three dot-separated groups — potential backtrack point."""
        pattern = CREDENTIAL_PATTERNS[9]  # eyJ...\\.eyJ...\\.[...]
        # Near-valid JWT with many dots to confuse the parser
        evil = "eyJ" + "a" * 100 + "." * 500 + "eyJ" + "b" * 100
        elapsed = self._time_pattern(pattern, evil)
        self.assertLess(elapsed, REGEX_TIME_LIMIT,
                        f"JWT pattern took {elapsed:.3f}s on adversarial input")

    def test_bearer_token_pattern_no_redos(self):
        """Bearer token pattern: \\S{20,} class."""
        pattern = CREDENTIAL_PATTERNS[11]  # bearer/token...
        evil = "bearer=" + "a.b-c_" * 2000
        elapsed = self._time_pattern(pattern, evil)
        self.assertLess(elapsed, REGEX_TIME_LIMIT,
                        f"Bearer pattern took {elapsed:.3f}s")

    def test_password_pattern_no_redos(self):
        """Password pattern: \\S{8,} could match greedily then fail."""
        pattern = CREDENTIAL_PATTERNS[12]  # password/passwd/pwd
        evil = "password=" + "x" * 10000
        elapsed = self._time_pattern(pattern, evil)
        self.assertLess(elapsed, REGEX_TIME_LIMIT,
                        f"Password pattern took {elapsed:.3f}s")

    def test_connection_string_pattern_no_redos(self):
        """Connection string: \\S+:\\S+@ has two greedy quantifiers."""
        pattern = CREDENTIAL_PATTERNS[13]  # postgres://...
        # Adversarial: long username/password with no @ terminator
        evil = "postgres://" + "a" * 5000 + ":" + "b" * 5000
        elapsed = self._time_pattern(pattern, evil)
        self.assertLess(elapsed, REGEX_TIME_LIMIT,
                        f"Connection string pattern took {elapsed:.3f}s")

    def test_all_patterns_against_random_noise(self):
        """Run all patterns against 100KB of random-ish characters.

        This catches patterns that degrade on large non-matching input.
        """
        import string
        noise = (string.ascii_letters + string.digits + "._-=:/ ") * 1000
        for i, pattern in enumerate(CREDENTIAL_PATTERNS):
            elapsed = self._time_pattern(pattern, noise)
            self.assertLess(elapsed, REGEX_TIME_LIMIT,
                            f"Pattern {i} ({pattern.pattern[:30]}) took "
                            f"{elapsed:.3f}s on noise input")

    def test_all_patterns_against_repeated_near_match(self):
        """Feed each pattern a string that almost matches, repeated 1000 times.

        This is the classic ReDoS trigger — a near-match that forces
        the regex engine to explore many paths before failing.
        """
        near_misses = [
            "AKIA" + "A" * 15 + "!",                              # AWS key
            "aws_secret_access_key= " + "a" * 19 + "!",           # AWS secret
            "AIza" + "A" * 34 + "!",                               # GCP
            "sk_live_" + "a" * 19 + "!",                           # Stripe
            "ghp_" + "A" * 35 + "!",                               # GitHub
            "github_pat_" + "A" * 21 + "!",                        # GitHub PAT
            "sk-" + "A" * 19 + "!",                                # OpenAI
            "sk-ant-" + "A" * 19 + "!",                            # Anthropic
            "api_key=" + "A" * 19 + "!",                           # Generic
            "eyJ" + "A" * 9 + "." + "eyJ" + "A" * 9 + "." + "!", # JWT
            "-----BEGIN PRIVATE KEY---",                            # PEM (truncated)
            "bearer=" + "A" * 19 + "!",                            # Bearer
            "password=" + "A" * 7 + "!",                           # Password
            "postgres://a:b",                                       # Connection (no @)
            "gw_tok_" + "A" * 19 + "!",                            # OpenClaw
        ]
        for i, near_miss in enumerate(near_misses):
            if i >= len(CREDENTIAL_PATTERNS):
                break
            evil = (near_miss + " ") * 1000
            elapsed = self._time_pattern(CREDENTIAL_PATTERNS[i], evil)
            self.assertLess(elapsed, REGEX_TIME_LIMIT,
                            f"Pattern {i} took {elapsed:.3f}s on repeated near-miss")


class TestReDoSDLPPatterns(unittest.TestCase):
    """Same ReDoS testing for DLP-Lite patterns."""

    def test_all_dlp_patterns_against_noise(self):
        """All DLP patterns must complete within time limit on large input."""
        import string
        noise = (string.ascii_letters + string.digits + "._-=:/ \n") * 1000
        for i, pattern in enumerate(SECRET_PATTERNS):
            start = time.monotonic()
            pattern.findall(noise)
            elapsed = time.monotonic() - start
            self.assertLess(elapsed, REGEX_TIME_LIMIT,
                            f"DLP pattern {i} ({pattern.pattern[:30]}) took "
                            f"{elapsed:.3f}s")

    def test_env_file_pattern_no_redos(self):
        """The .env pattern has nested quantifiers: ([A-Z_]{3,}=[^\\s]{8,}\\n){3,}

        This is the highest ReDoS risk in the codebase — nested quantifiers
        with a repeat. Test it specifically with adversarial input.
        """
        # The env pattern is SECRET_PATTERNS[9]
        env_pattern = SECRET_PATTERNS[9]
        # Adversarial: many lines that almost match but fail on the last char
        evil = ("ABC_DEF=" + "x" * 7 + " ") * 5000  # space instead of \n
        start = time.monotonic()
        env_pattern.findall(evil)
        elapsed = time.monotonic() - start
        self.assertLess(elapsed, REGEX_TIME_LIMIT,
                        f".env pattern took {elapsed:.3f}s on adversarial input")


# =========================================================================
# 2. Malformed JSON — Fuzzing the Sidecar
# =========================================================================

class TestSidecarMalformedInput(unittest.TestCase):
    """Fuzz the sidecar server with malformed HTTP requests.

    Attack: A malicious MCP server or compromised adapter sends garbage
    to the sidecar's /v1/policy/check endpoint, hoping to crash it or
    bypass the fail-closed contract.
    """

    TEST_SECRET = "self-defence-test-secret"

    @classmethod
    def setUpClass(cls):
        """Create a test client for the sidecar with mandatory auth."""
        from unwind.sidecar.server import create_app
        from starlette.testclient import TestClient
        cls.app = create_app(shared_secret=cls.TEST_SECRET)
        cls.client = TestClient(cls.app)
        cls.headers = {
            "Authorization": f"Bearer {cls.TEST_SECRET}",
            "X-UNWIND-API-Version": "1",
        }

    def _post_policy(self, body=None, raw_body=None):
        """Send a POST to /v1/policy/check."""
        if raw_body is not None:
            return self.client.post(
                "/v1/policy/check",
                content=raw_body,
                headers={**self.headers, "Content-Type": "application/json"},
            )
        return self.client.post(
            "/v1/policy/check",
            json=body,
            headers=self.headers,
        )

    def test_empty_body(self):
        """Empty POST body should return 422, NOT crash."""
        resp = self._post_policy(raw_body=b"")
        self.assertIn(resp.status_code, (422, 400))

    def test_null_json(self):
        """JSON null body should return 422."""
        resp = self._post_policy(raw_body=b"null")
        self.assertIn(resp.status_code, (422, 400))

    def test_json_array_instead_of_object(self):
        """JSON array body should return 422."""
        resp = self._post_policy(body=[1, 2, 3])
        self.assertEqual(resp.status_code, 422)

    def test_json_string_instead_of_object(self):
        """JSON string body should return 422."""
        resp = self._post_policy(raw_body=b'"hello"')
        self.assertEqual(resp.status_code, 422)

    def test_deeply_nested_json(self):
        """500-level deep JSON nesting should not crash the server."""
        # Build nested dict: {"a": {"a": {"a": ... }}}
        inner = {"toolName": "test", "params": {}, "agentId": "a", "sessionKey": "s"}
        for _ in range(200):
            inner = {"nested": inner}
        resp = self._post_policy(body=inner)
        # Should get 422 (missing required fields at top level) or 200 with block
        self.assertIn(resp.status_code, (200, 422))

    def test_huge_tool_name(self):
        """1MB tool name should be rejected, not processed."""
        body = {
            "toolName": "x" * (1024 * 1024),
            "params": {},
            "agentId": "agent1",
            "sessionKey": "sess1",
        }
        resp = self._post_policy(body=body)
        # Should be 422 (exceeds max length) — NOT a hang
        self.assertEqual(resp.status_code, 422)

    def test_huge_params_value(self):
        """10MB string in params should be handled within time limit."""
        body = {
            "toolName": "fs_read",
            "params": {"path": "x" * (10 * 1024 * 1024)},
            "agentId": "agent1",
            "sessionKey": "sess1",
        }
        start = time.monotonic()
        resp = self._post_policy(body=body)
        elapsed = time.monotonic() - start
        self.assertIn(resp.status_code, (200, 422))
        self.assertLess(elapsed, 5.0,
                        f"10MB params took {elapsed:.1f}s — potential DoS")

    def test_many_params_keys(self):
        """10,000 parameter keys should not hang the recursive scanner."""
        body = {
            "toolName": "http_post",
            "params": {f"key_{i}": f"value_{i}" for i in range(10000)},
            "agentId": "agent1",
            "sessionKey": "sess1",
        }
        start = time.monotonic()
        resp = self._post_policy(body=body)
        elapsed = time.monotonic() - start
        self.assertIn(resp.status_code, (200,))
        self.assertLess(elapsed, 5.0,
                        f"10K params took {elapsed:.1f}s — potential DoS")

    def test_binary_garbage(self):
        """Random bytes should return 422, not crash."""
        import os
        garbage = os.urandom(1024)
        resp = self._post_policy(raw_body=garbage)
        self.assertIn(resp.status_code, (422, 400))

    def test_truncated_json(self):
        """Truncated JSON should return 422."""
        resp = self._post_policy(raw_body=b'{"toolName": "test", "par')
        self.assertIn(resp.status_code, (422, 400))

    def test_unicode_bomb(self):
        """Repeated Unicode combining characters in tool name."""
        body = {
            "toolName": "a" + "\u0300" * 10000,  # 'a' + 10K combining accents
            "params": {},
            "agentId": "agent1",
            "sessionKey": "sess1",
        }
        resp = self._post_policy(body=body)
        # Should be 422 (exceeds max length when expanded) or 200
        self.assertIn(resp.status_code, (200, 422))

    def test_null_bytes_in_params(self):
        """Null bytes in parameter values should not crash regex scanning."""
        body = {
            "toolName": "http_post",
            "params": {"body": "hello\x00world\x00AKIAIOSFODNN7EXAMPLE"},
            "agentId": "agent1",
            "sessionKey": "sess1",
        }
        resp = self._post_policy(body=body)
        self.assertEqual(resp.status_code, 200)


# =========================================================================
# 3. URL Parser Confusion — Egress Policy Bypasses
# =========================================================================

class TestEgressParserConfusion(unittest.TestCase):
    """Test URL tricks that exploit differences between Python's urlparse
    and what the actual HTTP client resolves.

    Attack: A malicious tool call uses a URL that urlparse interprets as
    safe, but the HTTP client resolves to a blocked destination (or vice
    versa). These bypass techniques are catalogued on SSRF cheat sheets
    and automated scanners try them all.
    """

    def setUp(self):
        self.check = EgressPolicyCheck(_make_config())

    def test_metadata_with_auth_prefix(self):
        """http://evil.com@169.254.169.254/ — userinfo tricks urlparse."""
        result = self.check.check("http://evil.com@169.254.169.254/latest/meta-data/")
        self.assertIsNotNone(result, "Auth-prefix metadata bypass not caught")

    def test_metadata_with_port(self):
        """http://169.254.169.254:80/ — explicit port should still block."""
        result = self.check.check("http://169.254.169.254:80/latest/meta-data/")
        self.assertIsNotNone(result, "Metadata with port not blocked")

    def test_metadata_ipv6_mapped(self):
        """http://[::ffff:169.254.169.254]/ — IPv6-mapped IPv4."""
        result = self.check.check("http://[::ffff:169.254.169.254]/")
        # urlparse extracts hostname as "::ffff:169.254.169.254"
        # Our pattern should catch link-local
        # Note: this may NOT be caught by current hostname checks —
        # that's OK, SSRF shield handles IP-level. Document the gap.
        # This test documents the current behaviour.
        pass  # Documenting: IPv6-mapped addresses handled by SSRF shield, not egress policy

    def test_metadata_decimal_ip(self):
        """http://2852039166/ — decimal encoding of 169.254.169.254."""
        result = self.check.check("http://2852039166/latest/meta-data/")
        # urlparse gives hostname "2852039166" — doesn't match our patterns
        # This is correctly handled by SSRF shield (IP resolution), not egress policy
        # Document the boundary
        pass  # Documenting: decimal IP handled by SSRF shield

    def test_metadata_hex_ip(self):
        """http://0xa9.0xfe.0xa9.0xfe/ — hex octet encoding."""
        result = self.check.check("http://0xa9.0xfe.0xa9.0xfe/")
        # Same as decimal — SSRF shield resolves this, not egress policy
        pass  # Documenting: hex IP handled by SSRF shield

    def test_denylist_with_trailing_dot(self):
        """pastebin.com. — trailing dot is valid DNS but might bypass matching."""
        result = self.check.check("http://pastebin.com./api")
        # urlparse gives hostname "pastebin.com." — trailing dot
        # Our exact match check compares against "pastebin.com" — this COULD bypass
        # This test catches the gap
        if result is None:
            self.fail("Trailing-dot DNS bypass: pastebin.com. evaded denylist")

    def test_denylist_with_mixed_case(self):
        """PaStEbIn.CoM — case variation."""
        result = self.check.check("http://PaStEbIn.CoM/api")
        self.assertIsNotNone(result, "Mixed case bypass not caught")

    def test_denylist_subdomain(self):
        """evil.pastebin.com — subdomain should also be caught."""
        result = self.check.check("http://evil.pastebin.com/upload")
        self.assertIsNotNone(result, "Subdomain bypass not caught")

    def test_internal_service_with_port(self):
        """consul.local:8500 — internal service with explicit port."""
        result = self.check.check("http://consul.local:8500/v1/catalog")
        self.assertIsNotNone(result, "Internal service with port not blocked")

    def test_kubernetes_metadata_bypass(self):
        """kubernetes.default.svc.cluster.local with path tricks."""
        result = self.check.check(
            "http://kubernetes.default.svc.cluster.local/api/v1/secrets"
        )
        self.assertIsNotNone(result, "K8s metadata not blocked")

    def test_url_with_fragment(self):
        """http://pastebin.com/api#fragment — fragment should not affect hostname."""
        result = self.check.check("http://pastebin.com/api#innocent")
        self.assertIsNotNone(result, "URL with fragment bypassed denylist")

    def test_url_with_empty_hostname(self):
        """http:///path — empty hostname should be blocked (fail-closed)."""
        result = self.check.check("http:///etc/passwd")
        self.assertIsNotNone(result, "Empty hostname should be blocked")

    def test_backslash_url(self):
        """http://pastebin.com\\@safe.com — backslash URL confusion."""
        result = self.check.check("http://pastebin.com\\@safe.com/api")
        # Different parsers handle backslash differently
        # Python urlparse treats this as hostname "pastebin.com\\@safe.com"
        # which contains pastebin.com — should still match denylist
        # If it doesn't, that's a gap
        if result is None:
            # Not necessarily a failure — depends on urlparse behaviour
            pass

    def test_data_url_blocked(self):
        """data:text/html,... — data URLs should not bypass."""
        result = self.check.check("data:text/html,<script>exfil()</script>")
        # data URLs have no hostname — should hit EGRESS_NO_HOST
        self.assertIsNotNone(result, "data: URL should be blocked (no hostname)")

    def test_javascript_url_blocked(self):
        """javascript: URLs should be blocked."""
        result = self.check.check("javascript:void(0)")
        self.assertIsNotNone(result, "javascript: URL should be blocked (no hostname)")

    def test_file_url_blocked(self):
        """file:///etc/passwd should be blocked (no hostname)."""
        result = self.check.check("file:///etc/passwd")
        # file URLs may have empty hostname
        self.assertIsNotNone(result, "file: URL should be blocked")


# =========================================================================
# 4. Input Size Bombs
# =========================================================================

class TestInputSizeBombs(unittest.TestCase):
    """Test that oversized inputs don't cause OOM or hangs.

    Attack: Malicious tool calls with enormous parameter values designed
    to exhaust memory or CPU in the credential/DLP scanners.
    """

    def test_credential_scan_large_value(self):
        """10MB string should complete scanning within 5 seconds."""
        check = CredentialExposureCheck(_make_config())
        big_value = "x" * (10 * 1024 * 1024)
        start = time.monotonic()
        findings = check._scan_value(big_value)
        elapsed = time.monotonic() - start
        self.assertLess(elapsed, 5.0,
                        f"10MB scan took {elapsed:.1f}s — potential DoS")

    def test_credential_scan_many_small_params(self):
        """10,000 small params should complete within 5 seconds."""
        check = CredentialExposureCheck(_make_config())
        params = {f"key_{i}": f"value_{i}_normal_text" for i in range(10000)}
        start = time.monotonic()
        check.check("http_post", params)
        elapsed = time.monotonic() - start
        self.assertLess(elapsed, 5.0,
                        f"10K params scan took {elapsed:.1f}s")

    def test_dlp_scan_large_payload(self):
        """10MB egress payload should complete within 5 seconds."""
        config = _make_config()
        check = DLPLiteCheck(config)
        big_payload = "normal text " * (1024 * 1024)
        start = time.monotonic()
        check.check(big_payload)
        elapsed = time.monotonic() - start
        self.assertLess(elapsed, 5.0,
                        f"10MB DLP scan took {elapsed:.1f}s")


# =========================================================================
# 5. Parameter Depth Bombs
# =========================================================================

class TestParameterDepthBombs(unittest.TestCase):
    """Test that deeply nested parameters don't cause stack overflow.

    Attack: A tool call with deeply nested dicts (1000+ levels) that
    would cause a stack overflow in the recursive scanner if there's
    no depth limit.
    """

    def test_depth_100_no_crash(self):
        """100-level nesting should not crash."""
        check = CredentialExposureCheck(_make_config())
        inner = {"key": "AKIAIOSFODNN7EXAMPLE"}
        for _ in range(100):
            inner = {"nested": inner}
        # Should not raise RecursionError
        findings = check._scan_params_recursive(inner)
        # Credential is at depth 101, beyond max_depth=5, so not found
        self.assertEqual(len(findings), 0)

    def test_depth_1000_no_crash(self):
        """1000-level nesting should not crash (max_depth stops it)."""
        check = CredentialExposureCheck(_make_config())
        inner = {"key": "AKIAIOSFODNN7EXAMPLE"}
        for _ in range(1000):
            inner = {"nested": inner}
        findings = check._scan_params_recursive(inner)
        self.assertEqual(len(findings), 0)

    def test_wide_and_deep(self):
        """50 keys per level, 5 levels deep — tests MAX_SCAN_ITEMS cap.

        Without the cap, 50^5 = 312M leaf nodes would OOM the process.
        The cap (10,000 items) should trigger quickly and report the limit.
        """
        check = CredentialExposureCheck(_make_config())

        def build_wide(depth):
            if depth <= 0:
                return {"val": "clean_text_no_creds"}
            return {f"k{i}": build_wide(depth - 1) for i in range(50)}

        params = build_wide(4)  # 50^4 = 6.25M — well above cap
        start = time.monotonic()
        findings = check._scan_params_recursive(params)
        elapsed = time.monotonic() - start
        self.assertLess(elapsed, 5.0,
                        f"Wide+deep scan took {elapsed:.1f}s")
        # Should have hit the scan limit
        self.assertTrue(
            any("SCAN_LIMIT_REACHED" in f for f in findings),
            "Wide+deep bomb should trigger SCAN_LIMIT_REACHED"
        )


# =========================================================================
# 6. Unicode / Encoding Tricks
# =========================================================================

class TestUnicodeTricks(unittest.TestCase):
    """Test that Unicode tricks don't confuse credential detection.

    Attack: Credentials embedded using Unicode lookalikes, zero-width
    characters, or right-to-left override to evade regex patterns.
    These are mass-attack viable because the payloads are simple to
    generate.
    """

    def test_zero_width_chars_in_key(self):
        """AKIA key with zero-width spaces inserted should still be caught.

        Note: This tests current behaviour. Zero-width chars WILL break
        regex matching — this is a known limitation. The test documents it.
        If the key is split by zero-width chars, it won't match.
        """
        check = CredentialExposureCheck(_make_config())
        # Zero-width space (U+200B) inserted between characters
        zwsp = "\u200b"
        obfuscated = f"AK{zwsp}IA{zwsp}IOSFODNN7EXAMPLE"
        findings = check._scan_value(obfuscated)
        # Current regex WON'T match this — document as known limitation
        # The entropy scanner in DLP-Lite is the fallback for these cases
        # This test just confirms the regex doesn't CRASH on these inputs
        # No assertion on findings — we're testing for crashes, not detection

    def test_right_to_left_override(self):
        """RTL override character should not crash scanner."""
        check = CredentialExposureCheck(_make_config())
        rtl = "\u202e"  # Right-to-left override
        text = f"key is {rtl}AKIAIOSFODNN7EXAMPLE"
        findings = check._scan_value(text)
        # Should not crash — detection may or may not work depending on
        # regex engine's Unicode handling

    def test_homoglyph_key(self):
        """Credential using Latin lookalike characters (e.g., Cyrillic А for Latin A)."""
        check = CredentialExposureCheck(_make_config())
        # Cyrillic А (U+0410) looks like Latin A but is different codepoint
        text = "\u0410KI\u0410IOSFODNN7EXAMPLE"  # АKIАIOSFODNN7EXAMPLE
        findings = check._scan_value(text)
        # re.ASCII flag means only ASCII chars match [A-Z] — Cyrillic won't match
        # This is actually CORRECT behaviour — the credential wouldn't be valid
        # with Cyrillic chars anyway. Document this.

    def test_fullwidth_characters(self):
        """Fullwidth ASCII characters (U+FF01-FF5E) in credentials."""
        check = CredentialExposureCheck(_make_config())
        # Fullwidth 'A' is U+FF21
        text = "\uff21\uff2b\uff29\uff21IOSFODNN7EXAMPLE"
        findings = check._scan_value(text)
        # re.ASCII flag correctly rejects these — document as expected behaviour

    def test_null_byte_injection(self):
        """Null bytes in credential strings should not crash scanner."""
        check = CredentialExposureCheck(_make_config())
        text = "AKIA\x00IOSFODNN7EXAMPLE"
        findings = check._scan_value(text)
        # Null byte splits the string for regex — may not match
        # But MUST NOT crash


if __name__ == "__main__":
    unittest.main()
