88import 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
1216class 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