use futures::StreamExt;
use gossan_core::{Config, DiscoverySource, DomainTarget, Target};
use std::collections::{HashMap, HashSet};
use std::net::IpAddr;
use std::sync::Arc;
const ALL_VARIANTS: &str = include_str!("permutations.txt");
const SEPS: &[&str] = &["-", "", "."];
pub async fn expand(
found: &[Target],
root_domain: &str,
config: &Config,
wildcard_ips: &HashSet<IpAddr>,
resolver: &hickory_resolver::TokioResolver,
) -> anyhow::Result<Vec<Target>> {
let known: HashSet<String> = found
.iter()
.filter_map(|t| t.domain().map(String::from))
.collect();
let candidates = generate_markov_and_dictionary_candidates(found, root_domain, &known);
if candidates.is_empty() {
return Ok(vec![]);
}
tracing::debug!(
count = candidates.len(),
root = root_domain,
"permutation candidates generated via probabilistic modeling"
);
let resolver = Arc::new(resolver.clone());
let targets: Vec<Target> = futures::stream::iter(candidates)
.map(|candidate| {
let resolver = Arc::clone(&resolver);
let wildcards = wildcard_ips.clone();
async move {
let Ok(lookup) = resolver.lookup_ip(candidate.as_str()).await else {
return None;
};
if lookup.iter().any(|ip| wildcards.contains(&ip)) {
return None;
}
Some(Target::Domain(DomainTarget {
domain: candidate,
source: DiscoverySource::DnsBruteforce,
}))
}
})
.buffer_unordered(config.concurrency)
.filter_map(|x| async move { x })
.collect()
.await;
tracing::info!(
found = targets.len(),
root = root_domain,
"permutation hits"
);
Ok(targets)
}
const MAX_TOKENS: usize = 200;
fn tokenize(domain: &str, root_domain: &str) -> Vec<String> {
let mut tokens = Vec::new();
let without_root = domain
.strip_suffix(&format!(".{}", root_domain))
.unwrap_or(domain);
if without_root.is_empty() {
return tokens;
}
for part in without_root.split(|c| c == '.' || c == '-') {
if tokens.len() >= MAX_TOKENS {
break;
}
if part.len() >= 2 {
let mut current = String::new();
let mut is_num = false;
for c in part.chars() {
let num = c.is_ascii_digit();
if current.is_empty() {
current.push(c);
is_num = num;
} else if is_num == num {
current.push(c);
} else {
if current.len() >= 2 {
tokens.push(current.clone());
if tokens.len() >= MAX_TOKENS {
return tokens;
}
}
current.clear();
current.push(c);
is_num = num;
}
}
if current.len() >= 2 || (is_num && !current.is_empty()) {
tokens.push(current);
}
} else if !part.is_empty() {
tokens.push(part.to_string());
}
}
tokens
}
const MAX_CANDIDATES: usize = 100_000;
fn generate_markov_and_dictionary_candidates(
found: &[Target],
root_domain: &str,
known: &HashSet<String>,
) -> Vec<String> {
let mut candidates = HashSet::new();
let all_variants: Vec<&str> = ALL_VARIANTS
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect();
let mut tokens = HashSet::new();
let mut transitions: HashMap<String, HashSet<String>> = HashMap::new();
for t in found {
if let Some(domain) = t.domain() {
if domain == root_domain {
continue;
}
if !domain.ends_with(&format!(".{root_domain}")) {
continue;
}
let tks = tokenize(domain, root_domain);
for t in &tks {
tokens.insert(t.clone());
}
for i in 0..tks.len().saturating_sub(1) {
transitions
.entry(tks[i].clone())
.or_default()
.insert(tks[i + 1].clone());
}
}
}
'markov: for start_node in &tokens {
if let Some(next_nodes) = transitions.get(start_node) {
for next in next_nodes {
for sep in SEPS {
if candidates.len() >= MAX_CANDIDATES {
break 'markov;
}
candidates.insert(format!("{}{}{}.{}", start_node, sep, next, root_domain));
if candidates.len() >= MAX_CANDIDATES {
break 'markov;
}
candidates.insert(format!("{}{}{}.{}", next, sep, start_node, root_domain));
}
}
}
}
'dict: for prefix in &tokens {
for variant in &all_variants {
for sep in SEPS {
if candidates.len() >= MAX_CANDIDATES {
break 'dict;
}
candidates.insert(format!("{}{}{}.{}", prefix, sep, variant, root_domain));
if candidates.len() >= MAX_CANDIDATES {
break 'dict;
}
candidates.insert(format!("{}{}{}.{}", variant, sep, prefix, root_domain));
}
}
for i in 1..=5 {
if candidates.len() >= MAX_CANDIDATES {
break 'dict;
}
candidates.insert(format!("{}{}.{}", prefix, i, root_domain));
if candidates.len() >= MAX_CANDIDATES {
break 'dict;
}
candidates.insert(format!("{}-{}.{}", prefix, i, root_domain));
}
}
candidates
.into_iter()
.filter(|c| !known.contains(c))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use gossan_core::{DiscoverySource, DomainTarget};
use proptest::prelude::*;
fn domain_target(domain: &str) -> Target {
Target::Domain(DomainTarget {
domain: domain.into(),
source: DiscoverySource::Seed,
})
}
#[test]
fn tokenize_extracts_labels_and_numeric_boundaries() {
let tks = tokenize("dev-api1.example.com", "example.com");
assert!(tks.contains(&"dev".to_string()));
assert!(tks.contains(&"api".to_string()));
assert!(tks.contains(&"1".to_string()));
}
#[test]
fn markov_generation_learns_transitions() {
let found = vec![domain_target("prod-db.example.com")];
let known = HashSet::new();
let candidates = generate_markov_and_dictionary_candidates(&found, "example.com", &known);
assert!(candidates.iter().any(|c| c == "prod-db.example.com"));
assert!(candidates.iter().any(|c| c == "db-prod.example.com"));
assert!(candidates.iter().any(|c| c == "proddb.example.com"));
}
#[test]
fn dictionary_pollination() {
let found = vec![domain_target("auth.example.com")];
let known = HashSet::new();
let candidates = generate_markov_and_dictionary_candidates(&found, "example.com", &known);
assert!(candidates.iter().any(|c| c == "auth1.example.com"));
assert!(candidates.iter().any(|c| c == "auth-2.example.com"));
}
#[test]
fn partial_suffix_does_not_leak_tokens() {
let found = vec![domain_target("notexample.com")];
let known = HashSet::new();
let candidates = generate_markov_and_dictionary_candidates(&found, "example.com", &known);
assert!(
!candidates.iter().any(|c| c.contains("notexample")),
"label-boundary leak: got {candidates:?}"
);
}
#[test]
fn tokenize_limits_tokens_on_pathological_input() {
let domain = (0..500)
.map(|i| if i % 2 == 0 { "a" } else { "1" })
.collect::<Vec<_>>()
.join("-");
let tks = tokenize(&format!("{}.{}", domain, "example.com"), "example.com");
assert!(
tks.len() <= MAX_TOKENS,
"tokenize must cap tokens, got {}",
tks.len()
);
}
#[test]
fn candidate_generation_is_bounded() {
let domain = (0..500)
.map(|i| if i % 2 == 0 { "a" } else { "1" })
.collect::<Vec<_>>()
.join("-");
let found = vec![domain_target(&format!("{}.{}", domain, "example.com"))];
let known = HashSet::new();
let candidates = generate_markov_and_dictionary_candidates(&found, "example.com", &known);
assert!(
candidates.len() <= MAX_CANDIDATES,
"candidates must be bounded, got {}",
candidates.len()
);
}
proptest! {
#[test]
fn tokenize_never_panics(domain in ".*", root in ".*") {
let _ = tokenize(&domain, &root);
}
#[test]
fn tokenize_is_bounded(domain in ".{0,1024}", root in ".{0,256}") {
let tks = tokenize(&domain, &root);
prop_assert!(tks.len() <= MAX_TOKENS);
}
#[test]
fn candidates_never_panics(
domains in prop::collection::vec(".{0,256}", 0..10),
root in ".{0,128}",
) {
let found: Vec<Target> = domains
.into_iter()
.map(|d| domain_target(&d))
.collect();
let known = HashSet::new();
let _ = generate_markov_and_dictionary_candidates(&found, &root, &known);
}
#[test]
fn candidates_are_bounded(
domains in prop::collection::vec(".{0,256}", 0..10),
root in ".{0,128}",
) {
let found: Vec<Target> = domains
.into_iter()
.map(|d| domain_target(&d))
.collect();
let known = HashSet::new();
let c = generate_markov_and_dictionary_candidates(&found, &root, &known);
prop_assert!(c.len() <= MAX_CANDIDATES);
}
#[test]
fn candidates_end_with_root(
domains in prop::collection::vec(r"[a-z0-9]{1,20}(\.[a-z0-9]{1,20}){0,3}", 0..5),
root in r"[a-z0-9]{1,20}(\.[a-z0-9]{1,20}){1,3}",
) {
let found: Vec<Target> = domains
.into_iter()
.map(|d| domain_target(&d))
.collect();
let known = HashSet::new();
let c = generate_markov_and_dictionary_candidates(&found, &root, &known);
for cand in &c {
prop_assert!(
cand.ends_with(&format!(".{}", root)) || cand.as_str() == root.as_str(),
"candidate {} does not end with root {}", cand, root
);
}
}
}
}