Skip to main content

waf_threatintel/
feed.rs

1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4//! The threat-intel **feed** (ARCHITECTURE_ENTERPRISE §6): the write side that *populates* the
5//! `rep:<ip>` keyspace the [`ReputationModule`](crate::ReputationModule) reads. A [`ReputationFeed`]
6//! is a source of `(ip, verdict)` entries; the driver ([`crate::start_reputation_feed`]) polls the
7//! sources on an interval and publishes a snapshot via [`ReputationWriter`](waf_state_redis::ReputationWriter).
8//!
9//! ## Frozen decisions this module encodes
10//! - **F5 — verdict is per-SOURCE, not per-entry**: the remote content says WHICH ips are listed,
11//!   never HOW severe. Point a blocklist at a `Malicious` source and a greylist at a `Suspicious`
12//!   one. Commercial subscription APIs are a follow-on behind this same trait (mirrors how
13//!   `DnsProvider` grew Cloudflare/Route53).
14//! - **F3 — exact IPs only**: the read is `GET rep:<exact-ip>`, so a CIDR range cannot match
15//!   without changing the (frozen) lookup shape. CIDR lines are skipped with a warn, declared a
16//!   follow-on.
17
18use std::net::IpAddr;
19use std::path::PathBuf;
20use std::sync::Arc;
21
22use async_trait::async_trait;
23use tokio_rustls::rustls::crypto::ring;
24use tokio_rustls::rustls::{ClientConfig, RootCertStore};
25use tracing::warn;
26use waf_state_redis::Reputation;
27
28/// Why a feed fetch failed. The driver treats any error as "keep the previous snapshot"
29/// (fail-to-no-opinion): a down feed must neither block nor unblock — entries just age out.
30#[derive(Debug)]
31pub enum FeedError {
32    /// Local file could not be read.
33    Io(String),
34    /// HTTP request/response failure (network, non-2xx, body decode).
35    Http(String),
36    /// The source could not be constructed (e.g. the TLS client config).
37    Config(String),
38}
39
40impl std::fmt::Display for FeedError {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        match self {
43            Self::Io(e) => write!(f, "feed file read error: {e}"),
44            Self::Http(e) => write!(f, "feed HTTP error: {e}"),
45            Self::Config(e) => write!(f, "feed config error: {e}"),
46        }
47    }
48}
49impl std::error::Error for FeedError {}
50
51/// A source of IP-reputation entries. Object-safe (async via `async_trait`) so the driver can hold
52/// a `Vec<Box<dyn ReputationFeed>>` of heterogeneous sources.
53#[async_trait]
54// `async_trait` emits `#[must_use]` on the generated methods, whose `Pin<Box<dyn Future>>` return is
55// already must-use; clippy ≥ 1.99 flags that macro output. Nothing is lost: the future stays must-use.
56#[allow(clippy::double_must_use)]
57pub trait ReputationFeed: Send + Sync {
58    /// Short label for logs (e.g. the file path or URL).
59    fn name(&self) -> &str;
60    /// Fetch the current list of `(ip, verdict)` entries. An `Err` is the driver's cue to keep the
61    /// previous snapshot, never to clear it.
62    async fn fetch(&self) -> Result<Vec<(IpAddr, Reputation)>, FeedError>;
63}
64
65/// Parse a plaintext IP list — **one IP per line** — into `(ip, verdict)` entries, assigning the
66/// source's fixed `verdict` to every IP (F5). Robust to the shapes real blocklists use:
67/// - blank lines and `#` comments (whole-line or trailing) are skipped;
68/// - the first whitespace token of a line is taken as the IP (so `1.2.3.4 some label` works);
69/// - **CIDR** ranges (`a.b.c.d/n`) are skipped with a warn (F3: exact-IP only in v1);
70/// - unparseable lines are skipped with a warn (a bad line never poisons the whole snapshot).
71///
72/// Pure and IO-free so the parsing contract is unit-tested without a file or a socket.
73fn parse_ip_list(text: &str, verdict: Reputation, source: &str) -> Vec<(IpAddr, Reputation)> {
74    let mut out = Vec::new();
75    for (idx, raw) in text.lines().enumerate() {
76        // Strip a trailing `# comment`, then trim; a whole-line comment becomes empty.
77        let line = raw.split('#').next().unwrap_or("").trim();
78        if line.is_empty() {
79            continue;
80        }
81        // Take the first token so `IP<space>label` (and `IP<tab>date`) lists parse.
82        let token = line.split_whitespace().next().unwrap_or("");
83        if token.contains('/') {
84            warn!(source, line = idx + 1, entry = token, "reputation feed: CIDR unsupported (v1 exact-IP only), skipping");
85            continue;
86        }
87        match token.parse::<IpAddr>() {
88            Ok(ip) => out.push((ip, verdict)),
89            Err(_) => {
90                warn!(source, line = idx + 1, entry = token, "reputation feed: malformed IP, skipping");
91            }
92        }
93    }
94    out
95}
96
97/// A [`ReputationFeed`] backed by a local file (one IP per line). Useful for tests, air-gapped
98/// deployments, and lists maintained out-of-band (e.g. rendered by another process).
99pub struct FileFeed {
100    path: PathBuf,
101    verdict: Reputation,
102    name: String,
103}
104
105impl FileFeed {
106    /// A file source assigning `verdict` to every listed IP.
107    pub fn new(path: impl Into<PathBuf>, verdict: Reputation) -> Self {
108        let path = path.into();
109        let name = format!("file:{}", path.display());
110        Self { path, verdict, name }
111    }
112}
113
114#[async_trait]
115impl ReputationFeed for FileFeed {
116    fn name(&self) -> &str {
117        &self.name
118    }
119
120    async fn fetch(&self) -> Result<Vec<(IpAddr, Reputation)>, FeedError> {
121        // A periodic read of a local file: a plain blocking read is fine on the feed's background
122        // task (it sleeps for minutes between cycles), and it avoids a tokio "fs" dependency.
123        let text = std::fs::read_to_string(&self.path).map_err(|e| FeedError::Io(e.to_string()))?;
124        Ok(parse_ip_list(&text, self.verdict, &self.name))
125    }
126}
127
128/// A [`ReputationFeed`] that GETs a plaintext IP list over HTTPS. The client uses a `ring`-based
129/// rustls config (no aws-lc-rs/cmake) — the same crypto provider the core uses — matching
130/// `CloudflareDns`.
131pub struct HttpListFeed {
132    url: String,
133    verdict: Reputation,
134    name: String,
135    http: reqwest::Client,
136}
137
138impl HttpListFeed {
139    /// An HTTPS source assigning `verdict` to every listed IP. Fails (boot-fatal at wiring) if the
140    /// HTTPS client cannot be built.
141    pub fn new(url: impl Into<String>, verdict: Reputation) -> Result<Self, FeedError> {
142        let mut roots = RootCertStore::empty();
143        roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
144        // `ring` explicitly — reqwest carries no provider (`rustls-no-provider`).
145        let tls = ClientConfig::builder_with_provider(Arc::new(ring::default_provider()))
146            .with_safe_default_protocol_versions()
147            .map_err(|e| FeedError::Config(format!("rustls client config: {e}")))?
148            .with_root_certificates(roots)
149            .with_no_client_auth();
150        let http = reqwest::Client::builder()
151            .use_preconfigured_tls(tls)
152            .build()
153            .map_err(|e| FeedError::Config(format!("http client: {e}")))?;
154        let url = url.into();
155        let name = format!("http:{url}");
156        Ok(Self { url, verdict, name, http })
157    }
158}
159
160#[async_trait]
161impl ReputationFeed for HttpListFeed {
162    fn name(&self) -> &str {
163        &self.name
164    }
165
166    async fn fetch(&self) -> Result<Vec<(IpAddr, Reputation)>, FeedError> {
167        let resp = self
168            .http
169            .get(&self.url)
170            .send()
171            .await
172            .map_err(|e| FeedError::Http(e.to_string()))?
173            .error_for_status()
174            .map_err(|e| FeedError::Http(e.to_string()))?;
175        let text = resp.text().await.map_err(|e| FeedError::Http(format!("read body: {e}")))?;
176        Ok(parse_ip_list(&text, self.verdict, &self.name))
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn parses_ips_skipping_comments_blanks_cidr_and_junk() {
186        let list = "\
187# a header comment
1881.2.3.4
189  5.6.7.8   # trailing comment
1902001:db8::1
191
19210.0.0.0/8
1939.9.9.9 some-label
194not-an-ip
195";
196        let got = parse_ip_list(list, Reputation::Malicious, "test");
197        let ips: Vec<IpAddr> = got.iter().map(|(ip, _)| *ip).collect();
198        assert_eq!(
199            ips,
200            vec![
201                "1.2.3.4".parse::<IpAddr>().unwrap(),
202                "5.6.7.8".parse().unwrap(),
203                "2001:db8::1".parse().unwrap(),
204                "9.9.9.9".parse().unwrap(),
205            ],
206            "comments/blanks/CIDR/junk skipped; IP-with-label kept"
207        );
208        assert!(got.iter().all(|(_, v)| *v == Reputation::Malicious), "verdict is per-source (F5)");
209    }
210
211    #[test]
212    fn empty_or_all_junk_yields_no_entries() {
213        assert!(parse_ip_list("", Reputation::Suspicious, "t").is_empty());
214        assert!(parse_ip_list("# just a comment\n\n  \n", Reputation::Suspicious, "t").is_empty());
215        assert!(parse_ip_list("garbage\n1.2.3/8\n", Reputation::Suspicious, "t").is_empty());
216    }
217
218    #[tokio::test]
219    async fn file_feed_reads_and_parses() {
220        let dir = std::env::temp_dir();
221        let path = dir.join(format!("waf-feed-test-{}.txt", std::process::id()));
222        std::fs::write(&path, "1.1.1.1\n# c\n2.2.2.2\n").unwrap();
223
224        let feed = FileFeed::new(&path, Reputation::Suspicious);
225        let entries = feed.fetch().await.expect("reads the file");
226        std::fs::remove_file(&path).ok();
227
228        assert_eq!(entries.len(), 2);
229        assert!(entries.iter().all(|(_, v)| *v == Reputation::Suspicious));
230        assert!(feed.name().starts_with("file:"));
231    }
232
233    #[tokio::test]
234    async fn file_feed_missing_file_is_an_io_error() {
235        let feed = FileFeed::new("/no/such/path/definitely-missing.list", Reputation::Malicious);
236        assert!(matches!(feed.fetch().await, Err(FeedError::Io(_))));
237    }
238
239    #[test]
240    fn http_feed_builds_with_ring_tls() {
241        // Proves the ring-based rustls client + reqwest client actually build (no aws-lc/cmake).
242        let feed = HttpListFeed::new("https://example.invalid/list.txt", Reputation::Malicious)
243            .expect("client builds");
244        assert_eq!(feed.name(), "http:https://example.invalid/list.txt");
245    }
246}