diff --git a/classes/__init__.py b/classes/__init__.py index 82e4a7f..ee073dd 100644 --- a/classes/__init__.py +++ b/classes/__init__.py @@ -506,6 +506,29 @@ def load_from_input(self, body): self.DetailedResults = body['DetailedResults'] self.RiskDetectionTotalCount = body.get('RiskDetectionTotalCount') +class AADInsightsModule: + '''An AAD Insights Module object''' + + def __init__(self): + self.AnalyzedEntities = 0 + self.NewUsersCount = 0 + self.NewDevicesCount = 0 + self.RelatedUsers = False + self.ModuleName = 'AADInsightsModule' + self.IPDetails = [] + self.HostDetails = [] + self.DetailedResults = [] + + def load_from_input(self, body): + self.AnalyzedEntities = body['AnalyzedEntities'] + self.NewUsersCount = body['NewUsersCount'] + self.NewDevicesCount = body['NewDevicesCount'] + self.RelatedUsers = body['RelatedUsers'] + self.ModuleName = 'AADInsightsModule' + self.IPDetails = body['IPDetails'] + self.HostDetails = body['HostDetails'] + self.DetailedResults = body['DetailedResults'] + class FileModule: '''A File Module object''' diff --git a/modules/aadinsights.py b/modules/aadinsights.py new file mode 100644 index 0000000..d33511c --- /dev/null +++ b/modules/aadinsights.py @@ -0,0 +1,249 @@ +from classes import BaseModule, Response, AADInsightsModule, STATError, STATNotFound +from shared import rest, data +from datetime import datetime +import json + +def execute_aadinsights_module (req_body): + + #Inputs NewThresholdInDays, LookbackInDays, AddIncidentComments, AddIncidentTask, Entities, IncidentTaskInstructions, LookbackInDays, NewThresholdInDays + + base_object = BaseModule() + base_object.load_from_input(req_body['BaseModuleBody']) + + aadinsights_object = AADInsightsModule() + new_threshold = req_body.get('NewThresholdInDays', 7) + lookback = req_body.get('LookbackInDays', 30) + + + people_top = 10 + for account in base_object.Accounts: + userid = account.get('id') + + if userid: + upn = account.get('userPrincipalName') + current_account = { + 'UserId': f'{userid}', + 'UserPrincipalName': f'{upn}', + 'IsNewUser': False, + 'CommonLocations': [], + 'CommonIPs' : [], + 'CommonDevices': '[]]', + 'RecentActivities': [], + 'UserCreationTime': None, + 'People': [] + } + # Build related people list + path = f'/v1.0/users/{userid}/people/?$select=id,userPrincipalName&$top={people_top}' + try: + people_list = json.loads(rest.rest_call_get(base_object, api='msgraph', path=path).content) + current_account['People'] = [item["id"] for item in people_list["value"]] + current_account['People'].remove(userid) + except STATNotFound: + pass + # to do - handle permission error? + + # Check if the user is new + try: + user_creation_time = datetime.strptime(account.get('createdDateTime'), "%Y-%m-%dT%H:%M:%SZ") + current_account['UserCreationTime'] = account.get('createdDateTime') + if (datetime.now() - user_creation_time ).days > new_threshold: + current_account['IsNewUser'] = False + else: + current_account['IsNewUser'] = True + except: + current_account['IsNewUser'] = False + + CommonLocations_query = f''' + let TopIPs = materialize( SigninLogs + | where TimeGenerated > ago({lookback}d) + | where UserId == "{userid}" + | where ResultType == 0 + | extend CommonLocation = strcat( LocationDetails.city, ", ", LocationDetails.state, ", ", LocationDetails.countryOrRegion) + | extend CommonLocation = iif(CommonLocation == ", , ", "Unknown", CommonLocation) + | summarize TotalUser = count() by CommonLocation + | join kind=leftouter( + SigninLogs + | where TimeGenerated > ago({lookback}d) + | where UserId != "{userid}" + | where ResultType == 0 + | extend CommonLocation = strcat( LocationDetails.city, ", ", LocationDetails.state, ", ", LocationDetails.countryOrRegion) + | extend CommonLocation = iif(CommonLocation == ", , ", "Unknown", CommonLocation) + | summarize TotalTenant = count() by CommonLocation + ) on CommonLocation + | project-away CommonLocation1 + | extend TotalTenant = iif( isempty(TotalTenant), 0, TotalTenant) + | order by TotalUser desc + | serialize Top = row_number() + ); + let TopOthers = TopIPs | where Top > 9 | summarize TotalUser = sum(TotalUser) | extend CommonLocation = "Others", Top = 10; + TopIPs | where Top <= 9 + | union TopOthers + | where TotalUser != 0 + | order by Top asc + | project-reorder Top, CommonLocation, TotalUser''' + CommonLocations_results = rest.execute_la_query(base_object, CommonLocations_query, lookback) + if CommonLocations_results: + current_account['CommonLocations'] = CommonLocations_results + + CommonIPs_query = f''' + let TopIPs = materialize( SigninLogs + | where TimeGenerated > ago({lookback}d) + | where UserId == "{userid}" + | where ResultType == 0 + | summarize TotalUser = count() by IPAddress + | join kind=leftouter( + SigninLogs + | where TimeGenerated > ago({lookback}d) + | where UserId != "{userid}" + | where ResultType == 0 + | summarize TotalTenant = count() by IPAddress + ) on IPAddress + | project-away IPAddress1 + | extend TotalTenant = iif( isempty(TotalTenant), 0, TotalTenant) + | order by TotalUser desc + | serialize Top = row_number() + ); + let TopOthers = TopIPs | where Top > 9 | summarize TotalUser = sum(TotalUser) | extend IPAddress = "Others", Top = 10; + TopIPs | where Top <= 9 + | union TopOthers + | where TotalUser != 0 + | order by Top asc + | project-reorder Top, IPAddress, TotalUser''' + CommonIPs_results = rest.execute_la_query(base_object, CommonIPs_query, lookback) + if CommonIPs_results: + current_account['CommonIPs'] = CommonIPs_results + + CommonDevices_query = f''' + SigninLogs + | where TimeGenerated > ago({lookback}d) + | where ResultType == 0 + | where UserId == "{userid}" + | where isnotempty(DeviceDetail.deviceId) + | distinct DeviceName = tostring(DeviceDetail.displayName), DeviceOS = tostring(DeviceDetail.operatingSystem)''' + CommonDevices_results = rest.execute_la_query(base_object, CommonDevices_query, lookback) + if CommonDevices_results: + current_account['CommonDevices'] = CommonDevices_results + + RecentActivities_query = f''' + AuditLogs + | where TimeGenerated > ago({lookback}d) + | where Result =~ "success" + | where OperationName has_any ("Consent to application","Reset user password","Change password (self-service)","Reset password (self-service)","User registered security info","Register device","User deleted security info","User changed default security info","Add service principal credentials","Update StsRefreshTokenValidFrom Timestamp") + | mv-apply TargetResources on ( + where TargetResources.id == "{userid}" + ) + | project TimeGenerated, OperationName + | order by TimeGenerated desc''' + RecentActivities_results = rest.execute_la_query(base_object, RecentActivities_query, lookback) + if RecentActivities_results: + current_account['RecentActivities'] = RecentActivities_results + + aadinsights_object.DetailedResults.append(current_account) + + for account in base_object.Accounts: + userid = account.get('id') + if userid in [person for item in aadinsights_object.DetailedResults for person in item.get("People", [])]: + aadinsights_object.RelatedUsers = True + break #don't to continue if we already found a related user + + for ip in base_object.IPs: + ipaddress = ip.get('Address') + if ip.get('IPType') != 2: #IPType 2 is a private IP (RFC1918) + current_ip = { + 'IPAddress': f'{ipaddress}', + 'IPPrevelanceSuccess': 0, + 'IPPrevelanceWrongPassword': 0, + 'IPPrevelanceFirstTimeSeenInScope': None, + 'IPType': ip.get('IPType') + } + IPPrevelance_query = f''' + SigninLogs + | where TimeGenerated > ago({lookback}d) + | where IPAddress == "{ipaddress}" + | where ResultType in (0, 50126) + | summarize IPSuccess = countif(ResultType == 0), IPWrongPassword = countif(ResultType == 50126), FirstTimeSeenInScope = min(TimeGenerated)''' + IPPrevelance_results = rest.execute_la_query(base_object, IPPrevelance_query, lookback) + if IPPrevelance_results: + current_ip['IPPrevelanceSuccess'] = IPPrevelance_results[0]['IPSuccess'] + current_ip['IPPrevelanceWrongPassword'] = IPPrevelance_results[0]['IPWrongPassword'] + current_ip['IPPrevelanceFirstTimeSeenInScope'] = IPPrevelance_results[0]['FirstTimeSeenInScope'] + + aadinsights_object.IPDetails.append(current_ip) + + for host in base_object.Hosts: + hostaadid = host.get('RawEntity', {}).get('additionalData', {}).get('AadDeviceId') + hostname = host.get("FQDN", "Unknown") + if hostaadid: + current_host = { + 'HostAadId': f'{hostaadid}', + 'HostName': f'{hostname}', + 'HostCreationTime': None, + 'IsNewHost': False + } + path = f'/v1.0/devices/{hostaadid}' + try: + host_creation_time = json.loads(rest.rest_call_get(base_object, api='msgraph', path=path).content)['createdDateTime'] + current_host['HostCreationTime'] = datetime.strptime(host_creation_time, "%Y-%m-%dT%H:%M:%SZ") + if (datetime.now() - host_creation_time ).days > new_threshold: + current_host['IsNewHost'] = False + else: + current_host['IsNewHost'] = True + except STATNotFound: + pass + else: + current_host['IsNewHost'] = False + + aadinsights_object.HostDetails.append(current_host) + + entities_nb = len(aadinsights_object.DetailedResults) + len(aadinsights_object.IPDetails) + len(aadinsights_object.HostDetails) + if entities_nb != 0: + aadinsights_object.AnalyzedEntities = entities_nb + aadinsights_object.NewUsersCount = sum(1 for item in aadinsights_object.DetailedResults if item.get("IsNewUser") == True) + aadinsights_object.NewDevicesCount = sum(1 for item in aadinsights_object.HostDetails if item.get("IsNewUser") == True) + + if req_body.get('AddIncidentComments', True): + comment = f'

