1from __future__ import annotations
   2
   3from typing import TYPE_CHECKING
   4
   5from ..results import AuditResult, CheckResult
   6from .base import Audit
   7
   8if TYPE_CHECKING:
   9    import requests
  10
  11    from ..scanner import Scanner
  12
  13
  14class CSPAudit(Audit):
  15    """Content Security Policy checks."""
  16
  17    name = "Content Security Policy (CSP)"
  18    slug = "csp"
  19    description = "Validates Content Security Policy configuration to prevent XSS and data injection attacks. Nonce-based policies are recommended. See: https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP"
  20
  21    def check(self, scanner: Scanner) -> AuditResult:
  22        """Check if CSP is present and configured securely."""
  23        response = scanner.fetch()
  24
  25        # Check for both enforcing and report-only CSP headers
  26        csp_header = response.headers.get("Content-Security-Policy")
  27        csp_report_only_header = response.headers.get(
  28            "Content-Security-Policy-Report-Only"
  29        )
  30
  31        if not csp_header and not csp_report_only_header:
  32            # No CSP header detected at all
  33            return AuditResult(
  34                name=self.name,
  35                detected=False,
  36                required=self.required,
  37                checks=[],
  38                description=self.description,
  39            )
  40
  41        # Determine which header to analyze
  42        # Prefer the enforcing header if both are present
  43        header_to_analyze = csp_header if csp_header else csp_report_only_header
  44        is_report_only = csp_header is None and csp_report_only_header is not None
  45
  46        # Parse CSP once
  47        directives = self._parse_csp(header_to_analyze)
  48
  49        # Compute effective CSP (what browsers actually enforce)
  50        # This removes 'unsafe-inline' when nonces/hashes are present, etc.
  51        effective_directives = self._get_effective_csp(directives)
  52
  53        # CSP header detected - run nested checks (based on Google CSP Evaluator)
  54        checks = [
  55            self._check_report_only_mode(
  56                is_report_only, bool(csp_header), bool(csp_report_only_header)
  57            ),
  58            self._check_syntax(header_to_analyze, directives),
  59            self._check_missing_semicolon(header_to_analyze),
  60            self._check_unsafe_inline(effective_directives, directives),
  61            self._check_unsafe_eval(directives),
  62            self._check_plain_url_schemes(directives),
  63            self._check_wildcards(directives),
  64            self._check_script_directive(directives),
  65            self._check_object_src_present(directives),
  66            self._check_strict_csp_base_uri(directives),
  67            self._check_strict_csp_object_src(directives),
  68            self._check_allowlist_bypass(directives),
  69            self._check_nonce_length(directives),
  70            self._check_http_source(directives),
  71            self._check_ip_source(directives),
  72            self._check_deprecated_directives(directives),
  73            self._check_reporting(directives, response),
  74            self._check_strict_dynamic_not_standalone(directives),
  75        ]
  76
  77        return AuditResult(
  78            name=self.name,
  79            detected=True,
  80            required=self.required,
  81            checks=checks,
  82            description=self.description,
  83        )
  84
  85    def _check_report_only_mode(
  86        self, is_report_only: bool, has_enforcing: bool, has_report_only: bool
  87    ) -> CheckResult:
  88        """Check if CSP is in report-only mode (not enforcing)."""
  89        if is_report_only:
  90            # Only Report-Only header present - policy is NOT enforced
  91            return CheckResult(
  92                name="report-only-mode",
  93                passed=False,
  94                message="CSP is in Report-Only mode (violations reported but not enforced; remove '-Report-Only' suffix to enforce)",
  95            )
  96
  97        if has_enforcing and has_report_only:
  98            # Both headers present - likely testing a new policy
  99            return CheckResult(
 100                name="report-only-mode",
 101                passed=True,
 102                message="CSP has both enforcing and Report-Only headers (testing a new policy alongside existing one)",
 103            )
 104
 105        # Only enforcing header present - this is the desired state
 106        return CheckResult(
 107            name="report-only-mode",
 108            passed=True,
 109            message="CSP is in enforcing mode",
 110        )
 111
 112    def _check_unsafe_inline(
 113        self,
 114        effective_directives: dict[str, list[str]],
 115        directives: dict[str, list[str]],
 116    ) -> CheckResult:
 117        """Check if CSP contains unsafe-inline in script directives (HIGH severity).
 118
 119        Uses the effective CSP to avoid false positives when nonces/hashes are present.
 120        In CSP2+, browsers ignore 'unsafe-inline' when nonces or hashes are present.
 121
 122        Checks script-src, script-src-elem, and script-src-attr with fallback to default-src.
 123
 124        Note: We only check script-src, not style-src, because:
 125        - unsafe-inline in script-src is a critical XSS vulnerability
 126        - unsafe-inline in style-src is much less dangerous and harder to remove in practice
 127        """
 128        # Check all script directives
 129        directives_to_check = ["script-src", "script-src-attr", "script-src-elem"]
 130
 131        for directive in directives_to_check:
 132            # Get the effective directive for this one (handles fallback)
 133            effective_directive_name = self._get_effective_directive(
 134                directive, directives
 135            )
 136
 137            # Get values from effective CSP (with unsafe-inline removed if nonces/hashes present)
 138            effective_values = effective_directives.get(effective_directive_name, [])
 139
 140            if "'unsafe-inline'" in effective_values:
 141                # Build a message indicating which directive failed
 142                directive_label = (
 143                    effective_directive_name
 144                    if effective_directive_name == directive
 145                    else f"{directive} (via {effective_directive_name})"
 146                )
 147                return CheckResult(
 148                    name="unsafe-inline",
 149                    passed=False,
 150                    message=f"CSP contains 'unsafe-inline' in {directive_label} which allows inline scripts and event handlers",
 151                )
 152
 153        return CheckResult(
 154            name="unsafe-inline",
 155            passed=True,
 156            message="CSP does not contain 'unsafe-inline' in script directives",
 157        )
 158
 159    def _check_unsafe_eval(self, directives: dict[str, list[str]]) -> CheckResult:
 160        """Check if CSP contains unsafe-eval directive (MEDIUM severity).
 161
 162        Checks script-src, script-src-elem, and script-src-attr with fallback to default-src.
 163        """
 164        # Check all script directives
 165        directives_to_check = ["script-src", "script-src-attr", "script-src-elem"]
 166
 167        for directive in directives_to_check:
 168            # Get the effective directive for this one (handles fallback)
 169            effective_directive_name = self._get_effective_directive(
 170                directive, directives
 171            )
 172
 173            # Get values from the directive
 174            values = directives.get(effective_directive_name, [])
 175
 176            if "'unsafe-eval'" in values:
 177                # Build a message indicating which directive failed
 178                directive_label = (
 179                    effective_directive_name
 180                    if effective_directive_name == directive
 181                    else f"{directive} (via {effective_directive_name})"
 182                )
 183                return CheckResult(
 184                    name="unsafe-eval",
 185                    passed=False,
 186                    message=f"CSP contains 'unsafe-eval' in {directive_label} which allows dangerous eval() calls",
 187                )
 188
 189        return CheckResult(
 190            name="unsafe-eval",
 191            passed=True,
 192            message="CSP does not contain 'unsafe-eval' in script directives",
 193        )
 194
 195    def _check_plain_url_schemes(self, directives: dict[str, list[str]]) -> CheckResult:
 196        """Check for plain URL schemes like https:, http:, data: (HIGH severity).
 197
 198        Checks all XSS-critical directives: script-src, script-src-elem, script-src-attr,
 199        object-src, and base-uri.
 200        """
 201        # XSS-critical directives to check
 202        xss_directives = [
 203            "script-src",
 204            "script-src-attr",
 205            "script-src-elem",
 206            "object-src",
 207            "base-uri",
 208        ]
 209
 210        dangerous_schemes = ["https:", "http:", "data:"]
 211        found_issues = []
 212
 213        for directive in xss_directives:
 214            # Get the effective directive (handles fallback)
 215            effective_directive_name = self._get_effective_directive(
 216                directive, directives
 217            )
 218
 219            # Get values from the directive
 220            values = directives.get(effective_directive_name, [])
 221
 222            # Check for dangerous schemes
 223            for scheme in dangerous_schemes:
 224                if scheme in values:
 225                    directive_label = (
 226                        effective_directive_name
 227                        if effective_directive_name == directive
 228                        else f"{directive} (via {effective_directive_name})"
 229                    )
 230                    found_issues.append(f"{scheme} in {directive_label}")
 231
 232        if found_issues:
 233            return CheckResult(
 234                name="plain-url-schemes",
 235                passed=False,
 236                message=f"CSP contains overly broad URL schemes: {', '.join(found_issues)}",
 237            )
 238
 239        return CheckResult(
 240            name="plain-url-schemes",
 241            passed=True,
 242            message="CSP does not contain plain URL schemes in XSS-critical directives",
 243        )
 244
 245    def _check_wildcards(self, directives: dict[str, list[str]]) -> CheckResult:
 246        """Check for wildcard (*) in sensitive directives (HIGH severity).
 247
 248        Checks all XSS-critical directives: script-src, script-src-elem, script-src-attr,
 249        object-src, and base-uri.
 250        """
 251        # XSS-critical directives to check
 252        xss_directives = [
 253            "script-src",
 254            "script-src-attr",
 255            "script-src-elem",
 256            "object-src",
 257            "base-uri",
 258        ]
 259
 260        found_wildcards = []
 261
 262        for directive in xss_directives:
 263            # Get the effective directive (handles fallback)
 264            effective_directive_name = self._get_effective_directive(
 265                directive, directives
 266            )
 267
 268            # Get values from the directive
 269            values = directives.get(effective_directive_name, [])
 270
 271            # Check for wildcard (*)
 272            if "*" in values:
 273                directive_label = (
 274                    effective_directive_name
 275                    if effective_directive_name == directive
 276                    else f"{directive} (via {effective_directive_name})"
 277                )
 278                found_wildcards.append(directive_label)
 279
 280        if found_wildcards:
 281            return CheckResult(
 282                name="wildcards",
 283                passed=False,
 284                message=f"CSP contains wildcards in: {', '.join(found_wildcards)}",
 285            )
 286
 287        return CheckResult(
 288            name="wildcards",
 289            passed=True,
 290            message="CSP does not contain dangerous wildcards in XSS-critical directives",
 291        )
 292
 293    def _check_script_directive(self, directives: dict[str, list[str]]) -> CheckResult:
 294        """Check that script-src or default-src is present."""
 295        if "script-src" not in directives and "default-src" not in directives:
 296            return CheckResult(
 297                name="script-directive",
 298                passed=False,
 299                message="CSP missing script-src and default-src (no script restrictions)",
 300            )
 301
 302        return CheckResult(
 303            name="script-directive",
 304            passed=True,
 305            message="CSP has script-src or default-src",
 306        )
 307
 308    def _check_object_src_present(
 309        self, directives: dict[str, list[str]]
 310    ) -> CheckResult:
 311        """Check that object-src or default-src is present."""
 312        if "object-src" not in directives and "default-src" not in directives:
 313            return CheckResult(
 314                name="object-src-present",
 315                passed=False,
 316                message="CSP missing object-src (allows <object>/<embed> injection)",
 317            )
 318
 319        return CheckResult(
 320            name="object-src-present",
 321            passed=True,
 322            message="CSP has object-src or default-src",
 323        )
 324
 325    def _check_strict_csp_base_uri(
 326        self, directives: dict[str, list[str]]
 327    ) -> CheckResult:
 328        """Check that base-uri is properly configured for strict CSP.
 329
 330        base-uri is only required when:
 331        - Script nonces are present, OR
 332        - Script hashes AND strict-dynamic are present
 333
 334        Accepts both 'none' and 'self' as valid values.
 335        """
 336        # Check script-src (or fallback to default-src) for nonces/hashes
 337        script_src_directive = self._get_effective_directive("script-src", directives)
 338        script_values = directives.get(script_src_directive, [])
 339
 340        # Check for script nonces
 341        has_script_nonces = any(v.startswith("'nonce-") for v in script_values)
 342
 343        # Check for script hashes with strict-dynamic
 344        has_script_hashes = any(v.startswith("'sha") for v in script_values)
 345        has_strict_dynamic = "'strict-dynamic'" in script_values
 346
 347        # Only require base-uri when using script nonces or (hashes + strict-dynamic)
 348        needs_base_uri = has_script_nonces or (has_script_hashes and has_strict_dynamic)
 349
 350        if not needs_base_uri:
 351            return CheckResult(
 352                name="strict-csp-base-uri",
 353                passed=True,
 354                message="Not using strict CSP (script nonces/hashes with strict-dynamic) - base-uri check not applicable",
 355            )
 356
 357        # Strict CSP detected - base-uri should be present
 358        if "base-uri" not in directives:
 359            return CheckResult(
 360                name="strict-csp-base-uri",
 361                passed=False,
 362                message="Strict CSP missing base-uri (can be 'none' or 'self' to prevent base tag injection)",
 363            )
 364
 365        # Check if base-uri is 'none' or 'self'
 366        base_uri_values = directives.get("base-uri", [])
 367        if base_uri_values == ["'none'"] or base_uri_values == ["'self'"]:
 368            value_label = base_uri_values[0]
 369            return CheckResult(
 370                name="strict-csp-base-uri",
 371                passed=True,
 372                message=f"Strict CSP base-uri correctly set to {value_label}",
 373            )
 374
 375        # base-uri has other values
 376        return CheckResult(
 377            name="strict-csp-base-uri",
 378            passed=False,
 379            message=f"Strict CSP base-uri should be 'none' or 'self' (currently: {' '.join(base_uri_values)})",
 380        )
 381
 382    def _check_strict_csp_object_src(
 383        self, directives: dict[str, list[str]]
 384    ) -> CheckResult:
 385        """Check that object-src is defined for strict CSP.
 386
 387        For strict CSPs, object-src should be defined. Setting it to 'none' is
 388        recommended but not required - the policy may intentionally allow some
 389        object embeds.
 390        """
 391        has_nonces_or_hashes = any(
 392            any(v.startswith("'nonce-") or v.startswith("'sha") for v in values)
 393            for values in directives.values()
 394        )
 395
 396        if not has_nonces_or_hashes:
 397            return CheckResult(
 398                name="strict-csp-object-src",
 399                passed=True,
 400                message="Not using strict CSP (nonces/hashes) - object-src check not applicable",
 401            )
 402
 403        # Strict CSP detected - object-src should be defined
 404        object_src = directives.get("object-src", [])
 405        default_src = directives.get("default-src", [])
 406
 407        # Check if object-src is defined (either directly or via default-src)
 408        if not object_src and not default_src:
 409            return CheckResult(
 410                name="strict-csp-object-src",
 411                passed=False,
 412                message="Strict CSP missing object-src (allows plugin injection; set to 'none' if not using plugins)",
 413            )
 414
 415        # If object-src is defined, check if it's 'none' (best practice)
 416        if object_src == ["'none'"]:
 417            return CheckResult(
 418                name="strict-csp-object-src",
 419                passed=True,
 420                message="Strict CSP object-src correctly set to 'none'",
 421            )
 422
 423        # object-src is defined but not 'none'
 424        if object_src:
 425            return CheckResult(
 426                name="strict-csp-object-src",
 427                passed=True,
 428                message=f"Strict CSP has object-src defined (consider tightening to 'none': currently {' '.join(object_src)})",
 429            )
 430
 431        # Covered by default-src
 432        return CheckResult(
 433            name="strict-csp-object-src",
 434            passed=True,
 435            message="Strict CSP object-src covered by default-src (consider explicit object-src 'none')",
 436        )
 437
 438    def _check_allowlist_bypass(self, directives: dict[str, list[str]]) -> CheckResult:
 439        """Check for known CSP bypass domains in allowlists (HIGH severity)."""
 440        # Top known bypass domains from Google CSP Evaluator
 441        # (subset of most dangerous JSONP/Angular endpoints)
 442        bypass_domains = {
 443            # Google services with JSONP endpoints
 444            "google-analytics.com",
 445            "www.google-analytics.com",
 446            "ssl.google-analytics.com",
 447            "googletagmanager.com",
 448            "www.googletagmanager.com",
 449            "www.googleadservices.com",
 450            # CDNs with Angular/JSONP
 451            "ajax.googleapis.com",
 452            "cdnjs.cloudflare.com",
 453            "cdn.jsdelivr.net",
 454            # Yandex
 455            "yandex.st",
 456            "yastatic.net",
 457        }
 458
 459        script_src = directives.get("script-src", [])
 460        default_src = directives.get("default-src", [])
 461        effective_src = script_src if script_src else default_src
 462
 463        found_bypasses = []
 464        for value in effective_src:
 465            # Remove protocol and quotes
 466            cleaned = (
 467                value.replace("https://", "").replace("http://", "").replace("'", "")
 468            )
 469            # Check if any bypass domain is in this value
 470            for bypass_domain in bypass_domains:
 471                if bypass_domain in cleaned:
 472                    found_bypasses.append(bypass_domain)
 473                    break
 474
 475        if found_bypasses:
 476            return CheckResult(
 477                name="allowlist-bypass",
 478                passed=False,
 479                message=f"CSP allows known bypass domains: {', '.join(set(found_bypasses))}",
 480            )
 481
 482        return CheckResult(
 483            name="allowlist-bypass",
 484            passed=True,
 485            message="CSP does not contain known bypass domains",
 486        )
 487
 488    def _check_nonce_length(self, directives: dict[str, list[str]]) -> CheckResult:
 489        """Check that nonces are at least 8 characters and use valid base64/base64url charset.
 490
 491        Follows Google CSP Evaluator: minimum 8 characters (not 22).
 492        Charset validation is informational only.
 493        """
 494        import re
 495
 496        # Collect all nonces from all directives
 497        nonces = []
 498        for values in directives.values():
 499            for value in values:
 500                if value.startswith("'nonce-"):
 501                    # Extract nonce value (remove 'nonce-' prefix and trailing ')
 502                    nonce = value[7:-1] if value.endswith("'") else value[7:]
 503                    nonces.append(nonce)
 504
 505        if not nonces:
 506            # No nonces used, check passes
 507            return CheckResult(
 508                name="nonce-length",
 509                passed=True,
 510                message="No nonces in CSP",
 511            )
 512
 513        length_issues = []
 514        charset_warnings = []
 515
 516        # Check each nonce
 517        for nonce in nonces:
 518            # Check length (minimum 8 characters per Google CSP Evaluator)
 519            if len(nonce) < 8:
 520                length_issues.append(
 521                    f"nonce '{nonce}' is too short ({len(nonce)} chars, minimum 8)"
 522                )
 523
 524            # Check base64/base64url charset (informational)
 525            # Standard base64: A-Z, a-z, 0-9, +, /, =
 526            # URL-safe base64url: A-Z, a-z, 0-9, -, _ (typically no padding)
 527            if not re.match(r"^[A-Za-z0-9+/=_-]+$", nonce):
 528                charset_warnings.append(
 529                    f"nonce '{nonce}' contains non-base64 characters (should use base64/base64url charset)"
 530                )
 531
 532        # Length issues are failures
 533        if length_issues:
 534            return CheckResult(
 535                name="nonce-length",
 536                passed=False,
 537                message=f"Nonce validation failed: {'; '.join(length_issues)}",
 538            )
 539
 540        # Charset warnings are informational
 541        if charset_warnings:
 542            return CheckResult(
 543                name="nonce-length",
 544                passed=True,
 545                message=f"Nonces valid but consider using base64 charset: {'; '.join(charset_warnings)}",
 546            )
 547
 548        return CheckResult(
 549            name="nonce-length",
 550            passed=True,
 551            message=f"All {len(nonces)} nonce(s) are valid",
 552        )
 553
 554    def _check_http_source(self, directives: dict[str, list[str]]) -> CheckResult:
 555        """Check for http:// URLs in CSP directives (mixed content vulnerability)."""
 556        http_sources = []
 557
 558        # Check all directives for http:// URLs
 559        for directive_name, values in directives.items():
 560            for value in values:
 561                # Look for http:// (but not http: scheme which is checked elsewhere)
 562                if value.startswith("http://") or value.startswith("'http://"):
 563                    http_sources.append(f"{directive_name}: {value}")
 564
 565        if http_sources:
 566            return CheckResult(
 567                name="http-source",
 568                passed=False,
 569                message=f"CSP contains insecure HTTP sources: {', '.join(http_sources)}",
 570            )
 571
 572        return CheckResult(
 573            name="http-source",
 574            passed=True,
 575            message="CSP does not contain insecure HTTP sources",
 576        )
 577
 578    def _check_syntax(
 579        self, csp_header: str, directives: dict[str, list[str]]
 580    ) -> CheckResult:
 581        """Check for CSP syntax issues: unknown directives, invalid keywords, missing separators."""
 582        issues = []
 583
 584        # Known CSP directives (CSP Level 3 and legacy)
 585        known_directives = {
 586            # Fetch directives
 587            "default-src",
 588            "script-src",
 589            "script-src-elem",
 590            "script-src-attr",
 591            "style-src",
 592            "style-src-elem",
 593            "style-src-attr",
 594            "img-src",
 595            "font-src",
 596            "connect-src",
 597            "media-src",
 598            "object-src",
 599            "frame-src",
 600            "child-src",
 601            "worker-src",
 602            "manifest-src",
 603            "prefetch-src",  # Deprecated but valid
 604            # Document directives
 605            "base-uri",
 606            "plugin-types",  # Deprecated but valid
 607            "sandbox",
 608            "disown-opener",  # Deprecated but valid
 609            # Navigation directives
 610            "form-action",
 611            "frame-ancestors",
 612            "navigate-to",
 613            # Reporting directives
 614            "report-uri",  # Still needed for Firefox/Safari
 615            "report-to",
 616            # Other directives
 617            "upgrade-insecure-requests",
 618            "block-all-mixed-content",  # Deprecated
 619            "reflected-xss",  # Deprecated but valid
 620            "referrer",  # Deprecated but valid
 621            "require-sri-for",  # Deprecated but valid
 622            "require-trusted-types-for",
 623            "trusted-types",
 624            "webrtc",
 625        }
 626
 627        # Known CSP keywords (must be in quotes)
 628        known_keywords = {
 629            "'none'",
 630            "'self'",
 631            "'unsafe-inline'",
 632            "'unsafe-eval'",
 633            "'unsafe-hashes'",
 634            "'strict-dynamic'",
 635            "'report-sample'",
 636            "'wasm-unsafe-eval'",
 637        }
 638
 639        # Check for unknown directives (typos)
 640        for directive_name in directives.keys():
 641            if directive_name not in known_directives:
 642                # Could be a typo or experimental directive
 643                issues.append(f"unknown directive '{directive_name}' (typo?)")
 644
 645        # Check for invalid keywords (missing quotes or typos)
 646        for directive_name, values in directives.items():
 647            for value in values:
 648                # Keywords without quotes (common mistake)
 649                if value.lower() in [
 650                    "none",
 651                    "self",
 652                    "unsafe-inline",
 653                    "unsafe-eval",
 654                    "strict-dynamic",
 655                ]:
 656                    issues.append(
 657                        f"{directive_name} has unquoted keyword '{value}' (should be '{value}')"
 658                    )
 659                # Quoted but not a recognized keyword (possible typo)
 660                elif value.startswith("'") and value.endswith("'"):
 661                    if (
 662                        value not in known_keywords
 663                        and not value.startswith("'nonce-")
 664                        and not value.startswith("'sha")
 665                    ):
 666                        issues.append(
 667                            f"{directive_name} has unrecognized keyword {value} (typo?)"
 668                        )
 669
 670        if issues:
 671            return CheckResult(
 672                name="syntax",
 673                passed=False,
 674                message=f"CSP syntax issues: {'; '.join(issues[:3])}",  # Limit to 3 issues
 675            )
 676
 677        return CheckResult(
 678            name="syntax",
 679            passed=True,
 680            message="CSP syntax is valid",
 681        )
 682
 683    def _check_ip_source(self, directives: dict[str, list[str]]) -> CheckResult:
 684        """Check for IP address sources in CSP (less secure than domains)."""
 685        import ipaddress
 686
 687        ip_sources = []
 688
 689        # Check all directives for IP addresses
 690        for directive_name, values in directives.items():
 691            for value in values:
 692                # Skip keywords
 693                if value.startswith("'"):
 694                    continue
 695
 696                # Remove port if present
 697                host = value.split(":")[0]
 698
 699                # Try to parse as IP address
 700                try:
 701                    ipaddress.ip_address(host)
 702                    ip_sources.append(f"{directive_name}: {value}")
 703                except ValueError:
 704                    # Not an IP address, that's good
 705                    continue
 706
 707        if ip_sources:
 708            return CheckResult(
 709                name="ip-source",
 710                passed=False,
 711                message=f"CSP uses IP addresses (prefer domains): {', '.join(ip_sources)}",
 712            )
 713
 714        return CheckResult(
 715            name="ip-source",
 716            passed=True,
 717            message="CSP does not use IP address sources",
 718        )
 719
 720    def _check_deprecated_directives(
 721        self, directives: dict[str, list[str]]
 722    ) -> CheckResult:
 723        """Check for deprecated CSP directives (matches Google CSP Evaluator)."""
 724        deprecated = {
 725            "reflected-xss": "use X-XSS-Protection header instead",
 726            "referrer": "use Referrer-Policy header instead",
 727            "disown-opener": "use Cross Origin Opener Policy header instead",
 728            "prefetch-src": "may cease to work at any time",
 729        }
 730
 731        found_deprecated = []
 732
 733        for directive_name in directives.keys():
 734            if directive_name in deprecated:
 735                reason = deprecated[directive_name]
 736                found_deprecated.append(f"{directive_name} ({reason})")
 737
 738        if found_deprecated:
 739            return CheckResult(
 740                name="deprecated-directives",
 741                passed=False,
 742                message=f"CSP uses deprecated directives: {'; '.join(found_deprecated)}",
 743            )
 744
 745        return CheckResult(
 746            name="deprecated-directives",
 747            passed=True,
 748            message="CSP does not use deprecated directives",
 749        )
 750
 751    def _check_missing_semicolon(self, csp_header: str) -> CheckResult:
 752        """Check for missing semicolons between directives (SYNTAX severity)."""
 753        # Known CSP directive names
 754        known_directives = {
 755            "default-src",
 756            "script-src",
 757            "script-src-elem",
 758            "script-src-attr",
 759            "style-src",
 760            "style-src-elem",
 761            "style-src-attr",
 762            "img-src",
 763            "font-src",
 764            "connect-src",
 765            "media-src",
 766            "object-src",
 767            "frame-src",
 768            "child-src",
 769            "worker-src",
 770            "manifest-src",
 771            "base-uri",
 772            "form-action",
 773            "frame-ancestors",
 774            "navigate-to",
 775            "report-uri",
 776            "report-to",
 777            "sandbox",
 778            "upgrade-insecure-requests",
 779            "block-all-mixed-content",
 780            "require-trusted-types-for",
 781            "trusted-types",
 782            "webrtc",
 783        }
 784
 785        # Split by semicolon and check each directive part
 786        parts = csp_header.split(";")
 787        for part in parts:
 788            tokens = part.strip().split()
 789            if not tokens:
 790                continue
 791
 792            # Check if any known directive appears after the first token
 793            # This indicates a missing semicolon
 794            for i, token in enumerate(tokens[1:], 1):
 795                if token in known_directives:
 796                    return CheckResult(
 797                        name="missing-semicolon",
 798                        passed=False,
 799                        message=f"Missing semicolon before '{token}' directive",
 800                    )
 801
 802        return CheckResult(
 803            name="missing-semicolon",
 804            passed=True,
 805            message="CSP directives properly separated with semicolons",
 806        )
 807
 808    def _check_reporting(
 809        self, directives: dict[str, list[str]], response: requests.Response
 810    ) -> CheckResult:
 811        """Check if CSP reporting is configured with modern Reporting-Endpoints header.
 812
 813        Validates that report-to endpoints exist in Reporting-Endpoints header.
 814        The report-uri directive and Report-To header are deprecated and should not be used.
 815        """
 816        has_report_uri = "report-uri" in directives
 817        has_report_to = "report-to" in directives
 818
 819        # Check for Reporting-Endpoints header (modern, Reporting API v1)
 820        reporting_endpoints_header = response.headers.get("Reporting-Endpoints", "")
 821
 822        # No reporting configured - this is optional
 823        if not has_report_uri and not has_report_to:
 824            return CheckResult(
 825                name="reporting",
 826                passed=True,
 827                message="CSP has no reporting configured (optional)",
 828            )
 829
 830        # Using deprecated report-uri directive
 831        if has_report_uri:
 832            return CheckResult(
 833                name="reporting",
 834                passed=False,
 835                message="CSP uses deprecated report-uri (migrate to report-to with Reporting-Endpoints)",
 836            )
 837
 838        # Using report-to directive - validate the endpoint exists in Reporting-Endpoints
 839        if has_report_to:
 840            # Get the endpoint name(s) from the directive
 841            report_to_values = directives.get("report-to", [])
 842            if not report_to_values:
 843                return CheckResult(
 844                    name="reporting",
 845                    passed=False,
 846                    message="CSP report-to directive is empty",
 847                )
 848
 849            endpoint_name = report_to_values[0]  # report-to should have one value
 850
 851            # Must have Reporting-Endpoints header for report-to to work
 852            if not reporting_endpoints_header:
 853                return CheckResult(
 854                    name="reporting",
 855                    passed=False,
 856                    message="CSP report-to directive requires Reporting-Endpoints header",
 857                )
 858
 859            # Validate endpoint exists in Reporting-Endpoints header
 860            # Format: endpoint-name="url", other="url2"
 861            if f'{endpoint_name}="' in reporting_endpoints_header:
 862                return CheckResult(
 863                    name="reporting",
 864                    passed=True,
 865                    message="CSP reporting correctly configured with Reporting-Endpoints",
 866                )
 867            else:
 868                return CheckResult(
 869                    name="reporting",
 870                    passed=False,
 871                    message=f"CSP report-to references '{endpoint_name}' but it's not defined in Reporting-Endpoints header",
 872                )
 873
 874        # Shouldn't reach here, but safety fallback
 875        return CheckResult(
 876            name="reporting",
 877            passed=True,
 878            message="CSP reporting check completed",
 879        )
 880
 881    def _check_strict_dynamic_not_standalone(
 882        self, directives: dict[str, list[str]]
 883    ) -> CheckResult:
 884        """Check that 'strict-dynamic' is not used without nonces/hashes (INFO severity)."""
 885        script_src = directives.get("script-src", directives.get("default-src", []))
 886
 887        # Check if strict-dynamic is present
 888        has_strict_dynamic = "'strict-dynamic'" in script_src
 889
 890        if not has_strict_dynamic:
 891            # Not using strict-dynamic, check not applicable
 892            return CheckResult(
 893                name="strict-dynamic-standalone",
 894                passed=True,
 895                message="CSP does not use 'strict-dynamic'",
 896            )
 897
 898        # Check if using nonces or hashes
 899        has_nonces = any(val.startswith("'nonce-") for val in script_src)
 900        has_hashes = any(val.startswith("'sha") for val in script_src)
 901
 902        if not has_nonces and not has_hashes:
 903            return CheckResult(
 904                name="strict-dynamic-standalone",
 905                passed=False,
 906                message="CSP uses 'strict-dynamic' without nonces/hashes (will block ALL scripts)",
 907            )
 908
 909        return CheckResult(
 910            name="strict-dynamic-standalone",
 911            passed=True,
 912            message="CSP uses 'strict-dynamic' with nonces/hashes",
 913        )
 914
 915    def _parse_csp(self, csp_header: str) -> dict[str, list[str]]:
 916        """Parse CSP header into a dictionary of directives."""
 917        directives = {}
 918        for directive in csp_header.split(";"):
 919            directive = directive.strip()
 920            if not directive:
 921                continue
 922
 923            parts = directive.split()
 924            if not parts:
 925                continue
 926
 927            directive_name = parts[0]
 928            directive_values = parts[1:] if len(parts) > 1 else []
 929            directives[directive_name] = directive_values
 930
 931        return directives
 932
 933    def _get_effective_directive(
 934        self, directive: str, directives: dict[str, list[str]]
 935    ) -> str:
 936        """Get the effective directive considering fallback rules.
 937
 938        Returns the directive itself if present, or the appropriate fallback directive.
 939        Follows CSP spec fallback rules:
 940        - script-src-elem/attr → script-src → default-src
 941        - style-src-elem/attr → style-src → default-src
 942        - Other fetch directives → default-src
 943        """
 944        if directive in directives:
 945            return directive
 946
 947        # Handle script-src-elem and script-src-attr fallback
 948        if directive in ("script-src-attr", "script-src-elem"):
 949            if "script-src" in directives:
 950                return "script-src"
 951
 952        # Handle style-src-elem and style-src-attr fallback
 953        if directive in ("style-src-attr", "style-src-elem"):
 954            if "style-src" in directives:
 955                return "style-src"
 956
 957        # Fetch directives fall back to default-src
 958        fetch_directives = {
 959            "child-src",
 960            "connect-src",
 961            "font-src",
 962            "frame-src",
 963            "img-src",
 964            "manifest-src",
 965            "media-src",
 966            "object-src",
 967            "script-src",
 968            "script-src-attr",
 969            "script-src-elem",
 970            "style-src",
 971            "style-src-attr",
 972            "style-src-elem",
 973            "worker-src",
 974            "prefetch-src",
 975        }
 976
 977        if directive in fetch_directives:
 978            return "default-src"
 979
 980        return directive
 981
 982    def _get_effective_csp(
 983        self, directives: dict[str, list[str]]
 984    ) -> dict[str, list[str]]:
 985        """Compute the effective CSP as enforced by browsers (CSP2+).
 986
 987        Browsers ignore certain directive values in the presence of nonces/hashes:
 988        - 'unsafe-inline' is ignored if nonces or hashes are present (CSP2+)
 989        - Allowlist sources are ignored when 'strict-dynamic' is present (CSP3+)
 990
 991        This prevents false positives on strict nonce-based CSPs.
 992        """
 993        # Create a deep copy of directives
 994        effective_directives: dict[str, list[str]] = {}
 995        for directive, values in directives.items():
 996            effective_directives[directive] = list(values)
 997
 998        # Check script-src, script-src-attr, and script-src-elem
 999        for directive_to_check in ["script-src", "script-src-attr", "script-src-elem"]:
