1use super::super::{AuditGate, AuditOptions, Finding, Severity, Site};
18use std::collections::HashSet;
19
20const NAME: &str = "csp_sri";
21
22#[derive(Debug, Clone, Copy)]
32pub struct CspSriGate;
33
34impl AuditGate for CspSriGate {
35 fn name(&self) -> &'static str {
36 NAME
37 }
38
39 fn explain(&self) -> &'static str {
40 "Asserts every page declares a Content-Security-Policy (via a \
41 <meta http-equiv=\"Content-Security-Policy\"> tag or a site \
42 _headers file) and that every cross-origin <script src> / \
43 <link rel=\"stylesheet\"> carries a valid `integrity` SRI \
44 attribute. Policy drift between pages is reported as a warn."
45 }
46
47 fn run(&self, site: &Site, _opts: &AuditOptions) -> Vec<Finding> {
48 let mut findings = Vec::new();
49 let mut policies: HashSet<String> = HashSet::new();
50
51 let site_headers = site.root.join("_headers");
53 let has_site_csp = site_headers.exists()
54 && std::fs::read_to_string(&site_headers).is_ok_and(|s| {
55 s.to_lowercase().contains("content-security-policy")
56 });
57
58 for path in &site.html_files {
59 let Ok(html) = site.read(path) else { continue };
60 let rel = site.rel(path);
61
62 let policy = extract_meta_csp(&html);
63 if !has_site_csp && policy.is_none() {
64 findings.push(
65 Finding::new(
66 NAME,
67 Severity::Error,
68 "Page has no Content-Security-Policy (no <meta http-equiv> and no _headers)",
69 )
70 .with_code("CSP-MISSING")
71 .with_path(rel.clone()),
72 );
73 }
74 if let Some(p) = policy {
75 let _ = policies.insert(p);
76 }
77
78 for asset in extract_remote_assets(&html) {
79 if asset.integrity.is_none() {
80 findings.push(
81 Finding::new(
82 NAME,
83 Severity::Error,
84 format!(
85 "{} {} missing SRI `integrity` attribute",
86 asset.kind, asset.href
87 ),
88 )
89 .with_code("SRI-MISSING")
90 .with_path(rel.clone()),
91 );
92 }
93 }
94 }
95
96 if policies.len() > 1 {
97 findings.push(
98 Finding::new(
99 NAME,
100 Severity::Warn,
101 format!(
102 "Found {} distinct CSP policies across pages",
103 policies.len()
104 ),
105 )
106 .with_code("CSP-DRIFT"),
107 );
108 }
109
110 findings
111 }
112}
113
114fn extract_meta_csp(html: &str) -> Option<String> {
115 let lower = html.to_ascii_lowercase();
116 let needle = "<meta";
117 let mut cursor = 0;
118 while let Some(rel) = lower[cursor..].find(needle) {
119 let abs = cursor + rel;
120 let end = super::find_tag_end(html, abs);
121 let tag = &html[abs..end];
122 cursor = end;
123 let is_csp = super::hreflang_attr(tag, "http-equiv")
127 .is_some_and(|v| v.eq_ignore_ascii_case("content-security-policy"));
128 if is_csp {
129 if let Some(c) = super::hreflang_attr(tag, "content") {
130 return Some(c);
131 }
132 }
133 }
134 None
135}
136
137struct RemoteAsset {
138 kind: &'static str,
139 href: String,
140 integrity: Option<String>,
141}
142
143fn extract_remote_assets(html: &str) -> Vec<RemoteAsset> {
144 let mut out = Vec::new();
145 let lower = html.to_lowercase();
146
147 let mut cursor = 0;
148 while let Some(rel) = lower[cursor..].find("<script") {
149 let abs = cursor + rel;
150 let end = super::find_tag_end(html, abs);
151 let tag = &html[abs..end];
152 cursor = end;
153 let Some(src) = super::hreflang_attr(tag, "src") else {
154 continue;
155 };
156 if !is_remote(&src) {
157 continue;
158 }
159 out.push(RemoteAsset {
160 kind: "<script src=>",
161 href: src,
162 integrity: super::hreflang_attr(tag, "integrity"),
163 });
164 }
165
166 let mut cursor = 0;
167 while let Some(rel) = lower[cursor..].find("<link") {
168 let abs = cursor + rel;
169 let end = super::find_tag_end(html, abs);
170 let tag = &html[abs..end];
171 cursor = end;
172 let is_stylesheet = super::hreflang_attr(tag, "rel").is_some_and(|r| {
175 r.split_ascii_whitespace()
176 .any(|t| t.eq_ignore_ascii_case("stylesheet"))
177 });
178 if !is_stylesheet {
179 continue;
180 }
181 let Some(href) = super::hreflang_attr(tag, "href") else {
182 continue;
183 };
184 if !is_remote(&href) {
185 continue;
186 }
187 out.push(RemoteAsset {
188 kind: "<link rel=stylesheet>",
189 href,
190 integrity: super::hreflang_attr(tag, "integrity"),
191 });
192 }
193
194 out
195}
196
197fn is_remote(href: &str) -> bool {
198 href.starts_with("http://")
199 || href.starts_with("https://")
200 || href.starts_with("//")
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206
207 fn site_with(pages: &[(&str, &str)]) -> Site {
208 let tmp = tempfile::tempdir().unwrap();
209 let root = tmp.path().to_path_buf();
210 let mut files = Vec::new();
211 for (rel, html) in pages {
212 let p = root.join(rel);
213 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
215 std::fs::write(&p, html).unwrap();
216 files.push(p);
217 }
218 std::mem::forget(tmp);
219 Site {
220 root,
221 html_files: files,
222 }
223 }
224
225 #[test]
226 fn page_with_csp_and_sri_is_clean() {
227 let html = r#"<html><head>
228 <meta http-equiv="Content-Security-Policy" content="default-src 'self'">
229 <script src="https://cdn.example/x.js" integrity="sha256-abc"></script>
230 <link rel="stylesheet" href="/local.css">
231 </head><body></body></html>"#;
232 let s = site_with(&[("index.html", html)]);
233 let f = CspSriGate.run(&s, &AuditOptions::default());
234 assert!(f.is_empty(), "got {f:?}");
235 }
236
237 #[test]
238 fn page_missing_csp_and_sri_flagged() {
239 let html = r#"<html><head>
240 <script src="https://cdn.example/x.js"></script>
241 </head><body></body></html>"#;
242 let s = site_with(&[("index.html", html)]);
243 let f = CspSriGate.run(&s, &AuditOptions::default());
244 assert!(f.iter().any(|x| x.code.as_deref() == Some("CSP-MISSING")));
245 assert!(f.iter().any(|x| x.code.as_deref() == Some("SRI-MISSING")));
246 }
247
248 #[test]
249 fn site_headers_csp_satisfies_requirement() {
250 let html = r#"<html><head>
251 <script src="https://cdn.example/x.js" integrity="sha256-z"></script>
252 </head><body></body></html>"#;
253 let tmp = tempfile::tempdir().unwrap();
254 let root = tmp.path().to_path_buf();
255 std::fs::write(
256 root.join("_headers"),
257 "/*\n Content-Security-Policy: default-src 'self'\n",
258 )
259 .unwrap();
260 let p = root.join("index.html");
261 std::fs::write(&p, html).unwrap();
262 let s = Site {
263 root,
264 html_files: vec![p],
265 };
266 std::mem::forget(tmp);
267 let f = CspSriGate.run(&s, &AuditOptions::default());
268 assert!(f.is_empty(), "got {f:?}");
269 }
270
271 #[test]
272 fn protocol_relative_stylesheet_needs_integrity() {
273 let html = r#"<html><head>
274 <meta http-equiv="Content-Security-Policy" content="default-src 'self'">
275 <link rel="stylesheet" href="//cdn.example/styles.css">
276 </head><body></body></html>"#;
277 let s = site_with(&[("index.html", html)]);
278 let f = CspSriGate.run(&s, &AuditOptions::default());
279 assert!(f.iter().any(|x| x.code.as_deref() == Some("SRI-MISSING")));
280 }
281
282 #[test]
283 fn local_assets_dont_need_integrity() {
284 let html = r#"<html><head>
285 <meta http-equiv="Content-Security-Policy" content="default-src 'self'">
286 <script src="/local.js"></script>
287 <link rel="stylesheet" href="/local.css">
288 </head><body></body></html>"#;
289 let s = site_with(&[("index.html", html)]);
290 let f = CspSriGate.run(&s, &AuditOptions::default());
291 assert!(
292 f.is_empty(),
293 "local assets should not require SRI, got {f:?}"
294 );
295 }
296
297 #[test]
298 fn http_remote_script_needs_integrity() {
299 let html = r#"<html><head>
300 <meta http-equiv="Content-Security-Policy" content="default-src 'self'">
301 <script src="http://cdn.example/x.js"></script>
302 </head><body></body></html>"#;
303 let s = site_with(&[("index.html", html)]);
304 let f = CspSriGate.run(&s, &AuditOptions::default());
305 assert!(f.iter().any(|x| x.code.as_deref() == Some("SRI-MISSING")));
306 }
307
308 #[test]
309 fn csp_drift_warns() {
310 let a = r#"<html><head>
311 <meta http-equiv="Content-Security-Policy" content="default-src 'self'">
312 </head><body></body></html>"#;
313 let b = r#"<html><head>
314 <meta http-equiv="Content-Security-Policy" content="default-src 'self' https:">
315 </head><body></body></html>"#;
316 let s = site_with(&[("a.html", a), ("b.html", b)]);
317 let f = CspSriGate.run(&s, &AuditOptions::default());
318 assert!(
319 f.iter().any(|x| x.code.as_deref() == Some("CSP-DRIFT")),
320 "expected CSP-DRIFT, got {f:?}"
321 );
322 }
323
324 #[test]
325 fn script_without_src_skipped() {
326 let html = r#"<html><head>
327 <meta http-equiv="Content-Security-Policy" content="default-src 'self'">
328 <script>console.log('inline');</script>
329 </head><body></body></html>"#;
330 let s = site_with(&[("index.html", html)]);
331 let f = CspSriGate.run(&s, &AuditOptions::default());
332 assert!(f.is_empty(), "inline script should not need SRI, got {f:?}");
333 }
334
335 #[test]
336 fn link_non_stylesheet_skipped() {
337 let html = r#"<html><head>
338 <meta http-equiv="Content-Security-Policy" content="default-src 'self'">
339 <link rel="preconnect" href="https://cdn.example">
340 </head><body></body></html>"#;
341 let s = site_with(&[("index.html", html)]);
342 let f = CspSriGate.run(&s, &AuditOptions::default());
343 assert!(f.is_empty(), "preconnect should not need SRI, got {f:?}");
344 }
345
346 #[test]
347 fn single_quoted_stylesheet_recognised() {
348 let html = r#"<html><head>
349 <meta http-equiv="Content-Security-Policy" content="default-src 'self'">
350 <link rel='stylesheet' href='https://cdn.example/s.css'>
351 </head><body></body></html>"#;
352 let s = site_with(&[("index.html", html)]);
353 let f = CspSriGate.run(&s, &AuditOptions::default());
354 assert!(f.iter().any(|x| x.code.as_deref() == Some("SRI-MISSING")));
355 }
356
357 #[test]
358 fn minified_unquoted_csp_meta_recognised() {
359 let html = "<html><head>\
364 <meta content=\"default-src 'self'\" http-equiv=Content-Security-Policy>\
365 <script src=\"https://cdn.example/x.js\"></script>\
366 </head><body></body></html>";
367 let s = site_with(&[("index.html", html)]);
368 let f = CspSriGate.run(&s, &AuditOptions::default());
369 assert!(
370 f.iter().all(|x| x.code.as_deref() != Some("CSP-MISSING")),
371 "unquoted http-equiv must count: {f:?}"
372 );
373 }
374
375 #[test]
376 fn other_http_equiv_meta_does_not_count_as_csp() {
377 let html = "<html><head>\
380 <meta http-equiv=X-UA-Compatible content=\"IE=edge\">\
381 </head><body></body></html>";
382 let s = site_with(&[("index.html", html)]);
383 let f = CspSriGate.run(&s, &AuditOptions::default());
384 assert!(
385 f.iter().any(|x| x.code.as_deref() == Some("CSP-MISSING")),
386 "non-CSP http-equiv must still flag: {f:?}"
387 );
388 }
389
390 #[test]
391 fn minified_unquoted_stylesheet_rel_needs_integrity() {
392 let html = "<html><head>\
393 <meta http-equiv=Content-Security-Policy content=\"default-src 'self'\">\
394 <link href=https://cdn.example/s.css rel=stylesheet>\
395 </head><body></body></html>";
396 let s = site_with(&[("index.html", html)]);
397 let f = CspSriGate.run(&s, &AuditOptions::default());
398 assert!(
399 f.iter().any(|x| x.code.as_deref() == Some("SRI-MISSING")),
400 "unquoted rel=stylesheet must be seen: {f:?}"
401 );
402 }
403
404 #[test]
405 fn metadata_methods_exposed() {
406 let g = CspSriGate;
407 assert_eq!(g.name(), "csp_sri");
408 assert!(g.explain().contains("Content-Security-Policy"));
409 let _copy: CspSriGate = g;
410 let _clone = g;
411 let dbg = format!("{g:?}");
412 assert!(dbg.contains("CspSriGate"));
413 }
414
415 #[test]
416 fn empty_site_returns_no_findings() {
417 let s = site_with(&[]);
418 let f = CspSriGate.run(&s, &AuditOptions::default());
419 assert!(f.is_empty());
420 }
421
422 #[test]
423 fn meta_csp_without_content_attr_yields_none() {
424 let html = r#"<meta http-equiv="Content-Security-Policy">"#;
427 assert_eq!(extract_meta_csp(html), None);
428 }
429
430 #[test]
431 fn stylesheet_link_without_href_is_skipped() {
432 let html = r#"<link rel="stylesheet"><link rel="stylesheet" href="https://cdn.example/a.css">"#;
433 let assets = extract_remote_assets(html);
434 assert_eq!(assets.len(), 1, "href-less link must be skipped");
435 assert_eq!(assets[0].href, "https://cdn.example/a.css");
436 }
437
438 #[test]
439 fn unreadable_html_skipped() {
440 let tmp = tempfile::tempdir().unwrap();
441 let root = tmp.path().to_path_buf();
442 let dir_as_file = root.join("page.html");
443 std::fs::create_dir_all(&dir_as_file).unwrap();
444 let s = Site {
445 root,
446 html_files: vec![dir_as_file],
447 };
448 let _ = CspSriGate.run(&s, &AuditOptions::default());
449 std::mem::forget(tmp);
450 }
451}