use serde::Deserialize;
use crate::error::{Result, SeerError};
pub(crate) struct Source {
pub name: &'static str,
pub base: String,
pub build_url: fn(&str, &str) -> String,
pub parse: fn(&[u8]) -> Result<Vec<String>>,
}
pub(crate) fn default_sources() -> Vec<Source> {
vec![
Source {
name: "crt.sh (Certificate Transparency)",
base: "https://crt.sh".to_string(),
build_url: crtsh_url,
parse: parse_crtsh,
},
Source {
name: "certspotter (Certificate Transparency)",
base: "https://api.certspotter.com".to_string(),
build_url: certspotter_url,
parse: parse_certspotter,
},
]
}
fn crtsh_url(base: &str, domain: &str) -> String {
format!("{}/?q=%25.{}&output=json", base, domain)
}
fn certspotter_url(base: &str, domain: &str) -> String {
format!(
"{}/v1/issuances?domain={}&include_subdomains=true&expand=dns_names",
base, domain
)
}
#[derive(Debug, Deserialize)]
struct CrtShEntry {
#[serde(default)]
common_name: String,
#[serde(default)]
name_value: Option<String>,
}
pub(crate) fn parse_crtsh(body: &[u8]) -> Result<Vec<String>> {
let entries: Vec<CrtShEntry> = serde_json::from_slice(body)
.map_err(|e| SeerError::HttpError(format!("Failed to parse crt.sh response: {}", e)))?;
let mut names = Vec::new();
for entry in &entries {
names.extend(entry.common_name.split('\n').map(str::to_string));
if let Some(ref name_value) = entry.name_value {
names.extend(name_value.split('\n').map(str::to_string));
}
}
Ok(names)
}
#[derive(Debug, Deserialize)]
struct CertSpotterIssuance {
#[serde(default)]
dns_names: Vec<String>,
}
pub(crate) fn parse_certspotter(body: &[u8]) -> Result<Vec<String>> {
let issuances: Vec<CertSpotterIssuance> = serde_json::from_slice(body).map_err(|e| {
SeerError::HttpError(format!("Failed to parse certspotter response: {}", e))
})?;
Ok(issuances.into_iter().flat_map(|i| i.dns_names).collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn crtsh_url_encodes_wildcard() {
assert_eq!(
crtsh_url("https://crt.sh", "example.com"),
"https://crt.sh/?q=%25.example.com&output=json"
);
}
#[test]
fn certspotter_url_includes_subdomains() {
assert_eq!(
certspotter_url("https://api.certspotter.com", "example.com"),
"https://api.certspotter.com/v1/issuances?domain=example.com&include_subdomains=true&expand=dns_names"
);
}
#[test]
fn parse_crtsh_extracts_newline_separated_names() {
let body =
br#"[{"common_name":"example.com","name_value":"api.example.com\nmail.example.com"}]"#;
let names = parse_crtsh(body).unwrap();
assert!(names.contains(&"example.com".to_string()));
assert!(names.contains(&"api.example.com".to_string()));
assert!(names.contains(&"mail.example.com".to_string()));
}
#[test]
fn parse_certspotter_extracts_dns_names() {
let body = br#"[{"dns_names":["zac.app","www.zac.app"]},{"dns_names":["api.zac.app"]}]"#;
let names = parse_certspotter(body).unwrap();
assert!(names.contains(&"zac.app".to_string()));
assert!(names.contains(&"www.zac.app".to_string()));
assert!(names.contains(&"api.zac.app".to_string()));
}
#[test]
fn parsers_error_on_html_body() {
let html = b"<html>502 Bad Gateway</html>";
assert!(parse_crtsh(html).is_err());
assert!(parse_certspotter(html).is_err());
}
}