Public/Get-AACSecurityPosture.ps1
|
function Get-AACSecurityPosture { <# .EXTERNALHELP Azure.Admin.Console-help.xml .SYNOPSIS Your security posture in one place - Microsoft Defender for Cloud's secure scores, recommendations by control, active alerts, Defender plans and regulatory compliance, and Azure Policy compliance - flattened to one list of findings, with a Spectre.Console view, objects, and CSV, PDF and interactive HTML reports. .DESCRIPTION Reads Defender for Cloud with Azure Resource Graph (securityresources; Reader or Security Reader is enough, no Az modules) - every query at once. -Section picks what to read (all of them by default): Score each subscription's secure score (Defender's own points, added up across subscriptions as Defender does) and the secure score controls, with the potential increase of fixing each Recommendations every unhealthy recommendation on each resource: severity, the control it belongs to, category, description, remediation steps and its portal page Alerts active and in-progress security alerts: severity, intent, resource, how old, and the alert's page Plans which Defender plans are on or off per subscription Compliance each regulatory standard's passed and failed controls, and for a failed control the resources failing the recommendations behind it Policy Azure Policy: each assignment's compliance rate (compliant / compliant + non-compliant resources), and every non-compliant resource with the policy and its effect -ResourceGroupName and -Tag narrow the recommendations, alerts and compliance failures to those resources (tag values are matched exactly, ignoring case); scores, plans and standards are per subscription. -Standard picks compliance standards by name. One row per finding (AAC.SecurityFinding), the same in the objects, CSV, HTML and PDF: Section (Recommendation, Alert, Compliance, Policy or Plan), Severity (none for Policy), Title, Category, Control, Resource, Type, ResourceGroup, SubscriptionName, State, Detail, Link (the Azure portal page), Since and ResourceId - most severe first. The resource tree with each node's score and findings is Get-AACInventory's; this command is the posture itself. What you get depends on where the command runs: at the prompt tiles, the subscriptions (score, findings, alerts, plans), the recommendations grouped by recommendation with every resource, the alerts, compliance by standard with the failed controls, and the plans that are off - a page at a time piped onward the findings, with no view -PassThru the view and the findings -NoDisplay the findings only -CsvPath writes the findings. -HtmlPath writes an interactive report: tiles and charts that filter the tables - findings, subscriptions, secure score controls, compliance standards and controls, Defender plans - each searchable, with portal links and a CSV download. -PdfPath writes the same as a PDF. .PARAMETER SubscriptionId Only these subscriptions. Defaults to every subscription the account can see. .PARAMETER ResourceGroupName Only the recommendations, alerts and compliance failures on resources in these resource groups. .PARAMETER Tag Only the recommendations, alerts and compliance failures on resources with these tags, e.g. @{ Environment = 'Prod' } (all must match). .PARAMETER Section What to read: Score, Recommendations, Alerts, Plans, Compliance, Policy. All of them by default. .PARAMETER Standard Only these regulatory compliance standards (names or wildcards, e.g. 'Microsoft-cloud-security-benchmark' or '*ISO*'). .PARAMETER CsvPath Write every finding to this CSV file. .PARAMETER PdfPath Write a PDF report to this file. .PARAMETER HtmlPath Write an interactive HTML report to this file. .PARAMETER Title The PDF and HTML reports' title. .PARAMETER PassThru Show the view and also return the findings. .PARAMETER NoDisplay Return the findings without showing the view. .PARAMETER NoPaging Show the whole view at once instead of a page at a time. .EXAMPLE Connect-AAC Get-AACSecurityPosture Every subscription's secure score, recommendations, alerts, plans and compliance. .EXAMPLE Get-AACSecurityPosture -Tag @{ Environment = 'Prod' } -HtmlPath .\out\Security.html The production resources' findings as an interactive HTML report. .EXAMPLE Get-AACSecurityPosture -Section Alerts, Plans Only the active alerts and the Defender plans. .EXAMPLE Get-AACSecurityPosture -Section Compliance -Standard '*ISO*' -PdfPath .\out\ISO.pdf ISO 27001 compliance, with the failing resources, as a PDF. .EXAMPLE Get-AACSecurityPosture -Section Policy -NoDisplay | Group-Object Category | Sort-Object Count -Descending The Azure Policy assignments with the most non-compliant resources. .EXAMPLE Get-AACSecurityPosture -NoDisplay | Where-Object { $_.Section -eq 'Recommendation' -and $_.Severity -eq 'High' } | Group-Object Title The High recommendations, and how many resources each is on. .OUTPUTS AAC.SecurityFinding (piped onward, or with -PassThru or -NoDisplay) #> [CmdletBinding()] [OutputType('AAC.SecurityFinding')] param( [ValidatePattern('^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')] [string[]] $SubscriptionId, [ValidateNotNullOrEmpty()] [string[]] $ResourceGroupName, [hashtable] $Tag, [ValidateSet('Score', 'Recommendations', 'Alerts', 'Plans', 'Compliance', 'Policy')] [string[]] $Section = @('Score', 'Recommendations', 'Alerts', 'Plans', 'Compliance', 'Policy'), [SupportsWildcards()] [string[]] $Standard, [string] $CsvPath, [string] $PdfPath, [string] $HtmlPath, [string] $Title = 'Security posture', [switch] $PassThru, [switch] $NoDisplay, [switch] $NoPaging ) # A failure anywhere below ends as a Spectre.Console error panel and this # command's own terminating error, not a line inside the module. A stopped # pipeline (Select-Object -First, Ctrl+C) is no failure: just return - a # rethrow would stop the caller's whole script, not only this command. trap { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { return }; $PSCmdlet.ThrowTerminatingError((Show-AACError -ErrorRecord $_ -Cmdlet $PSCmdlet)) } $pipedOnward = $MyInvocation.PipelinePosition -lt $MyInvocation.PipelineLength $interactive = -not $NoDisplay -and -not $pipedOnward $showView = $interactive -and -not ($CsvPath -or $PdfPath -or $HtmlPath) $returnObjects = $PassThru -or $NoDisplay -or $pipedOnward $resolve = { param([string] $Path) if ($Path) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } } $csvFullPath = & $resolve $CsvPath $pdfFullPath = & $resolve $PdfPath $htmlFullPath = & $resolve $HtmlPath $quote = { param([string] $Text) "'" + ($Text -replace '\\', '\\' -replace "'", "\'") + "'" } # The Defender queries each section needs (Get-AACDefenderQuery, shared with Get-AACInventory). $needs = @{ Score = @('Scores', 'Controls') Recommendations = @('Recommendations', 'ControlAssessments') Alerts = @('Alerts') Plans = @('Plans') Compliance = @('Standards', 'ComplianceControls', 'ComplianceAssessments', 'Recommendations') Policy = @('PolicyStates', 'PolicyAssignments') } # Scores for the subscriptions table, whatever the sections. $names = @(@('Scores') + @($Section | ForEach-Object { $needs[$_] }) | Select-Object -Unique) if ($interactive) { Write-AACRule -Title 'Azure Admin Console :: Security posture' -Color 'deepskyblue3_1' } $state = Invoke-AACProgress -ScriptBlock { $null = Get-AACAccessToken $queries = Get-AACDefenderQuery -Name $names $queries.Insert(0, 'subscriptions', "resourcecontainers | where type =~ 'microsoft.resources/subscriptions' | project subscriptionId, name") # Assignments are often at management group scope: read them tenant-wide. if ($queries.Contains('PolicyAssignments')) { $queries['PolicyAssignments'] = @{ Tenant = $true; Query = $queries['PolicyAssignments'] } } # A resource group or tag selection: the resources it covers. $narrowed = $ResourceGroupName -or ($Tag -and $Tag.Count) if ($narrowed) { $where = @( if ($ResourceGroupName) { "resourceGroup in~ ($((@($ResourceGroupName | ForEach-Object { & $quote $_ })) -join ', '))" } if ($Tag) { foreach ($key in $Tag.Keys) { "tostring(tags[$(& $quote ([string]$key))]) =~ $(& $quote ([string]$Tag[$key]))" } } ) $queries['scope'] = "resources | where $($where -join ' and ') | project id = tolower(id)" if ($ResourceGroupName -and -not $Tag) { $queries['scopeGroups'] = "resourcecontainers | where type =~ 'microsoft.resources/subscriptions/resourcegroups' and name in~ ($((@($ResourceGroupName | ForEach-Object { & $quote $_ })) -join ', ')) | project id = tolower(id)" } } $labels = @{ subscriptions = 'subscriptions'; Scores = 'secure scores'; Controls = 'secure score controls'; ControlAssessments = 'control membership'; Recommendations = 'recommendations'; Alerts = 'alerts'; Plans = 'Defender plans'; Standards = 'compliance standards'; ComplianceControls = 'compliance controls'; ComplianceAssessments = 'compliance assessments'; PolicyStates = 'Azure Policy states'; PolicyAssignments = 'policy assignments'; scope = 'the resources in scope'; scopeGroups = 'the resource groups in scope' } Update-AACProgress -Id 'read' -Total $queries.Count -Description 'Reading Microsoft Defender for Cloud from Azure Resource Graph' $batch = Invoke-AACGraphBatch -Query $queries -SubscriptionId $SubscriptionId -AllowFailure @($names) -OnProgress { param($Name, $Done, $Total) Update-AACProgress -Id 'read' -Increment 1 -Description "Read the $($labels[$Name]) ($Done of $Total queries)" } $notices = [System.Collections.Generic.List[string]]::new() foreach ($name in @($batch.Errors.Keys | Sort-Object)) { $notices.Add("The $($labels[$name]) couldn't be read: $($batch.Errors[$name] -replace '\s+', ' ')") } $subscriptionNames = @{} foreach ($row in @($batch.Rows['subscriptions'])) { $subscriptionNames[([string]$row['subscriptionId']).ToLowerInvariant()] = [string]$row['name'] } if ($SubscriptionId) { foreach ($id in $SubscriptionId) { if (-not $subscriptionNames.Contains($id.ToLowerInvariant())) { $subscriptionNames[$id.ToLowerInvariant()] = $id } } } $rows = @{} foreach ($name in $names) { $rows[$name] = @($batch.Rows[$name]) } if ($Standard) { foreach ($name in 'Standards', 'ComplianceControls', 'ComplianceAssessments') { if ($rows.Contains($name)) { $rows[$name] = @($rows[$name] | Where-Object { $standardName = [string]$_['standard']; @($Standard | Where-Object { $standardName -like $_ }).Count }) } } } # What the sections didn't ask for isn't shown, even when read for another. if ($Section -notcontains 'Recommendations') { $rows.Remove('Recommendations'); if ($Section -contains 'Compliance') { $rows['ComplianceRecommendations'] = @($batch.Rows['Recommendations']) } } $scopeIds = $null if ($narrowed) { $scopeIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($row in @($batch.Rows['scope']) + @($batch.Rows['scopeGroups'])) { if ($row) { [void]$scopeIds.Add([string]$row['id']) } } } $posture = ConvertTo-AACSecurityPosture -Rows $rows -SubscriptionName $subscriptionNames -ResourceId $scopeIds if ($Section -notcontains 'Score') { $posture.Controls = @() } $stats = $posture.Stats if ($Section -contains 'Plans' -and -not $posture.Plans.Count -and -not $batch.Errors.Contains('Plans')) { $notices.Add('No Defender plans were found: Defender for Cloud may not be set up on these subscriptions.') } if ($Section -contains 'Alerts' -and $stats.PlansOn -eq 0 -and $Section -contains 'Plans') { $notices.Add('No Defender plan is on, so there can be no security alerts - an empty alert list is not a clean bill of health.') } if ($Section -contains 'Compliance' -and -not $posture.Standards.Count -and -not $batch.Errors.Contains('Standards')) { $notices.Add("No regulatory compliance standards were found$(if ($Standard) { " matching $($Standard -join ', ')" }): assign one in Defender for Cloud > Regulatory compliance.") } if ($Section -contains 'Policy' -and -not $posture.PolicyAssignments.Count -and -not $batch.Errors.Contains('PolicyStates')) { $notices.Add('No Azure Policy compliance data was found: no policies are assigned to these subscriptions, or they have not been evaluated yet.') } if ($narrowed) { $notices.Add('Secure scores, Defender plans and compliance standards are per subscription, so they cover whole subscriptions; the findings are narrowed to the resources selected.') } Update-AACProgress -Id 'read' -Complete -Description ('Secure score {0}; {1:N0} recommendation(s) on {2:N0} resource(s), {3:N0} alert(s), {4:N0} plan(s) off, {5:N0} failed compliance control(s)' -f $(if ($null -ne $stats.SecureScore) { "$($stats.SecureScore)%" } else { 'not available' }), $stats.Recommendations, $stats.Resources, $stats.Alerts, $stats.PlansOff, $stats.FailedControls) $scope = [ordered]@{ Subscriptions = if ($SubscriptionId) { $SubscriptionId -join ', ' } else { 'every subscription the account can see' } } if ($ResourceGroupName) { $scope['Resource groups'] = $ResourceGroupName -join ', ' } if ($Tag -and $Tag.Count) { $scope['Tags'] = (@($Tag.Keys | Sort-Object | ForEach-Object { "$_ = $($Tag[$_])" })) -join '; ' } if ($Standard) { $scope['Standards'] = $Standard -join ', ' } $scope['Sections'] = $Section -join ', ' $posture.Notice = $notices.ToArray() $posture.Sections = @($Section) $null = Invoke-AACExport -CsvPath $csvFullPath -CsvObject @($posture.Findings) -Noun 'finding' -PdfPath $pdfFullPath -WritePdf { Write-AACSecurityPosturePdf -Posture $posture -Path $pdfFullPath -Title $Title -Detail $scope } -HtmlPath $htmlFullPath -WriteHtml { Write-AACSecurityPostureHtml -Posture $posture -Path $htmlFullPath -Title $Title -Detail $scope } @{ Posture = $posture; Scope = $scope } } if ($showView) { Invoke-AACPagedOutput -NoPaging:$NoPaging -ScriptBlock { Show-AACSecurityPostureView -Posture $state.Posture -Scope $state.Scope } } elseif ($interactive) { foreach ($notice in @($state.Posture.Notice)) { Write-AACMarkup "[grey58]$([Spectre.Console.Markup]::Escape($notice))[/]" } } if ($returnObjects) { $state.Posture.Findings } } |