AZFW.NSG.LogViewer.psm1
class LogBlob : System.IComparable { [string]$Name [string]$LastModified LogBlob([string]$Name,[string]$LastModified) { $this.Name = $Name; $this.LastModified = $LastModified } [int]CompareTo($that) { return ($that.Name.CompareTo($this.Name)) } } Function Get-StorageAccountBlobsAPI { param( [string]$StorageAccountName, [string]$Container, [string]$StorageKey ) $bom = ([char[]][byte[]]@(239,187,191)) -join '' $return_obj = New-Object System.Collections.ArrayList $marker = '' Do { $uri = ('https://{0}.blob.core.windows.net/{1}?restype=container&comp=list&maxresults=5000' -f $StorageAccountName, $Container) If ($marker) {$uri += ('&marker={0}' -f $marker)} $request_uri = New-Object System.Uri $uri $date_string = (Get-Date).ToString('R',[System.Globalization.CultureInfo]::InvariantCulture) $headers = @{ 'x-ms-date' = $date_string 'x-ms-version' = '2019-02-02' } $canonicalized_string = "GET`n`n`n`nx-ms-date:$date_string`nx-ms-version:2019-02-02`n/$($StorageAccountName)/$($Container)?comp=list" [byte[]]$key = [Convert]::FromBase64String($StorageKey) $hmac = New-Object System.Security.Cryptography.HMACSHA256 @(,$key) [byte[]]$hmac_data = [System.Text.Encoding]::UTF8.GetBytes($canonicalized_string) $signature = [Convert]::ToBase64String($hmac.ComputeHash($hmac_data)) $headers['Authorization'] = ('SharedKeyLite {0}:{1}' -f $StorageAccountName, $signature) $result = Invoke-RestMethod -Uri $request_uri -Headers $headers -TimeoutSec 120 $xml = [xml]($result -replace "^$bom", '') ForEach ($blob in $xml.EnumerationResults.Blobs.Blob) { $return_obj.Add( [LogBlob]::new($blob.Name,$blob.Properties.'Last-Modified') ) > $null } $marker = $xml.EnumerationResults.NextMarker Write-Host "... found $($return_obj.Count) blobs" } Until (!$marker) $return_obj.Sort() Return $return_obj } Function Get-StorageAccountBlobContentAPI { param( [string]$StorageAccountName, [string]$Container, [string]$BlobName, [string]$StorageKey ) $request_uri = New-Object System.Uri ('https://{0}.blob.core.windows.net/{1}/{2}' -f $StorageAccountName, $Container, $BlobName) $date_string = (Get-Date).ToString('R',[System.Globalization.CultureInfo]::InvariantCulture) $headers = @{ 'x-ms-date' = $date_string 'x-ms-version' = '2019-02-02' } $canonicalized_string = "GET`n`n`n`nx-ms-date:$date_string`nx-ms-version:2019-02-02`n/$($StorageAccountName)/$($Container)/$($BlobName)" [byte[]]$key = [Convert]::FromBase64String($StorageKey) $hmac = New-Object System.Security.Cryptography.HMACSHA256 @(,$key) [byte[]]$hmac_data = [System.Text.Encoding]::UTF8.GetBytes($canonicalized_string) $signature = [Convert]::ToBase64String($hmac.ComputeHash($hmac_data)) $headers['Authorization'] = ('SharedKeyLite {0}:{1}' -f $StorageAccountName, $signature) $result = Invoke-WebRequest -Uri $request_uri -Headers $headers Return $result.ToString() } Function Get-ModuleCompatability { param( [bool]$Console = $false, [bool]$Last = $false ) If (!(Get-Module Az.Network -ListAvailable) -or !(Get-Module Az.Network -ListAvailable)) { Throw 'Install the Azure (Az) modules to use this tool' } If (!$Last) { If ($Console -and !(Get-Module Microsoft.PowerShell.ConsoleGuiTools -ListAvailable)) { Throw 'Install the module "Microsoft.PowerShell.ConsuleGuiTools" to use -Console. Or, specify the number of logs to load with -Last.' } If (!$Console -and !(Get-Module Microsoft.PowerShell.GraphicalTools -ListAvailable) -and $PSVersionTable.PSVersion.Major -ge 6 -and [Environment]::OSVersion.Platform -ne 'Win32NT') { Throw 'Install the module "Microsoft.PowerShell.GraphicalTools" to select logs via GUI in PowerShell Core. Or, specify the number of logs to load with -Last.' } } } Function New-NSGLogReport { <# .SYNOPSIS Generates a HTML report from NSG logs. .DESCRIPTION Downloads NSG flow logs, parses them, and generates a HTML report using ag-Grid. .PARAMETER ResourceGroupName The resource group containing the target virtual machine. .PARAMETER Name The name of the target virtual machine. .PARAMETER BeforeDate Only load logs before this date. .PARAMETER Last The number of previous, recent NSG logs to load. NSG logs are rotated hourly, on the hour. .PARAMETER Path The path to create the HTML report. Default is %TEMP%\nsg_report.htm .PARAMETER Console Use Out-ConsoleGridView instead of Out-GridView. Requires Microsoft.PowerShell.ConsoleGuiTools and Powershell Core 7 or newer. .INPUTS Microsoft.Azure.Commands.Compute.Models.PSVirtualMachine A virtual machine object .OUTPUTS None .EXAMPLE Get-AzVM -ResourceGroupName contoso -Name vm1 | New-NSGLogReport -Last 2 .EXAMPLE Get-AzVM -ResourceGroupName contoso -Name vm1 | New-NSGLogReport -Last 2 -BeforeDate '2020-03-29 18:00' .EXAMPLE New-NSGLogReport -ResourceGroupName contoso -Name vm1 .EXAMPLE New-NSGLogReport -ResourceGroupName contoso -Name vm1 -Console #> [CmdletBinding(DefaultParametersetname='Default')] param( [Parameter(Mandatory=$true,ParameterSetName='Default')] [string]$ResourceGroupName, [Parameter(Mandatory=$true,ParameterSetName='Default')] [string] $Name, [Parameter(ValueFromPipeline=$true,Mandatory=$true,ParameterSetName='FromPipeline')] [Microsoft.Azure.Commands.Compute.Models.PSVirtualMachine] $InputObject, [Parameter()] [datetime] $BeforeDate, [Parameter()] [int] $Last, [Parameter()] [string] $Path = "$env:TEMP\nsg_report.htm", [Parameter()] [switch] $Console ) $ErrorActionPreference = 'Stop' $LOG_CONTAINER_NAME = 'insights-logs-networksecuritygroupflowevent' # Check module availability Get-ModuleCompatability -Console $Console -Last $Last # Load the VM object If ($PSCmdlet.ParameterSetName -eq 'Default') { Write-Host 'Loading VM ...' $vm_obj = Get-AzVM -Name $Name -ResourceGroupName $ResourceGroupName -ErrorAction Stop } Else { $vm_obj = $InputObject } # Retrieve the VM's NIC Write-Host 'Loading NIC ...' If ($vm_obj.NetworkProfile.NetworkInterfaces.Count -ne 1) { Throw 'This cmdlet only supports VMs with exactly 1 network interface.' } $nic_obj = Get-AzNetworkInterface -ResourceId $vm_obj.NetworkProfile.NetworkInterfaces[0].Id # Retrieve the VM's Subnet Write-Host "Loading subnet ..." $subnet_obj = Get-AzVirtualNetworkSubnetConfig -ResourceId $nic_obj.IpConfigurations[0].Subnet.Id # Retrieve NSG attached to NIC If ($nic_obj.IpConfigurations.Count -ne 1) { Throw 'This cmdlet only supports NICs with exactly 1 IP configuration.' } If (-not ($nic_obj.NetworkSecurityGroup.Id -or $subnet_obj.NetworkSecurityGroup.Id)) { Throw 'There is no NSG attached to either the NIC or its subnet' } If ($nic_obj.NetworkSecurityGroup.Id -and $subnet_obj.NetworkSecurityGroup.Id) { Write-Warning "A NSG is attached to both the NIC and its subnet. Only results from the NIC's NSG will be returned." } If ($nic_obj.NetworkSecurityGroup.Id) { $nsg_id = $nic_obj.NetworkSecurityGroup.Id } Else { $nsg_id = $subnet_obj.NetworkSecurityGroup.Id } Write-Host 'Loading NSG ...' $nsg_obj = Get-AzResource -ResourceId $nsg_id | Get-AzNetworkSecurityGroup # Retrieve network watcher Write-Host 'Loading network watcher ...' Try { $watcher_obj = Get-AzNetworkWatcher -Location $nsg_obj.Location -ErrorAction Stop } Catch { Throw "Could not retrieve network watcher for $($nsg_obj.Location). It is probably not configured." } # Retrieve log storage account Write-Host 'Loading logs storage account ...' $watcher_status = Get-AzNetworkWatcherFlowLogStatus -NetworkWatcher $watcher_obj -TargetResourceId $nsg_obj.Id $sa_obj = Get-AzResource -ResourceId $watcher_status.StorageId | Get-AzStorageAccount $sa_key = ($sa_obj | Get-AzStorageAccountKey)[0].Value # Retrieve log blobs Write-Host 'Finding log blobs ...' $nic_mac = $nic_obj.MacAddress -replace '-','' $blobs = Get-StorageAccountBlobsAPI -StorageAccountName $sa_obj.StorageAccountName -Container $LOG_CONTAINER_NAME -StorageKey $sa_key | ?{$_.Name -match $nic_mac} $nsg_log_timestamp_selection = @{ n = 'LogTime' e = { $_.Name -match '\/y=(\d{4})\/m=(\d{1,2})\/d=(\d{1,2})\/h=(\d{1,2})\/m=(\d{1,2})\/macAddress=\w{12}\/PT1H.json$' > $null Return [datetime]"$($Matches[1])-$($Matches[2])-$($Matches[3]) $($Matches[4]):$($Matches[5])" } } $all_blobs = $blobs | Select $nsg_log_timestamp_selection, LastModified, Name If ($BeforeDate) { $all_blobs = $all_blobs | ?{$_.LogTime -lt $BeforeDate} } If ($Last) { $target_blobs = $all_blobs | Select -First $Last } Else { If ($Console) { $target_blobs = $all_blobs | Out-ConsoleGridView -PassThru } Else { $target_blobs = $all_blobs | Out-GridView -PassThru } } [datetime]$origin = '1970-01-01 00:00:00' ForEach ($blob in $target_blobs) { # Download blob Write-Host "Downloading blob: $($blob.Name) ..." $nsg_log = Get-StorageAccountBlobContentAPI -StorageAccountName $sa_obj.StorageAccountName -Container $LOG_CONTAINER_NAME -BlobName $blob.Name -StorageKey $sa_key | ConvertFrom-Json # Parse JSON $output = @{ nsg_data = [string[]]@() } Write-Host "Parsing blob: $($blob.Name)..." Write-Host "$($nsg_log.records.properties.flows.flows.flowtuples.Count) items to process..." ForEach ($flow in $nsg_log.records.properties.flows) { $rule = $flow.rule ForEach ($tuple in $flow.flows.flowTuples) { $nsg_flow_item = $tuple -split ',' $time = ($origin.AddSeconds($nsg_flow_item[0])).ToString('yyyy-MM-dd HH:mm:ss.fff') $rule = $rule $sourceIP = $nsg_flow_item[1] $destinationIP = $nsg_flow_item[2] $sourcePort = $nsg_flow_item[3] $destinationPort = $nsg_flow_item[4] $protocol = Switch ($nsg_flow_item[5]) { 'T' {'TCP'}; 'U' {'UDP'}; default {$nsg_flow_item[6]} } $direction = Switch ($nsg_flow_item[6]) { 'I' {'Inbound'}; 'O' {'Outbound'}; default {$nsg_flow_item[6]} } $allowed = Switch ($nsg_flow_item[7]) { 'A' {'Allow'}; 'D' {'Deny'}; default {$nsg_flow_item[6]} } $output.nsg_data += ("{{ time: '{0}', protocol: '{1}', source_ip: '{2}', source_port: {3}, destination_ip: '{4}', destination_port: {5}, direction: '{6}', allowed: '{7}', rule: '{8}' }}," -f ` $time, $protocol, $sourceIP, $sourcePort, $destinationIP, $destinationPort, $direction, $allowed, $rule) } } } # Init file stream If (Test-Path $Path) {Remove-Item $Path} $file_stream = New-Object System.IO.StreamWriter $Path # Write report file using template $report = Get-Content "$PSScriptRoot\template\nsg_report_template.htm" ForEach ($line in $report) { $line = [regex]::Replace($line,'{{ ([^\s]*) }}', { param($match) Return ($output."$($match.Groups[1].Value)" -join "`n") } ) $file_stream.Write($line) $file_stream.Write("`n") } $file_stream.Close() & $Path } Function Parse-AZFWLog { param( [ref]$AZFWLog, [hashtable]$Output ) ForEach ($line in ($AZFWLog.Value.Trim() -split "`n")) { $log = $line | ConvertFrom-Json $category = $log.category $time = ([datetime]$log.time).ToString('yyyy-MM-dd HH:mm:ss.fff') If ($category -eq 'AzureFirewallApplicationRule') { $msg = $log.properties.msg If ($msg -match '^(\S+)\s+request from (\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d{1,5}) to (\S+):(\d{1,5})\. Action: (\S+)\. Rule collection: (\S+)\. Rule: ([^\.]+)$') { $protocol = $matches[1] $source_ip = $matches[2] $source_port = $matches[3] $destination_ip = $matches[4] $destination_port = $matches[5] $action = $matches[6] $rule_collection = $matches[7] $url = $null $rule = $matches[8] } ElseIf ($msg -match '^(\S+)\s+request from (\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d{1,5}) to (\S+):(\d{1,5})\. Action: (\S+)\. No rule matched. Proceeding with default action$') { $protocol = $matches[1] $source_ip = $matches[2] $source_port = $matches[3] $destination_ip = $matches[4] $destination_port = $matches[5] $action = $matches[6] $rule_collection = $null $url = $null $rule = 'DEFAULT' } ElseIf ($msg -match '^(\S+)\s+request from (\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d{1,5}) to (\S+):(\d{1,5})\. Url: (\S+)\. Action: (\S+)\. No rule matched. Proceeding with default action$') { $protocol = $matches[1] $source_ip = $matches[2] $source_port = $matches[3] $destination_ip = $matches[4] $destination_port = $matches[5] $action = $matches[7] $rule_collection = $null $url = $matches[6] $rule = 'DEFAULT' } ElseIf ($msg -match '^(\S+)\s+request from (\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d{1,5}) to (\S+):(\d{1,5})\. Url: (\S+)\. Action: (\S+)\. Rule collection: (\S+)\. Rule: ([^\.]+)$') { $protocol = $matches[1] $source_ip = $matches[2] $source_port = $matches[3] $destination_ip = $matches[4] $destination_port = $matches[5] $action = $matches[7] $rule_collection = $matches[8] $url = $matches[6] $rule = $matches[9] } ElseIf ($msg -match '^(\S+)\s+request from (\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d{1,5}) was denied\. Reason: (.+)$') { $protocol = $matches[1] $source_ip = $matches[2] $source_port = $matches[3] $destination_ip = $null $destination_port = $null $action = 'Deny' $rule_collection = $null $url = $null $rule = "[$($matches[4])]" } ElseIf ($msg -match '^(\S+)\s+request from (\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d{1,5})\. Action: Deny\. Reason: (.+)$') { $protocol = $matches[1] $source_ip = $matches[2] $source_port = $matches[3] $destination_ip = $null $destination_port = $null $action = 'Deny' $rule_collection = $null $url = $null $rule = "[$($matches[4])]" } Else { Write-Warning "[AzureFirewallApplicationRule] Can not parse [$msg]" Return } # Protect null integer values from causing a syntax error If (!$source_port) {$source_port = "''"} If (!$destination_port) {$destination_port = "''"} $Output.applog_data += ("{{ time: '{0}', protocol: '{1}', source_ip: '{2}', source_port: {3}, destination_ip: '{4}', destination_port: {5}, url: '{6}', action: '{7}', rule_collection: '{8}', rule: '{9}' }},`n" -f ` $time, $protocol, $source_ip, $source_port, $destination_ip, $destination_port, $url, $action, $rule_collection, $rule ) } ElseIf ($category -eq 'AzureFirewallNetworkRule') { $msg = $log.properties.msg If ($msg -match '^(\S+) request from (\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d{1,5}) to (\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d{1,5})\. Action: (\S+)$') { $protocol = $matches[1] $source_ip = $matches[2] $source_port = $matches[3] $destination_ip = $matches[4] $destination_port = $matches[5] $action = $matches[6] } ElseIf ($msg -match '^(\S+) request from (\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}) to (\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\. Action: (\S+)$') { $protocol = $matches[1] $source_ip = $matches[2] $source_port = $null $destination_ip = $matches[3] $destination_port = $null $action = $matches[4] } ElseIf ($msg -match '^ICMP Type=(\d+) request from (\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):*(\d{1,5})* to (\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):*(\d{1,5})*\. Action: (\S+)') { $protocol = "ICMP[$($matches[1])]" $source_ip = $matches[2] $source_port = $matches[3] $destination_ip = $matches[4] $destination_port = $matches[5] $action = $matches[6] } ElseIf ($msg -match '^(\S+) request from (\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d{1,5}) to (\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d{1,5})\. Action: (.*)$') { $protocol = $matches[1] $source_ip = $matches[2] $source_port = $matches[3] $destination_ip = $matches[4] $destination_port = $matches[5] $action = $matches[6] } Else { Write-Warning "[AzureFirewallNetworkRule] Can not parse [$msg]" Return } # Protect null integer values from causing a syntax error If (!$source_port) {$source_port = "''"} If (!$destination_port) {$destination_port = "''"} $Output.netlog_data += ("{{ time: '{0}', protocol: '{1}', source_ip: '{2}', source_port: {3}, destination_ip: '{4}', destination_port: {5}, action: '{6}' }},`n" -f ` $time, $protocol, $source_ip, $source_port, $destination_ip, $destination_port, $action ) } Else { Write-Warning "Can not parse unknown category [$($category)]" } } } Function New-AZFWLogReport { <# .SYNOPSIS Generates a HTML report from Azure Firewall logs. .DESCRIPTION Downloads Azure Firewall logs, parses them, and generates a HTML report using ag-Grid. .PARAMETER StorageAccountName The storage account containing Azure Firewall logs. .PARAMETER BeforeDate Only load logs before this date. .PARAMETER Last The number of previous, recent NSG logs to load. NSG logs are rotated hourly, on the hour. .PARAMETER Path The path to create the HTML report. Default is %TEMP%\azfw_report.htm .PARAMETER Console Use Out-ConsoleGridView instead of Out-GridView. Requires Microsoft.PowerShell.ConsoleGuiTools and Powershell Core 7 or newer. .INPUTS None .OUTPUTS None .EXAMPLE New-AZFWLogReport -StorageAccountName constosofwlogs -Last 2 .EXAMPLE New-AZFWLogReport -StorageAccountName constosofwlogs -Last 2 -BeforeDate '2020-03-29 18:00' .EXAMPLE New-AZFWLogReport -StorageAccountName constosofwlogs .EXAMPLE New-AZFWLogReport -StorageAccountName constosofwlogs -Console #> param( [Parameter(Mandatory=$true)] [string] $StorageAccountName, [Parameter()] [datetime] $BeforeDate, [Parameter()] [int] $Last, [Parameter()] [string] $Path = "$env:TEMP\azfw_report.htm", [Parameter()] [switch] $Console ) $ErrorActionPreference = 'Stop' $LOG_CONTAINER_NAME = 'insights-logs-azurefirewall' # Check module availability Get-ModuleCompatability -Last $Last -Console $Console Write-Output "Loading logs storage account ..." $sa_obj = Get-AzStorageAccount | ?{$_.StorageAccountName -eq $StorageAccountName} $sa_context = $sa_obj.Context $sa_key = ($sa_obj | Get-AzStorageAccountKey)[0].Value $blob_timestamp_selection = @{ n = 'LogTime' e = { $_.Name -match '\/PROVIDERS\/MICROSOFT\.NETWORK\/AZUREFIREWALLS\/\S+?\/y=(\d{4})\/m=(\d{2})\/d=(\d{2})\/h=(\d{2})\/m=(\d{2})\/PT1H\.json$' > $null Return [datetime]"$($Matches[1])-$($Matches[2])-$($Matches[3]) $($Matches[4]):$($Matches[5])" } } # Listing log blobs Write-Output "Finding log blobs ..." $all_blobs = Get-StorageAccountBlobsAPI -StorageAccountName $sa_obj.StorageAccountName -Container $LOG_CONTAINER_NAME -StorageKey $sa_key | Select $blob_timestamp_selection, LastModified, Name If ($BeforeDate) { $all_blobs = $all_blobs | ?{$_.LogTime -lt $BeforeDate} } If ($Last) { $target_blobs = $all_blobs | Select -First $Last } Else { If ($Console) { $target_blobs = $all_blobs | Out-ConsoleGridView -PassThru } Else { $target_blobs = $all_blobs | Out-GridView -PassThru } } $output = @{ applog_data = [string[]]@() netlog_data = [string[]]@() } ForEach ($blob in $target_blobs) { # Download blob Write-Host "Downloading blob: $($blob.Name) ..." $azfw_log = Get-StorageAccountBlobContentAPI -StorageAccountName $sa_obj.StorageAccountName -Container $LOG_CONTAINER_NAME -BlobName $blob.Name -StorageKey $sa_key # Parse Write-Host "Parsing blob: $($blob.Name) ..." Parse-AZFWLog -AZFWLog ([ref]$azfw_log) -Output $output } # Init file stream If (Test-Path $Path) {Remove-Item $Path} $file_stream = New-Object System.IO.StreamWriter $Path # Write report file using template $report = Get-Content "$PSScriptRoot\template\azfw_report_template.htm" ForEach ($line in $report) { $line = [regex]::Replace($line,'{{ ([^\s]*) }}', { param($match) Return ($output."$($match.Groups[1].Value)" -join "`n") } ) $file_stream.Write($line) $file_stream.Write("`n") } $file_stream.Close() & $Path } Export-ModuleMember -Function New-NSGLogReport Export-ModuleMember -Function New-AZFWLogReport |