Private/Helpers.ps1
|
# Logging, console output, and NSG/port helpers function Write-AuditLog { <# .SYNOPSIS Write a timestamped log line to the console (and optionally to a log file). DEBUG/VERBOSE lines are suppressed unless the corresponding mode flag is set. #> param( [Parameter(Mandatory)][string]$Message, [ValidateSet("INFO","WARNING","ERROR","DEBUG","VERBOSE")][string]$Level = "INFO" ) $ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss" $line = "[$ts] [$Level] $Message" switch ($Level) { "ERROR" { Write-Host $line -ForegroundColor Red } "WARNING" { Write-Host $line -ForegroundColor Yellow } "DEBUG" { if ($script:DEBUG_MODE) { Write-Host $line -ForegroundColor Cyan } } "VERBOSE" { if ($script:VERBOSE_MODE) { Write-Host $line -ForegroundColor DarkGray } } default { Write-Host $line } } if ($script:LOG_FILE) { Add-Content -Path $script:LOG_FILE -Value $line -Encoding UTF8 } } # ── Port helpers ───────────────────────────────────────────────────────────── function Remove-DuplicateResults { <# .SYNOPSIS Remove identical duplicate results while preserving order. Duplicates can arise from Azure Resource Graph pagination quirks. #> param([object[]]$Results) $seen = @{} $out = [System.Collections.Generic.List[object]]::new() foreach ($r in $Results) { $key = "{0}|{1}|{2}|{3}|{4}" -f $r.ControlId, $r.SubscriptionId, $r.Resource, $r.Status, $r.Details if (-not $seen.ContainsKey($key)) { $seen[$key] = $true $out.Add($r) } } return $out.ToArray() } function Test-PortInRange { <# .SYNOPSIS Return $true if $Port matches the given NSG port spec. Handles: "*", "22", "1024-65535" #> param([string]$PortSpec, [int]$Port) if ($PortSpec -eq '*') { return $true } if ($PortSpec -match '^\d+$') { return ([int]$PortSpec -eq $Port) } if ($PortSpec -match '^(\d+)-(\d+)$') { return ([int]$Matches[1] -le $Port -and $Port -le [int]$Matches[2]) } return $false } # ── NSG helpers ────────────────────────────────────────────────────────────── function Get-NsgBadRules { <# .SYNOPSIS Find NSG rules that Allow Inbound TCP traffic from internet on the specified ports. Returns array of non-compliant rule names. Handles both Resource Graph format (flat, top-level properties) and az CLI format (nested under .properties). #> param( [object[]]$Rules, [int[]]$Ports, [string[]]$Protocols = @("TCP", "Tcp", "*") ) if (-not $Rules -or $Rules.Count -eq 0) { return @() } $badRules = [System.Collections.Generic.List[string]]::new() foreach ($rule in $Rules) { # Support both flat (Resource Graph) and nested (az CLI) shapes $props = if ($rule.PSObject.Properties['properties']) { $rule.properties } else { $rule } $access = [string]($props.PSObject.Properties['access']?.Value) $direction = [string]($props.PSObject.Properties['direction']?.Value) $proto = [string]($props.PSObject.Properties['protocol']?.Value) if ($access -ne 'Allow') { continue } if ($direction -ne 'Inbound') { continue } # Normalise both singular and plural source address fields into one list $srcAddrs = [System.Collections.Generic.List[string]]::new() $sap = [string]($props.PSObject.Properties['sourceAddressPrefix']?.Value) if ($sap) { $srcAddrs.Add($sap) } $sapProp = $props.PSObject.Properties['sourceAddressPrefixes'] if ($sapProp -and $sapProp.Value) { foreach ($sa in $sapProp.Value) { if ($sa) { $srcAddrs.Add([string]$sa) } } } $isInternet = $false foreach ($sa in $srcAddrs) { if ($script:INTERNET_SRCS -contains $sa.ToLower()) { $isInternet = $true; break } } if (-not $isInternet) { continue } $protoUpper = if ($proto) { $proto.ToUpper() } else { '' } if ($protoUpper -ne '*' -and $Protocols -notcontains $proto -and $Protocols -notcontains $protoUpper) { continue } # Collect all destination port ranges $portRanges = [System.Collections.Generic.List[string]]::new() $dprProp = $props.PSObject.Properties['destinationPortRange'] if ($dprProp -and $dprProp.Value -and [string]$dprProp.Value) { $portRanges.Add([string]$dprProp.Value) } $dprsProp = $props.PSObject.Properties['destinationPortRanges'] if ($dprsProp -and $dprsProp.Value) { foreach ($pr in $dprsProp.Value) { if ($pr) { $portRanges.Add([string]$pr) } } } $matched = $false foreach ($portRange in $portRanges) { if ($matched) { break } foreach ($targetPort in $Ports) { if (Test-PortInRange -PortSpec $portRange -Port $targetPort) { $matched = $true; break } } } if ($matched) { $ruleName = if ($rule.name) { [string]$rule.name } else { "unknown-rule" } if ($badRules -notcontains $ruleName) { $badRules.Add($ruleName) } } } return $badRules.ToArray() } function Get-NsgUdpBadRules { <# .SYNOPSIS Find NSG rules that Allow Inbound UDP from internet (any port). #> param([object[]]$Rules) if (-not $Rules -or $Rules.Count -eq 0) { return @() } $badRules = [System.Collections.Generic.List[string]]::new() foreach ($rule in $Rules) { $props = if ($rule.PSObject.Properties['properties']) { $rule.properties } else { $rule } $access = [string]($props.PSObject.Properties['access']?.Value) $direction = [string]($props.PSObject.Properties['direction']?.Value) $proto = [string]($props.PSObject.Properties['protocol']?.Value) if ($access -ne 'Allow') { continue } if ($direction -ne 'Inbound') { continue } # Normalise both singular and plural source address fields into one list $srcAddrs = [System.Collections.Generic.List[string]]::new() $sap = [string]($props.PSObject.Properties['sourceAddressPrefix']?.Value) if ($sap) { $srcAddrs.Add($sap) } $sapProp = $props.PSObject.Properties['sourceAddressPrefixes'] if ($sapProp -and $sapProp.Value) { foreach ($sa in $sapProp.Value) { if ($sa) { $srcAddrs.Add([string]$sa) } } } $isInternet = $false foreach ($sa in $srcAddrs) { if ($script:INTERNET_SRCS -contains $sa.ToLower()) { $isInternet = $true; break } } if (-not $isInternet) { continue } $protoUpper = if ($proto) { $proto.ToUpper() } else { '' } if ($protoUpper -ne 'UDP' -and $protoUpper -ne '*') { continue } $ruleName = if ($rule.name) { [string]$rule.name } else { "unknown-rule" } if ($badRules -notcontains $ruleName) { $badRules.Add($ruleName) } } return $badRules.ToArray() } # ── Sort helpers ───────────────────────────────────────────────────────────── function Get-ControlSortKey { <# .SYNOPSIS Convert "9.3.10" to a sortable string "009.003.010" for correct numeric ordering. #> param([string]$ControlId) ($ControlId -split '\.' | ForEach-Object { $_.PadLeft(3, '0') }) -join '.' } |