use serde::{Deserialize, Serialize};
use std::net::IpAddr;
use url::Url;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum DiscoverySource {
Seed,
CertificateTransparency,
DnsBruteforce,
PassiveDns,
PortScan,
TechStack,
JsAnalysis,
HiddenProbe,
Crawl,
RapidDns,
AlienVault,
UrlScan,
CommonCrawl,
VirusTotal,
SecurityTrails,
Shodan,
GitHub,
Censys,
BinaryEdge,
FullHunt,
Chaos,
Bevigil,
Fofa,
HunterIo,
Netlas,
ZoomEye,
C99,
Quake,
ThreatBook,
Anubis,
Asn,
AsnLookup,
ScmMapping,
DnsDumpster,
CloudDiscovery,
BufferOver,
GoogleCt,
FacebookCt,
AppleCt,
CloudflareCt,
DigiCertCt,
SectigoCt,
IdenTrustCt,
EntrustCt,
GoDaddyCt,
AmazonCt,
PassiveTotal,
Spyse,
Dnslytics,
ThreatMiner,
PtrArchive,
Riddler,
SiteDossier,
SonarSearch,
Circl,
Mnemonic,
FarsightDnsdb,
Sublist3r,
Omnisint,
Digitorus,
Columbus,
Crobat,
ThreatCrowd,
Rook,
Pugrecon,
SubdomainCenter,
Synapsint,
Jter,
Bing,
Baidu,
DuckDuckGo,
Yahoo,
Exalead,
Ask,
GreyNoise,
IpInfo,
ViewDns,
ZoneWalk,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DomainTarget {
pub domain: String,
pub source: DiscoverySource,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RepositoryTarget {
pub url: Url,
pub service: ScmService,
pub source: DiscoverySource,
pub branch: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum ScmService {
GitHub,
GitLab,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HostTarget {
pub ip: IpAddr,
pub domain: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum Protocol {
Tcp,
Udp,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceTarget {
pub host: HostTarget,
pub port: u16,
pub protocol: Protocol,
pub banner: Option<String>,
pub tls: bool,
}
impl ServiceTarget {
#[must_use]
pub fn is_web(&self) -> bool {
self.banner
.as_deref()
.is_some_and(|b| b.starts_with("HTTP"))
|| matches!(
self.port,
80 | 443
| 3000
| 3443
| 4443
| 4433
| 5000
| 5443
| 7443
| 8000
| 8080
| 8443
| 8888
| 9000
| 9090
| 9443
| 10443
)
}
#[must_use]
pub fn base_url(&self) -> Option<Url> {
let scheme = if self.tls || self.port == 443 || self.port == 8443 {
"https"
} else {
"http"
};
let host = match &self.host.domain {
Some(d) => d.clone(),
None => match self.host.ip {
IpAddr::V6(v6) => format!("[{v6}]"),
IpAddr::V4(v4) => v4.to_string(),
},
};
let port_str = match (scheme, self.port) {
("https", 443) | ("http", 80) => String::new(),
_ => format!(":{}", self.port),
};
Url::parse(&format!("{scheme}://{host}{port_str}")).ok()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Technology {
pub name: String,
pub version: Option<String>,
pub category: TechCategory,
pub confidence: u8,
}
#[allow(clippy::doc_markdown)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum TechCategory {
Cms,
Framework,
Language,
Server,
Cdn,
Analytics,
Security,
Database,
Os,
Other,
}
#[allow(clippy::doc_markdown)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebAssetTarget {
pub url: Url,
pub service: ServiceTarget,
pub tech: Vec<Technology>,
pub status: u16,
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub favicon_hash: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body_hash: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub forms: Vec<DiscoveredForm>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub params: Vec<DiscoveredParam>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscoveredForm {
pub action: String,
pub method: String,
pub inputs: Vec<(String, String)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscoveredParam {
pub name: String,
pub location: ParamLocation,
pub source: ParamSource,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ParamLocation {
Query,
Body,
Path,
Header,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ParamSource {
HtmlForm,
UrlObserved,
BruteForce,
ApiSpec,
JsAnalysis,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum Target {
Domain(DomainTarget),
Host(HostTarget),
Service(ServiceTarget),
Web(Box<WebAssetTarget>),
Network(NetworkTarget),
Repository(RepositoryTarget),
InternalPackage(InternalPackageTarget),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InternalPackageTarget {
pub name: String,
pub source_repo: Url,
pub ecosystem: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetworkTarget {
pub cidr: String,
pub source: DiscoverySource,
}
impl Target {
#[must_use]
pub fn domain(&self) -> Option<&str> {
match self {
Target::Domain(d) => Some(&d.domain),
Target::Host(h) => h.domain.as_deref(),
Target::Service(s) => s.host.domain.as_deref(),
Target::Web(w) => w
.service
.host
.domain
.as_deref()
.or_else(|| w.url.host_str()),
Target::Repository(r) => r.url.host_str(),
Target::Network(_) | Target::InternalPackage(_) => None,
}
}
#[must_use]
pub fn ip(&self) -> Option<IpAddr> {
match self {
Target::Host(h) => Some(h.ip),
Target::Service(s) => Some(s.host.ip),
Target::Web(w) => Some(w.service.host.ip),
_ => None,
}
}
#[must_use]
pub fn base_url(&self) -> Option<String> {
match self {
Target::Domain(d) => Some(format!("https://{}/", d.domain)),
Target::Host(h) => Some(match h.ip {
IpAddr::V6(v6) => format!("http://[{v6}]/"),
IpAddr::V4(v4) => format!("http://{v4}/"),
}),
Target::Service(s) => s.base_url().map(|u| u.to_string()),
Target::Web(w) => {
let mut u = w.url.clone();
u.set_path("/");
u.set_query(None);
u.set_fragment(None);
Some(u.to_string())
}
Target::Repository(r) => Some(r.url.to_string()),
Target::Network(_) | Target::InternalPackage(_) => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn host(domain: Option<&str>) -> HostTarget {
HostTarget {
ip: "203.0.113.10".parse().unwrap(),
domain: domain.map(str::to_string),
}
}
fn service(port: u16, tls: bool, banner: Option<&str>, domain: Option<&str>) -> ServiceTarget {
ServiceTarget {
host: host(domain),
port,
protocol: Protocol::Tcp,
banner: banner.map(str::to_string),
tls,
}
}
#[test]
fn service_is_web_for_common_ports() {
for port in [80, 443, 8080, 8443, 8000, 8888] {
assert!(
service(port, false, None, Some("example.com")).is_web(),
"port {port}"
);
}
}
#[test]
fn service_is_web_for_http_banner_even_on_nonstandard_port() {
assert!(service(12345, false, Some("HTTP/1.1 200 OK"), Some("example.com")).is_web());
}
#[test]
fn service_is_not_web_for_non_http_ports_without_banner_hint() {
assert!(!service(22, false, Some("SSH-2.0-OpenSSH_9.7"), Some("example.com")).is_web());
}
#[test]
fn base_url_uses_https_for_tls_services() {
let url = service(9443, true, None, Some("example.com"))
.base_url()
.unwrap();
assert_eq!(url.as_str(), "https://example.com:9443/");
}
#[test]
fn base_url_uses_https_for_implicit_tls_ports() {
let url = service(8443, false, None, Some("example.com"))
.base_url()
.unwrap();
assert_eq!(url.as_str(), "https://example.com:8443/");
}
#[test]
fn base_url_omits_default_ports() {
assert_eq!(
service(80, false, None, Some("example.com"))
.base_url()
.unwrap()
.as_str(),
"http://example.com/"
);
assert_eq!(
service(443, false, None, Some("example.com"))
.base_url()
.unwrap()
.as_str(),
"https://example.com/"
);
}
#[test]
fn base_url_falls_back_to_ip_when_domain_missing() {
let url = service(8080, false, None, None).base_url().unwrap();
assert_eq!(url.as_str(), "http://203.0.113.10:8080/");
}
#[test]
fn base_url_brackets_ipv6_service_host() {
let mut svc = service(8080, false, None, None);
svc.host.ip = "2001:db8::1".parse().unwrap();
let url = svc.base_url().unwrap();
assert_eq!(url.as_str(), "http://[2001:db8::1]:8080/");
}
#[test]
fn target_host_base_url_brackets_ipv6() {
let t = Target::Host(HostTarget {
ip: "2001:db8::1".parse().unwrap(),
domain: None,
});
assert_eq!(t.base_url().as_deref(), Some("http://[2001:db8::1]/"));
}
#[test]
fn target_domain_returns_expected_value_for_each_variant() {
let domain = Target::Domain(DomainTarget {
domain: "example.com".into(),
source: DiscoverySource::Seed,
});
let host = Target::Host(host(Some("host.example.com")));
let svc = Target::Service(service(443, true, None, Some("svc.example.com")));
let web = Target::Web(Box::new(WebAssetTarget {
url: Url::parse("https://web.example.com/admin").unwrap(),
service: service(443, true, None, Some("web.example.com")),
tech: vec![],
status: 200,
title: Some("Admin".into()),
favicon_hash: Some(123),
body_hash: Some("abcd".into()),
forms: vec![],
params: vec![],
}));
assert_eq!(domain.domain(), Some("example.com"));
assert_eq!(host.domain(), Some("host.example.com"));
assert_eq!(svc.domain(), Some("svc.example.com"));
assert_eq!(web.domain(), Some("web.example.com"));
}
#[test]
fn target_domain_is_none_for_host_and_service_without_domain() {
assert_eq!(Target::Host(host(None)).domain(), None);
assert_eq!(
Target::Service(service(22, false, None, None)).domain(),
None
);
}
#[test]
fn protocol_serializes_lowercase() {
assert_eq!(serde_json::to_value(Protocol::Tcp).unwrap(), json!("tcp"));
assert_eq!(serde_json::to_value(Protocol::Udp).unwrap(), json!("udp"));
}
#[test]
fn discovery_source_serializes_snake_case() {
assert_eq!(
serde_json::to_value(DiscoverySource::CertificateTransparency).unwrap(),
json!("certificate_transparency")
);
assert_eq!(
serde_json::to_value(DiscoverySource::HiddenProbe).unwrap(),
json!("hidden_probe")
);
}
#[test]
fn target_serializes_with_kind_tag() {
let target = Target::Domain(DomainTarget {
domain: "example.com".into(),
source: DiscoverySource::UrlScan,
});
let value = serde_json::to_value(target).unwrap();
assert_eq!(value["kind"], json!("domain"));
assert_eq!(value["source"], json!("url_scan"));
}
}