1from __future__ import annotations
  2
  3from typing import TYPE_CHECKING
  4
  5import requests
  6from requests.exceptions import ConnectionError as RequestsConnectionError
  7from requests.exceptions import SSLError
  8
  9from . import __version__
 10from .audits import (
 11    ContentTypeOptionsAudit,
 12    CookiesAudit,
 13    CORSAudit,
 14    CSPAudit,
 15    FrameOptionsAudit,
 16    HSTSAudit,
 17    RedirectsAudit,
 18    ReferrerPolicyAudit,
 19    StatusCodeAudit,
 20    TLSAudit,
 21)
 22from .metadata import ScanMetadata
 23
 24if TYPE_CHECKING:
 25    from .audits.base import Audit
 26    from .results import ScanResult
 27
 28
 29class Scanner:
 30    """Main scanner that runs security checks against a URL."""
 31
 32    def __init__(self, url: str, disabled_audits: set[str] | None = None) -> None:
 33        self.url = url
 34        self.disabled_audits = disabled_audits or set()
 35        self.response: requests.Response | None = None
 36        self.fetch_exception: Exception | None = None
 37
 38        # Initialize all available audits
 39        # Required audits first, then optional ones
 40        self.audits: list[Audit] = [
 41            # Required security audits
 42            StatusCodeAudit(),  # Check status code first (most fundamental)
 43            CSPAudit(),
 44            HSTSAudit(),
 45            TLSAudit(),
 46            RedirectsAudit(),
 47            ContentTypeOptionsAudit(),
 48            FrameOptionsAudit(),
 49            ReferrerPolicyAudit(),
 50            # Optional audits
 51            CookiesAudit(),
 52            CORSAudit(),
 53        ]
 54
 55    def fetch(self) -> requests.Response:
 56        """Fetch the URL and cache the response."""
 57        if self.response is None:
 58            try:
 59                user_agent = (
 60                    f"plain-scan/{__version__} (+https://plainframework.com/scan)"
 61                )
 62                self.response = requests.get(
 63                    self.url,
 64                    allow_redirects=True,
 65                    timeout=30,
 66                    headers={"User-Agent": user_agent},
 67                )
 68            except (
 69                SSLError,
 70                RequestsConnectionError,
 71            ) as e:
 72                # Store TLS/network exceptions so TLSAudit can report them
 73                self.fetch_exception = e
 74                raise
 75        return self.response
 76
 77    def scan(self) -> ScanResult:
 78        """Run all security checks and return results."""
 79        from .results import ScanResult
 80
 81        # Try to fetch the URL once
 82        # If this fails with TLS/network errors, we store the exception
 83        # and continue so TLSAudit can report the issue
 84        response = None
 85        try:
 86            response = self.fetch()
 87        except (
 88            SSLError,
 89            RequestsConnectionError,
 90        ):
 91            # Exception is already stored in self.fetch_exception
 92            # Continue with scan so TLSAudit can report it
 93            pass
 94
 95        # Collect metadata about the request
 96        metadata = ScanMetadata.from_response(response)
 97
 98        # Run each audit
 99        scan_result = ScanResult(url=self.url, metadata=metadata)
100        for audit in self.audits:
101            # If audit is disabled by user, add to results but mark as disabled
102            if audit.slug in self.disabled_audits:
103                from .results import AuditResult
104
105                scan_result.audits.append(
106                    AuditResult(
107                        name=audit.name,
108                        detected=False,
109                        required=audit.required,
110                        checks=[],
111                        disabled=True,
112                        description=audit.description,
113                    )
114                )
115            else:
116                # Try to run the audit
117                # If the initial fetch failed and this audit needs the response,
118                # it will fail. TLSAudit handles fetch exceptions specially.
119                try:
120                    audit_result = audit.check(self)
121                    scan_result.audits.append(audit_result)
122                except (
123                    SSLError,
124                    RequestsConnectionError,
125                ):
126                    # Audit couldn't run due to fetch failure
127                    # Skip non-TLS audits since they need a successful response
128                    if audit.slug != "tls":
129                        from .results import AuditResult, CheckResult
130
131                        scan_result.audits.append(
132                            AuditResult(
133                                name=audit.name,
134                                detected=False,
135                                required=audit.required,
136                                checks=[
137                                    CheckResult(
138                                        name="Connection",
139                                        passed=False,
140                                        message="Could not connect to URL to run audit",
141                                    )
142                                ],
143                                description=audit.description,
144                            )
145                        )
146                    else:
147                        # TLS audit should have handled this - re-raise
148                        raise
149
150        return scan_result