modules/Devolutions.CIEM.Graph/Private/ResolveCIEMAttackPathRemediationScript.ps1
|
function ConvertToCIEMAttackPathRuleSlug { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Name ) $ErrorActionPreference = 'Stop' $slug = $Name.Trim().ToLowerInvariant() -replace '[^a-z0-9]+', '-' -replace '^-|-$', '' if ([string]::IsNullOrWhiteSpace($slug)) { throw "Attack path rule name '$Name' cannot be converted to a remediation script folder slug." } $slug } function ConvertToCIEMAttackPathPsuScriptName { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$RemediationScriptPath ) $ErrorActionPreference = 'Stop' $normalizedPath = $RemediationScriptPath.Replace('\', '/') $leaf = @($normalizedPath -split '/')[-1] $scriptName = [System.IO.Path]::GetFileNameWithoutExtension($leaf) if ([string]::IsNullOrWhiteSpace($scriptName)) { throw "Attack path remediation script path '$RemediationScriptPath' cannot be converted to a PSU script name." } $scriptName } function GetCIEMAttackPathRemediationScriptTemplateContent { [CmdletBinding()] param() $ErrorActionPreference = 'Stop' $templatePath = Join-Path $script:GraphRoot 'Data/attack_path_remediation_script_template.ps1' if (-not (Test-Path -Path $templatePath -PathType Leaf)) { throw "Attack path remediation script template not found: $templatePath" } $content = Get-Content -Path $templatePath -Raw if ([string]::IsNullOrWhiteSpace($content)) { throw "Attack path remediation script template is empty: $templatePath" } $content } function MergeCIEMAttackPathRemediationScriptTemplate { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$TemplateContent, [Parameter(Mandatory)] [string]$ScriptBodyContent, [Parameter(Mandatory)] [string]$ScriptName ) $ErrorActionPreference = 'Stop' if ([string]::IsNullOrWhiteSpace($TemplateContent)) { throw "Cannot create attack path remediation script '$ScriptName' because the shared template is empty." } if ([string]::IsNullOrWhiteSpace($ScriptBodyContent)) { throw "Cannot create attack path remediation script '$ScriptName' because the script body is empty." } $bodyPlaceholder = '{{CIEM_ATTACK_PATH_SCRIPT_BODY}}' $placeholderCount = [regex]::Matches($TemplateContent, [regex]::Escape($bodyPlaceholder)).Count if ($placeholderCount -ne 1) { throw "Cannot create attack path remediation script '$ScriptName' because the shared template must contain exactly one $bodyPlaceholder placeholder." } $TemplateContent.Replace($bodyPlaceholder, $ScriptBodyContent.Trim()).TrimEnd() } function ConvertToCIEMPowerShellSingleQuotedString { [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyString()] [string]$Value, [Parameter(Mandatory)] [string]$Name ) $ErrorActionPreference = 'Stop' if ([string]::IsNullOrWhiteSpace($Value)) { throw "Cannot render remediation script because '$Name' is empty." } "'$($Value.Replace("'", "''"))'" } function ConvertFromCIEMAttackPathProperties { [CmdletBinding()] param( [Parameter()] [AllowNull()] [string]$PropertiesJson, [Parameter(Mandatory)] [string]$Context ) $ErrorActionPreference = 'Stop' if ([string]::IsNullOrWhiteSpace($PropertiesJson)) { [pscustomobject]@{} return } try { $PropertiesJson | ConvertFrom-Json -ErrorAction Stop } catch { throw "Cannot render remediation script because $Context properties are invalid JSON: $($_.Exception.Message)" } } function GetCIEMRequiredObjectValue { [CmdletBinding()] param( [Parameter(Mandatory)] [object]$Object, [Parameter(Mandatory)] [string]$PropertyName, [Parameter(Mandatory)] [string]$Context ) $ErrorActionPreference = 'Stop' if (-not $Object.PSObject.Properties[$PropertyName]) { throw "Cannot render remediation script because $Context is missing '$PropertyName'." } $value = [string]$Object.$PropertyName if ([string]::IsNullOrWhiteSpace($value)) { throw "Cannot render remediation script because $Context '$PropertyName' is empty." } $value } function NewCIEMRoleAssignmentDeleteCommandBlock { [CmdletBinding()] param( [Parameter(Mandatory)] [CIEMAttackPath]$AttackPath ) $ErrorActionPreference = 'Stop' $commands = [System.Collections.Generic.List[string]]::new() foreach ($edge in @($AttackPath.Edges | Where-Object { $_.kind -eq 'HasRole' })) { $principalId = GetCIEMRequiredObjectValue -Object $edge -PropertyName 'source_id' -Context 'HasRole edge' $scope = GetCIEMRequiredObjectValue -Object $edge -PropertyName 'target_id' -Context 'HasRole edge' $props = ConvertFromCIEMAttackPathProperties -PropertiesJson $edge.properties -Context 'HasRole edge' $roleDefinitionId = GetCIEMRequiredObjectValue -Object $props -PropertyName 'role_definition_id' -Context 'HasRole edge properties' $commands.Add("az role assignment delete --assignee-object-id $(ConvertToCIEMPowerShellSingleQuotedString -Value $principalId -Name 'principal id') --role $(ConvertToCIEMPowerShellSingleQuotedString -Value $roleDefinitionId -Name 'role definition id') --scope $(ConvertToCIEMPowerShellSingleQuotedString -Value $scope -Name 'scope') --only-show-errors") } if ($commands.Count -eq 0) { throw "Cannot render remediation script because attack path '$($AttackPath.PatternId)' has no direct HasRole edge." } $commands -join "`n" } function NewCIEMNsgRuleDeleteCommandBlock { [CmdletBinding()] param( [Parameter(Mandatory)] [CIEMAttackPath]$AttackPath ) $ErrorActionPreference = 'Stop' $commands = [System.Collections.Generic.List[string]]::new() $seenRuleIds = @{} foreach ($edge in @($AttackPath.Edges | Where-Object { $_.kind -eq 'AllowsInbound' })) { $nsgId = GetCIEMRequiredObjectValue -Object $edge -PropertyName 'target_id' -Context 'AllowsInbound edge' $props = ConvertFromCIEMAttackPathProperties -PropertiesJson $edge.properties -Context 'AllowsInbound edge' if (-not $props.PSObject.Properties['open_ports'] -or @($props.open_ports).Count -eq 0) { throw "Cannot render remediation script because AllowsInbound edge properties are missing 'open_ports'." } foreach ($openPort in @($props.open_ports)) { $ruleName = GetCIEMRequiredObjectValue -Object $openPort -PropertyName 'rule_name' -Context 'AllowsInbound open_ports entry' $ruleId = "$nsgId/securityRules/$ruleName" if (-not $seenRuleIds.ContainsKey($ruleId)) { $seenRuleIds[$ruleId] = $true $commands.Add("az network nsg rule delete --ids $(ConvertToCIEMPowerShellSingleQuotedString -Value $ruleId -Name 'NSG rule id') --only-show-errors") } } } if ($commands.Count -eq 0) { throw "Cannot render remediation script because attack path '$($AttackPath.PatternId)' has no AllowsInbound edge." } $commands -join "`n" } function NewCIEMGroupMemberRemoveCommandBlock { [CmdletBinding()] param( [Parameter(Mandatory)] [CIEMAttackPath]$AttackPath ) $ErrorActionPreference = 'Stop' $commands = [System.Collections.Generic.List[string]]::new() $seenMemberships = @{} foreach ($edge in @($AttackPath.Edges | Where-Object { $_.kind -eq 'MemberOf' })) { $memberId = GetCIEMRequiredObjectValue -Object $edge -PropertyName 'source_id' -Context 'MemberOf edge' $groupId = GetCIEMRequiredObjectValue -Object $edge -PropertyName 'target_id' -Context 'MemberOf edge' $membershipKey = "$groupId|$memberId" if (-not $seenMemberships.ContainsKey($membershipKey)) { $seenMemberships[$membershipKey] = $true $commands.Add("az ad group member remove --group $(ConvertToCIEMPowerShellSingleQuotedString -Value $groupId -Name 'group id') --member-id $(ConvertToCIEMPowerShellSingleQuotedString -Value $memberId -Name 'member id') --only-show-errors") } } foreach ($edge in @($AttackPath.Edges | Where-Object { $_.kind -eq 'InheritedRole' })) { $memberId = GetCIEMRequiredObjectValue -Object $edge -PropertyName 'source_id' -Context 'InheritedRole edge' $props = ConvertFromCIEMAttackPathProperties -PropertiesJson $edge.properties -Context 'InheritedRole edge' $groupId = GetCIEMRequiredObjectValue -Object $props -PropertyName 'inherited_from' -Context 'InheritedRole edge properties' $membershipKey = "$groupId|$memberId" if (-not $seenMemberships.ContainsKey($membershipKey)) { $seenMemberships[$membershipKey] = $true $commands.Add("az ad group member remove --group $(ConvertToCIEMPowerShellSingleQuotedString -Value $groupId -Name 'group id') --member-id $(ConvertToCIEMPowerShellSingleQuotedString -Value $memberId -Name 'member id') --only-show-errors") } } if ($commands.Count -eq 0) { throw "Cannot render remediation script because attack path '$($AttackPath.PatternId)' has no group membership edge." } $commands -join "`n" } function GetCIEMAttackPathChainText { [CmdletBinding()] param( [Parameter(Mandatory)] [CIEMAttackPath]$AttackPath ) $ErrorActionPreference = 'Stop' $labels = @($AttackPath.Path | ForEach-Object { $label = if ($_.display_name) { $_.display_name } else { $_.id } "$label ($($_.kind))" }) $labels -join ' -> ' } function ConvertToCIEMAttackPathId { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$PatternId, [Parameter(Mandatory)] [object[]]$Path, [Parameter(Mandatory)] [object[]]$Edges ) $ErrorActionPreference = 'Stop' if ([string]::IsNullOrWhiteSpace($PatternId)) { throw 'Cannot create attack path id because PatternId is empty.' } if (@($Path).Count -eq 0) { throw "Cannot create attack path id for '$PatternId' because Path is empty." } $parts = [System.Collections.Generic.List[string]]::new() $parts.Add($PatternId) foreach ($node in @($Path)) { $parts.Add((GetCIEMRequiredObjectValue -Object $node -PropertyName 'id' -Context "attack path '$PatternId' node")) } foreach ($edge in @($Edges)) { $edgeId = GetCIEMRequiredObjectValue -Object $edge -PropertyName 'id' -Context "attack path '$PatternId' edge" $sourceId = GetCIEMRequiredObjectValue -Object $edge -PropertyName 'source_id' -Context "attack path '$PatternId' edge" $targetId = GetCIEMRequiredObjectValue -Object $edge -PropertyName 'target_id' -Context "attack path '$PatternId' edge" $kind = GetCIEMRequiredObjectValue -Object $edge -PropertyName 'kind' -Context "attack path '$PatternId' edge" $parts.Add("$edgeId/$sourceId/$targetId/$kind") } $content = $parts -join '|' $sha = [System.Security.Cryptography.SHA256]::Create() try { $bytes = [System.Text.Encoding]::UTF8.GetBytes($content) $hash = [System.BitConverter]::ToString($sha.ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() } "$PatternId-$($hash.Substring(0, 16))" } function GetCIEMActiveAzureAuthenticationProfileForAttackPathScript { [CmdletBinding()] param() $ErrorActionPreference = 'Stop' $profiles = @(Get-CIEMAzureAuthenticationProfile -ProviderId 'azure' -IsActive $true) if ($profiles.Count -ne 1) { throw "Cannot render attack path remediation script because exactly one active Azure authentication profile is required; found $($profiles.Count)." } $profiles[0] } function GetCIEMRequiredTokenObjectValue { [CmdletBinding()] param( [Parameter(Mandatory)] [object]$Object, [Parameter(Mandatory)] [string]$PropertyName, [Parameter(Mandatory)] [string]$Context ) $ErrorActionPreference = 'Stop' if (-not $Object.PSObject.Properties[$PropertyName]) { throw "Cannot render attack path remediation script because $Context is missing '$PropertyName'." } $value = [string]$Object.$PropertyName if ([string]::IsNullOrWhiteSpace($value)) { throw "Cannot render attack path remediation script because $Context '$PropertyName' is empty." } $value } function ResolveCIEMAttackPathScriptContent { [CmdletBinding()] param( [Parameter(Mandatory)] [object]$Pattern, [Parameter(Mandatory)] [CIEMAttackPath]$AttackPath, [Parameter(Mandatory)] [string]$ScriptContent ) $ErrorActionPreference = 'Stop' if ([string]::IsNullOrWhiteSpace($ScriptContent)) { throw "Cannot render attack path remediation script because script content is empty for '$($AttackPath.PsuScriptName)'." } $content = $ScriptContent $tokens = @([regex]::Matches($content, '{{([A-Z0-9_]+)}}') | ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique) $authProfile = $null $psuEnvironment = $null foreach ($token in $tokens) { $value = switch ($token) { 'PATTERN_NAME' { [string]$Pattern.Name; break } 'PATH_CHAIN' { GetCIEMAttackPathChainText -AttackPath $AttackPath; break } 'ROLE_ASSIGNMENT_DELETE_COMMANDS' { NewCIEMRoleAssignmentDeleteCommandBlock -AttackPath $AttackPath; break } 'NSG_RULE_DELETE_COMMANDS' { NewCIEMNsgRuleDeleteCommandBlock -AttackPath $AttackPath; break } 'GROUP_MEMBER_REMOVE_COMMANDS' { NewCIEMGroupMemberRemoveCommandBlock -AttackPath $AttackPath; break } 'AUTH_PROFILE_ID' { if ($null -eq $authProfile) { $authProfile = GetCIEMActiveAzureAuthenticationProfileForAttackPathScript } GetCIEMRequiredTokenObjectValue -Object $authProfile -PropertyName 'Id' -Context 'active Azure authentication profile' break } 'AUTH_PROFILE_NAME' { if ($null -eq $authProfile) { $authProfile = GetCIEMActiveAzureAuthenticationProfileForAttackPathScript } GetCIEMRequiredTokenObjectValue -Object $authProfile -PropertyName 'Name' -Context 'active Azure authentication profile' break } 'AUTH_PROFILE_METHOD' { if ($null -eq $authProfile) { $authProfile = GetCIEMActiveAzureAuthenticationProfileForAttackPathScript } GetCIEMRequiredTokenObjectValue -Object $authProfile -PropertyName 'Method' -Context 'active Azure authentication profile' break } 'TENANT_ID' { if ($null -eq $authProfile) { $authProfile = GetCIEMActiveAzureAuthenticationProfileForAttackPathScript } GetCIEMRequiredTokenObjectValue -Object $authProfile -PropertyName 'TenantId' -Context 'active Azure authentication profile' break } 'CLIENT_ID' { if ($null -eq $authProfile) { $authProfile = GetCIEMActiveAzureAuthenticationProfileForAttackPathScript } GetCIEMRequiredTokenObjectValue -Object $authProfile -PropertyName 'ClientId' -Context 'active Azure authentication profile' break } 'MANAGED_IDENTITY_CLIENT_ID' { if ($null -eq $authProfile) { $authProfile = GetCIEMActiveAzureAuthenticationProfileForAttackPathScript } GetCIEMRequiredTokenObjectValue -Object $authProfile -PropertyName 'ManagedIdentityClientId' -Context 'active Azure authentication profile' break } 'PSU_ENVIRONMENT' { if ($null -eq $psuEnvironment) { $psuEnvironment = Get-PSUInstalledEnvironment } GetCIEMRequiredTokenObjectValue -Object $psuEnvironment -PropertyName 'Environment' -Context 'PSU environment' break } 'PSU_WEBSITE_NAME' { if ($null -eq $psuEnvironment) { $psuEnvironment = Get-PSUInstalledEnvironment } GetCIEMRequiredTokenObjectValue -Object $psuEnvironment -PropertyName 'WebsiteName' -Context 'PSU environment' break } default { throw "Attack path remediation script '$($AttackPath.PsuScriptName)' contains unknown token '$token'." } } $content = $content.Replace("{{$token}}", [string]$value) } if ($content -match '{{[A-Z0-9_]+}}') { throw "Attack path remediation script '$($AttackPath.PsuScriptName)' contains unresolved tokens." } $content.TrimEnd() } function ResolveCIEMAttackPathRemediationScript { [CmdletBinding()] param( [Parameter(Mandatory)] [object]$Pattern, [Parameter(Mandatory)] [CIEMAttackPath]$AttackPath ) $ErrorActionPreference = 'Stop' $relativeScriptPath = [string]$Pattern.RemediationScriptPath if ([string]::IsNullOrWhiteSpace($relativeScriptPath)) { throw "Attack path pattern '$($Pattern.Id)' is missing RemediationScriptPath." } $scriptPath = Join-Path $script:ModuleRoot $relativeScriptPath if (-not (Test-Path -Path $scriptPath -PathType Leaf)) { throw "Attack path pattern '$($Pattern.Id)' references missing remediation script '$relativeScriptPath'." } $templateContent = GetCIEMAttackPathRemediationScriptTemplateContent $scriptContent = MergeCIEMAttackPathRemediationScriptTemplate ` -TemplateContent $templateContent ` -ScriptBodyContent (Get-Content -Path $scriptPath -Raw) ` -ScriptName $relativeScriptPath $content = ResolveCIEMAttackPathScriptContent -Pattern $Pattern -AttackPath $AttackPath -ScriptContent $scriptContent [pscustomobject]@{ RelativePath = $relativeScriptPath Content = $content } } |