Entra ID Insights Module - (Last {lookback} days)

' + if aadinsights_object.RelatedUsers: + comment += f'

Users in this incidents are related (they actively collaborate on Microsoft 365)

' + for item in aadinsights_object.DetailedResults: + comment += f'

👤 {item.get("UserPrincipalName", item.get("UserId", "Unknown"))}

' + comment += f'' + + for item in aadinsights_object.IPDetails: + comment += f'

🌐 {item.get("IPAddress")}

' + if item.get('IPType') != 2: + comment += f'' + else: + comment += f'' + + for item in aadinsights_object.HostDetails: + comment += f'

💻 {item.get("HostName")}

' + comment += f'' + + comment_result = rest.add_incident_comment(base_object, comment) + + if req_body.get('AddIncidentTask', False): + if aadinsights_object.NewUsersCount > 0 or aadinsights_object.NewDevicesCount > 0 or aadinsights_object.RelatedUsers: + ask_result = rest.add_incident_task(base_object, req_body.get('QueryDescription', 'Review new users and devices, and take in consideration related users'), req_body.get('IncidentTaskInstructions')) + + return Response(aadinsights_object) diff --git a/modules/base.py b/modules/base.py index c196c32..769c2df 100644 --- a/modules/base.py +++ b/modules/base.py @@ -168,7 +168,7 @@ def enrich_accounts(entities): account_entities = list(filter(lambda x: x['kind'].lower() == 'account', entities)) base_object.AccountsCount = len(account_entities) - attributes = 'userPrincipalName,id,onPremisesSecurityIdentifier,onPremisesDistinguishedName,onPremisesDomainName,onPremisesSamAccountName,onPremisesSyncEnabled,mail,city,state,country,department,jobTitle,officeLocation,accountEnabled&$expand=manager($select=userPrincipalName,mail,id)' + attributes = 'userPrincipalName,id,onPremisesSecurityIdentifier,onPremisesDistinguishedName,onPremisesDomainName,onPremisesSamAccountName,onPremisesSyncEnabled,mail,city,state,country,department,jobTitle,officeLocation,accountEnabled,createdDateTime&$expand=manager($select=userPrincipalName,mail,id)' for account in account_entities: aad_id = data.coalesce(account.get('properties',{}).get('aadUserId'), account.get('AadUserId')) @@ -264,7 +264,7 @@ def get_account_by_mail(account, attributes, properties, enrich_method:str='Mail (IdentityInfo | where AccountUPN =~ '{account}' | summarize arg_max(TimeGenerated, *) by AccountUPN -| project userPrincipalName=AccountUPN, id=AccountObjectId, onPremisesSecurityIdentifier=AccountSID, onPremisesDistinguishedName=OnPremisesDistinguishedName, onPremisesDomainName=AccountDomain, onPremisesSamAccountName=AccountName, mail=MailAddress, department=Department, jobTitle=JobTitle, accountEnabled=IsAccountEnabled, manager=Manager)''' +| project userPrincipalName=AccountUPN, id=AccountObjectId, onPremisesSecurityIdentifier=AccountSID, onPremisesDistinguishedName=OnPremisesDistinguishedName, onPremisesDomainName=AccountDomain, onPremisesSamAccountName=AccountName, mail=MailAddress, department=Department, jobTitle=JobTitle, accountEnabled=IsAccountEnabled, manager=Manager, createdDateTime=AccountCreationTime)''' try: user_info = json.loads(rest.rest_call_get(base_object, api='msgraph', path=f'''/v1.0/users?$filter=(mail%20eq%20'{account}')&$select={attributes}''').content) except STATError: @@ -289,7 +289,7 @@ def get_account_by_dn(account, attributes, properties, enrich_method:str='DN'): (IdentityInfo | where OnPremisesDistinguishedName =~ '{account}' | summarize arg_max(TimeGenerated, *) by OnPremisesDistinguishedName -| project userPrincipalName=AccountUPN, id=AccountObjectId, onPremisesSecurityIdentifier=AccountSID, onPremisesDistinguishedName=OnPremisesDistinguishedName, onPremisesDomainName=AccountDomain, onPremisesSamAccountName=AccountName, mail=MailAddress, department=Department, jobTitle=JobTitle, accountEnabled=IsAccountEnabled, manager=Manager)''' +| project userPrincipalName=AccountUPN, id=AccountObjectId, onPremisesSecurityIdentifier=AccountSID, onPremisesDistinguishedName=OnPremisesDistinguishedName, onPremisesDomainName=AccountDomain, onPremisesSamAccountName=AccountName, mail=MailAddress, department=Department, jobTitle=JobTitle, accountEnabled=IsAccountEnabled, manager=Manager, createdDateTime=AccountCreationTime)''' results = rest.execute_la_query(base_object, query, 14) if results and results[0]['id']: @@ -308,7 +308,7 @@ def get_account_by_sid(account, attributes, properties, enrich_method:str='SID') (IdentityInfo | where AccountSID =~ '{account}' | summarize arg_max(TimeGenerated, *) by AccountSID -| project userPrincipalName=AccountUPN, id=AccountObjectId, onPremisesSecurityIdentifier=AccountSID, onPremisesDistinguishedName=OnPremisesDistinguishedName, onPremisesDomainName=AccountDomain, onPremisesSamAccountName=AccountName, mail=MailAddress, department=Department, jobTitle=JobTitle, accountEnabled=IsAccountEnabled, manager=Manager)''' +| project userPrincipalName=AccountUPN, id=AccountObjectId, onPremisesSecurityIdentifier=AccountSID, onPremisesDistinguishedName=OnPremisesDistinguishedName, onPremisesDomainName=AccountDomain, onPremisesSamAccountName=AccountName, mail=MailAddress, department=Department, jobTitle=JobTitle, accountEnabled=IsAccountEnabled, manager=Manager, createdDateTime=AccountCreationTime)''' try: user_info = json.loads(rest.rest_call_get(base_object, api='msgraph', path=f'''/v1.0/users?$filter=(onPremisesSecurityIdentifier%20eq%20'{account}')&$select={attributes}''').content) @@ -332,7 +332,7 @@ def get_account_by_samaccountname(account, attributes, properties, enrich_method (IdentityInfo | where AccountName =~ '{account}' | summarize arg_max(TimeGenerated, *) by AccountSID, AccountObjectId -| project userPrincipalName=AccountUPN, id=AccountObjectId, onPremisesSecurityIdentifier=AccountSID, onPremisesDistinguishedName=OnPremisesDistinguishedName, onPremisesDomainName=AccountDomain, onPremisesSamAccountName=AccountName, mail=MailAddress, department=Department, jobTitle=JobTitle, accountEnabled=IsAccountEnabled, manager=Manager)''' +| project userPrincipalName=AccountUPN, id=AccountObjectId, onPremisesSecurityIdentifier=AccountSID, onPremisesDistinguishedName=OnPremisesDistinguishedName, onPremisesDomainName=AccountDomain, onPremisesSamAccountName=AccountName, mail=MailAddress, department=Department, jobTitle=JobTitle, accountEnabled=IsAccountEnabled, manager=Manager, createdDateTime=AccountCreationTime)''' results = rest.execute_la_query(base_object, query, 14) if len(results) == 1 and results[0].get('id'): diff --git a/shared/coordinator.py b/shared/coordinator.py index aced643..dc4c8a5 100644 --- a/shared/coordinator.py +++ b/shared/coordinator.py @@ -1,4 +1,4 @@ -from modules import base, kql, watchlist, ti, relatedalerts, scoring, ueba, playbook, exchange, aadrisks, file, createincident, mdca, mde, exposure_device, exposure_user +from modules import base, kql, watchlist, ti, relatedalerts, scoring, ueba, playbook, exchange, aadrisks, aadinsights, file, createincident, mdca, mde, exposure_device, exposure_user from classes import STATError def initiate_module(module_name, req_body): @@ -25,6 +25,8 @@ def initiate_module(module_name, req_body): return_data = file.execute_file_module(req_body) case 'aadrisks': return_data = aadrisks.execute_aadrisks_module(req_body) + case 'aadinsights': + return_data = aadinsights.execute_aadinsights_module(req_body) case 'deviceexposure': return_data = exposure_device.execute_device_exposure_module(req_body) case 'userexposure':