use crate::config::Config;
use crate::filter::Filter;
use crate::glob::glob_match;
use crate::model::{Capture, Entry};
use ahash::AHashMap;
use serde::Serialize;
#[derive(Debug, Serialize)]
pub struct ChecksResult {
pub findings: Vec<CheckFinding>,
}
#[derive(Debug, Serialize)]
pub struct CheckFinding {
pub rule: String,
pub host: String,
pub norm_path: String,
pub detail: String,
pub entry_ids: Vec<String>,
}
fn req_content_type(e: &Entry) -> Option<String> {
e.req_headers
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case("content-type"))
.map(|(_, v)| v.to_ascii_lowercase())
}
fn looks_like_json(body: &str) -> bool {
let t = body.trim_start();
t.starts_with('{') || t.starts_with('[')
}
fn content_type_issues(e: &Entry) -> Vec<String> {
let mut v = Vec::new();
let req_ct = req_content_type(e).unwrap_or_default();
if let Some(b) = e.req_body.as_deref().filter(|b| !b.is_empty())
&& looks_like_json(b)
&& !req_ct.contains("json")
{
v.push("request JSON body without application/json content-type".to_string());
}
let resp_ct = e
.content_type
.clone()
.unwrap_or_default()
.to_ascii_lowercase();
match e.resp_body.as_deref().filter(|b| !b.is_empty()) {
Some(b) => {
if looks_like_json(b) && resp_ct.contains("html") {
v.push("JSON response served as text/html".to_string());
}
}
None => {
if resp_ct.contains("json") && e.status == 200 {
v.push("empty body with JSON content-type".to_string());
}
}
}
v
}
pub fn compute_checks(cap: &Capture, filter: &Filter, config: &Config, top: usize) -> ChecksResult {
let mut map: AHashMap<(String, String, String, String), Vec<String>> = AHashMap::new();
for e in cap.entries.iter().filter(|e| filter.matches(e)) {
for rule in &config.required_headers {
if glob_match(&rule.host, &e.host) {
for h in &rule.headers {
let present = e.req_headers.iter().any(|(n, _)| n.eq_ignore_ascii_case(h));
if !present {
let key = (
"missing-header".to_string(),
e.host.clone(),
e.norm_path.clone(),
format!("missing required header: {h}"),
);
map.entry(key).or_default().push(e.id.clone());
}
}
}
}
for detail in content_type_issues(e) {
let key = (
"content-type".to_string(),
e.host.clone(),
e.norm_path.clone(),
detail,
);
map.entry(key).or_default().push(e.id.clone());
}
}
let mut findings: Vec<CheckFinding> = map
.into_iter()
.map(
|((rule, host, norm_path, detail), entry_ids)| CheckFinding {
rule,
host,
norm_path,
detail,
entry_ids,
},
)
.collect();
findings.sort_by(|a, b| {
b.entry_ids
.len()
.cmp(&a.entry_ids.len())
.then(a.rule.cmp(&b.rule))
.then(a.host.cmp(&b.host))
.then(a.detail.cmp(&b.detail))
});
findings.truncate(top);
ChecksResult { findings }
}
pub fn render_checks_text(r: &ChecksResult) -> String {
let mut out = String::new();
out.push_str("== wiretrail checks ==\n");
for f in &r.findings {
out.push_str(&format!(
"\n[{}] {} {}\n {} ({} entries)\n",
f.rule,
f.host,
f.norm_path,
f.detail,
f.entry_ids.len()
));
}
out
}
#[cfg(test)]
mod tests {
use super::compute_checks;
use crate::config::Config;
use crate::filter::Filter;
use crate::model::{sample_capture, sample_entry};
fn cfg_required(host: &str, headers: &[&str]) -> Config {
let yaml = format!(
"required_headers:\n - host: \"{host}\"\n headers: [{}]\n",
headers
.iter()
.map(|h| format!("\"{h}\""))
.collect::<Vec<_>>()
.join(", ")
);
Config::from_yaml_str(&yaml).unwrap()
}
#[test]
fn flags_missing_required_header() {
let e = sample_entry(0, "api.x", "GET", "/data", 200); let cap = sample_capture(vec![e]);
let cfg = cfg_required("api.x", &["Authorization"]);
let r = compute_checks(&cap, &Filter::parse(&[]).unwrap(), &cfg, 50);
assert!(r.findings.iter().any(|f| f.rule == "missing-header"
&& f.detail.contains("Authorization")
&& f.entry_ids.contains(&"e000000".to_string())));
}
#[test]
fn present_header_not_flagged() {
let mut e = sample_entry(0, "api.x", "GET", "/data", 200);
e.req_headers = vec![("Authorization".into(), "Bearer x".into())];
let cfg = cfg_required("api.x", &["Authorization"]);
let r = compute_checks(
&sample_capture(vec![e]),
&Filter::parse(&[]).unwrap(),
&cfg,
50,
);
assert!(r.findings.iter().all(|f| f.rule != "missing-header"));
}
#[test]
fn flags_json_body_without_json_content_type() {
let mut e = sample_entry(0, "api.x", "POST", "/data", 200);
e.req_headers = vec![("Content-Type".into(), "text/plain".into())];
e.req_body = Some(r#"{"a":1}"#.to_string());
let r = compute_checks(
&sample_capture(vec![e]),
&Filter::parse(&[]).unwrap(),
&Config::default(),
50,
);
assert!(
r.findings
.iter()
.any(|f| f.rule == "content-type" && f.detail.contains("JSON body"))
);
}
}