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