Scripts/Run-AzureAppRegAudit-Standalone.ps1

<#
.SYNOPSIS
    Standalone Azure App Registration Audit Script (no module dependencies).
 
.DESCRIPTION
    A single self-contained script that performs a comprehensive audit of all
    Azure App Registrations in a tenant. No external PowerShell module functions
    are required — all scoring, classification, and bucketing logic is inline.
 
    Collects 13 signals per app, calculates a Deletion Safety Score (0-100),
    assigns each app to one of four cleanup buckets, and exports actionable CSVs.
 
    Architecture: Auth -> Bulk-Load -> Per-App Signals -> Score -> Bucket -> Export
 
    Four cleanup buckets:
    - Bucket 1 (SafeToDisable) : Score 80-100
    - Bucket 2 (NeedsInvestigation) : Score 50-79
    - Bucket 3 (LikelyActive) : Score 20-49
    - Bucket 4 (BusinessCritical) : Score 0-19
 
    No automatic deletions. 100% read-only. All actions require human review.
 
.PARAMETER AuditMode
    Quick : Skip per-app sign-in log queries (~30 min for 9,000 apps)
    Full : Collect all 13 signals including sign-in logs (~3-4 hrs)
    DryRun : Process only -DryRunCount apps for testing (~2-5 min)
    Default: Quick
 
.PARAMETER TenantId
    (Optional) Entra ID tenant ID. Auto-detected from Graph context if omitted.
 
.PARAMETER OutputRoot
    (Optional) Root folder for audit output. A timestamped subfolder is created.
    Default: C:\AuditOutput
 
.PARAMETER DryRunCount
    (Optional) Number of apps to process in DryRun mode. Default: 50.
 
.PARAMETER SkipAzureRBAC
    (Optional) Skip Azure RBAC data collection.
 
.PARAMETER ThrottleDelayMs
    (Optional) Delay in ms between per-app Graph calls. Default: 200.
 
.EXAMPLE
    .\Run-AzureAppRegAudit-Standalone.ps1
    # Quick audit with defaults
 
.EXAMPLE
    .\Run-AzureAppRegAudit-Standalone.ps1 -AuditMode DryRun -DryRunCount 20 -SkipAzureRBAC
    # Test run with 20 apps, no Azure RBAC
 
.EXAMPLE
    .\Run-AzureAppRegAudit-Standalone.ps1 -AuditMode Full -OutputRoot "D:\Audits"
    # Full audit with all signals
 
.NOTES
    Author: Lakshmanachari Panuganti
    Created: 8th March 2026
    Version: 1.0
    Prerequisites: PowerShell 7.2+, Microsoft.Graph modules, Az.Resources (optional)
#>


#Requires -Version 7.2

[CmdletBinding(SupportsShouldProcess)]
param (
    [ValidateSet("Quick", "Full", "DryRun")]
    [string]$AuditMode = "Quick",

    [string]$TenantId,

    [string]$OutputRoot = "C:\AuditOutput",

    [int]$DryRunCount = 50,

    [switch]$SkipAzureRBAC,

    [int]$ThrottleDelayMs = 200
)

$ErrorActionPreference = "Stop"
$scriptStart = Get-Date

# ────────────────────────────────────────────────────────────────
# CONSTANTS
# ────────────────────────────────────────────────────────────────
$MicrosoftTenantId = "f8cdef31-a31e-4b4a-93e4-5f571e91255a"
$graphResourceId = "00000003-0000-0000-c000-000000000000"
$DateStamp = Get-Date -Format "yyyyMMdd"

$GraphScopes = @(
    "Application.Read.All",
    "Directory.Read.All",
    "AuditLog.Read.All",
    "Policy.Read.All"
)

$BroadPermissions = @{
    "df021288-bdef-4463-88db-98f22de89214" = "User.ReadWrite.All"
    "62a82d76-70ea-41e2-9197-370581804d09" = "Group.ReadWrite.All"
    "19dbc75e-c2e2-444c-a770-ec69d8559fc7" = "Directory.ReadWrite.All"
    "9e3f62cf-ca93-4989-b6ce-bf83c28f9fe8" = "RoleManagement.ReadWrite.Directory"
    "e2a3a72e-5f79-4c64-b1b1-878b674786c9" = "Mail.ReadWrite"
    "75359482-378d-4052-8f01-80520e7db3cd" = "Files.ReadWrite.All"
    "dc50a0fb-09a3-484d-be87-e023b12c6440" = "SecurityEvents.ReadWrite.All"
    "1bfefb4e-e0b5-418b-a88f-73c46d2cc8e9" = "Application.ReadWrite.All"
    "dbaae8cf-10b5-4b86-a4a1-f871c94c6571" = "GroupMember.ReadWrite.All"
    "9492366f-7969-46a4-8d15-ed1a20078fff" = "Sites.ReadWrite.All"
    "6931bccd-447a-43d1-b442-00a195474b6c" = "MailboxSettings.ReadWrite"
    "ef54d2bf-783f-4e0f-bca1-3210c0444d99" = "Calendars.ReadWrite"
    "741f803b-c850-494e-b5df-cde7c675a1ca" = "User.ReadWrite.All (Delegated)"
}

$PrivilegedEntraRoles = @(
    "Global Administrator", "Privileged Role Administrator",
    "Application Administrator", "Cloud Application Administrator",
    "Exchange Administrator", "SharePoint Administrator"
)

$PrivilegedAzureRoles = @("Owner", "Contributor", "User Access Administrator")

$FederatedIssuerLabels = @{
    "https://kubernetes.default.svc"              = "AKS"
    "https://app.terraform.io"                    = "Terraform Cloud"
    "https://vstoken.dev.azure.com"               = "Azure DevOps"
    "https://sts.amazonaws.com"                   = "AWS"
}

$OwnerNotificationColumns = @(
    "AppName", "AppId", "DeletionSafetyScore", "BucketLabel", "RecommendedAction",
    "LastUsedDate", "UsageConfidence", "UsageLocationSummary", "DeletionBlastRadius",
    "HasExpiredSecret", "OldestSecretAgeDays", "AuthMethod", "CreatedDate"
)

# ────────────────────────────────────────────────────────────────
# INLINE HELPER FUNCTIONS (replaces private module functions)
# ────────────────────────────────────────────────────────────────

function Write-Log {
    param(
        [ValidateSet("INFO", "WARN", "ERROR", "DEBUG")]
        [string]$Level = "INFO",
        [string]$Signal = "General",
        [string]$Message
    )
    $ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
    $entry = "[$ts] [$Level] [$Signal] $Message"
    Add-Content -Path $script:LogFile -Value $entry
    switch ($Level) {
        "ERROR" { Write-Warning $Message }
        "WARN" { Write-Warning $Message }
        "DEBUG" { Write-Verbose $Message }
        default { Write-Host $entry }
    }
}

function Invoke-GraphRetry {
    <# Simple retry wrapper for Graph calls with HTTP 429 handling #>
    param(
        [scriptblock]$ScriptBlock,
        [string]$OperationName = "GraphCall",
        [int]$MaxRetries = 3
    )
    $attempt = 0
    while ($attempt -lt $MaxRetries) {
        try {
            return & $ScriptBlock
        } catch {
            $statusCode = $_.Exception.Response.StatusCode.value__
            if ($statusCode -eq 429) {
                $retryAfter = 30
                try { $retryAfter = [int]$_.Exception.Response.Headers["Retry-After"] } catch {}
                Write-Log "WARN" $OperationName "Throttled. Waiting ${retryAfter}s (attempt $($attempt+1)/$MaxRetries)"
                Start-Sleep -Seconds $retryAfter
                $attempt++
            } else {
                Write-Log "ERROR" $OperationName "HTTP $statusCode - $($_.Exception.Message)"
                return $null
            }
        }
    }
    Write-Log "ERROR" $OperationName "Failed after $MaxRetries retries."
    return $null
}

