use std::sync::Arc;
use waf_core::{Config, Decision, Phase, RequestContext, ScoreItem, Severity, WafModule};
use waf_state_redis::{Reputation, ReputationStore};
mod driver;
mod feed;
pub use driver::{start_reputation_feed, FeedConfig};
pub use feed::{FeedError, FileFeed, HttpListFeed, ReputationFeed};
#[derive(Debug, Clone, Copy)]
pub struct ReputationConfig {
pub enabled: bool,
pub malicious: Severity,
pub suspicious: Severity,
}
impl Default for ReputationConfig {
fn default() -> Self {
Self { enabled: false, malicious: Severity::Critical, suspicious: Severity::Warning }
}
}
pub struct ReputationModule {
store: Arc<dyn ReputationStore>,
cfg: ReputationConfig,
}
impl ReputationModule {
pub fn new(store: Arc<dyn ReputationStore>, cfg: ReputationConfig) -> Self {
Self { store, cfg }
}
}
impl WafModule for ReputationModule {
fn id(&self) -> &str {
"reputation"
}
fn phase(&self) -> Phase {
Phase::Connection
}
fn init(&mut self, _cfg: &Config) {}
fn inspect(&self, ctx: &RequestContext) -> Decision {
if !self.cfg.enabled {
return Decision::Allow;
}
let (rule_id, severity) = match self.store.reputation(ctx.client_ip) {
Reputation::Malicious => ("reputation-malicious", self.cfg.malicious),
Reputation::Suspicious => ("reputation-suspicious", self.cfg.suspicious),
Reputation::Unknown => return Decision::Allow,
};
Decision::Scores(vec![ScoreItem { rule_id: rule_id.to_string(), severity }])
}
}
#[cfg(test)]
mod tests {
use std::net::IpAddr;
use waf_core::testkit::Request;
use waf_core::{Config, RequestContext, WafMode};
use waf_pipeline::{Pipeline, PipelineVerdict};
use super::*;
struct FixedRep(Reputation);
impl ReputationStore for FixedRep {
fn reputation(&self, _ip: IpAddr) -> Reputation {
self.0
}
}
fn ctx_with_ip(ip: &str) -> RequestContext {
let mut c = Request::new().build();
c.client_ip = ip.parse().unwrap();
c
}
fn enabled() -> ReputationConfig {
ReputationConfig { enabled: true, malicious: Severity::Critical, suspicious: Severity::Warning }
}
fn blocking_config() -> Config {
let mut c = Config::default();
c.waf.mode = WafMode::Blocking;
c.waf.block_threshold = 5; c
}
#[test]
fn malicious_scores_with_configured_severity_clean_allows() {
let m = ReputationModule::new(Arc::new(FixedRep(Reputation::Malicious)), enabled());
let d = m.inspect(&ctx_with_ip("1.2.3.4"));
assert!(
matches!(d, Decision::Scores(ref v) if v.len() == 1 && v[0].severity == Severity::Critical),
"malicious IP must score Critical, got {d:?}"
);
let unknown = ReputationModule::new(Arc::new(FixedRep(Reputation::Unknown)), enabled());
assert_eq!(unknown.inspect(&ctx_with_ip("1.2.3.4")), Decision::Allow);
}
#[test]
fn disabled_module_never_scores() {
let cfg = ReputationConfig { enabled: false, ..enabled() };
let m = ReputationModule::new(Arc::new(FixedRep(Reputation::Malicious)), cfg);
assert_eq!(m.inspect(&ctx_with_ip("1.2.3.4")), Decision::Allow);
}
#[test]
fn malicious_blocks_on_datapath_but_control_without_module_passes() {
let config = blocking_config();
let mut ctx = ctx_with_ip("9.9.9.9");
let pipe = Pipeline::new(
&config,
vec![Box::new(ReputationModule::new(Arc::new(FixedRep(Reputation::Malicious)), enabled()))],
);
assert!(
!matches!(pipe.run(&mut ctx), PipelineVerdict::Allow),
"malicious IP must push score ≥ threshold → not Allow"
);
let mut ctx_ctrl = ctx_with_ip("9.9.9.9");
let bare = Pipeline::new(&config, vec![]);
assert!(
matches!(bare.run(&mut ctx_ctrl), PipelineVerdict::Allow),
"without the module the same IP must pass (control)"
);
}
#[test]
fn unknown_does_not_block_request_keeps_flowing() {
let config = blocking_config();
let mut ctx = ctx_with_ip("9.9.9.9");
let pipe = Pipeline::new(
&config,
vec![Box::new(ReputationModule::new(Arc::new(FixedRep(Reputation::Unknown)), enabled()))],
);
assert!(matches!(pipe.run(&mut ctx), PipelineVerdict::Allow));
}
}