tests/Test-Assessment.41212.ps1

<#
.SYNOPSIS
    Hunting capabilities are operationalized in Microsoft Sentinel via saved hunting queries or bookmarks
#>

function Test-Assessment-41212 {
    [ZtTest(
        Category = 'Security information and event management',
        ImplementationCost = 'Medium',
        Service = ('Azure'),
        MinimumLicense = ('Consumption-based: Microsoft Sentinel'),
        Pillar = 'SecOps',
        RiskLevel = 'Medium',
        SfiPillar = 'Accelerate response and remediation',
        TenantType = ('Workforce'),
        TestId = 41212,
        Title = 'Hunting capabilities are operationalized in Microsoft Sentinel via saved hunting queries or bookmarks',
        UserImpact = 'Low'
    )]
    [CmdletBinding()]
    param()

    #region Data Collection

    Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose
    $activity = 'Checking Sentinel hunting queries and bookmarks'

    # Q1 + Q2 + 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       = '41212'
            Title        = 'Hunting capabilities are operationalized in Microsoft Sentinel via saved hunting queries or bookmarks'
            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       = '41212'
            Title        = 'Hunting capabilities are operationalized in Microsoft Sentinel via saved hunting queries or bookmarks'
            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 hunting 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 hunting check.' -Tag Test -Level VeryVerbose
        Add-ZtTestResultDetail -SkippedBecause NotApplicable
        return
    }

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

    if ($onboardedWorkspaces.Count -eq 0) {
        if ($forbiddenWorkspaces.Count -gt 0 -or $onboardingErrorWorkspaces.Count -gt 0) {
            # Auth errors or onboarding-state failures mean we cannot confirm whether those workspaces have Sentinel onboarded;
            # we cannot rule out a passing workspace exists among the inaccessible ones.
            $params = @{
                TestId       = '41212'
                Title        = 'Hunting capabilities are operationalized in Microsoft Sentinel via saved hunting queries or bookmarks'
                Status       = $false
                Result       = '⚠️ One or more Log Analytics workspaces returned insufficient permissions 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 with full visibility — Skipped.
            Write-PSFMessage 'No Sentinel-onboarded workspaces found — skipping Sentinel hunting check.' -Tag Test -Level VeryVerbose
            Add-ZtTestResultDetail -SkippedBecause NotApplicable
        }
        return
    }

    # Q1 (spec): List saved searches; filter to those with category "Hunting Queries".
    $rawSavedSearchesByWorkspace = @{}

    foreach ($workspace in $onboardedWorkspaces) {
        Write-ZtProgress -Activity $activity -Status "Fetching saved hunting queries for $($workspace.WorkspaceName) in $($workspace.SubscriptionName)"
        $savedSearchesPath = "$($workspace.WorkspaceId)/savedSearches?api-version=2026-03-01"

        try {
            $rawSavedSearchesByWorkspace[$workspace.WorkspaceId] = @(Invoke-ZtAzureRequest -Path $savedSearchesPath -ErrorAction Stop)
        }
        catch {
            $rawSavedSearchesByWorkspace[$workspace.WorkspaceId] = $null
            Write-PSFMessage "Error querying saved hunting queries for workspace '$($workspace.WorkspaceName)' in subscription '$($workspace.SubscriptionName)': $_" -Tag Test -Level Warning
        }
    }

    # Q2 (spec): List bookmarks for each Sentinel-onboarded workspace.
    $rawBookmarksByWorkspace = @{}

    foreach ($workspace in $onboardedWorkspaces) {
        Write-ZtProgress -Activity $activity -Status "Fetching bookmarks for $($workspace.WorkspaceName) in $($workspace.SubscriptionName)"
        $bookmarksPath = "$($workspace.WorkspaceId)/providers/Microsoft.SecurityInsights/bookmarks?api-version=2025-09-01"

        try {
            $rawBookmarksByWorkspace[$workspace.WorkspaceId] = @(Invoke-ZtAzureRequest -Path $bookmarksPath -ErrorAction Stop)
        }
        catch {
            $rawBookmarksByWorkspace[$workspace.WorkspaceId] = $null
            Write-PSFMessage "Error querying bookmarks for workspace '$($workspace.WorkspaceName)' in subscription '$($workspace.SubscriptionName)': $_" -Tag Test -Level Warning
        }
    }

    #endregion Data Collection

    #region Assessment Logic

    $workspaceResults = foreach ($workspace in $onboardedWorkspaces) {
        $rawSavedSearches = $rawSavedSearchesByWorkspace[$workspace.WorkspaceId]
        $rawBookmarks     = $rawBookmarksByWorkspace[$workspace.WorkspaceId]

        $q1Error = $null -eq $rawSavedSearches
        $q2Error = $null -eq $rawBookmarks

        $huntingQueryCount   = $null
        $bookmarkCount       = $null
        $recentBookmarkName  = $null
        $recentBookmarkBy    = $null

        if (-not $q1Error) {
            # savedSearches of category "Hunting Queries" are the persisted hunting surface.
            $huntingSearches   = @($rawSavedSearches | Where-Object { $_.properties.category -eq 'Hunting Queries' })
            $huntingQueryCount = $huntingSearches.Count
        }

        if (-not $q2Error) {
            $bookmarkCount = $rawBookmarks.Count

            if ($bookmarkCount -gt 0) {
                # Surface the most recently created bookmark for the display table.
                $recentBookmark    = $rawBookmarks | Sort-Object { $_.properties.created } -Descending | Select-Object -First 1
                $recentBookmarkName = $recentBookmark.properties.displayName
                $recentBookmarkBy  = if ($recentBookmark.properties.createdBy.name) {
                    $recentBookmark.properties.createdBy.name
                } elseif ($recentBookmark.properties.createdBy.email) {
                    $recentBookmark.properties.createdBy.email
                } else { $null }
            }
        }

        # Spec evaluation order — first matching rule wins:
        # Rule 1: Pass if Q1 succeeded with count >= 1 OR Q2 succeeded with count >= 1.
        # A confirmed positive from either surface is authoritative even if the other errored.
        # Rule 2: Investigate if either query errored (absence cannot be confirmed while a query fails).
        # Rule 3: Fail if both succeeded and both counts are zero.
        $rowStatus = if ((-not $q1Error -and $huntingQueryCount -ge 1) -or (-not $q2Error -and $bookmarkCount -ge 1)) {
            'Pass'
        } elseif ($q1Error -or $q2Error) {
            'Investigate'
        } else {
            'Fail'
        }

        [PSCustomObject]@{
            SubscriptionName   = $workspace.SubscriptionName
            SubscriptionId     = $workspace.SubscriptionId
            WorkspaceName      = $workspace.WorkspaceName
            ResourceGroup      = $workspace.ResourceGroup
            WorkspaceId        = $workspace.WorkspaceId
            HuntingQueryCount  = $huntingQueryCount
            BookmarkCount      = $bookmarkCount
            RecentBookmarkName = $recentBookmarkName
            RecentBookmarkBy   = $recentBookmarkBy
            RowStatus          = $rowStatus
        }
    }
    $workspaceResults = @($workspaceResults)

    $passedItems      = @($workspaceResults | Where-Object { $_.RowStatus -eq 'Pass' })
    $investigateItems = @($workspaceResults | Where-Object { $_.RowStatus -eq 'Investigate' })

    $passed       = $passedItems.Count -gt 0
    $customStatus = $null

    if (-not $passed -and ($investigateItems.Count -gt 0 -or $forbiddenWorkspaces.Count -gt 0 -or $onboardingErrorWorkspaces.Count -gt 0)) {
        $customStatus       = 'Investigate'
        $testResultMarkdown = "⚠️ Hunting capability could not be confirmed — one or more workspaces had insufficient permissions on the Sentinel onboarding check, or the saved hunting queries or bookmarks API returned an unexpected response. Re-run after verifying Microsoft Sentinel Reader access on each affected workspace.`n`n%TestResult%"
    }
    elseif ($passed) {
        $testResultMarkdown = "✅ Hunting capability is operationalized in the Sentinel workspace.`n`n%TestResult%"
    }
    else {
        $testResultMarkdown = "❌ No saved hunting queries or bookmarks exist in the Sentinel workspace.`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' }
    $portalSentinelLink = "$portalHost/#view/HubsExtension/BrowseResource/resourceType/microsoft.securityinsightsarg%2Fsentinel"
    $tableTitle         = 'Hunting queries and bookmarks per workspace'

    $formatTemplate = @'
 
 
### [{0}]({1})
 
| Subscription | Workspace | Hunting queries | Bookmarks | Recent bookmark | Created by | Status |
| :----------- | :-------- | --------------: | --------: | :-------------- | :--------- | :----- |
{2}
'@


    $tableRows      = ''
    $maxDisplay     = 10
    $statusPriority = @{ Fail = 0; Investigate = 1; Pass = 2 }
    $displayResults = @($workspaceResults | Sort-Object { $statusPriority[$_.RowStatus] }, SubscriptionName, WorkspaceName)
    $hasMoreItems   = $false
    if ($workspaceResults.Count -gt $maxDisplay) {
        $displayResults = @($displayResults | Select-Object -First $maxDisplay)
        $hasMoreItems   = $true
    }

    foreach ($result in $displayResults) {
        $subLink         = "$portalHost/#resource/subscriptions/$($result.SubscriptionId)"
        $sentinelId      = "/subscriptions/$($result.SubscriptionId)/resourcegroups/$($result.ResourceGroup)/providers/microsoft.securityinsightsarg/sentinel/$($result.WorkspaceName)"
        $huntingLink     = "$portalHost/#view/Microsoft_Azure_Security_Insights/MainMenuBlade/~/Hunting/id/$($sentinelId -replace '/', '%2F')"
        $subMd           = "[$(Get-SafeMarkdown $result.SubscriptionName)]($subLink)"
        $workspaceMd     = "[$(Get-SafeMarkdown $result.WorkspaceName)]($huntingLink)"
        $huntingCountMd  = if ($null -eq $result.HuntingQueryCount) { '—' } else { $result.HuntingQueryCount }
        $bookmarkCountMd = if ($null -eq $result.BookmarkCount) { '—' } else { $result.BookmarkCount }
        $recentNameMd    = if ($result.RecentBookmarkName) { Get-SafeMarkdown -Text $result.RecentBookmarkName } else { '—' }
        $recentByMd      = if ($result.RecentBookmarkBy) { Get-SafeMarkdown -Text $result.RecentBookmarkBy } else { '—' }
        $statusDisplay   = switch ($result.RowStatus) {
            'Pass'        { '✅ Pass' }
            'Fail'        { '❌ Fail' }
            'Investigate' { '⚠️ Investigate' }
        }
        $tableRows += "| $subMd | $workspaceMd | $huntingCountMd | $bookmarkCountMd | $recentNameMd | $recentByMd | $statusDisplay |`n"
    }

    if ($hasMoreItems) {
        $remainingCount = $workspaceResults.Count - $maxDisplay
        $tableRows     += "`n... and $remainingCount more. [View all in Microsoft Sentinel]($portalSentinelLink)`n"
    }

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

    #endregion Report Generation

    $params = @{
        TestId = '41212'
        Title  = 'Hunting capabilities are operationalized in Microsoft Sentinel via saved hunting queries or bookmarks'
        Status = $passed
        Result = $testResultMarkdown
    }
    if ($customStatus) {
        $params.CustomStatus = $customStatus
    }

    Add-ZtTestResultDetail @params
}