1000            # Get the effective directive for this one (handles fallback)
1001            effective_directive_name = self._get_effective_directive(
1002                directive_to_check, directives
1003            )
1004
1005            # Skip if not in effective directives
1006            if effective_directive_name not in effective_directives:
1007                continue
1008
1009            values = directives.get(effective_directive_name, [])
1010            effective_values = effective_directives[effective_directive_name]
1011
1012            # Check if nonces or hashes are present
1013            has_nonces = any(v.startswith("'nonce-") for v in values)
1014            has_hashes = any(v.startswith("'sha") for v in values)
1015
1016            if has_nonces or has_hashes:
1017                # CSP2+: Remove 'unsafe-inline' when nonces/hashes are present
1018                if "'unsafe-inline'" in effective_values:
1019                    effective_values.remove("'unsafe-inline'")
1020
1021            # Check if strict-dynamic is present
1022            has_strict_dynamic = "'strict-dynamic'" in values
1023
1024            if has_strict_dynamic:
1025                # CSP3+: Remove allowlist sources when strict-dynamic is present
1026                # Keep only keywords (starting with ') except 'self' and 'unsafe-inline'
1027                effective_values[:] = [
1028                    v
1029                    for v in effective_values
1030                    if v.startswith("'")
1031                    and v not in ("'self'", "'unsafe-inline'")
1032                    or v in ("'strict-dynamic'",)  # Keep strict-dynamic itself
1033                ]
1034
1035        return effective_directives