use super::{Mutator, MutatorParams};
use crate::error::{Result, TqlError};
use hickory_resolver::config::{ResolverConfig, ResolverOpts};
use hickory_resolver::name_server::TokioConnectionProvider;
use hickory_resolver::Resolver;
use once_cell::sync::Lazy;
use serde_json::{json, Value as JsonValue};
use std::net::IpAddr;
use std::str::FromStr;
use std::sync::Arc;
use tokio::runtime::{Handle, Runtime};
type TokioResolver = Resolver<TokioConnectionProvider>;
static SHARED_RUNTIME: Lazy<Arc<Runtime>> =
Lazy::new(|| Arc::new(Runtime::new().expect("Failed to create shared tokio runtime for DNS")));
pub struct NSLookupMutator {
params: MutatorParams,
resolver: Arc<TokioResolver>,
runtime: Arc<Runtime>,
}
impl NSLookupMutator {
pub fn new(params: MutatorParams) -> Self {
let runtime = SHARED_RUNTIME.clone();
let resolver = Resolver::builder_with_config(
ResolverConfig::default(),
TokioConnectionProvider::default(),
)
.with_options(ResolverOpts::default())
.build();
Self {
params,
resolver: Arc::new(resolver),
runtime,
}
}
fn run_async<F, T>(&self, future: F) -> T
where
F: std::future::Future<Output = T> + Send + 'static,
T: Send + 'static,
{
if let Ok(handle) = Handle::try_current() {
std::thread::scope(|s| {
s.spawn(move || handle.block_on(future))
.join()
.expect("DNS lookup thread panicked")
})
} else {
self.runtime.block_on(future)
}
}
fn lookup_hostname(&self, hostname: &str) -> Result<DnsResult> {
let resolver = self.resolver.clone();
let hostname_owned = hostname.to_string();
let result: std::result::Result<DnsResult, TqlError> = self.run_async(async move {
match resolver.lookup_ip(&hostname_owned).await {
Ok(lookup) => {
let addresses: Vec<String> = lookup.iter().map(|ip| ip.to_string()).collect();
Ok(DnsResult {
query: hostname_owned,
query_type: "A".to_string(), answers: addresses,
response_code: "NOERROR".to_string(),
})
}
Err(_) => Ok(DnsResult {
query: hostname_owned,
query_type: "A".to_string(),
answers: vec![],
response_code: "NXDOMAIN".to_string(),
}),
}
});
result
}
fn reverse_lookup(&self, ip_str: &str) -> Result<DnsResult> {
let ip: IpAddr = IpAddr::from_str(ip_str).map_err(|e| {
TqlError::MutatorError(format!("Invalid IP address '{}': {}", ip_str, e))
})?;
let resolver = self.resolver.clone();
let ip_for_query = ip_str.to_string();
let result: std::result::Result<DnsResult, TqlError> = self.run_async(async move {
match resolver.reverse_lookup(ip).await {
Ok(lookup) => {
let hostnames: Vec<String> = lookup
.iter()
.map(|name| name.to_string().trim_end_matches('.').to_string())
.collect();
Ok(DnsResult {
query: ip_for_query,
query_type: "PTR".to_string(),
answers: hostnames,
response_code: "NOERROR".to_string(),
})
}
Err(_) => Ok(DnsResult {
query: ip_for_query,
query_type: "PTR".to_string(),
answers: vec![],
response_code: "NXDOMAIN".to_string(),
}),
}
});
result
}
fn is_ip_address(value: &str) -> bool {
IpAddr::from_str(value).is_ok()
}
fn build_ecs_dns_data(result: &DnsResult) -> JsonValue {
let mut ecs_data = json!({
"question": {
"name": result.query,
"type": result.query_type
},
"answers": result.answers,
"response_code": result.response_code
});
if (result.query_type == "A" || result.query_type == "AAAA") && !result.answers.is_empty() {
ecs_data["resolved_ip"] = json!(result.answers);
}
ecs_data
}
fn get_enrichment_paths(field_name: &str) -> (String, String) {
if field_name.contains('.') {
let parent_path = field_name.rsplit('.').skip(1).collect::<Vec<_>>();
let parent = parent_path.into_iter().rev().collect::<Vec<_>>().join(".");
(format!("{}.domain", parent), format!("{}.dns", parent))
} else {
("domain".to_string(), "dns".to_string())
}
}
}
struct DnsResult {
query: String,
query_type: String,
answers: Vec<String>,
response_code: String,
}
impl Mutator for NSLookupMutator {
fn apply(&self, field_name: &str, _record: &JsonValue, value: &JsonValue) -> Result<JsonValue> {
let save_enrichment = self
.params
.get("save")
.and_then(|v| v.as_bool())
.unwrap_or(true);
match value {
JsonValue::String(s) => {
let dns_result = if Self::is_ip_address(s) {
self.reverse_lookup(s)?
} else {
self.lookup_hostname(s)?
};
let ecs_data = Self::build_ecs_dns_data(&dns_result);
let domain = dns_result.answers.first().cloned();
if save_enrichment {
let (domain_field, dns_field) = Self::get_enrichment_paths(field_name);
let return_value = match &domain {
Some(d) => json!(d),
None => value.clone(), };
let enrichment = json!({
"_tql_enrichment": {
"type": "dns",
"domain_field": domain_field,
"dns_field": dns_field,
"domain": domain,
"dns": ecs_data
},
"_tql_return_value": return_value,
"_tql_preserve_original": true,
"_tql_original_value": value
});
Ok(enrichment)
} else {
Ok(value.clone())
}
}
JsonValue::Array(arr) => {
let mut results = Vec::new();
let mut all_domains = Vec::new();
let mut all_dns = Vec::new();
for item in arr {
if let JsonValue::String(s) = item {
let dns_result = if Self::is_ip_address(s) {
self.reverse_lookup(s)?
} else {
self.lookup_hostname(s)?
};
let ecs_data = Self::build_ecs_dns_data(&dns_result);
all_dns.push(ecs_data);
if let Some(domain) = dns_result.answers.first() {
all_domains.push(domain.clone());
results.push(JsonValue::String(domain.clone()));
} else {
results.push(item.clone());
}
} else {
results.push(item.clone());
}
}
if save_enrichment {
let (domain_field, dns_field) = Self::get_enrichment_paths(field_name);
Ok(json!({
"_tql_enrichment": {
"type": "dns",
"domain_field": domain_field,
"dns_field": dns_field,
"domain": all_domains,
"dns": all_dns
},
"_tql_return_value": results,
"_tql_preserve_original": true,
"_tql_original_value": value
}))
} else {
Ok(JsonValue::Array(results))
}
}
_ => Ok(value.clone()),
}
}
fn name(&self) -> &str {
"nslookup"
}
fn is_enrichment(&self) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::collections::HashMap;
#[test]
fn test_nslookup_reverse_lookup_public_ip() {
let mutator = NSLookupMutator::new(HashMap::new());
let record = json!({});
let value = json!("8.8.8.8");
let result = mutator.apply("destination.ip", &record, &value).unwrap();
assert!(result.is_object());
let enrichment = result
.get("_tql_enrichment")
.expect("Should have enrichment");
assert_eq!(enrichment.get("type").unwrap(), "dns");
assert_eq!(
enrichment.get("domain_field").unwrap(),
"destination.domain"
);
assert_eq!(enrichment.get("dns_field").unwrap(), "destination.dns");
let domain = enrichment.get("domain");
assert!(domain.is_some());
if let Some(JsonValue::String(d)) = domain {
assert!(
d.contains("dns.google") || d.contains("google"),
"Expected google domain, got: {}",
d
);
}
let dns = enrichment.get("dns").expect("Should have dns data");
assert!(dns.get("question").is_some());
assert!(dns.get("answers").is_some());
assert_eq!(dns.get("response_code").unwrap(), "NOERROR");
}
#[test]
fn test_nslookup_forward_lookup() {
let mutator = NSLookupMutator::new(HashMap::new());
let record = json!({});
let value = json!("localhost");
let result = mutator.apply("hostname", &record, &value).unwrap();
assert!(result.is_object());
let enrichment = result
.get("_tql_enrichment")
.expect("Should have enrichment");
assert_eq!(enrichment.get("type").unwrap(), "dns");
let dns = enrichment.get("dns").expect("Should have dns data");
let answers = dns.get("answers").expect("Should have answers");
assert!(answers.is_array());
}
#[test]
fn test_nslookup_private_ip_no_reverse() {
let mutator = NSLookupMutator::new(HashMap::new());
let record = json!({});
let value = json!("192.168.1.1");
let result = mutator.apply("source.ip", &record, &value).unwrap();
assert!(result.is_object());
let enrichment = result
.get("_tql_enrichment")
.expect("Should have enrichment");
let dns = enrichment.get("dns").expect("Should have dns data");
let response_code = dns.get("response_code").unwrap().as_str().unwrap();
assert!(response_code == "NXDOMAIN" || response_code == "NOERROR");
}
#[test]
fn test_nslookup_without_save_enrichment() {
let mut params = HashMap::new();
params.insert("save".to_string(), json!(false));
let mutator = NSLookupMutator::new(params);
let record = json!({});
let value = json!("8.8.8.8");
let result = mutator.apply("ip", &record, &value).unwrap();
assert_eq!(
result, value,
"With save=false, should return original value"
);
}
#[test]
fn test_nslookup_array_of_ips() {
let mutator = NSLookupMutator::new(HashMap::new());
let record = json!({});
let value = json!(["8.8.8.8", "8.8.4.4"]);
let result = mutator.apply("destination.ip", &record, &value).unwrap();
assert!(result.is_object());
let enrichment = result
.get("_tql_enrichment")
.expect("Should have enrichment");
let domain = enrichment.get("domain");
assert!(domain.is_some());
let dns = enrichment.get("dns");
assert!(dns.is_some());
assert!(dns.unwrap().is_array());
}
#[test]
fn test_nslookup_non_string() {
let mutator = NSLookupMutator::new(HashMap::new());
let record = json!({});
let value = json!(42);
assert_eq!(mutator.apply("field", &record, &value).unwrap(), json!(42));
let value = json!(true);
assert_eq!(
mutator.apply("field", &record, &value).unwrap(),
json!(true)
);
let value = json!(null);
assert_eq!(
mutator.apply("field", &record, &value).unwrap(),
json!(null)
);
}
#[test]
fn test_enrichment_field_paths() {
let (domain, dns) = NSLookupMutator::get_enrichment_paths("destination.ip");
assert_eq!(domain, "destination.domain");
assert_eq!(dns, "destination.dns");
let (domain, dns) = NSLookupMutator::get_enrichment_paths("network.outer.ip");
assert_eq!(domain, "network.outer.domain");
assert_eq!(dns, "network.outer.dns");
let (domain, dns) = NSLookupMutator::get_enrichment_paths("ip");
assert_eq!(domain, "domain");
assert_eq!(dns, "dns");
}
#[test]
fn test_is_ip_address() {
assert!(NSLookupMutator::is_ip_address("192.168.1.1"));
assert!(NSLookupMutator::is_ip_address("8.8.8.8"));
assert!(NSLookupMutator::is_ip_address("0.0.0.0"));
assert!(NSLookupMutator::is_ip_address("::1"));
assert!(NSLookupMutator::is_ip_address("2001:db8::1"));
assert!(NSLookupMutator::is_ip_address("fe80::1"));
assert!(!NSLookupMutator::is_ip_address("localhost"));
assert!(!NSLookupMutator::is_ip_address("google.com"));
assert!(!NSLookupMutator::is_ip_address("dns.google"));
}
}