use async_trait::async_trait;
use serde::Deserialize;
use crate::enrichment::{AsnInfo, GeoInfo, IntelEnrichment, ServiceInfo};
use crate::sources::IntelSource;
const BASE_URL: &str = "https://api.shodan.io";
pub struct ShodanSource {
client: reqwest::Client,
api_key: Option<String>,
}
impl ShodanSource {
pub fn new(client: reqwest::Client, api_key: Option<String>) -> Self {
Self { client, api_key }
}
}
#[async_trait]
impl IntelSource for ShodanSource {
fn name(&self) -> &'static str {
"shodan"
}
async fn query_ip(&self, ip: &str) -> anyhow::Result<IntelEnrichment> {
let key = self
.api_key
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Shodan requires an API key"))?;
let mut url = url::Url::parse(BASE_URL)?;
{
let mut segs = url.path_segments_mut().map_err(|_| anyhow::anyhow!("invalid base URL"))?;
segs.push("shodan");
segs.push("host");
segs.push(ip);
}
url.query_pairs_mut().append_pair("key", key);
let resp = self.client.get(url).send().await?.error_for_status()?;
let body: ShodanResp = gossan_core::net::bounded_json(resp, super::MAX_INTEL_JSON_BYTES).await?;
let mut enrichment = IntelEnrichment::new("shodan", "ip", ip);
if let Some(ports) = body.ports {
for port in ports {
enrichment.services.push(ServiceInfo {
port,
protocol: "tcp".to_string(),
banner: None,
product: None,
});
}
}
if let (Some(asn), Some(ref org)) = (body.asn, &body.org) {
enrichment.asn = Some(AsnInfo {
asn: asn.to_string(),
org: Some(org.clone()),
domain: None,
});
}
if let (Some(country_name), Some(city)) = (body.country_name, body.city) {
enrichment.geo = Some(GeoInfo {
country: Some(country_name),
city: Some(city),
region: None,
});
}
if let Some(tags) = body.tags {
enrichment.tags = tags;
}
enrichment
.raw
.insert("os".to_string(), serde_json::to_value(body.os)?);
enrichment
.raw
.insert("isp".to_string(), serde_json::to_value(body.isp)?);
Ok(enrichment)
}
async fn query_domain(&self, domain: &str) -> anyhow::Result<IntelEnrichment> {
let key = self
.api_key
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Shodan requires an API key"))?;
let mut url = url::Url::parse(BASE_URL)?;
{
let mut segs = url.path_segments_mut().map_err(|_| anyhow::anyhow!("invalid base URL"))?;
segs.push("dns");
segs.push("domain");
segs.push(domain);
}
url.query_pairs_mut().append_pair("key", key);
let resp = self.client.get(url).send().await?.error_for_status()?;
let body: ShodanDnsResp = gossan_core::net::bounded_json(resp, super::MAX_INTEL_JSON_BYTES).await?;
let mut enrichment = IntelEnrichment::new("shodan", "domain", domain);
for sub in body.subdomains.unwrap_or_default() {
enrichment.passive_dns.push(crate::enrichment::DnsRecord {
record_type: "A".to_string(),
value: format!("{sub}.{domain}"),
first_seen: None,
last_seen: None,
});
}
Ok(enrichment)
}
}
#[derive(Debug, Deserialize)]
struct ShodanResp {
#[serde(default)]
ports: Option<Vec<u16>>,
#[serde(default)]
asn: Option<String>,
#[serde(default)]
org: Option<String>,
#[serde(default)]
country_name: Option<String>,
#[serde(default)]
city: Option<String>,
#[serde(default)]
tags: Option<Vec<String>>,
#[serde(default)]
os: Option<String>,
#[serde(default)]
isp: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ShodanDnsResp {
#[serde(default)]
subdomains: Option<Vec<String>>,
}