function Get-DeletionSafetyScore {
    <# Calculates score 0-100 (higher = safer to delete) #>
    param([hashtable]$Signals)

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

    if ($Signals.IsMicrosoftApp) {
        return @{ Score = 0; ScoreReasons = "Microsoft1stPartyApp" }
    }

    # Hard blocks
    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)") }

    # Sign-in evidence
    $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
    }
    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
    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 (safer to delete)
    if ($Signals.NoServicePrincipal) { $score += 10; $reasons.Add("+NoSP") }
    if ($Signals.AllSecretsExpired -and $Signals.CertCount -eq 0 -and -not $Signals.UsedByExternalSystem) {
        $score += 5; $reasons.Add("+AllSecretsExpired")
    }
    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") }
    if ($Signals.IsVerifiedPublisher) { $score -= 5; $reasons.Add("VerifiedPublisher") }

    $score = [Math]::Max(0, [Math]::Min(100, $score))
    return @{ Score = $score; ScoreReasons = $reasons -join "|" }
}

function Get-UsageStatus {
    <# Classifies usage as High/Medium/Low confidence #>
    param([hashtable]$Signals)

    $candidates = @($Signals.LastInteractiveSignInDate, $Signals.LastSPSignInDate) |
        Where-Object { $_ -is [datetime] } | Sort-Object -Descending
    $LastUsedDate = if ($candidates.Count -gt 0) { $candidates[0] } else { $null }

    $hasConfigSignal = (
        $Signals.AppRoleAssignmentCount -gt 0 -or $Signals.ConsumedByAppsCount -gt 0 -or
        $Signals.OAuthGrantCount -gt 0 -or $Signals.DirectoryRoleCount -gt 0 -or
        $Signals.AzureRBACRoleCount -gt 0 -or $Signals.FederatedCredentialCount -gt 0 -or
        $Signals.AssignedUsersCount -gt 0 -or $Signals.AssignedGroupsCount -gt 0
    )

    if ($null -ne $LastUsedDate) {
        $IsUnused = $false; $UsageConfidence = "High"
    } elseif ($Signals.UsedByExternalSystem) {
        $IsUnused = $false; $UsageConfidence = "High"; $LastUsedDate = "FederatedActive"
    } elseif ($hasConfigSignal) {
        $IsUnused = $false; $UsageConfidence = "Medium"; $LastUsedDate = "SignalFound-NoDate"
    } else {
        $IsUnused = $true; $UsageConfidence = "Low"; $LastUsedDate = "NoSignal"
    }

    $UnusedMoreThan1Year = $IsUnused -or ($LastUsedDate -is [datetime] -and $LastUsedDate -lt (Get-Date).AddDays(-365))
    $DaysSinceLastUse = if ($LastUsedDate -is [datetime]) { [int]((Get-Date) - $LastUsedDate).TotalDays } else { -1 }

    return @{
        IsUnused            = $IsUnused
        UsageConfidence     = $UsageConfidence
        LastUsedDate        = $LastUsedDate
        UnusedMoreThan1Year = $UnusedMoreThan1Year
        DaysSinceLastUse    = $DaysSinceLastUse
    }
}

function Get-CleanupBucket {
    <# Assigns app to bucket 1-4 based on score and usage #>
    param([int]$Score, [bool]$IsUnused, [string]$UsageConfidence, [bool]$IsMicrosoftApp = $false)

    if ($IsMicrosoftApp) {
        return @{ Bucket = 4; Label = "Microsoft1stParty"; Action = "DO NOT TOUCH - MICROSOFT OWNED" }
    }
    if (-not $IsUnused -and $UsageConfidence -eq "High") {
        return @{ Bucket = 4; Label = "BusinessCritical"; Action = "DO NOT TOUCH" }
    }
    switch ($true) {
        ($Score -ge 80) { return @{ Bucket = 1; Label = "SafeToDisable"; Action = "DISABLE NOW -> DELETE IN 30 DAYS" } }
        ($Score -ge 50) { return @{ Bucket = 2; Label = "NeedsInvestigation"; Action = "SEND TO OWNER FOR REVIEW" } }
        ($Score -ge 20) { return @{ Bucket = 3; Label = "LikelyActive"; Action = "DO NOT TOUCH - GATHER MORE EVIDENCE" } }
        default { return @{ Bucket = 4; Label = "BusinessCritical"; Action = "DO NOT TOUCH" } }
    }
}

function Get-ExternalSystemType {
    <# Identifies CI/CD system from federated credential issuers #>
    param([string[]]$Issuers)

    if ($null -eq $Issuers -or $Issuers.Count -eq 0) { return "None" }
    $types = [System.Collections.Generic.List[string]]::new()
    foreach ($issuer in $Issuers) {
        $matched = $false
        foreach ($key in $FederatedIssuerLabels.Keys) {
            if ($issuer -like "$key*") {
                if (-not $types.Contains($FederatedIssuerLabels[$key])) { $types.Add($FederatedIssuerLabels[$key]) }
                $matched = $true; break
            }
        }
        if (-not $matched -and -not $types.Contains("Unknown")) { $types.Add("Unknown") }
    }
    return ($types -join ";")
}

function Get-WhereUsed {
    <# Human-readable usage location summary #>
    param([hashtable]$Signals)

    $locations = [System.Collections.Generic.List[string]]::new()
    if ($Signals.LastSignInResourceName -ne "NoData") { $locations.Add("UserSignIn->$($Signals.LastSignInResourceName)") }
    if ($Signals.DaemonUsageDetected) { $locations.Add("DaemonSignIn->$($Signals.LastSPSignInResourceName)") }
    if ($Signals.AppRoleAssignedResources -ne "None") { $locations.Add("CallsAPI->$($Signals.AppRoleAssignedResources)") }
    if ($Signals.ConsumedByApps -ne "") { $locations.Add("ConsumedBy->$($Signals.ConsumedByApps)") }
    if ($Signals.AzureRBACScopes -ne "None") { $locations.Add("AzureScope->$($Signals.AzureRBACScopes)") }
    if ($Signals.UsedByExternalSystem) { $locations.Add("ExternalSystem->$($Signals.ExternalSystemType)") }
    if ($Signals.AssignedGroupNames -ne "") { $locations.Add("AssignedGroups->$($Signals.AssignedGroupNames)") }
    if ($Signals.IsApiProvider) { $locations.Add("ExposesAPI->$($Signals.ExposedPermissions)") }
    return if ($locations.Count -gt 0) { $locations -join " | " } else { "NoUsageSignalFound" }
}

# ────────────────────────────────────────────────────────────────
# BANNER
# ────────────────────────────────────────────────────────────────
$bar = [string]::new([char]0x2550, 70)
Write-Host ""
Write-Host $bar -ForegroundColor Cyan
Write-Host " Azure App Registration Audit - Standalone Script" -ForegroundColor Cyan
Write-Host " Mode: $AuditMode | Started: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" -ForegroundColor Cyan
Write-Host $bar -ForegroundColor Cyan
Write-Host ""

$SkipSignInLogs = $AuditMode -ne "Full"
$DryRunLimit = if ($AuditMode -eq "DryRun") { $DryRunCount } else { 0 }

# ────────────────────────────────────────────────────────────────
# STEP 1: Pre-requisite Module Check
# ────────────────────────────────────────────────────────────────
Write-Host "[1/7] Checking pre-requisite modules..." -ForegroundColor White

$requiredModules = @(
    @{ Name = "Microsoft.Graph.Authentication"; MinVer = "2.0.0" },
    @{ Name = "Microsoft.Graph.Applications"; MinVer = "2.0.0" },
    @{ Name = "Microsoft.Graph.Reports"; MinVer = "2.0.0" },
    @{ Name = "Microsoft.Graph.Identity.Governance"; MinVer = "2.0.0" },
    @{ Name = "Microsoft.Graph.Identity.DirectoryManagement"; MinVer = "2.0.0" }
)
if (-not $SkipAzureRBAC) {
    $requiredModules += @{ Name = "Az.Resources"; MinVer = "6.0.0" }
    $requiredModules += @{ Name = "Az.Accounts"; MinVer = "2.0.0" }
}

