Skip to main content

waf_threatintel/
lib.rs

1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4//! Threat intelligence for [Light WAF](https://github.com/0x00spor3/Light-WAF): cluster-wide IP
5//! reputation, in two halves.
6//!
7//! - **Read**: [`ReputationModule`] is a detection module that runs at the connection phase, looks up
8//!   the client IP in a `waf_state_redis::ReputationStore` and adds a severity to the request's
9//!   cumulative anomaly score (`Malicious` / `Suspicious`; `Unknown` adds nothing). A store outage
10//!   reads as `Unknown`: it neither blocks nor unblocks traffic.
11//! - **Write**: [`start_reputation_feed`] polls [`ReputationFeed`] sources ([`FileFeed`],
12//!   [`HttpListFeed`]) and populates the store, with a per-cycle single-writer lock and per-entry TTL
13//!   so de-listed IPs age out.
14//!
15//! Reputation stays out of the core's API: the core datapath never reads or writes it.
16
17use 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/// How an IP-reputation verdict maps to an anomaly-score severity. No hardcoded points: the
28/// module declares a [`Severity`] and the pipeline resolves `severity -> points` via
29/// `[waf.severity_scores]` (so the operator tunes whether reputation alone blocks).
30#[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
43/// A premium `WafModule` that scores a request by the reputation of its client IP.
44pub struct ReputationModule {
45    store: Arc<dyn ReputationStore>,
46    cfg: ReputationConfig,
47}
48
49impl ReputationModule {
50    /// Fully configured at construction: the core `Config` carries no reputation fields, so the
51    /// enterprise wiring builds this from `[enterprise.reputation]` and `init()` is a no-op.
52    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        // `Unknown` (no opinion — also the store's fail-safe when the feed is down) MUST NOT
73        // contribute: the request keeps flowing through normal inspection.
74        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        // Distinct `rule_id` namespace so a future signature feed (§6) can dedup the same IP
80        // and not double-count it into the cumulative score.
81        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    /// A stub store with a fixed verdict — lets the bite-test exercise the module on the real
96    /// pipeline without Redis. (The real Redis fail-to-Unknown is proven in waf-state-redis.)
97    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; // default severity critical=6 ≥ 5 ⇒ a malicious IP blocks
118        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    /// THE bite-test: a malicious IP raises the cumulative score → the pipeline blocks; the
142    /// SAME request with NO reputation module passes → proves the module is on the datapath and
143    /// causes the block (not green for some other reason).
144    #[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    /// fail-to-Unknown / no-opinion: an Unknown verdict (what the Redis store returns when the
167    /// feed is down) must NOT block — the request still flows through inspection.
168    #[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}