Check for Connectwise Entraprise apps across GDAP Tenant

<#
.SYNOPSIS
    Checks every GDAP customer tenant for the compromised ConnectWise vendor
    Enterprise Applications (service principals) named in Huntress's October
    2026 advisory on unauthorized ConnectWise vendor-credential access.

.DESCRIPTION
    Huntress reported that a ConnectWise vendor credential was obtained by an
    unauthorized party and used to pull Microsoft Graph tokens for several
    ConnectWise multi-tenant applications, with at least one instance observed
    self-elevating to Global Administrator in a customer tenant:
    https://www.huntress.com/blog/vendor-credential-enables-unauthorized-access

    Reads the customer tenant list produced by Get-GDAPCustomerTenants.ps1 and,
    for each customer, looks for Enterprise Applications whose AppId matches one
    of the known-compromised ConnectWise application IDs. For every match found
    it also checks whether that service principal has been added to the Global
    Administrator directory role - the specific escalation Huntress observed.

    AUTH DESIGN - delegated via GDAP only, no app registration/service principal
    of our own, matching how CIPP does this under the hood:
      1. ONE interactive sign-in (New-PartnerAccessToken -UseAuthorizationCode),
         using the same public multi-tenant client ID Connect-MgGraph itself
         uses ("Microsoft Graph Command Line Tools": 14d82eec-204b-4c2f-b7e8-
         296a70dab67e). This is delegated - it runs as you, under your own GDAP
         role assignments, exactly like the interactive version of this script
         did, just once instead of once per tenant.
      2. For every customer tenant, the refresh token from step 1 is redeemed
         for a Graph-scoped access token FOR THAT TENANT
         (New-PartnerAccessToken -RefreshToken ... -Tenant <customerTenantId>).
         This is a silent token redemption - no browser, no prompt - because
         it's just an OAuth refresh-token grant against a tenant your GDAP
         relationship already gives you a role in. This is the same mechanism
         Get-PartnerCenterLicensing.ps1 uses for Partner Center tokens, applied
         to Microsoft Graph instead.
      3. The resulting access token is used directly against the Graph v1.0
         REST API (Invoke-RestMethod) - no Microsoft.Graph module needed, which
         also sidesteps Connect-MgGraph's own per-tenant interactive re-prompt
         behaviour that made the original version of this script re-prompt for
         all 214 tenants one at a time.

    This only tells you whether the named vendor app is PRESENT (consented) in
    a customer's tenant - that's expected for ConnectWise RMM/backup customers
    and is not itself proof of compromise. Treat any row this script returns as
    "needs vendor-advisory triage", not "confirmed breach": cross-check sign-in/
    audit logs for that service principal against the activity windows in
    Huntress's writeup, and follow ConnectWise's own remediation guidance.

.PARAMETER CustomerCsv
    Path to the customer tenant list from Get-GDAPCustomerTenants.ps1.

.PARAMETER OutputPath
    Folder to write the CSV reports to. Defaults to the script's own folder.

.PARAMETER AppIds
    Override the built-in list of known-compromised ConnectWise AppIds (e.g. to
    add IDs from a later Huntress/ConnectWise update) instead of editing the
    script.

.PARAMETER IncludeOtherConnectWiseApps
    Also report any OTHER service principal in the tenant owned by ConnectWise's
    tenant (AppOwnerOrganizationId) that isn't one of the specific IOC AppIds
    above. This is informational only - ConnectWise has many legitimate
    multi-tenant apps - but gives a fuller picture of ConnectWise's footprint
    in the tenant while this advisory is still developing.

.NOTES
    Requires the PartnerCenter PowerShell module (already a dependency of
    Get-PartnerCenterLicensing.ps1 in this folder) purely for its
    New-PartnerAccessToken helper - no Partner Center API calls are made here,
    it's used only as a generic MSAL wrapper to mint Graph tokens per tenant.
    Needs Application.Read.All and Directory.Read.All to already be usable via
    your GDAP role in each customer tenant - the same requirement the previous,
    fully-interactive version of this script had.
#>

