Skip to main content

har/analysis/
checks.rs

1use crate::config::Config;
2use crate::filter::Filter;
3use crate::glob::glob_match;
4use crate::model::{Capture, Entry};
5use ahash::AHashMap;
6use serde::Serialize;
7
8#[derive(Debug, Serialize)]
9pub struct ChecksResult {
10    pub findings: Vec<CheckFinding>,
11}
12
13#[derive(Debug, Serialize)]
14pub struct CheckFinding {
15    pub rule: String,
16    pub host: String,
17    pub norm_path: String,
18    pub detail: String,
19    pub entry_ids: Vec<String>,
20}
21
22fn req_content_type(e: &Entry) -> Option<String> {
23    e.req_headers
24        .iter()
25        .find(|(n, _)| n.eq_ignore_ascii_case("content-type"))
26        .map(|(_, v)| v.to_ascii_lowercase())
27}
28
29fn looks_like_json(body: &str) -> bool {
30    let t = body.trim_start();
31    t.starts_with('{') || t.starts_with('[')
32}
33
34fn content_type_issues(e: &Entry) -> Vec<String> {
35    let mut v = Vec::new();
36    let req_ct = req_content_type(e).unwrap_or_default();
37    if let Some(b) = e.req_body.as_deref().filter(|b| !b.is_empty())
38        && looks_like_json(b)
39        && !req_ct.contains("json")
40    {
41        v.push("request JSON body without application/json content-type".to_string());
42    }
43    let resp_ct = e
44        .content_type
45        .clone()
46        .unwrap_or_default()
47        .to_ascii_lowercase();
48    match e.resp_body.as_deref().filter(|b| !b.is_empty()) {
49        Some(b) => {
50            if looks_like_json(b) && resp_ct.contains("html") {
51                v.push("JSON response served as text/html".to_string());
52            }
53        }
54        None => {
55            if resp_ct.contains("json") && e.status == 200 {
56                v.push("empty body with JSON content-type".to_string());
57            }
58        }
59    }
60    v
61}
62
63/// Run built-in checks: missing required headers (config) + content-type mismatch.
64pub fn compute_checks(cap: &Capture, filter: &Filter, config: &Config, top: usize) -> ChecksResult {
65    // key = (rule, host, norm_path, detail) -> entry ids
66    let mut map: AHashMap<(String, String, String, String), Vec<String>> = AHashMap::new();
67
68    for e in cap.entries.iter().filter(|e| filter.matches(e)) {
69        // missing required headers
70        for rule in &config.required_headers {
71            if glob_match(&rule.host, &e.host) {
72                for h in &rule.headers {
73                    let present = e.req_headers.iter().any(|(n, _)| n.eq_ignore_ascii_case(h));
74                    if !present {
75                        let key = (
76                            "missing-header".to_string(),
77                            e.host.clone(),
78                            e.norm_path.clone(),
79                            format!("missing required header: {h}"),
80                        );
81                        map.entry(key).or_default().push(e.id.clone());
82                    }
83                }
84            }
85        }
86        // content-type mismatches
87        for detail in content_type_issues(e) {
88            let key = (
89                "content-type".to_string(),
90                e.host.clone(),
91                e.norm_path.clone(),
92                detail,
93            );
94            map.entry(key).or_default().push(e.id.clone());
95        }
96    }
97
98    let mut findings: Vec<CheckFinding> = map
99        .into_iter()
100        .map(
101            |((rule, host, norm_path, detail), entry_ids)| CheckFinding {
102                rule,
103                host,
104                norm_path,
105                detail,
106                entry_ids,
107            },
108        )
109        .collect();
110    findings.sort_by(|a, b| {
111        b.entry_ids
112            .len()
113            .cmp(&a.entry_ids.len())
114            .then(a.rule.cmp(&b.rule))
115            .then(a.host.cmp(&b.host))
116            .then(a.detail.cmp(&b.detail))
117    });
118    findings.truncate(top);
119    ChecksResult { findings }
120}
121
122/// Render checks findings as deterministic terminal text.
123pub fn render_checks_text(r: &ChecksResult) -> String {
124    let mut out = String::new();
125    out.push_str("== wiretrail checks ==\n");
126    for f in &r.findings {
127        out.push_str(&format!(
128            "\n[{}] {} {}\n  {} ({} entries)\n",
129            f.rule,
130            f.host,
131            f.norm_path,
132            f.detail,
133            f.entry_ids.len()
134        ));
135    }
136    out
137}
138
139#[cfg(test)]
140mod tests {
141    use super::compute_checks;
142    use crate::config::Config;
143    use crate::filter::Filter;
144    use crate::model::{sample_capture, sample_entry};
145
146    fn cfg_required(host: &str, headers: &[&str]) -> Config {
147        let yaml = format!(
148            "required_headers:\n  - host: \"{host}\"\n    headers: [{}]\n",
149            headers
150                .iter()
151                .map(|h| format!("\"{h}\""))
152                .collect::<Vec<_>>()
153                .join(", ")
154        );
155        Config::from_yaml_str(&yaml).unwrap()
156    }
157
158    #[test]
159    fn flags_missing_required_header() {
160        let e = sample_entry(0, "api.x", "GET", "/data", 200); // no Authorization
161        let cap = sample_capture(vec![e]);
162        let cfg = cfg_required("api.x", &["Authorization"]);
163        let r = compute_checks(&cap, &Filter::parse(&[]).unwrap(), &cfg, 50);
164        assert!(r.findings.iter().any(|f| f.rule == "missing-header"
165            && f.detail.contains("Authorization")
166            && f.entry_ids.contains(&"e000000".to_string())));
167    }
168
169    #[test]
170    fn present_header_not_flagged() {
171        let mut e = sample_entry(0, "api.x", "GET", "/data", 200);
172        e.req_headers = vec![("Authorization".into(), "Bearer x".into())];
173        let cfg = cfg_required("api.x", &["Authorization"]);
174        let r = compute_checks(
175            &sample_capture(vec![e]),
176            &Filter::parse(&[]).unwrap(),
177            &cfg,
178            50,
179        );
180        assert!(r.findings.iter().all(|f| f.rule != "missing-header"));
181    }
182
183    #[test]
184    fn flags_json_body_without_json_content_type() {
185        let mut e = sample_entry(0, "api.x", "POST", "/data", 200);
186        e.req_headers = vec![("Content-Type".into(), "text/plain".into())];
187        e.req_body = Some(r#"{"a":1}"#.to_string());
188        let r = compute_checks(
189            &sample_capture(vec![e]),
190            &Filter::parse(&[]).unwrap(),
191            &Config::default(),
192            50,
193        );
194        assert!(
195            r.findings
196                .iter()
197                .any(|f| f.rule == "content-type" && f.detail.contains("JSON body"))
198        );
199    }
200}