CloudSecurity/Get-CloudComplianceStatus.ps1
|
function ConvertTo-CloudSecureScoreNumber { [CmdletBinding()] [OutputType([double])] param( [AllowNull()][object]$Value, [Parameter(Mandatory = $true)][string]$FieldName ) if ($null -eq $Value -or $Value -is [bool]) { throw [System.FormatException]::new("$FieldName must be a finite, non-negative number.") } $allowedTypes = @( 'System.Byte', 'System.SByte', 'System.Int16', 'System.UInt16', 'System.Int32', 'System.UInt32', 'System.Int64', 'System.UInt64', 'System.Single', 'System.Double', 'System.Decimal', 'System.String' ) if ($allowedTypes -notcontains $Value.GetType().FullName) { throw [System.FormatException]::new("$FieldName must be a finite, non-negative number.") } $parsed = 0.0 $text = [string]$Value if ([string]::IsNullOrWhiteSpace($text) -or -not [double]::TryParse($text, [Globalization.NumberStyles]::Float, [Globalization.CultureInfo]::InvariantCulture, [ref]$parsed) -or [double]::IsNaN($parsed) -or [double]::IsInfinity($parsed) -or $parsed -lt 0.0) { throw [System.FormatException]::new("$FieldName must be a finite, non-negative number.") } return [double]$parsed } function Get-CloudComplianceStatus { <# .SYNOPSIS Retrieves cloud compliance status and live Azure security telemetry .DESCRIPTION Reports cloud environment compliance against various frameworks: CIS, ISO27001, NIST, GDPR, HIPAA, SOC2, AzureSecurityBenchmark. When Microsoft Graph is connected, retrieves the latest Secure Score through Microsoft Graph. When an Azure subscription is available, also queries the latest Azure Policy states and summarizes compliance by policy assignment. These aggregate measurements are additive and are never treated as proof that an individual framework control is compliant. Catalog controls without a control-specific query remain NotEvaluated. ComplianceScore covers evaluated catalog controls only and stays $null when no catalog control was evaluated. EvaluatedCount instead reports how many independent live data queries produced usable results. .PARAMETER Framework Compliance framework (Default: CIS) .PARAMETER IncludeRemediation Include remediation steps .PARAMETER SubscriptionId Azure subscription for Policy Insights. If omitted, uses the current Az context subscription when one is available. .PARAMETER ExportPath Path to save compliance report .PARAMETER Format Report format: HTML, JSON, CSV, or All (Default: HTML) .EXAMPLE Get-CloudComplianceStatus -Framework CIS .EXAMPLE Get-CloudComplianceStatus -Framework CIS -SubscriptionId '00000000-0000-0000-0000-000000000000' .OUTPUTS PSCustomObject with Framework, Controls, Summary, ComplianceScore, SecureScore, SecureScoreMax, PolicyCompliant, PolicyNonCompliant, EvaluatedCount, and Source. ComplianceScore is $null when no catalog controls were evaluated. #> [CmdletBinding()] param( [ValidateSet('CIS', 'ISO27001', 'NIST', 'GDPR', 'HIPAA', 'SOC2', 'AzureSecurityBenchmark')] [string]$Framework = 'CIS', [switch]$IncludeRemediation, [ValidateNotNullOrEmpty()] [string]$SubscriptionId, [string]$ExportPath, [ValidateSet('HTML', 'JSON', 'CSV', 'All')] [string]$Format = 'HTML' ) begin { Write-Host "========================================" -ForegroundColor Cyan Write-Host " CLOUD COMPLIANCE ASSESSMENT" -ForegroundColor Cyan Write-Host "========================================" -ForegroundColor Cyan Write-Host "Framework: $Framework" -ForegroundColor Yellow $graphConnected = $false $graphContextCommand = Get-Command -Name 'Get-MgContext' -ErrorAction SilentlyContinue if ($null -ne $graphContextCommand) { try { $graphContext = Get-MgContext -ErrorAction Stop if ($null -ne $graphContext) { $graphConnected = $true } } catch { Write-Verbose "Get-MgContext failed: $($_.Exception.Message)" } } $graphConnectInstruction = "Connect-MgGraph -Scopes 'SecurityEvents.Read.All'" $policyAuthorizationInstruction = "Azure Policy requires Azure Resource Manager scope https://management.azure.com/user_impersonation and Reader or Microsoft.PolicyInsights/policyStates/read on the subscription. Install Az.Accounts, then run Connect-AzAccount." if (-not $graphConnected) { Write-Warning "Not connected to Microsoft Graph. Secure Score will be NotEvaluated. Run $graphConnectInstruction. Azure Policy discovery and queries will continue independently." } # Initialize results. Null numeric fields mean unknown, never zero. $results = [PSCustomObject]@{ AssessmentDate = Get-Date -Format "yyyy-MM-dd HH:mm:ss" Framework = $Framework FrameworkVersion = "Latest" Controls = @() ComplianceScore = $null SecureScore = $null SecureScoreMax = $null SecureScorePercentage = $null SecureScoreStatus = 'NotEvaluated' SecureScoreNote = 'Not evaluated.' PolicyCompliant = $null PolicyNonCompliant = $null PolicyOther = $null PolicyAssignments = @() PolicyComplianceStatus = 'NotEvaluated' PolicyComplianceNote = 'Not evaluated.' PolicyTransport = $null SubscriptionId = $SubscriptionId EvaluatedCount = 0 Source = 'Graph' Summary = @{ TotalControls = 0 Evaluated = 0 NotEvaluated = 0 Compliant = 0 NonCompliant = 0 PartiallyCompliant = 0 } } } process { try { Write-Host "`nLoading $Framework control catalog..." -ForegroundColor Cyan $catalog = @() # Catalog of framework control references. These entries identify the # controls only; their compliance status is NOT evaluated (see below). switch ($Framework) { 'CIS' { $catalog = @( @{ ControlId = "1.1.1" Title = "Ensure multifactor authentication is enabled" Category = "Identity" Severity = "Critical" Remediation = "Enable MFA via Conditional Access" }, @{ ControlId = "1.1.2" Title = "Ensure global admins are limited" Category = "Identity" Severity = "High" Remediation = "N/A" } ) } 'GDPR' { $catalog = @( @{ ControlId = "Art. 32" Title = "Security of processing" Category = "Technical Measures" Severity = "Critical" Remediation = "N/A" } ) } 'NIST' { $catalog = @( @{ ControlId = "ID.AM-1" Title = "Physical devices inventoried" Category = "Identify" Severity = "Medium" Remediation = "N/A" } ) } default { $catalog = @() } } if (@($catalog).Count -eq 0) { Write-Warning "No control catalog is defined for framework '$Framework'." } if ($graphConnected) { Write-Host "`nRetrieving Microsoft Secure Score..." -ForegroundColor Cyan try { $secureScoreCommand = Get-Command -Name 'Get-MgSecuritySecureScore' -ErrorAction SilentlyContinue $graphRequestCommand = Get-Command -Name 'Invoke-MgGraphRequest' -ErrorAction SilentlyContinue if ($null -ne $secureScoreCommand) { $scoreResponse = Get-MgSecuritySecureScore -Top 1 -ErrorAction Stop } elseif ($null -ne $graphRequestCommand) { $scoreResponse = Invoke-MgGraphRequest -Method GET -Uri '/beta/security/secureScores?$top=1' -ErrorAction Stop } else { throw 'Neither Get-MgSecuritySecureScore nor Invoke-MgGraphRequest is available. Install the Microsoft.Graph.Security or Microsoft.Graph.Authentication module.' } $scoreValueProperty = Get-CloudDataProperty -InputObject $scoreResponse -Name 'value' if ($scoreValueProperty.Exists) { $secureScores = @($scoreValueProperty.Value) } elseif ($null -ne $scoreResponse) { $secureScores = @($scoreResponse) } else { $secureScores = @() } $latestScore = @($secureScores | Where-Object { $null -ne $_ } | Select-Object -First 1) if ($latestScore.Count -eq 0) { throw 'Microsoft Graph returned no Secure Score records.' } $currentScoreProperty = Get-CloudDataProperty -InputObject $latestScore[0] -Name 'currentScore' $maxScoreProperty = Get-CloudDataProperty -InputObject $latestScore[0] -Name 'maxScore' if (-not $currentScoreProperty.Exists -or $null -eq $currentScoreProperty.Value -or -not $maxScoreProperty.Exists -or $null -eq $maxScoreProperty.Value) { throw 'The Secure Score record did not contain numeric currentScore and maxScore fields.' } $currentScore = ConvertTo-CloudSecureScoreNumber -Value $currentScoreProperty.Value -FieldName 'currentScore' $maxScore = ConvertTo-CloudSecureScoreNumber -Value $maxScoreProperty.Value -FieldName 'maxScore' if ($maxScore -le 0.0) { throw 'The Secure Score maxScore must be greater than zero.' } if ($currentScore -gt $maxScore) { throw 'The Secure Score currentScore cannot exceed maxScore.' } $secureScorePercentage = [Math]::Round(($currentScore / $maxScore) * 100, 1) $results.SecureScore = $currentScore $results.SecureScoreMax = $maxScore $results.SecureScorePercentage = $secureScorePercentage $results.SecureScoreStatus = 'Evaluated' $results.SecureScoreNote = 'Latest Microsoft Graph secureScore record requested with top 1 (REST equivalent: GET /beta/security/secureScores?$top=1). This is a Microsoft Secure Score, not a framework compliance percentage.' $results.EvaluatedCount++ } catch { $results.SecureScore = $null $results.SecureScoreMax = $null $results.SecureScorePercentage = $null $results.SecureScoreStatus = 'NotEvaluated' $results.SecureScoreNote = "Not evaluated: Secure Score query failed: $($_.Exception.Message). Requires Microsoft Graph scope SecurityEvents.Read.All; run $graphConnectInstruction." Write-Warning $results.SecureScoreNote } } Write-Host "`nRetrieving Azure Policy compliance..." -ForegroundColor Cyan $effectiveSubscriptionId = $SubscriptionId $subscriptionDiscoveryError = $null if ([string]::IsNullOrWhiteSpace($effectiveSubscriptionId)) { $azContextCommand = Get-Command -Name 'Get-AzContext' -ErrorAction SilentlyContinue if ($null -ne $azContextCommand) { try { $azContext = Get-AzContext -ErrorAction Stop $azSubscriptionProperty = Get-CloudDataProperty -InputObject $azContext -Name 'Subscription' if ($azSubscriptionProperty.Exists) { $azSubscriptionIdProperty = Get-CloudDataProperty -InputObject $azSubscriptionProperty.Value -Name 'Id' if ($azSubscriptionIdProperty.Exists -and $null -ne $azSubscriptionIdProperty.Value) { $effectiveSubscriptionId = [string]$azSubscriptionIdProperty.Value } } } catch { $subscriptionDiscoveryError = $_.Exception.Message } } } $results.SubscriptionId = $effectiveSubscriptionId if ([string]::IsNullOrWhiteSpace($effectiveSubscriptionId)) { $results.PolicyComplianceNote = "Not evaluated: no Azure subscription was supplied or selected. Pass -SubscriptionId or run Connect-AzAccount followed by Select-AzSubscription. $policyAuthorizationInstruction" if ($subscriptionDiscoveryError) { $results.PolicyComplianceNote += " Azure context discovery failed: $subscriptionDiscoveryError" } Write-Warning $results.PolicyComplianceNote } else { try { $policyResult = Invoke-CloudPolicyInsightsQuery -SubscriptionId $effectiveSubscriptionId $results.PolicyTransport = $policyResult.Transport $results.PolicyCompliant = 0 $results.PolicyNonCompliant = 0 $results.PolicyOther = 0 $assignmentSummaries = @{} foreach ($policyState in @($policyResult.States)) { if ($null -eq $policyState) { continue } $assignmentProperty = Get-CloudDataProperty -InputObject $policyState -Name 'policyAssignmentId' $assignmentId = [string]$assignmentProperty.Value if ([string]::IsNullOrWhiteSpace($assignmentId)) { $assignmentId = '(unknown policy assignment)' } if (-not $assignmentSummaries.ContainsKey($assignmentId)) { $assignmentSummaries[$assignmentId] = [ordered]@{ PolicyAssignmentId = $assignmentId Compliant = 0 NonCompliant = 0 Other = 0 } } $complianceProperty = Get-CloudDataProperty -InputObject $policyState -Name 'complianceState' $complianceState = [string]$complianceProperty.Value if ($complianceState.Equals('Compliant', [System.StringComparison]::OrdinalIgnoreCase)) { $results.PolicyCompliant++ $assignmentSummaries[$assignmentId].Compliant++ } elseif ($complianceState.Equals('NonCompliant', [System.StringComparison]::OrdinalIgnoreCase)) { $results.PolicyNonCompliant++ $assignmentSummaries[$assignmentId].NonCompliant++ } else { $results.PolicyOther++ $assignmentSummaries[$assignmentId].Other++ } } $results.PolicyAssignments = @( $assignmentSummaries.Values | Sort-Object -Property PolicyAssignmentId | ForEach-Object { [PSCustomObject]$_ } ) $policyStateCount = @($policyResult.States).Count $results.PolicyComplianceStatus = 'Evaluated' $results.PolicyComplianceNote = "Evaluated $policyStateCount latest policy-state record(s) in subscription $effectiveSubscriptionId using $($policyResult.Transport). Counts are grouped by policyAssignmentId and are not framework-control results." if ($results.PolicyOther -gt 0) { $results.PolicyComplianceNote += " $($results.PolicyOther) record(s) had Exempt, Unknown, missing, or other compliance states and were counted separately." } $results.EvaluatedCount++ } catch { $results.PolicyCompliant = $null $results.PolicyNonCompliant = $null $results.PolicyOther = $null $results.PolicyAssignments = @() $results.PolicyComplianceStatus = 'NotEvaluated' $results.PolicyComplianceNote = "Not evaluated: Azure Policy Insights query failed: $($_.Exception.Message). $policyAuthorizationInstruction Invoke-MgGraphRequest is a Microsoft Graph transport and cannot be assumed to authorize management.azure.com." Write-Warning $results.PolicyComplianceNote } } if (-not $graphConnected) { $notConnectedReason = "Not evaluated: no Microsoft Graph context. Run $graphConnectInstruction." $results.SecureScoreNote = $notConnectedReason } if (@($catalog).Count -gt 0) { Write-Warning "Aggregate Secure Score and Azure Policy results do not establish compliance for individual $Framework controls. Catalog controls without a control-specific query remain NotEvaluated and are excluded from ComplianceScore." } foreach ($control in $catalog) { if (-not $graphConnected) { $notEvaluatedNote = "Not evaluated: no Microsoft Graph context. Run $graphConnectInstruction." } else { switch ($control.ControlId) { '1.1.1' { $notEvaluatedNote = "Not evaluated: aggregate Secure Score and Azure Policy counts cannot prove MFA coverage for CIS 1.1.1. Use the org-wide MFA result from Test-M365SecurityPosture with AuditLog.Read.All and perform the framework mapping separately." } '1.1.2' { $notEvaluatedNote = "Not evaluated: CIS 1.1.2 requires correlation of active and eligible directory role assignments through /beta/roleManagement/directory/roleAssignments and roleEligibilitySchedules; no control-specific query is mapped here." } 'Art. 32' { $notEvaluatedNote = "Not evaluated: GDPR Article 32 requires evidence across identity, encryption, resilience, restoration, and testing. Aggregate Secure Score and Azure Policy counts are insufficient evidence." } 'ID.AM-1' { $notEvaluatedNote = "Not evaluated: NIST ID.AM-1 requires device inventory evidence, for example Azure Resource Graph /providers/Microsoft.ResourceGraph/resources correlated with device inventory. No control-specific inventory query is mapped here." } default { $notEvaluatedNote = "Not evaluated: no control-specific API query and compliance mapping are implemented for this catalog entry." } } } $results.Controls += [PSCustomObject]@{ ControlId = $control.ControlId Title = $control.Title Category = $control.Category Status = "NotEvaluated" Severity = $control.Severity Note = $notEvaluatedNote Remediation = if ($IncludeRemediation) { $control.Remediation } else { $null } } $results.Summary.TotalControls++ $results.Summary.NotEvaluated++ } # ComplianceScore covers catalog controls, not the additive live queries. $evaluatedControlCount = $results.Summary.Compliant + $results.Summary.NonCompliant + $results.Summary.PartiallyCompliant $results.Summary.Evaluated = $evaluatedControlCount if ($evaluatedControlCount -gt 0) { $compliantCount = $results.Summary.Compliant + ($results.Summary.PartiallyCompliant * 0.5) $results.ComplianceScore = [Math]::Round(($compliantCount / $evaluatedControlCount) * 100, 1) } # Export if requested if ($ExportPath) { Write-Host "`nExporting reports..." -ForegroundColor Cyan if (-not (Test-Path $ExportPath)) { New-Item -Path $ExportPath -ItemType Directory -Force | Out-Null } $timestamp = Get-Date -Format "yyyyMMdd_HHmmss" if ($Format -in @('JSON', 'All')) { $jsonPath = Join-Path $ExportPath "Compliance_${Framework}_$timestamp.json" $results | ConvertTo-Json -Depth 10 | Out-File $jsonPath -Encoding UTF8 Write-Host "Exported JSON: $jsonPath" -ForegroundColor Green } if ($Format -in @('CSV', 'All')) { $csvPath = Join-Path $ExportPath "Compliance_${Framework}_$timestamp.csv" $results.Controls | ConvertTo-CsvSafeObject | Export-Csv $csvPath -NoTypeInformation Write-Host "Exported CSV: $csvPath" -ForegroundColor Green } if ($Format -in @('HTML', 'All')) { $htmlPath = Join-Path $ExportPath "Compliance_${Framework}_$timestamp.html" $htmlContent = Generate-CloudComplianceHTML -Results $results $htmlContent | Out-File $htmlPath -Encoding UTF8 Write-Host "Exported HTML: $htmlPath" -ForegroundColor Green } } } catch { Write-Error "Error during assessment: $_" throw } } end { Write-Host "`n========================================" -ForegroundColor Green Write-Host " COMPLIANCE ASSESSMENT COMPLETE" -ForegroundColor Green Write-Host "========================================" -ForegroundColor Green if ($null -ne $results.ComplianceScore) { $scoreColor = if ($results.ComplianceScore -ge 80) { 'Green' } elseif ($results.ComplianceScore -ge 60) { 'Yellow' } else { 'Red' } Write-Host "`nCompliance Score: $($results.ComplianceScore)%" -ForegroundColor $scoreColor } else { Write-Host "`nCompliance Score: N/A (no controls were evaluated)" -ForegroundColor Yellow } Write-Host "Framework: $($results.Framework)" -ForegroundColor Cyan Write-Host "Total Controls: $($results.Summary.TotalControls)" -ForegroundColor White Write-Host "Evaluated Controls: $($results.Summary.Evaluated)" -ForegroundColor White Write-Host "Not Evaluated Controls: $($results.Summary.NotEvaluated)" -ForegroundColor Yellow Write-Host "Compliant: $($results.Summary.Compliant)" -ForegroundColor Green Write-Host "Non-Compliant: $($results.Summary.NonCompliant)" -ForegroundColor Red Write-Host "Live Queries Evaluated: $($results.EvaluatedCount)" -ForegroundColor White if ($results.SecureScoreStatus -eq 'Evaluated') { Write-Host "Secure Score: $($results.SecureScore) / $($results.SecureScoreMax)" -ForegroundColor Cyan } else { Write-Host "Secure Score: NotEvaluated" -ForegroundColor Yellow } if ($results.PolicyComplianceStatus -eq 'Evaluated') { Write-Host "Azure Policy States: $($results.PolicyCompliant) compliant, $($results.PolicyNonCompliant) non-compliant, $($results.PolicyOther) other" -ForegroundColor Cyan } else { Write-Host "Azure Policy States: NotEvaluated" -ForegroundColor Yellow } return $results } } function Generate-CloudComplianceHTML { param($Results) $hasScore = ($null -ne $Results) -and ($null -ne $Results.ComplianceScore) if ($hasScore) { $scoreColor = if ($Results.ComplianceScore -ge 80) { '#107c10' } elseif ($Results.ComplianceScore -ge 60) { '#f7630c' } else { '#d13438' } $scoreDisplay = "$($Results.ComplianceScore)%" } else { $scoreColor = '#605e5c' $scoreDisplay = "N/A" } $html = @' <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>Cloud Compliance Status Report</title> <style> body { font-family: Arial, sans-serif; margin: 20px; background: #f5f5f5; } .container { max-width: 1200px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); } h1 { color: #0078d4; border-bottom: 3px solid #0078d4; padding-bottom: 10px; } h2 { color: #106ebe; margin-top: 30px; } table { width: 100%; border-collapse: collapse; margin: 20px 0; } th { background: #0078d4; color: white; padding: 12px; text-align: left; } td { padding: 10px; border-bottom: 1px solid #ddd; } tr:hover { background: #f8f9fa; } .compliant { color: #107c10; font-weight: bold; } .non-compliant { color: #d13438; font-weight: bold; } .partial { color: #f7630c; font-weight: bold; } .not-evaluated { color: #605e5c; font-weight: bold; } .score-box { text-align: center; padding: 30px; margin: 20px 0; border-radius: 10px; color: white; } .score-value { font-size: 48px; font-weight: bold; } </style> </head> <body> <div class="container"> <h1>Cloud Compliance Status Report</h1> '@ $html += @" <div class="score-box" style="background: $scoreColor;"> <div class="score-value">$scoreDisplay</div> <div>Compliance Score</div> </div> <h2>Framework Information</h2> <p><strong>Framework:</strong> $($Results.Framework)</p> <p><strong>Version:</strong> $($Results.FrameworkVersion)</p> <p><strong>Assessment Date:</strong> $($Results.AssessmentDate)</p> <h2>Live Azure Security Telemetry</h2> <p><strong>Source:</strong> $($Results.Source); <strong>Live queries evaluated:</strong> $($Results.EvaluatedCount)</p> <p><strong>Secure Score:</strong> $(if ($Results.SecureScoreStatus -eq 'Evaluated') { "$($Results.SecureScore) / $($Results.SecureScoreMax)" } else { 'N/A' })</p> <p>$(ConvertTo-HtmlEncoded $Results.SecureScoreNote)</p> <p><strong>Azure Policy states:</strong> $(if ($Results.PolicyComplianceStatus -eq 'Evaluated') { "$($Results.PolicyCompliant) compliant, $($Results.PolicyNonCompliant) non-compliant, $($Results.PolicyOther) other" } else { 'N/A' })</p> <p>$(ConvertTo-HtmlEncoded $Results.PolicyComplianceNote)</p> <h2>Control Summary</h2> <p><strong>Total Controls:</strong> $($Results.Summary.TotalControls)</p> <p><strong>Evaluated:</strong> $($Results.Summary.Evaluated)</p> <p><strong>Not Evaluated:</strong> $($Results.Summary.NotEvaluated)</p> <p><strong>Compliant:</strong> $($Results.Summary.Compliant)</p> <p><strong>Non-Compliant:</strong> $($Results.Summary.NonCompliant)</p> <h2>Compliance Controls</h2> <table> <thead> <tr> <th>Control ID</th> <th>Title</th> <th>Category</th> <th>Severity</th> <th>Status</th> </tr> </thead> <tbody> "@ foreach ($control in $Results.Controls) { $statusClass = switch ($control.Status) { 'Compliant' { 'compliant' } 'Non-Compliant' { 'non-compliant' } 'Partially Compliant' { 'partial' } default { 'not-evaluated' } } $html += @" <tr> <td>$($control.ControlId)</td> <td>$($control.Title)</td> <td>$($control.Category)</td> <td>$($control.Severity)</td> <td class="$statusClass">$($control.Status)</td> </tr> "@ } $html += @' </tbody> </table> <div style="margin-top: 40px; padding-top: 20px; border-top: 2px solid #ddd; text-align: center; color: #605e5c; font-size: 12px;"> <p>Generated by WindowsSecurityAudit Module</p> </div> </div> </body> </html> '@ return $html } |