318 lines
8.7 KiB
Rust
318 lines
8.7 KiB
Rust
use hickory_resolver::TokioResolver;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
|
|
|
use crate::error::Result;
|
|
|
|
/// Default DNSBL servers to check, same as the TypeScript IPReputationChecker.
|
|
pub const DEFAULT_DNSBL_SERVERS: &[&str] = &[
|
|
"zen.spamhaus.org",
|
|
"bl.spamcop.net",
|
|
"b.barracudacentral.org",
|
|
"spam.dnsbl.sorbs.net",
|
|
"dnsbl.sorbs.net",
|
|
"cbl.abuseat.org",
|
|
"xbl.spamhaus.org",
|
|
"pbl.spamhaus.org",
|
|
"dnsbl-1.uceprotect.net",
|
|
"psbl.surriel.com",
|
|
];
|
|
|
|
/// Result of a DNSBL check.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct DnsblResult {
|
|
/// IP address that was checked.
|
|
pub ip: String,
|
|
/// Number of DNSBL servers that list this IP.
|
|
pub listed_count: usize,
|
|
/// Names of DNSBL servers that list this IP.
|
|
pub listed_on: Vec<String>,
|
|
/// Total number of DNSBL servers checked.
|
|
pub total_checked: usize,
|
|
}
|
|
|
|
/// Result of a full IP reputation check.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ReputationResult {
|
|
/// Reputation score: 0 (worst) to 100 (best).
|
|
pub score: u8,
|
|
/// Whether the IP is considered spam source.
|
|
pub is_spam: bool,
|
|
/// IP address that was checked.
|
|
pub ip: String,
|
|
/// DNSBL results.
|
|
pub dnsbl: DnsblResult,
|
|
/// Heuristic IP type classification.
|
|
pub ip_type: IpType,
|
|
}
|
|
|
|
/// Heuristic IP type classification.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum IpType {
|
|
Residential,
|
|
Datacenter,
|
|
Proxy,
|
|
Tor,
|
|
Vpn,
|
|
Unknown,
|
|
}
|
|
|
|
/// Risk level based on reputation score.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum RiskLevel {
|
|
/// Score < 20
|
|
High,
|
|
/// Score 20-49
|
|
Medium,
|
|
/// Score 50-79
|
|
Low,
|
|
/// Score >= 80
|
|
Trusted,
|
|
}
|
|
|
|
/// Get the risk level for a reputation score.
|
|
pub fn risk_level(score: u8) -> RiskLevel {
|
|
match score {
|
|
0..=19 => RiskLevel::High,
|
|
20..=49 => RiskLevel::Medium,
|
|
50..=79 => RiskLevel::Low,
|
|
_ => RiskLevel::Trusted,
|
|
}
|
|
}
|
|
|
|
/// Check an IP against DNSBL servers.
|
|
///
|
|
/// * `ip` - The IP address to check (IPv4 or IPv6)
|
|
/// * `dnsbl_servers` - DNSBL servers to query (use `DEFAULT_DNSBL_SERVERS` for defaults)
|
|
/// * `resolver` - DNS resolver to use
|
|
pub async fn check_dnsbl(
|
|
ip: IpAddr,
|
|
dnsbl_servers: &[&str],
|
|
resolver: &TokioResolver,
|
|
) -> Result<DnsblResult> {
|
|
let reversed = match ip {
|
|
IpAddr::V4(v4) => reverse_ipv4(v4),
|
|
IpAddr::V6(v6) => reverse_ipv6(v6),
|
|
};
|
|
let total = dnsbl_servers.len();
|
|
|
|
// Query all DNSBL servers in parallel
|
|
let mut handles = Vec::with_capacity(total);
|
|
for &server in dnsbl_servers {
|
|
let query = format!("{}.{}", reversed, server);
|
|
let resolver = resolver.clone();
|
|
let server_name = server.to_string();
|
|
handles.push(tokio::spawn(async move {
|
|
match resolver.lookup_ip(&query).await {
|
|
Ok(_) => Some(server_name), // IP is listed
|
|
Err(_) => None, // IP is not listed (NXDOMAIN)
|
|
}
|
|
}));
|
|
}
|
|
|
|
let mut listed_on = Vec::new();
|
|
for handle in handles {
|
|
match handle.await {
|
|
Ok(Some(server)) => listed_on.push(server),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
Ok(DnsblResult {
|
|
ip: ip.to_string(),
|
|
listed_count: listed_on.len(),
|
|
listed_on,
|
|
total_checked: total,
|
|
})
|
|
}
|
|
|
|
/// Full IP reputation check: DNSBL + heuristic classification + scoring.
|
|
pub async fn check_reputation(
|
|
ip: IpAddr,
|
|
dnsbl_servers: &[&str],
|
|
resolver: &TokioResolver,
|
|
) -> Result<ReputationResult> {
|
|
let dnsbl = check_dnsbl(ip, dnsbl_servers, resolver).await?;
|
|
let ip_type = classify_ip(ip);
|
|
|
|
// Scoring: start at 100
|
|
let mut score: i16 = 100;
|
|
|
|
// Subtract 10 per DNSBL listing
|
|
score -= (dnsbl.listed_count as i16) * 10;
|
|
|
|
// Subtract 30 for suspicious IP types
|
|
match ip_type {
|
|
IpType::Proxy | IpType::Tor | IpType::Vpn => {
|
|
score -= 30;
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
let score = score.clamp(0, 100) as u8;
|
|
let is_spam = score < 50;
|
|
|
|
Ok(ReputationResult {
|
|
score,
|
|
is_spam,
|
|
ip: ip.to_string(),
|
|
dnsbl,
|
|
ip_type,
|
|
})
|
|
}
|
|
|
|
/// Reverse IPv4 octets for DNSBL queries: "1.2.3.4" -> "4.3.2.1".
|
|
fn reverse_ipv4(ip: Ipv4Addr) -> String {
|
|
let octets = ip.octets();
|
|
format!("{}.{}.{}.{}", octets[3], octets[2], octets[1], octets[0])
|
|
}
|
|
|
|
/// Reverse IPv6 address to nibble format for DNSBL queries.
|
|
///
|
|
/// Expands to full 32-nibble hex, reverses, and dot-separates each nibble.
|
|
/// E.g. `2001:db8::1` -> `1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2`
|
|
fn reverse_ipv6(ip: Ipv6Addr) -> String {
|
|
let segments = ip.segments();
|
|
let full_hex: String = segments.iter().map(|s| format!("{:04x}", s)).collect();
|
|
full_hex
|
|
.chars()
|
|
.rev()
|
|
.map(|c| c.to_string())
|
|
.collect::<Vec<_>>()
|
|
.join(".")
|
|
}
|
|
|
|
/// Heuristic IP type classification based on well-known prefix ranges.
|
|
/// Same heuristics as the TypeScript IPReputationChecker.
|
|
fn classify_ip(ip: IpAddr) -> IpType {
|
|
let ip_str = ip.to_string();
|
|
|
|
// Known Tor exit node prefixes
|
|
if ip_str.starts_with("171.25.")
|
|
|| ip_str.starts_with("185.220.")
|
|
|| ip_str.starts_with("95.216.")
|
|
{
|
|
return IpType::Tor;
|
|
}
|
|
|
|
// Known VPN provider prefixes
|
|
if ip_str.starts_with("185.156.") || ip_str.starts_with("37.120.") {
|
|
return IpType::Vpn;
|
|
}
|
|
|
|
// Known proxy prefixes
|
|
if ip_str.starts_with("34.92.") || ip_str.starts_with("34.206.") {
|
|
return IpType::Proxy;
|
|
}
|
|
|
|
// Major cloud provider prefixes (datacenter)
|
|
if ip_str.starts_with("13.")
|
|
|| ip_str.starts_with("35.")
|
|
|| ip_str.starts_with("52.")
|
|
|| ip_str.starts_with("34.")
|
|
|| ip_str.starts_with("104.")
|
|
{
|
|
return IpType::Datacenter;
|
|
}
|
|
|
|
IpType::Residential
|
|
}
|
|
|
|
/// Validate an IPv4 address string.
|
|
pub fn is_valid_ipv4(ip: &str) -> bool {
|
|
ip.parse::<Ipv4Addr>().is_ok()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_reverse_ipv4() {
|
|
let ip: Ipv4Addr = "1.2.3.4".parse().unwrap();
|
|
assert_eq!(reverse_ipv4(ip), "4.3.2.1");
|
|
|
|
let ip: Ipv4Addr = "192.168.1.100".parse().unwrap();
|
|
assert_eq!(reverse_ipv4(ip), "100.1.168.192");
|
|
}
|
|
|
|
#[test]
|
|
fn test_classify_ip() {
|
|
assert_eq!(
|
|
classify_ip("171.25.193.20".parse().unwrap()),
|
|
IpType::Tor
|
|
);
|
|
assert_eq!(
|
|
classify_ip("185.156.73.1".parse().unwrap()),
|
|
IpType::Vpn
|
|
);
|
|
assert_eq!(
|
|
classify_ip("34.92.1.1".parse().unwrap()),
|
|
IpType::Proxy
|
|
);
|
|
assert_eq!(
|
|
classify_ip("52.0.0.1".parse().unwrap()),
|
|
IpType::Datacenter
|
|
);
|
|
assert_eq!(
|
|
classify_ip("203.0.113.1".parse().unwrap()),
|
|
IpType::Residential
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_risk_level() {
|
|
assert_eq!(risk_level(10), RiskLevel::High);
|
|
assert_eq!(risk_level(30), RiskLevel::Medium);
|
|
assert_eq!(risk_level(60), RiskLevel::Low);
|
|
assert_eq!(risk_level(90), RiskLevel::Trusted);
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_valid_ipv4() {
|
|
assert!(is_valid_ipv4("1.2.3.4"));
|
|
assert!(is_valid_ipv4("255.255.255.255"));
|
|
assert!(!is_valid_ipv4("999.999.999.999"));
|
|
assert!(!is_valid_ipv4("not-an-ip"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_reverse_ipv6() {
|
|
let ip: Ipv6Addr = "2001:0db8:0000:0000:0000:0000:0000:0001".parse().unwrap();
|
|
assert_eq!(
|
|
reverse_ipv6(ip),
|
|
"1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_reverse_ipv6_loopback() {
|
|
let ip: Ipv6Addr = "::1".parse().unwrap();
|
|
assert_eq!(
|
|
reverse_ipv6(ip),
|
|
"1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_check_dnsbl_ipv6_runs() {
|
|
// Verify IPv6 actually goes through DNSBL queries (not skipped)
|
|
let resolver = hickory_resolver::TokioResolver::builder_tokio()
|
|
.map(|b| b.build())
|
|
.unwrap();
|
|
let ip: IpAddr = "::1".parse().unwrap();
|
|
let result = check_dnsbl(ip, DEFAULT_DNSBL_SERVERS, &resolver).await.unwrap();
|
|
// Loopback should not be listed on any DNSBL
|
|
assert_eq!(result.listed_count, 0);
|
|
// But total_checked should be > 0 — proving IPv6 was actually queried
|
|
assert_eq!(result.total_checked, DEFAULT_DNSBL_SERVERS.len());
|
|
}
|
|
|
|
#[test]
|
|
fn test_default_dnsbl_servers() {
|
|
assert_eq!(DEFAULT_DNSBL_SERVERS.len(), 10);
|
|
assert!(DEFAULT_DNSBL_SERVERS.contains(&"zen.spamhaus.org"));
|
|
}
|
|
}
|