$missingModules = @()
foreach ($mod in $requiredModules) {
    $installed = Get-Module -ListAvailable -Name $mod.Name | Sort-Object Version -Descending | Select-Object -First 1
    if ($null -eq $installed) {
        $missingModules += $mod.Name
        Write-Host " [MISSING] $($mod.Name)" -ForegroundColor Red
    } elseif ($installed.Version -lt [version]$mod.MinVer) {
        $missingModules += "$($mod.Name) (need >= $($mod.MinVer))"
        Write-Host " [OUTDATED] $($mod.Name) v$($installed.Version)" -ForegroundColor Yellow
    } else {
        Write-Host " [OK] $($mod.Name) v$($installed.Version)" -ForegroundColor Green
    }
}

if ($missingModules.Count -gt 0) {
    Write-Host "`n Missing modules: $($missingModules -join ', ')" -ForegroundColor Yellow
    $choice = Read-Host " Install now? (Y/N)"
    if ($choice -in @("Y", "y")) {
        foreach ($mod in $requiredModules) {
            $installed = Get-Module -ListAvailable -Name $mod.Name | Sort-Object Version -Descending | Select-Object -First 1
            if ($null -eq $installed -or $installed.Version -lt [version]$mod.MinVer) {
                Write-Host " Installing $($mod.Name)..." -ForegroundColor Cyan
                Install-Module -Name $mod.Name -MinimumVersion $mod.MinVer -Scope CurrentUser -Force -AllowClobber
            }
        }
    } else {
        throw "Cannot proceed without required modules."
    }
}
Write-Host ""

# ────────────────────────────────────────────────────────────────
# STEP 2: Authentication
# ────────────────────────────────────────────────────────────────
Write-Host "[2/7] Authenticating..." -ForegroundColor White

$mgContext = Get-MgContext -ErrorAction SilentlyContinue
if ($null -eq $mgContext) {
    Write-Host " Connecting to Microsoft Graph..." -ForegroundColor Cyan
    if ($TenantId) { Connect-MgGraph -Scopes $GraphScopes -TenantId $TenantId -NoWelcome }
    else { Connect-MgGraph -Scopes $GraphScopes -NoWelcome }
    $mgContext = Get-MgContext
} else {
    $missingScopes = $GraphScopes | Where-Object { $_ -notin $mgContext.Scopes }
    if ($missingScopes.Count -gt 0) {
        Write-Host " Reconnecting (missing scopes: $($missingScopes -join ', '))..." -ForegroundColor Yellow
        Disconnect-MgGraph -ErrorAction SilentlyContinue
        if ($TenantId) { Connect-MgGraph -Scopes $GraphScopes -TenantId $TenantId -NoWelcome }
        else { Connect-MgGraph -Scopes $GraphScopes -NoWelcome }
        $mgContext = Get-MgContext
    } else {
        Write-Host " Reusing existing Graph session" -ForegroundColor Green
    }
}

# Validate token with a lightweight call
try {
    $null = Get-MgOrganization -ErrorAction Stop | Select-Object -First 1
    Write-Host " Graph token validated" -ForegroundColor Green
} catch {
    throw "Graph token invalid or expired. Re-authenticate with Connect-MgGraph. Error: $($_.Exception.Message)"
}

$effectiveTenantId = $TenantId ?? $mgContext.TenantId
$authIdentity = $mgContext.Account ?? $mgContext.AppName ?? "Unknown"
Write-Host " Graph : $authIdentity | Tenant: $effectiveTenantId" -ForegroundColor Green

# Azure auth (if needed)
$AzSubscriptions = @()
if (-not $SkipAzureRBAC) {
    $azContext = Get-AzContext -ErrorAction SilentlyContinue
    if ($null -eq $azContext) {
        Write-Host " Connecting to Azure..." -ForegroundColor Cyan
        if ($TenantId) { Connect-AzAccount -TenantId $TenantId } else { Connect-AzAccount }
        $azContext = Get-AzContext
    } else {
        Write-Host " Reusing existing Azure session" -ForegroundColor Green
    }
    Write-Host " Azure : $($azContext.Account.Id)" -ForegroundColor Green
    $AzSubscriptions = @(Get-AzSubscription -TenantId $effectiveTenantId)
    Write-Host " Subscriptions: $($AzSubscriptions.Count)" -ForegroundColor Green
} else {
    Write-Host " Azure : SKIPPED" -ForegroundColor DarkGray
}
Write-Host ""

# ────────────────────────────────────────────────────────────────
# STEP 3: Output Directories & Log File
# ────────────────────────────────────────────────────────────────
Write-Host "[3/7] Setting up output..." -ForegroundColor White

$runTimestamp = Get-Date -Format "yyyyMMdd_HHmmss"
$runFolder = Join-Path $OutputRoot "AppRegAudit_${effectiveTenantId}_${runTimestamp}"
$logDir = Join-Path $runFolder "Logs"
$ownerNotifDir = Join-Path $runFolder "OwnerNotifications"
foreach ($dir in @($runFolder, $logDir, $ownerNotifDir)) {
    if (-not (Test-Path $dir)) { New-Item -Path $dir -ItemType Directory -Force | Out-Null }
}
$script:LogFile = Join-Path $logDir "AuditLog_${effectiveTenantId}_${runTimestamp}.log"

Write-Host " Output : $runFolder" -ForegroundColor Green
Write-Host ""

# ────────────────────────────────────────────────────────────────
# STEP 4: Bulk Pre-Load (8 data sets)
# ────────────────────────────────────────────────────────────────
Write-Host "[4/7] Bulk-loading data from Graph..." -ForegroundColor White

# 1. App Registrations
Write-Log "INFO" "Bulk" "Loading App Registrations..."
$AllApps = Get-MgApplication -All `
    -ExpandProperty "owners,federatedIdentityCredentials" `
    -Property @(
    "id", "appId", "displayName", "createdDateTime", "signInAudience",
    "publisherDomain", "tags", "passwordCredentials", "keyCredentials",
    "requiredResourceAccess", "web", "spa", "publicClient", "api", "info",
    "notes", "verifiedPublisher"
)
if ($null -eq $AllApps -or $AllApps.Count -eq 0) { throw "ZERO App Registrations returned. Check permissions." }
$AppCache = @{}; foreach ($app in $AllApps) { $AppCache[$app.AppId] = $app }
Write-Log "INFO" "Bulk" "Loaded $($AllApps.Count) App Registrations"

# 2. Service Principals
Write-Log "INFO" "Bulk" "Loading Service Principals..."
$AllSPs = Get-MgServicePrincipal -All -Property @(
    "id", "appId", "displayName", "accountEnabled", "appOwnerOrganizationId",
    "servicePrincipalType", "createdDateTime", "signInActivity", "tags",
    "homepage", "replyUrls", "alternativeNames"
)
if ($null -eq $AllSPs -or $AllSPs.Count -eq 0) { throw "ZERO Service Principals returned." }
$SpCache = @{}; $MiCache = @{}; $MicrosoftAppIds = [System.Collections.Generic.HashSet[string]]::new()
foreach ($sp in $AllSPs) {
    $SpCache[$sp.AppId] = $sp
    if ($sp.ServicePrincipalType -eq "ManagedIdentity") { $MiCache[$sp.Id] = $sp }
    if ($sp.AppOwnerOrganizationId -eq $MicrosoftTenantId) { [void]$MicrosoftAppIds.Add($sp.AppId) }
}
Write-Log "INFO" "Bulk" "Loaded $($AllSPs.Count) SPs ($($MiCache.Count) MIs, $($MicrosoftAppIds.Count) Microsoft)"

# 3. OAuth2 Permission Grants
Write-Log "INFO" "Bulk" "Loading OAuth2 Grants..."
$AllOAuthGrants = Get-MgOauth2PermissionGrant -All -Property "id,clientId,resourceId,scope,consentType,principalId"
$OAuthGrantCache = @{}
foreach ($grant in $AllOAuthGrants) {
    if (-not $OAuthGrantCache.ContainsKey($grant.ClientId)) {
        $OAuthGrantCache[$grant.ClientId] = [System.Collections.Generic.List[object]]::new()
    }
    $OAuthGrantCache[$grant.ClientId].Add($grant)
}
Write-Log "INFO" "Bulk" "Loaded $(($AllOAuthGrants | Measure-Object).Count) OAuth2 Grants"

