tellaro-query-language 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tellaro_query_language-0.1.0.dist-info/LICENSE +21 -0
- tellaro_query_language-0.1.0.dist-info/METADATA +401 -0
- tellaro_query_language-0.1.0.dist-info/RECORD +56 -0
- tellaro_query_language-0.1.0.dist-info/WHEEL +4 -0
- tellaro_query_language-0.1.0.dist-info/entry_points.txt +7 -0
- tql/__init__.py +47 -0
- tql/analyzer.py +385 -0
- tql/cache/__init__.py +7 -0
- tql/cache/base.py +25 -0
- tql/cache/memory.py +63 -0
- tql/cache/redis.py +68 -0
- tql/core.py +929 -0
- tql/core_components/README.md +92 -0
- tql/core_components/__init__.py +20 -0
- tql/core_components/file_operations.py +113 -0
- tql/core_components/opensearch_operations.py +869 -0
- tql/core_components/stats_operations.py +200 -0
- tql/core_components/validation_operations.py +599 -0
- tql/evaluator.py +379 -0
- tql/evaluator_components/README.md +131 -0
- tql/evaluator_components/__init__.py +17 -0
- tql/evaluator_components/field_access.py +176 -0
- tql/evaluator_components/special_expressions.py +296 -0
- tql/evaluator_components/value_comparison.py +315 -0
- tql/exceptions.py +160 -0
- tql/geoip_normalizer.py +233 -0
- tql/mutator_analyzer.py +830 -0
- tql/mutators/__init__.py +222 -0
- tql/mutators/base.py +78 -0
- tql/mutators/dns.py +316 -0
- tql/mutators/encoding.py +218 -0
- tql/mutators/geo.py +363 -0
- tql/mutators/list.py +212 -0
- tql/mutators/network.py +163 -0
- tql/mutators/security.py +225 -0
- tql/mutators/string.py +165 -0
- tql/opensearch.py +78 -0
- tql/opensearch_components/README.md +130 -0
- tql/opensearch_components/__init__.py +17 -0
- tql/opensearch_components/field_mapping.py +399 -0
- tql/opensearch_components/lucene_converter.py +305 -0
- tql/opensearch_components/query_converter.py +775 -0
- tql/opensearch_mappings.py +309 -0
- tql/opensearch_stats.py +451 -0
- tql/parser.py +1363 -0
- tql/parser_components/README.md +72 -0
- tql/parser_components/__init__.py +20 -0
- tql/parser_components/ast_builder.py +162 -0
- tql/parser_components/error_analyzer.py +101 -0
- tql/parser_components/field_extractor.py +112 -0
- tql/parser_components/grammar.py +473 -0
- tql/post_processor.py +737 -0
- tql/scripts.py +124 -0
- tql/stats_evaluator.py +444 -0
- tql/stats_transformer.py +184 -0
- tql/validators.py +110 -0
tql/mutators/__init__.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Mutators package for TQL.
|
|
3
|
+
|
|
4
|
+
This module maintains backward compatibility while organizing mutators into logical groups.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import builtins
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
# Import cache infrastructure
|
|
11
|
+
from ..cache import CacheManager, LocalCacheManager, RedisCacheManager
|
|
12
|
+
|
|
13
|
+
# Import all mutator classes
|
|
14
|
+
from .base import BaseMutator, append_to_result
|
|
15
|
+
from .dns import NSLookupMutator
|
|
16
|
+
from .encoding import Base64DecodeMutator, Base64EncodeMutator, URLDecodeMutator
|
|
17
|
+
from .geo import GeoIPLookupMutator, GeoIPResolver
|
|
18
|
+
from .list import (
|
|
19
|
+
AllMutator,
|
|
20
|
+
AnyMutator,
|
|
21
|
+
AverageMutator,
|
|
22
|
+
AvgMutator,
|
|
23
|
+
MaxMutator,
|
|
24
|
+
MinMutator,
|
|
25
|
+
SumMutator,
|
|
26
|
+
)
|
|
27
|
+
from .network import IsGlobalMutator, IsPrivateMutator
|
|
28
|
+
from .security import DefangMutator, RefangMutator
|
|
29
|
+
from .string import LengthMutator, LowercaseMutator, SplitMutator, TrimMutator, UppercaseMutator
|
|
30
|
+
|
|
31
|
+
# Maintain backward compatibility
|
|
32
|
+
__all__ = [
|
|
33
|
+
# Base
|
|
34
|
+
"BaseMutator",
|
|
35
|
+
"append_to_result",
|
|
36
|
+
# String mutators
|
|
37
|
+
"LowercaseMutator",
|
|
38
|
+
"UppercaseMutator",
|
|
39
|
+
"TrimMutator",
|
|
40
|
+
"SplitMutator",
|
|
41
|
+
"LengthMutator",
|
|
42
|
+
# Encoding mutators
|
|
43
|
+
"Base64EncodeMutator",
|
|
44
|
+
"Base64DecodeMutator",
|
|
45
|
+
"URLDecodeMutator",
|
|
46
|
+
# Security mutators
|
|
47
|
+
"RefangMutator",
|
|
48
|
+
"DefangMutator",
|
|
49
|
+
# Network mutators
|
|
50
|
+
"IsPrivateMutator",
|
|
51
|
+
"IsGlobalMutator",
|
|
52
|
+
# DNS mutator
|
|
53
|
+
"NSLookupMutator",
|
|
54
|
+
# GeoIP mutator
|
|
55
|
+
"GeoIPLookupMutator",
|
|
56
|
+
"GeoIPResolver",
|
|
57
|
+
# List mutators
|
|
58
|
+
"AnyMutator",
|
|
59
|
+
"AllMutator",
|
|
60
|
+
"AvgMutator",
|
|
61
|
+
"AverageMutator",
|
|
62
|
+
"SumMutator",
|
|
63
|
+
"MaxMutator",
|
|
64
|
+
"MinMutator",
|
|
65
|
+
# Cache
|
|
66
|
+
"CacheManager",
|
|
67
|
+
"LocalCacheManager",
|
|
68
|
+
"RedisCacheManager",
|
|
69
|
+
# Factory functions
|
|
70
|
+
"create_mutator",
|
|
71
|
+
"apply_mutators",
|
|
72
|
+
# Constants
|
|
73
|
+
"ALLOWED_MUTATORS",
|
|
74
|
+
"ENRICHMENT_MUTATORS",
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
# Allowed mutators dictionary (backward compatibility)
|
|
78
|
+
ALLOWED_MUTATORS: Dict[str, Optional[Dict[str, type]]] = {
|
|
79
|
+
# String transform mutators
|
|
80
|
+
"lowercase": None,
|
|
81
|
+
"uppercase": None,
|
|
82
|
+
"trim": None,
|
|
83
|
+
"split": {"delimiter": str, "field": str},
|
|
84
|
+
"length": {"field": str},
|
|
85
|
+
# URL and security transform mutators
|
|
86
|
+
"refang": {"field": str},
|
|
87
|
+
"defang": {"field": str},
|
|
88
|
+
# Encoding/decoding mutators (enrichment)
|
|
89
|
+
"b64encode": {"field": str},
|
|
90
|
+
"b64decode": {"field": str},
|
|
91
|
+
"urldecode": {"field": str},
|
|
92
|
+
# List evaluation mutators
|
|
93
|
+
"any": None,
|
|
94
|
+
"all": None,
|
|
95
|
+
"avg": None,
|
|
96
|
+
"average": None, # Alias for avg
|
|
97
|
+
"max": None,
|
|
98
|
+
"min": None,
|
|
99
|
+
"sum": None,
|
|
100
|
+
# Network mutators
|
|
101
|
+
"is_private": None,
|
|
102
|
+
"is_global": None,
|
|
103
|
+
# Existing mutators
|
|
104
|
+
"nslookup": {"servers": List, "append_field": str, "force": bool, "save": bool, "types": List, "field": str},
|
|
105
|
+
"geoip_lookup": {"db_path": str, "cache": bool, "cache_ttl": int, "force": bool, "save": bool, "field": str},
|
|
106
|
+
"geo": {
|
|
107
|
+
"db_path": str,
|
|
108
|
+
"cache": bool,
|
|
109
|
+
"cache_ttl": int,
|
|
110
|
+
"force": bool,
|
|
111
|
+
"save": bool,
|
|
112
|
+
"field": str,
|
|
113
|
+
}, # Alias for geoip_lookup
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
# Define which mutators are enrichment mutators (they add data to records)
|
|
117
|
+
ENRICHMENT_MUTATORS = {
|
|
118
|
+
"nslookup",
|
|
119
|
+
"geoip_lookup",
|
|
120
|
+
"geo",
|
|
121
|
+
# Encoding/decoding mutators are enrichment
|
|
122
|
+
"b64encode",
|
|
123
|
+
"b64decode",
|
|
124
|
+
"urldecode",
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def create_mutator(name: str, params: Optional[List[List[Any]]] = None) -> BaseMutator: # noqa: C901
|
|
129
|
+
"""
|
|
130
|
+
Factory function to create a mutator instance.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
name: The mutator name (case-insensitive).
|
|
134
|
+
params: Optional parameters as a list of [key, value] pairs, e.g.
|
|
135
|
+
[['servers', ['1.1.1.1', '1.0.0.1']], ['append_field', 'dns.answers']].
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
An instance of the appropriate mutator.
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
ValueError: If the mutator is not allowed or if parameters are invalid.
|
|
142
|
+
"""
|
|
143
|
+
key = name.lower()
|
|
144
|
+
if key not in ALLOWED_MUTATORS:
|
|
145
|
+
raise ValueError(f"Mutator '{name}' is not allowed.")
|
|
146
|
+
expected = ALLOWED_MUTATORS[key]
|
|
147
|
+
params_dict = {}
|
|
148
|
+
if params:
|
|
149
|
+
for pair in params:
|
|
150
|
+
if isinstance(pair, builtins.list) and len(pair) == 2:
|
|
151
|
+
param_key, param_value = pair
|
|
152
|
+
if expected is not None and param_key.lower() not in expected:
|
|
153
|
+
raise ValueError(f"Parameter '{param_key}' is not allowed for mutator '{name}'.")
|
|
154
|
+
params_dict[param_key.lower()] = param_value
|
|
155
|
+
else:
|
|
156
|
+
raise ValueError("Parameters must be a list of [key, value] pairs.")
|
|
157
|
+
|
|
158
|
+
# Create the appropriate mutator instance
|
|
159
|
+
if key == "lowercase":
|
|
160
|
+
return LowercaseMutator(params_dict)
|
|
161
|
+
elif key == "uppercase":
|
|
162
|
+
return UppercaseMutator(params_dict)
|
|
163
|
+
elif key == "trim":
|
|
164
|
+
return TrimMutator(params_dict)
|
|
165
|
+
elif key == "split":
|
|
166
|
+
return SplitMutator(params_dict)
|
|
167
|
+
elif key == "length":
|
|
168
|
+
return LengthMutator(params_dict)
|
|
169
|
+
elif key == "refang":
|
|
170
|
+
return RefangMutator(params_dict)
|
|
171
|
+
elif key == "defang":
|
|
172
|
+
return DefangMutator(params_dict)
|
|
173
|
+
elif key == "b64encode":
|
|
174
|
+
return Base64EncodeMutator(params_dict)
|
|
175
|
+
elif key == "b64decode":
|
|
176
|
+
return Base64DecodeMutator(params_dict)
|
|
177
|
+
elif key == "urldecode":
|
|
178
|
+
return URLDecodeMutator(params_dict)
|
|
179
|
+
elif key == "is_private":
|
|
180
|
+
return IsPrivateMutator(params_dict)
|
|
181
|
+
elif key == "is_global":
|
|
182
|
+
return IsGlobalMutator(params_dict)
|
|
183
|
+
elif key == "nslookup":
|
|
184
|
+
return NSLookupMutator(params_dict)
|
|
185
|
+
elif key in ["geoip_lookup", "geo"]:
|
|
186
|
+
return GeoIPLookupMutator(params_dict)
|
|
187
|
+
elif key == "any":
|
|
188
|
+
return AnyMutator(params_dict)
|
|
189
|
+
elif key == "all":
|
|
190
|
+
return AllMutator(params_dict)
|
|
191
|
+
elif key in ["avg", "average"]:
|
|
192
|
+
return AvgMutator(params_dict)
|
|
193
|
+
elif key == "sum":
|
|
194
|
+
return SumMutator(params_dict)
|
|
195
|
+
elif key == "max":
|
|
196
|
+
return MaxMutator(params_dict)
|
|
197
|
+
elif key == "min":
|
|
198
|
+
return MinMutator(params_dict)
|
|
199
|
+
else:
|
|
200
|
+
# For now, return a base mutator for unimplemented mutators
|
|
201
|
+
# This will be replaced as we implement more mutators
|
|
202
|
+
return BaseMutator(params_dict)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def apply_mutators(value: Any, mutators: List[Dict[str, Any]], field_name: str, record: Dict[str, Any]) -> Any:
|
|
206
|
+
"""
|
|
207
|
+
Apply a sequence of mutators to a value.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
value: The original value.
|
|
211
|
+
mutators: A list of mutator dictionaries, each with keys "name" and optional "params".
|
|
212
|
+
field_name: The name of the field being processed.
|
|
213
|
+
record: The entire record (for enrichment mutators).
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
The final mutated value.
|
|
217
|
+
"""
|
|
218
|
+
result = value
|
|
219
|
+
for m in mutators:
|
|
220
|
+
mut = create_mutator(m["name"], m.get("params"))
|
|
221
|
+
result = mut.apply(field_name, record, result)
|
|
222
|
+
return result
|
tql/mutators/base.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Base mutator class and utilities."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PerformanceClass(Enum):
|
|
8
|
+
"""Performance classification for mutators."""
|
|
9
|
+
|
|
10
|
+
FAST = "fast" # Minimal performance impact
|
|
11
|
+
MODERATE = "moderate" # Noticeable but acceptable impact
|
|
12
|
+
SLOW = "slow" # Significant performance impact
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def append_to_result(record: Dict[str, Any], dotted_field_name: str, value: Any) -> None:
|
|
16
|
+
"""
|
|
17
|
+
Append a value to a record at the location specified by a dotted field name.
|
|
18
|
+
This function traverses the record, creating any missing parent keys,
|
|
19
|
+
and then sets the final key to the given value.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
record: The record dictionary.
|
|
23
|
+
dotted_field_name: The dotted path (e.g., "dns.answers.class").
|
|
24
|
+
value: The value to set.
|
|
25
|
+
"""
|
|
26
|
+
parts = dotted_field_name.split(".")
|
|
27
|
+
d = record
|
|
28
|
+
for part in parts[:-1]:
|
|
29
|
+
if part not in d or not isinstance(d[part], dict):
|
|
30
|
+
d[part] = {}
|
|
31
|
+
d = d[part]
|
|
32
|
+
d[parts[-1]] = value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class BaseMutator:
|
|
36
|
+
"""Base class for all mutators."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, params: Optional[Dict[str, Any]] = None) -> None:
|
|
39
|
+
"""
|
|
40
|
+
Initialize a mutator.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
params: Optional parameters for the mutator.
|
|
44
|
+
"""
|
|
45
|
+
self.params = params or {}
|
|
46
|
+
self.is_enrichment = False # Override in enrichment mutators
|
|
47
|
+
|
|
48
|
+
# Performance characteristics - override in subclasses
|
|
49
|
+
# These represent the performance impact in different contexts
|
|
50
|
+
self.performance_in_memory = PerformanceClass.FAST
|
|
51
|
+
self.performance_opensearch = PerformanceClass.MODERATE
|
|
52
|
+
|
|
53
|
+
def apply(self, field_name: str, record: Dict[str, Any], value: Any) -> Any:
|
|
54
|
+
"""
|
|
55
|
+
Apply the mutator to a value.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
field_name: The name of the field being processed.
|
|
59
|
+
record: The full record (may be modified for enrichment mutators).
|
|
60
|
+
value: The current field value.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
The transformed value.
|
|
64
|
+
"""
|
|
65
|
+
return value
|
|
66
|
+
|
|
67
|
+
def get_performance_class(self, context: str = "in_memory") -> PerformanceClass:
|
|
68
|
+
"""Get the performance classification for this mutator in a specific context.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
context: Execution context ("in_memory" or "opensearch")
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Performance classification
|
|
75
|
+
"""
|
|
76
|
+
if context == "opensearch":
|
|
77
|
+
return self.performance_opensearch
|
|
78
|
+
return self.performance_in_memory
|
tql/mutators/dns.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""DNS lookup mutator."""
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import socket
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
import dns.rdataclass
|
|
9
|
+
import dns.rdatatype
|
|
10
|
+
import dns.resolver
|
|
11
|
+
|
|
12
|
+
dns_available = True
|
|
13
|
+
except ImportError:
|
|
14
|
+
dns_available = False
|
|
15
|
+
dns = None # type: ignore
|
|
16
|
+
|
|
17
|
+
from ..validators import validate_field
|
|
18
|
+
from .base import BaseMutator, PerformanceClass, append_to_result
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NSLookupMutator(BaseMutator):
|
|
22
|
+
"""
|
|
23
|
+
Enrichment mutator that performs DNS lookups on hostnames or IP addresses.
|
|
24
|
+
|
|
25
|
+
Performance Characteristics:
|
|
26
|
+
- In-memory: SLOW - Network I/O for DNS queries (can be mitigated with caching)
|
|
27
|
+
- OpenSearch: SLOW - Network I/O plus post-processing overhead
|
|
28
|
+
|
|
29
|
+
This mutator can:
|
|
30
|
+
- Perform forward DNS lookups (hostname to IP)
|
|
31
|
+
- Perform reverse DNS lookups (IP to hostname)
|
|
32
|
+
- Query specific DNS record types
|
|
33
|
+
- Support force lookup to bypass existing data
|
|
34
|
+
- Return enriched data without modifying the original field value
|
|
35
|
+
|
|
36
|
+
Parameters:
|
|
37
|
+
servers: List of DNS server IPs to use (optional)
|
|
38
|
+
append_field: Field name to store results (default: field_name + '_resolved')
|
|
39
|
+
force: Force new lookup even if data exists (default: False)
|
|
40
|
+
save: Save enrichment to record (default: True)
|
|
41
|
+
types: List of DNS record types to query (default: auto-detect)
|
|
42
|
+
field: Field name to store results (preferred over append_field)
|
|
43
|
+
|
|
44
|
+
Example:
|
|
45
|
+
hostname | nslookup(servers=['8.8.8.8']) contains 'google.com'
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, params: Optional[Dict[str, Any]] = None) -> None:
|
|
49
|
+
super().__init__(params)
|
|
50
|
+
self.is_enrichment = True
|
|
51
|
+
# DNS lookups are slow due to network I/O
|
|
52
|
+
self.performance_in_memory = PerformanceClass.SLOW
|
|
53
|
+
# Even slower in OpenSearch context due to post-processing overhead
|
|
54
|
+
self.performance_opensearch = PerformanceClass.SLOW
|
|
55
|
+
|
|
56
|
+
def apply(self, field_name: str, record: Dict[str, Any], value: Any) -> Any: # noqa: C901
|
|
57
|
+
# Handle different input types
|
|
58
|
+
if value is None:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
if isinstance(value, str):
|
|
62
|
+
queries = [value]
|
|
63
|
+
elif isinstance(value, list) and all(isinstance(item, str) for item in value):
|
|
64
|
+
queries = value
|
|
65
|
+
else:
|
|
66
|
+
return None # Return None for invalid input types instead of raising
|
|
67
|
+
|
|
68
|
+
# Check if we should force lookup
|
|
69
|
+
force_lookup = self.params.get("force", False)
|
|
70
|
+
save_enrichment = self.params.get("save", True)
|
|
71
|
+
|
|
72
|
+
# Check if DNS data already exists in the record
|
|
73
|
+
# Determine where to store the enrichment data
|
|
74
|
+
# Priority: field parameter > append_field parameter > default location
|
|
75
|
+
|
|
76
|
+
# Check for explicit field parameter first
|
|
77
|
+
if "field" in self.params:
|
|
78
|
+
append_field = self.params["field"]
|
|
79
|
+
elif "append_field" in self.params:
|
|
80
|
+
# Legacy parameter support
|
|
81
|
+
append_field = self.params["append_field"]
|
|
82
|
+
else:
|
|
83
|
+
# Default behavior: use 'domain' as the field name
|
|
84
|
+
# If field is like destination.ip, it should be destination.domain
|
|
85
|
+
# If field is just ip, it should be domain
|
|
86
|
+
if "." in field_name:
|
|
87
|
+
# Nested field like destination.ip
|
|
88
|
+
parent_path = field_name.rsplit(".", 1)[0]
|
|
89
|
+
append_field = parent_path + ".domain"
|
|
90
|
+
else:
|
|
91
|
+
# Top-level field
|
|
92
|
+
append_field = "domain"
|
|
93
|
+
existing_dns_data = None
|
|
94
|
+
|
|
95
|
+
# Check for existing data at the append field location
|
|
96
|
+
parts = append_field.split(".")
|
|
97
|
+
current: Optional[Dict[str, Any]] = record
|
|
98
|
+
for part in parts[:-1]:
|
|
99
|
+
if isinstance(current, dict) and part in current:
|
|
100
|
+
current = current[part]
|
|
101
|
+
else:
|
|
102
|
+
current = None
|
|
103
|
+
break
|
|
104
|
+
|
|
105
|
+
if current and isinstance(current, dict) and parts[-1] in current:
|
|
106
|
+
existing_dns_data = current[parts[-1]]
|
|
107
|
+
|
|
108
|
+
# If not forcing and DNS data exists, return it
|
|
109
|
+
if not force_lookup and existing_dns_data:
|
|
110
|
+
return existing_dns_data
|
|
111
|
+
|
|
112
|
+
# Get custom DNS servers from parameters, if provided.
|
|
113
|
+
servers = self.params.get("servers")
|
|
114
|
+
if servers is not None:
|
|
115
|
+
if not validate_field(servers, [(list, str)]):
|
|
116
|
+
raise ValueError("The 'servers' parameter must be a list of IP address strings.")
|
|
117
|
+
for srv in servers:
|
|
118
|
+
try:
|
|
119
|
+
ipaddress.ip_address(srv)
|
|
120
|
+
except ValueError:
|
|
121
|
+
raise ValueError(f"Invalid DNS server address: {srv}")
|
|
122
|
+
|
|
123
|
+
# Get requested DNS record types
|
|
124
|
+
requested_types = self.params.get("types", [])
|
|
125
|
+
|
|
126
|
+
resolved_results: Dict[str, Any] = {}
|
|
127
|
+
|
|
128
|
+
for query_value in queries:
|
|
129
|
+
# Auto-detect if this is an IP address (for reverse lookup)
|
|
130
|
+
is_ip = False
|
|
131
|
+
try:
|
|
132
|
+
ipaddress.ip_address(query_value)
|
|
133
|
+
is_ip = True
|
|
134
|
+
except ValueError:
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
if servers is not None or requested_types:
|
|
138
|
+
# Use dnspython for advanced queries
|
|
139
|
+
if not dns_available:
|
|
140
|
+
raise ImportError(
|
|
141
|
+
"dnspython is required for nslookup with custom servers or specific record types."
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
resolver = dns.resolver.Resolver()
|
|
145
|
+
if servers is not None:
|
|
146
|
+
resolver.nameservers = servers
|
|
147
|
+
|
|
148
|
+
records_list = []
|
|
149
|
+
|
|
150
|
+
# Determine which record types to query
|
|
151
|
+
if requested_types:
|
|
152
|
+
# Use explicitly requested types
|
|
153
|
+
query_types = requested_types
|
|
154
|
+
elif is_ip:
|
|
155
|
+
# Auto-detect: reverse lookup for IPs
|
|
156
|
+
query_types = ["PTR"]
|
|
157
|
+
else:
|
|
158
|
+
# Auto-detect: common forward lookup types
|
|
159
|
+
query_types = ["A", "AAAA"]
|
|
160
|
+
|
|
161
|
+
# Perform queries for each record type
|
|
162
|
+
for record_type in query_types:
|
|
163
|
+
try:
|
|
164
|
+
# Handle reverse lookups for PTR records
|
|
165
|
+
if record_type == "PTR" and is_ip:
|
|
166
|
+
# Convert IP to reverse DNS format
|
|
167
|
+
# Use the already imported ipaddress module
|
|
168
|
+
ip_obj = ipaddress.ip_address(query_value)
|
|
169
|
+
if isinstance(ip_obj, ipaddress.IPv4Address):
|
|
170
|
+
# IPv4 reverse format: 4.3.2.1.in-addr.arpa
|
|
171
|
+
octets = str(ip_obj).split(".")
|
|
172
|
+
reverse_name = ".".join(reversed(octets)) + ".in-addr.arpa"
|
|
173
|
+
else:
|
|
174
|
+
# IPv6 reverse format
|
|
175
|
+
hex_str = ip_obj.exploded.replace(":", "")
|
|
176
|
+
reverse_name = ".".join(reversed(hex_str)) + ".ip6.arpa"
|
|
177
|
+
|
|
178
|
+
answer = resolver.resolve(reverse_name, record_type)
|
|
179
|
+
else:
|
|
180
|
+
# Regular forward lookup
|
|
181
|
+
answer = resolver.resolve(query_value, record_type)
|
|
182
|
+
|
|
183
|
+
for rdata in answer:
|
|
184
|
+
record_dict = {
|
|
185
|
+
"class": dns.rdataclass.to_text(rdata.rdclass) if hasattr(rdata, "rdclass") else "IN",
|
|
186
|
+
"data": rdata.to_text().rstrip("."), # Remove trailing dot from FQDNs
|
|
187
|
+
"name": str(answer.qname).rstrip(".") if hasattr(answer, "qname") else query_value,
|
|
188
|
+
"ttl": answer.ttl if hasattr(answer, "ttl") else 0,
|
|
189
|
+
"type": record_type,
|
|
190
|
+
}
|
|
191
|
+
records_list.append(record_dict)
|
|
192
|
+
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.Timeout, Exception):
|
|
193
|
+
# Continue to next record type if this one fails
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
# Convert to ECS-compliant structure
|
|
197
|
+
if records_list:
|
|
198
|
+
resolved_results[query_value] = self._format_dns_ecs(query_value, records_list, query_types)
|
|
199
|
+
else:
|
|
200
|
+
resolved_results[query_value] = self._format_dns_ecs(query_value, [], query_types)
|
|
201
|
+
else:
|
|
202
|
+
# Fallback to socket for basic lookups
|
|
203
|
+
try:
|
|
204
|
+
if is_ip:
|
|
205
|
+
# Reverse lookup
|
|
206
|
+
hostname, _, _ = socket.gethostbyaddr(query_value)
|
|
207
|
+
records = [{"class": "IN", "data": hostname, "name": query_value, "ttl": 0, "type": "PTR"}]
|
|
208
|
+
resolved_results[query_value] = self._format_dns_ecs(query_value, records, ["PTR"])
|
|
209
|
+
else:
|
|
210
|
+
# Forward lookup
|
|
211
|
+
infos = socket.getaddrinfo(query_value, None)
|
|
212
|
+
ips = list({str(info[4][0]) for info in infos})
|
|
213
|
+
records = []
|
|
214
|
+
for ip in ips:
|
|
215
|
+
# Determine record type based on IP version
|
|
216
|
+
try:
|
|
217
|
+
ip_obj = ipaddress.ip_address(ip)
|
|
218
|
+
record_type = "A" if ip_obj.version == 4 else "AAAA"
|
|
219
|
+
except ValueError:
|
|
220
|
+
record_type = "A" # Default to A record
|
|
221
|
+
|
|
222
|
+
records.append(
|
|
223
|
+
{"class": "IN", "data": ip, "name": query_value, "ttl": 0, "type": record_type}
|
|
224
|
+
)
|
|
225
|
+
resolved_results[query_value] = self._format_dns_ecs(
|
|
226
|
+
query_value, records, ["A", "AAAA"] if not is_ip else ["PTR"]
|
|
227
|
+
)
|
|
228
|
+
except Exception:
|
|
229
|
+
resolved_results[query_value] = self._format_dns_ecs(
|
|
230
|
+
query_value, [], ["A", "AAAA"] if not is_ip else ["PTR"]
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# Save enrichment if requested
|
|
234
|
+
if save_enrichment:
|
|
235
|
+
# For single value lookups, unwrap the result
|
|
236
|
+
if len(queries) == 1 and queries[0] in resolved_results:
|
|
237
|
+
# Store the ECS data directly, not wrapped in IP key
|
|
238
|
+
append_to_result(record, append_field, resolved_results[queries[0]])
|
|
239
|
+
else:
|
|
240
|
+
# For multiple queries, keep the dictionary structure
|
|
241
|
+
append_to_result(record, append_field, resolved_results)
|
|
242
|
+
|
|
243
|
+
# For enrichment-only mode, return the resolved data
|
|
244
|
+
# This allows it to be used in geo-style parenthetical expressions
|
|
245
|
+
return resolved_results
|
|
246
|
+
|
|
247
|
+
def _format_dns_ecs( # noqa: C901
|
|
248
|
+
self, query_value: str, records: List[Dict[str, Any]], query_types: List[str]
|
|
249
|
+
) -> Dict[str, Any]:
|
|
250
|
+
"""Format DNS results in ECS-compliant structure.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
query_value: The original query (hostname or IP)
|
|
254
|
+
records: List of DNS records returned
|
|
255
|
+
query_types: List of DNS record types that were queried
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
ECS-compliant DNS data structure
|
|
259
|
+
"""
|
|
260
|
+
# Build ECS structure
|
|
261
|
+
ecs_data = {
|
|
262
|
+
"question": {"name": query_value, "type": query_types[0] if query_types else "A"}, # Primary query type
|
|
263
|
+
"answers": records,
|
|
264
|
+
"response_code": "NOERROR" if records else "NXDOMAIN",
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
# Extract specific data for convenience fields
|
|
268
|
+
resolved_ips = []
|
|
269
|
+
hostnames = []
|
|
270
|
+
mx_records = []
|
|
271
|
+
txt_records = []
|
|
272
|
+
|
|
273
|
+
for record in records:
|
|
274
|
+
record_type = record.get("type", "")
|
|
275
|
+
data = record.get("data", "")
|
|
276
|
+
|
|
277
|
+
if record_type in ["A", "AAAA"] and data:
|
|
278
|
+
resolved_ips.append(data)
|
|
279
|
+
elif record_type == "PTR" and data:
|
|
280
|
+
hostnames.append(data)
|
|
281
|
+
elif record_type == "CNAME" and data:
|
|
282
|
+
hostnames.append(data)
|
|
283
|
+
elif record_type == "MX" and data:
|
|
284
|
+
mx_records.append(data)
|
|
285
|
+
elif record_type == "TXT" and data:
|
|
286
|
+
txt_records.append(data)
|
|
287
|
+
|
|
288
|
+
# Add resolved_ip array (ECS standard field)
|
|
289
|
+
if resolved_ips:
|
|
290
|
+
ecs_data["resolved_ip"] = resolved_ips
|
|
291
|
+
|
|
292
|
+
# Add convenience fields for easier access
|
|
293
|
+
if hostnames:
|
|
294
|
+
ecs_data["hostname"] = hostnames[0] # Single hostname for simple access
|
|
295
|
+
ecs_data["hostnames"] = hostnames # Array of all hostnames
|
|
296
|
+
|
|
297
|
+
# Add record type specific arrays for convenience
|
|
298
|
+
if resolved_ips:
|
|
299
|
+
# Separate IPv4 and IPv6
|
|
300
|
+
ipv4 = [ip for ip in resolved_ips if ":" not in ip]
|
|
301
|
+
ipv6 = [ip for ip in resolved_ips if ":" in ip]
|
|
302
|
+
if ipv4:
|
|
303
|
+
ecs_data["a"] = ipv4
|
|
304
|
+
if ipv6:
|
|
305
|
+
ecs_data["aaaa"] = ipv6
|
|
306
|
+
|
|
307
|
+
if hostnames and any(r.get("type") == "PTR" for r in records):
|
|
308
|
+
ecs_data["ptr"] = hostnames[0] # Backward compatibility
|
|
309
|
+
|
|
310
|
+
if mx_records:
|
|
311
|
+
ecs_data["mx"] = mx_records
|
|
312
|
+
|
|
313
|
+
if txt_records:
|
|
314
|
+
ecs_data["txt"] = txt_records
|
|
315
|
+
|
|
316
|
+
return ecs_data
|