-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathEXECUTOR.py
More file actions
817 lines (680 loc) · 26.6 KB
/
Copy pathEXECUTOR.py
File metadata and controls
817 lines (680 loc) · 26.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
"""Executor module for threat hunting and log analytics operations.
This module provides functions for interacting with Microsoft Defender for Endpoint,
Azure Log Analytics, and OpenAI for threat hunting operations.
"""
# Standard library
import re
from datetime import timedelta
import json
import urllib.parse
# Third-party libraries
import pandas as pd
import requests
from colorama import Fore, Style
from openai import RateLimitError, OpenAIError
from azure.identity import DefaultAzureCredential
# Local modules
import prompt_management
def get_bearer_token():
"""
Get an Azure bearer token for Microsoft Defender API authentication.
Uses DefaultAzureCredential to obtain a token for the Microsoft Defender
API endpoint.
Returns:
The authentication token object from Azure Identity.
"""
credential = DefaultAzureCredential()
token = credential.get_token("https://api.securitycenter.microsoft.com/.default")
return token
def get_mde_workstation_id_from_name(token, device_name):
"""
Look up a Defender for Endpoint machine ID by device name.
Works if the user provides either the FQDN or just the short hostname.
Args:
token: An Azure Identity token (DefaultAzureCredential or similar).
device_name (str): Short hostname or full FQDN string.
Returns:
str: The machine ID.
Raises:
ValueError: If no matches are found.
"""
headers = {"Authorization": f"Bearer {token.token}"}
# Use 'startswith' so "linux-target-1" will match
# "linux-target-1.p2zfvso05mlezjev3ck4vqd3kd.cx.internal.cloudapp.net"
filter_q = urllib.parse.quote(f"startswith(computerDnsName,'{device_name}')")
url = f"https://api.securitycenter.microsoft.com/api/machines?$filter={filter_q}"
resp = requests.get(url, headers=headers, timeout=30)
resp.raise_for_status()
machines = resp.json().get("value", [])
if not machines:
raise ValueError(f"No machine found starting with {device_name}")
# If multiple machines match, pick the first.
# You could add logic here (e.g., choose the most recent 'lastSeen').
machine_id = machines[0]["id"]
return machine_id
def quarantine_virtual_machine(token, machine_id):
"""
Quarantine a virtual machine by isolating it in Microsoft Defender.
Args:
token: An Azure Identity token for authentication.
machine_id (str): The Microsoft Defender machine ID to isolate.
Returns:
bool: True if isolation was successful, False otherwise.
"""
headers = {
"Authorization": f"Bearer {token.token}",
"Content-Type": "application/json",
}
# Example: Isolate a machine
payload = {
"Comment": "Isolation via Python Agentic AI using DefaultAzureCredential",
"IsolationType": "Full",
}
resp = requests.post(
f"https://api.securitycenter.microsoft.com/api/machines/{machine_id}/isolate",
headers=headers,
json=payload,
timeout=30,
)
if resp.status_code in (200, 201):
return True
return False
def hunt(
openai_client, threat_hunt_system_message, threat_hunt_user_message, openai_model
):
"""
Run the threat hunting flow with OpenAI.
This function:
1. Formats the logs into a string
2. Selects appropriate system prompt from context
3. Passes logs + role to model
4. Parses and returns a raw array
Handles rate-limit/token overage errors gracefully.
Args:
openai_client: The OpenAI client instance.
threat_hunt_system_message: The system message for threat hunting.
threat_hunt_user_message: The user message containing logs/context.
openai_model (str): The OpenAI model to use.
Returns:
dict or None: Parsed JSON results from the model, or None on error.
"""
results = []
messages = [threat_hunt_system_message, threat_hunt_user_message]
try:
response = openai_client.chat.completions.create(
model=openai_model,
messages=messages,
response_format={"type": "json_object"},
)
results = json.loads(response.choices[0].message.content)
return results
except RateLimitError as e:
error_msg = str(e)
# Print dark red warning
print(
f"{Fore.LIGHTRED_EX}{Style.BRIGHT}"
f"🚨ERROR: Rate limit or token overage detected!{Style.RESET_ALL}"
)
print(
f"{Fore.LIGHTRED_EX}{Style.BRIGHT}"
f"The input was too large for this model or hit rate limits."
)
print(f"{Style.RESET_ALL}——————————\nRaw Error:\n{error_msg}\n——————————")
print(f"{Fore.WHITE}Suggestions:")
print("- Use fewer logs or reduce input size.")
print("- Switch to a model with a larger context window.")
print("- Retry later if rate-limited.\n")
return None # You can also choose to raise again or exit
except OpenAIError as e:
print(f"{Fore.RED}Unexpected OpenAI API error:\n{e}")
return None
def get_query_context(openai_client, user_message, model):
"""
Extract and parse the function call selected by the LLM.
This tool call is part of OpenAI's function calling feature, where the model
chooses a tool (function) from the provided list, and returns the arguments
it wants to use to call it.
Args:
openai_client: The OpenAI client instance.
user_message: The user's message/request.
model (str): The OpenAI model to use.
Returns:
dict: The parsed arguments for the selected tool.
See: https://platform.openai.com/docs/guides/function-calling
"""
print(
f"{Fore.LIGHTGREEN_EX}\nDeciding log search parameters "
f"based on user request...\n"
)
system_message = prompt_management.SYSTEM_PROMPT_TOOL_SELECTION
response = openai_client.chat.completions.create(
model=model,
messages=[system_message, user_message],
tools=prompt_management.TOOLS,
tool_choice="required",
)
# Check if tool calls exist in the response
if response.choices[0].message.tool_calls:
function_call = response.choices[0].message.tool_calls[0]
args = json.loads(function_call.function.arguments)
else:
# Handle case where no tool call is returned
args = {
"table_name": "",
"device_name": "",
"caller": "",
"user_principal_name": "",
"time_range_hours": 96,
"fields": [],
"about_individual_user": False,
"about_individual_host": False,
"about_network_security_group": False,
"rationale": "No specific query context could be determined from the user's input",
}
return args # or return function_call, args
def query_log_analytics(
log_analytics_client,
workspace_id,
timerange_hours,
table_name,
device_name,
fields,
caller,
user_principal_name,
):
"""
Query Azure Log Analytics for threat hunting data.
Constructs and executes a KQL query based on the specified parameters.
Args:
log_analytics_client: The Azure Log Analytics client.
workspace_id (str): The Log Analytics workspace ID.
timerange_hours (int): Number of hours to query back.
table_name (str): The table to query.
device_name (str): Device name to filter by.
fields (str): Comma-separated list of fields to project.
caller (str): Caller to filter by for AzureActivity table.
user_principal_name (str): User principal name for SigninLogs.
Returns:
dict: Contains 'records' (CSV string) and 'count' (number of records).
"""
if table_name == "AzureNetworkAnalytics_CL":
user_query = f"""{table_name}
| where FlowType_s == "MaliciousFlow"
| project {fields}"""
elif table_name == "AzureActivity":
user_query = f"""{table_name}
| where isnotempty(Caller) and Caller !in ("d37a587a-4ef3-464f-a288-445e60ed248c","ef669d55-9245-4118-8ba7-f78e3e7d0212","3e4fe3d2-24ff-4972-92b3-35518d6e6462")
| where Caller startswith "{caller}"
| project {fields}"""
elif table_name == "SigninLogs":
user_query = f"""{table_name}
| where UserPrincipalName startswith "{user_principal_name}"
| project {fields}"""
else:
user_query = f"""{table_name}
| where DeviceName startswith "{device_name}"
| project {fields}"""
print(f"{Fore.LIGHTGREEN_EX}Constructed KQL Query:")
print(f"{Fore.WHITE}{user_query}\n")
print(
f"{Fore.LIGHTGREEN_EX}Querying Log Analytics Workspace ID: "
f"'{workspace_id}'..."
)
response = log_analytics_client.query_workspace(
workspace_id=workspace_id,
query=user_query,
timespan=timedelta(hours=timerange_hours),
)
if len(response.tables[0].rows) == 0:
print(f"{Fore.WHITE}No data returned from Log Analytics.")
return {"records": "", "count": 0}
# Extract the table
table = response.tables[0]
# TODO: Handle if returns 0 events
record_count = len(response.tables[0].rows)
# Extract columns and rows using dot notation
columns = table.columns # Already a list of strings
rows = table.rows # List of row data
df = pd.DataFrame(rows, columns=columns)
records = df.to_csv(index=False)
return {"records": records, "count": record_count}
def detect_ioc_type(ioc: str) -> str:
"""Detect the type of IOC based on its format.
Args:
ioc: The indicator of compromise to classify
Returns:
str: Type of IOC ('ip', 'hash', 'domain', 'filename', 'process', 'location', 'username', 'metadata', 'guid')
"""
# First, strip common prefixes that GPT adds to IOCs
clean_ioc = ioc
prefixes_to_strip = [
# Basic prefixes
"Username: ",
"Usernames: ",
"IP: ",
"IPs: ",
"Location: ",
"Timestamp: ",
"Timestamps: ",
"Timestamp(s): ",
"App: ",
"Multiple usernames: ",
"Multiple timestamps ",
# Azure/Entra ID prefixes
"UserId: ",
"TenantId: ",
"ASN: ",
"DeviceIds seen: ",
"AppId: ",
"ResourceServicePrincipalId(s): ",
"SignInIds: ",
"UserAgent: ",
"HomeTenantId: ",
"Device/Session IDs: ",
"ServicePrincipalId: ",
"DeviceDetail: ",
"Session/Correlation IDs: ",
"IP Address: ",
"Sample Sign-in Ids (SignIn.Id): ",
"Sample AppIds: ",
"Sample App IDs: ",
"Sample Device/Session IDs: ",
"Authentication detail: ",
]
for prefix in prefixes_to_strip:
if ioc.startswith(prefix):
clean_ioc = ioc[len(prefix) :]
# Determine type from prefix
if any(x in prefix for x in ["Username", "UserId"]) or "@" in clean_ioc:
return "username"
if "IP" in prefix or "Address" in prefix:
return "ip"
if "Location" in prefix:
return "location"
if "ASN" in prefix:
return "ip" # ASN can be used to look up IP ranges
if any(
x in prefix
for x in [
"Timestamp",
"App:",
"Authentication",
"DeviceDetail",
"UserAgent",
]
):
return "metadata" # Skip these - not useful for pivoting
if any(
x in prefix
for x in [
"TenantId",
"AppId",
"DeviceIds",
"SignInIds",
"ServicePrincipalId",
"Session",
"Correlation",
"Sample",
]
):
return "guid" # These are Azure IDs - could be useful for pivoting
break
# Use the cleaned IOC for pattern matching
ioc_to_check = clean_ioc.strip()
# IPv4 pattern (including if it's after a prefix)
if re.match(r"^(\d{1,3}\.){3}\d{1,3}$", ioc_to_check):
return "ip"
# IPv6 pattern
if re.match(r"^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$", ioc_to_check):
return "ip"
# Also match partial IPv6
if ":" in ioc_to_check and re.match(r"^[0-9a-fA-F:]+$", ioc_to_check):
return "ip"
# SHA256 hash (64 hex characters)
if re.match(r"^[a-fA-F0-9]{64}$", ioc_to_check):
return "hash"
# SHA1 hash (40 hex characters)
if re.match(r"^[a-fA-F0-9]{40}$", ioc_to_check):
return "hash"
# MD5 hash (32 hex characters)
if re.match(r"^[a-fA-F0-9]{32}$", ioc_to_check):
return "hash"
# GUID/UUID pattern (8-4-4-4-12 hex format)
if re.match(
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
ioc_to_check,
):
return "guid"
# Domain pattern
if re.match(
r"^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?\.[a-zA-Z]{2,}$", ioc_to_check
):
return "domain"
# Email pattern (contains @)
if "@" in ioc_to_check and "." in ioc_to_check:
return "username"
# Location/address pattern (e.g., "Houston, Texas, US" or "New York")
if re.match(r"^[A-Za-z\s]+,?\s+[A-Za-z]+,?\s+[A-Z]{2}$", ioc_to_check):
return "location"
if re.match(r"^[A-Za-z\s]+,?\s+[A-Za-z]+$", ioc_to_check):
if len(ioc_to_check) < 30:
return "location"
# US state pattern or country code
if re.match(r"^[A-Z]{2}$", ioc_to_check) and ioc_to_check not in ["IP", "ID", "OK"]:
return "location"
# Skip metadata types - not useful for pivoting
if "timestamp" in ioc.lower() or "app:" in ioc.lower():
return "metadata"
# Skip "Multiple" entries
if ioc.startswith("Multiple"):
return "metadata"
return "process" # Default assumption
def generate_ioc_pivot_query(
ioc: str, ioc_type: str, original_table: str, time_range_hours: int = 24
) -> dict:
"""Generate a KQL query to pivot on an IOC.
Args:
ioc: The indicator of compromise (IP, hash, domain, etc.)
ioc_type: Type of IOC ('ip', 'hash', 'domain', 'filename', 'process', 'location', 'username', 'guid')
original_table: The table that contained this IOC
time_range_hours: Time window for follow-up queries
Returns:
dict: Query parameters for the pivot search
"""
# Strip prefixes to get clean IOC value
clean_ioc = ioc
prefixes_to_strip = [
"Username: ",
"Usernames: ",
"IP: ",
"IPs: ",
"Location: ",
"UserId: ",
"TenantId: ",
"AppId: ",
"IP Address: ",
]
for prefix in prefixes_to_strip:
if ioc.startswith(prefix):
clean_ioc = ioc[len(prefix) :]
break
# Handle parentheses with location info (e.g., "103.232.162.106 (Essendon, Victoria, AU)")
if "(" in clean_ioc:
clean_ioc = clean_ioc.split("(")[0].strip()
# Handle multiple values - take first one
if "," in clean_ioc:
clean_ioc = clean_ioc.split(",")[0].strip()
# Handle partial values like "c54012928a03..."
if "..." in clean_ioc:
clean_ioc = clean_ioc.split("...")[0].strip()
# Handle GUIDs - extract just the GUID part
guid_match = re.search(
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}",
clean_ioc,
)
if guid_match:
clean_ioc = guid_match.group(0)
pivot_queries = {
"ip": {
"DeviceNetworkEvents": f"""DeviceNetworkEvents
| where RemoteIP == "{clean_ioc}" or LocalIP == "{clean_ioc}"
| where TimeGenerated > ago({time_range_hours}h)
""",
"SigninLogs": f"""SigninLogs
| where IPAddress == "{clean_ioc}"
| where TimeGenerated > ago({time_range_hours}h)
""",
},
"hash": {
"DeviceProcessEvents": f"""DeviceProcessEvents
| where InitiatingProcessSHA256 == "{clean_ioc}" or SHA256 == "{clean_ioc}"
| where TimeGenerated > ago({time_range_hours}h)
""",
"DeviceFileEvents": f"""DeviceFileEvents
| where SHA256 == "{clean_ioc}"
| where TimeGenerated > ago({time_range_hours}h)
""",
},
"domain": {
"DeviceNetworkEvents": f"""DeviceNetworkEvents
| where Domain == "{clean_ioc}"
| where TimeGenerated > ago({time_range_hours}h)
"""
},
"filename": {
"DeviceProcessEvents": f"""DeviceProcessEvents
| where FileName == "{clean_ioc}" or ProcessCommandLine contains "{clean_ioc}"
| where TimeGenerated > ago({time_range_hours}h)
"""
},
"process": {
"DeviceProcessEvents": f"""DeviceProcessEvents
| where ProcessCommandLine contains "{clean_ioc}"
| where TimeGenerated > ago({time_range_hours}h)
"""
},
"location": {
"SigninLogs": f"""SigninLogs
| where Location == "{clean_ioc}"
| where TimeGenerated > ago({time_range_hours}h)
"""
},
"username": {
"DeviceLogonEvents": f"""DeviceLogonEvents
| where AccountName == "{clean_ioc}" or InitiatingProcessAccountName == "{clean_ioc}"
| where TimeGenerated > ago({time_range_hours}h)
""",
"SigninLogs": f"""SigninLogs
| where UserPrincipalName == "{clean_ioc}" or UserId == "{clean_ioc}"
| where TimeGenerated > ago({time_range_hours}h)
""",
},
"guid": {
"SigninLogs": f"""SigninLogs
| where UserId == "{clean_ioc}" or AppId == "{clean_ioc}"
| where TimeGenerated > ago({time_range_hours}h)
"""
},
}
# Return the most relevant query based on IOC type
return {
"query": pivot_queries.get(ioc_type, {}).get(original_table, ""),
"ioc": ioc,
"ioc_type": ioc_type,
"clean_ioc": clean_ioc,
"time_range_hours": time_range_hours,
}
def query_log_analytics_with_ioc(
log_analytics_client, workspace_id: str, kql_query: str, time_range_hours: int
):
"""Execute a custom KQL query for IOC pivoting.
Args:
log_analytics_client: The Azure Log Analytics client
workspace_id: The workspace ID
kql_query: The KQL query string
time_range_hours: Time window
Returns:
dict: Records and count
"""
print(f"{Fore.CYAN}[IOC PIVOT] Executing: {kql_query[:100]}...")
response = log_analytics_client.query_workspace(
workspace_id=workspace_id,
query=kql_query,
timespan=timedelta(hours=time_range_hours),
)
if len(response.tables[0].rows) == 0:
return {"records": "", "count": 0}
table = response.tables[0]
columns = table.columns
rows = table.rows
df = pd.DataFrame(rows, columns=columns)
records = df.to_csv(index=False)
return {"records": records, "count": len(rows)}
def iterative_hunt(
openai_client,
log_analytics_client,
workspace_id,
initial_findings: list,
original_query_context: dict,
openai_model: str,
max_iterations: int = 3,
):
"""Perform iterative threat hunting by pivoting on discovered IOCs.
Args:
openai_client: OpenAI client
log_analytics_client: Azure Log Analytics client
workspace_id: Workspace ID
initial_findings: Findings from the initial hunt
original_query_context: Original query parameters
openai_model: Model to use
max_iterations: Maximum pivot rounds
Returns:
list: All findings including pivoted results
"""
all_findings = list(initial_findings)
processed_iocs = set()
# Summary of initial findings
print(
f"\n{Fore.LIGHTYELLOW_EX}=== Starting Iterative Hunting (max {max_iterations} iterations) ===\n"
)
print(
f"{Fore.WHITE}Initial hunt found {Fore.LIGHTRED_EX}{len(initial_findings)}{Fore.WHITE} threat(s)\n"
)
# Extract and display all IOCs from initial findings
print(f"{Fore.CYAN}--- Extracting IOCs from initial findings ---")
all_iocs = []
for idx, finding in enumerate(initial_findings, 1):
iocs = finding.get("indicators_of_compromise", [])
title = finding.get("title", "Unknown")[:50]
print(f"{Fore.WHITE} Finding #{idx}: {title}")
if iocs:
print(f"{Fore.WHITE} IOCs: {', '.join(iocs[:5])}")
if len(iocs) > 5:
print(f"{Fore.WHITE} ... and {len(iocs) - 5} more")
else:
print(f"{Fore.LIGHTBLACK_EX} No IOCs found")
all_iocs.extend(iocs)
unique_iocs = list(set(all_iocs))
print(f"{Fore.GREEN}Total unique IOCs to pivot on: {len(unique_iocs)}\n")
for iteration in range(max_iterations):
print(
f"\n{Fore.LIGHTYELLOW_EX}=== Iteration {iteration + 1} of {max_iterations} ===\n"
)
new_findings = []
iocs_processed_this_round = 0
# Process each finding from previous iteration
findings_to_process = all_findings[-10:] if iteration > 0 else initial_findings
print(
f"{Fore.CYAN}Processing {len(findings_to_process)} findings for IOC pivots...\n"
)
for finding in findings_to_process:
iocs = finding.get("indicators_of_compromise", [])
finding_title = finding.get("title", "Unknown")[:40]
for ioc in iocs:
if ioc in processed_iocs:
continue
processed_iocs.add(ioc)
ioc_type = detect_ioc_type(ioc)
iocs_processed_this_round += 1
# Skip metadata types - not useful for pivoting
if ioc_type == "metadata":
print(
f"{Fore.LIGHTBLACK_EX}[{iocs_processed_this_round}] Skipping metadata IOC: {ioc[:50]}..."
)
continue
print(
f"{Fore.MAGENTA}[{iocs_processed_this_round}] Pivoting on IOC: {Fore.WHITE}{ioc[:60]}"
)
print(
f"{Fore.LIGHTBLACK_EX} Type: {ioc_type} | Related to: {finding_title}"
)
# Generate pivot query
pivot = generate_ioc_pivot_query(
ioc=ioc,
ioc_type=ioc_type,
original_table=original_query_context.get(
"table_name", "DeviceProcessEvents"
),
time_range_hours=original_query_context.get("time_range_hours", 96),
)
if not pivot["query"]:
print(
f"{Fore.LIGHTBLACK_EX} No pivot query available for this IOC type"
)
continue
# Display the KQL query that will be executed
print(f"{Fore.CYAN} Executing KQL Query:")
for line in pivot["query"].strip().split("\n"):
print(f"{Fore.WHITE} {line}")
# Execute pivot query
pivot_results = query_log_analytics_with_ioc(
log_analytics_client=log_analytics_client,
workspace_id=workspace_id,
kql_query=pivot["query"],
time_range_hours=pivot["time_range_hours"],
)
if int(pivot_results["count"]) == 0:
print(f"{Fore.LIGHTBLACK_EX} Result: No matching events found")
continue
print(
f"{Fore.GREEN} Result: Found {int(pivot_results['count'])} related events"
)
# Show sample of the data found
if int(pivot_results["count"]) > 0:
print(
f"{Fore.CYAN} Analyzing {min(int(pivot_results['count']), 5)} event(s) with GPT..."
)
# Analyze pivot results
pivot_system_msg = prompt_management.SYSTEM_PROMPT_THREAT_HUNT
pivot_user_msg = prompt_management.build_threat_hunt_prompt(
user_prompt=f"Search for related threats. IOC being pivoted: {ioc}",
table_name=original_query_context.get(
"table_name", "DeviceProcessEvents"
),
log_data=str(pivot_results["records"]),
)
# Run analysis on pivot data
try:
response = openai_client.chat.completions.create(
model=openai_model,
messages=[pivot_system_msg, pivot_user_msg],
response_format={"type": "json_object"},
)
pivot_findings = json.loads(response.choices[0].message.content)
findings_list = pivot_findings.get("findings", [])
for pf in findings_list:
pf["pivot_source_ioc"] = ioc
pf["pivot_iteration"] = iteration + 1
pf["related_to_finding"] = finding.get("title", "Unknown")
new_findings.extend(findings_list)
if findings_list:
print(
f"{Fore.GREEN} Analysis: Found {len(findings_list)} new threat(s):"
)
for nf_idx, nf in enumerate(findings_list, 1):
nf_title = nf.get("title", "Unknown")[:50]
nf_confidence = nf.get("confidence", "Unknown")
print(
f"{Fore.WHITE} {nf_idx}. {nf_title} ({nf_confidence} confidence)"
)
else:
print(
f"{Fore.YELLOW} Analysis: No additional threats detected"
)
except Exception as e:
print(f"{Fore.RED} Error analyzing pivot: {e}")
print(f"\n{Fore.CYAN}--- Iteration {iteration + 1} Summary ---")
print(f"{Fore.WHITE}IOCs processed this round: {iocs_processed_this_round}")
print(f"{Fore.WHITE}New threats discovered: {len(new_findings)}")
if not new_findings:
print(
f"{Fore.YELLOW}No new findings in iteration {iteration + 1}, stopping."
)
break
all_findings.extend(new_findings)
print(f"\n{Fore.LIGHTGREEN_EX}=== Iterative Hunting Complete ===")
print(f"{Fore.WHITE}Total findings: {Fore.LIGHTGREEN_EX}{len(all_findings)}")
print(f"{Fore.WHITE} - Initial: {len(initial_findings)}")
print(f"{Fore.WHITE} - From pivots: {len(all_findings) - len(initial_findings)}")
print(f"{Fore.WHITE}Total unique IOCs processed: {len(processed_iocs)}\n")
return all_findings