[CmdletBinding()]
param(
    [string]$CustomerCsv = (Join-Path $PSScriptRoot 'GDAPCustomers.csv'),
    [string]$OutputPath  = $PSScriptRoot,
    [string[]]$AppIds,
    [switch]$IncludeOtherConnectWiseApps,
    [string]$UserPrincipalName
)

$ErrorActionPreference = 'Stop'

if (-not (Test-Path $CustomerCsv)) {
    throw "Customer list not found at '$CustomerCsv'. Run Get-GDAPCustomerTenants.ps1 first."
}

Import-Module PartnerCenter -RequiredVersion 3.0.10 -ErrorAction Stop

# The well-known Microsoft Graph PowerShell ("Microsoft Graph Command Line Tools")
# public client ID - the exact same one Connect-MgGraph uses internally (confirmed
# by extracting the GUID literal from Microsoft.Graph.Authentication.dll). Reused
# here via New-PartnerAccessToken purely as a generic MSAL wrapper so we can redeem
# one interactive sign-in into a silent per-tenant token, which Connect-MgGraph
# itself doesn't support across repeated -TenantId switches in one session.
$graphClientId = '14d82eec-204b-4c2f-b7e8-296a70dab67e'
$graphScope    = 'https://graph.microsoft.com/.default'
# Kept deliberately minimal and near-universally pre-consented, to avoid tripping an
# admin-consent prompt (which showed up as "access_denied" when the bootstrap sign-in
# requested .default straight away) - the admin-scoped request only happens per customer
# tenant below, where it was already working in the previous interactive version.
$bootstrapScope = 'https://graph.microsoft.com/User.Read offline_access'

# Source: https://www.huntress.com/blog/vendor-credential-enables-unauthorized-access
# (Huntress advisory, 2026-10). The compromised credential belongs to ConnectWise's
# own tenant and was used against these specific multi-tenant management apps.
$connectWiseTenantId = '3f9cd7a0-127a-4ed4-bf06-2f830d5616b7'
$knownVendorApps = @(
    [pscustomobject]@{ AppId = '506e08f9-6e9a-4da4-942b-a099a03644ae'; Label = 'ConnectWise Cloud Backup (formerly SkyKick)';      Note = "Huntress: credential confirmed in hostile hands - used to pull Graph tokens from unauthorized infrastructure (observed 2026-10-15)." }
    [pscustomobject]@{ AppId = '24205314-2b89-4e07-a2a4-579e898a5640'; Label = 'ConnectWise Cloud Management - MS Teams';          Note = "Huntress: in one customer tenant this app assigned itself to Global Administrator, then ran a Python script modifying two other applications." }
    [pscustomobject]@{ AppId = 'b78f6a77-d4e5-46fb-87e5-d1648bd02519'; Label = 'ConnectWise Cloud Management - Exchange Online';   Note = "Huntress: identified with high confidence as abused for unauthorized access." }
    [pscustomobject]@{ AppId = 'b2b55f5b-7758-4719-8d8f-88ae172e6bdb'; Label = 'ConnectWise Cloud Management - SharePoint Online'; Note = "Huntress: one of multiple ConnectWise management apps observed in the exploitation activity." }
    [pscustomobject]@{ AppId = '0de14c77-b9b0-4137-9360-34878a929827'; Label = 'ConnectWise Cloud Management - MS Graph';         Note = "Huntress: used to obtain tokens as part of the unauthorized access campaign." }
    [pscustomobject]@{ AppId = '412dcf96-176d-4b3a-a1b0-4981ad66971e'; Label = 'ConnectWise Cloud Management - Partner Center';   Note = "Huntress: associated with vendor automation in the incident timeline." }
    [pscustomobject]@{ AppId = '2399d300-6fca-4e6a-a686-399fefe75b83'; Label = 'ConnectWise Cloud Management (generic)';          Note = "Huntress: general management app recommended for review and disablement." }
)
if ($AppIds) {
    $knownVendorApps = $AppIds | ForEach-Object { [pscustomobject]@{ AppId = $_; Label = '(custom AppId)'; Note = '' } }
}

$globalAdminRoleTemplateId = '62e90394-69f5-4237-9190-012177145e10'

