modules/Invoke-AzGovViz.ps1
|
#Requires -Version 7.0 <# .SYNOPSIS Wrapper for AzGovViz (Azure Governance Visualizer). .DESCRIPTION Runs AzGovVizParallel.ps1 for a management group and returns a summary PSObject. If AzGovViz is not installed/found, writes a warning and returns empty result. Never throws. .PARAMETER ManagementGroupId Management group ID to analyze. .PARAMETER OutputPath Directory for AzGovViz output. Defaults to .\output\azgovviz. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $ManagementGroupId, [string] $OutputPath = (Join-Path (Get-Location) 'output' 'azgovviz') ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $sanitizePath = Join-Path $PSScriptRoot 'shared' 'Sanitize.ps1' if (Test-Path $sanitizePath) { . $sanitizePath } $retryPath = Join-Path $PSScriptRoot 'shared' 'Retry.ps1' if (Test-Path $retryPath) { . $retryPath } $installerPath = Join-Path $PSScriptRoot 'shared' 'Installer.ps1' if (Test-Path $installerPath) { . $installerPath } $missingToolPath = Join-Path $PSScriptRoot 'shared' 'MissingTool.ps1' if (Test-Path $missingToolPath) { . $missingToolPath } $errorsPath = Join-Path $PSScriptRoot 'shared' 'Errors.ps1' if (Test-Path $errorsPath) { . $errorsPath } $envelopePath = Join-Path $PSScriptRoot 'shared' 'New-WrapperEnvelope.ps1' if (Test-Path $envelopePath) { . $envelopePath } if (-not (Get-Command New-WrapperEnvelope -ErrorAction SilentlyContinue)) { function New-WrapperEnvelope { param([string]$Source,[string]$Status='Failed',[string]$Message='',[object[]]$FindingErrors=@()) return [PSCustomObject]@{ Source=$Source; SchemaVersion='1.0'; Status=$Status; Message=$Message; Findings=@(); Errors=@($FindingErrors) } } } if (-not (Get-Command Write-MissingToolNotice -ErrorAction SilentlyContinue)) { function Write-MissingToolNotice { param([string]$Tool, [string]$Message) Write-Warning $Message } } if (-not (Get-Command Remove-Credentials -ErrorAction SilentlyContinue)) { function Remove-Credentials { param([string]$Text) return $Text } } if (-not (Get-Command New-FindingError -ErrorAction SilentlyContinue)) { function New-FindingError { param([string]$Source,[string]$Category,[string]$Reason,[string]$Remediation,[string]$Details) return [pscustomobject]@{ Source=$Source; Category=$Category; Reason=$Reason; Remediation=$Remediation; Details=$Details } } } if (-not (Get-Command Format-FindingErrorMessage -ErrorAction SilentlyContinue)) { function Format-FindingErrorMessage { param([Parameter(Mandatory)]$FindingError) $line = "[{0}] {1}: {2}" -f $FindingError.Source, $FindingError.Category, $FindingError.Reason if ($FindingError.Remediation) { $line += " Action: $($FindingError.Remediation)" } return $line } } if (-not (Get-Command Invoke-WithRetry -ErrorAction SilentlyContinue)) { function Invoke-WithRetry { param ([Parameter(Mandatory)][scriptblock]$ScriptBlock) return & $ScriptBlock } } if (-not (Get-Command Invoke-WithTimeout -ErrorAction SilentlyContinue)) { function Invoke-WithTimeout { param ( [Parameter(Mandatory)][string]$Command, [Parameter(Mandatory)][string[]]$Arguments, [int]$TimeoutSec = 300 ) $output = & $Command @Arguments 2>&1 | Out-String return [PSCustomObject]@{ ExitCode = $LASTEXITCODE; Output = $output.Trim() } } } function Find-AzGovViz { $candidates = [System.Collections.Generic.List[string]]::new() $candidates.Add((Join-Path (Get-Location) 'AzGovVizParallel.ps1')) $candidates.Add((Join-Path (Get-Location) 'tools' 'AzGovViz' 'AzGovVizParallel.ps1')) $candidates.Add((Join-Path (Split-Path $PSScriptRoot -Parent) 'tools' 'AzGovViz' 'AzGovVizParallel.ps1')) if ($env:USERPROFILE) { $candidates.Add((Join-Path $env:USERPROFILE 'AzGovViz' 'AzGovVizParallel.ps1')) } if ($env:HOME) { $candidates.Add((Join-Path $env:HOME 'AzGovViz' 'AzGovVizParallel.ps1')) } foreach ($c in $candidates) { if (Test-Path $c) { return $c } } return $null } function Get-RowValue { param ( [Parameter(Mandatory)] [psobject] $Row, [Parameter(Mandatory)] [string[]] $Names ) foreach ($name in $Names) { $prop = $Row.PSObject.Properties[$name] if ($null -eq $prop) { continue } $value = [string]$prop.Value if (-not [string]::IsNullOrWhiteSpace($value)) { return $value } } return '' } function ConvertTo-BooleanValue { param ([string]$Value) if ([string]::IsNullOrWhiteSpace($Value)) { return $false } switch -Regex ($Value.Trim().ToLowerInvariant()) { '^(true|1|yes|y)$' { return $true } default { return $false } } } function Get-PolicyEffectSeverity { param ([string]$Effect) switch -Regex (($Effect ?? '').Trim().ToLowerInvariant()) { '^deny$' { return 'High' } '^audit$' { return 'Medium' } '^auditifnotexists$' { return 'Low' } default { return 'Medium' } } } function ConvertTo-DelimitedList { param ([string]$Value) if ([string]::IsNullOrWhiteSpace($Value)) { return @() } return @( $Value -split '[,;|]' | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } ) } function ConvertTo-UniqueStringArray { param ([object[]]$Items) $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $values = [System.Collections.Generic.List[string]]::new() foreach ($item in @($Items)) { if ($null -eq $item) { continue } $text = [string]$item if ([string]::IsNullOrWhiteSpace($text)) { continue } $trimmed = $text.Trim() if ($seen.Add($trimmed)) { $values.Add($trimmed) | Out-Null } } return @($values) } function Get-AzGovVizPillar { param ( [string]$Category, [string]$Title ) $normalizedCategory = ($Category ?? '').Trim().ToLowerInvariant() $normalizedTitle = ($Title ?? '').Trim().ToLowerInvariant() if ($normalizedCategory -match '^(policy|identity)$') { return 'Security' } if ($normalizedCategory -match '^(cost|costoptimization|finops)$') { return 'Cost' } if ($normalizedTitle -match 'orphaned') { return 'Cost' } return 'Operational Excellence' } function ConvertTo-BaselineTag { param ([string]$Name) if ([string]::IsNullOrWhiteSpace($Name)) { return '' } $slug = ($Name.ToLowerInvariant() -replace '[^a-z0-9]+', '-').Trim('-') if ([string]::IsNullOrWhiteSpace($slug)) { return '' } return "initiative:$slug" } function Get-AzGovVizFrameworks { param ([psobject]$FindingLike) if ($FindingLike.PSObject.Properties['Frameworks'] -and @($FindingLike.Frameworks).Count -gt 0) { $normalized = [System.Collections.Generic.List[hashtable]]::new() foreach ($framework in @($FindingLike.Frameworks)) { if ($null -eq $framework) { continue } $name = '' $controls = @() if ($framework -is [System.Collections.IDictionary]) { $name = [string]($framework['Name'] ?? $framework['name'] ?? '') $controls = @($framework['Controls'] ?? $framework['controls'] ?? @()) } else { $nameProp = $framework.PSObject.Properties['Name'] if ($null -eq $nameProp) { $nameProp = $framework.PSObject.Properties['name'] } $controlsProp = $framework.PSObject.Properties['Controls'] if ($null -eq $controlsProp) { $controlsProp = $framework.PSObject.Properties['controls'] } $name = if ($null -ne $nameProp) { [string]$nameProp.Value } else { '' } $controls = if ($null -ne $controlsProp) { @($controlsProp.Value) } else { @() } } if ([string]::IsNullOrWhiteSpace($name)) { continue } if ($name -ieq 'MCSB') { $name = 'CAF' } $normalized.Add(@{ Name = $name Controls = @(ConvertTo-UniqueStringArray -Items $controls) }) | Out-Null } return @($normalized) } $policySetId = Get-RowValue -Row $FindingLike -Names @('PolicySetDefinitionId', 'PolicySetId', 'policySetId', 'policySetDefinitionId') $mcsbRaw = Get-RowValue -Row $FindingLike -Names @('MCSBControls', 'McsbControls', 'MCSBControlIds', 'ControlIds') $mcsbControls = ConvertTo-DelimitedList -Value $mcsbRaw $frameworks = [System.Collections.Generic.List[hashtable]]::new() if ($policySetId) { $frameworks.Add(@{ Name = 'ALZ' Controls = @($policySetId) }) } if (@($mcsbControls).Count -gt 0) { $frameworks.Add(@{ Name = 'CAF' Controls = @($mcsbControls) }) } return @($frameworks) } function Get-AzGovVizBaselineTags { param ([psobject]$FindingLike) $tags = [System.Collections.Generic.List[string]]::new() if ($FindingLike.PSObject.Properties['BaselineTags'] -and @($FindingLike.BaselineTags).Count -gt 0) { foreach ($existingTag in @($FindingLike.BaselineTags)) { if (-not [string]::IsNullOrWhiteSpace([string]$existingTag)) { $tags.Add(([string]$existingTag).Trim()) | Out-Null } } } $initiativeName = Get-RowValue -Row $FindingLike -Names @( 'PolicySetDefinitionName', 'PolicyInitiativeName', 'PolicyAssignmentName', 'InitiativeName' ) foreach ($name in (ConvertTo-DelimitedList -Value $initiativeName)) { $tag = ConvertTo-BaselineTag -Name $name if ($tag) { $tags.Add($tag) } } $category = Get-RowValue -Row $FindingLike -Names @('Category', 'category') if (-not [string]::IsNullOrWhiteSpace($category)) { $categoryTag = ConvertTo-BaselineTag -Name "category-$category" if ($categoryTag) { $tags.Add($categoryTag) | Out-Null } } return @(ConvertTo-UniqueStringArray -Items @($tags)) } function Get-AzGovVizImpact { param ( [string]$Severity, [string]$Category ) $severityKey = ($Severity ?? '').Trim().ToLowerInvariant() switch ($severityKey) { 'critical' { return 'High' } 'high' { return 'High' } 'medium' { return 'Medium' } 'low' { return 'Low' } 'info' { return 'Low' } } $categoryKey = ($Category ?? '').Trim().ToLowerInvariant() if ($categoryKey -match '^(policy|identity)$') { return 'High' } if ($categoryKey -match '^(cost|costoptimization|finops)$') { return 'Medium' } return 'Medium' } function Get-AzGovVizEffort { param ([string]$Category) $categoryKey = ($Category ?? '').Trim().ToLowerInvariant() if ($categoryKey -eq 'operations') { return 'Medium' } if ($categoryKey -eq 'identity') { return 'High' } if ($categoryKey -eq 'policy') { return 'Medium' } if ($categoryKey -match '^(cost|costoptimization|finops)$') { return 'Low' } return 'Low' } function Get-AzGovVizRemediationSnippets { param ( [string]$Remediation, [string]$Category ) $content = $Remediation if ([string]::IsNullOrWhiteSpace($content)) { $content = switch -Regex (($Category ?? '').Trim().ToLowerInvariant()) { '^policy$' { 'Review policy assignment, initiative scope, and non-compliant resources in AzGovViz output and apply corrective policy actions.' } '^identity$' { 'Review privileged role assignments and reduce standing access using least privilege.' } '^operations$' { 'Enable required diagnostics settings and route telemetry to an approved destination.' } '^cost|costoptimization|finops$' { 'Review cost optimization opportunities and remove or right-size orphaned assets.' } default { 'Review the finding in AzGovViz output and apply the recommended governance control.' } } } return @( @{ language = 'text' code = $content.Trim() } ) } function Get-AzGovVizDeepLink { param ( [string]$Category, [string]$ResourceId, [string]$Scope, [string]$ManagementGroupId, [string]$ManagementGroupResourceId, [string]$PolicySetId, [string]$ReportUri ) $normalizedCategory = ($Category ?? '').Trim().ToLowerInvariant() if ($normalizedCategory -eq 'policy') { if (-not [string]::IsNullOrWhiteSpace($PolicySetId) -and $PolicySetId -match '/policySetDefinitions/([^/\?]+)') { $initiativeName = $Matches[1] return "https://www.azadvertizer.net/azpolicyinitiativesadvertizer/$initiativeName.html" } if (-not [string]::IsNullOrWhiteSpace($ReportUri)) { return "$ReportUri#policy" } $effectiveScope = if ($Scope) { $Scope } elseif ($ResourceId) { $ResourceId } elseif ($ManagementGroupResourceId) { $ManagementGroupResourceId } elseif ($ManagementGroupId) { "/providers/Microsoft.Management/managementGroups/$ManagementGroupId" } else { '' } if ($effectiveScope) { return "https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Compliance?scope=$([uri]::EscapeDataString($effectiveScope))" } return 'https://github.com/JulianHayward/Azure-MG-Sub-Governance-Reporting' } if ($ResourceId) { return "https://portal.azure.com/#@/resource$($ResourceId)/overview" } if ($ManagementGroupResourceId) { return "https://portal.azure.com/#@/resource$($ManagementGroupResourceId)/overview" } if ($ManagementGroupId) { return "https://portal.azure.com/#@/resource/providers/Microsoft.Management/managementGroups/$ManagementGroupId/overview" } if (-not [string]::IsNullOrWhiteSpace($ReportUri)) { return "$ReportUri#governance" } return '' } function Get-AzGovVizEvidenceUris { param ( [string]$ReportUri, [string]$Category ) if ([string]::IsNullOrWhiteSpace($ReportUri)) { return @() } $anchor = switch -Regex (($Category ?? '').Trim().ToLowerInvariant()) { '^policy$' { '#policy' } '^identity$' { '#rbac' } '^cost|costoptimization|finops$' { '#cost' } '^operations$' { '#operations' } default { '#governance' } } return @("$ReportUri$anchor") } function Resolve-AzGovVizReportUri { param ([string]$OutputPath) $report = Get-ChildItem -Path $OutputPath -Filter '*.html' -Recurse -ErrorAction SilentlyContinue | Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1 if (-not $report) { return '' } return ([System.Uri]::new($report.FullName)).AbsoluteUri } function Get-AzGovVizToolVersion { param ([string]$ScriptPath) try { $content = Get-Content -Path $ScriptPath -Raw -ErrorAction Stop foreach ($pattern in @( '(?im)^\s*\$script:Version\s*=\s*[''"](?<v>[^''"]+)[''"]', '(?im)^\s*Version\s*[:=]\s*[''"](?<v>[^''"]+)[''"]', '(?im)^\s*#\s*Version[:\s]+(?<v>\d+\.\d+(?:\.\d+)?)' )) { if ($content -match $pattern) { return $Matches['v'] } } } catch { Write-Verbose "Could not infer AzGovViz version from script: $(Remove-Credentials -Text ([string]$_))" } return 'unknown' } function New-AzGovVizFinding { param ( [Parameter(Mandatory)] [object]$FindingLike, [string]$ManagementGroupId, [string]$ToolVersion, [string]$ReportUri ) $props = @{} foreach ($p in $FindingLike.PSObject.Properties) { $props[$p.Name] = $p.Value } $category = if ($props.ContainsKey('Category')) { [string]$props['Category'] } else { 'Governance' } $title = if ($props.ContainsKey('Title')) { [string]$props['Title'] } else { '' } $resourceId = if ($props.ContainsKey('ResourceId')) { [string]$props['ResourceId'] } else { '' } $scope = if ($props.ContainsKey('Scope')) { [string]$props['Scope'] } else { '' } $policySetId = if ($props.ContainsKey('PolicySetId')) { [string]$props['PolicySetId'] } elseif ($props.ContainsKey('PolicySetDefinitionId')) { [string]$props['PolicySetDefinitionId'] } else { '' } $managementGroupResourceId = if ($props.ContainsKey('ManagementGroupResourceId')) { [string]$props['ManagementGroupResourceId'] } elseif ($ManagementGroupId) { "/providers/Microsoft.Management/managementGroups/$ManagementGroupId" } else { '' } $props['Source'] = 'azgovviz' if (-not $props.ContainsKey('SchemaVersion') -or [string]::IsNullOrWhiteSpace([string]$props['SchemaVersion'])) { $props['SchemaVersion'] = '1.0' } if (-not $props.ContainsKey('Category') -or [string]::IsNullOrWhiteSpace([string]$props['Category'])) { $props['Category'] = 'Governance' } if (-not $props.ContainsKey('Pillar') -or [string]::IsNullOrWhiteSpace([string]$props['Pillar'])) { $props['Pillar'] = Get-AzGovVizPillar -Category $category -Title $title } try { if (-not $props.ContainsKey('Frameworks') -or @($props['Frameworks']).Count -eq 0) { $props['Frameworks'] = @(Get-AzGovVizFrameworks -FindingLike ([pscustomobject]$props)) } } catch { $props['Frameworks'] = @() } try { if (-not $props.ContainsKey('BaselineTags') -or @($props['BaselineTags']).Count -eq 0) { $props['BaselineTags'] = @(Get-AzGovVizBaselineTags -FindingLike ([pscustomobject]$props)) } } catch { $props['BaselineTags'] = @() } if (-not $props.ContainsKey('ToolVersion') -or [string]::IsNullOrWhiteSpace([string]$props['ToolVersion'])) { $props['ToolVersion'] = $ToolVersion } if ($ManagementGroupId -and (-not $props.ContainsKey('ManagementGroupId') -or [string]::IsNullOrWhiteSpace([string]$props['ManagementGroupId']))) { $props['ManagementGroupId'] = $ManagementGroupId } if ($managementGroupResourceId -and (-not $props.ContainsKey('ManagementGroupResourceId') -or [string]::IsNullOrWhiteSpace([string]$props['ManagementGroupResourceId']))) { $props['ManagementGroupResourceId'] = $managementGroupResourceId } if (-not $props.ContainsKey('DeepLinkUrl') -or [string]::IsNullOrWhiteSpace([string]$props['DeepLinkUrl'])) { $props['DeepLinkUrl'] = Get-AzGovVizDeepLink -Category $category -ResourceId $resourceId -Scope $scope -ManagementGroupId $ManagementGroupId -ManagementGroupResourceId $managementGroupResourceId -PolicySetId $policySetId -ReportUri $ReportUri } if (-not $props.ContainsKey('EvidenceUris') -or @($props['EvidenceUris']).Count -eq 0) { $props['EvidenceUris'] = @(Get-AzGovVizEvidenceUris -ReportUri $ReportUri -Category $category) } if (-not $props.ContainsKey('Impact') -or [string]::IsNullOrWhiteSpace([string]$props['Impact'])) { $props['Impact'] = Get-AzGovVizImpact -Severity ([string]$props['Severity']) -Category $category } if (-not $props.ContainsKey('Effort') -or [string]::IsNullOrWhiteSpace([string]$props['Effort'])) { $props['Effort'] = Get-AzGovVizEffort -Category $category } if (-not $props.ContainsKey('RemediationSnippets') -or @($props['RemediationSnippets']).Count -eq 0) { $props['RemediationSnippets'] = @(Get-AzGovVizRemediationSnippets -Remediation ([string]($props['Remediation'] ?? '')) -Category $category) } return [pscustomobject]$props } function Import-AzGovVizCsvFindings { [CmdletBinding()] param ( [Parameter(Mandatory)] [string] $OutputPath, [string] $ManagementGroupId, [string] $ToolVersion, [string] $ReportUri ) $findings = [System.Collections.Generic.List[psobject]]::new() $csvFiles = Get-ChildItem -Path $OutputPath -Filter '*.csv' -Recurse -ErrorAction SilentlyContinue foreach ($file in $csvFiles) { $fileName = $file.Name.ToLowerInvariant() try { $rows = Import-Csv -Path $file.FullName -ErrorAction Stop } catch { Write-Warning "Could not parse AzGovViz CSV $($file.Name): $(Remove-Credentials -Text ([string]$_))" continue } if (-not $rows) { continue } switch -Regex ($fileName) { 'policycompliancestates' { foreach ($row in $rows) { $effect = Get-RowValue -Row $row -Names @('PolicyEffect', 'effect') $complianceState = Get-RowValue -Row $row -Names @('ComplianceState', 'complianceState') $policyAssignment = Get-RowValue -Row $row -Names @('PolicyAssignmentName', 'policyAssignmentName', 'PolicyAssignment') $policySetId = Get-RowValue -Row $row -Names @('PolicySetDefinitionId', 'PolicySetId', 'policySetId') $policyInitiativeName = Get-RowValue -Row $row -Names @('PolicySetDefinitionName', 'PolicyInitiativeName', 'initiativeName') $mcsbControls = Get-RowValue -Row $row -Names @('MCSBControls', 'McsbControls', 'MCSBControlIds') $resourceId = Get-RowValue -Row $row -Names @('ResourceId', 'resourceId', 'ResourceID', 'Scope', 'scope') $scope = Get-RowValue -Row $row -Names @('Scope', 'scope') $stateText = if ($complianceState) { $complianceState } else { 'Unknown' } $isCompliant = ($complianceState -eq 'Compliant') if ($isCompliant) { continue } $titleSuffix = if ($policyAssignment) { ": $policyAssignment" } else { '' } $findings.Add((New-AzGovVizFinding -FindingLike ([pscustomobject]@{ Source = 'azgovviz' Category = 'Policy' Title = "Policy compliance state$titleSuffix" Compliant = $false Severity = Get-PolicyEffectSeverity -Effect $effect Detail = "ComplianceState=$stateText; Effect=$effect; Scope=$scope" ResourceId = $resourceId Scope = $scope PolicySetId = $policySetId PolicySetDefinitionName = $policyInitiativeName MCSBControls = $mcsbControls SchemaVersion = '1.0' }) -ManagementGroupId $ManagementGroupId -ToolVersion $ToolVersion -ReportUri $ReportUri)) } } 'roleassignments' { foreach ($row in $rows) { $principalId = Get-RowValue -Row $row -Names @('RoleAssignmentIdentityObjectId', 'ObjectId', 'PrincipalId', 'principalId', 'AssigneeObjectId') if (-not $principalId) { continue } $roleName = Get-RowValue -Row $row -Names @('RoleDefinitionName', 'RoleName', 'roleDefinitionName') $scope = Get-RowValue -Row $row -Names @('RoleAssignmentScope', 'Scope', 'scope') $scopeType = Get-RowValue -Row $row -Names @('RoleAssignmentScopeType', 'ScopeType', 'scopeType') $principalType = Get-RowValue -Row $row -Names @('RoleAssignmentIdentityObjectType', 'PrincipalType', 'principalType', 'ObjectType') $isPrivilegedRole = $roleName -match '^(Owner|Contributor|User Access Administrator)$' $isBroadScopeType = $scopeType -match '^(tenant|managementgroup|subscription)$' $isBroadScopePath = $scope -match '^/subscriptions/[^/]+$' -or $scope -match '^/providers/microsoft\.management/managementgroups/' $isBroadScope = $isBroadScopeType -or $isBroadScopePath $isCompliant = -not ($isPrivilegedRole -and $isBroadScope) if ($isCompliant) { continue } $severity = 'High' $resourceId = if ($scope) { $scope } else { '' } $findings.Add((New-AzGovVizFinding -FindingLike ([pscustomobject]@{ Source = 'azgovviz' Category = 'Identity' Title = "Role assignment: $roleName" Compliant = $false Severity = $severity Detail = "PrincipalType=$principalType; Scope=$scope; ScopeType=$scopeType" ResourceId = $resourceId PrincipalId = $principalId PrincipalType = $principalType Scope = $scope SchemaVersion = '1.0' }) -ManagementGroupId $ManagementGroupId -ToolVersion $ToolVersion -ReportUri $ReportUri)) } } 'resourcediagnosticscapabilit' { foreach ($row in $rows) { $resourceId = Get-RowValue -Row $row -Names @('ResourceId', 'resourceId', 'ResourceID') if (-not $resourceId) { continue } $capable = ConvertTo-BooleanValue (Get-RowValue -Row $row -Names @('DiagnosticsCapable', 'diagnosticsCapable')) $configured = ConvertTo-BooleanValue (Get-RowValue -Row $row -Names @('DiagnosticsConfigured', 'diagnosticsConfigured')) if (-not $capable) { continue } if ($configured) { continue } $findings.Add((New-AzGovVizFinding -FindingLike ([pscustomobject]@{ Source = 'azgovviz' Category = 'Operations' Title = 'Resource diagnostics settings not configured' Compliant = $false Severity = 'Medium' Detail = "DiagnosticsCapable=$capable; DiagnosticsConfigured=$configured" ResourceId = $resourceId Remediation = 'Enable diagnostic settings to route logs and metrics to an approved destination.' SchemaVersion = '1.0' }) -ManagementGroupId $ManagementGroupId -ToolVersion $ToolVersion -ReportUri $ReportUri)) } } 'resourceswithouttags' { foreach ($row in $rows) { $resourceId = Get-RowValue -Row $row -Names @('ResourceId', 'resourceId', 'ResourceID') if (-not $resourceId) { continue } $missingTags = Get-RowValue -Row $row -Names @('MissingTags', 'missingTags', 'TagNames') $findings.Add((New-AzGovVizFinding -FindingLike ([pscustomobject]@{ Source = 'azgovviz' Category = 'Governance' Title = 'Resource missing required tags' Compliant = $false Severity = 'Low' Detail = if ($missingTags) { "MissingTags=$missingTags" } else { 'Missing one or more required tags.' } ResourceId = $resourceId Remediation = 'Apply required governance tags according to your tagging policy.' SchemaVersion = '1.0' }) -ManagementGroupId $ManagementGroupId -ToolVersion $ToolVersion -ReportUri $ReportUri)) } } 'orphanedresources' { foreach ($row in $rows) { $resourceId = Get-RowValue -Row $row -Names @('ResourceId', 'resourceId', 'ResourceID') if (-not $resourceId) { continue } $monthlyCost = Get-RowValue -Row $row -Names @('EstimatedMonthlyCost', 'MonthlyCost', 'Cost') $costText = if ($monthlyCost) { "EstimatedMonthlyCost=$monthlyCost" } else { 'Potential cost waste from orphaned resource.' } $findings.Add((New-AzGovVizFinding -FindingLike ([pscustomobject]@{ Source = 'azgovviz' Category = 'Cost' Title = 'Orphaned resource detected' Compliant = $false Severity = 'Medium' Detail = $costText ResourceId = $resourceId Remediation = 'Remove the orphaned resource or attach it to an active workload.' SchemaVersion = '1.0' }) -ManagementGroupId $ManagementGroupId -ToolVersion $ToolVersion -ReportUri $ReportUri)) } } default { Write-Verbose "Skipping unsupported AzGovViz CSV file: $($file.Name)" } } } return @($findings) } $azGovVizScript = Find-AzGovViz if (-not $azGovVizScript) { Write-MissingToolNotice -Tool 'azgovviz' -Message "AzGovViz (AzGovVizParallel.ps1) not found. Skipping. Clone from https://github.com/JulianHayward/Azure-MG-Sub-Governance-Reporting" return [PSCustomObject]@{ Source = 'azgovviz' Status = 'Skipped' Message = 'AzGovVizParallel.ps1 not found' Findings = @() Errors = @() } } if (-not (Test-Path $OutputPath)) { $null = New-Item -ItemType Directory -Path $OutputPath -Force } try { Write-Verbose "Running AzGovViz for management group: $ManagementGroupId" $toolVersion = Get-AzGovVizToolVersion -ScriptPath $azGovVizScript $runAzGovViz = { $result = Invoke-WithTimeout -Command 'pwsh' -Arguments @( '-File', $azGovVizScript, '-ManagementGroupId', $ManagementGroupId, '-OutputPath', $OutputPath, '-AzureDevOpsWikiAsCode', 'False', '-HierarchyTreeOnly', 'False' ) -TimeoutSec 300 if ($result.ExitCode -ne 0) { throw (Format-FindingErrorMessage (New-FindingError ` -Source 'wrapper:azgovviz' ` -Category 'UnexpectedFailure' ` -Reason "AzGovViz exited with code $($result.ExitCode)." ` -Remediation 'Review AzGovViz CLI output and ensure required modules are installed; rerun.' ` -Details (Remove-Credentials -Text ([string]$result.Output)))) } } Invoke-WithRetry -ScriptBlock $runAzGovViz -MaxAttempts 3 -InitialDelaySeconds 2 -MaxDelaySeconds 10 | Out-Null $reportUri = Resolve-AzGovVizReportUri -OutputPath $OutputPath $summaryFiles = Get-ChildItem -Path $OutputPath -Filter '*Summary*.json' -Recurse -ErrorAction SilentlyContinue $findings = [System.Collections.Generic.List[psobject]]::new() foreach ($file in $summaryFiles) { try { $data = Get-Content -Raw $file.FullName | ConvertFrom-Json -ErrorAction Stop if ($data -is [System.Array]) { foreach ($entry in $data) { $findings.Add((New-AzGovVizFinding -FindingLike $entry -ManagementGroupId $ManagementGroupId -ToolVersion $toolVersion -ReportUri $reportUri)) } } else { $findings.Add((New-AzGovVizFinding -FindingLike $data -ManagementGroupId $ManagementGroupId -ToolVersion $toolVersion -ReportUri $reportUri)) } } catch { Write-Warning "Could not parse AzGovViz output $($file.Name): $(Remove-Credentials -Text ([string]$_))" } } foreach ($csvFinding in (Import-AzGovVizCsvFindings -OutputPath $OutputPath -ManagementGroupId $ManagementGroupId -ToolVersion $toolVersion -ReportUri $reportUri)) { $findings.Add($csvFinding) } return [PSCustomObject]@{ Source = 'azgovviz' Status = 'Success' Message = '' Findings = @($findings) Errors = @() } } catch { Write-Warning "AzGovViz run failed: $(Remove-Credentials -Text ([string]$_))" return [PSCustomObject]@{ Source = 'azgovviz' Status = 'Failed' Message = Remove-Credentials -Text ([string]$_) Findings = @() Errors = @() } } |