Checks/Section7.ps1

# Section 7 — Networking Services
# CIS Microsoft Azure Foundations Benchmark v6.0.0

function Invoke-Section7Checks {
    [CmdletBinding()]
    param(
        [string]$SubscriptionId,
        [string]$SubscriptionName,
        [hashtable]$PrefetchData
    )

    $results = [System.Collections.Generic.List[object]]::new()
    $sid     = $SubscriptionId
    $sname   = $SubscriptionName

    $nsgs        = @(Get-PrefetchData -PrefetchData $PrefetchData -Key "nsgs"         -SubscriptionId $sid)
    $appGateways = @(Get-PrefetchData -PrefetchData $PrefetchData -Key "app_gateways" -SubscriptionId $sid)
    $watchers    = @(Get-PrefetchData -PrefetchData $PrefetchData -Key "watchers"     -SubscriptionId $sid)
    $locations   = @(Get-PrefetchData -PrefetchData $PrefetchData -Key "locations"    -SubscriptionId $sid)
    $wafPolicies = @(Get-PrefetchData -PrefetchData $PrefetchData -Key "waf_policies" -SubscriptionId $sid)

    # Prefetch failure sentinels — dependent checks must throw so their catch
    # blocks emit ERROR instead of a false PASS/INFO on unreadable data.
    $nsgErr   = Get-PrefetchError -PrefetchData $PrefetchData -Key "nsgs"
    $agwErr   = Get-PrefetchError -PrefetchData $PrefetchData -Key "app_gateways"
    $watchErr = Get-PrefetchError -PrefetchData $PrefetchData -Key "watchers"
    $locErr   = Get-PrefetchError -PrefetchData $PrefetchData -Key "locations"
    $wafErr   = Get-PrefetchError -PrefetchData $PrefetchData -Key "waf_policies"


    # ── 7.1 — RDP (3389) not open from internet ──────────────────────────────
    try {
        if ($nsgErr) { throw "NSG prefetch failed: $nsgErr" }
        if ($nsgs.Count -eq 0) {
            $results.Add((New-InfoResult "7.1" "No NSGs found." $sid $sname))
        } else {
            foreach ($nsg in $nsgs) {
                $rules    = @($nsg.rules)
                $badRules = @(Get-NsgBadRules -Rules $rules -Ports @(3389) -Protocols @("TCP","Tcp","*"))
                $pass     = $badRules.Count -eq 0
                $results.Add((New-CISResult `
                    -ControlId "7.1" `
                    -Status $(if ($pass) { $script:PASS } else { $script:FAIL }) `
                    -Details $(if ($pass) { "No rules allow RDP from internet." } else { "Rule(s) allow RDP (3389) from internet: $($badRules -join ', ')" }) `
                    -Remediation $(if (-not $pass) { "NSG > $([string]$nsg.name) > Inbound security rules > Remove/restrict rules exposing port 3389" } else { "" }) `
                    -SubscriptionId $sid -SubscriptionName $sname -Resource ([string]$nsg.name)))
            }
        }
    } catch {
        $results.Add((New-ErrorResult "7.1" $_.Exception.Message $sid $sname))
    }

    # ── 7.2 — SSH (22) not open from internet ────────────────────────────────
    try {
        if ($nsgErr) { throw "NSG prefetch failed: $nsgErr" }
        if ($nsgs.Count -eq 0) {
            $results.Add((New-InfoResult "7.2" "No NSGs found." $sid $sname))
        } else {
            foreach ($nsg in $nsgs) {
                $rules    = @($nsg.rules)
                $badRules = @(Get-NsgBadRules -Rules $rules -Ports @(22) -Protocols @("TCP","Tcp","*"))
                $pass     = $badRules.Count -eq 0
                $results.Add((New-CISResult `
                    -ControlId "7.2" `
                    -Status $(if ($pass) { $script:PASS } else { $script:FAIL }) `
                    -Details $(if ($pass) { "No rules allow SSH from internet." } else { "Rule(s) allow SSH (22) from internet: $($badRules -join ', ')" }) `
                    -Remediation $(if (-not $pass) { "NSG > $([string]$nsg.name) > Inbound security rules > Remove/restrict rules exposing port 22" } else { "" }) `
                    -SubscriptionId $sid -SubscriptionName $sname -Resource ([string]$nsg.name)))
            }
        }
    } catch {
        $results.Add((New-ErrorResult "7.2" $_.Exception.Message $sid $sname))
    }

    # ── 7.3 — UDP not open from internet ─────────────────────────────────────
    try {
        if ($nsgErr) { throw "NSG prefetch failed: $nsgErr" }
        if ($nsgs.Count -eq 0) {
            $results.Add((New-InfoResult "7.3" "No NSGs found." $sid $sname))
        } else {
            foreach ($nsg in $nsgs) {
                $rules    = @($nsg.rules)
                $badRules = @(Get-NsgUdpBadRules -Rules $rules)
                $pass     = $badRules.Count -eq 0
                $results.Add((New-CISResult `
                    -ControlId "7.3" `
                    -Status $(if ($pass) { $script:PASS } else { $script:FAIL }) `
                    -Details $(if ($pass) { "No rules allow UDP from internet." } else { "Rule(s) allow UDP from internet: $($badRules -join ', ')" }) `
                    -Remediation $(if (-not $pass) { "NSG > $([string]$nsg.name) > Inbound security rules > Remove/restrict UDP rules from internet" } else { "" }) `
                    -SubscriptionId $sid -SubscriptionName $sname -Resource ([string]$nsg.name)))
            }
        }
    } catch {
        $results.Add((New-ErrorResult "7.3" $_.Exception.Message $sid $sname))
    }

    # ── 7.4 — HTTP/HTTPS exposure evaluation ─────────────────────────────────
    try {
        if ($nsgErr) { throw "NSG prefetch failed: $nsgErr" }
        if ($nsgs.Count -eq 0) {
            $results.Add((New-InfoResult "7.4" "No NSGs found." $sid $sname))
        } else {
            foreach ($nsg in $nsgs) {
                $rules      = @($nsg.rules)
                $httpRules  = @(Get-NsgBadRules -Rules $rules -Ports @(80)  -Protocols @("TCP","Tcp","*"))
                $httpsRules = @(Get-NsgBadRules -Rules $rules -Ports @(443) -Protocols @("TCP","Tcp","*"))
                # Filter nulls before combining to avoid spurious Count inflation
                $exposed    = @(@($httpRules | Where-Object { $_ }) + @($httpsRules | Where-Object { $_ }) | Select-Object -Unique)

                # HTTP/HTTPS open from internet is a FAIL per CIS benchmark
                $status = if ($exposed.Count -gt 0) { $script:FAIL } else { $script:PASS }
                $results.Add((New-CISResult `
                    -ControlId "7.4" `
                    -Status $status `
                    -Details $(if ($exposed.Count -gt 0) { "HTTP/HTTPS inbound allowed from internet. Rules: $($exposed -join ', ')" } else { "No rules allow HTTP/HTTPS from internet." }) `
                    -Remediation $(if ($exposed.Count -gt 0) { "NSG > $([string]$nsg.name) > Inbound security rules > Restrict HTTP/HTTPS to known IPs or use an Application Gateway/WAF instead." } else { "" }) `
                    -SubscriptionId $sid -SubscriptionName $sname -Resource ([string]$nsg.name)))
            }
        }
    } catch {
        $results.Add((New-ErrorResult "7.4" $_.Exception.Message $sid $sname))
    }

    # Network Watcher flow-log queries only work against real Azure regions.
    # Resources such as Traffic Manager or Azure DNS report location 'global',
    # and some are tagged 'europe', 'asia', etc. Strip those pseudo-locations
    # so we don't fire spurious 'network watcher not enabled' errors.
    $pseudoLocations = @('global','europe','asia','northamerica','southamerica','australia','us','uk','france','germany','japan','korea','norway','southafrica','switzerland','uae','brazil','india','canada','china')

    # Pre-compute region lists used by 7.5, 7.6, and 7.8
    $regionList  = @(@($locations | ForEach-Object { [string]$_.location }) | Select-Object -Unique | Where-Object { $pseudoLocations -notcontains $_ })
    $watcherLocs = @(@($watchers | Where-Object { [string]$_.state -eq "Succeeded" } | ForEach-Object { [string]$_.location }) | Select-Object -Unique)

    # Collect all flow logs ONCE — only from watchers that are actually running.
    # A failed read is recorded so 7.5/7.8 emit ERROR instead of FAIL ('no flow logs').
    $flowLogErr  = $null
    $allFlowLogs = [System.Collections.Generic.List[object]]::new()
    foreach ($watcher in @($watchers | Where-Object { [string]$_.state -eq "Succeeded" })) {
        $watcherId   = [string]$watcher.id
        $parts       = $watcherId -split '/'
        $watcherRg   = $parts[4]
        $watcherName = $parts[-1]
        try {
            $fls = @(Get-AzNetworkWatcherFlowLog -NetworkWatcherName $watcherName -ResourceGroupName $watcherRg -ErrorAction Stop)
            foreach ($fl in $fls) { $allFlowLogs.Add($fl) }
        } catch {
            $flowLogErr = "Could not read flow logs from watcher '$watcherName': $($_.Exception.Message)"
        }
    }

    # ── 7.5 — NSG Flow Log retention >= 90 days ────────────────────────
    try {
        if ($watchErr) { throw "Network Watcher prefetch failed: $watchErr" }
        if ($flowLogErr) { throw $flowLogErr }
        if ($allFlowLogs.Count -eq 0) {
            $results.Add((New-CISResult `
                -ControlId "7.5" `
                -Status $script:FAIL `
                -Details "No NSG flow logs found." `
                -Remediation "Network Watcher > NSG flow logs > Enable with retention >= 90 days" `
                -SubscriptionId $sid -SubscriptionName $sname))
        } else {
            foreach ($fl in $allFlowLogs) {
                $days = [int]$fl.RetentionPolicy.Days
                $en   = $fl.RetentionPolicy.Enabled
                $pass = $en -and $days -ge 90
                $results.Add((New-CISResult `
                    -ControlId "7.5" `
                    -Status $(if ($pass) { $script:PASS } else { $script:FAIL }) `
                    -Details "Retention: $days days (enabled: $en). Flow log: $([string]$fl.Name)" `
                    -Remediation $(if (-not $pass) { "Network Watcher > NSG flow logs > $([string]$fl.Name) > Retention >= 90 days" } else { "" }) `
                    -SubscriptionId $sid -SubscriptionName $sname -Resource ([string]$fl.Name)))
            }
        }
    } catch {
        $results.Add((New-ErrorResult "7.5" $_.Exception.Message $sid $sname))
    }

    # ── 7.6 — Network Watcher enabled for all regions ────────────────────────
    try {
        if ($watchErr) { throw "Network Watcher prefetch failed: $watchErr" }
        if ($locErr)   { throw "Resource locations prefetch failed: $locErr" }
        $missingRegions = @($regionList | Where-Object { $watcherLocs -notcontains $_ })

        if ($regionList.Count -eq 0) {
            $results.Add((New-InfoResult "7.6" "No regions with resources found." $sid $sname))
        } elseif ($missingRegions.Count -eq 0) {
            $results.Add((New-CISResult `
                -ControlId "7.6" `
                -Status $script:PASS -Details "Network Watcher enabled in all $($regionList.Count) region(s)." `
                -SubscriptionId $sid -SubscriptionName $sname))
        } else {
            $results.Add((New-CISResult `
                -ControlId "7.6" `
                -Status $script:FAIL `
                -Details "Network Watcher missing in: $($missingRegions -join ', ')" `
                -Remediation "Network Watcher > Enable in all regions where resources are deployed." `
                -SubscriptionId $sid -SubscriptionName $sname))
        }
    } catch {
        $results.Add((New-ErrorResult "7.6" $_.Exception.Message $sid $sname))
    }

    # ── 7.8 — VNet flow log retention >= 90 days ─────────────────────────────
    try {
        if ($watchErr) { throw "Network Watcher prefetch failed: $watchErr" }
        if ($flowLogErr) { throw $flowLogErr }
        $allVnetFlowLogs = @($allFlowLogs | Where-Object {
            [string]$_.TargetResourceId -match '(?i)/virtualnetworks/'
        })

        if ($allVnetFlowLogs.Count -eq 0) {
            $results.Add((New-CISResult `
                -ControlId "7.8" `
                -Status $script:FAIL `
                -Details "No VNet flow logs configured — flow logging has not been enabled for any VNet in this subscription. See also CIS 6.1.1.7 (VNet flow logs to Log Analytics)." `
                -Remediation "Network Watcher > Flow logs > Create a flow log for each VNet with retention >= 90 days and Traffic Analytics enabled (CIS 6.1.1.7)." `
                -SubscriptionId $sid -SubscriptionName $sname))
        } else {
            foreach ($fl in $allVnetFlowLogs) {
                $days = [int]$fl.RetentionPolicy.Days
                $en   = $fl.RetentionPolicy.Enabled
                $pass = $en -and $days -ge 90
                $results.Add((New-CISResult `
                    -ControlId "7.8" `
                    -Status $(if ($pass) { $script:PASS } else { $script:FAIL }) `
                    -Details "VNet flow log '$([string]$fl.Name)': $days days, enabled = $en" `
                    -Remediation $(if (-not $pass) { "Network Watcher > Flow logs > Set retention >= 90 days" } else { "" }) `
                    -SubscriptionId $sid -SubscriptionName $sname -Resource ([string]$fl.Name)))
            }
        }
    } catch {
        $results.Add((New-ErrorResult "7.8" $_.Exception.Message $sid $sname))
    }

    # ── 7.11 — Subnets associated with NSGs ──────────────────────────────────
    try {
        $subnetErr = Get-PrefetchError -PrefetchData $PrefetchData -Key "subnets"
        if ($subnetErr) { throw "Subnet prefetch failed: $subnetErr" }
        $subnets = @(Get-PrefetchData -PrefetchData $PrefetchData -Key "subnets" -SubscriptionId $sid)
        $applicableSubnets = @($subnets | Where-Object {
            [string]$_.vnetName -and [string]$_.subnetName -and
            ($script:EXEMPT_SUBNETS -notcontains [string]$_.subnetName.ToLower())
        })

        if ($subnets.Count -eq 0) {
            $results.Add((New-InfoResult "7.11" "No subnets found." $sid $sname))
        } elseif ($applicableSubnets.Count -eq 0) {
            $results.Add((New-InfoResult "7.11" "No applicable subnets found (only platform-managed subnets exist)." $sid $sname))
        } else {
            foreach ($subnet in $applicableSubnets) {
                $vnetName   = [string]$subnet.vnetName
                $subnetName = [string]$subnet.subnetName
                $hasNsg     = [string]$subnet.hasNsg -in @("True", "true", "1")
                $resource   = "$vnetName/$subnetName"
                $results.Add((New-CISResult `
                    -ControlId "7.11" `
                    -Status $(if ($hasNsg) { $script:PASS } else { $script:FAIL }) `
                    -Details $(if ($hasNsg) { "Subnet '$resource': NSG associated." } else { "Subnet '$resource': no NSG associated." }) `
                    -Remediation $(if (-not $hasNsg) { "VNet '$vnetName' > Subnets > '$subnetName' > Network security group: assign an NSG." } else { "" }) `
                    -SubscriptionId $sid -SubscriptionName $sname -Resource $resource))
            }
        }
    } catch {
        $results.Add((New-ErrorResult "7.11" $_.Exception.Message $sid $sname))
    }

    # ── Application Gateway (WAF) checks 7.10, 7.12–7.15 ────────────────────
    # Wrapped in try/catch: deserialized $appGateways objects (from $using: in
    # parallel runspaces) may have null-valued properties stripped by CLIXML
    # serialization; accessing them under Set-StrictMode throws without this.
    try {
    if ($agwErr) { throw "Application Gateway prefetch failed: $agwErr" }
    if ($wafErr) { throw "WAF policy prefetch failed: $wafErr" }
    if ($appGateways.Count -eq 0) {
        foreach ($cid in @("7.10","7.12","7.13","7.14","7.15")) {
            $results.Add((New-InfoResult $cid "No Application Gateways found." $sid $sname))
        }
    } else {
        foreach ($agw in $appGateways) {
            # Use safe property access: null properties may be absent on deserialized objects
            $agwName  = [string]($agw.PSObject.Properties['name']?.Value)
            $http2    = [string]($agw.PSObject.Properties['enableHttp2']?.Value)
            $wafOn    = [string]($agw.PSObject.Properties['wafEnabled']?.Value)
            $wafBody  = [string]($agw.PSObject.Properties['wafReqBody']?.Value)
            $sslProto = [string]($agw.PSObject.Properties['sslMinProto']?.Value)
            $wafPolId = [string]($agw.PSObject.Properties['wafPolicyId']?.Value)

            # 7.10 — WAF enabled
            $wafEnabled = $wafOn -eq "true" -or $wafOn -eq "True" -or $wafPolId -ne ""
            $results.Add((New-CISResult `
                -ControlId "7.10" `
                -Status $(if ($wafEnabled) { $script:PASS } else { $script:FAIL }) `
                -Details $(if ($wafEnabled) { "WAF is enabled." } else { "WAF is not enabled on this Application Gateway." }) `
                -Remediation $(if (-not $wafEnabled) { "Application Gateway > $agwName > Web application firewall > Enable WAF" } else { "" }) `
                -SubscriptionId $sid -SubscriptionName $sname -Resource $agwName))

            # 7.12 — TLS 1.2+ (empty/unset ssl policy is non-compliant — must be explicitly configured)
            $tlsOk = $sslProto -match "TLSv1_2|TLSv1_3"
            $results.Add((New-CISResult `
                -ControlId "7.12" `
                -Status $(if ($tlsOk) { $script:PASS } else { $script:FAIL }) `
                -Details "Minimum TLS: $(if($sslProto){"$sslProto"}else{'not set'})" `
                -Remediation $(if (-not $tlsOk) { "Application Gateway > $agwName > Listeners > SSL policy > Set minimum TLS to TLSv1_2" } else { "" }) `
                -SubscriptionId $sid -SubscriptionName $sname -Resource $agwName))

            # 7.13 — HTTP2 (was 7.12)
            $h2Ok = $http2 -eq "true" -or $http2 -eq "True"
            $results.Add((New-CISResult `
                -ControlId "7.13" `
                -Status $(if ($h2Ok) { $script:PASS } else { $script:FAIL }) `
                -Details "HTTP2 enabled: $http2" `
                -Remediation $(if (-not $h2Ok) { "Application Gateway > $agwName > Configuration > Enable HTTP2" } else { "" }) `
                -SubscriptionId $sid -SubscriptionName $sname -Resource $agwName))

            # 7.14 — WAF request body inspection (was 7.13)
            if ($wafPolId) {
                $matchedPol = $wafPolicies | Where-Object { [string]$_.id -eq $wafPolId }
                if ($matchedPol) {
                    $bodyInspect = [string]($matchedPol.PSObject.Properties['requestBodyInspect']?.Value)
                    $bodyOk = $bodyInspect -eq "true" -or $bodyInspect -eq "True"
                    $results.Add((New-CISResult `
                        -ControlId "7.14" `
                        -Status $(if ($bodyOk) { $script:PASS } else { $script:FAIL }) `
                        -Details "Request body inspection: $bodyInspect" `
                        -Remediation $(if (-not $bodyOk) { "WAF Policy > Policy Settings > Enable request body inspection" } else { "" }) `
                        -SubscriptionId $sid -SubscriptionName $sname -Resource $agwName))

                    # 7.15 — Bot protection mode in Prevention (was 7.14)
                    $botMode = [string]($matchedPol.PSObject.Properties['botMode']?.Value)
                    $botOk   = $botMode -eq "Prevention"
                    $results.Add((New-CISResult `
                        -ControlId "7.15" `
                        -Status $(if ($botOk) { $script:PASS } else { $script:FAIL }) `
                        -Details "Bot protection mode: $(if($botMode){"$botMode"}else{'Not configured'})" `
                        -Remediation $(if (-not $botOk) { "WAF Policy > Bot protection > Set to Prevention" } else { "" }) `
                        -SubscriptionId $sid -SubscriptionName $sname -Resource $agwName))
                } else {
                    $results.Add((New-InfoResult "7.14" "WAF policy $wafPolId not found in prefetch data." $sid $sname $agwName))
                    $results.Add((New-InfoResult "7.15" "WAF policy $wafPolId not found in prefetch data." $sid $sname $agwName))
                }
            } else {
                $wafBodyOk = $wafBody -eq "true" -or $wafBody -eq "True"
                $results.Add((New-CISResult `
                    -ControlId "7.14" `
                    -Status $(if ($wafBodyOk) { $script:PASS } else { $script:FAIL }) `
                    -Details $(if ($wafBody) { "Request body inspection (inline): $wafBody" } else { "WAF is not enabled on this Application Gateway; request body inspection is not configured." }) `
                    -Remediation $(if (-not $wafBodyOk) { "Application Gateway > $agwName > WAF > Request body inspection > Enable" } else { "" }) `
                    -SubscriptionId $sid -SubscriptionName $sname -Resource $agwName))

                # 7.15 without WAF policy — cannot determine bot protection
                $results.Add((New-InfoResult "7.15" "No WAF policy linked; cannot assess bot protection mode." $sid $sname $agwName))
            }
        }
    }
    } catch {
        $agwErrMsg = $_.Exception.Message
        foreach ($cid in @("7.10","7.12","7.13","7.14","7.15")) {
            $results.Add((New-ErrorResult $cid $agwErrMsg $sid $sname))
        }
    }

    return $results.ToArray()
}

function Invoke-Section7TenantChecks {
    <#
    .SYNOPSIS
    Section 7 controls v6 defines without a reliable automated read — surfaced once
    as MANUAL so the report mirrors the benchmark's control set. (7.9 is 'Automated'
    in v6 but only documents a portal audit for the VPN Gateway P2S setting, so it is
    surfaced for manual review rather than asserted PASS/FAIL.)
    #>

    $manual = @(
        @{ Id="7.7"; Msg="Manual verification required — periodically review all Public IP addresses (Azure Portal > Public IP addresses) and remove or justify each exposed endpoint." }
        @{ Id="7.9"; Msg="Manual verification required — for each VPN Gateway with a Point-to-Site configuration, ensure the Authentication type is set to 'Azure Active Directory' only." }
        @{ Id="7.16"; Msg="Manual verification required — confirm Azure Network Security Perimeter is configured to secure supported PaaS resources." }
    )

    $results = [System.Collections.Generic.List[object]]::new()
    foreach ($m in $manual) {
        $results.Add((New-ManualResult $m.Id $m.Msg))
    }
    return $results.ToArray()
}