tests/Test-Assessment.41214.ps1
|
<#
.SYNOPSIS At least one Microsoft Sentinel automation rule executes a playbook for automated threat response. .DESCRIPTION Enumerates Sentinel-onboarded Log Analytics workspaces and evaluates every RunPlaybook action. Each workspace passes only when it has an eligible action and every eligible action references an existing, enabled Logic App. The tenant passes only when every evaluated Sentinel workspace passes. .NOTES Test ID: 41214 Workshop Task: SECOPS_109 Pillar: SecOps Category: Security information and event management Required API: Azure Resource Manager (management.azure.com) #> function Test-Assessment-41214 { [ZtTest( Category = 'Security information and event management', ImplementationCost = 'Medium', MinimumLicense = ('Consumption-based: Microsoft Sentinel'), Pillar = 'SecOps', RiskLevel = 'Medium', Service = ('Azure'), SfiPillar = 'Accelerate response and remediation', TenantType = ('Workforce'), TestId = 41214, Title = 'At least one Microsoft Sentinel automation rule executes a playbook for automated threat response', UserImpact = 'Low' )] [CmdletBinding()] param() #region Data Collection Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose $activity = 'Checking playbook automation in Microsoft Sentinel workspaces' # Sentinel workspace discovery and onboarding checks are provided by the shared helper. $allWorkspaces = Get-SentinelWorkspaceData -Activity $activity if ($null -eq $allWorkspaces -or $allWorkspaces -eq 'Forbidden') { $params = @{ TestId = '41214' Title = 'At least one Microsoft Sentinel automation rule executes a playbook for automated threat response' Status = $false Result = '⚠️ Automation rules or the referenced playbook workflow could not be resolved or read.' CustomStatus = 'Investigate' } Add-ZtTestResultDetail @params return } if ($allWorkspaces -in @('NoSubscriptions', 'NoWorkspaces')) { Write-PSFMessage 'No Sentinel-onboarded workspaces are available for playbook evaluation.' -Tag Test -Level VeryVerbose Add-ZtTestResultDetail -SkippedBecause NotApplicable return } $checkableWorkspaces = @($allWorkspaces | Where-Object { -not $_.PermissionError }) $forbiddenWorkspaces = @($allWorkspaces | Where-Object { $_.PermissionError }) $onboardedWorkspaces = @($checkableWorkspaces | Where-Object { $_.SentinelOnboarded }) $unresolvedWorkspaces = @($checkableWorkspaces | Where-Object { $_.OnboardingError }) if ($onboardedWorkspaces.Count -eq 0) { if ($forbiddenWorkspaces.Count -gt 0 -or $unresolvedWorkspaces.Count -gt 0) { $params = @{ TestId = '41214' Title = 'At least one Microsoft Sentinel automation rule executes a playbook for automated threat response' Status = $false Result = '⚠️ Automation rules or the referenced playbook workflow could not be resolved or read.' CustomStatus = 'Investigate' } Add-ZtTestResultDetail @params } else { Write-PSFMessage 'No Sentinel-onboarded workspaces were found.' -Tag Test -Level VeryVerbose Add-ZtTestResultDetail -SkippedBecause NotApplicable } return } # Q1: List automation rules for each Sentinel-onboarded workspace. $rulesByWorkspace = @{} foreach ($workspace in $onboardedWorkspaces) { Write-ZtProgress -Activity $activity -Status "Fetching automation rules for '$($workspace.WorkspaceName)' in '$($workspace.SubscriptionName)'" $automationRulesPath = "$($workspace.WorkspaceId)/providers/Microsoft.SecurityInsights/automationRules?api-version=2024-09-01" try { $rules = @(Invoke-ZtAzureRequest -Path $automationRulesPath -ErrorAction Stop) $malformedRuleCount = 0 $actionRecords = [System.Collections.Generic.List[object]]::new() foreach ($rule in $rules) { $propertiesProperty = $rule.PSObject.Properties['properties'] $ruleProperties = if ($null -ne $propertiesProperty) { $propertiesProperty.Value } else { $null } $actionsProperty = if ($null -ne $ruleProperties) { $ruleProperties.PSObject.Properties['actions'] } else { $null } if ($null -eq $actionsProperty -or $null -eq $actionsProperty.Value) { $malformedRuleCount++ continue } foreach ($action in @($actionsProperty.Value | Where-Object { $_.actionType -eq 'RunPlaybook' })) { $triggeringLogicProperty = $ruleProperties.PSObject.Properties['triggeringLogic'] $triggeringLogic = if ($null -ne $triggeringLogicProperty) { $triggeringLogicProperty.Value } else { $null } $enabledProperty = if ($null -ne $triggeringLogic) { $triggeringLogic.PSObject.Properties['isEnabled'] } else { $null } $expirationProperty = if ($null -ne $triggeringLogic) { $triggeringLogic.PSObject.Properties['expirationTimeUtc'] } else { $null } $enabledValue = if ($null -ne $enabledProperty) { $enabledProperty.Value } else { $null } $expiration = if ($null -ne $expirationProperty) { $expirationProperty.Value } else { $null } $parsedExpiration = [DateTimeOffset]::MinValue $expirationDisplay = 'Never' $eligibilityError = $null if ($null -eq $triggeringLogic -or $enabledValue -isnot [bool]) { $eligibilityError = 'The automation rule response did not contain a valid enabled state.' } elseif (-not [string]::IsNullOrWhiteSpace([string]$expiration)) { if ([DateTimeOffset]::TryParse([string]$expiration, [ref]$parsedExpiration)) { $expirationDisplay = $parsedExpiration.UtcDateTime.ToString('yyyy-MM-dd HH:mm:ssZ') } else { $expirationDisplay = 'Invalid' $eligibilityError = 'The automation rule response contained an invalid expiration time.' } } $actionRecords.Add([PSCustomObject]@{ Rule = $rule Action = $action RuleEnabled = if ($enabledValue -is [bool]) { $enabledValue } else { $null } ParsedExpiration = $parsedExpiration ExpirationDisplay = $expirationDisplay EligibilityError = $eligibilityError Outcome = 'NotQueried' Response = $null }) } } $rulesByWorkspace[$workspace.WorkspaceId] = [PSCustomObject]@{ TotalRuleCount = $rules.Count MalformedRuleCount = $malformedRuleCount ActionRecords = $actionRecords QueryError = $false } } catch { $rulesByWorkspace[$workspace.WorkspaceId] = [PSCustomObject]@{ TotalRuleCount = 0 MalformedRuleCount = 0 ActionRecords = @() QueryError = $true } Write-PSFMessage "Error querying automation rules for workspace '$($workspace.WorkspaceName)' in subscription '$($workspace.SubscriptionName)': $_" -Tag Test -Level Warning } } # Q2 depends on Q1. Compare tenants before querying each eligible playbook. $now = [DateTimeOffset]::UtcNow $azContext = Get-AzContext -ErrorAction SilentlyContinue $assessmentTenantId = if ($azContext) { [string]$azContext.Tenant.Id } else { $null } foreach ($workspace in $onboardedWorkspaces) { $collectedWorkspace = $rulesByWorkspace[$workspace.WorkspaceId] if ($collectedWorkspace.QueryError) { continue } foreach ($actionRecord in $collectedWorkspace.ActionRecords) { $rule = $actionRecord.Rule $action = $actionRecord.Action $ruleExpired = $actionRecord.ParsedExpiration -ne [DateTimeOffset]::MinValue -and $actionRecord.ParsedExpiration -le $now if ($actionRecord.EligibilityError -or $actionRecord.RuleEnabled -ne $true -or $ruleExpired) { continue } $logicAppResourceId = [string]$action.actionConfiguration.logicAppResourceId if ([string]::IsNullOrWhiteSpace($logicAppResourceId)) { continue } $playbookTenantId = [string]$action.actionConfiguration.tenantId if ([string]::IsNullOrWhiteSpace($playbookTenantId) -or [string]::IsNullOrWhiteSpace($assessmentTenantId)) { $actionRecord.Outcome = 'TenantUnavailable' continue } if ($playbookTenantId -ne $assessmentTenantId) { $actionRecord.Outcome = 'CrossTenant' continue } $playbookApiVersion = if ($logicAppResourceId -match '(?i)/providers/Microsoft\.Logic/workflows/[^/]+/?$') { '2019-05-01' } elseif ($logicAppResourceId -match '(?i)/providers/Microsoft\.Web/sites/[^/]+/workflows/[^/]+/?$') { '2022-09-01' } elseif ($logicAppResourceId -match '(?i)/providers/Microsoft\.Web/sites/') { $actionRecord.Outcome = 'WorkflowUnresolved' continue } else { $actionRecord.Outcome = 'Unsupported' continue } Write-ZtProgress -Activity $activity -Status "Resolving playbook referenced by '$($rule.properties.displayName)'" try { $q2Response = Invoke-ZtAzureRequest -Path "$logicAppResourceId`?api-version=$playbookApiVersion" -FullResponse -ErrorAction Stop $actionRecord.Outcome = 'Response' $actionRecord.Response = $q2Response } catch { $actionRecord.Outcome = 'RequestFailed' Write-PSFMessage "Error resolving playbook referenced by automation rule '$($rule.properties.displayName)': $_" -Tag Test -Level Warning } } } #endregion Data Collection #region Assessment Logic $workspaceResults = [System.Collections.Generic.List[object]]::new() $actionResults = [System.Collections.Generic.List[object]]::new() foreach ($workspace in $onboardedWorkspaces) { $collectedWorkspace = $rulesByWorkspace[$workspace.WorkspaceId] $totalRuleCount = $collectedWorkspace.TotalRuleCount $runPlaybookActionCount = 0 $eligibleActionCount = 0 $excludedActionCount = 0 $healthyActionCount = 0 $unhealthyActionCount = 0 $unresolvedActionCount = $collectedWorkspace.MalformedRuleCount if (-not $collectedWorkspace.QueryError) { $runPlaybookActionCount = $collectedWorkspace.ActionRecords.Count foreach ($actionRecord in $collectedWorkspace.ActionRecords) { $rule = $actionRecord.Rule $action = $actionRecord.Action $ruleEnabled = $actionRecord.RuleEnabled $expirationDisplay = $actionRecord.ExpirationDisplay $ruleExpired = $actionRecord.ParsedExpiration -ne [DateTimeOffset]::MinValue -and $actionRecord.ParsedExpiration -le $now $logicAppResourceId = [string]$action.actionConfiguration.logicAppResourceId $tenantId = [string]$action.actionConfiguration.tenantId $playbookName = if ($logicAppResourceId) { ($logicAppResourceId -split '/')[-1] } else { 'Unavailable' } $playbookType = if ($logicAppResourceId -match '(?i)/providers/Microsoft\.Logic/workflows/') { 'Consumption' } elseif ($logicAppResourceId -match '(?i)/providers/Microsoft\.Web/sites/') { 'Standard' } else { 'Unsupported' } $playbookState = $null $playbookResolved = $null $eligibility = 'Eligible' $q2Result = 'Not queried' $actionStatus = 'Investigate' $actionReason = '' if ($actionRecord.EligibilityError) { $eligibility = 'Unreadable' $actionReason = $actionRecord.EligibilityError $unresolvedActionCount++ } elseif (-not $ruleEnabled) { $eligibility = 'Excluded' $actionStatus = 'Excluded' $actionReason = 'The automation rule is disabled.' $excludedActionCount++ } elseif ($ruleExpired) { $eligibility = 'Excluded' $actionStatus = 'Excluded' $actionReason = 'The automation rule is expired.' $excludedActionCount++ } elseif ([string]::IsNullOrWhiteSpace($logicAppResourceId)) { $eligibleActionCount++ $actionReason = 'The RunPlaybook action did not contain a Logic App resource ID.' $unresolvedActionCount++ } elseif ($actionRecord.Outcome -eq 'WorkflowUnresolved') { $eligibleActionCount++ $actionReason = 'The specific Standard workflow child could not be resolved from the playbook resource ID.' $unresolvedActionCount++ } elseif ($playbookType -eq 'Unsupported' -or $actionRecord.Outcome -eq 'Unsupported') { $eligibleActionCount++ $actionReason = 'The referenced playbook resource type is not supported by the documented workflow query.' $unresolvedActionCount++ } else { $eligibleActionCount++ if ($actionRecord.Outcome -eq 'CrossTenant') { $q2Result = 'Not called (cross-tenant)' $actionReason = 'The referenced playbook is cross-tenant and cannot be evaluated in the current Azure context.' $unresolvedActionCount++ } elseif ($actionRecord.Outcome -eq 'TenantUnavailable') { $q2Result = 'Not called (tenant unavailable)' $actionReason = 'The playbook tenant or assessment context tenant could not be determined.' $unresolvedActionCount++ } elseif ($actionRecord.Outcome -eq 'RequestFailed' -or $null -eq $actionRecord.Response) { $q2Result = 'Request failed' $actionReason = 'The referenced Logic App could not be read.' $unresolvedActionCount++ } else { $statusCode = [int]$actionRecord.Response.StatusCode $q2Result = "HTTP $statusCode" if ($statusCode -eq 200) { try { $workflow = $actionRecord.Response.Content | ConvertFrom-Json -Depth 100 -ErrorAction Stop $workflowPropertiesProperty = if ($null -ne $workflow) { $workflow.PSObject.Properties['properties'] } else { $null } $workflowProperties = if ($null -ne $workflowPropertiesProperty) { $workflowPropertiesProperty.Value } else { $null } $statePropertyName = if ($playbookType -eq 'Standard') { 'flowState' } else { 'state' } $stateProperty = if ($null -ne $workflowProperties) { $workflowProperties.PSObject.Properties[$statePropertyName] } else { $null } $playbookState = if ($null -ne $stateProperty) { $stateProperty.Value } else { $null } $workflowNameProperty = if ($null -ne $workflow) { $workflow.PSObject.Properties['name'] } else { $null } if ($null -ne $workflowNameProperty -and -not [string]::IsNullOrWhiteSpace([string]$workflowNameProperty.Value)) { $playbookName = ([string]$workflowNameProperty.Value -split '/')[-1] } $playbookState = [string]$playbookState $playbookResolved = $true if ($playbookState -eq 'Enabled') { $actionStatus = 'Pass' $actionReason = 'The rule is active and the referenced playbook is enabled.' $healthyActionCount++ } else { $actionStatus = 'Fail' $actionReason = 'The referenced playbook is not enabled.' $unhealthyActionCount++ } } catch { $actionReason = 'The Logic App response was malformed.' $unresolvedActionCount++ } } elseif ($statusCode -eq 404) { $playbookResolved = $false $actionStatus = 'Fail' $actionReason = 'The referenced playbook was not found.' $unhealthyActionCount++ } else { $actionReason = if ($statusCode -in @(401, 403)) { 'The referenced Logic App could not be read.' } else { "The Logic App request returned HTTP $statusCode." } $unresolvedActionCount++ } } } if ($actionStatus -eq 'Investigate' -and $q2Result -ne 'Not queried') { $q2Diagnostics = "Q2 result: $q2Result." if (-not [string]::IsNullOrWhiteSpace($tenantId)) { $q2Diagnostics = "Playbook tenant: $tenantId; $q2Diagnostics" } $actionReason = "$actionReason $q2Diagnostics" } $actionResults.Add([PSCustomObject]@{ SubscriptionName = $workspace.SubscriptionName SubscriptionId = $workspace.SubscriptionId WorkspaceName = $workspace.WorkspaceName WorkspaceId = $workspace.WorkspaceId RuleName = [string]$rule.properties.displayName RuleId = [string]$rule.id RuleEnabled = $ruleEnabled Expiration = $expirationDisplay Eligibility = $eligibility ActionOrder = if ($null -ne $action.order) { [string]$action.order } else { '—' } PlaybookName = $playbookName PlaybookType = $playbookType PlaybookId = $logicAppResourceId PlaybookState = $playbookState PlaybookResolved = $playbookResolved RowStatus = $actionStatus StatusDetails = $actionReason }) } } $workspaceStatus = if ($collectedWorkspace.QueryError) { 'Investigate' } elseif ($unhealthyActionCount -gt 0) { 'Fail' } elseif ($unresolvedActionCount -gt 0) { 'Investigate' } elseif ($eligibleActionCount -gt 0) { 'Pass' } else { 'Fail' } $workspaceReason = switch ($workspaceStatus) { 'Pass' { 'Every eligible RunPlaybook action references an enabled playbook.' } 'Fail' { if ($eligibleActionCount -eq 0) { 'No enabled, non-expired automation rule references a playbook.' } else { 'One or more referenced playbooks are missing, disabled, or suspended.' } } 'Investigate' { if ($collectedWorkspace.QueryError) { 'The automation rules request failed.' } else { 'One or more eligible actions could not be evaluated.' } } } $workspaceResults.Add([PSCustomObject]@{ SubscriptionName = $workspace.SubscriptionName SubscriptionId = $workspace.SubscriptionId WorkspaceName = $workspace.WorkspaceName WorkspaceId = $workspace.WorkspaceId TotalRuleCount = $totalRuleCount RunPlaybookActionCount = $runPlaybookActionCount EligibleActionCount = $eligibleActionCount ExcludedActionCount = $excludedActionCount HealthyActionCount = $healthyActionCount UnhealthyActionCount = $unhealthyActionCount UnresolvedActionCount = $unresolvedActionCount RowStatus = $workspaceStatus StatusDetails = $workspaceReason }) } foreach ($workspace in @($forbiddenWorkspaces) + @($unresolvedWorkspaces)) { $workspaceResults.Add([PSCustomObject]@{ SubscriptionName = $workspace.SubscriptionName SubscriptionId = $workspace.SubscriptionId WorkspaceName = $workspace.WorkspaceName WorkspaceId = $workspace.WorkspaceId TotalRuleCount = 0 RunPlaybookActionCount = 0 EligibleActionCount = 0 ExcludedActionCount = 0 HealthyActionCount = 0 UnhealthyActionCount = 0 UnresolvedActionCount = 1 RowStatus = 'Investigate' StatusDetails = 'The Microsoft Sentinel onboarding state could not be determined.' }) } $failedItems = @($workspaceResults | Where-Object { $_.RowStatus -eq 'Fail' }) $investigateItems = @($workspaceResults | Where-Object { $_.RowStatus -eq 'Investigate' }) $passed = $failedItems.Count -eq 0 -and $investigateItems.Count -eq 0 $customStatus = $null if ($failedItems.Count -gt 0) { $testResultMarkdown = "❌ No automation rule executes a playbook, or a referenced playbook workflow is missing, disabled, or suspended.`n`n%TestResult%" } elseif ($investigateItems.Count -gt 0) { $customStatus = 'Investigate' $testResultMarkdown = "⚠️ Automation rules or the referenced playbook workflow could not be resolved or read.`n`n%TestResult%" } else { $testResultMarkdown = "✅ Microsoft Sentinel executes Consumption or Standard playbooks for automated threat 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" $workspaceRows = '' foreach ($result in $workspaceResults | Sort-Object SubscriptionName, WorkspaceName) { $subscriptionLink = "$portalHost/#resource/subscriptions/$($result.SubscriptionId)" $workspaceLink = "$portalHost/#resource$($result.WorkspaceId)" $subscriptionMd = "[$(Get-SafeMarkdown $result.SubscriptionName)]($subscriptionLink)" $workspaceMd = "[$(Get-SafeMarkdown $result.WorkspaceName)]($workspaceLink)" $statusDisplay = switch ($result.RowStatus) { 'Pass' { '✅ Pass' } 'Fail' { '❌ Fail' } 'Investigate' { '⚠️ Investigate' } } $workspaceRows += "| $subscriptionMd | $workspaceMd | $($result.TotalRuleCount) | $($result.RunPlaybookActionCount) | $($result.EligibleActionCount) | $($result.ExcludedActionCount) | $($result.HealthyActionCount) | $($result.UnhealthyActionCount) | $($result.UnresolvedActionCount) | $statusDisplay | $($result.StatusDetails) |`n" } $workspaceSection = @" ## [Playbook automation summary per Microsoft Sentinel workspace]($portalSentinelLink) | Subscription | Workspace | Total rules | RunPlaybook actions | Eligible | Excluded | Healthy | Unhealthy | Unreadable | Workspace status | Reason | | :----------- | :-------- | ----------: | ------------------: | -------: | -------: | ------: | --------: | ---------: | :--------------- | :----- | $workspaceRows "@ $detailRows = '' $maxDisplay = 10 $statusPriority = @{ Fail = 0; Investigate = 1; Pass = 2; Excluded = 3 } $displayResults = @($actionResults | Sort-Object { $statusPriority[$_.RowStatus] }, SubscriptionName, WorkspaceName, RuleName, ActionOrder) $hasMoreItems = $displayResults.Count -gt $maxDisplay if ($hasMoreItems) { $displayResults = @($displayResults | Select-Object -First $maxDisplay) } foreach ($result in $displayResults) { $subscriptionLink = "$portalHost/#resource/subscriptions/$($result.SubscriptionId)" $workspaceLink = "$portalHost/#resource$($result.WorkspaceId)" $subscriptionMd = "[$(Get-SafeMarkdown $result.SubscriptionName)]($subscriptionLink)" $workspaceMd = "[$(Get-SafeMarkdown $result.WorkspaceName)]($workspaceLink)" $ruleNameMd = Get-SafeMarkdown $result.RuleName if ($result.RuleId) { $ruleNameMd = "[$ruleNameMd]($portalHost/#resource$($result.RuleId))" } $playbookNameMd = Get-SafeMarkdown $result.PlaybookName if ($result.PlaybookId -and $result.PlaybookResolved -eq $true) { $playbookNameMd = "[$playbookNameMd]($portalHost/#resource$($result.PlaybookId))" } $ruleEnabledMd = if ($null -eq $result.RuleEnabled) { '—' } elseif ($result.RuleEnabled) { '✅ Yes' } else { '❌ No' } $stateMd = switch ($result.PlaybookState) { 'Enabled' { '✅ Enabled' } 'Disabled' { '❌ Disabled' } 'Suspended' { '⚠️ Suspended' } default { '—' } } $statusDisplay = switch ($result.RowStatus) { 'Pass' { '✅ Pass' } 'Fail' { '❌ Fail' } 'Investigate' { '⚠️ Investigate' } 'Excluded' { 'Excluded' } } $detailRows += "| $subscriptionMd | $workspaceMd | $ruleNameMd | $ruleEnabledMd | $($result.Expiration) | $($result.Eligibility) | $($result.ActionOrder) | $playbookNameMd | $($result.PlaybookType) | $stateMd | $statusDisplay | $($result.StatusDetails) |`n" } if ($hasMoreItems) { $remainingCount = $actionResults.Count - $maxDisplay $detailRows += "| … | … | $remainingCount more of $($actionResults.Count) total | … | … | … | … | … | … | … | … | [View all in Microsoft Sentinel]($portalSentinelLink) |`n" } if ($detailRows) { $detailSection = @" ## Playbook action details | Subscription | Workspace | Automation rule | Rule enabled | Expiration UTC | Eligibility | Action order | Playbook | Logic App type | Playbook state | Status | Reason | | :----------- | :-------- | :-------------- | :----------- | :------------- | :---------- | -----------: | :------- | :------------- | :------------- | :----- | :----- | $detailRows "@ } else { $detailSection = @' ## Playbook action details No RunPlaybook actions were found. '@ } $formatTemplate = @' {0} {1} '@ $mdInfo = $formatTemplate -f $workspaceSection, $detailSection $testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo #endregion Report Generation $params = @{ TestId = '41214' Title = 'At least one Microsoft Sentinel automation rule executes a playbook for automated threat response' Status = $passed Result = $testResultMarkdown } if ($customStatus) { $params.CustomStatus = $customStatus } Add-ZtTestResultDetail @params } |