use super::{Mutator, MutatorParams};
use crate::error::{Result, TqlError};
use hickory_resolver::config::{ResolverConfig, ResolverOpts, GOOGLE};
use hickory_resolver::net::runtime::TokioRuntimeProvider;
use hickory_resolver::proto::rr::{Name, RData};
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<TokioRuntimeProvider>;
static SHARED_RUNTIME: Lazy<Arc<Runtime>> =
Lazy::new(|| Arc::new(Runtime::new().expect("Failed to create shared tokio runtime for DNS")));
pub trait DnsResolver: Send + Sync {
fn lookup_ip(&self, hostname: &str) -> std::result::Result<Vec<IpAddr>, String>;
fn reverse_lookup(&self, ip: IpAddr) -> std::result::Result<Vec<String>, String>;
}
pub struct SystemDnsResolver {
resolver: Arc<TokioResolver>,
runtime: Arc<Runtime>,
}
impl SystemDnsResolver {
pub fn new() -> Self {
let runtime = SHARED_RUNTIME.clone();
let resolver = Resolver::builder(TokioRuntimeProvider::default())
.unwrap_or_else(|_| {
Resolver::builder_with_config(
ResolverConfig::udp_and_tcp(&GOOGLE),
TokioRuntimeProvider::default(),
)
})
.with_options(ResolverOpts::default())
.build()
.expect("Failed to build DNS resolver");
Self {
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)
}
}
}
impl Default for SystemDnsResolver {
fn default() -> Self {
Self::new()
}
}
impl DnsResolver for SystemDnsResolver {
fn lookup_ip(&self, hostname: &str) -> std::result::Result<Vec<IpAddr>, String> {
let resolver = self.resolver.clone();
let hostname = hostname.to_string();
self.run_async(async move {
resolver
.lookup_ip(&hostname)
.await
.map(|lookup| lookup.iter().collect())
.map_err(|e| e.to_string())
})
}
fn reverse_lookup(&self, ip: IpAddr) -> std::result::Result<Vec<String>, String> {
let resolver = self.resolver.clone();
let reverse_name = Name::from(ip).to_string();
self.run_async(async move {
resolver
.reverse_lookup(reverse_name)
.await
.map(|lookup| {
lookup
.answers()
.iter()
.filter_map(|r| match &r.data {
RData::PTR(ptr) => Some(ptr.0.to_string()),
_ => None,
})
.collect()
})
.map_err(|e| e.to_string())
})
}
}
#[cfg(not(all(test, not(feature = "integration-tests"))))]
fn default_resolver() -> Arc<dyn DnsResolver> {
Arc::new(SystemDnsResolver::new())
}
#[cfg(all(test, not(feature = "integration-tests")))]
fn default_resolver() -> Arc<dyn DnsResolver> {
struct NoDns;
impl DnsResolver for NoDns {
fn lookup_ip(&self, hostname: &str) -> std::result::Result<Vec<IpAddr>, String> {
Err(format!("unit tests do not resolve DNS ({hostname})"))
}
fn reverse_lookup(&self, ip: IpAddr) -> std::result::Result<Vec<String>, String> {
Err(format!("unit tests do not resolve DNS ({ip})"))
}
}
Arc::new(NoDns)
}
pub struct NSLookupMutator {
params: MutatorParams,
resolver: Arc<dyn DnsResolver>,
}
impl NSLookupMutator {
pub fn new(params: MutatorParams) -> Self {
Self::with_resolver(params, default_resolver())
}
pub fn with_resolver(params: MutatorParams, resolver: Arc<dyn DnsResolver>) -> Self {
Self { params, resolver }
}
fn lookup_hostname(&self, hostname: &str) -> Result<DnsResult> {
Ok(match self.resolver.lookup_ip(hostname) {
Ok(addresses) => DnsResult {
query: hostname.to_string(),
query_type: "A".to_string(), answers: addresses.iter().map(|ip| ip.to_string()).collect(),
response_code: "NOERROR".to_string(),
},
Err(_) => DnsResult {
query: hostname.to_string(),
query_type: "A".to_string(),
answers: vec![],
response_code: "NXDOMAIN".to_string(),
},
})
}
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))
})?;
Ok(match self.resolver.reverse_lookup(ip) {
Ok(hostnames) => DnsResult {
query: ip_str.to_string(),
query_type: "PTR".to_string(),
answers: hostnames
.iter()
.map(|name| name.trim_end_matches('.').to_string())
.collect(),
response_code: "NOERROR".to_string(),
},
Err(_) => DnsResult {
query: ip_str.to_string(),
query_type: "PTR".to_string(),
answers: vec![],
response_code: "NXDOMAIN".to_string(),
},
})
}
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;
use std::sync::Mutex;
#[derive(Default)]
struct StubResolver {
forward: HashMap<String, Vec<IpAddr>>,
reverse: HashMap<IpAddr, Vec<String>>,
queries: Mutex<Vec<String>>,
}
impl StubResolver {
fn forward(mut self, hostname: &str, addresses: &[&str]) -> Self {
let parsed = addresses.iter().map(|a| a.parse().unwrap()).collect();
self.forward.insert(hostname.to_string(), parsed);
self
}
fn reverse(mut self, ip: &str, names: &[&str]) -> Self {
let names = names.iter().map(|n| n.to_string()).collect();
self.reverse.insert(ip.parse().unwrap(), names);
self
}
fn queries(&self) -> Vec<String> {
self.queries.lock().unwrap().clone()
}
}
impl DnsResolver for StubResolver {
fn lookup_ip(&self, hostname: &str) -> std::result::Result<Vec<IpAddr>, String> {
self.queries.lock().unwrap().push(format!("A {hostname}"));
self.forward
.get(hostname)
.cloned()
.ok_or_else(|| format!("no record for {hostname}"))
}
fn reverse_lookup(&self, ip: IpAddr) -> std::result::Result<Vec<String>, String> {
self.queries.lock().unwrap().push(format!("PTR {ip}"));
self.reverse
.get(&ip)
.cloned()
.ok_or_else(|| format!("no PTR for {ip}"))
}
}
fn mutator(params: MutatorParams, stub: StubResolver) -> (NSLookupMutator, Arc<StubResolver>) {
let stub = Arc::new(stub);
let mutator = NSLookupMutator::with_resolver(params, stub.clone());
(mutator, stub)
}
#[test]
fn test_nslookup_reverse_lookup_public_ip() {
let (mutator, stub) = mutator(
HashMap::new(),
StubResolver::default().reverse("8.8.8.8", &["dns.google."]),
);
let value = json!("8.8.8.8");
let result = mutator.apply("destination.ip", &json!({}), &value).unwrap();
assert_eq!(stub.queries(), vec!["PTR 8.8.8.8"]);
assert_eq!(
result,
json!({
"_tql_enrichment": {
"type": "dns",
"domain_field": "destination.domain",
"dns_field": "destination.dns",
"domain": "dns.google",
"dns": {
"question": {"name": "8.8.8.8", "type": "PTR"},
"answers": ["dns.google"],
"response_code": "NOERROR"
}
},
"_tql_return_value": "dns.google",
"_tql_preserve_original": true,
"_tql_original_value": "8.8.8.8"
})
);
}
#[test]
fn test_nslookup_forward_lookup() {
let (mutator, stub) = mutator(
HashMap::new(),
StubResolver::default().forward("localhost", &["127.0.0.1", "::1"]),
);
let result = mutator
.apply("hostname", &json!({}), &json!("localhost"))
.unwrap();
assert_eq!(stub.queries(), vec!["A localhost"]);
let enrichment = &result["_tql_enrichment"];
assert_eq!(enrichment["type"], "dns");
assert_eq!(enrichment["domain_field"], "domain");
assert_eq!(enrichment["dns_field"], "dns");
assert_eq!(
enrichment["dns"],
json!({
"question": {"name": "localhost", "type": "A"},
"answers": ["127.0.0.1", "::1"],
"resolved_ip": ["127.0.0.1", "::1"],
"response_code": "NOERROR"
})
);
assert_eq!(result["_tql_return_value"], "127.0.0.1");
}
#[test]
fn test_nslookup_forward_lookup_failure_is_nxdomain() {
let (mutator, stub) = mutator(HashMap::new(), StubResolver::default());
let value = json!("no-such-host.invalid");
let result = mutator.apply("hostname", &json!({}), &value).unwrap();
assert_eq!(stub.queries(), vec!["A no-such-host.invalid"]);
let enrichment = &result["_tql_enrichment"];
assert_eq!(enrichment["domain"], JsonValue::Null);
assert_eq!(
enrichment["dns"],
json!({
"question": {"name": "no-such-host.invalid", "type": "A"},
"answers": [],
"response_code": "NXDOMAIN"
})
);
assert_eq!(result["_tql_return_value"], value);
}
#[test]
fn test_nslookup_private_ip_no_reverse() {
let (mutator, stub) = mutator(HashMap::new(), StubResolver::default());
let value = json!("192.168.1.1");
let result = mutator.apply("source.ip", &json!({}), &value).unwrap();
assert_eq!(stub.queries(), vec!["PTR 192.168.1.1"]);
let enrichment = &result["_tql_enrichment"];
assert_eq!(enrichment["domain_field"], "source.domain");
assert_eq!(enrichment["domain"], JsonValue::Null);
assert_eq!(
enrichment["dns"],
json!({
"question": {"name": "192.168.1.1", "type": "PTR"},
"answers": [],
"response_code": "NXDOMAIN"
})
);
assert_eq!(result["_tql_return_value"], value);
assert_eq!(result["_tql_original_value"], value);
}
#[test]
fn test_nslookup_without_save_enrichment() {
let mut params = HashMap::new();
params.insert("save".to_string(), json!(false));
let (mutator, stub) = mutator(
params,
StubResolver::default().reverse("8.8.8.8", &["dns.google."]),
);
let value = json!("8.8.8.8");
let result = mutator.apply("ip", &json!({}), &value).unwrap();
assert_eq!(
result, value,
"With save=false, should return original value"
);
assert_eq!(stub.queries(), vec!["PTR 8.8.8.8"]);
}
#[test]
fn test_nslookup_array_of_ips() {
let (mutator, stub) = mutator(
HashMap::new(),
StubResolver::default().reverse("8.8.8.8", &["dns.google."]),
);
let value = json!(["8.8.8.8", "8.8.4.4"]);
let result = mutator.apply("destination.ip", &json!({}), &value).unwrap();
assert_eq!(stub.queries(), vec!["PTR 8.8.8.8", "PTR 8.8.4.4"]);
let enrichment = &result["_tql_enrichment"];
assert_eq!(enrichment["domain"], json!(["dns.google"]));
assert_eq!(
enrichment["dns"],
json!([
{
"question": {"name": "8.8.8.8", "type": "PTR"},
"answers": ["dns.google"],
"response_code": "NOERROR"
},
{
"question": {"name": "8.8.4.4", "type": "PTR"},
"answers": [],
"response_code": "NXDOMAIN"
}
])
);
assert_eq!(
result["_tql_return_value"],
json!(["dns.google", "8.8.4.4"])
);
}
#[test]
fn test_nslookup_non_string() {
let (mutator, stub) = mutator(HashMap::new(), StubResolver::default());
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)
);
assert!(stub.queries().is_empty());
}
#[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"));
}
}