Checks/Section6.ps1

# Section 6 — Monitoring & Management
# CIS Microsoft Azure Foundations Benchmark v6.0.0

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

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

    # ── 6.1.1.1 — Subscription diagnostic settings exist ─────────────────────
    try {
        $subDiagUri = "https://management.azure.com/subscriptions/$sid/providers/microsoft.insights/diagnosticSettings?api-version=2021-05-01-preview"
        $r = Invoke-ArmRest -Uri $subDiagUri
        if (-not $r.Success) { throw ([string]$r.Error) }

        $settings = @()
        if ($r.Success -and $r.Data) {
            # REST endpoint returns {value: [...]}
            $raw = $r.Data
            if ($raw.PSObject.Properties['value']) {
                $settings = @($raw.value)
            } else {
                $settings = @($raw)
            }
        }
        $hasDiag  = $settings.Count -gt 0

        $results.Add((New-CISResult `
            -ControlId "6.1.1.1" `
            -Status $(if ($hasDiag) { $script:PASS } else { $script:FAIL }) `
            -Details $(if ($hasDiag) { "Found $($settings.Count) subscription-level diagnostic setting(s)." } else { "No subscription-level diagnostic settings found." }) `
            -Remediation $(if (-not $hasDiag) { "Monitor > Activity Log > Export Activity Logs > Add diagnostic setting" } else { "" }) `
            -SubscriptionId $sid -SubscriptionName $sname))

        # ── 6.1.1.2 — Required log categories captured ─────────────────────
        # Even when a diagnostic setting exists it must capture: Security,
        # Administrative, Alert, and Policy log categories.
        try {
            $requiredCategories = @("security", "administrative", "alert", "policy")
            $foundCategories    = @()

            foreach ($setting in $settings) {
                $logsList = $setting.PSObject.Properties['logs']?.Value
                if ($logsList) {
                    foreach ($log in @($logsList)) {
                        if ([string]$log.enabled -eq "True" -or [string]$log.enabled -eq "true") {
                            $cat = [string]($log.PSObject.Properties['category']?.Value)
                            if ($cat) { $foundCategories += $cat.ToLower() }
                        }
                    }
                }
            }

            $foundCategories = @($foundCategories | Select-Object -Unique)
            $missingCats     = @($requiredCategories | Where-Object { $foundCategories -notcontains $_ })

            if ($settings.Count -eq 0) {
                $results.Add((New-CISResult `
                    -ControlId "6.1.1.2" `
                    -Status $script:FAIL `
                    -Details "No subscription diagnostic settings — cannot evaluate categories." `
                    -Remediation "Enable a diagnostic setting and configure all four categories: Security, Administrative, Alert, Policy." `
                    -SubscriptionId $sid -SubscriptionName $sname))
            } elseif ($missingCats.Count -eq 0) {
                $results.Add((New-CISResult `
                    -ControlId "6.1.1.2" `
                    -Status $script:PASS `
                    -Details "All required categories enabled: Security, Administrative, Alert, Policy." `
                    -SubscriptionId $sid -SubscriptionName $sname))
            } else {
                $results.Add((New-CISResult `
                    -ControlId "6.1.1.2" `
                    -Status $script:FAIL `
                    -Details "Missing required log categories: $($missingCats -join ', '). Found: $($foundCategories -join ', ')." `
                    -Remediation "Monitor > Activity Log > Export Activity Logs > Edit diagnostic setting > Enable: Administrative, Security, Alert, Policy" `
                    -SubscriptionId $sid -SubscriptionName $sname))
            }
        } catch {
            $results.Add((New-ErrorResult "6.1.1.2" $_.Exception.Message $sid $sname))
        }
    } catch {
        $results.Add((New-ErrorResult "6.1.1.1" $_.Exception.Message $sid $sname))
        $results.Add((New-ErrorResult "6.1.1.2" $_.Exception.Message $sid $sname))
    }

    # ── 6.1.1.4 — Key Vault diagnostic logging ───────────────────────────────
    try {
        $kvErr = Get-PrefetchError -PrefetchData $PrefetchData -Key "keyvaults"
        if ($kvErr) { throw "Key Vault prefetch failed: $kvErr" }
        $kvs = @(Get-PrefetchData -PrefetchData $PrefetchData -Key "keyvaults" -SubscriptionId $sid)

        if ($kvs.Count -eq 0) {
            $results.Add((New-InfoResult "6.1.1.4" "No Key Vaults found." $sid $sname))
        } else {
            foreach ($kv in $kvs) {
                $kvName  = [string]$kv.name
                $diagList = @(Get-AzDiagnosticSetting -ResourceId ([string]$kv.id) -ErrorAction Stop -WarningAction SilentlyContinue)

                # Accept 'audit' or 'allLogs' category group — both satisfy the requirement
                $hasAudit = $false
                foreach ($setting in $diagList) {
                    # @() coercion handles both the current fixed-array and the upcoming List<T>
                    # type change in Az.Monitor 7.0 (Get-AzDiagnosticSetting breaking change)
                    foreach ($log in @($setting.Log)) {
                        if (-not $log.Enabled) { continue }
                        $val = if ($log.CategoryGroup) { $log.CategoryGroup } else { $log.Category }
                        if ($val -imatch "^(audit|allLogs)$") { $hasAudit = $true; break }
                    }
                    if ($hasAudit) { break }
                }

                $results.Add((New-CISResult `
                    -ControlId "6.1.1.4" `
                    -Status $(if ($hasAudit) { $script:PASS } else { $script:FAIL }) `
                    -Details $(if ($hasAudit) { "Vault '$kvName': audit/allLogs diagnostic logging enabled." } else { "Vault '$kvName': no audit logging enabled (requires 'audit' or 'allLogs' category)." }) `
                    -Remediation $(if (-not $hasAudit) { "Key Vault > $kvName > Diagnostic Settings > Add setting > Enable audit or allLogs category" } else { "" }) `
                    -SubscriptionId $sid -SubscriptionName $sname -Resource $kvName))
            }
        }
    } catch {
        $results.Add((New-ErrorResult "6.1.1.4" $_.Exception.Message $sid $sname))
    }

    # ── 6.1.2.x — Activity log alerts (CIS v6.0.0 controls 6.1.2.1–11) ────────────
    # IDs and operations aligned with Python implementation / CIS benchmark PDF.
    # 6.1.2.1–10: operationName field matching.
    # 6.1.2.11: category field (ServiceHealth) — different check logic.
    try {
        # -ErrorAction Stop: a permission failure must land in the catch (ERROR),
        # not silently produce an empty list that FAILs all 11 alert controls.
        # Disabled alert rules do not alert — only enabled rules can satisfy the control.
        $alerts = @(Get-AzActivityLogAlert -ErrorAction Stop | Where-Object {
            $enabledProp = $_.PSObject.Properties['Enabled']?.Value
            $null -eq $enabledProp -or [bool]$enabledProp
        })

        $alertChecks = @(
            @{ Id="6.1.2.1";  Op="microsoft.authorization/policyassignments/write" }
            @{ Id="6.1.2.2";  Op="microsoft.authorization/policyassignments/delete" }
            @{ Id="6.1.2.3";  Op="microsoft.network/networksecuritygroups/write" }
            @{ Id="6.1.2.4";  Op="microsoft.network/networksecuritygroups/delete" }
            @{ Id="6.1.2.5";  Op="microsoft.security/securitysolutions/write" }
            @{ Id="6.1.2.6";  Op="microsoft.security/securitysolutions/delete" }
            @{ Id="6.1.2.7";  Op="microsoft.sql/servers/firewallrules/write" }
            @{ Id="6.1.2.8";  Op="microsoft.sql/servers/firewallrules/delete" }
            @{ Id="6.1.2.9";  Op="microsoft.network/publicipaddresses/write" }
            @{ Id="6.1.2.10"; Op="microsoft.network/publicipaddresses/delete" }
        )

        foreach ($ac in $alertChecks) {
            $hasAlert = @($alerts | Where-Object {
                $allOf = $_.Condition?.AllOf
                if (-not $allOf) { return $false }
                @($allOf | Where-Object {
                    $_.Field -eq "operationName" -and $_.Equal -ieq $ac.Op
                }).Count -gt 0
            }).Count -gt 0
            $pass = $hasAlert

            $results.Add((New-CISResult `
                -ControlId $ac.Id `
                -Status $(if ($pass) { $script:PASS } else { $script:FAIL }) `
                -Details $(if ($pass) { "Activity log alert configured for operation: $($ac.Op)" } else { "No activity log alert found for: $($ac.Op)" }) `
                -Remediation $(if (-not $pass) { "Monitor > Alerts > + Create > Activity Log > Operation name: $($ac.Op)" } else { "" }) `
                -SubscriptionId $sid -SubscriptionName $sname))
        }

        # 6.1.2.11 — Service Health alert (category = ServiceHealth, not operationName)
        $shAlert = @($alerts | Where-Object {
            $allOf = $_.Condition?.AllOf
            if (-not $allOf) { return $false }
            @($allOf | Where-Object {
                $_.Field -eq "category" -and $_.Equal -ieq "servicehealth"
            }).Count -gt 0
        }).Count -gt 0
        $shPass = $shAlert

        $results.Add((New-CISResult `
            -ControlId "6.1.2.11" `
            -Status $(if ($shPass) { $script:PASS } else { $script:FAIL }) `
            -Details $(if ($shPass) { "Service Health activity log alert found." } else { "No Service Health activity log alert found." }) `
            -Remediation $(if (-not $shPass) { "Monitor > Alerts > + Create > Activity Log > Category = Service Health" } else { "" }) `
            -SubscriptionId $sid -SubscriptionName $sname))

    } catch {
        foreach ($ac in @("6.1.2.1","6.1.2.2","6.1.2.3","6.1.2.4","6.1.2.5","6.1.2.6","6.1.2.7","6.1.2.8","6.1.2.9","6.1.2.10","6.1.2.11")) {
            $results.Add((New-ErrorResult $ac $_.Exception.Message $sid $sname))
        }
    }

    # ── 6.1.3.1 — Application Insights configured ────────────────────────────
    try {
        $aiUrl = "https://management.azure.com/subscriptions/$sid/providers/microsoft.insights/components?api-version=2020-02-02"
        $r = Invoke-AzRestPaged -Uri $aiUrl
        if (-not $r.Success) { throw ([string]$r.Error) }

        $hasAppInsights = $r.Data -and ($r.Data | Measure-Object).Count -gt 0
        $count = if ($r.Data) { ($r.Data | Measure-Object).Count } else { 0 }

        $results.Add((New-CISResult `
            -ControlId "6.1.3.1" `
            -Status $(if ($hasAppInsights) { $script:PASS } else { $script:FAIL }) `
            -Details $(if ($hasAppInsights) { "$count Application Insights component(s) found." } else { "No Application Insights components found." }) `
            -Remediation $(if (-not $hasAppInsights) { "Create Application Insights component via Azure Portal > Monitor > Application Insights > + Create" } else { "" }) `
            -SubscriptionId $sid -SubscriptionName $sname))
    } catch {
        $results.Add((New-ErrorResult "6.1.3.1" $_.Exception.Message $sid $sname))
    }

    return $results.ToArray()
}

function Invoke-Section6TenantChecks {
    <#
    .SYNOPSIS
    Tenant-level Section 6 controls that v6 defines as Manual (no reliable automated
    read) — surfaced once so the report mirrors the benchmark's control set.
    #>

    $manual = @(
        @{ Id="6.1.1.3"; Msg="Manual verification required — confirm the storage account holding the activity-log container is encrypted with a customer-managed key (CMK) in Azure Key Vault." }
        @{ Id="6.1.1.5"; Msg="Manual verification required — Network Watcher > NSG flow logs: ensure flow logs are enabled and traffic analytics sends data to a Log Analytics workspace." }
        @{ Id="6.1.1.6"; Msg="Manual verification required — Network Watcher > VNet flow logs: ensure flow logs are enabled and traffic analytics sends data to a Log Analytics workspace." }
        @{ Id="6.1.1.7"; Msg="Manual verification required — Microsoft Entra ID > Diagnostic settings: ensure MicrosoftGraphActivityLogs are sent to an appropriate destination." }
        @{ Id="6.1.1.8"; Msg="Manual verification required — Microsoft Entra ID > Diagnostic settings: ensure Entra activity logs (AuditLogs, SignInLogs, etc.) are sent to an appropriate destination." }
        @{ Id="6.1.1.9"; Msg="Manual verification required — Intune (Microsoft Entra) > Diagnostic settings: ensure Intune audit and operational logs are sent to a Log Analytics workspace." }
        @{ Id="6.1.4"; Msg="Manual verification required — review Azure Policy compliance (e.g. 'Audit diagnostic setting') to confirm resource logging is enabled across all supported services." }
        @{ Id="6.1.5"; Msg="Manual verification required — confirm production artifacts that require monitoring and an SLA are not running on Basic, Free, or Consumption SKUs." }
        @{ Id="6.2"; Msg="Manual verification required — confirm CanNotDelete or ReadOnly resource locks are applied to mission-critical resources." }
    )

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