Private/Get-PSUDeletionSafetyScore.ps1

<#
.SYNOPSIS
    Calculates a Deletion Safety Score for an Azure App Registration.
 
.DESCRIPTION
    Evaluates a hashtable of collected signals about an App Registration and
    produces a score from 0-100 indicating how safe it is to delete. Higher
    score = safer to delete. Lower score = higher risk.
 
    The scoring engine applies weighted deductions for evidence of active use
    (sign-ins, consumers, RBAC roles, etc.) and bonuses for indicators of
    abandonment (no SP, all secrets expired, old age with no sign-in).
 
    Microsoft 1st-party apps always return score 0 (never touch).
 
.PARAMETER Signals
    A hashtable containing all collected signals for the app. Expected keys:
    IsMicrosoftApp, ConsumedByAppsCount, HasPrivilegedEntraRole,
    HasPrivilegedAzureRole, AssignedUsersCount, SPLastSignInDate,
    SPLastDaemonSignInDate, LastInteractiveSignInDate, DaemonUsageDetected,
    UsedByExternalSystem, ExternalSystemType, HasAzureRBACRoles,
    AzureRBACRoleCount, AssignedGroupsCount, AdminConsentGranted,
    AppRoleAssignmentCount, OAuthGrantCount, IsApiProvider, ExposedScopeCount,
    DirectoryRoleCount, HasExpiredSecret, SecretCount, HasExpiredCert,
    CertCount, IsCoveredByCAPolicy, NoServicePrincipal, AllSecretsExpired,
    AppAgeDays, IsVerifiedPublisher.
 
.EXAMPLE
    $score = Get-PSUDeletionSafetyScore -Signals $signalHashtable
    $score.Score # 85
    $score.ScoreReasons # "+NoSP|+OldUnused:1200d"
 
    Calculates the deletion safety score for an app with collected signals.
 
.OUTPUTS
    [Hashtable] with keys: Score (int 0-100), ScoreReasons (string, pipe-delimited).
 
.NOTES
    Author: Lakshmanachari Panuganti
    Created: 7th March 2026
    Last Modified: 7th March 2026
    Version: 1.0
 
.LINK
    https://github.com/lakshmanachari-panuganti/OMG.PSUtilities/tree/main/OMG.PSUtilities.AzureCore
#>

