Kari.psm1
|
#region Private Functions function Assert-KariGraphConnection { [CmdletBinding()] [OutputType([bool])] param () $context = Get-MgContext -ErrorAction SilentlyContinue if (-not $context) { Write-Verbose "Not connected to Microsoft Graph" return $false } $ReqScopes = @("Directory.Read.All") if(($ReqScopes | Where-Object { $context.Scopes -notcontains $_ }).Count -ne 0){ Write-Verbose "Insufficient Graph API permissions. 'Directory.Read.All' scope is least required." return $false } Write-Verbose "Connected to Tenant ID '$($context.TenantId)' with sufficient permissions." return $true } function Get-KnownRogueApplications { [CmdletBinding()] # https://huntresslabs.github.io/rogueapps/ $JsonUri = 'https://raw.githubusercontent.com/huntresslabs/rogueapps/main/public/rogueapps.json' return Invoke-RestMethod -Uri $JsonUri -Method Get } function Get-KariHuntResultObject { [CmdletBinding()] param ( [guid]$AppId, [guid]$ObjectId, [string]$DisplayName, [string]$Issue, [string]$Details, [datetime]$CreatedAt ) return [PSCustomObject]@{ AppId = $AppId ObjectId = $ObjectId DisplayName = $DisplayName Issue = $Issue Details = $Details CreatedAt = $CreatedAt } } #endregion #region Public Functions <# .SYNOPSIS Retrieves API Permission names for both Delegated and App-Only permissions assigned to a service principal. .DESCRIPTION Retrieves API Permission names for both Delegated and App-Only permissions assigned to a service principal. Currently, it doesn't distinguish between Microsoft Graph and other API permissions, so the output may contain a mix of permission names from different APIs. If any names are identical across APIs, they will appear only once in the output. This may or may not change in future versions. .PARAMETER App The Service Principal object representing the application to analyze. .EXAMPLE $App = Get-MgServicePrincipalByAppId -AppId '<AppId>' $Permissions = Get-KariSpPermissions -App $App Retrieves a string array of permission names assigned to the specified application. .LINK https://learn.microsoft.com/en-us/entra/identity-platform/permissions-consent-overview .LINK https://www.coreview.com/blog/entra-app-registration-security #> function Get-KariSpPermissions { [CmdletBinding()] [OutputType([System.Collections.ArrayList])] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [Microsoft.Graph.PowerShell.Models.MicrosoftGraphServicePrincipal]$App ) # TODO Distinguish between Microsoft Graph and other API permissions in output, also by returning a populated PSObject begin { $PermissionScopes = New-Object System.Collections.ArrayList # '' list of scope words } process { # Get Delegated Permissions $Delegated = Get-MgOauth2PermissionGrant -Filter "clientId eq '$($App.Id)'" foreach($Grant in $Delegated) { $PermissionScopes.AddRange($Grant.Scope.Split(' ')) | Out-Null } # Get App-Only Permissions $AppRoles = Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $App.Id foreach($Role in $AppRoles) { $ResourceSp = Get-MgServicePrincipal -ServicePrincipalId $Role.ResourceId # Get the resource service principal app for each role $AppRole = $ResourceSp.AppRoles | Where-Object { $_.Id -eq $Role.AppRoleId } $PermissionScopes.Add($AppRole.Value) | Out-Null } } end { return $PermissionScopes | Select-Object -Unique } } Export-ModuleMember -Function Get-KariSpPermissions <# .SYNOPSIS Analyzes a Service Principal application for suspicious indicators. .DESCRIPTION Analyzes a Service Principal application for suspicious indicators by checking the application against known indicators of compromise and best practices. Best used when scanning a subset of applications, retrieved via Get-MgServicePrincipal with appropriate filtering. Otherwise use 'Invoke-KariHunt' to scan all Enterprise Applications in the tenant. .PARAMETER App The Service Principal object representing the application to analyze. .PARAMETER IgnoreCriteria An array of criteria to ignore during analysis. See the Validation Set for possible values. .EXAMPLE $SomeAppObject | Get-KariHuntAppResult Scans a single application object for suspicious indicators. .EXAMPLE Get-MgServicePrincipalByAppId -AppId <AppId> | Get-KariHuntAppResult Analyzes a single application in the tenant for suspicious indicators. .EXAMPLE Get-MgServicePrincipal -All | Get-KariHuntAppResult Analyzes all service principals in the tenant for suspicious indicators. Doesn't filter for Enterprise applications, which will lead to false positives. .EXAMPLE Get-KariHuntAppResult -IgnoreCriteria 'OldApplication','ExpiredSecret' -App $Object Analyzes the provided application object while ignoring checks for old applications and expired secrets. #> function Get-KariHuntAppResult { [CmdletBinding()] [OutputType([System.Collections.ArrayList])] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [Microsoft.Graph.PowerShell.Models.MicrosoftGraphServicePrincipal]$App, [Parameter(Mandatory = $false)] [ValidateSet('KnownRogueApps', 'GenericName', 'NoAlphanumeric', 'CallbackURI', 'InsecureURI', 'DisplayNameMatchesOwnerUPN', 'ShortDisplayName', 'ExpiredCertificate', 'ExpiredSecret', 'OldApplication', 'HighRiskAPIPermissions' )] [string[]]$IgnoreCriteria = @() ) begin { $results = New-Object System.Collections.ArrayList $KnownRogueApps = try { Get-KnownRogueApplications -ErrorAction Continue } catch { @() ; Write-Warning "Failed to retrieve known rogue applications list from GitHub." } $Now = Get-Date } process { Write-Verbose "Processing application: $($App.DisplayName)" $AppReg = try { Get-MgApplicationByAppId -AppId $App.AppId -ErrorAction SilentlyContinue } catch { $null } $RedirectUris = $App.replyUrls [datetime]$CreatedAt = $App.AdditionalProperties.createdDateTime $AppCommonMeta = @{ AppId = $App.AppId ObjectId = $App.Id DisplayName = $App.DisplayName CreatedAt = $CreatedAt } # Check if application matches any known rogue applications if (@($IgnoreCriteria) -notcontains 'KnownRogueApps' -and ($KnownRogueApps | Where-Object { $App.AppId -eq $_.AppId })) { $results.Add( $(Get-KariHuntResultObject @AppCommonMeta ` -Issue "Known Rogue Application" -Details "App listed in a known rogue applications database - '$($App.DisplayName)'.") ) | Out-Null Write-Verbose "Rogue Application detected: $($App.DisplayName) ($($App.AppId))" } # Check if application display name similar to 'Test', 'Test app', etc. if (@($IgnoreCriteria) -notcontains 'GenericName' -and $App.DisplayName -match '(?i)(?:demo|test|testing|sample|example|placeholder|dummy|temp|trial)') { $results.Add( $(Get-KariHuntResultObject @AppCommonMeta ` -Issue "Generic Application Name" -Details "Generic/non-meaningful named app - '$($App.DisplayName)'.") ) | Out-Null Write-Verbose "Generic Application Name detected: $($App.DisplayName) ($($App.AppId))" } # Check if application name contains no alphanumeric characters if (@($IgnoreCriteria) -notcontains 'NoAlphanumeric' -and $App.DisplayName -notmatch '[a-zA-Z0-9]') { $results.Add( $(Get-KariHuntResultObject @AppCommonMeta ` -Issue "No Alphanumeric Characters" -Details "Name contains no alphanumeric characters - '$($App.DisplayName)'.") ) | Out-Null Write-Verbose "No Alphanumeric Characters detected: $($App.DisplayName) ($($App.AppId))" } # Check if application reply URLs contain localhost/access or 127.0.0.1/access $CallbackMatch = $RedirectUris -match '(//)?(localhost|127\.0\.0\.1)(:\d+)?/access/?$' if (@($IgnoreCriteria) -notcontains 'CallbackURI' -and $CallbackMatch) { $results.Add( $(Get-KariHuntResultObject @AppCommonMeta ` -Issue "Callback Redirect URI" -Details "Contains a loopback redirect URI - '$($CallbackMatch -join ', ')'.") ) | Out-Null Write-Verbose "Callback Redirect URI detected: $($App.DisplayName) ($($App.AppId))" } # Check if application reply URL is HTTP (not encrypted) $RedirectMatch = $RedirectUris -match '^http://' if (@($IgnoreCriteria) -notcontains 'InsecureURI' -and $RedirectMatch) { $results.Add( $(Get-KariHuntResultObject @AppCommonMeta ` -Issue "Insecure Redirect URI" -Details "Contains insecure HTTP redirect URI - '$($RedirectMatch -join ', ')'.") ) | Out-Null Write-Verbose "Insecure Redirect URI detected: $($App.DisplayName) ($($App.AppId))" } # Check if application display name matches Owner(s) UPN if (@($IgnoreCriteria) -notcontains 'DisplayNameMatchesOwnerUPN') { $Owners = Get-MgServicePrincipalOwnerAsUser -ServicePrincipalId $App.Id -All -ErrorAction SilentlyContinue foreach ($Owner in $Owners) { if ($App.DisplayName.ToString() -eq $Owner.UserPrincipalName.ToString()) { $results.Add( $(Get-KariHuntResultObject @AppCommonMeta ` -Issue "Display Name Matches Owner UPN" -Details "Name matches an owner's UPN name - $($App.DisplayName).") ) | Out-Null Write-Verbose "Display Name Matches Owner UPN detected: $($App.DisplayName) ($($App.AppId))" } } } # Check if application display name is less than 3 characters if (@($IgnoreCriteria) -notcontains 'ShortDisplayName' -and $App.DisplayName.Length -lt 3) { $results.Add( $(Get-KariHuntResultObject @AppCommonMeta ` -Issue "Short Display Name" -Details "Display name is less than 3 characters - '$($App.DisplayName)'.") ) | Out-Null Write-Verbose "Short Display Name detected: $($App.DisplayName) ($($App.AppId))" } # Check if Application Certificates are expired if ($AppReg -and @($IgnoreCriteria) -notcontains 'ExpiredCertificate') { foreach ($Cert in $AppReg.KeyCredentials) { if ($Cert.EndDateTime -lt $Now) { $results.Add( $(Get-KariHuntResultObject @AppCommonMeta ` -Issue "Expired Certificate" -Details "Has an expired certificate - '$($Cert.DisplayName)'.") ) | Out-Null Write-Verbose "Expired Certificate detected: $($App.DisplayName) ($($App.AppId))" } } } # Check if Secrets are expired if ($AppReg -and @($IgnoreCriteria) -notcontains 'ExpiredSecret') { foreach ($Secret in $AppReg.PasswordCredentials) { if ($Secret.EndDateTime -lt $Now) { $results.Add( $(Get-KariHuntResultObject @AppCommonMeta ` -Issue "Expired Secret" -Details "Has an expired secret - '$($Secret.DisplayName)'.") ) | Out-Null Write-Verbose "Expired Secret detected: $($App.DisplayName) ($($App.AppId))" } } } # Check if App is older than 3 years if (@($IgnoreCriteria) -notcontains 'OldApplication' -and ($Now - $CreatedAt).TotalDays -gt 1095) { $results.Add( $(Get-KariHuntResultObject @AppCommonMeta ` -Issue "Old Application" -Details "Created over 3 years ago - '$($CreatedAt.ToString('yyyy-MM-dd'))'.") ) | Out-Null Write-Verbose "Old Application detected: $($App.DisplayName) ($($App.AppId))" } # Check if App has unusual or high risk API permissions if( @($IgnoreCriteria) -notcontains 'HighRiskAPIPermissions') { $AppPermissions = Get-KariSpPermissions -App $App $DodgyPermissions = @( # TODO Add more permissions as identified 'Directory.ReadWrite.All', 'Directory.AccessAsUser.All', 'Domain.ReadWrite.All', 'Group.ReadWrite.All', 'User.ReadWrite.All', 'Policy.ReadWrite.ApplicationConfiguration', 'Application.ReadWrite.OwnedBy', 'Application.ReadWrite.All', 'RoleManagement.ReadWrite.Directory', 'PrivilegedAccess.ReadWrite.AzureAD', 'Files.ReadWrite.All' ) $BadPermissionsFound = $AppPermissions | Where-Object { $DodgyPermissions -contains $_ } foreach ($permission in $BadPermissionsFound) { $results.Add( $(Get-KariHuntResultObject @AppCommonMeta ` -Issue "High Risk API Permission" -Details "Has high risk API permission - '$permission'.") ) | Out-Null Write-Verbose "High Risk API Permission detected: $permission" } } } end { return $results } } Export-ModuleMember -Function Get-KariHuntAppResult <# .SYNOPSIS Hunts down any suspicious applications in the tenant. .DESCRIPTION Hunts down any suspicious applications in the tenant by analyzing a set of properties against known indicators of compromise and best practices. .PARAMETER IgnoreCriteria An array of criteria to ignore during analysis. See the Validation Set for possible values. .EXAMPLE Invoke-KariHunt Checks all applications against the criteria and outputs as an ArrayList of objects. .EXAMPLE Invoke-KariHunt | Export-Csv -Path './report.csv' Exports a CSV report of the results .EXAMPLE Invoke-KariHunt -IgnoreCriteria 'OldApplication','ExpiredSecret' Ignores checking for applications that are old and applications with expired secrets. .LINK https://github.com/hudsonm62/PS-Kari #> function Invoke-KariHunt { [CmdletBinding()][Alias('ikh')] param ( [Parameter(Mandatory = $false)] [ValidateSet('KnownRogueApps', 'GenericName', 'NoAlphanumeric', 'CallbackURI', 'InsecureURI', 'DisplayNameMatchesOwnerUPN', 'ShortDisplayName', 'ExpiredCertificate', 'ExpiredSecret', 'OldApplication', 'HighRiskAPIPermissions' )] [string[]]$IgnoreCriteria = @() ) # Validate Graph connection if(-not (Assert-KariGraphConnection)) { throw "Not connected to Microsoft Graph with sufficient permissions. Please connect using Connect-MgGraph with 'Directory.Read.All' scope." } # Get All Applications $AllApps = Get-MgServicePrincipal -All -ErrorAction Stop | ` Where-Object { $_.Tags -contains "WindowsAzureActiveDirectoryIntegratedApp" } | Sort-Object DisplayName Write-Verbose "Retrieved $($AllApps.Count) Enterprise applications from Microsoft Graph." # Process Applications $result = $AllApps | Get-KariHuntAppResult -IgnoreCriteria $IgnoreCriteria $NoOfApps = ($result | Select-Object -Property AppId -Unique).Count $HitCount = $result.Count if($result.Count -le 0){ Write-Information "No suspicious applications found." -InformationAction Continue return $null } else { Write-Verbose "Found '$HitCount' issues in '$NoOfApps' app(s)." return $result } } Export-ModuleMember -Function Invoke-KariHunt -Alias 'ikh' #endregion |