Private/Write-AACNsgHtml.ps1
|
function Write-AACNsgHtml { <# .SYNOPSIS Writes the network security group assessment (ConvertTo-AACNsgAssessment) as an interactive HTML report. .DESCRIPTION Tiles (NSGs, rules, findings by severity, unassociated NSGs, NSGs without flow logs or diagnostics - each filtering its table), charts (findings by severity and by check, NSGs by risk), and tables - each searchable, filterable, groupable and downloadable as CSV: NSGs metadata, what each is applied to, rule counts, flow logs, Traffic Analytics, diagnostics, risk, findings Rules every rule, grouped by NSG, in evaluation order, with its risk and finding Findings severity, check, NSG, rule, what was found and what to do Associations each NSG's subnets and network interfaces Logging each NSG's flow logs and diagnostic settings #> [CmdletBinding()] [OutputType([System.IO.FileInfo])] param( [Parameter(Mandatory)] [hashtable] $Assessment, [Parameter(Mandatory)] [string] $Path, [Parameter(Mandatory)] [string] $Title, [System.Collections.IDictionary] $Detail ) $stats = $Assessment.Stats $groups = @($Assessment.Groups) $severityTones = @{ High = 'bad'; Medium = 'warn'; Low = 'info'; Info = 'neutral' } $flowTones = @{ 'Enabled (VNet flow log)' = 'good'; 'Enabled (NSG flow log)' = 'warn'; Disabled = 'bad'; None = 'bad' } $diagTones = @{ Enabled = 'good'; Disabled = 'bad'; Unknown = 'neutral'; 'Not checked' = 'neutral' } $tiles = @( @{ Value = '{0:N0}' -f $stats.Groups; Label = 'network security groups'; Tone = 'info'; Table = 'nsgs' } @{ Value = '{0:N0}' -f $stats.Rules; Label = 'custom rules'; Tone = 'neutral'; Table = 'rules'; Filters = @{ Kind = 'Custom' } } @{ Value = '{0:N0}' -f $stats.High; Label = 'high-severity findings'; Tone = $(if ($stats.High) { 'bad' } else { 'good' }); Table = 'findings'; Filters = @{ Severity = 'High' } } @{ Value = '{0:N0}' -f $stats.Medium; Label = 'medium-severity findings'; Tone = $(if ($stats.Medium) { 'warn' } else { 'good' }); Table = 'findings'; Filters = @{ Severity = 'Medium' } } @{ Value = '{0:N0}' -f $stats.Low; Label = 'low-severity findings'; Tone = 'info'; Table = 'findings'; Filters = @{ Severity = 'Low' } } @{ Value = '{0:N0}' -f $stats.Unassociated; Label = 'unassociated NSGs'; Tone = $(if ($stats.Unassociated) { 'warn' } else { 'good' }); Table = 'nsgs'; Filters = @{ Associated = 'No' } } @{ Value = '{0:N0}' -f $stats.WithoutFlowLogs; Label = 'without flow logs'; Tone = $(if ($stats.WithoutFlowLogs) { 'warn' } else { 'good' }); Table = 'findings'; Filters = @{ Check = 'No flow logs' } } @{ Value = '{0:N0}' -f $stats.WithoutDiagnostics; Label = 'without diagnostic settings'; Tone = $(if ($stats.WithoutDiagnostics) { 'warn' } else { 'good' }); Table = 'nsgs'; Filters = @{ Diagnostics = 'Disabled' } } ) $bySeverity = @(foreach ($level in 'High', 'Medium', 'Low', 'Info') { $n = @($Assessment.Findings | Where-Object Severity -EQ $level).Count if ($n) { @{ Label = $level; Value = $n; Tone = $severityTones[$level] -replace 'neutral', ''; Filter = $level } } }) $byCheck = @($Assessment.Findings | Group-Object -Property Check | Sort-Object -Property @{ Expression = 'Count'; Descending = $true }, Name | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Filter = $_.Name } }) $byRisk = @($groups | Where-Object Risk | Sort-Object -Property @{ Expression = { $_.High * 1000 + $_.Medium * 10 + $_.Low }; Descending = $true } | Select-Object -First 12 | ForEach-Object { @{ Label = $_.Name; Value = $_.High + $_.Medium + $_.Low; Display = "H$($_.High) M$($_.Medium) L$($_.Low)"; Tone = $severityTones[$_.Risk] -replace 'neutral', ''; Filter = $_.Name } }) $charts = @( @{ Title = 'Findings by severity'; Items = $bySeverity; Table = 'findings'; Column = 'Severity' } @{ Title = 'Findings by check'; Items = $byCheck; Table = 'findings'; Column = 'Check'; Tone = 'warn' } @{ Title = 'NSGs with the most findings'; Items = $byRisk; Table = 'findings'; Column = 'Nsg' } @{ Title = 'VMs protected per NSG (through their NIC or subnet)'; Items = @($groups | Where-Object { $_.VirtualMachines -gt 0 } | Sort-Object -Property @{ Expression = 'VirtualMachines'; Descending = $true }, Name | Select-Object -First 15 | ForEach-Object { @{ Label = $_.Name; Value = $_.VirtualMachines; Filter = $_.Name } }); Table = 'nsgs'; Column = 'Name'; Tone = 'info' } ) $nsgRows = @($groups | Select-Object -Property *, @{ Name = 'AssociatedText'; Expression = { if ($_.Associated) { 'Yes' } else { 'No' } } }, @{ Name = 'Analytics'; Expression = { if ($_.TrafficAnalytics) { 'On' } elseif ($_.FlowLogs -like 'Enabled*') { 'Off' } else { '' } } }) foreach ($row in $nsgRows) { $row | Add-Member -NotePropertyName 'Associated' -NotePropertyValue $row.AssociatedText -Force } $ruleRows = @($Assessment.Rules | Select-Object -Property *, @{ Name = 'Kind'; Expression = { if ($_.IsDefault) { 'Default' } else { 'Custom' } } }) $associations = @(foreach ($nsg in $groups) { foreach ($subnet in $nsg.Subnets) { [pscustomobject]@{ Nsg = $nsg.Name; Kind = 'Subnet'; Name = $subnet.Name; Where = $subnet.VirtualNetwork; Address = $subnet.Prefix; ResourceGroup = $nsg.ResourceGroup; SubscriptionName = $nsg.SubscriptionName; Id = $subnet.Id } } foreach ($nic in $nsg.NetworkInterfaces) { [pscustomobject]@{ Nsg = $nsg.Name; Kind = 'Network interface'; Name = $nic.Name; Where = $nic.VirtualMachine; Address = $nic.PrivateIp; ResourceGroup = $nsg.ResourceGroup; SubscriptionName = $nsg.SubscriptionName; Id = $nic.Id } } }) $logging = @(foreach ($nsg in $groups) { foreach ($log in $nsg.FlowLogDetail) { [pscustomobject]@{ Nsg = $nsg.Name; Kind = $log.Kind; Name = $log.Name; Status = $(if ($log.Enabled) { 'Enabled' } else { 'Disabled' }); Destination = $(if ($log.Storage) { "Storage: $($log.Storage)" }); Retention = $(if ($log.RetentionEnabled -and $log.RetentionDays) { "$($log.RetentionDays) days" } else { 'no limit' }); Analytics = $(if ($log.Analytics) { "On$(if ($log.Workspace) { " ($($log.Workspace), every $($log.Interval) min)" })" } else { 'Off' }); Id = $log.Id } } foreach ($setting in $nsg.DiagnosticSettings) { [pscustomobject]@{ Nsg = $nsg.Name; Kind = 'Diagnostic setting'; Name = $setting.Name; Status = $(if ($setting.Categories) { 'Enabled' } else { 'Disabled' }); Destination = (@($(if ($setting.Workspace) { "Log Analytics: $($setting.Workspace)" }), $(if ($setting.Storage) { "Storage: $($setting.Storage)" }), $(if ($setting.EventHub) { "Event Hub: $($setting.EventHub)" })) | Where-Object { $_ }) -join '; '; Retention = ''; Analytics = "Logs: $($setting.Categories)"; Id = '' } } }) $tables = @( @{ Id = 'nsgs'; Title = 'Network security groups'; Noun = 'NSGs'; File = 'nsgs'; Rows = $nsgRows; GroupBy = @('SubscriptionName', 'ResourceGroup', 'Location', 'Risk') Columns = @( @{ Key = 'Name'; Label = 'NSG'; Type = 'resource'; IdKey = 'Id' } @{ Key = 'Risk'; Label = 'Risk'; Type = 'badge'; Tones = $severityTones; Facet = $true } @{ Key = 'High'; Label = 'High'; Type = 'number'; Sum = $true; Format = 'N0'; Tone = 'bad' } @{ Key = 'Medium'; Label = 'Medium'; Type = 'number'; Sum = $true; Format = 'N0'; Tone = 'warn' } @{ Key = 'Low'; Label = 'Low'; Type = 'number'; Sum = $true; Format = 'N0' } @{ Key = 'VirtualMachines'; Label = 'VMs protected'; Type = 'number'; Sum = $true; Format = 'N0' } @{ Key = 'AppliedTo'; Label = 'Applied to'; Type = 'wide' } @{ Key = 'Associated'; Label = 'Associated'; Type = 'badge'; Tones = @{ Yes = 'good'; No = 'warn' }; Facet = $true } @{ Key = 'InboundRules'; Label = 'Inbound'; Type = 'number' } @{ Key = 'OutboundRules'; Label = 'Outbound'; Type = 'number' } @{ Key = 'FlowLogs'; Label = 'Flow logs'; Type = 'badge'; Tones = $flowTones; Facet = $true } @{ Key = 'FlowLogRetention'; Label = 'Retention' } @{ Key = 'Analytics'; Label = 'Traffic Analytics'; Type = 'badge'; Tones = @{ On = 'good'; Off = 'warn' }; Facet = $true } @{ Key = 'Diagnostics'; Label = 'Diagnostics'; Type = 'badge'; Tones = $diagTones; Facet = $true } @{ Key = 'LogDestinations'; Label = 'Log destinations'; Type = 'wide' } @{ Key = 'SubscriptionName'; Label = 'Subscription'; Facet = $true } @{ Key = 'ResourceGroup'; Label = 'Resource group'; Facet = $true } @{ Key = 'Location'; Label = 'Location'; Facet = $true } @{ Key = 'Tags'; Label = 'Tags'; Type = 'wide' } @{ Key = 'Id'; Label = 'Resource ID'; Hidden = $true } ) } @{ Id = 'findings'; Title = 'Findings'; Note = 'Most severe first. Hover a rule''s finding in the rules table for the same detail.'; Noun = 'findings'; File = 'nsg-findings'; Rows = @($Assessment.Findings) GroupBy = @('Nsg', 'Check', 'Severity') Columns = @( @{ Key = 'Severity'; Label = 'Severity'; Type = 'badge'; Tones = $severityTones; Facet = $true } @{ Key = 'Check'; Label = 'Check'; Facet = $true } @{ Key = 'Nsg'; Label = 'NSG'; Type = 'resource'; IdKey = 'NsgId'; Facet = $true } @{ Key = 'Rule'; Label = 'Rule' } @{ Key = 'Detail'; Label = 'What was found'; Type = 'wide' } @{ Key = 'Recommendation'; Label = 'What to do'; Type = 'wide' } @{ Key = 'ResourceGroup'; Label = 'Resource group'; Facet = $true } @{ Key = 'SubscriptionName'; Label = 'Subscription'; Facet = $true } @{ Key = 'NsgId'; Label = 'NSG ID'; Hidden = $true } ) } @{ Id = 'rules'; Title = 'Rules'; Note = 'Per NSG and direction, in the order Azure evaluates them: custom rules by priority, then the defaults.'; Noun = 'rules'; File = 'nsg-rules'; Rows = $ruleRows GroupBy = @('Nsg', 'Direction', 'Access', 'Risk'); Group = 'Nsg' Columns = @( @{ Key = 'Nsg'; Label = 'NSG'; Facet = $true } @{ Key = 'Direction'; Label = 'Direction'; Type = 'badge'; Tones = @{ Inbound = 'info'; Outbound = 'violet' }; Facet = $true } @{ Key = 'Priority'; Label = 'Priority'; Type = 'number' } @{ Key = 'Name'; Label = 'Rule' } @{ Key = 'Access'; Label = 'Access'; Type = 'badge'; Tones = @{ Allow = 'good'; Deny = 'bad' }; Facet = $true } @{ Key = 'Protocol'; Label = 'Protocol'; Facet = $true } @{ Key = 'Source'; Label = 'Source'; Type = 'wide' } @{ Key = 'SourcePorts'; Label = 'Source ports' } @{ Key = 'Destination'; Label = 'Destination'; Type = 'wide' } @{ Key = 'DestinationPorts'; Label = 'Destination ports' } @{ Key = 'Risk'; Label = 'Risk'; Type = 'badge'; Tones = $severityTones; Facet = $true } @{ Key = 'Finding'; Label = 'Finding'; Type = 'wide' } @{ Key = 'Kind'; Label = 'Custom or default'; Type = 'badge'; Tones = @{ Custom = 'info'; Default = 'neutral' }; Facet = $true } @{ Key = 'Description'; Label = 'Description'; Type = 'wide' } @{ Key = 'ResourceGroup'; Label = 'Resource group'; Hidden = $true } @{ Key = 'SubscriptionName'; Label = 'Subscription'; Hidden = $true } ) } @{ Id = 'associations'; Title = 'Associations'; Note = 'The subnets and network interfaces each NSG is applied to.'; Noun = 'associations'; File = 'nsg-associations'; Rows = $associations; GroupBy = @('Nsg', 'Kind') Columns = @( @{ Key = 'Nsg'; Label = 'NSG'; Facet = $true } @{ Key = 'Kind'; Label = 'Kind'; Type = 'badge'; Tones = @{ Subnet = 'info'; 'Network interface' = 'violet' }; Facet = $true } @{ Key = 'Name'; Label = 'Subnet or NIC'; Type = 'resource'; IdKey = 'Id' } @{ Key = 'Where'; Label = 'Virtual network or VM' } @{ Key = 'Address'; Label = 'Prefix or IP' } @{ Key = 'ResourceGroup'; Label = 'NSG resource group'; Facet = $true } @{ Key = 'SubscriptionName'; Label = 'Subscription'; Facet = $true } ) } @{ Id = 'logging'; Title = 'Logging'; Note = 'Flow logs (an NSG flow log, or a virtual network flow log on the NSG''s VNet, subnet or NIC) and diagnostic settings.'; Noun = 'log settings'; File = 'nsg-logging'; Rows = $logging; GroupBy = @('Nsg', 'Kind') Columns = @( @{ Key = 'Nsg'; Label = 'NSG'; Facet = $true } @{ Key = 'Kind'; Label = 'Kind'; Type = 'badge'; Tones = @{ 'Virtual network flow log' = 'good'; 'NSG flow log' = 'warn'; 'Diagnostic setting' = 'info' }; Facet = $true } @{ Key = 'Name'; Label = 'Name' } @{ Key = 'Status'; Label = 'Status'; Type = 'badge'; Tones = @{ Enabled = 'good'; Disabled = 'bad' }; Facet = $true } @{ Key = 'Destination'; Label = 'Destination'; Type = 'wide' } @{ Key = 'Retention'; Label = 'Retention' } @{ Key = 'Analytics'; Label = 'Traffic Analytics / logs'; Type = 'wide' } ) } ) $notices = @() if (@($groups | Where-Object Diagnostics -EQ 'Not checked').Count) { $notices += @{ Tone = 'info'; Text = 'Diagnostic settings were not read (-NoDiagnosticSetting).' } } Write-AACHtmlReport -Path $Path -Title $Title -Subtitle 'Network security groups: associations, rules, logging and risks' -Fact $Detail -Tile $tiles -Chart $charts -Table $tables -Notice $notices } |