1use async_trait::async_trait;
4use once_cell::sync::Lazy;
5use regex::Regex;
6
7use crate::{
8 config::Config,
9 error::CapturedError,
10 http_client::HttpClient,
11 reports::{Finding, Severity},
12};
13
14use super::Scanner;
15
16pub struct CspScanner;
17
18impl CspScanner {
19 pub fn new(_config: &Config) -> Self {
20 Self
21 }
22}
23
24static REQUIRED_DIRECTIVES: &[&str] = &["default-src", "script-src", "object-src", "base-uri"];
26
27static UNSAFE_SOURCES: &[(&str, &str)] = &[
29 (
30 "'unsafe-inline'",
31 "Allows inline scripts/styles — XSS mitigation lost.",
32 ),
33 (
34 "'unsafe-eval'",
35 "Allows eval() — bypasses script-src restrictions.",
36 ),
37 (
38 "'unsafe-hashes'",
39 "Allows execution of hashed inline handlers.",
40 ),
41 (
42 "data:",
43 "'data:' URI in script context allows arbitrary script execution.",
44 ),
45 ("http:", "Plain HTTP source allows MITM script injection."),
46 ("*", "Wildcard source allows loading from any host."),
47];
48
49static BYPASS_HOSTS: Lazy<Vec<Regex>> = Lazy::new(|| {
51 [
52 r"(?i)^(?:https?://)?cdn\.cloudflare\.com(?:/[^\s]*)?$",
53 r"(?i)^(?:https?://)?ajax\.googleapis\.com(?:/[^\s]*)?$",
54 r"(?i)^(?:https?://)?cdnjs\.cloudflare\.com(?:/[^\s]*)?$",
55 r"(?i)^(?:https?://)?cdn\.jsdelivr\.net(?:/[^\s]*)?$",
56 r"(?i)^(?:https?://)?unpkg\.com(?:/[^\s]*)?$",
57 r"(?i)^(?:https?://)?rawgit\.com(?:/[^\s]*)?$",
58 r"(?i)^(?:https?://)?raw\.githubusercontent\.com(?:/[^\s]*)?$",
59 r"(?i)^(?:https?://)?stackpath\.bootstrapcdn\.com(?:/[^\s]*)?$",
60 r"(?i)^(?:https?://)?code\.jquery\.com(?:/[^\s]*)?$",
61 r"(?i)^(?:https?://)?yandex\.st(?:/[^\s]*)?$",
62 r"(?i)^(?:https?://)?api\.twitter\.com(?:/[^\s]*)?$",
63 r"(?i)^(?:https?://)?platform\.twitter\.com(?:/[^\s]*)?$",
64 ]
65 .iter()
66 .map(|p| Regex::new(p).unwrap())
67 .collect()
68});
69
70#[async_trait]
71impl Scanner for CspScanner {
72 fn name(&self) -> &'static str {
73 "csp"
74 }
75
76 async fn scan(
77 &self,
78 url: &str,
79 client: &HttpClient,
80 _config: &Config,
81 ) -> (Vec<Finding>, Vec<CapturedError>) {
82 let mut findings = Vec::new();
83 let mut errors = Vec::new();
84
85 let resp = match client.get(url).await {
86 Ok(r) => r,
87 Err(e) => {
88 errors.push(e);
89 return (findings, errors);
90 }
91 };
92
93 let csp_value = match resp.header("content-security-policy") {
95 Some(v) => v.to_string(),
96 None => {
97 if let Some(ro) = resp.header("content-security-policy-report-only") {
99 findings.push(Finding::new(
100 url,
101 "csp/report-only",
102 "CSP Report-Only",
103 Severity::Info,
104 "Only CSP Report-Only header present; policy is not enforced.",
105 "csp",
106 )
107 .with_evidence(format!(
108 "Content-Security-Policy-Report-Only: {ro}"
109 ))
110 .with_remediation(
111 "Deploy an enforcing Content-Security-Policy header after validating reports.",
112 ));
113 } else {
114 findings.push(
115 Finding::new(
116 url,
117 "csp/missing",
118 "No CSP header",
119 Severity::Info,
120 "No Content-Security-Policy header detected. CSP is a defense-in-depth mechanism.",
121 "csp",
122 )
123 .with_remediation(
124 "Add a Content-Security-Policy header with least-privilege sources.",
125 ),
126 );
127 }
128 return (findings, errors);
129 }
130 };
131
132 let directives = parse_csp(&csp_value);
134
135 for req in REQUIRED_DIRECTIVES {
137 if !directives.contains_key(*req) {
138 let severity = match *req {
140 "default-src" | "script-src" => Severity::Low, _ => Severity::Info, };
143 findings.push(
144 Finding::new(
145 url,
146 format!("csp/missing-directive/{req}"),
147 format!("CSP missing '{req}'"),
148 severity,
149 format!("CSP is missing the '{req}' directive. Not exploitable without an injection vulnerability."),
150 "csp",
151 )
152 .with_evidence(format!("Content-Security-Policy: {csp_value}"))
153 .with_remediation(format!(
154 "Add the '{req}' directive with a restrictive allowlist."
155 )),
156 );
157 }
158 }
159
160 let script_sources = directives
162 .get("script-src")
163 .or_else(|| directives.get("default-src"))
164 .cloned()
165 .unwrap_or_default();
166
167 for (token, desc) in UNSAFE_SOURCES {
168 if script_sources.iter().any(|s| s.eq_ignore_ascii_case(token)) {
169 let severity = match *token {
171 "*" => Severity::Medium, "'unsafe-inline'" | "'unsafe-eval'" => Severity::Low, _ => Severity::Info, };
175
176 findings.push(
177 Finding::new(
178 url,
179 format!("csp/unsafe-source/{}", token.trim_matches('\'')),
180 format!("CSP unsafe source: {token}"),
181 severity,
182 format!("script-src contains '{token}': {desc} Note: Not exploitable without an injection vulnerability."),
183 "csp",
184 )
185 .with_evidence(format!("Content-Security-Policy: {csp_value}"))
186 .with_remediation(
187 "Remove unsafe script sources and use nonces or hashes for inline scripts.",
188 ),
189 );
190 }
191 }
192
193 for source in &script_sources {
195 for re in BYPASS_HOSTS.iter() {
196 if re.is_match(source) {
197 findings.push(
198 Finding::new(
199 url,
200 "csp/bypassable-cdn",
201 "CSP bypassable CDN",
202 Severity::Medium,
203 format!(
204 "script-src allows '{source}', which hosts JSONP endpoints or \
205 third-party scripts that can bypass CSP."
206 ),
207 "csp",
208 )
209 .with_evidence(format!("Content-Security-Policy: {csp_value}"))
210 .with_remediation(
211 "Pin scripts with subresource integrity or self-host critical assets.",
212 ),
213 );
214 break;
215 }
216 }
217 }
218
219 if !directives.contains_key("frame-ancestors") {
221 findings.push(Finding::new(
222 url,
223 "csp/missing-frame-ancestors",
224 "CSP missing frame-ancestors",
225 Severity::Low,
226 "CSP lacks 'frame-ancestors' directive (clickjacking protection).",
227 "csp",
228 )
229 .with_evidence(format!("Content-Security-Policy: {csp_value}"))
230 .with_remediation(
231 "Add 'frame-ancestors' with a strict allowlist (or 'none') to prevent clickjacking.",
232 ));
233 }
234
235 (findings, errors)
236 }
237}
238
239fn parse_csp(header: &str) -> std::collections::HashMap<String, Vec<String>> {
243 let mut map = std::collections::HashMap::new();
244
245 for directive in header.split(';') {
246 let directive = directive.trim();
247 if directive.is_empty() {
248 continue;
249 }
250 let mut parts = directive.splitn(2, char::is_whitespace);
251 let name = parts.next().unwrap_or("").trim().to_ascii_lowercase();
252 let sources: Vec<String> = parts
253 .next()
254 .unwrap_or("")
255 .split_whitespace()
256 .map(|s| s.to_string())
257 .collect();
258
259 map.insert(name, sources);
260 }
261
262 map
263}