Checks/Network/Test-NsgDenyInbound.ps1

#Requires -Version 5.1
<#
.SYNOPSIS
    ARCH.5.2 - Perimeterschutz: Blockieren direkter oeffentlicher Verbindungen.
.DESCRIPTION
    Verifies NSGs contain effective Deny rules for SSH (22) and RDP (3389)
    from the Internet. Checks both explicit Deny rules and absence of Allow
    from Internet sources.
.PARAMETER ResourceGroup
    Azure resource group name.
.PARAMETER NsgNames
    Array of NSG names to check.
.PARAMETER Ports
    Array of port numbers to verify Deny rules for.
.PARAMETER SubscriptionId
    Optional Azure subscription ID.
#>

function Test-NsgDenyInbound {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$ResourceGroup,
        [Parameter(Mandatory)][string[]]$NsgNames,
        [Parameter(Mandatory)][string[]]$Ports,
        [string]$SubscriptionId = ''
    )

    $results = @()
    $internetSources = @('Internet', '*', '0.0.0.0/0')

    foreach ($nsgName in $NsgNames) {
        $response = Get-AzCliResponse -Arguments @("network", "nsg", "rule", "list", "-g", $ResourceGroup, "--nsg-name", $nsgName) -SubscriptionId $SubscriptionId

        if ($response.ExitCode -ne 0 -or -not $response.Output) {
            $results += [ComplianceResult]::new(
                'ARCH.5.2', "NSG $nsgName - Deny SSH/RDP from Internet", 'Network',
                [BsiCheckMode]::Remote, [BsiCheckStatus]::Error, [BsiSeverity]::Critical,
                "Could not list rules for NSG $nsgName"
            )
            $results[-1].CheckFunction = 'Test-NsgDenyInbound'
            $results[-1].Remediation = "Create NSG rule: Deny inbound from Internet on ports 22, 3389"
            continue
        }

        $rules = $response.Output | ConvertFrom-Json

        foreach ($port in $Ports) {
            $relevantRules = $rules | Where-Object {
                $_.direction -eq 'Inbound' -and
                ($_.sourceAddressPrefix -in $internetSources -or
                 ($_.sourceAddressPrefixes -and ($_.sourceAddressPrefixes | Where-Object { $_ -in $internetSources })))
            }

            $denyRules = $relevantRules | Where-Object {
                $_.access -eq 'Deny' -and
                ($_.destinationPortRange -eq $port -or $_.destinationPortRange -eq '*' -or
                 ($_.destinationPortRanges -and $_.destinationPortRanges -contains $port))
            }

            $allowRules = $relevantRules | Where-Object {
                $_.access -eq 'Allow' -and
                ($_.destinationPortRange -eq $port -or $_.destinationPortRange -eq '*' -or
                 ($_.destinationPortRanges -and $_.destinationPortRanges -contains $port))
            }

            $pass = $false
            $details = ''

            if (-not $denyRules -and -not $allowRules) {
                # No explicit rules — default Azure behavior is deny-all inbound, which is fine
                $pass = $true
                $details = "No explicit rules for port $port; default DenyAllInbound applies"
            } elseif ($denyRules -and -not $allowRules) {
                $pass = $true
                $details = "Explicit Deny rule(s) exist for port $port; no Allow from Internet"
            } elseif ($denyRules -and $allowRules) {
                $denyPriorities = @($denyRules | ForEach-Object { [int]$_.priority } | Where-Object { $_ -gt 0 })
                $allowPriorities = @($allowRules | ForEach-Object { [int]$_.priority } | Where-Object { $_ -gt 0 })

                if ($denyPriorities -and $allowPriorities) {
                    $minDeny = ($denyPriorities | Measure-Object -Minimum).Minimum
                    $minAllow = ($allowPriorities | Measure-Object -Minimum).Minimum
                    $pass = $minDeny -lt $minAllow
                    $details = "Deny priority: $minDeny, Allow priority: $minAllow"
                } else {
                    $details = "Could not compare rule priorities"
                }
            } else {
                # Allow without Deny — FAIL
                $details = "Allow rule for port $port exists without a higher-priority Deny rule"
            }

            $status = if ($pass) { [BsiCheckStatus]::Pass } else { [BsiCheckStatus]::Fail }

            $result = [ComplianceResult]::new(
                'ARCH.5.2', "NSG $nsgName - Effective Deny for port $port from Internet", 'Network',
                [BsiCheckMode]::Remote, $status, [BsiSeverity]::Critical,
                $details
            )
            $result.CheckFunction   = 'Test-NsgDenyInbound'
            $result.Evidence        = "Deny rules: $(if ($denyRules) { $denyRules.Count } else { 0 }), Allow rules: $(if ($allowRules) { $allowRules.Count } else { 0 })"
            $result.Remediation     = "Ensure NSG rule with lower priority number Denys inbound port $port from Internet"
            $result.BsiReference    = 'BSI-G-00549'
            $result.Metadata['nsg'] = $nsgName
            $result.Metadata['port'] = $port
            $results += $result
        }
    }

    return $results
}