tests/Test-Assessment.50001.ps1
|
<#
.SYNOPSIS Combines all Microsoft Defender for Cloud Secure Score recommendations and MCSB compliance assessments into a unified view. Shows every recommendation from either source as a unique test result. .DESCRIPTION This test runs two Azure Resource Graph queries: 1. Secure Score recommendations — per-resource state (Healthy/Unhealthy/NotApplicable), secure score control name, description, severity, and remediation. 2. MCSB compliance assessments (full) — per-resource × per-MCSB-control rows with control names, resourceState, and all display fields. Recommendations are merged by `recommendationName` (assessment GUID), forming a Venn diagram: LEFT ONLY (secure score only): emitted with secure score data; no MCSB columns. RIGHT ONLY (MCSB only): emitted with MCSB data; MCSB control columns always present. BOTH (intersection): secure score resources and state are the primary truth; each resource row is enriched with MCSB control(s) where mapped. Category format: - Both: "<secure score control> - <MCSB domain(s)>" - Secure score only: secure score control name - MCSB only: MCSB domain name(s) Pass/fail logic: - Secure-score-backed results (left-only + both): all NotApplicable → Skipped; any Unhealthy → Failed; all Healthy → Passed. - MCSB-only results (right-only): all notapplicable → Skipped; any unhealthy → Failed; all healthy → Passed. (resourceState field, lowercase.) Both queries are non-fatal: if one query fails, results from the other are still emitted. Only if both return empty are no results emitted. .NOTES Test ID: 50001 Category: Microsoft Defender for Cloud Required API: Azure Resource Graph - SecurityResources (microsoft.security/securescores/securescorecontrols + microsoft.security/assessments + microsoft.security/regulatorycompliancestandards/.../regulatorycomplianceassessments) #> function Test-Assessment-50001 { [ZtTest( Category = 'Microsoft Defender for Cloud', MinimumLicense = ('N/A'), Pillar = 'Infrastructure', RiskLevel = 'High', Service = ('Azure'), SfiPillar = 'Protect tenants and isolate production systems', TenantType = ('Workforce'), TestId = 50001, Title = 'Microsoft Defender for Cloud Recommendations' )] [CmdletBinding()] param() #region Helper Functions function Get-NormalizedRisk { param([string] $Severity) switch ($Severity) { 'Critical' { 'Critical' } # New classification added by Microsoft on March 2025 'High' { 'High' } 'Medium' { 'Medium' } 'Low' { 'Low' } default { 'Unranked' } # Treat unhandled severities as Unranked } } function Get-ColumnFlags { param($Rows) $showResourceGroup = $false; $showResourceType = $false; $showResource = $false foreach ($r in $Rows) { if (-not $showResourceGroup -and -not [string]::IsNullOrWhiteSpace($r.resourceGroup)) { $showResourceGroup = $true } if (-not $showResourceType -and -not [string]::IsNullOrWhiteSpace($r.resourceType)) { $showResourceType = $true } if (-not $showResource -and -not [string]::IsNullOrWhiteSpace($r.resourceName)) { $showResource = $true } if ($showResourceGroup -and $showResourceType -and $showResource) { break } } [pscustomobject]@{ ResourceGroup = $showResourceGroup; ResourceType = $showResourceType; Resource = $showResource } } function Get-RowLinks { param($Row) $subLink = "https://portal.azure.com/#resource/subscriptions/$($Row.subscriptionId)" $subText = if (-not [string]::IsNullOrWhiteSpace($Row.subscriptionName)) { Get-SafeMarkdown $Row.subscriptionName } else { $Row.subscriptionId } $resText = if (-not [string]::IsNullOrWhiteSpace($Row.resourceName)) { Get-SafeMarkdown $Row.resourceName } else { $Row.resourceId } $portalLink = $Row.azurePortalRecommendationLink [pscustomobject]@{ SubMd = "[$subText]($subLink)" ResMd = "[$resText](https://portal.azure.com/#resource$($Row.resourceId))" PortalLinkMd = if (-not [string]::IsNullOrWhiteSpace($portalLink)) { "[View recommendation]($portalLink)" } else { '' } } } function Get-McsbCells { param( [hashtable] $McsbForThisRec, [string] $ResourceId ) $key = if ($ResourceId) { $ResourceId.ToLowerInvariant() } else { '' } if ($McsbForThisRec.ContainsKey($key)) { $items = $McsbForThisRec[$key] return [pscustomobject]@{ Control = ($items | Select-Object -ExpandProperty Control | Sort-Object -Unique) -join ', ' ControlName = ($items | Select-Object -ExpandProperty ControlName | Where-Object { $_ } | Sort-Object -Unique) -join ', ' } } return [pscustomobject]@{ Control = ''; ControlName = '' } } #endregion Helper Functions #region Data Collection Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose $activity = 'Checking Microsoft Defender for Cloud Recommendations' Write-ZtProgress -Activity $activity -Status 'Checking Azure connection' $azContext = Get-AzContext -ErrorAction SilentlyContinue if (-not $azContext) { Write-PSFMessage 'Not connected to Azure.' -Level Warning Add-ZtTestResultDetail -SkippedBecause NotConnectedAzure return } # KQL 1: Secure Score recommendations (per-resource state + secure score control name) $secureScoreQuery = @' securityresources | where type == "microsoft.security/securescores/securescorecontrols" | extend secureScoreName = (extract("/providers/Microsoft.Security/secureScores/([^/]*)/", 1, id)) | where secureScoreName == "ascScore" | extend environment = (tostring(properties.environment)) | extend scope = (extract("(.*)/providers/Microsoft.Security/secureScores/", 1, id)) | where (environment == "AWS" or environment == "Azure" or environment == "AzureDevOps" or environment == "DockerHub" or environment == "GCP" or environment == "GitHub" or environment == "GitLab" or environment == "JFrog") | extend controlDisplayName = (tostring(properties.displayName)) | extend controlName = (name) | extend assessmentKeys = (extract_all("\"id\":\".*?/assessmentMetadata/([^\"]*)\"", tostring(properties.definition.properties.assessmentDefinitions))) | extend notApplicableResourceCount = (toint(properties.notApplicableResourceCount)) | extend unhealthyResourceCount = (toint(properties.unhealthyResourceCount)) | extend healthyResourceCount = (toint(properties.healthyResourceCount)) | extend controlMaxScore = (toint(properties.score.max)) | extend currentScore = (todouble(properties.score.current)) | join kind=leftouter ( securityresources | where type == "microsoft.security/securescores" | where name == "ascScore" | extend environment = (tostring(properties.environment)) | extend scopeMaxScore = (toint(properties.score.max)) | extend scopeWeight = (toint(properties.weight)) | parse id with scope "/providers/Microsoft.Security/secureScores" * | where (environment == "AWS" or environment == "Azure" or environment == "AzureDevOps" or environment == "DockerHub" or environment == "GCP" or environment == "GitHub" or environment == "GitLab" or environment == "JFrog") | project scope, scopeWeight, scopeMaxScore, joinOn = 1 | join kind=leftouter ( securityresources | where type == "microsoft.security/securescores" | where name == "ascScore" | extend environment = (tostring(properties.environment)) | where (environment == "AWS" or environment == "Azure" or environment == "AzureDevOps" or environment == "DockerHub" or environment == "GCP" or environment == "GitHub" or environment == "GitLab" or environment == "JFrog") | extend scopeWeight = (toint(properties.weight)) | project scopeWeight | summarize sumParentScopeWeight = todouble(sum(scopeWeight)), joinOn = 1 ) on joinOn | project-away joinOn, joinOn1 | project sumParentScopeWeight, scope, scopeWeight = todouble(scopeWeight), scopeMaxScore = todouble(scopeMaxScore) ) on scope | extend scopeWeight = iff(controlMaxScore == 0, todouble(0), scopeWeight) | summarize assessmentKeys = any(assessmentKeys), controlDisplayName = any(controlDisplayName), notApplicableResourceCount = sum(notApplicableResourceCount), unhealthyResourceCount = sum(unhealthyResourceCount), healthyResourceCount = sum(healthyResourceCount), controlMaxScore = max(controlMaxScore), sumParentScopeWeight = max(sumParentScopeWeight), impactRatio = sum(iff(scopeMaxScore == 0, todouble(0), scopeWeight / scopeMaxScore)), controlAggregatedCurrentScoreSum = sum(iff(scopeMaxScore == 0, todouble(0), scopeWeight * currentScore / scopeMaxScore)) by controlName | extend controlAggregatedMaxScoreSum = impactRatio * controlMaxScore | extend controlAggregatedCurrentScore = iff(controlAggregatedMaxScoreSum == 0, todouble(0), controlAggregatedCurrentScoreSum / controlAggregatedMaxScoreSum) * controlMaxScore | extend potentialScoreIncrease = iff(sumParentScopeWeight == 0, todouble(0), (controlAggregatedMaxScoreSum - controlAggregatedCurrentScoreSum) / sumParentScopeWeight) * 100 | project controlsAssessmentsData = pack_all(), controlMaxScore | extend assessmentKeys = controlsAssessmentsData.assessmentKeys | extend controlData = pack( "controlDisplayName", controlsAssessmentsData.controlDisplayName, "controlName", controlsAssessmentsData.controlName, "assessmentKeys", controlsAssessmentsData.assessmentKeys, "notApplicableResourceCount", controlsAssessmentsData.notApplicableResourceCount, "unhealthyResourceCount", controlsAssessmentsData.unhealthyResourceCount, "healthyResourceCount", controlsAssessmentsData.healthyResourceCount, "totalResourceCount", toint(controlsAssessmentsData.notApplicableResourceCount) + toint(controlsAssessmentsData.unhealthyResourceCount) + toint(controlsAssessmentsData.healthyResourceCount), "maxScore", controlsAssessmentsData.controlMaxScore, "currentScore", controlsAssessmentsData.controlAggregatedCurrentScore, "potentialScoreIncrease", controlsAssessmentsData.potentialScoreIncrease) | mv-expand assessmentKeys limit 400 | project assessmentKey = tostring(assessmentKeys), controlData | summarize controlsData = make_set(controlData) by assessmentKey | join kind=inner ( securityresources | where type == "microsoft.security/assessments" | extend assessmentDetails = parse_json(properties) | extend resourceDetails = parse_json(assessmentDetails.resourceDetails) | extend fullResourceType = tostring(resourceDetails.ResourceType) | extend resourceType = tostring(split(fullResourceType, '/')[1]) | extend exportedTimestamp = now() | extend recommendationId = id | extend recommendationName = tostring(split(id, '/')[array_length(split(id, '/')) - 1]) | extend azurePortalLink = tostring(assessmentDetails.links.azurePortal) | extend azurePortalRecommendationLink = case( isempty(azurePortalLink), "", azurePortalLink startswith "https://", azurePortalLink, azurePortalLink startswith "http://", azurePortalLink, strcat("https://", azurePortalLink) ) ) on $left.assessmentKey == $right.recommendationName | project exportedTimestamp, subscriptionId, resourceGroup, resourceType, resourceName = tostring(resourceDetails.ResourceName), displayName = tostring(assessmentDetails.displayName), state = tostring(assessmentDetails.status.code), severity = tostring(assessmentDetails.metadata.severity), remediationSteps = tostring(assessmentDetails.metadata.remediationDescription), resourceId = tostring(resourceDetails.ResourceId), recommendationName, controls = controlsData, description = tostring(assessmentDetails.metadata.description), recommendationDisplayName = tostring(assessmentDetails.metadata.displayName), notApplicableReason = tostring(assessmentDetails.status.cause), azurePortalRecommendationLink, recommendationId | join kind=leftouter ( resourcecontainers | where type == "microsoft.resources/subscriptions" | project subscriptionId, subscriptionName = name ) on subscriptionId | project subscriptionId, subscriptionName, resourceGroup, resourceType, resourceName, resourceId, recommendationId, recommendationName, description, recommendationDisplayName, remediationSteps, severity, state, notApplicableReason, controls = coalesce(tostring(parse_json(controls)[0].controlDisplayName), ""), azurePortalRecommendationLink | order by recommendationDisplayName asc '@ # KQL 2: MCSB compliance assessments — full query # Returns all fields needed to emit standalone test results for MCSB-only recommendations. $mcsbQuery = @' securityresources | where type == "microsoft.security/regulatorycompliancestandards/regulatorycompliancecontrols/regulatorycomplianceassessments" | extend scope = properties.scope | where isempty(scope) or scope in~("Subscription", "MultiCloudAggregation") | parse id with * "regulatoryComplianceStandards/" complianceStandardId "/regulatoryComplianceControls/" complianceControlId "/regulatoryComplianceAssessments" * | extend complianceStandardId = replace("-", " ", complianceStandardId) | where complianceStandardId == "Microsoft cloud security benchmark" | extend failedResources = toint(properties.failedResources), passedResources = toint(properties.passedResources), skippedResources = toint(properties.skippedResources) | where failedResources + passedResources + skippedResources > 0 or properties.assessmentType == "MicrosoftManaged" | join kind = leftouter ( securityresources | where type == "microsoft.security/assessments" ) on subscriptionId, name | extend complianceState = tostring(properties.state) | extend resourceSource = tolower(tostring(properties1.resourceDetails.Source)) | extend recommendationId = iff(isnull(id1) or isempty(id1), id, id1) | extend resourceId = trim(' ', tolower(tostring(case( resourceSource =~ "azure", properties1.resourceDetails.Id, resourceSource =~ "gcp", properties1.resourceDetails.GcpResourceId, resourceSource =~ "aws" and isnotempty(tostring(properties1.resourceDetails.ConnectorId)), properties1.resourceDetails.Id, resourceSource =~ "aws", properties1.resourceDetails.AwsResourceId, extract("^(.+)/providers/Microsoft.Security/assessments/.+$", 1, recommendationId) )))) | extend regexResourceId = extract_all(@"/providers/[^/]+(?:/([^/]+)/[^/]+(?:/[^/]+/[^/]+)?)?/([^/]+)/([^/]+)$", resourceId)[0] | extend resourceType = iff( resourceSource =~ "aws" and isnotempty(tostring(properties1.resourceDetails.ConnectorId)), tostring(properties1.additionalData.ResourceType), iff(regexResourceId[1] != "", regexResourceId[1], iff(regexResourceId[0] != "", regexResourceId[0], "subscriptions")) ) | extend resourceName = tostring(regexResourceId[2]) | extend recommendationName = name | extend recommendationDisplayName = tostring(iff(isnull(properties1.displayName) or isempty(properties1.displayName), properties.description, properties1.displayName)) | extend description = tostring(properties1.metadata.description) | extend remediationSteps = tostring(properties1.metadata.remediationDescription) | extend severity = tostring(properties1.metadata.severity) | extend azurePortalLink = tostring(properties1.links.azurePortal) | extend azurePortalRecommendationLink = case( isempty(azurePortalLink), "", azurePortalLink startswith "https://", azurePortalLink, azurePortalLink startswith "http://", azurePortalLink, strcat("https://", azurePortalLink) ) | mvexpand statusPerInitiative = properties1.statusPerInitiative | extend expectedInitiative = statusPerInitiative.policyInitiativeName =~ "ASC Default" | summarize arg_max(toint(expectedInitiative), *) by complianceControlId, recommendationId | extend expectedInitiativeBool = expectedInitiative == 1 | extend state = iff(expectedInitiativeBool, tolower(statusPerInitiative.assessmentStatus.code), tolower(properties1.status.code)) | extend notApplicableReason = iff(expectedInitiativeBool, tostring(statusPerInitiative.assessmentStatus.cause), tostring(properties1.status.cause)) | join kind = leftouter ( securityresources | where type == "microsoft.security/regulatorycompliancestandards/regulatorycompliancecontrols" | parse id with * "regulatoryComplianceStandards/" complianceStandardId "/regulatoryComplianceControls/" * | extend complianceStandardId = replace("-", " ", complianceStandardId) | where complianceStandardId == "Microsoft cloud security benchmark" | where properties.state != "Unsupported" | extend controlName = tostring(properties.description) | project controlId = name, controlName | distinct controlId, controlName ) on $left.complianceControlId == $right.controlId | extend exportedTimestamp = now() | join kind=leftouter ( resourcecontainers | where type == "microsoft.resources/subscriptions" | extend subscriptionId = tostring(split(id, "/")[2]) | project subscriptionId, subscriptionName = name ) on subscriptionId | project exportedTimestamp, complianceStandard = complianceStandardId, complianceControl = complianceControlId, complianceControlName = controlName, recommendationState = complianceState, subscriptionId, subscriptionName, resourceGroup = resourceGroup1, resourceType, resourceName, resourceId, recommendationId, recommendationName, recommendationDisplayName, description, remediationSteps, severity, resourceState = state, notApplicableReason, azurePortalRecommendationLink | order by complianceControl asc, recommendationId asc '@ Write-ZtProgress -Activity $activity -Status 'Querying Azure Resource Graph for secure score recommendations' $secureScoreRecs = @() try { $secureScoreRecs = @(Invoke-ZtAzureResourceGraphRequest -Query $secureScoreQuery) Write-PSFMessage "Secure Score query returned $($secureScoreRecs.Count) records" -Tag Test -Level VeryVerbose } catch { Write-PSFMessage "Secure Score ARG query failed: $($_.Exception.Message)" -Tag Test -Level Warning } Write-ZtProgress -Activity $activity -Status 'Querying Azure Resource Graph for MCSB compliance assessments' $mcsbRecs = @() try { $mcsbRecs = @(Invoke-ZtAzureResourceGraphRequest -Query $mcsbQuery) Write-PSFMessage "MCSB query returned $($mcsbRecs.Count) records" -Tag Test -Level VeryVerbose } catch { Write-PSFMessage "MCSB ARG query failed: $($_.Exception.Message)" -Tag Test -Level Warning } #endregion Data Collection #region Report Generation if ($secureScoreRecs.Count -eq 0 -and $mcsbRecs.Count -eq 0) { Write-PSFMessage 'No recommendations found from either query.' -Tag Test -Level Verbose Add-ZtTestResultDetail -SkippedBecause NotApplicable -Result 'No Microsoft Defender for Cloud assessments found. Ensure Microsoft Defender for Cloud is enabled on your Azure subscriptions.' return } # MCSB domain map (control ID prefix -> human-readable domain name) $mcsbDomainMap = @{ 'NS' = 'Network Security' 'IM' = 'Identity Management' 'PA' = 'Privileged Access' 'DP' = 'Data Protection' 'AM' = 'Asset Management' 'LT' = 'Logging and Threat Detection' 'IR' = 'Incident Response' 'PV' = 'Posture and Vulnerability Management' 'ES' = 'Endpoint Security' 'BR' = 'Backup and Recovery' 'DS' = 'DevOps Security' 'GS' = 'Governance and Strategy' } # Build MCSB enrichment lookups from $mcsbRecs: # $mcsbByRec: recommendationName -> { resourceId.lower -> [{Control, ControlName}, ...] } # $mcsbDomainsByRec: recommendationName -> HashSet[domain names] $mcsbByRec = @{} $mcsbDomainsByRec = @{} foreach ($m in $mcsbRecs) { if ([string]::IsNullOrWhiteSpace($m.recommendationName)) { continue } if (-not $mcsbByRec.ContainsKey($m.recommendationName)) { $mcsbByRec[$m.recommendationName] = @{} $mcsbDomainsByRec[$m.recommendationName] = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) } $resKey = if ($m.resourceId) { $m.resourceId.ToLowerInvariant() } else { '' } if (-not $mcsbByRec[$m.recommendationName].ContainsKey($resKey)) { $mcsbByRec[$m.recommendationName][$resKey] = [System.Collections.Generic.List[object]]::new() } $null = $mcsbByRec[$m.recommendationName][$resKey].Add([PSCustomObject]@{ Control = $m.complianceControl ControlName = $m.complianceControlName }) if (-not [string]::IsNullOrWhiteSpace($m.complianceControl)) { $prefix = ($m.complianceControl -split '\.')[0].ToUpper() $domainName = if ($mcsbDomainMap.ContainsKey($prefix)) { $mcsbDomainMap[$prefix] } else { $m.complianceControl } $null = $mcsbDomainsByRec[$m.recommendationName].Add($domainName) } } # Build per-recommendation group hashes for O(1) lookup $secureScoreGroupHash = $secureScoreRecs | Group-Object -Property recommendationName -AsHashTable -AsString $mcsbGroupHash = $mcsbRecs | Group-Object -Property recommendationName -AsHashTable -AsString if (-not $secureScoreGroupHash) { $secureScoreGroupHash = @{} } if (-not $mcsbGroupHash) { $mcsbGroupHash = @{} } # Build a sorted union of all recommendation names (SecureScore first to get display names, then MCSB-only) $allRecsSorted = @( $secureScoreGroupHash.Keys | ForEach-Object { [PSCustomObject]@{ Name = $_; DisplayName = $secureScoreGroupHash[$_][0].recommendationDisplayName } } $mcsbGroupHash.Keys | Where-Object { -not $secureScoreGroupHash.ContainsKey($_) } | ForEach-Object { [PSCustomObject]@{ Name = $_; DisplayName = $mcsbGroupHash[$_][0].recommendationDisplayName } } ) | Sort-Object DisplayName # Pre-register all recommendations as Running/Queued so the progress dashboard shows # them all immediately. Each flips to Done as its report block completes. foreach ($rec in $allRecsSorted) { Update-ZtProgressState -WorkerId $rec.Name -WorkerName $rec.DisplayName -WorkerStatus 'Running' -WorkerDetail 'Queued' } foreach ($rec in $allRecsSorted) { $recName = $rec.Name $inSS = $secureScoreGroupHash.ContainsKey($recName) # ── SECURE SCORE BACKED (left-only or both intersection) ───────────────────── if ($inSS) { $rows = $secureScoreGroupHash[$recName] $firstRow = $rows[0] $testId = $recName $title = $firstRow.recommendationDisplayName $risk = Get-NormalizedRisk $firstRow.severity # MCSB enrichment lookup — populated when this rec also appears in MCSB $mcsbForThisRec = if ($mcsbByRec.ContainsKey($recName)) { $mcsbByRec[$recName] } else { @{} } $showMcsb = $mcsbForThisRec.Count -gt 0 # Category: "secure score control - MCSB domain(s)" when both exist $secureScoreCategory = $firstRow.controls $mcsbDomainList = $null if ($mcsbDomainsByRec.ContainsKey($recName) -and $mcsbDomainsByRec[$recName].Count -gt 0) { $mcsbDomainList = ($mcsbDomainsByRec[$recName] | Sort-Object) -join ', ' } $category = if (-not [string]::IsNullOrWhiteSpace($secureScoreCategory) -and $mcsbDomainList) { "$secureScoreCategory - $mcsbDomainList" } elseif (-not [string]::IsNullOrWhiteSpace($secureScoreCategory)) { $secureScoreCategory } elseif ($mcsbDomainList) { $mcsbDomainList } else { 'Microsoft Defender for Cloud' } # Description + optional remediation section $remediationSection = if (-not [string]::IsNullOrWhiteSpace($firstRow.remediationSteps)) { "`n**Remediation action**`n`n$($firstRow.remediationSteps)" } else { '' } $descriptionMd = "$($firstRow.description)`n$remediationSection" # State separation (secure score: title-case) $applicableRows = @($rows | Where-Object { $_.state -ne 'NotApplicable' }) $notApplicableRows = @($rows | Where-Object { $_.state -eq 'NotApplicable' }) # Column presence flags $colFlags = Get-ColumnFlags $rows $showResourceGroup = $colFlags.ResourceGroup $showResourceType = $colFlags.ResourceType $showResource = $colFlags.Resource # Dynamic table header $tableHeader = '|' $tableSep = '|' $tableHeader += ' Subscription |'; $tableSep += ' :----------- |' if ($showResourceGroup) { $tableHeader += ' Resource group |'; $tableSep += ' :------------- |' } if ($showResourceType) { $tableHeader += ' Resource type |'; $tableSep += ' :------------ |' } if ($showMcsb) { $tableHeader += ' MCSB control |'; $tableSep += ' :----------- |' $tableHeader += ' MCSB control name |'; $tableSep += ' :---------------- |' } if ($showResource) { $tableHeader += ' Affected resource |'; $tableSep += ' :---------------- |' } $tableHeader += ' Status |'; $tableSep += ' :----- |' $tableHeader += ' Azure portal |'; $tableSep += ' :----------- |' $tableHeaderMd = "$tableHeader`n$tableSep" # All-NotApplicable path if ($applicableRows.Count -eq 0) { $naReasons = ($notApplicableRows | ForEach-Object { $_.notApplicableReason } | Where-Object { $_ } | Select-Object -Unique) -join ', ' $naTableRows = @(foreach ($row in $notApplicableRows | Sort-Object subscriptionName, resourceGroup, resourceName) { $links = Get-RowLinks $row $mcsbCells = Get-McsbCells -McsbForThisRec $mcsbForThisRec -ResourceId $row.resourceId $rowMd = '|' $rowMd += " $($links.SubMd) |" if ($showResourceGroup) { $rowMd += " $($row.resourceGroup) |" } if ($showResourceType) { $rowMd += " $($row.resourceType) |" } if ($showMcsb) { $rowMd += " $($mcsbCells.Control) |"; $rowMd += " $($mcsbCells.ControlName) |" } if ($showResource) { $rowMd += " $($links.ResMd) |" } $rowMd += ' N/A |' $rowMd += " $($links.PortalLinkMd) |" "$rowMd`n" }) -join '' $naResultMd = @" $naReasons $tableHeaderMd $naTableRows "@ $params = @{ TestId = $testId Title = $title Description = $descriptionMd SkippedBecause = 'NotApplicable' Result = $naResultMd Pillar = 'Infrastructure' Category = $category Risk = $risk } Add-ZtTestResultDetail @params Update-ZtProgressState -WorkerId $testId -WorkerName $title -WorkerStatus 'Done' continue } # Pass/fail: any Unhealthy → Failed; otherwise Passed $hasUnhealthy = @($applicableRows | Where-Object { $_.state -eq 'Unhealthy' }).Count -gt 0 $passed = -not $hasUnhealthy # Result table $tableRows = @(foreach ($row in $rows | Sort-Object subscriptionName, resourceGroup, resourceName) { $links = Get-RowLinks $row $stateIcon = switch ($row.state) { 'Healthy' { '✅' } 'Unhealthy' { '❌' } default { 'N/A' } } $mcsbCells = Get-McsbCells -McsbForThisRec $mcsbForThisRec -ResourceId $row.resourceId $rowMd = '|' $rowMd += " $($links.SubMd) |" if ($showResourceGroup) { $rowMd += " $($row.resourceGroup) |" } if ($showResourceType) { $rowMd += " $($row.resourceType) |" } if ($showMcsb) { $rowMd += " $($mcsbCells.Control) |"; $rowMd += " $($mcsbCells.ControlName) |" } if ($showResource) { $rowMd += " $($links.ResMd) |" } $rowMd += " $stateIcon |" $rowMd += " $($links.PortalLinkMd) |" "$rowMd`n" }) -join '' $resultMd = @" $title $tableHeaderMd $tableRows "@ $params = @{ TestId = $testId Title = $title Status = $passed Result = $resultMd Description = $descriptionMd Risk = $risk Pillar = 'Infrastructure' Category = $category } Add-ZtTestResultDetail @params Update-ZtProgressState -WorkerId $testId -WorkerName $title -WorkerStatus 'Done' } # ── MCSB ONLY (right-only) ──────────────────────────────────────────────────── else { $rows = $mcsbGroupHash[$recName] $firstRow = $rows[0] $testId = $recName $title = $firstRow.recommendationDisplayName $risk = Get-NormalizedRisk $firstRow.severity # Category: MCSB domain name(s) $mcsbDomainList = $null if ($mcsbDomainsByRec.ContainsKey($recName) -and $mcsbDomainsByRec[$recName].Count -gt 0) { $mcsbDomainList = ($mcsbDomainsByRec[$recName] | Sort-Object) -join ', ' } $category = if ($mcsbDomainList) { $mcsbDomainList } else { 'Microsoft cloud security benchmark' } # Description + optional remediation section $remediationSection = if (-not [string]::IsNullOrWhiteSpace($firstRow.remediationSteps)) { "`n**Remediation action**`n`n$($firstRow.remediationSteps)" } else { '' } $descriptionMd = "$($firstRow.description)`n$remediationSection" # State separation (MCSB resourceState is lowercase) $applicableRows = @($rows | Where-Object { $_.resourceState -ne 'notapplicable' }) $notApplicableRows = @($rows | Where-Object { $_.resourceState -eq 'notapplicable' }) # Column presence flags $colFlags = Get-ColumnFlags $rows $showResourceGroup = $colFlags.ResourceGroup $showResourceType = $colFlags.ResourceType $showResource = $colFlags.Resource # Dynamic table header — MCSB control columns are always present for MCSB-only recs $tableHeader = '|' $tableSep = '|' $tableHeader += ' Subscription |'; $tableSep += ' :----------- |' if ($showResourceGroup) { $tableHeader += ' Resource group |'; $tableSep += ' :------------- |' } if ($showResourceType) { $tableHeader += ' Resource type |'; $tableSep += ' :------------ |' } $tableHeader += ' MCSB control |'; $tableSep += ' :----------- |' $tableHeader += ' MCSB control name |'; $tableSep += ' :---------------- |' if ($showResource) { $tableHeader += ' Affected resource |'; $tableSep += ' :---------------- |' } $tableHeader += ' Status |'; $tableSep += ' :----- |' $tableHeader += ' Azure portal |'; $tableSep += ' :----------- |' $tableHeaderMd = "$tableHeader`n$tableSep" # All-NotApplicable path if ($applicableRows.Count -eq 0) { $naReasons = ($notApplicableRows | ForEach-Object { $_.notApplicableReason } | Where-Object { $_ } | Select-Object -Unique) -join ', ' $naTableRows = @(foreach ($row in $notApplicableRows | Sort-Object subscriptionName, complianceControl, resourceGroup, resourceName) { $links = Get-RowLinks $row $rowMd = '|' $rowMd += " $($links.SubMd) |" if ($showResourceGroup) { $rowMd += " $($row.resourceGroup) |" } if ($showResourceType) { $rowMd += " $($row.resourceType) |" } $rowMd += " $($row.complianceControl) |" $rowMd += " $($row.complianceControlName) |" if ($showResource) { $rowMd += " $($links.ResMd) |" } $rowMd += ' N/A |' $rowMd += " $($links.PortalLinkMd) |" "$rowMd`n" }) -join '' $naResultMd = @" $naReasons $tableHeaderMd $naTableRows "@ $params = @{ TestId = $testId Title = $title Description = $descriptionMd SkippedBecause = 'NotApplicable' Result = $naResultMd Pillar = 'Infrastructure' Category = $category Risk = $risk } Add-ZtTestResultDetail @params Update-ZtProgressState -WorkerId $testId -WorkerName $title -WorkerStatus 'Done' continue } # Pass/fail: any unhealthy → Failed; otherwise Passed $hasUnhealthy = @($applicableRows | Where-Object { $_.resourceState -eq 'unhealthy' }).Count -gt 0 $passed = -not $hasUnhealthy # Result table $tableRows = @(foreach ($row in $rows | Sort-Object subscriptionName, complianceControl, resourceGroup, resourceName) { $links = Get-RowLinks $row $stateIcon = switch ($row.resourceState) { 'healthy' { '✅' } 'unhealthy' { '❌' } default { 'N/A' } } $rowMd = '|' $rowMd += " $($links.SubMd) |" if ($showResourceGroup) { $rowMd += " $($row.resourceGroup) |" } if ($showResourceType) { $rowMd += " $($row.resourceType) |" } $rowMd += " $($row.complianceControl) |" $rowMd += " $($row.complianceControlName) |" if ($showResource) { $rowMd += " $($links.ResMd) |" } $rowMd += " $stateIcon |" $rowMd += " $($links.PortalLinkMd) |" "$rowMd`n" }) -join '' $resultMd = @" $title $tableHeaderMd $tableRows "@ $params = @{ TestId = $testId Title = $title Status = $passed Result = $resultMd Description = $descriptionMd Risk = $risk Pillar = 'Infrastructure' Category = $category } Add-ZtTestResultDetail @params Update-ZtProgressState -WorkerId $testId -WorkerName $title -WorkerStatus 'Done' } } #endregion Report Generation $bothCount = @($secureScoreGroupHash.Keys | Where-Object { $mcsbGroupHash.ContainsKey($_) }).Count $secureScoreOnlyCount = $secureScoreGroupHash.Count - $bothCount $mcsbOnlyCount = $mcsbGroupHash.Count - $bothCount Write-PSFMessage "Emitted $($allRecsSorted.Count) test results: $secureScoreOnlyCount secure-score-only, $mcsbOnlyCount MCSB-only, $bothCount merged (both)" -Tag Test -Level VeryVerbose } |