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
51fn 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
89fn 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
126fn 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
171pub 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 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
225pub 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 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}