use crate::model::metadata::ProviderMetadata;
use async_trait::async_trait;
use hickory_resolver::{
error::ResolveErrorKind, name_server::TokioConnectionProvider, AsyncResolver,
};
use sectxtlib::SecurityTxt;
use url::Url;
use walker_common::fetcher::{self, Fetcher, Json};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("failed to parse security.txt: {0}")]
SecurityTxt(#[from] sectxtlib::ParseError),
#[error("failed to fetch: {0}")]
Fetch(#[from] fetcher::Error),
#[error("unable to discover metadata")]
NotFound,
#[error("DNS request failed: {0}")]
Dns(#[from] hickory_resolver::error::ResolveError),
}
#[async_trait(?Send)]
pub trait MetadataSource {
async fn load_metadata(&self, fetcher: &Fetcher) -> Result<ProviderMetadata, Error>;
}
#[async_trait(?Send)]
impl MetadataSource for Url {
async fn load_metadata(&self, fetcher: &Fetcher) -> Result<ProviderMetadata, Error> {
Ok(fetcher
.fetch::<Json<ProviderMetadata>>(self.clone())
.await?
.into_inner())
}
}
#[derive(Clone)]
pub struct MetadataRetriever {
pub base_url: String,
}
impl MetadataRetriever {
pub fn new(base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into(),
}
}
pub async fn get_metadata_url_from_security_text(
fetcher: &Fetcher,
host_url: String,
) -> Result<Option<Url>, Error> {
let Some(text) = fetcher.fetch::<Option<String>>(host_url).await? else {
return Ok(None);
};
let text = SecurityTxt::parse(&text)?;
let url = text
.extension
.into_iter()
.filter(|ext| ext.name == "csaf")
.filter_map(|ext| Url::parse(&ext.value).ok())
.find(|url| url.scheme() == "https");
Ok(url)
}
pub async fn approach_full_url(
&self,
fetcher: &Fetcher,
) -> Result<Option<ProviderMetadata>, Error> {
let Ok(url) = Url::parse(&self.base_url) else {
return Ok(None);
};
Ok(Some(
fetcher
.fetch::<Json<ProviderMetadata>>(url)
.await?
.into_inner(),
))
}
pub async fn approach_well_known(
&self,
fetcher: &Fetcher,
) -> Result<Option<ProviderMetadata>, Error> {
let url = format!(
"https://{}/.well-known/csaf/provider-metadata.json",
self.base_url,
);
log::debug!("Trying to retrieve by well-known approach: {url}");
Ok(fetcher
.fetch::<Option<Json<ProviderMetadata>>>(url)
.await?
.map(|metadata| metadata.into_inner()))
}
pub async fn approach_dns(&self, fetcher: &Fetcher) -> Result<Option<ProviderMetadata>, Error> {
let host = format!("csaf.data.security.{}", self.base_url);
log::debug!("Trying to retrieve by DNS approach: {host}");
#[cfg(not(any(unix, target_os = "windows")))]
let resolver = AsyncResolver::new(
hickory_resolver::config::ResolverConfig::default(),
hickory_resolver::config::ResolverOpts::default(),
TokioConnectionProvider::default(),
)?;
#[cfg(any(unix, target_os = "windows"))]
let resolver = AsyncResolver::from_system_conf(TokioConnectionProvider::default())?;
match resolver.lookup_ip(&host).await {
Ok(result) => {
if result.iter().count() == 0 {
return Ok(None);
}
}
Err(err) if matches!(err.kind(), ResolveErrorKind::NoRecordsFound { .. }) => {
return Ok(None);
}
Err(err) => {
return Err(err.into());
}
}
let url = format!("https://{host}");
Ok(fetcher
.fetch::<Option<Json<ProviderMetadata>>>(url)
.await?
.map(|value| value.into_inner()))
}
pub async fn approach_security_txt(
&self,
fetcher: &Fetcher,
path: &str,
) -> Result<Option<ProviderMetadata>, Error> {
let url = format!("https://{}/{path}", self.base_url);
log::debug!("Trying to retrieve by security.txt approach: {url}");
if let Some(url) = Self::get_metadata_url_from_security_text(fetcher, url).await? {
Ok(Some(
fetcher
.fetch::<Json<ProviderMetadata>>(url)
.await?
.into_inner(),
))
} else {
Ok(None)
}
}
}
#[async_trait(?Send)]
impl MetadataSource for MetadataRetriever {
async fn load_metadata(&self, fetcher: &Fetcher) -> Result<ProviderMetadata, Error> {
if let Some(metadata) = self.approach_full_url(fetcher).await? {
return Ok(metadata);
}
if let Some(metadata) = self.approach_well_known(fetcher).await? {
return Ok(metadata);
}
if let Some(metadata) = self
.approach_security_txt(fetcher, ".well-known/security.txt")
.await?
{
return Ok(metadata);
}
if let Some(metadata) = self.approach_security_txt(fetcher, "security.txt").await? {
return Ok(metadata);
}
if let Some(metadata) = self.approach_dns(fetcher).await? {
return Ok(metadata);
}
Err(Error::NotFound)
}
}
#[cfg(test)]
mod test {
use super::*;
use walker_common::fetcher::FetcherOptions;
#[tokio::test]
async fn test_dns_fail() {
let fetcher = Fetcher::new(FetcherOptions::default()).await.unwrap();
let retriever = MetadataRetriever::new("this-should-not-exist");
let result = retriever.approach_dns(&fetcher).await.unwrap();
assert!(result.is_none());
}
#[ignore]
#[tokio::test]
async fn test_dns_success() {
let fetcher = Fetcher::new(FetcherOptions::default()).await.unwrap();
let retriever = MetadataRetriever::new("nozominetworks.com");
let result = retriever.approach_dns(&fetcher).await.unwrap();
assert!(result.is_some());
}
}