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