Skip to main content

gossan_intel/
lib.rs

1#![forbid(unsafe_code)]
2// pedantic moved to workspace [lints.clippy] in root Cargo.toml
3#![cfg_attr(
4    not(test),
5    deny(
6        clippy::unwrap_used,
7        clippy::expect_used,
8        clippy::todo,
9        clippy::unimplemented,
10        clippy::panic
11    )
12)]
13#![allow(
14    clippy::module_name_repetitions,
15    clippy::must_use_candidate,
16    clippy::missing_errors_doc
17)]
18
19//! Intelligence scanner — online enrichment + offline bulk datasets.
20//!
21//! # Sources
22//! - GreyNoise
23//! - Censys host context v2
24//! - Shodan
25//! - AbuseIPDB
26//! - VirusTotal
27//! - URLScan
28//! - ASN (ipinfo.io)
29//! - Passive DNS
30//!
31//! # Caching
32//! All enrichment is cached in a SQLite-backed TTL cache keyed by
33//! `(source, target_type, target_value)`.
34
35use async_trait::async_trait;
36use gossan_core::{Config, ScanClient, ScanInput, Scanner, Target};
37use std::sync::Arc;
38
39pub mod cache;
40pub mod db;
41pub mod enrichment;
42pub mod ingest;
43pub mod query;
44pub mod ratelimit;
45pub mod sources;
46
47use cache::IntelCache;
48use sources::IntelSource;
49
50/// Intel scanner configuration.
51pub struct IntelScanner {
52    /// HTTP client shared across all sources.
53    pub client: ScanClient,
54    /// Online enrichment sources.
55    pub sources: Vec<Arc<dyn IntelSource>>,
56    /// Persistent TTL cache.
57    pub cache: Option<Arc<IntelCache>>,
58    /// Cache TTL in seconds.
59    pub cache_ttl_secs: u64,
60    /// Optional offline bulk database.
61    pub db: Option<Arc<db::IntelDb>>,
62    /// Per-service rate limiter.
63    pub limiter: Option<Arc<ratelimit::ServiceRateLimiter>>,
64}
65
66impl IntelScanner {
67    /// Build an intel scanner from configuration.
68    ///
69    /// # Errors
70    ///
71    /// Returns an error if the cache database cannot be opened.
72    pub fn from_config(config: &Config) -> anyhow::Result<Self> {
73        let client = ScanClient::default_client();
74        let inner = client.inner().clone();
75
76        let mut sources: Vec<Arc<dyn IntelSource>> = Vec::new();
77
78        let greynoise_key = config.api_keys.get("greynoise").cloned();
79        sources.push(Arc::new(sources::greynoise::GreyNoiseSource::new(
80            inner.clone(),
81            greynoise_key,
82        )));
83
84        let censys_id = config.api_keys.get("censys_id").cloned();
85        let censys_secret = config.api_keys.get("censys_secret").cloned();
86        sources.push(Arc::new(sources::censys::CensysSource::new(
87            inner.clone(),
88            censys_id,
89            censys_secret,
90        )));
91
92        let shodan_key = config.api_keys.get("shodan").cloned();
93        sources.push(Arc::new(sources::shodan::ShodanSource::new(
94            inner.clone(),
95            shodan_key,
96        )));
97
98        let abuseipdb_key = config.api_keys.get("abuseipdb").cloned();
99        sources.push(Arc::new(sources::abuseipdb::AbuseIpdbSource::new(
100            inner.clone(),
101            abuseipdb_key,
102        )));
103
104        let vt_key = config.api_keys.get("virustotal").cloned();
105        sources.push(Arc::new(sources::virustotal::VirusTotalSource::new(
106            inner.clone(),
107            vt_key,
108        )));
109
110        let urlscan_key = config.api_keys.get("urlscan").cloned();
111        sources.push(Arc::new(sources::urlscan::UrlScanSource::new(
112            inner.clone(),
113            urlscan_key,
114        )));
115
116        let asn_token = config.api_keys.get("ipinfo").cloned();
117        sources.push(Arc::new(sources::asn::AsnSource::new(
118            inner.clone(),
119            asn_token,
120        )));
121
122        let pdns_key = config.api_keys.get("passive_dns").cloned();
123        let pdns_endpoint = config
124            .api_keys
125            .get("passive_dns_endpoint")
126            .cloned()
127            .unwrap_or_else(|| "https://api.dnsdb.info".to_string());
128        sources.push(Arc::new(sources::passive_dns::PassiveDnsSource::new(
129            inner.clone(),
130            pdns_key,
131            pdns_endpoint,
132        )));
133
134        let cache = if let Some(ref path) = config.intel_db_path {
135            Some(Arc::new(IntelCache::open(path)?))
136        } else {
137            None
138        };
139
140        let db = if let Some(ref path) = config.intel_db_path {
141            Some(Arc::new(db::IntelDb::open(path)?))
142        } else {
143            None
144        };
145
146        let limiter = Some(ratelimit::build_limiter(config.rate_limit.max(1)));
147
148        Ok(Self {
149            client,
150            sources,
151            cache,
152            cache_ttl_secs: 86_400, // 24h default
153            db,
154            limiter,
155        })
156    }
157
158    /// Create a scanner with only the offline bulk database (legacy mode).
159    pub fn new(path: &str) -> anyhow::Result<Self> {
160        let db = Arc::new(db::IntelDb::open(path)?);
161        Ok(Self {
162            client: ScanClient::default_client(),
163            sources: Vec::new(),
164            cache: None,
165            cache_ttl_secs: 86_400,
166            db: Some(db),
167            limiter: None,
168        })
169    }
170
171    /// Enrich a single target using all configured sources.
172    ///
173    /// Emits findings via `input.emit()` and returns the number of enrichments.
174    pub async fn enrich_target(&self, target: &Target, input: &ScanInput) -> anyhow::Result<usize> {
175        let mut emitted = 0usize;
176
177        // 1. Offline bulk lookup (legacy)
178        if let Some(ref db) = self.db {
179            let db = Arc::clone(db);
180            let target = target.clone();
181            let live_tx = input.live_tx.clone();
182            let count = tokio::task::spawn_blocking(move || {
183                let mut count = 0usize;
184                let records_by_ip = if let Some(ip_addr) = target.ip() {
185                    db.query_by_ip(&ip_addr.to_string()).unwrap_or_default()
186                } else {
187                    vec![]
188                };
189                let records_by_host = if let Some(host) = target.domain() {
190                    db.query_by_host(host).unwrap_or_default()
191                } else {
192                    vec![]
193                };
194                for r in records_by_ip.iter().chain(records_by_host.iter()) {
195                    if let Some(finding) = query::record_to_finding(r) {
196                        let _ = live_tx.send(finding);
197                        count += 1;
198                    }
199                }
200                count
201            })
202            .await?;
203            emitted += count;
204        }
205
206        // 2. Online enrichment
207        let ip = target.ip().map(|i| i.to_string());
208        let domain = target.domain().map(|s| s.to_string());
209
210        for source in &self.sources {
211            if let Some(ref limiter) = self.limiter {
212                ratelimit::acquire(limiter, source.name()).await;
213            }
214
215            let enrichment = if let Some(ref ip) = ip {
216                if let Some(ref cache) = self.cache {
217                    if let Some(cached) = cache.get(source.name(), "ip", ip, self.cache_ttl_secs)? {
218                        Ok(cached)
219                    } else {
220                        let result = source.query_ip(ip).await;
221                        if let Ok(ref e) = result {
222                            let _ = cache.put(e);
223                        }
224                        result
225                    }
226                } else {
227                    source.query_ip(ip).await
228                }
229            } else if let Some(ref domain) = domain {
230                if let Some(ref cache) = self.cache {
231                    if let Some(cached) =
232                        cache.get(source.name(), "domain", domain, self.cache_ttl_secs)?
233                    {
234                        Ok(cached)
235                    } else {
236                        let result = source.query_domain(domain).await;
237                        if let Ok(ref e) = result {
238                            let _ = cache.put(e);
239                        }
240                        result
241                    }
242                } else {
243                    source.query_domain(domain).await
244                }
245            } else {
246                continue;
247            };
248
249            match enrichment {
250                Ok(e) => {
251                    if let Some(finding) = query::enrichment_to_finding(&e) {
252                        input.emit(finding);
253                        emitted += 1;
254                    }
255                }
256                Err(e) => {
257                    tracing::warn!(source = source.name(), error = %e, "intel source failed");
258                }
259            }
260        }
261
262        Ok(emitted)
263    }
264}
265
266#[async_trait]
267impl Scanner for IntelScanner {
268    fn name(&self) -> &'static str {
269        "intel"
270    }
271    fn tags(&self) -> &[&'static str] {
272        &["passive", "active", "intel", "enrichment"]
273    }
274    fn accepts(&self, target: &Target) -> bool {
275        matches!(target, Target::Domain(_) | Target::Host(_))
276    }
277
278    async fn run(&self, input: ScanInput, _config: &Config) -> anyhow::Result<()> {
279        let mut rx = input.target_rx.lock().await;
280        while let Some(target) = rx.recv().await {
281            if let Err(e) = self.enrich_target(&target, &input).await {
282                tracing::warn!(error = %e, "intel enrichment failed for target");
283            }
284        }
285        Ok(())
286    }
287}