PS script to create HTML Report with vertical tabs

Just a weekend musing.

Tried to create a function which if given array input with columns Title and content, then it creates a vertical tabbed html. Would try to give it better formatting later on

Function New-TabbedHTML {
[CmdletBinding()]
param (
[Parameter(Mandatory=$true)][ValidateScript({
($_ | Get-Member | Where-Object {$_.membertype -eq "NoteProperty"}).count -eq 2 -AND
($_ | Get-member | Where-Object {$_.membertype -eq "Noteproperty"}).Name -contains "Content" -AND
($_ | Get-member | Where-Object {$_.membertype -eq "Noteproperty"}).Name -contains "Title"
})][array]$inputArray,
[Parameter(ValueFromPipeline = $true, mandatory = $false)][ValidateSet('horizontal', 'vertical')][String]$Alignment = "vertical",
[Parameter(Mandatory=$true)][string]$title
)
$hCSS = @"
.tab {
overflow: hidden;
border: 1px solid Aliceblue;
background-color: AliceBlue;
background: linear-gradient(lightblue, blue);
}
.tab button {
background-color: inherit;
float: left;
border: none;
outline: none;
cursor: pointer;
padding: 10px 10px ;
transition: 0.2s;
}
.tab button:hover {
background-color: khaki;
}
.tab button.active {
background-color: seagreen;
}
.tabcontent {
display: none;
padding: 6px 12px;
border: 1px solid lightblue;
border-top: none;
}
<style>
h1 { font-family: Arial, Helvetica, sans-serif; color: navy; font-size: 28px; }
h2 { font-family: Arial, Helvetica, sans-serif; color: midnightblue; font-size: 16px; }
table { font-size: 12px; border: 1px; font-family: Arial, Helvetica, sans-serif; }
td { padding: 4px; margin: 0px; border: 1; }
th { background: #395870; background: linear-gradient(#49708f, #293f50); color: #fff; font-size: 11px; text-transform: uppercase; padding: 10px 15px; vertical-align: middle; }
tbody tr:nth-child(even) { background: aliceblue; }
</style>
"@
$vCSS = @"
.tab {
float: left;
border: 1px solid lightblue;
background-color: AliceBlue;
background: linear-gradient(lightblue, blue);
width: 15%;
height: 1300px;
}
.tab button {
display: block;
background-color: inherit;
color: black;
padding: 22px 16px;
width: 100%;
border: 1px solid lightblue;
outline: none;
text-align: left;
cursor: pointer;
transition: 0.3s;
font-size: 17px;
}
.tab button:hover {
background-color: khaki;
}
.tab button.active {
background-color: seagreen;
}
.tabcontent {
float: left;
padding: 0px 12px;
border: 1px solid lightblue;
width: 85%;
border-left: none;
height: 1300px;
}
<style>
h1 { font-family: Arial, Helvetica, sans-serif; color: navy; font-size: 28px; }
h2 { font-family: Arial, Helvetica, sans-serif; color: midnightblue; font-size: 16px; }
table { font-size: 12px; border: 1px; font-family: Arial, Helvetica, sans-serif; }
td { padding: 4px; margin: 0px; border: 1; }
th { background: #395870; background: linear-gradient(#49708f, #293f50); color: #fff; font-size: 11px; text-transform: uppercase; padding: 10px 15px; vertical-align: middle; }
tbody tr:nth-child(even) { background: aliceblue; }
</style>
"@
$jScript = @"
function openTab(evt, tabName) {
var i, tabcontent, tablinks;
tabcontent = document.getElementsByClassName("tabcontent");
for (i = 0; i < tabcontent.length; i++) {
tabcontent[i].style.display = "none";
}
tablinks = document.getElementsByClassName("tablinks");
for (i = 0; i < tablinks.length; i++) {
tablinks[i].className = tablinks[i].className.replace(" active", "");
}
document.getElementById(tabName).style.display = "block";
evt.currentTarget.className += " active";
}
document.getElementById("defaultOpen").click();
"@
$Content = "<!DOCTYPE html><html><head>"+ "<title>$title</title>" +"<meta name='viewport' content='width=device-width, initial-scale=1'><style>" + "`n" + "* {box-sizing: border-box}" + "`n" + "body {font-family: Arial; background-color: lightblue;}"
if($Alignment -eq 'horizontal'){
$Content += "`n" + $hCSS + "`n" + "</style></head><body>"
} else {
$Content += "`n" + $vCSS + "`n" + "</style></head><body>"
}
$Content += "`n" + "<div class='tab'>" + "`n"
$i = 0
ForEach($row in $inputArray){
if($i -eq 0){ $Content += "<button class='tablinks' onclick=`"openTab(event, '$($row.Title)')`" id='defaultOpen' >$($row.Title)</button>" + "`n" }
else { $Content += "<button class='tablinks' onclick=`"openTab(event, '$($row.Title)')`">$($row.Title)</button>" + "`n" }
$i++
}
$Content += "</div>" + "`n"
ForEach($row in $inputArray){
$Content += "<div id='$($row.Title)' class='tabcontent'><p>$($row.Content)</p></div>" + "`n"
}
$Content += "<script>`n" + $jScript + "`n</script></body></html>"
Return $Content
}
New-TabbedHTML -inputArray (get-service | Select-Object @{l="Title";e={$_.DisplayName}},@{l="Content";e={$_.status}} -first 5) -title "Random" -Alignment horizontal | set-content c:\temp\new.htm
invoke-item c:\temp\new.htm

PS script to perform Entra ID assessment

It’s been a while since I wrote PS script for Active Directory Assessment and the next step was to write one for Entra ID assessment, covering the common questions related to Zero trust and security related settings.

Initially I tried to find out various cmdlets out there to gather the info, but considering the transition mess of old modules to new modules and focus on Graph modules which are intentionally more suitable on developer side than common Entra Administrators, it was becoming tedious and also for gather just small info, I needed to install so many modules. To avoid that I opted for direct API calls than running cmdlet and woah that has been a revelation.

Apart from Microsoft Graph, there are other APIs as well which are in play when you try to gather the data from Entra ID and a little bit of efforts can help you to find out how to authenticate with them. Once you are through that process, rest all pieces start falling in line.

So this was the back story, here goes the actual script

<#
Author : Nitish Kumar (nitish@nitishkumar.net)
Performs Entra ID Assessment
version 1.0 | 17/07/2024 Initial version
version 1.1 | 19/07/2024 Error handling improvements
version 1.2 | 28/07/2024 Application details performance improvements
Disclaimer: This script is designed to only read data from the entra id and should not cause any problems or change configurations but author do not claim to be responsible for any issues. Do due dilligence before running in the production environment
#>
<#
.SYNOPSIS
Get-EntraIDDetails.ps1 – Perform Entra ID assessment and generate a HTML report.
.DESCRIPTION
Script to get important details of Entra ID
.NOTES
This would need a number of permissions, which would involve the Global admin permissions for the first time but all these permissions are READ permissions (except two) and would not make change in curnt configuration. The script is NOT using any POST or PATCH methods with API so it would not change anything in the environment
.LINK
https://nitishkumar.net
.EXAMPLE
.\Get-EntraIDDetails.ps1
#>
# This function creates log entries for the major steps in the script.
function Write-Log {
[CmdletBinding()]
Param(
[Parameter(ValueFromPipeline = $true, mandatory = $true)]$logtext,
[Parameter(ValueFromPipeline = $true, mandatory = $true)]$logpath
)
$Stamp = (Get-Date).toString("yyyy/MM/dd HH:mm:ss")
$LogMessage = "$Stamp : $logtext"
$isWritten = $false
do {
try {
Add-content $logpath -value $LogMessage -Force -ErrorAction SilentlyContinue
$isWritten = $true
}
catch {
}
} until ( $isWritten )
}
# This function creates a balloon notification to display on client computers.
function New-BaloonNotification {
Param(
[Parameter(ValueFromPipeline = $true, mandatory = $true)][String]$title,
[Parameter(ValueFromPipeline = $true, mandatory = $true)][String]$message,
[Parameter(ValueFromPipeline = $true, mandatory = $false)][ValidateSet('None', 'Info', 'Warning', 'Error')][String]$icon = "Info",
[Parameter(ValueFromPipeline = $true, mandatory = $false)][scriptblock]$Script
)
Add-Type -AssemblyName System.Windows.Forms
if ($null -eq $script:balloonToolTip) { $script:balloonToolTip = New-Object System.Windows.Forms.NotifyIcon }
$tip = New-Object System.Windows.Forms.NotifyIcon
$path = Get-Process -id $pid | Select-Object -ExpandProperty Path
$tip.Icon = [System.Drawing.Icon]::ExtractAssociatedIcon($path)
$tip.BalloonTipIcon = $Icon
$tip.BalloonTipText = $message
$tip.BalloonTipTitle = $title
$tip.Visible = $true
try {
register-objectevent $tip BalloonTipClicked BalloonClicked_event -Action { $script.Invoke() } | Out-Null
}
catch {}
$tip.ShowBalloonTip(10000) # Even if we set it for 1000 milliseconds, it usually follows OS minimum 10 seconds
Start-Sleep -seconds 1
$tip.Dispose() # Important to dispose otherwise the icon stays in notifications till reboot
Get-EventSubscriber -SourceIdentifier "BalloonClicked_event" -ErrorAction SilentlyContinue | Unregister-Event # In case if the Event Subscription is not disposed
}
# This function gives user option to opt out from some of the permissions required, report would be reduced as well
function Get-PermSelection {
[CmdletBinding()]
Param(
[Parameter(ValueFromPipeline = $true, mandatory = $true)]$permissions
)
Add-Type -AssemblyName System.Windows.Forms
[System.Windows.Forms.Application]::EnableVisualStyles() # To enable system theme
$OKButton = New-Object System.Windows.Forms.Button -Property @{
Location = New-Object System.Drawing.Point(75, 220)
Size = New-Object System.Drawing.Size(75, 23)
Text = 'OK'
DialogResult = [System.Windows.Forms.DialogResult]::OK
}
$CancelButton = New-Object System.Windows.Forms.Button -Property @{
Location = New-Object System.Drawing.Point(250, 220)
Size = New-Object System.Drawing.Size(75, 23)
Text = 'Cancel'
DialogResult = [System.Windows.Forms.DialogResult]::Cancel
}
$label = New-Object System.Windows.Forms.Label -Property @{
Location = New-Object System.Drawing.Point(10, 20)
Size = New-Object System.Drawing.Size(370, 20)
Text = 'Select the permissions, you wish to allow, all needed for complete report'
}
$listBox = New-Object System.Windows.Forms.Listbox -Property @{
Location = New-Object System.Drawing.Point(10, 50)
Size = New-Object System.Drawing.Size(370, 150)
SelectionMode = 'MultiExtended'
Height = 150
}
[void] $listBox.Items.AddRange($permissions)
$SScreen = New-Object system.Windows.Forms.Form -Property @{
Width = 400
Height = 300
TopMost = $true
StartPosition = 1
FormBorderStyle = 5
BackColor = [System.Drawing.Color]::White
AcceptButton = $OKButton
CancelButton = $CancelButton
}
$SScreen.Controls.AddRange(@($OKButton, $CancelButton, $label, $listBox))
# All permissions are selected by default
for ($i = 0; $i -lt $listBox.Items.Count; $i++) {
$listBox.SetSelected($i, $true)
}
$result = $SScreen.ShowDialog()
if ($result -eq [System.Windows.Forms.DialogResult]::OK) {
return $listBox.SelectedItems
}
else {
return $null
}
}
# Function to parse datetime string with different cultures
function Convert-ToDateTime {
param (
[string[]]$dateStrings
)
# List of cultures to test
$cultures = @('en-US', 'en-GB', 'fr-FR', 'de-DE', 'es-ES', 'en-IN')
$results = @()
if (-Not $dateStrings) {
return $null
}
foreach ($dateString in $dateStrings) {
if ([string]::IsNullOrEmpty($dateString)) {
$results += $null
continue
}
$parsed = $null
foreach ($culture in $cultures) {
try {
$cultureInfo = [System.Globalization.CultureInfo]::GetCultureInfo($culture)
$parsed = [datetime]::Parse($dateString, $cultureInfo)
break
}
catch {
# Continue to the next culture if parsing fails
continue
}
}
if (-NOT $parsed) {
throw "Unable to parse date string: $dateString"
}
$results += $parsed.ToString("dd-MM-yyyy HH:mm:ss")
}
return $results
}
function Get-SensitiveApps {
[CmdletBinding()]
Param(
[Parameter(ValueFromPipeline = $true, mandatory = $false)][array]$Sensitivepermissions = ("User.Read.All", "User.ReadWrite.All", "Mail.ReadWrite", "Files.ReadWrite.All", "Calendars.ReadWrite", "Mail.Send", "User.Export.All", "Directory.Read.All", "Exchange.ManageAsApp", "Directory.ReadWrite.All", "Sites.ReadWrite.All", "Application.ReadWrite.All", "Group.ReadWrite.All", "ServicePrincipalEndPoint.ReadWrite.All", "GroupMember.ReadWrite.All", "RoleManagement.ReadWrite.Directory", "AppRoleAssignment.ReadWrite.All")
)
# Populate a set of hash tables with permissions used for different Office 365 management functions
$GraphApp = (invoke-MgGraphRequest -uri "https://graph.microsoft.com/v1.0/serviceprincipals?`$filter=appid eq '00000003-0000-0000-c000-000000000000'&`$select=appid,AppRoles").value
$GraphRoles = @{}
ForEach ($Role in $GraphApp.AppRoles) { $GraphRoles.Add([string]$Role.Id, [string]$Role.Value) }
$ExoPermissions = @{}
$ExoApp = (invoke-MgGraphRequest -uri "https://graph.microsoft.com/v1.0/serviceprincipals?`$filter=appid eq '00000002-0000-0ff1-ce00-000000000000'&`$select=appid,AppRoles").value
ForEach ($Role in $ExoApp.AppRoles) { $ExoPermissions.Add([string]$Role.Id, [string]$Role.Value) }
$O365Permissions = @{}
$O365API = (invoke-MgGraphRequest -uri "https://graph.microsoft.com/v1.0/serviceprincipals?`$filter=DisplayName eq 'Office 365 Management APIs'&`$select=appid,AppRoles").value
ForEach ($Role in $O365API.AppRoles) { $O365Permissions.Add([string]$Role.Id, [string]$Role.Value) }
$AzureADPermissions = @{}
$AzureAD = (invoke-MgGraphRequest -uri "https://graph.microsoft.com/v1.0/serviceprincipals?`$filter=DisplayName eq 'Windows Azure Active Directory'&`$select=appid,AppRoles").value
ForEach ($Role in $AzureAD.AppRoles) { $AzureADPermissions.Add([string]$Role.Id, [string]$Role.Value) }
$TeamsPermissions = @{}
$TeamsApp = (invoke-MgGraphRequest -uri "https://graph.microsoft.com/v1.0/serviceprincipals?`$filter=DisplayName eq 'Skype and Teams Tenant Admin API'&`$select=appid,AppRoles").value
ForEach ($Role in $TeamsApp.AppRoles) { $TeamsPermissions.Add([string]$Role.Id, [string]$Role.Value) }
$RightsManagementPermissions = @{}
$RightsManagementApp = (invoke-MgGraphRequest -uri "https://graph.microsoft.com/v1.0/serviceprincipals?`$filter=DisplayName eq 'Microsoft Rights Management Services'&`$select=appid,AppRoles").value
ForEach ($Role in $RightsManagementApp.AppRoles) { $RightsManagementPermissions.Add([string]$Role.Id, [string]$Role.Value) }
$Appdetails = @()
$sps = @()
$managedidentities = @()
$appcreds = @()
$approles = @()
$Sensitivepermissions = ("User.Read.All", "User.ReadWrite.All", "Mail.ReadWrite", "Files.ReadWrite.All", "Calendars.ReadWrite", "Mail.Send", "User.Export.All", "Directory.Read.All", "Exchange.ManageAsApp", "Directory.ReadWrite.All", "Sites.ReadWrite.All", "Application.ReadWrite.All", "Group.ReadWrite.All", "ServicePrincipalEndPoint.ReadWrite.All", "GroupMember.ReadWrite.All", "RoleManagement.ReadWrite.Directory", "AppRoleAssignment.ReadWrite.All")
$uri = "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=tags/any(t:t+eq+'WindowsAzureActiveDirectoryIntegratedApp')&`$top=999&`$select=id,appid,displayname,createdDateTime,accountEnabled,servicePrincipalType,signInAudience,appRoleAssignmentRequired,appOwnerOrganizationId"
do {
$response = Invoke-MgGraphRequest -Uri $uri
$apps = $response.value
$SPs += $apps
$uri = $response.'@odata.nextLink'
} while ($uri)
$Uri = "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=ServicePrincipalType eq 'ManagedIdentity'&`$top=999&`$select=id,appid,displayname,createdDateTime,accountEnabled,servicePrincipalType,signInAudience,appRoleAssignmentRequired,appOwnerOrganizationId"
do {
$response = Invoke-MgGraphRequest -Uri $uri
$apps = $response.value
$managedidentities += $apps
$uri = $response.'@odata.nextLink'
} while ($uri)
$AllApps = $SPs + $managedidentities
$Uri = "https://graph.microsoft.com/v1.0/applications?`$select=appid,passwordCredentials,keycredentials&`$top=999"
do {
$response = Invoke-MgGraphRequest -Uri $uri
$apps = $response.value
$appcreds += $apps
$uri = $response.'@odata.nextLink'
} while ($uri)
$Uri = "https://graph.microsoft.com/v1.0/serviceprincipals?`$top=999&`$expand=appRoleAssignments&`$select=appId,appRoleAssignments"
do {
$response = Invoke-MgGraphRequest -Uri $uri
$apps = $response.value
$approles += $apps
$uri = $response.'@odata.nextLink'
} while ($uri)
$i = 0
$count = $AllApps.count
ForEach ($app in $AllApps) {
$i++
Write-Progress -Activity "Processing $($app.displayName)" -Status "$i of $count completed" -PercentComplete ($i * 100 / $count)
$Roles = $null
$Roles = $approles | Where-Object { $_.appid -eq $app.appid }
[array]$Permission = $Null
$spermissions = $null
if (($Roles.count) -gt 0) {
ForEach ($Approle in $Roles.appRoleAssignments) {
Switch ($AppRole.ResourceDisplayName) {
"Microsoft Graph" {
$Permission += $GraphRoles[$AppRole.AppRoleId]
}
"Office 365 Exchange Online" {
$Permission += $ExoPermissions[$AppRole.AppRoleId]
}
"Office 365 Management APIs" {
$Permission += $O365Permissions[$AppRole.AppRoleId]
}
"Windows Azure Active Directory" {
$Permission += $AzureADPermissions[$AppRole.AppRoleId]
}
"Skype and Teams Tenant Admin API" {
$Permission += $TeamsPermissions[$AppRole.AppRoleId]
}
"Microsoft Rights Management Services" {
$Permission += $RightsManagementPermissions[$AppRole.AppRoleId]
}
}
}
if ($Permission) {
$spermissions = (compare-object -ReferenceObject ($Permission | Where-Object { $_ }) -DifferenceObject $Sensitivepermissions -IncludeEqual | Where-Object { $_.SideIndicator -eq "==" }).inputobject
}
}
$secrets = @()
$secrets = $appcreds | Where-Object { $_.appid -eq $app.appid }
$passwords = $secrets.passwordcredentials | ForEach-Object { [pscustomobject]@{displayname = $_.displayname; startdatetime = $_.startdatetime; enddatetime = $_.enddatetime } }
$certs = $secrets.keycredentials | ForEach-Object { [pscustomobject]@{displayname = $_.displayname; startdatetime = $_.startdatetime; enddatetime = $_.enddatetime; usage = $_.usage; type = $_.type; customKeyIdentifier = $_.customKeyIdentifier } }
$temp = [pscustomobject]@{
id = $app.id
displayName = $app.displayName
createdDateTime = $app.createdDateTime
enabled = $app.accountEnabled
servicePrincipalType = $app.servicePrincipalType
permissions = $permission -join "`n"
sensitivepermissions = $spermissions -join "`n"
secretdisplayname = $passwords.displayname -join "`n"
secretstartdate = (Convert-ToDateTime -dateStrings $passwords.startdatetime) -join "`n"
secretenddate = (Convert-ToDateTime -dateStrings $passwords.enddatetime) -join "`n"
certdisplayname = $certs.displayname -join "`n"
certthumbprint = $certs.customKeyIdentifier -join "`n"
certstartdate = (Convert-ToDateTime -dateStrings $certs.startdatetime) -join "`n"
certenddate = (Convert-ToDateTime -dateStrings $certs.enddatetime) -join "`n"
certusage = $certs.usage -join "`n"
certtype = $certs.type -join "`n"
signInAudience = $app.signInAudience
appRoleAssignmentRequired = $app.appRoleAssignmentRequired
appOwnerOrganizationId = $app.appOwnerOrganizationId
}
$Appdetails += $temp
}
return $Appdetails
}
$logpath = "c:\temp\EntraIDDReport_$(get-date -Uformat "%Y%m%d-%H%M%S").txt"
#Import PowerShell Module, install if not already installed
if (get-module -List Az.Accounts) {
Import-Module Az.Accounts
}
Else {
Write-Output "Installing the module Az.Accounts as current user scope"
try {
Set-PSRepository PSGallery -InstallationPolicy Trusted
Install-Module -Name Az.Accounts -Scope CurrentUser -Confirm:$False -Force
}
catch {
Write-Output "Could not load the necessary module Az.Accounts, so can not proceed."
exit
}
}
if (get-module -List Microsoft.Graph.Authentication) {
Import-Module Microsoft.Graph.Authentication
}
Else {
Write-Output "Installing the module Microsoft.Graph.Authentication as current user scope"
try {
Set-PSRepository PSGallery -InstallationPolicy Trusted
Install-Module -Name Microsoft.graph.authentication -Scope CurrentUser -Confirm:$False -Force
}
catch {
Write-Output "Could not load the necessary module Microsoft.Graph.Authentication, so can not proceed."
exit
}
}
$message = "Modules check done"
Write-Log -logtext $message -logpath $logpath
New-BaloonNotification -title "Information" -message $message
# Output formating options
$logopath = "https://raw.githubusercontent.com/laymanstake/laymanstake/master/images/logo.png&quot;
$ReportPath = "c:\temp\EntraIDReport_$(get-date -Uformat "%Y%m%d-%H%M%S").html"
$CopyRightInfo = " @Copyright Nitish Kumar <a href='https://github.com/laymanstake'>Visit nitishkumar.net</a>"
# CSS codes to format the report
$header = @"
<style>
body { background-color: #D3D3D3; }
h1 { font-family: Arial, Helvetica, sans-serif; color: #e68a00; font-size: 28px; }
h2 { font-family: Arial, Helvetica, sans-serif; color: #000099; font-size: 16px; }
table { font-size: 12px; border: 1px; font-family: Arial, Helvetica, sans-serif; }
td { padding: 4px; margin: 0px; border: 1; }
th { background: #395870; background: linear-gradient(#49708f, #293f50); color: #fff; font-size: 11px; text-transform: uppercase; padding: 10px 15px; vertical-align: middle; }
tbody tr:nth-child(even) { background: #f0f0f2; }
CreationDate { font-family: Arial, Helvetica, sans-serif; color: #ff3300; font-size: 12px; }
</style>
"@
If ($logopath) {
$header = $header + "<img src=$logopath alt='Company logo' width='150' height='150' align='right'>"
}
<#
# Need to run below if you wish remove ALL user consented delegated permissions from the Microsoft Graph Command Line Tools enterprise application
connect-mggraph -Scopes Directory.ReadWrite.All
$PrincipalId = (invoke-mggraphrequest -uri "https://graph.microsoft.com/v1.0/me?`$select=id").id
$sp = (invoke-mggraphrequest -uri "https://graph.microsoft.com/v1.0/servicePrincipals?`$search=`"displayName:Microsoft Graph Command Line Tools`"`&`$select=id,displayName" -Headers @{ "ConsistencyLevel" = "eventual" }).value
$oAuthgrants = (Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants?`$filter=clientid eq '$($sp.id)'and PrincipalId eq '$($PrincipalId)'").value
invoke-mggraphrequest -method DELETE -uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants/$($oAuthgrants.id)&quot;
#>
$requiredscopes = @(
"IdentityProvider.Read.All", # Required for reading configured identity providers
"Directory.Read.All", # Required for reading licenses, organization settings, roles
"OnPremDirectorySynchronization.Read.All", # Required for on-prem directory synchronization settings
"Application.Read.All", # Required for reading enabled directory extensions
"RoleManagement.Read.All" # Required for reading Piviledged and RBAC roles
"AccessReview.Read.All", # Required for reading access review settings
"Policy.Read.All", # Required for reading conditional access policy details
"SecurityEvents.Read.All", # Required for reading Identity security score details
"Directory.ReadWrite.All", # Required for reading Pass Through authenication agent details
"Policy.ReadWrite.AuthenticationMethod" # Required for reading authentication method details
) # Enterprise Application named Microsoft Graph Command Line Tools would be granted delegated permissions
$message = "opt out screen though all permissions are required for full report"
Write-Log -logtext $message -logpath $logpath
$selectscopes = Get-PermSelection -permissions @('IdentityProvider.Read.All', 'Directory.Read.All', 'OnPremDirectorySynchronization.Read.All', 'Application.Read.All', 'RoleManagement.Read.All', 'AccessReview.Read.All', 'Policy.Read.All', 'SecurityEvents.Read.All', 'Directory.ReadWrite.All', 'Policy.ReadWrite.AuthenticationMethod')
if ($selectscopes) {
$requiredscopes = $selectscopes
}
if (Get-MgContext) {
# Disconnect current connection before starting
try {
$null = Disconnect-MGGraph
Connect-MGGraph -NoWelcome -scopes $requiredscopes -ErrorAction Stop
}
catch {
$message = "MS Graph login: " + $error[0].exception.message + " : " + ($error[0].errordetails.message -split "`n")[0]
Write-Log -logtext $message -logpath $logpath
Write-Output "Unable to login to Graph Command Line Tools 1"
}
}
else {
# Connect with tenant if no existing connection
try {
Write-Host "No starting connection"
Connect-MGGraph -NoWelcome -scopes $requiredscopes -ErrorAction Stop
}
catch {
$message = "MS Graph login: " + $error[0].exception.message + " : " + ($error[0].errordetails.message -split "`n")[0]
Write-Log -logtext $message -logpath $logpath
Write-Output "Unable to login to Graph Command Line Tools"
exit
}
}
$ConnectionDetail = Get-MgContext | Select-Object Account, TenantId, Environment, Scopes
$message = "Microsoft Graph connection done"
Write-Log -logtext $message -logpath $logpath
New-BaloonNotification -title "Information" -message $message
$message = "Connecting to Az module"
Write-Log -logtext $message -logpath $logpath
New-BaloonNotification -title "Information" -message $message
$null = Disconnect-AzAccount -InformationAction Ignore -WarningAction Ignore
if (Get-AzAccessToken -ErrorAction:SilentlyContinue -WarningAction:SilentlyContinue) {
try {
$null = Disconnect-AzAccount
$null = Connect-AzAccount -AccountId $ConnectionDetail.Account -TenantId $ConnectionDetail.TenantId -Scope CurrentUser -ErrorAction Stop -WarningAction Ignore -InformationAction Ignore *>&1
}
catch {
$message = $Error[0].exception.message
Write-Log -logtext $message -logpath $logpath
Write-Output "Unable to login to Az Accounts"
}
}
else {
try {
$null = Connect-AzAccount -AccountId $ConnectionDetail.Account -TenantId $ConnectionDetail.TenantId -Scope CurrentUser -ErrorAction Stop -WarningAction Ignore -InformationAction Ignore *>&1
}
catch {
$message = $Error[0].exception.message
Write-Log -logtext $message -logpath $logpath
Write-Output "Unable to login to Az Accounts"
}
}
# Workaround to hit undocumented api
$resource = '74658136-14ec-4630-ad9b-26e160ff0fc6'
# Keeping Az token for using later on
$null = Update-AzConfig -DisplayBreakingChangeWarning $false
if ((get-module -List Az.Accounts).version.major -ge 3) {
$encryptedToken = (Get-AzAccessToken -AsSecureString -ErrorAction Stop).token
$azToken = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto([System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($encryptedToken))
$encryptedtoken1 = (Get-AzAccessToken -ResourceUrl $resource -TenantId $ConnectionDetail.TenantId -AsSecureString -ErrorAction Stop).token
$Token1 = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto([System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($encryptedtoken1))
}
else {
$azToken = (Get-AzAccessToken -ErrorAction Stop).token
$token1 = (Get-AzAccessToken -ResourceUrl $resource -TenantId $ConnectionDetail.TenantId).token
}
$message = "Connection to Az module completed."
Write-Log -logtext $message -logpath $logpath
$portaltoken = (Get-AzAccessToken -ResourceUrl "https://admin.microsoft.com&quot; -TenantId $ConnectionDetail.TenantId).token
$portalheaders = @{
"Authorization" = "Bearer $($portaltoken)"
"Content-Type" = "application/json"
}
try {
$SpeechEnabled = Invoke-RestMethod 'https://admin.microsoft.com/admin/api/services/apps/azurespeechservices&#39; -Method GET -Headers $portalheaders
}
catch {
$message = "Azure speech details: " + $error[0].exception.message + " : " + ($error[0].errordetails.message -split "`n")[0]
Write-Log -logtext $message -logpath $logpath
}
try {
$BasicAuthDetails = Invoke-RestMethod 'https://admin.microsoft.com/admin/api/services/apps/modernAuth&#39; -Method GET -Headers $portalheaders
}
catch {
$message = "Basic/ Modern Auth details: " + $error[0].exception.message + " : " + ($error[0].errordetails.message -split "`n")[0]
Write-Log -logtext $message -logpath $logpath
}
if ($ConnectionDetail.scopes -contains "Directory.Read.All" -OR $ConnectionDetail.scopes -contains "Directory.ReadWrite.All") {
try {
$ServicePlans = ((Invoke-mgGraphRequest -Uri "https://graph.microsoft.com/v1.0/subscribedSkus?`$select=skuPartNumber,skuId,prepaidUnits,consumedUnits,servicePlans").value | Where-Object { $_.ServicePlans.ProvisioningStatus -eq "Success" }).ServicePlans.ServicePlanName
}
catch {
$message = "Service Plan details: " + $error[0].exception.message + " : " + ($error[0].errordetails.message -split "`n")[0]
Write-Log -logtext $message -logpath $logpath
}
If ($ServicePlans -contains "AAD_Premium_P2") {
$EntraLicense = "Entra ID P2"
}
elseif ($ServicePlans -contains "AAD_Premium") {
$EntraLicense = "Entra ID P1"
}
else {
$EntraLicense = "Entra ID Free"
}
}
# Get app ID for Entra ID Connected registered app
if ($ConnectionDetail.scopes -contains "Directory.Read.All" -OR $ConnectionDetail.scopes -contains "Directory.ReadWrite.All" -OR $ConnectionDetail.scopes -contains "Application.Read.All") {
try {
$app = ((Invoke-MgGraphRequest -uri "https://graph.microsoft.com/v1.0/applications?`$select=id,appid,displayName").value | Where-Object { $_.displayName -eq "Tenant Schema Extension App" }) | ForEach-Object { [pscustomobject]@{id = $_.id; appid = $_.appid } }
}
catch {
$message = "Directory Extensions Details: " + $error[0].exception.message + " : " + ($error[0].errordetails.message -split "`n")[0]
Write-Log -logtext $message -logpath $logpath
}
if ($app) {
try {
$DirectoryExtensions = (invoke-mggraphrequest -uri "https://graph.microsoft.com/v1.0/applications/$($app.id)/extensionProperties?`$select=name").value.name | ForEach-Object { $_.replace("extension_" + $app.appid.replace("-", "") + "_", "") }
$message = "Directory extensions identified: $($DirectoryExtensions -join ",")"
Write-Log -logtext $message -logpath $logpath
}
catch {
$message = "Directory Extensions Details: " + $error[0].exception.message + " : " + ($error[0].errordetails.message -split "`n")[0]
Write-Log -logtext $message -logpath $logpath
}
}
}
# On-Premise configuration
if ($ConnectionDetail.scopes -contains "OnPremDirectorySynchronization.Read.All") {
try {
$OnPremConfigDetails = (Invoke-mgGraphRequest -Uri "https://graph.microsoft.com/v1.0/directory/onPremisesSynchronization?`$select=features").value.features | ForEach-Object { [pscustomobject]@{PasswordHashSync = $_.passwordSyncEnabled; passwordWritebackEnabled = $_.passwordWritebackEnabled; cloudPasswordPolicyForPasswordSyncedUsersEnabled = $_.cloudPasswordPolicyForPasswordSyncedUsersEnabled; userWritebackEnabled = $_.userWritebackEnabled; groupWriteBackEnabled = $_.groupWriteBackEnabled; deviceWritebackEnabled = $_.deviceWritebackEnabled; unifiedGroupWritebackEnabled = $_.unifiedGroupWritebackEnabled; directoryExtensionsEnabled = $_.directoryExtensionsEnabled; synchronizeUpnForManagedUsersEnabled = $_.synchronizeUpnForManagedUsersEnabled } }
}
catch {
$message = "Onprem config Details: " + $error[0].exception.message + " : " + ($error[0].errordetails.message -split "`n")[0]
Write-Log -logtext $message -logpath $logpath
}
#$PHSEnabled = $OnPremConfigDetails.PasswordHashSync
}
# Pass through authentication details
if ($ConnectionDetail.scopes -contains "Directory.ReadWrite.All") {
try {
$PTAAgentDetail = (Invoke-mgGraphRequest -Uri "https://graph.microsoft.com/beta/onPremisesPublishingProfiles/authentication/agentGroups?`$expand=agents").value.Agents | ForEach-Object { [PSCustomObject]@{machinename = $_.machinename; externalIp = $_.externalIp; status = $_.status; supportedPublishingTypes = $_.supportedPublishingTypes -join "," } }
}
catch {
$message = "PTA Agent Details: " + $error[0].exception.message + " : " + ($error[0].errordetails.message -split "`n")[0]
Write-Log -logtext $message -logpath $logpath
}
$PTAEnabled = $PTAAgentDetail.machinename.count -ge 1
}
if ($ConnectionDetail.scopes -contains "IdentityProvider.Read.All") {
try {
$IdentityProviders = (invoke-MgGraphRequest -uri "https://graph.microsoft.com/v1.0/identityProviders?`$select=name").value.values -join ","
}
catch {
$message = "Identity Details: " + $error[0].exception.message + " : " + ($error[0].errordetails.message -split "`n")[0]
Write-Log -logtext $message -logpath $logpath
}
}
if ($ConnectionDetail.scopes -contains "Policy.Read.All") {
try {
$SecurityDefaults = (Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/beta/policies/identitySecurityDefaultsEnforcementPolicy&quot;)["isEnabled"]
}
catch {
$message = "Security defaults: " + $error[0].exception.message + " : " + ($error[0].errordetails.message -split "`n")[0]
Write-Log -logtext $message -logpath $logpath
}
}
try {
$tenantsetting = Invoke-RestMethod 'https://main.iam.ad.ext.azure.com/api/Directories/Properties&#39; -Headers @{Authorization = "Bearer $($token1)"; "x-ms-client-request-id" = [guid]::NewGuid().ToString(); "x-ms-client-session-id" = [guid]::NewGuid().ToString() } | Select-Object @{l = "AdminPortalAccess"; e = { if ($_.restrictNonAdminUsers) { "restrictNonAdminUsers" } else { "allusersallowed" } } }, @{l = "LinkedInEnabled"; e = { switch ($_.enableLinkedInAppFamily) { 1: { "False" }; 0 { "EnabledforAll" }; 4 { "SelectGroupOnly" } } } }
}
catch {
$message = "Tenant additional details: " + $error[0].exception.message + " : " + ($error[0].errordetails.message -split "`n")[0]
Write-Log -logtext $message -logpath $logpath
}
try {
$ptasss = Invoke-RestMethod 'https://main.iam.ad.ext.azure.com/api/Directories/ADConnectStatus&#39; -Headers @{Authorization = "Bearer $($token1)"; "x-ms-client-request-id" = [guid]::NewGuid().ToString(); "x-ms-client-session-id" = [guid]::NewGuid().ToString() } | Select-Object @{l = "PTA"; e = { $_.passThroughAuthenticationEnabled } }, @{l = "seamlessSingleSign"; e = { $_.seamlessSingleSignOnEnabled } }
$phs = Invoke-RestMethod 'https://main.iam.ad.ext.azure.com/api/Directories/GetPasswordSyncStatus&#39; -Headers @{Authorization = "Bearer $($token1)"; "x-ms-client-request-id" = [guid]::NewGuid().ToString(); "x-ms-client-session-id" = [guid]::NewGuid().ToString() }
}
catch {
$message = "PTA/PHS/Seamless Signon details: " + $error[0].exception.message + " : " + ($error[0].errordetails.message -split "`n")[0]
Write-Log -logtext $message -logpath $logpath
}
if ($ConnectionDetail.scopes -contains "Directory.Read.All" -OR $ConnectionDetail.scopes -contains "Directory.ReadWrite.All") {
try {
$TenantBasicDetail = (Invoke-mgGraphRequest -Uri "https://graph.microsoft.com/v1.0/organization&quot;).value | ForEach-Object { [pscustomobject]@{DisplayName = $_.displayName; createdDateTime = $_.createdDateTime; countryLetterCode = $_.countryLetterCode; TenantID = $_.Id; OnPremisesSyncEnabled = $_.OnPremisesSyncEnabled; OnPremisesLastSyncDateTime = $_.OnPremisesLastSyncDateTime; TenantType = $_.TenantType; EntraID = $EntraLicense; Domain = (($_.VerifiedDomains | Where-Object { $_.Name -notlike "*.Onmicrosoft.com" }) | ForEach-Object { "$($_.Type):$($_.Name)" } ) -join "`n"; SecurityDefaults = $SecurityDefaults ; PTAEnbled = $ptasss.pta; PHSEnabled = $phs; SeamlessSignOn = $ptasss.seamlessSingleSign; passwordWritebackEnabled = $OnPremConfigDetails.passwordWritebackEnabled; DirectoryExtensions = ($DirectoryExtensions -join ","); groupWriteBackEnabled = $OnPremConfigDetails.groupWriteBackEnabled; IdentityProviders = $IdentityProviders; cloudPasswordPolicyForPasswordSyncedUsersEnabled = $OnPremConfigDetails.cloudPasswordPolicyForPasswordSyncedUsersEnabled; AdminPortalAccess = $tenantsetting.AdminPortalAccess ; LinkedInEnabled = $tenantsetting.LinkedInEnabled ; SpeechEnabled = $SpeechEnabled.isTenantEnabled } }
$message = "Tenant basic details done"
Write-Log -logtext $message -logpath $logpath
}
catch {
$message = "Tenant basic Details: " + $error[0].exception.message + " : " + ($error[0].errordetails.message -split "`n")[0]
Write-Log -logtext $message -logpath $logpath
}
}
# Find latest available Entra ID connect version
try {
$VersionHistory = Invoke-RestMethod "https://raw.githubusercontent.com/MicrosoftDocs/entra-docs/main/docs/identity/hybrid/connect/reference-connect-version-history.md&quot;
}
catch {
$message = $error[0].exception.message
Write-Log -logtext $message -logpath $logpath
}
$LatestVersion = $VersionHistory -split "`n" | Where-Object { $_ -match "^## [0-9]+\.[0-9]+\.[0-9]+\.[0-9]+" } | ForEach-Object { $_ -replace "## " } | Sort-Object | Select-Object -Last 1
if ($LatestVersion -notmatch "^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$") {
Write-Output "Unable to determine latest version of Azure AD Connect"
}
$LatestVersion = $LatestVersion.ToString()
$message = "Latest version for Entra ID connect found from GitHub as $LatestVersion."
Write-Log -logtext $message -logpath $logpath
# Check if the Azure API to for Entra ID connect health accessible
try {
$PremiumCheck = Invoke-RestMethod -Uri 'https://management.azure.com/providers/Microsoft.ADHybridHealthService/services/GetServices/PremiumCheck?serviceType=AadSyncService&skipCount=0&takeCount=50&api-version=2014-01-01&#39; -Headers @{'Authorization' = "Bearer $azToken" }
}
catch {
$message = "API accessibility: " + $error[0].exception.message + " : " + ($error[0].errordetails.message -split "`n")[0]
Write-Log -logtext $message -logpath $logpath
}
if ($PremiumCheck.PSObject.Properties.Count -ge 1) {
try {
$EntraIDConnectDetails = (Invoke-RestMethod -Uri "https://management.azure.com/providers/Microsoft.ADHybridHealthService/services/$($PremiumCheck.value%5B0%5D.serviceName)/servicemembers?api-version=2014-01-01&quot; -Headers @{'Authorization' = "Bearer $azToken" }).value | ForEach-Object { [pscustomobject]@{machinename = $_.machinename; Enabled = -Not($_.disabled); version = (Invoke-RestMethod -Uri "https://management.azure.com/providers/Microsoft.ADHybridHealthService/services/$($PremiumCheck.value%5B0%5D.serviceName)/servicemembers/$($_.serviceMemberId)/serviceconfiguration?api-version=2014-01-01&quot; -Headers @{'Authorization' = "Bearer $azToken" }).version; LatestVersionAvailable = $LatestVersion; staging = ($_.monitoringConfigurationsComputed | Where-Object { $_.key -eq "StagingMode" }).value; createdDate = [DateTime]::Parse($_.createdDate).ToString("yyyy-MM-dd HH:mm:ss"); lastReboot = [DateTime]::Parse($_.lastreboot).ToString("yyyy-MM-dd HH:mm:ss"); OsName = $_.Osname } }
$message = "Entra ID connect servers found: $(if($EntraIDConnectDetails){$EntraIDConnectDetails.machinename -join ","})."
Write-Log -logtext $message -logpath $logpath
New-BaloonNotification -title "Information" -message $message
}
catch {
$message = "Entra ID connect Details: " + $error[0].exception.message + " : " + ($error[0].errordetails.message -split "`n")[0]
Write-Log -logtext $message -logpath $logpath
}
}
if ($EntraLicense -ne "Entra ID Free") {
# Password protection details
if ($ConnectionDetail.scopes -contains "Directory.Read.All" -OR $ConnectionDetail.scopes -contains "Directory.ReadWrite.All") {
$PasswordProtectionDetails = [PSCustomObject]@{}
try {
((Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/groupSettings&quot;).value | Where-Object { $_.displayName -eq "Password Rule Settings" }).values | Where-Object { $_ } | ForEach-Object { $PasswordProtectionDetails | Add-Member -NotePropertyName $_.Name -NotePropertyValue (($_.value -split "\t") -join "`n") }
$message = "Entra ID password protection details done."
Write-Log -logtext $message -logpath $logpath
}
catch {
$message = "Entra ID password protection Details: " + $error[0].exception.message + " : " + ($error[0].errordetails.message -split "`n")[0]
Write-Log -logtext $message -logpath $logpath
}
}
}
if ($ConnectionDetail.scopes -contains "Policy.ReadWrite.AuthenticationMethod") {
try {
$EnabledAuthMethods = (Invoke-mgGraphRequest -Uri "https://graph.microsoft.com/v1.0/policies/authenticationMethodsPolicy?`$select=authenticationMethodConfigurations").authenticationMethodConfigurations | ForEach-Object { [pscustomobject]@{AuthMethodType = $_.Id; State = $_.state } }
}
catch {
$message = "Entra ID enabled auth methods Details: " + $error[0].exception.message + " : " + ($error[0].errordetails.message -split "`n")[0]
Write-Log -logtext $message -logpath $logpath
}
}
if ($ConnectionDetail.scopes -contains "Directory.Read.All" -OR $ConnectionDetail.scopes -contains "Directory.ReadWrite.All" -OR $ConnectionDetail.scopes -contains "RoleManagement.Read.All") {
$MonitoredPriviledgedRoles = ("Global Administrator", "Global Reader", "Security Administrator", "Privileged Authentication Administrator", "User Administrator")
try {
$ActivatedRoles = (Invoke-mgGraphRequest -Uri "https://graph.microsoft.com/v1.0/directoryRoles?`$select=id,displayName").value | ForEach-Object { [pscustomobject]@{Id = $_.Id; DisplayName = $_.displayName } }
}
catch {
$message = "Entra ID activated role Details: " + $error[0].exception.message + " : " + ($error[0].errordetails.message -split "`n")[0]
Write-Log -logtext $message -logpath $logpath
}
$RoleDetail = ForEach ($privilegedRole in $MonitoredPriviledgedRoles) {
$RoleID = ($ActivatedRoles | Where-Object { $_.DisplayName -eq $privilegedRole }).Id
If ($privilegedRole -in $ActivatedRoles.DisplayName) {
$name = $privilegedRole
try {
$Count = (Invoke-mgGraphRequest -Uri "https://graph.microsoft.com/v1.0/directoryRoles/$RoleID/members&quot; -Headers @{ "ConsistencyLevel" = "eventual" }).value.displayname.count
}
catch {
$message = "Entra ID priviledged role Details: " + $error[0].exception.message + " : " + ($error[0].errordetails.message -split "`n")[0]
Write-Log -logtext $message -logpath $logpath
}
}
else {
$name = $privilegedRole
$count = "Role not activated"
}
[PSCustomObject]@{
Name = $Name
Count = $Count
}
}
$message = "Entra ID admin roles details done."
Write-Log -logtext $message -logpath $logpath
# RBAC roles details
try {
$Roles = ((Invoke-mggraphRequest -Uri "https://graph.microsoft.com/v1.0/roleManagement/directory/roleDefinitions?`$select=id,isBuiltIn,displayName,isEnabled,rolePermissions").value | ForEach-Object { [pscustomobject]@{id = $_.id; isBuiltIn = $_.isBuiltIn; displayName = $_.displayName; Enabled = $_.isEnabled; rolePermissions = ($_.rolePermissions.allowedResourceActions -join "`n") } })
$RBACRoles = $Roles | Where-Object { $_.isBuiltIn -eq $false }
$message = "Entra ID RBAC roles details done."
Write-Log -logtext $message -logpath $logpath
}
catch {
$message = "Entra ID RBAC role Details: " + $error[0].exception.message + " : " + ($error[0].errordetails.message -split "`n")[0]
Write-Log -logtext $message -logpath $logpath
}
}
if ($EntraLicense -ne "Entra ID Free" -AND $ConnectionDetail.scopes -contains "RoleManagement.Read.All") {
# PIM Roles
try {
$ActivePIMAssignments = (invoke-mggraphRequest -Uri "https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignmentSchedules?`$expand=principal").value | ForEach-Object { $roledef = $_.RoleDefinitionId; [pscustomobject]@{RoleName = ($Roles | Where-Object { $_.id -eq $roledef }).displayName; PrincipalName = $_.Principal.displayName; PrincipalType = ($_.Principal."@odata.type").replace("`#microsoft.graph.", ""); state = $_.assignmenttype; membership = $_.memberType; StartTime = $_.scheduleInfo.StartDateTime; EndTime = $_.scheduleInfo.expiration.enddatetime; type = $_.scheduleInfo.expiration.type; directoryScopeId = $_.directoryScopeId } }
}
catch {
$message = "Entra ID active PIM assignment Details: " + $error[0].exception.message + " : " + ($error[0].errordetails.message -split "`n")[0]
Write-Log -logtext $message -logpath $logpath
}
try {
$ElligiblePIMAssignments = (invoke-mggraphRequest -Uri "https://graph.microsoft.com/v1.0/roleManagement/directory/roleEligibilitySchedules?`$expand=principal").value | ForEach-Object { $roledef = $_.RoleDefinitionId; [pscustomobject]@{RoleName = ($Roles | Where-Object { $_.id -eq $roledef }).displayName; PrincipalName = $_.Principal.displayName; PrincipalType = ($_.Principal."@odata.type").replace("`#microsoft.graph.", ""); state = $_.assignmenttype; membership = $_.memberType; StartTime = $_.scheduleInfo.StartDateTime; EndTime = $_.scheduleInfo.expiration.enddatetime; type = $_.scheduleInfo.expiration.type; directoryScopeId = $_.directoryScopeId } }
}
catch {
$message = "Entra ID elligible PIM assignment Details: " + $error[0].exception.message + " : " + ($error[0].errordetails.message -split "`n")[0]
Write-Log -logtext $message -logpath $logpath
}
$PIMRoles = $ActivePIMAssignments + $ElligiblePIMAssignments
$message = "Entra ID Priviledged identity management details done."
Write-Log -logtext $message -logpath $logpath
}
if ($EntraLicense -eq "Entra ID P2" -AND $ConnectionDetail.scopes -contains "AccessReview.Read.All") {
try {
$Accessreviews = (invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/identityGovernance/accessReviews/definitions?`$select=displayName,status,instanceEnumerationScope,createdDateTime,lastModifiedDateTime,descriptionForReviewers,descriptionForAdmins").value | ForEach-Object { [pscustomobject]@{AccessReviewName = $_.displayName; status = $_.status; scope = if ($_.instanceEnumerationScope.query) { (invoke-mggraphrequest -uri $_.instanceEnumerationScope.query).displayName -join "," } else { (Invoke-MgGraphRequest -uri $_.scope.resourceScopes.query).DisplayName -join "," }; createdDateTime = $_.createdDateTime; lastModifiedDateTime = $_.lastModifiedDateTime; descriptionForReviewers = $_.descriptionForReviewers; descriptionForAdmins = $_.descriptionForAdmins } }
$message = "Entra ID access review details done."
Write-Log -logtext $message -logpath $logpath
}
catch {
$message = "Entra ID access review Details: " + $error[0].exception.message + " : " + ($error[0].errordetails.message -split "`n")[0]
Write-Log -logtext $message -logpath $logpath
}
}
if ($ConnectionDetail.scopes -contains "Directory.Read.All" -OR $ConnectionDetail.scopes -contains "Directory.ReadWrite.All") {
# License summary
try {
$LicenseDetail = (Invoke-mgGraphRequest -Uri "https://graph.microsoft.com/v1.0/subscribedSkus?$select=skuPartNumber,skuId,prepaidUnits,consumedUnits,servicePlans&quot;).value | ForEach-Object { [pscustomobject]@{Skuid = $_.skuId; skuPartNumber = $_.skuPartNumber; activeUnits = $_.prepaidUnits["enabled"]; consumedUnits = $_.consumedUnits; availableUnits = ($_.prepaidUnits["enabled"] – $_.consumedUnits) } }
$message = "License summary done."
Write-Log -logtext $message -logpath $logpath
}
catch {
$message = "Entra ID license summary: " + $error[0].exception.message + " : " + ($error[0].errordetails.message -split "`n")[0]
Write-Log -logtext $message -logpath $logpath
}
}
if ($ConnectionDetail.scopes -contains "Policy.Read.All") {
try {
$CASPolicyDetail = (Invoke-mgGraphRequest -Uri "https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies?`$select=displayname,state,createdDateTime,modifiedDateTime,conditions" ).value | ForEach-Object { [pscustomobject]@{DisplayName = $_.displayName; State = $_.state; createdDateTime = $_.createdDateTime; modifiedDateTime = $_.modifiedDateTime; locations = $_.conditions.locations.includeLocations -join "`n"; platforms = $_.conditions.platforms.includeplatforms -join "`n" ; clientapplicationtypes = $_.conditions.clientAppTypes -join "`n" } }
$message = "Conditional access policies summary done."
Write-Log -logtext $message -logpath $logpath
}
catch {
$message = "Entra ID CAS policy summary: " + $error[0].exception.message + " : " + ($error[0].errordetails.message -split "`n")[0]
Write-Log -logtext $message -logpath $logpath
}
try {
$PasswordLessDetails = (invoke-MgGraphRequest -uri "https://graph.microsoft.com/beta/policies/authenticationmethodspolicy/authenticationMethodConfigurations/MicrosoftAuthenticator&quot;).includetargets | ForEach-Object { [pscustomobject]@{authenticationMode = if ($_.authenticationMode -eq "any" -OR $_.authenticationMode -eq "deviceBasedPush") { "Passwordless" } else { "Password Based" }; id = $_.id; isRegistrationRequired = $_.isRegistrationRequired; targetType = $_.targetType } }
$message = "Passwordless Auth details summary done."
Write-Log -logtext $message -logpath $logpath
}
catch {
$message = "Entra ID passwordless config: " + $error[0].exception.message + " : " + ($error[0].errordetails.message -split "`n")[0]
Write-Log -logtext $message -logpath $logpath
}
# Collaberation settings
try {
$Collabsettings = (invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/policies/authorizationPolicy?`$select=defaultUserRolePermissions,allowInvitesFrom,allowEmailVerifiedUsersToJoinOrganization,blockMsolPowerShell") | ForEach-Object { [pscustomobject]@{AppRegistrationForAll = $_.defaultUserRolePermissions.allowedToCreateApps; allowedToReadOtherUsers = $_.defaultUserRolePermissions.allowedToReadOtherUsers; allowedToCreateSecurityGroups = $_.defaultUserRolePermissions.allowedToCreateSecurityGroups; AllowGuestInvitesFrom = $_.allowInvitesFrom; allowEmailVerifiedUsersToJoinOrganization = $_.allowEmailVerifiedUsersToJoinOrganization; blockMsolPowerShell = $_.blockMsolPowerShell; allowedToCreateTenants = $_.defaultUserRolePermissions.allowedToCreateTenants } }
$message = "Collaberation details summary done."
Write-Log -logtext $message -logpath $logpath
}
catch {
$message = "Entra ID collaberation details: " + $error[0].exception.message + " : " + ($error[0].errordetails.message -split "`n")[0]
Write-Log -logtext $message -logpath $logpath
}
}
# SSPR settings | This is a workaround using undocumented API
try {
$sspr = Invoke-RestMethod 'https://main.iam.ad.ext.azure.com/api/PasswordReset/PasswordResetPolicies?getPasswordResetEnabledGroup=true&#39; -Headers @{Authorization = "Bearer $($token1)"; "x-ms-client-request-id" = [guid]::NewGuid().ToString(); "x-ms-client-session-id" = [guid]::NewGuid().ToString() } | Select-Object @{l = "SSPRStatus"; e = { If ($_.enablementType -eq 1) { "Enabled" } else { "disabled" } } }, @{l = "AuthMethodCout"; e = { $_.numberOfAuthenticationMethodsRequired } }, @{l = "numberOfQuestionsToRegister"; e = { $_.numberOfQuestionsToRegister -join "," } }, @{l = "numberOfQuestionsToReset"; e = { $_.numberOfQuestionsToReset -join "," } }, @{l = "GroupsInScope"; e = { $_.passwordResetEnabledGroupName -join "`n" } }, @{l = "skipRegistrationAllowed"; e = { $_.skipRegistrationAllowed } }, @{l = "skipRegistrationMaxAllowedDays"; e = { $_.skipRegistrationMaxAllowedDays } }, @{l = "customizeHelpdeskLink"; e = { $_.customizeHelpdeskLink } }, @{l = "customHelpdeskEmailOrUrl"; e = { $_.customHelpdeskEmailOrUrl } }
}
catch {
$message = "SSPR details: " + $error[0].exception.message + " : " + ($error[0].errordetails.message -split "`n")[0]
Write-Log -logtext $message -logpath $logpath
}
if ($ConnectionDetail.scopes -contains "SecurityEvents.Read.All") {
# Identtity Secure score recommendations
try {
$Controls = (invoke-mggraphRequest -Uri "https://graph.microsoft.com/v1.0/Security/secureScoreControlProfiles?`$filter=controlCategory eq 'Identity'").value | ForEach-Object { [pscustomobject]@{controlCategory = $_.controlCategory; id = $_.id; title = $_.title; service = $_.service; userImpact = $_.userImpact; threats = ($_.threats -join ","); actionType = $_.actionType; remediation = $_.remediation; maxScore = $_.maxScore; deprecated = $_.deprecated } }
}
catch {
$message = "Entra ID secure score controls: " + $error[0].exception.message + " : " + ($error[0].errordetails.message -split "`n")[0]
Write-Log -logtext $message -logpath $logpath
}
try {
$Scores = (invoke-mggraphRequest -Uri "https://graph.microsoft.com/v1.0/Security/secureScores&quot;).value | ForEach-Object { [pscustomobject]@{createdDateTime = $_.createdDateTime; currentScore = $_.currentScore; maxScore = $_.maxScore; controlScores = $_.controlScores; licensedUserCount = $_.licensedUserCount; activeUserCount = $_.activeUserCount } }
}
catch {
$message = "Entra ID secure score details: " + $error[0].exception.message + " : " + ($error[0].errordetails.message -split "`n")[0]
Write-Log -logtext $message -logpath $logpath
}
$SecureScoreReport = @()
if ($scores) {
$latestScore = $scores[0]
foreach ($control in $latestScore.controlScores | Where-Object { $_.controlCategory -eq "Identity" }) {
$controlProfile = $Controls | Where-Object { $_.id -contains $control.controlname }
$SecureScoreReport += [PSCustomObject]@{
ControlCategory = $control.ControlCategory
Title = $controlProfile.title
description = $control.description
Threats = $controlprofile.threats
scoreInPercentage = $control.scoreInPercentage
Score = "$([int]$control.score) / $([int]$controlProfile.maxScore)"
UserImpact = $controlProfile.userImpact
actionType = $controlProfile.actionType
remediation = $controlProfile.remediation
implementationStatus = $control.implementationStatus
lastSynced = $control.lastSynced
}
}
}
}
$threshold = 7 # number of days after which cert/secret would be expired
$apps = @()
$expiringsecrets = @()
$expiringcerts = @()
$sensitiveapps = @()
if ($ConnectionDetail.scopes -contains "Directory.Read.All") {
$apps = Get-SensitiveApps
$expiringsecrets = ($apps | Where-Object { $_.secretenddate }) | Where-Object { (($_.secretenddate -split "`n" | ForEach-Object { [datetime]::ParseExact($_, "dd-MM-yyyy HH:mm:ss", [System.Globalization.CultureInfo]::InvariantCulture) }) | Measure-Object -Maximum).maximum -lt (get-date).Adddays($threshold) }
$expiringcerts = ($apps | Where-Object { $_.certenddate }) | Where-Object { (($_.certenddate -split "`n" | ForEach-Object { [datetime]::ParseExact($_, "dd-MM-yyyy HH:mm:ss", [System.Globalization.CultureInfo]::InvariantCulture) }) | Measure-Object -Maximum).maximum -lt (get-date).Adddays($threshold) }
$sensitiveapps = $apps | Where-Object { $_.sensitivepermissions }
}
$message = "Creating HTML Report…"
Write-Log -logtext $message -logpath $logpath
New-BaloonNotification -title "Information" -message $message
# Create HTML table elements
if ($EnabledAuthMethods) {
$EnabledAuthSummary = ($EnabledAuthMethods | Sort-Object State -Descending | ConvertTo-Html -As Table -Fragment -PreContent "<h2>Auth Methods Summary : $($TenantBasicDetail.DisplayName)</h2>")
}
if ($RoleDetail) {
$RoleSummary = ($RoleDetail | Sort-Object Count | ConvertTo-Html -As Table -Fragment -PreContent "<h2>Priviledged Entra Role Summary: $($TenantBasicDetail.DisplayName)</h2>")
}
if ($TenantBasicDetail) {
$TenantSummary = ($TenantBasicDetail | ConvertTo-Html -As List -Fragment -PreContent "<h2>Entra Summary: $forest</h2>") -replace "`n", "<br>" -replace ",", "<br>"
}
if ($BasicAuthDetails) {
$BasicAuthSummary = ($BasicAuthDetails | ConvertTo-Html -As List -Fragment -PreContent "<h2>Basic/Modern Auth Summary: $forest</h2>") -replace "`n", "<br>"
}
if ($EntraIDConnectDetails) {
$EntraIDConnectSummary = $EntraIDConnectDetails | ConvertTo-Html -As Table -Fragment -PreContent "<h2>Entra ID connect agents Summary: $($TenantBasicDetail.DisplayName)</h2>"
}
if ($PTAEnabled) {
$PTAAgentSummary = $PTAAgentDetail | ConvertTo-Html -As Table -Fragment -PreContent "<h2>Pass through agents Summary: $($TenantBasicDetail.DisplayName)</h2>"
}
If ($RBACRoles) {
$RBACRolesSummary = ($RBACRoles | ConvertTo-Html -As Table -Fragment -PreContent "<h2>RBAC Roles Summary: $($TenantBasicDetail.DisplayName)</h2>") -replace "`n", "<br>"
}
If ($PIMRoles) {
$PIMRolesSummary = ($PIMRoles | Sort-Object RoleName, PrincipalType, type | ConvertTo-Html -As Table -Fragment -PreContent "<h2>PIM Roles Summary: $($TenantBasicDetail.DisplayName)</h2>") -replace "`n", "<br>"
}
if ($Accessreviews) {
$AccessreviewSummary = ($Accessreviews | ConvertTo-Html -As Table -Fragment -PreContent "<h2>Access Review Summary: $($TenantBasicDetail.DisplayName)</h2>") -replace "`n", "<br>"
}
if ($PasswordProtectionDetails.PSObject.Properties.Count -ge 1) {
$PasswordProtectionSummary = ($PasswordProtectionDetails | ConvertTo-Html -As List -Fragment -PreContent "<h2>Password Protection Summary: $($TenantBasicDetail.DisplayName)</h2>") -replace "`n", "<br>"
}
if ($CASPolicyDetail) {
$CASSummary = ($CASPolicyDetail | ConvertTo-Html -As Table -Fragment -PreContent "<h2>Conditional Access Policy Summary: $($TenantBasicDetail.DisplayName)</h2>") -replace "`n", "<br>"
}
if ($PasswordLessDetails) {
$PasswordLessSummary = $PasswordLessDetails | ConvertTo-Html -As Table -Fragment -PreContent "<h2>Passwordless Auth mode Summary: $($TenantBasicDetail.DisplayName)</h2>"
}
If ($Collabsettings) {
$CollabsettingsSummary = $Collabsettings | ConvertTo-Html -As List -Fragment -PreContent "<h2>Collaberation settings Summary: $($TenantBasicDetail.DisplayName)</h2>"
}
If ($LicenseDetail) {
$LicenseSummary = $LicenseDetail | ConvertTo-Html -As Table -Fragment -PreContent "<h2>License Summary: $($TenantBasicDetail.DisplayName)</h2>"
}
If ($SecureScoreReport) {
$SecureScoreReportSummary = $SecureScoreReport | ConvertTo-Html -As Table -Fragment -PreContent "<h2>Identity – Secure Scores Summary: $($TenantBasicDetail.DisplayName)</h2>"
}
if ($expiringsecrets) {
$expiringsecretSummary = ($expiringsecrets | select-object displayName, createdDateTime, enabled, servicePrincipalType, secretdisplayname, secretstartdate, secretenddate | ConvertTo-Html -As Table -Fragment -PreContent "<h2>Apps – Expiring secrets Summary: $($TenantBasicDetail.DisplayName)</h2>") -replace "`n", "<br>"
}
if ($expiringcerts) {
$expiringcertSummary = ($expiringcerts | select-object displayName, createdDateTime, enabled, servicePrincipalType, certdisplayname, certthumbprint, certstartdate, certenddate, certusage, certtype | ConvertTo-Html -As Table -Fragment -PreContent "<h2>Apps – Expiring certificate Summary: $($TenantBasicDetail.DisplayName)</h2>") -replace "`n", "<br>"
}
if ($sensitiveapps) {
$sensitiveappSummary = ($sensitiveapps | select-object displayName, createdDateTime, enabled, servicePrincipalType, permissions, sensitivepermissions | ConvertTo-Html -As Table -Fragment -PreContent "<h2>sensitive apps Summary: $($TenantBasicDetail.DisplayName)</h2>") -replace "`n", "<br>"
}
if ($sspr) {
$ssprsummary = ($sspr | ConvertTo-Html -As List -Fragment -PreContent "<h2>SSPR setting Summary: $($TenantBasicDetail.DisplayName)</h2>") -replace "`n", "<br>"
}
$ReportRaw = ConvertTo-HTML -Body "$TenantSummary $BasicAuthSummary $ssprsummary $CollabsettingsSummary $EntraIDConnectSummary $PasswordLessSummary $PTAAgentSummary $LicenseSummary $RoleSummary $RBACRolesSummary $PIMRolesSummary $AccessreviewSummary $PasswordProtectionSummary $EnabledAuthSummary $CASSummary $SecureScoreReportSummary $expiringsecretSummary $expiringcertSummary $sensitiveappsummary" -Head $header -Title "Report on Entra ID: $($TenantBasicDetail.Displayname)" -PostContent "<p id='CreationDate'>Creation Date: $(Get-Date) $CopyRightInfo </p>"
# To preseve HTMLformatting in description
$ReportRaw = [System.Web.HttpUtility]::HtmlDecode($ReportRaw)
$ReportRaw | Out-File $ReportPath
Invoke-item $ReportPath

I would be working on the script further on this github link so keep an eye out

PS script for Entra ID Application report including secret/cert details

As Microsoft has discontinued legacy authentication and excluding service accounts from multi-factor authentication comes with own risk, everyone moved or would need to move to app registrations sooner or later. This leads to another problem that how to monitor all service principals, keep on renewing certificates/ secrets and also keep an eye on the service principals with excessive permissions (get real, most admins don’t understand the difference between application permissions/ delegated permissions and end up creating apps with dangerous permissions).

I wrote the below script which should help in addressing the challenges and considering it uses only Graph API calls, you need only the authentication module Microsoft.Graph.Authentication

<#
Author : Nitish Kumar (nitish@nitishkumar.net)
Performs Entra ID Assessment
version 1.0 | 17/07/2024 Initial version
version 1.2 | 28/07/2024 Application details performance improvements
Disclaimer: This script is designed to only read data from the entra id and should not cause any problems or change configurations but author do not claim to be responsible for any issues. Do due dilligence before running in the production environment
#>
Import-module Microsoft.Graph.Authentication
# Output formating options
$logopath = "https://raw.githubusercontent.com/laymanstake/laymanstake/master/images/logo.png&quot;
$ReportPath = "c:\temp\EntraIDReport_$(get-date -Uformat "%Y%m%d-%H%M%S").html"
$CopyRightInfo = " @Copyright Nitish Kumar <a href='https://github.com/laymanstake'>Visit nitishkumar.net</a>"
# CSS codes to format the report
$header = @"
<style>
body { background-color: #D3D3D3; }
h1 { font-family: Arial, Helvetica, sans-serif; color: #e68a00; font-size: 28px; }
h2 { font-family: Arial, Helvetica, sans-serif; color: #000099; font-size: 16px; }
table { font-size: 12px; border: 1px; font-family: Arial, Helvetica, sans-serif; }
td { padding: 4px; margin: 0px; border: 1; }
th { background: #395870; background: linear-gradient(#49708f, #293f50); color: #fff; font-size: 11px; text-transform: uppercase; padding: 10px 15px; vertical-align: middle; }
tbody tr:nth-child(even) { background: #f0f0f2; }
CreationDate { font-family: Arial, Helvetica, sans-serif; color: #ff3300; font-size: 12px; }
</style>
"@
If ($logopath) {
$header = $header + "<img src=$logopath alt='Company logo' width='150' height='150' align='right'>"
}
# Function to parse datetime string with different cultures
function Convert-ToDateTime {
param (
[string[]]$dateStrings
)
# List of cultures to test
$cultures = @('en-US', 'en-GB', 'fr-FR', 'de-DE', 'es-ES', 'en-IN')
$results = @()
if(-Not $dateStrings){
return $null
}
foreach ($dateString in $dateStrings) {
if ([string]::IsNullOrEmpty($dateString)) {
$results += $null
continue
}
$parsed = $null
foreach ($culture in $cultures) {
try {
$cultureInfo = [System.Globalization.CultureInfo]::GetCultureInfo($culture)
$parsed = [datetime]::Parse($dateString, $cultureInfo)
break
} catch {
# Continue to the next culture if parsing fails
continue
}
}
if (-NOT $parsed) {
throw "Unable to parse date string: $dateString"
}
$results += $parsed.ToString("dd-MM-yyyy HH:mm:ss")
}
return $results
}
function Get-SensitiveApps {
[CmdletBinding()]
Param(
[Parameter(ValueFromPipeline = $true, mandatory = $false)][array]$Sensitivepermissions = ("User.Read.All", "User.ReadWrite.All", "Mail.ReadWrite", "Files.ReadWrite.All", "Calendars.ReadWrite", "Mail.Send", "User.Export.All", "Directory.Read.All", "Exchange.ManageAsApp", "Directory.ReadWrite.All", "Sites.ReadWrite.All", "Application.ReadWrite.All", "Group.ReadWrite.All", "ServicePrincipalEndPoint.ReadWrite.All", "GroupMember.ReadWrite.All", "RoleManagement.ReadWrite.Directory", "AppRoleAssignment.ReadWrite.All")
)
# Populate a set of hash tables with permissions used for different Office 365 management functions
$GraphApp = (invoke-MgGraphRequest -uri "https://graph.microsoft.com/v1.0/serviceprincipals?`$filter=appid eq '00000003-0000-0000-c000-000000000000'").value
$GraphRoles = @{}
ForEach ($Role in $GraphApp.AppRoles) { $GraphRoles.Add([string]$Role.Id, [string]$Role.Value) }
$ExoPermissions = @{}
$ExoApp = (invoke-MgGraphRequest -uri "https://graph.microsoft.com/v1.0/serviceprincipals?`$filter=appid eq '00000002-0000-0ff1-ce00-000000000000'").value
ForEach ($Role in $ExoApp.AppRoles) { $ExoPermissions.Add([string]$Role.Id, [string]$Role.Value) }
$O365Permissions = @{}
$O365API = (invoke-MgGraphRequest -uri "https://graph.microsoft.com/v1.0/serviceprincipals?`$filter=DisplayName eq 'Office 365 Management APIs'").value
ForEach ($Role in $O365API.AppRoles) { $O365Permissions.Add([string]$Role.Id, [string]$Role.Value) }
$AzureADPermissions = @{}
$AzureAD = (invoke-MgGraphRequest -uri "https://graph.microsoft.com/v1.0/serviceprincipals?`$filter=DisplayName eq 'Windows Azure Active Directory'").value
ForEach ($Role in $AzureAD.AppRoles) { $AzureADPermissions.Add([string]$Role.Id, [string]$Role.Value) }
$TeamsPermissions = @{}
$TeamsApp = (invoke-MgGraphRequest -uri "https://graph.microsoft.com/v1.0/serviceprincipals?`$filter=DisplayName eq 'Skype and Teams Tenant Admin API'").value
ForEach ($Role in $TeamsApp.AppRoles) { $TeamsPermissions.Add([string]$Role.Id, [string]$Role.Value) }
$RightsManagementPermissions = @{}
$RightsManagementApp = (invoke-MgGraphRequest -uri "https://graph.microsoft.com/v1.0/serviceprincipals?`$filter=DisplayName eq 'Microsoft Rights Management Services'").value
ForEach ($Role in $RightsManagementApp.AppRoles) { $RightsManagementPermissions.Add([string]$Role.Id, [string]$Role.Value) }
$Appdetails = @()
$sps = @()
$managedidentities = @()
$appcreds = @()
$approles = @()
$Sensitivepermissions = ("User.Read.All", "User.ReadWrite.All", "Mail.ReadWrite", "Files.ReadWrite.All", "Calendars.ReadWrite", "Mail.Send", "User.Export.All", "Directory.Read.All", "Exchange.ManageAsApp", "Directory.ReadWrite.All", "Sites.ReadWrite.All", "Application.ReadWrite.All", "Group.ReadWrite.All", "ServicePrincipalEndPoint.ReadWrite.All", "GroupMember.ReadWrite.All", "RoleManagement.ReadWrite.Directory", "AppRoleAssignment.ReadWrite.All")
$uri = "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=tags/any(t:t+eq+'WindowsAzureActiveDirectoryIntegratedApp')&`$top=999&`$select=id,appid,displayname,createdDateTime,accountEnabled,servicePrincipalType,signInAudience,appRoleAssignmentRequired,appOwnerOrganizationId"
do {
$response = Invoke-MgGraphRequest -Uri $uri
$apps = $response.value
$SPs += $apps
$uri = $response.'@odata.nextLink'
} while ($uri)
$Uri = "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=ServicePrincipalType eq 'ManagedIdentity'&`$top=999&`$select=id,appid,displayname,createdDateTime,accountEnabled,servicePrincipalType,signInAudience,appRoleAssignmentRequired,appOwnerOrganizationId"
do {
$response = Invoke-MgGraphRequest -Uri $uri
$apps = $response.value
$managedidentities += $apps
$uri = $response.'@odata.nextLink'
} while ($uri)
$AllApps = $SPs + $managedidentities
$Uri = "https://graph.microsoft.com/v1.0/applications?`$select=appid,passwordCredentials,keycredentials&`$top=999"
do {
$response = Invoke-MgGraphRequest -Uri $uri
$apps = $response.value
$appcreds += $apps
$uri = $response.'@odata.nextLink'
} while ($uri)
$Uri = "https://graph.microsoft.com/v1.0/serviceprincipals?`$top=999&`$expand=appRoleAssignments&`$select=appId,appRoleAssignments"
do {
$response = Invoke-MgGraphRequest -Uri $uri
$apps = $response.value
$approles += $apps
$uri = $response.'@odata.nextLink'
} while ($uri)
$i = 0
$count = $AllApps.count
ForEach ($app in $AllApps) {
$i++
Write-Progress -Activity "Processing $($app.displayName)" -Status "$i of $count completed" -PercentComplete ($i * 100 / $count)
$Roles = $null
$Roles = $approles | Where-Object { $_.appid -eq $app.appid }
[array]$Permission = $Null
$spermissions = $null
if (($Roles.count) -gt 0) {
ForEach ($Approle in $Roles.appRoleAssignments) {
Switch ($AppRole.ResourceDisplayName) {
"Microsoft Graph" {
$Permission += $GraphRoles[$AppRole.AppRoleId]
}
"Office 365 Exchange Online" {
$Permission += $ExoPermissions[$AppRole.AppRoleId]
}
"Office 365 Management APIs" {
$Permission += $O365Permissions[$AppRole.AppRoleId]
}
"Windows Azure Active Directory" {
$Permission += $AzureADPermissions[$AppRole.AppRoleId]
}
"Skype and Teams Tenant Admin API" {
$Permission += $TeamsPermissions[$AppRole.AppRoleId]
}
"Microsoft Rights Management Services" {
$Permission += $RightsManagementPermissions[$AppRole.AppRoleId]
}
}
}
if ($Permission) {
$spermissions = (compare-object -ReferenceObject ($Permission | Where-Object { $_ }) -DifferenceObject $Sensitivepermissions -IncludeEqual | Where-Object { $_.SideIndicator -eq "==" }).inputobject
}
}
$secrets = @()
$secrets = $appcreds | Where-Object { $_.appid -eq $app.appid }
$passwords = $secrets.passwordcredentials | ForEach-Object { [pscustomobject]@{displayname = $_.displayname; startdatetime = $_.startdatetime; enddatetime = $_.enddatetime } }
$certs = $secrets.keycredentials | ForEach-Object { [pscustomobject]@{displayname = $_.displayname; startdatetime = $_.startdatetime; enddatetime = $_.enddatetime; usage = $_.usage; type = $_.type; customKeyIdentifier = $_.customKeyIdentifier } }
$temp = [pscustomobject]@{
id = $app.id
displayName = $app.displayName
createdDateTime = $app.createdDateTime
enabled = $app.accountEnabled
servicePrincipalType = $app.servicePrincipalType
permissions = $permission -join "`n"
sensitivepermissions = $spermissions -join "`n"
secretdisplayname = $passwords.displayname -join "`n"
secretstartdate = (Convert-ToDateTime -dateStrings $passwords.startdatetime) -join "`n"
secretenddate = (Convert-ToDateTime -dateStrings $passwords.enddatetime) -join "`n"
certdisplayname = $certs.displayname -join "`n"
certthumbprint = $certs.customKeyIdentifier -join "`n"
certstartdate = (Convert-ToDateTime -dateStrings $certs.startdatetime) -join "`n"
certenddate = (Convert-ToDateTime -dateStrings $certs.enddatetime) -join "`n"
certusage = $certs.usage -join "`n"
certtype = $certs.type -join "`n"
signInAudience = $app.signInAudience
appRoleAssignmentRequired = $app.appRoleAssignmentRequired
appOwnerOrganizationId = $app.appOwnerOrganizationId
}
$Appdetails += $temp
}
return $Appdetails
}
$threshold = 30 # number of days after which cert/secret would be expired
$apps = @()
$expiringsecrets = @()
$expiringcerts = @()
$sensitiveapps = @()
disconnect-mggraph
Connect-MgGraph -NoWelcome -scopes Directory.read.all
$ConnectionDetail = Get-mgContext
clear-host
if ($ConnectionDetail.scopes -contains "Directory.Read.All") {
$apps = Get-SensitiveApps
$expiringsecrets = ($apps | Where-Object { $_.secretenddate }) | Where-Object { (($_.secretenddate -split "`n" | ForEach-Object { [datetime]::ParseExact($_, "dd-MM-yyyy HH:mm:ss", [System.Globalization.CultureInfo]::InvariantCulture) }) | Measure-Object -Maximum).maximum -lt (get-date).Adddays($threshold) }
$expiringcerts = ($apps | Where-Object { $_.certenddate }) | Where-Object { (($_.certenddate -split "`n" | ForEach-Object { [datetime]::ParseExact($_, "dd-MM-yyyy HH:mm:ss", [System.Globalization.CultureInfo]::InvariantCulture)}) | Measure-Object -Maximum).maximum -lt (get-date).Adddays($threshold) }
$sensitiveapps = $apps | Where-Object { $_.sensitivepermissions }
}
$sensitiveapps | export-csv -nti c:\temp\appreport.csv
$expiringcerts | export-csv -nti c:\temp\appreport-expiringcerts.csv
$expiringsecrets | export-csv -nti c:\temp\appreport-expiringsecerts.csv
if ($sensitiveapps) {
$sensitiveappssummary = ($sensitiveapps | ConvertTo-Html -As Table -Fragment -PreContent "<h2>Sensitive apps Summary</h2>") -replace "`n", "<br>"
}
if ($expiringcerts) {
$expiringcertssummary = ($expiringcerts | ConvertTo-Html -As Table -Fragment -PreContent "<h2>Expiriring certificates Summary</h2>") -replace "`n", "<br>"
}
if ($expiringsecrets) {
$expiringsecretssummary = ($expiringsecrets | ConvertTo-Html -As Table -Fragment -PreContent "<h2>Expiring secrets Summary</h2>") -replace "`n", "<br>"
}
$ReportRaw = ConvertTo-HTML -Body "$sensitiveappssummary $expiringcertssummary $expiringsecretssummary" -Head $header -Title "Report on Entra ID: $($TenantBasicDetail.Displayname)" -PostContent "<p id='CreationDate'>Creation Date: $(Get-Date) $CopyRightInfo </p>"
# To preseve HTMLformatting in description
$ReportRaw = [System.Web.HttpUtility]::HtmlDecode($ReportRaw)
$ReportRaw | Out-File $ReportPath
Invoke-item $ReportPath
# $expiringsecrets , $expiringcerts and $sensitiveapps variables can be used to get particular details.
<# $MailCredential = Get-Credential -Message "Enter the password for the email account: " -UserName "contactfor_nitish@hotmail.com"
$body = Get-Content $ReportPath1 -Raw
New-Email -RecipientAddressTo "nitish@nitishkumar.net" -SenderAddress "contactfor_nitish@hotmail.com" -SMTPServer "smtp.office365.com" -SMTPServerPort 587 -Subject "AD Assessment Report $(get-date -Uformat "%Y%m%d-%H%M%S")" -Body $body -credential $MailCredential #>

MFA temporary exclusion Tool using SharePoint List and Power Automate

A couple of years ago, when we implemented Multi-Factor Authentication for a client through phased conditional access policies, our primary challenge was handling MFA exclusion requests. This involved validating business justifications and exempting users via an exemption group in the policies. We also had to follow up with users to remove them from the exemption group to ensure compliance.

It wasn’t just manual work, we were also challenged by the Security team. The authority to validate the business case should have been with the Security team, not with the operations. In the initial phases, many people wanted to be exempt from MFA hassles. These exemptions weren’t properly tracked, and even if a ticket was raised, there was no easy way to link it with the action in the audit logs. Furthermore, the project team that implemented MFA wasn’t supposed to handle such requests forever, and neither we nor the security team wanted to give this right to the helpdesk team.

To resolve the situation, we came up with a solution involving SharePoint List and Power Automate, which was able to delegate the task to helpdesk while keeping security team satisfied from compliance perspective and keeping clear correlation between the ticket reference and the action taken.

Step 1: Prepare the sharepoint list

We need to create a SharePoint list which should contain all relevant details which we want to capture from an exemption case from compliance point of view, like Ticket number, pre-approved scenario, user details, date, additional remarks if any.

Default ID field you would leave as such, second field would be a calculated field with the formula as =TEXT(Modified,”mm-dd-yyyy hh:mm:ss”) so that it would keep the timestamp of row creation, then next can be a user picker with allow multiple selection as unchecked and require that this column contains information is checked. Reason can be a choice field in which you would put pre-approved scenarios like below:

  1. Mobile / office phone not available
  2. Change of mobile / office phone
  3. Need to update MFA preference
  4. Campus support request
  5. Others (if others then need to specify the use case and also provide security approval)

Then the last column might be a mult-line field with name as additional remark. Also you can keep attachment field which is available by default as on from show / hide columns.

This list would need to have two levels of access, one as owner, which might be with administrator and then helpdesk can be given contributor access without delete (need to create a custom role and assign it to a group, in which we would add helpdesk user or anyone else who need to be delegated the task to exempt users) on the list so that they can add rows but can not delete rows.

Step 2: Create Power Automate flow

You would need to have an account with Power Automate license and that account needs to have permissions on sharepoint list which we just created and then it should have owner permissions on the exception group, which we would create in Entra ID.

Click on integrate, then Power Automate and then create a flow like below (new UI and old UI)

The trigger would need site address and list name (available from drop down), then in Get User profile (v2), you can put an expression like below

split(triggerOutputs()?['body/UserName/Claims'],'|')[2]

This would pick the userPrincipalname and then you can pass the obtained ID attribute to add user to group, where you need to specify group id manually.

Delay untill block can be a code like to wait for 24 hrs

addHours(utcnow(),24)


Then you add Remove member from group action, which would need to have group id manually specified and then ID obtained from get profile step.

And that’s It. You add a row in sharepoint, it would trigger the flow which would add the user in group, wait for 24 hrs and then would remove the same. Can add another line and it can run in parallel for the same in the same manner if under settings for the first trigger, you have specified concurrency.

Tell me how you find this solution?

Log Analytics Alerts via Email – Automation Account -PS Runbook

Couple of years back, I worked on a project with one of my client and friend to replace SCOM monitoring with Log Analytics based alerting system. This was an exiciting project considering the fact that we were designing a solution end-to-end but for me the best part was PowerShell runbook running from Azure/ Hybrid Worker (On-Premise). This meant that I don’t need to have a Windows machine anywhere to run PowerShell and I can automate things with the same good old PowerShell. This functionality can be crucial for even newer solutions like Entra ID Lifecycle workflow custom extensions to do a number of things.

In this blog post (Yeah I know I am writing after a long time), I would try to go through end-to-end solution for creating an alert and very customized notification email. Even if you do not go ITSM connector way, with the customized email, you can drop that email to a specific mailbox address which can trigger ServiceNow workflow with relevant details to create a ticket in system. One can understand that how this capability can serve for many other use cases as well.

Step 1: Get an Azure subscription and some credits. Automation account would not cost much for the runbook runs but active subscription is a must otherwise it would not allow you to create one but the credits are required importantly for the log ingestion to a Log analytics workspace.

Step 2: Create a log analytics workspace by any name along with resource group, in which it would be created. Chose a suitable location for performance and cost reasons. Pricing can be Pay as go.

Step 3: Create an automation account along with relevant resource group, where we would create runbooks later on.

Step 4: Onboard the machines from where you need to ingest the logs. For the same, the way been to download Log analytics agents and then configure the same by putting workspace ID and key but post August 2024, the way forward would be Azure Monitor agent. For the same, you would need to onboard the server/ machine to Azure Arc and then add Azure monitor agent as extension there. Once the same is onboarded then you need to configure that which logs would be pushed to workspace, which you can do with Data collection rules.

Step 5: Once you got the logs reaching to Log Analytics workspace, now is the time to setup alerts and runbook to send email on the alert. Let’s see how to go with that.

Search for alerts in Azure and then click on Alert rules. In Alert rules, click create and then chose relevant Log analytics workspace, then on next, under condition, chose signal as Custom log search and then put the kusto query for the specific condition you want to evaluate. A sample one is below:

SecurityEvent
| where EventID == 4624 and (LogonType == 10 or LogonType == 2)
| project TimeGenerated, Account, AccountType, Computer, LogonType

Next is to setup frequency and threshold on the same page and on next page, there is option for action group, which we can come back later to, next page in details, you can chose priority of the alert and identity (can be left to default). This should be enough to create the alert.

You can come back to create Action group once we are ready with runbook and then the same can be updated to the alert rule.

Step 6: For credentials, we need to create a service principal. Though as bare minimum, we need mail.send permissions, but I would put the data.read permissions as well for reading the log analytics query results as well.

We would go to App registrations and then create a new one, give a name. Once created then would note down tenant ID, Application ID and then also would create a secret and note down the value of the same. Next, we need to allow permissions so under permissions, app mail.read application permissions so that it can send email as any user (for secure config, it can be deleted or target to specific account) and then from add permissions, API my organization uses, you pick Log analytics API and chose data.read permission to be added. Then you need to given admin consent so that your identity is ready. Tenant ID would be used in Runbook script while Application ID and Secret would go to credentials.

For creating credentials, you go to Automation accounts, select the account you created and then under shared resources, go to credentials and add one by name GA as I have referred in the script (can chose any other name but would need to update script) and then for username, put application ID and for password, put secret value and your identity is done.

Step 7: Go to Automation accounts, open the automation account, which you created in step 3 and then go to runbooks. Create a runbook, give a name, chose PowerShell, select version 5 and create. Once created open the same and click edit and then can put the code like below:

param (
    [parameter (mandatory=$false)][Object]$webhookData,
    [parameter (mandatory=$false)][string]$tenantID = "xxxxxxxxxxx",    
    [parameter (mandatory=$false)][string]$mailSender = "xxxxxxxxxxx@xxxxxxxxxx.onmicrosoft.com",
    [parameter (mandatory=$false)][string]$recipients = "nitish@nitishkumar.net"
)

# Replace the name of Automation Credential variable
$credential = (Get-AutomationPSCredential -Name "xxxxx")

# Function to get log query results from the API url
function Get-QueryResults {
    param (
        [parameter (mandatory=$false)][string]$queryUrl,
        [parameter (mandatory=$false)][pscredential]$credential,
        [parameter (mandatory=$false)][string]$tenantID
    )

    # AZ module is loaded by default
    Connect-AzAccount -ServicePrincipal -Credential $credential -Tenant $tenantID 

    $token = (Get-AzAccessToken -ResourceUrl "https://api.loganalytics.io").Token
    $logQueryHeaders = @{
        Authorization  = "Bearer $token"        
    }

    $resultsTable = invoke-RestMethod -Method Get "$queryUrl" -Headers $logQueryHeaders

    $count = 0
    # Note down the number of row in query results
    foreach ($table in $resultsTable.Tables) {
        $count += $table.Rows.Count
    }

    # create an object of the size
    $results = New-Object object[] $count

    # Fill the table object
    $i = 0;
    foreach ($table in $resultsTable.Tables) {
        foreach ($row in $table.Rows) {
            $properties = @{}
            for ($columnNum=0; $columnNum -lt $table.Columns.Count; $columnNum++) {
                $properties[$table.Columns[$columnNum].name] = $row[$columnNum]
            }      
            $results[$i] = (New-Object PSObject -Property $properties)
            $null = $i++
        }
    }

    return $results
}

# Function to send custom email
function Send-CustomMail {
    param (
        [parameter (mandatory=$false)][string]$mailSender,
        [parameter (mandatory=$false)][string]$recipients,
        [parameter (mandatory=$false)][string]$subject,
        [parameter (mandatory=$false)][string]$mailbody,
        [parameter (mandatory=$false)][pscredential]$credential,
        [parameter (mandatory=$false)][string]$tenantID
    )
    
    $clientID = $credential.UserName
    $clientSecret = $credential.GetNetworkCredential().Password

    #Connect to Graph API
    $tokenBody = @{
        Grant_Type = "client_credentials"
        Scope = "https://graph.microsoft.com/.default"
        Client_Id = $clientID
        Client_Secret = $clientSecret
    }

    $tokenResponse = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$tenantID/oauth2/v2.0/token" -Method POST -Body $tokenBody
    $headers = @{
        "Authorization" = "Bearer $($tokenResponse.access_token)"
        "Content-type" = "application/json"
    }

    #Send email
    $URLsend = "https://graph.microsoft.com/v1.0/users/$mailSender/sendMail"

    $BodyJsonsend =  @{
            "Message" = @{
                "Subject" = "$subject"
                "Body" = @{
                    "ContentType" = "Html"
                    "Content" = "$mailbody"
                }
                "ToRecipients"  = @(
                    @{
                        "EmailAddress" = @{
                            "Address" = "$recipients"
                        }
                    }
                )            
            }
            "SaveToSentItems" = "false"
    } | ConvertTo-Json -Depth 5

    Invoke-RestMethod -Method POST -Uri $URLsend -Headers $headers -Body $BodyJsonsend
}

if($Webhookdata -ne $null){
    # Pick the webhook data in JSON format
    $essentials = $Webhookdata.RequestBody | ConvertFrom-JSON

    # Extract the relevant value of interest from JSON data
    $AlertRule = $essentials.data.essentials.alertRule
    $severity = $essentials.data.essentials.severity
    $signalType = $essentials.data.essentials.signalType
    $firedDateTime = $essentials.data.essentials.firedDateTime
    $conditionType = $essentials.data.alertContext.conditionType
    $condition = $essentials.data.alertContext.condition.allOf[0].searchQuery
    $threshold = $essentials.data.alertContext.condition.AllOf[0].threshold
    $Count = $essentials.data.alertContext.condition.AllOf[0].metricValue
    $resultsUrl = $essentials.data.alertContext.condition.AllOf[0].linkToFilteredSearchResultsAPI

$header = @"
<style>    
    h1 { font-family: Arial, Helvetica, sans-serif; color: #e68a00; font-size: 28px; }    
    h2 { font-family: Arial, Helvetica, sans-serif; color: #000099; font-size: 16px; }    
    table { font-size: 12px; border: 2px;  font-family: Arial, Helvetica, sans-serif; } 	
    td { padding: 4px; margin: 0px; border: 1; }	
    th { background: #395870; background: linear-gradient(#49708f, #293f50); color: #fff; font-size: 11px; text-transform: uppercase; padding: 10px 15px; vertical-align: middle; }
    tbody tr:nth-child(even) { background: #f0f0f2; }    
</style>
"@

    $results = Get-QueryResults -TenantId $tenantID -Credential $credential -queryUrl $resultsUrl 
    $resultsOutput = $(($results | Select-Object TimeGenerated, Computer, Account, AccountType, LogonType | ConvertTo-Html -As Table -fragment) -replace "\\", "&#047;")
    $mailbody = "$($header)Alert rule named $AlertRule fired since below $conditionType condition met $count times while threshold is $threshold <br><br> $condition <br><br> $resultsOutput <a href=$resultsUrl>Link to query results</a>" 
    $Subject = "$AlertRule - $severity - $signalType - $firedDateTime"
    
    Send-CustomMail -TenantId $tenantID -subject $Subject -recipients $recipients -mailSender $mailSender -mailbody $mailbody -Credential $credential
}

Now your runbook is ready but one more thing to be done, add a webhook, open specific runbook and then click on add webhook. GIve a name, change expiry if needed and then note down the url. Don’t need to change parameters. Now you are ready to go back to action groups.

Step 8: Go to Alerts, then action groups and create an action group send-mail. Under actions, chose webhook, give a name and then put the url of webhook, which we noted down earlier and save.

Final: This should be all, you create an alert rule, which is supposed to be fired when condition met, it would trigger the runbook, which would send an email with the specified format as customized in the code.

Let me know what you think of the solution, what use cases you can think of the same and what improvements are possible.

PS Script to perform complete AD environment Assessment

It’s been a while since I blogged about something, so when I almost finished the script, I thought to post the same.

It’s quite long (about 3000 lines+ and counting) and I know what I think when I see long script, DO NOT RUN it since it’s hard to understand what it’s doing. But DO NOT GET OEVRWHELMED in this case as I have kept the code clean and modular. All tasks are being done by smaller functions, most of which should not be even 30 lines long and hence easier to understand.

The output as tested in a production environment been around 1Mb+ with two dozens domain controllers similar number of sites etc. The script is aims to be swiss army knife for AD Admins

The script performs READ operations only (except one place) even though it does need domain admin credentials and Enterprise admin credentials if you looking to get info from entire forest. The only WRITE operation it performs is in running secedit.exe and generating a file after reading security settings so that it can collect the data from that. The file gets stored in temp and gets deleted as well during script run. I will keep on looking for ways to do without that but thought to add the disclaimer.

Continue reading “PS Script to perform complete AD environment Assessment”

Collection of PS Functions for useful GUI elements

This time, I have started a project to create library of useful PowerShell functions to create UI elements like OkCancel box, FileBrowser, Ballon Notification and most lovely toast notifications. Would keep on adding the functions as the use cases come to mind. The aim is to keep it simple, readable and portable with least dependencies.

Let’s see how it goes. Any suggestions welcome

Continue reading “Collection of PS Functions for useful GUI elements”