modules/Devolutions.CIEM.Graph/Tests/Unit/CIEMAttackPathPattern.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"
    }

    Sync-CIEMAttackPathRuleCatalog | Out-Null
}

Describe 'Get-CIEMAttackPathPattern — catalog projection' {

    BeforeEach {
        Invoke-CIEMQuery -Query 'DELETE FROM attack_paths'
        Invoke-CIEMQuery -Query 'DELETE FROM attack_path_rules'
        Sync-CIEMAttackPathRuleCatalog | Out-Null
    }

    Context 'Command structure' {
        It 'is available as a public command' {
            Get-Command -Module Devolutions.CIEM -Name Get-CIEMAttackPathPattern -ErrorAction Stop | Should -Not -BeNullOrEmpty
        }

        It 'has no filter parameters (callers use Where-Object)' {
            $cmd = Get-Command -Module Devolutions.CIEM -Name Get-CIEMAttackPathPattern
            $cmd.Parameters.Keys | Where-Object { $_ -notin @('Verbose','Debug','ErrorAction','WarningAction','InformationAction','ErrorVariable','WarningVariable','InformationVariable','OutVariable','OutBuffer','PipelineVariable','ProgressAction') } | Should -BeNullOrEmpty
        }
    }

    Context 'Happy path — shipped catalog' {

        It 'returns all 10 known pattern IDs' {
            $results = @(Get-CIEMAttackPathPattern)
            $ids = @($results | ForEach-Object { $_.Id })
            $ids | Should -Contain 'disabled-account-with-roles'
            $ids | Should -Contain 'dormant-privileged-subscription-access'
            $ids | Should -Contain 'group-inherited-privilege-escalation'
            $ids | Should -Contain 'internet-exposed-privileged-mi'
            $ids | Should -Contain 'open-management-port'
            $ids | Should -Contain 'public-vm-to-keyvault'
            $ids | Should -Contain 'guest-user-with-privileged-role'
            $ids | Should -Contain 'privileged-managed-identity-broad-scope'
            $ids | Should -Contain 'service-principal-owner-on-subscription'
            $ids | Should -Contain 'guest-in-privileged-group'
        }

        It 'returns at least 10 patterns (resilient to future additions)' {
            @(Get-CIEMAttackPathPattern).Count | Should -BeGreaterOrEqual 10
        }

        $knownPatterns = @(
            @{ Id = 'disabled-account-with-roles';                   StepCount = 2; Severity = 'high';     Category = 'identity-hygiene' }
            @{ Id = 'dormant-privileged-subscription-access';        StepCount = 3; Severity = 'critical'; Category = 'identity-hygiene' }
            @{ Id = 'group-inherited-privilege-escalation';          StepCount = 2; Severity = 'high';     Category = 'identity-privilege' }
            @{ Id = 'internet-exposed-privileged-mi';                StepCount = 8; Severity = 'critical'; Category = 'identity-network-compound' }
            @{ Id = 'open-management-port';                          StepCount = 3; Severity = 'high';     Category = 'network-exposure' }
            @{ Id = 'public-vm-to-keyvault';                         StepCount = 9; Severity = 'critical'; Category = 'identity-network-compound' }
            @{ Id = 'guest-user-with-privileged-role';               StepCount = 2; Severity = 'critical'; Category = 'identity-privilege' }
            @{ Id = 'privileged-managed-identity-broad-scope';       StepCount = 5; Severity = 'critical'; Category = 'identity-privilege' }
            @{ Id = 'service-principal-owner-on-subscription';       StepCount = 3; Severity = 'high';     Category = 'identity-privilege' }
            @{ Id = 'guest-in-privileged-group';                     StepCount = 4; Severity = 'high';     Category = 'identity-privilege' }
        )

        It 'projects <Id> with StepCount=<StepCount>, Severity=<Severity>, Category=<Category>' -TestCases $knownPatterns {
            param($Id, $StepCount, $Severity, $Category)
            $p = Get-CIEMAttackPathPattern | Where-Object Id -eq $Id
            $p           | Should -Not -BeNullOrEmpty
            $p.StepCount | Should -Be $StepCount
            $p.Severity  | Should -Be $Severity
            $p.Category  | Should -Be $Category
            $p.Name      | Should -Not -BeNullOrEmpty
            $p.Description | Should -Not -BeNullOrEmpty
            $p.Remediation | Should -Not -BeNullOrEmpty
            $p.RemediationScriptPath | Should -Not -BeNullOrEmpty
        }

        It 'returns remediation guidance for every shipped pattern' {
            $results = @(Get-CIEMAttackPathPattern)
            foreach ($p in $results) {
                $p.Remediation | Should -Not -BeNullOrEmpty -Because "pattern '$($p.Id)' must provide remediation guidance"
                $p.Remediation | Should -Match 'rerun Azure discovery' -Because "pattern '$($p.Id)' remediation must include validation guidance"
            }
        }

        It 'maps every shipped pattern to a rule-name slug remediation script folder' {
            $scriptRoot = InModuleScope Devolutions.CIEM { $script:ModuleRoot }
            $results = @(Get-CIEMAttackPathPattern)
            foreach ($p in $results) {
                $slug = InModuleScope Devolutions.CIEM -Parameters @{ name = $p.Name } {
                    param($name)
                    ConvertToCIEMAttackPathRuleSlug -Name $name
                }
                $p.RemediationScriptPath | Should -Be "modules/Devolutions.CIEM.Graph/Data/attack_path_remediation_scripts/$slug.ps1"
                Join-Path $scriptRoot $p.RemediationScriptPath | Should -Exist
            }
        }
    }

    Context 'Return shape' {

        It 'returns typed CIEMAttackPathRule objects' {
            $first = @(Get-CIEMAttackPathPattern)[0]
            $first.GetType().Name | Should -Be 'CIEMAttackPathRule'
        }

        It 'StepCount is [int], not [string]' {
            $first = @(Get-CIEMAttackPathPattern)[0]
            $first.StepCount | Should -BeOfType [int]
        }

        It 'Remediation is [string], not an object or array' {
            $first = @(Get-CIEMAttackPathPattern)[0]
            $first.Remediation | Should -BeOfType [string]
        }

        It 'RemediationScriptPath is [string], not an object or array' {
            $first = @(Get-CIEMAttackPathPattern)[0]
            $first.RemediationScriptPath | Should -BeOfType [string]
        }

        It 'always returns an array (full catalog)' {
            ,(Get-CIEMAttackPathPattern) | Should -BeOfType [array]
        }

        It 'all Severity values are lowercase (case-sensitive match)' {
            $results = @(Get-CIEMAttackPathPattern)
            foreach ($p in $results) {
                $p.Severity | Should -CMatch '^(critical|high|medium|low)$'
            }
        }
    }

    Context 'Caller-side filtering via Where-Object' {

        It 'filtering by Severity critical returns exactly 5 patterns' {
            $critical = @(Get-CIEMAttackPathPattern | Where-Object Severity -eq 'critical')
            $critical.Count | Should -Be 5
            $critical | ForEach-Object { $_.Severity | Should -Be 'critical' }
        }

        It 'filtering by Severity high returns exactly 5 patterns' {
            $high = @(Get-CIEMAttackPathPattern | Where-Object Severity -eq 'high')
            $high.Count | Should -Be 5
            $high | ForEach-Object { $_.Severity | Should -Be 'high' }
        }
    }

    Context 'Schema guardrails — shipped patterns only use known primitives' {

        BeforeAll {
            $script:ValidNodeKinds = @(
                'EntraUser', 'EntraServicePrincipal', 'EntraGroup', 'EntraManagedIdentity', 'EntraDirectoryRole',
                'AzureTenant', 'AzureSubscription', 'AzureResourceGroup', 'AzureVM', 'AzureNIC', 'AzureNSG',
                'AzureVNet', 'AzurePublicIP', 'AzureKeyVault', 'AzureRoleAssignment',
                'Internet'
            )
            $script:ValidEdgeKinds = @(
                'HasRole', 'InheritedRole', 'MemberOf', 'TransitiveMemberOf', 'OwnerOf',
                'HasRoleMember', 'HasManagedIdentity', 'AttachedTo', 'HasPublicIP',
                'InSubnet', 'AllowsInbound', 'ContainedIn'
            )
            $script:ValidFilterOps = @('eq', 'neq', 'gt', 'lt', 'gt_or_null', 'in', 'contains_port')

            $script:PatternDir = InModuleScope Devolutions.CIEM { Join-Path $script:GraphRoot 'Data' 'attack_paths' }
            $script:PatternFiles = @(Get-ChildItem -Path $script:PatternDir -Filter '*.json' -File)
        }

        It 'every step with a kind uses a known node kind' {
            foreach ($file in $script:PatternFiles) {
                $raw = Get-Content $file.FullName -Raw | ConvertFrom-Json
                foreach ($step in $raw.steps) {
                    if ($step.kind) {
                        $kinds = @($step.kind)
                        foreach ($k in $kinds) {
                            $k | Should -BeIn $script:ValidNodeKinds -Because "pattern '$($raw.id)' in $($file.Name) uses unknown kind '$k'"
                        }
                    }
                }
            }
        }

        It 'every step with an edge uses a known edge kind' {
            foreach ($file in $script:PatternFiles) {
                $raw = Get-Content $file.FullName -Raw | ConvertFrom-Json
                foreach ($step in $raw.steps) {
                    if ($step.edge) {
                        $step.edge | Should -BeIn $script:ValidEdgeKinds -Because "pattern '$($raw.id)' in $($file.Name) uses unknown edge '$($step.edge)'"
                    }
                }
            }
        }

        It 'every filter op is a known operator' {
            foreach ($file in $script:PatternFiles) {
                $raw = Get-Content $file.FullName -Raw | ConvertFrom-Json
                foreach ($step in $raw.steps) {
                    if ($step.node_filter) {
                        $step.node_filter.op | Should -BeIn $script:ValidFilterOps -Because "pattern '$($raw.id)' node_filter op '$($step.node_filter.op)' is unknown"
                    }
                    if ($step.filter) {
                        $step.filter.op | Should -BeIn $script:ValidFilterOps -Because "pattern '$($raw.id)' edge filter op '$($step.filter.op)' is unknown"
                    }
                }
            }
        }

        It 'every pattern has required top-level fields' {
            foreach ($file in $script:PatternFiles) {
                $raw = Get-Content $file.FullName -Raw | ConvertFrom-Json
                $raw.id          | Should -Not -BeNullOrEmpty -Because "pattern in $($file.Name) missing id"
                $raw.name        | Should -Not -BeNullOrEmpty -Because "pattern '$($raw.id)' missing name"
                $raw.severity    | Should -Not -BeNullOrEmpty -Because "pattern '$($raw.id)' missing severity"
                $raw.category    | Should -Not -BeNullOrEmpty -Because "pattern '$($raw.id)' missing category"
                $raw.description | Should -Not -BeNullOrEmpty -Because "pattern '$($raw.id)' missing description"
                $raw.remediation | Should -Not -BeNullOrEmpty -Because "pattern '$($raw.id)' missing remediation"
                $raw.remediation_script | Should -Not -BeNullOrEmpty -Because "pattern '$($raw.id)' missing remediation_script"
                @($raw.steps).Count | Should -BeGreaterThan 0 -Because "pattern '$($raw.id)' has no steps"
            }
        }

        It 'every remediation script template has no unknown replacement token format' {
            $scriptRoot = InModuleScope Devolutions.CIEM { $script:ModuleRoot }
            foreach ($file in $script:PatternFiles) {
                $raw = Get-Content $file.FullName -Raw | ConvertFrom-Json
                $scriptPath = Join-Path $scriptRoot $raw.remediation_script
                $scriptPath | Should -Exist -Because "pattern '$($raw.id)' references a missing remediation script template"
                $content = Get-Content $scriptPath -Raw
                $tokens = @([regex]::Matches($content, '{{([A-Z0-9_]+)}}') | ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique)
                foreach ($token in $tokens) {
                    $token | Should -BeIn @(
                        'PATTERN_NAME',
                        'PATH_CHAIN',
                        'ROLE_ASSIGNMENT_DELETE_COMMANDS',
                        'NSG_RULE_DELETE_COMMANDS',
                        'GROUP_MEMBER_REMOVE_COMMANDS',
                        'AUTH_PROFILE_ID',
                        'AUTH_PROFILE_NAME',
                        'AUTH_PROFILE_METHOD',
                        'TENANT_ID',
                        'CLIENT_ID',
                        'MANAGED_IDENTITY_CLIENT_ID',
                        'PSU_ENVIRONMENT',
                        'PSU_WEBSITE_NAME'
                    ) -Because "pattern '$($raw.id)' template uses unknown token '$token'"
                }
            }
        }
    }

    Context 'Database-backed rule handling' {

        It 'returns caller-created active database rules as typed objects' {
            Invoke-CIEMQuery -Query @'
INSERT INTO attack_path_rules (
    id, name, severity, category, description, remediation,
    remediation_script_path, psu_script_name, steps_json, disabled, updated_at
) VALUES (
    @id, @name, @severity, @category, @description, @remediation,
    @remediation_script_path, @psu_script_name, @steps_json, 0, @updated_at
)
'@
 -Parameters @{
                id                      = 'custom-db-rule'
                name                    = 'Custom DB Rule'
                severity                = 'low'
                category                = 'custom'
                description             = 'Custom rule from database'
                remediation             = 'Fix the custom rule and rerun Azure discovery.'
                remediation_script_path = 'modules/Devolutions.CIEM.Graph/Data/attack_path_remediation_scripts/management-port-open-to-the-internet.ps1'
                psu_script_name         = 'management-port-open-to-the-internet'
                steps_json              = '[{"kind":"Internet"},{"edge":"AllowsInbound","direction":"outbound"},{"kind":"AzureNSG"}]'
                updated_at              = '2026-04-17T00:00:00.0000000Z'
            } -AsNonQuery | Out-Null

            $rule = @(Get-CIEMAttackPathPattern | Where-Object Id -eq 'custom-db-rule')[0]

            $rule.GetType().Name | Should -Be 'CIEMAttackPathRule'
            $rule.Id | Should -Be 'custom-db-rule'
            $rule.StepCount | Should -Be 3
            $rule.PsuScriptName | Should -Be 'management-port-open-to-the-internet'
        }

        It 'excludes disabled database rules' {
            Invoke-CIEMQuery -Query @'
INSERT INTO attack_path_rules (
    id, name, severity, category, description, remediation,
    remediation_script_path, psu_script_name, steps_json, disabled, updated_at
) VALUES (
    @id, @name, @severity, @category, @description, @remediation,
    @remediation_script_path, @psu_script_name, @steps_json, 1, @updated_at
)
'@
 -Parameters @{
                id                      = 'disabled-db-rule'
                name                    = 'Disabled DB Rule'
                severity                = 'low'
                category                = 'custom'
                description             = 'Disabled rule from database'
                remediation             = 'Fix the custom rule and rerun Azure discovery.'
                remediation_script_path = 'modules/Devolutions.CIEM.Graph/Data/attack_path_remediation_scripts/management-port-open-to-the-internet.ps1'
                psu_script_name         = 'management-port-open-to-the-internet'
                steps_json              = '[{"kind":"Internet"},{"edge":"AllowsInbound","direction":"outbound"},{"kind":"AzureNSG"}]'
                updated_at              = '2026-04-17T00:00:00.0000000Z'
            } -AsNonQuery | Out-Null

            @(Get-CIEMAttackPathPattern | Where-Object Id -eq 'disabled-db-rule') | Should -HaveCount 0
        }

        It 'throws when an active database rule has no PSU script reference' {
            Invoke-CIEMQuery -Query @'
INSERT INTO attack_path_rules (
    id, name, severity, category, description, remediation,
    remediation_script_path, psu_script_name, steps_json, disabled, updated_at
) VALUES (
    @id, @name, @severity, @category, @description, @remediation,
    @remediation_script_path, @psu_script_name, @steps_json, 0, @updated_at
)
'@
 -Parameters @{
                id                      = 'missing-script-rule'
                name                    = 'Missing Script Rule'
                severity                = 'low'
                category                = 'custom'
                description             = 'Invalid active rule'
                remediation             = 'Fix the custom rule and rerun Azure discovery.'
                remediation_script_path = 'modules/Devolutions.CIEM.Graph/Data/attack_path_remediation_scripts/management-port-open-to-the-internet.ps1'
                psu_script_name         = ''
                steps_json              = '[{"kind":"Internet"},{"edge":"AllowsInbound","direction":"outbound"},{"kind":"AzureNSG"}]'
                updated_at              = '2026-04-17T00:00:00.0000000Z'
            } -AsNonQuery | Out-Null

            { Get-CIEMAttackPathPattern } | Should -Throw '*PSU script reference*'
        }
    }
}