modules/Devolutions.CIEM.Graph/Tests/Unit/CIEMAttackPathPersistence.Tests.ps1
|
BeforeAll { Remove-Module Devolutions.CIEM -Force -ErrorAction SilentlyContinue Import-Module (Join-Path $PSScriptRoot '..' '..' '..' '..' 'Devolutions.CIEM.psd1') Mock -ModuleName Devolutions.CIEM Write-CIEMLog {} New-CIEMDatabase -Path "$TestDrive/ciem.db" $azureSchema = Join-Path $PSScriptRoot '..' '..' '..' 'Azure' 'Infrastructure' 'Data' 'azure_schema.sql' Invoke-CIEMQuery -Query (Get-Content $azureSchema -Raw) $discoverySchema = Join-Path $PSScriptRoot '..' '..' '..' 'Azure' 'Discovery' 'Data' 'discovery_schema.sql' Invoke-CIEMQuery -Query (Get-Content $discoverySchema -Raw) $graphSchema = Join-Path $PSScriptRoot '..' '..' 'Data' 'graph_schema.sql' Invoke-CIEMQuery -Query (Get-Content $graphSchema -Raw) InModuleScope Devolutions.CIEM { $script:DatabasePath = "$TestDrive/ciem.db" } } Describe 'Attack Path persistence' { BeforeEach { Invoke-CIEMQuery -Query 'DELETE FROM attack_paths' Invoke-CIEMQuery -Query 'DELETE FROM attack_path_rules' Invoke-CIEMQuery -Query 'DELETE FROM graph_edges' Invoke-CIEMQuery -Query 'DELETE FROM graph_nodes' } Context 'database schema' { It 'creates attack_path_rules and attack_paths tables' { $tables = @(Invoke-CIEMQuery -Query "SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('attack_path_rules', 'attack_paths') ORDER BY name") $tables.name | Should -Contain 'attack_path_rules' $tables.name | Should -Contain 'attack_paths' } It 'stores the PSU script reference on attack path rules' { $columns = @(Invoke-CIEMQuery -Query "PRAGMA table_info('attack_path_rules')") $columnNames = @($columns | ForEach-Object { $_.name }) $columnNames | Should -Contain 'psu_script_name' $columnNames | Should -Contain 'steps_json' } It 'stores materialized path and edge JSON on attack paths' { $columns = @(Invoke-CIEMQuery -Query "PRAGMA table_info('attack_paths')") $columnNames = @($columns | ForEach-Object { $_.name }) $columnNames | Should -Contain 'path_json' $columnNames | Should -Contain 'edges_json' $columnNames | Should -Contain 'path_chain' } } Context 'rule catalog sync' { It 'stores shipped attack path rules in the database with PSU script names' { $result = Sync-CIEMAttackPathRuleCatalog $result.Status | Should -Be 'Synced' $result.RuleCount | Should -BeGreaterOrEqual 10 $row = Invoke-CIEMQuery -Query "SELECT id, name, psu_script_name, steps_json FROM attack_path_rules WHERE id = @id" -Parameters @{ id = 'open-management-port' } $row.id | Should -Be 'open-management-port' $row.name | Should -Be 'Management port open to the internet' $row.psu_script_name | Should -Be 'management-port-open-to-the-internet' $row.steps_json | Should -Match 'AllowsInbound' } It 'returns attack path patterns from the database rule catalog' { Sync-CIEMAttackPathRuleCatalog | Out-Null $pattern = Get-CIEMAttackPathPattern | Where-Object Id -eq 'open-management-port' $pattern | Should -Not -BeNullOrEmpty $pattern.GetType().Name | Should -Be 'CIEMAttackPathRule' $pattern.StepCount | Should -Be 3 $pattern.PsuScriptName | Should -Be 'management-port-open-to-the-internet' $pattern.RemediationScriptPath | Should -Be 'modules/Devolutions.CIEM.Graph/Data/attack_path_remediation_scripts/management-port-open-to-the-internet.ps1' } } Context 'attack path materialization' { BeforeEach { Sync-CIEMAttackPathRuleCatalog | Out-Null Save-CIEMGraphNode -Id '__internet__' -Kind 'Internet' -DisplayName 'Internet' -Provider 'global' Save-CIEMGraphNode -Id '/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Network/networkSecurityGroups/nsg1' -Kind 'AzureNSG' -DisplayName 'nsg1' -Provider 'azure' Save-CIEMGraphEdge -SourceId '__internet__' -TargetId '/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Network/networkSecurityGroups/nsg1' -Kind 'AllowsInbound' ` -Properties '{"open_ports":[{"port":3389,"protocol":"TCP","rule_name":"AllowRDP"}]}' -Computed 1 } It 'materializes evaluated attack paths into attack_paths' { $result = @(Update-CIEMAttackPath -PatternId 'open-management-port' -PassThru) $result | Should -HaveCount 1 $result[0].GetType().Name | Should -Be 'CIEMAttackPath' $result[0].PatternId | Should -Be 'open-management-port' $result[0].PsuScriptName | Should -Be 'management-port-open-to-the-internet' $result[0].PathChain | Should -Match 'Internet' $result[0].PathChain | Should -Match 'nsg1' $row = Invoke-CIEMQuery -Query 'SELECT rule_id, path_json, edges_json, path_chain FROM attack_paths WHERE id = @id' -Parameters @{ id = $result[0].Id } $row.rule_id | Should -Be 'open-management-port' $row.path_json | Should -Match 'AzureNSG' $row.edges_json | Should -Match 'AllowsInbound' $row.path_chain | Should -Be $result[0].PathChain } It 'returns stored attack paths without re-evaluating the graph' { Update-CIEMAttackPath -PatternId 'open-management-port' | Out-Null Invoke-CIEMQuery -Query 'DELETE FROM graph_edges' Invoke-CIEMQuery -Query 'DELETE FROM graph_nodes' $stored = @(Get-CIEMAttackPath -PatternId 'open-management-port') $stored | Should -HaveCount 1 $stored[0].PatternId | Should -Be 'open-management-port' $stored[0].PathChain | Should -Match 'Internet' $stored[0].RemediationScript | Should -BeNullOrEmpty } It 'removes stale stored attack paths when refreshed' { Update-CIEMAttackPath -PatternId 'open-management-port' | Out-Null Invoke-CIEMQuery -Query 'DELETE FROM graph_edges' $result = @(Update-CIEMAttackPath -PatternId 'open-management-port' -PassThru) $stored = @(Get-CIEMAttackPath -PatternId 'open-management-port') $result | Should -HaveCount 0 $stored | Should -HaveCount 0 } } Context 'PSU remediation script rendering' { BeforeEach { Sync-CIEMAttackPathRuleCatalog | Out-Null Save-CIEMGraphNode -Id '__internet__' -Kind 'Internet' -DisplayName 'Internet' -Provider 'global' Save-CIEMGraphNode -Id '/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Network/networkSecurityGroups/nsg1' -Kind 'AzureNSG' -DisplayName 'nsg1' -Provider 'azure' Save-CIEMGraphEdge -SourceId '__internet__' -TargetId '/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Network/networkSecurityGroups/nsg1' -Kind 'AllowsInbound' ` -Properties '{"open_ports":[{"port":3389,"protocol":"TCP","rule_name":"AllowRDP"}]}' -Computed 1 $script:StoredAttackPath = @(Update-CIEMAttackPath -PatternId 'open-management-port' -PassThru)[0] } It 'reads the PSU script by rule reference and replaces attack path, auth profile, and PSU environment placeholders' { Mock -ModuleName Devolutions.CIEM Get-PSUScript { [pscustomobject]@{ Name = $Name Content = @' # {{PATTERN_NAME}} # {{PATH_CHAIN}} # {{AUTH_PROFILE_ID}} # {{AUTH_PROFILE_NAME}} # {{AUTH_PROFILE_METHOD}} # {{TENANT_ID}} # {{CLIENT_ID}} # {{MANAGED_IDENTITY_CLIENT_ID}} # {{PSU_ENVIRONMENT}} # {{PSU_WEBSITE_NAME}} {{NSG_RULE_DELETE_COMMANDS}} '@ } } Mock -ModuleName Devolutions.CIEM Get-CIEMAzureAuthenticationProfile { [pscustomobject]@{ Id = 'profile-1' Name = 'Production' Method = 'ServicePrincipalSecret' TenantId = 'tenant-1' ClientId = 'client-1' ManagedIdentityClientId = 'mi-client-1' } } Mock -ModuleName Devolutions.CIEM Get-PSUInstalledEnvironment { [pscustomobject]@{ Environment = 'AzureWebApp' SupportsManagedIdentity = $true WebsiteName = 'ciem-prod' } } $scriptText = Get-CIEMAttackPathRemediationScript -Id $script:StoredAttackPath.Id $scriptText | Should -Match '# Management port open to the internet' $scriptText | Should -Match 'Internet \(Internet\)' $scriptText | Should -Match '# profile-1' $scriptText | Should -Match '# Production' $scriptText | Should -Match '# ServicePrincipalSecret' $scriptText | Should -Match '# tenant-1' $scriptText | Should -Match '# client-1' $scriptText | Should -Match '# mi-client-1' $scriptText | Should -Match '# AzureWebApp' $scriptText | Should -Match '# ciem-prod' $scriptText | Should -Match 'az network nsg rule delete' $scriptText | Should -Not -Match '{{' Should -Invoke -CommandName Get-PSUScript -ModuleName Devolutions.CIEM -Times 1 -ParameterFilter { $Name -eq 'management-port-open-to-the-internet' -and $Integrated } } } } |