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
63pub fn compute_checks(cap: &Capture, filter: &Filter, config: &Config, top: usize) -> ChecksResult {
65 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 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 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
122pub 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); 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}