Skip to content

Commit 7efd2ea

Browse files
Merge branch 'release/3.5.21'
2 parents 3da19d4 + 317b85c commit 7efd2ea

File tree

2 files changed

+110
-66
lines changed

2 files changed

+110
-66
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
## [3.5.20](https://github.com/TheHive-Project/Cortex-Analyzers/tree/3.5.20) (2025-07-28)
4+
5+
[Full Changelog](https://github.com/TheHive-Project/Cortex-Analyzers/compare/3.5.19...3.5.20)
6+
7+
**Merged pull requests:**
8+
9+
- Various small improvements [\#1365](https://github.com/TheHive-Project/Cortex-Analyzers/pull/1365) ([nusantara-self](https://github.com/nusantara-self))
10+
311
## [3.5.19](https://github.com/TheHive-Project/Cortex-Analyzers/tree/3.5.19) (2025-07-24)
412

513
[Full Changelog](https://github.com/TheHive-Project/Cortex-Analyzers/compare/3.5.18...3.5.19)

analyzers/MSEntraID/MSEntraID.py

Lines changed: 102 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
import re
99
#import json
1010

11+
12+
GUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-"
13+
r"[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$")
14+
1115
# Initialize Azure Class
1216
class MSEntraID(Analyzer):
1317
def __init__(self):
@@ -38,25 +42,40 @@ def authenticate(self):
3842

3943
return token_r.json().get('access_token')
4044

41-
def resolve_user_guid(self, email, headers, base_url):
42-
"""Resolves a userPrincipalName (email) to an objectId (GUID) using direct lookup (most compatible)."""
43-
url = f"{base_url}users/{email}?$select=id"
44-
response = requests.get(url, headers=headers)
4545

46-
if response.status_code != 200:
47-
self.error(f"Failed to resolve GUID for user {email}: {response.content}")
46+
def resolve_user_guid(self, upn_or_mail: str, headers: dict, base_url: str) -> str:
47+
"""
48+
Robustly turn a UPN or mail address into the user objectId (GUID).
49+
Works for cloud users, B2B guests, aliases, vanity domains…
50+
"""
51+
if GUID_RE.match(upn_or_mail):
52+
return upn_or_mail # already a GUID
53+
54+
# Escape single quotes inside the address (rare)
55+
quoted = upn_or_mail.replace("'", "''")
56+
57+
filter_q = (f"(userPrincipalName eq '{quoted}') "
58+
f"or (mail eq '{quoted}')")
59+
60+
resp = requests.get(
61+
f"{base_url}users",
62+
headers=headers,
63+
params={"$filter": filter_q, "$select": "id"}
64+
)
65+
if resp.status_code != 200:
66+
self.error(f"[GUID‑lookup] HTTP {resp.status_code}: {resp.text}")
4867

49-
user = response.json()
50-
user_id = user.get("id")
51-
if not user_id:
52-
self.error(f"ID not found in response for {email}")
68+
users = resp.json().get("value", [])
69+
if not users:
70+
self.error(f"[GUID‑lookup] No user matches '{upn_or_mail}'")
5371

54-
return user_id
72+
return users[0]["id"]
5573

5674
def ensure_user_guid(self, base_url, headers):
57-
if "@" in self.user:
58-
return self.resolve_user_guid(self.user, headers, base_url)
59-
return self.user
75+
if GUID_RE.match(self.user):
76+
return self.user
77+
return self.resolve_user_guid(self.user, headers, base_url)
78+
6079

6180
def handle_get_signins(self, headers, base_url):
6281
"""
@@ -78,7 +97,7 @@ def handle_get_signins(self, headers, base_url):
7897
# Query sign-in logs
7998
endpoint = (
8099
f"auditLogs/signIns?$filter=userId eq '{self.guid}'"
81-
f"and createdDateTime ge {format_time}&$top={self.lookup_limit}"
100+
f" and createdDateTime ge {format_time}&$top={self.lookup_limit}"
82101
)
83102
r = requests.get(base_url + endpoint, headers=headers)
84103

@@ -409,43 +428,45 @@ def handle_get_directoryAuditLogs(self, headers, base_url):
409428
self.error("No user principal name supplied for directory audit logs")
410429
self.guid = self.ensure_user_guid(base_url, headers)
411430

431+
adv_headers = headers.copy()
432+
adv_headers["ConsistencyLevel"] = "eventual"
433+
412434
# Calculate time range (past X days)
413-
filter_time = datetime.utcnow() - timedelta(days=self.time_range)
414-
filter_time_str = filter_time.strftime('%Y-%m-%dT%H:%M:%SZ')
415-
416-
# Build endpoint
417-
# Example: GET /auditLogs/directoryAudits?$filter=activityDateTime ge 2023-01-01T00:00:00Z&$top=12
418-
endpoint = (
419-
"auditLogs/directoryAudits?"
420-
f"$filter=activityDateTime ge {filter_time_str} "
421-
f"and initiatedBy/user/id eq '{self.guid}'"
422-
f"&$top={self.lookup_limit}"
423-
)
424-
425-
# Perform the GET request
426-
r = requests.get(base_url + endpoint, headers=headers)
427-
if r.status_code != 200:
428-
self.error(f"Failure to fetch directory audit logs: {r.content}")
429-
430-
# Parse the returned JSON
431-
audit_data = r.json().get('value', [])
432-
433-
# Build the result object
434-
result = {
435+
filter_time = (datetime.utcnow() - timedelta(days=self.time_range)) \
436+
.strftime('%Y-%m-%dT%H:%M:%SZ')
437+
438+
filter_q = (
439+
f"activityDateTime ge {filter_time} and ("
440+
f"initiatedBy/user/id eq '{self.guid}' "
441+
f"or initiatedBy/user/userPrincipalName eq '{self.user.lower()}')"
442+
)
443+
444+
url = f"{base_url}auditLogs/directoryAudits"
445+
params = {"$filter": filter_q, "$top": self.lookup_limit}
446+
447+
audit_rows = []
448+
while url:
449+
r = requests.get(url, headers=adv_headers, params=params)
450+
if r.status_code != 200:
451+
self.error(f"Directory audit fetch failed: {r.text}")
452+
data = r.json()
453+
audit_rows.extend(data.get("value", []))
454+
url = data.get("@odata.nextLink") # None when no more pages
455+
params = None # only on first request
456+
457+
self.report({
435458
"filterParameters": {
436459
"timeRangeDays": self.time_range,
437460
"lookupLimit": self.lookup_limit,
438-
"startTime": filter_time_str
461+
"startTime": filter_time
439462
},
440-
"directoryAudits": audit_data
441-
}
442-
443-
# Return the results to TheHive
444-
self.report(result)
463+
"directoryAudits": audit_rows
464+
})
445465

446-
except Exception as ex:
466+
except Exception:
447467
self.error(traceback.format_exc())
448468

469+
449470
def handle_get_devices(self, headers, base_url):
450471
"""
451472
Retrieves enrolled device(s) from Intune by either:
@@ -470,36 +491,51 @@ def handle_get_devices(self, headers, base_url):
470491
if self.data_type == 'mail':
471492
self.user = query_value
472493
# Resolve UPN to GUID and use exact match
473-
# guid = self.ensure_user_guid(base_url, headers)
474-
endpoint = (
475-
f"deviceManagement/managedDevices?"
476-
f"$filter=userPrincipalName eq '{self.user}'"
477-
)
478-
else:
479-
# Use startswith for partial hostname matches
480-
endpoint = (
481-
f"deviceManagement/managedDevices?"
482-
f"$filter=startswith(deviceName,'{query_value}')"
494+
self.guid = self.ensure_user_guid(base_url, headers)
495+
safe_upn = self.user.replace("'", "''")
496+
497+
filter_q = (
498+
f"(userPrincipalName eq '{safe_upn}' "
499+
f"or userId eq '{self.guid}')"
483500
)
484-
485-
# Perform the GET request
486-
r = requests.get(base_url + endpoint, headers=headers)
487-
if r.status_code != 200:
488-
self.error(f"Failure to pull device(s) for query '{query_value}': {r.content}")
489-
490-
# Parse and report the results
491-
devices_data = r.json().get('value', [])
492-
self.report({"query": query_value, "devices": devices_data})
493-
494-
except Exception as ex:
501+
else: # hostname
502+
safe_name = query_value.replace("'", "''")
503+
filter_q = f"startswith(deviceName,'{safe_name}')"
504+
505+
url = f"{base_url}deviceManagement/managedDevices"
506+
params = {"$filter": filter_q, "$top": 100} # 100 = max page size
507+
508+
devices = []
509+
while url and len(devices) < self.lookup_limit:
510+
try:
511+
r = requests.get(url, headers=headers, params=params)
512+
except requests.exceptions.RequestException as e:
513+
self.error(f"Network error while contacting Microsoft Graph: {e}")
514+
if r.status_code != 200:
515+
self.error(f"ManagedDevice fetch failed: {r.text}")
516+
517+
data = r.json()
518+
devices.extend(data.get("value", []))
519+
520+
url = data.get("@odata.nextLink") # None = last page
521+
params = None
522+
523+
devices = devices[: self.lookup_limit]
524+
525+
self.report({"query": query_value, "devices": devices})
526+
527+
except Exception:
495528
self.error(traceback.format_exc())
496529

497530

498531
def run(self):
499532
Analyzer.run(self)
500533

501534
token = self.authenticate()
502-
headers = { 'Authorization': f'Bearer {token}' }
535+
headers = {
536+
'Authorization': f'Bearer {token}',
537+
'User-Agent': 'strangebee-thehive/1.0'
538+
}
503539
base_url = 'https://graph.microsoft.com/v1.0/'
504540

505541
# Decide which service to run

0 commit comments

Comments
 (0)