# Author: Alex Chen # # Usage: # 1. Analyze expiration status of the certs installed in each resource groups and locations, only the latest version (later Expiration Date) will be shown if multiple versions are detected in single resource group and location, e.g. # .\Analyze-AppServiceCerts.ps1 # # 2. Search the resource groups and locations which have the specified cert installed, only the latest version (later Expiration Date) will be shown if multiple versions are detected in single resource group and location, e.g. # .\Analyze-AppServiceCerts.ps1 -k '[PLACEHOLDER_OF_THUMBPRINT]' # .\Analyze-AppServiceCerts.ps1 -k 'contoso.com' # # 3. Enumerate all unique certs installed in the app services, only the latest version (later Expiration Date) will be shown if multiple versions are detected in single resource group and location, e.g. # .\Analyze-AppServiceCerts.ps1 -ul # # 4. Scan all app registrations (and associated secrets and certs) and certs referenced in the app service configurations, also accepts additional keywords to scan specific app services only, e.g. # .\Analyze-AppServiceCerts.ps1 -cfg # .\Analyze-AppServiceCerts.ps1 -cfg -k '[WEBAPP_NAME_KEYWORDS]' # # Limitations: # 1. .NET Core config files haven't been supported yet. # 2. First party apps are not supported. # 3. The config name of the app registrations must contains a 'ClientId' or 'Audience' (e.g. ida:ClientId), if there's any new pattern come out later, we need to update the script accordingly. # 4. Large desktop resolution (e.g. 1920*1080) is required to have all report columns shown properly. Param( [switch] [Alias('ul')] $uniquelist, [switch] [Alias('cfg')] $configs, [string] [Alias('k')] $keywords = '' ) # Start Summarize-Certs function Summarize-Certs($subscriptions) { [System.Collections.ArrayList] $findings = @() foreach($sub in $subscriptions) { Set-AzContext -SubscriptionId $sub.Id $resourceGroups = Get-AzResourceGroup $index = 0 foreach ($rg in $resourceGroups) { $certs = Get-AzWebAppCertificate -ResourceGroupName $rg.ResourceGroupName $groups = $certs | Sort-Object SubjectName, ExpirationDate -Desc | Group-Object SubjectName, Location foreach($group in $groups) { $latestCert = $group[0].Group[0] $findings.Add([PSCustomObject] @{ Subscription = $sub.Name; ResourceGroup = $rg.ResourceGroupName; Location = $latestCert.Location; SubjectName = $latestCert.SubjectName; Thumbprint = $latestCert.Thumbprint; ExpirationDate = $latestCert.ExpirationDate }) | Out-Null } $progress = ++$index * 100 / $resourceGroups.Count Write-Progress -Activity "Enumerating certificates" -Status $rg.ResourceGroupName -CurrentOperation $sub.Name -PercentComplete $progress } } Write-Host '' Write-Host -ForegroundColor Red ('{0} items found' -f $findings.Count) $viewFields = Get-ViewFields -array $findings $findings | Sort-Object Subscription, ExpirationDate | Format-Table $viewFields -GroupBy Subscription } # End Summarize-Certs # Search-Certs function Search-Certs($subscriptions, $keywords) { $searchTerm = '*' + $keywords + '*' [System.Collections.ArrayList] $findings = @() foreach($sub in $subscriptions) { Set-AzContext -SubscriptionId $sub.Id $resourceGroups = Get-AzResourceGroup $index = 0 foreach ($rg in $resourceGroups) { $certs = (Get-AzWebAppCertificate -ResourceGroupName $rg.ResourceGroupName) | Where-Object { $_.SubjectName -Like $searchTerm -Or $_.Thumbprint -Like $searchTerm } $groups = $certs | Sort-Object SubjectName, ExpirationDate -Desc | Group-Object SubjectName, Location foreach($group in $groups) { $latestCert = $group[0].Group[0] $cert = [PSCustomObject] @{ Subscription = $sub.Name; ResourceGroup = $rg.ResourceGroupName; Location = $latestCert.Location; SubjectName = $latestCert.SubjectName; Thumbprint = $latestCert.Thumbprint; ExpirationDate = $latestCert.ExpirationDate } $findings.Add($cert) | Out-Null } $progress = ++$index * 100 / $resourceGroups.Count Write-Progress -Activity 'Searching certificates' -Status $rg.ResourceGroupName -CurrentOperation $sub.Name -PercentComplete $progress } } Write-Host '' Write-Host -ForegroundColor Green ('{0} certificates match the keywords: {1}' -f $findings.Count, $keywords) $viewFields = Get-ViewFields -array $findings $findings | Sort-Object Subscription, ResourceGroup, Location, SubjectName | Format-Table $viewFields -GroupBy Subscription } # End Search-Certs # Start Get-Certs function Get-Certs($subscriptions) { [System.Collections.ArrayList] $findings = @() foreach($sub in $subscriptions) { Set-AzContext -SubscriptionId $sub.Id $resourceGroups = Get-AzResourceGroup $index = 0 foreach ($rg in $resourceGroups) { $certs = Get-AzWebAppCertificate -ResourceGroupName $rg.ResourceGroupName $groups = $certs | Sort-Object SubjectName, ExpirationDate -Desc | Group-Object SubjectName, Location foreach($group in $groups) { $latestCert = $group[0].Group[0] if (($findings | Where-Object { $_.Subscription -eq $sub.Name -and $_.Thumbprint -eq $latestCert.Thumbprint }).Count -eq 0) { $cert = [PSCustomObject] @{ Subscription = $sub.Name; SubjectName = $latestCert.SubjectName; Thumbprint = $latestCert.Thumbprint; ExpirationDate = $latestCert.ExpirationDate } $findings.Add($cert) | Out-Null } } $progress = ++$index * 100 / $resourceGroups.Count Write-Progress -Activity 'Enumerating certificates' -Status $rg.ResourceGroupName -CurrentOperation $sub.Name -PercentComplete $progress } } Write-Host '' Write-Host -ForegroundColor Green ('{0} certificates found' -f $findings.Count) $viewFields = Get-ViewFields -array $findings $findings | Sort-Object Subscription, SubjectName, ExpirationDate | Format-Table $viewFields -GroupBy Subscription } # End Get-Certs # Start Get-AppIdsNThumbprints function Get-AppIdsNThumbprints($subscriptions, $keywords) { [System.Collections.ArrayList] $finalData = @() foreach($sub in $subscriptions) { Set-AzContext -SubscriptionId $sub.Id $webApps = Get-AzWebApp if ($keywords) { $webApps = $webApps | Where-Object { $_.Name -match $keywords } } $index = 0 foreach ($wa in $webApps) { # get all config files of the web app and jobs $configFiles = Get-WebAppConfigFiles -resourceGroup $wa.ResourceGroup -webApp $wa.Name foreach ($cfgFile in $configFiles) { if ($cfgFile.Name -eq 'Configuration blade') { # extract data from Configuration blade appSettings $appSettings = $cfgFile.Content.PSObject.Properties } else { # extract data from web.config appSettings $appSettings = $cfgFile.Content.SelectNodes('//appSettings/add') # extract certs from behaviors $data = Extract-BehaviorCerts -configs $cfgFile.Content Merge-Data -finalData $finalData -data $data -location $cfgFile.Name -subscription $sub.Name -resourceGroup $wa.ResourceGroup -webApp $wa.Name } $data = Extract-AppSettings -appSettings $appSettings Merge-Data -finalData $finalData -data $data -location $cfgFile.Name -subscription $sub.Name -resourceGroup $wa.ResourceGroup -webApp $wa.Name } $progress = ++$index * 100 / $webApps.Count Write-Progress -Activity 'Scanning app service configurations' -Status ('{0}/{1} => {2}' -f $index, $webApps.Count, $wa.Name) -CurrentOperation $sub.Name -PercentComplete $progress } Populate-AADAppDetails -finalData $finalData -subscription $sub.Name Populate-CertDetails -finalData $finalData -subscription $sub.Name } Write-Host '' Write-Host -ForegroundColor Green ('{0} items found' -f $finalData.Count) $viewFields = Get-ViewFields -array $finalData $finalData | Sort-Object Subscription, Type, ResourceGroup, WebApp, Name | Format-Table $viewFields -GroupBy Subscription } function Get-WebAppConfigFiles($resourceGroup, $webApp) { [System.Collections.ArrayList] $configFiles = @() $authHeaderValue = Get-KuduApiAuthorizationHeaderValue -resourceGroup $resourceGroup -webApp $webApp $settingsFile = Get-KuduContent -authToken $authHeaderValue -path ('https://{0}.scm.azurewebsites.net/api/settings' -f $webApp) if ($settingsFile) { $configFiles.Add(@{ Name = 'Configuration blade'; Content = $settingsFile }) | Out-Null } $rootContent = Get-KuduContent -authToken $authHeaderValue -webApp $webApp $item = $rootContent | Where-Object { $_.name -match 'web.config' } | Select-Object -First 1 if ($item) { $file = Get-KuduContent -authToken $authHeaderValue -path $item.href $configFiles.Add(@{ Name = $item.name; Content = $file }) | Out-Null } $item = $rootContent | Where-Object { $_.name -match 'App_Data' } | Select-Object -First 1 if ($item) { $appDataContent = Get-KuduContent -authToken $authHeaderValue -path $item.href $item = $appDataContent | Where-Object { $_.name -match 'jobs' } | Select-Object -First 1 if ($item) { $jobsContent = Get-KuduContent -authToken $authHeaderValue -path $item.href foreach ($jobType in $jobsContent) { $jobFolders = Get-KuduContent -authToken $authHeaderValue -path $jobType.href foreach ($jobFolder in $jobFolders) { $jobFiles = Get-KuduContent -authToken $authHeaderValue -path $jobFolder.href $jobConfigFile = $jobFiles | Where-Object { $_.name -match '.exe.config' } | Select-Object -First 1 if ($jobConfigFile) { $file = Get-KuduContent -authToken $authHeaderValue -path $jobConfigFile.href $configFiles.Add(@{ Name = $jobConfigFile.name; Content = $file }) | Out-Null } } } } } return $configFiles } function Get-KuduApiAuthorizationHeaderValue($resourceGroup, $webApp){ [Xml]$pubProfile = Get-AzWebAppPublishingProfile -ResourceGroupName $resourceGroup -Name $webApp $profile = $pubProfile.SelectSingleNode('//publishProfile[@publishMethod="MSDeploy"]') return ("Basic {0}" -f [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(("{0}:{1}" -f $profile.userName, $profile.userPWD)))) } function Get-KuduContent($authToken, $webApp, $path){ if ($path -and $path -match '^https://') { $kuduApiUrl = [Uri]$path } else { $kuduApiUrl = 'https://{0}.scm.azurewebsites.net/api/vfs/site/wwwroot/{1}' -f $webApp, $path } try { $result = Invoke-RestMethod -Uri $kuduApiUrl ` -Headers @{ "Authorization" = $authToken; "If-Match" = "*"} ` -Method GET } catch { Write-Host -ForegroundColor Red ('Failed to get file: ' + $kuduApiUrl + ', will skip it, error message: ' + $_.Exception.Message) return $null } if ($path -and $path -match '\.\w+$' -and $result.PSObject.TypeNames -match 'string') { # remove first 3 unexpected chars from file content return [Xml]($result.Substring(3)) } else { return $result } } function Merge-Data($finalData, $data, $location, $subscription, $resourceGroup, $webApp, [switch]$overwrite) { foreach ($ar in $data) { $item = $finalData | Where-Object { $_.WebApp -eq $webApp -and $_.Name -eq $ar.Name } if ($item) { if ($overwrite) { $item.Value = $ar.Value $item.Location = $location } } else { $finalData.Add([PSCustomObject] @{ Type = $ar.Type; Subscription = $subscription; ResourceGroup = $resourceGroup; WebApp = $webApp; Name = $ar.Name; Value = $ar.Value; FriendlyValue = ''; ExpirationDate = ''; Location = $location }) | Out-Null } } } function Extract-AppSettings($appSettings) { [System.Collections.ArrayList] $data = @() foreach ($setting in $appSettings) { if ($setting.key) { # *.config file $name = $setting.key } else { # Configurations $name = $setting.Name } if ($setting.Value -match '^\w{40}$') { $data.Add(@{ Name = $name; Value = $setting.Value; Type = 'Thumbprint' }) | Out-Null } if ($name -match '(ClientId|Audience)' -and $setting.Value -match '^\w{8}-\w{4}-\w{4}-\w{4}-\w{12}$') { $data.Add(@{ Name = $name; Value = $setting.Value; Type = 'AppId' }) | Out-Null } } return $data } function Extract-BehaviorCerts($configs) { [System.Collections.ArrayList] $data = @() $behaviors = $configs.SelectNodes('//behavior') foreach ($bhv in $behaviors) { $certs = $bhv.SelectNodes('clientCredentials/clientCertificate') foreach ($cert in $certs) { $data.Add(@{ Name = ('Behavior: ' + $bhv.name); Value = $cert.findValue; Type = 'Thumbprint' }) | Out-Null } } return $data } function Populate-AADAppDetails($finalData, $subscription) { # Check if the Azure AD PowerShell module has already been loaded. if ( ! ( Get-Module AzureAD ) ) { # Check if the Azure AD PowerShell module is installed. if ( Get-Module -ListAvailable -Name AzureAD ) { # The Azure AD PowerShell module is not load and it is installed. This module # must be loaded for other operations performed by this script. Write-Host -Fore Yellow "Loading the Azure AD PowerShell module..." Import-Module AzureAD } else { Install-Module AzureAD } } # connect silently $context = Get-AzContext Connect-AzureAD -TenantId $context.Tenant.Id -AccountId $context.Account.Id $subData = $finalData | Where-Object { $_.Subscription -eq $subscription -and $_.Type -eq 'AppId' } $index = 0 foreach ($data in $subData) { $app = Get-AzureADApplication -Filter ("AppId eq '" + $data.Value + "'") | Select-Object -First 1 if ($app) { $data.FriendlyValue = $app.DisplayName foreach($cred in $app.KeyCredentials) { $finalData.Add([PSCustomObject] @{ Type = 'Thumbprint'; Subscription = $data.Subscription; # NOTICE: An thumbprint isn't specific to a subscription, this is just used to locate the cert, and also identify the subscription associated with the parent app registration ResourceGroup = ''; WebApp = ''; Name = ''; Value = [System.Convert]::ToBase64String($cred.CustomKeyIdentifier); FriendlyValue = ''; ExpirationDate = $cred.EndDate; Location = ('AADApp: ' + $app.DisplayName) }) | Out-Null } foreach($cred in $app.PasswordCredentials) { $finalData.Add([PSCustomObject] @{ Type = 'AppSecret'; Subscription = $data.Subscription; # NOTICE: An secret isn't specific to a subscription, this is just used to identify the subscription associated with the parent app registration ResourceGroup = ''; WebApp = ''; Name = ''; Value = '[HIDDEN]'; FriendlyValue = ''; ExpirationDate = $cred.EndDate; Location = ('AADApp: ' + $app.DisplayName) }) | Out-Null } } else { $data.FriendlyValue = '[NOT_FOUND]' } $progress = ++$index * 100 / $subData.Count Write-Progress -Activity 'Scanning app registrations' -Status ('{0}/{1} => {2}' -f $index, $subData.Count, $data.Value) -PercentComplete $progress } } function Populate-CertDetails($finalData, $subscription) { $subData = $finalData | Where-Object { $_.Subscription -eq $subscription -and $_.Type -eq 'Thumbprint' } $certs = Get-AzWebAppCertificate #| Select-Object SubjectName, Thumbprint, ExpirationDate, Location, @{ Name = 'ResourceGroup'; Expression = { $_.Id -match '/resourceGroups/(.+)/providers' | Out-Null; $matches[1] } } foreach ($data in $subData) { $cert = $certs | Where-Object { $_.Thumbprint -eq $data.Value } | Select-Object -First 1 if ($cert) { $data.FriendlyValue = $cert.SubjectName $data.ExpirationDate = $cert.ExpirationDate } } } # End Get-AppIdsNThumbprints # Start Common function Get-ViewFields($array) { if ($array.Count -gt 0) { # remove subscription field from rendering as we will group by subscription $fields = $array[0].PSObject.Properties.Name | Where-Object { -not($_ -eq 'Subscription') } $count = $fields.Count $fields = $fields | Where-Object { -not($_ -eq 'ExpirationDate') } # Refer this article for supported colors: # https://docs.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences#text-formatting # https://stackoverflow.com/questions/20705102/how-to-colorise-powershell-output-of-format-table if ($fields.Count -lt $count) { $fields = $fields + @{ Label = 'ExpirationDate'; Expression = { # NOTICE: All variable references here must be either in this scope or global, but the function scope wouldn't work $days = 30 $now = Get-Date $nDaysAfterNow = $now.AddDays($days) if($_.ExpirationDate -lt $now){ $color = 91 } elseif($_.ExpirationDate -lt $nDaysAfterNow){ $color = 93 } else { $color = 0 } $e = [char]27 "$e[${color}m$($_.ExpirationDate)${e}[0m" } } } $fields } } # End Common if (!(Get-AzContext)) { Connect-AzAccount } # choose subscriptions $subs = Get-AzSubscription | Out-GridView -Title 'Choose subscriptions' -PassThru if ($subs.Count -eq 0) { Write-Host -ForegroundColor Yellow 'Please choose at least 1 subscription' exit } $startTime = Get-Date $previousSub = (Get-AzContext).Subscription.Id if ($uniquelist) { Get-Certs -subscriptions $subs } elseif ($configs) { Get-AppIdsNThumbprints -subscriptions $subs -keywords $keywords } elseif ($keywords) { Search-Certs -subscriptions $subs -keywords $keywords } else { Summarize-Certs -subscriptions $subs } # back to previous subscription Set-AzContext -SubscriptionId $previousSub | Out-Null Write-Host -ForegroundColor Yellow ('Time cost: ' + ((Get-Date) - $startTime)) Write-Host ''