function Get-PSUDeletionSafetyScore {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [hashtable]$Signals
    )

    begin {
        Write-Verbose "[$($MyInvocation.MyCommand.Name)] Calculating score"
    }

    process {
        $score = 100
        $reasons = [System.Collections.Generic.List[string]]::new()

        # -- INSTANT OVERRIDE: Microsoft 1st-party apps -----------------------
        if ($Signals.IsMicrosoftApp) {
            return @{
                Score        = 0
                ScoreReasons = "Microsoft1stPartyApp"
            }
        }

        # -- HARD BLOCKS (score floor = 0, do not touch) ----------------------
        if ($Signals.ConsumedByAppsCount -gt 0) {
            $score -= 70
            $reasons.Add("ConsumedBy:$($Signals.ConsumedByAppsCount)apps")
        }
        if ($Signals.HasPrivilegedEntraRole) {
            $score -= 60
            $reasons.Add("PrivilegedEntraRole")
        }
        if ($Signals.HasPrivilegedAzureRole) {
            $score -= 60
            $reasons.Add("PrivilegedAzureRole")
        }
        if ($Signals.AssignedUsersCount -gt 100) {
            $score -= 70
            $reasons.Add("ManyAssignedUsers:$($Signals.AssignedUsersCount)")
        }

        # -- STRONG EVIDENCE OF ACTIVE USE ------------------------------------

        # Use ALL-TIME sign-in date from signInActivity first (more reliable than audit logs)
        $bestSignInDate = $null
        if ($null -ne $Signals.SPLastSignInDate) { $bestSignInDate = $Signals.SPLastSignInDate }
        if ($null -ne $Signals.SPLastDaemonSignInDate -and
            ($null -eq $bestSignInDate -or $Signals.SPLastDaemonSignInDate -gt $bestSignInDate)) {
            $bestSignInDate = $Signals.SPLastDaemonSignInDate
        }
        # Overlay audit log dates if they are more recent
        if ($null -ne $Signals.LastInteractiveSignInDate -and
            ($null -eq $bestSignInDate -or $Signals.LastInteractiveSignInDate -gt $bestSignInDate)) {
            $bestSignInDate = $Signals.LastInteractiveSignInDate
        }

        if ($null -ne $bestSignInDate) {
            $daysSince = [int]((Get-Date) - $bestSignInDate).TotalDays
            if ($daysSince -le 30) { $score -= 60; $reasons.Add("SignIn:${daysSince}dAgo") }
            elseif ($daysSince -le 90) { $score -= 50; $reasons.Add("SignIn:${daysSince}dAgo") }
            elseif ($daysSince -le 180) { $score -= 35; $reasons.Add("SignIn:${daysSince}dAgo") }
            elseif ($daysSince -le 365) { $score -= 20; $reasons.Add("SignIn:${daysSince}dAgo") }
            elseif ($daysSince -le 730) { $score -= 10; $reasons.Add("SignIn:${daysSince}dAgo") }
        }
        if ($Signals.DaemonUsageDetected) {
            $score -= 40
            $reasons.Add("DaemonSignInDetected")
        }
        if ($Signals.UsedByExternalSystem) {
            $score -= 50
            $reasons.Add("FederatedCreds:$($Signals.ExternalSystemType)")
        }
        if ($Signals.HasAzureRBACRoles) {
            $score -= 40
            $reasons.Add("AzureRBACRoles:$($Signals.AzureRBACRoleCount)")
        }

        # -- MODERATE EVIDENCE ------------------------------------------------
        if ($Signals.AssignedUsersCount -gt 0 -and $Signals.AssignedUsersCount -le 100) {
            $score -= 40
            $reasons.Add("AssignedUsers:$($Signals.AssignedUsersCount)")
        }
        if ($Signals.AssignedGroupsCount -gt 0) {
            $score -= 35
            $reasons.Add("AssignedGroups:$($Signals.AssignedGroupsCount)")
        }
        if ($Signals.AdminConsentGranted) {
            $score -= 20
            $reasons.Add("AdminConsentGranted")
        }
        if ($Signals.AppRoleAssignmentCount -gt 0) {
            $score -= 15
            $reasons.Add("OutboundAPIAssignments:$($Signals.AppRoleAssignmentCount)")
        }
        if ($Signals.OAuthGrantCount -gt 0) {
            $score -= 15
            $reasons.Add("OAuthGrants:$($Signals.OAuthGrantCount)")
        }
        if ($Signals.IsApiProvider) {
            $score -= 25
            $reasons.Add("ExposesAPI:$($Signals.ExposedScopeCount)scopes")
        }
        if ($Signals.DirectoryRoleCount -gt 0) {
            $score -= 20
            $reasons.Add("DirectoryRoles:$($Signals.DirectoryRoleCount)")
        }

        # -- WEAK EVIDENCE (configuration exists, may or may not be in use) ---
        if (-not $Signals.HasExpiredSecret -and $Signals.SecretCount -gt 0) {
            $score -= 10
            $reasons.Add("ActiveSecret")
        }
        if (-not $Signals.HasExpiredCert -and $Signals.CertCount -gt 0) {
            $score -= 10
            $reasons.Add("ActiveCert")
        }
        if ($Signals.IsCoveredByCAPolicy) {
            $score -= 10
            $reasons.Add("CAProtected")
        }

        # -- POSITIVE SIGNALS (increases confidence it is truly unused) -------
        if ($Signals.NoServicePrincipal) {
            $score += 10
            $reasons.Add("+NoSP")
        }
        # AllSecretsExpired = ALL secrets expired (not just "any expired")
        if ($Signals.AllSecretsExpired -and $Signals.CertCount -eq 0 -and -not $Signals.UsedByExternalSystem) {
            $score += 5
            $reasons.Add("+AllSecretsExpired")
        }
        # App age factor: older unused apps are safer to clean up
        if ($Signals.AppAgeDays -gt 730 -and $null -eq $bestSignInDate) {
            $score += 10
            $reasons.Add("+OldUnused:$($Signals.AppAgeDays)d")
        }
        if ($Signals.AppAgeDays -lt 30) {
            $score -= 15
            $reasons.Add("NewlyCreated:$($Signals.AppAgeDays)d")
        }
        # Verified publisher bonus (legitimate SaaS apps from known vendors)
        if ($Signals.IsVerifiedPublisher) {
            $score -= 5
            $reasons.Add("VerifiedPublisher")
        }

        # Clamp to 0-100
        $score = [Math]::Max(0, [Math]::Min(100, $score))

        return @{
            Score        = $score
            ScoreReasons = $reasons -join "|"
        }
    }
}