# 4. Outbound App Role Assignments
Write-Log "INFO" "Bulk" "Loading outbound App Role Assignments..."
$OutboundRoleCache = @{}; $outboundTotal = 0
foreach ($sp in $AllSPs) {
    if ($sp.ServicePrincipalType -eq "ManagedIdentity" -or $MicrosoftAppIds.Contains($sp.AppId)) { continue }
    $assignments = Invoke-GraphRetry -OperationName "OutboundRoles-$($sp.Id)" -ScriptBlock {
        Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id -All
    }
    if ($null -ne $assignments -and $assignments.Count -gt 0) {
        $OutboundRoleCache[$sp.Id] = [System.Collections.Generic.List[object]]::new()
        foreach ($a in $assignments) { $OutboundRoleCache[$sp.Id].Add($a) }
        $outboundTotal += $assignments.Count
    }
    if ($ThrottleDelayMs -gt 0) { Start-Sleep -Milliseconds $ThrottleDelayMs }
}
Write-Log "INFO" "Bulk" "Loaded $outboundTotal outbound role assignments"

# 5. Inbound App Role Assignments (consumers)
Write-Log "INFO" "Bulk" "Loading inbound App Role Assignments..."
$InboundRoleCache = @{}; $inboundTotal = 0
foreach ($sp in $AllSPs) {
    if ($sp.ServicePrincipalType -eq "ManagedIdentity" -or $MicrosoftAppIds.Contains($sp.AppId)) { continue }
    $assignments = Invoke-GraphRetry -OperationName "InboundRoles-$($sp.Id)" -ScriptBlock {
        Get-MgServicePrincipalAppRoleAssignedTo -ServicePrincipalId $sp.Id -All
    }
    if ($null -ne $assignments -and $assignments.Count -gt 0) {
        $InboundRoleCache[$sp.Id] = [System.Collections.Generic.List[object]]::new()
        foreach ($a in $assignments) { $InboundRoleCache[$sp.Id].Add($a) }
        $inboundTotal += $assignments.Count
    }
    if ($ThrottleDelayMs -gt 0) { Start-Sleep -Milliseconds $ThrottleDelayMs }
}
Write-Log "INFO" "Bulk" "Loaded $inboundTotal inbound role assignments"

# 6. Directory Roles
Write-Log "INFO" "Bulk" "Loading Directory Roles..."
$AllRoleDefinitions = Get-MgRoleManagementDirectoryRoleDefinition -All -Property "id,displayName,isBuiltIn"
$RoleDefCache = @{}; foreach ($rd in $AllRoleDefinitions) { $RoleDefCache[$rd.Id] = $rd }

$AllDirRoleAssignments = Get-MgRoleManagementDirectoryRoleAssignment -All -Property "id,principalId,roleDefinitionId,directoryScopeId"
$DirRoleCache = @{}
foreach ($ra in $AllDirRoleAssignments) {
    $ra | Add-Member -NotePropertyName "RoleDefinition" -NotePropertyValue $RoleDefCache[$ra.RoleDefinitionId] -Force
    if (-not $DirRoleCache.ContainsKey($ra.PrincipalId)) {
        $DirRoleCache[$ra.PrincipalId] = [System.Collections.Generic.List[object]]::new()
    }
    $DirRoleCache[$ra.PrincipalId].Add($ra)
}
Write-Log "INFO" "Bulk" "Loaded $($AllDirRoleAssignments.Count) directory role assignments"

# 7. Azure RBAC
$AzureRbacCache = @{}
if (-not $SkipAzureRBAC) {
    Write-Log "INFO" "Bulk" "Loading Azure RBAC across $($AzSubscriptions.Count) subscriptions..."
    foreach ($sub in $AzSubscriptions) {
        try {
            Set-AzContext -SubscriptionId $sub.Id -ErrorAction Stop | Out-Null
            $subAssignments = Get-AzRoleAssignment -ErrorAction Stop
            foreach ($ra in $subAssignments) {
                if ([string]::IsNullOrEmpty($ra.ObjectId)) { continue }
                if (-not $AzureRbacCache.ContainsKey($ra.ObjectId)) {
                    $AzureRbacCache[$ra.ObjectId] = [System.Collections.Generic.List[object]]::new()
                }
                $AzureRbacCache[$ra.ObjectId].Add($ra)
            }
        } catch {
            Write-Log "WARN" "RBAC" "Sub $($sub.Id): $($_.Exception.Message)"
        }
    }
    Write-Log "INFO" "Bulk" "RBAC loaded across $($AzSubscriptions.Count) subscriptions"
}

# 8. Conditional Access Policies
Write-Log "INFO" "Bulk" "Loading CA Policies..."
$AllCAPolicies = Get-MgIdentityConditionalAccessPolicy -All -Property "id,displayName,state,conditions"
$CaProtectedAppIds = [System.Collections.Generic.HashSet[string]]::new()
foreach ($policy in $AllCAPolicies) {
    if ($policy.State -in @("enabled", "Enabled", "enabledForReportingButNotEnforced")) {
        foreach ($appId in $policy.Conditions.Applications.IncludeApplications) {
            [void]$CaProtectedAppIds.Add($appId)
        }
    }
}
Write-Log "INFO" "Bulk" "Loaded $($AllCAPolicies.Count) CA policies, $($CaProtectedAppIds.Count) protected apps"
Write-Host ""

# ────────────────────────────────────────────────────────────────
# STEP 5: Per-App Signal Collection + Scoring
# ────────────────────────────────────────────────────────────────
Write-Host "[5/7] Processing apps ($AuditMode mode)..." -ForegroundColor White

$MasterReport = [System.Collections.Generic.List[object]]::new()
$appsToProcess = if ($DryRunLimit -gt 0) { $AllApps | Select-Object -First $DryRunLimit } else { $AllApps }
$totalApps = @($appsToProcess).Count
$currentApp = 0

if (-not $SkipSignInLogs -and $totalApps -gt 100) {
    Write-Log "WARN" "SignIn" "Processing $totalApps apps WITH sign-in logs. Estimated: 2-4 hours."
}

