tests/Test-Assessment.41210.ps1

<#
.SYNOPSIS
    Microsoft Threat Intelligence feeds are integrated into Microsoft Sentinel
 
.DESCRIPTION
    This test enumerates all Sentinel-onboarded Log Analytics workspaces across in-scope Azure
    subscriptions and verifies that at least one has threat intelligence indicators ingested.
    Integration is evaluated by inspecting two resources per workspace:
    - Q1: the dataConnectors collection, filtered for the three documented TI connector kinds
      (MicrosoftThreatIntelligence, ThreatIntelligence, ThreatIntelligenceTaxii).
    - Q2: the threatIntelligence/main/metrics endpoint, which reports indicator counts grouped
      by source, threat type, and pattern type. Indicators are confirmed present when the sum of
      sourceMetrics[].metricValue is greater than zero.
 
    Pass: At least one workspace has non-zero indicators regardless of connector state.
    Pass (no conn): Indicators are present but were supplied via the Microsoft Graph / Sentinel
                    upload API — no managed TI data connector is configured (noted in the table).
    Investigate: A TI connector is configured but no indicators have been ingested, or an API
                    call for Q1 or Q2 returned an unexpected error (not the 404 zero-count case).
    Fail: No indicators and no TI connector across all onboarded workspaces.
    Skip: No Sentinel-onboarded workspaces found, or no subscriptions / workspaces accessible.
 
.NOTES
    Test ID: 41210
    Workshop Task: SECOPS_104
    Pillar: SecOps
    Category: Threat intelligence
    Required permissions:
      - Reader on each subscription (for subscription and workspace enumeration)
      - Microsoft Sentinel Reader on each workspace (for data connectors and TI metrics)
#>


