Skip to main content

har/analysis/
rules.rs

1use crate::config::{Config, Rule};
2use crate::filter::Filter;
3use crate::glob::glob_match;
4use crate::model::{Capture, Entry};
5use crate::opaque::is_opaque;
6use ahash::AHashMap;
7use serde::Serialize;
8
9#[derive(Debug, Serialize)]
10pub struct RulesResult {
11    pub findings: Vec<RuleFinding>,
12}
13
14#[derive(Debug, Serialize)]
15pub struct RuleFinding {
16    pub rule: String,
17    pub severity: String,
18    pub detail: String,
19    pub entry_ids: Vec<String>,
20}
21
22fn sev_rank(s: &str) -> u8 {
23    match s {
24        "critical" => 3,
25        "high" => 2,
26        "medium" => 1,
27        _ => 0,
28    }
29}
30
31fn matcher_opt(pat: &Option<String>, text: &str) -> bool {
32    match pat {
33        Some(p) => glob_match(p, text),
34        None => true,
35    }
36}
37
38fn rule_matches(rule: &Rule, e: &Entry) -> bool {
39    matcher_opt(&rule.host, &e.host)
40        && matcher_opt(&rule.path, &e.path)
41        && matcher_opt(&rule.method, &e.method)
42        && matcher_opt(&rule.status, &e.status.to_string())
43}
44
45fn has_header(e: &Entry, name: &str) -> bool {
46    e.req_headers
47        .iter()
48        .any(|(n, _)| n.eq_ignore_ascii_case(name))
49}
50
51/// Evaluate one generic rule against an entry: `(rule_name, severity, detail)` tuples.
52fn eval_rule(rule: &Rule, e: &Entry) -> Vec<(String, String, String)> {
53    let mut out = Vec::new();
54    if !rule_matches(rule, e) {
55        return out;
56    }
57    if rule.forbid {
58        out.push((
59            rule.name.clone(),
60            "high".into(),
61            "matched a forbidden rule".into(),
62        ));
63        return out;
64    }
65    for h in &rule.require_headers {
66        if !has_header(e, h) {
67            out.push((
68                rule.name.clone(),
69                "high".into(),
70                format!("missing required header: {h}"),
71            ));
72        }
73    }
74    if let Some(budget) = rule.max_latency_ms
75        && e.duration_ms > budget
76    {
77        out.push((
78            rule.name.clone(),
79            "medium".into(),
80            format!(
81                "latency {:.0}ms exceeds budget {budget:.0}ms",
82                e.duration_ms
83            ),
84        ));
85    }
86    out
87}
88
89/// Built-in rule packs expressible with generic `Rule` fields.
90fn pack_rules(pack: &str) -> Vec<Rule> {
91    match pack {
92        "auth" => vec![Rule {
93            name: "auth: Authorization required".into(),
94            require_headers: vec!["Authorization".into()],
95            ..Rule::default()
96        }],
97        "caching" => vec![Rule {
98            name: "caching: GET 200 needs Cache-Control".into(),
99            method: Some("GET".into()),
100            status: Some("200".into()),
101            require_headers: vec!["Cache-Control".into()],
102            ..Rule::default()
103        }],
104        "payments" => vec![
105            Rule {
106                name: "payments: idempotency key on charges".into(),
107                path: Some("*charge*".into()),
108                require_headers: vec!["Idempotency-Key".into()],
109                ..Rule::default()
110            },
111            Rule {
112                name: "payments: idempotency key on payments".into(),
113                path: Some("*payment*".into()),
114                require_headers: vec!["Idempotency-Key".into()],
115                ..Rule::default()
116            },
117        ],
118        _ => vec![],
119    }
120}
121
122fn is_special_pack(pack: &str) -> bool {
123    matches!(pack, "security" | "rest" | "graphql")
124}
125
126/// Packs that need a custom predicate (not expressible via `Rule` fields).
127fn eval_special(pack: &str, e: &Entry) -> Vec<(String, String, String)> {
128    let mut out = Vec::new();
129    match pack {
130        "security" => {
131            for (k, v) in &e.query {
132                if is_opaque(v) {
133                    out.push((
134                        "security: no secrets in query".into(),
135                        "high".into(),
136                        format!("opaque secret in query param `{k}`"),
137                    ));
138                }
139            }
140        }
141        "rest"
142            if e.method.eq_ignore_ascii_case("GET")
143                && e.req_body.as_deref().is_some_and(|b| !b.is_empty()) =>
144        {
145            out.push((
146                "rest: no mutation over GET".into(),
147                "medium".into(),
148                "GET request carries a body".into(),
149            ));
150        }
151        "graphql"
152            if e.method.eq_ignore_ascii_case("POST")
153                && glob_match("*/graphql", &e.path)
154                && !e
155                    .req_body
156                    .as_deref()
157                    .unwrap_or("")
158                    .contains("operationName") =>
159        {
160            out.push((
161                "graphql: operationName required".into(),
162                "low".into(),
163                "GraphQL POST without operationName".into(),
164            ));
165        }
166        _ => {}
167    }
168    out
169}
170
171/// Evaluate config rules + built-in packs against the filtered capture.
172pub fn compute_rules(
173    cap: &Capture,
174    filter: &Filter,
175    config: &Config,
176    packs: &[String],
177    top: usize,
178) -> RulesResult {
179    let mut rules: Vec<Rule> = config.rules.clone();
180    for p in packs {
181        rules.extend(pack_rules(p));
182    }
183
184    // key = (rule, severity, detail) -> entry ids
185    let mut map: AHashMap<(String, String, String), Vec<String>> = AHashMap::new();
186    for e in cap.entries.iter().filter(|e| filter.matches(e)) {
187        for rule in &rules {
188            for (name, sev, detail) in eval_rule(rule, e) {
189                map.entry((name, sev, detail))
190                    .or_default()
191                    .push(e.id.clone());
192            }
193        }
194        for p in packs {
195            if is_special_pack(p) {
196                for (name, sev, detail) in eval_special(p, e) {
197                    map.entry((name, sev, detail))
198                        .or_default()
199                        .push(e.id.clone());
200                }
201            }
202        }
203    }
204
205    let mut findings: Vec<RuleFinding> = map
206        .into_iter()
207        .map(|((rule, severity, detail), entry_ids)| RuleFinding {
208            rule,
209            severity,
210            detail,
211            entry_ids,
212        })
213        .collect();
214    findings.sort_by(|a, b| {
215        sev_rank(&b.severity)
216            .cmp(&sev_rank(&a.severity))
217            .then(b.entry_ids.len().cmp(&a.entry_ids.len()))
218            .then(a.rule.cmp(&b.rule))
219            .then(a.detail.cmp(&b.detail))
220    });
221    findings.truncate(top);
222    RulesResult { findings }
223}
224
225/// Render rule findings as deterministic terminal text.
226pub fn render_rules_text(r: &RulesResult) -> String {
227    let mut out = String::new();
228    out.push_str("== wiretrail rules ==\n");
229    for f in &r.findings {
230        out.push_str(&format!(
231            "\n[{}] {}\n  {} ({} entries)\n",
232            f.severity,
233            f.rule,
234            f.detail,
235            f.entry_ids.len()
236        ));
237    }
238    out
239}
240
241#[cfg(test)]
242mod tests {
243    use super::compute_rules;
244    use crate::config::Config;
245    use crate::filter::Filter;
246    use crate::model::{Entry, sample_capture, sample_entry};
247
248    fn no_filter() -> Filter {
249        Filter::parse(&[]).unwrap()
250    }
251
252    #[test]
253    fn config_rule_require_header_fires() {
254        let cfg = Config::from_yaml_str(
255            "rules:\n  - name: needs-auth\n    host: \"api.x\"\n    require_headers: [\"Authorization\"]\n",
256        )
257        .unwrap();
258        let cap = sample_capture(vec![sample_entry(0, "api.x", "GET", "/a", 200)]);
259        let r = compute_rules(&cap, &no_filter(), &cfg, &[], 50);
260        assert!(r.findings.iter().any(|f| f.rule == "needs-auth"
261            && f.severity == "high"
262            && f.detail.contains("Authorization")));
263    }
264
265    #[test]
266    fn config_rule_max_latency_fires() {
267        let cfg = Config::from_yaml_str(
268            "rules:\n  - name: too-slow\n    host: \"api.x\"\n    max_latency_ms: 5\n",
269        )
270        .unwrap();
271        // sample_entry sets duration_ms = 10.0 > 5
272        let cap = sample_capture(vec![sample_entry(0, "api.x", "GET", "/a", 200)]);
273        let r = compute_rules(&cap, &no_filter(), &cfg, &[], 50);
274        assert!(
275            r.findings
276                .iter()
277                .any(|f| f.rule == "too-slow" && f.severity == "medium")
278        );
279    }
280
281    #[test]
282    fn config_rule_forbid_fires() {
283        let cfg = Config::from_yaml_str(
284            "rules:\n  - name: no-staging\n    host: \"*.staging\"\n    forbid: true\n",
285        )
286        .unwrap();
287        let cap = sample_capture(vec![sample_entry(0, "api.staging", "GET", "/a", 200)]);
288        let r = compute_rules(&cap, &no_filter(), &cfg, &[], 50);
289        assert!(
290            r.findings
291                .iter()
292                .any(|f| f.rule == "no-staging" && f.severity == "high")
293        );
294    }
295
296    #[test]
297    fn auth_pack_flags_missing_authorization() {
298        let cap = sample_capture(vec![sample_entry(0, "api.x", "GET", "/a", 200)]);
299        let r = compute_rules(
300            &cap,
301            &no_filter(),
302            &Config::default(),
303            &["auth".to_string()],
304            50,
305        );
306        assert!(
307            r.findings
308                .iter()
309                .any(|f| f.detail.contains("Authorization"))
310        );
311    }
312
313    #[test]
314    fn security_pack_flags_opaque_query_secret() {
315        let mut e: Entry = sample_entry(0, "api.x", "GET", "/a", 200);
316        e.query = vec![(
317            "token".into(),
318            "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9abcXYZ".into(),
319        )];
320        let r = compute_rules(
321            &sample_capture(vec![e]),
322            &no_filter(),
323            &Config::default(),
324            &["security".to_string()],
325            50,
326        );
327        assert!(
328            r.findings
329                .iter()
330                .any(|f| f.severity == "high" && f.detail.contains("token"))
331        );
332    }
333
334    #[test]
335    fn present_header_not_flagged() {
336        let mut e = sample_entry(0, "api.x", "GET", "/a", 200);
337        e.req_headers = vec![("Authorization".into(), "Bearer x".into())];
338        let r = compute_rules(
339            &sample_capture(vec![e]),
340            &no_filter(),
341            &Config::default(),
342            &["auth".to_string()],
343            50,
344        );
345        assert!(r.findings.is_empty());
346    }
347}