use anyhow::Context;
use gossan_core::reqwest::{Client, Url};
use std::collections::HashMap;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct OwnershipAttrs {
pub organization: Option<String>,
pub email: Option<String>,
pub registrant: Option<String>,
}
impl OwnershipAttrs {
pub fn correlation_key(&self) -> Option<String> {
if let Some(email) = self.email.as_ref().map(|e| e.trim().to_lowercase()) {
if !email.is_empty() && email.contains('@') {
return Some(format!("email:{email}"));
}
}
if let Some(org) = self.organization.as_ref().map(|o| normalize_org(o)) {
if !org.is_empty() && !is_weak_org(&org) {
return Some(format!("org:{org}"));
}
}
if let Some(name) = self.registrant.as_ref().map(|n| normalize_org(n)) {
if !name.is_empty() && !is_weak_org(&name) {
return Some(format!("registrant:{name}"));
}
}
None
}
pub fn is_empty(&self) -> bool {
self.organization.is_none() && self.email.is_none() && self.registrant.is_none()
}
}
fn normalize_org(value: &str) -> String {
value
.trim()
.to_lowercase()
.chars()
.filter(|c| c.is_ascii_alphanumeric() || c.is_whitespace())
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn is_weak_org(org: &str) -> bool {
matches!(
org,
"n/a"
| "na"
| "none"
| "not applicable"
| "redacted for privacy"
| "privacy protected"
| "data protected"
| "withheld for privacy"
| "domains by proxy"
| "whois privacy"
| "privacygodaddycom"
| "contact privacy inc"
) || org.contains("privacy")
|| org.contains("redacted")
|| org.contains("proxy")
}
pub async fn get_sibling_domains(client: &Client, domain: &str) -> anyhow::Result<Vec<String>> {
get_sibling_domains_with_base(client, domain, "https://api.hackertarget.com").await
}
pub async fn get_sibling_domains_with_base(
client: &Client,
domain: &str,
base: &str,
) -> anyhow::Result<Vec<String>> {
let _attrs = fetch_ownership_attrs(client, domain, base).await?;
Ok(Vec::new())
}
pub async fn fetch_ownership_attrs(
client: &Client,
domain: &str,
base: &str,
) -> anyhow::Result<OwnershipAttrs> {
let mut attrs = match fetch_whois_text(client, domain, base).await {
Ok(whois) => parse_whois_ownership(&whois),
Err(e) => {
tracing::debug!(error = %e, domain, "whois ownership fetch failed; trying RDAP");
OwnershipAttrs::default()
}
};
if attrs.correlation_key().is_none() {
match fetch_rdap_text(client, domain).await {
Ok(rdap) => {
let rdap_attrs = parse_rdap_ownership(&rdap);
if attrs.organization.is_none() {
attrs.organization = rdap_attrs.organization;
}
if attrs.email.is_none() {
attrs.email = rdap_attrs.email;
}
if attrs.registrant.is_none() {
attrs.registrant = rdap_attrs.registrant;
}
}
Err(e) => {
if attrs.is_empty() {
return Err(e).context(format!("ownership lookup failed for {domain}"));
}
tracing::debug!(error = %e, domain, "rdap ownership fetch failed");
}
}
}
Ok(attrs)
}
async fn fetch_whois_text(client: &Client, domain: &str, base: &str) -> anyhow::Result<String> {
let mut url = Url::parse(&format!("{}/whois/", base.trim_end_matches('/')))?;
url.query_pairs_mut().append_pair("q", domain);
let r = client.get(url.as_str()).send().await?;
Ok(gossan_core::net::bounded_text(r, crate::MAX_HORIZONTAL_TEXT_BYTES).await?)
}
async fn fetch_rdap_text(client: &Client, domain: &str) -> anyhow::Result<String> {
let url = format!("https://rdap.org/domain/{}", domain.trim_end_matches('.'));
let r = client.get(url).send().await?;
Ok(gossan_core::net::bounded_text(r, crate::MAX_HORIZONTAL_TEXT_BYTES).await?)
}
pub async fn group_siblings_by_ownership(
client: &Client,
domains: &[String],
base: &str,
) -> anyhow::Result<HashMap<String, Vec<String>>> {
let mut by_key: HashMap<String, Vec<String>> = HashMap::new();
for domain in domains {
let attrs = match fetch_ownership_attrs(client, domain, base).await {
Ok(attrs) => attrs,
Err(e) => {
tracing::warn!(error = %e, domain, "ownership attrs unavailable; skipping domain");
continue;
}
};
if let Some(key) = attrs.correlation_key() {
by_key.entry(key).or_default().push(domain.clone());
}
}
by_key.retain(|_, v| v.len() > 1);
Ok(by_key)
}
pub fn parse_whois_ownership(resp: &str) -> OwnershipAttrs {
let mut attrs = OwnershipAttrs::default();
for raw in resp.lines() {
let line = raw.trim();
let Some((key, value)) = line.split_once(':') else {
continue;
};
let key = key.trim().to_ascii_lowercase();
let value = value.trim();
if value.is_empty() {
continue;
}
match key.as_str() {
"orgname"
| "organization"
| "organisation"
| "registrant organization"
| "registrant organisation"
| "owner"
| "org" => {
if attrs.organization.is_none() {
attrs.organization = Some(value.to_string());
}
}
"registrant email"
| "admin email"
| "tech email"
| "e-mail"
| "email" => {
if attrs.email.is_none() && value.contains('@') {
attrs.email = Some(value.to_string());
}
}
"registrant name" | "registrant" | "name" => {
if attrs.registrant.is_none() {
attrs.registrant = Some(value.to_string());
}
}
_ => {}
}
}
attrs
}
pub fn parse_rdap_ownership(resp: &str) -> OwnershipAttrs {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(resp) {
let mut attrs = OwnershipAttrs::default();
extract_rdap_entities(&v, &mut attrs);
if !attrs.is_empty() {
return attrs;
}
}
parse_whois_ownership(resp)
}
fn extract_rdap_entities(value: &serde_json::Value, attrs: &mut OwnershipAttrs) {
match value {
serde_json::Value::Object(map) => {
if let Some(fn_) = map.get("fn").and_then(|v| v.as_str()) {
if attrs.registrant.is_none() {
attrs.registrant = Some(fn_.to_string());
}
}
if let Some(org) = map.get("org").and_then(|v| v.as_str()) {
if attrs.organization.is_none() {
attrs.organization = Some(org.to_string());
}
}
if let Some(email) = map.get("email").and_then(|v| v.as_str()) {
if attrs.email.is_none() {
attrs.email = Some(email.to_string());
}
}
if let Some(vcard) = map.get("vcardArray").and_then(|v| v.as_array()) {
if let Some(entries) = vcard.get(1).and_then(|v| v.as_array()) {
for entry in entries {
let Some(arr) = entry.as_array() else { continue };
let Some(kind) = arr.first().and_then(|v| v.as_str()) else { continue };
let Some(text) = arr.get(3).and_then(|v| v.as_str()) else { continue };
match kind {
"fn" if attrs.registrant.is_none() => {
attrs.registrant = Some(text.to_string());
}
"org" if attrs.organization.is_none() => {
attrs.organization = Some(text.to_string());
}
"email" if attrs.email.is_none() => {
attrs.email = Some(text.to_string());
}
_ => {}
}
}
}
}
for child in map.values() {
extract_rdap_entities(child, attrs);
}
}
serde_json::Value::Array(items) => {
for child in items {
extract_rdap_entities(child, attrs);
}
}
_ => {}
}
}
pub fn parse_reverseip_response(resp: &str) -> Vec<String> {
resp.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| l.trim().to_string())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_whois_extracts_org_and_email() {
let resp = "Domain Name: EXAMPLE.COM\nRegistrant Organization: Example Org LLC\nRegistrant Email: owner@example.com\n";
let attrs = parse_whois_ownership(resp);
assert_eq!(attrs.organization.as_deref(), Some("Example Org LLC"));
assert_eq!(attrs.email.as_deref(), Some("owner@example.com"));
assert!(attrs.correlation_key().unwrap().starts_with("email:"));
}
#[test]
fn privacy_org_is_weak_and_ignored() {
let resp = "Organization: REDACTED FOR PRIVACY\n";
let attrs = parse_whois_ownership(resp);
assert!(attrs.correlation_key().is_none());
}
#[test]
fn parse_rdap_vcard_email() {
let json = r#"{
"entities": [{
"vcardArray": ["vcard", [
["fn", {}, "text", "Alice"],
["email", {}, "text", "alice@example.com"]
]]
}]
}"#;
let attrs = parse_rdap_ownership(json);
assert_eq!(attrs.email.as_deref(), Some("alice@example.com"));
assert_eq!(attrs.registrant.as_deref(), Some("Alice"));
}
#[test]
fn parse_reverseip_handles_empty_and_lines() {
let resp = "example.com\n\nsub.example.com\n ";
let v = parse_reverseip_response(resp);
assert_eq!(
v,
vec!["example.com".to_string(), "sub.example.com".to_string()]
);
let empty = "";
let v2 = parse_reverseip_response(empty);
assert!(v2.is_empty());
}
}