mirror of
https://dev.azure.com/effectory/Survey%20Software/_git/Cloud%20Engineering
synced 2026-02-27 18:52:18 +01:00
added documetation
This commit is contained in:
@@ -1,27 +1,146 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Exports comprehensive Azure Alert Rules inventory across all enabled subscriptions with associated action groups and configuration details.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This script performs a complete audit of Azure Alert Rules across multiple alert types and all enabled subscriptions.
|
||||||
|
It inventories Smart Detector Alert Rules, Scheduled Query Rules, Metric Alerts, and Activity Log Alerts,
|
||||||
|
including their associated Action Groups, receivers, and tag information.
|
||||||
|
|
||||||
|
The script processes four main types of Azure alerts:
|
||||||
|
- Smart Detector Alert Rules (Application Insights anomaly detection)
|
||||||
|
- Scheduled Query Rules (Log Analytics/KQL-based alerts)
|
||||||
|
- Metric Alert Rules (Resource metric-based alerts)
|
||||||
|
- Activity Log Alert Rules (Azure Activity Log event alerts)
|
||||||
|
|
||||||
|
For each alert rule, the script captures detailed information including:
|
||||||
|
- Alert configuration and state
|
||||||
|
- Associated Action Groups and their receivers
|
||||||
|
- Tag information for governance tracking
|
||||||
|
- Subscription and resource group context
|
||||||
|
|
||||||
|
.PARAMETER None
|
||||||
|
This script does not accept parameters. It processes all enabled Azure subscriptions accessible to the current user.
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
CSV file named with timestamp pattern: "yyyy-MM-dd HHmm alert rules.csv"
|
||||||
|
Also displays results in formatted table output to console.
|
||||||
|
|
||||||
|
CSV contains columns for alert details, action group information, and governance tags.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\AlertRules.ps1
|
||||||
|
|
||||||
|
Exports all alert rules from all enabled subscriptions to a timestamped CSV file and displays results.
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Author: Cloud Engineering Team
|
||||||
|
Version: 1.0
|
||||||
|
Created: 2024
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
- Azure PowerShell module (Az) must be installed
|
||||||
|
- User must be authenticated (Connect-AzAccount)
|
||||||
|
- Requires read permissions on Azure Monitor, Action Groups, and resource tags across all subscriptions
|
||||||
|
- Tenant ID is hardcoded and may need adjustment for different environments
|
||||||
|
|
||||||
|
Security Considerations:
|
||||||
|
- Script uses Azure access tokens for REST API authentication
|
||||||
|
- Requires permissions to read alert rules and action groups across all subscriptions
|
||||||
|
- Output file contains sensitive alerting configuration information
|
||||||
|
|
||||||
|
Performance Notes:
|
||||||
|
- Processing time varies based on number of subscriptions and alert rules
|
||||||
|
- Script processes all enabled subscriptions sequentially
|
||||||
|
- REST API calls for Smart Detector rules add processing time
|
||||||
|
|
||||||
|
Alert Types Covered:
|
||||||
|
- microsoft.alertsmanagement/smartdetectoralertrules (Application Insights anomalies)
|
||||||
|
- microsoft.insights/scheduledqueryrules (Log Analytics queries)
|
||||||
|
- Microsoft.Insights/metricAlerts (Resource metrics)
|
||||||
|
- Microsoft.Insights/ActivityLogAlerts (Activity log events)
|
||||||
|
|
||||||
|
.LINK
|
||||||
|
https://docs.microsoft.com/en-us/azure/azure-monitor/alerts/
|
||||||
|
https://docs.microsoft.com/en-us/azure/azure-monitor/alerts/action-groups
|
||||||
|
#>
|
||||||
|
|
||||||
|
#Requires -Modules Az
|
||||||
#Connect-AzAccount
|
#Connect-AzAccount
|
||||||
|
|
||||||
$access_token = (Get-AzAccessToken -TenantId "e9792fd7-4044-47e7-a40d-3fba46f1cd09").Token
|
# Get Azure access token for REST API calls (required for Smart Detector rules)
|
||||||
|
# Note: Tenant ID is hardcoded and should be updated for different environments
|
||||||
|
$access_token_secure = (Get-AzAccessToken -TenantId "e9792fd7-4044-47e7-a40d-3fba46f1cd09").Token
|
||||||
|
$access_token = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto([System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($access_token_secure))
|
||||||
|
|
||||||
|
# Set output field separator for array-to-string conversion
|
||||||
$ofs = ', '
|
$ofs = ', '
|
||||||
|
|
||||||
function GetSmartDetectorActionGroupIds {
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Retrieves Action Group IDs and details for Smart Detector Alert Rules using Azure Management REST API.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This function queries the Azure Management REST API to retrieve detailed information about Smart Detector Alert Rules,
|
||||||
|
including their associated Action Groups. Smart Detector rules are used for Application Insights anomaly detection
|
||||||
|
and require REST API calls as they're not fully supported by PowerShell cmdlets.
|
||||||
|
|
||||||
|
.PARAMETER alertRuleName
|
||||||
|
The name of the Smart Detector Alert Rule to query.
|
||||||
|
|
||||||
|
.PARAMETER resourceGroupName
|
||||||
|
The resource group containing the Smart Detector Alert Rule.
|
||||||
|
|
||||||
|
.PARAMETER subscriptionId
|
||||||
|
The subscription ID containing the alert rule.
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
Returns an array of custom objects containing alert rule details and associated Action Group IDs.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
GetSmartDetectorActionGroupIds -alertRuleName "Failure Anomalies - authorization-functions-v2" -resourceGroupName "authorization" -subscriptionId "3190b0fd-4a66-4636-a204-5b9f18be78a6"
|
||||||
|
|
||||||
|
Retrieves Action Group details for the specified Smart Detector Alert Rule.
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
- Uses REST API version 2019-06-01 for Smart Detector Alert Rules
|
||||||
|
- Requires valid Azure access token for authentication
|
||||||
|
- URL-encodes alert rule names to handle special characters
|
||||||
|
#>
|
||||||
|
function GetSmartDetectorActionGroupIds {
|
||||||
param (
|
param (
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
[string] $alertRuleName,
|
[string] $alertRuleName,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
[string] $resourceGroupName,
|
[string] $resourceGroupName,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
[string] $subscriptionId
|
[string] $subscriptionId
|
||||||
)
|
)
|
||||||
|
|
||||||
## example : GetSmartDetectorActionGroupIds -alertRuleName "Failure Anomalies - authorization-functions-v2" -resourceGroupName "authorization" -subscriptionId "3190b0fd-4a66-4636-a204-5b9f18be78a6"
|
try {
|
||||||
|
# URL-encode the alert rule name to handle special characters
|
||||||
$escapedAlertRuleName = [uri]::EscapeDataString($alertRuleName)
|
$escapedAlertRuleName = [uri]::EscapeDataString($alertRuleName)
|
||||||
|
|
||||||
|
# Construct REST API URL for Smart Detector Alert Rule
|
||||||
$url = "https://management.azure.com/subscriptions/$subscriptionId/resourceGroups/$resourceGroupName/providers/microsoft.alertsManagement/smartDetectorAlertRules/$escapedAlertRuleName`?api-version=2019-06-01"
|
$url = "https://management.azure.com/subscriptions/$subscriptionId/resourceGroups/$resourceGroupName/providers/microsoft.alertsManagement/smartDetectorAlertRules/$escapedAlertRuleName`?api-version=2019-06-01"
|
||||||
$head = @{ Authorization =" Bearer $access_token" }
|
|
||||||
|
# Create authorization header with bearer token
|
||||||
|
$head = @{ Authorization = " Bearer $access_token" }
|
||||||
|
|
||||||
|
# Execute REST API call to retrieve Smart Detector rule details
|
||||||
$response = Invoke-RestMethod -Uri $url -Method GET -Headers $head
|
$response = Invoke-RestMethod -Uri $url -Method GET -Headers $head
|
||||||
|
|
||||||
|
# Process response and extract Action Group information
|
||||||
$response | ForEach-Object {
|
$response | ForEach-Object {
|
||||||
$alert = $_
|
$alert = $_
|
||||||
$alert.properties.actionGroups
|
|
||||||
| ForEach-Object {
|
# Process each Action Group associated with the alert rule
|
||||||
|
$alert.properties.actionGroups | ForEach-Object {
|
||||||
$actionGroup = $_
|
$actionGroup = $_
|
||||||
|
|
||||||
|
# Extract individual Action Group IDs
|
||||||
$_.groupIds | ForEach-Object {
|
$_.groupIds | ForEach-Object {
|
||||||
[pscustomobject]@{
|
[pscustomobject]@{
|
||||||
Id = $alert.id
|
Id = $alert.id
|
||||||
@@ -36,70 +155,140 @@ function GetSmartDetectorActionGroupIds {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning "Failed to retrieve Smart Detector Alert Rule: $alertRuleName in $resourceGroupName. Error: $($_.Exception.Message)"
|
||||||
|
return $null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Sanitizes alert rule descriptions for CSV export by removing newline characters.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This utility function cleans up alert rule descriptions by replacing newline and carriage return
|
||||||
|
characters with hyphens to ensure proper CSV formatting. It also removes duplicate hyphens
|
||||||
|
that might result from the replacement process.
|
||||||
|
|
||||||
|
.PARAMETER description
|
||||||
|
The description string to sanitize. Can be null or empty.
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
Returns a cleaned description string suitable for CSV export, or empty string if input is null.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
GetDecentDescription -description "Line 1`nLine 2`rLine 3"
|
||||||
|
|
||||||
|
Returns "Line 1 - Line 2 - Line 3"
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
- Handles null input gracefully
|
||||||
|
- Replaces both Unix (`n) and Windows (`r) newline characters
|
||||||
|
- Removes duplicate hyphens that may result from consecutive newlines
|
||||||
|
#>
|
||||||
function GetDecentDescription {
|
function GetDecentDescription {
|
||||||
param (
|
param (
|
||||||
|
[AllowEmptyString()]
|
||||||
[string] $description
|
[string] $description
|
||||||
)
|
)
|
||||||
|
|
||||||
if ($null -eq $description) {
|
# Handle null or empty descriptions
|
||||||
""
|
if ($null -eq $description -or $description -eq "") {
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
$description.Replace("`n"," - ").Replace("`r"," - ").Replace(" - - "," - ")
|
# Replace newline characters with hyphens and clean up duplicates
|
||||||
|
return $description.Replace("`n", " - ").Replace("`r", " - ").Replace(" - - ", " - ")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Main script execution begins
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "Starting comprehensive Azure Alert Rules inventory across all enabled subscriptions."
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
|
||||||
|
# Generate timestamped filename for CSV export
|
||||||
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
||||||
$fileName = ".\$date alert rules.csv"
|
$fileName = ".\$date alert rules.csv"
|
||||||
|
Write-Host "Output file: $fileName"
|
||||||
|
|
||||||
|
# Retrieve all enabled Azure subscriptions
|
||||||
|
Write-Host "Retrieving enabled subscriptions..."
|
||||||
$subscriptions = Get-AzSubscription | Where-Object State -eq "Enabled"
|
$subscriptions = Get-AzSubscription | Where-Object State -eq "Enabled"
|
||||||
|
Write-Host "Found $($subscriptions.Count) enabled subscription(s) to process."
|
||||||
|
|
||||||
|
# Class definition for structured alert rule data
|
||||||
class AlertRule {
|
class AlertRule {
|
||||||
[string] $SubscriptionId = ""
|
# Subscription and resource context
|
||||||
[string] $SubscriptionName = ""
|
[string] $SubscriptionId = "" # Azure subscription GUID
|
||||||
[string] $Id = ""
|
[string] $SubscriptionName = "" # Subscription display name
|
||||||
[string] $ResourceGroupName = ""
|
[string] $Id = "" # Full Azure resource ID of the alert rule
|
||||||
[string] $Type = ""
|
[string] $ResourceGroupName = "" # Resource group containing the alert rule
|
||||||
[string] $Name = ""
|
[string] $Type = "" # Azure resource type of the alert rule
|
||||||
[string] $Description = ""
|
[string] $Name = "" # Alert rule name
|
||||||
[string] $State = ""
|
|
||||||
[string] $ActionGroupId = ""
|
|
||||||
[string] $ActionGroupName = ""
|
|
||||||
[string] $ActionGroupResourceGroupName = ""
|
|
||||||
[string] $ActionGroupEnabled = ""
|
|
||||||
[string] $ActionGroupArmRoleReceivers = ""
|
|
||||||
[string] $ActionGroupEmailReceivers = ""
|
|
||||||
[string] $AzureFunctionReceivers = ""
|
|
||||||
[string] $Tag_Team = ""
|
|
||||||
[string] $Tag_Product = ""
|
|
||||||
[string] $Tag_Environment = ""
|
|
||||||
[string] $Tag_Data = ""
|
|
||||||
[string] $Tag_CreatedOnDate = ""
|
|
||||||
[string] $Tag_Deployment = ""
|
|
||||||
|
|
||||||
|
# Alert rule configuration
|
||||||
|
[string] $Description = "" # Alert rule description (sanitized for CSV)
|
||||||
|
[string] $State = "" # Alert rule state (Enabled/Disabled)
|
||||||
|
|
||||||
|
# Action Group associations
|
||||||
|
[string] $ActionGroupId = "" # Associated Action Group resource ID
|
||||||
|
[string] $ActionGroupName = "" # Action Group name
|
||||||
|
[string] $ActionGroupResourceGroupName = "" # Resource group containing the Action Group
|
||||||
|
[string] $ActionGroupEnabled = "" # Action Group enabled status
|
||||||
|
|
||||||
|
# Action Group receiver details
|
||||||
|
[string] $ActionGroupArmRoleReceivers = "" # ARM role-based receivers (comma-separated)
|
||||||
|
[string] $ActionGroupEmailReceivers = "" # Email receivers (comma-separated)
|
||||||
|
[string] $AzureFunctionReceivers = "" # Azure Function receivers (comma-separated)
|
||||||
|
|
||||||
|
# Governance and metadata tags
|
||||||
|
[string] $Tag_Team = "" # Team responsible for the alert
|
||||||
|
[string] $Tag_Product = "" # Product/service associated with the alert
|
||||||
|
[string] $Tag_Environment = "" # Environment (dev, test, prod, etc.)
|
||||||
|
[string] $Tag_Data = "" # Data classification tag
|
||||||
|
[string] $Tag_CreatedOnDate = "" # Creation date tag
|
||||||
|
[string] $Tag_Deployment = "" # Deployment pipeline tag
|
||||||
}
|
}
|
||||||
|
|
||||||
[Microsoft.Azure.Commands.Insights.OutputClasses.PSActionGroupResource[]]$actionGroups = @()
|
# Pre-load all Action Groups from all subscriptions for efficient lookup
|
||||||
foreach ($subscription in $subscriptions)
|
Write-Host "Pre-loading Action Groups from all subscriptions for efficient processing..."
|
||||||
{
|
[Microsoft.Azure.PowerShell.Cmdlets.Monitor.ActionGroup.Models.IActionGroupResource[]]$actionGroups = @()
|
||||||
Set-AzContext -SubscriptionId $subscription.Id | out-null
|
|
||||||
|
foreach ($subscription in $subscriptions) {
|
||||||
|
Write-Host " Loading Action Groups from subscription: $($subscription.Name)"
|
||||||
|
Set-AzContext -SubscriptionId $subscription.Id | Out-Null
|
||||||
$actionGroups += Get-AzActionGroup
|
$actionGroups += Get-AzActionGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Write-Host "Loaded $($actionGroups.Count) Action Group(s) across all subscriptions."
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Initialize result collection for all alert rules
|
||||||
[AlertRule[]]$Result = @()
|
[AlertRule[]]$Result = @()
|
||||||
|
|
||||||
foreach ($subscription in $subscriptions)
|
# Process each subscription for alert rules
|
||||||
{
|
foreach ($subscription in $subscriptions) {
|
||||||
Set-AzContext -SubscriptionId $subscription.Id
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "Processing subscription: [$($subscription.Name)] - $($subscription.Id)"
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
|
||||||
|
# Set Azure context to current subscription
|
||||||
|
Set-AzContext -SubscriptionId $subscription.Id | Out-Null
|
||||||
|
|
||||||
|
# Process Smart Detector Alert Rules (Application Insights anomaly detection)
|
||||||
|
Write-Host "Processing Smart Detector Alert Rules..."
|
||||||
$smartDetectorRules = Get-AzResource -ResourceType "microsoft.alertsmanagement/smartdetectoralertrules"
|
$smartDetectorRules = Get-AzResource -ResourceType "microsoft.alertsmanagement/smartdetectoralertrules"
|
||||||
foreach ($smartDetectorRule in $smartDetectorRules)
|
Write-Host " Found $($smartDetectorRules.Count) Smart Detector Alert Rule(s)"
|
||||||
{
|
|
||||||
|
foreach ($smartDetectorRule in $smartDetectorRules) {
|
||||||
|
# Retrieve Action Group details for the Smart Detector rule via REST API
|
||||||
$actions = GetSmartDetectorActionGroupIds -alertRuleName $smartDetectorRule.Name -resourceGroupName $smartDetectorRule.ResourceGroupName -subscriptionId $subscription.Id
|
$actions = GetSmartDetectorActionGroupIds -alertRuleName $smartDetectorRule.Name -resourceGroupName $smartDetectorRule.ResourceGroupName -subscriptionId $subscription.Id
|
||||||
|
|
||||||
|
# Handle Smart Detector rules without Action Groups
|
||||||
if (($null -eq $actions) -or ($actions.Length -eq 0)) {
|
if (($null -eq $actions) -or ($actions.Length -eq 0)) {
|
||||||
|
# Create alert rule entry without Action Group details
|
||||||
[AlertRule] $AlertRule = [AlertRule]::new()
|
[AlertRule] $AlertRule = [AlertRule]::new()
|
||||||
|
|
||||||
$AlertRule.SubscriptionId = $subscription.Id
|
$AlertRule.SubscriptionId = $subscription.Id
|
||||||
@@ -108,6 +297,8 @@ foreach ($subscription in $subscriptions)
|
|||||||
$AlertRule.Name = $smartDetectorRule.Name
|
$AlertRule.Name = $smartDetectorRule.Name
|
||||||
$AlertRule.Type = $smartDetectorRule.ResourceType
|
$AlertRule.Type = $smartDetectorRule.ResourceType
|
||||||
$AlertRule.ResourceGroupName = $smartDetectorRule.ResourceGroupName
|
$AlertRule.ResourceGroupName = $smartDetectorRule.ResourceGroupName
|
||||||
|
|
||||||
|
# Extract governance tags
|
||||||
$AlertRule.Tag_Team = $smartDetectorRule.Tags.team
|
$AlertRule.Tag_Team = $smartDetectorRule.Tags.team
|
||||||
$AlertRule.Tag_Product = $smartDetectorRule.Tags.product
|
$AlertRule.Tag_Product = $smartDetectorRule.Tags.product
|
||||||
$AlertRule.Tag_Environment = $smartDetectorRule.Tags.environment
|
$AlertRule.Tag_Environment = $smartDetectorRule.Tags.environment
|
||||||
@@ -118,11 +309,15 @@ foreach ($subscription in $subscriptions)
|
|||||||
$Result += $AlertRule
|
$Result += $AlertRule
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
foreach($action in $actions) {
|
# Process Smart Detector rules with Action Groups
|
||||||
|
foreach ($action in $actions) {
|
||||||
|
# Create alert rule entry with Action Group details
|
||||||
[AlertRule] $AlertRule = [AlertRule]::new()
|
[AlertRule] $AlertRule = [AlertRule]::new()
|
||||||
|
|
||||||
$actionGroup = $actionGroups | where { $_.id -eq [uri]::UnescapeDataString($action.ActionGroupId) }
|
# Find corresponding Action Group from pre-loaded collection
|
||||||
|
$actionGroup = $actionGroups | Where-Object { $_.id -eq [uri]::UnescapeDataString($action.ActionGroupId) }
|
||||||
|
|
||||||
|
# Populate basic alert rule information
|
||||||
$AlertRule.SubscriptionId = $subscription.Id
|
$AlertRule.SubscriptionId = $subscription.Id
|
||||||
$AlertRule.SubscriptionName = $subscription.Name
|
$AlertRule.SubscriptionName = $subscription.Name
|
||||||
$AlertRule.Id = $smartDetectorRule.Id
|
$AlertRule.Id = $smartDetectorRule.Id
|
||||||
@@ -133,15 +328,19 @@ foreach ($subscription in $subscriptions)
|
|||||||
$AlertRule.State = $action.State
|
$AlertRule.State = $action.State
|
||||||
$AlertRule.ActionGroupId = $action.ActionGroupId
|
$AlertRule.ActionGroupId = $action.ActionGroupId
|
||||||
|
|
||||||
|
# Populate Action Group details if found
|
||||||
if ($null -ne $actionGroup) {
|
if ($null -ne $actionGroup) {
|
||||||
$AlertRule.ActionGroupName = $actionGroup.Name
|
$AlertRule.ActionGroupName = $actionGroup.Name
|
||||||
$AlertRule.ActionGroupResourceGroupName = $actionGroup.ResourceGroupName
|
$AlertRule.ActionGroupResourceGroupName = $actionGroup.ResourceGroupName
|
||||||
$AlertRule.ActionGroupEnabled = $actionGroup.Enabled
|
$AlertRule.ActionGroupEnabled = $actionGroup.Enabled
|
||||||
|
|
||||||
|
# Extract receiver information (convert arrays to comma-separated strings)
|
||||||
$AlertRule.ActionGroupArmRoleReceivers = [string] ( $actionGroup.ArmRoleReceivers | ForEach-Object { $_.Name } )
|
$AlertRule.ActionGroupArmRoleReceivers = [string] ( $actionGroup.ArmRoleReceivers | ForEach-Object { $_.Name } )
|
||||||
$AlertRule.ActionGroupEmailReceivers = [string] ( $actionGroup.EmailReceivers | ForEach-Object { $_.EmailAddress } )
|
$AlertRule.ActionGroupEmailReceivers = [string] ( $actionGroup.EmailReceivers | ForEach-Object { $_.EmailAddress } )
|
||||||
$AlertRule.AzureFunctionReceivers = [string] ($actionGroup.AzureFunctionReceivers | ForEach-Object { $_.FunctionName } )
|
$AlertRule.AzureFunctionReceivers = [string] ($actionGroup.AzureFunctionReceivers | ForEach-Object { $_.FunctionName } )
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Extract governance tags
|
||||||
$AlertRule.Tag_Team = $smartDetectorRule.Tags.team
|
$AlertRule.Tag_Team = $smartDetectorRule.Tags.team
|
||||||
$AlertRule.Tag_Product = $smartDetectorRule.Tags.product
|
$AlertRule.Tag_Product = $smartDetectorRule.Tags.product
|
||||||
$AlertRule.Tag_Environment = $smartDetectorRule.Tags.environment
|
$AlertRule.Tag_Environment = $smartDetectorRule.Tags.environment
|
||||||
@@ -154,14 +353,19 @@ foreach ($subscription in $subscriptions)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# microsoft.insights/scheduledqueryrules
|
# Process Scheduled Query Rules (Log Analytics/KQL-based alerts)
|
||||||
|
Write-Host "Processing Scheduled Query Rules (Log Analytics alerts)..."
|
||||||
$scheduledQueryRules = Get-AzScheduledQueryRule
|
$scheduledQueryRules = Get-AzScheduledQueryRule
|
||||||
$scheduledQueryRulesResources = Get-AzResource -ResourceType "microsoft.insights/scheduledqueryrules"
|
$scheduledQueryRulesResources = Get-AzResource -ResourceType "microsoft.insights/scheduledqueryrules"
|
||||||
foreach($scheduledQueryRule in $scheduledQueryRules) {
|
Write-Host " Found $($scheduledQueryRules.Count) Scheduled Query Rule(s)"
|
||||||
$resource = $scheduledQueryRulesResources | where { $_.id -eq $scheduledQueryRule.Id }
|
|
||||||
|
|
||||||
if (($null -eq $scheduledQueryRule.ActionGroup) -or ($scheduledQueryRule.ActionGroup.Length -eq 0))
|
foreach ($scheduledQueryRule in $scheduledQueryRules) {
|
||||||
{
|
# Get corresponding resource for tag information
|
||||||
|
$resource = $scheduledQueryRulesResources | Where-Object { $_.id -eq $scheduledQueryRule.Id }
|
||||||
|
|
||||||
|
# Handle Scheduled Query Rules without Action Groups
|
||||||
|
if (($null -eq $scheduledQueryRule.ActionGroup) -or ($scheduledQueryRule.ActionGroup.Length -eq 0)) {
|
||||||
|
# Create alert rule entry without Action Group details
|
||||||
[AlertRule] $AlertRule = [AlertRule]::new()
|
[AlertRule] $AlertRule = [AlertRule]::new()
|
||||||
$AlertRule.SubscriptionId = $subscription.Id
|
$AlertRule.SubscriptionId = $subscription.Id
|
||||||
$AlertRule.SubscriptionName = $subscription.Name
|
$AlertRule.SubscriptionName = $subscription.Name
|
||||||
@@ -171,21 +375,27 @@ foreach ($subscription in $subscriptions)
|
|||||||
$AlertRule.ResourceGroupName = $resource.ResourceGroupName
|
$AlertRule.ResourceGroupName = $resource.ResourceGroupName
|
||||||
$AlertRule.Description = GetDecentDescription $scheduledQueryRule.Description
|
$AlertRule.Description = GetDecentDescription $scheduledQueryRule.Description
|
||||||
$AlertRule.State = $scheduledQueryRule.Enabled -eq $true ? "Enabled" : "Disabled"
|
$AlertRule.State = $scheduledQueryRule.Enabled -eq $true ? "Enabled" : "Disabled"
|
||||||
$AlertRule.Tag_Team = $smartDetectorRule.Tags.team
|
|
||||||
$AlertRule.Tag_Product = $smartDetectorRule.Tags.product
|
# Extract governance tags from the resource (note: using $resource instead of $smartDetectorRule)
|
||||||
$AlertRule.Tag_Environment = $smartDetectorRule.Tags.environment
|
$AlertRule.Tag_Team = $resource.Tags.team
|
||||||
$AlertRule.Tag_Data = $smartDetectorRule.Tags.data
|
$AlertRule.Tag_Product = $resource.Tags.product
|
||||||
$AlertRule.Tag_CreatedOnDate = $smartDetectorRule.Tags.CreatedOnDate
|
$AlertRule.Tag_Environment = $resource.Tags.environment
|
||||||
$AlertRule.Tag_Deployment = $smartDetectorRule.Tags.drp_deployment
|
$AlertRule.Tag_Data = $resource.Tags.data
|
||||||
|
$AlertRule.Tag_CreatedOnDate = $resource.Tags.CreatedOnDate
|
||||||
|
$AlertRule.Tag_Deployment = $resource.Tags.drp_deployment
|
||||||
|
|
||||||
$Result += $AlertRule
|
$Result += $AlertRule
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
foreach($action in $scheduledQueryRule.ActionGroup) {
|
# Process Scheduled Query Rules with Action Groups
|
||||||
|
foreach ($action in $scheduledQueryRule.ActionGroup) {
|
||||||
|
# Create alert rule entry with Action Group details
|
||||||
[AlertRule] $AlertRule = [AlertRule]::new()
|
[AlertRule] $AlertRule = [AlertRule]::new()
|
||||||
|
|
||||||
$actionGroup = $actionGroups | where { $_.id -eq [uri]::UnescapeDataString($action) }
|
# Find corresponding Action Group from pre-loaded collection
|
||||||
|
$actionGroup = $actionGroups | Where-Object { $_.id -eq [uri]::UnescapeDataString($action) }
|
||||||
|
|
||||||
|
# Populate basic alert rule information
|
||||||
$AlertRule.SubscriptionId = $subscription.Id
|
$AlertRule.SubscriptionId = $subscription.Id
|
||||||
$AlertRule.SubscriptionName = $subscription.Name
|
$AlertRule.SubscriptionName = $subscription.Name
|
||||||
$AlertRule.Id = $scheduledQueryRule.Id
|
$AlertRule.Id = $scheduledQueryRule.Id
|
||||||
@@ -196,32 +406,40 @@ foreach ($subscription in $subscriptions)
|
|||||||
$AlertRule.State = $scheduledQueryRule.Enabled -eq $true ? "Enabled" : "Disabled"
|
$AlertRule.State = $scheduledQueryRule.Enabled -eq $true ? "Enabled" : "Disabled"
|
||||||
$AlertRule.ActionGroupId = $action
|
$AlertRule.ActionGroupId = $action
|
||||||
|
|
||||||
|
# Populate Action Group details if found
|
||||||
if ($null -ne $actionGroup) {
|
if ($null -ne $actionGroup) {
|
||||||
$AlertRule.ActionGroupName = $actionGroup.Name
|
$AlertRule.ActionGroupName = $actionGroup.Name
|
||||||
$AlertRule.ActionGroupResourceGroupName = $actionGroup.ResourceGroupName
|
$AlertRule.ActionGroupResourceGroupName = $actionGroup.ResourceGroupName
|
||||||
$AlertRule.ActionGroupEnabled = $actionGroup.Enabled
|
$AlertRule.ActionGroupEnabled = $actionGroup.Enabled
|
||||||
|
|
||||||
|
# Extract receiver information (convert arrays to comma-separated strings)
|
||||||
$AlertRule.ActionGroupArmRoleReceivers = [string] ( $actionGroup.ArmRoleReceivers | ForEach-Object { $_.Name } )
|
$AlertRule.ActionGroupArmRoleReceivers = [string] ( $actionGroup.ArmRoleReceivers | ForEach-Object { $_.Name } )
|
||||||
$AlertRule.ActionGroupEmailReceivers = [string] ( $actionGroup.EmailReceivers | ForEach-Object { $_.EmailAddress } )
|
$AlertRule.ActionGroupEmailReceivers = [string] ( $actionGroup.EmailReceivers | ForEach-Object { $_.EmailAddress } )
|
||||||
$AlertRule.AzureFunctionReceivers = [string] ($actionGroup.AzureFunctionReceivers | ForEach-Object { $_.FunctionName } )
|
$AlertRule.AzureFunctionReceivers = [string] ($actionGroup.AzureFunctionReceivers | ForEach-Object { $_.FunctionName } )
|
||||||
}
|
}
|
||||||
|
|
||||||
$AlertRule.Tag_Team = $smartDetectorRule.Tags.team
|
# Extract governance tags from the resource
|
||||||
$AlertRule.Tag_Product = $smartDetectorRule.Tags.product
|
$AlertRule.Tag_Team = $resource.Tags.team
|
||||||
$AlertRule.Tag_Environment = $smartDetectorRule.Tags.environment
|
$AlertRule.Tag_Product = $resource.Tags.product
|
||||||
$AlertRule.Tag_Data = $smartDetectorRule.Tags.data
|
$AlertRule.Tag_Environment = $resource.Tags.environment
|
||||||
$AlertRule.Tag_CreatedOnDate = $smartDetectorRule.Tags.CreatedOnDate
|
$AlertRule.Tag_Data = $resource.Tags.data
|
||||||
$AlertRule.Tag_Deployment = $smartDetectorRule.Tags.drp_deployment
|
$AlertRule.Tag_CreatedOnDate = $resource.Tags.CreatedOnDate
|
||||||
|
$AlertRule.Tag_Deployment = $resource.Tags.drp_deployment
|
||||||
|
|
||||||
$Result += $AlertRule
|
$Result += $AlertRule
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Microsoft.Insights/metricAlerts
|
# Process Metric Alert Rules (Resource metric-based alerts)
|
||||||
|
Write-Host "Processing Metric Alert Rules..."
|
||||||
$metricAlerts = Get-AzMetricAlertRuleV2
|
$metricAlerts = Get-AzMetricAlertRuleV2
|
||||||
foreach($metricAlert in $metricAlerts) {
|
Write-Host " Found $($metricAlerts.Count) Metric Alert Rule(s)"
|
||||||
if (($null -eq $metricAlert.Actions) -or ($metricAlert.Actions.Length -eq 0))
|
|
||||||
{
|
foreach ($metricAlert in $metricAlerts) {
|
||||||
|
# Handle Metric Alerts without Action Groups
|
||||||
|
if (($null -eq $metricAlert.Actions) -or ($metricAlert.Actions.Length -eq 0)) {
|
||||||
|
# Create alert rule entry without Action Group details
|
||||||
[AlertRule] $AlertRule = [AlertRule]::new()
|
[AlertRule] $AlertRule = [AlertRule]::new()
|
||||||
$AlertRule.SubscriptionId = $subscription.Id
|
$AlertRule.SubscriptionId = $subscription.Id
|
||||||
$AlertRule.SubscriptionName = $subscription.Name
|
$AlertRule.SubscriptionName = $subscription.Name
|
||||||
@@ -231,6 +449,8 @@ foreach ($subscription in $subscriptions)
|
|||||||
$AlertRule.ResourceGroupName = $metricAlert.ResourceGroup
|
$AlertRule.ResourceGroupName = $metricAlert.ResourceGroup
|
||||||
$AlertRule.Description = GetDecentDescription $metricAlert.Description
|
$AlertRule.Description = GetDecentDescription $metricAlert.Description
|
||||||
$AlertRule.State = $metricAlert.Enabled -eq $true ? "Enabled" : "Disabled"
|
$AlertRule.State = $metricAlert.Enabled -eq $true ? "Enabled" : "Disabled"
|
||||||
|
|
||||||
|
# Extract governance tags
|
||||||
$AlertRule.Tag_Team = $metricAlert.Tags.team
|
$AlertRule.Tag_Team = $metricAlert.Tags.team
|
||||||
$AlertRule.Tag_Product = $metricAlert.Tags.product
|
$AlertRule.Tag_Product = $metricAlert.Tags.product
|
||||||
$AlertRule.Tag_Environment = $metricAlert.Tags.environment
|
$AlertRule.Tag_Environment = $metricAlert.Tags.environment
|
||||||
@@ -241,11 +461,15 @@ foreach ($subscription in $subscriptions)
|
|||||||
$Result += $AlertRule
|
$Result += $AlertRule
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
foreach($action in $metricAlert.Actions) {
|
# Process Metric Alerts with Action Groups
|
||||||
|
foreach ($action in $metricAlert.Actions) {
|
||||||
|
# Create alert rule entry with Action Group details
|
||||||
[AlertRule] $AlertRule = [AlertRule]::new()
|
[AlertRule] $AlertRule = [AlertRule]::new()
|
||||||
|
|
||||||
$actionGroup = $actionGroups | where { $_.id -eq [uri]::UnescapeDataString($action.ActionGroupId) }
|
# Find corresponding Action Group from pre-loaded collection
|
||||||
|
$actionGroup = $actionGroups | Where-Object { $_.id -eq [uri]::UnescapeDataString($action.ActionGroupId) }
|
||||||
|
|
||||||
|
# Populate basic alert rule information
|
||||||
$AlertRule.SubscriptionId = $subscription.Id
|
$AlertRule.SubscriptionId = $subscription.Id
|
||||||
$AlertRule.SubscriptionName = $subscription.Name
|
$AlertRule.SubscriptionName = $subscription.Name
|
||||||
$AlertRule.Id = $metricAlert.Id
|
$AlertRule.Id = $metricAlert.Id
|
||||||
@@ -256,15 +480,19 @@ foreach ($subscription in $subscriptions)
|
|||||||
$AlertRule.State = $metricAlert.Enabled -eq $true ? "Enabled" : "Disabled"
|
$AlertRule.State = $metricAlert.Enabled -eq $true ? "Enabled" : "Disabled"
|
||||||
$AlertRule.ActionGroupId = $action.ActionGroupId
|
$AlertRule.ActionGroupId = $action.ActionGroupId
|
||||||
|
|
||||||
|
# Populate Action Group details if found
|
||||||
if ($null -ne $actionGroup) {
|
if ($null -ne $actionGroup) {
|
||||||
$AlertRule.ActionGroupName = $actionGroup.Name
|
$AlertRule.ActionGroupName = $actionGroup.Name
|
||||||
$AlertRule.ActionGroupResourceGroupName = $actionGroup.ResourceGroupName
|
$AlertRule.ActionGroupResourceGroupName = $actionGroup.ResourceGroupName
|
||||||
$AlertRule.ActionGroupEnabled = $actionGroup.Enabled
|
$AlertRule.ActionGroupEnabled = $actionGroup.Enabled
|
||||||
|
|
||||||
|
# Extract receiver information (convert arrays to comma-separated strings)
|
||||||
$AlertRule.ActionGroupArmRoleReceivers = [string] ( $actionGroup.ArmRoleReceivers | ForEach-Object { $_.Name } )
|
$AlertRule.ActionGroupArmRoleReceivers = [string] ( $actionGroup.ArmRoleReceivers | ForEach-Object { $_.Name } )
|
||||||
$AlertRule.ActionGroupEmailReceivers = [string] ( $actionGroup.EmailReceivers | ForEach-Object { $_.EmailAddress } )
|
$AlertRule.ActionGroupEmailReceivers = [string] ( $actionGroup.EmailReceivers | ForEach-Object { $_.EmailAddress } )
|
||||||
$AlertRule.AzureFunctionReceivers = [string] ($actionGroup.AzureFunctionReceivers | ForEach-Object { $_.FunctionName } )
|
$AlertRule.AzureFunctionReceivers = [string] ($actionGroup.AzureFunctionReceivers | ForEach-Object { $_.FunctionName } )
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Extract governance tags
|
||||||
$AlertRule.Tag_Team = $metricAlert.Tags.team
|
$AlertRule.Tag_Team = $metricAlert.Tags.team
|
||||||
$AlertRule.Tag_Product = $metricAlert.Tags.product
|
$AlertRule.Tag_Product = $metricAlert.Tags.product
|
||||||
$AlertRule.Tag_Environment = $metricAlert.Tags.environment
|
$AlertRule.Tag_Environment = $metricAlert.Tags.environment
|
||||||
@@ -277,13 +505,15 @@ foreach ($subscription in $subscriptions)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Process Activity Log Alert Rules (Azure Activity Log event alerts)
|
||||||
# Microsoft.Insights/ActivityLogAlerts
|
Write-Host "Processing Activity Log Alert Rules..."
|
||||||
$activityLogAlerts = Get-AzActivityLogAlert
|
$activityLogAlerts = Get-AzActivityLogAlert
|
||||||
foreach($activityLogAlert in $activityLogAlerts) {
|
Write-Host " Found $($activityLogAlerts.Count) Activity Log Alert Rule(s)"
|
||||||
|
|
||||||
if (($null -eq $activityLogAlert.ActionGroup) -or ($activityLogAlert.ActionGroup.Length -eq 0))
|
foreach ($activityLogAlert in $activityLogAlerts) {
|
||||||
{
|
# Handle Activity Log Alerts without Action Groups
|
||||||
|
if (($null -eq $activityLogAlert.ActionGroup) -or ($activityLogAlert.ActionGroup.Length -eq 0)) {
|
||||||
|
# Create alert rule entry without Action Group details
|
||||||
[AlertRule] $AlertRule = [AlertRule]::new()
|
[AlertRule] $AlertRule = [AlertRule]::new()
|
||||||
$AlertRule.SubscriptionId = $subscription.Id
|
$AlertRule.SubscriptionId = $subscription.Id
|
||||||
$AlertRule.SubscriptionName = $subscription.Name
|
$AlertRule.SubscriptionName = $subscription.Name
|
||||||
@@ -293,6 +523,8 @@ foreach ($subscription in $subscriptions)
|
|||||||
$AlertRule.ResourceGroupName = $activityLogAlert.ResourceGroupName
|
$AlertRule.ResourceGroupName = $activityLogAlert.ResourceGroupName
|
||||||
$AlertRule.Description = GetDecentDescription $activityLogAlert.Description
|
$AlertRule.Description = GetDecentDescription $activityLogAlert.Description
|
||||||
$AlertRule.State = $activityLogAlert.Enabled -eq $true ? "Enabled" : "Disabled"
|
$AlertRule.State = $activityLogAlert.Enabled -eq $true ? "Enabled" : "Disabled"
|
||||||
|
|
||||||
|
# Extract governance tags
|
||||||
$AlertRule.Tag_Team = $activityLogAlert.Tags.team
|
$AlertRule.Tag_Team = $activityLogAlert.Tags.team
|
||||||
$AlertRule.Tag_Product = $activityLogAlert.Tags.product
|
$AlertRule.Tag_Product = $activityLogAlert.Tags.product
|
||||||
$AlertRule.Tag_Environment = $activityLogAlert.Tags.environment
|
$AlertRule.Tag_Environment = $activityLogAlert.Tags.environment
|
||||||
@@ -303,11 +535,15 @@ foreach ($subscription in $subscriptions)
|
|||||||
$Result += $AlertRule
|
$Result += $AlertRule
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
foreach($action in $activityLogAlert.ActionGroup) {
|
# Process Activity Log Alerts with Action Groups
|
||||||
|
foreach ($action in $activityLogAlert.ActionGroup) {
|
||||||
|
# Create alert rule entry with Action Group details
|
||||||
[AlertRule] $AlertRule = [AlertRule]::new()
|
[AlertRule] $AlertRule = [AlertRule]::new()
|
||||||
|
|
||||||
$actionGroup = $actionGroups | where { $_.id -eq [uri]::UnescapeDataString($action.Id) }
|
# Find corresponding Action Group from pre-loaded collection
|
||||||
|
$actionGroup = $actionGroups | Where-Object { $_.id -eq [uri]::UnescapeDataString($action.Id) }
|
||||||
|
|
||||||
|
# Populate basic alert rule information
|
||||||
$AlertRule.SubscriptionId = $subscription.Id
|
$AlertRule.SubscriptionId = $subscription.Id
|
||||||
$AlertRule.SubscriptionName = $subscription.Name
|
$AlertRule.SubscriptionName = $subscription.Name
|
||||||
$AlertRule.Id = $activityLogAlert.Id
|
$AlertRule.Id = $activityLogAlert.Id
|
||||||
@@ -318,15 +554,19 @@ foreach ($subscription in $subscriptions)
|
|||||||
$AlertRule.State = $activityLogAlert.Enabled -eq $true ? "Enabled" : "Disabled"
|
$AlertRule.State = $activityLogAlert.Enabled -eq $true ? "Enabled" : "Disabled"
|
||||||
$AlertRule.ActionGroupId = $action.Id
|
$AlertRule.ActionGroupId = $action.Id
|
||||||
|
|
||||||
|
# Populate Action Group details if found
|
||||||
if ($null -ne $actionGroup) {
|
if ($null -ne $actionGroup) {
|
||||||
$AlertRule.ActionGroupName = $actionGroup.Name
|
$AlertRule.ActionGroupName = $actionGroup.Name
|
||||||
$AlertRule.ActionGroupResourceGroupName = $actionGroup.ResourceGroupName
|
$AlertRule.ActionGroupResourceGroupName = $actionGroup.ResourceGroupName
|
||||||
$AlertRule.ActionGroupEnabled = $actionGroup.Enabled
|
$AlertRule.ActionGroupEnabled = $actionGroup.Enabled
|
||||||
|
|
||||||
|
# Extract receiver information (convert arrays to comma-separated strings)
|
||||||
$AlertRule.ActionGroupArmRoleReceivers = [string] ( $actionGroup.ArmRoleReceivers | ForEach-Object { $_.Name } )
|
$AlertRule.ActionGroupArmRoleReceivers = [string] ( $actionGroup.ArmRoleReceivers | ForEach-Object { $_.Name } )
|
||||||
$AlertRule.ActionGroupEmailReceivers = [string] ( $actionGroup.EmailReceivers | ForEach-Object { $_.EmailAddress } )
|
$AlertRule.ActionGroupEmailReceivers = [string] ( $actionGroup.EmailReceivers | ForEach-Object { $_.EmailAddress } )
|
||||||
$AlertRule.AzureFunctionReceivers = [string] ($actionGroup.AzureFunctionReceivers | ForEach-Object { $_.FunctionName } )
|
$AlertRule.AzureFunctionReceivers = [string] ($actionGroup.AzureFunctionReceivers | ForEach-Object { $_.FunctionName } )
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Extract governance tags
|
||||||
$AlertRule.Tag_Team = $activityLogAlert.Tags.team
|
$AlertRule.Tag_Team = $activityLogAlert.Tags.team
|
||||||
$AlertRule.Tag_Product = $activityLogAlert.Tags.product
|
$AlertRule.Tag_Product = $activityLogAlert.Tags.product
|
||||||
$AlertRule.Tag_Environment = $activityLogAlert.Tags.environment
|
$AlertRule.Tag_Environment = $activityLogAlert.Tags.environment
|
||||||
@@ -338,8 +578,54 @@ foreach ($subscription in $subscriptions)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Write-Host "Completed processing subscription: $($subscription.Name)"
|
||||||
|
Write-Host ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Export results and display summary
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "Exporting results and generating summary..."
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
|
||||||
|
# Export comprehensive alert rules data to CSV
|
||||||
$Result | Export-Csv -Path $fileName -NoTypeInformation -Force
|
$Result | Export-Csv -Path $fileName -NoTypeInformation -Force
|
||||||
|
|
||||||
$Result | ft
|
# Generate summary statistics
|
||||||
|
$summaryStats = @{
|
||||||
|
TotalAlertRules = $Result.Count
|
||||||
|
SmartDetectorRules = ($Result | Where-Object { $_.Type -eq "microsoft.alertsmanagement/smartdetectoralertrules" }).Count
|
||||||
|
ScheduledQueryRules = ($Result | Where-Object { $_.Type -eq "microsoft.insights/scheduledqueryrules" }).Count
|
||||||
|
MetricAlerts = ($Result | Where-Object { $_.Type -eq "Microsoft.Insights/metricAlerts" }).Count
|
||||||
|
ActivityLogAlerts = ($Result | Where-Object { $_.Type -eq "Microsoft.Insights/ActivityLogAlerts" }).Count
|
||||||
|
EnabledRules = ($Result | Where-Object { $_.State -eq "Enabled" }).Count
|
||||||
|
DisabledRules = ($Result | Where-Object { $_.State -eq "Disabled" }).Count
|
||||||
|
RulesWithActionGroups = ($Result | Where-Object { $_.ActionGroupId -ne "" }).Count
|
||||||
|
RulesWithoutActionGroups = ($Result | Where-Object { $_.ActionGroupId -eq "" }).Count
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Alert Rules Inventory Summary:"
|
||||||
|
Write-Host "==============================="
|
||||||
|
Write-Host "Total Alert Rules Found: $($summaryStats.TotalAlertRules)"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "By Alert Type:"
|
||||||
|
Write-Host " Smart Detector Rules: $($summaryStats.SmartDetectorRules)"
|
||||||
|
Write-Host " Scheduled Query Rules (Log Analytics): $($summaryStats.ScheduledQueryRules)"
|
||||||
|
Write-Host " Metric Alert Rules: $($summaryStats.MetricAlerts)"
|
||||||
|
Write-Host " Activity Log Alert Rules: $($summaryStats.ActivityLogAlerts)"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "By State:"
|
||||||
|
Write-Host " Enabled Rules: $($summaryStats.EnabledRules)"
|
||||||
|
Write-Host " Disabled Rules: $($summaryStats.DisabledRules)"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "By Action Group Association:"
|
||||||
|
Write-Host " Rules with Action Groups: $($summaryStats.RulesWithActionGroups)"
|
||||||
|
Write-Host " Rules without Action Groups: $($summaryStats.RulesWithoutActionGroups)"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Results exported to: $fileName"
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
|
||||||
|
# Display formatted table output to console
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Detailed Alert Rules (displaying first 50 rows):"
|
||||||
|
$Result | Select-Object -First 50 | Format-Table -AutoSize
|
||||||
@@ -1,36 +1,186 @@
|
|||||||
#Connect-AzAccount
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Exports detailed information about Azure Application Insights resources across all enabled subscriptions.
|
||||||
|
|
||||||
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
.DESCRIPTION
|
||||||
$fileName = ".\$date appinsights.csv"
|
This script analyzes all Application Insights resources across enabled Azure subscriptions and
|
||||||
|
collects comprehensive information including:
|
||||||
|
- Basic resource metadata (ID, name, resource group, subscription)
|
||||||
|
- Log Analytics workspace associations
|
||||||
|
- Resource tags for governance and organization
|
||||||
|
|
||||||
$subscriptions = Get-AzSubscription | Where-Object State -eq "Enabled"
|
The script is particularly useful for:
|
||||||
|
- Application Insights inventory and governance
|
||||||
|
- Monitoring workspace associations for centralized logging
|
||||||
|
- Tag compliance auditing
|
||||||
|
- Cost management and resource organization
|
||||||
|
|
||||||
class AppInsightsCheck {
|
.PARAMETER SubscriptionFilter
|
||||||
[string] $SubscriptionId = ""
|
Optional array of subscription IDs to analyze. If not specified, all enabled subscriptions are processed.
|
||||||
[string] $SubscriptionName = ""
|
|
||||||
[string] $Id = ""
|
.PARAMETER OutputPath
|
||||||
[string] $ResourceGroupName = ""
|
Custom path for the output CSV file. If not specified, creates a timestamped file in the current directory.
|
||||||
[string] $Name = ""
|
|
||||||
[string] $WorkspaceResourceId = ""
|
.EXAMPLE
|
||||||
[string] $Tag_Team = ""
|
.\AppInsightsWorkspace.ps1
|
||||||
[string] $Tag_Product = ""
|
|
||||||
[string] $Tag_Environment = ""
|
Analyzes all Application Insights resources across all enabled subscriptions.
|
||||||
[string] $Tag_Data = ""
|
|
||||||
[string] $Tag_CreatedOnDate = ""
|
.EXAMPLE
|
||||||
[string] $Tag_Deployment = ""
|
.\AppInsightsWorkspace.ps1 -SubscriptionFilter @("12345678-1234-1234-1234-123456789012", "87654321-4321-4321-4321-210987654321")
|
||||||
|
|
||||||
|
Analyzes Application Insights resources in specific subscriptions only.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\AppInsightsWorkspace.ps1 -OutputPath "C:\Reports\appinsights-analysis.csv"
|
||||||
|
|
||||||
|
Analyzes all Application Insights resources and saves to a custom location.
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
Creates a CSV file with the following columns:
|
||||||
|
- SubscriptionId: Azure subscription unique identifier
|
||||||
|
- SubscriptionName: Azure subscription display name
|
||||||
|
- Id: Application Insights resource ID
|
||||||
|
- ResourceGroupName: Resource group containing the Application Insights resource
|
||||||
|
- Name: Application Insights resource name
|
||||||
|
- WorkspaceResourceId: Associated Log Analytics workspace resource ID (if any)
|
||||||
|
- Tag_Team: Value of 'team' tag
|
||||||
|
- Tag_Product: Value of 'product' tag
|
||||||
|
- Tag_Environment: Value of 'environment' tag
|
||||||
|
- Tag_Data: Value of 'data' tag
|
||||||
|
- Tag_CreatedOnDate: Value of 'CreatedOnDate' tag
|
||||||
|
- Tag_Deployment: Value of 'drp_deployment' tag
|
||||||
|
|
||||||
|
Also displays a formatted table of results in the console.
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Author: Cloud Engineering Team
|
||||||
|
Created: 2025
|
||||||
|
Requires: PowerShell 5.1 or later, Az PowerShell module
|
||||||
|
Dependencies: Az.ApplicationInsights, Az.Accounts, Az.Resources modules
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
- Install Az PowerShell module: Install-Module -Name Az
|
||||||
|
- Connect to Azure: Connect-AzAccount
|
||||||
|
- Appropriate permissions to read Application Insights resources across target subscriptions
|
||||||
|
|
||||||
|
Performance Considerations:
|
||||||
|
- Processing time depends on the number of subscriptions and Application Insights resources
|
||||||
|
- The script switches contexts between subscriptions, which may take time with many subscriptions
|
||||||
|
- Large numbers of resources may result in longer execution times
|
||||||
|
|
||||||
|
Tag Analysis:
|
||||||
|
The script looks for specific tags commonly used for governance:
|
||||||
|
- team: Identifies the responsible team
|
||||||
|
- product: Associates the resource with a product or service
|
||||||
|
- environment: Indicates the environment (dev, test, prod, etc.)
|
||||||
|
- data: Data classification or sensitivity level
|
||||||
|
- CreatedOnDate: Resource creation timestamp
|
||||||
|
- drp_deployment: Deployment-related information
|
||||||
|
|
||||||
|
Workspace Association:
|
||||||
|
- Modern Application Insights resources should be associated with Log Analytics workspaces
|
||||||
|
- Resources without workspace associations may be using legacy standalone mode
|
||||||
|
- Workspace associations enable advanced querying and cross-resource analytics
|
||||||
|
|
||||||
|
.LINK
|
||||||
|
https://docs.microsoft.com/en-us/powershell/module/az.applicationinsights/
|
||||||
|
https://docs.microsoft.com/en-us/azure/azure-monitor/app/app-insights-overview
|
||||||
|
#>
|
||||||
|
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $false, HelpMessage = "Array of subscription IDs to analyze (analyzes all enabled subscriptions if not specified)")]
|
||||||
|
[string[]]$SubscriptionFilter = @(),
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false, HelpMessage = "Custom path for the output CSV file")]
|
||||||
|
[string]$OutputPath = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check Azure PowerShell authentication
|
||||||
|
Write-Host "Verifying Azure PowerShell authentication..." -ForegroundColor Yellow
|
||||||
|
try {
|
||||||
|
$azContext = Get-AzContext
|
||||||
|
if (-not $azContext) {
|
||||||
|
Write-Host "Not authenticated to Azure. Attempting to connect..." -ForegroundColor Yellow
|
||||||
|
Connect-AzAccount
|
||||||
|
$azContext = Get-AzContext
|
||||||
|
}
|
||||||
|
Write-Host "Azure authentication verified - Account: $($azContext.Account.Id)" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Host "ERROR: Unable to authenticate to Azure. Please run 'Connect-AzAccount' manually." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Generate filename if not provided
|
||||||
|
if (-not $OutputPath) {
|
||||||
|
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
||||||
|
$OutputPath = ".\$date appinsights.csv"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get target subscriptions based on filter or all enabled subscriptions
|
||||||
|
Write-Host "Retrieving target subscriptions..." -ForegroundColor Yellow
|
||||||
|
if ($SubscriptionFilter.Count -gt 0) {
|
||||||
|
$subscriptions = $SubscriptionFilter | ForEach-Object {
|
||||||
|
Get-AzSubscription -SubscriptionId $_ | Where-Object State -eq "Enabled"
|
||||||
|
} | Where-Object { $_ -ne $null }
|
||||||
|
Write-Host "Analyzing $($subscriptions.Count) filtered subscriptions" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
$subscriptions = Get-AzSubscription | Where-Object State -eq "Enabled"
|
||||||
|
Write-Host "Analyzing all $($subscriptions.Count) enabled subscriptions" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
# Define a class to structure Application Insights resource information
|
||||||
|
class AppInsightsCheck {
|
||||||
|
[string] $SubscriptionId = "" # Azure subscription unique identifier
|
||||||
|
[string] $SubscriptionName = "" # Azure subscription display name
|
||||||
|
[string] $Id = "" # Application Insights resource ID
|
||||||
|
[string] $ResourceGroupName = "" # Resource group containing the resource
|
||||||
|
[string] $Name = "" # Application Insights resource name
|
||||||
|
[string] $WorkspaceResourceId = "" # Associated Log Analytics workspace resource ID
|
||||||
|
[string] $Tag_Team = "" # Team responsible for the resource
|
||||||
|
[string] $Tag_Product = "" # Product or service association
|
||||||
|
[string] $Tag_Environment = "" # Environment designation (dev, test, prod)
|
||||||
|
[string] $Tag_Data = "" # Data classification or sensitivity level
|
||||||
|
[string] $Tag_CreatedOnDate = "" # Resource creation date from tags
|
||||||
|
[string] $Tag_Deployment = "" # Deployment-related information
|
||||||
|
}
|
||||||
|
|
||||||
|
# Initialize array to store Application Insights analysis results
|
||||||
[AppInsightsCheck[]]$Result = @()
|
[AppInsightsCheck[]]$Result = @()
|
||||||
|
$totalResourcesProcessed = 0
|
||||||
|
|
||||||
foreach ($subscription in $subscriptions)
|
# Display analysis banner
|
||||||
{
|
Write-Host "`n========================================================================================================================================================================"
|
||||||
Set-AzContext -SubscriptionId $subscription.Id
|
Write-Host "AZURE APPLICATION INSIGHTS ANALYSIS"
|
||||||
|
Write-Host "========================================================================================================================================================================"
|
||||||
|
|
||||||
$allAppinsights = Get-AzApplicationInsights
|
# Process each subscription to analyze Application Insights resources
|
||||||
foreach ($appinsights in $allAppinsights)
|
foreach ($subscription in $subscriptions) {
|
||||||
{
|
Write-Host "`nAnalyzing subscription: $($subscription.Name) ($($subscription.Id))" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Switch to the current subscription context
|
||||||
|
Set-AzContext -SubscriptionId $subscription.Id -ErrorAction Stop | Out-Null
|
||||||
|
|
||||||
|
# Get all Application Insights resources in the subscription
|
||||||
|
Write-Host " Retrieving Application Insights resources..." -ForegroundColor Gray
|
||||||
|
$allAppinsights = Get-AzApplicationInsights -ErrorAction Stop
|
||||||
|
|
||||||
|
if ($allAppinsights.Count -eq 0) {
|
||||||
|
Write-Host " No Application Insights resources found" -ForegroundColor Yellow
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host " Found $($allAppinsights.Count) Application Insights resources" -ForegroundColor Green
|
||||||
|
|
||||||
|
# Process each Application Insights resource
|
||||||
|
foreach ($appinsights in $allAppinsights) {
|
||||||
|
Write-Host " Processing: $($appinsights.Name)" -ForegroundColor Gray
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Create new analysis object and populate basic information
|
||||||
[AppInsightsCheck] $AppInsightsCheck = [AppInsightsCheck]::new()
|
[AppInsightsCheck] $AppInsightsCheck = [AppInsightsCheck]::new()
|
||||||
|
|
||||||
$AppInsightsCheck.SubscriptionId = $subscription.Id
|
$AppInsightsCheck.SubscriptionId = $subscription.Id
|
||||||
$AppInsightsCheck.SubscriptionName = $subscription.Name
|
$AppInsightsCheck.SubscriptionName = $subscription.Name
|
||||||
$AppInsightsCheck.Id = $appinsights.Id
|
$AppInsightsCheck.Id = $appinsights.Id
|
||||||
@@ -38,8 +188,18 @@ foreach ($subscription in $subscriptions)
|
|||||||
$AppInsightsCheck.ResourceGroupName = $appinsights.ResourceGroupName
|
$AppInsightsCheck.ResourceGroupName = $appinsights.ResourceGroupName
|
||||||
$AppInsightsCheck.WorkspaceResourceId = $appinsights.WorkspaceResourceId
|
$AppInsightsCheck.WorkspaceResourceId = $appinsights.WorkspaceResourceId
|
||||||
|
|
||||||
$resource = Get-AzResource -ResourceId $appinsights.Id
|
# Check workspace association
|
||||||
|
if ($appinsights.WorkspaceResourceId) {
|
||||||
|
Write-Host " Workspace-based Application Insights" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host " Legacy standalone Application Insights (consider migrating)" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
# Retrieve detailed resource information for tags
|
||||||
|
Write-Host " Retrieving resource tags..." -ForegroundColor Gray
|
||||||
|
$resource = Get-AzResource -ResourceId $appinsights.Id -ErrorAction Stop
|
||||||
|
|
||||||
|
# Extract governance tags
|
||||||
$AppInsightsCheck.Tag_Team = $resource.Tags.team
|
$AppInsightsCheck.Tag_Team = $resource.Tags.team
|
||||||
$AppInsightsCheck.Tag_Product = $resource.Tags.product
|
$AppInsightsCheck.Tag_Product = $resource.Tags.product
|
||||||
$AppInsightsCheck.Tag_Environment = $resource.Tags.environment
|
$AppInsightsCheck.Tag_Environment = $resource.Tags.environment
|
||||||
@@ -47,10 +207,82 @@ foreach ($subscription in $subscriptions)
|
|||||||
$AppInsightsCheck.Tag_CreatedOnDate = $resource.Tags.CreatedOnDate
|
$AppInsightsCheck.Tag_CreatedOnDate = $resource.Tags.CreatedOnDate
|
||||||
$AppInsightsCheck.Tag_Deployment = $resource.Tags.drp_deployment
|
$AppInsightsCheck.Tag_Deployment = $resource.Tags.drp_deployment
|
||||||
|
|
||||||
|
# Report on tag compliance
|
||||||
|
$tagCount = @($AppInsightsCheck.Tag_Team, $AppInsightsCheck.Tag_Product, $AppInsightsCheck.Tag_Environment) | Where-Object { $_ } | Measure-Object | Select-Object -ExpandProperty Count
|
||||||
|
if ($tagCount -eq 3) {
|
||||||
|
Write-Host " All required tags present" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host " Missing required tags (team, product, environment)" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add to results
|
||||||
$Result += $AppInsightsCheck
|
$Result += $AppInsightsCheck
|
||||||
|
$totalResourcesProcessed++
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
Write-Host " ERROR processing resource: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
# Still add basic info even if tag retrieval fails
|
||||||
|
[AppInsightsCheck] $AppInsightsCheck = [AppInsightsCheck]::new()
|
||||||
|
$AppInsightsCheck.SubscriptionId = $subscription.Id
|
||||||
|
$AppInsightsCheck.SubscriptionName = $subscription.Name
|
||||||
|
$AppInsightsCheck.Id = $appinsights.Id
|
||||||
|
$AppInsightsCheck.Name = $appinsights.Name
|
||||||
|
$AppInsightsCheck.ResourceGroupName = $appinsights.ResourceGroupName
|
||||||
|
$AppInsightsCheck.WorkspaceResourceId = $appinsights.WorkspaceResourceId
|
||||||
|
$Result += $AppInsightsCheck
|
||||||
|
$totalResourcesProcessed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
Write-Host " ERROR accessing subscription: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$Result | Export-Csv -Path $fileName -NoTypeInformation -Force
|
# Export results to CSV file
|
||||||
|
Write-Host "`nExporting results to: $OutputPath" -ForegroundColor Yellow
|
||||||
|
$Result | Export-Csv -Path $OutputPath -NoTypeInformation -Force
|
||||||
|
|
||||||
$Result | ft
|
# Calculate and display summary statistics
|
||||||
|
$totalSubscriptions = $subscriptions.Count
|
||||||
|
$workspaceBasedCount = ($Result | Where-Object { $_.WorkspaceResourceId -ne "" }).Count
|
||||||
|
$legacyCount = ($Result | Where-Object { $_.WorkspaceResourceId -eq "" }).Count
|
||||||
|
$taggedResourcesCount = ($Result | Where-Object { $_.Tag_Team -ne "" -and $_.Tag_Product -ne "" -and $_.Tag_Environment -ne "" }).Count
|
||||||
|
|
||||||
|
# Display completion summary
|
||||||
|
Write-Host "`n========================================================================================================================================================================"
|
||||||
|
Write-Host "APPLICATION INSIGHTS ANALYSIS COMPLETED SUCCESSFULLY!" -ForegroundColor Green
|
||||||
|
Write-Host "========================================================================================================================================================================"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "SUMMARY STATISTICS:" -ForegroundColor Cyan
|
||||||
|
Write-Host "Subscriptions analyzed: $totalSubscriptions" -ForegroundColor Yellow
|
||||||
|
Write-Host "Total Application Insights resources: $totalResourcesProcessed" -ForegroundColor Yellow
|
||||||
|
Write-Host "Workspace-based resources: $workspaceBasedCount" -ForegroundColor Yellow
|
||||||
|
Write-Host "Legacy standalone resources: $legacyCount" -ForegroundColor Yellow
|
||||||
|
Write-Host "Resources with complete tags (team, product, environment): $taggedResourcesCount" -ForegroundColor Yellow
|
||||||
|
|
||||||
|
# Highlight areas needing attention
|
||||||
|
if ($legacyCount -gt 0) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "RECOMMENDATIONS:" -ForegroundColor Cyan
|
||||||
|
Write-Host "- $legacyCount legacy Application Insights resources should be migrated to workspace-based mode" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($taggedResourcesCount -lt $totalResourcesProcessed) {
|
||||||
|
$untaggedCount = $totalResourcesProcessed - $taggedResourcesCount
|
||||||
|
Write-Host "- $untaggedCount resources are missing required governance tags" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Output file: $OutputPath" -ForegroundColor Yellow
|
||||||
|
|
||||||
|
# Display results table
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "DETAILED RESULTS:" -ForegroundColor Cyan
|
||||||
|
$Result | Format-Table -Property SubscriptionName, Name, ResourceGroupName, @{
|
||||||
|
Name = 'WorkspaceAssociated'
|
||||||
|
Expression = { if ($_.WorkspaceResourceId) { 'Yes' } else { 'No' } }
|
||||||
|
}, Tag_Team, Tag_Product, Tag_Environment -AutoSize
|
||||||
|
|
||||||
|
Write-Host "========================================================================================================================================================================"
|
||||||
@@ -1,72 +1,192 @@
|
|||||||
#Connect-AzAccount
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Exports Azure Privileged Identity Management (PIM) role eligible assignments across all management groups, subscriptions, resource groups, and resources.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This script comprehensively inventories all PIM eligible role assignments across the entire Azure environment hierarchy.
|
||||||
|
It traverses management groups, subscriptions, resource groups, and individual resources to collect detailed information
|
||||||
|
about role eligibility schedules. The script uses Azure REST API calls to retrieve PIM data and exports results to CSV.
|
||||||
|
|
||||||
|
The script performs a complete audit of:
|
||||||
|
- Management Group level PIM assignments
|
||||||
|
- Subscription level PIM assignments
|
||||||
|
- Resource Group level PIM assignments
|
||||||
|
- Individual Resource level PIM assignments
|
||||||
|
|
||||||
|
All data is consolidated into a single CSV file with detailed scope and principal information.
|
||||||
|
|
||||||
|
.PARAMETER None
|
||||||
|
This script does not accept parameters. It processes all accessible management groups and their child resources.
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
CSV file named with timestamp pattern: "yyyy-MM-dd HHmm azure_pim_assignments.csv"
|
||||||
|
Contains columns for scope hierarchy, role definitions, principals, and assignment metadata.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\AzurePIM.ps1
|
||||||
|
|
||||||
|
Exports all PIM eligible assignments to a timestamped CSV file in the current directory.
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Author: Cloud Engineering Team
|
||||||
|
Version: 1.0
|
||||||
|
Created: 2024
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
- Azure PowerShell module (Az) must be installed
|
||||||
|
- User must be authenticated (Connect-AzAccount)
|
||||||
|
- Requires appropriate permissions to read PIM assignments across all scopes
|
||||||
|
- Tenant ID is hardcoded and may need adjustment for different environments
|
||||||
|
|
||||||
|
Security Considerations:
|
||||||
|
- Script uses Azure access tokens for REST API authentication
|
||||||
|
- Requires elevated permissions to access PIM data across all scopes
|
||||||
|
- Output file contains sensitive role assignment information
|
||||||
|
|
||||||
|
Performance Notes:
|
||||||
|
- Processing time varies significantly based on environment size
|
||||||
|
- Script processes resources sequentially which may take considerable time
|
||||||
|
- Consider running during off-peak hours for large environments
|
||||||
|
|
||||||
|
.LINK
|
||||||
|
https://docs.microsoft.com/en-us/azure/active-directory/privileged-identity-management/
|
||||||
|
https://docs.microsoft.com/en-us/rest/api/authorization/roleeligibilityscheduleinstances
|
||||||
|
#>
|
||||||
|
|
||||||
|
#Requires -Modules Az
|
||||||
|
#Connect-AzAccount
|
||||||
|
|
||||||
|
# Class definition for structured PIM assignment data
|
||||||
class ResourceCheck {
|
class ResourceCheck {
|
||||||
[string] $Level = ""
|
# Hierarchy level indicators
|
||||||
[string] $ManagementGroupId = ""
|
[string] $Level = "" # Management Group, Subscription, Resource Group, or Resource
|
||||||
[string] $ManagementGroupName = ""
|
[string] $ManagementGroupId = "" # Management group identifier
|
||||||
[string] $SubscriptionId = ""
|
[string] $ManagementGroupName = "" # Management group display name
|
||||||
[string] $SubscriptionName = ""
|
[string] $SubscriptionId = "" # Azure subscription GUID
|
||||||
[string] $ResourceId = ""
|
[string] $SubscriptionName = "" # Subscription display name
|
||||||
[string] $ResourceGroup = ""
|
|
||||||
[string] $ResourceName = ""
|
# Resource identification
|
||||||
[string] $ResourceType = ""
|
[string] $ResourceId = "" # Full Azure resource identifier
|
||||||
[string] $RoleEligibilityScheduleId = ""
|
[string] $ResourceGroup = "" # Resource group name
|
||||||
[string] $Scope = ""
|
[string] $ResourceName = "" # Individual resource name
|
||||||
[string] $RoleDefinitionId = ""
|
[string] $ResourceType = "" # Azure resource type
|
||||||
[string] $RoleDefinitionName = ""
|
|
||||||
[string] $RoleDefinitionType = ""
|
# PIM assignment details
|
||||||
[string] $PrincipalId = ""
|
[string] $RoleEligibilityScheduleId = "" # Unique PIM schedule identifier
|
||||||
[string] $PrincipalName = ""
|
[string] $Scope = "" # Assignment scope path
|
||||||
[string] $PrincipalType = ""
|
[string] $RoleDefinitionId = "" # Azure RBAC role definition ID
|
||||||
[string] $Status = ""
|
[string] $RoleDefinitionName = "" # Human-readable role name
|
||||||
[string] $StartDateTime = ""
|
[string] $RoleDefinitionType = "" # Role definition type
|
||||||
[string] $EndDateTime = ""
|
|
||||||
[string] $CreatedOn = ""
|
# Principal (user/group/service principal) information
|
||||||
|
[string] $PrincipalId = "" # Principal object ID
|
||||||
|
[string] $PrincipalName = "" # Principal display name
|
||||||
|
[string] $PrincipalType = "" # User, Group, or ServicePrincipal
|
||||||
|
|
||||||
|
# Assignment metadata
|
||||||
|
[string] $Status = "" # Assignment status (Active, Eligible, etc.)
|
||||||
|
[string] $StartDateTime = "" # Assignment start date/time
|
||||||
|
[string] $EndDateTime = "" # Assignment expiration date/time
|
||||||
|
[string] $CreatedOn = "" # Assignment creation timestamp
|
||||||
}
|
}
|
||||||
|
|
||||||
function GetEligibleAssignments {
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Retrieves PIM eligible role assignments for a specified scope using Azure Management REST API.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This function queries the Azure Management REST API to retrieve role eligibility schedule instances
|
||||||
|
for a given scope. It handles authentication using Azure access tokens and filters results to
|
||||||
|
exclude inherited assignments.
|
||||||
|
|
||||||
|
.PARAMETER scope
|
||||||
|
The Azure resource scope path to query for PIM assignments. Can be management group, subscription,
|
||||||
|
resource group, or individual resource scope.
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
Returns an array of PIM assignment objects with properties containing assignment details,
|
||||||
|
or empty string if no assignments found.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
GetEligibleAssignments -scope "subscriptions/12345678-1234-1234-1234-123456789012"
|
||||||
|
|
||||||
|
Retrieves all PIM eligible assignments for the specified subscription.
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
- Uses hardcoded tenant ID which may need adjustment for different environments
|
||||||
|
- REST API version 2020-10-01 is used for compatibility
|
||||||
|
- Filters out inherited assignments to show only direct assignments
|
||||||
|
#>
|
||||||
|
function GetEligibleAssignments {
|
||||||
param (
|
param (
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
[string] $scope
|
[string] $scope
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Get Azure access token for the specified tenant
|
||||||
|
# Note: Tenant ID is hardcoded and should be updated for different environments
|
||||||
$access_token_secure = (Get-AzAccessToken -TenantId "e9792fd7-4044-47e7-a40d-3fba46f1cd09").Token
|
$access_token_secure = (Get-AzAccessToken -TenantId "e9792fd7-4044-47e7-a40d-3fba46f1cd09").Token
|
||||||
$access_token = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto([System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($access_token_secure))
|
$access_token = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto([System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($access_token_secure))
|
||||||
|
|
||||||
|
# Construct REST API URL for role eligibility schedule instances
|
||||||
$url = "https://management.azure.com/$scope/providers/Microsoft.Authorization/roleEligibilityScheduleInstances?api-version=2020-10-01&`$filter=atScope()"
|
$url = "https://management.azure.com/$scope/providers/Microsoft.Authorization/roleEligibilityScheduleInstances?api-version=2020-10-01&`$filter=atScope()"
|
||||||
|
|
||||||
$head = @{ Authorization =" Bearer $access_token" }
|
# Create authorization header with bearer token
|
||||||
|
$head = @{ Authorization = " Bearer $access_token" }
|
||||||
|
|
||||||
|
# Execute REST API call to retrieve PIM assignments
|
||||||
$response = Invoke-RestMethod -Uri $url -Method GET -Headers $head
|
$response = Invoke-RestMethod -Uri $url -Method GET -Headers $head
|
||||||
|
|
||||||
|
# Process response and filter out inherited assignments
|
||||||
$response | ForEach-Object {
|
$response | ForEach-Object {
|
||||||
$responseValue = $_.value
|
$responseValue = $_.value
|
||||||
if ($responseValue.Length -gt 0) {
|
if ($responseValue.Length -gt 0) {
|
||||||
|
# Return only direct assignments (exclude inherited)
|
||||||
return $responseValue | ForEach-Object {
|
return $responseValue | ForEach-Object {
|
||||||
return ($_.properties | Where-Object MemberType -NE "Inherited")
|
return ($_.properties | Where-Object MemberType -NE "Inherited")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
|
# Return empty string if no assignments found
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning "Failed to retrieve PIM assignments for scope: $scope. Error: $($_.Exception.Message)"
|
||||||
|
return ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Main script execution begins
|
||||||
Write-Host "======================================================================================================================================================================"
|
Write-Host "======================================================================================================================================================================"
|
||||||
Write-Host "Creating PIM assignments overview."
|
Write-Host "Creating comprehensive PIM assignments overview across all Azure scopes."
|
||||||
Write-Host "======================================================================================================================================================================"
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
|
||||||
|
# Generate timestamped filename for CSV export
|
||||||
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
||||||
$fileName = ".\$date azure_pim_assignments.csv"
|
$fileName = ".\$date azure_pim_assignments.csv"
|
||||||
|
Write-Host "Output file: $fileName"
|
||||||
|
|
||||||
|
# Retrieve all management groups accessible to the current user
|
||||||
|
Write-Host "Retrieving management groups..."
|
||||||
$managementGroups = Get-AzManagementGroup
|
$managementGroups = Get-AzManagementGroup
|
||||||
|
Write-Host "Found $($managementGroups.Count) management group(s) to process."
|
||||||
|
|
||||||
|
# Process each management group for PIM assignments
|
||||||
foreach ($managementGroup in $managementGroups)
|
foreach ($managementGroup in $managementGroups)
|
||||||
{
|
{
|
||||||
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
||||||
Write-Host "Management group [$($managementGroup.Name)]"
|
Write-Host "Processing Management Group: [$($managementGroup.Name)] - $($managementGroup.DisplayName)"
|
||||||
|
|
||||||
|
# Retrieve PIM assignments at management group level
|
||||||
$assignments = GetEligibleAssignments -scope "providers/Microsoft.Management/managementGroups/$($managementGroup.Name)"
|
$assignments = GetEligibleAssignments -scope "providers/Microsoft.Management/managementGroups/$($managementGroup.Name)"
|
||||||
|
|
||||||
|
# Process management group level assignments
|
||||||
[ResourceCheck[]]$Result = @()
|
[ResourceCheck[]]$Result = @()
|
||||||
foreach ($assignment in $assignments) {
|
foreach ($assignment in $assignments) {
|
||||||
|
# Create structured object for management group assignment
|
||||||
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
||||||
$resourceCheck.Level = "Management Group"
|
$resourceCheck.Level = "Management Group"
|
||||||
$resourceCheck.ManagementGroupId = $managementGroup.Id
|
$resourceCheck.ManagementGroupId = $managementGroup.Id
|
||||||
@@ -85,24 +205,38 @@ foreach ($managementGroup in $managementGroups)
|
|||||||
$resourceCheck.CreatedOn = $assignment.createdOn
|
$resourceCheck.CreatedOn = $assignment.createdOn
|
||||||
$Result += $resourceCheck
|
$Result += $resourceCheck
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Export management group assignments to CSV
|
||||||
|
if ($Result.Count -gt 0) {
|
||||||
$Result | Export-Csv -Path $fileName -Append -NoTypeInformation
|
$Result | Export-Csv -Path $fileName -Append -NoTypeInformation
|
||||||
|
Write-Host " Exported $($Result.Count) management group assignment(s)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Retrieve active subscriptions within the management group
|
||||||
$subscriptions = Get-AzManagementGroupSubscription -Group $managementGroup.Name | Where-Object State -eq "Active"
|
$subscriptions = Get-AzManagementGroupSubscription -Group $managementGroup.Name | Where-Object State -eq "Active"
|
||||||
|
Write-Host " Found $($subscriptions.Count) active subscription(s) in management group"
|
||||||
|
|
||||||
|
# Process each subscription for PIM assignments
|
||||||
foreach ($subscription in $subscriptions)
|
foreach ($subscription in $subscriptions)
|
||||||
{
|
{
|
||||||
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
||||||
|
|
||||||
|
# Extract subscription ID from the full path
|
||||||
$scope = $subscription.Id.Substring($subscription.Parent.Length, $subscription.Id.Length - $subscription.Parent.Length)
|
$scope = $subscription.Id.Substring($subscription.Parent.Length, $subscription.Id.Length - $subscription.Parent.Length)
|
||||||
$subscriptionId = $scope.Replace("/subscriptions/", "")
|
$subscriptionId = $scope.Replace("/subscriptions/", "")
|
||||||
Write-Host "Subscription [$($subscription.DisplayName) - $subscriptionId]"
|
Write-Host "Processing Subscription: [$($subscription.DisplayName)] - $subscriptionId"
|
||||||
|
|
||||||
|
# Set Azure context to the current subscription
|
||||||
Set-AzContext -SubscriptionId $subscriptionId | Out-Null
|
Set-AzContext -SubscriptionId $subscriptionId | Out-Null
|
||||||
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
||||||
|
|
||||||
|
# Retrieve PIM assignments at subscription level
|
||||||
$assignments = GetEligibleAssignments -scope $scope
|
$assignments = GetEligibleAssignments -scope $scope
|
||||||
|
|
||||||
|
# Process subscription level assignments
|
||||||
[ResourceCheck[]]$Result = @()
|
[ResourceCheck[]]$Result = @()
|
||||||
foreach ($assignment in $assignments) {
|
foreach ($assignment in $assignments) {
|
||||||
|
# Create structured object for subscription assignment
|
||||||
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
||||||
$resourceCheck.Level = "Subscription"
|
$resourceCheck.Level = "Subscription"
|
||||||
$resourceCheck.ManagementGroupId = $managementGroup.Id
|
$resourceCheck.ManagementGroupId = $managementGroup.Id
|
||||||
@@ -123,18 +257,28 @@ foreach ($managementGroup in $managementGroups)
|
|||||||
$resourceCheck.CreatedOn = $assignment.createdOn
|
$resourceCheck.CreatedOn = $assignment.createdOn
|
||||||
$Result += $resourceCheck
|
$Result += $resourceCheck
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Export subscription assignments to CSV
|
||||||
|
if ($Result.Count -gt 0) {
|
||||||
$Result | Export-Csv -Path $fileName -Append -NoTypeInformation
|
$Result | Export-Csv -Path $fileName -Append -NoTypeInformation
|
||||||
|
Write-Host " Exported $($Result.Count) subscription assignment(s)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Retrieve all resource groups in the current subscription
|
||||||
$allResourceGroups = Get-AzResourceGroup
|
$allResourceGroups = Get-AzResourceGroup
|
||||||
|
Write-Host " Found $($allResourceGroups.Count) resource group(s) to process"
|
||||||
|
|
||||||
|
# Process each resource group for PIM assignments
|
||||||
foreach ($group in $allResourceGroups) {
|
foreach ($group in $allResourceGroups) {
|
||||||
|
Write-Host " Processing Resource Group: $($group.ResourceGroupName)"
|
||||||
|
|
||||||
Write-Host $group.ResourceGroupName
|
# Retrieve PIM assignments at resource group level
|
||||||
|
|
||||||
$assignments = GetEligibleAssignments -scope $group.ResourceId
|
$assignments = GetEligibleAssignments -scope $group.ResourceId
|
||||||
|
|
||||||
|
# Process resource group level assignments
|
||||||
[ResourceCheck[]]$Result = @()
|
[ResourceCheck[]]$Result = @()
|
||||||
foreach ($assignment in $assignments) {
|
foreach ($assignment in $assignments) {
|
||||||
|
# Create structured object for resource group assignment
|
||||||
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
||||||
$resourceCheck.Level = "Resource Group"
|
$resourceCheck.Level = "Resource Group"
|
||||||
$resourceCheck.ManagementGroupId = $managementGroup.Id
|
$resourceCheck.ManagementGroupId = $managementGroup.Id
|
||||||
@@ -156,16 +300,26 @@ foreach ($managementGroup in $managementGroups)
|
|||||||
$resourceCheck.CreatedOn = $assignment.createdOn
|
$resourceCheck.CreatedOn = $assignment.createdOn
|
||||||
$Result += $resourceCheck
|
$Result += $resourceCheck
|
||||||
}
|
}
|
||||||
$Result | Export-Csv -Path $fileName -Append -NoTypeInformation
|
|
||||||
|
|
||||||
|
# Export resource group assignments to CSV
|
||||||
|
if ($Result.Count -gt 0) {
|
||||||
|
$Result | Export-Csv -Path $fileName -Append -NoTypeInformation
|
||||||
|
Write-Host " Exported $($Result.Count) resource group assignment(s)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Retrieve all resources within the current resource group
|
||||||
$allResources = Get-AzResource -ResourceGroupName $group.ResourceGroupName
|
$allResources = Get-AzResource -ResourceGroupName $group.ResourceGroupName
|
||||||
|
|
||||||
|
# Process each individual resource for PIM assignments
|
||||||
foreach ($resource in $allResources)
|
foreach ($resource in $allResources)
|
||||||
{
|
{
|
||||||
|
# Retrieve PIM assignments at individual resource level
|
||||||
$assignments = GetEligibleAssignments -scope $resource.ResourceId
|
$assignments = GetEligibleAssignments -scope $resource.ResourceId
|
||||||
|
|
||||||
|
# Process individual resource level assignments
|
||||||
[ResourceCheck[]]$Result = @()
|
[ResourceCheck[]]$Result = @()
|
||||||
foreach ($assignment in $assignments) {
|
foreach ($assignment in $assignments) {
|
||||||
|
# Create structured object for resource assignment
|
||||||
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
||||||
$resourceCheck.Level = "Resource"
|
$resourceCheck.Level = "Resource"
|
||||||
$resourceCheck.ManagementGroupId = $managementGroup.Id
|
$resourceCheck.ManagementGroupId = $managementGroup.Id
|
||||||
@@ -190,13 +344,27 @@ foreach ($managementGroup in $managementGroups)
|
|||||||
$resourceCheck.CreatedOn = $assignment.createdOn
|
$resourceCheck.CreatedOn = $assignment.createdOn
|
||||||
$Result += $resourceCheck
|
$Result += $resourceCheck
|
||||||
}
|
}
|
||||||
$Result | Export-Csv -Path $fileName -Append -NoTypeInformation
|
|
||||||
|
|
||||||
|
# Export individual resource assignments to CSV
|
||||||
|
if ($Result.Count -gt 0) {
|
||||||
|
$Result | Export-Csv -Path $fileName -Append -NoTypeInformation
|
||||||
|
Write-Host " Exported $($Result.Count) assignment(s) for resource: $($resource.Name)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Script completion summary
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "PIM assignments export completed successfully."
|
||||||
|
Write-Host "Results saved to: $fileName"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Summary:"
|
||||||
|
Write-Host "- Processed $($managementGroups.Count) management group(s)"
|
||||||
|
Write-Host "- Traversed all subscriptions, resource groups, and individual resources"
|
||||||
|
Write-Host "- Exported all PIM eligible role assignments to CSV format"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Note: Review the CSV file for comprehensive PIM assignment details across all Azure scopes."
|
||||||
Write-Host "======================================================================================================================================================================"
|
Write-Host "======================================================================================================================================================================"
|
||||||
Write-Host "Done."
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,67 +1,225 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Comprehensive Azure RBAC assignment analysis across entire Azure tenant.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This script analyzes RBAC assignments across all Azure resources in an Azure tenant,
|
||||||
|
providing a complete inventory of role assignments at every level of the Azure
|
||||||
|
resource hierarchy. The script generates a detailed CSV report with comprehensive
|
||||||
|
metadata including resource information, role assignments, and organizational tags.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
• Recursive analysis of Management Groups, Subscriptions, Resource Groups, and Resources
|
||||||
|
• Complete RBAC assignment enumeration with role and principal details
|
||||||
|
• Organizational metadata collection (tags, locations, resource types)
|
||||||
|
• Hierarchical resource context (management group → subscription → resource group → resource)
|
||||||
|
• Timestamped CSV output for historical tracking and compliance reporting
|
||||||
|
• Comprehensive error handling to continue processing despite individual failures
|
||||||
|
|
||||||
|
.PARAMETER SubscriptionIds
|
||||||
|
Optional array of specific subscription IDs to process. If not provided, all active
|
||||||
|
subscriptions across all management groups will be processed. When specified, only
|
||||||
|
the listed subscriptions will be analyzed for RBAC assignments.
|
||||||
|
|
||||||
|
.PARAMETER OutputPath
|
||||||
|
Optional custom path for the output CSV file. If not provided, the file will be
|
||||||
|
created in the current directory with a timestamped filename.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\AzureRBAC.ps1
|
||||||
|
Process all management groups and subscriptions to generate complete RBAC inventory.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\AzureRBAC.ps1 -SubscriptionIds @("a134faf1-7a89-4f2c-8389-06d00bd5e2a7", "30ce4e64-4299-4b93-91b8-4c953f63678e")
|
||||||
|
Process only the specified subscriptions for RBAC analysis.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\AzureRBAC.ps1 -SubscriptionIds @("12345678-1234-1234-1234-123456789012") -OutputPath "C:\Reports\rbac-analysis.csv"
|
||||||
|
Process a specific subscription and save results to a custom location.
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
CSV file: [YYYY-MM-DD HHMM] azure_rbac_assignments.csv
|
||||||
|
|
||||||
|
The output file contains the following columns:
|
||||||
|
- ResourceId: Azure resource identifier
|
||||||
|
- Id: Resource ID (for individual resources)
|
||||||
|
- Kind: Resource type (ManagementGroup, Subscription, ResourceGroup, Resource)
|
||||||
|
- Location: Azure region/location
|
||||||
|
- ResourceName: Name of the resource
|
||||||
|
- ResourceGroupName: Resource group containing the resource
|
||||||
|
- ResourceType: Azure resource type (e.g., Microsoft.Storage/storageAccounts)
|
||||||
|
- ManagementGroupId: Parent management group identifier
|
||||||
|
- ManagementGroupName: Parent management group display name
|
||||||
|
- SubscriptionId: Subscription identifier
|
||||||
|
- SubscriptionName: Subscription display name
|
||||||
|
- Tag_Team: Team tag value for organizational tracking
|
||||||
|
- Tag_Product: Product tag value for product alignment
|
||||||
|
- Tag_Environment: Environment tag value (Dev, Test, Prod, etc.)
|
||||||
|
- Tag_Data: Data classification tag value
|
||||||
|
- Tag_Delete: Deletion schedule tag value
|
||||||
|
- Tag_Split: Cost allocation split tag value
|
||||||
|
- RBAC_RoleAssignmentId: Unique role assignment identifier
|
||||||
|
- RBAC_Scope: Scope where the role assignment is effective
|
||||||
|
- RBAC_DisplayName: Display name of the assigned principal
|
||||||
|
- RBAC_SignInName: Sign-in name/UPN of the assigned principal
|
||||||
|
- RBAC_RoleDefinitionName: Name of the assigned Azure role
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Requires PowerShell modules: Az.Accounts, Az.Resources
|
||||||
|
|
||||||
|
Requires appropriate Azure RBAC permissions:
|
||||||
|
• Reader access or higher on Management Groups
|
||||||
|
• Reader access or higher on Subscriptions
|
||||||
|
• Reader access or higher on Resource Groups and Resources
|
||||||
|
• Microsoft.Authorization/roleAssignments/read permission at appropriate scopes
|
||||||
|
|
||||||
|
Authentication:
|
||||||
|
• Must be authenticated to Azure (Connect-AzAccount) before running
|
||||||
|
• Service Principal or Managed Identity authentication supported
|
||||||
|
• Requires appropriate tenant-level permissions for comprehensive analysis
|
||||||
|
|
||||||
|
Performance Considerations:
|
||||||
|
• Processing time scales with number of resources and role assignments
|
||||||
|
• Large tenants may require several minutes to hours for complete analysis
|
||||||
|
• Network connectivity and API throttling may affect processing speed
|
||||||
|
• Memory usage scales with number of resources processed
|
||||||
|
|
||||||
|
Compatibility:
|
||||||
|
• PowerShell 5.1 and PowerShell 7.x
|
||||||
|
• Azure PowerShell module version 8.0 or later
|
||||||
|
• Windows, macOS, and Linux support
|
||||||
|
|
||||||
|
Output Management:
|
||||||
|
• CSV files are appended to support incremental data collection
|
||||||
|
• Timestamped filenames prevent overwrites
|
||||||
|
• Large result sets may generate substantial file sizes
|
||||||
|
• Consider data retention and storage management policies
|
||||||
|
|
||||||
|
.LINK
|
||||||
|
https://docs.microsoft.com/en-us/azure/role-based-access-control/
|
||||||
|
https://docs.microsoft.com/en-us/powershell/azure/
|
||||||
|
|
||||||
|
AUTHOR: Cloud Engineering Team
|
||||||
|
CREATED: Azure governance and compliance toolkit
|
||||||
|
VERSION: 2.0 - Simplified RBAC analysis without PIM detection
|
||||||
|
UPDATED: Focused on core RBAC assignment enumeration and organizational metadata
|
||||||
|
#>
|
||||||
|
|
||||||
|
# Script parameters
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $false, HelpMessage = "Array of subscription IDs to process. If not specified, all subscriptions will be processed.")]
|
||||||
|
[string[]]$SubscriptionIds = @(),
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false, HelpMessage = "Custom output path for the CSV file. If not specified, uses timestamped filename in current directory.")]
|
||||||
|
[string]$OutputPath = ""
|
||||||
|
)
|
||||||
|
|
||||||
#Connect-AzAccount
|
#Connect-AzAccount
|
||||||
Import-Module Az.Accounts
|
Import-Module Az.Accounts
|
||||||
Import-Module Az.Resources
|
Import-Module Az.Resources
|
||||||
|
|
||||||
|
# PowerShell class to represent Azure resource and RBAC assignment data
|
||||||
class ResourceCheck {
|
class ResourceCheck {
|
||||||
[string] $ResourceId = ""
|
# Resource identification and metadata
|
||||||
[string] $Id = ""
|
[string] $ResourceId = "" # Azure resource identifier (full ARM path)
|
||||||
[string] $Kind = ""
|
[string] $Id = "" # Resource ID (used for individual resources)
|
||||||
[string] $Location = ""
|
[string] $Kind = "" # Resource level (ManagementGroup, Subscription, ResourceGroup, Resource)
|
||||||
[string] $ResourceName = ""
|
[string] $Location = "" # Azure region/location where resource is deployed
|
||||||
[string] $ResourceGroupName = ""
|
[string] $ResourceName = "" # Name of the resource
|
||||||
[string] $ResourceType = ""
|
[string] $ResourceGroupName = "" # Resource group containing the resource
|
||||||
[string] $ManagementGroupId = ""
|
[string] $ResourceType = "" # Azure resource type (e.g., Microsoft.Storage/storageAccounts)
|
||||||
[string] $ManagementGroupName = ""
|
|
||||||
[string] $SubscriptionId = ""
|
# Organizational hierarchy context
|
||||||
[string] $SubscriptionName = ""
|
[string] $ManagementGroupId = "" # Parent management group identifier
|
||||||
[string] $Tag_Team = ""
|
[string] $ManagementGroupName = "" # Parent management group display name
|
||||||
[string] $Tag_Product = ""
|
[string] $SubscriptionId = "" # Subscription identifier
|
||||||
[string] $Tag_Environment = ""
|
[string] $SubscriptionName = "" # Subscription display name
|
||||||
[string] $Tag_Data = ""
|
|
||||||
[string] $Tag_Delete = ""
|
# Organizational metadata tags (customize based on organizational tagging strategy)
|
||||||
[string] $Tag_Split = ""
|
[string] $Tag_Team = "" # Team responsible for the resource
|
||||||
[string] $RBAC_RoleAssignmentId = ""
|
[string] $Tag_Product = "" # Product/application alignment
|
||||||
[string] $RBAC_Scope = ""
|
[string] $Tag_Environment = "" # Environment classification (Dev, Test, Prod, etc.)
|
||||||
[string] $RBAC_DisplayName = ""
|
[string] $Tag_Data = "" # Data classification level
|
||||||
[string] $RBAC_SignInName = ""
|
[string] $Tag_Delete = "" # Scheduled deletion information
|
||||||
[string] $RBAC_RoleDefinitionName = ""
|
[string] $Tag_Split = "" # Cost allocation split information
|
||||||
|
|
||||||
|
# RBAC assignment details
|
||||||
|
[string] $RBAC_RoleAssignmentId = "" # Unique identifier for the role assignment
|
||||||
|
[string] $RBAC_Scope = "" # Scope where the role assignment is effective
|
||||||
|
[string] $RBAC_DisplayName = "" # Display name of the assigned principal
|
||||||
|
[string] $RBAC_SignInName = "" # Sign-in name/UPN of the assigned principal
|
||||||
|
[string] $RBAC_RoleDefinitionName = "" # Name of the assigned Azure role
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Display script execution banner
|
||||||
Write-Host "========================================================================================================================================================================"
|
Write-Host "========================================================================================================================================================================"
|
||||||
Write-Host "Creating resource RBAC assignment overview."
|
Write-Host "Creating resource RBAC assignment overview."
|
||||||
Write-Host "========================================================================================================================================================================"
|
Write-Host "========================================================================================================================================================================"
|
||||||
|
Write-Host "Azure RBAC Assignment Analysis"
|
||||||
|
Write-Host "========================================================================================================================================================================"
|
||||||
|
|
||||||
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
# Display processing scope information
|
||||||
$fileName = ".\$date azure_rbac_assignments.csv"
|
if ($SubscriptionIds.Count -gt 0) {
|
||||||
|
Write-Host "SCOPE: Processing specific subscriptions only"
|
||||||
|
Write-Host "Subscription IDs to process:"
|
||||||
|
foreach ($subId in $SubscriptionIds) {
|
||||||
|
Write-Host " - $subId"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Host "SCOPE: Processing ALL active subscriptions across all management groups"
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Generate filename for output CSV (use custom path or default timestamped filename)
|
||||||
|
if ($OutputPath -ne "") {
|
||||||
|
$fileName = $OutputPath
|
||||||
|
Write-Host "Using custom output file: $fileName"
|
||||||
|
} else {
|
||||||
|
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
||||||
|
$fileName = ".\$date azure_rbac_assignments.csv"
|
||||||
|
Write-Host "Using default timestamped filename: $fileName"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Discover all management groups in the tenant
|
||||||
|
# This provides the top-level organizational structure for Azure resources
|
||||||
$managementGroups = Get-AzManagementGroup
|
$managementGroups = Get-AzManagementGroup
|
||||||
|
|
||||||
|
# Process each management group in the tenant
|
||||||
foreach ($managementGroup in $managementGroups)
|
foreach ($managementGroup in $managementGroups)
|
||||||
{
|
{
|
||||||
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
||||||
Write-Host "Management group [$($managementGroup.Name)]"
|
Write-Host "Management group [$($managementGroup.Name)]"
|
||||||
|
|
||||||
|
# Initialize collection for management group level role assignments
|
||||||
[ResourceCheck[]]$Result = @()
|
[ResourceCheck[]]$Result = @()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
# Get role assignments directly assigned to this management group (not inherited)
|
||||||
$roleAssignments = Get-AzRoleAssignment -Scope $managementGroup.Id | Where-Object Scope -eq $managementGroup.Id
|
$roleAssignments = Get-AzRoleAssignment -Scope $managementGroup.Id | Where-Object Scope -eq $managementGroup.Id
|
||||||
|
|
||||||
|
# Process each role assignment at the management group level
|
||||||
foreach($roleAssignment in $roleAssignments) {
|
foreach($roleAssignment in $roleAssignments) {
|
||||||
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
||||||
$resourceCheck.ResourceId = ""
|
|
||||||
$resourceCheck.Kind = "ManagementGroup"
|
# Set management group context (no individual resource context at this level)
|
||||||
$resourceCheck.Location = ""
|
$resourceCheck.ResourceId = "" # No specific resource for MG assignments
|
||||||
$resourceCheck.ResourceGroupName = ""
|
$resourceCheck.Kind = "ManagementGroup" # Indicates this is a management group level assignment
|
||||||
|
$resourceCheck.Location = "" # Management groups don't have locations
|
||||||
|
$resourceCheck.ResourceGroupName = "" # No resource group context
|
||||||
$resourceCheck.ManagementGroupId = $managementGroup.Id
|
$resourceCheck.ManagementGroupId = $managementGroup.Id
|
||||||
$resourceCheck.ManagementGroupName = $managementGroup.DisplayName
|
$resourceCheck.ManagementGroupName = $managementGroup.DisplayName
|
||||||
$resourceCheck.SubscriptionId = ""
|
$resourceCheck.SubscriptionId = "" # No subscription context at MG level
|
||||||
$resourceCheck.SubscriptionName = ""
|
$resourceCheck.SubscriptionName = ""
|
||||||
|
|
||||||
|
# Management groups don't have tags in the same way resources do
|
||||||
$resourceCheck.Tag_Team = ""
|
$resourceCheck.Tag_Team = ""
|
||||||
$resourceCheck.Tag_Product = ""
|
$resourceCheck.Tag_Product = ""
|
||||||
$resourceCheck.Tag_Environment = ""
|
$resourceCheck.Tag_Environment = ""
|
||||||
$resourceCheck.Tag_Data = ""
|
$resourceCheck.Tag_Data = ""
|
||||||
$resourceCheck.Tag_Delete = ""
|
$resourceCheck.Tag_Delete = ""
|
||||||
$resourceCheck.Tag_Split = ""
|
$resourceCheck.Tag_Split = ""
|
||||||
|
|
||||||
|
# Populate RBAC assignment details
|
||||||
$resourceCheck.RBAC_RoleAssignmentId = $roleAssignment.RoleAssignmentId
|
$resourceCheck.RBAC_RoleAssignmentId = $roleAssignment.RoleAssignmentId
|
||||||
$resourceCheck.RBAC_Scope = $roleAssignment.Scope
|
$resourceCheck.RBAC_Scope = $roleAssignment.Scope
|
||||||
$resourceCheck.RBAC_DisplayName = $roleAssignment.DisplayName
|
$resourceCheck.RBAC_DisplayName = $roleAssignment.DisplayName
|
||||||
@@ -75,39 +233,68 @@ foreach ($managementGroup in $managementGroups)
|
|||||||
$Result | Export-Csv -Path $fileName -Append -NoTypeInformation
|
$Result | Export-Csv -Path $fileName -Append -NoTypeInformation
|
||||||
|
|
||||||
|
|
||||||
$subscriptions = Get-AzManagementGroupSubscription -Group $managementGroup.Name | Where-Object State -eq "Active"
|
# Get all active subscriptions under this management group
|
||||||
|
$allSubscriptions = Get-AzManagementGroupSubscription -Group $managementGroup.Name | Where-Object State -eq "Active"
|
||||||
|
|
||||||
|
# Filter subscriptions if specific subscription IDs were provided
|
||||||
|
if ($SubscriptionIds.Count -gt 0) {
|
||||||
|
$subscriptions = $allSubscriptions | Where-Object {
|
||||||
|
$subscriptionId = $_.Id.Split('/')[-1] # Extract subscription ID from resource path
|
||||||
|
$subscriptionId -in $SubscriptionIds
|
||||||
|
}
|
||||||
|
if ($subscriptions.Count -eq 0) {
|
||||||
|
Write-Host "No matching subscriptions found in management group '$($managementGroup.DisplayName)' for the specified subscription IDs."
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
Write-Host "Processing $($subscriptions.Count) filtered subscription(s) in management group '$($managementGroup.DisplayName)'"
|
||||||
|
} else {
|
||||||
|
$subscriptions = $allSubscriptions
|
||||||
|
Write-Host "Processing all $($subscriptions.Count) active subscription(s) in management group '$($managementGroup.DisplayName)'"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Process each subscription under the current management group
|
||||||
foreach ($subscription in $subscriptions)
|
foreach ($subscription in $subscriptions)
|
||||||
{
|
{
|
||||||
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
||||||
|
|
||||||
|
# Extract subscription ID from the full resource path
|
||||||
$scope = $subscription.Id.Substring($subscription.Parent.Length, $subscription.Id.Length - $subscription.Parent.Length)
|
$scope = $subscription.Id.Substring($subscription.Parent.Length, $subscription.Id.Length - $subscription.Parent.Length)
|
||||||
$subscriptionId = $scope.Replace("/subscriptions/", "")
|
$subscriptionId = $scope.Replace("/subscriptions/", "")
|
||||||
Write-Host "Subscription [$($subscription.DisplayName) - $subscriptionId]"
|
Write-Host "Subscription [$($subscription.DisplayName) - $subscriptionId]"
|
||||||
|
|
||||||
|
# Set Azure PowerShell context to the current subscription
|
||||||
Set-AzContext -SubscriptionId $subscriptionId | Out-Null
|
Set-AzContext -SubscriptionId $subscriptionId | Out-Null
|
||||||
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
||||||
|
|
||||||
|
# Initialize result array for this subscription's role assignments
|
||||||
[ResourceCheck[]]$Result = @()
|
[ResourceCheck[]]$Result = @()
|
||||||
|
|
||||||
|
# Get role assignments directly assigned to this subscription scope
|
||||||
try {
|
try {
|
||||||
$roleAssignments = Get-AzRoleAssignment -Scope $scope | Where-Object Scope -eq $scope
|
$roleAssignments = Get-AzRoleAssignment -Scope $scope | Where-Object Scope -eq $scope
|
||||||
|
|
||||||
|
# Process each subscription-level role assignment
|
||||||
foreach($roleAssignment in $roleAssignments) {
|
foreach($roleAssignment in $roleAssignments) {
|
||||||
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
||||||
$resourceCheck.ResourceId = ""
|
|
||||||
$resourceCheck.Kind = "Subscription"
|
# Set subscription context (no individual resource context at this level)
|
||||||
$resourceCheck.Location = ""
|
$resourceCheck.ResourceId = "" # No specific resource for subscription assignments
|
||||||
$resourceCheck.ResourceGroupName = ""
|
$resourceCheck.Kind = "Subscription" # Indicates this is a subscription level assignment
|
||||||
|
$resourceCheck.Location = "" # Subscriptions don't have specific locations
|
||||||
|
$resourceCheck.ResourceGroupName = "" # No resource group context at subscription level
|
||||||
$resourceCheck.ManagementGroupId = $managementGroup.Id
|
$resourceCheck.ManagementGroupId = $managementGroup.Id
|
||||||
$resourceCheck.ManagementGroupName = $managementGroup.DisplayName
|
$resourceCheck.ManagementGroupName = $managementGroup.DisplayName
|
||||||
$resourceCheck.SubscriptionId = $subscription.Id
|
$resourceCheck.SubscriptionId = $subscription.Id
|
||||||
$resourceCheck.SubscriptionName = $subscription.DisplayName
|
$resourceCheck.SubscriptionName = $subscription.DisplayName
|
||||||
|
# Extract subscription-level tags if available
|
||||||
$resourceCheck.Tag_Team = $subscription.Tags.team
|
$resourceCheck.Tag_Team = $subscription.Tags.team
|
||||||
$resourceCheck.Tag_Product = $subscription.Tags.product
|
$resourceCheck.Tag_Product = $subscription.Tags.product
|
||||||
$resourceCheck.Tag_Environment = $subscription.Tags.environment
|
$resourceCheck.Tag_Environment = $subscription.Tags.environment
|
||||||
$resourceCheck.Tag_Data = $subscription.Tags.data
|
$resourceCheck.Tag_Data = $subscription.Tags.data
|
||||||
$resourceCheck.Tag_Delete = $subscription.Tags.delete
|
$resourceCheck.Tag_Delete = $subscription.Tags.delete
|
||||||
$resourceCheck.Tag_Split = $subscription.Tags.split
|
$resourceCheck.Tag_Split = $subscription.Tags.split
|
||||||
|
|
||||||
|
# Populate RBAC assignment details
|
||||||
$resourceCheck.RBAC_RoleAssignmentId = $roleAssignment.RoleAssignmentId
|
$resourceCheck.RBAC_RoleAssignmentId = $roleAssignment.RoleAssignmentId
|
||||||
$resourceCheck.RBAC_Scope = $roleAssignment.Scope
|
$resourceCheck.RBAC_Scope = $roleAssignment.Scope
|
||||||
$resourceCheck.RBAC_DisplayName = $roleAssignment.DisplayName
|
$resourceCheck.RBAC_DisplayName = $roleAssignment.DisplayName
|
||||||
@@ -117,34 +304,46 @@ foreach ($managementGroup in $managementGroups)
|
|||||||
$Result += $resourceCheck
|
$Result += $resourceCheck
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
# Silently handle any errors during subscription-level role assignment processing
|
||||||
}
|
}
|
||||||
|
# Export subscription-level role assignments to CSV
|
||||||
$Result | Export-Csv -Path $fileName -Append -NoTypeInformation
|
$Result | Export-Csv -Path $fileName -Append -NoTypeInformation
|
||||||
|
|
||||||
|
# Get all resource groups within the current subscription
|
||||||
$resourceGroups = Get-AzResourceGroup
|
$resourceGroups = Get-AzResourceGroup
|
||||||
|
|
||||||
|
# Process each resource group for RBAC assignments
|
||||||
foreach ($resourceGroup in $resourceGroups) {
|
foreach ($resourceGroup in $resourceGroups) {
|
||||||
|
|
||||||
|
# Initialize result array for this resource group's role assignments
|
||||||
[ResourceCheck[]]$Result = @()
|
[ResourceCheck[]]$Result = @()
|
||||||
|
|
||||||
|
# Get role assignments at resource group level and below
|
||||||
try {
|
try {
|
||||||
$roleAssignments = Get-AzRoleAssignment -Scope $resourceGroup.ResourceId | Where-Object Scope -Like "$($resourceGroup.ResourceId)*"
|
$roleAssignments = Get-AzRoleAssignment -Scope $resourceGroup.ResourceId | Where-Object Scope -Like "$($resourceGroup.ResourceId)*"
|
||||||
|
|
||||||
|
# Process each resource group-level role assignment
|
||||||
foreach($roleAssignment in $roleAssignments) {
|
foreach($roleAssignment in $roleAssignments) {
|
||||||
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
||||||
|
# Set resource group context
|
||||||
$resourceCheck.ResourceId = $resourceGroup.ResourceId
|
$resourceCheck.ResourceId = $resourceGroup.ResourceId
|
||||||
$resourceCheck.Kind = "ResourceGroup"
|
$resourceCheck.Kind = "ResourceGroup" # Indicates this is a resource group level assignment
|
||||||
$resourceCheck.Location = $resourceGroup.Location
|
$resourceCheck.Location = $resourceGroup.Location
|
||||||
$resourceCheck.ResourceGroupName = $resourceGroup.ResourceGroupName
|
$resourceCheck.ResourceGroupName = $resourceGroup.ResourceGroupName
|
||||||
$resourceCheck.ManagementGroupId = $managementGroup.Id
|
$resourceCheck.ManagementGroupId = $managementGroup.Id
|
||||||
$resourceCheck.ManagementGroupName = $managementGroup.DisplayName
|
$resourceCheck.ManagementGroupName = $managementGroup.DisplayName
|
||||||
$resourceCheck.SubscriptionId = $subscription.Id
|
$resourceCheck.SubscriptionId = $subscription.Id
|
||||||
$resourceCheck.SubscriptionName = $subscription.DisplayName
|
$resourceCheck.SubscriptionName = $subscription.DisplayName
|
||||||
|
|
||||||
|
# Extract resource group-level tags if available
|
||||||
$resourceCheck.Tag_Team = $resourceGroup.Tags.team
|
$resourceCheck.Tag_Team = $resourceGroup.Tags.team
|
||||||
$resourceCheck.Tag_Product = $resourceGroup.Tags.product
|
$resourceCheck.Tag_Product = $resourceGroup.Tags.product
|
||||||
$resourceCheck.Tag_Environment = $resourceGroup.Tags.environment
|
$resourceCheck.Tag_Environment = $resourceGroup.Tags.environment
|
||||||
$resourceCheck.Tag_Data = $resourceGroup.Tags.data
|
$resourceCheck.Tag_Data = $resourceGroup.Tags.data
|
||||||
$resourceCheck.Tag_Delete = $resourceGroup.Tags.delete
|
$resourceCheck.Tag_Delete = $resourceGroup.Tags.delete
|
||||||
$resourceCheck.Tag_Split = $resourceGroup.Tags.split
|
$resourceCheck.Tag_Split = $resourceGroup.Tags.split
|
||||||
|
|
||||||
|
# Populate RBAC assignment details
|
||||||
$resourceCheck.RBAC_RoleAssignmentId = $roleAssignment.RoleAssignmentId
|
$resourceCheck.RBAC_RoleAssignmentId = $roleAssignment.RoleAssignmentId
|
||||||
$resourceCheck.RBAC_Scope = $roleAssignment.Scope
|
$resourceCheck.RBAC_Scope = $roleAssignment.Scope
|
||||||
$resourceCheck.RBAC_DisplayName = $roleAssignment.DisplayName
|
$resourceCheck.RBAC_DisplayName = $roleAssignment.DisplayName
|
||||||
@@ -154,38 +353,51 @@ foreach ($managementGroup in $managementGroups)
|
|||||||
$Result += $resourceCheck
|
$Result += $resourceCheck
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
# Silently handle any errors during resource group-level role assignment processing
|
||||||
}
|
}
|
||||||
|
# Export resource group-level role assignments to CSV
|
||||||
$Result | Export-Csv -Path $fileName -Append -NoTypeInformation
|
$Result | Export-Csv -Path $fileName -Append -NoTypeInformation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Get all individual resources within the current subscription
|
||||||
$allResources = Get-AzResource
|
$allResources = Get-AzResource
|
||||||
|
|
||||||
|
# Process each individual resource for RBAC assignments
|
||||||
foreach ($resource in $allResources) {
|
foreach ($resource in $allResources) {
|
||||||
|
|
||||||
|
# Initialize result array for this resource's role assignments
|
||||||
[ResourceCheck[]]$Result = @()
|
[ResourceCheck[]]$Result = @()
|
||||||
|
|
||||||
|
# Get role assignments directly assigned to this specific resource
|
||||||
try {
|
try {
|
||||||
$roleAssignments = Get-AzRoleAssignment -Scope $resource.ResourceId | Where-Object Scope -eq $resource.ResourceId
|
$roleAssignments = Get-AzRoleAssignment -Scope $resource.ResourceId | Where-Object Scope -eq $resource.ResourceId
|
||||||
|
|
||||||
|
# Process each resource-level role assignment
|
||||||
foreach($roleAssignment in $roleAssignments) {
|
foreach($roleAssignment in $roleAssignments) {
|
||||||
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
||||||
|
|
||||||
|
# Set individual resource context with full metadata
|
||||||
$resourceCheck.ResourceId = $resource.ResourceId
|
$resourceCheck.ResourceId = $resource.ResourceId
|
||||||
$resourceCheck.Id = $resource.Id
|
$resourceCheck.Id = $resource.Id # Additional resource identifier
|
||||||
$resourceCheck.Kind = "Resource"
|
$resourceCheck.Kind = "Resource" # Indicates this is an individual resource assignment
|
||||||
$resourceCheck.Location = $resource.Location
|
$resourceCheck.Location = $resource.Location
|
||||||
$resourceCheck.ResourceName = $resource.ResourceName
|
$resourceCheck.ResourceName = $resource.ResourceName
|
||||||
$resourceCheck.ResourceGroupName = $resource.ResourceGroupName
|
$resourceCheck.ResourceGroupName = $resource.ResourceGroupName
|
||||||
$resourceCheck.ResourceType = $resource.ResourceType
|
$resourceCheck.ResourceType = $resource.ResourceType # e.g., Microsoft.Storage/storageAccounts
|
||||||
$resourceCheck.ManagementGroupId = $managementGroup.Id
|
$resourceCheck.ManagementGroupId = $managementGroup.Id
|
||||||
$resourceCheck.ManagementGroupName = $managementGroup.DisplayName
|
$resourceCheck.ManagementGroupName = $managementGroup.DisplayName
|
||||||
$resourceCheck.SubscriptionId = $subscription.Id
|
$resourceCheck.SubscriptionId = $subscription.Id
|
||||||
$resourceCheck.SubscriptionName = $subscription.DisplayName
|
$resourceCheck.SubscriptionName = $subscription.DisplayName
|
||||||
|
|
||||||
|
# Extract resource-level tags if available
|
||||||
$resourceCheck.Tag_Team = $resource.Tags.team
|
$resourceCheck.Tag_Team = $resource.Tags.team
|
||||||
$resourceCheck.Tag_Product = $resource.Tags.product
|
$resourceCheck.Tag_Product = $resource.Tags.product
|
||||||
$resourceCheck.Tag_Environment = $resource.Tags.environment
|
$resourceCheck.Tag_Environment = $resource.Tags.environment
|
||||||
$resourceCheck.Tag_Data = $resource.Tags.data
|
$resourceCheck.Tag_Data = $resource.Tags.data
|
||||||
$resourceCheck.Tag_Delete = $resource.Tags.delete
|
$resourceCheck.Tag_Delete = $resource.Tags.delete
|
||||||
$resourceCheck.Tag_Split = $resource.Tags.split
|
$resourceCheck.Tag_Split = $resource.Tags.split
|
||||||
|
|
||||||
|
# Populate RBAC assignment details
|
||||||
$resourceCheck.RBAC_RoleAssignmentId = $roleAssignment.RoleAssignmentId
|
$resourceCheck.RBAC_RoleAssignmentId = $roleAssignment.RoleAssignmentId
|
||||||
$resourceCheck.RBAC_Scope = $roleAssignment.Scope
|
$resourceCheck.RBAC_Scope = $roleAssignment.Scope
|
||||||
$resourceCheck.RBAC_DisplayName = $roleAssignment.DisplayName
|
$resourceCheck.RBAC_DisplayName = $roleAssignment.DisplayName
|
||||||
@@ -195,11 +407,20 @@ foreach ($managementGroup in $managementGroups)
|
|||||||
$Result += $resourceCheck
|
$Result += $resourceCheck
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
# Silently handle any errors during individual resource-level role assignment processing
|
||||||
}
|
}
|
||||||
|
# Export individual resource-level role assignments to CSV
|
||||||
$Result | Export-Csv -Path $fileName -Append -NoTypeInformation
|
$Result | Export-Csv -Path $fileName -Append -NoTypeInformation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Final completion message
|
||||||
Write-Host "========================================================================================================================================================================"
|
Write-Host "========================================================================================================================================================================"
|
||||||
|
if ($SubscriptionIds.Count -gt 0) {
|
||||||
|
Write-Host "RBAC analysis complete for $($SubscriptionIds.Count) specified subscription(s)."
|
||||||
|
} else {
|
||||||
|
Write-Host "RBAC analysis complete for all active subscriptions across all management groups."
|
||||||
|
}
|
||||||
|
Write-Host "Results exported to: $fileName"
|
||||||
Write-Host "Done."
|
Write-Host "Done."
|
||||||
|
|||||||
@@ -1,86 +1,215 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Exports Azure Storage blob listings with optional container filtering and blob prefix matching to CSV format.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This script inventories Azure Storage blobs across containers within a specified storage account.
|
||||||
|
It supports multiple modes of operation including containers-only listing, full blob enumeration,
|
||||||
|
container exclusion filtering, and blob prefix filtering.
|
||||||
|
|
||||||
|
The script uses Azure Storage continuation tokens to handle large datasets efficiently and
|
||||||
|
exports results in batches to prevent memory issues with very large storage accounts.
|
||||||
|
|
||||||
|
Key Features:
|
||||||
|
- Container-only mode for quick container inventory
|
||||||
|
- Full blob enumeration with metadata
|
||||||
|
- Container exclusion filtering for system containers
|
||||||
|
- Blob prefix filtering for targeted inventory
|
||||||
|
- Large dataset handling with continuation tokens
|
||||||
|
- CSV export with timestamped filenames
|
||||||
|
|
||||||
|
.PARAMETER subscriptionId
|
||||||
|
[Required] The Azure subscription ID containing the storage account.
|
||||||
|
|
||||||
|
.PARAMETER resourcegroupName
|
||||||
|
[Required] The resource group name containing the storage account.
|
||||||
|
|
||||||
|
.PARAMETER storageAccountName
|
||||||
|
[Required] The name of the Azure Storage account to inventory.
|
||||||
|
|
||||||
|
.PARAMETER containersOnly
|
||||||
|
[Optional] Switch to export only container information without blob details.
|
||||||
|
Default: $false (full blob enumeration)
|
||||||
|
|
||||||
|
.PARAMETER excludedContainers
|
||||||
|
[Optional] Array of container names to exclude from the inventory.
|
||||||
|
Useful for filtering out system containers like '$logs', '$blobchangefeed', etc.
|
||||||
|
Default: Empty array (no exclusions)
|
||||||
|
|
||||||
|
.PARAMETER blobPrefix
|
||||||
|
[Optional] Blob name prefix filter to limit results to blobs starting with specified string.
|
||||||
|
Useful for targeting specific blob hierarchies or naming patterns.
|
||||||
|
Default: Empty string (no prefix filtering)
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
CSV file named with timestamp pattern: "yyyy-MM-dd HHmm - [StorageAccountName] - bloblist.csv"
|
||||||
|
Contains columns for subscription, resource group, storage account, container, blob name, and last modified date.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\AzureStoragebloblist.ps1 -subscriptionId "a134faf1-7a89-4f2c-8389-06d00bd5e2a7" -resourcegroupName "Default-Storage-WestEurope" -storageAccountName "ecestore"
|
||||||
|
.\AzureStoragebloblist.ps1 -subscriptionId "a134faf1-7a89-4f2c-8389-06d00bd5e2a7" -resourcegroupName "Default-Storage-WestEurope" -storageAccountName "mailingstore"
|
||||||
|
.\AzureStoragebloblist.ps1 -subscriptionId "a134faf1-7a89-4f2c-8389-06d00bd5e2a7" -resourcegroupName "Default-Storage-WestEurope" -storageAccountName "projectcenter"
|
||||||
|
.\AzureStoragebloblist.ps1 -subscriptionId "a134faf1-7a89-4f2c-8389-06d00bd5e2a7" -resourcegroupName "effectorycore" -storageAccountName "corerightsaggregator" -ContainersOnly $true
|
||||||
|
.\AzureStoragebloblist.ps1 -subscriptionId "14c2354d-45a9-4e0f-98ff-be58cdbcddc7" -resourcegroupName "ec-automation-prod" -storageAccountName "stecautomationprod"
|
||||||
|
.\AzureStoragebloblist.ps1 -subscriptionId "7feeb150-9ee0-4aea-992a-5f3a89d933e6" -resourcegroupName "Results" -storageAccountName "myeffectoryresults"
|
||||||
|
.\AzureStoragebloblist.ps1 -subscriptionId "3190b0fd-4a66-4636-a204-5b9f18be78a6" -resourcegroupName "authorization" -storageAccountName "authorizationv2"
|
||||||
|
.\AzureStoragebloblist.ps1 -subscriptionId "a134faf1-7a89-4f2c-8389-06d00bd5e2a7" -resourcegroupName "effectorycore" -storageAccountName "coremailings"
|
||||||
|
.\AzureStoragebloblist.ps1 -subscriptionId "7feeb150-9ee0-4aea-992a-5f3a89d933e6" -resourcegroupName "results-activity" -storageAccountName "effactivity" -excludedContainers "`$logs","`$blobchangefeed", "activitybackup-applease", "activitybackup-largemessages", "activitybackup-leases", "activitycleanup-applease", "activitycleanup-leases", "activityprojectors-largemessages", "activityprojectors-leases", "activityquestionnaireavailableactivitygenerat-largemessages", "activityquestionnaireavailableactivitygenerat-leases", "attachments", "azure-webjobs-hosts", "azure-webjobs-secrets", "testhubname-applease", "testhubname-largemessages", "testhubname-leases" -blobPrefix "projects"
|
||||||
|
.\AzureStoragebloblist.ps1 -subscriptionId "86945e42-fa5a-4bbc-948f-3f5407f15d3e" -resourcegroupName "hierarchy" -storageAccountName "hierarchyeff"
|
||||||
|
.\AzureStoragebloblist.ps1 -subscriptionId "6e2b45e4-5e7b-4628-8827-ec44e23d2f6b" -resourcegroupName "ParticipantIntegration-Settings" -storageAccountName "integrationsettings"
|
||||||
|
.\AzureStoragebloblist.ps1 -subscriptionId "1ab2120c-947c-40e2-96c7-460d3e9659de" -resourcegroupName "sa-backups" -storageAccountName "archivecommvault"
|
||||||
|
.\AzureStoragebloblist.ps1 -subscriptionId "1ab2120c-947c-40e2-96c7-460d3e9659de" -resourcegroupName "sa-backups" -storageAccountName "backupcommvault"
|
||||||
|
.\AzureStoragebloblist.ps1 -subscriptionId "2a07dfa7-69ee-4608-b2d5-14124fcccc31" -resourcegroupName "questionnaire-server-weu" -storageAccountName "questionnairestoreweu"
|
||||||
|
.\AzureStoragebloblist.ps1 -subscriptionId "f9ab522b-4895-492d-b8a8-ca6e1f60c2a8" -resourcegroupName "participant-exchange" -storageAccountName "participantexchangev2" -excludedContainers "leases","insights-metrics-pt1m","insights-logs-partitionkeystatistics","insights-logs-dataplanerequests","insights-logs-controlplanerequests","event-attachments","command-handlers","aggregates-streaming","aggregates","`$logs","`$blobchangefeed"
|
||||||
|
.\AzureStoragebloblist.ps1 -subscriptionId "14c2354d-45a9-4e0f-98ff-be58cdbcddc7" -resourcegroupName "ec-measurement" -storageAccountName "stecmeasurementprod"
|
||||||
|
.\AzureStoragebloblist.ps1 -subscriptionId "2a07dfa7-69ee-4608-b2d5-14124fcccc31" -resourcegroupName "questionnaire-server-weu" -storageAccountName "questionnairedataweu"
|
||||||
|
.\AzureStoragebloblist.ps1 -subscriptionId "54794e27-b714-4346-81bc-05eae7ccb5a5" -resourcegroupName "question-management-api-weu" -storageAccountName "qmprojectionsweu" -excludedContainers "`$logs","`$blobchangefeed"
|
||||||
|
.\AzureStoragebloblist.ps1 -subscriptionId "7feeb150-9ee0-4aea-992a-5f3a89d933e6" -resourcegroupName "Results" -storageAccountName "myeffectoryresults" -excludedContainers "`$logs","`$blobchangefeed", "attachments", "azure-webjobs-hosts", "azure-webjobs-secrets", "azure-webjobs-dashboard", "azure-webjobs-hosts", "azure-webjobs-secrets", "hierarchydatesettings-leases", "projectcalculations-leases","resultscleanup-applease","resultscleanup-leases","resultsgroupscorecalculator-leases","testhubname-leases"
|
||||||
|
.\AzureStoragebloblist.ps1 -subscriptionId "7feeb150-9ee0-4aea-992a-5f3a89d933e6" -resourcegroupName "results-calculation" -storageAccountName "resultscalculation" -excludedContainers "`$logs","`$blobchangefeed", "attachments", "azure-webjobs-hosts", "azure-webjobs-secrets", "local-leases", "local-applease", "calculations", "calculations-test"
|
||||||
|
.\AzureStoragebloblist.ps1 -subscriptionId "54794e27-b714-4346-81bc-05eae7ccb5a5" -resourcegroupName "question-management-internaldata_api-weu" -storageAccountName "qmidapiweustore"
|
||||||
|
.\AzureStoragebloblist.ps1 -subscriptionId "54794e27-b714-4346-81bc-05eae7ccb5a5" -resourcegroupName "question-management-library-weu" -storageAccountName "qmlibraryweu"
|
||||||
|
.\AzureStoragebloblist.ps1 -subscriptionId "54794e27-b714-4346-81bc-05eae7ccb5a5" -resourcegroupName "question-management-media-api-weu" -storageAccountName "qmmediaweu"
|
||||||
|
.\AzureStoragebloblist.ps1 -subscriptionId "54794e27-b714-4346-81bc-05eae7ccb5a5" -resourcegroupName "question-management-api-weu" -storageAccountName "qmprojectionsweu"
|
||||||
|
.\AzureStoragebloblist.ps1 -subscriptionId "2a07dfa7-69ee-4608-b2d5-14124fcccc31" -resourcegroupName "questionnaire-data-collector-api-weu" -storageAccountName "quedatacolstoreweu"
|
||||||
|
.\AzureStoragebloblist.ps1 -subscriptionId "34c83aa8-6a8f-4c5e-9c27-0f1730d233bb" -resourcegroupName "start-a-survey" -storageAccountName "startasurvey" -excludedContainers "active-projects","attachments","attachments-logs","azure-webjobs-hosts","azure-webjobs-secrets","durablefunctionshub-largemessages","durablefunctionshub-leases","event-documents","locales","locales-theme-names","pdf-temp","portal","public","schemas"
|
||||||
|
.\AzureStoragebloblist.ps1 -subscriptionId "7feeb150-9ee0-4aea-992a-5f3a89d933e6" -resourcegroupName "rg-yourfeedback-001" -storageAccountName "yourfeedback" -excludedContainers "`$logs","`$blobchangefeed"
|
||||||
|
|
||||||
|
Exports blobs with "projects" prefix while excluding system containers.
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Author: Cloud Engineering Team
|
||||||
|
Version: 1.0
|
||||||
|
Created: 2024
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
- Azure PowerShell module (Az.Storage) must be installed
|
||||||
|
- User must be authenticated (Connect-AzAccount)
|
||||||
|
- Requires Storage Blob Data Reader permissions or higher on the target storage account
|
||||||
|
|
||||||
|
Performance Considerations:
|
||||||
|
- Uses continuation tokens to handle large datasets efficiently
|
||||||
|
- Processes results in batches of 100,000 items to manage memory usage
|
||||||
|
- Export operations are performed incrementally to prevent timeouts
|
||||||
|
- Large storage accounts may take considerable time to process
|
||||||
|
|
||||||
|
Security Notes:
|
||||||
|
- Requires appropriate RBAC permissions on storage account
|
||||||
|
- Consider using managed identities for automated scenarios
|
||||||
|
- Output file contains blob metadata and should be handled securely
|
||||||
|
|
||||||
|
Common Use Cases:
|
||||||
|
- Storage account auditing and inventory
|
||||||
|
- Data migration planning and assessment
|
||||||
|
- Cleanup operations and lifecycle management
|
||||||
|
- Compliance reporting and data discovery
|
||||||
|
|
||||||
|
.LINK
|
||||||
|
https://docs.microsoft.com/en-us/azure/storage/blobs/
|
||||||
|
https://docs.microsoft.com/en-us/powershell/module/az.storage/
|
||||||
|
#>
|
||||||
|
|
||||||
|
#Requires -Modules Az.Storage
|
||||||
param (
|
param (
|
||||||
[string] $subscriptionId = "",
|
[Parameter(Mandatory = $true, HelpMessage = "Azure subscription ID containing the storage account")]
|
||||||
[string] $resourcegroupName = "",
|
[ValidateNotNullOrEmpty()]
|
||||||
[string] $storageAccountName = "",
|
[string] $subscriptionId,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true, HelpMessage = "Resource group name containing the storage account")]
|
||||||
|
[ValidateNotNullOrEmpty()]
|
||||||
|
[string] $resourcegroupName,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true, HelpMessage = "Azure Storage account name to inventory")]
|
||||||
|
[ValidateNotNullOrEmpty()]
|
||||||
|
[string] $storageAccountName,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false, HelpMessage = "Export only container information without blob details")]
|
||||||
[bool] $containersOnly = $false,
|
[bool] $containersOnly = $false,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false, HelpMessage = "Array of container names to exclude from inventory")]
|
||||||
[string[]] $excludedContainers = @(),
|
[string[]] $excludedContainers = @(),
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false, HelpMessage = "Blob name prefix filter for targeted inventory")]
|
||||||
[string] $blobPrefix = ""
|
[string] $blobPrefix = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
if (("" -eq $subscriptionId) -or ("" -eq $resourcegroupName) -or ("" -eq $storageAccountName)) {
|
# Parameter validation and initialization
|
||||||
throw "Parameter(s) missing."
|
Write-Host "======================================================================================================================================================================"
|
||||||
}
|
Write-Host "Starting Azure Storage Blob inventory process"
|
||||||
else {
|
Write-Host "======================================================================================================================================================================"
|
||||||
Write-Host "Processing subscription [$subscriptionId], resource group [$resourcegroupName], storage account [$storageAccountName]"
|
|
||||||
}
|
|
||||||
|
|
||||||
# .\AzureStoragebloblist.ps1 -subscriptionId "a134faf1-7a89-4f2c-8389-06d00bd5e2a7" -resourcegroupName "Default-Storage-WestEurope" -storageAccountName "ecestore"
|
Write-Host "Configuration:"
|
||||||
# .\AzureStoragebloblist.ps1 -subscriptionId "a134faf1-7a89-4f2c-8389-06d00bd5e2a7" -resourcegroupName "Default-Storage-WestEurope" -storageAccountName "mailingstore"
|
Write-Host " Subscription ID: $subscriptionId"
|
||||||
# .\AzureStoragebloblist.ps1 -subscriptionId "a134faf1-7a89-4f2c-8389-06d00bd5e2a7" -resourcegroupName "Default-Storage-WestEurope" -storageAccountName "projectcenter"
|
Write-Host " Resource Group: $resourcegroupName"
|
||||||
# .\AzureStoragebloblist.ps1 -subscriptionId "a134faf1-7a89-4f2c-8389-06d00bd5e2a7" -resourcegroupName "effectorycore" -storageAccountName "corerightsaggregator" -ContainersOnly $true
|
Write-Host " Storage Account: $storageAccountName"
|
||||||
# .\AzureStoragebloblist.ps1 -subscriptionId "14c2354d-45a9-4e0f-98ff-be58cdbcddc7" -resourcegroupName "ec-automation-prod" -storageAccountName "stecautomationprod"
|
Write-Host " Containers Only Mode: $containersOnly"
|
||||||
|
Write-Host " Excluded Containers: $($excludedContainers -join ', ')"
|
||||||
# .\AzureStoragebloblist.ps1 -subscriptionId "7feeb150-9ee0-4aea-992a-5f3a89d933e6" -resourcegroupName "Results" -storageAccountName "myeffectoryresults"
|
Write-Host " Blob Prefix Filter: $($blobPrefix -eq '' ? 'None' : $blobPrefix)"
|
||||||
# .\AzureStoragebloblist.ps1 -subscriptionId "3190b0fd-4a66-4636-a204-5b9f18be78a6" -resourcegroupName "authorization" -storageAccountName "authorizationv2"
|
Write-Host ""
|
||||||
|
|
||||||
# .\AzureStoragebloblist.ps1 -subscriptionId "a134faf1-7a89-4f2c-8389-06d00bd5e2a7" -resourcegroupName "effectorycore" -storageAccountName "coremailings"
|
|
||||||
# .\AzureStoragebloblist.ps1 -subscriptionId "7feeb150-9ee0-4aea-992a-5f3a89d933e6" -resourcegroupName "results-activity" -storageAccountName "effactivity" -excludedContainers "`$logs","`$blobchangefeed", "activitybackup-applease", "activitybackup-largemessages", "activitybackup-leases", "activitycleanup-applease", "activitycleanup-leases", "activityprojectors-largemessages", "activityprojectors-leases", "activityquestionnaireavailableactivitygenerat-largemessages", "activityquestionnaireavailableactivitygenerat-leases", "attachments", "azure-webjobs-hosts", "azure-webjobs-secrets", "testhubname-applease", "testhubname-largemessages", "testhubname-leases" -blobPrefix "projects"
|
|
||||||
# .\AzureStoragebloblist.ps1 -subscriptionId "86945e42-fa5a-4bbc-948f-3f5407f15d3e" -resourcegroupName "hierarchy" -storageAccountName "hierarchyeff"
|
|
||||||
|
|
||||||
# .\AzureStoragebloblist.ps1 -subscriptionId "6e2b45e4-5e7b-4628-8827-ec44e23d2f6b" -resourcegroupName "ParticipantIntegration-Settings" -storageAccountName "integrationsettings"
|
|
||||||
|
|
||||||
|
|
||||||
# .\AzureStoragebloblist.ps1 -subscriptionId "1ab2120c-947c-40e2-96c7-460d3e9659de" -resourcegroupName "sa-backups" -storageAccountName "archivecommvault"
|
|
||||||
# .\AzureStoragebloblist.ps1 -subscriptionId "1ab2120c-947c-40e2-96c7-460d3e9659de" -resourcegroupName "sa-backups" -storageAccountName "backupcommvault"
|
|
||||||
# .\AzureStoragebloblist.ps1 -subscriptionId "2a07dfa7-69ee-4608-b2d5-14124fcccc31" -resourcegroupName "questionnaire-server-weu" -storageAccountName "questionnairestoreweu"
|
|
||||||
#
|
|
||||||
# .\AzureStoragebloblist.ps1 -subscriptionId "f9ab522b-4895-492d-b8a8-ca6e1f60c2a8" -resourcegroupName "participant-exchange" -storageAccountName "participantexchangev2" -excludedContainers "leases","insights-metrics-pt1m","insights-logs-partitionkeystatistics","insights-logs-dataplanerequests","insights-logs-controlplanerequests","event-attachments","command-handlers","aggregates-streaming","aggregates","`$logs","`$blobchangefeed"
|
|
||||||
# .\AzureStoragebloblist.ps1 -subscriptionId "14c2354d-45a9-4e0f-98ff-be58cdbcddc7" -resourcegroupName "ec-measurement" -storageAccountName "stecmeasurementprod"
|
|
||||||
# .\AzureStoragebloblist.ps1 -subscriptionId "2a07dfa7-69ee-4608-b2d5-14124fcccc31" -resourcegroupName "questionnaire-server-weu" -storageAccountName "questionnairedataweu"
|
|
||||||
# .\AzureStoragebloblist.ps1 -subscriptionId "54794e27-b714-4346-81bc-05eae7ccb5a5" -resourcegroupName "question-management-api-weu" -storageAccountName "qmprojectionsweu" -excludedContainers "`$logs","`$blobchangefeed"
|
|
||||||
# .\AzureStoragebloblist.ps1 -subscriptionId "7feeb150-9ee0-4aea-992a-5f3a89d933e6" -resourcegroupName "Results" -storageAccountName "myeffectoryresults" -excludedContainers "`$logs","`$blobchangefeed", "attachments", "azure-webjobs-hosts", "azure-webjobs-secrets", "azure-webjobs-dashboard", "azure-webjobs-hosts", "azure-webjobs-secrets", "hierarchydatesettings-leases", "projectcalculations-leases","resultscleanup-applease","resultscleanup-leases","resultsgroupscorecalculator-leases","testhubname-leases"
|
|
||||||
# .\AzureStoragebloblist.ps1 -subscriptionId "7feeb150-9ee0-4aea-992a-5f3a89d933e6" -resourcegroupName "results-calculation" -storageAccountName "resultscalculation" -excludedContainers "`$logs","`$blobchangefeed", "attachments", "azure-webjobs-hosts", "azure-webjobs-secrets", "local-leases", "local-applease", "calculations", "calculations-test"
|
|
||||||
|
|
||||||
# .\AzureStoragebloblist.ps1 -subscriptionId "54794e27-b714-4346-81bc-05eae7ccb5a5" -resourcegroupName "question-management-internaldata_api-weu" -storageAccountName "qmidapiweustore"
|
|
||||||
# .\AzureStoragebloblist.ps1 -subscriptionId "54794e27-b714-4346-81bc-05eae7ccb5a5" -resourcegroupName "question-management-library-weu" -storageAccountName "qmlibraryweu"
|
|
||||||
# .\AzureStoragebloblist.ps1 -subscriptionId "54794e27-b714-4346-81bc-05eae7ccb5a5" -resourcegroupName "question-management-media-api-weu" -storageAccountName "qmmediaweu"
|
|
||||||
# .\AzureStoragebloblist.ps1 -subscriptionId "54794e27-b714-4346-81bc-05eae7ccb5a5" -resourcegroupName "question-management-api-weu" -storageAccountName "qmprojectionsweu"
|
|
||||||
|
|
||||||
# .\AzureStoragebloblist.ps1 -subscriptionId "2a07dfa7-69ee-4608-b2d5-14124fcccc31" -resourcegroupName "questionnaire-data-collector-api-weu" -storageAccountName "quedatacolstoreweu"
|
|
||||||
|
|
||||||
# .\AzureStoragebloblist.ps1 -subscriptionId "34c83aa8-6a8f-4c5e-9c27-0f1730d233bb" -resourcegroupName "start-a-survey" -storageAccountName "startasurvey" -excludedContainers "active-projects","attachments","attachments-logs","azure-webjobs-hosts","azure-webjobs-secrets","durablefunctionshub-largemessages","durablefunctionshub-leases","event-documents","locales","locales-theme-names","pdf-temp","portal","public","schemas"
|
|
||||||
|
|
||||||
# .\AzureStoragebloblist.ps1 -subscriptionId "7feeb150-9ee0-4aea-992a-5f3a89d933e6" -resourcegroupName "rg-yourfeedback-001" -storageAccountName "yourfeedback" -excludedContainers "`$logs","`$blobchangefeed"
|
|
||||||
|
|
||||||
|
# Class definition for structured blob inventory data
|
||||||
class BlobCheck {
|
class BlobCheck {
|
||||||
[string] $SubscriptionId = ""
|
[string] $SubscriptionId = "" # Azure subscription GUID
|
||||||
[string] $SubscriptionName = ""
|
[string] $SubscriptionName = "" # Subscription display name
|
||||||
[string] $ResourcegroupName = ""
|
[string] $ResourcegroupName = "" # Resource group containing the storage account
|
||||||
[string] $StorageAccountName = ""
|
[string] $StorageAccountName = "" # Storage account name
|
||||||
[string] $ContainerName = ""
|
[string] $ContainerName = "" # Blob container name
|
||||||
[string] $BlobName = ""
|
[string] $BlobName = "" # Individual blob name (empty for container-only mode)
|
||||||
[string] $LastModifiedDate = ""
|
[string] $LastModifiedDate = "" # Last modified timestamp for container or blob
|
||||||
}
|
}
|
||||||
|
|
||||||
[int] $maxCount = 100000
|
# Configuration constants for large dataset handling
|
||||||
$containerToken = $null
|
[int] $maxCount = 100000 # Maximum items per batch to manage memory usage
|
||||||
$blobToken = $null
|
$containerToken = $null # Continuation token for container enumeration
|
||||||
|
$blobToken = $null # Continuation token for blob enumeration
|
||||||
|
|
||||||
|
# Generate timestamped output filename
|
||||||
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
||||||
$fileName = ".\$date - $storageAccountName - bloblist.csv"
|
$fileName = ".\$date - $storageAccountName - bloblist.csv"
|
||||||
|
Write-Host "Output file: $fileName"
|
||||||
|
|
||||||
$subscription = Set-AzContext -SubscriptionId $subscriptionId
|
try {
|
||||||
$storageAccount = Get-AzStorageAccount -ResourceGroupName $resourcegroupName -Name $storageAccountName
|
# Set Azure context and retrieve storage account
|
||||||
|
Write-Host "Setting Azure context and retrieving storage account..."
|
||||||
|
$subscription = Set-AzContext -SubscriptionId $subscriptionId
|
||||||
|
$storageAccount = Get-AzStorageAccount -ResourceGroupName $resourcegroupName -Name $storageAccountName
|
||||||
|
|
||||||
|
Write-Host "Successfully connected to storage account: $($storageAccount.StorageAccountName)"
|
||||||
|
Write-Host "Storage account location: $($storageAccount.Location)"
|
||||||
|
Write-Host "Storage account SKU: $($storageAccount.Sku.Name)"
|
||||||
|
Write-Host ""
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Error "Failed to connect to storage account: $($_.Exception.Message)"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Execute inventory based on mode selection
|
||||||
if ($containersOnly -eq $true) {
|
if ($containersOnly -eq $true) {
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "CONTAINERS ONLY MODE: Inventorying container information without blob details"
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
|
||||||
|
$totalContainers = 0
|
||||||
|
|
||||||
|
# Container-only enumeration loop with continuation token support
|
||||||
do {
|
do {
|
||||||
|
Write-Host "Processing container batch (max $maxCount containers)..."
|
||||||
[BlobCheck[]]$Result = @()
|
[BlobCheck[]]$Result = @()
|
||||||
|
|
||||||
|
# Retrieve containers with continuation token support
|
||||||
$containers = Get-AzStorageContainer -Context $storageAccount.Context -MaxCount $maxCount -ContinuationToken $containerToken
|
$containers = Get-AzStorageContainer -Context $storageAccount.Context -MaxCount $maxCount -ContinuationToken $containerToken
|
||||||
|
|
||||||
|
# Apply container exclusion filters if specified
|
||||||
if ($excludedContainers.Length -gt 0) {
|
if ($excludedContainers.Length -gt 0) {
|
||||||
|
$originalCount = $containers.Count
|
||||||
$containers = $containers | Where-Object { $excludedContainers -notcontains $_.Name }
|
$containers = $containers | Where-Object { $excludedContainers -notcontains $_.Name }
|
||||||
|
$filteredCount = $originalCount - $containers.Count
|
||||||
|
if ($filteredCount -gt 0) {
|
||||||
|
Write-Host " Filtered out $filteredCount excluded container(s)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Process each container and create inventory records
|
||||||
foreach ($container in $containers) {
|
foreach ($container in $containers) {
|
||||||
[BlobCheck] $blobCheck = [BlobCheck]::new()
|
[BlobCheck] $blobCheck = [BlobCheck]::new()
|
||||||
$blobCheck.SubscriptionId = $subscription.Subscription.Id
|
$blobCheck.SubscriptionId = $subscription.Subscription.Id
|
||||||
@@ -88,44 +217,90 @@ if ($containersOnly -eq $true) {
|
|||||||
$blobCheck.ResourcegroupName = $resourcegroupName
|
$blobCheck.ResourcegroupName = $resourcegroupName
|
||||||
$blobCheck.StorageAccountName = $storageAccountName
|
$blobCheck.StorageAccountName = $storageAccountName
|
||||||
$blobCheck.ContainerName = $container.Name
|
$blobCheck.ContainerName = $container.Name
|
||||||
$blobCheck.BlobName = ""
|
$blobCheck.BlobName = "" # Empty for container-only mode
|
||||||
$blobCheck.LastModifiedDate = $container.LastModified
|
$blobCheck.LastModifiedDate = $container.LastModified
|
||||||
$Result += $blobCheck
|
$Result += $blobCheck
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Export current batch to CSV if results exist
|
||||||
if ($Result.Length -gt 0) {
|
if ($Result.Length -gt 0) {
|
||||||
$Result | Export-Csv -Path $fileName -NoTypeInformation -Append
|
$Result | Export-Csv -Path $fileName -NoTypeInformation -Append
|
||||||
|
$totalContainers += $Result.Length
|
||||||
|
Write-Host " Exported $($Result.Length) container(s) to CSV (Total: $totalContainers)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Check for continuation and prepare next iteration
|
||||||
if ($containers.Length -le 0) {
|
if ($containers.Length -le 0) {
|
||||||
Break;
|
Write-Host " No more containers to process"
|
||||||
|
Break
|
||||||
}
|
}
|
||||||
$containerToken = $containers[$containers.Count - 1].ContinuationToken;
|
$containerToken = $containers[$containers.Count - 1].ContinuationToken
|
||||||
}
|
}
|
||||||
while ($null -ne $containerToken)
|
while ($null -ne $containerToken)
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Container inventory completed. Total containers processed: $totalContainers"
|
||||||
}
|
}
|
||||||
elseif ($containersOnly -eq $false) {
|
elseif ($containersOnly -eq $false) {
|
||||||
do {
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "FULL BLOB ENUMERATION MODE: Inventorying all blobs across all containers"
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
|
||||||
|
$totalContainers = 0
|
||||||
|
$totalBlobs = 0
|
||||||
|
|
||||||
|
# Full blob enumeration with nested container/blob loops
|
||||||
|
do {
|
||||||
|
Write-Host "Processing container batch (max $maxCount containers)..."
|
||||||
|
|
||||||
|
# Retrieve containers with continuation token support
|
||||||
$containers = Get-AzStorageContainer -Context $storageAccount.Context -MaxCount $maxCount -ContinuationToken $containerToken
|
$containers = Get-AzStorageContainer -Context $storageAccount.Context -MaxCount $maxCount -ContinuationToken $containerToken
|
||||||
|
|
||||||
|
# Apply container exclusion filters if specified
|
||||||
if ($excludedContainers.Length -gt 0) {
|
if ($excludedContainers.Length -gt 0) {
|
||||||
|
$originalCount = $containers.Count
|
||||||
$containers = $containers | Where-Object { $excludedContainers -notcontains $_.Name }
|
$containers = $containers | Where-Object { $excludedContainers -notcontains $_.Name }
|
||||||
|
$filteredCount = $originalCount - $containers.Count
|
||||||
|
if ($filteredCount -gt 0) {
|
||||||
|
Write-Host " Filtered out $filteredCount excluded container(s)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Process each container for blob enumeration
|
||||||
foreach ($container in $containers) {
|
foreach ($container in $containers) {
|
||||||
|
Write-Host " Processing container: $($container.Name)"
|
||||||
|
$containerBlobCount = 0
|
||||||
|
|
||||||
|
# Reset blob continuation token for each container
|
||||||
|
$blobToken = $null
|
||||||
|
|
||||||
|
# Blob enumeration loop with continuation token support
|
||||||
do {
|
do {
|
||||||
[BlobCheck[]]$Result = @()
|
[BlobCheck[]]$Result = @()
|
||||||
|
|
||||||
|
# Retrieve blobs with optional prefix filtering
|
||||||
|
try {
|
||||||
if ("" -ne $blobPrefix) {
|
if ("" -ne $blobPrefix) {
|
||||||
|
Write-Host " Retrieving blobs with prefix '$blobPrefix' (max $maxCount blobs)..."
|
||||||
$blobList = Get-AzStorageBlob -Container $Container.Name -Context $storageAccount.Context -MaxCount $maxCount -ContinuationToken $blobToken -Prefix $blobPrefix
|
$blobList = Get-AzStorageBlob -Container $Container.Name -Context $storageAccount.Context -MaxCount $maxCount -ContinuationToken $blobToken -Prefix $blobPrefix
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
|
Write-Host " Retrieving all blobs (max $maxCount blobs)..."
|
||||||
$blobList = Get-AzStorageBlob -Container $Container.Name -Context $storageAccount.Context -MaxCount $maxCount -ContinuationToken $blobToken
|
$blobList = Get-AzStorageBlob -Container $Container.Name -Context $storageAccount.Context -MaxCount $maxCount -ContinuationToken $blobToken
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if ($blobList.Length -le 0) {
|
catch {
|
||||||
Break;
|
Write-Warning " Failed to retrieve blobs from container '$($container.Name)': $($_.Exception.Message)"
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Exit loop if no blobs found
|
||||||
|
if ($blobList.Length -le 0) {
|
||||||
|
Write-Host " No more blobs in container"
|
||||||
|
Break
|
||||||
|
}
|
||||||
|
|
||||||
|
# Process each blob and create inventory records
|
||||||
foreach ($blob in $blobList) {
|
foreach ($blob in $blobList) {
|
||||||
[BlobCheck] $blobCheck = [BlobCheck]::new()
|
[BlobCheck] $blobCheck = [BlobCheck]::new()
|
||||||
$blobCheck.SubscriptionId = $subscription.Subscription.Id
|
$blobCheck.SubscriptionId = $subscription.Subscription.Id
|
||||||
@@ -137,16 +312,66 @@ elseif ($containersOnly -eq $false) {
|
|||||||
$blobCheck.LastModifiedDate = $blob.LastModified
|
$blobCheck.LastModifiedDate = $blob.LastModified
|
||||||
$Result += $blobCheck
|
$Result += $blobCheck
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Export current batch to CSV
|
||||||
$Result | Export-Csv -Path $fileName -NoTypeInformation -Append
|
$Result | Export-Csv -Path $fileName -NoTypeInformation -Append
|
||||||
$blobToken = $blobList[$blobList.Count - 1].ContinuationToken;
|
$containerBlobCount += $Result.Length
|
||||||
|
$totalBlobs += $Result.Length
|
||||||
|
Write-Host " Exported $($Result.Length) blob(s) to CSV (Container total: $containerBlobCount, Overall total: $totalBlobs)"
|
||||||
|
|
||||||
|
# Prepare continuation token for next iteration
|
||||||
|
$blobToken = $blobList[$blobList.Count - 1].ContinuationToken
|
||||||
}
|
}
|
||||||
while ($null -ne $blobToken)
|
while ($null -ne $blobToken)
|
||||||
}
|
|
||||||
|
|
||||||
if ($containers.Length -le 0) {
|
Write-Host " Container '$($container.Name)' completed. Total blobs: $containerBlobCount"
|
||||||
Break;
|
$totalContainers++
|
||||||
}
|
}
|
||||||
$containerToken = $containers[$containers.Count - 1].ContinuationToken;
|
# Check for continuation and prepare next container batch
|
||||||
|
if ($containers.Length -le 0) {
|
||||||
|
Write-Host "No more containers to process"
|
||||||
|
Break
|
||||||
|
}
|
||||||
|
$containerToken = $containers[$containers.Count - 1].ContinuationToken
|
||||||
}
|
}
|
||||||
while ($null -ne $containerToken)
|
while ($null -ne $containerToken)
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Full blob inventory completed."
|
||||||
|
Write-Host " Total containers processed: $totalContainers"
|
||||||
|
Write-Host " Total blobs exported: $totalBlobs"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Script completion summary
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "Azure Storage blob inventory completed successfully."
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "Results exported to: $fileName"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Display final summary based on mode
|
||||||
|
if ($containersOnly) {
|
||||||
|
Write-Host "Summary (Containers Only Mode):"
|
||||||
|
Write-Host " Storage Account: $storageAccountName"
|
||||||
|
Write-Host " Containers inventoried (after exclusions)"
|
||||||
|
if ($excludedContainers.Length -gt 0) {
|
||||||
|
Write-Host " Excluded containers: $($excludedContainers.Length)"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Host "Summary (Full Blob Enumeration Mode):"
|
||||||
|
Write-Host " Storage Account: $storageAccountName"
|
||||||
|
Write-Host " Total containers processed: $totalContainers"
|
||||||
|
Write-Host " Total blobs inventoried: $totalBlobs"
|
||||||
|
if ($excludedContainers.Length -gt 0) {
|
||||||
|
Write-Host " Excluded containers: $($excludedContainers.Length)"
|
||||||
|
}
|
||||||
|
if ($blobPrefix -ne "") {
|
||||||
|
Write-Host " Blob prefix filter applied: $blobPrefix"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "For large storage accounts, consider using container exclusions or blob prefix filters"
|
||||||
|
Write-Host "to optimize processing time and focus on relevant data."
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
@@ -1,35 +1,236 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Exports all entities from a specified Azure Storage Table to a CSV file.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This script connects to an Azure Storage Account and exports all entities (rows) from a specified
|
||||||
|
table to a CSV file. It's designed for data extraction, backup, and analysis purposes from Azure
|
||||||
|
Table Storage.
|
||||||
|
|
||||||
|
The script retrieves all entities from the specified table without filtering and exports them
|
||||||
|
with all their properties to a timestamped CSV file. This is useful for:
|
||||||
|
- Data backup and archival
|
||||||
|
- Data analysis and reporting
|
||||||
|
- Debugging and troubleshooting table contents
|
||||||
|
- Data migration between environments
|
||||||
|
|
||||||
|
The exported CSV includes all entity properties with their values, making it suitable for
|
||||||
|
further processing in Excel, PowerBI, or other data analysis tools.
|
||||||
|
|
||||||
|
.PARAMETER subscriptionId
|
||||||
|
The Azure subscription ID containing the storage account.
|
||||||
|
This parameter is mandatory and must be a valid GUID format.
|
||||||
|
|
||||||
|
Example: "86945e42-fa5a-4bbc-948f-3f5407f15d3e"
|
||||||
|
|
||||||
|
.PARAMETER resourcegroupName
|
||||||
|
The name of the resource group containing the storage account.
|
||||||
|
This parameter is mandatory and is case-sensitive.
|
||||||
|
|
||||||
|
Example: "hierarchy"
|
||||||
|
|
||||||
|
.PARAMETER storageAccountName
|
||||||
|
The name of the Azure Storage Account containing the table.
|
||||||
|
This parameter is mandatory and must be a valid storage account name (3-24 characters,
|
||||||
|
lowercase letters and numbers only).
|
||||||
|
|
||||||
|
Example: "hierarchyeff"
|
||||||
|
|
||||||
|
.PARAMETER tableName
|
||||||
|
The name of the table from which to export entities.
|
||||||
|
This parameter is mandatory and is case-sensitive. Table names must follow Azure
|
||||||
|
Table naming conventions (3-63 characters, alphanumeric and hyphens, no consecutive hyphens).
|
||||||
|
|
||||||
|
Example: "auditlog"
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\AzureStorageTableListEntities.ps1 -subscriptionId "86945e42-fa5a-4bbc-948f-3f5407f15d3e" -resourcegroupName "hierarchy" -storageAccountName "hierarchyeff" -tableName "auditlog"
|
||||||
|
|
||||||
|
Exports all entities from the 'auditlog' table in the 'hierarchyeff' storage account to a CSV file.
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Author: Cloud Engineering Team
|
||||||
|
Version: 1.0
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
- Azure PowerShell module (Az) must be installed
|
||||||
|
- AzTable PowerShell module must be available for installation
|
||||||
|
- User must be authenticated to Azure (Connect-AzAccount)
|
||||||
|
- User must have at least 'Storage Table Data Reader' permissions on the storage account
|
||||||
|
|
||||||
|
Required Permissions:
|
||||||
|
- Reader access to the subscription and resource group
|
||||||
|
- Storage Table Data Reader or Storage Account Contributor on the storage account
|
||||||
|
|
||||||
|
Output File:
|
||||||
|
- Format: "YYYY-MM-DD HHMM - {StorageAccountName} - tablecheck.csv"
|
||||||
|
- Location: Current directory
|
||||||
|
- Content: All table entities with their properties
|
||||||
|
|
||||||
|
Performance Considerations:
|
||||||
|
- Large tables may take significant time to export
|
||||||
|
- Consider the table size and available memory when running against large datasets
|
||||||
|
- Network bandwidth may impact export speed for tables with many entities
|
||||||
|
|
||||||
|
Security Notes:
|
||||||
|
- Exported CSV files may contain sensitive data - handle appropriately
|
||||||
|
- Ensure proper access controls on the output directory
|
||||||
|
- Consider encryption for sensitive table data exports
|
||||||
|
|
||||||
|
.LINK
|
||||||
|
https://docs.microsoft.com/en-us/azure/storage/tables/
|
||||||
|
https://docs.microsoft.com/en-us/powershell/module/az.storage/
|
||||||
|
#>
|
||||||
|
|
||||||
param (
|
param (
|
||||||
[string] $subscriptionId = "",
|
[Parameter(Mandatory = $true, HelpMessage = "Azure subscription ID (GUID format)")]
|
||||||
[string] $resourcegroupName = "",
|
[ValidateNotNullOrEmpty()]
|
||||||
[string] $storageAccountName = "",
|
[ValidateScript({
|
||||||
[string] $tableName = ""
|
if ($_ -match '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') {
|
||||||
|
$true
|
||||||
|
} else {
|
||||||
|
throw "Subscription ID must be a valid GUID format"
|
||||||
|
}
|
||||||
|
})]
|
||||||
|
[string] $subscriptionId,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true, HelpMessage = "Resource group name containing the storage account")]
|
||||||
|
[ValidateNotNullOrEmpty()]
|
||||||
|
[ValidateLength(1, 90)]
|
||||||
|
[string] $resourcegroupName,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true, HelpMessage = "Storage account name (3-24 characters, lowercase alphanumeric)")]
|
||||||
|
[ValidateNotNullOrEmpty()]
|
||||||
|
[ValidateLength(3, 24)]
|
||||||
|
[ValidatePattern('^[a-z0-9]+$')]
|
||||||
|
[string] $storageAccountName,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true, HelpMessage = "Table name to export entities from")]
|
||||||
|
[ValidateNotNullOrEmpty()]
|
||||||
|
[ValidateLength(3, 63)]
|
||||||
|
[ValidatePattern('^[A-Za-z][A-Za-z0-9]*$')]
|
||||||
|
[string] $tableName
|
||||||
)
|
)
|
||||||
|
|
||||||
if (("" -eq $subscriptionId) -or ("" -eq $resourcegroupName) -or ("" -eq $storageAccountName) -or ("" -eq $tableName)) {
|
# Display configuration for user verification
|
||||||
throw "Parameter(s) missing."
|
Write-Host "======================================================================================================================================================================"
|
||||||
}
|
Write-Host "Azure Storage Table Entity Export Configuration"
|
||||||
else {
|
Write-Host "======================================================================================================================================================================"
|
||||||
Import-Module AzTable
|
Write-Host "Subscription ID: $subscriptionId"
|
||||||
|
Write-Host "Resource Group: $resourcegroupName"
|
||||||
|
Write-Host "Storage Account: $storageAccountName"
|
||||||
|
Write-Host "Table Name: $tableName"
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Import required module for Azure Table operations
|
||||||
|
Write-Host "Importing AzTable module for table operations..."
|
||||||
|
try {
|
||||||
|
Import-Module AzTable -ErrorAction Stop
|
||||||
|
Write-Host "✓ AzTable module imported successfully"
|
||||||
|
} catch {
|
||||||
|
Write-Error "Failed to import AzTable module. Please install it using: Install-Module -Name AzTable"
|
||||||
|
throw "Required module AzTable is not available"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Generate timestamped filename for export
|
||||||
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
||||||
$fileName = ".\$date - $storageAccountName - tablecheck.csv"
|
$fileName = ".\$date - $storageAccountName - tablecheck.csv"
|
||||||
|
Write-Host "Export file: $fileName"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
# .\AzureStorageTableListEntities.ps1 -subscriptionId "86945e42-fa5a-4bbc-948f-3f5407f15d3e" -resourcegroupName "hierarchy" -storageAccountName "hierarchyeff" -tableName "auditlog"
|
# Set Azure context to the specified subscription
|
||||||
|
Write-Host "Setting Azure context..."
|
||||||
|
try {
|
||||||
$subscription = Set-AzContext -SubscriptionId $subscriptionId
|
$subscription = Set-AzContext -SubscriptionId $subscriptionId -ErrorAction Stop
|
||||||
$storageAccount = Get-AzStorageAccount -ResourceGroupName $resourcegroupName -Name $storageAccountName
|
Write-Host "✓ Successfully connected to subscription: $($subscription.Subscription.Name)"
|
||||||
|
} catch {
|
||||||
$tables = Get-AzStorageTable -Context $storageAccount.Context -Name $tableName
|
Write-Error "Failed to set Azure context to subscription: $subscriptionId"
|
||||||
|
Write-Error "Please ensure you are authenticated (Connect-AzAccount) and have access to this subscription"
|
||||||
foreach ($table in $tables) {
|
throw $_
|
||||||
|
|
||||||
$rows = Get-AzTableRow -table $table.CloudTable
|
|
||||||
|
|
||||||
if (($null -ne $rows) -and ($rows.Length -gt 0)) {
|
|
||||||
Write-Host "Processing subscription [$subscriptionId], resource group [$resourcegroupName], storage account [$storageAccountName] -> table [$($table.Name)]"
|
|
||||||
$rows | Export-Csv -Path $fileName -NoTypeInformation -Append
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Get the storage account reference
|
||||||
|
Write-Host "Retrieving storage account information..."
|
||||||
|
try {
|
||||||
|
$storageAccount = Get-AzStorageAccount -ResourceGroupName $resourcegroupName -Name $storageAccountName -ErrorAction Stop
|
||||||
|
Write-Host "✓ Successfully connected to storage account: $storageAccountName"
|
||||||
|
} catch {
|
||||||
|
Write-Error "Failed to retrieve storage account '$storageAccountName' from resource group '$resourcegroupName'"
|
||||||
|
Write-Error "Please verify the storage account name and resource group name are correct"
|
||||||
|
throw $_
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get the specified table reference
|
||||||
|
Write-Host "Accessing table '$tableName'..."
|
||||||
|
try {
|
||||||
|
$tables = Get-AzStorageTable -Context $storageAccount.Context -Name $tableName -ErrorAction Stop
|
||||||
|
Write-Host "✓ Successfully accessed table: $tableName"
|
||||||
|
} catch {
|
||||||
|
Write-Error "Failed to access table '$tableName' in storage account '$storageAccountName'"
|
||||||
|
Write-Error "Please verify the table name is correct and you have appropriate permissions"
|
||||||
|
throw $_
|
||||||
|
}
|
||||||
|
|
||||||
|
# Initialize counters for statistics
|
||||||
|
$totalEntities = 0
|
||||||
|
$tablesProcessed = 0
|
||||||
|
|
||||||
|
# Process each table (typically just one with specific name)
|
||||||
|
foreach ($table in $tables) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Processing table: $($table.Name)"
|
||||||
|
Write-Host "Retrieving all entities from the table..."
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Get all rows/entities from the table
|
||||||
|
$rows = Get-AzTableRow -table $table.CloudTable -ErrorAction Stop
|
||||||
|
|
||||||
|
# Check if table contains any entities
|
||||||
|
if (($null -ne $rows) -and ($rows.Length -gt 0)) {
|
||||||
|
Write-Host "✓ Found $($rows.Length) entities in table '$($table.Name)'"
|
||||||
|
Write-Host "Exporting entities to CSV file..."
|
||||||
|
|
||||||
|
# Export entities to CSV file
|
||||||
|
$rows | Export-Csv -Path $fileName -NoTypeInformation -Append
|
||||||
|
|
||||||
|
# Update statistics
|
||||||
|
$totalEntities += $rows.Length
|
||||||
|
$tablesProcessed++
|
||||||
|
|
||||||
|
Write-Host "✓ Successfully exported $($rows.Length) entities to $fileName"
|
||||||
|
} else {
|
||||||
|
Write-Host "⚠ Table '$($table.Name)' contains no entities to export"
|
||||||
|
$tablesProcessed++
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Error "Failed to retrieve entities from table '$($table.Name)'"
|
||||||
|
Write-Error $_.Exception.Message
|
||||||
|
throw $_
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Display completion summary
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "Azure Storage Table entity export completed successfully."
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "Export Summary:"
|
||||||
|
Write-Host " Storage Account: $storageAccountName"
|
||||||
|
Write-Host " Table Name: $tableName"
|
||||||
|
Write-Host " Tables Processed: $tablesProcessed"
|
||||||
|
Write-Host " Total Entities Exported: $totalEntities"
|
||||||
|
Write-Host " Output File: $fileName"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
if ($totalEntities -gt 0) {
|
||||||
|
Write-Host "✓ Export completed successfully. All table entities have been saved to the CSV file."
|
||||||
|
Write-Host "The CSV file can be opened in Excel, PowerBI, or processed with other data analysis tools."
|
||||||
|
} else {
|
||||||
|
Write-Host "⚠ No entities were found in the specified table to export."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Note: Large tables may contain sensitive data. Please handle the exported file appropriately"
|
||||||
|
Write-Host "and ensure proper access controls are in place."
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
@@ -1,78 +1,357 @@
|
|||||||
#Connect-AzAccount
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Inventories and monitors Azure App Service certificates across all enabled subscriptions.
|
||||||
|
|
||||||
$fileName = ".\2020-12-23 azure_appservice_certificates (3).csv"
|
.DESCRIPTION
|
||||||
|
This script performs a comprehensive audit of all Azure App Service certificates across all
|
||||||
|
enabled subscriptions in your Azure tenant. It extracts certificate details including expiration
|
||||||
|
dates, thumbprints, subject names, and calculates the remaining days until expiration.
|
||||||
|
|
||||||
$subscriptions = Get-AzSubscription | Where-Object State -eq "Enabled"
|
The script is designed for:
|
||||||
|
- Certificate lifecycle management and monitoring
|
||||||
|
- Proactive identification of expiring certificates
|
||||||
|
- Compliance auditing and reporting
|
||||||
|
- Security assessments of certificate inventory
|
||||||
|
- Planning certificate renewal activities
|
||||||
|
|
||||||
|
The script processes all enabled subscriptions automatically and exports results to a timestamped
|
||||||
|
CSV file, making it suitable for automated monitoring and reporting workflows.
|
||||||
|
|
||||||
|
Key features:
|
||||||
|
- Multi-subscription certificate discovery
|
||||||
|
- Expiration date calculation with days remaining
|
||||||
|
- Error handling for inaccessible or invalid certificates
|
||||||
|
- Detailed logging and progress reporting
|
||||||
|
- CSV export for further analysis and alerting
|
||||||
|
|
||||||
|
.PARAMETER None
|
||||||
|
This script does not accept parameters and processes all enabled subscriptions automatically.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\Certificates.ps1
|
||||||
|
|
||||||
|
Runs the certificate inventory across all enabled subscriptions and exports results to a
|
||||||
|
timestamped CSV file in the current directory.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
# Schedule for automated monitoring
|
||||||
|
$scriptPath = "C:\Scripts\Certificates.ps1"
|
||||||
|
& $scriptPath
|
||||||
|
|
||||||
|
Executes the script from a scheduled task or automation workflow for regular certificate monitoring.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
# Run and immediately view results
|
||||||
|
.\Certificates.ps1
|
||||||
|
Get-Content ".\$(Get-Date -Format 'yyyy-MM-dd HHmm') azure_appservice_certificates.csv"
|
||||||
|
|
||||||
|
Runs the script and displays the generated CSV content for immediate review.
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Author: Cloud Engineering Team
|
||||||
|
Version: 1.0
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
- Azure PowerShell module (Az) must be installed
|
||||||
|
- User must be authenticated to Azure (Connect-AzAccount)
|
||||||
|
- User must have at least 'Reader' permissions across target subscriptions
|
||||||
|
- Access to Microsoft.Web/certificates resources
|
||||||
|
|
||||||
|
Required Permissions:
|
||||||
|
- Reader access to subscriptions containing App Service certificates
|
||||||
|
- Web App Certificate Reader or App Service Certificate Reader permissions
|
||||||
|
- Resource Group Reader permissions for certificate resource groups
|
||||||
|
|
||||||
|
Output File:
|
||||||
|
- Format: "YYYY-MM-DD HHMM azure_appservice_certificates.csv"
|
||||||
|
- Location: Current directory
|
||||||
|
- Content: Certificate inventory with expiration analysis
|
||||||
|
|
||||||
|
Certificate Status Analysis:
|
||||||
|
- TotalDays > 30: Certificate is healthy
|
||||||
|
- TotalDays 7-30: Certificate expires soon (warning)
|
||||||
|
- TotalDays < 7: Certificate expires very soon (critical)
|
||||||
|
- TotalDays < 0: Certificate has already expired (urgent action required)
|
||||||
|
|
||||||
|
Performance Considerations:
|
||||||
|
- Processing time depends on the number of subscriptions and certificates
|
||||||
|
- Large tenants with many certificates may require extended execution time
|
||||||
|
- Network latency affects certificate detail retrieval
|
||||||
|
|
||||||
|
Security and Compliance:
|
||||||
|
- Certificate thumbprints and subject names are included in output
|
||||||
|
- Ensure proper access controls on generated CSV files
|
||||||
|
- Consider encryption for sensitive certificate inventory data
|
||||||
|
- Regular execution recommended for proactive certificate management
|
||||||
|
|
||||||
|
Common Use Cases:
|
||||||
|
- Monthly certificate expiration reports
|
||||||
|
- Pre-renewal planning and notifications
|
||||||
|
- Compliance audits requiring certificate inventory
|
||||||
|
- Security assessments of certificate lifecycle management
|
||||||
|
|
||||||
|
.LINK
|
||||||
|
https://docs.microsoft.com/en-us/azure/app-service/configure-ssl-certificate
|
||||||
|
https://docs.microsoft.com/en-us/powershell/module/az.websites/
|
||||||
|
#>
|
||||||
|
|
||||||
|
# Ensure user is authenticated to Azure
|
||||||
|
# Uncomment the following line if authentication is needed:
|
||||||
|
# Connect-AzAccount
|
||||||
|
|
||||||
|
# Display script header and configuration
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "Azure App Service Certificate Inventory and Monitoring"
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "Starting certificate discovery across all enabled subscriptions..."
|
||||||
|
Write-Host "Script execution started: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Generate timestamped filename for export
|
||||||
|
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
||||||
|
$fileName = ".\$date azure_appservice_certificates.csv"
|
||||||
|
Write-Host "Export file: $fileName"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Get all enabled subscriptions for processing
|
||||||
|
Write-Host "Retrieving enabled subscriptions..."
|
||||||
|
try {
|
||||||
|
$subscriptions = Get-AzSubscription | Where-Object State -eq "Enabled"
|
||||||
|
Write-Host "✓ Found $($subscriptions.Count) enabled subscription(s) to process:"
|
||||||
|
foreach ($sub in $subscriptions) {
|
||||||
|
Write-Host " - $($sub.Name) ($($sub.Id))"
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Error "Failed to retrieve Azure subscriptions. Please ensure you are authenticated (Connect-AzAccount)"
|
||||||
|
throw $_
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Define certificate information class for structured data collection
|
||||||
class CertificateCheck {
|
class CertificateCheck {
|
||||||
|
# Azure subscription identifier containing the certificate
|
||||||
[string] $SubscriptionId = ""
|
[string] $SubscriptionId = ""
|
||||||
|
|
||||||
|
# Full Azure resource ID of the certificate
|
||||||
[string] $CertificateId = ""
|
[string] $CertificateId = ""
|
||||||
|
|
||||||
|
# Resource group name where the certificate is deployed
|
||||||
[string] $ResourceGroupName = ""
|
[string] $ResourceGroupName = ""
|
||||||
|
|
||||||
|
# Certificate subject name (Common Name and additional fields)
|
||||||
[string] $SubjectName = ""
|
[string] $SubjectName = ""
|
||||||
|
|
||||||
|
# Certificate thumbprint (SHA-1 hash identifier)
|
||||||
[string] $ThumbPrint = ""
|
[string] $ThumbPrint = ""
|
||||||
|
|
||||||
|
# Certificate expiration date and time
|
||||||
[DateTime] $ExpirationDate
|
[DateTime] $ExpirationDate
|
||||||
|
|
||||||
|
# Number of days remaining until expiration (negative if expired)
|
||||||
[double] $TotalDays
|
[double] $TotalDays
|
||||||
|
|
||||||
|
# Certificate health status (Expired, Critical, Warning, Healthy, Error)
|
||||||
|
[string] $Health = ""
|
||||||
|
|
||||||
|
# Error messages or status comments for problematic certificates
|
||||||
[string] $Comment = ""
|
[string] $Comment = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Initialize result collection and processing variables
|
||||||
[CertificateCheck[]]$Result = @()
|
[CertificateCheck[]]$Result = @()
|
||||||
|
$StartDate = (Get-Date)
|
||||||
|
$totalCertificates = 0
|
||||||
|
$processedSubscriptions = 0
|
||||||
|
$certificatesWithIssues = 0
|
||||||
|
|
||||||
$StartDate=(GET-DATE)
|
Write-Host "======================================================================================================================================================================"
|
||||||
[CertificateCheck[]]$Result = @()
|
Write-Host "Processing Certificates by Subscription"
|
||||||
foreach ($subscription in $subscriptions)
|
Write-Host "======================================================================================================================================================================"
|
||||||
{
|
|
||||||
Set-AzContext -SubscriptionId $subscription.Id
|
|
||||||
|
|
||||||
$certs = Get-AzResource -ResourceType Microsoft.Web/certificates -ExpandProperties -ApiVersion 2018-02-01 | Select * -Expand Properties
|
# Process each enabled subscription
|
||||||
foreach ($cert in $certs)
|
foreach ($subscription in $subscriptions) {
|
||||||
{
|
Write-Host ""
|
||||||
|
Write-Host "Processing subscription: $($subscription.Name) ($($subscription.Id))"
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Set Azure context to current subscription
|
||||||
|
Set-AzContext -SubscriptionId $subscription.Id -ErrorAction Stop | Out-Null
|
||||||
|
Write-Host "✓ Successfully connected to subscription"
|
||||||
|
|
||||||
|
# Retrieve all App Service certificates in the subscription
|
||||||
|
Write-Host "Discovering App Service certificates..."
|
||||||
|
$certs = Get-AzResource -ResourceType Microsoft.Web/certificates -ApiVersion "2018-02-01" -ExpandProperties | Select-Object * -ExpandProperty Properties
|
||||||
|
|
||||||
|
if ($certs) {
|
||||||
|
Write-Host "✓ Found $($certs.Count) certificate(s) in subscription"
|
||||||
|
$subscriptionCertCount = 0
|
||||||
|
|
||||||
|
# Process each certificate found
|
||||||
|
foreach ($cert in $certs) {
|
||||||
$id = $cert.Id
|
$id = $cert.Id
|
||||||
|
Write-Host " Processing certificate: $($cert.Name)"
|
||||||
|
|
||||||
|
# Create new certificate check instance
|
||||||
[CertificateCheck] $certificateCheck = [CertificateCheck]::new()
|
[CertificateCheck] $certificateCheck = [CertificateCheck]::new()
|
||||||
|
|
||||||
|
# Populate basic certificate information
|
||||||
$certificateCheck.SubscriptionId = $subscription.Id
|
$certificateCheck.SubscriptionId = $subscription.Id
|
||||||
$certificateCheck.CertificateId = $id
|
$certificateCheck.CertificateId = $id
|
||||||
$certificateCheck.ThumbPrint = $cert.Properties.thumbprint
|
$certificateCheck.ThumbPrint = $cert.Properties.thumbprint
|
||||||
$certificateCheck.ResourceGroupName = $cert.ResourceGroupName
|
$certificateCheck.ResourceGroupName = $cert.ResourceGroupName
|
||||||
|
|
||||||
try
|
try {
|
||||||
{
|
|
||||||
$thumbprint = $certificateCheck.ThumbPrint
|
$thumbprint = $certificateCheck.ThumbPrint
|
||||||
|
|
||||||
$certificate = Get-AzWebAppCertificate -ResourceGroupName $certificateCheck.ResourceGroupName -Thumbprint $thumbprint -debug -verbose
|
# Retrieve detailed certificate information
|
||||||
|
$certificate = Get-AzWebAppCertificate -ResourceGroupName $certificateCheck.ResourceGroupName -Thumbprint $thumbprint -ErrorAction Stop
|
||||||
|
|
||||||
if ($null -eq $certificate)
|
if ($null -eq $certificate) {
|
||||||
{
|
$certificateCheck.Health = "Error"
|
||||||
$certificateCheck.Comment = "Could not find certificate"
|
$certificateCheck.Comment = "Could not find certificate details"
|
||||||
}
|
$certificatesWithIssues++
|
||||||
else
|
Write-Host " ⚠ Warning: Certificate details not accessible"
|
||||||
{
|
} else {
|
||||||
try
|
try {
|
||||||
{
|
# Extract certificate subject name and expiration details
|
||||||
$subjectname = $certificate.SubjectName
|
$subjectname = $certificate.SubjectName
|
||||||
$certificateCheck.SubjectName = $subjectname
|
$certificateCheck.SubjectName = $subjectname
|
||||||
|
|
||||||
Write-Host "Subject name: $subjectname"
|
Write-Host " ✓ Subject: $subjectname"
|
||||||
|
|
||||||
$EndDate=[datetime]$certificate.ExpirationDate
|
# Calculate expiration and days remaining
|
||||||
|
$EndDate = [datetime]$certificate.ExpirationDate
|
||||||
$certificateCheck.ExpirationDate = $EndDate
|
$certificateCheck.ExpirationDate = $EndDate
|
||||||
$span = NEW-TIMESPAN –Start $StartDate –End $EndDate
|
$span = New-TimeSpan -Start $StartDate -End $EndDate
|
||||||
$certificateCheck.TotalDays = $span.TotalDays
|
$certificateCheck.TotalDays = [Math]::Round($span.TotalDays, 1)
|
||||||
|
|
||||||
|
# Determine and assign health status based on expiration
|
||||||
|
if ($certificateCheck.TotalDays -lt 0) {
|
||||||
|
$certificateCheck.Health = "Expired"
|
||||||
|
Write-Host " 🔴 EXPIRED: $([Math]::Abs($certificateCheck.TotalDays)) days ago" -ForegroundColor Red
|
||||||
|
$certificatesWithIssues++
|
||||||
|
} elseif ($certificateCheck.TotalDays -lt 7) {
|
||||||
|
$certificateCheck.Health = "Critical"
|
||||||
|
Write-Host " 🟠 CRITICAL: Expires in $($certificateCheck.TotalDays) days" -ForegroundColor Yellow
|
||||||
|
$certificatesWithIssues++
|
||||||
|
} elseif ($certificateCheck.TotalDays -lt 30) {
|
||||||
|
$certificateCheck.Health = "Warning"
|
||||||
|
Write-Host " 🟡 WARNING: Expires in $($certificateCheck.TotalDays) days" -ForegroundColor Yellow
|
||||||
|
} else {
|
||||||
|
$certificateCheck.Health = "Healthy"
|
||||||
|
Write-Host " ✓ Healthy: Expires in $($certificateCheck.TotalDays) days"
|
||||||
}
|
}
|
||||||
catch {
|
} catch {
|
||||||
$certificateCheck.Comment = "Could not find expiry for certificate"
|
$certificateCheck.Health = "Error"
|
||||||
|
$certificateCheck.Comment = "Could not determine expiration date"
|
||||||
|
$certificatesWithIssues++
|
||||||
|
Write-Host " ⚠ Warning: Could not determine expiration date"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} catch {
|
||||||
catch
|
$certificateCheck.Health = "Error"
|
||||||
{
|
$certificateCheck.Comment = "Could not load certificate details"
|
||||||
$certificateCheck.Comment = "Could not load certificate"
|
$certificatesWithIssues++
|
||||||
|
Write-Host " ❌ Error: Could not load certificate details"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Add certificate to results collection
|
||||||
$Result += $certificateCheck
|
$Result += $certificateCheck
|
||||||
|
$totalCertificates++
|
||||||
|
$subscriptionCertCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host " ✓ Processed $subscriptionCertCount certificate(s) in subscription"
|
||||||
|
} else {
|
||||||
|
Write-Host " ℹ No App Service certificates found in this subscription"
|
||||||
|
}
|
||||||
|
|
||||||
|
$processedSubscriptions++
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
Write-Host " ❌ Error processing subscription: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
Write-Host " Please verify permissions and subscription access"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$Result | Export-Csv -Path $fileName -NoTypeInformation -Force
|
Write-Host ""
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "Exporting Results and Analysis"
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
|
||||||
$Result | ft
|
# Export results to CSV file
|
||||||
|
Write-Host "Exporting certificate inventory to CSV file..."
|
||||||
|
try {
|
||||||
|
$Result | Export-Csv -Path $fileName -NoTypeInformation -Force
|
||||||
|
Write-Host "✓ Successfully exported $($Result.Count) certificate records to: $fileName"
|
||||||
|
} catch {
|
||||||
|
Write-Error "Failed to export results to CSV file: $($_.Exception.Message)"
|
||||||
|
throw $_
|
||||||
|
}
|
||||||
|
|
||||||
|
# Display results summary table
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Certificate Inventory Summary:"
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
$Result | Format-Table -AutoSize
|
||||||
|
|
||||||
|
# Generate detailed analysis and statistics
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "Certificate Analysis Summary"
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "Execution completed: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Processing Statistics:"
|
||||||
|
Write-Host " Subscriptions processed: $processedSubscriptions"
|
||||||
|
Write-Host " Total certificates discovered: $totalCertificates"
|
||||||
|
Write-Host " Certificates with issues: $certificatesWithIssues"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Analyze certificate expiration status using Health property
|
||||||
|
if ($Result.Count -gt 0) {
|
||||||
|
$expiredCerts = $Result | Where-Object { $_.Health -eq "Expired" }
|
||||||
|
$criticalCerts = $Result | Where-Object { $_.Health -eq "Critical" }
|
||||||
|
$warnCerts = $Result | Where-Object { $_.Health -eq "Warning" }
|
||||||
|
$healthyCerts = $Result | Where-Object { $_.Health -eq "Healthy" }
|
||||||
|
$errorCerts = $Result | Where-Object { $_.Health -eq "Error" }
|
||||||
|
|
||||||
|
Write-Host "Certificate Status Analysis:"
|
||||||
|
Write-Host " 🔴 Expired certificates: $($expiredCerts.Count)"
|
||||||
|
Write-Host " 🟠 Critical (< 7 days): $($criticalCerts.Count)"
|
||||||
|
Write-Host " 🟡 Warning (7-30 days): $($warnCerts.Count)"
|
||||||
|
Write-Host " ✓ Healthy (> 30 days): $($healthyCerts.Count)"
|
||||||
|
Write-Host " ❌ Error/Inaccessible: $($errorCerts.Count)"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Display urgent action items
|
||||||
|
if ($expiredCerts.Count -gt 0 -or $criticalCerts.Count -gt 0) {
|
||||||
|
Write-Host "🚨 URGENT ACTION REQUIRED:"
|
||||||
|
if ($expiredCerts.Count -gt 0) {
|
||||||
|
Write-Host " - $($expiredCerts.Count) certificate(s) have already expired"
|
||||||
|
}
|
||||||
|
if ($criticalCerts.Count -gt 0) {
|
||||||
|
Write-Host " - $($criticalCerts.Count) certificate(s) expire within 7 days"
|
||||||
|
}
|
||||||
|
Write-Host " Review the CSV file for detailed certificate information"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($warnCerts.Count -gt 0) {
|
||||||
|
Write-Host "⚠ RENEWAL PLANNING NEEDED:"
|
||||||
|
Write-Host " - $($warnCerts.Count) certificate(s) expire within 30 days"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Host "ℹ No certificates found across all processed subscriptions"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Output File Information:"
|
||||||
|
Write-Host " File Path: $fileName"
|
||||||
|
Write-Host " File Size: $([Math]::Round((Get-Item $fileName).Length / 1KB, 2)) KB"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Recommendations:"
|
||||||
|
Write-Host " - Schedule regular execution for proactive certificate monitoring"
|
||||||
|
Write-Host " - Set up alerts for certificates expiring within 30 days"
|
||||||
|
Write-Host " - Implement automated renewal processes where possible"
|
||||||
|
Write-Host " - Review and resolve any certificates with error status"
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
@@ -1,56 +1,328 @@
|
|||||||
# .\FrontDoorRoutes.ps1 -SubscriptionId "4820b5d8-cc1d-49bd-93e5-0c7a656371b7" -ResourceGroupName "my-effectory-global" -FrontDoorName "my-effectory-frontDoor"
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Exports Azure Front Door route configuration and origin mappings to a CSV file.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This script retrieves and documents the complete routing configuration for an Azure Front Door
|
||||||
|
(Standard/Premium) CDN profile. It extracts detailed information about endpoints, routes,
|
||||||
|
route patterns, origin groups, and individual origins, providing a comprehensive view of
|
||||||
|
traffic routing and backend configurations.
|
||||||
|
|
||||||
|
The script is designed for:
|
||||||
|
- Front Door configuration documentation and auditing
|
||||||
|
- Traffic routing analysis and optimization
|
||||||
|
- Origin backend inventory and health monitoring
|
||||||
|
- Troubleshooting routing issues and misconfigurations
|
||||||
|
- Migration planning and configuration validation
|
||||||
|
- Compliance documentation for CDN configurations
|
||||||
|
|
||||||
|
Key features:
|
||||||
|
- Complete route topology mapping from endpoints to backends
|
||||||
|
- Pattern matching rules documentation
|
||||||
|
- Origin health and enabled state tracking
|
||||||
|
- Route enablement status monitoring
|
||||||
|
- Structured CSV export for analysis and reporting
|
||||||
|
|
||||||
|
The exported data includes full URL construction for both front-end endpoints and
|
||||||
|
backend origins, making it easy to understand the complete request flow through
|
||||||
|
the Front Door configuration.
|
||||||
|
|
||||||
|
.PARAMETER SubscriptionId
|
||||||
|
The Azure subscription ID containing the Front Door profile.
|
||||||
|
This parameter is optional - if not provided, the script will use the current
|
||||||
|
subscription context. Must be a valid GUID format.
|
||||||
|
|
||||||
|
Example: "4820b5d8-cc1d-49bd-93e5-0c7a656371b7"
|
||||||
|
|
||||||
|
.PARAMETER ResourceGroupName
|
||||||
|
The name of the resource group containing the Front Door profile.
|
||||||
|
This parameter is mandatory and is case-sensitive.
|
||||||
|
|
||||||
|
Example: "my-effectory-global"
|
||||||
|
|
||||||
|
.PARAMETER FrontDoorName
|
||||||
|
The name of the Azure Front Door (Standard/Premium) profile to analyze.
|
||||||
|
This parameter is mandatory and is case-sensitive. Must be a valid Front Door
|
||||||
|
profile name (not the legacy Front Door Classic).
|
||||||
|
|
||||||
|
Example: "my-effectory-frontDoor"
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\FrontDoorRoutes.ps1 -SubscriptionId "4820b5d8-cc1d-49bd-93e5-0c7a656371b7" -ResourceGroupName "my-effectory-global" -FrontDoorName "my-effectory-frontDoor"
|
||||||
|
|
||||||
|
Exports all route configurations for the specified Front Door profile with explicit subscription targeting.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\FrontDoorRoutes.ps1 -ResourceGroupName "production-rg" -FrontDoorName "prod-frontdoor"
|
||||||
|
|
||||||
|
Exports route configurations using the current subscription context.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
# Analyze multiple Front Door profiles
|
||||||
|
$frontDoors = @("frontdoor1", "frontdoor2", "frontdoor3")
|
||||||
|
foreach ($fd in $frontDoors) {
|
||||||
|
.\FrontDoorRoutes.ps1 -ResourceGroupName "global-rg" -FrontDoorName $fd
|
||||||
|
}
|
||||||
|
|
||||||
|
Batch processes multiple Front Door profiles for comprehensive documentation.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
# Export and immediately analyze results
|
||||||
|
.\FrontDoorRoutes.ps1 -ResourceGroupName "my-rg" -FrontDoorName "my-frontdoor"
|
||||||
|
$results = Import-Csv ".\$(Get-Date -Format 'yyyy-MM-dd HHmm') Front Door Routes (my-frontdoor).csv"
|
||||||
|
$results | Where-Object RouteEnabled -eq "Disabled" | Format-Table
|
||||||
|
|
||||||
|
Exports configuration and immediately identifies disabled routes for analysis.
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Author: Cloud Engineering Team
|
||||||
|
Version: 1.0
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
- Azure PowerShell module (Az) must be installed
|
||||||
|
- Az.Cdn module specifically required for Front Door operations
|
||||||
|
- User must be authenticated to Azure (Connect-AzAccount)
|
||||||
|
- User must have at least 'Reader' permissions on the Front Door profile
|
||||||
|
|
||||||
|
Required Permissions:
|
||||||
|
- Reader access to the subscription and resource group
|
||||||
|
- CDN Profile Reader or Contributor permissions on the Front Door profile
|
||||||
|
- Access to Front Door endpoints, routes, and origin groups
|
||||||
|
|
||||||
|
Front Door Compatibility:
|
||||||
|
- Supports Azure Front Door Standard and Premium profiles
|
||||||
|
- Does NOT support legacy Azure Front Door Classic (different API)
|
||||||
|
- Requires Front Door profile to be in Standard or Premium tier
|
||||||
|
|
||||||
|
Output File:
|
||||||
|
- Format: "YYYY-MM-DD HHMM Front Door Routes ({FrontDoorName}).csv"
|
||||||
|
- Location: Current directory
|
||||||
|
- Content: Complete route topology with origins and patterns
|
||||||
|
|
||||||
|
CSV Structure:
|
||||||
|
- FrontDoorName: Front Door profile name
|
||||||
|
- EndpointName: Front Door endpoint name
|
||||||
|
- RouteName: Individual route configuration name
|
||||||
|
- RoutePatterns: URL patterns matched by this route (semicolon-separated)
|
||||||
|
- RouteUrl: Complete front-end URL for the endpoint
|
||||||
|
- OriginGroupName: Backend origin group name
|
||||||
|
- OriginName: Individual origin/backend name
|
||||||
|
- OriginUrl: Backend origin hostname/URL
|
||||||
|
- OriginEnabled: Origin availability status
|
||||||
|
- RouteEnabled: Route activation status
|
||||||
|
|
||||||
|
Performance Considerations:
|
||||||
|
- Processing time depends on the number of endpoints and routes
|
||||||
|
- Large Front Door configurations may require extended execution time
|
||||||
|
- Network latency affects configuration retrieval speed
|
||||||
|
|
||||||
|
Troubleshooting Notes:
|
||||||
|
- Ensure Front Door is Standard/Premium (not Classic)
|
||||||
|
- Verify resource group and Front Door names are correct
|
||||||
|
- Check that all required Az modules are installed and updated
|
||||||
|
- Confirm appropriate permissions on the Front Door resource
|
||||||
|
|
||||||
|
.LINK
|
||||||
|
https://docs.microsoft.com/en-us/azure/frontdoor/
|
||||||
|
https://docs.microsoft.com/en-us/powershell/module/az.cdn/
|
||||||
|
#>
|
||||||
|
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory = $false, HelpMessage = "Azure subscription ID (optional - uses current context if not specified)")]
|
||||||
|
[ValidateScript({
|
||||||
|
if ([string]::IsNullOrEmpty($_) -or ($_ -match '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$')) {
|
||||||
|
$true
|
||||||
|
} else {
|
||||||
|
throw "Subscription ID must be a valid GUID format"
|
||||||
|
}
|
||||||
|
})]
|
||||||
[string]$SubscriptionId,
|
[string]$SubscriptionId,
|
||||||
|
|
||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory = $true, HelpMessage = "Resource group name containing the Front Door profile")]
|
||||||
|
[ValidateNotNullOrEmpty()]
|
||||||
|
[ValidateLength(1, 90)]
|
||||||
[string]$ResourceGroupName,
|
[string]$ResourceGroupName,
|
||||||
|
|
||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory = $true, HelpMessage = "Front Door profile name (Standard/Premium)")]
|
||||||
|
[ValidateNotNullOrEmpty()]
|
||||||
|
[ValidateLength(1, 260)]
|
||||||
[string]$FrontDoorName
|
[string]$FrontDoorName
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Display script header and configuration
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "Azure Front Door Route Configuration Export"
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "Front Door Profile: $FrontDoorName"
|
||||||
|
Write-Host "Resource Group: $ResourceGroupName"
|
||||||
|
if ($SubscriptionId) {
|
||||||
|
Write-Host "Target Subscription: $SubscriptionId"
|
||||||
|
} else {
|
||||||
|
Write-Host "Using current subscription context"
|
||||||
|
}
|
||||||
|
Write-Host "Script execution started: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Generate timestamped filename for export
|
||||||
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
||||||
$fileName = ".\$date Front Door Routes ($FrontDoorName).csv"
|
$fileName = ".\$date Front Door Routes ($FrontDoorName).csv"
|
||||||
|
Write-Host "Export file: $fileName"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
# Connect to Azure if not already connected
|
# Ensure Azure authentication
|
||||||
|
Write-Host "Verifying Azure authentication..."
|
||||||
if (-not (Get-AzContext)) {
|
if (-not (Get-AzContext)) {
|
||||||
Connect-AzAccount
|
Write-Host "No Azure context found. Initiating authentication..."
|
||||||
|
try {
|
||||||
|
Connect-AzAccount -ErrorAction Stop
|
||||||
|
Write-Host "✓ Successfully authenticated to Azure"
|
||||||
|
} catch {
|
||||||
|
Write-Error "Failed to authenticate to Azure. Please run Connect-AzAccount manually."
|
||||||
|
throw $_
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Host "✓ Azure authentication verified"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Select subscription if provided
|
# Set target subscription if provided
|
||||||
if ($SubscriptionId) {
|
if ($SubscriptionId) {
|
||||||
Select-AzSubscription -SubscriptionId $SubscriptionId
|
Write-Host "Setting subscription context..."
|
||||||
Write-Host "Selected subscription: $SubscriptionId" -ForegroundColor Yellow
|
try {
|
||||||
|
$context = Select-AzSubscription -SubscriptionId $SubscriptionId -ErrorAction Stop
|
||||||
|
Write-Host "✓ Successfully connected to subscription: $($context.Subscription.Name)" -ForegroundColor Green
|
||||||
|
} catch {
|
||||||
|
Write-Error "Failed to select subscription: $SubscriptionId"
|
||||||
|
Write-Error "Please verify the subscription ID and ensure you have access"
|
||||||
|
throw $_
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$currentContext = Get-AzContext
|
||||||
|
Write-Host "✓ Using current subscription: $($currentContext.Subscription.Name)" -ForegroundColor Green
|
||||||
}
|
}
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "Retrieving Front Door Configuration"
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
|
||||||
try {
|
try {
|
||||||
# Get Front Door profile
|
# Get Front Door profile and validate existence
|
||||||
$frontDoor = Get-AzFrontDoorCdnProfile -ResourceGroupName $ResourceGroupName -Name $FrontDoorName
|
Write-Host "Accessing Front Door profile '$FrontDoorName'..."
|
||||||
|
$frontDoor = Get-AzFrontDoorCdnProfile -ResourceGroupName $ResourceGroupName -Name $FrontDoorName -ErrorAction Stop
|
||||||
|
|
||||||
if (-not $frontDoor) {
|
if (-not $frontDoor) {
|
||||||
Write-Error "Front Door '$FrontDoorName' not found in resource group '$ResourceGroupName'"
|
Write-Error "Front Door profile '$FrontDoorName' not found in resource group '$ResourceGroupName'"
|
||||||
|
Write-Error "Please verify the Front Door name and resource group are correct"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get all endpoints
|
Write-Host "✓ Successfully accessed Front Door profile: $($frontDoor.Name)"
|
||||||
$endpoints = Get-AzFrontDoorCdnEndpoint -ResourceGroupName $ResourceGroupName -ProfileName $FrontDoorName
|
Write-Host " Profile Type: $($frontDoor.Sku.Name)"
|
||||||
|
Write-Host " Profile State: $($frontDoor.FrontDoorId)"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
$routeData = @()
|
# Get all endpoints for the Front Door profile
|
||||||
|
Write-Host "Discovering Front Door endpoints..."
|
||||||
|
$endpoints = Get-AzFrontDoorCdnEndpoint -ResourceGroupName $ResourceGroupName -ProfileName $FrontDoorName -ErrorAction Stop
|
||||||
|
|
||||||
|
if (-not $endpoints -or $endpoints.Count -eq 0) {
|
||||||
|
Write-Warning "No endpoints found for Front Door profile '$FrontDoorName'"
|
||||||
|
Write-Host "This Front Door profile may not have any configured endpoints."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "✓ Found $($endpoints.Count) endpoint(s):"
|
||||||
foreach ($endpoint in $endpoints) {
|
foreach ($endpoint in $endpoints) {
|
||||||
# Get routes for each endpoint
|
Write-Host " - $($endpoint.Name) (https://$($endpoint.HostName))"
|
||||||
$routes = Get-AzFrontDoorCdnRoute -ResourceGroupName $ResourceGroupName -ProfileName $FrontDoorName -EndpointName $endpoint.Name
|
}
|
||||||
|
Write-Host ""
|
||||||
|
# Initialize collection for route data and processing counters
|
||||||
|
$routeData = @()
|
||||||
|
$totalRoutes = 0
|
||||||
|
$totalOrigins = 0
|
||||||
|
$enabledRoutes = 0
|
||||||
|
$disabledRoutes = 0
|
||||||
|
$enabledOrigins = 0
|
||||||
|
$disabledOrigins = 0
|
||||||
|
|
||||||
|
Write-Host "Processing endpoints and route configurations..."
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Process each endpoint to extract route and origin information
|
||||||
|
foreach ($endpoint in $endpoints) {
|
||||||
|
Write-Host "Processing endpoint: $($endpoint.Name)"
|
||||||
|
Write-Host " Endpoint URL: https://$($endpoint.HostName)"
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Get all routes configured for this endpoint
|
||||||
|
$routes = Get-AzFrontDoorCdnRoute -ResourceGroupName $ResourceGroupName -ProfileName $FrontDoorName -EndpointName $endpoint.Name -ErrorAction Stop
|
||||||
|
|
||||||
|
if (-not $routes -or $routes.Count -eq 0) {
|
||||||
|
Write-Host " ⚠ No routes found for this endpoint" -ForegroundColor Yellow
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host " ✓ Found $($routes.Count) route(s) for endpoint"
|
||||||
|
|
||||||
|
# Process each route in the endpoint
|
||||||
foreach ($route in $routes) {
|
foreach ($route in $routes) {
|
||||||
# Get origin group details
|
Write-Host " Processing route: $($route.Name)"
|
||||||
|
Write-Host " Patterns: $($route.PatternsToMatch -join ', ')"
|
||||||
|
Write-Host " Status: $($route.EnabledState)"
|
||||||
|
|
||||||
|
# Track route statistics
|
||||||
|
$totalRoutes++
|
||||||
|
if ($route.EnabledState -eq "Enabled") {
|
||||||
|
$enabledRoutes++
|
||||||
|
} else {
|
||||||
|
$disabledRoutes++
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Extract origin group information from route
|
||||||
$originGroupId = $route.OriginGroupId
|
$originGroupId = $route.OriginGroupId
|
||||||
$originGroupName = ($originGroupId -split '/')[-1]
|
$originGroupName = ($originGroupId -split '/')[-1]
|
||||||
|
|
||||||
$origins = Get-AzFrontDoorCdnOrigin -ResourceGroupName $ResourceGroupName -ProfileName $FrontDoorName -OriginGroupName $originGroupName
|
Write-Host " Origin Group: $originGroupName"
|
||||||
|
|
||||||
|
# Get all origins in the origin group
|
||||||
|
$origins = Get-AzFrontDoorCdnOrigin -ResourceGroupName $ResourceGroupName -ProfileName $FrontDoorName -OriginGroupName $originGroupName -ErrorAction Stop
|
||||||
|
|
||||||
|
if (-not $origins -or $origins.Count -eq 0) {
|
||||||
|
Write-Host " ⚠ No origins found in origin group '$originGroupName'" -ForegroundColor Yellow
|
||||||
|
|
||||||
|
# Create entry even if no origins found
|
||||||
|
$routeData += [PSCustomObject]@{
|
||||||
|
FrontDoorName = $FrontDoorName
|
||||||
|
EndpointName = $endpoint.Name
|
||||||
|
RouteName = $route.Name
|
||||||
|
RoutePatterns = ($route.PatternsToMatch -join '; ')
|
||||||
|
RouteUrl = "https://$($endpoint.HostName)"
|
||||||
|
OriginGroupName = $originGroupName
|
||||||
|
OriginName = "No origins found"
|
||||||
|
OriginUrl = "N/A"
|
||||||
|
OriginEnabled = "N/A"
|
||||||
|
RouteEnabled = $route.EnabledState
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host " ✓ Found $($origins.Count) origin(s) in group"
|
||||||
|
|
||||||
|
# Process each origin in the origin group
|
||||||
foreach ($origin in $origins) {
|
foreach ($origin in $origins) {
|
||||||
|
Write-Host " Origin: $($origin.Name) -> $($origin.HostName) ($($origin.EnabledState))"
|
||||||
|
|
||||||
|
# Track origin statistics
|
||||||
|
$totalOrigins++
|
||||||
|
if ($origin.EnabledState -eq "Enabled") {
|
||||||
|
$enabledOrigins++
|
||||||
|
} else {
|
||||||
|
$disabledOrigins++
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create structured data entry for CSV export
|
||||||
$routeData += [PSCustomObject]@{
|
$routeData += [PSCustomObject]@{
|
||||||
FrontDoorName = $FrontDoorName
|
FrontDoorName = $FrontDoorName
|
||||||
EndpointName = $endpoint.Name
|
EndpointName = $endpoint.Name
|
||||||
@@ -64,14 +336,126 @@ try {
|
|||||||
RouteEnabled = $route.EnabledState
|
RouteEnabled = $route.EnabledState
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host " ❌ Error processing origin group: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
|
||||||
|
# Create error entry for troubleshooting
|
||||||
|
$routeData += [PSCustomObject]@{
|
||||||
|
FrontDoorName = $FrontDoorName
|
||||||
|
EndpointName = $endpoint.Name
|
||||||
|
RouteName = $route.Name
|
||||||
|
RoutePatterns = ($route.PatternsToMatch -join '; ')
|
||||||
|
RouteUrl = "https://$($endpoint.HostName)"
|
||||||
|
OriginGroupName = "Error retrieving"
|
||||||
|
OriginName = "Error"
|
||||||
|
OriginUrl = $_.Exception.Message
|
||||||
|
OriginEnabled = "Error"
|
||||||
|
RouteEnabled = $route.EnabledState
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host " ❌ Error processing routes for endpoint '$($endpoint.Name)': $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
# Export to CSV
|
Write-Host ""
|
||||||
Write-Host "Exporting Front Door routes to: $fileName" -ForegroundColor Green
|
}
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "Exporting Results and Analysis"
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
|
||||||
|
# Export route configuration to CSV file
|
||||||
|
Write-Host "Exporting Front Door route configuration to CSV..."
|
||||||
|
try {
|
||||||
$routeData | Export-Csv -Path $fileName -NoTypeInformation -Force
|
$routeData | Export-Csv -Path $fileName -NoTypeInformation -Force
|
||||||
|
Write-Host "✓ Successfully exported $($routeData.Count) route entries to: $fileName" -ForegroundColor Green
|
||||||
|
} catch {
|
||||||
|
Write-Error "Failed to export route data to CSV: $($_.Exception.Message)"
|
||||||
|
throw $_
|
||||||
|
}
|
||||||
|
|
||||||
|
# Display configuration summary table
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Front Door Route Configuration Summary:"
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
$routeData | Format-Table -Property FrontDoorName, EndpointName, RouteName, RoutePatterns, OriginGroupName, OriginName, RouteEnabled, OriginEnabled -AutoSize
|
||||||
|
|
||||||
|
# Generate detailed analysis and statistics
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "Configuration Analysis Summary"
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "Export completed: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Front Door Statistics:"
|
||||||
|
Write-Host " Profile Name: $FrontDoorName"
|
||||||
|
Write-Host " Profile Type: $($frontDoor.Sku.Name)"
|
||||||
|
Write-Host " Total Endpoints: $($endpoints.Count)"
|
||||||
|
Write-Host " Total Routes: $totalRoutes"
|
||||||
|
Write-Host " Total Origins: $totalOrigins"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Analyze route and origin health status
|
||||||
|
Write-Host "Route Status Analysis:"
|
||||||
|
Write-Host " ✓ Enabled Routes: $enabledRoutes"
|
||||||
|
Write-Host " ❌ Disabled Routes: $disabledRoutes"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
Write-Host "Origin Status Analysis:"
|
||||||
|
Write-Host " ✓ Enabled Origins: $enabledOrigins"
|
||||||
|
Write-Host " ❌ Disabled Origins: $disabledOrigins"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Identify potential issues
|
||||||
|
$issuesFound = @()
|
||||||
|
if ($disabledRoutes -gt 0) {
|
||||||
|
$issuesFound += "Some routes are disabled"
|
||||||
|
}
|
||||||
|
if ($disabledOrigins -gt 0) {
|
||||||
|
$issuesFound += "Some origins are disabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
$errorEntries = $routeData | Where-Object { $_.OriginName -eq "Error" }
|
||||||
|
if ($errorEntries.Count -gt 0) {
|
||||||
|
$issuesFound += "Configuration retrieval errors detected"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($issuesFound.Count -gt 0) {
|
||||||
|
Write-Host "⚠ ATTENTION REQUIRED:"
|
||||||
|
foreach ($issue in $issuesFound) {
|
||||||
|
Write-Host " - $issue" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
Write-Host " Review the CSV file for detailed information on affected routes/origins"
|
||||||
|
} else {
|
||||||
|
Write-Host "✓ All routes and origins are enabled and accessible"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Output File Information:"
|
||||||
|
Write-Host " File Path: $fileName"
|
||||||
|
Write-Host " File Size: $([Math]::Round((Get-Item $fileName).Length / 1KB, 2)) KB"
|
||||||
|
Write-Host " Records Exported: $($routeData.Count)"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Recommendations:"
|
||||||
|
Write-Host " - Review disabled routes and origins for intentional configuration"
|
||||||
|
Write-Host " - Validate routing patterns match expected traffic flow"
|
||||||
|
Write-Host " - Monitor origin health and performance regularly"
|
||||||
|
Write-Host " - Document configuration changes for audit trails"
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
Write-Error "Error retrieving Front Door routes: $($_.Exception.Message)"
|
Write-Host ""
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "❌ ERROR OCCURRED DURING PROCESSING"
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Error "Error retrieving Front Door route configuration: $($_.Exception.Message)"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Troubleshooting Steps:"
|
||||||
|
Write-Host " 1. Verify Front Door profile name and resource group are correct"
|
||||||
|
Write-Host " 2. ensure the Front Door is Standard or Premium (not Classic)"
|
||||||
|
Write-Host " 3. Check that you have appropriate permissions on the Front Door resource"
|
||||||
|
Write-Host " 4. Confirm the Az.Cdn PowerShell module is installed and up to date"
|
||||||
|
Write-Host " 5. Verify network connectivity to Azure services"
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
throw $_
|
||||||
}
|
}
|
||||||
@@ -1,90 +1,321 @@
|
|||||||
#Connect-AzAccount
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Exports Azure Key Vault access policies across all management groups and subscriptions for security auditing and compliance.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This script performs a comprehensive audit of Azure Key Vault access policies across an entire
|
||||||
|
Azure tenant, scanning all management groups, subscriptions, and resource groups. It identifies
|
||||||
|
Key Vaults using legacy access policy-based authentication (not RBAC) and exports detailed
|
||||||
|
permission information for security analysis and compliance reporting.
|
||||||
|
|
||||||
|
The script is designed for:
|
||||||
|
- Security auditing and access review processes
|
||||||
|
- Compliance reporting for Key Vault permissions
|
||||||
|
- Access policy governance and standardization
|
||||||
|
- Migration planning from access policies to RBAC
|
||||||
|
- Risk assessment of Key Vault permissions
|
||||||
|
- Regular security reviews and attestation processes
|
||||||
|
|
||||||
|
Key features:
|
||||||
|
- Multi-tenant hierarchical scanning (Management Groups → Subscriptions → Key Vaults)
|
||||||
|
- Access policy-based Key Vault filtering (excludes RBAC-only vaults)
|
||||||
|
- Comprehensive permission breakdown (Keys, Secrets, Certificates, Storage)
|
||||||
|
- Identity resolution with display names and application details
|
||||||
|
- Resource tagging extraction for governance analysis
|
||||||
|
- Structured CSV export for security team analysis
|
||||||
|
|
||||||
|
The script specifically targets Key Vaults using traditional access policies and skips
|
||||||
|
those configured for RBAC-only access, providing focused analysis on legacy permission models.
|
||||||
|
|
||||||
|
.PARAMETER None
|
||||||
|
This script does not accept parameters and processes all accessible management groups and subscriptions automatically.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\KeyVaultAccessPolicies.ps1
|
||||||
|
|
||||||
|
Runs the complete Key Vault access policy audit across all management groups and subscriptions.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
# Connect with specific account first
|
||||||
|
Connect-AzAccount -Tenant "your-tenant-id"
|
||||||
|
.\KeyVaultAccessPolicies.ps1
|
||||||
|
|
||||||
|
Authenticates with specific tenant context before running the comprehensive audit.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
# Schedule for automated security reviews
|
||||||
|
$scriptPath = "C:\SecurityScripts\KeyVaultAccessPolicies.ps1"
|
||||||
|
& $scriptPath
|
||||||
|
|
||||||
|
Executes the script from a scheduled security review process for regular compliance reporting.
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Author: Cloud Engineering Team
|
||||||
|
Version: 1.0
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
- Azure PowerShell module (Az) must be installed
|
||||||
|
- User must be authenticated to Azure (Connect-AzAccount)
|
||||||
|
- User must have at least 'Reader' permissions across all target subscriptions
|
||||||
|
- Access to Management Group hierarchy and Key Vault resources
|
||||||
|
|
||||||
|
Required Permissions:
|
||||||
|
- Management Group Reader permissions at the tenant root or target management groups
|
||||||
|
- Reader access to all subscriptions containing Key Vaults
|
||||||
|
- Key Vault Reader or Key Vault Contributor permissions on Key Vault resources
|
||||||
|
- Microsoft Graph permissions may be needed for identity display name resolution
|
||||||
|
|
||||||
|
Security Context:
|
||||||
|
- This script reads access policy configurations but does not modify them
|
||||||
|
- Exported data contains sensitive permission information - handle appropriately
|
||||||
|
- Consider running from secure, controlled environments only
|
||||||
|
- Ensure proper access controls on output files
|
||||||
|
|
||||||
|
Output File:
|
||||||
|
- Format: "YYYY-MM-DD HHMM azure_key_vault_access_policies.csv"
|
||||||
|
- Location: Current directory
|
||||||
|
- Content: Comprehensive access policy inventory with identity and permission details
|
||||||
|
|
||||||
|
CSV Structure:
|
||||||
|
- Management Group information (ID, Name)
|
||||||
|
- Subscription details (ID, Name)
|
||||||
|
- Key Vault resource information (ID, Name, Location, Resource Group)
|
||||||
|
- Access Policy details (Object ID, Display Name, Application ID, Application Name)
|
||||||
|
- Permission breakdowns (Keys, Secrets, Certificates, Storage permissions)
|
||||||
|
- Resource tags (Team, Product, Environment, Data classification, Deployment, Creation date)
|
||||||
|
|
||||||
|
Performance Considerations:
|
||||||
|
- Processing time depends on the number of management groups, subscriptions, and Key Vaults
|
||||||
|
- Large Azure tenants may require extended execution time
|
||||||
|
- Network latency affects resource enumeration and permission retrieval
|
||||||
|
- Consider running during off-peak hours for large environments
|
||||||
|
|
||||||
|
Security and Compliance Notes:
|
||||||
|
- Key Vault access policies are being deprecated in favor of RBAC
|
||||||
|
- This script helps identify vaults still using legacy access policies
|
||||||
|
- Use results to plan migration from access policies to Azure RBAC
|
||||||
|
- Regular execution recommended for continuous security monitoring
|
||||||
|
- Exported data should be classified and protected appropriately
|
||||||
|
|
||||||
|
Filtering Logic:
|
||||||
|
- Only processes Key Vaults with EnableRbacAuthorization = FALSE
|
||||||
|
- Skips RBAC-only Key Vaults (EnableRbacAuthorization = TRUE)
|
||||||
|
- Focuses analysis on legacy access policy configurations
|
||||||
|
|
||||||
|
Common Use Cases:
|
||||||
|
- Quarterly security reviews and access attestation
|
||||||
|
- Pre-migration analysis for RBAC conversion projects
|
||||||
|
- Compliance audits requiring Key Vault permission documentation
|
||||||
|
- Risk assessments of privileged access to cryptographic resources
|
||||||
|
- Governance reviews of Key Vault access patterns
|
||||||
|
|
||||||
|
.LINK
|
||||||
|
https://docs.microsoft.com/en-us/azure/key-vault/general/security-features
|
||||||
|
https://docs.microsoft.com/en-us/azure/key-vault/general/rbac-guide
|
||||||
|
#>
|
||||||
|
|
||||||
|
# Ensure user is authenticated to Azure
|
||||||
|
# Uncomment the following line if authentication is needed:
|
||||||
|
# Connect-AzAccount
|
||||||
|
|
||||||
|
# Define comprehensive resource information class for Key Vault access policy analysis
|
||||||
class ResourceCheck {
|
class ResourceCheck {
|
||||||
[string] $ManagementGroupId = ""
|
# Management Group hierarchy information
|
||||||
[string] $ManagementGroupName = ""
|
[string] $ManagementGroupId = "" # Azure Management Group ID
|
||||||
[string] $SubscriptionId = ""
|
[string] $ManagementGroupName = "" # Management Group display name
|
||||||
[string] $SubscriptionName = ""
|
|
||||||
[string] $ResourceGroup = ""
|
# Subscription context information
|
||||||
[string] $ResourceId = ""
|
[string] $SubscriptionId = "" # Azure subscription ID
|
||||||
[string] $Location = ""
|
[string] $SubscriptionName = "" # Subscription display name
|
||||||
[string] $ResourceName = ""
|
|
||||||
[string] $AccessPolicy_ObjectId = ""
|
# Resource location and identification
|
||||||
[string] $AccessPolicy_DisplayName = ""
|
[string] $ResourceGroup = "" # Resource group containing the Key Vault
|
||||||
[string] $AccessPolicy_ApplicationId = ""
|
[string] $ResourceId = "" # Full Azure resource ID of the Key Vault
|
||||||
[string] $AccessPolicy_ApplicationDisplayName = ""
|
[string] $Location = "" # Azure region where Key Vault is deployed
|
||||||
[string] $AccessPolicy_Keys = ""
|
[string] $ResourceName = "" # Key Vault name
|
||||||
[string] $AccessPolicy_Secrets = ""
|
|
||||||
[string] $AccessPolicy_Certificates = ""
|
# Access policy identity information
|
||||||
[string] $AccessPolicy_Storage = ""
|
[string] $AccessPolicy_ObjectId = "" # Azure AD Object ID with access
|
||||||
[string] $Tag_Team = ""
|
[string] $AccessPolicy_DisplayName = "" # Display name of the identity (user/service principal/group)
|
||||||
[string] $Tag_Product = ""
|
[string] $AccessPolicy_ApplicationId = "" # Application ID (for service principals)
|
||||||
[string] $Tag_Environment = ""
|
[string] $AccessPolicy_ApplicationDisplayName = "" # Application display name
|
||||||
[string] $Tag_Data = ""
|
|
||||||
[string] $Tag_Deployment = ""
|
# Permission details by Key Vault resource type
|
||||||
[string] $Tag_CreatedOnDate = ""
|
[string] $AccessPolicy_Keys = "" # Permissions granted to cryptographic keys
|
||||||
|
[string] $AccessPolicy_Secrets = "" # Permissions granted to secrets
|
||||||
|
[string] $AccessPolicy_Certificates = "" # Permissions granted to certificates
|
||||||
|
[string] $AccessPolicy_Storage = "" # Permissions granted to storage account keys
|
||||||
|
|
||||||
|
# Resource governance tags for compliance and organization
|
||||||
|
[string] $Tag_Team = "" # Team responsible for the Key Vault
|
||||||
|
[string] $Tag_Product = "" # Product or service associated with the Key Vault
|
||||||
|
[string] $Tag_Environment = "" # Environment classification (dev/test/prod)
|
||||||
|
[string] $Tag_Data = "" # Data classification level
|
||||||
|
[string] $Tag_Deployment = "" # Deployment method or automation tag
|
||||||
|
[string] $Tag_CreatedOnDate = "" # Resource creation timestamp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Write-Host "======================================================================================================================================================================"
|
Write-Host "======================================================================================================================================================================"
|
||||||
Write-Host "Creating key vault access policy resource overview."
|
Write-Host "Azure Key Vault Access Policy Security Audit"
|
||||||
Write-Host "======================================================================================================================================================================"
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "Starting comprehensive Key Vault access policy analysis across Azure tenant..."
|
||||||
|
Write-Host "Script execution started: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Scope: All Management Groups → All Active Subscriptions → All Key Vaults (Access Policy-based only)"
|
||||||
|
Write-Host "Target: Key Vaults using legacy access policies (excludes RBAC-only vaults)"
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Generate timestamped filename for export
|
||||||
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
||||||
$fileName = ".\$date azure_key_vault_access_policies.csv"
|
$fileName = ".\$date azure_key_vault_access_policies.csv"
|
||||||
|
Write-Host "Export file: $fileName"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
$managementGroups = Get-AzManagementGroup
|
# Initialize processing counters for statistics
|
||||||
|
$totalManagementGroups = 0
|
||||||
|
$totalSubscriptions = 0
|
||||||
|
$totalKeyVaults = 0
|
||||||
|
$totalAccessPolicies = 0
|
||||||
|
$rbacOnlyVaults = 0
|
||||||
|
$processedResourceGroups = 0
|
||||||
|
|
||||||
foreach ($managementGroup in $managementGroups)
|
Write-Host "Discovering Management Group hierarchy..."
|
||||||
{
|
try {
|
||||||
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
$managementGroups = Get-AzManagementGroup -ErrorAction Stop
|
||||||
Write-Host "Management group [$($managementGroup.Name)]"
|
$totalManagementGroups = $managementGroups.Count
|
||||||
|
Write-Host "✓ Found $totalManagementGroups Management Group(s) to process:"
|
||||||
$subscriptions = Get-AzManagementGroupSubscription -Group $managementGroup.Name | Where-Object State -eq "Active"
|
foreach ($mg in $managementGroups) {
|
||||||
|
Write-Host " - $($mg.DisplayName) ($($mg.Name))"
|
||||||
foreach ($subscription in $subscriptions)
|
}
|
||||||
{
|
} catch {
|
||||||
|
Write-Error "Failed to retrieve Management Groups. Please ensure you have appropriate permissions."
|
||||||
|
Write-Error "Required permissions: Management Group Reader at tenant root or target management groups"
|
||||||
|
throw $_
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Process each Management Group in the hierarchy
|
||||||
|
foreach ($managementGroup in $managementGroups) {
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "Processing Management Group: $($managementGroup.DisplayName) ($($managementGroup.Name))"
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Get all active subscriptions within this management group
|
||||||
|
Write-Host "Discovering active subscriptions in management group..."
|
||||||
|
$subscriptions = Get-AzManagementGroupSubscription -Group $managementGroup.Name -ErrorAction Stop | Where-Object State -eq "Active"
|
||||||
|
|
||||||
|
if (-not $subscriptions -or $subscriptions.Count -eq 0) {
|
||||||
|
Write-Host "ℹ No active subscriptions found in management group '$($managementGroup.DisplayName)'"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "✓ Found $($subscriptions.Count) active subscription(s):"
|
||||||
|
foreach ($sub in $subscriptions) {
|
||||||
|
Write-Host " - $($sub.DisplayName)"
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Process each active subscription
|
||||||
|
foreach ($subscription in $subscriptions) {
|
||||||
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
||||||
|
# Extract subscription ID from the full resource path
|
||||||
$scope = $subscription.Id.Substring($subscription.Parent.Length, $subscription.Id.Length - $subscription.Parent.Length)
|
$scope = $subscription.Id.Substring($subscription.Parent.Length, $subscription.Id.Length - $subscription.Parent.Length)
|
||||||
$subscriptionId = $scope.Replace("/subscriptions/", "")
|
$subscriptionId = $scope.Replace("/subscriptions/", "")
|
||||||
Write-Host "Subscription [$($subscription.DisplayName) - $subscriptionId]"
|
|
||||||
Set-AzContext -SubscriptionId $subscriptionId | Out-Null
|
Write-Host "Processing Subscription: $($subscription.DisplayName) ($subscriptionId)"
|
||||||
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
||||||
|
|
||||||
$allResourceGroups = Get-AzResourceGroup
|
try {
|
||||||
|
# Set Azure context to the current subscription
|
||||||
|
Set-AzContext -SubscriptionId $subscriptionId -ErrorAction Stop | Out-Null
|
||||||
|
Write-Host "✓ Successfully connected to subscription context"
|
||||||
|
$totalSubscriptions++
|
||||||
|
|
||||||
|
|
||||||
|
# Get all resource groups in the current subscription
|
||||||
|
Write-Host "Discovering resource groups..."
|
||||||
|
$allResourceGroups = Get-AzResourceGroup -ErrorAction Stop
|
||||||
|
Write-Host "✓ Found $($allResourceGroups.Count) resource group(s) to scan"
|
||||||
|
|
||||||
|
# Initialize result collection for this subscription
|
||||||
[ResourceCheck[]]$Result = @()
|
[ResourceCheck[]]$Result = @()
|
||||||
|
$subscriptionKeyVaults = 0
|
||||||
|
$subscriptionAccessPolicies = 0
|
||||||
|
$subscriptionRbacVaults = 0
|
||||||
|
|
||||||
|
# Process each resource group to find Key Vaults
|
||||||
foreach ($group in $allResourceGroups) {
|
foreach ($group in $allResourceGroups) {
|
||||||
|
Write-Host " Scanning resource group: $($group.ResourceGroupName)"
|
||||||
|
$processedResourceGroups++
|
||||||
|
|
||||||
$allVaults = Get-AzKeyVault -ResourceGroupName $group.ResourceGroupName
|
try {
|
||||||
|
# Get all Key Vaults in the current resource group
|
||||||
|
$allVaults = Get-AzKeyVault -ResourceGroupName $group.ResourceGroupName -ErrorAction Stop
|
||||||
|
|
||||||
|
if (-not $allVaults -or $allVaults.Count -eq 0) {
|
||||||
|
Write-Host " ℹ No Key Vaults found in resource group"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host " ✓ Found $($allVaults.Count) Key Vault(s)"
|
||||||
|
|
||||||
|
# Process each Key Vault found
|
||||||
foreach ($vault in $allVaults) {
|
foreach ($vault in $allVaults) {
|
||||||
|
Write-Host " Processing Key Vault: $($vault.VaultName)"
|
||||||
|
|
||||||
$vaultWithAllProps = Get-AzKeyVault -ResourceGroupName $group.ResourceGroupName -Name $vault.VaultName
|
try {
|
||||||
|
# Get detailed Key Vault properties including access policies
|
||||||
|
$vaultWithAllProps = Get-AzKeyVault -ResourceGroupName $group.ResourceGroupName -Name $vault.VaultName -ErrorAction Stop
|
||||||
|
$totalKeyVaults++
|
||||||
|
$subscriptionKeyVaults++
|
||||||
|
|
||||||
if ($vaultWithAllProps.EnableRbacAuthorization -ne "TRUE") {
|
# Check if vault uses traditional access policies (not RBAC-only)
|
||||||
|
if ($vaultWithAllProps.EnableRbacAuthorization -ne $true) {
|
||||||
|
Write-Host " 📋 Access Policy-based vault: $($vaultWithAllProps.ResourceId)"
|
||||||
|
|
||||||
Write-Host $vaultWithAllProps.ResourceId
|
# Check if vault has any access policies configured
|
||||||
|
if (-not $vaultWithAllProps.AccessPolicies -or $vaultWithAllProps.AccessPolicies.Count -eq 0) {
|
||||||
|
Write-Host " ⚠ Warning: No access policies found on this vault" -ForegroundColor Yellow
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
foreach($accessPolicy in $vaultWithAllProps.AccessPolicies) {
|
Write-Host " ✓ Found $($vaultWithAllProps.AccessPolicies.Count) access policy/policies"
|
||||||
|
|
||||||
|
# Process each access policy in the vault
|
||||||
|
foreach ($accessPolicy in $vaultWithAllProps.AccessPolicies) {
|
||||||
|
Write-Host " Identity: $($accessPolicy.DisplayName) ($($accessPolicy.ObjectId))"
|
||||||
|
|
||||||
|
# Create comprehensive resource check entry
|
||||||
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
||||||
|
|
||||||
|
# Populate management group and subscription information
|
||||||
$resourceCheck.ManagementGroupId = $managementGroup.Id
|
$resourceCheck.ManagementGroupId = $managementGroup.Id
|
||||||
$resourceCheck.ManagementGroupName = $managementGroup.DisplayName
|
$resourceCheck.ManagementGroupName = $managementGroup.DisplayName
|
||||||
$resourceCheck.SubscriptionId = $subscription.Id
|
$resourceCheck.SubscriptionId = $subscription.Id
|
||||||
$resourceCheck.SubscriptionName = $subscription.DisplayName
|
$resourceCheck.SubscriptionName = $subscription.DisplayName
|
||||||
|
|
||||||
|
# Populate Key Vault resource information
|
||||||
$resourceCheck.ResourceGroup = $vaultWithAllProps.ResourceGroupName
|
$resourceCheck.ResourceGroup = $vaultWithAllProps.ResourceGroupName
|
||||||
$resourceCheck.ResourceId = $vaultWithAllProps.ResourceId
|
$resourceCheck.ResourceId = $vaultWithAllProps.ResourceId
|
||||||
$resourceCheck.Location = $vaultWithAllProps.Location
|
$resourceCheck.Location = $vaultWithAllProps.Location
|
||||||
$resourceCheck.ResourceName = $vaultWithAllProps.VaultName
|
$resourceCheck.ResourceName = $vaultWithAllProps.VaultName
|
||||||
|
|
||||||
|
# Populate access policy identity information
|
||||||
$resourceCheck.AccessPolicy_ObjectId = $accessPolicy.ObjectId
|
$resourceCheck.AccessPolicy_ObjectId = $accessPolicy.ObjectId
|
||||||
$resourceCheck.AccessPolicy_DisplayName = $accessPolicy.DisplayName
|
$resourceCheck.AccessPolicy_DisplayName = $accessPolicy.DisplayName
|
||||||
$resourceCheck.AccessPolicy_ApplicationId = $accessPolicy.ApplicationId
|
$resourceCheck.AccessPolicy_ApplicationId = $accessPolicy.ApplicationId
|
||||||
$resourceCheck.AccessPolicy_ApplicationDisplayName = $accessPolicy.ApplicationIdDisplayName
|
$resourceCheck.AccessPolicy_ApplicationDisplayName = $accessPolicy.ApplicationIdDisplayName
|
||||||
|
|
||||||
|
# Populate permission details for each resource type
|
||||||
$resourceCheck.AccessPolicy_Keys = $accessPolicy.PermissionsToKeysStr
|
$resourceCheck.AccessPolicy_Keys = $accessPolicy.PermissionsToKeysStr
|
||||||
$resourceCheck.AccessPolicy_Secrets = $accessPolicy.PermissionsToSecretsStr
|
$resourceCheck.AccessPolicy_Secrets = $accessPolicy.PermissionsToSecretsStr
|
||||||
$resourceCheck.AccessPolicy_Certificates = $accessPolicy.PermissionsToCertificatesStr
|
$resourceCheck.AccessPolicy_Certificates = $accessPolicy.PermissionsToCertificatesStr
|
||||||
$resourceCheck.AccessPolicy_Storage = $accessPolicy.PermissionsToStorageStr
|
$resourceCheck.AccessPolicy_Storage = $accessPolicy.PermissionsToStorageStr
|
||||||
|
|
||||||
|
# Extract resource tags for governance analysis
|
||||||
$resourceCheck.Tag_Team = $vaultWithAllProps.Tags.team
|
$resourceCheck.Tag_Team = $vaultWithAllProps.Tags.team
|
||||||
$resourceCheck.Tag_Product = $vaultWithAllProps.Tags.product
|
$resourceCheck.Tag_Product = $vaultWithAllProps.Tags.product
|
||||||
$resourceCheck.Tag_Environment = $vaultWithAllProps.Tags.environment
|
$resourceCheck.Tag_Environment = $vaultWithAllProps.Tags.environment
|
||||||
@@ -92,15 +323,122 @@ foreach ($managementGroup in $managementGroups)
|
|||||||
$resourceCheck.Tag_CreatedOnDate = $vaultWithAllProps.Tags.CreatedOnDate
|
$resourceCheck.Tag_CreatedOnDate = $vaultWithAllProps.Tags.CreatedOnDate
|
||||||
$resourceCheck.Tag_Deployment = $vaultWithAllProps.Tags.drp_deployment
|
$resourceCheck.Tag_Deployment = $vaultWithAllProps.Tags.drp_deployment
|
||||||
|
|
||||||
|
# Add to results collection
|
||||||
$Result += $resourceCheck
|
$Result += $resourceCheck
|
||||||
|
$totalAccessPolicies++
|
||||||
|
$subscriptionAccessPolicies++
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
Write-Host " 🔐 RBAC-only vault (skipped): $($vault.VaultName)" -ForegroundColor Blue
|
||||||
|
$rbacOnlyVaults++
|
||||||
|
$subscriptionRbacVaults++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
Write-Host " ❌ Error processing vault '$($vault.VaultName)': $($_.Exception.Message)" -ForegroundColor Red
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$Result | Export-Csv -Path $fileName -Append -NoTypeInformation
|
} catch {
|
||||||
|
Write-Host " ❌ Error scanning resource group '$($group.ResourceGroupName)': $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Subscription Summary:"
|
||||||
|
Write-Host " Key Vaults found: $subscriptionKeyVaults"
|
||||||
|
Write-Host " Access policies extracted: $subscriptionAccessPolicies"
|
||||||
|
Write-Host " RBAC-only vaults (skipped): $subscriptionRbacVaults"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Export subscription results to CSV file
|
||||||
|
if ($Result.Count -gt 0) {
|
||||||
|
Write-Host "Exporting $($Result.Count) access policy entries to CSV..."
|
||||||
|
try {
|
||||||
|
$Result | Export-Csv -Path $fileName -Append -NoTypeInformation -ErrorAction Stop
|
||||||
|
Write-Host "✓ Successfully exported subscription data"
|
||||||
|
} catch {
|
||||||
|
Write-Error "Failed to export data for subscription '$($subscription.DisplayName)': $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Host "ℹ No access policy data to export from this subscription"
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
Write-Host "❌ Error processing subscription '$($subscription.DisplayName)': $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
Write-Host " Please verify subscription access and permissions" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
Write-Host "❌ Error processing management group '$($managementGroup.DisplayName)': $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
Write-Host " Please verify management group access and permissions" -ForegroundColor Red
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Write-Host "======================================================================================================================================================================"
|
|
||||||
Write-Host "Done."
|
Write-Host ""
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "Key Vault Access Policy Audit Completed"
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "Execution completed: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Display comprehensive execution statistics
|
||||||
|
Write-Host "Processing Statistics:"
|
||||||
|
Write-Host " Management Groups processed: $totalManagementGroups"
|
||||||
|
Write-Host " Subscriptions processed: $totalSubscriptions"
|
||||||
|
Write-Host " Resource Groups scanned: $processedResourceGroups"
|
||||||
|
Write-Host " Total Key Vaults found: $totalKeyVaults"
|
||||||
|
Write-Host " Access policies extracted: $totalAccessPolicies"
|
||||||
|
Write-Host " RBAC-only vaults (skipped): $rbacOnlyVaults"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Analyze and display key findings
|
||||||
|
if ($totalAccessPolicies -gt 0) {
|
||||||
|
Write-Host "✓ Successfully exported Key Vault access policy data to: $fileName"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
Write-Host "Security Analysis Insights:"
|
||||||
|
if ($rbacOnlyVaults -gt 0) {
|
||||||
|
Write-Host " ✓ $rbacOnlyVaults Key Vault(s) using modern RBAC authentication"
|
||||||
|
}
|
||||||
|
|
||||||
|
$legacyVaults = $totalKeyVaults - $rbacOnlyVaults
|
||||||
|
if ($legacyVaults -gt 0) {
|
||||||
|
Write-Host " ⚠ $legacyVaults Key Vault(s) still using legacy access policies" -ForegroundColor Yellow
|
||||||
|
Write-Host " Consider migrating to Azure RBAC for improved security and management"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Output File Information:"
|
||||||
|
if (Test-Path $fileName) {
|
||||||
|
Write-Host " File Path: $fileName"
|
||||||
|
Write-Host " File Size: $([Math]::Round((Get-Item $fileName).Length / 1KB, 2)) KB"
|
||||||
|
Write-Host " Records Exported: $totalAccessPolicies"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Host "ℹ No Key Vault access policies found across all processed subscriptions"
|
||||||
|
Write-Host " This could indicate:"
|
||||||
|
Write-Host " - All Key Vaults are using RBAC-only authentication"
|
||||||
|
Write-Host " - No Key Vaults exist in the scanned subscriptions"
|
||||||
|
Write-Host " - Permission issues preventing access to Key Vault configurations"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Security Recommendations:"
|
||||||
|
Write-Host " - Review exported access policies for excessive permissions"
|
||||||
|
Write-Host " - Validate that all identities with access are still required"
|
||||||
|
Write-Host " - Consider implementing Azure RBAC for new Key Vaults"
|
||||||
|
Write-Host " - Plan migration from access policies to RBAC for existing vaults"
|
||||||
|
Write-Host " - Implement regular access reviews and permission audits"
|
||||||
|
Write-Host " - Ensure proper governance tags are applied to all Key Vaults"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Compliance Notes:"
|
||||||
|
Write-Host " - Exported data contains sensitive security information"
|
||||||
|
Write-Host " - Handle output file with appropriate data classification controls"
|
||||||
|
Write-Host " - Consider encryption for long-term storage of audit results"
|
||||||
|
Write-Host " - Schedule regular execution for continuous security monitoring"
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
|
||||||
|
|||||||
@@ -1,83 +1,377 @@
|
|||||||
#Connect-AzAccount
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Inventories secrets in Azure Key Vaults using legacy access policies across all management groups and subscriptions.
|
||||||
|
|
||||||
[string] $userObjectId = "c6025a2e-416c-42da-96ef-dd507382793a" #Should be interactive user (this one is Jurjen)
|
.DESCRIPTION
|
||||||
|
This script performs a comprehensive inventory of secrets stored in Azure Key Vaults that use
|
||||||
|
traditional access policy-based authentication (not RBAC). It temporarily grants list permissions
|
||||||
|
to a specified user account, enumerates all secret names, and then removes the temporary access.
|
||||||
|
|
||||||
|
⚠️ SECURITY WARNING: This script temporarily modifies Key Vault access policies during execution.
|
||||||
|
It grants temporary secret list permissions to the specified user account and removes them afterwards.
|
||||||
|
|
||||||
|
The script is designed for:
|
||||||
|
- Security auditing and secret inventory management
|
||||||
|
- Compliance reporting for secret governance
|
||||||
|
- Migration planning from access policies to RBAC
|
||||||
|
- Secret lifecycle management and cleanup identification
|
||||||
|
- Risk assessment of stored secrets across the organization
|
||||||
|
- Regular security reviews and secret attestation processes
|
||||||
|
|
||||||
|
Key features:
|
||||||
|
- Multi-tenant hierarchical scanning (Management Groups → Subscriptions → Key Vaults)
|
||||||
|
- Access policy-based Key Vault filtering (excludes RBAC-only vaults)
|
||||||
|
- Temporary access policy modification for secret enumeration
|
||||||
|
- Automatic cleanup of temporary permissions
|
||||||
|
- Comprehensive secret name inventory (does not retrieve secret values)
|
||||||
|
- Resource tagging extraction for governance analysis
|
||||||
|
- Structured CSV export for security team analysis
|
||||||
|
|
||||||
|
IMPORTANT SECURITY CONSIDERATIONS:
|
||||||
|
- Script only retrieves secret names, not secret values
|
||||||
|
- Temporary access policies are automatically cleaned up
|
||||||
|
- Requires privileged permissions to modify Key Vault access policies
|
||||||
|
- Should be run from secure, controlled environments only
|
||||||
|
- All activities are logged in Azure Activity Log for audit trails
|
||||||
|
|
||||||
|
.PARAMETER None
|
||||||
|
This script does not accept command-line parameters. The user Object ID must be configured
|
||||||
|
within the script before execution.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
# Update the userObjectId variable with your Object ID first
|
||||||
|
$userObjectId = "your-user-object-id-here"
|
||||||
|
.\KeyVaultNonRBACSecrets.ps1
|
||||||
|
|
||||||
|
Runs the complete Key Vault secret inventory after configuring the user Object ID.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
# Get your current user Object ID
|
||||||
|
$currentUser = Get-AzADUser -Mail (Get-AzContext).Account.Id
|
||||||
|
Write-Host "Your Object ID: $($currentUser.Id)"
|
||||||
|
|
||||||
|
# Then update the script and run
|
||||||
|
.\KeyVaultNonRBACSecrets.ps1
|
||||||
|
|
||||||
|
Retrieves your Object ID for configuration and runs the inventory.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
# Connect with specific account first for security
|
||||||
|
Connect-AzAccount -Tenant "your-tenant-id"
|
||||||
|
.\KeyVaultNonRBACSecrets.ps1
|
||||||
|
|
||||||
|
Authenticates with specific tenant context before running the sensitive inventory operation.
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Author: Cloud Engineering Team
|
||||||
|
Version: 1.0
|
||||||
|
|
||||||
|
⚠️ SECURITY NOTICE: This script requires and uses highly privileged permissions to temporarily
|
||||||
|
modify Key Vault access policies. Use with extreme caution and only in authorized security
|
||||||
|
audit scenarios.
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
- Azure PowerShell module (Az) must be installed
|
||||||
|
- User must be authenticated to Azure (Connect-AzAccount)
|
||||||
|
- User must have Key Vault Access Policy management permissions across target vaults
|
||||||
|
- User Object ID must be configured in the script before execution
|
||||||
|
|
||||||
|
Required Permissions:
|
||||||
|
- Management Group Reader permissions at the tenant root or target management groups
|
||||||
|
- Key Vault Contributor or Key Vault Access Policy Administrator on all target Key Vaults
|
||||||
|
- Reader access to all subscriptions containing Key Vaults
|
||||||
|
- Sufficient privileges to modify and remove Key Vault access policies
|
||||||
|
|
||||||
|
Security Context:
|
||||||
|
- Script temporarily grants 'List' permissions on secrets to the specified user
|
||||||
|
- Access policies are automatically removed after secret enumeration
|
||||||
|
- Only secret names are collected, not secret values
|
||||||
|
- All access policy modifications are logged in Azure Activity Log
|
||||||
|
- Failed cleanup operations may leave temporary permissions (manual removal required)
|
||||||
|
|
||||||
|
Output File:
|
||||||
|
- Format: "YYYY-MM-DD HHMM azure_key_vault_secrets.csv"
|
||||||
|
- Location: Current directory
|
||||||
|
- Content: Secret inventory with Key Vault context and governance tags
|
||||||
|
|
||||||
|
CSV Structure:
|
||||||
|
- Management Group information (ID, Name)
|
||||||
|
- Subscription details (ID, Name)
|
||||||
|
- Key Vault resource information (ID, Name, Location, Resource Group)
|
||||||
|
- Secret name (Secret_Key field)
|
||||||
|
- Resource tags (Team, Product, Environment, Data classification, Deployment, Creation date)
|
||||||
|
|
||||||
|
Performance Considerations:
|
||||||
|
- Processing time depends on the number of Key Vaults and secrets
|
||||||
|
- Access policy modifications add latency to each Key Vault operation
|
||||||
|
- Large Azure tenants may require extended execution time
|
||||||
|
- Network latency affects both enumeration and policy modification operations
|
||||||
|
|
||||||
|
Risk Mitigation:
|
||||||
|
- Script implements automatic cleanup of temporary permissions
|
||||||
|
- Only grants minimal required permissions (List secrets only)
|
||||||
|
- Does not retrieve or expose secret values
|
||||||
|
- Focuses only on access policy-based vaults (skips RBAC vaults)
|
||||||
|
- All operations are auditable through Azure Activity Log
|
||||||
|
|
||||||
|
Failure Scenarios:
|
||||||
|
- If script fails during execution, temporary access policies may remain
|
||||||
|
- Manual cleanup may be required using Remove-AzKeyVaultAccessPolicy
|
||||||
|
- Network interruptions may prevent proper cleanup
|
||||||
|
- Insufficient permissions may cause partial processing
|
||||||
|
|
||||||
|
Compliance and Governance:
|
||||||
|
- Use only for authorized security audits and compliance activities
|
||||||
|
- Document all executions for audit trail purposes
|
||||||
|
- Ensure proper approval for access policy modification activities
|
||||||
|
- Handle exported secret names with appropriate data classification
|
||||||
|
- Consider encryption for long-term storage of inventory results
|
||||||
|
|
||||||
|
.LINK
|
||||||
|
https://docs.microsoft.com/en-us/azure/key-vault/general/security-features
|
||||||
|
https://docs.microsoft.com/en-us/azure/key-vault/general/rbac-guide
|
||||||
|
https://docs.microsoft.com/en-us/azure/key-vault/general/logging
|
||||||
|
#>
|
||||||
|
|
||||||
|
# Ensure user is authenticated to Azure
|
||||||
|
# Uncomment the following line if authentication is needed:
|
||||||
|
# Connect-AzAccount
|
||||||
|
|
||||||
|
# ⚠️ SECURITY CONFIGURATION: Dynamically retrieve current user's Object ID for temporary access policy grants
|
||||||
|
# This user will receive temporary 'List' permissions on secrets during processing
|
||||||
|
Write-Host "Retrieving current user's Object ID for temporary access policy grants..."
|
||||||
|
try {
|
||||||
|
$currentContext = Get-AzContext -ErrorAction Stop
|
||||||
|
if (-not $currentContext) {
|
||||||
|
throw "No Azure context found. Please run Connect-AzAccount first."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get the current user's Object ID from Azure AD
|
||||||
|
$currentUser = Get-AzADUser -Mail $currentContext.Account.Id -ErrorAction Stop
|
||||||
|
[string] $userObjectId = $currentUser.Id
|
||||||
|
|
||||||
|
if ([string]::IsNullOrEmpty($userObjectId)) {
|
||||||
|
throw "Could not retrieve Object ID for current user: $($currentContext.Account.Id)"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "✓ Successfully retrieved Object ID for user: $($currentContext.Account.Id)"
|
||||||
|
Write-Host " Object ID: $userObjectId"
|
||||||
|
Write-Host " Display Name: $($currentUser.DisplayName)"
|
||||||
|
} catch {
|
||||||
|
Write-Error "Failed to retrieve current user's Object ID: $($_.Exception.Message)"
|
||||||
|
Write-Error "Please ensure you are authenticated with Connect-AzAccount and have a valid user account"
|
||||||
|
Write-Error "Note: Service Principal authentication is not supported for this operation"
|
||||||
|
throw $_
|
||||||
|
}
|
||||||
|
|
||||||
|
# Define comprehensive resource information class for Key Vault secret inventory
|
||||||
class ResourceCheck {
|
class ResourceCheck {
|
||||||
[string] $ManagementGroupId = ""
|
# Management Group hierarchy information
|
||||||
[string] $ManagementGroupName = ""
|
[string] $ManagementGroupId = "" # Azure Management Group ID
|
||||||
[string] $SubscriptionId = ""
|
[string] $ManagementGroupName = "" # Management Group display name
|
||||||
[string] $SubscriptionName = ""
|
|
||||||
[string] $ResourceGroup = ""
|
# Subscription context information
|
||||||
[string] $ResourceId = ""
|
[string] $SubscriptionId = "" # Azure subscription ID
|
||||||
[string] $Location = ""
|
[string] $SubscriptionName = "" # Subscription display name
|
||||||
[string] $ResourceName = ""
|
|
||||||
[string] $Secret_Key = ""
|
# Resource location and identification
|
||||||
[string] $Tag_Team = ""
|
[string] $ResourceGroup = "" # Resource group containing the Key Vault
|
||||||
[string] $Tag_Product = ""
|
[string] $ResourceId = "" # Full Azure resource ID of the Key Vault
|
||||||
[string] $Tag_Environment = ""
|
[string] $Location = "" # Azure region where Key Vault is deployed
|
||||||
[string] $Tag_Data = ""
|
[string] $ResourceName = "" # Key Vault name
|
||||||
[string] $Tag_Deployment = ""
|
|
||||||
[string] $Tag_CreatedOnDate = ""
|
# Secret inventory information
|
||||||
|
[string] $Secret_Key = "" # Name of the secret (not the secret value)
|
||||||
|
|
||||||
|
# Resource governance tags for compliance and organization
|
||||||
|
[string] $Tag_Team = "" # Team responsible for the Key Vault
|
||||||
|
[string] $Tag_Product = "" # Product or service associated with the Key Vault
|
||||||
|
[string] $Tag_Environment = "" # Environment classification (dev/test/prod)
|
||||||
|
[string] $Tag_Data = "" # Data classification level
|
||||||
|
[string] $Tag_Deployment = "" # Deployment method or automation tag
|
||||||
|
[string] $Tag_CreatedOnDate = "" # Resource creation timestamp
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "======================================================================================================================================================================"
|
Write-Host "======================================================================================================================================================================"
|
||||||
Write-Host "Creating key vault secrets overview for key vaults with access policies."
|
Write-Host "🔐 Azure Key Vault Secret Inventory (Access Policy-Based Vaults Only)"
|
||||||
Write-Host "======================================================================================================================================================================"
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "⚠️ SECURITY WARNING: This script temporarily modifies Key Vault access policies during execution"
|
||||||
|
Write-Host "Script execution started: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Security Configuration:"
|
||||||
|
Write-Host " User Object ID for temporary access: $userObjectId"
|
||||||
|
Write-Host " Permissions granted: List secrets only (temporary)"
|
||||||
|
Write-Host " Scope: Access policy-based Key Vaults only (RBAC vaults excluded)"
|
||||||
|
Write-Host " Data collected: Secret names only (values are NOT retrieved)"
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Validate that we successfully retrieved a user Object ID
|
||||||
|
if ([string]::IsNullOrEmpty($userObjectId)) {
|
||||||
|
Write-Host "❌ CRITICAL ERROR: Could not retrieve current user's Object ID" -ForegroundColor Red
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "This could indicate:"
|
||||||
|
Write-Host " - You are not authenticated to Azure (run Connect-AzAccount)"
|
||||||
|
Write-Host " - You are authenticated with a Service Principal (user account required)"
|
||||||
|
Write-Host " - Your account is not found in Azure AD"
|
||||||
|
Write-Host " - Insufficient permissions to query Azure AD user information"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Please ensure you are authenticated with a valid user account and try again."
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
throw "User Object ID retrieval failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Generate timestamped filename for export
|
||||||
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
||||||
$fileName = ".\$date azure_key_vault_secrets.csv"
|
$fileName = ".\$date azure_key_vault_secrets.csv"
|
||||||
|
Write-Host "Export file: $fileName"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
$managementGroups = Get-AzManagementGroup
|
# Initialize processing counters and security tracking
|
||||||
|
$totalManagementGroups = 0
|
||||||
|
$totalSubscriptions = 0
|
||||||
|
$totalKeyVaults = 0
|
||||||
|
$totalSecrets = 0
|
||||||
|
$rbacOnlyVaults = 0
|
||||||
|
$processedResourceGroups = 0
|
||||||
|
$accessPolicyModifications = 0
|
||||||
|
$cleanupFailures = @()
|
||||||
|
|
||||||
foreach ($managementGroup in $managementGroups)
|
Write-Host "Discovering Management Group hierarchy..."
|
||||||
{
|
try {
|
||||||
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
$managementGroups = Get-AzManagementGroup -ErrorAction Stop
|
||||||
Write-Host "Management group [$($managementGroup.Name)]"
|
$totalManagementGroups = $managementGroups.Count
|
||||||
|
Write-Host "✓ Found $totalManagementGroups Management Group(s) to process:"
|
||||||
$subscriptions = Get-AzManagementGroupSubscription -Group $managementGroup.Name | Where-Object State -eq "Active"
|
foreach ($mg in $managementGroups) {
|
||||||
|
Write-Host " - $($mg.DisplayName) ($($mg.Name))"
|
||||||
foreach ($subscription in $subscriptions)
|
}
|
||||||
{
|
} catch {
|
||||||
|
Write-Error "Failed to retrieve Management Groups. Please ensure you have appropriate permissions."
|
||||||
|
Write-Error "Required permissions: Management Group Reader at tenant root or target management groups"
|
||||||
|
throw $_
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Process each Management Group in the hierarchy
|
||||||
|
foreach ($managementGroup in $managementGroups) {
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "Processing Management Group: $($managementGroup.DisplayName) ($($managementGroup.Name))"
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Get all active subscriptions within this management group
|
||||||
|
Write-Host "Discovering active subscriptions in management group..."
|
||||||
|
$subscriptions = Get-AzManagementGroupSubscription -Group $managementGroup.Name -ErrorAction Stop | Where-Object State -eq "Active"
|
||||||
|
|
||||||
|
if (-not $subscriptions -or $subscriptions.Count -eq 0) {
|
||||||
|
Write-Host "ℹ No active subscriptions found in management group '$($managementGroup.DisplayName)'"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "✓ Found $($subscriptions.Count) active subscription(s):"
|
||||||
|
foreach ($sub in $subscriptions) {
|
||||||
|
Write-Host " - $($sub.DisplayName)"
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Process each active subscription
|
||||||
|
foreach ($subscription in $subscriptions) {
|
||||||
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
||||||
|
# Extract subscription ID from the full resource path
|
||||||
$scope = $subscription.Id.Substring($subscription.Parent.Length, $subscription.Id.Length - $subscription.Parent.Length)
|
$scope = $subscription.Id.Substring($subscription.Parent.Length, $subscription.Id.Length - $subscription.Parent.Length)
|
||||||
$subscriptionId = $scope.Replace("/subscriptions/", "")
|
$subscriptionId = $scope.Replace("/subscriptions/", "")
|
||||||
Write-Host "Subscription [$($subscription.DisplayName) - $subscriptionId]"
|
|
||||||
Set-AzContext -SubscriptionId $subscriptionId | Out-Null
|
Write-Host "Processing Subscription: $($subscription.DisplayName) ($subscriptionId)"
|
||||||
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
||||||
|
|
||||||
$allResourceGroups = Get-AzResourceGroup
|
try {
|
||||||
|
# Set Azure context to the current subscription
|
||||||
|
Set-AzContext -SubscriptionId $subscriptionId -ErrorAction Stop | Out-Null
|
||||||
|
Write-Host "✓ Successfully connected to subscription context"
|
||||||
|
$totalSubscriptions++
|
||||||
|
|
||||||
|
|
||||||
|
# Get all resource groups in the current subscription
|
||||||
|
Write-Host "Discovering resource groups..."
|
||||||
|
$allResourceGroups = Get-AzResourceGroup -ErrorAction Stop
|
||||||
|
Write-Host "✓ Found $($allResourceGroups.Count) resource group(s) to scan"
|
||||||
|
|
||||||
|
# Initialize result collection for this subscription
|
||||||
[ResourceCheck[]]$Result = @()
|
[ResourceCheck[]]$Result = @()
|
||||||
|
$subscriptionKeyVaults = 0
|
||||||
|
$subscriptionSecrets = 0
|
||||||
|
$subscriptionRbacVaults = 0
|
||||||
|
$subscriptionAccessPolicyMods = 0
|
||||||
|
|
||||||
|
# Process each resource group to find Key Vaults
|
||||||
foreach ($group in $allResourceGroups) {
|
foreach ($group in $allResourceGroups) {
|
||||||
|
Write-Host " Scanning resource group: $($group.ResourceGroupName)"
|
||||||
|
$processedResourceGroups++
|
||||||
|
|
||||||
$allVaults = Get-AzKeyVault -ResourceGroupName $group.ResourceGroupName
|
try {
|
||||||
|
# Get all Key Vaults in the current resource group
|
||||||
|
$allVaults = Get-AzKeyVault -ResourceGroupName $group.ResourceGroupName -ErrorAction Stop
|
||||||
|
|
||||||
|
if (-not $allVaults -or $allVaults.Count -eq 0) {
|
||||||
|
Write-Host " ℹ No Key Vaults found in resource group"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host " ✓ Found $($allVaults.Count) Key Vault(s)"
|
||||||
|
|
||||||
|
# Process each Key Vault found
|
||||||
foreach ($vault in $allVaults) {
|
foreach ($vault in $allVaults) {
|
||||||
|
Write-Host " 🔐 Processing Key Vault: $($vault.VaultName)"
|
||||||
|
|
||||||
Write-Host $vault.VaultName
|
try {
|
||||||
|
# Get detailed Key Vault properties
|
||||||
|
$vaultWithAllProps = Get-AzKeyVault -ResourceGroupName $group.ResourceGroupName -Name $vault.VaultName -ErrorAction Stop
|
||||||
|
$totalKeyVaults++
|
||||||
|
$subscriptionKeyVaults++
|
||||||
|
|
||||||
$vaultWithAllProps = Get-AzKeyVault -ResourceGroupName $group.ResourceGroupName -Name $vault.VaultName
|
# Check if vault uses traditional access policies (not RBAC-only)
|
||||||
|
if ($vaultWithAllProps.EnableRbacAuthorization -ne $true) {
|
||||||
|
Write-Host " 📋 Access Policy-based vault - processing secrets..."
|
||||||
|
|
||||||
if ($vaultWithAllProps.EnableRbacAuthorization -ne "TRUE") {
|
# ⚠️ SECURITY CRITICAL: Temporarily grant List permissions to enumerate secrets
|
||||||
|
try {
|
||||||
|
Write-Host " 🔑 Granting temporary List permissions to user: $userObjectId"
|
||||||
|
Set-AzKeyVaultAccessPolicy -VaultName $vault.VaultName -ObjectId $userObjectId -PermissionsToSecrets "List" -ErrorAction Stop
|
||||||
|
$accessPolicyModifications++
|
||||||
|
$subscriptionAccessPolicyMods++
|
||||||
|
|
||||||
Write-Host " -- processing..."
|
# Enumerate all secrets in the vault
|
||||||
|
Write-Host " 📝 Enumerating secrets..."
|
||||||
|
$secrets = Get-AzKeyVaultSecret -VaultName $vault.VaultName -ErrorAction Stop
|
||||||
|
|
||||||
Set-AzKeyVaultAccessPolicy -VaultName $vault.VaultName -ObjectId $userObjectId -PermissionsToSecrets "List"
|
if (-not $secrets -or $secrets.Count -eq 0) {
|
||||||
|
Write-Host " ℹ No secrets found in this vault"
|
||||||
|
} else {
|
||||||
|
Write-Host " ✓ Found $($secrets.Count) secret(s)"
|
||||||
|
|
||||||
$secrets = Get-AzKeyVaultSecret -VaultName $vault.VaultName
|
# Process each secret found
|
||||||
|
foreach ($secret in $secrets) {
|
||||||
|
Write-Host " Secret: $($secret.Name)"
|
||||||
|
|
||||||
foreach($secret in $secrets)
|
# Create comprehensive resource check entry
|
||||||
{
|
|
||||||
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
||||||
|
|
||||||
|
# Populate management group and subscription information
|
||||||
$resourceCheck.ManagementGroupId = $managementGroup.Id
|
$resourceCheck.ManagementGroupId = $managementGroup.Id
|
||||||
$resourceCheck.ManagementGroupName = $managementGroup.DisplayName
|
$resourceCheck.ManagementGroupName = $managementGroup.DisplayName
|
||||||
$resourceCheck.SubscriptionId = $subscription.Id
|
$resourceCheck.SubscriptionId = $subscription.Id
|
||||||
$resourceCheck.SubscriptionName = $subscription.Name
|
$resourceCheck.SubscriptionName = $subscription.DisplayName
|
||||||
|
|
||||||
|
# Populate Key Vault resource information
|
||||||
$resourceCheck.ResourceGroup = $vaultWithAllProps.ResourceGroupName
|
$resourceCheck.ResourceGroup = $vaultWithAllProps.ResourceGroupName
|
||||||
$resourceCheck.ResourceId = $vaultWithAllProps.ResourceId
|
$resourceCheck.ResourceId = $vaultWithAllProps.ResourceId
|
||||||
$resourceCheck.Location = $vaultWithAllProps.Location
|
$resourceCheck.Location = $vaultWithAllProps.Location
|
||||||
$resourceCheck.ResourceName = $vaultWithAllProps.VaultName
|
$resourceCheck.ResourceName = $vaultWithAllProps.VaultName
|
||||||
|
|
||||||
|
# Populate secret information (name only, not value)
|
||||||
$resourceCheck.Secret_Key = $secret.Name
|
$resourceCheck.Secret_Key = $secret.Name
|
||||||
|
|
||||||
|
# Extract resource tags for governance analysis
|
||||||
$resourceCheck.Tag_Team = $vaultWithAllProps.Tags.team
|
$resourceCheck.Tag_Team = $vaultWithAllProps.Tags.team
|
||||||
$resourceCheck.Tag_Product = $vaultWithAllProps.Tags.product
|
$resourceCheck.Tag_Product = $vaultWithAllProps.Tags.product
|
||||||
$resourceCheck.Tag_Environment = $vaultWithAllProps.Tags.environment
|
$resourceCheck.Tag_Environment = $vaultWithAllProps.Tags.environment
|
||||||
@@ -85,17 +379,187 @@ foreach ($managementGroup in $managementGroups)
|
|||||||
$resourceCheck.Tag_CreatedOnDate = $vaultWithAllProps.Tags.CreatedOnDate
|
$resourceCheck.Tag_CreatedOnDate = $vaultWithAllProps.Tags.CreatedOnDate
|
||||||
$resourceCheck.Tag_Deployment = $vaultWithAllProps.Tags.drp_deployment
|
$resourceCheck.Tag_Deployment = $vaultWithAllProps.Tags.drp_deployment
|
||||||
|
|
||||||
|
# Add to results collection
|
||||||
$Result += $resourceCheck
|
$Result += $resourceCheck
|
||||||
}
|
$totalSecrets++
|
||||||
|
$subscriptionSecrets++
|
||||||
Remove-AzKeyVaultAccessPolicy -VaultName $vault.VaultName -ObjectId $userObjectId
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$Result | Export-Csv -Path $fileName -Append -NoTypeInformation
|
# ⚠️ SECURITY CRITICAL: Remove temporary permissions immediately
|
||||||
|
Write-Host " 🧹 Removing temporary permissions..."
|
||||||
|
try {
|
||||||
|
Remove-AzKeyVaultAccessPolicy -VaultName $vault.VaultName -ObjectId $userObjectId -ErrorAction Stop
|
||||||
|
Write-Host " ✓ Successfully removed temporary permissions"
|
||||||
|
} catch {
|
||||||
|
Write-Host " ❌ CLEANUP FAILURE: Could not remove temporary permissions!" -ForegroundColor Red
|
||||||
|
$cleanupFailures += @{
|
||||||
|
VaultName = $vault.VaultName
|
||||||
|
ResourceGroup = $group.ResourceGroupName
|
||||||
|
SubscriptionId = $subscriptionId
|
||||||
|
Error = $_.Exception.Message
|
||||||
|
}
|
||||||
|
Write-Host " ⚠️ Manual cleanup required: Remove-AzKeyVaultAccessPolicy -VaultName '$($vault.VaultName)' -ObjectId '$userObjectId'" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
Write-Host " ❌ Error accessing vault secrets: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
Write-Host " This may indicate insufficient permissions or vault access restrictions"
|
||||||
|
|
||||||
|
# Ensure cleanup attempt even on failure
|
||||||
|
try {
|
||||||
|
Remove-AzKeyVaultAccessPolicy -VaultName $vault.VaultName -ObjectId $userObjectId -ErrorAction SilentlyContinue
|
||||||
|
} catch {
|
||||||
|
# Silent cleanup attempt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
Write-Host " 🔐 RBAC-only vault (skipped): $($vault.VaultName)" -ForegroundColor Blue
|
||||||
|
$rbacOnlyVaults++
|
||||||
|
$subscriptionRbacVaults++
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
Write-Host " ❌ Error processing vault '$($vault.VaultName)': $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
Write-Host " ❌ Error scanning resource group '$($group.ResourceGroupName)': $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Subscription Summary:"
|
||||||
|
Write-Host " Key Vaults found: $subscriptionKeyVaults"
|
||||||
|
Write-Host " Secrets inventoried: $subscriptionSecrets"
|
||||||
|
Write-Host " Access policy modifications: $subscriptionAccessPolicyMods"
|
||||||
|
Write-Host " RBAC-only vaults (skipped): $subscriptionRbacVaults"
|
||||||
|
if ($cleanupFailures.Count -gt 0) {
|
||||||
|
Write-Host " ⚠️ Cleanup failures: $($cleanupFailures.Count)" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Export subscription results to CSV file
|
||||||
|
if ($Result.Count -gt 0) {
|
||||||
|
Write-Host "Exporting $($Result.Count) secret entries to CSV..."
|
||||||
|
try {
|
||||||
|
$Result | Export-Csv -Path $fileName -Append -NoTypeInformation -ErrorAction Stop
|
||||||
|
Write-Host "✓ Successfully exported subscription data"
|
||||||
|
} catch {
|
||||||
|
Write-Error "Failed to export data for subscription '$($subscription.DisplayName)': $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Host "ℹ No secret data to export from this subscription"
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
Write-Host "❌ Error processing subscription '$($subscription.DisplayName)': $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
Write-Host " Please verify subscription access and permissions" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
Write-Host "❌ Error processing management group '$($managementGroup.DisplayName)': $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
Write-Host " Please verify management group access and permissions" -ForegroundColor Red
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Write-Host "======================================================================================================================================================================"
|
|
||||||
Write-Host "Done."
|
Write-Host ""
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "🔐 Key Vault Secret Inventory Completed"
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "Execution completed: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Display comprehensive execution statistics
|
||||||
|
Write-Host "Processing Statistics:"
|
||||||
|
Write-Host " Management Groups processed: $totalManagementGroups"
|
||||||
|
Write-Host " Subscriptions processed: $totalSubscriptions"
|
||||||
|
Write-Host " Resource Groups scanned: $processedResourceGroups"
|
||||||
|
Write-Host " Total Key Vaults found: $totalKeyVaults"
|
||||||
|
Write-Host " Secrets inventoried: $totalSecrets"
|
||||||
|
Write-Host " Access policy modifications: $accessPolicyModifications"
|
||||||
|
Write-Host " RBAC-only vaults (skipped): $rbacOnlyVaults"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Critical security status check
|
||||||
|
if ($cleanupFailures.Count -gt 0) {
|
||||||
|
Write-Host "🚨 CRITICAL SECURITY ALERT: MANUAL CLEANUP REQUIRED" -ForegroundColor Red
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "The following Key Vaults still have temporary permissions that need manual removal:" -ForegroundColor Red
|
||||||
|
Write-Host ""
|
||||||
|
foreach ($failure in $cleanupFailures) {
|
||||||
|
Write-Host " Vault: $($failure.VaultName)" -ForegroundColor Red
|
||||||
|
Write-Host " Resource Group: $($failure.ResourceGroup)" -ForegroundColor Red
|
||||||
|
Write-Host " Subscription: $($failure.SubscriptionId)" -ForegroundColor Red
|
||||||
|
Write-Host " Cleanup Command: Remove-AzKeyVaultAccessPolicy -VaultName '$($failure.VaultName)' -ObjectId '$userObjectId'" -ForegroundColor Yellow
|
||||||
|
Write-Host " Error: $($failure.Error)" -ForegroundColor Red
|
||||||
|
Write-Host ""
|
||||||
|
}
|
||||||
|
Write-Host "⚠️ Please run the cleanup commands above to remove temporary permissions immediately!" -ForegroundColor Yellow
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
} else {
|
||||||
|
Write-Host "✅ Security Status: All temporary access policies were successfully removed"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Analyze and display key findings
|
||||||
|
if ($totalSecrets -gt 0) {
|
||||||
|
Write-Host "✓ Successfully exported Key Vault secret inventory to: $fileName"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
Write-Host "Security Analysis Insights:"
|
||||||
|
if ($rbacOnlyVaults -gt 0) {
|
||||||
|
Write-Host " ✓ $rbacOnlyVaults Key Vault(s) using modern RBAC authentication"
|
||||||
|
}
|
||||||
|
|
||||||
|
$legacyVaults = $totalKeyVaults - $rbacOnlyVaults
|
||||||
|
if ($legacyVaults -gt 0) {
|
||||||
|
Write-Host " ⚠ $legacyVaults Key Vault(s) still using legacy access policies" -ForegroundColor Yellow
|
||||||
|
Write-Host " Consider migrating to Azure RBAC for improved security and management"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Output File Information:"
|
||||||
|
if (Test-Path $fileName) {
|
||||||
|
Write-Host " File Path: $fileName"
|
||||||
|
Write-Host " File Size: $([Math]::Round((Get-Item $fileName).Length / 1KB, 2)) KB"
|
||||||
|
Write-Host " Records Exported: $totalSecrets"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Host "ℹ No Key Vault secrets found across all processed subscriptions"
|
||||||
|
Write-Host " This could indicate:"
|
||||||
|
Write-Host " - All Key Vaults are using RBAC-only authentication"
|
||||||
|
Write-Host " - No secrets exist in the scanned Key Vaults"
|
||||||
|
Write-Host " - Permission issues preventing access to Key Vault contents"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Security Recommendations:"
|
||||||
|
Write-Host " - Review exported secret inventory for unused or expired secrets"
|
||||||
|
Write-Host " - Implement secret rotation policies for all identified secrets"
|
||||||
|
Write-Host " - Consider implementing Azure RBAC for new Key Vaults"
|
||||||
|
Write-Host " - Plan migration from access policies to RBAC for existing vaults"
|
||||||
|
Write-Host " - Implement regular secret lifecycle management and cleanup"
|
||||||
|
Write-Host " - Ensure proper governance tags are applied to all Key Vaults"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Compliance and Security Notes:"
|
||||||
|
Write-Host " - This inventory contains sensitive secret name information"
|
||||||
|
Write-Host " - Handle output file with appropriate data classification controls"
|
||||||
|
Write-Host " - All access policy modifications are logged in Azure Activity Log"
|
||||||
|
Write-Host " - Consider encryption for long-term storage of inventory results"
|
||||||
|
Write-Host " - Schedule regular execution for continuous secret governance"
|
||||||
|
Write-Host " - Ensure manual cleanup is performed if cleanup failures occurred"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Audit Trail:"
|
||||||
|
Write-Host " - All temporary access policy changes are logged in Azure Activity Log"
|
||||||
|
Write-Host " - Search for 'Microsoft.KeyVault/vaults/accessPolicies/write' operations"
|
||||||
|
Write-Host " - Filter by Object ID: $userObjectId"
|
||||||
|
Write-Host " - Execution timeframe: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,74 @@
|
|||||||
#Connect-AzAccount
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Generates a comprehensive inventory of Azure Key Vaults across all management groups and subscriptions.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This script enumerates all Azure Key Vaults within enabled subscriptions across the entire Azure tenant,
|
||||||
|
collecting detailed configuration properties, security settings, and governance tags. The results are
|
||||||
|
exported to a timestamped CSV file for analysis, compliance reporting, and security auditing.
|
||||||
|
|
||||||
|
Key capabilities:
|
||||||
|
- Multi-tenant Key Vault discovery across all management groups
|
||||||
|
- Configuration analysis including RBAC, purge protection, and soft delete settings
|
||||||
|
- Governance tag extraction for team ownership and compliance tracking
|
||||||
|
- Public network access configuration reporting
|
||||||
|
- Timestamped CSV export for audit trails and trend analysis
|
||||||
|
|
||||||
|
.PARAMETER None
|
||||||
|
This script does not accept parameters and will process all accessible Key Vaults.
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
CSV File: "<date> azure_key_vaults.csv"
|
||||||
|
Contains columns for:
|
||||||
|
- Resource identification (ID, name, resource group, location)
|
||||||
|
- Management hierarchy (management group, subscription)
|
||||||
|
- Governance tags (team, product, environment, data classification)
|
||||||
|
- Security configuration (RBAC, purge protection, soft delete, network access)
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\KeyVaults.ps1
|
||||||
|
|
||||||
|
Discovers all Key Vaults and generates: "2024-10-30 1435 azure_key_vaults.csv"
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
File Name : KeyVaults.ps1
|
||||||
|
Author : Cloud Engineering Team
|
||||||
|
Prerequisite : Azure PowerShell module (Az.KeyVault, Az.Resources, Az.Accounts)
|
||||||
|
Copyright : (c) 2024 Effectory. All rights reserved.
|
||||||
|
|
||||||
|
Version History:
|
||||||
|
1.0 - Initial release with comprehensive Key Vault inventory functionality
|
||||||
|
|
||||||
|
.LINK
|
||||||
|
https://docs.microsoft.com/en-us/azure/key-vault/
|
||||||
|
https://docs.microsoft.com/en-us/powershell/module/az.keyvault/
|
||||||
|
|
||||||
|
.COMPONENT
|
||||||
|
Requires Azure PowerShell modules:
|
||||||
|
- Az.KeyVault (for Key Vault enumeration and property retrieval)
|
||||||
|
- Az.Resources (for resource group and management group access)
|
||||||
|
- Az.Accounts (for authentication and subscription management)
|
||||||
|
|
||||||
|
.ROLE
|
||||||
|
Required Azure permissions:
|
||||||
|
- Key Vault Reader or higher on all subscriptions
|
||||||
|
- Management Group Reader for organizational hierarchy access
|
||||||
|
|
||||||
|
.FUNCTIONALITY
|
||||||
|
- Multi-subscription Key Vault discovery
|
||||||
|
- Security configuration analysis and reporting
|
||||||
|
- Governance tag extraction and compliance tracking
|
||||||
|
- CSV export with comprehensive audit trail
|
||||||
|
#>
|
||||||
|
|
||||||
|
#Requires -Modules Az.KeyVault, Az.Resources, Az.Accounts
|
||||||
|
#Requires -Version 5.1
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param()
|
||||||
|
|
||||||
|
# Uncomment the following line if authentication is required
|
||||||
|
#Connect-AzAccount
|
||||||
|
|
||||||
class ResourceCheck {
|
class ResourceCheck {
|
||||||
[string] $ResourceId = ""
|
[string] $ResourceId = ""
|
||||||
@@ -22,49 +92,110 @@ class ResourceCheck {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Initialize script execution
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$ProgressPreference = "SilentlyContinue"
|
||||||
|
$startTime = Get-Date
|
||||||
|
|
||||||
Write-Host "======================================================================================================================================================================"
|
Write-Host "======================================================================================================================================================================"
|
||||||
Write-Host "Creating key vault resource overview."
|
Write-Host "🔐 AZURE KEY VAULT INVENTORY GENERATOR" -ForegroundColor Cyan
|
||||||
Write-Host "======================================================================================================================================================================"
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "⏰ Started: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
$subscriptions = Get-AzSubscription | Where-Object State -eq "Enabled"
|
try {
|
||||||
|
# Validate Azure authentication
|
||||||
|
$context = Get-AzContext
|
||||||
|
if (-not $context) {
|
||||||
|
throw "No Azure context found. Please run Connect-AzAccount first."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "🔐 Authenticated as: $($context.Account.Id)" -ForegroundColor Green
|
||||||
|
Write-Host "🏢 Tenant: $($context.Tenant.Id)" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
# Initialize output file
|
||||||
$fileName = ".\$date azure_key_vaults.csv"
|
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
||||||
|
$fileName = ".\$date azure_key_vaults.csv"
|
||||||
|
Write-Host "📄 Output file: $fileName" -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
$managementGroups = Get-AzManagementGroup
|
# Get management groups for organizational structure
|
||||||
|
Write-Host "🏗️ Discovering management group structure..." -ForegroundColor Cyan
|
||||||
|
$managementGroups = Get-AzManagementGroup
|
||||||
|
Write-Host "✅ Found $($managementGroups.Count) management groups" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
foreach ($managementGroup in $managementGroups)
|
# Initialize counters for progress tracking
|
||||||
{
|
$totalKeyVaults = 0
|
||||||
|
$processedManagementGroups = 0
|
||||||
|
$processedSubscriptions = 0
|
||||||
|
$securityIssues = @()
|
||||||
|
|
||||||
|
# Process each management group
|
||||||
|
foreach ($managementGroup in $managementGroups) {
|
||||||
|
$processedManagementGroups++
|
||||||
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
||||||
Write-Host "Management group [$($managementGroup.Name)]"
|
Write-Host "🏗️ Management Group [$($managementGroup.DisplayName)]" -ForegroundColor Cyan
|
||||||
|
Write-Host " ID: $($managementGroup.Id)" -ForegroundColor DarkGray
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Get active subscriptions in this management group
|
||||||
$subscriptions = Get-AzManagementGroupSubscription -Group $managementGroup.Name | Where-Object State -eq "Active"
|
$subscriptions = Get-AzManagementGroupSubscription -Group $managementGroup.Name | Where-Object State -eq "Active"
|
||||||
|
Write-Host " 📋 Found $($subscriptions.Count) active subscriptions" -ForegroundColor Green
|
||||||
|
|
||||||
foreach ($subscription in $subscriptions)
|
foreach ($subscription in $subscriptions) {
|
||||||
{
|
$processedSubscriptions++
|
||||||
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
Write-Host ""
|
||||||
|
Write-Host " 🔄 Processing Subscription: $($subscription.DisplayName)" -ForegroundColor Yellow
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Extract subscription ID and set context
|
||||||
$scope = $subscription.Id.Substring($subscription.Parent.Length, $subscription.Id.Length - $subscription.Parent.Length)
|
$scope = $subscription.Id.Substring($subscription.Parent.Length, $subscription.Id.Length - $subscription.Parent.Length)
|
||||||
$subscriptionId = $scope.Replace("/subscriptions/", "")
|
$subscriptionId = $scope.Replace("/subscriptions/", "")
|
||||||
Write-Host "Subscription [$($subscription.DisplayName) - $subscriptionId]"
|
Write-Host " ID: $subscriptionId" -ForegroundColor DarkGray
|
||||||
Set-AzContext -SubscriptionId $subscriptionId | Out-Null
|
|
||||||
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
|
||||||
|
|
||||||
|
Set-AzContext -SubscriptionId $subscriptionId | Out-Null
|
||||||
|
|
||||||
|
# Get all resource groups in the subscription
|
||||||
$allResourceGroups = Get-AzResourceGroup
|
$allResourceGroups = Get-AzResourceGroup
|
||||||
[ResourceCheck[]]$Result = @()
|
[ResourceCheck[]]$Result = @()
|
||||||
|
$subscriptionKeyVaults = 0
|
||||||
|
|
||||||
foreach ($group in $allResourceGroups) {
|
foreach ($group in $allResourceGroups) {
|
||||||
|
Write-Host " 📁 Resource Group: $($group.ResourceGroupName)" -ForegroundColor DarkCyan -NoNewline
|
||||||
|
|
||||||
Write-Host $group.ResourceGroupName
|
try {
|
||||||
|
# Get Key Vaults in this resource group
|
||||||
|
$allVaults = Get-AzKeyVault -ResourceGroupName $group.ResourceGroupName -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
$allVaults = Get-AzKeyVault -ResourceGroupName $group.ResourceGroupName
|
if ($allVaults.Count -gt 0) {
|
||||||
|
Write-Host " - Found $($allVaults.Count) Key Vaults" -ForegroundColor Green
|
||||||
|
$subscriptionKeyVaults += $allVaults.Count
|
||||||
|
} else {
|
||||||
|
Write-Host " - No Key Vaults" -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($vault in $allVaults) {
|
foreach ($vault in $allVaults) {
|
||||||
|
try {
|
||||||
|
# Get detailed Key Vault properties
|
||||||
$vaultWithAllProps = Get-AzKeyVault -ResourceGroupName $group.ResourceGroupName -Name $vault.VaultName
|
$vaultWithAllProps = Get-AzKeyVault -ResourceGroupName $group.ResourceGroupName -Name $vault.VaultName
|
||||||
|
|
||||||
|
# Analyze security configuration
|
||||||
$enabledRBAC = $vaultWithAllProps.EnableRbacAuthorization -eq "TRUE"
|
$enabledRBAC = $vaultWithAllProps.EnableRbacAuthorization -eq "TRUE"
|
||||||
|
|
||||||
|
# Check for security concerns
|
||||||
|
if (-not $vaultWithAllProps.EnablePurgeProtection) {
|
||||||
|
$securityIssues += "⚠️ Purge protection disabled: $($vaultWithAllProps.VaultName) in $($group.ResourceGroupName)"
|
||||||
|
}
|
||||||
|
if (-not $vaultWithAllProps.EnableSoftDelete) {
|
||||||
|
$securityIssues += "⚠️ Soft delete disabled: $($vaultWithAllProps.VaultName) in $($group.ResourceGroupName)"
|
||||||
|
}
|
||||||
|
if ($vaultWithAllProps.PublicNetworkAccess -eq "Enabled") {
|
||||||
|
$securityIssues += "🌐 Public network access enabled: $($vaultWithAllProps.VaultName) in $($group.ResourceGroupName)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create resource check object
|
||||||
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
||||||
$resourceCheck.ManagementGroupId = $managementGroup.Id
|
$resourceCheck.ManagementGroupId = $managementGroup.Id
|
||||||
$resourceCheck.ManagementGroupName = $managementGroup.DisplayName
|
$resourceCheck.ManagementGroupName = $managementGroup.DisplayName
|
||||||
@@ -86,13 +217,109 @@ foreach ($managementGroup in $managementGroups)
|
|||||||
$resourceCheck.Prop_PublicNetworkAccess = $vaultWithAllProps.PublicNetworkAccess
|
$resourceCheck.Prop_PublicNetworkAccess = $vaultWithAllProps.PublicNetworkAccess
|
||||||
|
|
||||||
$Result += $resourceCheck
|
$Result += $resourceCheck
|
||||||
|
$totalKeyVaults++
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
Write-Host " ❌ Error processing vault $($vault.VaultName): $($_.Exception.Message)" -ForegroundColor Red
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host " - ❌ Error accessing resource group: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Export results for this subscription
|
||||||
|
if ($Result.Count -gt 0) {
|
||||||
$Result | Export-Csv -Path $fileName -Append -NoTypeInformation
|
$Result | Export-Csv -Path $fileName -Append -NoTypeInformation
|
||||||
|
Write-Host " ✅ Exported $($Result.Count) Key Vaults from subscription" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host " ℹ️ No Key Vaults found in subscription" -ForegroundColor DarkYellow
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Write-Host "======================================================================================================================================================================"
|
} catch {
|
||||||
Write-Host "Done."
|
Write-Host " ❌ Error processing subscription: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host " ❌ Error accessing management group: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
}
|
||||||
|
# Calculate execution time and generate summary report
|
||||||
|
$endTime = Get-Date
|
||||||
|
$executionTime = $endTime - $startTime
|
||||||
|
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "📊 AZURE KEY VAULT INVENTORY SUMMARY" -ForegroundColor Cyan
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "⏰ Execution Time: $($executionTime.ToString('hh\:mm\:ss'))" -ForegroundColor Green
|
||||||
|
Write-Host "🏗️ Management Groups Processed: $processedManagementGroups" -ForegroundColor Green
|
||||||
|
Write-Host "📋 Subscriptions Processed: $processedSubscriptions" -ForegroundColor Green
|
||||||
|
Write-Host "🔐 Total Key Vaults Discovered: $totalKeyVaults" -ForegroundColor Green
|
||||||
|
Write-Host "📄 Results Exported To: $fileName" -ForegroundColor Yellow
|
||||||
|
|
||||||
|
if (Test-Path $fileName) {
|
||||||
|
$fileSize = (Get-Item $fileName).Length
|
||||||
|
Write-Host "💾 File Size: $([math]::Round($fileSize/1KB, 2)) KB" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
# Display security analysis summary
|
||||||
|
if ($securityIssues.Count -gt 0) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "🚨 SECURITY ANALYSIS SUMMARY" -ForegroundColor Red
|
||||||
|
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
||||||
|
Write-Host "Found $($securityIssues.Count) potential security concerns:" -ForegroundColor Yellow
|
||||||
|
foreach ($issue in $securityIssues | Select-Object -First 10) {
|
||||||
|
Write-Host " $issue" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
if ($securityIssues.Count -gt 10) {
|
||||||
|
Write-Host " ... and $($securityIssues.Count - 10) more issues (see CSV for complete details)" -ForegroundColor DarkYellow
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "📋 Recommendations:" -ForegroundColor Cyan
|
||||||
|
Write-Host " • Enable purge protection on production Key Vaults" -ForegroundColor White
|
||||||
|
Write-Host " • Ensure soft delete is enabled for data recovery capabilities" -ForegroundColor White
|
||||||
|
Write-Host " • Consider disabling public network access where possible" -ForegroundColor White
|
||||||
|
Write-Host " • Implement RBAC authorization for enhanced security" -ForegroundColor White
|
||||||
|
} else {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "✅ SECURITY ANALYSIS: No major security concerns detected" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "📈 NEXT STEPS:" -ForegroundColor Cyan
|
||||||
|
Write-Host " 1. Review the generated CSV file for detailed Key Vault configurations" -ForegroundColor White
|
||||||
|
Write-Host " 2. Analyze governance tags for compliance with organizational standards" -ForegroundColor White
|
||||||
|
Write-Host " 3. Address any security recommendations identified above" -ForegroundColor White
|
||||||
|
Write-Host " 4. Consider implementing automated monitoring for Key Vault configurations" -ForegroundColor White
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "✅ Azure Key Vault inventory completed successfully!" -ForegroundColor Green
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "❌ CRITICAL ERROR OCCURRED" -ForegroundColor Red
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "Error: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
Write-Host "Line: $($_.InvocationInfo.ScriptLineNumber)" -ForegroundColor Red
|
||||||
|
Write-Host "Position: $($_.InvocationInfo.OffsetInLine)" -ForegroundColor Red
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "🔧 TROUBLESHOOTING STEPS:" -ForegroundColor Yellow
|
||||||
|
Write-Host " 1. Verify you are authenticated to Azure (Connect-AzAccount)" -ForegroundColor White
|
||||||
|
Write-Host " 2. Ensure you have Key Vault Reader permissions on target subscriptions" -ForegroundColor White
|
||||||
|
Write-Host " 3. Check that the Management Group Reader role is assigned" -ForegroundColor White
|
||||||
|
Write-Host " 4. Verify Azure PowerShell modules are installed and up to date" -ForegroundColor White
|
||||||
|
Write-Host " 5. Try running the script with -Verbose for additional diagnostic information" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "📞 For additional support, contact the Cloud Engineering team" -ForegroundColor Cyan
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
|
||||||
|
# Ensure we exit with error code for automation scenarios
|
||||||
|
exit 1
|
||||||
|
} finally {
|
||||||
|
# Reset progress preference
|
||||||
|
$ProgressPreference = "Continue"
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,98 @@
|
|||||||
#Connect-AzAccount
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Generates a hierarchical inventory of Azure Management Groups and Subscriptions.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This script creates a comprehensive mapping of Azure organizational structure by traversing
|
||||||
|
the management group hierarchy starting from a specified root management group. It discovers
|
||||||
|
and documents all subscriptions within a 3-level management group structure (Level 0-2).
|
||||||
|
|
||||||
|
The script provides detailed organizational visibility including:
|
||||||
|
- Hierarchical management group structure mapping
|
||||||
|
- Subscription placement and organizational context
|
||||||
|
- Subscription state tracking (Active, Disabled, etc.)
|
||||||
|
- Multi-level governance structure documentation
|
||||||
|
- CSV export for organizational analysis and compliance reporting
|
||||||
|
|
||||||
|
Note: The script is optimized for a maximum 3-level management group depth and starts
|
||||||
|
from a configurable root management group ID.
|
||||||
|
|
||||||
|
.PARAMETER RootManagementGroupId
|
||||||
|
The GUID of the root management group to start the hierarchy discovery from.
|
||||||
|
Defaults to the Effectory organization root management group.
|
||||||
|
|
||||||
|
Example: "12345678-1234-1234-1234-123456789012"
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
CSV File: "<date> azure_managementgroups.csv"
|
||||||
|
Contains columns for:
|
||||||
|
- Subscription identification (ID, name, state)
|
||||||
|
- Level 0 Management Group (root level)
|
||||||
|
- Level 1 Management Group (department/division level)
|
||||||
|
- Level 2 Management Group (team/project level)
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\ManagementGroups.ps1
|
||||||
|
|
||||||
|
Uses the default Effectory root management group and generates:
|
||||||
|
"2024-10-30 1435 azure_managementgroups.csv"
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\ManagementGroups.ps1 -RootManagementGroupId "87654321-4321-4321-4321-210987654321"
|
||||||
|
|
||||||
|
Discovers management group hierarchy starting from a custom root management group.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\ManagementGroups.ps1 -RootManagementGroupId "tenant-root-mg" -Verbose
|
||||||
|
|
||||||
|
Runs with verbose output for detailed discovery logging.
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
File Name : ManagementGroups.ps1
|
||||||
|
Author : Cloud Engineering Team
|
||||||
|
Prerequisite : Azure PowerShell module (Az.Resources, Az.Accounts)
|
||||||
|
Copyright : (c) 2024 Effectory. All rights reserved.
|
||||||
|
|
||||||
|
Version History:
|
||||||
|
1.0 - Initial release with 3-level management group hierarchy discovery
|
||||||
|
1.1 - Added parameterized root management group for flexibility
|
||||||
|
|
||||||
|
.LINK
|
||||||
|
https://docs.microsoft.com/en-us/azure/governance/management-groups/
|
||||||
|
https://docs.microsoft.com/en-us/powershell/module/az.resources/
|
||||||
|
|
||||||
|
.COMPONENT
|
||||||
|
Requires Azure PowerShell modules:
|
||||||
|
- Az.Resources (for management group and subscription enumeration)
|
||||||
|
- Az.Accounts (for authentication and context management)
|
||||||
|
|
||||||
|
.ROLE
|
||||||
|
Required Azure permissions:
|
||||||
|
- Management Group Reader on the root management group and all child groups
|
||||||
|
- Reader access to view subscription details within management groups
|
||||||
|
|
||||||
|
.FUNCTIONALITY
|
||||||
|
- Hierarchical management group discovery (3-level maximum)
|
||||||
|
- Subscription placement mapping and state tracking
|
||||||
|
- Organizational structure documentation and CSV export
|
||||||
|
- Cross-tenant compatible with configurable root management group
|
||||||
|
#>
|
||||||
|
|
||||||
|
#Requires -Modules Az.Resources, Az.Accounts
|
||||||
|
#Requires -Version 5.1
|
||||||
|
|
||||||
|
# Uncomment the following line if authentication is required
|
||||||
|
#Connect-AzAccount
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(
|
||||||
|
Mandatory = $false,
|
||||||
|
HelpMessage = "The GUID of the root management group to start hierarchy discovery from"
|
||||||
|
)]
|
||||||
|
[ValidateNotNullOrEmpty()]
|
||||||
|
[string]$RootManagementGroupId = 'e9792fd7-4044-47e7-a40d-3fba46f1cd09'
|
||||||
|
)
|
||||||
|
|
||||||
class ResourceCheck {
|
class ResourceCheck {
|
||||||
[string] $SubscriptionId = ""
|
[string] $SubscriptionId = ""
|
||||||
@@ -12,29 +106,74 @@ class ResourceCheck {
|
|||||||
[string] $Level2_ManagementGroupName = ""
|
[string] $Level2_ManagementGroupName = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "======================================================================================================================"
|
# Initialize script execution
|
||||||
Write-Host "Creating list of Effectory Management Groups and subscriptions."
|
$ErrorActionPreference = "Stop"
|
||||||
Write-Host "- Note: not very dynamic; Starts at hard coded root group and works up max 2 levels."
|
$ProgressPreference = "SilentlyContinue"
|
||||||
Write-Host "======================================================================================================================"
|
$startTime = Get-Date
|
||||||
|
|
||||||
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
Write-Host "======================================================================================================================================================================"
|
||||||
$fileName = ".\$date azure_managementgroups.csv"
|
Write-Host "🏗️ AZURE MANAGEMENT GROUP STRUCTURE DISCOVERY" -ForegroundColor Cyan
|
||||||
[ResourceCheck[]]$Result = @()
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "⏰ Started: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
$rootManagementGroup = (Get-AzManagementGroup -GroupId 'e9792fd7-4044-47e7-a40d-3fba46f1cd09' -Expand)[0]
|
try {
|
||||||
|
# Validate Azure authentication
|
||||||
|
$context = Get-AzContext
|
||||||
|
if (-not $context) {
|
||||||
|
throw "No Azure context found. Please run Connect-AzAccount first."
|
||||||
|
}
|
||||||
|
|
||||||
#level 0
|
Write-Host "🔐 Authenticated as: $($context.Account.Id)" -ForegroundColor Green
|
||||||
Write-Host "---------------------------------------------------------------------------------------------"
|
Write-Host "🏢 Tenant: $($context.Tenant.Id)" -ForegroundColor Green
|
||||||
Write-Host "Level 0 Management group [$($rootManagementGroup.Name)]"
|
Write-Host "🎯 Root Management Group: $RootManagementGroupId" -ForegroundColor Yellow
|
||||||
Write-Host "---------------------------------------------------------------------------------------------"
|
Write-Host ""
|
||||||
|
|
||||||
$subscriptions = $rootManagementGroup.Children | Where-Object Type -EQ '/subscriptions'
|
# Initialize output file and tracking variables
|
||||||
|
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
||||||
|
$fileName = ".\$date azure_managementgroups.csv"
|
||||||
|
Write-Host "📄 Output file: $fileName" -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
foreach ($subscription in $subscriptions)
|
[ResourceCheck[]]$Result = @()
|
||||||
{
|
$totalSubscriptions = 0
|
||||||
|
$managementGroupCount = 0
|
||||||
|
|
||||||
|
# Get root management group with error handling
|
||||||
|
Write-Host "🔍 Discovering root management group structure..." -ForegroundColor Cyan
|
||||||
|
$rootManagementGroup = Get-AzManagementGroup -GroupId $RootManagementGroupId -Expand -ErrorAction Stop
|
||||||
|
|
||||||
|
if (-not $rootManagementGroup) {
|
||||||
|
throw "Root management group '$RootManagementGroupId' not found or not accessible."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "✅ Root Management Group: $($rootManagementGroup.DisplayName)" -ForegroundColor Green
|
||||||
|
Write-Host " ID: $($rootManagementGroup.Id)" -ForegroundColor DarkGray
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Process Level 0 (Root) subscriptions
|
||||||
|
$managementGroupCount++
|
||||||
|
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
||||||
|
Write-Host "📋 LEVEL 0 (Root): $($rootManagementGroup.DisplayName)" -ForegroundColor Cyan
|
||||||
|
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
||||||
|
|
||||||
|
$subscriptions = $rootManagementGroup.Children | Where-Object Type -EQ '/subscriptions'
|
||||||
|
Write-Host " 📊 Direct subscriptions: $($subscriptions.Count)" -ForegroundColor Green
|
||||||
|
|
||||||
|
foreach ($subscription in $subscriptions) {
|
||||||
|
try {
|
||||||
$scope = $subscription.Id.Substring($subscription.Parent.Length, $subscription.Id.Length - $subscription.Parent.Length)
|
$scope = $subscription.Id.Substring($subscription.Parent.Length, $subscription.Id.Length - $subscription.Parent.Length)
|
||||||
$subscriptionId = $scope.Replace("/subscriptions/", "")
|
$subscriptionId = $scope.Replace("/subscriptions/", "")
|
||||||
Write-Host "Subscription [$($subscription.DisplayName) - $subscriptionId]"
|
|
||||||
|
# Color code subscription state
|
||||||
|
$stateColor = switch ($subscription.State) {
|
||||||
|
"Enabled" { "Green" }
|
||||||
|
"Disabled" { "Red" }
|
||||||
|
"Warned" { "Yellow" }
|
||||||
|
default { "White" }
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host " 📋 Subscription: $($subscription.DisplayName) [$subscriptionId] - $($subscription.State)" -ForegroundColor $stateColor
|
||||||
|
|
||||||
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
||||||
$resourceCheck.Level0_ManagementGroupId = $rootManagementGroup.Id
|
$resourceCheck.Level0_ManagementGroupId = $rootManagementGroup.Id
|
||||||
@@ -43,24 +182,45 @@ foreach ($subscription in $subscriptions)
|
|||||||
$resourceCheck.SubscriptionName = $subscription.DisplayName
|
$resourceCheck.SubscriptionName = $subscription.DisplayName
|
||||||
$resourceCheck.SubscriptionState = $subscription.State
|
$resourceCheck.SubscriptionState = $subscription.State
|
||||||
$Result += $resourceCheck
|
$Result += $resourceCheck
|
||||||
}
|
$totalSubscriptions++
|
||||||
|
|
||||||
#level 1
|
} catch {
|
||||||
foreach ($level1ManagementGroupLister in ($rootManagementGroup.Children | Where-Object Type -EQ 'Microsoft.Management/managementGroups'))
|
Write-Host " ❌ Error processing subscription: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
{
|
}
|
||||||
$level1ManagementGroup = (Get-AzManagementGroup -Group $level1ManagementGroupLister.Name -Expand)[0]
|
}
|
||||||
|
|
||||||
Write-Host " ---------------------------------------------------------------------------------------------"
|
# Process Level 1 management groups
|
||||||
Write-Host " Level 1 Management group [$($level1ManagementGroup.Name)]"
|
$level1Groups = $rootManagementGroup.Children | Where-Object Type -EQ 'Microsoft.Management/managementGroups'
|
||||||
Write-Host " ---------------------------------------------------------------------------------------------"
|
Write-Host ""
|
||||||
|
Write-Host "🔍 Found $($level1Groups.Count) Level 1 management groups" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
foreach ($level1ManagementGroupLister in $level1Groups) {
|
||||||
|
try {
|
||||||
|
$managementGroupCount++
|
||||||
|
$level1ManagementGroup = Get-AzManagementGroup -Group $level1ManagementGroupLister.Name -Expand -ErrorAction Stop
|
||||||
|
|
||||||
|
Write-Host " ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||||
|
Write-Host " 📂 LEVEL 1: $($level1ManagementGroup.DisplayName)" -ForegroundColor Yellow
|
||||||
|
Write-Host " ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||||
|
|
||||||
$subscriptions = $level1ManagementGroup.Children | Where-Object Type -EQ '/subscriptions'
|
$subscriptions = $level1ManagementGroup.Children | Where-Object Type -EQ '/subscriptions'
|
||||||
|
Write-Host " 📊 Direct subscriptions: $($subscriptions.Count)" -ForegroundColor Green
|
||||||
|
|
||||||
foreach ($subscription in $subscriptions)
|
foreach ($subscription in $subscriptions) {
|
||||||
{
|
try {
|
||||||
$scope = $subscription.Id.Substring($subscription.Parent.Length, $subscription.Id.Length - $subscription.Parent.Length)
|
$scope = $subscription.Id.Substring($subscription.Parent.Length, $subscription.Id.Length - $subscription.Parent.Length)
|
||||||
$subscriptionId = $scope.Replace("/subscriptions/", "")
|
$subscriptionId = $scope.Replace("/subscriptions/", "")
|
||||||
Write-Host " Subscription [$($subscription.DisplayName) - $subscriptionId]"
|
|
||||||
|
# Color code subscription state
|
||||||
|
$stateColor = switch ($subscription.State) {
|
||||||
|
"Enabled" { "Green" }
|
||||||
|
"Disabled" { "Red" }
|
||||||
|
"Warned" { "Yellow" }
|
||||||
|
default { "White" }
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host " 📋 Subscription: $($subscription.DisplayName) [$subscriptionId] - $($subscription.State)" -ForegroundColor $stateColor
|
||||||
|
|
||||||
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
||||||
$resourceCheck.Level0_ManagementGroupId = $rootManagementGroup.Id
|
$resourceCheck.Level0_ManagementGroupId = $rootManagementGroup.Id
|
||||||
@@ -71,24 +231,50 @@ foreach ($level1ManagementGroupLister in ($rootManagementGroup.Children | Where-
|
|||||||
$resourceCheck.SubscriptionName = $subscription.DisplayName
|
$resourceCheck.SubscriptionName = $subscription.DisplayName
|
||||||
$resourceCheck.SubscriptionState = $subscription.State
|
$resourceCheck.SubscriptionState = $subscription.State
|
||||||
$Result += $resourceCheck
|
$Result += $resourceCheck
|
||||||
|
$totalSubscriptions++
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
Write-Host " ❌ Error processing subscription: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#level 2
|
} catch {
|
||||||
foreach ($level2ManagementGroupLister in ($level1ManagementGroup.Children | Where-Object Type -EQ 'Microsoft.Management/managementGroups'))
|
Write-Host " ❌ Error accessing Level 1 management group '$($level1ManagementGroupLister.Name)': $($_.Exception.Message)" -ForegroundColor Red
|
||||||
{
|
}
|
||||||
$level2ManagementGroup = (Get-AzManagementGroup -Group $level2ManagementGroupLister.Name -Expand)[0]
|
|
||||||
|
|
||||||
Write-Host " ---------------------------------------------------------------------------------------------"
|
# Process Level 2 management groups (nested within Level 1)
|
||||||
Write-Host " Level 2 Management group [$($level2ManagementGroup.Name)]"
|
$level2Groups = $level1ManagementGroup.Children | Where-Object Type -EQ 'Microsoft.Management/managementGroups'
|
||||||
Write-Host " ---------------------------------------------------------------------------------------------"
|
|
||||||
|
if ($level2Groups.Count -gt 0) {
|
||||||
|
Write-Host " 🔍 Found $($level2Groups.Count) Level 2 management groups" -ForegroundColor Green
|
||||||
|
|
||||||
|
foreach ($level2ManagementGroupLister in $level2Groups) {
|
||||||
|
try {
|
||||||
|
$managementGroupCount++
|
||||||
|
$level2ManagementGroup = Get-AzManagementGroup -Group $level2ManagementGroupLister.Name -Expand -ErrorAction Stop
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||||
|
Write-Host " 📁 LEVEL 2: $($level2ManagementGroup.DisplayName)" -ForegroundColor Magenta
|
||||||
|
Write-Host " ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||||
|
|
||||||
$subscriptions = $level2ManagementGroup.Children | Where-Object Type -EQ '/subscriptions'
|
$subscriptions = $level2ManagementGroup.Children | Where-Object Type -EQ '/subscriptions'
|
||||||
|
Write-Host " 📊 Direct subscriptions: $($subscriptions.Count)" -ForegroundColor Green
|
||||||
|
|
||||||
foreach ($subscription in $subscriptions)
|
foreach ($subscription in $subscriptions) {
|
||||||
{
|
try {
|
||||||
$scope = $subscription.Id.Substring($subscription.Parent.Length, $subscription.Id.Length - $subscription.Parent.Length)
|
$scope = $subscription.Id.Substring($subscription.Parent.Length, $subscription.Id.Length - $subscription.Parent.Length)
|
||||||
$subscriptionId = $scope.Replace("/subscriptions/", "")
|
$subscriptionId = $scope.Replace("/subscriptions/", "")
|
||||||
Write-Host " Subscription [$($subscription.DisplayName) - $subscriptionId]"
|
|
||||||
|
# Color code subscription state
|
||||||
|
$stateColor = switch ($subscription.State) {
|
||||||
|
"Enabled" { "Green" }
|
||||||
|
"Disabled" { "Red" }
|
||||||
|
"Warned" { "Yellow" }
|
||||||
|
default { "White" }
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host " 📋 Subscription: $($subscription.DisplayName) [$subscriptionId] - $($subscription.State)" -ForegroundColor $stateColor
|
||||||
|
|
||||||
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
||||||
$resourceCheck.Level0_ManagementGroupId = $rootManagementGroup.Id
|
$resourceCheck.Level0_ManagementGroupId = $rootManagementGroup.Id
|
||||||
@@ -101,13 +287,105 @@ foreach ($level1ManagementGroupLister in ($rootManagementGroup.Children | Where-
|
|||||||
$resourceCheck.SubscriptionName = $subscription.DisplayName
|
$resourceCheck.SubscriptionName = $subscription.DisplayName
|
||||||
$resourceCheck.SubscriptionState = $subscription.State
|
$resourceCheck.SubscriptionState = $subscription.State
|
||||||
$Result += $resourceCheck
|
$Result += $resourceCheck
|
||||||
|
$totalSubscriptions++
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
Write-Host " ❌ Error processing subscription: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
Write-Host " ❌ Error accessing Level 2 management group '$($level2ManagementGroupLister.Name)': $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Host " ℹ️ No Level 2 management groups found" -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Export results to CSV
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "💾 Exporting results to CSV..." -ForegroundColor Cyan
|
||||||
|
$Result | Export-Csv -Path $fileName -NoTypeInformation
|
||||||
|
|
||||||
|
# Calculate execution time and generate summary report
|
||||||
|
$endTime = Get-Date
|
||||||
|
$executionTime = $endTime - $startTime
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "📊 AZURE MANAGEMENT GROUP DISCOVERY SUMMARY" -ForegroundColor Cyan
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "⏰ Execution Time: $($executionTime.ToString('hh\:mm\:ss'))" -ForegroundColor Green
|
||||||
|
Write-Host "🏗️ Management Groups Discovered: $managementGroupCount" -ForegroundColor Green
|
||||||
|
Write-Host "📋 Total Subscriptions Found: $totalSubscriptions" -ForegroundColor Green
|
||||||
|
Write-Host "📄 Results Exported To: $fileName" -ForegroundColor Yellow
|
||||||
|
|
||||||
|
if (Test-Path $fileName) {
|
||||||
|
$fileSize = (Get-Item $fileName).Length
|
||||||
|
Write-Host "💾 File Size: $([math]::Round($fileSize/1KB, 2)) KB" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
# Analyze subscription states
|
||||||
|
$subscriptionStates = $Result | Group-Object SubscriptionState
|
||||||
|
if ($subscriptionStates.Count -gt 0) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "📈 SUBSCRIPTION STATE ANALYSIS:" -ForegroundColor Cyan
|
||||||
|
foreach ($state in $subscriptionStates) {
|
||||||
|
$stateColor = switch ($state.Name) {
|
||||||
|
"Enabled" { "Green" }
|
||||||
|
"Disabled" { "Red" }
|
||||||
|
"Warned" { "Yellow" }
|
||||||
|
default { "White" }
|
||||||
|
}
|
||||||
|
Write-Host " $($state.Name): $($state.Count) subscriptions" -ForegroundColor $stateColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Provide organizational insights
|
||||||
|
$level0Subs = ($Result | Where-Object { [string]::IsNullOrEmpty($_.Level1_ManagementGroupId) }).Count
|
||||||
|
$level1Subs = ($Result | Where-Object { -not [string]::IsNullOrEmpty($_.Level1_ManagementGroupId) -and [string]::IsNullOrEmpty($_.Level2_ManagementGroupId) }).Count
|
||||||
|
$level2Subs = ($Result | Where-Object { -not [string]::IsNullOrEmpty($_.Level2_ManagementGroupId) }).Count
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "🏗️ ORGANIZATIONAL STRUCTURE:" -ForegroundColor Cyan
|
||||||
|
Write-Host " Root Level (Level 0): $level0Subs subscriptions" -ForegroundColor Green
|
||||||
|
Write-Host " Department/Division Level (Level 1): $level1Subs subscriptions" -ForegroundColor Yellow
|
||||||
|
Write-Host " Team/Project Level (Level 2): $level2Subs subscriptions" -ForegroundColor Magenta
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "📈 NEXT STEPS:" -ForegroundColor Cyan
|
||||||
|
Write-Host " 1. Review the generated CSV file for detailed organizational mapping" -ForegroundColor White
|
||||||
|
Write-Host " 2. Analyze subscription placement for governance compliance" -ForegroundColor White
|
||||||
|
Write-Host " 3. Consider moving orphaned subscriptions to appropriate management groups" -ForegroundColor White
|
||||||
|
Write-Host " 4. Use this data for policy assignment and resource organization planning" -ForegroundColor White
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "✅ Azure Management Group discovery completed successfully!" -ForegroundColor Green
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "❌ CRITICAL ERROR OCCURRED" -ForegroundColor Red
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "Error: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
Write-Host "Line: $($_.InvocationInfo.ScriptLineNumber)" -ForegroundColor Red
|
||||||
|
Write-Host "Position: $($_.InvocationInfo.OffsetInLine)" -ForegroundColor Red
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "🔧 TROUBLESHOOTING STEPS:" -ForegroundColor Yellow
|
||||||
|
Write-Host " 1. Verify you are authenticated to Azure (Connect-AzAccount)" -ForegroundColor White
|
||||||
|
Write-Host " 2. Ensure you have Management Group Reader permissions on the root management group" -ForegroundColor White
|
||||||
|
Write-Host " 3. Verify the root management group ID '$RootManagementGroupId' exists and is accessible" -ForegroundColor White
|
||||||
|
Write-Host " 4. Check that Azure PowerShell modules are installed and up to date" -ForegroundColor White
|
||||||
|
Write-Host " 5. Try running with a different root management group ID if the current one is invalid" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "📞 For additional support, contact the Cloud Engineering team" -ForegroundColor Cyan
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
|
||||||
|
# Ensure we exit with error code for automation scenarios
|
||||||
|
exit 1
|
||||||
|
} finally {
|
||||||
|
# Reset progress preference
|
||||||
|
$ProgressPreference = "Continue"
|
||||||
}
|
}
|
||||||
|
|
||||||
$Result | Export-Csv -Path $fileName -NoTypeInformation
|
|
||||||
|
|
||||||
|
|
||||||
Write-Host "============================================================================================="
|
|
||||||
Write-Host "Done."
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
#Connect-AzAccount
|
|
||||||
|
|
||||||
class ResourceCheck {
|
|
||||||
[string] $ResourceId = ""
|
|
||||||
[string] $Id = ""
|
|
||||||
[string] $Kind = ""
|
|
||||||
[string] $Location = ""
|
|
||||||
[string] $ResourceName = ""
|
|
||||||
[string] $ResourceGroupName = ""
|
|
||||||
[string] $ResourceType = ""
|
|
||||||
[string] $SubscriptionId = ""
|
|
||||||
[string] $SubscriptionName = ""
|
|
||||||
[string] $Tag_Team = ""
|
|
||||||
[string] $Tag_Product = ""
|
|
||||||
[string] $Tag_Environment = ""
|
|
||||||
[string] $Tag_Data = ""
|
|
||||||
[string] $Tag_Delete = ""
|
|
||||||
[string] $Tag_Split = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "========================================================================================================================================================================"
|
|
||||||
Write-Host "Creating policy assignment overview."
|
|
||||||
Write-Host "========================================================================================================================================================================"
|
|
||||||
|
|
||||||
# $subscriptions = Get-AzSubscription
|
|
||||||
|
|
||||||
# $fileName = "c:\temp\2020-08-12 azure_policies.csv"
|
|
||||||
# rm $fileName
|
|
||||||
|
|
||||||
# foreach ($subscription in $subscriptions)
|
|
||||||
# {
|
|
||||||
# Set-AzContext -SubscriptionId $subscription.Id
|
|
||||||
|
|
||||||
# $allAssignments = Get-AzPolicyAssignment
|
|
||||||
# $allAssignments | Export-Csv -Path $fileName -Append -NoTypeInformation
|
|
||||||
# }
|
|
||||||
|
|
||||||
$fileName = "c:\temp\2020-08-14 azure_policiy_definitions.csv"
|
|
||||||
Get-AzPolicyDefinition -Builtin | Export-Csv -Path $fileName -NoTypeInformation
|
|
||||||
|
|
||||||
Write-Host "========================================================================================================================================================================"
|
|
||||||
Write-Host "Done."
|
|
||||||
@@ -1,4 +1,81 @@
|
|||||||
#Connect-AzAccount
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Generates a comprehensive inventory of all Azure resources across enabled subscriptions.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This script enumerates all Azure resources within enabled subscriptions across the entire Azure tenant,
|
||||||
|
collecting detailed resource properties, governance tags, and managed identity information. The results
|
||||||
|
are exported to a timestamped CSV file for analysis, compliance reporting, and resource management.
|
||||||
|
|
||||||
|
Key capabilities:
|
||||||
|
- Cross-subscription resource discovery and enumeration
|
||||||
|
- Comprehensive resource metadata collection (type, location, resource group)
|
||||||
|
- Governance tag extraction for team ownership and compliance tracking
|
||||||
|
- Managed identity discovery and principal ID mapping
|
||||||
|
- Resource lifecycle management tags (delete, split, deployment tracking)
|
||||||
|
- Timestamped CSV export with complete audit trail
|
||||||
|
|
||||||
|
The script processes all enabled subscriptions and captures essential resource information
|
||||||
|
including resource hierarchy, governance tags, and security identities for comprehensive
|
||||||
|
Azure estate management and reporting.
|
||||||
|
|
||||||
|
.PARAMETER None
|
||||||
|
This script does not accept parameters and will process all resources in all enabled subscriptions.
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
CSV File: "<date> Resources.csv"
|
||||||
|
Contains columns for:
|
||||||
|
- Resource identification (ID, name, type, kind, location)
|
||||||
|
- Subscription and resource group context
|
||||||
|
- Governance tags (team, product, environment, data classification)
|
||||||
|
- Lifecycle management tags (delete, split, created date, deployment)
|
||||||
|
- Managed identity information (name, principal ID)
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\Resources.ps1
|
||||||
|
|
||||||
|
Discovers all resources across enabled subscriptions and generates:
|
||||||
|
"2024-10-30 1435 Resources.csv"
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
File Name : Resources.ps1
|
||||||
|
Author : Cloud Engineering Team
|
||||||
|
Prerequisite : Azure PowerShell module (Az.Resources, Az.Accounts, Az.ManagedServiceIdentity)
|
||||||
|
Copyright : (c) 2024 Effectory. All rights reserved.
|
||||||
|
|
||||||
|
Version History:
|
||||||
|
1.0 - Initial release with comprehensive resource inventory functionality
|
||||||
|
|
||||||
|
.LINK
|
||||||
|
https://docs.microsoft.com/en-us/azure/azure-resource-manager/management/overview
|
||||||
|
https://docs.microsoft.com/en-us/powershell/module/az.resources/
|
||||||
|
|
||||||
|
.COMPONENT
|
||||||
|
Requires Azure PowerShell modules:
|
||||||
|
- Az.Resources (for resource enumeration and property retrieval)
|
||||||
|
- Az.Accounts (for authentication and subscription management)
|
||||||
|
- Az.ManagedServiceIdentity (for managed identity discovery)
|
||||||
|
|
||||||
|
.ROLE
|
||||||
|
Required Azure permissions:
|
||||||
|
- Reader or higher on all target subscriptions
|
||||||
|
- Managed Identity Operator (for identity information retrieval)
|
||||||
|
|
||||||
|
.FUNCTIONALITY
|
||||||
|
- Multi-subscription resource discovery and enumeration
|
||||||
|
- Governance tag extraction and compliance tracking
|
||||||
|
- Managed identity mapping and security context analysis
|
||||||
|
- CSV export with comprehensive resource metadata
|
||||||
|
#>
|
||||||
|
|
||||||
|
#Requires -Modules Az.Resources, Az.Accounts, Az.ManagedServiceIdentity
|
||||||
|
#Requires -Version 5.1
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param()
|
||||||
|
|
||||||
|
# Uncomment the following line if authentication is required
|
||||||
|
#Connect-AzAccount
|
||||||
|
|
||||||
class ResourceCheck {
|
class ResourceCheck {
|
||||||
[string] $ResourceId = ""
|
[string] $ResourceId = ""
|
||||||
@@ -22,24 +99,77 @@ class ResourceCheck {
|
|||||||
[string] $ManagedIndentity_PrincipalId = ""
|
[string] $ManagedIndentity_PrincipalId = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "========================================================================================================================================================================"
|
# Initialize script execution
|
||||||
Write-Host "Creating resource overview."
|
$ErrorActionPreference = "Stop"
|
||||||
Write-Host "========================================================================================================================================================================"
|
$ProgressPreference = "SilentlyContinue"
|
||||||
|
$startTime = Get-Date
|
||||||
|
|
||||||
$subscriptions = Get-AzSubscription | Where-Object State -eq "Enabled"
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "🔍 AZURE RESOURCE INVENTORY GENERATOR" -ForegroundColor Cyan
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "⏰ Started: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
try {
|
||||||
$fileName = ".\$date Resources.csv"
|
# Validate Azure authentication
|
||||||
|
$context = Get-AzContext
|
||||||
|
if (-not $context) {
|
||||||
|
throw "No Azure context found. Please run Connect-AzAccount first."
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($subscription in $subscriptions)
|
Write-Host "🔐 Authenticated as: $($context.Account.Id)" -ForegroundColor Green
|
||||||
{
|
Write-Host "🏢 Tenant: $($context.Tenant.Id)" -ForegroundColor Green
|
||||||
Set-AzContext -SubscriptionId $subscription.Id
|
Write-Host ""
|
||||||
|
|
||||||
|
# Get enabled subscriptions
|
||||||
|
Write-Host "🔄 Discovering enabled subscriptions..." -ForegroundColor Cyan
|
||||||
|
$subscriptions = Get-AzSubscription | Where-Object State -eq "Enabled"
|
||||||
|
Write-Host "✅ Found $($subscriptions.Count) enabled subscriptions" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Initialize output file and tracking variables
|
||||||
|
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
||||||
|
$fileName = ".\$date Resources.csv"
|
||||||
|
Write-Host "📄 Output file: $fileName" -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
$totalResources = 0
|
||||||
|
$processedSubscriptions = 0
|
||||||
|
$resourceTypes = @{}
|
||||||
|
$managedIdentityCount = 0
|
||||||
|
$governanceIssues = @()
|
||||||
|
|
||||||
|
# Process each subscription
|
||||||
|
foreach ($subscription in $subscriptions) {
|
||||||
|
$processedSubscriptions++
|
||||||
|
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
||||||
|
Write-Host "🔄 Processing Subscription [$($processedSubscriptions)/$($subscriptions.Count)]: $($subscription.Name)" -ForegroundColor Yellow
|
||||||
|
Write-Host " ID: $($subscription.Id)" -ForegroundColor DarkGray
|
||||||
|
|
||||||
|
try {
|
||||||
|
Set-AzContext -SubscriptionId $subscription.Id | Out-Null
|
||||||
|
|
||||||
|
# Get all resources in the subscription
|
||||||
|
Write-Host " 🔍 Discovering resources..." -ForegroundColor Cyan
|
||||||
|
$allResources = Get-AzResource -ErrorAction Stop
|
||||||
|
Write-Host " ✅ Found $($allResources.Count) resources" -ForegroundColor Green
|
||||||
|
|
||||||
$allResources = Get-AzResource
|
|
||||||
[ResourceCheck[]]$Result = @()
|
[ResourceCheck[]]$Result = @()
|
||||||
|
$subscriptionResourceCount = 0
|
||||||
|
$subscriptionManagedIdentityCount = 0
|
||||||
|
|
||||||
foreach ($resource in $allResources)
|
foreach ($resource in $allResources) {
|
||||||
{
|
try {
|
||||||
|
$subscriptionResourceCount++
|
||||||
|
|
||||||
|
# Track resource types for analytics
|
||||||
|
if ($resourceTypes.ContainsKey($resource.ResourceType)) {
|
||||||
|
$resourceTypes[$resource.ResourceType]++
|
||||||
|
} else {
|
||||||
|
$resourceTypes[$resource.ResourceType] = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create resource check object
|
||||||
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
||||||
$resourceCheck.ResourceId = $resource.ResourceId
|
$resourceCheck.ResourceId = $resource.ResourceId
|
||||||
$resourceCheck.Id = $resource.Id
|
$resourceCheck.Id = $resource.Id
|
||||||
@@ -59,22 +189,169 @@ Set-AzContext -SubscriptionId $subscription.Id
|
|||||||
$resourceCheck.Tag_CreatedOnDate = $resource.Tags.CreatedOnDate
|
$resourceCheck.Tag_CreatedOnDate = $resource.Tags.CreatedOnDate
|
||||||
$resourceCheck.Tag_Deployment = $resource.Tags.drp_deployment
|
$resourceCheck.Tag_Deployment = $resource.Tags.drp_deployment
|
||||||
|
|
||||||
|
# Check for governance tag compliance
|
||||||
|
$missingTags = @()
|
||||||
|
if ([string]::IsNullOrEmpty($resource.Tags.team)) { $missingTags += "team" }
|
||||||
|
if ([string]::IsNullOrEmpty($resource.Tags.product)) { $missingTags += "product" }
|
||||||
|
if ([string]::IsNullOrEmpty($resource.Tags.environment)) { $missingTags += "environment" }
|
||||||
|
|
||||||
|
if ($missingTags.Count -gt 0) {
|
||||||
|
$governanceIssues += "⚠️ Missing tags [$($missingTags -join ', ')] on $($resource.ResourceType): $($resource.ResourceName) in $($resource.ResourceGroupName)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Attempt to get managed identity information
|
||||||
try {
|
try {
|
||||||
$managedIdentity = $null
|
$managedIdentity = Get-AzSystemAssignedIdentity -Scope $resource.ResourceId -ErrorAction SilentlyContinue
|
||||||
$managedIdentity = Get-AzSystemAssignedIdentity -Scope $resource.ResourceId -erroraction 'silentlycontinue'
|
if ($managedIdentity) {
|
||||||
$resourceCheck.ManagedIndentity_Name = $managedIdentity.Name
|
$resourceCheck.ManagedIndentity_Name = $managedIdentity.Name
|
||||||
$resourceCheck.ManagedIndentity_PrincipalId = $managedIdentity.PrincipalId
|
$resourceCheck.ManagedIndentity_PrincipalId = $managedIdentity.PrincipalId
|
||||||
|
$subscriptionManagedIdentityCount++
|
||||||
|
$managedIdentityCount++
|
||||||
|
} else {
|
||||||
|
$resourceCheck.ManagedIndentity_Name = ""
|
||||||
|
$resourceCheck.ManagedIndentity_PrincipalId = ""
|
||||||
}
|
}
|
||||||
catch {
|
} catch {
|
||||||
|
# Silently handle managed identity lookup failures
|
||||||
$resourceCheck.ManagedIndentity_Name = ""
|
$resourceCheck.ManagedIndentity_Name = ""
|
||||||
$resourceCheck.ManagedIndentity_PrincipalId = ""
|
$resourceCheck.ManagedIndentity_PrincipalId = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
$Result += $resourceCheck
|
$Result += $resourceCheck
|
||||||
|
$totalResources++
|
||||||
|
|
||||||
|
# Show progress for large subscriptions
|
||||||
|
if ($subscriptionResourceCount % 100 -eq 0) {
|
||||||
|
Write-Host " 📊 Processed $subscriptionResourceCount resources..." -ForegroundColor DarkCyan
|
||||||
}
|
}
|
||||||
|
|
||||||
$Result | Export-Csv -Path $fileName -Append -NoTypeInformation
|
} catch {
|
||||||
}
|
Write-Host " ❌ Error processing resource '$($resource.ResourceName)': $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Write-Host "========================================================================================================================================================================"
|
# Export results for this subscription
|
||||||
Write-Host "Done."
|
if ($Result.Count -gt 0) {
|
||||||
|
$Result | Export-Csv -Path $fileName -Append -NoTypeInformation
|
||||||
|
Write-Host " ✅ Exported $($Result.Count) resources" -ForegroundColor Green
|
||||||
|
if ($subscriptionManagedIdentityCount -gt 0) {
|
||||||
|
Write-Host " 🔐 Found $subscriptionManagedIdentityCount managed identities" -ForegroundColor Cyan
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Host " ℹ️ No resources found in subscription" -ForegroundColor DarkYellow
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
Write-Host " ❌ Error processing subscription: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Calculate execution time and generate comprehensive summary report
|
||||||
|
$endTime = Get-Date
|
||||||
|
$executionTime = $endTime - $startTime
|
||||||
|
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "📊 AZURE RESOURCE INVENTORY SUMMARY" -ForegroundColor Cyan
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "⏰ Execution Time: $($executionTime.ToString('hh\:mm\:ss'))" -ForegroundColor Green
|
||||||
|
Write-Host "📋 Subscriptions Processed: $processedSubscriptions" -ForegroundColor Green
|
||||||
|
Write-Host "🔍 Total Resources Discovered: $totalResources" -ForegroundColor Green
|
||||||
|
Write-Host "🔐 Managed Identities Found: $managedIdentityCount" -ForegroundColor Cyan
|
||||||
|
Write-Host "📄 Results Exported To: $fileName" -ForegroundColor Yellow
|
||||||
|
|
||||||
|
if (Test-Path $fileName) {
|
||||||
|
$fileSize = (Get-Item $fileName).Length
|
||||||
|
Write-Host "💾 File Size: $([math]::Round($fileSize/1KB, 2)) KB" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
# Display top resource types
|
||||||
|
if ($resourceTypes.Count -gt 0) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "📈 TOP RESOURCE TYPES:" -ForegroundColor Cyan
|
||||||
|
$topResourceTypes = $resourceTypes.GetEnumerator() | Sort-Object Value -Descending | Select-Object -First 10
|
||||||
|
foreach ($resourceType in $topResourceTypes) {
|
||||||
|
Write-Host " $($resourceType.Key): $($resourceType.Value) resources" -ForegroundColor White
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($resourceTypes.Count -gt 10) {
|
||||||
|
$remainingTypes = $resourceTypes.Count - 10
|
||||||
|
$remainingResources = ($resourceTypes.GetEnumerator() | Sort-Object Value -Descending | Select-Object -Skip 10 | Measure-Object Value -Sum).Sum
|
||||||
|
Write-Host " ... and $remainingTypes more types ($remainingResources resources)" -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Display governance analysis
|
||||||
|
if ($governanceIssues.Count -gt 0) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "🚨 GOVERNANCE ANALYSIS" -ForegroundColor Red
|
||||||
|
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
||||||
|
Write-Host "Found $($governanceIssues.Count) resources with missing governance tags:" -ForegroundColor Yellow
|
||||||
|
|
||||||
|
foreach ($issue in $governanceIssues | Select-Object -First 15) {
|
||||||
|
Write-Host " $issue" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($governanceIssues.Count -gt 15) {
|
||||||
|
Write-Host " ... and $($governanceIssues.Count - 15) more governance issues (see CSV for complete details)" -ForegroundColor DarkYellow
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "📋 Governance Recommendations:" -ForegroundColor Cyan
|
||||||
|
Write-Host " • Implement mandatory tagging policies for team, product, and environment" -ForegroundColor White
|
||||||
|
Write-Host " • Use Azure Policy to enforce governance tag compliance" -ForegroundColor White
|
||||||
|
Write-Host " • Establish resource naming conventions and tagging standards" -ForegroundColor White
|
||||||
|
Write-Host " • Regular governance audits using this resource inventory" -ForegroundColor White
|
||||||
|
} else {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "✅ GOVERNANCE ANALYSIS: All resources have required governance tags" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
# Security and identity insights
|
||||||
|
if ($managedIdentityCount -gt 0) {
|
||||||
|
$identityPercentage = [math]::Round(($managedIdentityCount / $totalResources) * 100, 1)
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "🔐 SECURITY ANALYSIS:" -ForegroundColor Cyan
|
||||||
|
Write-Host " Managed Identity Adoption: $identityPercentage% of resources ($managedIdentityCount/$totalResources)" -ForegroundColor Green
|
||||||
|
Write-Host " 💡 Consider expanding managed identity usage for enhanced security" -ForegroundColor White
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "📈 NEXT STEPS:" -ForegroundColor Cyan
|
||||||
|
Write-Host " 1. Review the generated CSV file for detailed resource analysis" -ForegroundColor White
|
||||||
|
Write-Host " 2. Address governance tag compliance issues identified above" -ForegroundColor White
|
||||||
|
Write-Host " 3. Analyze resource distribution across subscriptions and regions" -ForegroundColor White
|
||||||
|
Write-Host " 4. Consider resource optimization opportunities (unused resources, right-sizing)" -ForegroundColor White
|
||||||
|
Write-Host " 5. Implement automated resource monitoring and cost management" -ForegroundColor White
|
||||||
|
Write-Host " 6. Use managed identity information for security auditing" -ForegroundColor White
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "✅ Azure resource inventory completed successfully!" -ForegroundColor Green
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "❌ CRITICAL ERROR OCCURRED" -ForegroundColor Red
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "Error: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
Write-Host "Line: $($_.InvocationInfo.ScriptLineNumber)" -ForegroundColor Red
|
||||||
|
Write-Host "Position: $($_.InvocationInfo.OffsetInLine)" -ForegroundColor Red
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "🔧 TROUBLESHOOTING STEPS:" -ForegroundColor Yellow
|
||||||
|
Write-Host " 1. Verify you are authenticated to Azure (Connect-AzAccount)" -ForegroundColor White
|
||||||
|
Write-Host " 2. Ensure you have Reader permissions on all target subscriptions" -ForegroundColor White
|
||||||
|
Write-Host " 3. Check that Azure PowerShell modules are installed and up to date" -ForegroundColor White
|
||||||
|
Write-Host " 4. Verify Managed Identity Operator role for identity information retrieval" -ForegroundColor White
|
||||||
|
Write-Host " 5. Try running the script with -Verbose for additional diagnostic information" -ForegroundColor White
|
||||||
|
Write-Host " 6. Consider processing subscriptions individually if encountering timeout issues" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "📞 For additional support, contact the Cloud Engineering team" -ForegroundColor Cyan
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
|
||||||
|
# Ensure we exit with error code for automation scenarios
|
||||||
|
exit 1
|
||||||
|
} finally {
|
||||||
|
# Reset progress preference
|
||||||
|
$ProgressPreference = "Continue"
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,88 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Generates a comprehensive inventory of Azure Service Bus resources across all management groups and subscriptions.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This script enumerates all Azure Service Bus namespaces, topics, topic subscriptions, and queues
|
||||||
|
within active subscriptions across the entire Azure tenant. It provides detailed hierarchical
|
||||||
|
mapping of Service Bus messaging infrastructure for monitoring, compliance, and governance reporting.
|
||||||
|
|
||||||
|
Key capabilities:
|
||||||
|
- Multi-tenant Service Bus discovery across all management groups
|
||||||
|
- Hierarchical messaging structure mapping (namespaces → topics → subscriptions, queues)
|
||||||
|
- Complete Service Bus topology documentation
|
||||||
|
- Management group and subscription context tracking
|
||||||
|
- Resource hierarchy analysis for messaging architecture
|
||||||
|
- Timestamped CSV export for audit trails and capacity planning
|
||||||
|
|
||||||
|
The script processes the complete Service Bus messaging estate including:
|
||||||
|
- Service Bus namespaces (messaging containers)
|
||||||
|
- Topics (pub/sub messaging endpoints)
|
||||||
|
- Topic subscriptions (message consumers)
|
||||||
|
- Queues (point-to-point messaging endpoints)
|
||||||
|
|
||||||
|
.PARAMETER None
|
||||||
|
This script does not accept parameters and will process all Service Bus resources across all accessible namespaces.
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
CSV File: "<date> azure service bus.csv"
|
||||||
|
Contains columns for:
|
||||||
|
- Resource identification (ID, type, location)
|
||||||
|
- Management hierarchy (management group, subscription, resource group)
|
||||||
|
- Service Bus namespace information
|
||||||
|
- Messaging topology (topic names, subscription names, queue names)
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\ServiceBus.ps1
|
||||||
|
|
||||||
|
Discovers all Service Bus resources and generates:
|
||||||
|
"2024-10-30 1435 azure service bus.csv"
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
File Name : ServiceBus.ps1
|
||||||
|
Author : Cloud Engineering Team
|
||||||
|
Prerequisite : Azure PowerShell modules (Az.ServiceBus, Az.Resources, Az.Accounts)
|
||||||
|
Copyright : (c) 2024 Effectory. All rights reserved.
|
||||||
|
|
||||||
|
Version History:
|
||||||
|
1.0 - Initial release with comprehensive Service Bus inventory functionality
|
||||||
|
|
||||||
|
.LINK
|
||||||
|
https://docs.microsoft.com/en-us/azure/service-bus-messaging/
|
||||||
|
https://docs.microsoft.com/en-us/powershell/module/az.servicebus/
|
||||||
|
|
||||||
|
.COMPONENT
|
||||||
|
Requires Azure PowerShell modules:
|
||||||
|
- Az.ServiceBus (for Service Bus namespace, topic, queue, and subscription enumeration)
|
||||||
|
- Az.Resources (for resource group and management group access)
|
||||||
|
- Az.Accounts (for authentication and subscription management)
|
||||||
|
- Az.Automation (for automation context support)
|
||||||
|
|
||||||
|
.ROLE
|
||||||
|
Required Azure permissions:
|
||||||
|
- Service Bus Data Reader or higher on all Service Bus namespaces
|
||||||
|
- Management Group Reader for organizational hierarchy access
|
||||||
|
- Reader access on target subscriptions
|
||||||
|
|
||||||
|
.FUNCTIONALITY
|
||||||
|
- Multi-subscription Service Bus discovery
|
||||||
|
- Messaging topology analysis and hierarchical mapping
|
||||||
|
- Complete Service Bus infrastructure documentation
|
||||||
|
- CSV export with comprehensive messaging architecture details
|
||||||
|
#>
|
||||||
|
|
||||||
|
#Requires -Modules Az.ServiceBus, Az.Resources, Az.Accounts, Az.Automation
|
||||||
|
#Requires -Version 5.1
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param()
|
||||||
|
|
||||||
|
# Import required modules
|
||||||
Import-Module Az.Accounts
|
Import-Module Az.Accounts
|
||||||
Import-Module Az.Automation
|
Import-Module Az.Automation
|
||||||
Import-Module Az.ServiceBus
|
Import-Module Az.ServiceBus
|
||||||
Import-Module Az.Resources
|
Import-Module Az.Resources
|
||||||
|
|
||||||
$subscriptions = Get-AzSubscription
|
|
||||||
|
|
||||||
class ResourceCheck {
|
class ResourceCheck {
|
||||||
[string] $ResourceId = ""
|
[string] $ResourceId = ""
|
||||||
[string] $ManagementGroupId = ""
|
[string] $ManagementGroupId = ""
|
||||||
@@ -20,36 +98,94 @@ class ResourceCheck {
|
|||||||
[string] $QueueName = ""
|
[string] $QueueName = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Initialize script execution
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$ProgressPreference = "SilentlyContinue"
|
||||||
|
$startTime = Get-Date
|
||||||
|
|
||||||
Write-Host "======================================================================================================================================================================"
|
Write-Host "======================================================================================================================================================================"
|
||||||
Write-Host "Creating service bus resource overview."
|
Write-Host "🚌 AZURE SERVICE BUS INVENTORY GENERATOR" -ForegroundColor Cyan
|
||||||
Write-Host "======================================================================================================================================================================"
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "⏰ Started: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
try {
|
||||||
$fileName = ".\$date azure service bus.csv"
|
# Validate Azure authentication
|
||||||
|
$context = Get-AzContext
|
||||||
|
if (-not $context) {
|
||||||
|
throw "No Azure context found. Please run Connect-AzAccount first."
|
||||||
|
}
|
||||||
|
|
||||||
$managementGroups = Get-AzManagementGroup
|
Write-Host "🔐 Authenticated as: $($context.Account.Id)" -ForegroundColor Green
|
||||||
|
Write-Host "🏢 Tenant: $($context.Tenant.Id)" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
foreach ($managementGroup in $managementGroups) {
|
# Initialize output file and tracking variables
|
||||||
|
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
||||||
|
$fileName = ".\$date azure service bus.csv"
|
||||||
|
Write-Host "📄 Output file: $fileName" -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Initialize counters for progress tracking
|
||||||
|
$totalNamespaces = 0
|
||||||
|
$totalTopics = 0
|
||||||
|
$totalTopicSubscriptions = 0
|
||||||
|
$totalQueues = 0
|
||||||
|
$processedManagementGroups = 0
|
||||||
|
$processedSubscriptions = 0
|
||||||
|
|
||||||
|
# Get management groups for organizational structure
|
||||||
|
Write-Host "🏗️ Discovering management group structure..." -ForegroundColor Cyan
|
||||||
|
$managementGroups = Get-AzManagementGroup
|
||||||
|
Write-Host "✅ Found $($managementGroups.Count) management groups" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Process each management group
|
||||||
|
foreach ($managementGroup in $managementGroups) {
|
||||||
|
$processedManagementGroups++
|
||||||
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
||||||
Write-Host "Management group [$($managementGroup.Name)]"
|
Write-Host "🏗️ Management Group [$($managementGroup.DisplayName)]" -ForegroundColor Cyan
|
||||||
|
Write-Host " ID: $($managementGroup.Id)" -ForegroundColor DarkGray
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Get active subscriptions in this management group
|
||||||
$subscriptions = Get-AzManagementGroupSubscription -Group $managementGroup.Name | Where-Object State -eq "Active"
|
$subscriptions = Get-AzManagementGroupSubscription -Group $managementGroup.Name | Where-Object State -eq "Active"
|
||||||
|
Write-Host " 📋 Found $($subscriptions.Count) active subscriptions" -ForegroundColor Green
|
||||||
|
|
||||||
foreach ($subscription in $subscriptions) {
|
foreach ($subscription in $subscriptions) {
|
||||||
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
$processedSubscriptions++
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " 🔄 Processing Subscription: $($subscription.DisplayName)" -ForegroundColor Yellow
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Extract subscription ID and set context
|
||||||
$scope = $subscription.Id.Substring($subscription.Parent.Length, $subscription.Id.Length - $subscription.Parent.Length)
|
$scope = $subscription.Id.Substring($subscription.Parent.Length, $subscription.Id.Length - $subscription.Parent.Length)
|
||||||
$subscriptionId = $scope.Replace("/subscriptions/", "")
|
$subscriptionId = $scope.Replace("/subscriptions/", "")
|
||||||
Write-Host "Subscription [$($subscription.DisplayName) - $subscriptionId]"
|
Write-Host " ID: $subscriptionId" -ForegroundColor DarkGray
|
||||||
|
|
||||||
Set-AzContext -SubscriptionId $subscriptionId | Out-Null
|
Set-AzContext -SubscriptionId $subscriptionId | Out-Null
|
||||||
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
|
||||||
|
|
||||||
$servicebusses = Get-AzServiceBusNamespaceV2
|
# Get Service Bus namespaces in the subscription
|
||||||
|
Write-Host " 🔍 Discovering Service Bus namespaces..." -ForegroundColor Cyan
|
||||||
|
$servicebusses = Get-AzServiceBusNamespaceV2 -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
if ($servicebusses.Count -gt 0) {
|
||||||
|
Write-Host " ✅ Found $($servicebusses.Count) Service Bus namespaces" -ForegroundColor Green
|
||||||
|
$totalNamespaces += $servicebusses.Count
|
||||||
|
} else {
|
||||||
|
Write-Host " ℹ️ No Service Bus namespaces found" -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($servicebus in $servicebusses) {
|
foreach ($servicebus in $servicebusses) {
|
||||||
|
Write-Host " 📦 Processing namespace: $($servicebus.Name)" -ForegroundColor Yellow
|
||||||
|
|
||||||
Write-Host "Getting info for service bus [$($servicebus.Name)]"
|
try {
|
||||||
|
|
||||||
[ResourceCheck[]]$Result = @()
|
[ResourceCheck[]]$Result = @()
|
||||||
|
$namespaceTopics = 0
|
||||||
|
$namespaceTopicSubs = 0
|
||||||
|
$namespaceQueues = 0
|
||||||
|
|
||||||
|
# Add namespace entry
|
||||||
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
||||||
$resourceCheck.ResourceId = $servicebus.Id
|
$resourceCheck.ResourceId = $servicebus.Id
|
||||||
$resourceCheck.ManagementGroupId = $managementGroup.Id
|
$resourceCheck.ManagementGroupId = $managementGroup.Id
|
||||||
@@ -62,8 +198,18 @@ foreach ($managementGroup in $managementGroups) {
|
|||||||
$resourceCheck.ServiceBusName = $servicebus.Name
|
$resourceCheck.ServiceBusName = $servicebus.Name
|
||||||
$Result += $resourceCheck
|
$Result += $resourceCheck
|
||||||
|
|
||||||
#topics
|
# Process topics
|
||||||
$topics = Get-AzServiceBusTopic -NamespaceName $servicebus.Name -ResourceGroupName $servicebus.ResourceGroupName
|
Write-Host " 🔍 Discovering topics..." -ForegroundColor DarkCyan
|
||||||
|
try {
|
||||||
|
$topics = Get-AzServiceBusTopic -NamespaceName $servicebus.Name -ResourceGroupName $servicebus.ResourceGroupName -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
if ($topics.Count -gt 0) {
|
||||||
|
Write-Host " 📊 Found $($topics.Count) topics" -ForegroundColor Green
|
||||||
|
$namespaceTopics = $topics.Count
|
||||||
|
$totalTopics += $topics.Count
|
||||||
|
} else {
|
||||||
|
Write-Host " ℹ️ No topics found" -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($topic in $topics) {
|
foreach ($topic in $topics) {
|
||||||
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
||||||
@@ -79,11 +225,11 @@ foreach ($managementGroup in $managementGroups) {
|
|||||||
$resourceCheck.TopicName = $topic.Name
|
$resourceCheck.TopicName = $topic.Name
|
||||||
$Result += $resourceCheck
|
$Result += $resourceCheck
|
||||||
|
|
||||||
# topic subscriptions
|
# Process topic subscriptions
|
||||||
$topicSubs = Get-AzServiceBusSubscription -NamespaceName $servicebus.Name -ResourceGroupName $servicebus.ResourceGroupName -TopicName $topic.Name
|
try {
|
||||||
|
$topicSubs = Get-AzServiceBusSubscription -NamespaceName $servicebus.Name -ResourceGroupName $servicebus.ResourceGroupName -TopicName $topic.Name -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
foreach ($topicSub in $topicSubs) {
|
foreach ($topicSub in $topicSubs) {
|
||||||
|
|
||||||
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
||||||
$resourceCheck.ResourceId = $servicebus.Id
|
$resourceCheck.ResourceId = $servicebus.Id
|
||||||
$resourceCheck.ManagementGroupId = $managementGroup.Id
|
$resourceCheck.ManagementGroupId = $managementGroup.Id
|
||||||
@@ -97,14 +243,31 @@ foreach ($managementGroup in $managementGroups) {
|
|||||||
$resourceCheck.TopicName = $topic.Name
|
$resourceCheck.TopicName = $topic.Name
|
||||||
$resourceCheck.TopicSubscriptionName = $topicSub.Name
|
$resourceCheck.TopicSubscriptionName = $topicSub.Name
|
||||||
$Result += $resourceCheck
|
$Result += $resourceCheck
|
||||||
|
$namespaceTopicSubs++
|
||||||
|
$totalTopicSubscriptions++
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host " ⚠️ Error getting subscriptions for topic '$($topic.Name)': $($_.Exception.Message)" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host " ❌ Error getting topics: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
}
|
}
|
||||||
|
|
||||||
# queues
|
# Process queues
|
||||||
$queues = Get-AzServiceBusQueue -NamespaceName $servicebus.Name -ResourceGroupName $servicebus.ResourceGroupName
|
Write-Host " 🔍 Discovering queues..." -ForegroundColor DarkCyan
|
||||||
|
try {
|
||||||
|
$queues = Get-AzServiceBusQueue -NamespaceName $servicebus.Name -ResourceGroupName $servicebus.ResourceGroupName -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
if ($queues.Count -gt 0) {
|
||||||
|
Write-Host " 📊 Found $($queues.Count) queues" -ForegroundColor Green
|
||||||
|
$namespaceQueues = $queues.Count
|
||||||
|
$totalQueues += $queues.Count
|
||||||
|
} else {
|
||||||
|
Write-Host " ℹ️ No queues found" -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($queue in $queues) {
|
foreach ($queue in $queues) {
|
||||||
|
|
||||||
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
||||||
$resourceCheck.ResourceId = $servicebus.Id
|
$resourceCheck.ResourceId = $servicebus.Id
|
||||||
$resourceCheck.ManagementGroupId = $managementGroup.Id
|
$resourceCheck.ManagementGroupId = $managementGroup.Id
|
||||||
@@ -118,11 +281,126 @@ foreach ($managementGroup in $managementGroups) {
|
|||||||
$resourceCheck.QueueName = $queue.Name
|
$resourceCheck.QueueName = $queue.Name
|
||||||
$Result += $resourceCheck
|
$Result += $resourceCheck
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host " ❌ Error getting queues: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
# Export results for this namespace
|
||||||
|
if ($Result.Count -gt 0) {
|
||||||
$Result | Export-Csv -Path $fileName -Append -NoTypeInformation
|
$Result | Export-Csv -Path $fileName -Append -NoTypeInformation
|
||||||
|
Write-Host " ✅ Exported $($Result.Count) Service Bus resources" -ForegroundColor Green
|
||||||
|
Write-Host " Topics: $namespaceTopics, Subscriptions: $namespaceTopicSubs, Queues: $namespaceQueues" -ForegroundColor DarkGray
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
} catch {
|
||||||
Write-Host "======================================================================================================================================================================"
|
Write-Host " ❌ Error processing namespace '$($servicebus.Name)': $($_.Exception.Message)" -ForegroundColor Red
|
||||||
Write-Host "Done."
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
Write-Host " ❌ Error processing subscription: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host " ❌ Error accessing management group: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Calculate execution time and generate comprehensive summary report
|
||||||
|
$endTime = Get-Date
|
||||||
|
$executionTime = $endTime - $startTime
|
||||||
|
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "📊 AZURE SERVICE BUS INVENTORY SUMMARY" -ForegroundColor Cyan
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "⏰ Execution Time: $($executionTime.ToString('hh\:mm\:ss'))" -ForegroundColor Green
|
||||||
|
Write-Host "🏗️ Management Groups Processed: $processedManagementGroups" -ForegroundColor Green
|
||||||
|
Write-Host "📋 Subscriptions Processed: $processedSubscriptions" -ForegroundColor Green
|
||||||
|
Write-Host "🚌 Service Bus Namespaces: $totalNamespaces" -ForegroundColor Green
|
||||||
|
Write-Host "📡 Topics Discovered: $totalTopics" -ForegroundColor Cyan
|
||||||
|
Write-Host "📨 Topic Subscriptions: $totalTopicSubscriptions" -ForegroundColor Yellow
|
||||||
|
Write-Host "📬 Queues Discovered: $totalQueues" -ForegroundColor Magenta
|
||||||
|
Write-Host "📄 Results Exported To: $fileName" -ForegroundColor Yellow
|
||||||
|
|
||||||
|
if (Test-Path $fileName) {
|
||||||
|
$fileSize = (Get-Item $fileName).Length
|
||||||
|
Write-Host "💾 File Size: $([math]::Round($fileSize/1KB, 2)) KB" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
# Calculate messaging topology insights
|
||||||
|
$totalMessagingEndpoints = $totalTopics + $totalQueues
|
||||||
|
$averageTopicsPerNamespace = if ($totalNamespaces -gt 0) { [math]::Round($totalTopics / $totalNamespaces, 1) } else { 0 }
|
||||||
|
$averageQueuesPerNamespace = if ($totalNamespaces -gt 0) { [math]::Round($totalQueues / $totalNamespaces, 1) } else { 0 }
|
||||||
|
$averageSubsPerTopic = if ($totalTopics -gt 0) { [math]::Round($totalTopicSubscriptions / $totalTopics, 1) } else { 0 }
|
||||||
|
|
||||||
|
if ($totalNamespaces -gt 0) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "📈 MESSAGING TOPOLOGY ANALYSIS:" -ForegroundColor Cyan
|
||||||
|
Write-Host " Total Messaging Endpoints: $totalMessagingEndpoints (Topics + Queues)" -ForegroundColor White
|
||||||
|
Write-Host " Average Topics per Namespace: $averageTopicsPerNamespace" -ForegroundColor White
|
||||||
|
Write-Host " Average Queues per Namespace: $averageQueuesPerNamespace" -ForegroundColor White
|
||||||
|
if ($totalTopics -gt 0) {
|
||||||
|
Write-Host " Average Subscriptions per Topic: $averageSubsPerTopic" -ForegroundColor White
|
||||||
|
}
|
||||||
|
|
||||||
|
# Provide architecture insights
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "🏗️ ARCHITECTURE INSIGHTS:" -ForegroundColor Cyan
|
||||||
|
if ($totalTopics -gt $totalQueues) {
|
||||||
|
Write-Host " 📡 Pub/Sub Pattern Dominant: More topics ($totalTopics) than queues ($totalQueues)" -ForegroundColor Green
|
||||||
|
Write-Host " This indicates a preference for broadcast messaging patterns" -ForegroundColor White
|
||||||
|
} elseif ($totalQueues -gt $totalTopics) {
|
||||||
|
Write-Host " 📬 Point-to-Point Pattern Dominant: More queues ($totalQueues) than topics ($totalTopics)" -ForegroundColor Green
|
||||||
|
Write-Host " This indicates a preference for direct messaging patterns" -ForegroundColor White
|
||||||
|
} else {
|
||||||
|
Write-Host " ⚖️ Balanced Architecture: Equal distribution of topics and queues" -ForegroundColor Green
|
||||||
|
Write-Host " This indicates a mixed messaging architecture approach" -ForegroundColor White
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($totalTopicSubscriptions -gt ($totalTopics * 2)) {
|
||||||
|
Write-Host " 🔄 High Fan-out: Multiple consumers per topic (avg: $averageSubsPerTopic subscribers)" -ForegroundColor Yellow
|
||||||
|
Write-Host " Consider monitoring subscription performance and message distribution" -ForegroundColor White
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "📈 NEXT STEPS:" -ForegroundColor Cyan
|
||||||
|
Write-Host " 1. Review the generated CSV file for detailed Service Bus topology" -ForegroundColor White
|
||||||
|
Write-Host " 2. Analyze messaging patterns and identify optimization opportunities" -ForegroundColor White
|
||||||
|
Write-Host " 3. Monitor Service Bus performance metrics and throughput" -ForegroundColor White
|
||||||
|
Write-Host " 4. Consider namespace consolidation for cost optimization" -ForegroundColor White
|
||||||
|
Write-Host " 5. Implement message monitoring and alerting for critical endpoints" -ForegroundColor White
|
||||||
|
Write-Host " 6. Review security settings and access policies for each namespace" -ForegroundColor White
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "✅ Azure Service Bus inventory completed successfully!" -ForegroundColor Green
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "❌ CRITICAL ERROR OCCURRED" -ForegroundColor Red
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "Error: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
Write-Host "Line: $($_.InvocationInfo.ScriptLineNumber)" -ForegroundColor Red
|
||||||
|
Write-Host "Position: $($_.InvocationInfo.OffsetInLine)" -ForegroundColor Red
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "🔧 TROUBLESHOOTING STEPS:" -ForegroundColor Yellow
|
||||||
|
Write-Host " 1. Verify you are authenticated to Azure (Connect-AzAccount)" -ForegroundColor White
|
||||||
|
Write-Host " 2. Ensure you have Service Bus Data Reader permissions on target namespaces" -ForegroundColor White
|
||||||
|
Write-Host " 3. Check that the Management Group Reader role is assigned" -ForegroundColor White
|
||||||
|
Write-Host " 4. Verify Azure PowerShell modules are installed and up to date" -ForegroundColor White
|
||||||
|
Write-Host " 5. Confirm that Service Bus namespaces are accessible and not deleted" -ForegroundColor White
|
||||||
|
Write-Host " 6. Try running the script with -Verbose for additional diagnostic information" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "📞 For additional support, contact the Cloud Engineering team" -ForegroundColor Cyan
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
|
||||||
|
# Ensure we exit with error code for automation scenarios
|
||||||
|
exit 1
|
||||||
|
} finally {
|
||||||
|
# Reset progress preference
|
||||||
|
$ProgressPreference = "Continue"
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +1,164 @@
|
|||||||
#Connect-AzAccount
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Generates a comprehensive inventory of Azure Web Apps and deployment slots across all management groups and subscriptions.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This script enumerates all Azure Web Apps and their deployment slots within active subscriptions
|
||||||
|
across the entire Azure tenant, collecting detailed configuration properties, security settings,
|
||||||
|
governance tags, and deployment information. The results are exported to a timestamped CSV file
|
||||||
|
for analysis, compliance reporting, and security auditing.
|
||||||
|
|
||||||
|
Key capabilities:
|
||||||
|
- Multi-tenant Web App discovery across all management groups
|
||||||
|
- Deployment slot enumeration and configuration analysis
|
||||||
|
- Security configuration analysis (HTTPS, TLS versions, debugging, FTPS)
|
||||||
|
- Last deployment date tracking via Azure Management API
|
||||||
|
- Governance tag extraction for team ownership and compliance tracking
|
||||||
|
- Identity configuration analysis (system/user-assigned managed identity)
|
||||||
|
- Timestamped CSV export for audit trails and trend analysis
|
||||||
|
|
||||||
|
The script processes both production Web Apps and their deployment slots, providing
|
||||||
|
comprehensive visibility into the web application estate including security posture,
|
||||||
|
deployment practices, and governance compliance.
|
||||||
|
|
||||||
|
.PARAMETER None
|
||||||
|
This script does not accept parameters and will process all Web Apps across all accessible subscriptions.
|
||||||
|
Note: Visual Studio subscriptions are automatically excluded from processing.
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
CSV File: "<date> azure_webapps.csv"
|
||||||
|
Contains columns for:
|
||||||
|
- Resource identification (ID, name, type, kind, location, state)
|
||||||
|
- Management hierarchy (management group, subscription, resource group)
|
||||||
|
- Governance tags (team, product, environment, data classification)
|
||||||
|
- Security configuration (HTTPS, TLS version, debugging, FTPS state)
|
||||||
|
- Runtime configuration (PHP version, HTTP/2.0 support)
|
||||||
|
- Identity configuration (managed identity type)
|
||||||
|
- Deployment tracking (last successful deployment date)
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\WebApps.ps1
|
||||||
|
|
||||||
|
Discovers all Web Apps and deployment slots, generates:
|
||||||
|
"2024-10-30 1435 azure_webapps.csv"
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
File Name : WebApps.ps1
|
||||||
|
Author : Cloud Engineering Team
|
||||||
|
Prerequisite : Azure PowerShell module (Az.Websites, Az.Resources, Az.Accounts)
|
||||||
|
Copyright : (c) 2024 Effectory. All rights reserved.
|
||||||
|
|
||||||
|
Version History:
|
||||||
|
1.0 - Initial release with comprehensive Web App inventory functionality
|
||||||
|
|
||||||
|
.LINK
|
||||||
|
https://docs.microsoft.com/en-us/azure/app-service/
|
||||||
|
https://docs.microsoft.com/en-us/powershell/module/az.websites/
|
||||||
|
|
||||||
|
.COMPONENT
|
||||||
|
Requires Azure PowerShell modules:
|
||||||
|
- Az.Websites (for Web App enumeration and configuration retrieval)
|
||||||
|
- Az.Resources (for resource group and management group access)
|
||||||
|
- Az.Accounts (for authentication and access token management)
|
||||||
|
|
||||||
|
.ROLE
|
||||||
|
Required Azure permissions:
|
||||||
|
- Website Contributor or Reader on all App Service resources
|
||||||
|
- Management Group Reader for organizational hierarchy access
|
||||||
|
- Reader access on target subscriptions for deployment API calls
|
||||||
|
|
||||||
|
.FUNCTIONALITY
|
||||||
|
- Multi-subscription Web App discovery and slot enumeration
|
||||||
|
- Security configuration analysis and compliance checking
|
||||||
|
- Deployment tracking via Azure Management REST API
|
||||||
|
- Identity configuration analysis and managed identity reporting
|
||||||
|
- CSV export with comprehensive web application metadata
|
||||||
|
#>
|
||||||
|
|
||||||
|
#Requires -Modules Az.Websites, Az.Resources, Az.Accounts
|
||||||
|
#Requires -Version 5.1
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param()
|
||||||
|
|
||||||
|
# Uncomment the following line if authentication is required
|
||||||
|
#Connect-AzAccount
|
||||||
|
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Retrieves the last successful deployment date for an Azure Web App or deployment slot.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This function calls the Azure Management REST API to retrieve deployment history
|
||||||
|
and extract the last successful deployment timestamp for a Web App or slot.
|
||||||
|
|
||||||
|
.PARAMETER siteName
|
||||||
|
The name of the Azure Web App.
|
||||||
|
|
||||||
|
.PARAMETER resourceGroupName
|
||||||
|
The name of the resource group containing the Web App.
|
||||||
|
|
||||||
|
.PARAMETER subscriptionId
|
||||||
|
The subscription ID containing the Web App.
|
||||||
|
|
||||||
|
.PARAMETER slotName
|
||||||
|
Optional. The name of the deployment slot. If not provided, queries the production slot.
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
String. The last successful deployment end time, or empty string if no deployments found.
|
||||||
|
#>
|
||||||
function GetDeployment {
|
function GetDeployment {
|
||||||
|
[CmdletBinding()]
|
||||||
param (
|
param (
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
[string] $siteName,
|
[string] $siteName,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
[string] $resourceGroupName,
|
[string] $resourceGroupName,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
[string] $subscriptionId,
|
[string] $subscriptionId,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
[string] $slotName = ""
|
[string] $slotName = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
$access_token = (Get-AzAccessToken -TenantId "e9792fd7-4044-47e7-a40d-3fba46f1cd09").Token
|
try {
|
||||||
|
# Get current Azure context for tenant ID
|
||||||
|
$context = Get-AzContext
|
||||||
|
if (-not $context) {
|
||||||
|
Write-Warning "No Azure context found for deployment API call"
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
$url = ""
|
# Get access token for Azure Management API
|
||||||
|
$accessTokenInfo = Get-AzAccessToken -TenantId $context.Tenant.Id
|
||||||
|
$access_token = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto([System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($accessTokenInfo.Token))
|
||||||
|
|
||||||
|
# Build API URL for deployments
|
||||||
if ($slotName -ne "") {
|
if ($slotName -ne "") {
|
||||||
$url = "https://management.azure.com/subscriptions/$subscriptionId/resourceGroups/$resourceGroupName/providers/Microsoft.Web/sites/$siteName/slots/$slotName/deployments?api-version=2022-03-01"
|
$url = "https://management.azure.com/subscriptions/$subscriptionId/resourceGroups/$resourceGroupName/providers/Microsoft.Web/sites/$siteName/slots/$slotName/deployments?api-version=2022-03-01"
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
$url = "https://management.azure.com/subscriptions/$subscriptionId/resourceGroups/$resourceGroupName/providers/Microsoft.Web/sites/$siteName/deployments?api-version=2022-03-01"
|
$url = "https://management.azure.com/subscriptions/$subscriptionId/resourceGroups/$resourceGroupName/providers/Microsoft.Web/sites/$siteName/deployments?api-version=2022-03-01"
|
||||||
}
|
}
|
||||||
|
|
||||||
# GET https://management.azure.com/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Web/sites/{name}/slots/{slot}/deploymentStatus/{deploymentStatusId}?api-version=2022-03-01
|
# Make API call to get deployment history
|
||||||
$head = @{ Authorization =" Bearer $access_token" }
|
$headers = @{ Authorization = "Bearer $access_token" }
|
||||||
$response = Invoke-RestMethod -Uri $url -Method GET -Headers $head
|
$response = Invoke-RestMethod -Uri $url -Method GET -Headers $headers -ErrorAction SilentlyContinue
|
||||||
$response | ForEach-Object {
|
|
||||||
$responseValue = $_.value
|
# Extract last successful deployment date
|
||||||
if ($responseValue.Length -gt 0) {
|
if ($response -and $response.value -and $response.value.Length -gt 0) {
|
||||||
return $responseValue[0].properties.last_success_end_time
|
$lastDeployment = $response.value[0]
|
||||||
|
if ($lastDeployment.properties.last_success_end_time) {
|
||||||
|
return $lastDeployment.properties.last_success_end_time
|
||||||
}
|
}
|
||||||
else {
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
Write-Warning "Error retrieving deployment info for $siteName`: $($_.Exception.Message)"
|
||||||
return ""
|
return ""
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,45 +190,114 @@ class ResourceCheck {
|
|||||||
[string] $LastDeployDate = ""
|
[string] $LastDeployDate = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Initialize script execution
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$ProgressPreference = "SilentlyContinue"
|
||||||
|
$startTime = Get-Date
|
||||||
|
|
||||||
Write-Host "======================================================================================================================================================================"
|
Write-Host "======================================================================================================================================================================"
|
||||||
Write-Host "Creating webapp resource overview."
|
Write-Host "🌐 AZURE WEB APPS INVENTORY GENERATOR" -ForegroundColor Cyan
|
||||||
Write-Host "======================================================================================================================================================================"
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "⏰ Started: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Validate Azure authentication
|
||||||
|
$context = Get-AzContext
|
||||||
|
if (-not $context) {
|
||||||
|
throw "No Azure context found. Please run Connect-AzAccount first."
|
||||||
|
}
|
||||||
|
|
||||||
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
Write-Host "🔐 Authenticated as: $($context.Account.Id)" -ForegroundColor Green
|
||||||
$fileName = ".\$date azure_webapps.csv"
|
Write-Host "🏢 Tenant: $($context.Tenant.Id)" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Initialize output file and tracking variables
|
||||||
|
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
||||||
|
$fileName = ".\$date azure_webapps.csv"
|
||||||
|
Write-Host "📄 Output file: $fileName" -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
$managementGroups = Get-AzManagementGroup
|
# Initialize counters for progress tracking
|
||||||
|
$totalWebApps = 0
|
||||||
|
$totalSlots = 0
|
||||||
|
$processedManagementGroups = 0
|
||||||
|
$processedSubscriptions = 0
|
||||||
|
$securityIssues = @()
|
||||||
|
$deploymentTrackingErrors = 0
|
||||||
|
|
||||||
foreach ($managementGroup in $managementGroups)
|
# Get management groups for organizational structure
|
||||||
{
|
Write-Host "🏗️ Discovering management group structure..." -ForegroundColor Cyan
|
||||||
|
$managementGroups = Get-AzManagementGroup
|
||||||
|
Write-Host "✅ Found $($managementGroups.Count) management groups" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Process each management group
|
||||||
|
foreach ($managementGroup in $managementGroups) {
|
||||||
|
$processedManagementGroups++
|
||||||
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
||||||
Write-Host "Management group [$($managementGroup.Name)]"
|
Write-Host "🏗️ Management Group [$($managementGroup.DisplayName)]" -ForegroundColor Cyan
|
||||||
|
Write-Host " ID: $($managementGroup.Id)" -ForegroundColor DarkGray
|
||||||
|
|
||||||
$subscriptions = Get-AzManagementGroupSubscription -Group $managementGroup.Name | Where-Object State -eq "Active" | Where-Object DisplayName -NotLike "Visual Studio*"
|
try {
|
||||||
|
# Get active non-Visual Studio subscriptions in this management group
|
||||||
|
$subscriptions = Get-AzManagementGroupSubscription -Group $managementGroup.Name |
|
||||||
|
Where-Object State -eq "Active" |
|
||||||
|
Where-Object DisplayName -NotLike "Visual Studio*"
|
||||||
|
Write-Host " 📋 Found $($subscriptions.Count) active subscriptions (excluding Visual Studio)" -ForegroundColor Green
|
||||||
|
|
||||||
foreach ($subscription in $subscriptions)
|
foreach ($subscription in $subscriptions) {
|
||||||
{
|
$processedSubscriptions++
|
||||||
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
Write-Host ""
|
||||||
|
Write-Host " 🔄 Processing Subscription: $($subscription.DisplayName)" -ForegroundColor Yellow
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Extract subscription ID and set context
|
||||||
$scope = $subscription.Id.Substring($subscription.Parent.Length, $subscription.Id.Length - $subscription.Parent.Length)
|
$scope = $subscription.Id.Substring($subscription.Parent.Length, $subscription.Id.Length - $subscription.Parent.Length)
|
||||||
$subscriptionId = $scope.Replace("/subscriptions/", "")
|
$subscriptionId = $scope.Replace("/subscriptions/", "")
|
||||||
Write-Host "Subscription [$($subscription.DisplayName) - $subscriptionId]"
|
Write-Host " ID: $subscriptionId" -ForegroundColor DarkGray
|
||||||
Set-AzContext -SubscriptionId $subscriptionId | Out-Null
|
|
||||||
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
|
||||||
|
|
||||||
|
Set-AzContext -SubscriptionId $subscriptionId | Out-Null
|
||||||
|
|
||||||
|
# Get all resource groups in the subscription
|
||||||
$allResourceGroups = Get-AzResourceGroup
|
$allResourceGroups = Get-AzResourceGroup
|
||||||
[ResourceCheck[]]$Result = @()
|
[ResourceCheck[]]$Result = @()
|
||||||
|
$subscriptionWebApps = 0
|
||||||
|
$subscriptionSlots = 0
|
||||||
|
|
||||||
foreach ($group in $allResourceGroups) {
|
foreach ($group in $allResourceGroups) {
|
||||||
|
Write-Host " 📁 Resource Group: $($group.ResourceGroupName)" -ForegroundColor DarkCyan -NoNewline
|
||||||
|
|
||||||
$allWebApps = Get-AzWebApp -ResourceGroupName $group.ResourceGroupName
|
try {
|
||||||
|
# Get Web Apps in this resource group
|
||||||
|
$allWebApps = Get-AzWebApp -ResourceGroupName $group.ResourceGroupName -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
if ($allWebApps.Count -gt 0) {
|
||||||
|
Write-Host " - Found $($allWebApps.Count) Web Apps" -ForegroundColor Green
|
||||||
|
$subscriptionWebApps += $allWebApps.Count
|
||||||
|
} else {
|
||||||
|
Write-Host " - No Web Apps" -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($webApp in $allWebApps) {
|
foreach ($webApp in $allWebApps) {
|
||||||
|
Write-Host " 🌐 Web App: $($webApp.Name)" -ForegroundColor White
|
||||||
|
|
||||||
Write-Host $webApp.Name
|
try {
|
||||||
|
# Analyze security configuration
|
||||||
|
if (-not $webApp.HttpsOnly) {
|
||||||
|
$securityIssues += "🔓 HTTPS not enforced: $($webApp.Name) in $($group.ResourceGroupName)"
|
||||||
|
}
|
||||||
|
if ($webApp.SiteConfig.MinTlsVersion -lt "1.2") {
|
||||||
|
$securityIssues += "⚠️ TLS version below 1.2: $($webApp.Name) (version: $($webApp.SiteConfig.MinTlsVersion))"
|
||||||
|
}
|
||||||
|
if ($webApp.SiteConfig.RemoteDebuggingEnabled) {
|
||||||
|
$securityIssues += "🐛 Remote debugging enabled: $($webApp.Name) in $($group.ResourceGroupName)"
|
||||||
|
}
|
||||||
|
if ($webApp.SiteConfig.FtpsState -eq "AllAllowed") {
|
||||||
|
$securityIssues += "📂 FTPS allows unencrypted connections: $($webApp.Name) in $($group.ResourceGroupName)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create resource check object for Web App
|
||||||
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
||||||
$resourceCheck.ResourceId = $webApp.Id
|
$resourceCheck.ResourceId = $webApp.Id
|
||||||
$resourceCheck.Kind = $webApp.Kind
|
$resourceCheck.Kind = $webApp.Kind
|
||||||
@@ -125,19 +323,44 @@ foreach ($managementGroup in $managementGroups)
|
|||||||
$resourceCheck.Prop_FtpsState = $webApp.SiteConfig.FtpsState
|
$resourceCheck.Prop_FtpsState = $webApp.SiteConfig.FtpsState
|
||||||
$resourceCheck.Prop_Http20Enabled = $webApp.SiteConfig.Http20Enabled
|
$resourceCheck.Prop_Http20Enabled = $webApp.SiteConfig.Http20Enabled
|
||||||
$resourceCheck.Prop_Identity = $webApp.Identity.Type
|
$resourceCheck.Prop_Identity = $webApp.Identity.Type
|
||||||
$resourceCheck.LastDeployDate = GetDeployment -siteName $webApp.Name -resourceGroupName $group.ResourceGroupName -subscriptionId $subscriptionId
|
|
||||||
|
# Get deployment information with error handling
|
||||||
|
$deploymentDate = GetDeployment -siteName $webApp.Name -resourceGroupName $group.ResourceGroupName -subscriptionId $subscriptionId
|
||||||
|
if ([string]::IsNullOrEmpty($deploymentDate)) {
|
||||||
|
$deploymentTrackingErrors++
|
||||||
|
}
|
||||||
|
$resourceCheck.LastDeployDate = $deploymentDate
|
||||||
|
|
||||||
$Result += $resourceCheck
|
$Result += $resourceCheck
|
||||||
|
$totalWebApps++
|
||||||
|
|
||||||
$allSlots = Get-AzWebAppSlot -Name $webApp.Name -ResourceGroupName $webApp.ResourceGroup
|
# Process deployment slots
|
||||||
|
try {
|
||||||
|
$allSlots = Get-AzWebAppSlot -Name $webApp.Name -ResourceGroupName $webApp.ResourceGroup -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
if ($allSlots.Count -gt 0) {
|
||||||
|
Write-Host " 🔄 Found $($allSlots.Count) deployment slots" -ForegroundColor Cyan
|
||||||
|
$subscriptionSlots += $allSlots.Count
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($slotTemp in $allSlots) {
|
foreach ($slotTemp in $allSlots) {
|
||||||
|
try {
|
||||||
Write-Host $slotTemp.Name
|
Write-Host " 📍 Slot: $($slotTemp.Name)" -ForegroundColor DarkCyan
|
||||||
|
|
||||||
[string] $slotName = $slotTemp.Name.Split("/")[1]
|
[string] $slotName = $slotTemp.Name.Split("/")[1]
|
||||||
$slot = Get-AzWebAppSlot -Name $webApp.Name -ResourceGroupName $webApp.ResourceGroup -Slot $slotName
|
$slot = Get-AzWebAppSlot -Name $webApp.Name -ResourceGroupName $webApp.ResourceGroup -Slot $slotName
|
||||||
|
|
||||||
|
# Analyze slot security configuration
|
||||||
|
if (-not $slot.HttpsOnly) {
|
||||||
|
$securityIssues += "🔓 HTTPS not enforced on slot: $($slot.Name) in $($webApp.ResourceGroup)"
|
||||||
|
}
|
||||||
|
if ($slot.SiteConfig.MinTlsVersion -lt "1.2") {
|
||||||
|
$securityIssues += "⚠️ TLS version below 1.2 on slot: $($slot.Name) (version: $($slot.SiteConfig.MinTlsVersion))"
|
||||||
|
}
|
||||||
|
if ($slot.SiteConfig.RemoteDebuggingEnabled) {
|
||||||
|
$securityIssues += "🐛 Remote debugging enabled on slot: $($slot.Name) in $($webApp.ResourceGroup)"
|
||||||
|
}
|
||||||
|
|
||||||
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
[ResourceCheck] $resourceCheck = [ResourceCheck]::new()
|
||||||
$resourceCheck.ResourceId = $slot.Id
|
$resourceCheck.ResourceId = $slot.Id
|
||||||
$resourceCheck.Kind = $slot.Kind
|
$resourceCheck.Kind = $slot.Kind
|
||||||
@@ -164,16 +387,159 @@ foreach ($managementGroup in $managementGroups)
|
|||||||
$resourceCheck.Prop_Http20Enabled = $slot.SiteConfig.Http20Enabled
|
$resourceCheck.Prop_Http20Enabled = $slot.SiteConfig.Http20Enabled
|
||||||
$resourceCheck.Prop_Identity = $slot.Identity.Type
|
$resourceCheck.Prop_Identity = $slot.Identity.Type
|
||||||
|
|
||||||
$resourceCheck.LastDeployDate = GetDeployment -siteName $webApp.Name -resourceGroupName $group.ResourceGroupName -subscriptionId $subscriptionId -slotName $slotName
|
# Get deployment information for slot
|
||||||
|
$slotDeploymentDate = GetDeployment -siteName $webApp.Name -resourceGroupName $group.ResourceGroupName -subscriptionId $subscriptionId -slotName $slotName
|
||||||
|
if ([string]::IsNullOrEmpty($slotDeploymentDate)) {
|
||||||
|
$deploymentTrackingErrors++
|
||||||
|
}
|
||||||
|
$resourceCheck.LastDeployDate = $slotDeploymentDate
|
||||||
|
|
||||||
$Result += $resourceCheck
|
$Result += $resourceCheck
|
||||||
|
$totalSlots++
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
Write-Host " ❌ Error processing slot '$($slotTemp.Name)': $($_.Exception.Message)" -ForegroundColor Red
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host " ❌ Error getting slots for '$($webApp.Name)': $($_.Exception.Message)" -ForegroundColor Red
|
||||||
}
|
}
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
Write-Host " ❌ Error processing Web App '$($webApp.Name)': $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host " - ❌ Error accessing resource group: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Export results for this subscription
|
||||||
|
if ($Result.Count -gt 0) {
|
||||||
$Result | Export-Csv -Path $fileName -Append -NoTypeInformation
|
$Result | Export-Csv -Path $fileName -Append -NoTypeInformation
|
||||||
|
Write-Host " ✅ Exported $($Result.Count) Web App resources from subscription" -ForegroundColor Green
|
||||||
|
Write-Host " Web Apps: $subscriptionWebApps, Deployment Slots: $subscriptionSlots" -ForegroundColor DarkGray
|
||||||
|
} else {
|
||||||
|
Write-Host " ℹ️ No Web Apps found in subscription" -ForegroundColor DarkYellow
|
||||||
}
|
}
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
Write-Host " ❌ Error processing subscription: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host " ❌ Error accessing management group: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Calculate execution time and generate comprehensive summary report
|
||||||
|
$endTime = Get-Date
|
||||||
|
$executionTime = $endTime - $startTime
|
||||||
|
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "📊 AZURE WEB APPS INVENTORY SUMMARY" -ForegroundColor Cyan
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "⏰ Execution Time: $($executionTime.ToString('hh\:mm\:ss'))" -ForegroundColor Green
|
||||||
|
Write-Host "🏗️ Management Groups Processed: $processedManagementGroups" -ForegroundColor Green
|
||||||
|
Write-Host "📋 Subscriptions Processed: $processedSubscriptions" -ForegroundColor Green
|
||||||
|
Write-Host "🌐 Total Web Apps Discovered: $totalWebApps" -ForegroundColor Green
|
||||||
|
Write-Host "🔄 Total Deployment Slots: $totalSlots" -ForegroundColor Cyan
|
||||||
|
Write-Host "📄 Results Exported To: $fileName" -ForegroundColor Yellow
|
||||||
|
|
||||||
|
if (Test-Path $fileName) {
|
||||||
|
$fileSize = (Get-Item $fileName).Length
|
||||||
|
Write-Host "💾 File Size: $([math]::Round($fileSize/1KB, 2)) KB" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
# Display deployment tracking statistics
|
||||||
|
if ($deploymentTrackingErrors -gt 0) {
|
||||||
|
Write-Host "⚠️ Deployment Tracking Issues: $deploymentTrackingErrors Web Apps/slots" -ForegroundColor Yellow
|
||||||
|
Write-Host " (This may be due to API permissions or apps without deployment history)" -ForegroundColor DarkGray
|
||||||
|
} else {
|
||||||
|
Write-Host "✅ Deployment Tracking: Successfully retrieved for all Web Apps" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
# Display security analysis summary
|
||||||
|
if ($securityIssues.Count -gt 0) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "🚨 SECURITY ANALYSIS SUMMARY" -ForegroundColor Red
|
||||||
|
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
||||||
|
Write-Host "Found $($securityIssues.Count) potential security concerns:" -ForegroundColor Yellow
|
||||||
|
foreach ($issue in $securityIssues | Select-Object -First 15) {
|
||||||
|
Write-Host " $issue" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
if ($securityIssues.Count -gt 15) {
|
||||||
|
Write-Host " ... and $($securityIssues.Count - 15) more issues (see CSV for complete details)" -ForegroundColor DarkYellow
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "📋 Security Recommendations:" -ForegroundColor Cyan
|
||||||
|
Write-Host " • Enforce HTTPS-only access on all Web Apps and slots" -ForegroundColor White
|
||||||
|
Write-Host " • Upgrade minimum TLS version to 1.2 or higher" -ForegroundColor White
|
||||||
|
Write-Host " • Disable remote debugging on production Web Apps" -ForegroundColor White
|
||||||
|
Write-Host " • Configure FTPS to require SSL/TLS (disable 'AllAllowed')" -ForegroundColor White
|
||||||
|
Write-Host " • Enable managed identities for secure Azure service authentication" -ForegroundColor White
|
||||||
|
} else {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "✅ SECURITY ANALYSIS: No major security concerns detected" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
# Calculate and display Web App statistics
|
||||||
|
$totalWebAppResources = $totalWebApps + $totalSlots
|
||||||
|
$averageSlotsPerApp = if ($totalWebApps -gt 0) { [math]::Round($totalSlots / $totalWebApps, 1) } else { 0 }
|
||||||
|
|
||||||
|
if ($totalWebApps -gt 0) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "📈 WEB APP DEPLOYMENT ANALYSIS:" -ForegroundColor Cyan
|
||||||
|
Write-Host " Total Web App Resources: $totalWebAppResources (Apps + Slots)" -ForegroundColor White
|
||||||
|
Write-Host " Average Deployment Slots per App: $averageSlotsPerApp" -ForegroundColor White
|
||||||
|
|
||||||
|
if ($averageSlotsPerApp -gt 1) {
|
||||||
|
Write-Host " 🔄 High Slot Usage: Good deployment strategy with staging/testing slots" -ForegroundColor Green
|
||||||
|
} elseif ($averageSlotsPerApp -gt 0.5) {
|
||||||
|
Write-Host " 📊 Moderate Slot Usage: Some apps using deployment slots" -ForegroundColor Yellow
|
||||||
|
} else {
|
||||||
|
Write-Host " 💡 Low Slot Usage: Consider implementing deployment slots for safer deployments" -ForegroundColor White
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "📈 NEXT STEPS:" -ForegroundColor Cyan
|
||||||
|
Write-Host " 1. Review the generated CSV file for detailed Web App configurations" -ForegroundColor White
|
||||||
|
Write-Host " 2. Address security recommendations identified above" -ForegroundColor White
|
||||||
|
Write-Host " 3. Analyze deployment patterns and slot usage for optimization" -ForegroundColor White
|
||||||
|
Write-Host " 4. Implement monitoring and alerting for critical Web Apps" -ForegroundColor White
|
||||||
|
Write-Host " 5. Review governance tags for compliance with organizational standards" -ForegroundColor White
|
||||||
|
Write-Host " 6. Consider implementing Azure Application Insights for application monitoring" -ForegroundColor White
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "✅ Azure Web Apps inventory completed successfully!" -ForegroundColor Green
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "❌ CRITICAL ERROR OCCURRED" -ForegroundColor Red
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "Error: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
Write-Host "Line: $($_.InvocationInfo.ScriptLineNumber)" -ForegroundColor Red
|
||||||
|
Write-Host "Position: $($_.InvocationInfo.OffsetInLine)" -ForegroundColor Red
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "🔧 TROUBLESHOOTING STEPS:" -ForegroundColor Yellow
|
||||||
|
Write-Host " 1. Verify you are authenticated to Azure (Connect-AzAccount)" -ForegroundColor White
|
||||||
|
Write-Host " 2. Ensure you have Website Contributor or Reader permissions on App Service resources" -ForegroundColor White
|
||||||
|
Write-Host " 3. Check that the Management Group Reader role is assigned" -ForegroundColor White
|
||||||
|
Write-Host " 4. Verify Azure PowerShell modules are installed and up to date" -ForegroundColor White
|
||||||
|
Write-Host " 5. Confirm that deployment API permissions are available for deployment tracking" -ForegroundColor White
|
||||||
|
Write-Host " 6. Try running the script with -Verbose for additional diagnostic information" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "📞 For additional support, contact the Cloud Engineering team" -ForegroundColor Cyan
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
|
||||||
|
# Ensure we exit with error code for automation scenarios
|
||||||
|
exit 1
|
||||||
|
} finally {
|
||||||
|
# Reset progress preference
|
||||||
|
$ProgressPreference = "Continue"
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "======================================================================================================================================================================"
|
|
||||||
Write-Host "Done."
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,48 +1,147 @@
|
|||||||
|
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Exports Azure DevOps pipeline information to a CSV file.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This script retrieves all build pipelines from an Azure DevOps project and exports detailed information
|
||||||
|
about each pipeline to a CSV file. It collects pipeline metadata including ID, name, path, type,
|
||||||
|
author, creation date, pipeline type (Classic or YAML), and edit URLs.
|
||||||
|
|
||||||
|
.PARAMETER Token
|
||||||
|
The Azure DevOps Personal Access Token (PAT) used for authentication. This token must have
|
||||||
|
'Build (Read)' permissions to access pipeline information.
|
||||||
|
|
||||||
|
.PARAMETER Organization
|
||||||
|
The Azure DevOps organization name. Defaults to "effectory" if not specified.
|
||||||
|
|
||||||
|
.PARAMETER Project
|
||||||
|
The Azure DevOps project name. Defaults to "Survey%20Software" if not specified.
|
||||||
|
Note: URL-encoded project names should be used (spaces as %20).
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\Pipelines.ps1 -Token "your-personal-access-token"
|
||||||
|
|
||||||
|
Exports pipeline information using the default organization and project settings.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\Pipelines.ps1 -Token "your-pat-token" -Organization "myorg" -Project "MyProject"
|
||||||
|
|
||||||
|
Exports pipeline information for a specific organization and project.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\Pipelines.ps1 -Token "your-pat-token" -Project "My%20Custom%20Project"
|
||||||
|
|
||||||
|
Exports pipeline information for a custom project (with URL-encoded name) using the default organization.
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
Creates a timestamped CSV file in the current directory with the format: "yyyy-MM-dd HHmm pipelines.csv"
|
||||||
|
The CSV contains the following columns:
|
||||||
|
- Id: Pipeline ID
|
||||||
|
- Name: Pipeline name
|
||||||
|
- Path: Pipeline folder path
|
||||||
|
- Type: Pipeline type (build/release)
|
||||||
|
- Author: Pipeline author
|
||||||
|
- CreatedDate: Pipeline creation date
|
||||||
|
- PipelineType: Classic or YAML
|
||||||
|
- PipelineEditUrl: Direct URL to edit the pipeline
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Author: Cloud Engineering Team
|
||||||
|
Created: 2025
|
||||||
|
Requires: PowerShell 5.1 or later
|
||||||
|
Dependencies: Internet connectivity to Azure DevOps
|
||||||
|
|
||||||
|
The script uses Azure DevOps REST API version 7.1-preview.7 to retrieve pipeline information.
|
||||||
|
Ensure your Personal Access Token has the necessary permissions before running the script.
|
||||||
|
|
||||||
|
.LINK
|
||||||
|
https://docs.microsoft.com/en-us/rest/api/azure/devops/build/definitions/list
|
||||||
|
#>
|
||||||
|
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true, HelpMessage = "Azure DevOps Personal Access Token with Build (Read) permissions")]
|
||||||
|
[string]$Token,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false, HelpMessage = "Azure DevOps organization name")]
|
||||||
|
[string]$Organization = "effectory",
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false, HelpMessage = "Azure DevOps project name (URL-encoded if contains spaces)")]
|
||||||
|
[string]$Project = "Survey%20Software"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Define a class to structure pipeline information
|
||||||
class PipelineInfo {
|
class PipelineInfo {
|
||||||
[string] $Id = ""
|
[string] $Id = "" # Pipeline unique identifier
|
||||||
[string] $Name = ""
|
[string] $Name = "" # Pipeline display name
|
||||||
[string] $Path = ""
|
[string] $Path = "" # Pipeline folder path in Azure DevOps
|
||||||
[string] $Type = ""
|
[string] $Type = "" # Pipeline type (build/release)
|
||||||
[string] $Author = ""
|
[string] $Author = "" # Pipeline creator
|
||||||
[string] $CreatedDate = ""
|
[string] $CreatedDate = "" # Pipeline creation timestamp
|
||||||
[string] $PipelineType = ""
|
[string] $PipelineType = "" # Classic or YAML pipeline type
|
||||||
[string] $PipelineEditUrl = ""
|
[string] $PipelineEditUrl = "" # Direct URL to edit the pipeline
|
||||||
}
|
}
|
||||||
|
|
||||||
$token = "hyrvwxicogy37djvmhkwrcdexokcrpyudkk4j2n3n7gnjb5wsv5a"
|
# Encode the Personal Access Token for Basic Authentication
|
||||||
$token = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes(":$($token)"))
|
# Azure DevOps requires the token to be base64 encoded with a colon prefix
|
||||||
$organization = "effectory"
|
$encodedToken = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes(":$($Token)"))
|
||||||
$project = "Survey%20Software"
|
|
||||||
$head = @{ Authorization =" Basic $token" }
|
|
||||||
|
|
||||||
|
# Create authentication header for REST API calls
|
||||||
|
$head = @{ Authorization =" Basic $encodedToken" }
|
||||||
|
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Determines if a pipeline is Classic or YAML based.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Makes an additional API call to determine the pipeline process type.
|
||||||
|
Returns 1 for Classic pipelines, 2 for YAML pipelines.
|
||||||
|
|
||||||
|
.PARAMETER pipeLineId
|
||||||
|
The unique identifier of the pipeline to check.
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
Integer value: 1 = Classic pipeline, 2 = YAML pipeline
|
||||||
|
#>
|
||||||
function GetPipelineType {
|
function GetPipelineType {
|
||||||
|
|
||||||
param (
|
param (
|
||||||
[int] $pipeLineId
|
[int] $pipeLineId
|
||||||
)
|
)
|
||||||
|
|
||||||
$url = "https://dev.azure.com/$organization/$project/_apps/hub/ms.vss-build-web.ci-designer-hub?pipelineId=$pipeLineId&__rt=fps&__ver=2"
|
# Call Azure DevOps internal API to get pipeline process type
|
||||||
|
$url = "https://dev.azure.com/$Organization/$Project/_apps/hub/ms.vss-build-web.ci-designer-hub?pipelineId=$pipeLineId&__rt=fps&__ver=2"
|
||||||
$response = Invoke-RestMethod -Uri $url -Method GET -Headers $head
|
$response = Invoke-RestMethod -Uri $url -Method GET -Headers $head
|
||||||
|
|
||||||
|
# Extract the definition process type from the response
|
||||||
return $response.fps.dataProviders.data."ms.vss-build-web.pipeline-detail-data-provider".definitionProcessType
|
return $response.fps.dataProviders.data."ms.vss-build-web.pipeline-detail-data-provider".definitionProcessType
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Generate timestamped filename for the output CSV
|
||||||
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
||||||
$fileName = ".\$date pipelines.csv"
|
$fileName = ".\$date pipelines.csv"
|
||||||
|
|
||||||
|
# Display script execution banner
|
||||||
Write-Host "========================================================================================================================================================================"
|
Write-Host "========================================================================================================================================================================"
|
||||||
Write-Host "Creating service connection overview."
|
Write-Host "Creating pipeline overview for Organization: $Organization, Project: $Project"
|
||||||
|
Write-Host "Output file: $fileName"
|
||||||
Write-Host "========================================================================================================================================================================"
|
Write-Host "========================================================================================================================================================================"
|
||||||
|
|
||||||
|
|
||||||
$url="https://dev.azure.com/$organization/$project/_apis/build/definitions?api-version=7.1-preview.7"
|
# Call Azure DevOps REST API to get all build definitions
|
||||||
|
$url="https://dev.azure.com/$Organization/$Project/_apis/build/definitions?api-version=7.1-preview.7"
|
||||||
$response = Invoke-RestMethod -Uri $url -Method GET -Headers $head
|
$response = Invoke-RestMethod -Uri $url -Method GET -Headers $head
|
||||||
|
|
||||||
|
# Initialize array to store pipeline information
|
||||||
[PipelineInfo[]]$Result = @()
|
[PipelineInfo[]]$Result = @()
|
||||||
$response.value | ForEach-Object {
|
|
||||||
|
|
||||||
|
# Process each pipeline returned from the API
|
||||||
|
$response.value | ForEach-Object {
|
||||||
|
Write-Host "Processing pipeline: $($_.name)" -ForegroundColor Green
|
||||||
|
|
||||||
|
# Determine if this is a Classic or YAML pipeline
|
||||||
$definitionProcessType = GetPipelineType -pipeLineId $_.id
|
$definitionProcessType = GetPipelineType -pipeLineId $_.id
|
||||||
|
|
||||||
|
# Create new pipeline info object and populate properties
|
||||||
[PipelineInfo] $pipelineInfo = [PipelineInfo]::new()
|
[PipelineInfo] $pipelineInfo = [PipelineInfo]::new()
|
||||||
$pipelineInfo.Id = $_.id
|
$pipelineInfo.Id = $_.id
|
||||||
$pipelineInfo.Name = $_.name
|
$pipelineInfo.Name = $_.name
|
||||||
@@ -51,15 +150,22 @@ $response.value | ForEach-Object {
|
|||||||
$pipelineInfo.Author = $_.authoredby.DisplayName
|
$pipelineInfo.Author = $_.authoredby.DisplayName
|
||||||
$pipelineInfo.CreatedDate = $_.createdDate
|
$pipelineInfo.CreatedDate = $_.createdDate
|
||||||
$pipelineInfo.PipelineType = $definitionProcessType -eq 1 ? "Classic" : "Yaml"
|
$pipelineInfo.PipelineType = $definitionProcessType -eq 1 ? "Classic" : "Yaml"
|
||||||
|
|
||||||
|
# Generate appropriate edit URL based on pipeline type
|
||||||
$pipelineInfo.PipelineEditUrl = $definitionProcessType -eq 1 ?
|
$pipelineInfo.PipelineEditUrl = $definitionProcessType -eq 1 ?
|
||||||
"https://dev.azure.com/$organization/$project/_apps/hub/ms.vss-ciworkflow.build-ci-hub?_a=edit-build-definition&id=$($_.id)" :
|
"https://dev.azure.com/$Organization/$Project/_apps/hub/ms.vss-ciworkflow.build-ci-hub?_a=edit-build-definition&id=$($_.id)" :
|
||||||
"https://dev.azure.com/$organization/$project/_apps/hub/ms.vss-build-web.ci-designer-hub?pipelineId=$($_.id)&branch=master"
|
"https://dev.azure.com/$Organization/$Project/_apps/hub/ms.vss-build-web.ci-designer-hub?pipelineId=$($_.id)&branch=master"
|
||||||
|
|
||||||
|
# Add to results array
|
||||||
$Result += $pipelineInfo
|
$Result += $pipelineInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
# Export results to CSV file
|
||||||
|
$Result | Export-Csv -Path $fileName -NoTypeInformation
|
||||||
|
|
||||||
}
|
# Display completion summary
|
||||||
|
Write-Host "========================================================================================================================================================================"
|
||||||
$Result | Export-Csv -Path $fileName -Append -NoTypeInformation
|
Write-Host "Export completed successfully!" -ForegroundColor Green
|
||||||
|
Write-Host "Total pipelines processed: $($Result.Count)" -ForegroundColor Yellow
|
||||||
|
Write-Host "Output file: $fileName" -ForegroundColor Yellow
|
||||||
Write-Host "========================================================================================================================================================================"
|
Write-Host "========================================================================================================================================================================"
|
||||||
Write-Host "Done."
|
|
||||||
@@ -1,36 +1,121 @@
|
|||||||
|
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Exports Azure DevOps pull request information across all repositories to a CSV file.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This script retrieves all repositories from an Azure DevOps project and then collects
|
||||||
|
detailed information about all pull requests (active, completed, and abandoned) from each
|
||||||
|
repository. The information is exported to a timestamped CSV file for analysis and reporting.
|
||||||
|
|
||||||
|
.PARAMETER Organization
|
||||||
|
The Azure DevOps organization name. Defaults to "effectory" if not specified.
|
||||||
|
|
||||||
|
.PARAMETER Project
|
||||||
|
The Azure DevOps project name. Defaults to "survey software" if not specified.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\PullRequests.ps1
|
||||||
|
|
||||||
|
Exports pull request information using the default organization and project settings.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\PullRequests.ps1 -Organization "myorg" -Project "myproject"
|
||||||
|
|
||||||
|
Exports pull request information for a specific organization and project.
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
Creates a timestamped CSV file in the current directory with the format: "yyyy-MM-dd HHmm pull requests.csv"
|
||||||
|
The CSV contains the following columns:
|
||||||
|
- RepositoryId: Repository unique identifier
|
||||||
|
- RepositoryName: Repository name
|
||||||
|
- DefaultBranch: Repository default branch
|
||||||
|
- RepositoryWebUrl: Repository web URL
|
||||||
|
- PullRequestId: Pull request unique identifier
|
||||||
|
- PullRequestDate: Pull request creation date
|
||||||
|
- PullRequestName: Pull request title
|
||||||
|
- PullRequestCreatedBy: Pull request author
|
||||||
|
- PullRequestReviewers: Comma-separated list of reviewers
|
||||||
|
- PullRequestStatus: Pull request status (Active, Completed, Abandoned)
|
||||||
|
- PullRequestWebUrl: Direct URL to the pull request
|
||||||
|
- CompletionBypassReason: Reason for bypassing completion requirements (if any)
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Author: Cloud Engineering Team
|
||||||
|
Created: 2025
|
||||||
|
Requires: PowerShell 5.1 or later, Azure CLI installed and authenticated
|
||||||
|
Dependencies: Azure CLI (az) must be installed and user must be authenticated
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
- Install Azure CLI: https://docs.microsoft.com/en-us/cli/azure/install-azure-cli
|
||||||
|
- Authenticate: az login
|
||||||
|
- Set default subscription if needed: az account set --subscription "subscription-name"
|
||||||
|
|
||||||
|
The script processes all active repositories in the specified project and retrieves
|
||||||
|
all pull requests regardless of their status (active, completed, abandoned).
|
||||||
|
|
||||||
|
.LINK
|
||||||
|
https://docs.microsoft.com/en-us/cli/azure/repos/pr
|
||||||
|
#>
|
||||||
|
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $false, HelpMessage = "Azure DevOps organization name")]
|
||||||
|
[string]$Organization = "effectory",
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false, HelpMessage = "Azure DevOps project name")]
|
||||||
|
[string]$Project = "survey software"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Define a class to structure pull request information
|
||||||
class PullRequest {
|
class PullRequest {
|
||||||
[string] $RepositoryId = ""
|
[string] $RepositoryId = "" # Repository unique identifier
|
||||||
[string] $RepositoryName = ""
|
[string] $RepositoryName = "" # Repository display name
|
||||||
[string] $DefaultBranch = ""
|
[string] $DefaultBranch = "" # Repository default branch (e.g., main, master)
|
||||||
[string] $RepositoryWebUrl = ""
|
[string] $RepositoryWebUrl = "" # Repository web URL in Azure DevOps
|
||||||
[string] $PullRequestId = ""
|
[string] $PullRequestId = "" # Pull request unique identifier
|
||||||
[string] $PullRequestDate = ""
|
[string] $PullRequestDate = "" # Pull request creation timestamp
|
||||||
[string] $PullRequestName = ""
|
[string] $PullRequestName = "" # Pull request title/name
|
||||||
[string] $PullRequestCreatedBy = ""
|
[string] $PullRequestCreatedBy = "" # Pull request author display name
|
||||||
[string] $PullRequestReviewers = ""
|
[string] $PullRequestReviewers = "" # Comma-separated list of reviewer names
|
||||||
[string] $PullRequestStatus = ""
|
[string] $PullRequestStatus = "" # PR status: Active, Completed, Abandoned
|
||||||
[string] $PullRequestWebUrl = ""
|
[string] $PullRequestWebUrl = "" # Direct URL to view the pull request
|
||||||
[string] $CompletionBypassReason = ""
|
[string] $CompletionBypassReason = "" # Reason for bypassing completion policies
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Generate timestamped filename for the output CSV
|
||||||
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
||||||
$fileName = ".\$date pull requests.csv"
|
$fileName = ".\$date pull requests.csv"
|
||||||
|
|
||||||
|
# Display script execution banner
|
||||||
Write-Host "========================================================================================================================================================================"
|
Write-Host "========================================================================================================================================================================"
|
||||||
Write-Host "Creating pull request overview."
|
Write-Host "Creating pull request overview for Organization: $Organization, Project: $Project"
|
||||||
|
Write-Host "Output file: $fileName"
|
||||||
|
Write-Host "Note: This script requires Azure CLI to be installed and authenticated (az login)"
|
||||||
Write-Host "========================================================================================================================================================================"
|
Write-Host "========================================================================================================================================================================"
|
||||||
|
|
||||||
$repos = az repos list --organization "https://dev.azure.com/effectory/" --project "survey software" | ConvertFrom-Json | Select-Object | Where-Object { $true -ne $_.isDisabled }
|
# Retrieve all active repositories from the Azure DevOps project
|
||||||
|
Write-Host "Fetching repositories from project '$Project'..." -ForegroundColor Yellow
|
||||||
|
$repos = az repos list --organization "https://dev.azure.com/$Organization/" --project "$Project" | ConvertFrom-Json | Select-Object | Where-Object { $true -ne $_.isDisabled }
|
||||||
|
Write-Host "Found $($repos.Count) active repositories" -ForegroundColor Green
|
||||||
|
|
||||||
|
# Process each repository to collect pull request information
|
||||||
|
$totalPullRequests = 0
|
||||||
foreach ($repo in $repos)
|
foreach ($repo in $repos)
|
||||||
{
|
{
|
||||||
$prs = az repos pr list --project "survey software" --repository "$($repo.name)" --organization "https://dev.azure.com/effectory/" --status all | ConvertFrom-Json | Select-Object
|
Write-Host "Processing repository: $($repo.name)" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Retrieve all pull requests from the current repository (all statuses: active, completed, abandoned)
|
||||||
|
$prs = az repos pr list --project "$Project" --repository "$($repo.name)" --organization "https://dev.azure.com/$Organization/" --status all | ConvertFrom-Json | Select-Object
|
||||||
|
|
||||||
|
Write-Host " Found $($prs.Count) pull requests" -ForegroundColor Gray
|
||||||
|
|
||||||
|
# Initialize array to store pull request information for this repository
|
||||||
[PullRequest[]]$Result = @()
|
[PullRequest[]]$Result = @()
|
||||||
|
|
||||||
|
# Process each pull request in the current repository
|
||||||
foreach ($pr in $prs)
|
foreach ($pr in $prs)
|
||||||
{
|
{
|
||||||
|
# Create new pull request object and populate all properties
|
||||||
[PullRequest] $pullRequest = [PullRequest]::new()
|
[PullRequest] $pullRequest = [PullRequest]::new()
|
||||||
$pullRequest.RepositoryId = $repo.id
|
$pullRequest.RepositoryId = $repo.id
|
||||||
$pullRequest.RepositoryName = $repo.name
|
$pullRequest.RepositoryName = $repo.name
|
||||||
@@ -40,17 +125,30 @@ foreach ($repo in $repos)
|
|||||||
$pullRequest.PullRequestDate = $pr.creationDate
|
$pullRequest.PullRequestDate = $pr.creationDate
|
||||||
$pullRequest.PullRequestName = $pr.title
|
$pullRequest.PullRequestName = $pr.title
|
||||||
$pullRequest.PullRequestCreatedBy = $pr.createdBy.displayName
|
$pullRequest.PullRequestCreatedBy = $pr.createdBy.displayName
|
||||||
|
|
||||||
|
# Join all reviewer names into a comma-separated string
|
||||||
$pullRequest.PullRequestReviewers = $pr.reviewers | join-string -property displayName -Separator ','
|
$pullRequest.PullRequestReviewers = $pr.reviewers | join-string -property displayName -Separator ','
|
||||||
$pullRequest.PullRequestStatus = $pr.status
|
$pullRequest.PullRequestStatus = $pr.status
|
||||||
|
|
||||||
|
# Construct direct URL to the pull request
|
||||||
$pullRequest.PullRequestWebUrl = "$($repo.webUrl)/pullrequest/$($pr.pullRequestId)"
|
$pullRequest.PullRequestWebUrl = "$($repo.webUrl)/pullrequest/$($pr.pullRequestId)"
|
||||||
|
|
||||||
|
# Capture bypass reason if completion policies were bypassed
|
||||||
$pullRequest.CompletionBypassReason = $pr.completionOptions.bypassReason
|
$pullRequest.CompletionBypassReason = $pr.completionOptions.bypassReason
|
||||||
|
|
||||||
|
# Add to results array
|
||||||
$Result += $pullRequest
|
$Result += $pullRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Append results for this repository to the CSV file
|
||||||
$Result | Export-Csv -Path $fileName -Append -NoTypeInformation
|
$Result | Export-Csv -Path $fileName -Append -NoTypeInformation
|
||||||
|
$totalPullRequests += $Result.Count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Display completion summary
|
||||||
|
Write-Host "========================================================================================================================================================================"
|
||||||
|
Write-Host "Export completed successfully!" -ForegroundColor Green
|
||||||
|
Write-Host "Total repositories processed: $($repos.Count)" -ForegroundColor Yellow
|
||||||
|
Write-Host "Total pull requests exported: $totalPullRequests" -ForegroundColor Yellow
|
||||||
|
Write-Host "Output file: $fileName" -ForegroundColor Yellow
|
||||||
Write-Host "========================================================================================================================================================================"
|
Write-Host "========================================================================================================================================================================"
|
||||||
Write-Host "Done."
|
|
||||||
@@ -1,30 +1,110 @@
|
|||||||
|
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Exports Azure DevOps repository information along with last completed pull request details to a CSV file.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This script retrieves all repositories from an Azure DevOps project and collects detailed information
|
||||||
|
about each repository including basic metadata and information about the most recent completed pull request.
|
||||||
|
For active repositories, it identifies the last completed PR and captures details about the author,
|
||||||
|
reviewers, and other relevant information. The data is exported to a timestamped CSV file for analysis.
|
||||||
|
|
||||||
|
.PARAMETER Organization
|
||||||
|
The Azure DevOps organization name. Defaults to "effectory" if not specified.
|
||||||
|
|
||||||
|
.PARAMETER Project
|
||||||
|
The Azure DevOps project name. Defaults to "survey software" if not specified.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\Repositories.ps1
|
||||||
|
|
||||||
|
Exports repository information using the default organization and project settings.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\Repositories.ps1 -Organization "myorg" -Project "myproject"
|
||||||
|
|
||||||
|
Exports repository information for a specific organization and project.
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
Creates a timestamped CSV file in the current directory with the format: "yyyy-MM-dd HHmm repositories.csv"
|
||||||
|
The CSV contains the following columns:
|
||||||
|
- Id: Repository unique identifier
|
||||||
|
- Name: Repository name
|
||||||
|
- DefaultBranch: Repository default branch (e.g., main, master)
|
||||||
|
- IsDisabled: Boolean indicating if the repository is disabled
|
||||||
|
- WebUrl: Repository web URL in Azure DevOps
|
||||||
|
- LastPRDate: Creation date of the most recent completed pull request
|
||||||
|
- LastPRName: Title of the most recent completed pull request
|
||||||
|
- LastPRCreatedBy: Author of the most recent completed pull request
|
||||||
|
- LastPRReviewers: Comma-separated list of reviewers for the most recent completed PR
|
||||||
|
- LastPRUrl: Direct URL to the most recent completed pull request
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Author: Cloud Engineering Team
|
||||||
|
Created: 2025
|
||||||
|
Requires: PowerShell 5.1 or later, Azure CLI installed and authenticated
|
||||||
|
Dependencies: Azure CLI (az) must be installed and user must be authenticated
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
- Install Azure CLI: https://docs.microsoft.com/en-us/cli/azure/install-azure-cli
|
||||||
|
- Authenticate: az login
|
||||||
|
- Set default subscription if needed: az account set --subscription "subscription-name"
|
||||||
|
|
||||||
|
The script processes all repositories in the specified project, including disabled ones.
|
||||||
|
For disabled repositories, pull request information will be empty as they cannot be accessed.
|
||||||
|
Only completed pull requests are considered when determining the "last" PR.
|
||||||
|
|
||||||
|
.LINK
|
||||||
|
https://docs.microsoft.com/en-us/cli/azure/repos
|
||||||
|
#>
|
||||||
|
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory=$false, HelpMessage="Azure DevOps organization name")]
|
||||||
|
[string]$Organization = "effectory",
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false, HelpMessage="Azure DevOps project name")]
|
||||||
|
[string]$Project = "survey software"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Define a class to structure repository information
|
||||||
class Repository {
|
class Repository {
|
||||||
[string] $Id = ""
|
[string] $Id = "" # Repository unique identifier
|
||||||
[string] $Name = ""
|
[string] $Name = "" # Repository display name
|
||||||
[string] $DefaultBranch = ""
|
[string] $DefaultBranch = "" # Repository default branch (e.g., main, master)
|
||||||
[string] $IsDisabled = ""
|
[string] $IsDisabled = "" # Whether the repository is disabled (True/False)
|
||||||
[string] $WebUrl = ""
|
[string] $WebUrl = "" # Repository web URL in Azure DevOps
|
||||||
[string] $LastPRDate = ""
|
[string] $LastPRDate = "" # Creation date of most recent completed PR
|
||||||
[string] $LastPRName = ""
|
[string] $LastPRName = "" # Title of most recent completed PR
|
||||||
[string] $LastPRCreatedBy = ""
|
[string] $LastPRCreatedBy = "" # Author of most recent completed PR
|
||||||
[string] $LastPRReviewers = ""
|
[string] $LastPRReviewers = "" # Comma-separated list of reviewers
|
||||||
[string] $LastPRUrl = ""
|
[string] $LastPRUrl = "" # Direct URL to most recent completed PR
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Generate timestamped filename for the output CSV
|
||||||
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
||||||
$fileName = ".\$date repositories.csv"
|
$fileName = ".\$date repositories.csv"
|
||||||
|
|
||||||
|
# Display script execution banner
|
||||||
Write-Host "========================================================================================================================================================================"
|
Write-Host "========================================================================================================================================================================"
|
||||||
Write-Host "Creating repository overview."
|
Write-Host "Creating repository overview for Organization: $Organization, Project: $Project"
|
||||||
|
Write-Host "Output file: $fileName"
|
||||||
|
Write-Host "Note: This script requires Azure CLI to be installed and authenticated (az login)"
|
||||||
Write-Host "========================================================================================================================================================================"
|
Write-Host "========================================================================================================================================================================"
|
||||||
|
|
||||||
$repos = az repos list --organization "https://dev.azure.com/effectory/" --project "survey software" | ConvertFrom-Json | Select-Object
|
# Retrieve all repositories from the Azure DevOps project
|
||||||
|
Write-Host "Fetching repositories from project '$Project'..." -ForegroundColor Yellow
|
||||||
|
$repos = az repos list --organization "https://dev.azure.com/$Organization/" --project "$Project" | ConvertFrom-Json | Select-Object
|
||||||
|
Write-Host "Found $($repos.Count) repositories" -ForegroundColor Green
|
||||||
|
|
||||||
|
# Initialize array to store repository information
|
||||||
[Repository[]]$Result = @()
|
[Repository[]]$Result = @()
|
||||||
|
|
||||||
|
# Process each repository to collect information and last PR details
|
||||||
foreach ($repo in $repos)
|
foreach ($repo in $repos)
|
||||||
{
|
{
|
||||||
|
Write-Host "Processing repository: $($repo.name)" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Create new repository object and populate basic information
|
||||||
[Repository] $repository = [Repository]::new()
|
[Repository] $repository = [Repository]::new()
|
||||||
$repository.Id = $repo.id
|
$repository.Id = $repo.id
|
||||||
$repository.Name = $repo.name
|
$repository.Name = $repo.name
|
||||||
@@ -32,26 +112,56 @@ foreach ($repo in $repos)
|
|||||||
$repository.IsDisabled = $repo.isDisabled
|
$repository.IsDisabled = $repo.isDisabled
|
||||||
$repository.WebUrl = $repo.webUrl
|
$repository.WebUrl = $repo.webUrl
|
||||||
|
|
||||||
|
# Only attempt to get pull request information for active repositories
|
||||||
if ($true -ne $repo.isDisabled)
|
if ($true -ne $repo.isDisabled)
|
||||||
{
|
{
|
||||||
$lastPr = az repos pr list --project "survey software" --repository $repo.name --organization "https://dev.azure.com/effectory/" --status completed --top 1 | ConvertFrom-Json | Select-Object
|
Write-Host " Fetching last completed pull request..." -ForegroundColor Gray
|
||||||
|
|
||||||
|
# Get the most recent completed pull request (top 1, sorted by most recent)
|
||||||
|
$lastPr = az repos pr list --project "$Project" --repository $repo.name --organization "https://dev.azure.com/$Organization/" --status completed --top 1 | ConvertFrom-Json | Select-Object
|
||||||
|
|
||||||
|
# If a completed PR exists, capture its details
|
||||||
if ($lastPr)
|
if ($lastPr)
|
||||||
{
|
{
|
||||||
$repository.LastPRDate = $lastPr.creationDate
|
$repository.LastPRDate = $lastPr.creationDate
|
||||||
$repository.LastPRName = $lastPr.title
|
$repository.LastPRName = $lastPr.title
|
||||||
$repository.LastPRUrl = $lastPr.url
|
$repository.LastPRUrl = $lastPr.url
|
||||||
$repository.LastPRCreatedBy = $lastPr.createdBy.displayName
|
$repository.LastPRCreatedBy = $lastPr.createdBy.displayName
|
||||||
|
|
||||||
|
# Join all reviewer names into a comma-separated string
|
||||||
$repository.LastPRReviewers = $lastPr.reviewers | join-string -property displayName -Separator ','
|
$repository.LastPRReviewers = $lastPr.reviewers | join-string -property displayName -Separator ','
|
||||||
|
|
||||||
|
Write-Host " Last PR: $($lastPr.title) ($(Get-Date $lastPr.creationDate -Format 'yyyy-MM-dd'))" -ForegroundColor Gray
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Write-Host " No completed pull requests found" -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Write-Host " Repository is disabled - skipping pull request analysis" -ForegroundColor Yellow
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Add repository to results array
|
||||||
$Result += $repository
|
$Result += $repository
|
||||||
}
|
}
|
||||||
|
|
||||||
$Result | Export-Csv -Path $fileName -Append -NoTypeInformation
|
# Export results to CSV file
|
||||||
|
$Result | Export-Csv -Path $fileName -NoTypeInformation
|
||||||
|
|
||||||
|
# Display completion summary
|
||||||
Write-Host "========================================================================================================================================================================"
|
Write-Host "========================================================================================================================================================================"
|
||||||
Write-Host "Done."
|
Write-Host "Export completed successfully!" -ForegroundColor Green
|
||||||
|
Write-Host "Total repositories processed: $($Result.Count)" -ForegroundColor Yellow
|
||||||
|
|
||||||
# az repos pr list --project "survey software" --repository "ProjectCenter" --organization "https://dev.azure.com/effectory/" --status all --top 1
|
# Count repositories with and without recent PRs
|
||||||
|
$reposWithPRs = ($Result | Where-Object { $_.LastPRDate -ne "" }).Count
|
||||||
|
$activeRepos = ($Result | Where-Object { $_.IsDisabled -ne "True" }).Count
|
||||||
|
$disabledRepos = ($Result | Where-Object { $_.IsDisabled -eq "True" }).Count
|
||||||
|
|
||||||
|
Write-Host "Active repositories: $activeRepos" -ForegroundColor Yellow
|
||||||
|
Write-Host "Disabled repositories: $disabledRepos" -ForegroundColor Yellow
|
||||||
|
Write-Host "Repositories with completed PRs: $reposWithPRs" -ForegroundColor Yellow
|
||||||
|
Write-Host "Output file: $fileName" -ForegroundColor Yellow
|
||||||
|
Write-Host "========================================================================================================================================================================"
|
||||||
@@ -1,41 +1,144 @@
|
|||||||
|
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Analyzes Azure DevOps repositories to identify which have 'test' and 'accept' branches and their last activity.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This script retrieves all repositories from an Azure DevOps project and analyzes each one to determine:
|
||||||
|
- Basic repository information (ID, name, default branch, status, URL)
|
||||||
|
- Last commit activity on the default branch
|
||||||
|
- Whether a 'test' branch exists and its last commit activity
|
||||||
|
- Whether an 'accept' branch exists and its last commit activity
|
||||||
|
|
||||||
|
This is commonly used in development workflows where 'test' and 'accept' branches represent
|
||||||
|
specific deployment environments or approval stages in the development pipeline.
|
||||||
|
|
||||||
|
.PARAMETER Token
|
||||||
|
The Azure DevOps Personal Access Token (PAT) used for authentication. This token must have
|
||||||
|
'Code (Read)' permissions to access repository and commit information.
|
||||||
|
|
||||||
|
.PARAMETER Organization
|
||||||
|
The Azure DevOps organization name. Defaults to "effectory" if not specified.
|
||||||
|
|
||||||
|
.PARAMETER Project
|
||||||
|
The Azure DevOps project name. Defaults to "Survey Software" if not specified.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\RepositoriesWithTestAccept.ps1 -Token "your-personal-access-token"
|
||||||
|
|
||||||
|
Analyzes repositories using the default organization and project settings.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\RepositoriesWithTestAccept.ps1 -Token "your-pat-token" -Organization "myorg" -Project "MyProject"
|
||||||
|
|
||||||
|
Analyzes repositories for a specific organization and project.
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
Creates a timestamped CSV file in the current directory with the format: "yyyy-MM-dd HHmm repositories with test and accept.csv"
|
||||||
|
The CSV contains the following columns:
|
||||||
|
- Id: Repository unique identifier
|
||||||
|
- Name: Repository name
|
||||||
|
- DefaultBranch: Repository default branch (e.g., main, master)
|
||||||
|
- IsDisabled: Boolean indicating if the repository is disabled
|
||||||
|
- WebUrl: Repository web URL in Azure DevOps
|
||||||
|
- LastDefaultChange: Date of last commit on the default branch
|
||||||
|
- HasTest: Boolean indicating if a 'test' branch exists (True/False)
|
||||||
|
- LastTestChange: Date of last commit on the 'test' branch (empty if branch doesn't exist)
|
||||||
|
- HasAccept: Boolean indicating if an 'accept' branch exists (True/False)
|
||||||
|
- LastAcceptChange: Date of last commit on the 'accept' branch (empty if branch doesn't exist)
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Author: Cloud Engineering Team
|
||||||
|
Created: 2025
|
||||||
|
Requires: PowerShell 5.1 or later, Azure CLI installed and authenticated
|
||||||
|
Dependencies: Azure CLI (az) for repository listing, Azure DevOps REST API for commit information
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
- Install Azure CLI: https://docs.microsoft.com/en-us/cli/azure/install-azure-cli
|
||||||
|
- Authenticate: az login
|
||||||
|
- Azure DevOps Personal Access Token with Code (Read) permissions
|
||||||
|
|
||||||
|
The script uses both Azure CLI commands and REST API calls:
|
||||||
|
- Azure CLI for listing repositories
|
||||||
|
- REST API for checking branch existence and commit history
|
||||||
|
|
||||||
|
Branch Analysis:
|
||||||
|
- 'test' branch: Often used for testing environment deployments
|
||||||
|
- 'accept' branch: Often used for acceptance testing or staging environments
|
||||||
|
- Default branch: Usually 'main' or 'master', represents the primary development branch
|
||||||
|
|
||||||
|
Error Handling:
|
||||||
|
- If a branch doesn't exist, the API call will fail and the branch is marked as non-existent
|
||||||
|
- Disabled repositories are processed but branch analysis is skipped
|
||||||
|
- Network or authentication errors are handled gracefully
|
||||||
|
|
||||||
|
.LINK
|
||||||
|
https://docs.microsoft.com/en-us/rest/api/azure/devops/git/commits/get-commits
|
||||||
|
#>
|
||||||
|
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true, HelpMessage = "Azure DevOps Personal Access Token with Code (Read) permissions")]
|
||||||
|
[string]$Token,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false, HelpMessage = "Azure DevOps organization name")]
|
||||||
|
[string]$Organization = "effectory",
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false, HelpMessage = "Azure DevOps project name")]
|
||||||
|
[string]$Project = "Survey Software"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Define a class to structure repository information with branch analysis
|
||||||
class Repository {
|
class Repository {
|
||||||
[string] $Id = ""
|
[string] $Id = "" # Repository unique identifier
|
||||||
[string] $Name = ""
|
[string] $Name = "" # Repository display name
|
||||||
[string] $DefaultBranch = ""
|
[string] $DefaultBranch = "" # Repository default branch (e.g., main, master)
|
||||||
[string] $IsDisabled = ""
|
[string] $IsDisabled = "" # Whether the repository is disabled (True/False)
|
||||||
[string] $WebUrl = ""
|
[string] $WebUrl = "" # Repository web URL in Azure DevOps
|
||||||
[string] $LastDefaultChange = ""
|
[string] $LastDefaultChange = "" # Date of last commit on default branch
|
||||||
[string] $HasTest = ""
|
[string] $HasTest = "" # Whether 'test' branch exists (True/False)
|
||||||
[string] $LastTestChange = ""
|
[string] $LastTestChange = "" # Date of last commit on 'test' branch
|
||||||
[string] $HasAccept = ""
|
[string] $HasAccept = "" # Whether 'accept' branch exists (True/False)
|
||||||
[string] $LastAcceptChange = ""
|
[string] $LastAcceptChange = "" # Date of last commit on 'accept' branch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Initialize variables for API calls
|
||||||
[string] $url = ""
|
[string] $url = ""
|
||||||
[string] $repositoryId = ""
|
[string] $repositoryId = ""
|
||||||
[string] $branchName = ""
|
[string] $branchName = ""
|
||||||
|
|
||||||
|
# Generate timestamped filename for the output CSV
|
||||||
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
||||||
$fileName = ".\$date repositories with test and accept.csv"
|
$fileName = ".\$date repositories with test and accept.csv"
|
||||||
|
|
||||||
[string] $token = "yixqmupncd3b72zij4y5lfsenepak5rtvlba3sj33tvxvc4s7a6q" #"{INSERT_PERSONAL_ACCESS_TOKEN}"
|
# Prepare authentication for Azure DevOps REST API calls
|
||||||
$token = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes(":$($token)"))
|
# Personal Access Token must be base64 encoded with a colon prefix
|
||||||
$head = @{ Authorization =" Basic $token" }
|
$encodedToken = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes(":$($Token)"))
|
||||||
[string] $organization = "effectory"
|
$head = @{ Authorization =" Basic $encodedToken" }
|
||||||
[string] $project = "Survey%20Software"
|
|
||||||
|
|
||||||
|
# URL-encode the project name for API calls
|
||||||
|
$projectEncoded = $Project -replace " ", "%20"
|
||||||
|
|
||||||
|
# Display script execution banner
|
||||||
Write-Host "========================================================================================================================================================================"
|
Write-Host "========================================================================================================================================================================"
|
||||||
Write-Host "Creating repository overview."
|
Write-Host "Analyzing repositories for 'test' and 'accept' branches"
|
||||||
|
Write-Host "Organization: $Organization, Project: $Project"
|
||||||
|
Write-Host "Output file: $fileName"
|
||||||
Write-Host "========================================================================================================================================================================"
|
Write-Host "========================================================================================================================================================================"
|
||||||
|
|
||||||
$repos = az repos list --organization "https://dev.azure.com/$organization/" --project "survey software" | ConvertFrom-Json | Select-Object
|
# Retrieve all repositories from the Azure DevOps project
|
||||||
|
Write-Host "Fetching repositories from project '$Project'..." -ForegroundColor Yellow
|
||||||
|
$repos = az repos list --organization "https://dev.azure.com/$Organization/" --project "$Project" | ConvertFrom-Json | Select-Object
|
||||||
|
Write-Host "Found $($repos.Count) repositories" -ForegroundColor Green
|
||||||
|
|
||||||
|
# Initialize array to store repository analysis results
|
||||||
[Repository[]]$Result = @()
|
[Repository[]]$Result = @()
|
||||||
|
|
||||||
|
# Process each repository to analyze branch structure and activity
|
||||||
foreach ($repo in $repos)
|
foreach ($repo in $repos)
|
||||||
{
|
{
|
||||||
Write-Host $repo.name
|
Write-Host "Analyzing repository: $($repo.name)" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Create new repository object and populate basic information
|
||||||
[Repository] $repository = [Repository]::new()
|
[Repository] $repository = [Repository]::new()
|
||||||
$repository.Id = $repo.id
|
$repository.Id = $repo.id
|
||||||
$repository.Name = $repo.name
|
$repository.Name = $repo.name
|
||||||
@@ -43,50 +146,96 @@ foreach ($repo in $repos)
|
|||||||
$repository.IsDisabled = $repo.isDisabled
|
$repository.IsDisabled = $repo.isDisabled
|
||||||
$repository.WebUrl = $repo.webUrl
|
$repository.WebUrl = $repo.webUrl
|
||||||
|
|
||||||
|
# Only analyze branches for active repositories
|
||||||
if ($true -ne $repo.isDisabled)
|
if ($true -ne $repo.isDisabled)
|
||||||
{
|
{
|
||||||
$repositoryId = $repo.id
|
$repositoryId = $repo.id
|
||||||
|
|
||||||
|
# Analyze default branch activity
|
||||||
$branchName = $repo.defaultBranch
|
$branchName = $repo.defaultBranch
|
||||||
$branchName = $branchName.Replace("refs/heads/", "")
|
$branchName = $branchName.Replace("refs/heads/", "")
|
||||||
|
Write-Host " Checking default branch: $branchName" -ForegroundColor Gray
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$url="https://dev.azure.com/$organization/$project/_apis/git/repositories/$repositoryId/commits?searchCriteria.itemVersion.version=$branchName&searchCriteria.`$top=1&api-version=6.0"
|
$url="https://dev.azure.com/$Organization/$projectEncoded/_apis/git/repositories/$repositoryId/commits?searchCriteria.itemVersion.version=$branchName&searchCriteria.`$top=1&api-version=6.0"
|
||||||
$response = Invoke-RestMethod -Uri $url -Method GET -Headers $head
|
$response = Invoke-RestMethod -Uri $url -Method GET -Headers $head
|
||||||
$repository.LastDefaultChange = $response.value[0].committer.date
|
$repository.LastDefaultChange = $response.value[0].committer.date
|
||||||
|
Write-Host " Last commit: $(Get-Date $response.value[0].committer.date -Format 'yyyy-MM-dd HH:mm')" -ForegroundColor Gray
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
|
Write-Host " No commits found or branch inaccessible" -ForegroundColor Yellow
|
||||||
$repository.LastDefaultChange = ""
|
$repository.LastDefaultChange = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Check for 'test' branch existence and activity
|
||||||
|
Write-Host " Checking for 'test' branch..." -ForegroundColor Gray
|
||||||
try {
|
try {
|
||||||
$branchName = "test"
|
$branchName = "test"
|
||||||
$url="https://dev.azure.com/$organization/$project/_apis/git/repositories/$repositoryId/commits?searchCriteria.itemVersion.version=$branchName&searchCriteria.`$top=1&api-version=6.0"
|
$url="https://dev.azure.com/$Organization/$projectEncoded/_apis/git/repositories/$repositoryId/commits?searchCriteria.itemVersion.version=$branchName&searchCriteria.`$top=1&api-version=6.0"
|
||||||
$response = Invoke-RestMethod -Uri $url -Method GET -Headers $head
|
$response = Invoke-RestMethod -Uri $url -Method GET -Headers $head
|
||||||
$repository.HasTest = "True"
|
$repository.HasTest = "True"
|
||||||
$repository.LastTestChange = $response.value[0].committer.date
|
$repository.LastTestChange = $response.value[0].committer.date
|
||||||
|
Write-Host " 'test' branch found - Last commit: $(Get-Date $response.value[0].committer.date -Format 'yyyy-MM-dd HH:mm')" -ForegroundColor Green
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
$repository.HasTest = "False"
|
$repository.HasTest = "False"
|
||||||
$repository.LastTestChange = ""
|
$repository.LastTestChange = ""
|
||||||
|
Write-Host " 'test' branch not found" -ForegroundColor Yellow
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Check for 'accept' branch existence and activity
|
||||||
|
Write-Host " Checking for 'accept' branch..." -ForegroundColor Gray
|
||||||
try {
|
try {
|
||||||
$branchName = "accept"
|
$branchName = "accept"
|
||||||
$url="https://dev.azure.com/$organization/$project/_apis/git/repositories/$repositoryId/commits?searchCriteria.itemVersion.version=$branchName&searchCriteria.`$top=1&api-version=6.0"
|
$url="https://dev.azure.com/$Organization/$projectEncoded/_apis/git/repositories/$repositoryId/commits?searchCriteria.itemVersion.version=$branchName&searchCriteria.`$top=1&api-version=6.0"
|
||||||
$response = Invoke-RestMethod -Uri $url -Method GET -Headers $head
|
$response = Invoke-RestMethod -Uri $url -Method GET -Headers $head
|
||||||
$repository.HasAccept = "True"
|
$repository.HasAccept = "True"
|
||||||
$repository.LastAcceptChange = $response.value[0].committer.date
|
$repository.LastAcceptChange = $response.value[0].committer.date
|
||||||
|
Write-Host " 'accept' branch found - Last commit: $(Get-Date $response.value[0].committer.date -Format 'yyyy-MM-dd HH:mm')" -ForegroundColor Green
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
$repository.HasAccept = "False"
|
$repository.HasAccept = "False"
|
||||||
$repository.LastAcceptChange = ""
|
$repository.LastAcceptChange = ""
|
||||||
|
Write-Host " 'accept' branch not found" -ForegroundColor Yellow
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Write-Host " Repository is disabled - skipping branch analysis" -ForegroundColor Yellow
|
||||||
|
# Set default values for disabled repositories
|
||||||
|
$repository.LastDefaultChange = ""
|
||||||
|
$repository.HasTest = "N/A"
|
||||||
|
$repository.LastTestChange = ""
|
||||||
|
$repository.HasAccept = "N/A"
|
||||||
|
$repository.LastAcceptChange = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add repository to results array
|
||||||
$Result += $repository
|
$Result += $repository
|
||||||
}
|
}
|
||||||
|
|
||||||
$Result | Export-Csv -Path $fileName -Append -NoTypeInformation
|
# Export results to CSV file
|
||||||
|
$Result | Export-Csv -Path $fileName -NoTypeInformation
|
||||||
|
|
||||||
|
# Calculate and display summary statistics
|
||||||
|
$totalRepos = $Result.Count
|
||||||
|
$activeRepos = ($Result | Where-Object { $_.IsDisabled -ne "True" }).Count
|
||||||
|
$disabledRepos = ($Result | Where-Object { $_.IsDisabled -eq "True" }).Count
|
||||||
|
$reposWithTest = ($Result | Where-Object { $_.HasTest -eq "True" }).Count
|
||||||
|
$reposWithAccept = ($Result | Where-Object { $_.HasAccept -eq "True" }).Count
|
||||||
|
$reposWithBoth = ($Result | Where-Object { $_.HasTest -eq "True" -and $_.HasAccept -eq "True" }).Count
|
||||||
|
|
||||||
|
# Display completion summary
|
||||||
|
Write-Host "========================================================================================================================================================================"
|
||||||
|
Write-Host "Branch analysis completed successfully!" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "SUMMARY STATISTICS:" -ForegroundColor Cyan
|
||||||
|
Write-Host "Total repositories: $totalRepos" -ForegroundColor Yellow
|
||||||
|
Write-Host "Active repositories: $activeRepos" -ForegroundColor Yellow
|
||||||
|
Write-Host "Disabled repositories: $disabledRepos" -ForegroundColor Yellow
|
||||||
|
Write-Host "Repositories with 'test' branch: $reposWithTest" -ForegroundColor Yellow
|
||||||
|
Write-Host "Repositories with 'accept' branch: $reposWithAccept" -ForegroundColor Yellow
|
||||||
|
Write-Host "Repositories with both 'test' and 'accept' branches: $reposWithBoth" -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Output file: $fileName" -ForegroundColor Yellow
|
||||||
Write-Host "========================================================================================================================================================================"
|
Write-Host "========================================================================================================================================================================"
|
||||||
Write-Host "Done."
|
|
||||||
@@ -1,55 +1,256 @@
|
|||||||
|
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Exports Azure DevOps service connections with detailed service principal information to a CSV file.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This script retrieves all service connections from an Azure DevOps project and analyzes their
|
||||||
|
associated service principals. It combines information from Azure DevOps REST API and Azure
|
||||||
|
PowerShell cmdlets to provide comprehensive details about:
|
||||||
|
- Service connection metadata (ID, name, status, authorization scheme)
|
||||||
|
- Associated service principal details (Application ID, Object ID, display name)
|
||||||
|
- Service principal credential expiration dates
|
||||||
|
|
||||||
|
This is particularly useful for security auditing and monitoring service principal
|
||||||
|
credential expiration to prevent service disruptions.
|
||||||
|
|
||||||
|
.PARAMETER Token
|
||||||
|
The Azure DevOps Personal Access Token (PAT) used for authentication. This token must have
|
||||||
|
'Service Connections (Read)' permissions to access service endpoint information.
|
||||||
|
|
||||||
|
.PARAMETER Organization
|
||||||
|
The Azure DevOps organization name. Defaults to "effectory" if not specified.
|
||||||
|
|
||||||
|
.PARAMETER Project
|
||||||
|
The Azure DevOps project name. Defaults to "Survey Software" if not specified.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\ServiceConnections.ps1 -Token "your-personal-access-token"
|
||||||
|
|
||||||
|
Analyzes service connections using the default organization and project settings.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\ServiceConnections.ps1 -Token "your-pat-token" -Organization "myorg" -Project "MyProject"
|
||||||
|
|
||||||
|
Analyzes service connections for a specific organization and project.
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
Creates a timestamped CSV file in the current directory with the format: "yyyy-MM-dd HHmm serviceconnections.csv"
|
||||||
|
The CSV contains the following columns:
|
||||||
|
- Id: Service connection unique identifier
|
||||||
|
- Name: Service connection display name
|
||||||
|
- OperationStatus: Current operational status of the service connection
|
||||||
|
- AuthorizationScheme: Authentication method used (e.g., ServicePrincipal, ManagedServiceIdentity)
|
||||||
|
- ServicePrincipalApplicationId: Application (Client) ID of the associated service principal
|
||||||
|
- ServicePrincipalObjectId: Object ID of the service principal in Azure AD
|
||||||
|
- ServicePrincipalName: Display name of the service principal
|
||||||
|
- ServicePrincipalEndDateTime: Expiration date of the service principal credentials
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Author: Cloud Engineering Team
|
||||||
|
Created: 2025
|
||||||
|
Requires: PowerShell 5.1 or later, Az PowerShell module
|
||||||
|
Dependencies: Az PowerShell module must be installed and authenticated to Azure
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
- Install Az PowerShell module: Install-Module -Name Az
|
||||||
|
- Connect to Azure: Connect-AzAccount
|
||||||
|
- Azure DevOps Personal Access Token with Service Connections (Read) permissions
|
||||||
|
- Appropriate Azure AD permissions to read service principal information
|
||||||
|
|
||||||
|
The script combines two data sources:
|
||||||
|
1. Azure DevOps REST API - for service connection metadata
|
||||||
|
2. Azure PowerShell cmdlets - for detailed service principal information
|
||||||
|
|
||||||
|
Security Considerations:
|
||||||
|
- Monitor credential expiration dates to prevent service disruptions
|
||||||
|
- Regular auditing of service connections helps maintain security posture
|
||||||
|
- Service principals with expired credentials will cause deployment failures
|
||||||
|
|
||||||
|
Error Handling:
|
||||||
|
- If service principal information cannot be retrieved, those fields will be empty
|
||||||
|
- The script continues processing even if individual service principals fail
|
||||||
|
- Network or authentication errors are handled gracefully
|
||||||
|
|
||||||
|
.LINK
|
||||||
|
https://docs.microsoft.com/en-us/rest/api/azure/devops/serviceendpoint/endpoints
|
||||||
|
https://docs.microsoft.com/en-us/powershell/module/az.resources/get-azadserviceprincipal
|
||||||
|
#>
|
||||||
|
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true, HelpMessage = "Azure DevOps Personal Access Token with Service Connections (Read) permissions")]
|
||||||
|
[string]$Token,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false, HelpMessage = "Azure DevOps organization name")]
|
||||||
|
[string]$Organization = "effectory",
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false, HelpMessage = "Azure DevOps project name")]
|
||||||
|
[string]$Project = "Survey Software"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Define a class to structure service connection information with service principal details
|
||||||
class ServiceConnection {
|
class ServiceConnection {
|
||||||
[string] $Id = ""
|
[string] $Id = "" # Service connection unique identifier
|
||||||
[string] $Name = ""
|
[string] $Name = "" # Service connection display name
|
||||||
[string] $OperationStatus = ""
|
[string] $OperationStatus = "" # Current operational status
|
||||||
[string] $AuthorizationScheme = ""
|
[string] $AuthorizationScheme = "" # Authentication method (e.g., ServicePrincipal)
|
||||||
[string] $ServicePrincipalApplicationId = ""
|
[string] $ServicePrincipalApplicationId = "" # Application (Client) ID of service principal
|
||||||
[string] $ServicePrincipalObjectId = ""
|
[string] $ServicePrincipalObjectId = "" # Object ID of service principal in Azure AD
|
||||||
[string] $ServicePrincipalName = ""
|
[string] $ServicePrincipalName = "" # Display name of service principal
|
||||||
[string] $ServicePrincipalEndDateTime = ""
|
[string] $ServicePrincipalEndDateTime = "" # Expiration date of service principal credentials
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Generate timestamped filename for the output CSV
|
||||||
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
||||||
$fileName = ".\$date serviceconnections.csv"
|
$fileName = ".\$date serviceconnections.csv"
|
||||||
|
|
||||||
|
# Display script execution banner
|
||||||
Write-Host "========================================================================================================================================================================"
|
Write-Host "========================================================================================================================================================================"
|
||||||
Write-Host "Creating service connection overview."
|
Write-Host "Analyzing Azure DevOps service connections and associated service principals"
|
||||||
|
Write-Host "Organization: $Organization, Project: $Project"
|
||||||
|
Write-Host "Output file: $fileName"
|
||||||
|
Write-Host "Note: This script requires Azure PowerShell (Az module) to be installed and authenticated"
|
||||||
Write-Host "========================================================================================================================================================================"
|
Write-Host "========================================================================================================================================================================"
|
||||||
|
|
||||||
$token = "adlgsqh2uoedv6rf44hjd47z3ssuo5zonrqicif4ctjqlqqtlhdq"
|
# Prepare authentication for Azure DevOps REST API calls
|
||||||
$token = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes(":$($token)"))
|
# Personal Access Token must be base64 encoded with a colon prefix
|
||||||
$organization = "effectory"
|
$encodedToken = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes(":$($Token)"))
|
||||||
$project = "Survey%20Software"
|
|
||||||
|
|
||||||
$url="https://dev.azure.com/$organization/$project/_apis/serviceendpoint/endpoints?api-version=6.0-preview.4"
|
# URL-encode the project name for API calls
|
||||||
$head = @{ Authorization =" Basic $token" }
|
$projectEncoded = $Project -replace " ", "%20"
|
||||||
|
|
||||||
|
# Check if Azure PowerShell is available and user is authenticated
|
||||||
|
Write-Host "Verifying Azure PowerShell authentication..." -ForegroundColor Yellow
|
||||||
|
try {
|
||||||
|
$azContext = Get-AzContext
|
||||||
|
if (-not $azContext) {
|
||||||
|
Write-Host "ERROR: Not authenticated to Azure. Please run 'Connect-AzAccount' first." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host "Azure authentication verified - Tenant: $($azContext.Tenant.Id)" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Host "ERROR: Azure PowerShell module not found. Please install with 'Install-Module -Name Az'" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Retrieve service connections from Azure DevOps
|
||||||
|
Write-Host "Fetching service connections from Azure DevOps..." -ForegroundColor Yellow
|
||||||
|
$url="https://dev.azure.com/$Organization/$projectEncoded/_apis/serviceendpoint/endpoints?api-version=6.0-preview.4"
|
||||||
|
$head = @{ Authorization =" Basic $encodedToken" }
|
||||||
$response = Invoke-RestMethod -Uri $url -Method GET -Headers $head
|
$response = Invoke-RestMethod -Uri $url -Method GET -Headers $head
|
||||||
|
Write-Host "Found $($response.count) service connections" -ForegroundColor Green
|
||||||
|
|
||||||
|
# Initialize array to store service connection analysis results
|
||||||
[ServiceConnection[]]$Result = @()
|
[ServiceConnection[]]$Result = @()
|
||||||
|
|
||||||
|
# Process each service connection to gather detailed information
|
||||||
$response.value | ForEach-Object {
|
$response.value | ForEach-Object {
|
||||||
|
Write-Host "Analyzing service connection: $($_.name)" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Create new service connection object and populate basic information
|
||||||
[ServiceConnection] $serviceConnection = [ServiceConnection]::new()
|
[ServiceConnection] $serviceConnection = [ServiceConnection]::new()
|
||||||
$serviceConnection.Id = $_.id
|
$serviceConnection.Id = $_.id
|
||||||
$serviceConnection.Name = $_.name
|
$serviceConnection.Name = $_.name
|
||||||
$serviceConnection.OperationStatus = $_.operationStatus
|
$serviceConnection.OperationStatus = $_.operationStatus
|
||||||
$serviceConnection.AuthorizationScheme = $_.authorization.scheme
|
$serviceConnection.AuthorizationScheme = $_.authorization.scheme
|
||||||
|
|
||||||
|
Write-Host " Authorization scheme: $($_.authorization.scheme)" -ForegroundColor Gray
|
||||||
|
Write-Host " Operation status: $($_.operationStatus)" -ForegroundColor Gray
|
||||||
|
|
||||||
|
# Extract service principal information if available
|
||||||
$principalid = $_.authorization.parameters.serviceprincipalid
|
$principalid = $_.authorization.parameters.serviceprincipalid
|
||||||
if ($null -ne $principalid) {
|
if ($null -ne $principalid) {
|
||||||
$principal = Get-AzADServicePrincipal -ApplicationId $principalid
|
Write-Host " Retrieving service principal details..." -ForegroundColor Gray
|
||||||
$credential = Get-AzADAppCredential -ApplicationId $principalid
|
|
||||||
|
try {
|
||||||
|
# Get service principal information from Azure AD
|
||||||
|
$principal = Get-AzADServicePrincipal -ApplicationId $principalid -ErrorAction Stop
|
||||||
|
$credential = Get-AzADAppCredential -ApplicationId $principalid -ErrorAction Stop
|
||||||
|
|
||||||
$serviceConnection.ServicePrincipalApplicationId = $principalid
|
$serviceConnection.ServicePrincipalApplicationId = $principalid
|
||||||
$serviceConnection.ServicePrincipalObjectId = $principal.Id
|
$serviceConnection.ServicePrincipalObjectId = $principal.Id
|
||||||
$serviceConnection.ServicePrincipalName = $principal.DisplayName
|
$serviceConnection.ServicePrincipalName = $principal.DisplayName
|
||||||
$serviceConnection.ServicePrincipalEndDateTime = $credential.EndDateTime
|
|
||||||
|
# Handle multiple credentials - get the latest expiration date
|
||||||
|
if ($credential) {
|
||||||
|
$latestExpiration = ($credential | Sort-Object EndDateTime -Descending | Select-Object -First 1).EndDateTime
|
||||||
|
$serviceConnection.ServicePrincipalEndDateTime = $latestExpiration
|
||||||
|
|
||||||
|
Write-Host " Service Principal: $($principal.DisplayName)" -ForegroundColor Gray
|
||||||
|
Write-Host " Application ID: $principalid" -ForegroundColor Gray
|
||||||
|
Write-Host " Credential expires: $latestExpiration" -ForegroundColor Gray
|
||||||
|
|
||||||
|
# Warn about expiring credentials (within 30 days)
|
||||||
|
if ($latestExpiration -and $latestExpiration -lt (Get-Date).AddDays(30)) {
|
||||||
|
Write-Host " WARNING: Credential expires within 30 days!" -ForegroundColor Red
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host " No credentials found for this service principal" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Host " Error retrieving service principal details: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
# Keep the Application ID even if other details fail
|
||||||
|
$serviceConnection.ServicePrincipalApplicationId = $principalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host " No service principal associated with this connection" -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add service connection to results array
|
||||||
$Result += $serviceConnection
|
$Result += $serviceConnection
|
||||||
}
|
}
|
||||||
|
|
||||||
$Result | Export-Csv -Path $fileName -Append -NoTypeInformation
|
# Export results to CSV file
|
||||||
|
$Result | Export-Csv -Path $fileName -NoTypeInformation
|
||||||
|
|
||||||
|
# Calculate and display summary statistics
|
||||||
|
$totalConnections = $Result.Count
|
||||||
|
$connectionsWithServicePrincipals = ($Result | Where-Object { $_.ServicePrincipalApplicationId -ne "" }).Count
|
||||||
|
$connectionsWithoutServicePrincipals = $totalConnections - $connectionsWithServicePrincipals
|
||||||
|
|
||||||
|
# Check for expiring credentials (within 30 days)
|
||||||
|
$expiringCredentials = $Result | Where-Object {
|
||||||
|
$_.ServicePrincipalEndDateTime -ne "" -and
|
||||||
|
[DateTime]::Parse($_.ServicePrincipalEndDateTime) -lt (Get-Date).AddDays(30)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check for expired credentials
|
||||||
|
$expiredCredentials = $Result | Where-Object {
|
||||||
|
$_.ServicePrincipalEndDateTime -ne "" -and
|
||||||
|
[DateTime]::Parse($_.ServicePrincipalEndDateTime) -lt (Get-Date)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Display completion summary
|
||||||
|
Write-Host "========================================================================================================================================================================"
|
||||||
|
Write-Host "Service connection analysis completed successfully!" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "SUMMARY STATISTICS:" -ForegroundColor Cyan
|
||||||
|
Write-Host "Total service connections: $totalConnections" -ForegroundColor Yellow
|
||||||
|
Write-Host "Connections with service principals: $connectionsWithServicePrincipals" -ForegroundColor Yellow
|
||||||
|
Write-Host "Connections without service principals: $connectionsWithoutServicePrincipals" -ForegroundColor Yellow
|
||||||
|
|
||||||
|
if ($expiredCredentials.Count -gt 0) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "EXPIRED CREDENTIALS (IMMEDIATE ATTENTION REQUIRED):" -ForegroundColor Red
|
||||||
|
$expiredCredentials | ForEach-Object {
|
||||||
|
Write-Host " - $($_.Name): $($_.ServicePrincipalName) (expired $($_.ServicePrincipalEndDateTime))" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($expiringCredentials.Count -gt 0) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "EXPIRING CREDENTIALS (WITHIN 30 DAYS):" -ForegroundColor Yellow
|
||||||
|
$expiringCredentials | ForEach-Object {
|
||||||
|
Write-Host " - $($_.Name): $($_.ServicePrincipalName) (expires $($_.ServicePrincipalEndDateTime))" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Output file: $fileName" -ForegroundColor Yellow
|
||||||
Write-Host "========================================================================================================================================================================"
|
Write-Host "========================================================================================================================================================================"
|
||||||
Write-Host "Done."
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,20 +1,129 @@
|
|||||||
# PowerShell script to analyze renovate PRs across repositories with detailed statistics
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Analyzes Renovate pull requests across all repositories in an Azure DevOps project and generates detailed statistics.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This script connects to an Azure DevOps organization and project to analyze Renovate dependency update pull requests.
|
||||||
|
It processes all repositories (active, disabled, and locked) and generates comprehensive statistics including:
|
||||||
|
- Total Renovate PRs per repository
|
||||||
|
- Open, completed, and abandoned PR counts
|
||||||
|
- Latest creation and completion dates
|
||||||
|
- Branch information for latest PRs
|
||||||
|
- Repository status (active, disabled, locked, error)
|
||||||
|
|
||||||
|
The script outputs both a detailed text report and a CSV file for further analysis.
|
||||||
|
Renovate PRs are identified by their branch naming pattern: "refs/heads/renovate/*"
|
||||||
|
|
||||||
|
.PARAMETER Organization
|
||||||
|
The Azure DevOps organization name. Defaults to "effectory" if not specified.
|
||||||
|
|
||||||
|
.PARAMETER Project
|
||||||
|
The Azure DevOps project name. Defaults to "Survey Software" if not specified.
|
||||||
|
|
||||||
|
.PARAMETER PAT
|
||||||
|
The Azure DevOps Personal Access Token (PAT) used for authentication. This token must have
|
||||||
|
'Code (Read)' permissions to access repository and pull request information.
|
||||||
|
|
||||||
|
.PARAMETER OutputFile
|
||||||
|
The path and filename for the output text report. Defaults to a timestamped filename:
|
||||||
|
"RenovatePRs_Stats_yyyyMMdd_HHmmss.txt"
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\renovate-stats.ps1 -PAT "your-personal-access-token"
|
||||||
|
|
||||||
|
Analyzes Renovate PRs using default organization and project settings.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\renovate-stats.ps1 -PAT "your-pat-token" -Organization "myorg" -Project "MyProject"
|
||||||
|
|
||||||
|
Analyzes Renovate PRs for a specific organization and project.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\renovate-stats.ps1 -PAT "your-token" -OutputFile "C:\Reports\renovate-analysis.txt"
|
||||||
|
|
||||||
|
Analyzes Renovate PRs and saves the report to a custom location.
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
Creates two output files:
|
||||||
|
1. Text Report: Detailed statistics with tables and summaries (.txt)
|
||||||
|
2. CSV Export: Raw data for further analysis (.csv)
|
||||||
|
|
||||||
|
The text report includes:
|
||||||
|
- Repository-by-repository statistics table (sorted by last created date)
|
||||||
|
- Summary statistics (total repos, repos with/without Renovate, PR counts)
|
||||||
|
- List of disabled/locked/error repositories
|
||||||
|
|
||||||
|
The CSV contains columns:
|
||||||
|
- Repository: Repository name
|
||||||
|
- TotalRenovatePRs: Total count of Renovate PRs
|
||||||
|
- OpenPRs: Count of active Renovate PRs
|
||||||
|
- CompletedPRs: Count of completed Renovate PRs
|
||||||
|
- AbandonedPRs: Count of abandoned Renovate PRs
|
||||||
|
- LastCreated: Date of most recent Renovate PR creation (yyyy-MM-dd format)
|
||||||
|
- LastCompleted: Date of most recent Renovate PR completion (yyyy-MM-dd format)
|
||||||
|
- LatestOpenBranch: Branch name of most recent open Renovate PR
|
||||||
|
- LatestCompletedBranch: Branch name of most recent completed Renovate PR
|
||||||
|
- LastCompletedPRTitle: Title of most recent completed Renovate PR
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Author: Cloud Engineering Team
|
||||||
|
Created: 2025
|
||||||
|
Requires: PowerShell 5.1 or later
|
||||||
|
Dependencies: Internet connectivity to Azure DevOps
|
||||||
|
|
||||||
|
The script uses Azure DevOps REST API version 6.0 to retrieve repository and pull request information.
|
||||||
|
Ensure your Personal Access Token has the necessary permissions before running the script.
|
||||||
|
|
||||||
|
Renovate is a dependency update tool that creates automated pull requests. This script specifically
|
||||||
|
looks for PRs with branch names matching the pattern "refs/heads/renovate/*"
|
||||||
|
|
||||||
|
The script handles various repository states:
|
||||||
|
- Active: Normal repositories that can be analyzed
|
||||||
|
- Disabled: Repositories marked as disabled in Azure DevOps
|
||||||
|
- Locked: Repositories that are locked (read-only)
|
||||||
|
- Error: Repositories that couldn't be accessed due to API errors
|
||||||
|
|
||||||
|
Date parsing is culture-invariant and includes fallback mechanisms to handle various date formats
|
||||||
|
from the Azure DevOps API.
|
||||||
|
|
||||||
|
.LINK
|
||||||
|
https://docs.microsoft.com/en-us/rest/api/azure/devops/git/pull-requests/get-pull-requests
|
||||||
|
https://renovatebot.com/
|
||||||
|
#>
|
||||||
|
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false, HelpMessage="Azure DevOps organization name")]
|
||||||
[string]$Organization = "effectory",
|
[string]$Organization = "effectory",
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false, HelpMessage="Azure DevOps project name")]
|
||||||
[string]$Project = "Survey Software",
|
[string]$Project = "Survey Software",
|
||||||
|
|
||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory=$true, HelpMessage="Azure DevOps Personal Access Token with Code (Read) permissions")]
|
||||||
[string]$PAT,
|
[string]$PAT,
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false, HelpMessage="Output file path for the text report")]
|
||||||
[string]$OutputFile = "RenovatePRs_Stats_$(Get-Date -Format 'yyyyMMdd_HHmmss').txt"
|
[string]$OutputFile = "RenovatePRs_Stats_$(Get-Date -Format 'yyyyMMdd_HHmmss').txt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Outputs a message to both the console and the output file.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This helper function writes messages to the console with optional color formatting
|
||||||
|
and simultaneously appends the same message to the output file, unless ConsoleOnly is specified.
|
||||||
|
|
||||||
|
.PARAMETER Message
|
||||||
|
The message to output.
|
||||||
|
|
||||||
|
.PARAMETER ForegroundColor
|
||||||
|
The console text color. Defaults to "White".
|
||||||
|
|
||||||
|
.PARAMETER ConsoleOnly
|
||||||
|
If specified, the message will only be written to the console and not the output file.
|
||||||
|
Useful for progress messages that shouldn't clutter the report.
|
||||||
|
#>
|
||||||
function Write-Output-Both {
|
function Write-Output-Both {
|
||||||
param (
|
param (
|
||||||
[string]$Message,
|
[string]$Message,
|
||||||
@@ -29,28 +138,34 @@ function Write-Output-Both {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Initialize the output file with a header
|
||||||
Set-Content -Path $OutputFile -Value "Renovate Pull Requests Statistics - $(Get-Date)`n"
|
Set-Content -Path $OutputFile -Value "Renovate Pull Requests Statistics - $(Get-Date)`n"
|
||||||
|
|
||||||
|
# Prepare authentication for Azure DevOps REST API calls
|
||||||
|
# Personal Access Token must be base64 encoded with a colon prefix
|
||||||
$base64AuthInfo = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$PAT"))
|
$base64AuthInfo = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$PAT"))
|
||||||
$headers = @{
|
$headers = @{
|
||||||
Authorization = "Basic $base64AuthInfo"
|
Authorization = "Basic $base64AuthInfo"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Retrieve all repositories from the Azure DevOps project
|
||||||
$reposUrl = "https://dev.azure.com/$Organization/$Project/_apis/git/repositories?api-version=6.0"
|
$reposUrl = "https://dev.azure.com/$Organization/$Project/_apis/git/repositories?api-version=6.0"
|
||||||
$repositories = Invoke-RestMethod -Uri $reposUrl -Method Get -Headers $headers
|
$repositories = Invoke-RestMethod -Uri $reposUrl -Method Get -Headers $headers
|
||||||
|
|
||||||
|
# Initialize arrays to categorize repositories and store statistics
|
||||||
$repoStats = @()
|
$repoStats = @() # Active repositories with detailed statistics
|
||||||
$reposWithoutRenovate = @()
|
$reposWithoutRenovate = @() # Active repositories with no Renovate PRs
|
||||||
$disabledRepos = @()
|
$disabledRepos = @() # Disabled, locked, or error repositories
|
||||||
|
|
||||||
|
|
||||||
|
# Process each repository in the project
|
||||||
foreach ($repo in $repositories.value) {
|
foreach ($repo in $repositories.value) {
|
||||||
$repoName = $repo.name
|
$repoName = $repo.name
|
||||||
$repoId = $repo.id
|
$repoId = $repo.id
|
||||||
Write-Output-Both "Analyzing repository: $repoName" -ForegroundColor Gray -ConsoleOnly
|
Write-Output-Both "Analyzing repository: $repoName" -ForegroundColor Gray -ConsoleOnly
|
||||||
|
|
||||||
|
# Check repository status (disabled, locked, or active)
|
||||||
$isDisabled = $repo.isDisabled -eq $true
|
$isDisabled = $repo.isDisabled -eq $true
|
||||||
$repoDetailsUrl = "https://dev.azure.com/$Organization/$Project/_apis/git/repositories/$repoId`?api-version=6.0"
|
$repoDetailsUrl = "https://dev.azure.com/$Organization/$Project/_apis/git/repositories/$repoId`?api-version=6.0"
|
||||||
try {
|
try {
|
||||||
@@ -62,10 +177,12 @@ foreach ($repo in $repositories.value) {
|
|||||||
$isLocked = $false
|
$isLocked = $false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Skip analysis for disabled or locked repositories
|
||||||
if ($isDisabled -or $isLocked) {
|
if ($isDisabled -or $isLocked) {
|
||||||
$status = if ($isDisabled) { "DISABLED" } elseif ($isLocked) { "LOCKED" } else { "UNKNOWN" }
|
$status = if ($isDisabled) { "DISABLED" } elseif ($isLocked) { "LOCKED" } else { "UNKNOWN" }
|
||||||
Write-Output-Both " Repository status: $status - Skipping analysis" -ForegroundColor Yellow -ConsoleOnly
|
Write-Output-Both " Repository status: $status - Skipping analysis" -ForegroundColor Yellow -ConsoleOnly
|
||||||
|
|
||||||
|
# Create entry for disabled/locked repository with N/A values
|
||||||
$disabledRepo = [PSCustomObject]@{
|
$disabledRepo = [PSCustomObject]@{
|
||||||
Repository = "$repoName ($status)"
|
Repository = "$repoName ($status)"
|
||||||
TotalRenovatePRs = "N/A"
|
TotalRenovatePRs = "N/A"
|
||||||
@@ -83,23 +200,31 @@ foreach ($repo in $repositories.value) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Retrieve all pull requests for the current repository
|
||||||
$prsUrl = "https://dev.azure.com/$Organization/$Project/_apis/git/repositories/$repoId/pullrequests`?api-version=6.0&searchCriteria.status=all"
|
$prsUrl = "https://dev.azure.com/$Organization/$Project/_apis/git/repositories/$repoId/pullrequests`?api-version=6.0&searchCriteria.status=all"
|
||||||
try {
|
try {
|
||||||
$pullRequests = Invoke-RestMethod -Uri $prsUrl -Method Get -Headers $headers
|
$pullRequests = Invoke-RestMethod -Uri $prsUrl -Method Get -Headers $headers
|
||||||
|
|
||||||
|
# Filter for Renovate PRs based on branch naming pattern
|
||||||
$renovatePRs = $pullRequests.value | Where-Object { $_.sourceRefName -like "refs/heads/renovate/*" }
|
$renovatePRs = $pullRequests.value | Where-Object { $_.sourceRefName -like "refs/heads/renovate/*" }
|
||||||
|
|
||||||
if ($renovatePRs.Count -gt 0) {
|
if ($renovatePRs.Count -gt 0) {
|
||||||
|
# Categorize Renovate PRs by status
|
||||||
$openPRs = $renovatePRs | Where-Object { $_.status -eq "active" }
|
$openPRs = $renovatePRs | Where-Object { $_.status -eq "active" }
|
||||||
$completedPRs = $renovatePRs | Where-Object { $_.status -eq "completed" }
|
$completedPRs = $renovatePRs | Where-Object { $_.status -eq "completed" }
|
||||||
$abandonedPRs = $renovatePRs | Where-Object { $_.status -eq "abandoned" }
|
$abandonedPRs = $renovatePRs | Where-Object { $_.status -eq "abandoned" }
|
||||||
|
|
||||||
|
# Count PRs in each category
|
||||||
$openCount = $openPRs.Count
|
$openCount = $openPRs.Count
|
||||||
$completedCount = $completedPRs.Count
|
$completedCount = $completedPRs.Count
|
||||||
$abandonedCount = $abandonedPRs.Count
|
$abandonedCount = $abandonedPRs.Count
|
||||||
|
|
||||||
|
# Find the most recent PRs in each category for detailed reporting
|
||||||
$latestOpen = $openPRs | Sort-Object creationDate -Descending | Select-Object -First 1
|
$latestOpen = $openPRs | Sort-Object creationDate -Descending | Select-Object -First 1
|
||||||
$latestCompleted = $completedPRs | Sort-Object closedDate -Descending | Select-Object -First 1
|
$latestCompleted = $completedPRs | Sort-Object closedDate -Descending | Select-Object -First 1
|
||||||
$latestCreated = $renovatePRs | Sort-Object creationDate -Descending | Select-Object -First 1
|
$latestCreated = $renovatePRs | Sort-Object creationDate -Descending | Select-Object -First 1
|
||||||
|
|
||||||
|
# Extract key information from the latest PRs
|
||||||
$lastCreatedDate = if ($latestCreated) { $latestCreated.creationDate } else { "N/A" }
|
$lastCreatedDate = if ($latestCreated) { $latestCreated.creationDate } else { "N/A" }
|
||||||
$lastCompletedDate = if ($latestCompleted) { $latestCompleted.closedDate } else { "N/A" }
|
$lastCompletedDate = if ($latestCompleted) { $latestCompleted.closedDate } else { "N/A" }
|
||||||
$lastCompletedPRTitle = if ($latestCompleted) { $latestCompleted.title } else { "N/A" }
|
$lastCompletedPRTitle = if ($latestCompleted) { $latestCompleted.title } else { "N/A" }
|
||||||
@@ -118,7 +243,20 @@ foreach ($repo in $repositories.value) {
|
|||||||
LatestCompletedBranch = $latestCompletedBranch
|
LatestCompletedBranch = $latestCompletedBranch
|
||||||
LastCompletedPRTitle = $lastCompletedPRTitle
|
LastCompletedPRTitle = $lastCompletedPRTitle
|
||||||
RepoStatus = "ACTIVE"
|
RepoStatus = "ACTIVE"
|
||||||
SortDate = if ($lastCreatedDate -eq "N/A") { [DateTime]::MinValue } else { [DateTime]::Parse($lastCreatedDate) }
|
SortDate = if ($lastCreatedDate -eq "N/A") {
|
||||||
|
[DateTime]::MinValue
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
[DateTime]::Parse($lastCreatedDate, [System.Globalization.CultureInfo]::InvariantCulture)
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
[DateTime]::ParseExact($lastCreatedDate, "MM/dd/yyyy HH:mm:ss", [System.Globalization.CultureInfo]::InvariantCulture)
|
||||||
|
} catch {
|
||||||
|
Write-Output-Both " Warning: Could not parse date '$lastCreatedDate' for repository $repoName" -ForegroundColor Yellow -ConsoleOnly
|
||||||
|
[DateTime]::MinValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$repoStats += $repoStat
|
$repoStats += $repoStat
|
||||||
@@ -160,41 +298,71 @@ foreach ($repo in $repositories.value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Generate and display the main statistics report
|
||||||
Write-Output-Both "`n===== RENOVATE PULL REQUEST STATISTICS BY REPOSITORY (SORTED BY LAST CREATED DATE) =====" -ForegroundColor Green
|
Write-Output-Both "`n===== RENOVATE PULL REQUEST STATISTICS BY REPOSITORY (SORTED BY LAST CREATED DATE) =====" -ForegroundColor Green
|
||||||
if ($repoStats.Count -gt 0) {
|
if ($repoStats.Count -gt 0) {
|
||||||
|
# Sort repositories by last created date (most recent first)
|
||||||
$sortedStats = $repoStats | Sort-Object -Property SortDate -Descending
|
$sortedStats = $repoStats | Sort-Object -Property SortDate -Descending
|
||||||
|
|
||||||
|
# Format the data for display with culture-invariant date parsing
|
||||||
$displayStats = $sortedStats | Select-Object Repository, TotalRenovatePRs, OpenPRs, CompletedPRs, AbandonedPRs,
|
$displayStats = $sortedStats | Select-Object Repository, TotalRenovatePRs, OpenPRs, CompletedPRs, AbandonedPRs,
|
||||||
@{Name="LastCreated"; Expression={
|
@{Name="LastCreated"; Expression={
|
||||||
if ($_.LastCreatedDate -eq "N/A" -or $_.LastCreatedDate -eq "Error") { $_.LastCreatedDate }
|
if ($_.LastCreatedDate -eq "N/A" -or $_.LastCreatedDate -eq "Error") {
|
||||||
else { [DateTime]::Parse($_.LastCreatedDate).ToString("yyyy-MM-dd") }
|
$_.LastCreatedDate
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
[DateTime]::Parse($_.LastCreatedDate, [System.Globalization.CultureInfo]::InvariantCulture).ToString("yyyy-MM-dd")
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
[DateTime]::ParseExact($_.LastCreatedDate, "MM/dd/yyyy HH:mm:ss", [System.Globalization.CultureInfo]::InvariantCulture).ToString("yyyy-MM-dd")
|
||||||
|
} catch {
|
||||||
|
$_.LastCreatedDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}},
|
}},
|
||||||
@{Name="LastCompleted"; Expression={
|
@{Name="LastCompleted"; Expression={
|
||||||
if ($_.LastCompletedDate -eq "N/A" -or $_.LastCompletedDate -eq "Error") { $_.LastCompletedDate }
|
if ($_.LastCompletedDate -eq "N/A" -or $_.LastCompletedDate -eq "Error") {
|
||||||
else { [DateTime]::Parse($_.LastCompletedDate).ToString("yyyy-MM-dd") }
|
$_.LastCompletedDate
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
[DateTime]::Parse($_.LastCompletedDate, [System.Globalization.CultureInfo]::InvariantCulture).ToString("yyyy-MM-dd")
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
[DateTime]::ParseExact($_.LastCompletedDate, "MM/dd/yyyy HH:mm:ss", [System.Globalization.CultureInfo]::InvariantCulture).ToString("yyyy-MM-dd")
|
||||||
|
} catch {
|
||||||
|
$_.LastCompletedDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}},
|
}},
|
||||||
LatestOpenBranch, LatestCompletedBranch, LastCompletedPRTitle
|
LatestOpenBranch, LatestCompletedBranch, LastCompletedPRTitle
|
||||||
|
|
||||||
|
# Export detailed data to CSV for further analysis
|
||||||
$displayStats | Export-Csv -Path "$($OutputFile).csv" -NoTypeInformation
|
$displayStats | Export-Csv -Path "$($OutputFile).csv" -NoTypeInformation
|
||||||
# Add a note to the original output file
|
|
||||||
Add-Content -Path $OutputFile -Value "Full data available in: $($OutputFile).csv"
|
Add-Content -Path $OutputFile -Value "Full data available in: $($OutputFile).csv"
|
||||||
|
|
||||||
|
# Add formatted table to the text report and display on console
|
||||||
$statsTable = $displayStats | Format-Table -Property Repository, TotalRenovatePRs, OpenPRs, CompletedPRs, AbandonedPRs, LastCreated, LastCompleted, LatestCompletedBranch, LastCompletedPRTitle | Out-String
|
$statsTable = $displayStats | Format-Table -Property Repository, TotalRenovatePRs, OpenPRs, CompletedPRs, AbandonedPRs, LastCreated, LastCompleted, LatestCompletedBranch, LastCompletedPRTitle | Out-String
|
||||||
Add-Content -Path $OutputFile -Value $statsTable.Trim() # Trim to remove extra whitespace
|
Add-Content -Path $OutputFile -Value $statsTable.Trim()
|
||||||
$displayStats | Format-Table -AutoSize
|
$displayStats | Format-Table -AutoSize
|
||||||
|
|
||||||
|
# Calculate summary statistics
|
||||||
$totalRepos = $repositories.value.Count
|
$totalRepos = $repositories.value.Count
|
||||||
$reposWithRenovate = ($repoStats | Where-Object { $_.TotalRenovatePRs -gt 0 }).Count
|
$reposWithRenovate = ($repoStats | Where-Object { $_.TotalRenovatePRs -gt 0 }).Count
|
||||||
$reposDisabledOrLocked = $disabledRepos.Count
|
$reposDisabledOrLocked = $disabledRepos.Count
|
||||||
$activeRepos = $totalRepos - $reposDisabledOrLocked
|
$activeRepos = $totalRepos - $reposDisabledOrLocked
|
||||||
|
|
||||||
|
# Calculate percentage of active repositories with Renovate PRs
|
||||||
$percentWithRenovate = if ($activeRepos -gt 0) { [math]::Round(($reposWithRenovate / $activeRepos) * 100, 2) } else { 0 }
|
$percentWithRenovate = if ($activeRepos -gt 0) { [math]::Round(($reposWithRenovate / $activeRepos) * 100, 2) } else { 0 }
|
||||||
|
|
||||||
|
# Calculate totals across all active repositories
|
||||||
$totalPRs = ($repoStats | Measure-Object -Property TotalRenovatePRs -Sum).Sum
|
$totalPRs = ($repoStats | Measure-Object -Property TotalRenovatePRs -Sum).Sum
|
||||||
$totalOpenPRs = ($repoStats | Measure-Object -Property OpenPRs -Sum).Sum
|
$totalOpenPRs = ($repoStats | Measure-Object -Property OpenPRs -Sum).Sum
|
||||||
$totalCompletedPRs = ($repoStats | Measure-Object -Property CompletedPRs -Sum).Sum
|
$totalCompletedPRs = ($repoStats | Measure-Object -Property CompletedPRs -Sum).Sum
|
||||||
$totalAbandonedPRs = ($repoStats | Measure-Object -Property AbandonedPRs -Sum).Sum
|
$totalAbandonedPRs = ($repoStats | Measure-Object -Property AbandonedPRs -Sum).Sum
|
||||||
|
|
||||||
|
# Display comprehensive summary statistics
|
||||||
Write-Output-Both "`n===== SUMMARY STATISTICS =====" -ForegroundColor Cyan
|
Write-Output-Both "`n===== SUMMARY STATISTICS =====" -ForegroundColor Cyan
|
||||||
Write-Output-Both "Total repositories: $totalRepos"
|
Write-Output-Both "Total repositories: $totalRepos"
|
||||||
Write-Output-Both "Disabled, locked, or error repositories: $reposDisabledOrLocked"
|
Write-Output-Both "Disabled, locked, or error repositories: $reposDisabledOrLocked"
|
||||||
@@ -206,6 +374,7 @@ if ($repoStats.Count -gt 0) {
|
|||||||
Write-Output-Both "Total completed renovate PRs: $totalCompletedPRs"
|
Write-Output-Both "Total completed renovate PRs: $totalCompletedPRs"
|
||||||
Write-Output-Both "Total abandoned renovate PRs: $totalAbandonedPRs"
|
Write-Output-Both "Total abandoned renovate PRs: $totalAbandonedPRs"
|
||||||
|
|
||||||
|
# Display list of repositories that couldn't be analyzed
|
||||||
if ($disabledRepos.Count -gt 0) {
|
if ($disabledRepos.Count -gt 0) {
|
||||||
Write-Output-Both "`n===== DISABLED/LOCKED/ERROR REPOSITORIES (NOT INCLUDED IN MAIN REPORT) =====" -ForegroundColor Yellow
|
Write-Output-Both "`n===== DISABLED/LOCKED/ERROR REPOSITORIES (NOT INCLUDED IN MAIN REPORT) =====" -ForegroundColor Yellow
|
||||||
$disabledList = $disabledRepos | ForEach-Object { $_.Repository }
|
$disabledList = $disabledRepos | ForEach-Object { $_.Repository }
|
||||||
@@ -215,4 +384,5 @@ if ($repoStats.Count -gt 0) {
|
|||||||
Write-Output-Both "No active repositories found." -ForegroundColor Yellow
|
Write-Output-Both "No active repositories found." -ForegroundColor Yellow
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Display completion message
|
||||||
Write-Output-Both "`nReport saved to: $OutputFile" -ForegroundColor Cyan
|
Write-Output-Both "`nReport saved to: $OutputFile" -ForegroundColor Cyan
|
||||||
@@ -1,101 +1,329 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Comprehensive Entra ID (Azure AD) group membership analysis with recursive member enumeration and parent group discovery.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This script provides detailed analysis of Entra ID group memberships by recursively enumerating all members
|
||||||
|
of a specified group and discovering all parent groups the target group belongs to. It handles nested group
|
||||||
|
structures, prevents infinite loops through circular reference detection, and exports comprehensive reports.
|
||||||
|
|
||||||
|
Key Features:
|
||||||
|
• Recursive member enumeration with circular reference protection
|
||||||
|
• Parent group discovery and membership chain analysis
|
||||||
|
• Support for both group names and Group IDs as input
|
||||||
|
• Detailed member information including user principals and group types
|
||||||
|
• Dual CSV export: group members and parent group memberships
|
||||||
|
• Maximum recursion depth protection (50 levels)
|
||||||
|
• Comprehensive error handling and logging
|
||||||
|
|
||||||
|
.PARAMETER GroupId
|
||||||
|
The Group ID (GUID) or display name of the Entra ID group to analyze.
|
||||||
|
Supports both formats:
|
||||||
|
- Group ID: "12345678-1234-1234-1234-123456789012"
|
||||||
|
- Group Name: "Developer Team" or "# Developer ADM"
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\GroupMemberships.ps1 -GroupId "12345678-1234-1234-1234-123456789012"
|
||||||
|
|
||||||
|
Analyzes the group with the specified GUID, recursively enumerating all members and parent groups.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\GroupMemberships.ps1 -GroupId "# Developer ADM"
|
||||||
|
|
||||||
|
Analyzes the group with display name "# Developer ADM", automatically resolving the name to Group ID.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\GroupMemberships.ps1 -GroupId "Domain Admins"
|
||||||
|
|
||||||
|
Analyzes the "Domain Admins" group, useful for security auditing of privileged groups.
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
Two CSV files are generated:
|
||||||
|
1. "[timestamp] ([GroupName]) group members.csv" - Complete recursive member listing
|
||||||
|
2. "[timestamp] ([GroupName]) group memberships - parent groups.csv" - Parent group hierarchy
|
||||||
|
|
||||||
|
CSV Columns for Members:
|
||||||
|
- ParentGroupId: ID of the group containing this member
|
||||||
|
- ParentGroupName: Display name of the parent group
|
||||||
|
- ParentGroupType: Group type classification
|
||||||
|
- MemberId: Unique identifier of the member
|
||||||
|
- MemberType: Type of member (user, group, etc.)
|
||||||
|
- MemberName: Display name of the member
|
||||||
|
- MemberUPN: User Principal Name (for users)
|
||||||
|
- MemberEmail: Email address
|
||||||
|
- Level: Nesting level in the group hierarchy
|
||||||
|
- Path: Complete membership path showing nested relationships
|
||||||
|
|
||||||
|
CSV Columns for Parent Groups:
|
||||||
|
- ChildGroupId: ID of the child group
|
||||||
|
- ParentGroupId: ID of the parent group
|
||||||
|
- ParentGroupName: Display name of the parent group
|
||||||
|
- ParentGroupType: Group type classification
|
||||||
|
- ParentGroupEmail: Email address of the parent group
|
||||||
|
- MembershipLevel: Level in the parent hierarchy
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
File Name : GroupMemberships.ps1
|
||||||
|
Author : Cloud Engineering Team
|
||||||
|
Prerequisite : Microsoft Graph PowerShell SDK
|
||||||
|
Created : 2024
|
||||||
|
Updated : 2025-10-30
|
||||||
|
Version : 2.0
|
||||||
|
|
||||||
|
Required Permissions:
|
||||||
|
• Group.Read.All - Read group properties and memberships
|
||||||
|
• GroupMember.Read.All - Read group member details
|
||||||
|
• User.Read.All - Read user properties (for member details)
|
||||||
|
|
||||||
|
Security Considerations:
|
||||||
|
• Script requires privileged Graph API permissions
|
||||||
|
• Handles sensitive group membership data
|
||||||
|
• Implements circular reference protection
|
||||||
|
• Maximum recursion depth prevents infinite loops
|
||||||
|
• Comprehensive audit trail in CSV exports
|
||||||
|
|
||||||
|
Performance Notes:
|
||||||
|
• Large groups may take considerable time to process
|
||||||
|
• Recursive enumeration can be resource-intensive
|
||||||
|
• Implements caching to prevent duplicate API calls
|
||||||
|
• Progress indicators help track long-running operations
|
||||||
|
|
||||||
|
.LINK
|
||||||
|
https://docs.microsoft.com/en-us/graph/api/group-list-members
|
||||||
|
https://docs.microsoft.com/en-us/graph/api/group-list-memberof
|
||||||
|
|
||||||
|
.FUNCTIONALITY
|
||||||
|
• Entra ID group membership analysis
|
||||||
|
• Recursive member enumeration
|
||||||
|
• Parent group discovery
|
||||||
|
• Circular reference detection
|
||||||
|
• Comprehensive reporting
|
||||||
|
• Security auditing support
|
||||||
|
#>
|
||||||
|
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory=$true, HelpMessage="Enter the Group ID (GUID) or display name of the Entra ID group to analyze")]
|
||||||
|
[ValidateNotNullOrEmpty()]
|
||||||
[string]$GroupId
|
[string]$GroupId
|
||||||
)
|
)
|
||||||
|
|
||||||
# GroupMemberships.ps1 -GroupId "# Developer ADM"
|
# Example usage patterns:
|
||||||
# GroupMemberships.ps1 -GroupId "Domain Admins"
|
# .\GroupMemberships.ps1 -GroupId "# Developer ADM"
|
||||||
# GroupMemberships.ps1 -GroupId "# Developer"
|
# .\GroupMemberships.ps1 -GroupId "Domain Admins"
|
||||||
# GroupMemberships.ps1 -GroupId "# Interne Automatisering Team-Assistent"
|
# .\GroupMemberships.ps1 -GroupId "# Developer"
|
||||||
# GroupMemberships.ps1 -GroupId "# Interne Automatisering"
|
# .\GroupMemberships.ps1 -GroupId "# Interne Automatisering Team-Assistent"
|
||||||
|
# .\GroupMemberships.ps1 -GroupId "# Interne Automatisering"
|
||||||
|
|
||||||
|
#Requires -Modules Microsoft.Graph.Groups, Microsoft.Graph.Users
|
||||||
|
#Requires -Version 5.1
|
||||||
|
|
||||||
|
# Initialize script execution
|
||||||
|
Write-Host "🔍 Entra ID Group Membership Analyzer" -ForegroundColor Cyan
|
||||||
|
Write-Host "======================================" -ForegroundColor Cyan
|
||||||
|
Write-Host "📅 Started: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" -ForegroundColor Green
|
||||||
|
Write-Host "🎯 Target Group: $GroupId" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Generate timestamped output file paths
|
||||||
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
||||||
[string] $OutputPath = ".\$date ($GroupId) group members.csv"
|
[string] $OutputPath = ".\$date ($GroupId) group members.csv"
|
||||||
[string] $membershipOutputPath = ".\$date ($GroupId) group memberships - parent groups .csv"
|
[string] $membershipOutputPath = ".\$date ($GroupId) group memberships - parent groups .csv"
|
||||||
|
|
||||||
# Connect to Microsoft Graph if not already connected
|
# Initialize error tracking
|
||||||
Write-Host "Connecting to Microsoft Graph..."
|
$Global:ErrorCount = 0
|
||||||
Connect-MgGraph -Scopes "Group.Read.All", "GroupMember.Read.All" -NoWelcome
|
$Global:WarningCount = 0
|
||||||
|
|
||||||
# If GroupId is actually a group name, resolve it to the actual GroupId
|
try {
|
||||||
|
# Connect to Microsoft Graph with required permissions
|
||||||
|
Write-Host "🔐 Establishing Microsoft Graph connection..." -ForegroundColor Yellow
|
||||||
|
$requiredScopes = @("Group.Read.All", "GroupMember.Read.All", "User.Read.All")
|
||||||
|
|
||||||
|
# Check if already connected with required scopes
|
||||||
|
$currentContext = Get-MgContext
|
||||||
|
if ($currentContext -and $currentContext.Scopes) {
|
||||||
|
$missingScopes = $requiredScopes | Where-Object { $_ -notin $currentContext.Scopes }
|
||||||
|
if ($missingScopes.Count -gt 0) {
|
||||||
|
Write-Warning "Missing required scopes: $($missingScopes -join ', '). Reconnecting..."
|
||||||
|
$Global:WarningCount++
|
||||||
|
Disconnect-MgGraph -ErrorAction SilentlyContinue
|
||||||
|
$currentContext = $null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $currentContext) {
|
||||||
|
Connect-MgGraph -Scopes $requiredScopes -NoWelcome -ErrorAction Stop
|
||||||
|
Write-Host "✅ Successfully connected to Microsoft Graph" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host "✅ Using existing Microsoft Graph connection" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
# Display connection context
|
||||||
|
$context = Get-MgContext
|
||||||
|
Write-Host "🏢 Tenant: $($context.TenantId)" -ForegroundColor Gray
|
||||||
|
Write-Host "👤 Account: $($context.Account)" -ForegroundColor Gray
|
||||||
|
Write-Host "🔑 Scopes: $($context.Scopes -join ', ')" -ForegroundColor Gray
|
||||||
|
Write-Host ""
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Error "❌ Failed to connect to Microsoft Graph: $($_.Exception.Message)"
|
||||||
|
$Global:ErrorCount++
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve group identifier (support both Group ID and display name)
|
||||||
if ($GroupId -notmatch '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') {
|
if ($GroupId -notmatch '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') {
|
||||||
Write-Host "Resolving group name '$GroupId' to GroupId..."
|
Write-Host "🔍 Resolving group name '$GroupId' to Group ID..." -ForegroundColor Yellow
|
||||||
try {
|
try {
|
||||||
|
# Use filter to find groups by display name
|
||||||
$group = Get-MgGroup -Filter "displayName eq '$GroupId'" -ErrorAction Stop
|
$group = Get-MgGroup -Filter "displayName eq '$GroupId'" -ErrorAction Stop
|
||||||
if ($group) {
|
if ($group) {
|
||||||
if ($group.Count -gt 1) {
|
if ($group.Count -gt 1) {
|
||||||
Write-Warning "Multiple groups found with name '$GroupId'. Using the first one."
|
Write-Warning "⚠️ Multiple groups found with name '$GroupId'. Using the first match."
|
||||||
|
$Global:WarningCount++
|
||||||
|
Write-Host " Found groups:" -ForegroundColor Yellow
|
||||||
|
$group | ForEach-Object { Write-Host " - $($_.DisplayName) ($($_.Id))" -ForegroundColor Yellow }
|
||||||
$GroupId = $group[0].Id
|
$GroupId = $group[0].Id
|
||||||
} else {
|
} else {
|
||||||
$GroupId = $group.Id
|
$GroupId = $group.Id
|
||||||
}
|
}
|
||||||
Write-Host "Resolved to GroupId: $GroupId"
|
Write-Host "✅ Resolved to Group ID: $GroupId" -ForegroundColor Green
|
||||||
} else {
|
} else {
|
||||||
Write-Error "Group with name '$GroupId' not found."
|
Write-Error "❌ Group with name '$GroupId' not found in tenant."
|
||||||
|
$Global:ErrorCount++
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
Write-Error "Error resolving group name: $($_.Exception.Message)"
|
Write-Error "❌ Error resolving group name: $($_.Exception.Message)"
|
||||||
|
$Global:ErrorCount++
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
Write-Host "🆔 Using provided Group ID: $GroupId" -ForegroundColor Green
|
||||||
}
|
}
|
||||||
|
|
||||||
# Function to get groups that this group is a member of (reverse membership)
|
# Validate the resolved group exists and get basic information
|
||||||
|
try {
|
||||||
|
Write-Host "🔍 Validating target group..." -ForegroundColor Yellow
|
||||||
|
$targetGroup = Get-MgGroup -GroupId $GroupId -Select "Id,DisplayName,Description,GroupTypes,Mail,SecurityEnabled,MailEnabled" -ErrorAction Stop
|
||||||
|
|
||||||
|
Write-Host "✅ Target group validated:" -ForegroundColor Green
|
||||||
|
Write-Host " 📝 Name: $($targetGroup.DisplayName)" -ForegroundColor White
|
||||||
|
Write-Host " 🆔 ID: $($targetGroup.Id)" -ForegroundColor White
|
||||||
|
Write-Host " 📧 Email: $($targetGroup.Mail ?? 'N/A')" -ForegroundColor White
|
||||||
|
Write-Host " 🏷️ Type: $($targetGroup.GroupTypes -join ', ' ?? 'Security Group')" -ForegroundColor White
|
||||||
|
Write-Host " 🔒 Security Enabled: $($targetGroup.SecurityEnabled)" -ForegroundColor White
|
||||||
|
Write-Host " 📨 Mail Enabled: $($targetGroup.MailEnabled)" -ForegroundColor White
|
||||||
|
if ($targetGroup.Description) {
|
||||||
|
Write-Host " 📄 Description: $($targetGroup.Description)" -ForegroundColor White
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Error "❌ Failed to validate group '$GroupId': $($_.Exception.Message)"
|
||||||
|
$Global:ErrorCount++
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to get groups that this group is a member of (reverse membership discovery)
|
||||||
function Get-GroupMembershipRecursive {
|
function Get-GroupMembershipRecursive {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Recursively discovers all parent groups that the specified group belongs to.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This function performs reverse membership analysis by finding all groups that contain
|
||||||
|
the specified group as a member. It handles nested group structures and prevents
|
||||||
|
infinite loops through circular reference detection.
|
||||||
|
|
||||||
|
.PARAMETER GroupId
|
||||||
|
The Group ID to analyze for parent group memberships.
|
||||||
|
|
||||||
|
.PARAMETER Level
|
||||||
|
Current recursion level (used internally for depth tracking).
|
||||||
|
|
||||||
|
.PARAMETER ProcessedMemberships
|
||||||
|
Hashtable tracking processed groups to prevent circular references.
|
||||||
|
|
||||||
|
.RETURNS
|
||||||
|
Array of custom objects representing parent group relationships.
|
||||||
|
#>
|
||||||
param(
|
param(
|
||||||
|
[Parameter(Mandatory=$true)]
|
||||||
[string]$GroupId,
|
[string]$GroupId,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
[int]$Level = 0,
|
[int]$Level = 0,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
[hashtable]$ProcessedMemberships = @{}
|
[hashtable]$ProcessedMemberships = @{}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check for circular reference in membership chain
|
# Circular reference protection
|
||||||
if ($ProcessedMemberships.ContainsKey($GroupId)) {
|
if ($ProcessedMemberships.ContainsKey($GroupId)) {
|
||||||
Write-Warning "Circular membership reference detected for group: $GroupId"
|
Write-Warning "⚠️ Circular membership reference detected for group: $GroupId (Level: $Level)"
|
||||||
|
$Global:WarningCount++
|
||||||
return @()
|
return @()
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check for maximum depth
|
# Maximum recursion depth protection
|
||||||
if ($Level -gt 50) {
|
if ($Level -gt 50) {
|
||||||
Write-Warning "Maximum membership recursion depth reached for group: $GroupId"
|
Write-Warning "⚠️ Maximum membership recursion depth (50) reached for group: $GroupId"
|
||||||
|
$Global:WarningCount++
|
||||||
return @()
|
return @()
|
||||||
}
|
}
|
||||||
|
|
||||||
# Mark group as being processed for membership
|
# Mark group as being processed for membership discovery
|
||||||
$ProcessedMemberships[$GroupId] = $true
|
$ProcessedMemberships[$GroupId] = $true
|
||||||
$membershipResults = @()
|
$membershipResults = @()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
Write-Verbose "Discovering parent groups for GroupId: $GroupId (Level: $Level)"
|
||||||
|
|
||||||
# Get groups that this group is a member of
|
# Get groups that this group is a member of
|
||||||
$memberOfGroups = Get-MgGroupMemberOf -GroupId $GroupId -All
|
$memberOfGroups = Get-MgGroupMemberOf -GroupId $GroupId -All -ErrorAction Stop
|
||||||
|
|
||||||
foreach ($parentGroup in $memberOfGroups) {
|
foreach ($parentGroup in $memberOfGroups) {
|
||||||
|
# Only process actual groups (not other object types)
|
||||||
if ($parentGroup.AdditionalProperties['@odata.type'] -eq '#microsoft.graph.group') {
|
if ($parentGroup.AdditionalProperties['@odata.type'] -eq '#microsoft.graph.group') {
|
||||||
$parentGroupDetails = Get-MgGroup -GroupId $parentGroup.Id -Select "DisplayName,Mail,GroupTypes"
|
try {
|
||||||
|
# Get detailed information about the parent group
|
||||||
|
$parentGroupDetails = Get-MgGroup -GroupId $parentGroup.Id -Select "DisplayName,Mail,GroupTypes,SecurityEnabled,MailEnabled" -ErrorAction Stop
|
||||||
|
|
||||||
|
# Create membership relationship object
|
||||||
$membershipObject = [PSCustomObject]@{
|
$membershipObject = [PSCustomObject]@{
|
||||||
ChildGroupId = $GroupId
|
ChildGroupId = $GroupId
|
||||||
ParentGroupId = $parentGroup.Id
|
ParentGroupId = $parentGroup.Id
|
||||||
ParentGroupName = $parentGroupDetails.DisplayName
|
ParentGroupName = $parentGroupDetails.DisplayName
|
||||||
ParentGroupType = $parentGroupDetails.GroupTypes -join ","
|
ParentGroupType = if ($parentGroupDetails.GroupTypes) { $parentGroupDetails.GroupTypes -join "," } else { "Security" }
|
||||||
ParentGroupEmail = $parentGroupDetails.Mail
|
ParentGroupEmail = $parentGroupDetails.Mail ?? ""
|
||||||
MembershipLevel = $Level
|
MembershipLevel = $Level
|
||||||
|
SecurityEnabled = $parentGroupDetails.SecurityEnabled
|
||||||
|
MailEnabled = $parentGroupDetails.MailEnabled
|
||||||
|
DiscoveredAt = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||||
}
|
}
|
||||||
|
|
||||||
$membershipResults += $membershipObject
|
$membershipResults += $membershipObject
|
||||||
|
Write-Verbose "Found parent group: $($parentGroupDetails.DisplayName) at level $Level"
|
||||||
|
|
||||||
# Recursively get parent groups of this parent group
|
# Recursively discover parent groups of this parent group
|
||||||
if ($parentGroup.Id -ne $GroupId) {
|
if ($parentGroup.Id -ne $GroupId) {
|
||||||
$nestedMemberships = Get-GroupMembershipRecursive -GroupId $parentGroup.Id -Level ($Level + 1) -ProcessedMemberships $ProcessedMemberships
|
$nestedMemberships = Get-GroupMembershipRecursive -GroupId $parentGroup.Id -Level ($Level + 1) -ProcessedMemberships $ProcessedMemberships
|
||||||
$membershipResults += $nestedMemberships
|
$membershipResults += $nestedMemberships
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning "⚠️ Failed to get details for parent group $($parentGroup.Id): $($_.Exception.Message)"
|
||||||
|
$Global:WarningCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
Write-Error "Error getting group memberships for $GroupId`: $($_.Exception.Message)"
|
Write-Error "❌ Error getting group memberships for $GroupId`: $($_.Exception.Message)"
|
||||||
|
$Global:ErrorCount++
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
# Remove from processed memberships when done
|
# Clean up: remove from processed memberships when done
|
||||||
$ProcessedMemberships.Remove($GroupId)
|
$ProcessedMemberships.Remove($GroupId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,123 +331,322 @@ function Get-GroupMembershipRecursive {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# Initialize collections
|
# Initialize script-level collections for member enumeration
|
||||||
$groupMembers = @()
|
$script:groupMembers = @()
|
||||||
$processedGroups = @{}
|
$script:processedGroups = @{}
|
||||||
$groupStack = @()
|
$script:groupStack = @()
|
||||||
|
$script:memberCount = 0
|
||||||
|
$script:groupCount = 0
|
||||||
|
|
||||||
# Function to get group members recursively
|
# Function to recursively enumerate all group members
|
||||||
# This function will handle circular references and maximum recursion depth
|
|
||||||
function Get-GroupMembersRecursive {
|
function Get-GroupMembersRecursive {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Recursively enumerates all members of a group, including nested group members.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This function performs deep enumeration of group membership by recursively processing
|
||||||
|
nested groups. It includes comprehensive protection against circular references and
|
||||||
|
infinite recursion, while providing detailed member information.
|
||||||
|
|
||||||
|
.PARAMETER GroupId
|
||||||
|
The Group ID to enumerate members for.
|
||||||
|
|
||||||
|
.PARAMETER Level
|
||||||
|
Current recursion level (used internally for depth tracking).
|
||||||
|
|
||||||
|
.PARAMETER ParentGroupName
|
||||||
|
Name of the parent group (used for audit trail).
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Uses script-level variables to maintain state across recursive calls:
|
||||||
|
- $script:groupMembers: Collection of all discovered members
|
||||||
|
- $script:processedGroups: Tracks processed groups for circular reference protection
|
||||||
|
- $script:groupStack: Current processing stack for immediate loop detection
|
||||||
|
#>
|
||||||
param(
|
param(
|
||||||
|
[Parameter(Mandatory=$true)]
|
||||||
[string]$GroupId,
|
[string]$GroupId,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
[int]$Level = 0,
|
[int]$Level = 0,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
[string]$ParentGroupName = ""
|
[string]$ParentGroupName = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check for circular reference
|
# Circular reference protection
|
||||||
if ($processedGroups.ContainsKey($GroupId)) {
|
if ($script:processedGroups.ContainsKey($GroupId)) {
|
||||||
Write-Warning "Circular reference detected for group: $GroupId"
|
Write-Warning "⚠️ Circular reference detected for group: $GroupId (Level: $Level)"
|
||||||
|
$Global:WarningCount++
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check for stack overflow (max depth)
|
# Maximum recursion depth protection
|
||||||
if ($Level -gt 50) {
|
if ($Level -gt 50) {
|
||||||
Write-Warning "Maximum recursion depth reached for group: $GroupId"
|
Write-Warning "⚠️ Maximum recursion depth (50) reached for group: $GroupId"
|
||||||
|
$Global:WarningCount++
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
# Mark group as being processed
|
# Mark group as being processed and add to processing stack
|
||||||
$processedGroups[$GroupId] = $true
|
$script:processedGroups[$GroupId] = $true
|
||||||
$groupStack += $GroupId
|
$script:groupStack += $GroupId
|
||||||
|
$script:groupCount++
|
||||||
|
|
||||||
try {
|
try {
|
||||||
# Get the group information
|
# Get the group information with required properties
|
||||||
$group = Get-MgGroup -GroupId $GroupId -ErrorAction Stop
|
$group = Get-MgGroup -GroupId $GroupId -Select "Id,DisplayName,GroupTypes,Mail,SecurityEnabled,MailEnabled" -ErrorAction Stop
|
||||||
|
Write-Verbose "Processing group: $($group.DisplayName) (Level: $Level)"
|
||||||
|
|
||||||
# Get group members
|
# Get all group members
|
||||||
$members = Get-MgGroupMember -GroupId $GroupId -All
|
$members = Get-MgGroupMember -GroupId $GroupId -All -ErrorAction Stop
|
||||||
|
Write-Host " 📋 Processing $($members.Count) members in group: $($group.DisplayName) (Level: $Level)" -ForegroundColor Gray
|
||||||
|
|
||||||
foreach ($member in $members) {
|
foreach ($member in $members) {
|
||||||
# Create custom object for the result
|
$script:memberCount++
|
||||||
|
|
||||||
|
# Create comprehensive member object
|
||||||
$memberObject = [PSCustomObject]@{
|
$memberObject = [PSCustomObject]@{
|
||||||
ParentGroupId = $GroupId
|
ParentGroupId = $GroupId
|
||||||
ParentGroupName = $group.DisplayName
|
ParentGroupName = $group.DisplayName
|
||||||
ParentGroupType = $group.GroupTypes -join ","
|
ParentGroupType = if ($group.GroupTypes) { $group.GroupTypes -join "," } else { "Security" }
|
||||||
|
ParentGroupEmail = $group.Mail ?? ""
|
||||||
MemberId = $member.Id
|
MemberId = $member.Id
|
||||||
MemberType = $member.AdditionalProperties['@odata.type'] -replace '#microsoft.graph.', ''
|
MemberType = $member.AdditionalProperties['@odata.type'] -replace '#microsoft.graph.', ''
|
||||||
Level = $Level
|
Level = $Level
|
||||||
Path = ($groupStack -join " -> ") + " -> " + $member.Id
|
Path = ($script:groupStack -join " -> ") + " -> " + $member.Id
|
||||||
|
ProcessedAt = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||||
|
SecurityEnabled = $group.SecurityEnabled
|
||||||
|
MailEnabled = $group.MailEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get member details based on type
|
# Get detailed member information based on object type
|
||||||
|
try {
|
||||||
switch ($member.AdditionalProperties['@odata.type']) {
|
switch ($member.AdditionalProperties['@odata.type']) {
|
||||||
'#microsoft.graph.user' {
|
'#microsoft.graph.user' {
|
||||||
$user = Get-MgUser -UserId $member.Id -Select "DisplayName,UserPrincipalName,Mail"
|
$user = Get-MgUser -UserId $member.Id -Select "DisplayName,UserPrincipalName,Mail,AccountEnabled,UserType" -ErrorAction Stop
|
||||||
$memberObject | Add-Member -NotePropertyName "MemberName" -NotePropertyValue $user.DisplayName
|
$memberObject | Add-Member -NotePropertyName "MemberName" -NotePropertyValue $user.DisplayName
|
||||||
$memberObject | Add-Member -NotePropertyName "MemberUPN" -NotePropertyValue $user.UserPrincipalName
|
$memberObject | Add-Member -NotePropertyName "MemberUPN" -NotePropertyValue $user.UserPrincipalName
|
||||||
$memberObject | Add-Member -NotePropertyName "MemberEmail" -NotePropertyValue $user.Mail
|
$memberObject | Add-Member -NotePropertyName "MemberEmail" -NotePropertyValue ($user.Mail ?? "")
|
||||||
|
$memberObject | Add-Member -NotePropertyName "MemberGroupTypes" -NotePropertyValue ""
|
||||||
|
$memberObject | Add-Member -NotePropertyName "AccountEnabled" -NotePropertyValue $user.AccountEnabled
|
||||||
|
$memberObject | Add-Member -NotePropertyName "UserType" -NotePropertyValue ($user.UserType ?? "Member")
|
||||||
}
|
}
|
||||||
'#microsoft.graph.group' {
|
'#microsoft.graph.group' {
|
||||||
$memberGroup = Get-MgGroup -GroupId $member.Id -Select "DisplayName,Mail,GroupTypes"
|
$memberGroup = Get-MgGroup -GroupId $member.Id -Select "DisplayName,Mail,GroupTypes,SecurityEnabled,MailEnabled" -ErrorAction Stop
|
||||||
$memberObject | Add-Member -NotePropertyName "MemberName" -NotePropertyValue $memberGroup.DisplayName
|
$memberObject | Add-Member -NotePropertyName "MemberName" -NotePropertyValue $memberGroup.DisplayName
|
||||||
$memberObject | Add-Member -NotePropertyName "MemberUPN" -NotePropertyValue ""
|
$memberObject | Add-Member -NotePropertyName "MemberUPN" -NotePropertyValue ""
|
||||||
$memberObject | Add-Member -NotePropertyName "MemberEmail" -NotePropertyValue $memberGroup.Mail
|
$memberObject | Add-Member -NotePropertyName "MemberEmail" -NotePropertyValue ($memberGroup.Mail ?? "")
|
||||||
$memberObject | Add-Member -NotePropertyName "MemberGroupTypes" -NotePropertyValue ($memberGroup.GroupTypes -join ",")
|
$memberObject | Add-Member -NotePropertyName "MemberGroupTypes" -NotePropertyValue (if ($memberGroup.GroupTypes) { $memberGroup.GroupTypes -join "," } else { "Security" })
|
||||||
|
$memberObject | Add-Member -NotePropertyName "AccountEnabled" -NotePropertyValue $memberGroup.SecurityEnabled
|
||||||
|
$memberObject | Add-Member -NotePropertyName "UserType" -NotePropertyValue "Group"
|
||||||
}
|
}
|
||||||
default {
|
'#microsoft.graph.servicePrincipal' {
|
||||||
$memberObject | Add-Member -NotePropertyName "MemberName" -NotePropertyValue "Unknown"
|
$servicePrincipal = Get-MgServicePrincipal -ServicePrincipalId $member.Id -Select "DisplayName,AppId,ServicePrincipalType" -ErrorAction Stop
|
||||||
|
$memberObject | Add-Member -NotePropertyName "MemberName" -NotePropertyValue $servicePrincipal.DisplayName
|
||||||
$memberObject | Add-Member -NotePropertyName "MemberUPN" -NotePropertyValue ""
|
$memberObject | Add-Member -NotePropertyName "MemberUPN" -NotePropertyValue ""
|
||||||
$memberObject | Add-Member -NotePropertyName "MemberEmail" -NotePropertyValue ""
|
$memberObject | Add-Member -NotePropertyName "MemberEmail" -NotePropertyValue ""
|
||||||
|
$memberObject | Add-Member -NotePropertyName "MemberGroupTypes" -NotePropertyValue ""
|
||||||
|
$memberObject | Add-Member -NotePropertyName "AccountEnabled" -NotePropertyValue $true
|
||||||
|
$memberObject | Add-Member -NotePropertyName "UserType" -NotePropertyValue "ServicePrincipal"
|
||||||
}
|
}
|
||||||
|
default {
|
||||||
|
$memberObject | Add-Member -NotePropertyName "MemberName" -NotePropertyValue "Unknown Object Type"
|
||||||
|
$memberObject | Add-Member -NotePropertyName "MemberUPN" -NotePropertyValue ""
|
||||||
|
$memberObject | Add-Member -NotePropertyName "MemberEmail" -NotePropertyValue ""
|
||||||
|
$memberObject | Add-Member -NotePropertyName "MemberGroupTypes" -NotePropertyValue ""
|
||||||
|
$memberObject | Add-Member -NotePropertyName "AccountEnabled" -NotePropertyValue $null
|
||||||
|
$memberObject | Add-Member -NotePropertyName "UserType" -NotePropertyValue "Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning "⚠️ Failed to get details for member $($member.Id): $($_.Exception.Message)"
|
||||||
|
$Global:WarningCount++
|
||||||
|
$memberObject | Add-Member -NotePropertyName "MemberName" -NotePropertyValue "Error retrieving details"
|
||||||
|
$memberObject | Add-Member -NotePropertyName "MemberUPN" -NotePropertyValue ""
|
||||||
|
$memberObject | Add-Member -NotePropertyName "MemberEmail" -NotePropertyValue ""
|
||||||
|
$memberObject | Add-Member -NotePropertyName "MemberGroupTypes" -NotePropertyValue ""
|
||||||
|
$memberObject | Add-Member -NotePropertyName "AccountEnabled" -NotePropertyValue $null
|
||||||
|
$memberObject | Add-Member -NotePropertyName "UserType" -NotePropertyValue "Error"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Add member to results collection
|
||||||
$script:groupMembers += $memberObject
|
$script:groupMembers += $memberObject
|
||||||
|
|
||||||
# If member is a group, recurse into it
|
# If member is a group, recursively process its members
|
||||||
if ($member.AdditionalProperties['@odata.type'] -eq '#microsoft.graph.group') {
|
if ($member.AdditionalProperties['@odata.type'] -eq '#microsoft.graph.group') {
|
||||||
# Check if this group is already in the current path to prevent immediate loops
|
# Check if this group is already in the current processing path to prevent immediate loops
|
||||||
if ($member.Id -notin $groupStack) {
|
if ($member.Id -notin $script:groupStack) {
|
||||||
|
Write-Verbose "Recursing into nested group: $($memberObject.MemberName)"
|
||||||
Get-GroupMembersRecursive -GroupId $member.Id -Level ($Level + 1) -ParentGroupName $group.DisplayName
|
Get-GroupMembersRecursive -GroupId $member.Id -Level ($Level + 1) -ParentGroupName $group.DisplayName
|
||||||
} else {
|
} else {
|
||||||
Write-Warning "Immediate circular reference detected. Skipping group: $($memberGroup.DisplayName)"
|
Write-Warning "⚠️ Immediate circular reference detected. Skipping group: $($memberObject.MemberName)"
|
||||||
|
$Global:WarningCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
Write-Error "Error processing group $GroupId`: $($_.Exception.Message)"
|
Write-Error "❌ Error processing group $GroupId`: $($_.Exception.Message)"
|
||||||
|
$Global:ErrorCount++
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
# Remove from stack and processed groups when done
|
# Clean up: remove from processing stack and processed groups when done
|
||||||
$groupStack = $groupStack[0..($groupStack.Length-2)]
|
if ($script:groupStack.Length -gt 0) {
|
||||||
$processedGroups.Remove($GroupId)
|
$script:groupStack = $script:groupStack[0..($script:groupStack.Length-2)]
|
||||||
|
}
|
||||||
|
$script:processedGroups.Remove($GroupId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Start the recursive process
|
# Execute recursive member enumeration
|
||||||
Write-Host "Starting recursive group membership scan for group: $GroupId"
|
Write-Host "🔍 Starting recursive group membership analysis..." -ForegroundColor Cyan
|
||||||
Get-GroupMembersRecursive -GroupId $GroupId
|
Write-Host "📊 Progress will be shown for each processed group..." -ForegroundColor Gray
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
# Export results to CSV
|
$startTime = Get-Date
|
||||||
if ($groupMembers.Count -gt 0) {
|
try {
|
||||||
$groupMembers | Export-Csv -Path $OutputPath -NoTypeInformation
|
Get-GroupMembersRecursive -GroupId $GroupId
|
||||||
Write-Host "Results exported to: $OutputPath"
|
$processingTime = (Get-Date) - $startTime
|
||||||
Write-Host "Total members found: $($groupMembers.Count)"
|
|
||||||
} else {
|
Write-Host ""
|
||||||
Write-Host "No members found in the specified group."
|
Write-Host "✅ Member enumeration completed!" -ForegroundColor Green
|
||||||
|
Write-Host "⏱️ Processing time: $($processingTime.TotalSeconds.ToString('F2')) seconds" -ForegroundColor Gray
|
||||||
|
Write-Host "📈 Performance metrics:" -ForegroundColor Gray
|
||||||
|
Write-Host " • Groups processed: $script:groupCount" -ForegroundColor Gray
|
||||||
|
Write-Host " • Members discovered: $script:memberCount" -ForegroundColor Gray
|
||||||
|
Write-Host " • Processing rate: $([math]::Round($script:memberCount / $processingTime.TotalSeconds, 2)) members/second" -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Error "❌ Fatal error during member enumeration: $($_.Exception.Message)"
|
||||||
|
$Global:ErrorCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get group memberships (groups this group belongs to)
|
# Export member results to CSV with error handling
|
||||||
Write-Host "Getting group memberships for group: $GroupId"
|
Write-Host ""
|
||||||
$groupMemberships = Get-GroupMembershipRecursive -GroupId $GroupId
|
Write-Host "📄 Exporting member analysis results..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
if ($script:groupMembers.Count -gt 0) {
|
||||||
|
try {
|
||||||
|
$script:groupMembers | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8 -ErrorAction Stop
|
||||||
|
Write-Host "✅ Member results exported successfully!" -ForegroundColor Green
|
||||||
|
Write-Host " 📁 File: $OutputPath" -ForegroundColor White
|
||||||
|
Write-Host " 📊 Total members: $($script:groupMembers.Count)" -ForegroundColor White
|
||||||
|
|
||||||
|
# Provide member type breakdown
|
||||||
|
$memberTypes = $script:groupMembers | Group-Object MemberType
|
||||||
|
Write-Host " 📋 Member types:" -ForegroundColor White
|
||||||
|
foreach ($type in $memberTypes) {
|
||||||
|
Write-Host " • $($type.Name): $($type.Count)" -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
|
||||||
|
# Show nesting level statistics
|
||||||
|
$levels = $script:groupMembers | Group-Object Level
|
||||||
|
Write-Host " 🏗️ Nesting levels:" -ForegroundColor White
|
||||||
|
foreach ($level in ($levels | Sort-Object Name)) {
|
||||||
|
Write-Host " • Level $($level.Name): $($level.Count) members" -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Error "❌ Failed to export member results: $($_.Exception.Message)"
|
||||||
|
$Global:ErrorCount++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Host "ℹ️ No members found in the specified group or its nested groups." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
# Execute parent group membership discovery
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "🔍 Discovering parent group memberships..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$membershipStartTime = Get-Date
|
||||||
|
try {
|
||||||
|
$groupMemberships = Get-GroupMembershipRecursive -GroupId $GroupId
|
||||||
|
$membershipProcessingTime = (Get-Date) - $membershipStartTime
|
||||||
|
|
||||||
|
Write-Host "✅ Parent group discovery completed!" -ForegroundColor Green
|
||||||
|
Write-Host "⏱️ Processing time: $($membershipProcessingTime.TotalSeconds.ToString('F2')) seconds" -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Error "❌ Error during parent group discovery: $($_.Exception.Message)"
|
||||||
|
$Global:ErrorCount++
|
||||||
|
$groupMemberships = @()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Export parent group membership results with error handling
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "📄 Exporting parent group membership results..." -ForegroundColor Yellow
|
||||||
|
|
||||||
# Export group memberships to separate CSV if any found
|
|
||||||
if ($groupMemberships.Count -gt 0) {
|
if ($groupMemberships.Count -gt 0) {
|
||||||
$groupMemberships | Export-Csv -Path $membershipOutputPath -NoTypeInformation
|
try {
|
||||||
Write-Host "Group memberships exported to: $membershipOutputPath"
|
$groupMemberships | Export-Csv -Path $membershipOutputPath -NoTypeInformation -Encoding UTF8 -ErrorAction Stop
|
||||||
Write-Host "Total parent groups found: $($groupMemberships.Count)"
|
Write-Host "✅ Parent group memberships exported successfully!" -ForegroundColor Green
|
||||||
|
Write-Host " 📁 File: $membershipOutputPath" -ForegroundColor White
|
||||||
|
Write-Host " 📊 Total parent groups: $($groupMemberships.Count)" -ForegroundColor White
|
||||||
|
|
||||||
|
# Show membership level breakdown
|
||||||
|
$membershipLevels = $groupMemberships | Group-Object MembershipLevel
|
||||||
|
Write-Host " 🏗️ Membership levels:" -ForegroundColor White
|
||||||
|
foreach ($level in ($membershipLevels | Sort-Object Name)) {
|
||||||
|
Write-Host " • Level $($level.Name): $($level.Count) parent groups" -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
|
||||||
|
# Show parent group types
|
||||||
|
$parentGroupTypes = $groupMemberships | Group-Object ParentGroupType
|
||||||
|
Write-Host " 📋 Parent group types:" -ForegroundColor White
|
||||||
|
foreach ($type in $parentGroupTypes) {
|
||||||
|
Write-Host " • $($type.Name): $($type.Count) groups" -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Error "❌ Failed to export parent group memberships: $($_.Exception.Message)"
|
||||||
|
$Global:ErrorCount++
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Write-Host "No group memberships found for the specified group."
|
Write-Host "ℹ️ Target group is not a member of any other groups." -ForegroundColor Yellow
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Generate comprehensive execution summary
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "📊 EXECUTION SUMMARY" -ForegroundColor Cyan
|
||||||
|
Write-Host "=====================" -ForegroundColor Cyan
|
||||||
|
$totalTime = (Get-Date) - $startTime
|
||||||
|
Write-Host "🎯 Target Group: $($targetGroup.DisplayName)" -ForegroundColor White
|
||||||
|
Write-Host "⏱️ Total Execution Time: $($totalTime.TotalMinutes.ToString('F2')) minutes" -ForegroundColor White
|
||||||
|
Write-Host "📈 Performance Metrics:" -ForegroundColor White
|
||||||
|
Write-Host " • Groups Processed: $script:groupCount" -ForegroundColor Gray
|
||||||
|
Write-Host " • Total Members Found: $script:memberCount" -ForegroundColor Gray
|
||||||
|
Write-Host " • Parent Groups Found: $($groupMemberships.Count)" -ForegroundColor Gray
|
||||||
|
Write-Host " • Processing Rate: $([math]::Round($script:memberCount / $totalTime.TotalSeconds, 2)) members/second" -ForegroundColor Gray
|
||||||
|
|
||||||
|
# Display error and warning summary
|
||||||
|
if ($Global:ErrorCount -gt 0 -or $Global:WarningCount -gt 0) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "⚠️ ISSUES ENCOUNTERED:" -ForegroundColor Yellow
|
||||||
|
if ($Global:ErrorCount -gt 0) {
|
||||||
|
Write-Host " ❌ Errors: $Global:ErrorCount" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
if ($Global:WarningCount -gt 0) {
|
||||||
|
Write-Host " ⚠️ Warnings: $Global:WarningCount" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
Write-Host " 💡 Review output above for details" -ForegroundColor Cyan
|
||||||
|
} else {
|
||||||
|
Write-Host "✅ No errors or warnings encountered!" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
# Display output file locations
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "📁 OUTPUT FILES:" -ForegroundColor Cyan
|
||||||
|
if ($script:groupMembers.Count -gt 0) {
|
||||||
|
Write-Host " 📄 Member Analysis: $OutputPath" -ForegroundColor White
|
||||||
|
}
|
||||||
|
if ($groupMemberships.Count -gt 0) {
|
||||||
|
Write-Host " 📄 Parent Groups: $membershipOutputPath" -ForegroundColor White
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "✅ Group membership analysis completed successfully!" -ForegroundColor Green
|
||||||
|
Write-Host "📅 Finished: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" -ForegroundColor Green
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,98 @@
|
|||||||
Import-Module Microsoft.Graph
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Retrieves user login information from an Entra ID (Azure AD) group with recursive nested group support.
|
||||||
|
|
||||||
# .\UserLastLoginList.ps1 -GroupName "# Developer ADM"
|
.DESCRIPTION
|
||||||
|
This script connects to Microsoft Graph and retrieves all users from a specified Entra ID group,
|
||||||
|
including users in nested groups. For each user, it displays their display name, user principal name,
|
||||||
|
and last sign-in date/time information.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
• Recursive group membership enumeration (handles nested groups)
|
||||||
|
• Circular reference protection to prevent infinite loops
|
||||||
|
• Last sign-in activity tracking for security and compliance
|
||||||
|
• Comprehensive error handling and diagnostic output
|
||||||
|
• Automatic Microsoft Graph authentication with required scopes
|
||||||
|
|
||||||
|
.PARAMETER GroupName
|
||||||
|
The display name of the Entra ID group to analyze. This parameter is mandatory.
|
||||||
|
The script will search for an exact match of the group display name.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\UserLastLoginList.ps1 -GroupName "# Developer ADM"
|
||||||
|
Retrieves all users from the "# Developer ADM" group, including any nested group memberships.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\UserLastLoginList.ps1 -GroupName "IT Security Team" -Debug
|
||||||
|
Retrieves users with detailed debug output showing group processing and user enumeration steps.
|
||||||
|
|
||||||
|
.INPUTS
|
||||||
|
None. You cannot pipe objects to this script.
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
System.Management.Automation.PSCustomObject
|
||||||
|
Returns a formatted table with the following properties for each user:
|
||||||
|
- UserPrincipalName: The user's UPN (email-like identifier)
|
||||||
|
- DisplayName: The user's display name
|
||||||
|
- LastSignInDateTime: The user's last sign-in date/time, or "No sign-in data available" if unavailable
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Requires PowerShell 5.1 or later
|
||||||
|
Requires Microsoft.Graph PowerShell module
|
||||||
|
|
||||||
|
Required Microsoft Graph Permissions:
|
||||||
|
- User.Read.All: To read user profiles and sign-in activity
|
||||||
|
- Group.Read.All: To read group memberships and nested groups
|
||||||
|
|
||||||
|
The script will automatically prompt for authentication if not already connected to Microsoft Graph.
|
||||||
|
Sign-in activity data may not be available for all users depending on license and retention policies.
|
||||||
|
|
||||||
|
.LINK
|
||||||
|
https://docs.microsoft.com/en-us/graph/api/user-get
|
||||||
|
https://docs.microsoft.com/en-us/graph/api/group-list-members
|
||||||
|
|
||||||
|
.COMPONENT
|
||||||
|
Microsoft Graph PowerShell SDK
|
||||||
|
|
||||||
|
.ROLE
|
||||||
|
Identity and Access Management, Security Reporting
|
||||||
|
|
||||||
|
.FUNCTIONALITY
|
||||||
|
Entra ID group analysis, user activity reporting, nested group enumeration
|
||||||
|
#>
|
||||||
|
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory = $true)]
|
[Parameter(
|
||||||
|
Mandatory = $true,
|
||||||
|
HelpMessage = "Enter the display name of the Entra ID group to analyze"
|
||||||
|
)]
|
||||||
|
[ValidateNotNullOrEmpty()]
|
||||||
[string]$GroupName
|
[string]$GroupName
|
||||||
)
|
)
|
||||||
|
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Retrieves an Entra ID group by its display name.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This function searches for an Entra ID group using its display name and returns the group's unique identifier.
|
||||||
|
Uses Microsoft Graph API to perform an exact match search on the displayName property.
|
||||||
|
|
||||||
|
.PARAMETER EntraGroupName
|
||||||
|
The exact display name of the Entra ID group to search for.
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
System.String
|
||||||
|
Returns the group's unique identifier (GUID) if found, or $null if not found or error occurs.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
$groupId = Get-EntraGroupByName -EntraGroupName "Developers"
|
||||||
|
Retrieves the group ID for the "Developers" group.
|
||||||
|
#>
|
||||||
function Get-EntraGroupByName {
|
function Get-EntraGroupByName {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory = $true)]
|
[Parameter(Mandatory = $true)]
|
||||||
|
[ValidateNotNullOrEmpty()]
|
||||||
[string]$EntraGroupName
|
[string]$EntraGroupName
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,18 +116,54 @@ function Get-EntraGroupByName {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Initialize Microsoft Graph connection
|
||||||
|
Write-Host "🔐 Initializing Microsoft Graph connection..." -ForegroundColor Cyan
|
||||||
Write-Debug "Connecting to Microsoft Graph..."
|
Write-Debug "Connecting to Microsoft Graph..."
|
||||||
|
|
||||||
# Connect to Microsoft Graph if not already connected
|
# Connect to Microsoft Graph if not already connected
|
||||||
if (-not (Get-MgContext)) {
|
if (-not (Get-MgContext)) {
|
||||||
|
Write-Host "🔑 Authenticating to Microsoft Graph with required scopes..." -ForegroundColor Yellow
|
||||||
Connect-MgGraph -Scopes "User.Read.All", "Group.Read.All"
|
Connect-MgGraph -Scopes "User.Read.All", "Group.Read.All"
|
||||||
|
Write-Host "✅ Successfully connected to Microsoft Graph" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host "✅ Already connected to Microsoft Graph" -ForegroundColor Green
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Recursively retrieves all users from an Entra ID group, including nested groups.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This function performs a recursive search through an Entra ID group structure to find all users,
|
||||||
|
including those in nested groups. It includes circular reference protection to prevent infinite
|
||||||
|
loops when groups contain circular memberships.
|
||||||
|
|
||||||
|
.PARAMETER GroupId
|
||||||
|
The unique identifier (GUID) of the Entra ID group to process.
|
||||||
|
|
||||||
|
.PARAMETER ProcessedGroups
|
||||||
|
Internal hashtable used to track processed groups and prevent circular reference loops.
|
||||||
|
This parameter is used internally for recursion and should not be specified by callers.
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
System.Management.Automation.PSCustomObject[]
|
||||||
|
Returns an array of custom objects with the following properties:
|
||||||
|
- UserPrincipalName: The user's UPN
|
||||||
|
- DisplayName: The user's display name
|
||||||
|
- LastSignInDateTime: The user's last sign-in date/time or status message
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
$users = Get-EntraGroupUsersRecursive -GroupId "12345678-1234-1234-1234-123456789012"
|
||||||
|
Recursively retrieves all users from the specified group and its nested groups.
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
This function is designed to handle complex group hierarchies and prevents infinite recursion
|
||||||
|
through circular group memberships. It processes both user and group objects within the membership.
|
||||||
|
#>
|
||||||
function Get-EntraGroupUsersRecursive {
|
function Get-EntraGroupUsersRecursive {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory = $true)]
|
[Parameter(Mandatory = $true)]
|
||||||
|
[ValidateNotNullOrEmpty()]
|
||||||
[string]$GroupId,
|
[string]$GroupId,
|
||||||
[hashtable]$ProcessedGroups = @{}
|
[hashtable]$ProcessedGroups = @{}
|
||||||
)
|
)
|
||||||
@@ -88,20 +207,44 @@ function Get-EntraGroupUsersRecursive {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Main execution logic
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "🔍 Searching for Entra ID group: '$GroupName'" -ForegroundColor Cyan
|
||||||
|
|
||||||
$groupId = Get-EntraGroupByName -EntraGroupName $GroupName
|
$groupId = Get-EntraGroupByName -EntraGroupName $GroupName
|
||||||
|
|
||||||
if ($groupId) {
|
if ($groupId) {
|
||||||
|
Write-Host "✅ Group found! Group ID: $groupId" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
# Get users recursively from the group
|
# Get users recursively from the group
|
||||||
Write-Host "Getting users recursively from group..."
|
Write-Host "👥 Retrieving users recursively from group (including nested groups)..." -ForegroundColor Cyan
|
||||||
$recursiveUsers = Get-EntraGroupUsersRecursive -GroupId $groupId
|
$recursiveUsers = Get-EntraGroupUsersRecursive -GroupId $groupId
|
||||||
|
|
||||||
if ($recursiveUsers) {
|
if ($recursiveUsers) {
|
||||||
Write-Host "Found $($recursiveUsers.Count) users (including nested groups):"
|
Write-Host ""
|
||||||
|
Write-Host "📊 Analysis Results:" -ForegroundColor Green
|
||||||
|
Write-Host "===================" -ForegroundColor Green
|
||||||
|
Write-Host "Found $($recursiveUsers.Count) users (including nested groups)" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Display results sorted by display name
|
||||||
$recursiveUsers | Sort-Object DisplayName | Format-Table -AutoSize
|
$recursiveUsers | Sort-Object DisplayName | Format-Table -AutoSize
|
||||||
|
|
||||||
|
# Additional statistics
|
||||||
|
$usersWithSignInData = ($recursiveUsers | Where-Object { $_.LastSignInDateTime -ne "No sign-in data available" }).Count
|
||||||
|
$usersWithoutSignInData = $recursiveUsers.Count - $usersWithSignInData
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "📈 Sign-in Data Summary:" -ForegroundColor Yellow
|
||||||
|
Write-Host "========================" -ForegroundColor Yellow
|
||||||
|
Write-Host "Users with sign-in data: $usersWithSignInData" -ForegroundColor White
|
||||||
|
Write-Host "Users without sign-in data: $usersWithoutSignInData" -ForegroundColor White
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
Write-Warning "No users found in the group hierarchy or an error occurred."
|
Write-Warning "❌ No users found in the group hierarchy or an error occurred."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
Write-Warning "Group not found."
|
Write-Warning "❌ Group '$GroupName' not found. Please verify the group name and try again."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,63 @@
|
|||||||
Import-Module AzureAD
|
# Import required modules for PowerShell Core compatibility
|
||||||
|
Import-Module Microsoft.Graph.Groups
|
||||||
|
Import-Module Microsoft.Graph.Users
|
||||||
|
Import-Module Microsoft.Graph.Authentication
|
||||||
Import-Module SqlServer
|
Import-Module SqlServer
|
||||||
|
|
||||||
#Connect-AzureAD
|
# Authentication - uncomment as needed
|
||||||
|
#Connect-MgGraph -Scopes "Group.Read.All", "User.Read.All", "GroupMember.Read.All"
|
||||||
#Connect-AzAccount
|
#Connect-AzAccount
|
||||||
|
|
||||||
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
||||||
$filename = "c:\tmp\$date User group mappings.csv"
|
$filename = ".\$date User group mappings.csv"
|
||||||
|
|
||||||
|
|
||||||
Function Get-RecursiveAzureAdGroupMemberUsers{
|
Function Get-RecursiveMgGroupMemberUsers{
|
||||||
[cmdletbinding()]
|
[cmdletbinding()]
|
||||||
param(
|
param(
|
||||||
[parameter(Mandatory=$True,ValueFromPipeline=$true)]
|
[parameter(Mandatory=$True,ValueFromPipeline=$true)]
|
||||||
$AzureGroup
|
$MgGroup
|
||||||
)
|
)
|
||||||
Begin{
|
Begin{
|
||||||
If(-not(Get-AzureADCurrentSessionInfo)){Connect-AzureAD}
|
# Check if Microsoft Graph is connected
|
||||||
|
$context = Get-MgContext
|
||||||
|
If(-not($context)){
|
||||||
|
Write-Warning "Microsoft Graph not connected. Please run Connect-MgGraph first."
|
||||||
|
throw "Microsoft Graph connection required"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Process {
|
Process {
|
||||||
Write-Verbose -Message "Enumerating $($AzureGroup.DisplayName)"
|
Write-Verbose -Message "Enumerating $($MgGroup.DisplayName)"
|
||||||
$Members = Get-AzureADGroupMember -ObjectId $AzureGroup.ObjectId -All $true
|
|
||||||
$UserMembers = $Members | Where-Object{$_.ObjectType -eq 'User'}
|
# Get group members using Microsoft Graph
|
||||||
If($Members | Where-Object{$_.ObjectType -eq 'Group'}){
|
$Members = Get-MgGroupMember -GroupId $MgGroup.Id -All
|
||||||
$UserMembers += $Members | Where-Object{$_.ObjectType -eq 'Group'} | ForEach-Object{ Get-RecursiveAzureAdGroupMemberUsers -AzureGroup $_}
|
|
||||||
|
# Filter for user members and get full user details
|
||||||
|
$UserMembers = @()
|
||||||
|
$UserMemberIds = $Members | Where-Object {$_.AdditionalProperties["@odata.type"] -eq "#microsoft.graph.user"}
|
||||||
|
|
||||||
|
foreach ($userMember in $UserMemberIds) {
|
||||||
|
try {
|
||||||
|
$userDetails = Get-MgUser -UserId $userMember.Id -ErrorAction Stop
|
||||||
|
$UserMembers += $userDetails
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning "Could not retrieve user details for ID: $($userMember.Id)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Process nested groups recursively
|
||||||
|
$GroupMembers = $Members | Where-Object {$_.AdditionalProperties["@odata.type"] -eq "#microsoft.graph.group"}
|
||||||
|
If($GroupMembers){
|
||||||
|
foreach ($groupMember in $GroupMembers) {
|
||||||
|
try {
|
||||||
|
$nestedGroup = Get-MgGroup -GroupId $groupMember.Id -ErrorAction Stop
|
||||||
|
$UserMembers += Get-RecursiveMgGroupMemberUsers -MgGroup $nestedGroup
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning "Could not process nested group ID: $($groupMember.Id)"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
end {
|
end {
|
||||||
@@ -33,7 +68,9 @@ Function Get-RecursiveAzureAdGroupMemberUsers{
|
|||||||
# Get SQL records
|
# Get SQL records
|
||||||
Write-Host ("Get SQL records") -foreground Yellow
|
Write-Host ("Get SQL records") -foreground Yellow
|
||||||
|
|
||||||
$access_token = (Get-AzAccessToken -ResourceUrl https://database.windows.net).Token
|
$access_token_secure = (Get-AzAccessToken -ResourceUrl https://database.windows.net).Token
|
||||||
|
$access_token = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto([System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($access_token_secure))
|
||||||
|
|
||||||
$signInConnectionString = "Data Source=signin-effectory.database.windows.net;Initial Catalog=SignIn;Persist Security Info=False;Encrypt=True;TrustServerCertificate=False;Application Name=CloudEngineering";
|
$signInConnectionString = "Data Source=signin-effectory.database.windows.net;Initial Catalog=SignIn;Persist Security Info=False;Encrypt=True;TrustServerCertificate=False;Application Name=CloudEngineering";
|
||||||
$eceConnectionString = "Data Source=c0m7f8nybr.database.windows.net;Initial Catalog='Effectory Extranet';Persist Security Info=False;Encrypt=True;TrustServerCertificate=False;Application Name=CloudEngineering";
|
$eceConnectionString = "Data Source=c0m7f8nybr.database.windows.net;Initial Catalog='Effectory Extranet';Persist Security Info=False;Encrypt=True;TrustServerCertificate=False;Application Name=CloudEngineering";
|
||||||
|
|
||||||
@@ -171,7 +208,14 @@ foreach($mapping in $mappings) {
|
|||||||
Write-Host ("[$itemDate] [$a/$noMappings] - Mapping '$mappingName'") -foreground Green
|
Write-Host ("[$itemDate] [$a/$noMappings] - Mapping '$mappingName'") -foreground Green
|
||||||
|
|
||||||
#get users in mapping
|
#get users in mapping
|
||||||
$usersInMapping = Get-AzureADGroup -ObjectId $mapping.GroupId | Get-RecursiveAzureAdGroupMemberUsers
|
try {
|
||||||
|
$group = Get-MgGroup -GroupId $mapping.GroupId -ErrorAction Stop
|
||||||
|
$usersInMapping = Get-RecursiveMgGroupMemberUsers -MgGroup $group
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning "Could not retrieve group with ID: $($mapping.GroupId). Error: $($_.Exception.Message)"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
#get mapping claims
|
#get mapping claims
|
||||||
$mappingItemsInMapping = $mappingItems | Where-Object GroupId -eq $mapping.GroupId
|
$mappingItemsInMapping = $mappingItems | Where-Object GroupId -eq $mapping.GroupId
|
||||||
@@ -189,7 +233,8 @@ foreach($mapping in $mappings) {
|
|||||||
$userMappingItem.GroupId = $mappingItem.GroupId
|
$userMappingItem.GroupId = $mappingItem.GroupId
|
||||||
$userMappingItem.GroupMappingName = $mappingItem.GroupMappingName
|
$userMappingItem.GroupMappingName = $mappingItem.GroupMappingName
|
||||||
|
|
||||||
$userMappingItem.UserObjectId = $user.ObjectId
|
# Microsoft Graph user properties (property names are the same)
|
||||||
|
$userMappingItem.UserObjectId = $user.Id
|
||||||
$userMappingItem.UserDisplayName = $user.DisplayName
|
$userMappingItem.UserDisplayName = $user.DisplayName
|
||||||
$userMappingItem.UserMail = $user.Mail
|
$userMappingItem.UserMail = $user.Mail
|
||||||
$userMappingItem.UserUserPrincipalName = $user.UserPrincipalName
|
$userMappingItem.UserUserPrincipalName = $user.UserPrincipalName
|
||||||
|
|||||||
250
Powershell/Lists/README.md
Normal file
250
Powershell/Lists/README.md
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
# PowerShell List Scripts Collection
|
||||||
|
|
||||||
|
This directory contains a comprehensive collection of PowerShell scripts for generating inventory and reporting data across various platforms and services. Each script produces timestamped CSV exports with detailed information for analysis, compliance, and governance purposes.
|
||||||
|
|
||||||
|
## 📋 Table of Contents
|
||||||
|
|
||||||
|
- [Azure Scripts](#-azure-scripts)
|
||||||
|
- [Azure DevOps Scripts](#-azure-devops-scripts)
|
||||||
|
- [Entra ID (Azure AD) Scripts](#-entra-id-azure-ad-scripts)
|
||||||
|
- [Security & Vulnerability Scripts](#-security--vulnerability-scripts)
|
||||||
|
- [SQL Database Scripts](#-sql-database-scripts)
|
||||||
|
- [Application-Specific Scripts](#-application-specific-scripts)
|
||||||
|
- [Prerequisites](#-prerequisites)
|
||||||
|
- [Usage Guidelines](#-usage-guidelines)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔵 Azure Scripts
|
||||||
|
|
||||||
|
### Resource Inventory & Management
|
||||||
|
|
||||||
|
| Script | Description | Output |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| **Resources.ps1** | Comprehensive Azure resource inventory across all subscriptions | CSV with resource metadata, tags, managed identities |
|
||||||
|
| **AzureRBAC.ps1** | RBAC assignment analysis with PIM detection across Azure hierarchy | CSV with assignment details, PIM status, scope analysis |
|
||||||
|
| **ManagementGroups.ps1** | Management group hierarchy and subscription mapping | CSV with organizational structure |
|
||||||
|
|
||||||
|
### Storage & Data
|
||||||
|
|
||||||
|
| Script | Description | Output |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| **AzureStorageBlobList.ps1** | Blob storage inventory across storage accounts | CSV with blob details, metadata, access tiers |
|
||||||
|
| **AzureStorageTableListEntities.ps1** | Table storage entity enumeration | CSV with table entities and properties |
|
||||||
|
|
||||||
|
### Security & Access
|
||||||
|
|
||||||
|
| Script | Description | Output |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| **KeyVaults.ps1** | Key Vault inventory with configuration details | CSV with vault properties, access policies |
|
||||||
|
| **KeyVaultAccessPolicies.ps1** | Detailed Key Vault access policy analysis | CSV with permission mappings |
|
||||||
|
| **KeyVaultNonRBACSecrets.ps1** | Non-RBAC managed Key Vault secrets inventory | CSV with legacy access policy secrets |
|
||||||
|
| **Certificates.ps1** | Certificate inventory across Key Vaults | CSV with certificate details, expiration dates |
|
||||||
|
| **AzurePIM.ps1** | Privileged Identity Management assignments | CSV with PIM role assignments and status |
|
||||||
|
|
||||||
|
### Networking & Applications
|
||||||
|
|
||||||
|
| Script | Description | Output |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| **WebApps.ps1** | App Service and Web App inventory | CSV with app configurations, settings |
|
||||||
|
| **FrontDoorRoutes.ps1** | Azure Front Door routing configuration | CSV with route mappings and rules |
|
||||||
|
| **ServiceBus.ps1** | Service Bus namespaces and entity inventory | CSV with queues, topics, subscriptions |
|
||||||
|
|
||||||
|
### Monitoring & Alerts
|
||||||
|
|
||||||
|
| Script | Description | Output |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| **AlertRules.ps1** | Azure Monitor alert rules inventory | CSV with alert configurations |
|
||||||
|
| **AppInsightsWorkspace.ps1** | Application Insights workspace details | CSV with workspace configurations |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔵 Azure DevOps Scripts
|
||||||
|
|
||||||
|
| Script | Description | Output |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| **Repositories.ps1** | Repository inventory with last pull request details | CSV with repo metadata, recent PR info |
|
||||||
|
| **PullRequests.ps1** | Pull request history and statistics | CSV with PR details, reviewers, completion data |
|
||||||
|
| **Pipelines.ps1** | Build and release pipeline inventory | CSV with pipeline configurations |
|
||||||
|
| **ServiceConnections.ps1** | Service connection inventory and status | CSV with connection details, permissions |
|
||||||
|
| **RepositoriesWithTestAccept.ps1** | Repositories with specific testing configurations | CSV with test acceptance criteria |
|
||||||
|
| **renovate-stats.ps1** | Renovate bot statistics and dependency updates | CSV with update metrics |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟢 Entra ID (Azure AD) Scripts
|
||||||
|
|
||||||
|
| Script | Description | Output |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| **GroupMemberships.ps1** | Recursive group membership analysis with circular reference detection | CSV with complete membership hierarchy |
|
||||||
|
| **UserLastLoginList.ps1** | User last login analysis for group members | CSV with login activity and user status |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔴 Security & Vulnerability Scripts
|
||||||
|
|
||||||
|
### Snyk Integration
|
||||||
|
|
||||||
|
| Script | Description | Output |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| **SnykOverview.ps1** | Comprehensive Snyk organization and project inventory | CSV with project metadata, vulnerability counts |
|
||||||
|
| **SBOM.ps1** | Software Bill of Materials generation with enhanced package metadata | CSV with dependency details, vulnerability data, deprecation status |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟡 SQL Database Scripts
|
||||||
|
|
||||||
|
| Script | Description | Output |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| **SQLUserCheck.ps1** | Multi-server SQL database user audit with authentication analysis | CSV with user accounts, permissions, authentication types |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟣 Application-Specific Scripts
|
||||||
|
|
||||||
|
### MyEffectory
|
||||||
|
|
||||||
|
| Script | Description | Output |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| **GroupMappingsCheck.ps1** | Application-specific group mapping validation | CSV with mapping configurations |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Prerequisites
|
||||||
|
|
||||||
|
### Required PowerShell Modules
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Azure modules
|
||||||
|
Install-Module Az.Accounts, Az.Resources, Az.Storage, Az.KeyVault, Az.Monitor
|
||||||
|
Install-Module Microsoft.Graph.Identity.Governance
|
||||||
|
|
||||||
|
# Azure DevOps
|
||||||
|
Install-Module VSTeam
|
||||||
|
|
||||||
|
# SQL Server
|
||||||
|
Install-Module SqlServer
|
||||||
|
|
||||||
|
# Microsoft Graph
|
||||||
|
Install-Module Microsoft.Graph.Users, Microsoft.Graph.Groups
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication Requirements
|
||||||
|
|
||||||
|
- **Azure**: `Connect-AzAccount` with appropriate RBAC permissions
|
||||||
|
- **Microsoft Graph**: `Connect-MgGraph` with required scopes
|
||||||
|
- **Azure DevOps**: Personal Access Token or OAuth authentication
|
||||||
|
- **SQL Server**: Azure AD authentication or SQL authentication
|
||||||
|
|
||||||
|
### Permission Requirements
|
||||||
|
|
||||||
|
| Platform | Required Permissions |
|
||||||
|
|----------|---------------------|
|
||||||
|
| **Azure** | Reader or higher on target resources, PIM Admin for PIM detection |
|
||||||
|
| **Entra ID** | Directory Reader, Group Member Read permissions |
|
||||||
|
| **Azure DevOps** | Project Reader, Repository Read permissions |
|
||||||
|
| **SQL Server** | Database Reader, View Server State permissions |
|
||||||
|
| **Snyk** | API token with Organization Read permissions |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Usage Guidelines
|
||||||
|
|
||||||
|
### Basic Execution
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Run any script directly
|
||||||
|
.\Azure\Resources.ps1
|
||||||
|
.\DevOps\Repositories.ps1
|
||||||
|
.\Entra\GroupMemberships.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Parameters (where supported)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Single subscription analysis
|
||||||
|
.\Azure\AzureRBAC.ps1 -SubscriptionId "your-subscription-id"
|
||||||
|
|
||||||
|
# Enable detailed debugging
|
||||||
|
.\Azure\AzureRBAC.ps1 -DetailedDebug
|
||||||
|
|
||||||
|
# Custom organization/project
|
||||||
|
.\DevOps\Repositories.ps1 -Organization "myorg" -Project "myproject"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output Management
|
||||||
|
|
||||||
|
All scripts generate timestamped CSV files in the format:
|
||||||
|
```
|
||||||
|
YYYY-MM-DD HHMM script_description.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
### Best Practices
|
||||||
|
|
||||||
|
1. **Pre-Authentication**: Ensure proper authentication before running scripts
|
||||||
|
2. **Permissions**: Verify required permissions for target resources
|
||||||
|
3. **Network Connectivity**: Ensure access to required APIs and services
|
||||||
|
4. **Output Storage**: Consider output file locations and security
|
||||||
|
5. **Scheduling**: Many scripts are suitable for scheduled execution
|
||||||
|
6. **Error Handling**: Review script output for any errors or warnings
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
|
||||||
|
- **Authentication Issues**: Verify token expiration and scope permissions
|
||||||
|
- **API Throttling**: Some scripts may encounter rate limits with large datasets
|
||||||
|
- **Permission Errors**: Ensure service principals or user accounts have sufficient privileges
|
||||||
|
- **Network Connectivity**: Verify access to required endpoints and APIs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Output Analysis
|
||||||
|
|
||||||
|
### Common CSV Columns
|
||||||
|
|
||||||
|
Most scripts include standardized columns for:
|
||||||
|
- **Timestamps**: Creation and modification dates
|
||||||
|
- **Identifiers**: Unique IDs, names, and references
|
||||||
|
- **Governance**: Tags, ownership, environment classification
|
||||||
|
- **Security**: RBAC assignments, permissions, authentication types
|
||||||
|
- **Metadata**: Configuration details, status information
|
||||||
|
|
||||||
|
### Integration Options
|
||||||
|
|
||||||
|
- **Power BI**: Direct CSV import for dashboard creation
|
||||||
|
- **Excel**: Advanced filtering and pivot table analysis
|
||||||
|
- **Database**: Bulk import for historical trending
|
||||||
|
- **Automation**: Scheduled execution with result processing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Maintenance
|
||||||
|
|
||||||
|
### Regular Updates
|
||||||
|
|
||||||
|
- **Module Versions**: Keep PowerShell modules updated
|
||||||
|
- **API Changes**: Monitor for service API modifications
|
||||||
|
- **Permission Changes**: Verify continued access to required resources
|
||||||
|
- **Script Enhancements**: Check for new features and improvements
|
||||||
|
|
||||||
|
### Version Control
|
||||||
|
|
||||||
|
All scripts are maintained under version control with:
|
||||||
|
- Change tracking and history
|
||||||
|
- Documentation updates
|
||||||
|
- Testing and validation
|
||||||
|
- Community contributions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 Support
|
||||||
|
|
||||||
|
For issues, questions, or contributions:
|
||||||
|
- Review script help documentation (`Get-Help .\ScriptName.ps1 -Full`)
|
||||||
|
- Check error messages and troubleshooting sections
|
||||||
|
- Verify prerequisites and permissions
|
||||||
|
- Consult platform-specific documentation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Last Updated: October 31, 2025*
|
||||||
|
*Script Collection Version: 2.0*
|
||||||
@@ -1,76 +1,213 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Comprehensive SQL Server database user audit across multiple Azure SQL servers.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This script connects to multiple Azure SQL servers and databases to generate a comprehensive audit
|
||||||
|
report of all database users, including their authentication types, creation dates, and permissions.
|
||||||
|
It provides essential information for security auditing, compliance reporting, and user access management.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
• Multi-server database user enumeration across Azure SQL instances
|
||||||
|
• Authentication type detection (SQL, Windows, Azure AD)
|
||||||
|
• User creation and modification date tracking
|
||||||
|
• Comprehensive CSV reporting with timestamped output files
|
||||||
|
• Azure AD authentication using access tokens
|
||||||
|
• Automatic database discovery per server
|
||||||
|
|
||||||
|
.PARAMETER ServerList
|
||||||
|
Array of Azure SQL server FQDNs to audit. If not specified, uses a default list of common servers.
|
||||||
|
Each server should be provided as a fully qualified domain name (e.g., 'servername.database.windows.net').
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\SQLUserCheck.ps1
|
||||||
|
Executes a complete user audit across all default Azure SQL servers and databases.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\SQLUserCheck.ps1 -ServerList @('signin-effectory.database.windows.net', 'c0m7f8nybr.database.windows.net')
|
||||||
|
Audits users on specific Azure SQL servers instead of using the default server list.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
Connect-AzAccount
|
||||||
|
.\SQLUserCheck.ps1
|
||||||
|
Ensures Azure authentication is established before running the SQL user audit.
|
||||||
|
|
||||||
|
.INPUTS
|
||||||
|
None. This script does not accept pipeline input.
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
System.IO.FileInfo
|
||||||
|
Generates a timestamped CSV file containing user audit results with the following columns:
|
||||||
|
- ServerName: Azure SQL server name
|
||||||
|
- DatabaseName: Database name within the server
|
||||||
|
- UserName: Database user account name
|
||||||
|
- CreateDate: User account creation timestamp
|
||||||
|
- ModifyDate: Last modification timestamp
|
||||||
|
- Type: User principal type (User, Role, etc.)
|
||||||
|
- AuthenticationType: Authentication method (SQL_USER, WINDOWS_USER, EXTERNAL_USER)
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Requires PowerShell 5.1 or later
|
||||||
|
Requires SqlServer PowerShell module
|
||||||
|
Requires Az.Accounts PowerShell module for Azure authentication
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
- Must be connected to Azure (Connect-AzAccount)
|
||||||
|
- Requires appropriate SQL Server permissions on target databases
|
||||||
|
- Network connectivity to Azure SQL servers
|
||||||
|
|
||||||
|
Security Considerations:
|
||||||
|
- Uses Azure AD authentication with access tokens
|
||||||
|
- Does not store or transmit SQL credentials
|
||||||
|
- Audit trail is maintained in timestamped CSV files
|
||||||
|
|
||||||
|
.LINK
|
||||||
|
https://docs.microsoft.com/en-us/sql/relational-databases/system-catalog-views/sys-database-principals-transact-sql
|
||||||
|
https://docs.microsoft.com/en-us/azure/azure-sql/database/authentication-aad-overview
|
||||||
|
|
||||||
|
.COMPONENT
|
||||||
|
SqlServer PowerShell Module, Azure PowerShell Module
|
||||||
|
|
||||||
|
.ROLE
|
||||||
|
Database Administration, Security Auditing, Compliance Reporting
|
||||||
|
|
||||||
|
.FUNCTIONALITY
|
||||||
|
Azure SQL Server user auditing, database security assessment, access management reporting
|
||||||
|
#>
|
||||||
|
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $false, HelpMessage = "Array of Azure SQL server FQDNs to audit")]
|
||||||
|
[string[]]$ServerList = @(
|
||||||
|
'c0m7f8nybr.database.windows.net',
|
||||||
|
'calculations.database.windows.net',
|
||||||
|
'effectory.database.windows.net',
|
||||||
|
'effectorycore.database.windows.net',
|
||||||
|
'logit-backup.database.windows.net',
|
||||||
|
'mhpfktialk.database.windows.net',
|
||||||
|
'participants.database.windows.net',
|
||||||
|
'signin-effectory.database.windows.net',
|
||||||
|
'sqlserver01prod.6a1f4aa9f43a.database.windows.net'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
Import-Module SqlServer
|
Import-Module SqlServer
|
||||||
|
|
||||||
|
# Ensure Azure authentication is available
|
||||||
|
# Uncomment the following lines if not already authenticated to Azure
|
||||||
#Clear-AzContext
|
#Clear-AzContext
|
||||||
#Connect-AzAccount
|
#Connect-AzAccount
|
||||||
|
|
||||||
Write-Host "======================================================================================================================================================================"
|
Write-Host "======================================================================================================================================================================"
|
||||||
Write-Host "Creating SQL user list."
|
Write-Host "Creating comprehensive SQL user audit across Azure SQL servers."
|
||||||
Write-Host "======================================================================================================================================================================"
|
Write-Host "===================================================================================================================================================================="
|
||||||
|
|
||||||
|
# Generate timestamped output filename
|
||||||
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
||||||
$filename = ".\$date SQL User check.csv"
|
$filename = ".\$date SQL User check.csv"
|
||||||
|
Write-Host "📄 Output will be saved to: $filename" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Data structure for SQL Server database user information.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This class represents a database user record containing essential information
|
||||||
|
for security auditing and access management. Each instance captures user
|
||||||
|
details from a specific database on a specific server.
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Properties align with sys.database_principals system catalog view columns
|
||||||
|
for consistent data representation across different SQL Server versions.
|
||||||
|
#>
|
||||||
class UserItem {
|
class UserItem {
|
||||||
[string] $ServerName = ""
|
[string] $ServerName = "" # Azure SQL server name
|
||||||
[string] $DatabaseName = ""
|
[string] $DatabaseName = "" # Database name where user exists
|
||||||
[string] $UserName = ""
|
[string] $UserName = "" # Database user principal name
|
||||||
[string] $CreateDate = ""
|
[string] $CreateDate = "" # User creation timestamp
|
||||||
[string] $ModifyDate = ""
|
[string] $ModifyDate = "" # Last modification timestamp
|
||||||
[string] $Type = ""
|
[string] $Type = "" # Principal type (SQL_USER, WINDOWS_USER, etc.)
|
||||||
[string] $AuthenticationType = ""
|
[string] $AuthenticationType = "" # Authentication method (SQL, Windows, Azure AD)
|
||||||
}
|
}
|
||||||
|
|
||||||
$serverList= @('c0m7f8nybr.database.windows.net','calculations.database.windows.net','effectory.database.windows.net','effectorycore.database.windows.net',
|
Write-Host "🎯 Configured to audit $($ServerList.Count) Azure SQL servers" -ForegroundColor Green
|
||||||
'logit-backup.database.windows.net', 'mhpfktialk.database.windows.net', 'participants.database.windows.net', 'signin-effectory.database.windows.net',
|
|
||||||
'sqlserver01prod.6a1f4aa9f43a.database.windows.net')
|
|
||||||
|
|
||||||
|
# SQL query to discover all databases on each server
|
||||||
|
# Excludes system databases that are not relevant for user auditing
|
||||||
$databaseListQuery = @'
|
$databaseListQuery = @'
|
||||||
SELECT name, database_id, create_date
|
SELECT name, database_id, create_date
|
||||||
FROM sys.databases
|
FROM sys.databases
|
||||||
order by name;
|
WHERE name NOT IN ('master', 'tempdb', 'model', 'msdb')
|
||||||
|
ORDER BY name;
|
||||||
'@
|
'@
|
||||||
|
|
||||||
|
# SQL query to retrieve database user information
|
||||||
|
# Filters out roles ('R' type) and focuses on actual user principals
|
||||||
|
# Excludes 'guest' user which exists by default in all databases
|
||||||
$userListQuery = @'
|
$userListQuery = @'
|
||||||
select @@SERVERNAME as serverName,
|
SELECT @@SERVERNAME as serverName,
|
||||||
DB_NAME() as databaseName,
|
DB_NAME() as databaseName,
|
||||||
name as username,
|
name as username,
|
||||||
create_date,
|
create_date,
|
||||||
modify_date,
|
modify_date,
|
||||||
type_desc as type,
|
type_desc as type,
|
||||||
authentication_type_desc as authentication_type
|
authentication_type_desc as authentication_type
|
||||||
from sys.database_principals
|
FROM sys.database_principals
|
||||||
where type not in ('R')
|
WHERE type NOT IN ('R') -- Exclude database roles
|
||||||
and sid is not null
|
AND sid IS NOT NULL -- Exclude built-in principals without SIDs
|
||||||
and name != 'guest'
|
AND name NOT IN ('dbo', 'guest') -- Exclude default dbo and guest user
|
||||||
order by name;
|
AND name NOT LIKE '##%' -- Exclude system-generated users
|
||||||
|
ORDER BY name;
|
||||||
'@
|
'@
|
||||||
|
|
||||||
foreach ($server in $serverlist) {
|
# Initialize audit counters
|
||||||
|
$totalServersProcessed = 0
|
||||||
|
$totalDatabasesProcessed = 0
|
||||||
|
$totalUsersFound = 0
|
||||||
|
|
||||||
|
# Main server processing loop
|
||||||
|
foreach ($server in $ServerList) {
|
||||||
|
$totalServersProcessed++
|
||||||
|
|
||||||
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
||||||
Write-Host "Server [$server)]"
|
Write-Host "🖥️ Processing Server [$server] ($totalServersProcessed of $($ServerList.Count))" -ForegroundColor Cyan
|
||||||
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Get Azure AD access token for SQL Database authentication
|
||||||
|
# This provides secure, passwordless authentication to Azure SQL
|
||||||
|
Write-Host "🔐 Obtaining Azure AD access token for SQL authentication..." -ForegroundColor Yellow
|
||||||
$access_token_secure = (Get-AzAccessToken -ResourceUrl https://database.windows.net).Token
|
$access_token_secure = (Get-AzAccessToken -ResourceUrl https://database.windows.net).Token
|
||||||
$access_token = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto([System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($access_token_secure))
|
$access_token = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto([System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($access_token_secure))
|
||||||
|
|
||||||
$connectionString = "Data Source=$server;Initial Catalog=master;Persist Security Info=False;Encrypt=True;TrustServerCertificate=False;Application Name=CloudEngineering";
|
# Configure secure connection string for Azure SQL
|
||||||
|
$connectionString = "Data Source=$server;Initial Catalog=master;Persist Security Info=False;Encrypt=True;TrustServerCertificate=False;Application Name=CloudEngineering-UserAudit"
|
||||||
|
|
||||||
$databases = Invoke-Sqlcmd -Query $databaseListQuery -ConnectionString $connectionString -AccessToken $access_token
|
# Discover all user databases on the current server
|
||||||
|
Write-Host "📋 Discovering databases on server..." -ForegroundColor Gray
|
||||||
|
$databases = Invoke-Sqlcmd -Query $databaseListQuery -ConnectionString $connectionString -AccessToken $access_token -ErrorAction Stop
|
||||||
|
|
||||||
|
Write-Host "✅ Found $($databases.Count) user databases to audit" -ForegroundColor Green
|
||||||
|
|
||||||
|
# Process each database on the current server
|
||||||
foreach ($database in $databases) {
|
foreach ($database in $databases) {
|
||||||
|
$totalDatabasesProcessed++
|
||||||
|
|
||||||
Write-Host "Database [$($database.name)]"
|
Write-Host " 📊 Auditing Database [$($database.name)]" -ForegroundColor White
|
||||||
|
|
||||||
|
try {
|
||||||
[UserItem[]]$Result = @()
|
[UserItem[]]$Result = @()
|
||||||
|
|
||||||
|
# Configure database-specific connection string
|
||||||
$databaseName = $database.name
|
$databaseName = $database.name
|
||||||
$databaseConnectionString = "Data Source=$server;Initial Catalog=$databaseName;Persist Security Info=False;Encrypt=True;TrustServerCertificate=False;Application Name=CloudEngineering";
|
$databaseConnectionString = "Data Source=$server;Initial Catalog=$databaseName;Persist Security Info=False;Encrypt=True;TrustServerCertificate=False;Application Name=CloudEngineering-UserAudit"
|
||||||
|
|
||||||
$users = Invoke-Sqlcmd -Query $userListQuery -ConnectionString $databaseConnectionString -AccessToken $access_token
|
# Query database users and their authentication details
|
||||||
|
$users = Invoke-Sqlcmd -Query $userListQuery -ConnectionString $databaseConnectionString -AccessToken $access_token -ErrorAction Stop
|
||||||
|
|
||||||
|
# Process each user found in the database
|
||||||
foreach ($user in $users) {
|
foreach ($user in $users) {
|
||||||
|
$totalUsersFound++
|
||||||
|
|
||||||
[UserItem] $userItem = [UserItem]::new()
|
[UserItem] $userItem = [UserItem]::new()
|
||||||
$userItem.ServerName = $server
|
$userItem.ServerName = $server
|
||||||
$userItem.DatabaseName = $database.name
|
$userItem.DatabaseName = $database.name
|
||||||
@@ -82,9 +219,100 @@ foreach ($server in $serverlist) {
|
|||||||
$Result += $userItem
|
$Result += $userItem
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Export results for this database to CSV
|
||||||
|
if ($Result.Count -gt 0) {
|
||||||
$Result | Export-Csv -Path $fileName -Append -NoTypeInformation
|
$Result | Export-Csv -Path $fileName -Append -NoTypeInformation
|
||||||
|
Write-Host " ✅ Found $($Result.Count) users in database [$($database.name)]" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host " ℹ️ No users found in database [$($database.name)]" -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning " ❌ Failed to audit database [$($database.name)]: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning "❌ Failed to process server [$server]: $($_.Exception.Message)"
|
||||||
|
Write-Warning " This may be due to connectivity issues or insufficient permissions"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Generate comprehensive audit summary
|
||||||
Write-Host "======================================================================================================================================================================"
|
Write-Host "======================================================================================================================================================================"
|
||||||
Write-Host "Done."
|
Write-Host "📊 SQL User Audit Summary" -ForegroundColor Green
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Audit Results:" -ForegroundColor Cyan
|
||||||
|
Write-Host "=============="
|
||||||
|
Write-Host "• Servers Processed: $totalServersProcessed of $($ServerList.Count)" -ForegroundColor White
|
||||||
|
Write-Host "• Databases Audited: $totalDatabasesProcessed" -ForegroundColor White
|
||||||
|
Write-Host "• Total Users Found: $totalUsersFound" -ForegroundColor White
|
||||||
|
|
||||||
|
# Analyze authentication types if CSV file exists
|
||||||
|
if (Test-Path $fileName) {
|
||||||
|
try {
|
||||||
|
$csvData = Import-Csv $fileName
|
||||||
|
$sqlAuthUsers = ($csvData | Where-Object { $_.type -eq "SQL_USER" }).Count
|
||||||
|
$azureADUsers = ($csvData | Where-Object { $_.type -eq "EXTERNAL_USER" }).Count
|
||||||
|
$windowsUsers = ($csvData | Where-Object { $_.type -eq "WINDOWS_USER" }).Count
|
||||||
|
$otherUsers = $csvData.Count - $sqlAuthUsers - $azureADUsers - $windowsUsers
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Authentication Type Breakdown:" -ForegroundColor Yellow
|
||||||
|
Write-Host "=============================="
|
||||||
|
Write-Host "• SQL Authentication Users: $sqlAuthUsers" -ForegroundColor $(if($sqlAuthUsers -gt 0) {'Red'} else {'Green'})
|
||||||
|
Write-Host "• Azure AD Users: $azureADUsers" -ForegroundColor Green
|
||||||
|
Write-Host "• Windows Authentication Users: $windowsUsers" -ForegroundColor White
|
||||||
|
if ($otherUsers -gt 0) {
|
||||||
|
Write-Host "• Other Authentication Types: $otherUsers" -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
|
||||||
|
# Security recommendation if SQL users found
|
||||||
|
if ($sqlAuthUsers -gt 0) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "⚠️ Security Notice:" -ForegroundColor Red
|
||||||
|
Write-Host " $sqlAuthUsers SQL Authentication users detected." -ForegroundColor Yellow
|
||||||
|
Write-Host " Consider migrating to Azure AD authentication for enhanced security." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning "Could not analyze authentication types: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
if (Test-Path $fileName) {
|
||||||
|
$fileSize = (Get-Item $fileName).Length
|
||||||
|
Write-Host "📄 Results Export:" -ForegroundColor Cyan
|
||||||
|
Write-Host "=================="
|
||||||
|
Write-Host "• Output File: $fileName" -ForegroundColor White
|
||||||
|
Write-Host "• File Size: $([math]::Round($fileSize/1KB, 2)) KB" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Display sample of results if file exists and has content
|
||||||
|
try {
|
||||||
|
$sampleData = Import-Csv $fileName | Select-Object -First 5
|
||||||
|
if ($sampleData) {
|
||||||
|
Write-Host "📋 Sample Results (First 5 Users):" -ForegroundColor Cyan
|
||||||
|
Write-Host "===================================="
|
||||||
|
$sampleData | Format-Table -AutoSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning "Could not display sample results: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "✅ SQL User audit completed successfully!" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "📝 Next Steps:" -ForegroundColor Yellow
|
||||||
|
Write-Host "=============="
|
||||||
|
Write-Host "• Review the CSV file for user access patterns" -ForegroundColor White
|
||||||
|
Write-Host "• Identify users with inappropriate authentication types" -ForegroundColor White
|
||||||
|
Write-Host "• Validate user access against business requirements" -ForegroundColor White
|
||||||
|
Write-Host "• Consider implementing Azure AD authentication where applicable" -ForegroundColor White
|
||||||
|
|
||||||
|
Write-Host "======================================================================================================================================================================"
|
||||||
|
Write-Host "🏁 Audit process completed." -ForegroundColor Green
|
||||||
@@ -1,7 +1,102 @@
|
|||||||
Write-Host "================================================================================================="
|
<#
|
||||||
Write-Host "Creating Software Bill Of Materials."
|
.SYNOPSIS
|
||||||
Write-Host "================================================================================================="
|
Generates a comprehensive Software Bill of Materials (SBOM) by consolidating Snyk dependency exports with enhanced package metadata.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This script processes multiple Snyk CSV dependency exports to create a unified Software Bill of Materials
|
||||||
|
with enriched package information. It combines vulnerability data from Snyk with additional metadata
|
||||||
|
from package repositories (NuGet) to provide comprehensive dependency insights.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
• Multi-file CSV processing from Snyk dependency exports
|
||||||
|
• Enhanced NuGet package metadata enrichment (version history, deprecation status)
|
||||||
|
• Vulnerability aggregation across all projects and dependencies
|
||||||
|
• License information consolidation and analysis
|
||||||
|
• Deprecation detection for NuGet packages
|
||||||
|
• Comprehensive SBOM generation with timestamped output
|
||||||
|
• Support for npm, NuGet, and other package types
|
||||||
|
• Latest version tracking and publication date analysis
|
||||||
|
|
||||||
|
.PARAMETER None
|
||||||
|
This script does not accept parameters. Input files are processed from a predefined directory path.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\SBOM.ps1
|
||||||
|
Processes all Snyk CSV exports in c:\temp\snyk\ and generates a consolidated SBOM.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
# Prepare Snyk CSV exports first
|
||||||
|
# Export dependencies from Snyk UI or CLI to c:\temp\snyk\
|
||||||
|
.\SBOM.ps1
|
||||||
|
Creates enhanced SBOM with NuGet metadata enrichment.
|
||||||
|
|
||||||
|
.INPUTS
|
||||||
|
System.IO.FileInfo[]
|
||||||
|
Requires Snyk CSV dependency export files in c:\temp\snyk\ directory.
|
||||||
|
Expected CSV columns: id, name, version, type, issuesCritical, issuesHigh, issuesMedium,
|
||||||
|
issuesLow, dependenciesWithIssues, licenses, projects, license urls
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
System.IO.FileInfo
|
||||||
|
Generates a timestamped CSV file containing enriched SBOM data with the following columns:
|
||||||
|
- FileName: Source CSV file name for traceability
|
||||||
|
- id: Package unique identifier from Snyk
|
||||||
|
- name: Package name
|
||||||
|
- version: Package version
|
||||||
|
- type: Package type (npm, nuget, maven, etc.)
|
||||||
|
- issuesCritical/High/Medium/Low: Vulnerability counts by severity
|
||||||
|
- dependenciesWithIssues: Count of vulnerable dependencies
|
||||||
|
- licenses: License information from Snyk
|
||||||
|
- projects: Projects using this dependency
|
||||||
|
- license_urls: URLs to license information
|
||||||
|
- latestVersion: Most recent available version (NuGet packages)
|
||||||
|
- latestVersionUrl: URL to latest version (NuGet packages)
|
||||||
|
- latestVersionPublishedDate: Publication date of latest version
|
||||||
|
- firstPublishedDate: Initial publication date of current version
|
||||||
|
- versionUrl: URL to current version information
|
||||||
|
- isDeprecated: Boolean indicating deprecation status
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Requires PowerShell 5.1 or later
|
||||||
|
Requires PackageManagement module for NuGet package queries
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
- Snyk CSV dependency exports must be placed in c:\temp\snyk\ directory
|
||||||
|
- Network connectivity to nuget.org for package metadata enrichment
|
||||||
|
- PowerShell execution policy must allow script execution
|
||||||
|
|
||||||
|
Performance Considerations:
|
||||||
|
- Processing time depends on number of unique NuGet packages
|
||||||
|
- Each NuGet package requires API calls to nuget.org
|
||||||
|
- Large SBOM files may take several minutes to process
|
||||||
|
- Progress indicators show current processing status
|
||||||
|
|
||||||
|
Input File Requirements:
|
||||||
|
- Files must be CSV format with standard Snyk dependency export structure
|
||||||
|
- All CSV files in the source directory will be processed
|
||||||
|
- Files should contain complete dependency information from Snyk scans
|
||||||
|
|
||||||
|
.LINK
|
||||||
|
https://docs.snyk.io/products/snyk-open-source/dependency-management
|
||||||
|
https://docs.microsoft.com/en-us/nuget/api/overview
|
||||||
|
|
||||||
|
.COMPONENT
|
||||||
|
PackageManagement PowerShell Module, Snyk Dependency Exports
|
||||||
|
|
||||||
|
.ROLE
|
||||||
|
Software Composition Analysis, Security Governance, Compliance Reporting
|
||||||
|
|
||||||
|
.FUNCTIONALITY
|
||||||
|
SBOM generation, dependency analysis, vulnerability aggregation, package metadata enrichment
|
||||||
|
#>
|
||||||
|
|
||||||
|
Write-Host "========================================================================================================================================================================"
|
||||||
|
Write-Host "🔍 Software Bill of Materials (SBOM) Generator" -ForegroundColor Green
|
||||||
|
Write-Host "========================================================================================================================================================================"
|
||||||
|
Write-Host "📋 Processing Snyk dependency exports and enriching with package metadata..." -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Data structure class for enhanced SBOM entries
|
||||||
class CSVItem {
|
class CSVItem {
|
||||||
[string] $FileName = ""
|
[string] $FileName = ""
|
||||||
[string] $id = ""
|
[string] $id = ""
|
||||||
@@ -24,8 +119,36 @@ class CSVItem {
|
|||||||
[string] $isDeprecated = ""
|
[string] $isDeprecated = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
function PropagatePackage {
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Enriches package entries with additional metadata from package repositories.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This function queries package repositories (currently NuGet) to gather additional metadata
|
||||||
|
such as publication dates, latest versions, deprecation status, and repository URLs.
|
||||||
|
This enrichment provides comprehensive package lifecycle information for SBOM analysis.
|
||||||
|
|
||||||
|
.PARAMETER allItems
|
||||||
|
Array of CSVItem objects representing all packages in the SBOM.
|
||||||
|
|
||||||
|
.PARAMETER name
|
||||||
|
Name of the package to enrich with metadata.
|
||||||
|
|
||||||
|
.PARAMETER version
|
||||||
|
Version of the package to enrich.
|
||||||
|
|
||||||
|
.PARAMETER type
|
||||||
|
Package type (npm, nuget, maven, etc.). Only NuGet packages are currently enriched.
|
||||||
|
|
||||||
|
.PARAMETER progress
|
||||||
|
Progress indicator string showing current processing status.
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Currently supports NuGet package enrichment only.
|
||||||
|
Makes API calls to NuGet.org which may impact performance for large SBOMs.
|
||||||
|
Handles deprecated packages by checking multiple metadata fields.
|
||||||
|
#>
|
||||||
|
function PropagatePackage {
|
||||||
param (
|
param (
|
||||||
[CSVItem[]] $allItems,
|
[CSVItem[]] $allItems,
|
||||||
[string] $name,
|
[string] $name,
|
||||||
@@ -34,73 +157,138 @@ function PropagatePackage {
|
|||||||
[string] $progress
|
[string] $progress
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Find all SBOM entries matching this package
|
||||||
$foundItems = $allItems | Where-Object { ($_.name -eq $name) -and ($_.version -eq $version) -and ($_.type -eq $type)}
|
$foundItems = $allItems | Where-Object { ($_.name -eq $name) -and ($_.version -eq $version) -and ($_.type -eq $type)}
|
||||||
|
|
||||||
write-Host "[$progress] - Find $type package info for $name ($version) [$($foundItems.Length)]"
|
Write-Host " [$progress] 📦 Enriching $type package: $name ($version) - Found $($foundItems.Length) entries" -ForegroundColor Gray
|
||||||
|
|
||||||
|
# Currently only supports NuGet package enrichment
|
||||||
if ($type -ne "nuget") {
|
if ($type -ne "nuget") {
|
||||||
|
Write-Host " ⏭️ Skipping $type package (enrichment not supported)" -ForegroundColor DarkGray
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
$nuget = Find-Package $name -RequiredVersion $version -ProviderName Nuget
|
# Query NuGet repository for specific version metadata
|
||||||
|
|
||||||
if ($null -eq $nuget) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$lastNuget = Find-Package $name -ProviderName Nuget
|
$nuget = Find-Package $name -RequiredVersion $version -ProviderName Nuget -ErrorAction Stop
|
||||||
|
Write-Host " ✅ Found NuGet package metadata for $name $version" -ForegroundColor DarkGreen
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Host " ❌ Failed to find NuGet package: $name $version - $($_.Exception.Message)" -ForegroundColor DarkRed
|
||||||
|
return
|
||||||
}
|
}
|
||||||
catch {}
|
|
||||||
|
|
||||||
|
# Query for latest version information
|
||||||
|
$lastNuget = $null
|
||||||
|
try {
|
||||||
|
$lastNuget = Find-Package $name -ProviderName Nuget -ErrorAction Stop
|
||||||
|
Write-Host " 📈 Latest version found: $($lastNuget.Version)" -ForegroundColor DarkGreen
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Host " ⚠️ Could not determine latest version for $name" -ForegroundColor DarkYellow
|
||||||
|
}
|
||||||
|
|
||||||
|
# Enrich all matching SBOM entries with NuGet metadata
|
||||||
foreach ($propagateItem in $foundItems) {
|
foreach ($propagateItem in $foundItems) {
|
||||||
|
# Set publication date for current version
|
||||||
$propagateItem.firstPublishedDate = $nuget.metadata["published"]
|
$propagateItem.firstPublishedDate = $nuget.metadata["published"]
|
||||||
|
|
||||||
|
# Generate NuGet.org URL for current version
|
||||||
$propagateItem.versionUrl = "https://www.nuget.org/packages/$name/$version"
|
$propagateItem.versionUrl = "https://www.nuget.org/packages/$name/$version"
|
||||||
|
|
||||||
|
# Add latest version information if available
|
||||||
if ($null -ne $lastNuget) {
|
if ($null -ne $lastNuget) {
|
||||||
$propagateItem.latestVersion = $lastNuget.Version;
|
$propagateItem.latestVersion = $lastNuget.Version
|
||||||
$propagateItem.latestVersionPublishedDate = $lastNuget.metadata["published"]
|
$propagateItem.latestVersionPublishedDate = $lastNuget.metadata["published"]
|
||||||
$propagateItem.latestVersionUrl = "https://www.nuget.org/packages/$name/$($lastNuget.Version)"
|
$propagateItem.latestVersionUrl = "https://www.nuget.org/packages/$name/$($lastNuget.Version)"
|
||||||
}
|
}
|
||||||
$propagateItem.isDeprecated = ($null -eq $lastNuget) -or ($nuget.metadata["summary"] -like "*Deprecated*") -or ($nuget.metadata["title"] -like "*Deprecated*") -or ($nuget.metadata["tags"] -like "*Deprecated*")-or ($nuget.metadata["description"] -like "*Deprecated*")
|
|
||||||
|
# Determine deprecation status by checking multiple metadata fields
|
||||||
|
$isDeprecated = ($null -eq $lastNuget) -or
|
||||||
|
($nuget.metadata["summary"] -like "*Deprecated*") -or
|
||||||
|
($nuget.metadata["title"] -like "*Deprecated*") -or
|
||||||
|
($nuget.metadata["tags"] -like "*Deprecated*") -or
|
||||||
|
($nuget.metadata["description"] -like "*Deprecated*")
|
||||||
|
|
||||||
|
$propagateItem.isDeprecated = $isDeprecated
|
||||||
|
|
||||||
|
if ($isDeprecated) {
|
||||||
|
Write-Host " ⚠️ Package marked as deprecated: $name" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Write-Host " ✅ Successfully enriched $($foundItems.Length) SBOM entries" -ForegroundColor DarkGreen
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Generate timestamped output filename
|
||||||
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
||||||
$fileName = ".\$date snyk_npm_nuget_sbom.csv"
|
$fileName = ".\$date snyk_npm_nuget_sbom.csv"
|
||||||
|
|
||||||
Write-Host "-------------------------------------------------------------------------------------------------"
|
Write-Host "📁 Output file: $fileName" -ForegroundColor Gray
|
||||||
Write-Host "Parsing CSV Files.."
|
Write-Host ""
|
||||||
Write-Host "-------------------------------------------------------------------------------------------------"
|
|
||||||
|
|
||||||
|
Write-Host "========================================================================================================================================================================"
|
||||||
|
Write-Host "📋 Phase 1: Processing Snyk CSV Dependencies" -ForegroundColor Cyan
|
||||||
|
Write-Host "========================================================================================================================================================================"
|
||||||
|
|
||||||
|
# Define source directory for Snyk CSV exports
|
||||||
$csvDependenciesExportPath = "c:\temp\snyk\*.csv"
|
$csvDependenciesExportPath = "c:\temp\snyk\*.csv"
|
||||||
|
|
||||||
$files = Get-ChildItem $csvDependenciesExportPath
|
# Locate all CSV files in the source directory
|
||||||
|
try {
|
||||||
|
$files = Get-ChildItem $csvDependenciesExportPath -ErrorAction Stop
|
||||||
|
Write-Host "✅ Found $($files.Count) Snyk CSV file(s) to process" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Host "❌ No CSV files found in $csvDependenciesExportPath" -ForegroundColor Red
|
||||||
|
Write-Host " Please ensure Snyk dependency exports are placed in the directory" -ForegroundColor Yellow
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Initialize SBOM collection
|
||||||
[CSVItem[]]$CSVItems = @()
|
[CSVItem[]]$CSVItems = @()
|
||||||
|
$totalEntries = 0
|
||||||
|
|
||||||
|
# Process each CSV file
|
||||||
foreach($file in $files) {
|
foreach($file in $files) {
|
||||||
Write-Host $file.FullName
|
Write-Host ""
|
||||||
|
Write-Host "📄 Processing file: $($file.Name)" -ForegroundColor Yellow
|
||||||
|
Write-Host " 📍 Path: $($file.FullName)" -ForegroundColor Gray
|
||||||
|
|
||||||
$csv = Import-Csv -Path $file.FullName
|
try {
|
||||||
|
$csv = Import-Csv -Path $file.FullName -ErrorAction Stop
|
||||||
|
Write-Host " 📊 Found $($csv.Count) dependency entries" -ForegroundColor White
|
||||||
|
|
||||||
|
# Process each dependency entry in the CSV file
|
||||||
|
$entryCount = 0
|
||||||
foreach ($csvLine in $csv) {
|
foreach ($csvLine in $csv) {
|
||||||
|
$entryCount++
|
||||||
|
$totalEntries++
|
||||||
|
|
||||||
|
# Create new SBOM entry
|
||||||
[CSVItem] $CSVItem = [CSVItem]::new()
|
[CSVItem] $CSVItem = [CSVItem]::new()
|
||||||
$CSVItem.FileName = $file.Name
|
$CSVItem.FileName = $file.Name
|
||||||
|
|
||||||
|
# Map Snyk CSV data to SBOM structure
|
||||||
$CSVItem.id = $csvLine.id
|
$CSVItem.id = $csvLine.id
|
||||||
$CSVItem.name = $csvLine.name
|
$CSVItem.name = $csvLine.name
|
||||||
$CSVItem.version = $csvLine.version
|
$CSVItem.version = $csvLine.version
|
||||||
$CSVItem.type = $csvLine.type
|
$CSVItem.type = $csvLine.type
|
||||||
|
|
||||||
|
# Vulnerability information
|
||||||
$CSVItem.issuesCritical = $csvLine.issuesCritical
|
$CSVItem.issuesCritical = $csvLine.issuesCritical
|
||||||
$CSVItem.issuesHigh = $csvLine.issuesHigh
|
$CSVItem.issuesHigh = $csvLine.issuesHigh
|
||||||
$CSVItem.issuesMedium = $csvLine.issuesMedium
|
$CSVItem.issuesMedium = $csvLine.issuesMedium
|
||||||
$CSVItem.issuesLow = $csvLine.issuesLow
|
$CSVItem.issuesLow = $csvLine.issuesLow
|
||||||
$CSVItem.dependenciesWithIssues = $csvLine.dependenciesWithIssues
|
$CSVItem.dependenciesWithIssues = $csvLine.dependenciesWithIssues
|
||||||
|
|
||||||
|
# License and project information
|
||||||
$CSVItem.licenses = $csvLine.licenses
|
$CSVItem.licenses = $csvLine.licenses
|
||||||
$CSVItem.projects = $csvLine.projects
|
$CSVItem.projects = $csvLine.projects
|
||||||
$CSVItem.license_urls = $csvLine."license urls"
|
$CSVItem.license_urls = $csvLine."license urls"
|
||||||
|
|
||||||
|
# Version and metadata (will be enriched later for NuGet packages)
|
||||||
$CSVItem.latestVersion = $csvLine.latestVersion
|
$CSVItem.latestVersion = $csvLine.latestVersion
|
||||||
$CSVItem.latestVersionPublishedDate = $csvLine.latestVersionPublishedDate
|
$CSVItem.latestVersionPublishedDate = $csvLine.latestVersionPublishedDate
|
||||||
$CSVItem.firstPublishedDate = $csvLine.firstPublishedDate
|
$CSVItem.firstPublishedDate = $csvLine.firstPublishedDate
|
||||||
@@ -108,30 +296,114 @@ foreach($file in $files) {
|
|||||||
|
|
||||||
$CSVItems += $CSVItem
|
$CSVItems += $CSVItem
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "-------------------------------------------------------------------------------------------------"
|
Write-Host " ✅ Successfully processed $entryCount entries from $($file.Name)" -ForegroundColor Green
|
||||||
Write-Host "Determine objects.."
|
}
|
||||||
Write-Host "-------------------------------------------------------------------------------------------------"
|
catch {
|
||||||
|
Write-Host " ❌ Error processing $($file.Name): $($_.Exception.Message)" -ForegroundColor Red
|
||||||
$toDo = $CSVItems | Where-Object { $_.type -eq "nuget" } | Sort-Object -Property version| Sort-Object -Property name
|
Write-Host " Skipping this file and continuing..." -ForegroundColor Yellow
|
||||||
$counter = 0
|
continue
|
||||||
$length = $toDo.Length
|
|
||||||
foreach ($package in $toDo) {
|
|
||||||
$counter = $counter + 1
|
|
||||||
|
|
||||||
if ($package.latestVersion -eq "") {
|
|
||||||
PropagatePackage -allItems $CSVItems -name $package.name -type $package.type -version $package.version -progress ("{0:D4}/{1:D4}" -f $counter, $length)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "-------------------------------------------------------------------------------------------------"
|
Write-Host ""
|
||||||
Write-Host "Saving overview.."
|
Write-Host "📊 CSV Processing Summary:" -ForegroundColor Cyan
|
||||||
Write-Host "-------------------------------------------------------------------------------------------------"
|
Write-Host "=========================="
|
||||||
|
Write-Host "• Files Processed: $($files.Count)" -ForegroundColor White
|
||||||
|
Write-Host "• Total Dependencies: $totalEntries" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
$CSVItems | Export-Csv -Path $fileName -NoTypeInformation
|
# Analyze package types
|
||||||
|
$packageTypes = $CSVItems | Group-Object type | Sort-Object Count -Descending
|
||||||
|
Write-Host "🔍 Package Type Distribution:" -ForegroundColor Cyan
|
||||||
|
foreach ($type in $packageTypes) {
|
||||||
|
Write-Host " $($type.Name): $($type.Count) packages" -ForegroundColor White
|
||||||
|
}
|
||||||
|
|
||||||
Write-Host "Done."
|
Write-Host ""
|
||||||
|
Write-Host "========================================================================================================================================================================"
|
||||||
|
Write-Host "🔧 Phase 2: NuGet Package Metadata Enrichment" -ForegroundColor Cyan
|
||||||
|
Write-Host "========================================================================================================================================================================"
|
||||||
|
|
||||||
|
# Identify unique NuGet packages that need enrichment
|
||||||
|
$nugetPackages = $CSVItems | Where-Object { $_.type -eq "nuget" -and $_.latestVersion -eq "" } |
|
||||||
|
Sort-Object -Property name, version -Unique
|
||||||
|
$nugetCount = $nugetPackages.Count
|
||||||
|
|
||||||
|
if ($nugetCount -eq 0) {
|
||||||
|
Write-Host "ℹ️ No NuGet packages require enrichment (all already have metadata)" -ForegroundColor Blue
|
||||||
|
} else {
|
||||||
|
Write-Host "📦 Found $nugetCount unique NuGet packages requiring metadata enrichment" -ForegroundColor White
|
||||||
|
Write-Host "⏱️ This process may take several minutes depending on network connectivity..." -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
$counter = 0
|
||||||
|
foreach ($package in $nugetPackages) {
|
||||||
|
$counter++
|
||||||
|
$progressPercent = [math]::Round(($counter / $nugetCount) * 100, 1)
|
||||||
|
|
||||||
|
Write-Host "🔄 [$counter/$nugetCount - $progressPercent%] Processing NuGet package: $($package.name) v$($package.version)" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
PropagatePackage -allItems $CSVItems -name $package.name -type $package.type -version $package.version -progress ("{0:D4}/{1:D4}" -f $counter, $nugetCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "========================================================================================================================================================================"
|
||||||
|
Write-Host "💾 Phase 3: SBOM Export and Analysis" -ForegroundColor Cyan
|
||||||
|
Write-Host "========================================================================================================================================================================"
|
||||||
|
|
||||||
|
Write-Host "📄 Exporting enhanced SBOM to CSV..." -ForegroundColor White
|
||||||
|
|
||||||
|
try {
|
||||||
|
$CSVItems | Export-Csv -Path $fileName -NoTypeInformation -ErrorAction Stop
|
||||||
|
|
||||||
|
if (Test-Path $fileName) {
|
||||||
|
$fileSize = [math]::Round((Get-Item $fileName).Length / 1KB, 2)
|
||||||
|
Write-Host "✅ SBOM export completed successfully!" -ForegroundColor Green
|
||||||
|
Write-Host " 📁 File: $fileName" -ForegroundColor Gray
|
||||||
|
Write-Host " 📏 Size: $fileSize KB" -ForegroundColor Gray
|
||||||
|
Write-Host " 📊 Records: $($CSVItems.Count)" -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Host "❌ Failed to export SBOM: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "📈 SBOM Analysis Summary:" -ForegroundColor Cyan
|
||||||
|
Write-Host "========================"
|
||||||
|
|
||||||
|
# Vulnerability summary
|
||||||
|
$criticalCount = ($CSVItems | Where-Object { [int]$_.issuesCritical -gt 0 }).Count
|
||||||
|
$highCount = ($CSVItems | Where-Object { [int]$_.issuesHigh -gt 0 }).Count
|
||||||
|
$mediumCount = ($CSVItems | Where-Object { [int]$_.issuesMedium -gt 0 }).Count
|
||||||
|
$lowCount = ($CSVItems | Where-Object { [int]$_.issuesLow -gt 0 }).Count
|
||||||
|
|
||||||
|
Write-Host "🚨 Vulnerability Summary:" -ForegroundColor Yellow
|
||||||
|
Write-Host " Critical Issues: $criticalCount packages" -ForegroundColor $(if($criticalCount -gt 0) {'Red'} else {'Green'})
|
||||||
|
Write-Host " High Issues: $highCount packages" -ForegroundColor $(if($highCount -gt 0) {'Red'} else {'Green'})
|
||||||
|
Write-Host " Medium Issues: $mediumCount packages" -ForegroundColor $(if($mediumCount -gt 0) {'Yellow'} else {'Green'})
|
||||||
|
Write-Host " Low Issues: $lowCount packages" -ForegroundColor $(if($lowCount -gt 0) {'Yellow'} else {'Green'})
|
||||||
|
|
||||||
|
# Deprecation summary
|
||||||
|
$deprecatedCount = ($CSVItems | Where-Object { $_.isDeprecated -eq $true -or $_.isDeprecated -eq "True" }).Count
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "⚠️ Deprecation Summary:" -ForegroundColor Yellow
|
||||||
|
Write-Host " Deprecated Packages: $deprecatedCount" -ForegroundColor $(if($deprecatedCount -gt 0) {'Yellow'} else {'Green'})
|
||||||
|
|
||||||
|
# License summary
|
||||||
|
$unlicensedCount = ($CSVItems | Where-Object { $_.licenses -eq "" -or $_.licenses -eq $null }).Count
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "📋 License Summary:" -ForegroundColor Cyan
|
||||||
|
Write-Host " Packages with License Info: $($CSVItems.Count - $unlicensedCount)" -ForegroundColor Green
|
||||||
|
Write-Host " Packages without License Info: $unlicensedCount" -ForegroundColor $(if($unlicensedCount -gt 0) {'Yellow'} else {'Green'})
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "========================================================================================================================================================================"
|
||||||
|
Write-Host "✅ Software Bill of Materials generation completed successfully!" -ForegroundColor Green
|
||||||
|
Write-Host "========================================================================================================================================================================"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,103 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Comprehensive Snyk organization and project inventory across all accessible Snyk organizations.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This script connects to the Snyk API to retrieve a complete inventory of all organizations and their
|
||||||
|
associated projects. It provides essential information for security governance, project oversight,
|
||||||
|
and vulnerability management across your Snyk ecosystem.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
• Multi-organization project enumeration across Snyk groups
|
||||||
|
• Project metadata collection including repository, type, and runtime information
|
||||||
|
• Comprehensive CSV reporting with timestamped output files
|
||||||
|
• Automatic project name parsing for repository identification
|
||||||
|
• Support for all Snyk project types (npm, Maven, Docker, etc.)
|
||||||
|
• Secure API key management via Azure Key Vault
|
||||||
|
|
||||||
|
.PARAMETER None
|
||||||
|
This script does not accept parameters. Configuration is managed through Azure Key Vault integration.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\SnykOverview.ps1
|
||||||
|
Executes a complete inventory of all Snyk organizations and projects with CSV export.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
Connect-AzAccount
|
||||||
|
Set-AzContext -SubscriptionId "your-subscription-id"
|
||||||
|
.\SnykOverview.ps1
|
||||||
|
Ensures proper Azure authentication before accessing Key Vault for Snyk API credentials.
|
||||||
|
|
||||||
|
.INPUTS
|
||||||
|
None. This script does not accept pipeline input.
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
System.IO.FileInfo
|
||||||
|
Generates a timestamped CSV file containing Snyk project inventory with the following columns:
|
||||||
|
- OrganisationId: Snyk organization unique identifier
|
||||||
|
- OrganisationName: Human-readable organization name
|
||||||
|
- GroupId: Snyk group identifier for organization grouping
|
||||||
|
- OrganisationSlug: URL-friendly organization identifier
|
||||||
|
- ProjectId: Unique project identifier within Snyk
|
||||||
|
- ProjectRepo: Repository name extracted from project name
|
||||||
|
- ProjectName: Specific project/component name within repository
|
||||||
|
- ProjectType: Project technology type (npm, maven, docker, etc.)
|
||||||
|
- ProjectCreateDate: Project creation timestamp in Snyk
|
||||||
|
- ProjectTargetFile: Target manifest file (package.json, pom.xml, etc.)
|
||||||
|
- ProjectTargetRuntime: Runtime environment or version information
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Requires PowerShell 5.1 or later
|
||||||
|
Requires Az.KeyVault PowerShell module for secure API key retrieval
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
- Must be connected to Azure (Connect-AzAccount)
|
||||||
|
- Requires access to the 'consoleapp' Key Vault containing 'SnykKey' secret
|
||||||
|
- Snyk API token must have appropriate organization and project read permissions
|
||||||
|
- Network connectivity to api.snyk.io (HTTPS outbound on port 443)
|
||||||
|
|
||||||
|
API Information:
|
||||||
|
- Uses Snyk REST API v2023-08-29~beta
|
||||||
|
- Rate limiting: Respects Snyk API rate limits (varies by plan)
|
||||||
|
- Pagination: Handles up to 100 projects per organization (adjust limit if needed)
|
||||||
|
|
||||||
|
Security Considerations:
|
||||||
|
- API tokens are securely retrieved from Azure Key Vault
|
||||||
|
- No credentials are stored in script or output files
|
||||||
|
- Uses encrypted HTTPS connections to Snyk API
|
||||||
|
- Audit trail is maintained in timestamped CSV files
|
||||||
|
|
||||||
|
.LINK
|
||||||
|
https://docs.snyk.io/snyk-api-info/snyk-rest-api
|
||||||
|
https://docs.snyk.io/snyk-api-info/authentication-for-api
|
||||||
|
|
||||||
|
.COMPONENT
|
||||||
|
Az.KeyVault PowerShell Module, Snyk REST API
|
||||||
|
|
||||||
|
.ROLE
|
||||||
|
Security Administration, DevSecOps, Vulnerability Management
|
||||||
|
|
||||||
|
.FUNCTIONALITY
|
||||||
|
Snyk project inventory, security governance, vulnerability management reporting
|
||||||
|
#>
|
||||||
|
|
||||||
$access_token = Get-AzKeyVaultSecret -VaultName "consoleapp" -Name "SnykKey" -AsPlainText
|
$access_token = Get-AzKeyVaultSecret -VaultName "consoleapp" -Name "SnykKey" -AsPlainText
|
||||||
$head = @{ Authorization ="$access_token" }
|
$head = @{ Authorization ="$access_token" }
|
||||||
$version = "2023-08-29%7Ebeta"
|
$version = "2023-08-29%7Ebeta"
|
||||||
$ofs = ', '
|
$ofs = ', '
|
||||||
|
|
||||||
|
# Generate timestamped output filename
|
||||||
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
|
||||||
$fileName = ".\$date snyk projects.csv"
|
$fileName = ".\$date snyk projects.csv"
|
||||||
|
|
||||||
|
Write-Host "========================================================================================================================================================================"
|
||||||
|
Write-Host "🔍 Snyk Organization and Project Inventory" -ForegroundColor Green
|
||||||
|
Write-Host "========================================================================================================================================================================"
|
||||||
|
Write-Host "🔐 Retrieving Snyk API credentials from Azure Key Vault..." -ForegroundColor Cyan
|
||||||
|
Write-Host "📊 Output file: $fileName" -ForegroundColor Gray
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Data structure class for Snyk project information
|
||||||
class SnykOverview {
|
class SnykOverview {
|
||||||
[string] $OrganisationId = ""
|
[string] $OrganisationId = ""
|
||||||
[string] $OrganisationName = ""
|
[string] $OrganisationName = ""
|
||||||
@@ -20,29 +112,54 @@ class SnykOverview {
|
|||||||
[string] $ProjectTargetRunTime = ""
|
[string] $ProjectTargetRunTime = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Initialize results collection
|
||||||
[SnykOverview[]]$Result = @()
|
[SnykOverview[]]$Result = @()
|
||||||
|
$totalOrganizations = 0
|
||||||
|
$totalProjects = 0
|
||||||
|
|
||||||
|
Write-Host "📋 Retrieving Snyk organizations..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Retrieve all accessible Snyk organizations
|
||||||
$organisationUrl = "https://api.snyk.io/rest/orgs?version=$version"
|
$organisationUrl = "https://api.snyk.io/rest/orgs?version=$version"
|
||||||
$organisationResponse = Invoke-RestMethod -Uri $organisationUrl -Method GET -Headers $head
|
$organisationResponse = Invoke-RestMethod -Uri $organisationUrl -Method GET -Headers $head
|
||||||
|
|
||||||
|
Write-Host "✅ Found $($organisationResponse.data.Count) Snyk organization(s)" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Process each organization to retrieve its projects
|
||||||
foreach ($organisation in $organisationResponse.data)
|
foreach ($organisation in $organisationResponse.data)
|
||||||
{
|
{
|
||||||
|
$totalOrganizations++
|
||||||
$organisationId = $organisation.id
|
$organisationId = $organisation.id
|
||||||
|
|
||||||
|
Write-Host "----------------------------------------------------------------------------------------------------------------------------------------------------------------------"
|
||||||
|
Write-Host "🏢 Processing Organization [$($organisation.attributes.name)] (ID: $organisationId)" -ForegroundColor Yellow
|
||||||
|
|
||||||
|
# Retrieve all projects for the current organization
|
||||||
$projectUrl = "https://api.snyk.io/rest/orgs/$organisationId/projects?version=$version&limit=100"
|
$projectUrl = "https://api.snyk.io/rest/orgs/$organisationId/projects?version=$version&limit=100"
|
||||||
$projectResponse = Invoke-RestMethod -Uri $projectUrl -Method GET -Headers $head
|
$projectResponse = Invoke-RestMethod -Uri $projectUrl -Method GET -Headers $head
|
||||||
|
|
||||||
|
$orgProjectCount = $projectResponse.data.Count
|
||||||
|
Write-Host " 📦 Found $orgProjectCount project(s) in this organization" -ForegroundColor White
|
||||||
|
|
||||||
|
# Process each project within the organization
|
||||||
foreach ($project in $projectResponse.data)
|
foreach ($project in $projectResponse.data)
|
||||||
{
|
{
|
||||||
|
$totalProjects++
|
||||||
$projectName = $project.attributes.name
|
$projectName = $project.attributes.name
|
||||||
|
|
||||||
|
# Create new project record with comprehensive metadata
|
||||||
[SnykOverview] $SnykOverview = [SnykOverview]::new()
|
[SnykOverview] $SnykOverview = [SnykOverview]::new()
|
||||||
|
|
||||||
|
# Populate organization-level information
|
||||||
$SnykOverview.OrganisationId = $organisationId
|
$SnykOverview.OrganisationId = $organisationId
|
||||||
$SnykOverview.OrganisationName = $organisation.attributes.name
|
$SnykOverview.OrganisationName = $organisation.attributes.name
|
||||||
$SnykOverview.GroupId = $organisation.attributes.group_id
|
$SnykOverview.GroupId = $organisation.attributes.group_id
|
||||||
$SnykOverview.OrganisationSlug = $organisation.attributes.slug
|
$SnykOverview.OrganisationSlug = $organisation.attributes.slug
|
||||||
|
|
||||||
|
# Populate project-level information
|
||||||
$SnykOverview.ProjectId = $project.id
|
$SnykOverview.ProjectId = $project.id
|
||||||
|
# Parse project name to extract repository and component names (format: "repo:component")
|
||||||
$SnykOverview.ProjectRepo = $projectName.Split(":")[0]
|
$SnykOverview.ProjectRepo = $projectName.Split(":")[0]
|
||||||
$SnykOverview.ProjectName = $projectName.Split(":")[1]
|
$SnykOverview.ProjectName = $projectName.Split(":")[1]
|
||||||
$SnykOverview.ProjectType = $project.attributes.type
|
$SnykOverview.ProjectType = $project.attributes.type
|
||||||
@@ -54,6 +171,49 @@ foreach ($organisation in $organisationResponse.data)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "========================================================================================================================================================================"
|
||||||
|
Write-Host "📊 Snyk Inventory Summary" -ForegroundColor Green
|
||||||
|
Write-Host "========================================================================================================================================================================"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Inventory Results:" -ForegroundColor Cyan
|
||||||
|
Write-Host "=================="
|
||||||
|
Write-Host "• Organizations Processed: $totalOrganizations" -ForegroundColor White
|
||||||
|
Write-Host "• Total Projects Found: $totalProjects" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Export results to CSV file
|
||||||
|
Write-Host "💾 Exporting results to CSV..." -ForegroundColor Cyan
|
||||||
$Result | Export-Csv -Path $fileName -NoTypeInformation -Force
|
$Result | Export-Csv -Path $fileName -NoTypeInformation -Force
|
||||||
|
|
||||||
$Result | Format-Table
|
if (Test-Path $fileName) {
|
||||||
|
$fileSize = [math]::Round((Get-Item $fileName).Length / 1KB, 2)
|
||||||
|
Write-Host "✅ Export completed successfully!" -ForegroundColor Green
|
||||||
|
Write-Host " 📁 File: $fileName" -ForegroundColor Gray
|
||||||
|
Write-Host " 📏 Size: $fileSize KB" -ForegroundColor Gray
|
||||||
|
} else {
|
||||||
|
Write-Host "❌ Export failed - file not created" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "🔍 Project Type Breakdown:" -ForegroundColor Cyan
|
||||||
|
$projectTypes = $Result | Group-Object ProjectType | Sort-Object Count -Descending
|
||||||
|
foreach ($type in $projectTypes) {
|
||||||
|
Write-Host " $($type.Name): $($type.Count) projects" -ForegroundColor White
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "🏢 Organization Summary:" -ForegroundColor Cyan
|
||||||
|
$orgSummary = $Result | Group-Object OrganisationName | Sort-Object Count -Descending
|
||||||
|
foreach ($org in $orgSummary) {
|
||||||
|
Write-Host " $($org.Name): $($org.Count) projects" -ForegroundColor White
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "📋 Displaying first 10 results..." -ForegroundColor Cyan
|
||||||
|
$Result | Select-Object -First 10 | Format-Table -AutoSize
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "========================================================================================================================================================================"
|
||||||
|
Write-Host "✅ Snyk inventory completed successfully!" -ForegroundColor Green
|
||||||
|
Write-Host "========================================================================================================================================================================"
|
||||||
@@ -1,80 +1,156 @@
|
|||||||
|
/*
|
||||||
|
================================================================================
|
||||||
|
SQL SERVER DATABASE SIZE AND BACKUP ANALYSIS REPORT
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
DESCRIPTION:
|
||||||
|
Comprehensive analysis of SQL Server database sizes, space utilization,
|
||||||
|
and backup information. This script provides detailed insights into:
|
||||||
|
• Database file sizes (data and log files)
|
||||||
|
• Actual space used vs. allocated space
|
||||||
|
• Recovery model information
|
||||||
|
• Latest backup information for both full and log backups
|
||||||
|
• Backup size metrics including compression details
|
||||||
|
|
||||||
|
FEATURES:
|
||||||
|
• Multi-database analysis across entire SQL Server instance
|
||||||
|
• Real-time space usage calculation using dynamic SQL
|
||||||
|
• Backup history analysis from msdb.backupset
|
||||||
|
• Intelligent backup size reporting (compressed vs. uncompressed)
|
||||||
|
• Results ordered by total database size (largest first)
|
||||||
|
|
||||||
|
OUTPUT COLUMNS:
|
||||||
|
- database_id: SQL Server internal database identifier
|
||||||
|
- name: Database name
|
||||||
|
- state_desc: Database state (ONLINE, OFFLINE, etc.)
|
||||||
|
- recovery_model_desc: Recovery model (FULL, SIMPLE, BULK_LOGGED)
|
||||||
|
- total_size: Total allocated space (data + log files) in MB
|
||||||
|
- data_size: Total allocated data file space in MB
|
||||||
|
- data_used_size: Actual space used in data files in MB
|
||||||
|
- log_size: Total allocated log file space in MB
|
||||||
|
- log_used_size: Actual space used in log files in MB
|
||||||
|
- full_last_date: Date/time of most recent full backup
|
||||||
|
- full_size: Size of most recent full backup in MB
|
||||||
|
- log_last_date: Date/time of most recent log backup
|
||||||
|
- log_size: Size of most recent log backup in MB
|
||||||
|
|
||||||
|
USAGE SCENARIOS:
|
||||||
|
• Database capacity planning and growth analysis
|
||||||
|
• Storage optimization and space reclamation projects
|
||||||
|
• Backup strategy review and optimization
|
||||||
|
• Performance troubleshooting related to database size
|
||||||
|
• Compliance reporting for backup procedures
|
||||||
|
|
||||||
|
TECHNICAL IMPLEMENTATION:
|
||||||
|
• Uses temporary table to collect space usage across databases
|
||||||
|
• Dynamic SQL generation to query each online database
|
||||||
|
• Joins system catalog views (sys.databases, sys.master_files)
|
||||||
|
• Integrates backup history from msdb.backupset
|
||||||
|
• Handles compressed backup size calculations
|
||||||
|
|
||||||
|
PERMISSIONS REQUIRED:
|
||||||
|
• VIEW SERVER STATE permission
|
||||||
|
• Access to msdb database for backup history
|
||||||
|
• Database access to calculate space usage (connects to each database)
|
||||||
|
|
||||||
|
COMPATIBILITY:
|
||||||
|
• SQL Server 2008 R2 and later versions
|
||||||
|
• Works with Azure SQL Database (with limited backup history)
|
||||||
|
• Compatible with SQL Server on Linux
|
||||||
|
|
||||||
|
AUTHOR: Cloud Engineering Team
|
||||||
|
CREATED: Database administration and monitoring toolkit
|
||||||
|
UPDATED: Enhanced with comprehensive backup analysis and documentation
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
IF OBJECT_ID('tempdb.dbo.#space') IS NOT NULL
|
IF OBJECT_ID('tempdb.dbo.#space') IS NOT NULL
|
||||||
DROP TABLE #space
|
DROP TABLE #space
|
||||||
|
|
||||||
|
-- Create temporary table to store actual space usage from each database
|
||||||
CREATE TABLE #space (
|
CREATE TABLE #space (
|
||||||
database_id INT PRIMARY KEY
|
database_id INT PRIMARY KEY
|
||||||
, data_used_size DECIMAL(18,2)
|
, data_used_size DECIMAL(18,2) -- Actual space used in data files (MB)
|
||||||
, log_used_size DECIMAL(18,2)
|
, log_used_size DECIMAL(18,2) -- Actual space used in log files (MB)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
-- Variable to hold dynamically generated SQL for cross-database queries
|
||||||
DECLARE @SQL NVARCHAR(MAX)
|
DECLARE @SQL NVARCHAR(MAX)
|
||||||
|
|
||||||
|
-- Generate dynamic SQL to collect actual space usage from each online database
|
||||||
|
-- This approach is necessary because FILEPROPERTY() must be executed in the context of each database
|
||||||
SELECT @SQL = STUFF((
|
SELECT @SQL = STUFF((
|
||||||
SELECT '
|
SELECT '
|
||||||
USE [' + d.name + ']
|
USE [' + d.name + ']
|
||||||
INSERT INTO #space (database_id, data_used_size, log_used_size)
|
INSERT INTO #space (database_id, data_used_size, log_used_size)
|
||||||
SELECT
|
SELECT
|
||||||
DB_ID()
|
DB_ID()
|
||||||
, SUM(CASE WHEN [type] = 0 THEN space_used END)
|
, SUM(CASE WHEN [type] = 0 THEN space_used END) -- Data files (type = 0)
|
||||||
, SUM(CASE WHEN [type] = 1 THEN space_used END)
|
, SUM(CASE WHEN [type] = 1 THEN space_used END) -- Log files (type = 1)
|
||||||
FROM (
|
FROM (
|
||||||
SELECT s.[type], space_used = SUM(FILEPROPERTY(s.name, ''SpaceUsed'') * 8. / 1024)
|
SELECT s.[type], space_used = SUM(FILEPROPERTY(s.name, ''SpaceUsed'') * 8. / 1024) -- Convert pages to MB
|
||||||
FROM sys.database_files s
|
FROM sys.database_files s
|
||||||
GROUP BY s.[type]
|
GROUP BY s.[type]
|
||||||
) t;'
|
) t;'
|
||||||
FROM sys.databases d
|
FROM sys.databases d
|
||||||
WHERE d.[state] = 0
|
WHERE d.[state] = 0 -- Only include ONLINE databases
|
||||||
FOR XML PATH(''), TYPE).value('.', 'NVARCHAR(MAX)'), 1, 2, '')
|
FOR XML PATH(''), TYPE).value('.', 'NVARCHAR(MAX)'), 1, 2, '')
|
||||||
|
|
||||||
|
-- Execute the dynamic SQL to populate space usage data
|
||||||
EXEC sys.sp_executesql @SQL
|
EXEC sys.sp_executesql @SQL
|
||||||
|
|
||||||
|
-- Main query: Combine database information, file sizes, space usage, and backup data
|
||||||
SELECT
|
SELECT
|
||||||
d.database_id
|
d.database_id
|
||||||
, d.name
|
, d.name
|
||||||
, d.state_desc
|
, d.state_desc -- Database state (ONLINE, OFFLINE, etc.)
|
||||||
, d.recovery_model_desc
|
, d.recovery_model_desc -- Recovery model (FULL, SIMPLE, BULK_LOGGED)
|
||||||
, t.total_size
|
, t.total_size -- Total allocated space (data + log) in MB
|
||||||
, t.data_size
|
, t.data_size -- Allocated data file space in MB
|
||||||
, s.data_used_size
|
, s.data_used_size -- Actual space used in data files in MB
|
||||||
, t.log_size
|
, t.log_size -- Allocated log file space in MB
|
||||||
, s.log_used_size
|
, s.log_used_size -- Actual space used in log files in MB
|
||||||
, bu.full_last_date
|
, bu.full_last_date -- Most recent full backup date
|
||||||
, bu.full_size
|
, bu.full_size -- Most recent full backup size in MB
|
||||||
, bu.log_last_date
|
, bu.log_last_date -- Most recent log backup date
|
||||||
, bu.log_size
|
, bu.log_size -- Most recent log backup size in MB
|
||||||
FROM (
|
FROM (
|
||||||
|
-- Calculate allocated file sizes from sys.master_files (covers all databases from master)
|
||||||
SELECT
|
SELECT
|
||||||
database_id
|
database_id
|
||||||
, log_size = CAST(SUM(CASE WHEN [type] = 1 THEN size END) * 8. / 1024 AS DECIMAL(18,2))
|
, log_size = CAST(SUM(CASE WHEN [type] = 1 THEN size END) * 8. / 1024 AS DECIMAL(18,2)) -- Log files
|
||||||
, data_size = CAST(SUM(CASE WHEN [type] = 0 THEN size END) * 8. / 1024 AS DECIMAL(18,2))
|
, data_size = CAST(SUM(CASE WHEN [type] = 0 THEN size END) * 8. / 1024 AS DECIMAL(18,2)) -- Data files
|
||||||
, total_size = CAST(SUM(size) * 8. / 1024 AS DECIMAL(18,2))
|
, total_size = CAST(SUM(size) * 8. / 1024 AS DECIMAL(18,2)) -- Total allocated
|
||||||
FROM sys.master_files
|
FROM sys.master_files -- System view with all database files across instance
|
||||||
GROUP BY database_id
|
GROUP BY database_id
|
||||||
) t
|
) t
|
||||||
JOIN sys.databases d ON d.database_id = t.database_id
|
JOIN sys.databases d ON d.database_id = t.database_id -- Join with database catalog
|
||||||
LEFT JOIN #space s ON d.database_id = s.database_id
|
LEFT JOIN #space s ON d.database_id = s.database_id -- Join with actual space usage data
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
|
-- Subquery to get latest backup information for each database
|
||||||
SELECT
|
SELECT
|
||||||
database_name
|
database_name
|
||||||
, full_last_date = MAX(CASE WHEN [type] = 'D' THEN backup_finish_date END)
|
, full_last_date = MAX(CASE WHEN [type] = 'D' THEN backup_finish_date END) -- Latest full backup date
|
||||||
, full_size = MAX(CASE WHEN [type] = 'D' THEN backup_size END)
|
, full_size = MAX(CASE WHEN [type] = 'D' THEN backup_size END) -- Latest full backup size
|
||||||
, log_last_date = MAX(CASE WHEN [type] = 'L' THEN backup_finish_date END)
|
, log_last_date = MAX(CASE WHEN [type] = 'L' THEN backup_finish_date END) -- Latest log backup date
|
||||||
, log_size = MAX(CASE WHEN [type] = 'L' THEN backup_size END)
|
, log_size = MAX(CASE WHEN [type] = 'L' THEN backup_size END) -- Latest log backup size
|
||||||
FROM (
|
FROM (
|
||||||
|
-- Inner query to get most recent backup of each type per database
|
||||||
SELECT
|
SELECT
|
||||||
s.database_name
|
s.database_name
|
||||||
, s.[type]
|
, s.[type] -- 'D' = Full backup, 'L' = Log backup
|
||||||
, s.backup_finish_date
|
, s.backup_finish_date
|
||||||
, backup_size =
|
, backup_size = -- Smart backup size calculation
|
||||||
CAST(CASE WHEN s.backup_size = s.compressed_backup_size
|
CAST(CASE WHEN s.backup_size = s.compressed_backup_size
|
||||||
THEN s.backup_size
|
THEN s.backup_size -- No compression used
|
||||||
ELSE s.compressed_backup_size
|
ELSE s.compressed_backup_size -- Use compressed size
|
||||||
END / 1048576.0 AS DECIMAL(18,2))
|
END / 1048576.0 AS DECIMAL(18,2)) -- Convert bytes to MB
|
||||||
, RowNum = ROW_NUMBER() OVER (PARTITION BY s.database_name, s.[type] ORDER BY s.backup_finish_date DESC)
|
, RowNum = ROW_NUMBER() OVER (PARTITION BY s.database_name, s.[type] ORDER BY s.backup_finish_date DESC)
|
||||||
FROM msdb.dbo.backupset s
|
FROM msdb.dbo.backupset s -- SQL Server backup history
|
||||||
WHERE s.[type] IN ('D', 'L')
|
WHERE s.[type] IN ('D', 'L') -- Full and Log backups only
|
||||||
) f
|
) f
|
||||||
WHERE f.RowNum = 1
|
WHERE f.RowNum = 1 -- Only most recent backup of each type
|
||||||
GROUP BY f.database_name
|
GROUP BY f.database_name
|
||||||
) bu ON d.name = bu.database_name
|
) bu ON d.name = bu.database_name -- Join backup data with database info
|
||||||
ORDER BY t.total_size DESC
|
ORDER BY t.total_size DESC -- Order by largest databases first
|
||||||
@@ -1,28 +1,119 @@
|
|||||||
|
/*
|
||||||
|
================================================================================
|
||||||
|
SQL SERVER TABLE SIZE AND SPACE UTILIZATION ANALYSIS
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
DESCRIPTION:
|
||||||
|
Comprehensive analysis of table sizes and space utilization within a SQL Server database.
|
||||||
|
This script provides detailed insights into storage consumption, helping identify:
|
||||||
|
• Tables consuming the most storage space
|
||||||
|
• Space efficiency and potential optimization opportunities
|
||||||
|
• Row counts and storage allocation patterns
|
||||||
|
• Unused space that could be reclaimed through maintenance
|
||||||
|
|
||||||
|
FEATURES:
|
||||||
|
• Per-table storage analysis with schema information
|
||||||
|
• Detailed space breakdown (total, used, unused)
|
||||||
|
• Multiple unit reporting (KB and MB for flexibility)
|
||||||
|
• Row count correlation with storage size
|
||||||
|
• Excludes system tables and diagnostic tables
|
||||||
|
• Results ordered by storage consumption (largest first)
|
||||||
|
|
||||||
|
OUTPUT COLUMNS:
|
||||||
|
- TableName: Name of the table
|
||||||
|
- SchemaName: Schema containing the table
|
||||||
|
- rows: Number of rows in the table (from partition statistics)
|
||||||
|
- TotalSpaceKB: Total allocated space in kilobytes
|
||||||
|
- TotalSpaceMB: Total allocated space in megabytes (rounded to 2 decimals)
|
||||||
|
- UsedSpaceKB: Actually used space in kilobytes
|
||||||
|
- UsedSpaceMB: Actually used space in megabytes (rounded to 2 decimals)
|
||||||
|
- UnusedSpaceKB: Allocated but unused space in kilobytes
|
||||||
|
- UnusedSpaceMB: Allocated but unused space in megabytes (rounded to 2 decimals)
|
||||||
|
|
||||||
|
USAGE SCENARIOS:
|
||||||
|
• Database capacity planning and growth forecasting
|
||||||
|
• Storage optimization and space reclamation projects
|
||||||
|
• Performance troubleshooting (large table identification)
|
||||||
|
• Archive and purging strategy development
|
||||||
|
• Index maintenance priority assessment
|
||||||
|
• Storage cost analysis and optimization
|
||||||
|
|
||||||
|
TECHNICAL IMPLEMENTATION:
|
||||||
|
• Queries system catalog views for accurate space calculations
|
||||||
|
• Aggregates data across all indexes and partitions for each table
|
||||||
|
• Joins allocation units to get precise storage metrics
|
||||||
|
• Filters out system tables and development artifacts
|
||||||
|
• Uses 8KB page size for accurate space calculations
|
||||||
|
|
||||||
|
DATA SOURCES:
|
||||||
|
• sys.tables: Table metadata and properties
|
||||||
|
• sys.indexes: Index information for space aggregation
|
||||||
|
• sys.partitions: Partition-level row counts and object relationships
|
||||||
|
• sys.allocation_units: Physical storage allocation details
|
||||||
|
• sys.schemas: Schema ownership and organization
|
||||||
|
|
||||||
|
FILTERS APPLIED:
|
||||||
|
• Excludes tables with names starting with 'dt%' (development/diagnostic tables)
|
||||||
|
• Excludes Microsoft shipped system tables (is_ms_shipped = 0)
|
||||||
|
• Excludes system objects (object_id > 255)
|
||||||
|
• Only includes user-created tables in user schemas
|
||||||
|
|
||||||
|
PERFORMANCE CONSIDERATIONS:
|
||||||
|
• Efficient query using system catalog views
|
||||||
|
• Minimal performance impact on production systems
|
||||||
|
• Results cached by SQL Server's metadata caching
|
||||||
|
• Suitable for regular monitoring and reporting
|
||||||
|
|
||||||
|
PERMISSIONS REQUIRED:
|
||||||
|
• VIEW DEFINITION permission on target database
|
||||||
|
• Membership in db_datareader role (recommended)
|
||||||
|
• Access to system catalog views (granted by default to most users)
|
||||||
|
|
||||||
|
COMPATIBILITY:
|
||||||
|
• SQL Server 2005 and later versions
|
||||||
|
• Azure SQL Database compatible
|
||||||
|
• SQL Server on Linux compatible
|
||||||
|
• Works with all SQL Server editions
|
||||||
|
|
||||||
|
AUTHOR: Cloud Engineering Team
|
||||||
|
CREATED: Database administration and monitoring toolkit
|
||||||
|
UPDATED: Enhanced with comprehensive space analysis and documentation
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
SELECT
|
SELECT
|
||||||
t.name AS TableName,
|
t.name AS TableName, -- Table name for identification
|
||||||
s.name AS SchemaName,
|
s.name AS SchemaName, -- Schema name for organization context
|
||||||
p.rows,
|
p.rows, -- Row count from partition statistics
|
||||||
|
|
||||||
|
-- Total allocated space calculations (pages * 8KB per page)
|
||||||
SUM(a.total_pages) * 8 AS TotalSpaceKB,
|
SUM(a.total_pages) * 8 AS TotalSpaceKB,
|
||||||
CAST(ROUND(((SUM(a.total_pages) * 8) / 1024.00), 2) AS NUMERIC(36, 2)) AS TotalSpaceMB,
|
CAST(ROUND(((SUM(a.total_pages) * 8) / 1024.00), 2) AS NUMERIC(36, 2)) AS TotalSpaceMB,
|
||||||
|
|
||||||
|
-- Actually used space calculations
|
||||||
SUM(a.used_pages) * 8 AS UsedSpaceKB,
|
SUM(a.used_pages) * 8 AS UsedSpaceKB,
|
||||||
CAST(ROUND(((SUM(a.used_pages) * 8) / 1024.00), 2) AS NUMERIC(36, 2)) AS UsedSpaceMB,
|
CAST(ROUND(((SUM(a.used_pages) * 8) / 1024.00), 2) AS NUMERIC(36, 2)) AS UsedSpaceMB,
|
||||||
|
|
||||||
|
-- Unused (allocated but not used) space calculations
|
||||||
(SUM(a.total_pages) - SUM(a.used_pages)) * 8 AS UnusedSpaceKB,
|
(SUM(a.total_pages) - SUM(a.used_pages)) * 8 AS UnusedSpaceKB,
|
||||||
CAST(ROUND(((SUM(a.total_pages) - SUM(a.used_pages)) * 8) / 1024.00, 2) AS NUMERIC(36, 2)) AS UnusedSpaceMB
|
CAST(ROUND(((SUM(a.total_pages) - SUM(a.used_pages)) * 8) / 1024.00, 2) AS NUMERIC(36, 2)) AS UnusedSpaceMB
|
||||||
FROM
|
FROM
|
||||||
sys.tables t
|
sys.tables t -- Base table metadata
|
||||||
INNER JOIN
|
INNER JOIN
|
||||||
sys.indexes i ON t.object_id = i.object_id
|
sys.indexes i ON t.object_id = i.object_id -- All indexes on each table
|
||||||
INNER JOIN
|
INNER JOIN
|
||||||
sys.partitions p ON i.object_id = p.object_id AND i.index_id = p.index_id
|
sys.partitions p ON i.object_id = p.object_id AND i.index_id = p.index_id -- Partition information and row counts
|
||||||
INNER JOIN
|
INNER JOIN
|
||||||
sys.allocation_units a ON p.partition_id = a.container_id
|
sys.allocation_units a ON p.partition_id = a.container_id -- Physical storage allocation details
|
||||||
LEFT OUTER JOIN
|
LEFT OUTER JOIN
|
||||||
sys.schemas s ON t.schema_id = s.schema_id
|
sys.schemas s ON t.schema_id = s.schema_id -- Schema information (LEFT JOIN for safety)
|
||||||
WHERE
|
WHERE
|
||||||
t.name NOT LIKE 'dt%'
|
t.name NOT LIKE 'dt%' -- Exclude diagnostic/development tables (common naming pattern)
|
||||||
AND t.is_ms_shipped = 0
|
AND t.is_ms_shipped = 0 -- Exclude Microsoft system tables
|
||||||
AND i.object_id > 255
|
AND i.object_id > 255 -- Exclude system objects (object_id <= 255 are system objects)
|
||||||
GROUP BY
|
GROUP BY
|
||||||
t.name, s.name, p.rows
|
t.name, s.name, p.rows -- Group by table, schema, and row count for aggregation
|
||||||
ORDER BY
|
ORDER BY
|
||||||
TotalSpaceMB DESC, t.name
|
TotalSpaceMB DESC, -- Order by largest tables first
|
||||||
|
t.name -- Secondary sort by table name for consistency
|
||||||
@@ -1,32 +1,158 @@
|
|||||||
-- Query to list all objects in a database with their types
|
/*
|
||||||
|
================================================================================
|
||||||
|
SQL SERVER DATABASE OBJECT INVENTORY AND CATALOG ANALYSIS
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
DESCRIPTION:
|
||||||
|
Comprehensive inventory of all user-created database objects within a SQL Server database.
|
||||||
|
This script provides a complete catalog of database objects with type classification,
|
||||||
|
helping database administrators and developers understand the database structure and
|
||||||
|
identify objects for maintenance, documentation, or migration activities.
|
||||||
|
|
||||||
|
FEATURES:
|
||||||
|
• Complete database object inventory with schema context
|
||||||
|
• Human-readable object type descriptions
|
||||||
|
• Modification date tracking for change management
|
||||||
|
• Focused on user-created objects (excludes system objects)
|
||||||
|
• Filtered results to show primary development objects
|
||||||
|
• Organized output by schema, type, and name for easy navigation
|
||||||
|
|
||||||
|
OUTPUT COLUMNS:
|
||||||
|
- ObjectName: Fully qualified object name (Schema.ObjectName format)
|
||||||
|
- ObjectType: Human-readable description of the object type
|
||||||
|
- ModifiedDate: Last modification timestamp for change tracking
|
||||||
|
|
||||||
|
OBJECT TYPES INCLUDED:
|
||||||
|
• User Tables: Data storage tables created by users
|
||||||
|
• Views: Virtual tables and data abstractions
|
||||||
|
• Stored Procedures: Executable SQL code blocks
|
||||||
|
• Scalar Functions: Functions returning single values
|
||||||
|
• Inline Table Functions: Functions returning table results inline
|
||||||
|
• Table Functions: Functions returning table structures
|
||||||
|
• Extended Stored Procedures: System-level executable procedures
|
||||||
|
• CLR Objects: .NET Common Language Runtime integrated objects
|
||||||
|
- CLR Stored Procedures
|
||||||
|
- CLR Scalar Functions
|
||||||
|
- CLR Table Functions
|
||||||
|
- CLR Aggregate Functions
|
||||||
|
|
||||||
|
OBJECT TYPES EXCLUDED:
|
||||||
|
• Triggers (TR): Database triggers
|
||||||
|
• Primary Keys (PK): Primary key constraints
|
||||||
|
• Foreign Keys (F): Foreign key constraints
|
||||||
|
• Check Constraints (C): Data validation constraints
|
||||||
|
• Default Constraints (D): Default value constraints
|
||||||
|
• Unique Constraints (UQ): Unique value constraints
|
||||||
|
• System Tables (S): SQL Server system tables
|
||||||
|
• Service Queues (SQ): Service Broker queues
|
||||||
|
• Internal Tables (IT): SQL Server internal structures
|
||||||
|
|
||||||
|
USAGE SCENARIOS:
|
||||||
|
• Database documentation and inventory management
|
||||||
|
• Migration planning and object dependency analysis
|
||||||
|
• Code review and development lifecycle management
|
||||||
|
• Security auditing and permission analysis
|
||||||
|
• Database schema comparison and synchronization
|
||||||
|
• Object naming convention compliance checking
|
||||||
|
• Development team onboarding and knowledge transfer
|
||||||
|
|
||||||
|
TECHNICAL IMPLEMENTATION:
|
||||||
|
• Queries sys.objects catalog view for object metadata
|
||||||
|
• Joins with sys.schemas for complete naming context
|
||||||
|
• Uses CASE statement for user-friendly type descriptions
|
||||||
|
• Filters system objects using is_ms_shipped flag
|
||||||
|
• Additional filtering to focus on primary development objects
|
||||||
|
• Results ordered for logical grouping and easy scanning
|
||||||
|
|
||||||
|
DATA SOURCES:
|
||||||
|
• sys.objects: Core object metadata and properties
|
||||||
|
• sys.schemas: Schema ownership and organization information
|
||||||
|
|
||||||
|
FILTERS APPLIED:
|
||||||
|
• is_ms_shipped = 0: Excludes Microsoft system objects
|
||||||
|
• Object type filtering: Excludes constraints, triggers, and internal objects
|
||||||
|
• Focus on tables, views, procedures, and functions
|
||||||
|
|
||||||
|
PERFORMANCE CONSIDERATIONS:
|
||||||
|
• Efficient query using system catalog views
|
||||||
|
• Minimal performance impact (metadata-only query)
|
||||||
|
• Results typically cached by SQL Server
|
||||||
|
• Suitable for regular inventory and monitoring
|
||||||
|
|
||||||
|
PERMISSIONS REQUIRED:
|
||||||
|
• VIEW DEFINITION permission on target database
|
||||||
|
• Access to system catalog views (standard user access)
|
||||||
|
• Membership in db_datareader role (recommended)
|
||||||
|
|
||||||
|
COMPATIBILITY:
|
||||||
|
• SQL Server 2005 and later versions
|
||||||
|
• Azure SQL Database compatible
|
||||||
|
• SQL Server on Linux compatible
|
||||||
|
• Works with all SQL Server editions
|
||||||
|
|
||||||
|
CUSTOMIZATION OPTIONS:
|
||||||
|
• Modify object type filters to include/exclude specific types
|
||||||
|
• Add additional object properties (create_date, principal_id, etc.)
|
||||||
|
• Filter by specific schemas or naming patterns
|
||||||
|
• Add object size or usage statistics
|
||||||
|
|
||||||
|
AUTHOR: Cloud Engineering Team
|
||||||
|
CREATED: Database administration and development toolkit
|
||||||
|
UPDATED: Enhanced with comprehensive object type mapping and documentation
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- Main query: Database object inventory with type classification and metadata
|
||||||
SELECT
|
SELECT
|
||||||
s.name + '.' + o.name AS ObjectName,
|
s.name + '.' + o.name AS ObjectName, -- Fully qualified name (Schema.Object)
|
||||||
CASE o.type
|
CASE o.type -- Convert system type codes to readable descriptions
|
||||||
WHEN 'U' THEN 'User Table'
|
-- Primary database objects (included in results)
|
||||||
WHEN 'V' THEN 'View'
|
WHEN 'U' THEN 'User Table' -- User-defined tables
|
||||||
WHEN 'P' THEN 'Stored Procedure'
|
WHEN 'V' THEN 'View' -- Views and indexed views
|
||||||
WHEN 'FN' THEN 'Scalar Function'
|
WHEN 'P' THEN 'Stored Procedure' -- T-SQL stored procedures
|
||||||
WHEN 'IF' THEN 'Inline Table Function'
|
WHEN 'FN' THEN 'Scalar Function' -- Scalar user-defined functions
|
||||||
WHEN 'TF' THEN 'Table Function'
|
WHEN 'IF' THEN 'Inline Table Function' -- Inline table-valued functions
|
||||||
WHEN 'TR' THEN 'Trigger'
|
WHEN 'TF' THEN 'Table Function' -- Multi-statement table-valued functions
|
||||||
WHEN 'PK' THEN 'Primary Key'
|
WHEN 'X' THEN 'Extended Stored Procedure' -- System extended procedures
|
||||||
WHEN 'F' THEN 'Foreign Key'
|
|
||||||
WHEN 'C' THEN 'Check Constraint'
|
-- CLR (Common Language Runtime) objects
|
||||||
WHEN 'D' THEN 'Default Constraint'
|
WHEN 'PC' THEN 'CLR Stored Procedure' -- .NET CLR stored procedures
|
||||||
WHEN 'UQ' THEN 'Unique Constraint'
|
WHEN 'FS' THEN 'CLR Scalar Function' -- .NET CLR scalar functions
|
||||||
WHEN 'S' THEN 'System Table'
|
WHEN 'FT' THEN 'CLR Table Function' -- .NET CLR table-valued functions
|
||||||
WHEN 'SQ' THEN 'Service Queue'
|
WHEN 'AF' THEN 'CLR Aggregate Function' -- .NET CLR aggregate functions
|
||||||
WHEN 'IT' THEN 'Internal Table'
|
|
||||||
WHEN 'X' THEN 'Extended Stored Procedure'
|
-- Constraint and system objects (filtered out but documented)
|
||||||
WHEN 'PC' THEN 'CLR Stored Procedure'
|
WHEN 'TR' THEN 'Trigger' -- Database triggers
|
||||||
WHEN 'FS' THEN 'CLR Scalar Function'
|
WHEN 'PK' THEN 'Primary Key' -- Primary key constraints
|
||||||
WHEN 'FT' THEN 'CLR Table Function'
|
WHEN 'F' THEN 'Foreign Key' -- Foreign key constraints
|
||||||
WHEN 'AF' THEN 'CLR Aggregate Function'
|
WHEN 'C' THEN 'Check Constraint' -- Check constraints
|
||||||
ELSE 'Other'
|
WHEN 'D' THEN 'Default Constraint' -- Default constraints
|
||||||
|
WHEN 'UQ' THEN 'Unique Constraint' -- Unique constraints
|
||||||
|
WHEN 'S' THEN 'System Table' -- System tables
|
||||||
|
WHEN 'SQ' THEN 'Service Queue' -- Service Broker queues
|
||||||
|
WHEN 'IT' THEN 'Internal Table' -- Internal system tables
|
||||||
|
|
||||||
|
ELSE 'Other' -- Catch-all for unrecognized types
|
||||||
END AS ObjectType,
|
END AS ObjectType,
|
||||||
o.modify_date AS ModifiedDate
|
o.modify_date AS ModifiedDate -- Last modification timestamp
|
||||||
FROM sys.objects o
|
FROM sys.objects o -- Core object metadata catalog
|
||||||
INNER JOIN sys.schemas s ON o.schema_id = s.schema_id
|
INNER JOIN sys.schemas s ON o.schema_id = s.schema_id -- Schema information for full naming context
|
||||||
WHERE o.is_ms_shipped = 0 -- Exclude system objects
|
WHERE
|
||||||
and not (o.type in ('TR','PK','F','C','D','UQ','S','SQ','IT','',''))
|
o.is_ms_shipped = 0 -- Exclude Microsoft system objects
|
||||||
ORDER BY s.name, ObjectType, o.name;
|
AND NOT (o.type IN ( -- Filter out constraint and system object types
|
||||||
|
'TR', -- Triggers
|
||||||
|
'PK', -- Primary Keys
|
||||||
|
'F', -- Foreign Keys
|
||||||
|
'C', -- Check Constraints
|
||||||
|
'D', -- Default Constraints
|
||||||
|
'UQ', -- Unique Constraints
|
||||||
|
'S', -- System Tables
|
||||||
|
'SQ', -- Service Queues
|
||||||
|
'IT', -- Internal Tables
|
||||||
|
'' -- Empty/null types
|
||||||
|
))
|
||||||
|
ORDER BY
|
||||||
|
s.name, -- Primary sort: Schema name
|
||||||
|
ObjectType, -- Secondary sort: Object type for grouping
|
||||||
|
o.name -- Tertiary sort: Object name alphabetically
|
||||||
Reference in New Issue
Block a user