1use std::{collections::HashMap, fs::File, path::Path};
2
3use crate::{
4 analysis_result::AnalysisResult, analyze::Analyze, analyze_cookies::AnalyzeCookies,
5 analyze_cors::AnalyzeCORS, analyze_csp::AnalyzeCSP, analyze_hsts::AnalyzeHSTS,
6 analyze_permissions_policy::AnalyzePermissionsPolicy,
7 analyze_referrer_policy::AnalyzeReferrerPolicy,
8 analyze_x_content_type_options::AnalyzeXContentTypeOptions,
9 analyze_x_frame_options::AnalyzeXFrameOptions, scoring::ScanScore, severity::Severity,
10};
11
12pub struct ScanReport {
14 pub results: Vec<AnalysisResult>,
17 pub score: ScanScore,
18}
19
20pub struct HarScanner;
22
23impl HarScanner {
24 pub fn scan_file<P: AsRef<Path>>(path: P) -> Result<ScanReport, Box<dyn std::error::Error>> {
31 let file = File::open(path)?;
32 let har = har::from_reader(file)?;
33
34 let mut all_results: Vec<AnalysisResult> = Vec::new();
35
36 match har.log {
37 har::Spec::V1_2(log) => {
38 for entry in &log.entries {
39 let headers: Vec<(&str, &str)> = entry
40 .response
41 .headers
42 .iter()
43 .map(|h| (h.name.as_str(), h.value.as_str()))
44 .collect();
45 all_results.extend(analyze_response(&headers));
46 }
47 }
48 har::Spec::V1_3(log) => {
49 for entry in &log.entries {
50 let headers: Vec<(&str, &str)> = entry
51 .response
52 .headers
53 .iter()
54 .map(|h| (h.name.as_str(), h.value.as_str()))
55 .collect();
56 all_results.extend(analyze_response(&headers));
57 }
58 }
59 }
60
61 let results = deduplicate_by_worst_severity(all_results);
62 let score = ScanScore::calculate(&results);
63 Ok(ScanReport { results, score })
64 }
65}
66
67fn analyze_response(headers: &[(&str, &str)]) -> Vec<AnalysisResult> {
69 let get = |name: &str| -> Option<&str> {
70 headers
71 .iter()
72 .find(|(n, _)| n.to_lowercase() == name)
73 .map(|(_, v)| *v)
74 };
75
76 let get_all = |name: &str| -> Vec<&str> {
77 headers
78 .iter()
79 .filter(|(n, _)| n.to_lowercase() == name)
80 .map(|(_, v)| *v)
81 .collect()
82 };
83
84 let csp_value = get("content-security-policy");
85 let mut results = Vec::new();
86
87 if csp_value.is_none() {
89 results.push(
90 AnalysisResult::new(
91 Severity::Fail,
92 "Content-Security-Policy header",
93 "No CSP header found in response. Security policy is not enforced.",
94 )
95 .with_score(-25),
96 );
97 } else {
98 results.extend(AnalyzeCSP::new(csp_value).analyze());
99 }
100
101 results.extend(AnalyzeHSTS::new(get("strict-transport-security")).analyze());
102 results.extend(AnalyzePermissionsPolicy::new(get("permissions-policy")).analyze());
103 results.extend(AnalyzeXFrameOptions::new(get("x-frame-options")).analyze());
104 results.extend(AnalyzeXContentTypeOptions::new(get("x-content-type-options")).analyze());
105 results.extend(AnalyzeReferrerPolicy::new(get("referrer-policy")).analyze());
106 results.extend(
107 AnalyzeCORS::new(
108 get("access-control-allow-origin"),
109 get("access-control-allow-credentials"),
110 )
111 .analyze(),
112 );
113 results.extend(AnalyzeCookies::new(get_all("set-cookie")).analyze());
114
115 results
116}
117
118fn deduplicate_by_worst_severity(results: Vec<AnalysisResult>) -> Vec<AnalysisResult> {
121 let mut map: HashMap<String, AnalysisResult> = HashMap::new();
122
123 for result in results {
124 let entry = map
125 .entry(result.name.clone())
126 .or_insert_with(|| result.clone());
127 if severity_rank(&result.severity) > severity_rank(&entry.severity) {
128 *entry = result;
129 }
130 }
131
132 let mut deduplicated: Vec<AnalysisResult> = map.into_values().collect();
133 deduplicated.sort_by(|a, b| {
134 severity_rank(&b.severity)
135 .cmp(&severity_rank(&a.severity))
136 .then(a.name.cmp(&b.name))
137 });
138 deduplicated
139}
140
141fn severity_rank(severity: &Severity) -> u8 {
142 match severity {
143 Severity::Ok => 0,
144 Severity::Warning => 1,
145 Severity::Fail => 2,
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152 use std::io::Write;
153 use tempfile::NamedTempFile;
154
155 fn make_har(response_headers: &str) -> NamedTempFile {
156 let mut file = NamedTempFile::new().expect("failed to create temp file");
157 let content = format!(
158 r#"{{
159 "log": {{
160 "version": "1.2",
161 "creator": {{ "name": "test", "version": "1.0" }},
162 "entries": [
163 {{
164 "startedDateTime": "2024-01-01T00:00:00.000Z",
165 "time": 100.0,
166 "request": {{
167 "method": "GET",
168 "url": "https://example.com/",
169 "httpVersion": "HTTP/1.1",
170 "cookies": [],
171 "headers": [],
172 "queryString": [],
173 "headersSize": -1,
174 "bodySize": -1
175 }},
176 "response": {{
177 "status": 200,
178 "statusText": "OK",
179 "httpVersion": "HTTP/1.1",
180 "cookies": [],
181 "headers": [{response_headers}],
182 "content": {{ "size": 0, "mimeType": "text/html" }},
183 "redirectURL": "",
184 "headersSize": -1,
185 "bodySize": -1
186 }},
187 "cache": {{}},
188 "timings": {{ "send": 0.0, "wait": 100.0, "receive": 0.0 }}
189 }}
190 ]
191 }}
192}}"#
193 );
194 file.write_all(content.as_bytes())
195 .expect("failed to write temp file");
196 file
197 }
198
199 fn make_empty_har() -> NamedTempFile {
200 let mut file = NamedTempFile::new().expect("failed to create temp file");
201 let content = r#"{
202 "log": {
203 "version": "1.2",
204 "creator": { "name": "test", "version": "1.0" },
205 "entries": []
206 }
207}"#;
208 file.write_all(content.as_bytes())
209 .expect("failed to write temp file");
210 file
211 }
212
213 fn find<'a>(results: &'a [AnalysisResult], name: &str) -> Option<&'a AnalysisResult> {
214 results.iter().find(|r| r.name == name)
215 }
216
217 #[test]
218 fn scan_file_errors_on_missing_file() {
219 let result = HarScanner::scan_file("/nonexistent/path/file.har");
220 assert!(result.is_err());
221 }
222
223 #[test]
224 fn empty_har_returns_no_results_and_perfect_score() {
225 let tmp = make_empty_har();
226 let report = HarScanner::scan_file(tmp.path()).unwrap();
227 assert!(report.results.is_empty());
228 assert_eq!(report.score.score, 100);
229 }
230
231 #[test]
232 fn missing_csp_returns_fail_with_score_penalty() {
233 let tmp = make_har("");
234 let report = HarScanner::scan_file(tmp.path()).unwrap();
235 let r = find(&report.results, "Content-Security-Policy header").unwrap();
236 assert_eq!(r.severity, Severity::Fail);
237 assert_eq!(r.score_impact, -25);
238 }
239
240 #[test]
241 fn frame_ancestors_none_returns_ok() {
242 let tmp = make_har(
243 r#"{ "name": "content-security-policy", "value": "default-src 'self'; frame-ancestors 'none'" }"#,
244 );
245 let report = HarScanner::scan_file(tmp.path()).unwrap();
246 let r = find(
247 &report.results,
248 "Click-jacking protection, using frame-ancestors",
249 )
250 .unwrap();
251 assert_eq!(r.severity, Severity::Ok);
252 }
253
254 #[test]
255 fn frame_ancestors_self_returns_warning() {
256 let tmp =
257 make_har(r#"{ "name": "content-security-policy", "value": "frame-ancestors 'self'" }"#);
258 let report = HarScanner::scan_file(tmp.path()).unwrap();
259 let r = find(
260 &report.results,
261 "Click-jacking protection, using frame-ancestors",
262 )
263 .unwrap();
264 assert_eq!(r.severity, Severity::Warning);
265 }
266
267 #[test]
268 fn frame_ancestors_wildcard_returns_fail() {
269 let tmp =
270 make_har(r#"{ "name": "content-security-policy", "value": "frame-ancestors *" }"#);
271 let report = HarScanner::scan_file(tmp.path()).unwrap();
272 let r = find(
273 &report.results,
274 "Click-jacking protection, using frame-ancestors",
275 )
276 .unwrap();
277 assert_eq!(r.severity, Severity::Fail);
278 }
279
280 #[test]
281 fn missing_hsts_returns_fail() {
282 let tmp = make_har("");
283 let report = HarScanner::scan_file(tmp.path()).unwrap();
284 let r = find(&report.results, "HTTP Strict Transport Security (HSTS)").unwrap();
285 assert_eq!(r.severity, Severity::Fail);
286 assert_eq!(r.score_impact, -20);
287 }
288
289 #[test]
290 fn present_hsts_returns_ok() {
291 let tmp = make_har(
292 r#"{ "name": "strict-transport-security", "value": "max-age=63072000; includeSubDomains; preload" }"#,
293 );
294 let report = HarScanner::scan_file(tmp.path()).unwrap();
295 let r = find(&report.results, "HTTP Strict Transport Security (HSTS)").unwrap();
296 assert_eq!(r.severity, Severity::Ok);
297 assert_eq!(r.score_impact, 5);
298 }
299
300 #[test]
301 fn missing_permissions_policy_returns_warning() {
302 let tmp = make_har("");
303 let report = HarScanner::scan_file(tmp.path()).unwrap();
304 let r = find(&report.results, "Permissions Policy").unwrap();
305 assert_eq!(r.severity, Severity::Warning);
306 assert_eq!(r.score_impact, -5);
307 }
308
309 #[test]
310 fn restrictive_permissions_policy_returns_ok() {
311 let tmp = make_har(
312 r#"{ "name": "permissions-policy", "value": "camera=(), microphone=(), geolocation=(), payment=(), usb=()" }"#,
313 );
314 let report = HarScanner::scan_file(tmp.path()).unwrap();
315 let r = find(&report.results, "Permissions Policy").unwrap();
316 assert_eq!(r.severity, Severity::Ok);
317 assert_eq!(r.score_impact, 5);
318 }
319
320 #[test]
321 fn wildcard_permissions_policy_returns_fail() {
322 let tmp = make_har(r#"{ "name": "permissions-policy", "value": "camera=*" }"#);
323 let report = HarScanner::scan_file(tmp.path()).unwrap();
324 let r = find(&report.results, "Permissions Policy").unwrap();
325 assert_eq!(r.severity, Severity::Fail);
326 assert_eq!(r.score_impact, -10);
327 }
328
329 #[test]
330 fn missing_x_frame_options_returns_fail() {
331 let tmp = make_har("");
332 let report = HarScanner::scan_file(tmp.path()).unwrap();
333 let r = find(
334 &report.results,
335 "Click-jacking protection, using X-Frame-Options",
336 )
337 .unwrap();
338 assert_eq!(r.severity, Severity::Fail);
339 }
340
341 #[test]
342 fn missing_x_content_type_options_returns_fail() {
343 let tmp = make_har("");
344 let report = HarScanner::scan_file(tmp.path()).unwrap();
345 let r = find(&report.results, "MIME sniffing prevention").unwrap();
346 assert_eq!(r.severity, Severity::Fail);
347 }
348
349 #[test]
350 fn cors_wildcard_is_reported() {
351 let tmp = make_har(r#"{ "name": "access-control-allow-origin", "value": "*" }"#);
352 let report = HarScanner::scan_file(tmp.path()).unwrap();
353 let r = find(&report.results, "Cross-Origin Resource Sharing (CORS)").unwrap();
354 assert_eq!(r.severity, Severity::Warning);
355 }
356
357 #[test]
358 fn deduplicate_keeps_worst_severity_across_entries() {
359 let mut file = NamedTempFile::new().unwrap();
361 let content = r#"{
362 "log": {
363 "version": "1.2",
364 "creator": { "name": "test", "version": "1.0" },
365 "entries": [
366 {
367 "startedDateTime": "2024-01-01T00:00:00.000Z",
368 "time": 10.0,
369 "request": { "method": "GET", "url": "https://example.com/a", "httpVersion": "HTTP/1.1", "cookies": [], "headers": [], "queryString": [], "headersSize": -1, "bodySize": -1 },
370 "response": { "status": 200, "statusText": "OK", "httpVersion": "HTTP/1.1", "cookies": [], "headers": [{ "name": "strict-transport-security", "value": "max-age=63072000" }], "content": { "size": 0, "mimeType": "text/html" }, "redirectURL": "", "headersSize": -1, "bodySize": -1 },
371 "cache": {}, "timings": { "send": 0.0, "wait": 10.0, "receive": 0.0 }
372 },
373 {
374 "startedDateTime": "2024-01-01T00:00:00.100Z",
375 "time": 10.0,
376 "request": { "method": "GET", "url": "https://example.com/b", "httpVersion": "HTTP/1.1", "cookies": [], "headers": [], "queryString": [], "headersSize": -1, "bodySize": -1 },
377 "response": { "status": 200, "statusText": "OK", "httpVersion": "HTTP/1.1", "cookies": [], "headers": [], "content": { "size": 0, "mimeType": "text/html" }, "redirectURL": "", "headersSize": -1, "bodySize": -1 },
378 "cache": {}, "timings": { "send": 0.0, "wait": 10.0, "receive": 0.0 }
379 }
380 ]
381 }
382}"#;
383 file.write_all(content.as_bytes()).unwrap();
384 let report = HarScanner::scan_file(file.path()).unwrap();
385 let r = find(&report.results, "HTTP Strict Transport Security (HSTS)").unwrap();
387 assert_eq!(r.severity, Severity::Fail);
388 }
389
390 #[test]
391 fn results_sorted_fails_first() {
392 let tmp = make_har("");
393 let report = HarScanner::scan_file(tmp.path()).unwrap();
394 assert_eq!(report.results[0].severity, Severity::Fail);
396 }
397
398 #[test]
399 fn score_is_penalised_for_missing_headers() {
400 let tmp = make_har("");
401 let report = HarScanner::scan_file(tmp.path()).unwrap();
402 assert!(report.score.score < 50);
404 }
405}