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.
Files changed (56) hide show
  1. tellaro_query_language-0.1.0.dist-info/LICENSE +21 -0
  2. tellaro_query_language-0.1.0.dist-info/METADATA +401 -0
  3. tellaro_query_language-0.1.0.dist-info/RECORD +56 -0
  4. tellaro_query_language-0.1.0.dist-info/WHEEL +4 -0
  5. tellaro_query_language-0.1.0.dist-info/entry_points.txt +7 -0
  6. tql/__init__.py +47 -0
  7. tql/analyzer.py +385 -0
  8. tql/cache/__init__.py +7 -0
  9. tql/cache/base.py +25 -0
  10. tql/cache/memory.py +63 -0
  11. tql/cache/redis.py +68 -0
  12. tql/core.py +929 -0
  13. tql/core_components/README.md +92 -0
  14. tql/core_components/__init__.py +20 -0
  15. tql/core_components/file_operations.py +113 -0
  16. tql/core_components/opensearch_operations.py +869 -0
  17. tql/core_components/stats_operations.py +200 -0
  18. tql/core_components/validation_operations.py +599 -0
  19. tql/evaluator.py +379 -0
  20. tql/evaluator_components/README.md +131 -0
  21. tql/evaluator_components/__init__.py +17 -0
  22. tql/evaluator_components/field_access.py +176 -0
  23. tql/evaluator_components/special_expressions.py +296 -0
  24. tql/evaluator_components/value_comparison.py +315 -0
  25. tql/exceptions.py +160 -0
  26. tql/geoip_normalizer.py +233 -0
  27. tql/mutator_analyzer.py +830 -0
  28. tql/mutators/__init__.py +222 -0
  29. tql/mutators/base.py +78 -0
  30. tql/mutators/dns.py +316 -0
  31. tql/mutators/encoding.py +218 -0
  32. tql/mutators/geo.py +363 -0
  33. tql/mutators/list.py +212 -0
  34. tql/mutators/network.py +163 -0
  35. tql/mutators/security.py +225 -0
  36. tql/mutators/string.py +165 -0
  37. tql/opensearch.py +78 -0
  38. tql/opensearch_components/README.md +130 -0
  39. tql/opensearch_components/__init__.py +17 -0
  40. tql/opensearch_components/field_mapping.py +399 -0
  41. tql/opensearch_components/lucene_converter.py +305 -0
  42. tql/opensearch_components/query_converter.py +775 -0
  43. tql/opensearch_mappings.py +309 -0
  44. tql/opensearch_stats.py +451 -0
  45. tql/parser.py +1363 -0
  46. tql/parser_components/README.md +72 -0
  47. tql/parser_components/__init__.py +20 -0
  48. tql/parser_components/ast_builder.py +162 -0
  49. tql/parser_components/error_analyzer.py +101 -0
  50. tql/parser_components/field_extractor.py +112 -0
  51. tql/parser_components/grammar.py +473 -0
  52. tql/post_processor.py +737 -0
  53. tql/scripts.py +124 -0
  54. tql/stats_evaluator.py +444 -0
  55. tql/stats_transformer.py +184 -0
  56. tql/validators.py +110 -0
@@ -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