Private/HuntChecks.ps1
|
# The per-tenant checks Invoke-NSPHuntTenant runs, in the order it runs them. Each reads Graph # through Get-NSPHuntCollection and records findings with Add-NSPHuntFinding. $script:NSPHuntSpSelect = 'id,appId,displayName,accountEnabled,appOwnerOrganizationId,servicePrincipalType,keyCredentials,passwordCredentials' function Find-NSPHuntIocServicePrincipal { # The IOC apps' service principals (by app ID, owner tenant, display name prefix): whether they # are enabled, which directory roles they hold, and what application permissions they have. # Returns the service principals. param([Parameter(Mandatory)][hashtable]$Hunt) $ioc = $Hunt.Ioc $select = '$select=' + $script:NSPHuntSpSelect $found = @{} foreach ($appId in $ioc.Apps.Keys) { foreach ($sp in (Get-NSPHuntCollection -Uri (New-NSPHuntFilterUri 'servicePrincipals' "appId eq '$appId'" $select))) { $found[$sp.id] = $sp } } foreach ($owner in $ioc.OwnerTenantIds) { try { foreach ($sp in (Get-NSPHuntCollection -Uri (New-NSPHuntFilterUri 'servicePrincipals' "appOwnerOrganizationId eq $owner" $select))) { $found[$sp.id] = $sp } } catch { Write-Verbose "Owner-tenant filter not supported here: $($_.Exception.Message)" } } foreach ($prefix in $ioc.NamePrefixes) { foreach ($sp in (Get-NSPHuntCollection -Uri (New-NSPHuntFilterUri 'servicePrincipals' "startswith(displayName,'$prefix')" $select))) { $found[$sp.id] = $sp } } $sps = @($found.Values) if ($sps.Count -eq 0) { Add-NSPHuntFinding -Hunt $Hunt -Severity Info -Check 'IOC apps present' -Object '-' -Detail "No $($ioc.Name) service principals found." } foreach ($sp in $sps) { $object = "$($sp.displayName) ($($sp.appId))" $known = $ioc.Apps.ContainsKey(([string]$sp.appId).ToLowerInvariant()) $label = if ($known) { $ioc.Apps[([string]$sp.appId).ToLowerInvariant()] } else { "Matched by owner tenant $($sp.appOwnerOrganizationId) or name; not a listed IOC app ID" } if ($sp.accountEnabled -and $known) { Add-NSPHuntFinding -Hunt $Hunt -Severity High -Check 'IOC app ENABLED' -Object $object -Detail "$label. Service principal still enabled; disable (do not delete)." } elseif ($sp.accountEnabled) { Add-NSPHuntFinding -Hunt $Hunt -Severity Medium -Check 'Related app ENABLED' -Object $object -Detail "$label. Review whether it is in use; -DisableIocApps leaves it alone." } else { Add-NSPHuntFinding -Hunt $Hunt -Severity Info -Check 'IOC app disabled' -Object $object -Detail $label } $assignments = Get-NSPHuntCollection -Uri (New-NSPHuntFilterUri 'roleManagement/directory/roleAssignments' "principalId eq '$($sp.id)'" '$expand=roleDefinition') foreach ($assignment in $assignments) { $roleName = Get-NSPHuntValue $assignment 'roleDefinition', 'displayName' $severity = if ($script:NSPHuntPrivilegedRoles.ContainsKey([string]$assignment.roleDefinitionId)) { 'High' } else { 'Medium' } Add-NSPHuntFinding -Hunt $Hunt -Severity $severity -Check 'IOC app holds directory role' -Object $object -Detail "Role: $roleName (scope $($assignment.directoryScopeId)). Remove if not intentionally granted." -Evidence $assignment } $permissions = New-Object System.Collections.Generic.List[string] foreach ($grant in (Get-NSPHuntCollection -Uri "servicePrincipals/$($sp.id)/appRoleAssignments")) { $permissions.Add("$($grant.resourceDisplayName):$(Resolve-NSPHuntAppRoleName -Hunt $Hunt -ResourceId $grant.resourceId -AppRoleId $grant.appRoleId)") } if ($permissions.Count -gt 0) { $risky = @($permissions | Where-Object { $p = $_; @($script:NSPHuntHighRiskAppPermissions | Where-Object { $p -like "*:$_" }).Count }) $severity = if ($risky.Count -gt 0 -and $sp.accountEnabled) { 'Medium' } else { 'Info' } Add-NSPHuntFinding -Hunt $Hunt -Severity $severity -Check 'IOC app permissions' -Object $object -Detail ('{0} app permissions; high-risk: {1}' -f $permissions.Count, ($risky -join ', ')) -Evidence $permissions.ToArray() } } $sps } function Find-NSPHuntPrivilegedServicePrincipal { # Every other service principal holding a privileged role: a review list. param([Parameter(Mandatory)][hashtable]$Hunt, [string[]]$IocSpIds = @()) foreach ($roleId in $script:NSPHuntPrivilegedRoles.Keys) { foreach ($assignment in (Get-NSPHuntCollection -Uri (New-NSPHuntFilterUri 'roleManagement/directory/roleAssignments' "roleDefinitionId eq '$roleId'" '$expand=principal'))) { if ((Get-NSPHuntValue $assignment 'principal', '@odata.type') -ne '#microsoft.graph.servicePrincipal') { continue } if ($IocSpIds -contains $assignment.principalId) { continue } $name = Get-NSPHuntValue $assignment 'principal', 'displayName' $appId = Get-NSPHuntValue $assignment 'principal', 'appId' Add-NSPHuntFinding -Hunt $Hunt -Severity Low -Check 'Service principal with privileged role' -Object "$name ($appId)" -Detail "$($script:NSPHuntPrivilegedRoles[$roleId]) - confirm this is intended." } } } function Find-NSPHuntAuditEvent { # ApplicationManagement and RoleManagement directory audits in the window. Flags apps granting # roles (to themselves: Critical), IOC apps acting or being granted roles, credentials added by # applications, and IOC user agents. Returns the object IDs an application added credentials to. param([Parameter(Mandatory)][hashtable]$Hunt, [string[]]$IocSpIds = @()) $ioc = $Hunt.Ioc $audits = New-Object System.Collections.Generic.List[object] foreach ($category in 'ApplicationManagement', 'RoleManagement') { try { foreach ($e in (Get-NSPHuntCollection -Uri (New-NSPHuntFilterUri 'auditLogs/directoryAudits' "activityDateTime ge $($Hunt.SinceIso) and category eq '$category'" '$top=999'))) { $audits.Add($e) } } catch { Add-NSPHuntFinding -Hunt $Hunt -Severity Warning -Check 'Audit log query' -Object $category -Detail "Failed: $($_.Exception.Message)" } } Save-NSPHuntEvidence -Hunt $Hunt -Data $audits.ToArray() -Name 'directoryAudits_AppAndRoleMgmt' $appCredentialTargets = @{} foreach ($e in $audits) { $activity = [string]$e.activityDisplayName $appSpId = Get-NSPHuntValue $e 'initiatedBy', 'app', 'servicePrincipalId' $appId = Get-NSPHuntValue $e 'initiatedBy', 'app', 'appId' $appName = Get-NSPHuntValue $e 'initiatedBy', 'app', 'displayName' $user = Get-NSPHuntValue $e 'initiatedBy', 'user', 'userPrincipalName' $userAgent = $null foreach ($detail in @(Get-NSPHuntValue $e 'additionalDetails')) { if ($detail -and (Get-NSPHuntValue $detail 'key') -eq 'User-Agent') { $userAgent = Get-NSPHuntValue $detail 'value' } } $targets = @(Get-NSPHuntValue $e 'targetResources' | Where-Object { $_ }) $targetIds = @($targets | ForEach-Object { [string](Get-NSPHuntValue $_ 'id') }) $targetNames = @($targets | ForEach-Object { Get-NSPHuntValue $_ 'displayName' }) -join '; ' $initiator = if ($appName -or $appSpId) { "app:$appName ($appSpId)" } else { "user:$user" } $byApp = [bool]($appSpId -or $appId) $byIocApp = ($appSpId -and $IocSpIds -contains $appSpId) -or ($appId -and $ioc.Apps.ContainsKey(([string]$appId).ToLowerInvariant())) -or ($appName -and $ioc.NameRegex -and $appName -match $ioc.NameRegex) $targetsIoc = @($targetIds | Where-Object { $IocSpIds -contains $_ }).Count -gt 0 -or ($ioc.NameRegex -and $targetNames -match $ioc.NameRegex) $userAgentHit = $userAgent -and @($ioc.UserAgents | Where-Object { $userAgent -like "*$_*" }).Count -gt 0 $isRoleAdd = $activity -match 'Add (eligible )?member to role' $isCredential = $activity -match 'credentials|Certificates and secrets' $selfAssigned = $isRoleAdd -and $appSpId -and ($targetIds -contains $appSpId) if ($isCredential -and $byApp) { foreach ($t in $targetIds) { $appCredentialTargets[$t] = $true } } $severity = $null $why = @() if ($selfAssigned) { $severity = 'Critical'; $why += 'service principal assigned a role to ITSELF' } if ($byIocApp) { if (-not $severity) { $severity = 'High' }; $why += "initiated by $($ioc.Name) app" } if ($isRoleAdd -and $targetsIoc) { if (-not $severity) { $severity = 'High' }; $why += "role granted to $($ioc.Name) app" } if ($isRoleAdd -and $byApp -and -not $severity) { $severity = 'Medium'; $why += 'role assignment made by an application' } if ($isCredential -and $byApp -and -not $severity) { $severity = 'Medium'; $why += 'credential added by an application' } if ($userAgentHit) { if (-not $severity) { $severity = 'Medium' }; $why += "IOC user agent ($userAgent)" } if (-not $severity -and $isCredential) { $severity = 'Low'; $why += 'credential change by user (verify)' } if ($severity) { Add-NSPHuntFinding -Hunt $Hunt -Severity $severity -Check "Audit: $activity" -Object $targetNames -Detail ('{0}; by {1}; result {2}' -f ($why -join '; '), $initiator, $e.result) -Time (Format-NSPHuntUtc $e.activityDateTime) -Evidence $e } } $appCredentialTargets } function Find-NSPHuntNewCredential { # Secrets and certificates added in the window on every app registration and service principal # (the actor planted secrets on unrelated apps). High when an application added it. Returns the # new credentials (AppId, Name, KeyId, Type) for the sign-in check. param([Parameter(Mandatory)][hashtable]$Hunt, [hashtable]$AppCredentialTargets = @{}) $newCredentials = New-Object System.Collections.Generic.List[object] $apps = Get-NSPHuntCollection -Uri 'applications?$select=id,appId,displayName,keyCredentials,passwordCredentials&$top=999' $sps = Get-NSPHuntCollection -Uri ('servicePrincipals?$select=' + $script:NSPHuntSpSelect + '&$top=999') # Small grace: some tools backdate startDateTime by a few minutes. $windowStart = $Hunt.Since.AddMinutes(-10) foreach ($set in @(@{ Kind = 'App registration'; Items = $apps }, @{ Kind = 'Service principal'; Items = $sps })) { foreach ($o in $set.Items) { $credentials = @( foreach ($c in @(Get-NSPHuntValue $o 'passwordCredentials')) { if ($c) { [pscustomobject]@{ Type = 'Secret'; C = $c } } } foreach ($c in @(Get-NSPHuntValue $o 'keyCredentials')) { if ($c) { [pscustomobject]@{ Type = 'Certificate'; C = $c } } } ) foreach ($credential in $credentials) { $start = Get-NSPHuntValue $credential.C 'startDateTime' if (-not $start -or (ConvertTo-NSPHuntUtc $start) -lt $windowStart) { continue } $severity = 'Medium' $why = 'Added in hunt window. Verify someone on your side created it.' if ($AppCredentialTargets.ContainsKey([string]$o.id)) { $severity = 'High'; $why = 'Added in hunt window BY AN APPLICATION (see audit findings).' } if ($set.Kind -eq 'Service principal' -and $credential.Type -eq 'Certificate' -and (Get-NSPHuntValue $credential.C 'usage') -eq 'Verify' -and (Get-NSPHuntValue $o 'servicePrincipalType') -eq 'Application' -and $severity -eq 'Medium') { # SAML signing certificates show up here; keep them, lower, when nothing else points at them. $severity = 'Low'; $why += ' (possibly a SAML/SSO signing cert)' } $keyId = Get-NSPHuntValue $credential.C 'keyId' Add-NSPHuntFinding -Hunt $Hunt -Severity $severity -Check "New credential ($($set.Kind))" -Object "$($o.displayName) ($($o.appId))" -Detail ("{0} '{1}' keyId {2}, expires {3}. {4}" -f $credential.Type, (Get-NSPHuntValue $credential.C 'displayName'), $keyId, (Format-NSPHuntUtc (Get-NSPHuntValue $credential.C 'endDateTime')), $why) -Time (Format-NSPHuntUtc $start) $newCredentials.Add([pscustomobject]@{ AppId = $o.appId; Name = $o.displayName; KeyId = $keyId; Type = $credential.Type }) } } } , $newCredentials.ToArray() } function Find-NSPHuntSignIn { # Sign-in logs (beta): service principal sign-ins of the IOC apps, sign-ins of any type from # the IOC IPs, and whether a credential added in the window has signed in. param([Parameter(Mandatory)][hashtable]$Hunt, [object[]]$AddedKeys = @()) $ioc = $Hunt.Ioc $base = 'auditLogs/signIns' $top = '$top=999' $premiumBlocked = $false # IOC apps' service principal sign-ins $appSignIns = New-Object System.Collections.Generic.List[object] foreach ($appId in $ioc.Apps.Keys) { try { $filter = "createdDateTime ge $($Hunt.SinceIso) and appId eq '$appId' and signInEventTypes/any(t: t eq 'servicePrincipal')" foreach ($s in (Get-NSPHuntCollection -Uri (New-NSPHuntFilterUri $base $filter $top) -ApiVersion beta)) { $appSignIns.Add($s) } } catch { if (Test-NSPHuntPremiumError $_.Exception.Message) { $premiumBlocked = $true; break } Add-NSPHuntFinding -Hunt $Hunt -Severity Warning -Check 'Sign-in query' -Object $appId -Detail $_.Exception.Message } Start-Sleep -Milliseconds 300 } Save-NSPHuntEvidence -Hunt $Hunt -Data $appSignIns.ToArray() -Name 'signIns_IocApps_ServicePrincipal' $summary = @{} foreach ($s in $appSignIns) { $ip = [string](Get-NSPHuntValue $s 'ipAddress') $hit = Test-NSPHuntIocIp -Hunt $Hunt -Ip $ip $errorCode = Get-NSPHuntValue $s 'status', 'errorCode' $ok = ($errorCode -eq 0) $result = if ($ok) { 'success' } else { "fail:$errorCode" } $resource = Get-NSPHuntValue $s 'resourceDisplayName' if ($hit -eq 'Exact' -or $hit -eq 'Prefix') { $severity = if ($ok) { 'Critical' } else { 'High' } Add-NSPHuntFinding -Hunt $Hunt -Severity $severity -Check 'IOC app sign-in from IOC IP' -Object "$($s.appDisplayName) ($($s.appId))" -Detail ('IP {0} ({1} match) -> {2}; {3}' -f $ip, $hit, $resource, $(if ($ok) { 'SUCCESS' } else { "failed $errorCode" })) -Time (Format-NSPHuntUtc $s.createdDateTime) -Evidence $s continue } # The rest is aggregated so the report is not flooded. $key = '{0}|{1}|{2}|{3}' -f $s.appId, $ip, $resource, $result $created = ConvertTo-NSPHuntUtc $s.createdDateTime if (-not $summary.ContainsKey($key)) { $summary[$key] = [pscustomobject]@{ App = $s.appDisplayName; AppId = $s.appId; Ip = $ip; Resource = $resource; Result = $result; Hits = 0; First = $created; Last = $created; Context = ($hit -eq 'Context') } } $row = $summary[$key] $row.Hits++ if ($created -lt $row.First) { $row.First = $created } if ($created -gt $row.Last) { $row.Last = $created } } foreach ($row in $summary.Values) { $severity = 'Info' $note = '' if ($row.Result -eq 'success') { $severity = 'Medium'; $note = 'Successful IOC app sign-in in window; confirm it matches normal vendor traffic.' } if ($row.Result -eq 'fail:7000112') { $note = 'App disabled - blocked (expected after disable).' } if ($row.Result -eq 'fail:7000229') { $note = 'Service principal missing in tenant (probe; matches the pre-compromise recon pattern).' } if ($row.Context) { $note = ($note + ' ' + (Get-NSPHuntContextNote -Hunt $Hunt -Ip $row.Ip)).Trim() } Add-NSPHuntFinding -Hunt $Hunt -Severity $severity -Check 'IOC app sign-ins (summary)' -Object "$($row.App) ($($row.AppId))" -Detail ('{0}x from {1} -> {2}; {3}; {4} to {5}. {6}' -f $row.Hits, $row.Ip, $row.Resource, $row.Result, (Format-NSPHuntUtc $row.First), (Format-NSPHuntUtc $row.Last), $note) -Time (Format-NSPHuntUtc $row.First) } # Any sign-in, every event type, from the exact IOC IPs $ipSignIns = New-Object System.Collections.Generic.List[object] if (-not $premiumBlocked) { foreach ($ip in $ioc.IPs) { foreach ($eventType in 'interactiveUser', 'nonInteractiveUser', 'servicePrincipal', 'managedIdentity') { try { $filter = "createdDateTime ge $($Hunt.SinceIso) and ipAddress eq '$ip' and signInEventTypes/any(t: t eq '$eventType')" foreach ($s in (Get-NSPHuntCollection -Uri (New-NSPHuntFilterUri $base $filter $top) -ApiVersion beta)) { $ipSignIns.Add($s) } } catch { if (Test-NSPHuntPremiumError $_.Exception.Message) { $premiumBlocked = $true; break } Add-NSPHuntFinding -Hunt $Hunt -Severity Warning -Check 'Sign-in query' -Object "$ip/$eventType" -Detail $_.Exception.Message } Start-Sleep -Milliseconds 300 } if ($premiumBlocked) { break } } } $seen = @{} $unique = @(foreach ($s in $ipSignIns) { $id = [string](Get-NSPHuntValue $s 'id') if (-not $id) { $s; continue } if (-not $seen.ContainsKey($id)) { $seen[$id] = $true; $s } }) Save-NSPHuntEvidence -Hunt $Hunt -Data $unique -Name 'signIns_IocIPs_AllTypes' foreach ($s in $unique) { $errorCode = Get-NSPHuntValue $s 'status', 'errorCode' $who = Get-NSPHuntValue $s 'userPrincipalName' if (-not $who) { $who = Get-NSPHuntValue $s 'servicePrincipalName' } $severity = if ($errorCode -eq 0) { 'Critical' } else { 'High' } $outcome = if ($errorCode -eq 0) { 'SUCCESS' } else { "failed $errorCode $(Get-NSPHuntValue $s 'status', 'failureReason')" } Add-NSPHuntFinding -Hunt $Hunt -Severity $severity -Check 'Sign-in from IOC IP' -Object "$who / $($s.appDisplayName)" -Detail ('IP {0}, type {1}, {2}, UA {3}' -f $s.ipAddress, (@(Get-NSPHuntValue $s 'signInEventTypes') -join ','), $outcome, (Get-NSPHuntValue $s 'userAgent')) -Time (Format-NSPHuntUtc $s.createdDateTime) -Evidence $s } # Has a credential added in the window been used? $credentialSignIns = New-Object System.Collections.Generic.List[object] if (-not $premiumBlocked) { foreach ($group in ($AddedKeys | Group-Object AppId)) { if (-not $group.Name) { continue } try { $filter = "createdDateTime ge $($Hunt.SinceIso) and appId eq '$($group.Name)' and signInEventTypes/any(t: t eq 'servicePrincipal')" $signIns = Get-NSPHuntCollection -Uri (New-NSPHuntFilterUri $base $filter $top) -ApiVersion beta -MaxPages 20 } catch { if (Test-NSPHuntPremiumError $_.Exception.Message) { $premiumBlocked = $true; break } Add-NSPHuntFinding -Hunt $Hunt -Severity Warning -Check 'Sign-in query' -Object $group.Name -Detail $_.Exception.Message continue } $keyIds = @($group.Group | ForEach-Object { [string]$_.KeyId }) foreach ($s in $signIns) { $keyId = [string](Get-NSPHuntValue $s 'servicePrincipalCredentialKeyId') if (-not $keyId -or $keyIds -notcontains $keyId) { continue } $credentialSignIns.Add($s) $hit = Test-NSPHuntIocIp -Hunt $Hunt -Ip ([string]$s.ipAddress) $severity = if ($hit -eq 'Exact' -or $hit -eq 'Prefix') { 'Critical' } else { 'Medium' } Add-NSPHuntFinding -Hunt $Hunt -Severity $severity -Check 'New credential USED' -Object "$($s.appDisplayName) ($($s.appId))" -Detail ('keyId {0} used from {1} -> {2}; error {3}' -f $keyId, $s.ipAddress, (Get-NSPHuntValue $s 'resourceDisplayName'), (Get-NSPHuntValue $s 'status', 'errorCode')) -Time (Format-NSPHuntUtc $s.createdDateTime) -Evidence $s } Start-Sleep -Milliseconds 300 } } Save-NSPHuntEvidence -Hunt $Hunt -Data $credentialSignIns.ToArray() -Name 'signIns_NewCredentialUse' if ($premiumBlocked) { Add-NSPHuntFinding -Hunt $Hunt -Severity Warning -Check 'Sign-in logs' -Object '-' -Detail 'Sign-in logs not readable through Graph (no Entra ID P1). Export Service principal sign-ins from the Entra portal NOW; these tenants keep 7 days.' } } function Find-NSPHuntNewUser { param([Parameter(Mandatory)][hashtable]$Hunt) try { $users = Get-NSPHuntCollection -Uri (New-NSPHuntFilterUri 'users' "createdDateTime ge $($Hunt.SinceIso)" '$select=id,userPrincipalName,displayName,createdDateTime,accountEnabled,userType') foreach ($u in $users) { Add-NSPHuntFinding -Hunt $Hunt -Severity Info -Check 'User created in window' -Object $u.userPrincipalName -Detail "Type $($u.userType), enabled $($u.accountEnabled)" -Time (Format-NSPHuntUtc $u.createdDateTime) } } catch { Add-NSPHuntFinding -Hunt $Hunt -Severity Warning -Check 'New users query' -Object '-' -Detail $_.Exception.Message } } |