function Test-Assessment-41210 {

    [ZtTest(
        Category = 'Threat intelligence',
        ImplementationCost = 'Low',
        Service = ('Azure'),
        MinimumLicense = ('Consumption-based: Microsoft Sentinel'),
        Pillar = 'SecOps',
        RiskLevel = 'Medium',
        SfiPillar = 'Monitor and detect cyberthreats',
        TenantType = ('Workforce'),
        TestId = 41210,
        Title = 'Microsoft Threat Intelligence feeds are integrated into Microsoft Sentinel',
        UserImpact = 'Low'
    )]
    [CmdletBinding()]
    param()

    #region Data Collection
    Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose
    $activity = 'Checking threat intelligence integration in Sentinel workspaces'

    # 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       = '41210'
            Title        = 'Microsoft Threat Intelligence feeds are integrated into Microsoft Sentinel'
            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       = '41210'
            Title        = 'Microsoft Threat Intelligence feeds are integrated into Microsoft Sentinel'
            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 TI-integration 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 TI-integration 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 (PermissionError) or transient errors (OnboardingError) — state unknown.
            $params = @{
                TestId       = '41210'
                Title        = 'Microsoft Threat Intelligence feeds are integrated into Microsoft Sentinel'
                Status       = $false
                Result       = '⚠️ One or more Log Analytics workspaces returned an error or 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 TI-integration check.' -Tag Test -Level VeryVerbose
            Add-ZtTestResultDetail -SkippedBecause NotApplicable
        }
        return
    }

    # Q1 (spec): List data connectors for each workspace and identify TI connector kinds.
    # Q2 (spec): Retrieve indicator metrics. A 404 response means zero indicators (not an API error).
    $connectorsByWorkspace = @{}
    $metricsByWorkspace    = @{}
    $metricsOkByWorkspace  = @{}

    foreach ($workspace in $onboardedWorkspaces) {
        Write-ZtProgress -Activity $activity -Status "Fetching data connectors for $($workspace.WorkspaceName) in $($workspace.SubscriptionName)"
        $dataConnectorsPath = "$($workspace.WorkspaceId)/providers/Microsoft.SecurityInsights/dataConnectors?api-version=2024-09-01"

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

        Write-ZtProgress -Activity $activity -Status "Fetching threat intelligence metrics for $($workspace.WorkspaceName) in $($workspace.SubscriptionName)"
        $tiMetricsPath = "$($workspace.WorkspaceId)/providers/Microsoft.SecurityInsights/threatIntelligence/main/metrics?api-version=2024-09-01"

        try {
            $rawMetrics = @(Invoke-ZtAzureRequest -Path $tiMetricsPath -ErrorAction Stop)
            # A non-empty response must contain properties.sourceMetrics; absence is an unexpected shape.
            # Reject the entire response if any item is missing the documented structure.
            $schemaOk = ($rawMetrics.Count -eq 0) -or (($rawMetrics | Where-Object { $null -eq $_.properties -or $null -eq $_.properties.sourceMetrics }).Count -eq 0)
            if ($schemaOk) {
                $metricsByWorkspace[$workspace.WorkspaceId]   = $rawMetrics
                $metricsOkByWorkspace[$workspace.WorkspaceId] = $true
            }
            else {
                $metricsByWorkspace[$workspace.WorkspaceId]   = $null
                $metricsOkByWorkspace[$workspace.WorkspaceId] = $false
                Write-PSFMessage "Unexpected response shape from threat intelligence metrics API for workspace '$($workspace.WorkspaceName)' in subscription '$($workspace.SubscriptionName)': properties.sourceMetrics is absent or null." -Tag Test -Level Warning
            }
        }
        catch {
            $httpStatus = Get-ZtHttpStatusCode -ErrorRecord $_
            if ($httpStatus -eq 404 -and $_.Exception.Message -match 'No metrics data available') {
                # Spec: only this specific 404 means zero indicators — other 404s are unexpected shapes.
                $metricsByWorkspace[$workspace.WorkspaceId]   = @()
                $metricsOkByWorkspace[$workspace.WorkspaceId] = $true
            }
            else {
                $metricsByWorkspace[$workspace.WorkspaceId]   = $null
                $metricsOkByWorkspace[$workspace.WorkspaceId] = $false
                Write-PSFMessage "Error querying threat intelligence metrics for workspace '$($workspace.WorkspaceName)' in subscription '$($workspace.SubscriptionName)': $_" -Tag Test -Level Warning
            }
        }
    }

    #endregion Data Collection

    #region Assessment Logic

    # Documented TI connector kinds from the KnownDataConnectorKind enum.
    $tiKinds = [System.Collections.Generic.HashSet[string]]::new(
        [string[]]@('MicrosoftThreatIntelligence', 'ThreatIntelligence', 'ThreatIntelligenceTaxii'),
        [System.StringComparer]::OrdinalIgnoreCase
    )

    $workspaceResults = foreach ($workspace in $onboardedWorkspaces) {
        $rawConnectors = $connectorsByWorkspace[$workspace.WorkspaceId]
        $rawMetrics    = $metricsByWorkspace[$workspace.WorkspaceId]
        $metricsOk     = $metricsOkByWorkspace[$workspace.WorkspaceId]
        $q1Ok          = $null -ne $rawConnectors

        # Identify which TI connector kinds are present in this workspace (Q1).
        $tiConnectorKinds = @()
        if ($q1Ok) {
            $tiConnectorKinds = @($rawConnectors | Where-Object { $tiKinds.Contains($_.kind) } | Select-Object -ExpandProperty kind -Unique | Sort-Object)
        }

        # Compute indicator count and metric breakdowns from Q2.
        # indicatorCount = $null when the metrics API call failed (rendered as '—' in the table).
        $indicatorCount       = $null
        $sourceMetricsStr     = $null
        $threatTypeMetricsStr = $null
        $lastUpdated          = $null

        if ($metricsOk) {
            $indicatorCount       = 0
            $sourceMetricsAgg     = @{}
            $threatTypeMetricsAgg = @{}

            foreach ($metricsItem in $rawMetrics) {
                foreach ($source in $metricsItem.properties.sourceMetrics) {
                    $indicatorCount += [int]$source.metricValue
                    if ([int]$source.metricValue -gt 0) {
                        $sourceMetricsAgg[$source.metricName] = [int]$sourceMetricsAgg[$source.metricName] + [int]$source.metricValue
                    }
                }
                foreach ($tt in $metricsItem.properties.threatTypeMetrics) {
                    if ([int]$tt.metricValue -gt 0) {
                        $threatTypeMetricsAgg[$tt.metricName] = [int]$threatTypeMetricsAgg[$tt.metricName] + [int]$tt.metricValue
                    }
                }
                # Track the most recent lastUpdatedTimeUtc across all metric entries.
                if ($metricsItem.properties.lastUpdatedTimeUtc -and (
                    -not $lastUpdated -or
                    [datetime]$metricsItem.properties.lastUpdatedTimeUtc -gt [datetime]$lastUpdated
                )) {
                    $lastUpdated = $metricsItem.properties.lastUpdatedTimeUtc
                }
            }

            $sourceMetricsStr = if ($sourceMetricsAgg.Count -gt 0) {
                ($sourceMetricsAgg.GetEnumerator() | Sort-Object Name | ForEach-Object { "$($_.Name): $($_.Value)" }) -join ', '
            } else { '' }

            $threatTypeMetricsStr = if ($threatTypeMetricsAgg.Count -gt 0) {
                ($threatTypeMetricsAgg.GetEnumerator() | Sort-Object Name | ForEach-Object { "$($_.Name): $($_.Value)" }) -join ', '
            } else { '' }
        }

        # Determine per-workspace row status per spec truth table.
        $rowStatus = if (-not $q1Ok -or -not $metricsOk) {
            'Investigate'
        }
        elseif ($indicatorCount -gt 0 -and $tiConnectorKinds.Count -gt 0) {
            # Q1 has a TI connector AND Q2 reports non-zero indicators — fully integrated.
            'Pass'
        }
        elseif ($indicatorCount -gt 0 -and $tiConnectorKinds.Count -eq 0) {
            # Q2 reports indicators but Q1 has no TI connector — indicators via upload API.
            'PassNoConnector'
        }
        elseif ($indicatorCount -eq 0 -and $tiConnectorKinds.Count -gt 0) {
            # TI connector configured but no indicators have been ingested.
            'Investigate'
        }
        else {
            # No TI connector and no indicators.
            'Fail'
        }

        [PSCustomObject]@{
            SubscriptionName  = $workspace.SubscriptionName
            SubscriptionId    = $workspace.SubscriptionId
            WorkspaceName     = $workspace.WorkspaceName
            ResourceGroup     = $workspace.ResourceGroup
            WorkspaceId       = $workspace.WorkspaceId
            TiConnectorKinds  = ($tiConnectorKinds -join ', ')
            IndicatorCount    = $indicatorCount
            SourceMetrics     = $sourceMetricsStr
            ThreatTypeMetrics = $threatTypeMetricsStr
            LastUpdated       = $lastUpdated
            RowStatus         = $rowStatus
        }
    }
    $workspaceResults = @($workspaceResults)

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

    $passed       = ($passedItems.Count + $passNoConnectorItems.Count) -gt 0
    $customStatus = $null

    if ($passed -and ($investigateItems.Count -gt 0 -or $forbiddenWorkspaces.Count -gt 0 -or $onboardingErrorWorkspaces.Count -gt 0)) {
        # Incomplete scan: permission or transient errors mean some workspaces weren't checked.
        $customStatus       = 'Investigate'
        $testResultMarkdown = "⚠️ Microsoft Threat Intelligence appears integrated in at least one Sentinel workspace, but one or more workspaces could not be evaluated due to insufficient permissions or a transient error — the overall state cannot be confirmed. Ensure Microsoft Sentinel Reader is granted on all workspaces and re-run the assessment.`n`n%TestResult%"
    }
    elseif (-not $passed -and ($investigateItems.Count -gt 0 -or $forbiddenWorkspaces.Count -gt 0 -or $onboardingErrorWorkspaces.Count -gt 0)) {
        $customStatus       = 'Investigate'
        $testResultMarkdown = "⚠️ One or more Sentinel workspaces require manual review: a TI connector is configured but no indicators have been ingested, or an API call returned an unexpected response. Verify Microsoft Sentinel Reader access on each affected workspace and re-run the assessment.`n`n%TestResult%"
    }
    elseif ($passed) {
        if ($passNoConnectorItems.Count -gt 0 -and $passedItems.Count -eq 0) {
            # All passing workspaces have indicators supplied via the upload API — no managed connector.
            $testResultMarkdown = "✅ Microsoft Threat Intelligence is integrated into the Sentinel workspace. Indicators are present but were supplied through the Microsoft Graph / Sentinel upload API — no managed TI data connector is configured.`n`n%TestResult%"
        }
        else {
            $testResultMarkdown = "✅ Microsoft Threat Intelligence is integrated into the Sentinel workspace.`n`n%TestResult%"
        }
    }
    else {
        $testResultMarkdown = "❌ Microsoft Threat Intelligence is not integrated into 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         = 'Threat intelligence status per workspace'

    $formatTemplate = @'
 
 
## [{0}]({1})
 
| Subscription | Workspace | TI connectors | Total indicators | By source | By threat type | Last updated | Status |
| :----------- | :-------- | :------------ | ---------------: | :-------- | :------------- | :----------- | :----- |
{2}
'@


    $tableRows      = ''
    $maxDisplay     = 10
    $statusPriority = @{ Fail = 0; Investigate = 1; PassNoConnector = 2; Pass = 3 }
    $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)"
        $tiLink        = "$portalHost/#view/Microsoft_Azure_Security_Insights/MainMenuBlade/~/ThreatIntelligence/id/$($sentinelId -replace '/', '%2F')"
        $subMd         = "[$(Get-SafeMarkdown $result.SubscriptionName)]($subLink)"
        $workspaceMd   = "[$(Get-SafeMarkdown $result.WorkspaceName)]($tiLink)"
        $connectorsMd  = if ($result.TiConnectorKinds) { $result.TiConnectorKinds } else { '—' }
        $indicatorMd   = if ($null -eq $result.IndicatorCount) { '—' } else { $result.IndicatorCount }
        $sourceMd      = if ($result.SourceMetrics) { Get-SafeMarkdown -Text $result.SourceMetrics } else { '—' }
        $threatTypeMd  = if ($result.ThreatTypeMetrics) { Get-SafeMarkdown -Text $result.ThreatTypeMetrics } else { '—' }
        $lastUpdatedMd = if ($result.LastUpdated) { Get-FormattedDate -DateString $result.LastUpdated } else { '—' }
        $statusDisplay = switch ($result.RowStatus) {
            'Pass'            { '✅ Pass' }
            'PassNoConnector' { '✅ Pass (no connector)' }
            'Fail'            { '❌ Fail' }
            'Investigate'     { '⚠️ Investigate' }
        }
        $tableRows += "| $subMd | $workspaceMd | $connectorsMd | $indicatorMd | $sourceMd | $threatTypeMd | $lastUpdatedMd | $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 = '41210'
        Title  = 'Microsoft Threat Intelligence feeds are integrated into Microsoft Sentinel'
        Status = $passed
        Result = $testResultMarkdown
    }
    if ($null -ne $customStatus) {
        $params.CustomStatus = $customStatus
    }

    Add-ZtTestResultDetail @params
}