Public/Get-IOPasswordOnlyAccounts.ps1

function Get-IOPasswordOnlyAccounts {
    <#
    .SYNOPSIS
        Identifies accounts used for non-interactive sign-ins that rely solely on passwords
        (potential service accounts without cert/managed identity).
    .EXAMPLE
        Get-IOPasswordOnlyAccounts
    .EXAMPLE
        Get-IOPasswordOnlyAccounts -ToCsv "password-only.csv"
    #>

    [CmdletBinding()]
    param(
        [ValidateRange(1, 365)]
        [int]$LookbackDays = 30,

        [string]$ToCsv
    )

    $cmdName = $MyInvocation.MyCommand.Name
    Write-IOLog "Scanning for password-only accounts with non-interactive sign-ins in last $LookbackDays days..." -Level Info -Component $cmdName

    $results    = [System.Collections.Generic.List[PSCustomObject]]::new()
    $since      = [datetime]::UtcNow.AddDays(-$LookbackDays).ToString('yyyy-MM-ddTHH:mm:ssZ')
    $seenUsers  = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)

    # Get non-interactive sign-ins (these often indicate service accounts)
    $filter = "createdDateTime ge $since"

    try {
        $signIns = Invoke-IOGraphRequest -Uri "v1.0/auditLogs/signIns?`$filter=$filter&`$select=userId,userPrincipalName,userDisplayName,appDisplayName,authenticationMethodsUsed,createdDateTime,signInEventTypes&`$top=500" -Top 500
    }
    catch {
        # Sign-in logs require Entra ID P1/P2 — some tenants return 400 instead of 403
        if ($_.Exception.Message -match '400|BadRequest|403|Forbidden') {
            Write-IOLog 'Non-interactive sign-in logs require Entra ID P1/P2 and AuditLog.Read.All.' -Level Warning -Component $cmdName
            return
        }
        throw
    }

    # Post-filter to non-interactive user sign-ins only
    $signIns = @($signIns | Where-Object {
        $evt = if ($_.PSObject.Properties['signInEventTypes']) { $_.signInEventTypes } else { @() }
        $evt -contains 'nonInteractiveUser'
    })

    # Group by user and check their auth methods
    $userSignIns = $signIns | Group-Object -Property userId

    foreach ($group in $userSignIns) {
        $userId = $group.Name
        if ([string]::IsNullOrWhiteSpace($userId) -or $seenUsers.Contains($userId)) { continue }
        [void]$seenUsers.Add($userId)

        $sample = $group.Group[0]

        # Check what auth methods this user has registered
        try {
            $methods = Invoke-IOGraphRequest -Uri "v1.0/users/$userId/authentication/methods" -NoPagination -SkipConnectionCheck
        }
        catch {
            continue
        }

        $methodTypes = @($methods | ForEach-Object { $_.'@odata.type' } | Where-Object { $_ })

        # Flag if only password method registered (no FIDO, cert, phone, authenticator)
        $strongMethods = @($methodTypes | Where-Object {
            $_ -notin @(
                '#microsoft.graph.passwordAuthenticationMethod',
                '#microsoft.graph.emailAuthenticationMethod'
            )
        })

        if ($strongMethods.Count -eq 0) {
            $results.Add([PSCustomObject]@{
                DisplayName        = $sample.userDisplayName
                UserPrincipalName  = $sample.userPrincipalName
                UserId             = $userId
                NonInteractiveApps = ($group.Group | Select-Object -ExpandProperty appDisplayName -Unique | Select-Object -First 5) -join '; '
                SignInCount        = $group.Count
                RegisteredMethods  = ($methodTypes | ForEach-Object { $_.Split('.')[-1] -replace 'AuthenticationMethod','' }) -join '; '
                Risk               = if ($group.Count -gt 50) { 'Critical' } else { 'High' }
                Recommendation     = 'Migrate to Managed Identity or Certificate auth'
            })
        }
    }

    $sorted = $results | Sort-Object Risk, SignInCount -Descending
    Export-IOResult -Data $sorted -ToCsv $ToCsv -CommandName $cmdName
}