#![forbid(unsafe_code)]
#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::todo,
clippy::unimplemented,
clippy::panic
)
)]
#![allow(
clippy::module_name_repetitions,
clippy::must_use_candidate,
clippy::missing_errors_doc
)]
use async_trait::async_trait;
use futures::StreamExt;
use gossan_core::{
Config, DiscoverySource, DomainTarget, NetworkTarget, ScanInput, Scanner, Target,
};
use secfinding::{Finding, Severity};
use std::sync::Arc;
pub(crate) const MAX_HORIZONTAL_TEXT_BYTES: usize = 1 * 1024 * 1024;
pub mod asn;
pub mod conservative;
pub mod ownership;
pub mod permutation;
pub mod private_ip;
pub mod tld;
pub mod passive_dns;
pub struct HorizontalScanner;
#[async_trait]
impl Scanner for HorizontalScanner {
fn name(&self) -> &'static str {
"horizontal"
}
fn tags(&self) -> &[&'static str] {
&["passive", "network", "intel", "horizontal"]
}
fn accepts(&self, target: &Target) -> bool {
matches!(
target,
Target::Domain(_) | Target::Host(_) | Target::Network(_)
)
}
async fn run(&self, input: ScanInput, config: &Config) -> anyhow::Result<()> {
let client = gossan_core::ScanClient::from_config(config, Arc::clone(&input.resolver))?;
let inbound: Vec<Target> = {
let mut rx = input.target_rx.lock().await;
let mut buf = Vec::new();
while let Some(t) = rx.recv().await {
buf.push(t);
}
buf
};
let mut seed_domains: Vec<String> = Vec::new();
for target in &inbound {
if let Some(ip) = target.ip() {
if let Ok(prefixes) = asn::get_prefixes_for_ip(&client, &ip.to_string()).await {
for prefix in prefixes {
let network = Target::Network(NetworkTarget {
cidr: prefix.clone(),
source: DiscoverySource::AsnLookup,
});
input.emit_target(network).await;
}
}
}
if let Target::Network(net) = target {
if let Ok(prefix) = net.cidr.parse::<ipnet::IpNet>() {
let hosts: Vec<_> = prefix.hosts().take(16).collect();
let ptr_results: Vec<Option<String>> = futures::stream::iter(hosts)
.map(|ip| {
let resolver = Arc::clone(&input.resolver);
async move {
match resolver.reverse_lookup(ip).await {
Ok(r) => r.iter().next().map(|name| {
name.to_string().trim_end_matches('.').to_string()
}),
Err(e)
if e.is_nx_domain() || e.is_no_records_found() =>
{
None
}
Err(e) => {
tracing::warn!(
%ip,
error = %e,
"PTR reverse lookup failed; skipping host"
);
None
}
}
}
})
.buffer_unordered(config.concurrency)
.collect()
.await;
for name in ptr_results.into_iter().flatten() {
let new_domain = Target::Domain(DomainTarget {
domain: name.clone(),
source: DiscoverySource::Crawl, });
input.emit_target(new_domain).await;
}
}
}
if let Target::Domain(d) = target {
seed_domains.push(d.domain.clone());
const PREFIXES: &[&str] =
&["dev", "staging", "api", "admin", "test", "mail", "vpn"];
const SUFFIXES: &[&str] = &["-dev", "-staging", "-backup", "-old"];
for perm in permutation::deduplicate_permutations(
&permutation::generate_all_permutations(&d.domain, PREFIXES, SUFFIXES),
) {
if perm == d.domain {
continue;
}
input.emit_target(Target::Domain(DomainTarget {
domain: perm,
source: DiscoverySource::DnsBruteforce,
})).await;
}
if let Ok(records) =
passive_dns::query_hostsearch(&client, &d.domain, "https://api.hackertarget.com")
.await
{
for domain in passive_dns::extract_unique_domains(&records) {
input.emit_target(Target::Domain(DomainTarget {
domain,
source: DiscoverySource::PassiveDns,
})).await;
}
for ip in private_ip::filter_private_ips(
&passive_dns::extract_unique_ips(&records)
.into_iter()
.collect::<Vec<_>>(),
) {
if let Ok(addr) = ip.parse() {
input.emit_target(Target::Host(gossan_core::HostTarget {
ip: addr,
domain: Some(d.domain.clone()),
})).await;
}
}
}
const TLDS: &[&str] = &[
"com", "net", "org", "io", "co", "info", "biz", "us",
];
for variant in tld::generate_tld_variations(&d.domain, TLDS) {
if variant == d.domain {
continue;
}
input.emit_target(Target::Domain(DomainTarget {
domain: variant,
source: DiscoverySource::DnsBruteforce,
})).await;
}
}
}
if seed_domains.len() > 1 {
if let Ok(groups) = ownership::group_siblings_by_ownership(
&client,
&seed_domains,
"https://api.hackertarget.com",
)
.await
{
for (_key, domains) in groups {
for domain in &domains {
for sibling in domains.iter().filter(|s| *s != domain) {
input.emit_target(Target::Domain(DomainTarget {
domain: sibling.clone(),
source: DiscoverySource::Crawl,
})).await;
if let Some(finding) = Finding::builder(
"horizontal",
domain,
Severity::Info,
)
.title(
"Horizontal discovery: sibling domain found via ownership correlation"
.to_string(),
)
.detail(format!(
"Domain {} shares WHOIS/RDAP ownership attributes with {}.",
sibling, domain
))
.tag("horizontal")
.tag("ownership-pivot")
.kind(secfinding::FindingKind::InfoDisclosure)
.build_or_log()
{
input.emit(finding).await;
}
}
}
}
}
}
Ok(())
}
}