Skip to main content

harbor_core/
har_scanner.rs

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
12/// The complete output of a HAR file scan.
13pub struct ScanReport {
14    /// De-duplicated results, one per check — the worst severity observed
15    /// across all entries wins.
16    pub results: Vec<AnalysisResult>,
17    pub score: ScanScore,
18}
19
20/// Scans HAR (HTTP Archive) files for security issues.
21pub struct HarScanner;
22
23impl HarScanner {
24    /// Scan a HAR file and return a `ScanReport` containing deduplicated
25    /// results and the computed score.
26    ///
27    /// When a HAR file contains multiple entries the worst-case result for
28    /// each named check is kept, giving a conservative picture of the
29    /// overall security posture.
30    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
67/// Runs all analyzers against a single response's headers.
68fn 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    // CSP: report missing header as a scored failure, then run directive checks
88    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
118/// Keeps the result with the highest (worst) severity for each check name.
119/// Results are sorted by severity (Fail → Warning → Ok) for display.
120fn 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        // Build a two-entry HAR: first entry has good HSTS, second is missing it
360        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        // The missing-HSTS entry should win (Fail beats Ok)
386        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        // At least the first result should be a Fail when everything is missing
395        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        // Many headers missing — score must be well below 100
403        assert!(report.score.score < 50);
404    }
405}