$customers = Import-Csv -Path $CustomerCsv
if (-not $customers -or $customers.Count -eq 0) {
    Write-Warning "No customer tenants in '$CustomerCsv'. Nothing to check."
    return
}

$timestamp  = Get-Date -Format 'yyyyMMdd_HHmmss'
$reportPath = Join-Path $OutputPath "ConnectWiseVendorAppCheck_$timestamp.csv"
$errorsPath = Join-Path $OutputPath "ConnectWiseVendorAppCheck_Errors_$timestamp.csv"

$findings = [System.Collections.Generic.List[object]]::new()
$errors   = [System.Collections.Generic.List[object]]::new()

function Add-ErrorRow {
    param($TenantId, $TenantName, $Message)
    $errors.Add([pscustomobject]@{ TenantId = $TenantId; TenantName = $TenantName; Message = $Message })
}

function Invoke-GraphGet {
    param([string]$Uri, [string]$AccessToken)
    $headers = @{ Authorization = "Bearer $AccessToken"; ConsistencyLevel = 'eventual' }
    $items = [System.Collections.Generic.List[object]]::new()
    do {
        $resp = Invoke-RestMethod -Uri $Uri -Headers $headers -Method Get -ErrorAction Stop
        if ($resp.value) { foreach ($v in $resp.value) { $items.Add($v) } }
        $Uri = $resp.'@odata.nextLink'
    } while ($Uri)
    return $items
}

function Test-IsGlobalAdmin {
    param([string]$ServicePrincipalObjectId, [string]$AccessToken)
    try {
        $memberOf = Invoke-GraphGet -Uri "https://graph.microsoft.com/v1.0/servicePrincipals/$ServicePrincipalObjectId/memberOf" -AccessToken $AccessToken
        foreach ($m in $memberOf) {
            if ($m.'@odata.type' -eq '#microsoft.graph.directoryRole' -and $m.roleTemplateId -eq $globalAdminRoleTemplateId) {
                return $true
            }
        }
        return $false
    } catch {
        # Directory.Read.All missing or role membership not readable - leave unknown rather than assume safe.
        return $null
    }
}

if (-not $UserPrincipalName -and [Environment]::UserInteractive -and -not [Console]::IsInputRedirected) {
    try { $UserPrincipalName = Read-Host "Enter the UPN/email you'll sign in with (optional, just a reminder)" } catch { }
}

Write-Host "`n=== Sign in once (delegated, via your GDAP access) ===" -ForegroundColor Cyan
Write-Host "A browser sign-in window will open - sign in there with your own admin/GDAP account." -ForegroundColor Cyan
if ($UserPrincipalName) {
    Write-Host ">>> Sign in as: $UserPrincipalName <<<" -ForegroundColor Magenta
    Write-Host "If the browser offers a different/cached account, choose 'Use another account' and enter the above." -ForegroundColor Cyan
}
Write-Host "Use passkey/Windows Hello there if you have one registered. This happens ONCE - every customer tenant after this is a silent token redemption, no further prompts." -ForegroundColor Cyan
$auth = New-PartnerAccessToken -ApplicationId $graphClientId -Scopes $bootstrapScope -UseAuthorizationCode
Write-Host "Signed in." -ForegroundColor Green
Write-Host "Customer tenants to check: $($customers.Count)" -ForegroundColor Green

$refreshToken = $auth.RefreshToken

Write-Host "`n=== ConnectWise vendor app check across $($customers.Count) GDAP customer tenant(s) ===" -ForegroundColor Cyan
Write-Host "Checking for $($knownVendorApps.Count) known-compromised ConnectWise AppId(s)." -ForegroundColor Cyan

