tests/Test-Assessment.41202.ps1

<#
.SYNOPSIS
    Checks that third-party (non-Microsoft) data connectors are configured in Microsoft Sentinel
    for non-Microsoft workloads in scope.
 
.NOTES
    Test ID: 41202
    Workshop Task: SECOPS_094
    Pillar: SecOps
    Category: Security information and event management
    Required API: Azure Resource Manager (management.azure.com)
#>

function Test-Assessment-41202 {
    [ZtTest(
        Category = 'Security information and event management',
        ImplementationCost = 'Medium',
        MinimumLicense = ('Consumption-based: Microsoft Sentinel'),
        Pillar = 'SecOps',
        RiskLevel = 'Medium',
        Service = ('Azure'),
        SfiPillar = 'Monitor and detect cyberthreats',
        TenantType = ('Workforce'),
        TestId = 41202,
        Title = 'Third-party (non-Microsoft) data connectors are configured in Microsoft Sentinel for non-Microsoft workloads in scope',
        UserImpact = 'Low'
    )]
    [CmdletBinding()]
    param()

    $testTitle = 'Third-party (non-Microsoft) data connectors are configured in Microsoft Sentinel for non-Microsoft workloads in scope'

    #region Data Collection

    Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose
    $activity = 'Checking third-party data connectors in Microsoft Sentinel workspaces'
    $azContext = Get-AzContext -ErrorAction SilentlyContinue
    $tenantId = if ($azContext) { [string]$azContext.Tenant.Id } else { $null }
    # Identify the customer's publisher name so customer-authored codeless connectors (scope of 41203) are excluded from third-party classification.
    $customerPublisher = if ($tenantId) { Get-ZtTenantName -TenantId $tenantId } else { $null }
    # Get-ZtTenantName falls back to the tenant GUID when the org-name lookup fails; treat that as unresolved.
    if ($customerPublisher -eq $tenantId) { $customerPublisher = $null }

    # D1 + D2: workspace discovery 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 accessible — Skipped.
    # Returns 'NoWorkspaces' when no Log Analytics workspaces found — Skipped.
    # Returns array per-workspace results; PermissionError/OnboardingError mark inaccessible workspaces.
    $allWorkspaces = Get-SentinelWorkspaceData -Activity $activity

    if ($null -eq $allWorkspaces) {
        $params = @{
            TestId       = '41202'
            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       = '41202'
            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 third-party data connectors 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 third-party data connectors 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) {
            # Some scopes had permission or onboarding errors — cannot confirm Sentinel is absent.
            $params = @{
                TestId       = '41202'
                Title        = $testTitle
                Status       = $false
                Result       = '⚠️ No Sentinel-onboarded workspace was confirmed among accessible workspaces, and one or more workspaces returned an error or insufficient permissions. Ensure Microsoft Sentinel Reader is granted on all workspaces and re-run the assessment.'
                CustomStatus = 'Investigate'
            }
            Add-ZtTestResultDetail @params
        } else {
            # Enumeration succeeded everywhere; no Sentinel-onboarded workspace exists — not applicable.
            Write-PSFMessage 'No Sentinel-onboarded workspaces found — skipping Sentinel third-party data connectors check.' -Tag Test -Level VeryVerbose
            Add-ZtTestResultDetail -SkippedBecause NotApplicable
        }
        return
    }

    # Q1: data connectors list per onboarded workspace.
    # Q2: connector definitions, used as publisher fallback for codeless connectors with null Q1 publisher.
    $connectorsByWorkspace  = @{}
    $definitionsByWorkspace = @{}

    foreach ($workspace in $onboardedWorkspaces) {
        Write-ZtProgress -Activity $activity -Status "Fetching data connectors for workspace '$($workspace.WorkspaceName)' in '$($workspace.SubscriptionName)'"

        try {
            # Q1: GA 2024-09-01 returns empty for StaticUI/codeless connectors; preview version returns all.
            $q1Path = "$($workspace.WorkspaceId)/providers/Microsoft.SecurityInsights/dataConnectors?api-version=2023-12-01-preview"
            $connectorsByWorkspace[$workspace.WorkspaceId] = @(Invoke-ZtAzureRequest -Path $q1Path -ErrorAction Stop)
        }
        catch {
            $connectorsByWorkspace[$workspace.WorkspaceId] = $null
            Write-PSFMessage "Q1 error querying data connectors for workspace '$($workspace.WorkspaceName)' in subscription '$($workspace.SubscriptionName)': $_" -Tag Test -Level Warning
        }

        # Q2 fallback: only when a codeless Q1 connector has a null/empty publisher.
        $needsQ2 = $null -ne $connectorsByWorkspace[$workspace.WorkspaceId] -and
                   @($connectorsByWorkspace[$workspace.WorkspaceId] | Where-Object {
                       $_.kind -in @('GenericUI', 'APIPolling', 'RestApiPoller') -and
                       [string]::IsNullOrWhiteSpace($_.properties.connectorUiConfig.publisher)
                   }).Count -gt 0
        if ($needsQ2) {
            try {
                $q2Path = "$($workspace.WorkspaceId)/providers/Microsoft.SecurityInsights/dataConnectorDefinitions?api-version=2024-09-01"
                $definitionsByWorkspace[$workspace.WorkspaceId] = @(Invoke-ZtAzureRequest -Path $q2Path -ErrorAction Stop)
            }
            catch {
                $definitionsByWorkspace[$workspace.WorkspaceId] = $null
                Write-PSFMessage "Q2 error querying connector definitions for workspace '$($workspace.WorkspaceName)' in subscription '$($workspace.SubscriptionName)': $_" -Tag Test -Level Warning
            }
        }
    }

    #endregion Data Collection

    #region Assessment Logic

    # Microsoft first-party kinds (inlined from spec 41201).
    # StaticUI (content-hub) connectors expose only { id } in connectorUiConfig and are first-party by definition.
    $microsoftFirstPartyKinds = @(
        'AzureActiveDirectory', 'AzureActivityLog', 'AzureSecurityCenter',
        'AzureAdvancedThreatProtection', 'MicrosoftDefenderAdvancedThreatProtection',
        'MicrosoftCloudAppSecurity', 'MicrosoftThreatProtection', 'MicrosoftThreatIntelligence',
        'Office365', 'OfficeATP', 'OfficeIRM', 'Office365Project', 'OfficePowerBI',
        'IOT', 'MicrosoftPurviewInformationProtection', 'Dynamics365',
        'ThreatIntelligence', 'ThreatIntelligenceTaxii'
    )
    # Dedicated cloud-platform connector kinds are always third-party.
    $thirdPartyCloudKinds = @('AmazonWebServicesCloudTrail', 'AmazonWebServicesS3', 'GCP')
    # Codeless Connector Framework kinds — classification is publisher-based.
    $codelessKinds       = @('GenericUI', 'APIPolling', 'RestApiPoller')
    $microsoftPublishers = @('Microsoft', 'Microsoft Corporation')

    $workspaceResults = foreach ($workspace in $onboardedWorkspaces) {
        $rawConnectors  = $connectorsByWorkspace[$workspace.WorkspaceId]
        $rawDefinitions = $definitionsByWorkspace[$workspace.WorkspaceId]

        $connectorRows     = @()
        $thirdPartyFound   = $false
        $hasUnclassifiable = $false

        if ($null -ne $rawConnectors) {
            # Build definition-publisher lookup for the Q2 fallback when Q1 list-item publisher is null/empty.
            $definitionPublishers = @{}
            foreach ($definition in @($rawDefinitions)) {
                if ($definition.name) {
                    $definitionPublishers[$definition.name] = $definition.properties.connectorUiConfig.publisher
                }
            }

            foreach ($connector in @($rawConnectors)) {
                $kind           = $connector.kind
                $publisher      = $null
                $classification = 'Unclassifiable'
                $connStatus     = 'Investigate'

                if ($kind -eq 'StaticUI') {
                    # Content-hub connector — first-party, publisher not surfaced by API.
                    $publisher      = '—'
                    $classification = 'Microsoft first-party'
                    $connStatus     = 'n/a'
                }
                elseif ($microsoftFirstPartyKinds -contains $kind) {
                    $publisher      = '—'
                    $classification = 'Microsoft first-party'
                    $connStatus     = 'n/a'
                }
                elseif ($thirdPartyCloudKinds -contains $kind) {
                    $publisher      = '—'
                    $classification = 'Third-party'
                    $connStatus     = 'Pass'
                    $thirdPartyFound = $true
                }
                elseif ($codelessKinds -contains $kind) {
                    # Resolve publisher: Q1 list-item first; Q2 definition as fallback.
                    $publisher = $connector.properties.connectorUiConfig.publisher
                    if ([string]::IsNullOrWhiteSpace($publisher)) {
                        $definitionName = $connector.properties.connectorDefinitionName
                        if ($definitionName) {
                            $publisher = $definitionPublishers[$definitionName]
                        }
                    }

                    if ([string]::IsNullOrWhiteSpace($publisher)) {
                        # Publisher unresolvable after Q1 + Q2 — unclassifiable.
                        $publisher         = '—'
                        $classification    = 'Unclassifiable'
                        $connStatus        = 'Investigate'
                        $hasUnclassifiable = $true
                    }
                    elseif ($customerPublisher -and $publisher -eq $customerPublisher) {
                        # Customer-authored codeless connector — out of scope (evaluated by spec 41203).
                        $classification = 'Custom (41203)'
                        $connStatus     = 'n/a'
                    }
                    elseif ($microsoftPublishers -contains $publisher) {
                        $classification = 'Microsoft first-party'
                        $connStatus     = 'n/a'
                    }
                    elseif (-not $customerPublisher) {
                        # Customer identity unresolvable — cannot confirm connector is not customer-authored.
                        $classification    = 'Unclassifiable'
                        $connStatus        = 'Investigate'
                        $hasUnclassifiable = $true
                    }
                    else {
                        # Non-Microsoft, non-customer publisher — third-party.
                        $classification = 'Third-party'
                        $connStatus     = 'Pass'
                        $thirdPartyFound = $true
                    }
                }
                else {
                    # Unknown kind — treat conservatively as unclassifiable.
                    $publisher         = '—'
                    $classification    = 'Unclassifiable'
                    $connStatus        = 'Investigate'
                    $hasUnclassifiable = $true
                }

                $connectorRows += [PSCustomObject]@{
                    ConnectorName   = $connector.name
                    Kind            = $kind
                    Publisher       = if ([string]::IsNullOrWhiteSpace($publisher)) { '—' } else { $publisher }
                    Classification  = $classification
                    ConnectorStatus = $connStatus
                }
            }
        }

        # Workspace roll-up: unresolved signals (unclassifiable connectors, Q1 error) take precedence over Pass.
        $rowStatus = if ($null -eq $rawConnectors) {
            'Investigate'
        }
        elseif ($thirdPartyFound -and -not $hasUnclassifiable) {
            'Pass'
        }
        else {
            'Investigate'
        }

        [PSCustomObject]@{
            SubscriptionName    = $workspace.SubscriptionName
            SubscriptionId      = $workspace.SubscriptionId
            WorkspaceName       = $workspace.WorkspaceName
            ResourceGroup       = $workspace.ResourceGroup
            WorkspaceId         = $workspace.WorkspaceId
            ConnectorRows       = $connectorRows
            ThirdPartyCount     = @($connectorRows | Where-Object { $_.Classification -eq 'Third-party' }).Count
            FirstPartyCount     = @($connectorRows | Where-Object { $_.Classification -eq 'Microsoft first-party' }).Count
            CustomCount         = @($connectorRows | Where-Object { $_.Classification -eq 'Custom (41203)' }).Count
            UnclassifiableCount = @($connectorRows | Where-Object { $_.Classification -eq 'Unclassifiable' }).Count
            RowStatus           = $rowStatus
        }
    }
    $workspaceResults = @($workspaceResults)

    # Add Investigate placeholders for permission-error and onboarding-error workspaces.
    $unresolvedWorkspaceResults = foreach ($workspace in @($forbiddenWorkspaces) + @($unresolvedWorkspaces)) {
        [PSCustomObject]@{
            SubscriptionName    = $workspace.SubscriptionName
            SubscriptionId      = $workspace.SubscriptionId
            WorkspaceName       = $workspace.WorkspaceName
            ResourceGroup       = $workspace.ResourceGroup
            WorkspaceId         = $workspace.WorkspaceId
            ConnectorRows       = @()
            ThirdPartyCount     = 0
            FirstPartyCount     = 0
            CustomCount         = 0
            UnclassifiableCount = 0
            RowStatus           = 'Investigate'
        }
    }
    $workspaceResults = @($workspaceResults) + @($unresolvedWorkspaceResults)

    # Tenant roll-up: Pass only when every evaluated workspace has at least one third-party connector.
    $investigateItems = @($workspaceResults | Where-Object { $_.RowStatus -eq 'Investigate' })
    $passedItems      = @($workspaceResults | Where-Object { $_.RowStatus -eq 'Pass' })

    $passed       = ($investigateItems.Count -eq 0) -and ($passedItems.Count -gt 0)
    $customStatus = if (-not $passed) { 'Investigate' } else { $null }

    if ($passed) {
        $testResultMarkdown = "✅ At least one third-party (non-Microsoft) data connector is configured in the Sentinel workspace.`n`n%TestResult%"
    }
    else {
        $testResultMarkdown = "⚠️ No third-party data connector was found — confirm whether non-Microsoft workloads (AWS, GCP, on-premises Syslog/CEF, or third-party SaaS/security sources) are in scope, since the check cannot self-determine workload scope; or a connector's publisher could not be classified as Microsoft vs third-party; or a discovery, onboarding, Q1, or Q2 call returned an authorization failure, throttling, service error, or malformed response.`n`n%TestResult%"
    }

    #endregion Assessment Logic

    #region Report Generation

    $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"

    $formatTemplate = @'
 
 
## [{0}]({1})
 
| Subscription | Workspace | Connector name | Kind | Publisher | Classification | Status |
| :----------- | :-------- | :------------- | :--- | :-------- | :------------- | :----- |
{2}
 
### Workspace summary
 
| Subscription | Workspace | Third-party | Microsoft first-party | Custom (41203) | Unclassifiable | Status |
| :----------- | :-------- | :---------- | :-------------------- | :------------- | :------------- | :----- |
{3}
'@


    $connectorTableRows   = ''
    $workspaceSummaryRows = ''
    $statusPriority       = @{ Investigate = 0; Pass = 1 }
    $sortedResults        = @($workspaceResults | Sort-Object { $statusPriority[$_.RowStatus] }, SubscriptionName, WorkspaceName)

    foreach ($wsResult in $sortedResults) {
        $subLink       = "$portalHost/#resource/subscriptions/$($wsResult.SubscriptionId)"
        $sentinelId    = "/subscriptions/$($wsResult.SubscriptionId)/resourcegroups/$($wsResult.ResourceGroup)/providers/microsoft.securityinsightsarg/sentinel/$($wsResult.WorkspaceName)"
        $connectorLink = "$portalHost/#view/Microsoft_Azure_Security_Insights/MainMenuBlade/~/DataConnectors/id/$($sentinelId -replace '/', '%2F')"
        $subMd         = "[$(Get-SafeMarkdown $wsResult.SubscriptionName)]($subLink)"
        $workspaceMd   = "[$(Get-SafeMarkdown $wsResult.WorkspaceName)]($connectorLink)"

        # Connector table: one row per connector; placeholder when Q1 failed or zero connectors.
        if ($wsResult.ConnectorRows.Count -eq 0) {
            $wsStatusDisplay    = if ($wsResult.RowStatus -eq 'Pass') { '✅ Pass' } else { '⚠️ Investigate' }
            $connectorTableRows += "| $subMd | $workspaceMd | — | — | — | — | $wsStatusDisplay |`n"
        }
        else {
            foreach ($row in $wsResult.ConnectorRows) {
                $rowStatusDisplay = switch ($row.ConnectorStatus) {
                    'Pass'        { '✅ Pass' }
                    'Investigate' { '⚠️ Investigate' }
                    default       { 'n/a' }
                }
                $publisherMd        = if ($row.Publisher -eq '—') { '—' } else { Get-SafeMarkdown $row.Publisher }
                $connectorTableRows += "| $subMd | $workspaceMd | $(Get-SafeMarkdown $row.ConnectorName) | $($row.Kind) | $publisherMd | $($row.Classification) | $rowStatusDisplay |`n"
            }
        }

        $wsStatusDisplay      = if ($wsResult.RowStatus -eq 'Pass') { '✅ Pass' } else { '⚠️ Investigate' }
        $workspaceSummaryRows += "| $subMd | $workspaceMd | $($wsResult.ThirdPartyCount) | $($wsResult.FirstPartyCount) | $($wsResult.CustomCount) | $($wsResult.UnclassifiableCount) | $wsStatusDisplay |`n"
    }

    $mdInfo             = $formatTemplate -f 'Data connectors per Sentinel workspace', $portalSentinelLink, $connectorTableRows, $workspaceSummaryRows
    $testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo

    #endregion Report Generation

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

    Add-ZtTestResultDetail @params
}