From 5d04f7f16e2439d5b548064b65a731e0e9e69668 Mon Sep 17 00:00:00 2001 From: Mingwei Zhang Date: Mon, 24 Jun 2024 12:33:04 -0700 Subject: [PATCH] add bogons module to allow detecting bogon prefix and asns --- Cargo.toml | 2 +- README.md | 23 +++++++++ src/bogons/asn.rs | 79 +++++++++++++++++++++++++++++++ src/bogons/mod.rs | 66 ++++++++++++++++++++++++++ src/bogons/prefix.rs | 110 +++++++++++++++++++++++++++++++++++++++++++ src/bogons/utils.rs | 29 ++++++++++++ src/lib.rs | 24 ++++++++++ 7 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 src/bogons/asn.rs create mode 100644 src/bogons/mod.rs create mode 100644 src/bogons/prefix.rs create mode 100644 src/bogons/utils.rs diff --git a/Cargo.toml b/Cargo.toml index 2023baf..3b73def 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ regex = "1" anyhow = "1.0" chrono = { version = "0.4", features = ["serde"] } ipnet-trie = "0.1.0" -ipnet = "2.8" +ipnet = { version = "2.9", features = ["serde"] } tar = "0.4" tracing = "0.1" diff --git a/README.md b/README.md index e98f9bd..fc734d1 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,29 @@ let prefix = IpNet::from_str("1.1.1.0/24").unwrap(); assert_eq!(rpki.validate(&prefix, 13335), RpkiValidation::Valid); ``` +### Bogon utilities + +We provide a utility to check if an IP prefix or an ASN is a bogon. + +#### Data sources + +IANA special registries: +* IPv4: https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry.xhtml +* IPv6: https://www.iana.org/assignments/iana-ipv6-special-registry/iana-ipv6-special-registry.xhtml +* ASN: https://www.iana.org/assignments/iana-as-numbers-special-registry/iana-as-numbers-special-registry.xhtml + +#### Usage Examples + +```rust +use bgpkit_commons::bogons::Bogons; + +let bogons = Bogons::new().unwrap(); +assert!(bogons.matches_str("10.0.0.0/9")); +assert!(bogons.matches_str("112")); +assert!(bogons.is_bogon_prefix(&"2001::/24".parse().unwrap())); +assert!(bogons.is_bogon_asn(65535)); +``` + ### Feature Flags - `rustls`: use rustls instead of native-tls for the underlying HTTPS requests diff --git a/src/bogons/asn.rs b/src/bogons/asn.rs new file mode 100644 index 0000000..173eedd --- /dev/null +++ b/src/bogons/asn.rs @@ -0,0 +1,79 @@ +use crate::bogons::utils::{find_rfc_links, remove_footnotes, replace_commas_in_quotes}; +use anyhow::{anyhow, Result}; +use serde::{Deserialize, Serialize}; + +const IANA_ASN_SPECIAL_REGISTRY: &str = "https://www.iana.org/assignments/iana-as-numbers-special-registry/special-purpose-as-numbers.csv"; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct BogonAsn { + pub asn_range: (u32, u32), + pub description: String, + pub rfc_urls: Vec, +} + +impl BogonAsn { + pub fn matches(&self, asn: u32) -> bool { + asn >= self.asn_range.0 && asn <= self.asn_range.1 + } +} + +fn convert_to_range(s: &str) -> Result<(u32, u32)> { + let parts: Vec<&str> = s.split('-').collect(); + let start = parts[0].parse::()?; + let end = if parts.len() > 1 { + parts[1].parse::()? + } else { + start + }; + Ok((start, end)) +} + +pub fn load_bogon_asns() -> Result> { + let mut bogons = Vec::new(); + let mut prev_line: Option = None; + for line in oneio::read_lines(IANA_ASN_SPECIAL_REGISTRY)? { + let mut text = line.ok().ok_or(anyhow!("error reading line"))?; + if text.trim() == "" || text.starts_with("AS") { + continue; + } + // remove triple quotes + text = text.replace("\"\"\"", "\""); + // remove footnote + text = remove_footnotes(text); + // replace commas in quotes + text = replace_commas_in_quotes(text.as_str()); + + let splits: Vec<&str> = text.split(',').map(|x| x.trim()).collect(); + if splits.len() != 3 { + if prev_line.is_some() { + return Err(anyhow!("row missing fields: {}", text.as_str())); + } + prev_line = Some(text); + continue; + } + prev_line = None; + + let asn_range = convert_to_range(splits[0].replace('\"', "").trim())?; + let description = splits[1].to_string(); + let rfc_urls = find_rfc_links(splits[2]); + + bogons.push(BogonAsn { + asn_range, + description, + rfc_urls, + }); + } + + Ok(bogons) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_load_bogon_asns() { + let bogons = load_bogon_asns().unwrap(); + dbg!(bogons); + } +} diff --git a/src/bogons/mod.rs b/src/bogons/mod.rs new file mode 100644 index 0000000..a8814f5 --- /dev/null +++ b/src/bogons/mod.rs @@ -0,0 +1,66 @@ +//! # Module: bogons +//! +//! This module provides functions to detect whether some given prefix or ASN is a bogon ASN. +//! +//! We obtain the bogon ASN and prefixes data from IANA's special registries: +//! * IPv4: https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry.xhtml +//! * IPv6: https://www.iana.org/assignments/iana-ipv6-special-registry/iana-ipv6-special-registry.xhtml +//! * ASN: https://www.iana.org/assignments/iana-as-numbers-special-registry/iana-as-numbers-special-registry.xhtml +//! +//! The simplest way to check bogon is to provide a &str: +//! ``` +//! let bogons = bgpkit_commons::bogons::Bogons::new().unwrap(); +//! assert!(bogons.matches_str("10.0.0.0/9")); +//! assert!(bogons.matches_str("112")); +//! assert!(bogons.is_bogon_prefix(&"2001::/24".parse().unwrap())); +//! assert!(bogons.is_bogon_asn(65535)); +//! ``` +mod asn; +mod prefix; +mod utils; + +use crate::bogons::asn::load_bogon_asns; +use crate::bogons::prefix::load_bogon_prefixes; +use anyhow::Result; +pub use asn::BogonAsn; +use ipnet::IpNet; +pub use prefix::BogonPrefix; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Bogons { + pub prefixes: Vec, + pub asns: Vec, +} + +impl Bogons { + pub fn new() -> Result { + Ok(Bogons { + prefixes: load_bogon_prefixes()?, + asns: load_bogon_asns()?, + }) + } + + /// Check if a given string matches a bogon prefix or ASN. + pub fn matches_str(&self, s: &str) -> bool { + match s.parse::() { + Ok(ip) => self.is_bogon_prefix(&ip), + Err(_) => match s.parse::() { + Ok(asn) => self.is_bogon_asn(asn), + Err(_) => false, + }, + } + } + + /// Check if a given IP prefix is a bogon prefix. + pub fn is_bogon_prefix(&self, prefix: &IpNet) -> bool { + self.prefixes + .iter() + .any(|bogon_prefix| bogon_prefix.matches(prefix)) + } + + /// Check if a given ASN is a bogon ASN. + pub fn is_bogon_asn(&self, asn: u32) -> bool { + self.asns.iter().any(|bogon_asn| bogon_asn.matches(asn)) + } +} diff --git a/src/bogons/prefix.rs b/src/bogons/prefix.rs new file mode 100644 index 0000000..ef0e437 --- /dev/null +++ b/src/bogons/prefix.rs @@ -0,0 +1,110 @@ +use crate::bogons::utils::{find_rfc_links, remove_footnotes, replace_commas_in_quotes}; +use anyhow::{anyhow, Result}; +use chrono::NaiveDate; +use ipnet::IpNet; +use serde::{Deserialize, Serialize}; + +const IANA_PREFIX_SPECIAL_REGISTRY: [&str; 2] = [ + "https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry-1.csv", + "https://www.iana.org/assignments/iana-ipv6-special-registry/iana-ipv6-special-registry-1.csv", +]; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct BogonPrefix { + pub prefix: IpNet, + pub description: String, + pub rfc_urls: Vec, + pub allocation_date: NaiveDate, + pub termination_date: Option, + pub source: bool, + pub destination: bool, + pub forwardable: bool, + pub global: bool, + pub reserved: bool, +} + +impl BogonPrefix { + pub fn matches(&self, prefix: &IpNet) -> bool { + // if address family different, it is not a match + match (self.prefix, prefix) { + (IpNet::V4(_), IpNet::V6(_)) | (IpNet::V6(_), IpNet::V4(_)) => return false, + _ => {} + } + self.prefix.contains(prefix) + } +} + +pub fn load_bogon_prefixes() -> Result> { + let mut bogons = Vec::new(); + for iana_link in IANA_PREFIX_SPECIAL_REGISTRY { + let mut prev_line: Option = None; + for line in oneio::read_lines(iana_link)? { + let mut text = line.ok().ok_or(anyhow!("error reading line"))?; + if let Some(t) = &prev_line { + text = format!("{},{}", t, text); + } + if text.trim() == "" || text.starts_with("Address") { + continue; + } + // remove triple quotes + text = text.replace("\"\"\"", "\""); + // remove footnote + text = remove_footnotes(text); + // replace commas in quotes + text = replace_commas_in_quotes(text.as_str()); + + let splits: Vec<&str> = text.split(',').map(|x| x.trim()).collect(); + if splits.len() != 10 { + if prev_line.is_some() { + return Err(anyhow!("row missing fields: {}", text.as_str())); + } + prev_line = Some(text); + continue; + } + prev_line = None; + + let prefixes = splits[0] + .replace('\"', "") + .split(" ") + .map(|x| x.trim().parse::().unwrap()) + .collect::>(); + let description = splits[1].to_string(); + let rfc_urls = find_rfc_links(splits[2]); + let allocation_date = + NaiveDate::parse_from_str(format!("{}-01", splits[3]).as_str(), "%Y-%m-%d")?; + let termination_date = match format!("{}-01", splits[3]).as_str() { + "" | "N/A" => None, + d => Some(NaiveDate::parse_from_str(d, "%Y-%m-%d")?), + }; + let source = matches!(splits[5].to_lowercase().as_str(), "true"); + let destination = matches!(splits[6].to_lowercase().as_str(), "true"); + let forwardable = matches!(splits[7].to_lowercase().as_str(), "true"); + let global = matches!(splits[8].to_lowercase().as_str(), "true"); + let reserved = matches!(splits[9].to_lowercase().as_str(), "true"); + bogons.extend(prefixes.into_iter().map(|prefix| BogonPrefix { + prefix, + description: description.clone(), + rfc_urls: rfc_urls.clone(), + allocation_date, + termination_date, + source, + destination, + forwardable, + global, + reserved, + })); + } + } + Ok(bogons) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_load_bogon_prefixes() { + let bogons = load_bogon_prefixes().unwrap(); + dbg!(bogons); + } +} diff --git a/src/bogons/utils.rs b/src/bogons/utils.rs new file mode 100644 index 0000000..1d97c3f --- /dev/null +++ b/src/bogons/utils.rs @@ -0,0 +1,29 @@ +use regex::Regex; + +pub(crate) fn remove_footnotes(s: String) -> String { + let re = Regex::new(r"\[\d+\]").unwrap(); + let result = re.replace_all(s.as_str(), ""); + result.into_owned() +} + +pub(crate) fn replace_commas_in_quotes(s: &str) -> String { + let re = Regex::new(r#""[^"]*""#).unwrap(); + let result = re.replace_all(s, |caps: ®ex::Captures| { + let matched = caps.get(0).unwrap().as_str(); + matched.replace(",", "") + }); + result.into_owned() +} + +pub(crate) fn find_rfc_links(s: &str) -> Vec { + let re = Regex::new(r"\[RFC(\d+)\]").unwrap(); + let mut links = Vec::new(); + + for cap in re.captures_iter(s) { + let rfc_number = &cap[1]; + let link = format!("https://datatracker.ietf.org/doc/html/rfc{}", rfc_number); + links.push(link); + } + + links +} diff --git a/src/lib.rs b/src/lib.rs index be1f363..f87d126 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -175,6 +175,29 @@ //! assert_eq!(rpki.validate(&prefix, 13335), RpkiValidation::Valid); //! ``` //! +//! ## Bogon utilities +//! +//! We provide a utility to check if an IP prefix or an ASN is a bogon. +//! +//! ### Data sources +//! +//! IANA special registries: +//! * IPv4: https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry.xhtml +//! * IPv6: https://www.iana.org/assignments/iana-ipv6-special-registry/iana-ipv6-special-registry.xhtml +//! * ASN: https://www.iana.org/assignments/iana-as-numbers-special-registry/iana-as-numbers-special-registry.xhtml +//! +//! ### Usage Examples +//! +//! ``` +//! use bgpkit_commons::bogons::Bogons; +//! +//! let bogons = Bogons::new().unwrap(); +//! assert!(bogons.matches_str("10.0.0.0/9")); +//! assert!(bogons.matches_str("112")); +//! assert!(bogons.is_bogon_prefix(&"2001::/24".parse().unwrap())); +//! assert!(bogons.is_bogon_asn(65535)); +//! ``` +//! //! ## Feature Flags //! //! - `rustls`: use rustls instead of native-tls for the underlying HTTPS requests @@ -219,6 +242,7 @@ )] pub mod asnames; +pub mod bogons; pub mod collectors; pub mod countries; pub mod rpki;