tests/Test-Assessment.41205.ps1

<#
.SYNOPSIS
    Checks that data collection rules (DCRs) are used to control ingestion into Microsoft Sentinel workspaces.
 
.DESCRIPTION
    Verifies that every Sentinel-onboarded Log Analytics workspace is the destination of at least
    one Azure Monitor data collection rule whose data flow routes telemetry to that destination,
    confirming that ingestion is explicitly controlled rather than default-configured or absent.
 
.NOTES
    Test ID: 41205
    Workshop Task: SECOPS_097
    Pillar: SecOps
    Category: Security information and event management
    Required API: Azure Resource Manager (management.azure.com), Microsoft.Insights dataCollectionRules
#>

function Test-Assessment-41205 {
    [ZtTest(
        Category = 'Security information and event management',
        MinimumLicense = ('Consumption-based: Microsoft Sentinel'),
        ImplementationCost = 'Medium',
        Pillar = 'SecOps',
        RiskLevel = 'Medium',
        Service = ('Azure'),
        SfiPillar = 'Monitor and detect cyberthreats',
        TenantType = ('Workforce'),
        TestId = 41205,
        Title = 'Data collection rules (DCRs) are used to control ingestion into Microsoft Sentinel workspaces',
        UserImpact = 'Low'
    )]
    [CmdletBinding()]
    param()

    $testTitle = 'Data collection rules (DCRs) are used to control ingestion into Microsoft Sentinel workspaces'

    #region Data Collection

    Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose
    $activity = 'Checking data collection rules for Microsoft Sentinel workspaces'

    # Subscription + workspace enumeration and Sentinel onboarding check via shared helper.
    # Returns 'Forbidden' on ARG 401/403 (Investigate).
    # Returns $null on unexpected ARG failure (Investigate).
    # Returns 'NoSubscriptions' when no enabled subscriptions are accessible (Skip).
    # Returns 'NoWorkspaces' when no Log Analytics workspaces exist in scope (Skip).
    $allWorkspaces = Get-SentinelWorkspaceData -Activity $activity

    if ($null -eq $allWorkspaces) {
        $params = @{
            TestId       = '41205'
            Title        = $testTitle
            Status       = $false
            Result       = '⚠️ Azure Resource Graph returned an unexpected error while querying subscriptions or Log Analytics workspaces. This is likely a transient issue, please re-run the assessment.'
            CustomStatus = 'Investigate'
        }
        Add-ZtTestResultDetail @params
        return
    }

    if ($allWorkspaces -eq 'Forbidden') {
        $params = @{
            TestId       = '41205'
            Title        = $testTitle
            Status       = $false
            Result       = '⚠️ Azure Resource Graph returned insufficient permissions when querying subscriptions or workspaces. Ensure you have at least Reader access to the Azure subscriptions being tested.'
            CustomStatus = 'Investigate'
        }
        Add-ZtTestResultDetail @params
        return
    }

    if ($allWorkspaces -eq 'NoSubscriptions') {
        Write-PSFMessage 'No enabled subscriptions found — skipping Sentinel data collection rules check.' -Tag Test -Level VeryVerbose
        Add-ZtTestResultDetail -SkippedBecause NotApplicable
        return
    }

    if ($allWorkspaces -eq 'NoWorkspaces') {
        Write-PSFMessage 'No Log Analytics workspaces found across accessible subscriptions — skipping Sentinel data collection rules check.' -Tag Test -Level VeryVerbose
        Add-ZtTestResultDetail -SkippedBecause NotApplicable
        return
    }

    $checkableWorkspaces  = @($allWorkspaces | Where-Object { -not $_.PermissionError })
    $forbiddenWorkspaces  = @($allWorkspaces | Where-Object { $_.PermissionError })
    $unresolvedWorkspaces = @($checkableWorkspaces | Where-Object { $_.OnboardingError })
    $onboardedWorkspaces  = @($checkableWorkspaces | Where-Object { $_.SentinelOnboarded })

    if ($onboardedWorkspaces.Count -eq 0) {
        if ($forbiddenWorkspaces.Count -gt 0 -or $unresolvedWorkspaces.Count -gt 0) {
            # Errors mean we cannot confirm whether those workspaces have Sentinel onboarded.
            $params = @{
                TestId       = '41205'
                Title        = $testTitle
                Status       = $false
                Result       = '⚠️ One or more Log Analytics workspaces returned insufficient permissions or an unexpected error when checking Sentinel onboarding state. No Sentinel-onboarded workspace was confirmed among accessible workspaces — the overall state cannot be determined. Ensure Microsoft Sentinel Reader is granted on all workspaces and re-run the assessment.'
                CustomStatus = 'Investigate'
            }
            Add-ZtTestResultDetail @params
        }
        else {
            # Spec: no Sentinel-onboarded workspaces — Skipped.
            Write-PSFMessage 'No Sentinel-onboarded workspaces found — skipping Sentinel data collection rules check.' -Tag Test -Level VeryVerbose
            Add-ZtTestResultDetail -SkippedBecause NotApplicable
        }
        return
    }

    # Cache completed DCR definitions per subscription so workspaces in the same subscription
    # share the Q1 enumeration and Q2 detail requests.
    $dcrStateBySubscription = @{}

    foreach ($workspace in $onboardedWorkspaces) {
        $subId = $workspace.SubscriptionId
        if ($dcrStateBySubscription.ContainsKey($subId)) {
            continue
        }

        Write-ZtProgress -Activity $activity -Status "Listing data collection rules for subscription '$($workspace.SubscriptionName)' (Q1)"
        $dcrPath = "/subscriptions/$subId/providers/Microsoft.Insights/dataCollectionRules?api-version=2022-06-01"

        try {
            # GET requests are paged automatically and ARM list responses are unwrapped by the helper.
            $listedDcrs = @(Invoke-ZtAzureRequest -Path $dcrPath -ErrorAction Stop)
        }
        catch {
            $dcrStateBySubscription[$subId] = $null
            Write-PSFMessage "Error listing data collection rules for subscription '$($workspace.SubscriptionName)': $_" -Tag Test -Level Warning
            continue
        }

        $fullDcrs   = @()
        $errorDcrs  = @()
        foreach ($dcr in $listedDcrs) {
            try {
                Write-ZtProgress -Activity $activity -Status "Fetching data collection rule '$($dcr.name)' (Q2)"
                $fullDcr = Invoke-ZtAzureRequest -Path "$($dcr.id)?api-version=2022-06-01" -ErrorAction Stop
                $fullDcrs += @($fullDcr)
            }
            catch {
                $errorDcrs += [PSCustomObject]@{
                    Name       = $dcr.name
                    ResourceId = $dcr.id
                    Reason     = $_.Exception.Message
                }
                Write-PSFMessage "Error fetching data collection rule '$($dcr.name)' in subscription '$($workspace.SubscriptionName)': $_" -Tag Test -Level Warning
            }
        }

        $dcrStateBySubscription[$subId] = [PSCustomObject]@{
            Rules      = $fullDcrs
            ErrorRules = $errorDcrs
        }
    }

    #endregion Data Collection

    #region Assessment Logic

    $workspaceResults = foreach ($workspace in $onboardedWorkspaces) {
        $dcrState = $dcrStateBySubscription[$workspace.SubscriptionId]

        $dcrDetails       = @()
        $missingTransform = $false
        $rowStatus        = 'Fail'

        if ($null -eq $dcrState) {
            # DCR enumeration failed — cannot determine ingestion control for this workspace.
            $rowStatus = 'Investigate'
        }
        else {
            $dcrDetails = @(foreach ($dcr in $dcrState.Rules) {
                # dataSources is an object keyed by source type (windowsEventLogs, syslog,
                # performanceCounters, extensions, logFiles); report the populated keys.
                $dataSourceTypes = @()
                if ($dcr.properties.dataSources) {
                    $dataSourceTypes = @($dcr.properties.dataSources.PSObject.Properties |
                        Where-Object { @($_.Value).Count -gt 0 } |
                        ForEach-Object { $_.Name })
                }

                $matchingDestinationNames = @($dcr.properties.destinations.logAnalytics |
                    Where-Object { $_.workspaceResourceId -ieq $workspace.WorkspaceId -and -not [string]::IsNullOrWhiteSpace($_.name) } |
                    ForEach-Object { $_.name } | Select-Object -Unique)
                $routedFlows = @($dcr.properties.dataFlows | Where-Object {
                    $flowDestinations = @($_.destinations)
                    @($matchingDestinationNames | Where-Object { $_ -iin $flowDestinations }).Count -gt 0
                })
                $routesToDestination = $matchingDestinationNames.Count -gt 0 -and $routedFlows.Count -gt 0
                $transformPresent    = $routesToDestination -and @($routedFlows | Where-Object {
                    -not [string]::IsNullOrWhiteSpace($_.transformKql)
                }).Count -gt 0

                $reason = if ($routesToDestination -and $transformPresent) {
                    'Telemetry is routed with an ingestion transform.'
                }
                elseif ($routesToDestination) {
                    'Telemetry is routed without an ingestion transform.'
                }
                elseif ($matchingDestinationNames.Count -gt 0) {
                    'The destination is not referenced by a data flow.'
                }
                else {
                    'The workspace is not configured as a destination.'
                }

                [PSCustomObject]@{
                    Name                     = $dcr.name
                    ResourceId               = $dcr.id
                    MatchingDestinationNames = $matchingDestinationNames
                    RoutesToDestination      = $routesToDestination
                    DataSourceTypes          = $dataSourceTypes
                    TransformPresent         = $transformPresent
                    Status                   = if ($routesToDestination) { 'Pass' } else { 'Fail' }
                    Reason                   = $reason
                }
            })

            $dcrDetails += @(foreach ($errorDcr in $dcrState.ErrorRules) {
                [PSCustomObject]@{
                    Name                     = $errorDcr.Name
                    ResourceId               = $errorDcr.ResourceId
                    MatchingDestinationNames = @()
                    RoutesToDestination      = $null
                    DataSourceTypes          = @()
                    TransformPresent         = $null
                    Status                   = 'Investigate'
                    Reason                   = "The data collection rule detail could not be read: $($errorDcr.Reason)"
                }
            })

            $qualifyingDcrs   = @($dcrDetails | Where-Object { $_.Status -eq 'Pass' })
            $missingTransform = $qualifyingDcrs.Count -gt 0 -and @($qualifyingDcrs | Where-Object { $_.TransformPresent }).Count -eq 0

            $rowStatus = if (@($dcrState.ErrorRules).Count -gt 0) { 'Investigate' }
            elseif ($qualifyingDcrs.Count -gt 0) { 'Pass' }
            else { 'Fail' }
        }

        [PSCustomObject]@{
            SubscriptionName    = $workspace.SubscriptionName
            SubscriptionId      = $workspace.SubscriptionId
            WorkspaceName       = $workspace.WorkspaceName
            ResourceGroup       = $workspace.ResourceGroup
            WorkspaceId         = $workspace.WorkspaceId
            DataCollectionRules = $dcrDetails
            MissingTransform    = $missingTransform
            RowStatus           = $rowStatus
        }
    }
    $workspaceResults = @($workspaceResults)

    # Workspaces whose Sentinel onboarding state could not be confirmed are reported as
    # Investigate rows so they remain visible in the table alongside checked workspaces.
    $unresolvedWorkspaceResults = foreach ($workspace in @($forbiddenWorkspaces) + @($unresolvedWorkspaces)) {
        [PSCustomObject]@{
            SubscriptionName    = $workspace.SubscriptionName
            SubscriptionId      = $workspace.SubscriptionId
            WorkspaceName       = $workspace.WorkspaceName
            ResourceGroup       = $workspace.ResourceGroup
            WorkspaceId         = $workspace.WorkspaceId
            DataCollectionRules = @()
            MissingTransform    = $false
            RowStatus           = 'Investigate'
        }
    }
    $workspaceResults = @($workspaceResults) + @($unresolvedWorkspaceResults)

    $investigateItems      = @($workspaceResults | Where-Object { $_.RowStatus -eq 'Investigate' })
    $failedItems           = @($workspaceResults | Where-Object { $_.RowStatus -eq 'Fail' })
    $missingTransformItems = @($workspaceResults | Where-Object { $_.MissingTransform })
    $totalWorkspaceCount   = $workspaceResults.Count

    # Pass only when every onboarded workspace is targeted by at least one DCR and no workspace
    # state is unknown (API failure or insufficient permissions).
    $passed       = $failedItems.Count -eq 0 -and $investigateItems.Count -eq 0
    $customStatus = $null

    # Spec: missing ingestion transforms are surfaced as a warning and never change the verdict.
    $transformNote = ''
    if ($missingTransformItems.Count -gt 0) {
        $transformWorkspaceLabel = if ($missingTransformItems.Count -eq 1) { 'workspace has' } else { 'workspaces have' }
        $transformNote = "`n`n⚠️ $($missingTransformItems.Count) Sentinel $transformWorkspaceLabel qualifying data collection rules, but none of those rules apply an ingestion transform to the routed data flow."
    }

    $investigateNote = ''
    if ($failedItems.Count -gt 0 -and $investigateItems.Count -gt 0) {
        $investigateNote = "`n`n⚠️ In addition, $($investigateItems.Count) of $totalWorkspaceCount Sentinel workspaces could not be evaluated due to permission or API errors."
    }

    if ($failedItems.Count -gt 0) {
        $testResultMarkdown = "❌ Sentinel workspaces without a qualifying data collection rule configured to send telemetry: $($failedItems.Count) of $totalWorkspaceCount.$investigateNote$transformNote`n`n%TestResult%"
    }
    elseif ($investigateItems.Count -gt 0) {
        $customStatus       = 'Investigate'
        $testResultMarkdown = "⚠️ Sentinel workspaces that could not be evaluated due to permission or API errors: $($investigateItems.Count) of $totalWorkspaceCount.$transformNote`n`n%TestResult%"
    }
    else {
        $testResultMarkdown = "✅ Sentinel workspaces with a qualifying data collection rule controlling ingestion: $totalWorkspaceCount of $totalWorkspaceCount.$transformNote`n`n%TestResult%"
    }
    #endregion Assessment Logic

    #region Report Generation

    $azContext     = Get-AzContext -ErrorAction SilentlyContinue
    $portalHost    = if ($azContext -and $azContext.Environment.Name -eq 'AzureUSGovernment') { 'https://portal.azure.us' } else { 'https://portal.azure.com' }
    $portalDcrLink = "$portalHost/#view/HubsExtension/BrowseResource/resourceType/Microsoft.Insights%2FdataCollectionRules"
    $tableTitle    = 'Data collection rules per Sentinel workspace'

    $formatTemplate = @'
 
 
## [{0}]({1})
 
| Subscription | Workspace | DCR | Matching destination name | Data flow routes to destination | Data source types | Transform present | Status | Reason | Workspace status |
| :----------- | :-------- | :-- | :------------------------ | :------------------------------- | :---------------- | :---------------- | :----- | :----- | :--------------- |
{2}
'@


    $tableRows      = ''
    $maxDisplayRows = 10
    $statusPriority = @{ Fail = 0; Investigate = 1; Pass = 2 }
    $statusDisplay  = @{ Fail = '❌ Fail'; Investigate = '⚠️ Investigate'; Pass = '✅ Pass' }
    $sortedResults  = @($workspaceResults | Sort-Object { $statusPriority[$_.RowStatus] }, SubscriptionName, WorkspaceName)
    $reportRows     = @(foreach ($result in $sortedResults) {
        if ($result.DataCollectionRules.Count -eq 0) {
            [PSCustomObject]@{
                WorkspaceResult    = $result
                DataCollectionRule = $null
            }
            continue
        }

        foreach ($dcr in $result.DataCollectionRules) {
            [PSCustomObject]@{
                WorkspaceResult    = $result
                DataCollectionRule = $dcr
            }
        }
    })
    $displayRows  = @($reportRows | Select-Object -First $maxDisplayRows)
    $hasMoreItems = $reportRows.Count -gt $maxDisplayRows

    foreach ($reportRow in $displayRows) {
        $result      = $reportRow.WorkspaceResult
        $dcr         = $reportRow.DataCollectionRule
        $subLink     = "$portalHost/#resource/subscriptions/$($result.SubscriptionId)"
        $subMd       = "[$(Get-SafeMarkdown $result.SubscriptionName)]($subLink)"
        $workspaceMd = "[$(Get-SafeMarkdown $result.WorkspaceName)]($portalHost/#resource$($result.WorkspaceId)/overview)"
        $rollupMd    = $statusDisplay[$result.RowStatus]

        if ($null -eq $dcr) {
            # No DCRs (Fail) or unresolved enumeration/onboarding state (Investigate).
            $placeholderReason = if ($result.RowStatus -eq 'Fail') { 'No data collection rules exist in the subscription.' }
            else { 'Data collection rules or Sentinel onboarding state could not be read.' }
            $tableRows += "| $subMd | $workspaceMd | — | — | — | — | — | — | $placeholderReason | $rollupMd |`n"
            continue
        }

        $dcrNameMd     = if ([string]::IsNullOrWhiteSpace($dcr.Name)) { 'Unknown rule' } else { Get-SafeMarkdown $dcr.Name }
        $dcrMd         = if ([string]::IsNullOrWhiteSpace($dcr.ResourceId)) { $dcrNameMd } else { "[$dcrNameMd]($portalHost/#resource$($dcr.ResourceId)/overview)" }
        $destinationMd = if ($dcr.MatchingDestinationNames.Count -gt 0) { ($dcr.MatchingDestinationNames | ForEach-Object { Get-SafeMarkdown $_ }) -join ', ' } else { '—' }
        $routesMd      = if ($null -eq $dcr.RoutesToDestination) { '—' } elseif ($dcr.RoutesToDestination) { '✅ Yes' } else { '❌ No' }
        $sourcesMd     = if ($dcr.DataSourceTypes.Count -gt 0) { ($dcr.DataSourceTypes | ForEach-Object { Get-SafeMarkdown $_ }) -join ', ' } else { '—' }
        $transformMd   = if (-not $dcr.RoutesToDestination) { '—' } elseif ($dcr.TransformPresent) { '✅ Yes' } else { '⚠️ No' }
        $reasonMd      = Get-SafeMarkdown $dcr.Reason

        $tableRows += "| $subMd | $workspaceMd | $dcrMd | $destinationMd | $routesMd | $sourcesMd | $transformMd | $($statusDisplay[$dcr.Status]) | $reasonMd | $rollupMd |`n"
    }

    if ($hasMoreItems) {
        $remainingCount = $reportRows.Count - $maxDisplayRows
        $tableRows += "`n... and $remainingCount more. [View all data collection rules]($portalDcrLink)`n"
    }

    $mdInfo             = $formatTemplate -f $tableTitle, $portalDcrLink, $tableRows
    $testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo

    #endregion Report Generation

    $params = @{
        TestId = '41205'
        Title  = $testTitle
        Status = $passed
        Result = $testResultMarkdown
    }
    if ($customStatus) {
        $params.CustomStatus = $customStatus
    }

    Add-ZtTestResultDetail @params
}