1use std::sync::Arc;
18
19use waf_core::{Config, Decision, Phase, RequestContext, ScoreItem, Severity, WafModule};
20use waf_state_redis::{Reputation, ReputationStore};
21
22mod driver;
23mod feed;
24pub use driver::{start_reputation_feed, FeedConfig};
25pub use feed::{FeedError, FileFeed, HttpListFeed, ReputationFeed};
26
27#[derive(Debug, Clone, Copy)]
31pub struct ReputationConfig {
32 pub enabled: bool,
33 pub malicious: Severity,
34 pub suspicious: Severity,
35}
36
37impl Default for ReputationConfig {
38 fn default() -> Self {
39 Self { enabled: false, malicious: Severity::Critical, suspicious: Severity::Warning }
40 }
41}
42
43pub struct ReputationModule {
45 store: Arc<dyn ReputationStore>,
46 cfg: ReputationConfig,
47}
48
49impl ReputationModule {
50 pub fn new(store: Arc<dyn ReputationStore>, cfg: ReputationConfig) -> Self {
53 Self { store, cfg }
54 }
55}
56
57impl WafModule for ReputationModule {
58 fn id(&self) -> &str {
59 "reputation"
60 }
61
62 fn phase(&self) -> Phase {
63 Phase::Connection
64 }
65
66 fn init(&mut self, _cfg: &Config) {}
67
68 fn inspect(&self, ctx: &RequestContext) -> Decision {
69 if !self.cfg.enabled {
70 return Decision::Allow;
71 }
72 let (rule_id, severity) = match self.store.reputation(ctx.client_ip) {
75 Reputation::Malicious => ("reputation-malicious", self.cfg.malicious),
76 Reputation::Suspicious => ("reputation-suspicious", self.cfg.suspicious),
77 Reputation::Unknown => return Decision::Allow,
78 };
79 Decision::Scores(vec![ScoreItem { rule_id: rule_id.to_string(), severity }])
82 }
83}
84
85#[cfg(test)]
86mod tests {
87 use std::net::IpAddr;
88
89 use waf_core::testkit::Request;
90 use waf_core::{Config, RequestContext, WafMode};
91 use waf_pipeline::{Pipeline, PipelineVerdict};
92
93 use super::*;
94
95 struct FixedRep(Reputation);
98 impl ReputationStore for FixedRep {
99 fn reputation(&self, _ip: IpAddr) -> Reputation {
100 self.0
101 }
102 }
103
104 fn ctx_with_ip(ip: &str) -> RequestContext {
105 let mut c = Request::new().build();
106 c.client_ip = ip.parse().unwrap();
107 c
108 }
109
110 fn enabled() -> ReputationConfig {
111 ReputationConfig { enabled: true, malicious: Severity::Critical, suspicious: Severity::Warning }
112 }
113
114 fn blocking_config() -> Config {
115 let mut c = Config::default();
116 c.waf.mode = WafMode::Blocking;
117 c.waf.block_threshold = 5; c
119 }
120
121 #[test]
122 fn malicious_scores_with_configured_severity_clean_allows() {
123 let m = ReputationModule::new(Arc::new(FixedRep(Reputation::Malicious)), enabled());
124 let d = m.inspect(&ctx_with_ip("1.2.3.4"));
125 assert!(
126 matches!(d, Decision::Scores(ref v) if v.len() == 1 && v[0].severity == Severity::Critical),
127 "malicious IP must score Critical, got {d:?}"
128 );
129
130 let unknown = ReputationModule::new(Arc::new(FixedRep(Reputation::Unknown)), enabled());
131 assert_eq!(unknown.inspect(&ctx_with_ip("1.2.3.4")), Decision::Allow);
132 }
133
134 #[test]
135 fn disabled_module_never_scores() {
136 let cfg = ReputationConfig { enabled: false, ..enabled() };
137 let m = ReputationModule::new(Arc::new(FixedRep(Reputation::Malicious)), cfg);
138 assert_eq!(m.inspect(&ctx_with_ip("1.2.3.4")), Decision::Allow);
139 }
140
141 #[test]
145 fn malicious_blocks_on_datapath_but_control_without_module_passes() {
146 let config = blocking_config();
147
148 let mut ctx = ctx_with_ip("9.9.9.9");
149 let pipe = Pipeline::new(
150 &config,
151 vec![Box::new(ReputationModule::new(Arc::new(FixedRep(Reputation::Malicious)), enabled()))],
152 );
153 assert!(
154 !matches!(pipe.run(&mut ctx), PipelineVerdict::Allow),
155 "malicious IP must push score ≥ threshold → not Allow"
156 );
157
158 let mut ctx_ctrl = ctx_with_ip("9.9.9.9");
159 let bare = Pipeline::new(&config, vec![]);
160 assert!(
161 matches!(bare.run(&mut ctx_ctrl), PipelineVerdict::Allow),
162 "without the module the same IP must pass (control)"
163 );
164 }
165
166 #[test]
169 fn unknown_does_not_block_request_keeps_flowing() {
170 let config = blocking_config();
171 let mut ctx = ctx_with_ip("9.9.9.9");
172 let pipe = Pipeline::new(
173 &config,
174 vec![Box::new(ReputationModule::new(Arc::new(FixedRep(Reputation::Unknown)), enabled()))],
175 );
176 assert!(matches!(pipe.run(&mut ctx), PipelineVerdict::Allow));
177 }
178}