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