foreach ($app in $appsToProcess) {
    $currentApp++
    Write-Progress -Activity "Auditing App Registrations" -Status "$currentApp/$totalApps - $($app.DisplayName)" -PercentComplete (($currentApp / $totalApps) * 100)

    # ── Signal A: Base Properties ──
    $ObjectId = $app.Id
    $AppId = $app.AppId
    $DisplayName = $app.DisplayName
    $CreatedDateTime = $app.CreatedDateTime
    $SignInAudience = $app.SignInAudience
    $PublisherDomain = $app.PublisherDomain
    $Tags = ($app.Tags -join ";")
    $VerifiedPublisher = $app.VerifiedPublisher.DisplayName ?? "NotVerified"
    $IsVerifiedPublisher = (-not [string]::IsNullOrWhiteSpace($app.VerifiedPublisher.DisplayName))
    $IsMicrosoftApp = $MicrosoftAppIds.Contains($AppId)
    $AppAgeDays = [int]((Get-Date) - $CreatedDateTime).TotalDays

    $AllRedirectUris = @($app.Web.RedirectUris + $app.Spa.RedirectUris + $app.PublicClient.RedirectUris) | Where-Object { $_ }
    $WebRedirectUris = ($app.Web.RedirectUris -join ";")
    $SpaRedirectUris = ($app.Spa.RedirectUris -join ";")
    $HasLocalhostUri = ($AllRedirectUris | Where-Object { $_ -match "localhost" }).Count -gt 0
    $HasHttpUri = ($AllRedirectUris | Where-Object { $_ -match "^http://" }).Count -gt 0
    $HasNgrokUri = ($AllRedirectUris | Where-Object { $_ -match "ngrok" }).Count -gt 0
    $RedirectUriCount = $AllRedirectUris.Count
    $IsPublicClient = ($null -ne $app.PublicClient -and $app.PublicClient.RedirectUris.Count -gt 0)

    $ExposedScopeCount = $app.Api.Oauth2PermissionScopes.Count
    $ExposedRoleCount = $app.Api.AppRoles.Count
    $IsApiProvider = ($ExposedScopeCount -gt 0 -or $ExposedRoleCount -gt 0)
    $ExposedPermissions = @(
        ($app.Api.Oauth2PermissionScopes | ForEach-Object { $_.Value })
        ($app.Api.AppRoles | ForEach-Object { $_.Value })
    ) -join ";"
    $DeclaredPermissionCount = ($app.RequiredResourceAccess | ForEach-Object { $_.ResourceAccess.Count } | Measure-Object -Sum).Sum

    # ── Signal B: Service Principal + Sign-in Activity ──
    $sp = $SpCache[$AppId]
    if ($null -eq $sp) {
        $ServicePrincipalId = "NoSP"; $IsEnabled = "N/A"; $NoServicePrincipal = $true
        $ServicePrincipalType = "N/A"; $SPLastSignInDate = $null; $SPLastDaemonSignInDate = $null
    } else {
        $ServicePrincipalId = $sp.Id
        $IsEnabled = $sp.AccountEnabled
        $NoServicePrincipal = $false
        $ServicePrincipalType = $sp.ServicePrincipalType
        $SPLastSignInDate = $sp.SignInActivity.LastSignInDateTime
        $SPLastDaemonSignInDate = $sp.SignInActivity.LastNonInteractiveSignInDateTime
    }

    # ── Signal C: Owners ──
    $owners = $app.Owners
    $OwnerUPNs = if ($owners.Count -gt 0) {
        ($owners | ForEach-Object { $_.AdditionalProperties.userPrincipalName ?? $_.AdditionalProperties.displayName ?? $_.Id }) -join ";"
    } else { "NoOwner" }
    $OwnerCount = $owners.Count
    $HasOwner = $owners.Count -gt 0

    # ── Signal D: Federated Identity Credentials ──
    $fedCreds = $app.FederatedIdentityCredentials
    $FederatedCredentialCount = if ($null -ne $fedCreds) { $fedCreds.Count } else { 0 }
    $FederatedCredentials = if ($FederatedCredentialCount -gt 0) { ($fedCreds | ForEach-Object { $_.Name }) -join ";" } else { "None" }
    $FederatedIssuers = if ($FederatedCredentialCount -gt 0) { ($fedCreds | ForEach-Object { $_.Issuer }) -join ";" } else { "None" }
    $FederatedSubjects = if ($FederatedCredentialCount -gt 0) { ($fedCreds | ForEach-Object { $_.Subject }) -join ";" } else { "None" }
    $UsedByExternalSystem = $FederatedCredentialCount -gt 0
    $ExternalSystemType = Get-ExternalSystemType -Issuers $fedCreds.Issuer

    # ── Signal E: Interactive Sign-in Logs (optional) ──
    $LastInteractiveSignInDate = $null
    $LastSignInResourceName = "NoData"; $LastSignInUserUPN = "NoData"
    $LastSignInIPAddress = "NoData"; $LastSignInLocation = "NoData"
    $LastSignInClientApp = "NoData"; $LegacyAuthDetected = $false
    $IsCoveredByCAPolicy = $CaProtectedAppIds.Contains($AppId)

    if (-not $SkipSignInLogs -and -not $NoServicePrincipal) {
        $siDate = (Get-Date).AddDays(-30).ToString("yyyy-MM-ddTHH:mm:ssZ")
        $signIn = Invoke-GraphRetry -OperationName "InteractiveSignIn-$AppId" -ScriptBlock {
            Get-MgAuditLogSignIn -Filter "appId eq '$AppId' and createdDateTime ge $siDate" `
                -Top 1 -OrderBy "createdDateTime desc" `
                -Property @("createdDateTime", "resourceDisplayName", "resourceId", "userPrincipalName", "ipAddress", "location", "clientAppUsed", "conditionalAccessStatus", "status")
        }
        if ($null -ne $signIn) {
            $LastInteractiveSignInDate = $signIn.CreatedDateTime
            $LastSignInResourceName = $signIn.ResourceDisplayName ?? "NoData"
            $LastSignInUserUPN = $signIn.UserPrincipalName ?? "NoData"
            $LastSignInIPAddress = $signIn.IpAddress ?? "NoData"
            $loc = $signIn.Location
            $LastSignInLocation = if ($loc) { "$($loc.City), $($loc.CountryOrRegion)" } else { "NoData" }
            $LastSignInClientApp = $signIn.ClientAppUsed ?? "NoData"
            $LegacyAuthDetected = $LastSignInClientApp -match "Basic Auth|SMTP|POP3|IMAP|MAPI|Exchange ActiveSync|Other clients"
        }
        if ($ThrottleDelayMs -gt 0) { Start-Sleep -Milliseconds $ThrottleDelayMs }
    }

    # ── Signal F: SP/Daemon Sign-in Logs (optional) ──
    $LastSPSignInDate = $null; $LastSPSignInResourceName = "NoData"; $DaemonUsageDetected = $false
    if (-not $SkipSignInLogs -and -not $NoServicePrincipal) {
        $spSiDate = (Get-Date).AddDays(-7).ToString("yyyy-MM-ddTHH:mm:ssZ")
        $spSignIn = Invoke-GraphRetry -OperationName "SPSignIn-$AppId" -ScriptBlock {
            Get-MgAuditLogSignIn -Filter "appId eq '$AppId' and signInEventTypes/any(t: t eq 'servicePrincipal') and createdDateTime ge $spSiDate" `
                -Top 1 -OrderBy "createdDateTime desc" -Property @("createdDateTime", "resourceDisplayName", "ipAddress", "status")
        }
        if ($null -ne $spSignIn) {
            $LastSPSignInDate = $spSignIn.CreatedDateTime
            $LastSPSignInResourceName = $spSignIn.ResourceDisplayName ?? "NoData"
            $DaemonUsageDetected = $true
        }
        if ($ThrottleDelayMs -gt 0) { Start-Sleep -Milliseconds $ThrottleDelayMs }
    }

    # ── Signal G: Outbound App Role Assignments ──
    $outbound = if ($ServicePrincipalId -ne "NoSP") { $OutboundRoleCache[$ServicePrincipalId] } else { $null }
    $AppRoleAssignmentCount = if ($outbound) { $outbound.Count } else { 0 }
    $AppRoleAssignedResources = if ($outbound) { ($outbound | ForEach-Object { $_.ResourceDisplayName } | Select-Object -Unique) -join ";" } else { "None" }

    # ── Signal H: Inbound Consumers ──
    $inbound = if ($ServicePrincipalId -ne "NoSP") { $InboundRoleCache[$ServicePrincipalId] } else { $null }
    $consumingApps = $inbound | Where-Object { $_.PrincipalType -eq "ServicePrincipal" }
    $assignedUsers = $inbound | Where-Object { $_.PrincipalType -eq "User" }
    $assignedGroups = $inbound | Where-Object { $_.PrincipalType -eq "Group" }

    $ConsumedByAppsCount = ($consumingApps | Measure-Object).Count
    $ConsumedByApps = ($consumingApps | ForEach-Object { $_.PrincipalDisplayName }) -join ";"
    $AssignedUsersCount = ($assignedUsers | Measure-Object).Count
    $AssignedGroupsCount = ($assignedGroups | Measure-Object).Count
    $AssignedGroupNames = ($assignedGroups | ForEach-Object { $_.PrincipalDisplayName }) -join ";"
    $IsActingAsResourceApp = $ConsumedByAppsCount -gt 0
    $DeletionBlastRadius = $ConsumedByAppsCount + $AssignedUsersCount + $AssignedGroupsCount

    # ── Signal I: OAuth2 Permission Grants ──
    $grants = if ($ServicePrincipalId -ne "NoSP") { $OAuthGrantCache[$ServicePrincipalId] } else { $null }
    $OAuthGrantCount = if ($grants) { $grants.Count } else { 0 }
    $OAuthGrantScopes = if ($grants) { ($grants | ForEach-Object { $_.Scope }) -join ";" } else { "None" }
    $AdminConsentGranted = ($grants | Where-Object { $_.ConsentType -eq "AllPrincipals" }).Count -gt 0

    # ── Signal J: Directory Role Assignments ──
    $dirRoles = if ($ServicePrincipalId -ne "NoSP") { $DirRoleCache[$ServicePrincipalId] } else { $null }
    $DirectoryRoles = if ($dirRoles) { ($dirRoles | ForEach-Object { $_.RoleDefinition.DisplayName }) -join ";" } else { "None" }
    $DirectoryRoleCount = if ($dirRoles) { $dirRoles.Count } else { 0 }
    $HasPrivilegedEntraRole = ($dirRoles | Where-Object { $_.RoleDefinition.DisplayName -in $PrivilegedEntraRoles }).Count -gt 0

    # ── Signal K: Azure RBAC ──
    $rbac = if ($ServicePrincipalId -ne "NoSP") { $AzureRbacCache[$ServicePrincipalId] } else { $null }
    $AzureRBACRoleCount = if ($rbac) { $rbac.Count } else { 0 }
    $AzureRBACRoles = if ($rbac) { ($rbac | ForEach-Object { $_.RoleDefinitionName } | Select-Object -Unique) -join ";" } else { "None" }
    $AzureRBACScopes = if ($rbac) { ($rbac | ForEach-Object { $_.Scope } | Select-Object -Unique) -join ";" } else { "None" }
    $HasAzureRBACRoles = $AzureRBACRoleCount -gt 0
    $HasPrivilegedAzureRole = ($rbac | Where-Object { $_.RoleDefinitionName -in $PrivilegedAzureRoles }).Count -gt 0

    # ── Signal L: Credential Health ──
    $now = Get-Date
    $secrets = $app.PasswordCredentials; $SecretCount = $secrets.Count
    $HasExpiredSecret = ($secrets | Where-Object { $_.EndDateTime -lt $now }).Count -gt 0
    $NearestSecretExpiry = if ($SecretCount -gt 0) { ($secrets | Sort-Object EndDateTime | Select-Object -First 1).EndDateTime } else { "NoSecret" }
    $OldestSecretAgeDays = if ($SecretCount -gt 0) {
        $oldest = ($secrets | Sort-Object StartDateTime | Select-Object -First 1).StartDateTime
        if ($null -ne $oldest) { [int]($now - $oldest).TotalDays } else { -1 }
    } else { -1 }
    $AllSecretsExpired = ($SecretCount -gt 0 -and ($secrets | Where-Object { $_.EndDateTime -ge $now }).Count -eq 0)

    $certs = $app.KeyCredentials; $CertCount = $certs.Count
    $HasExpiredCert = ($certs | Where-Object { $_.EndDateTime -lt $now }).Count -gt 0
    $NearestCertExpiry = if ($CertCount -gt 0) { ($certs | Sort-Object EndDateTime | Select-Object -First 1).EndDateTime } else { "NoCert" }
    $AllCertsExpired = ($CertCount -gt 0 -and ($certs | Where-Object { $_.EndDateTime -ge $now }).Count -eq 0)

    $UsesPasswordlessAuth = ($FederatedCredentialCount -gt 0 -or $CertCount -gt 0 -or $ServicePrincipalType -eq "ManagedIdentity")
    $AuthMethod = if ($ServicePrincipalType -eq "ManagedIdentity") { "ManagedIdentity" }
    elseif ($FederatedCredentialCount -gt 0) { "FederatedCredential" }
    elseif ($CertCount -gt 0 -and $SecretCount -eq 0) { "CertificateOnly" }
    elseif ($SecretCount -gt 0) { "ClientSecret" }
    else { "NoCredential" }

    # ── Signal M: Broad Permission Detection ──
    $HasBroadGraphPermissions = $false
    $BroadPermissionNames = [System.Collections.Generic.List[string]]::new()
    foreach ($resource in $app.RequiredResourceAccess) {
        if ($resource.ResourceAppId -eq $graphResourceId) {
            foreach ($access in $resource.ResourceAccess) {
                if ($BroadPermissions.ContainsKey($access.Id.ToString())) {
                    $HasBroadGraphPermissions = $true
                    $BroadPermissionNames.Add($BroadPermissions[$access.Id.ToString()])
                }
            }
        }
    }

    # ── SCORE / CLASSIFY / BUCKET ──
    $signalHash = @{
        IsMicrosoftApp = $IsMicrosoftApp; ConsumedByAppsCount = $ConsumedByAppsCount
        HasPrivilegedEntraRole = $HasPrivilegedEntraRole; HasPrivilegedAzureRole = $HasPrivilegedAzureRole
        AssignedUsersCount = $AssignedUsersCount; SPLastSignInDate = $SPLastSignInDate
        SPLastDaemonSignInDate = $SPLastDaemonSignInDate; LastInteractiveSignInDate = $LastInteractiveSignInDate
        DaemonUsageDetected = $DaemonUsageDetected; UsedByExternalSystem = $UsedByExternalSystem
        ExternalSystemType = $ExternalSystemType; HasAzureRBACRoles = $HasAzureRBACRoles
        AzureRBACRoleCount = $AzureRBACRoleCount; AssignedGroupsCount = $AssignedGroupsCount
        AdminConsentGranted = $AdminConsentGranted; AppRoleAssignmentCount = $AppRoleAssignmentCount
        OAuthGrantCount = $OAuthGrantCount; IsApiProvider = $IsApiProvider
        ExposedScopeCount = $ExposedScopeCount; DirectoryRoleCount = $DirectoryRoleCount
        HasExpiredSecret = $HasExpiredSecret; SecretCount = $SecretCount
        HasExpiredCert = $HasExpiredCert; CertCount = $CertCount
        IsCoveredByCAPolicy = $IsCoveredByCAPolicy; NoServicePrincipal = $NoServicePrincipal
        AllSecretsExpired = $AllSecretsExpired; AppAgeDays = $AppAgeDays
        IsVerifiedPublisher = $IsVerifiedPublisher; FederatedCredentialCount = $FederatedCredentialCount
        LastSPSignInDate = $LastSPSignInDate; AppRoleAssignedResources = $AppRoleAssignedResources
        ConsumedByApps = $ConsumedByApps; AzureRBACScopes = $AzureRBACScopes
        AssignedGroupNames = $AssignedGroupNames; ExposedPermissions = $ExposedPermissions
        LastSignInResourceName = $LastSignInResourceName; LastSPSignInResourceName = $LastSPSignInResourceName
    }

    $scoreResult = Get-DeletionSafetyScore -Signals $signalHash
    $usageResult = Get-UsageStatus -Signals $signalHash
    $bucketResult = Get-CleanupBucket -Score $scoreResult.Score -IsUnused $usageResult.IsUnused -UsageConfidence $usageResult.UsageConfidence -IsMicrosoftApp $IsMicrosoftApp
    $whereUsed = Get-WhereUsed -Signals $signalHash

    $DaysSinceInteractiveSignIn = if ($LastInteractiveSignInDate -is [datetime]) { [int]((Get-Date) - $LastInteractiveSignInDate).TotalDays } else { -1 }
    $DaysSinceSPSignIn = if ($LastSPSignInDate -is [datetime]) { [int]((Get-Date) - $LastSPSignInDate).TotalDays } else { -1 }

    # ── Build Master Record ──
    $MasterReport.Add([PSCustomObject]@{
            CleanupBucket              = $bucketResult.Bucket
            BucketLabel                = $bucketResult.Label
            RecommendedAction          = $bucketResult.Action
            DeletionSafetyScore        = $scoreResult.Score
            ScoreReasons               = $scoreResult.ScoreReasons
            AppName                    = $DisplayName
            AppId                      = $AppId
            ObjectId                   = $ObjectId
            CreatedDate                = $CreatedDateTime
            SignInAudience             = $SignInAudience
            ServicePrincipalId         = $ServicePrincipalId
            NoServicePrincipal         = $NoServicePrincipal
            IsEnabled                  = $IsEnabled
            ServicePrincipalType       = $ServicePrincipalType
            Tags                       = $Tags
            VerifiedPublisher          = $VerifiedPublisher
            IsMicrosoftApp             = $IsMicrosoftApp
            AppAgeDays                 = $AppAgeDays
            OwnerUPNs                  = $OwnerUPNs
            OwnerCount                 = $OwnerCount
            HasOwner                   = $HasOwner
            IsUnused                   = $usageResult.IsUnused
            UnusedMoreThan1Year        = $usageResult.UnusedMoreThan1Year
            UsageConfidence            = $usageResult.UsageConfidence
            LastUsedDate               = $usageResult.LastUsedDate
            DaysSinceLastUse           = $usageResult.DaysSinceLastUse
            UsageLocationSummary       = $whereUsed
            LastInteractiveSignInDate  = if ($null -ne $LastInteractiveSignInDate) { $LastInteractiveSignInDate } else { "NoData" }
            DaysSinceInteractiveSignIn = $DaysSinceInteractiveSignIn
            LastSignInResourceName     = $LastSignInResourceName
            LastSignInUserUPN          = $LastSignInUserUPN
            LastSignInIPAddress        = $LastSignInIPAddress
            LastSignInLocation         = $LastSignInLocation
            LastSignInClientApp        = $LastSignInClientApp
            LegacyAuthDetected         = $LegacyAuthDetected
            IsCoveredByCAPolicy        = $IsCoveredByCAPolicy
            SPLastSignInDate           = if ($null -ne $SPLastSignInDate) { $SPLastSignInDate } else { "NoData" }
            SPLastDaemonSignInDate     = if ($null -ne $SPLastDaemonSignInDate) { $SPLastDaemonSignInDate } else { "NoData" }
            LastSPSignInDate           = if ($null -ne $LastSPSignInDate) { $LastSPSignInDate } else { "NoData" }
            DaysSinceSPSignIn          = $DaysSinceSPSignIn
            LastSPSignInResourceName   = $LastSPSignInResourceName
            DaemonUsageDetected        = $DaemonUsageDetected
            DeletionBlastRadius        = $DeletionBlastRadius
            ConsumedByAppsCount        = $ConsumedByAppsCount
            ConsumedByApps             = $ConsumedByApps
            AssignedUsersCount         = $AssignedUsersCount
            AssignedGroupsCount        = $AssignedGroupsCount
            AssignedGroupNames         = $AssignedGroupNames
            IsActingAsResourceApp      = $IsActingAsResourceApp
            AppRoleAssignmentCount     = $AppRoleAssignmentCount
            AppRoleAssignedResources   = $AppRoleAssignedResources
            OAuthGrantCount            = $OAuthGrantCount
            OAuthGrantScopes           = $OAuthGrantScopes
            AdminConsentGranted        = $AdminConsentGranted
            DirectoryRoles             = $DirectoryRoles
            DirectoryRoleCount         = $DirectoryRoleCount
            HasPrivilegedEntraRole     = $HasPrivilegedEntraRole
            AzureRBACRoleCount         = $AzureRBACRoleCount
            AzureRBACRoles             = $AzureRBACRoles
            AzureRBACScopes            = $AzureRBACScopes
            HasAzureRBACRoles          = $HasAzureRBACRoles
            HasPrivilegedAzureRole     = $HasPrivilegedAzureRole
            FederatedCredentialCount   = $FederatedCredentialCount
            FederatedCredentials       = $FederatedCredentials
            FederatedIssuers           = $FederatedIssuers
            FederatedSubjects          = $FederatedSubjects
            UsedByExternalSystem       = $UsedByExternalSystem
            ExternalSystemType         = $ExternalSystemType
            SecretCount                = $SecretCount
            NearestSecretExpiry        = $NearestSecretExpiry
            HasExpiredSecret           = $HasExpiredSecret
            AllSecretsExpired          = $AllSecretsExpired
            OldestSecretAgeDays        = $OldestSecretAgeDays
            CertCount                  = $CertCount
            NearestCertExpiry          = $NearestCertExpiry
            HasExpiredCert             = $HasExpiredCert
            AllCertsExpired            = $AllCertsExpired
            AuthMethod                 = $AuthMethod
            UsesPasswordlessAuth       = $UsesPasswordlessAuth
            IsPublicClient             = $IsPublicClient
            IsApiProvider              = $IsApiProvider
            ExposedScopeCount          = $ExposedScopeCount
            ExposedRoleCount           = $ExposedRoleCount
            ExposedPermissions         = $ExposedPermissions
            DeclaredPermissionCount    = $DeclaredPermissionCount
            HasBroadGraphPermissions   = $HasBroadGraphPermissions
            BroadPermissionNames       = ($BroadPermissionNames -join ";")
            RedirectUriCount           = $RedirectUriCount
            WebRedirectUris            = $WebRedirectUris
            SpaRedirectUris            = $SpaRedirectUris
            HasLocalhostUri            = $HasLocalhostUri
            HasHttpUri                 = $HasHttpUri
            HasNgrokUri                = $HasNgrokUri
        })

    Write-Log "DEBUG" "App" "[$currentApp/$totalApps] $AppId | Score=$($scoreResult.Score) | $($bucketResult.Label)"
}

Write-Progress -Activity "Auditing App Registrations" -Completed
Write-Log "INFO" "Phase2" "Completed per-app processing for $totalApps apps"
Write-Host ""

# ────────────────────────────────────────────────────────────────
# STEP 6: Managed Identity Sweep
# ────────────────────────────────────────────────────────────────
Write-Host "[6/7] Managed Identity sweep ($($MiCache.Count) MIs)..." -ForegroundColor White

$MiReport = [System.Collections.Generic.List[object]]::new()
foreach ($mi in $MiCache.Values) {
    $miRbac = $AzureRbacCache[$mi.Id]
    $miDirRole = $DirRoleCache[$mi.Id]
    $miRBACRoles = if ($miRbac) { ($miRbac | ForEach-Object { $_.RoleDefinitionName }) -join ";" } else { "None" }
    $miDirRoles = if ($miDirRole) { ($miDirRole | ForEach-Object { $_.RoleDefinition.DisplayName }) -join ";" } else { "None" }

    $altNames = $mi.AlternativeNames
    $isSystemAssigned = ($altNames | Where-Object { $_ -like '/subscriptions/*' }).Count -gt 0
    $miType = if ($isSystemAssigned) { "SystemAssigned" } else { "UserAssigned" }
    $LinkedResource = if ($isSystemAssigned) {
        ($altNames | Where-Object { $_ -like '/subscriptions/*' }) | Select-Object -First 1
    } else { $mi.DisplayName }

    $MiReport.Add([PSCustomObject]@{
            DisplayName            = $mi.DisplayName
            ServicePrincipalId     = $mi.Id
            AppId                  = $mi.AppId
            ManagedIdentityType    = $miType
            LinkedResource         = $LinkedResource
            IsEnabled              = $mi.AccountEnabled
            CreatedDateTime        = $mi.CreatedDateTime
            AzureRBACRoles         = $miRBACRoles
            AzureRBACRoleCount     = if ($miRbac) { $miRbac.Count } else { 0 }
            HasPrivilegedAzureRole = ($miRbac | Where-Object { $_.RoleDefinitionName -in $PrivilegedAzureRoles }).Count -gt 0
            DirectoryRoles         = $miDirRoles
            HasPrivilegedEntraRole = ($miDirRole | Where-Object { $_.RoleDefinition.DisplayName -in $PrivilegedEntraRoles }).Count -gt 0
            PossiblyOrphaned       = ($miRbac.Count -eq 0 -and $miDirRole.Count -eq 0)
        })
}
Write-Log "INFO" "MI" "MI sweep complete: $($MiReport.Count) MIs"
Write-Host ""

# ────────────────────────────────────────────────────────────────
# STEP 7: Export CSVs + Console Summary
# ────────────────────────────────────────────────────────────────
Write-Host "[7/7] Exporting results..." -ForegroundColor White

# Master CSV
$masterPath = Join-Path $runFolder "MasterAudit_Full_${effectiveTenantId}_${DateStamp}.csv"
$MasterReport | Export-Csv -Path $masterPath -NoTypeInformation -Encoding UTF8
Write-Log "INFO" "Export" "Master: $masterPath"

# Bucket CSVs
$bucketLabels = @{ 1 = "Bucket1_SafeToDisable"; 2 = "Bucket2_NeedsInvestigation"; 3 = "Bucket3_LikelyActive"; 4 = "Bucket4_BusinessCritical" }
for ($b = 1; $b -le 4; $b++) {
    $bucketApps = $MasterReport | Where-Object { $_.CleanupBucket -eq $b }
    $bucketPath = Join-Path $runFolder "$($bucketLabels[$b])_${effectiveTenantId}_${DateStamp}.csv"
    if ($b -eq 1) {
        $bucketApps | Select-Object *, @{N = 'ProposedDisableDate'; E = { Get-Date -Format 'yyyy-MM-dd' } },
        @{N = 'ProposedDeleteDate'; E = { (Get-Date).AddDays(30).ToString('yyyy-MM-dd') } },
        @{N = 'DisabledConfirmed'; E = { '' } }, @{N = 'OwnerNotified'; E = { '' } } |
            Export-Csv -Path $bucketPath -NoTypeInformation -Encoding UTF8
    } else {
        $bucketApps | Export-Csv -Path $bucketPath -NoTypeInformation -Encoding UTF8
    }
    Write-Log "INFO" "Export" "$($bucketLabels[$b]): $(@($bucketApps).Count) apps"
}

# MI CSV
$miPath = Join-Path $runFolder "ManagedIdentity_Audit_${effectiveTenantId}_${DateStamp}.csv"
$MiReport | Export-Csv -Path $miPath -NoTypeInformation -Encoding UTF8

# Owner Notification CSVs
$byOwner = $MasterReport | Where-Object { $_.OwnerUPNs -ne "NoOwner" } |
    ForEach-Object {
        $rec = $_; $rec.OwnerUPNs -split ";" | ForEach-Object { @{ Owner = $_; App = $rec } }
    } | Group-Object -Property Owner

foreach ($ownerGroup in $byOwner) {
    $safeFileName = $ownerGroup.Name -replace '[\\/:*?"<>|@]', '_'
    $ownerApps = $ownerGroup.Group | Select-Object -ExpandProperty App
    $ownerPath = Join-Path $ownerNotifDir "Owner_${safeFileName}_${DateStamp}.csv"
    $ownerApps | Select-Object $OwnerNotificationColumns | Export-Csv -Path $ownerPath -NoTypeInformation -Encoding UTF8
}

$noOwnerApps = $MasterReport | Where-Object { $_.OwnerUPNs -eq "NoOwner" -and $_.CleanupBucket -le 2 }
$escalationPath = Join-Path $ownerNotifDir "NoOwner_Escalation_${DateStamp}.csv"
$noOwnerApps | Select-Object $OwnerNotificationColumns | Export-Csv -Path $escalationPath -NoTypeInformation -Encoding UTF8

# ── Console Summary ──
$duration = (Get-Date) - $scriptStart
$durationStr = "$([int]$duration.TotalMinutes) min $($duration.Seconds) sec"

$b1 = @($MasterReport | Where-Object { $_.CleanupBucket -eq 1 }).Count
$b2 = @($MasterReport | Where-Object { $_.CleanupBucket -eq 2 }).Count
$b3 = @($MasterReport | Where-Object { $_.CleanupBucket -eq 3 }).Count
$b4 = @($MasterReport | Where-Object { $_.CleanupBucket -eq 4 }).Count

$withOwners = @($MasterReport | Where-Object { $_.HasOwner }).Count
$withoutOwners = @($MasterReport | Where-Object { -not $_.HasOwner }).Count
$highConf = @($MasterReport | Where-Object { $_.UsageConfidence -eq "High" }).Count
$medConf = @($MasterReport | Where-Object { $_.UsageConfidence -eq "Medium" }).Count
$lowConf = @($MasterReport | Where-Object { $_.UsageConfidence -eq "Low" }).Count

$line = [string]::new([char]0x2550, 66)
$thin = [string]::new([char]0x2500, 66)

Write-Host ""
Write-Host $line -ForegroundColor Cyan
Write-Host " Azure App Registration Audit - Summary" -ForegroundColor Cyan
Write-Host $line -ForegroundColor Cyan
Write-Host " Tenant : $effectiveTenantId"
Write-Host " Mode : $AuditMode"
Write-Host " Duration : $durationStr"
Write-Host " Subscriptions : $($AzSubscriptions.Count)"
Write-Host $thin -ForegroundColor DarkGray
Write-Host " INVENTORY" -ForegroundColor White
Write-Host " App Registrations : $($MasterReport.Count)"
Write-Host " Managed Identities : $($MiReport.Count)"
Write-Host $thin -ForegroundColor DarkGray
Write-Host " CLEANUP BUCKETS" -ForegroundColor White
Write-Host " Bucket 1 - Safe to Disable : $b1" -ForegroundColor Red
Write-Host " Bucket 2 - Needs Investigation: $b2" -ForegroundColor Yellow
Write-Host " Bucket 3 - Likely Active : $b3" -ForegroundColor DarkYellow
Write-Host " Bucket 4 - Business Critical : $b4" -ForegroundColor Green
Write-Host $thin -ForegroundColor DarkGray
Write-Host " OWNERSHIP" -ForegroundColor White
Write-Host " With Owners : $withOwners"
Write-Host " Without Owners : $withoutOwners"
Write-Host " Ownerless Escalation : $(@($noOwnerApps).Count)"
Write-Host $thin -ForegroundColor DarkGray
Write-Host " USAGE CONFIDENCE" -ForegroundColor White
Write-Host " High (confirmed) : $highConf"
Write-Host " Medium (signals) : $medConf"
Write-Host " Low (no signal) : $lowConf"
Write-Host $thin -ForegroundColor DarkGray
Write-Host " RISK FLAGS" -ForegroundColor White
Write-Host " Privileged Entra Role: $(@($MasterReport | Where-Object { $_.HasPrivilegedEntraRole }).Count)"
Write-Host " Privileged Azure Role: $(@($MasterReport | Where-Object { $_.HasPrivilegedAzureRole }).Count)"
Write-Host " Broad Graph Perms : $(@($MasterReport | Where-Object { $_.HasBroadGraphPermissions }).Count)"
Write-Host " Expired Secrets : $(@($MasterReport | Where-Object { $_.HasExpiredSecret }).Count)"
Write-Host " Secrets > 1yr Old : $(@($MasterReport | Where-Object { $_.OldestSecretAgeDays -gt 365 }).Count)"
Write-Host " Legacy Auth Detected : $(@($MasterReport | Where-Object { $_.LegacyAuthDetected }).Count)"
Write-Host $thin -ForegroundColor DarkGray
Write-Host " OUTPUT" -ForegroundColor White
Write-Host " Folder : $runFolder"
Write-Host " Log : $($script:LogFile)"

$outputFiles = Get-ChildItem -Path $runFolder -File -Recurse | Where-Object { $_.Extension -in @('.csv', '.log') }
Write-Host " Files : $($outputFiles.Count)" -ForegroundColor White
foreach ($f in $outputFiles | Sort-Object Extension, Name) {
    $sizeKB = [math]::Round($f.Length / 1KB, 1)
    $relPath = $f.FullName.Replace($runFolder, "").TrimStart('\')
    Write-Host " $($f.Extension.PadRight(6)) ${sizeKB}KB $relPath" -ForegroundColor DarkGray
}

Write-Host $line -ForegroundColor Cyan
Write-Host ""

# Open output folder
if ($IsWindows -or $PSVersionTable.PSVersion.Major -le 5) {
    Start-Process explorer.exe -ArgumentList $runFolder
} elseif ($IsMacOS) {
    Start-Process "open" -ArgumentList $runFolder
} elseif ($IsLinux) {
    Start-Process "xdg-open" -ArgumentList $runFolder
}

Write-Log "INFO" "Done" "Audit complete. Duration: $durationStr. Apps: $($MasterReport.Count). B1=$b1 B2=$b2 B3=$b3 B4=$b4"