$idx = 0
foreach ($customer in $customers) {
    $idx++
    $tenantLabel = if ($customer.TenantName) { $customer.TenantName } else { $customer.TenantId }
    Write-Host "`n[$idx/$($customers.Count)] $tenantLabel" -ForegroundColor Yellow

    $customerToken = $null
    try {
        $customerToken = New-PartnerAccessToken -ApplicationId $graphClientId -RefreshToken $refreshToken -Scopes $graphScope -Tenant $customer.TenantId -ErrorAction Stop
    } catch {
        Write-Host "  Skipped - no Graph access via GDAP in this tenant: $($_.Exception.Message)" -ForegroundColor DarkGray
        Add-ErrorRow -TenantId $customer.TenantId -TenantName $tenantLabel -Message "Token redemption failed: $($_.Exception.Message)"
        continue
    }
    # Refresh tokens can rotate on each redemption - always carry the newest one forward.
    if ($customerToken.RefreshToken) { $refreshToken = $customerToken.RefreshToken }
    $accessToken = $customerToken.AccessToken

    foreach ($vendorApp in $knownVendorApps) {
        try {
            $sp = Invoke-GraphGet -Uri "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=appId eq '$($vendorApp.AppId)'" -AccessToken $accessToken | Select-Object -First 1
        } catch {
            Add-ErrorRow -TenantId $customer.TenantId -TenantName $tenantLabel -Message "Lookup failed for AppId $($vendorApp.AppId): $($_.Exception.Message)"
            continue
        }
        if (-not $sp) { continue }

        $isGA = Test-IsGlobalAdmin -ServicePrincipalObjectId $sp.id -AccessToken $accessToken
        $severity = if ($isGA -eq $true) { 'CRITICAL - app has Global Administrator' } elseif ($null -eq $isGA) { 'Present - GA membership unknown (insufficient read access)' } else { 'Present' }

        $findings.Add([pscustomobject]@{
            TenantId               = $customer.TenantId
            TenantName             = $tenantLabel
            AppId                  = $vendorApp.AppId
            VendorAppLabel         = $vendorApp.Label
            ServicePrincipalName   = $sp.displayName
            ServicePrincipalObjId  = $sp.id
            AccountEnabled         = $sp.accountEnabled
            IsGlobalAdministrator  = $isGA
            Severity               = $severity
            HuntressNote           = $vendorApp.Note
        })
        $color = if ($isGA -eq $true) { 'Red' } else { 'Yellow' }
        Write-Host "    [FOUND] $($vendorApp.Label) - $severity" -ForegroundColor $color
    }

    if ($IncludeOtherConnectWiseApps) {
        try {
            $otherSPs = Invoke-GraphGet -Uri "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=appOwnerOrganizationId eq $connectWiseTenantId&`$count=true" -AccessToken $accessToken
            foreach ($sp in $otherSPs) {
                if ($knownVendorApps.AppId -contains $sp.appId) { continue }
                $findings.Add([pscustomobject]@{
                    TenantId               = $customer.TenantId
                    TenantName             = $tenantLabel
                    AppId                  = $sp.appId
                    VendorAppLabel         = '(other ConnectWise app - not a named IOC)'
                    ServicePrincipalName   = $sp.displayName
                    ServicePrincipalObjId  = $sp.id
                    AccountEnabled         = $sp.accountEnabled
                    IsGlobalAdministrator  = $null
                    Severity               = 'Informational'
                    HuntressNote           = ''
                })
            }
        } catch {
            Add-ErrorRow -TenantId $customer.TenantId -TenantName $tenantLabel -Message "Other-ConnectWise-app lookup failed: $($_.Exception.Message)"
        }
    }
}

$findings | Export-Csv -Path $reportPath -NoTypeInformation -Encoding UTF8
$errors   | Export-Csv -Path $errorsPath -NoTypeInformation -Encoding UTF8

Write-Host "`n=== Done ===" -ForegroundColor Cyan
Write-Host "Customer tenants checked       : $($customers.Count)" -ForegroundColor Green
Write-Host "Vendor app matches found       : $($findings.Count)" -ForegroundColor $(if ($findings.Count -gt 0) { 'Red' } else { 'Green' })
Write-Host "Tenants with a lookup error    : $($errors.Count)" -ForegroundColor Yellow
Write-Host "Report                         : $reportPath" -ForegroundColor Green
Write-Host "Errors log                     : $errorsPath" -ForegroundColor Yellow
if ($findings | Where-Object { $_.Severity -like 'CRITICAL*' }) {
    Write-Host "`nCRITICAL rows found - at least one vendor app holds Global Administrator in a customer tenant. Treat as an active incident, not routine triage." -ForegroundColor Red
}
(No Ratings Yet)