v0.163.0
  1from __future__ import annotations
  2
  3from typing import TYPE_CHECKING
  4
  5import httpx
  6
  7from . import __version__
  8from .audits import (
  9    ContentTypeOptionsAudit,
 10    CookiesAudit,
 11    CORSAudit,
 12    CSPAudit,
 13    FrameOptionsAudit,
 14    HSTSAudit,
 15    RedirectsAudit,
 16    ReferrerPolicyAudit,
 17    StatusCodeAudit,
 18    TLSAudit,
 19)
 20from .metadata import ScanMetadata
 21
 22if TYPE_CHECKING:
 23    from .audits.base import Audit
 24    from .results import ScanResult
 25
 26__all__ = ["Scanner"]
 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: httpx.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) -> httpx.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 = httpx.get(
 63                    self.url,
 64                    follow_redirects=True,
 65                    timeout=30,
 66                    headers={"User-Agent": user_agent},
 67                )
 68            except httpx.TransportError as e:
 69                # Store TLS/network exceptions so TLSAudit can report them.
 70                # TransportError covers cert failures, connection refused, and
 71                # connect/read timeouts alike (httpx splits these across
 72                # ConnectError and TimeoutException, both TransportError).
 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 httpx.TransportError:
 88            # Exception is already stored in self.fetch_exception
 89            # Continue with scan so TLSAudit can report it
 90            pass
 91
 92        # Collect metadata about the request
 93        metadata = ScanMetadata.from_response(response)
 94
 95        # Run each audit
 96        scan_result = ScanResult(url=self.url, metadata=metadata)
 97        for audit in self.audits:
 98            # If audit is disabled by user, add to results but mark as disabled
 99            if audit.slug in self.disabled_audits:
100                from .results import AuditResult
101
102                scan_result.audits.append(
103                    AuditResult(
104                        name=audit.name,
105                        detected=False,
106                        required=audit.required,
107                        checks=[],
108                        disabled=True,
109                        description=audit.description,
110                    )
111                )
112            else:
113                # Try to run the audit
114                # If the initial fetch failed and this audit needs the response,
115                # it will fail. TLSAudit handles fetch exceptions specially.
116                try:
117                    audit_result = audit.check(self)
118                    scan_result.audits.append(audit_result)
119                except httpx.TransportError:
120                    # Audit couldn't run due to fetch failure
121                    # Skip non-TLS audits since they need a successful response
122                    if audit.slug != "tls":
123                        from .results import AuditResult, CheckResult
124
125                        scan_result.audits.append(
126                            AuditResult(
127                                name=audit.name,
128                                detected=False,
129                                required=audit.required,
130                                checks=[
131                                    CheckResult(
132                                        name="Connection",
133                                        passed=False,
134                                        message="Could not connect to URL to run audit",
135                                    )
136                                ],
137                                description=audit.description,
138                            )
139                        )
140                    else:
141                        # TLS audit should have handled this - re-raise
142                        raise
143
144        return scan_result