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 paginate: Option<PaginationSpec>,
}
pub(crate) struct PaginationSpec {
pub url_after: fn(&str, &str, Option<&str>) -> String,
pub parse_page: fn(&[u8]) -> Result<CertSpotterPage>,
pub max_pages: usize,
}
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,
paginate: None,
},
Source {
name: "certspotter (Certificate Transparency)",
base: "https://api.certspotter.com".to_string(),
build_url: certspotter_url,
parse: parse_certspotter,
paginate: Some(PaginationSpec {
url_after: certspotter_url_after,
parse_page: parse_certspotter_page,
max_pages: CERTSPOTTER_MAX_PAGES,
}),
},
]
}
fn crtsh_url(base: &str, domain: &str) -> String {
format!("{}/?q=%25.{}&output=json", base, domain)
}
fn certspotter_url(base: &str, domain: &str) -> String {
certspotter_url_after(base, domain, None)
}
pub(crate) const CERTSPOTTER_MAX_PAGES: usize = 10;
fn certspotter_url_after(base: &str, domain: &str, after: Option<&str>) -> String {
let mut url = format!(
"{}/v1/issuances?domain={}&include_subdomains=true&expand=dns_names",
base, domain
);
if let Some(cursor) = after {
url.push_str("&after=");
url.push_str(cursor);
}
url
}
#[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)]
id: Option<String>,
#[serde(default)]
dns_names: Vec<String>,
}
pub(crate) struct CertSpotterPage {
pub names: Vec<String>,
pub next_cursor: Option<String>,
}
pub(crate) fn parse_certspotter(body: &[u8]) -> Result<Vec<String>> {
Ok(parse_certspotter_page(body)?.names)
}
pub(crate) fn parse_certspotter_page(body: &[u8]) -> Result<CertSpotterPage> {
let issuances: Vec<CertSpotterIssuance> = serde_json::from_slice(body).map_err(|e| {
SeerError::HttpError(format!("Failed to parse certspotter response: {}", e))
})?;
let next_cursor = issuances
.iter()
.filter_map(|i| i.id.as_deref())
.filter_map(|id| id.parse::<u64>().ok().map(|n| (n, id)))
.max_by_key(|(n, _)| *n)
.map(|(_, id)| id.to_string());
let names = issuances.into_iter().flat_map(|i| i.dns_names).collect();
Ok(CertSpotterPage { names, next_cursor })
}
#[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 certspotter_url_after_appends_cursor() {
assert_eq!(
certspotter_url_after("https://api.certspotter.com", "example.com", None),
certspotter_url("https://api.certspotter.com", "example.com"),
);
assert_eq!(
certspotter_url_after("https://api.certspotter.com", "example.com", Some("12345")),
"https://api.certspotter.com/v1/issuances?domain=example.com&include_subdomains=true&expand=dns_names&after=12345"
);
}
#[test]
fn parse_certspotter_page_extracts_names_and_next_cursor() {
let body =
br#"[{"id":"100","dns_names":["a.zac.app"]},{"id":"205","dns_names":["b.zac.app"]}]"#;
let page = parse_certspotter_page(body).unwrap();
assert!(page.names.contains(&"a.zac.app".to_string()));
assert!(page.names.contains(&"b.zac.app".to_string()));
assert_eq!(
page.next_cursor.as_deref(),
Some("205"),
"next cursor is the max issuance id on the page"
);
}
#[test]
fn parse_certspotter_page_empty_has_no_cursor() {
let page = parse_certspotter_page(b"[]").unwrap();
assert!(page.names.is_empty());
assert!(page.next_cursor.is_none());
}
#[test]
fn parse_certspotter_page_uses_numeric_max_not_lexicographic() {
let body =
br#"[{"id":"9","dns_names":["a.zac.app"]},{"id":"100","dns_names":["b.zac.app"]}]"#;
let page = parse_certspotter_page(body).unwrap();
assert_eq!(page.next_cursor.as_deref(), Some("100"));
}
#[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());
}
}