Public/Import-CIEMScript.ps1
|
function Import-CIEMScript { <# .SYNOPSIS Registers CIEM PSU automation scripts from the manifest. .DESCRIPTION Loads core script definitions and attack path remediation templates from data/psu-scripts.json and registers them as named PSU scripts. #> [CmdletBinding(SupportsShouldProcess)] [OutputType([pscustomobject])] param() $ErrorActionPreference = 'Stop' if (-not (Get-Command -Name 'New-PSUScript' -ErrorAction SilentlyContinue)) { throw 'Import-CIEMScript requires New-PSUScript in the current session.' } $manifestPath = Join-Path -Path $script:ModuleRoot -ChildPath 'data/psu-scripts.json' if (-not (Test-Path -Path $manifestPath -PathType Leaf)) { throw "CIEM PSU script manifest not found: $manifestPath" } $manifest = Get-Content -Path $manifestPath -Raw | ConvertFrom-Json -Depth 10 $managedScriptNotes = 'ManagedBy=Devolutions.CIEM;Source=data/psu-scripts.json' $legacyScriptExactNames = @( 'Devolutions.CIEM' 'Devolutions.CIEM/New-CIEMScanRun' 'Devolutions.CIEM/Start-CIEMAzureDiscovery' 'Devolutions.CIEM/Invoke-CIEMIdentityGraphBuild' 'Devolutions.CIEM/Invoke-CIEMAttackPathRefresh' ) $legacyPathPatterns = @( '.*/Devolutions-CIEM/psu-app/Checks/New-CIEMScanRun\.ps1$' '.*/Devolutions-CIEM/psu-app/Checks/Start-CIEMAzureDiscovery\.ps1$' '.*/Devolutions-CIEM/psu-app/modules/Devolutions\.CIEM\.Graph/Data/attack_path_remediation_scripts/[^/]+\.ps1$' ) $normalizeScriptName = { param( [Parameter(Mandatory)] [AllowEmptyString()] [string]$Name ) $Name.Replace('\', '/').TrimStart('/') } $getPsuRepositoryPath = { param( [Parameter(Mandatory)] [string]$Name ) $normalizedName = & $normalizeScriptName -Name $Name if ($normalizedName -match '\.ps1$') { $normalizedName } else { "$normalizedName.ps1" } } $getExistingScriptPath = { param( [Parameter(Mandatory)] [object]$Script ) foreach ($propertyName in @('FullPath', 'Path')) { $property = $Script.PSObject.Properties[$propertyName] if ($property -and -not [string]::IsNullOrWhiteSpace([string]$property.Value)) { & $normalizeScriptName -Name ([string]$property.Value) return } } '' } $getExistingScriptNotes = { param( [Parameter(Mandatory)] [object]$Script ) foreach ($propertyName in @('Notes', 'CommitNotes')) { $property = $Script.PSObject.Properties[$propertyName] if ($property -and -not [string]::IsNullOrWhiteSpace([string]$property.Value)) { [string]$property.Value return } } '' } $scriptDefinitions = [System.Collections.Generic.List[object]]::new() $expectedScriptNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $expectedScriptPaths = @{} $folderPaths = [System.Collections.Generic.List[string]]::new() $expectedFolderPaths = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($folder in @($manifest.folders)) { $folderPath = [string]$folder if ([string]::IsNullOrWhiteSpace($folderPath)) { throw 'CIEM script manifest contains an empty folder path.' } if ([System.IO.Path]::IsPathRooted($folderPath)) { throw "CIEM script manifest folder must use a relative path: $folderPath" } if ($folderPath -match '(^|[\\/])\.\.([\\/]|$)') { throw "CIEM script manifest folder contains invalid parent path traversal: $folderPath" } $normalizedFolderPath = & $normalizeScriptName -Name $folderPath if (-not $expectedFolderPaths.Add($normalizedFolderPath)) { throw "CIEM script manifest contains a duplicate folder path: $normalizedFolderPath" } $folderPaths.Add($normalizedFolderPath) } foreach ($scriptDef in @($manifest.scripts)) { $scriptName = [string]$scriptDef.name if ([string]::IsNullOrWhiteSpace($scriptName)) { throw 'CIEM script manifest contains an entry with an empty name.' } $normalizedScriptName = & $normalizeScriptName -Name $scriptName if ($normalizedScriptName -match '^Checks/AttackPathRemediation-') { throw "CIEM script manifest script name '$scriptName' is reserved for template scripts and must not be registered in PSU automation." } if (-not $expectedScriptNames.Add($normalizedScriptName)) { throw "CIEM script manifest contains a duplicate script name: $normalizedScriptName" } $expectedScriptPaths[$normalizedScriptName] = & $getPsuRepositoryPath -Name $normalizedScriptName $path = [string]$scriptDef.path if ([string]::IsNullOrWhiteSpace($path)) { throw "CIEM script manifest entry '$normalizedScriptName' is missing path." } if ([System.IO.Path]::IsPathRooted($path)) { throw "CIEM script manifest entry '$normalizedScriptName' must use a relative path: $path" } if ($path -match '(^|[\\/])\.\.([\\/]|$)') { throw "CIEM script manifest entry '$normalizedScriptName' contains invalid parent path traversal: $path" } $absolutePath = Join-Path -Path $script:ModuleRoot -ChildPath $path if (-not (Test-Path -Path $absolutePath -PathType Leaf)) { throw "CIEM script not found for registration '$normalizedScriptName': $absolutePath" } $scriptDefinitions.Add([pscustomobject]@{ Name = $normalizedScriptName AbsolutePath = $absolutePath Description = [string]$scriptDef.description Status = [string]$scriptDef.status Timeout = [double]$scriptDef.timeout DisableManualInvocation = $false RepositoryPath = $null Type = 'Core' }) } $remediationTemplates = $manifest.remediationTemplates if ($null -eq $remediationTemplates) { throw 'CIEM script manifest is missing remediationTemplates.' } $templateRootPath = [string]$remediationTemplates.path if ([string]::IsNullOrWhiteSpace($templateRootPath)) { throw 'CIEM script manifest remediationTemplates is missing path.' } if ([System.IO.Path]::IsPathRooted($templateRootPath)) { throw "CIEM script manifest remediationTemplates path must be relative: $templateRootPath" } if ($templateRootPath -match '(^|[\\/])\.\.([\\/]|$)') { throw "CIEM script manifest remediationTemplates path contains invalid parent path traversal: $templateRootPath" } $templateNamePrefixProperty = $remediationTemplates.PSObject.Properties['namePrefix'] if (-not $templateNamePrefixProperty) { throw 'CIEM script manifest remediationTemplates is missing namePrefix.' } $templateNamePrefix = [string]$templateNamePrefixProperty.Value $normalizedTemplateNamePrefix = & $normalizeScriptName -Name $templateNamePrefix if ($normalizedTemplateNamePrefix -ne '') { throw "CIEM script manifest remediationTemplates namePrefix must be empty so PSU attack path script names use the template file basename: $templateNamePrefix" } $templatePath = [string]$remediationTemplates.templatePath if ([string]::IsNullOrWhiteSpace($templatePath)) { throw 'CIEM script manifest remediationTemplates is missing templatePath.' } if ([System.IO.Path]::IsPathRooted($templatePath)) { throw "CIEM script manifest remediationTemplates templatePath must be relative: $templatePath" } if ($templatePath -match '(^|[\\/])\.\.([\\/]|$)') { throw "CIEM script manifest remediationTemplates templatePath contains invalid parent path traversal: $templatePath" } $absoluteTemplatePath = Join-Path -Path $script:ModuleRoot -ChildPath $templatePath if (-not (Test-Path -Path $absoluteTemplatePath -PathType Leaf)) { throw "CIEM attack path remediation script template not found: $absoluteTemplatePath" } $attackPathScriptTemplate = Get-Content -Path $absoluteTemplatePath -Raw if ([string]::IsNullOrWhiteSpace($attackPathScriptTemplate)) { throw "CIEM attack path remediation script template is empty: $absoluteTemplatePath" } $templateRoot = Join-Path -Path $script:ModuleRoot -ChildPath $templateRootPath if (-not (Test-Path -Path $templateRoot -PathType Container)) { throw "CIEM attack path remediation template folder not found: $templateRoot" } $templateFiles = @(Get-ChildItem -Path $templateRoot -Filter '*.ps1' -File | Sort-Object Name) if ($templateFiles.Count -eq 0) { throw "CIEM attack path remediation template folder contains no scripts: $templateRoot" } foreach ($templateFile in $templateFiles) { $normalizedScriptName = "$normalizedTemplateNamePrefix$([System.IO.Path]::GetFileNameWithoutExtension($templateFile.Name))" if (-not $expectedScriptNames.Add($normalizedScriptName)) { throw "CIEM script manifest contains a duplicate script name: $normalizedScriptName" } $repositoryPath = "Identities/AttackPaths/$normalizedScriptName.ps1" $expectedScriptPaths[$normalizedScriptName] = $repositoryPath $scriptDefinitions.Add([pscustomobject]@{ Name = $normalizedScriptName AbsolutePath = $templateFile.FullName Description = [string]$remediationTemplates.description Status = [string]$remediationTemplates.status Timeout = [double]$remediationTemplates.timeout DisableManualInvocation = [bool]$remediationTemplates.disableManualInvocation RepositoryPath = $repositoryPath Type = 'AttackPath' }) } $syncedFolders = 0 if ($folderPaths.Count -gt 0) { $getFolderCommand = Get-Command -Name 'Get-PSUFolder' -ErrorAction SilentlyContinue $newFolderCommand = Get-Command -Name 'New-PSUFolder' -ErrorAction SilentlyContinue if (-not $getFolderCommand) { throw 'Import-CIEMScript requires Get-PSUFolder to sync PSU script folders.' } if (-not $newFolderCommand) { throw 'Import-CIEMScript requires New-PSUFolder to sync PSU script folders.' } foreach ($folderPath in $folderPaths) { $folderName = @($folderPath -split '/')[-1] $matchingFolders = @(Get-PSUFolder -Name $folderName | Where-Object { $pathProperty = $_.PSObject.Properties['Path'] $nameProperty = $_.PSObject.Properties['Name'] $existingFolderPath = if ($pathProperty -and -not [string]::IsNullOrWhiteSpace([string]$pathProperty.Value)) { [string]$pathProperty.Value } elseif ($nameProperty -and -not [string]::IsNullOrWhiteSpace([string]$nameProperty.Value)) { [string]$nameProperty.Value } else { '' } -not [string]::IsNullOrWhiteSpace($existingFolderPath) -and (& $normalizeScriptName -Name $existingFolderPath) -eq $folderPath }) if ($matchingFolders.Count -gt 1) { throw "Multiple PSU script folders found for CIEM folder path '$folderPath'." } if ($matchingFolders.Count -eq 1) { continue } if ($PSCmdlet.ShouldProcess($folderPath, 'Create PSU script folder')) { New-PSUFolder -Path $folderPath -Type Script | Out-Null } $syncedFolders++ } } $prunedScripts = 0 $getScriptCommand = Get-Command -Name 'Get-PSUScript' -ErrorAction SilentlyContinue $removeScriptCommand = Get-Command -Name 'Remove-PSUScript' -ErrorAction SilentlyContinue if ($getScriptCommand -and $removeScriptCommand) { foreach ($existingScript in @(Get-PSUScript)) { $existingName = [string]$existingScript.Name if ([string]::IsNullOrWhiteSpace($existingName)) { continue } $normalizedExistingName = & $normalizeScriptName -Name $existingName $isStaleScript = $false if ($expectedScriptPaths.ContainsKey($normalizedExistingName)) { $expectedRepositoryPath = [string]$expectedScriptPaths[$normalizedExistingName] $existingRepositoryPath = & $getExistingScriptPath -Script $existingScript if ([string]::IsNullOrWhiteSpace($existingRepositoryPath) -or $existingRepositoryPath -eq $expectedRepositoryPath) { continue } if ((& $getExistingScriptNotes -Script $existingScript) -eq $managedScriptNotes) { $isStaleScript = $true } else { throw "Existing PSU script '$normalizedExistingName' is stored at '$existingRepositoryPath' but CIEM expects '$expectedRepositoryPath'." } } if (-not $isStaleScript) { $isStaleScript = $legacyScriptExactNames -contains $normalizedExistingName } if (-not $isStaleScript -and $normalizedExistingName -match '^Checks/AttackPathRemediation-') { $isStaleScript = $true } if (-not $isStaleScript -and $normalizedExistingName -match '^Identities/AttackPaths/AttackPathRemediation-') { $isStaleScript = $true } if (-not $isStaleScript) { foreach ($pathPattern in $legacyPathPatterns) { if ($normalizedExistingName -match $pathPattern) { $isStaleScript = $true break } } } if (-not $isStaleScript) { if ((& $getExistingScriptNotes -Script $existingScript) -eq $managedScriptNotes) { $isStaleScript = $true } } if (-not $isStaleScript) { continue } if ($PSCmdlet.ShouldProcess($normalizedExistingName, 'Remove stale CIEM PSU script')) { Remove-PSUScript -Script $existingScript | Out-Null } $prunedScripts++ } } $coreScripts = 0 $attackPathScripts = 0 $setScriptCommand = Get-Command -Name 'Set-PSUScript' -ErrorAction SilentlyContinue foreach ($scriptDef in $scriptDefinitions) { $sourceContent = Get-Content -Path $scriptDef.AbsolutePath -Raw if ($scriptDef.Type -eq 'AttackPath') { $content = MergeCIEMAttackPathRemediationScriptTemplate ` -TemplateContent $attackPathScriptTemplate ` -ScriptBodyContent $sourceContent ` -ScriptName $scriptDef.Name } else { $content = $sourceContent } if ([string]::IsNullOrWhiteSpace($content)) { throw "CIEM script content is empty for registration '$($scriptDef.Name)': $($scriptDef.AbsolutePath)" } $existingMatches = @() if ($getScriptCommand) { $existingMatches = @(Get-PSUScript -Name $scriptDef.Name | Where-Object { if ($null -eq $_) { return $false } if ([string]::IsNullOrWhiteSpace([string]$scriptDef.RepositoryPath)) { return $true } $existingRepositoryPath = & $getExistingScriptPath -Script $_ [string]::IsNullOrWhiteSpace($existingRepositoryPath) -or $existingRepositoryPath -eq [string]$scriptDef.RepositoryPath }) } if ($existingMatches.Count -gt 1) { throw "Multiple PSU scripts found for CIEM script name '$($scriptDef.Name)'." } if ($existingMatches.Count -eq 1) { if (-not $setScriptCommand) { throw 'Import-CIEMScript requires Set-PSUScript when existing scripts are present.' } if ($PSCmdlet.ShouldProcess($scriptDef.Name, 'Update PSU script')) { Set-PSUScript -Script $existingMatches[0] ` -Content $content ` -Description $scriptDef.Description ` -Status $scriptDef.Status ` -TimeOut $scriptDef.Timeout ` -DisableManualInvocation ([bool]$scriptDef.DisableManualInvocation) ` -Notes $managedScriptNotes | Out-Null } } else { if ($PSCmdlet.ShouldProcess($scriptDef.Name, 'Register PSU script')) { $newScriptParams = @{ Name = $scriptDef.Name ScriptBlock = [scriptblock]::Create($content) Description = $scriptDef.Description Status = $scriptDef.Status TimeOut = $scriptDef.Timeout DisableManualInvocation = [bool]$scriptDef.DisableManualInvocation Notes = $managedScriptNotes } if (-not [string]::IsNullOrWhiteSpace([string]$scriptDef.RepositoryPath)) { $newScriptParams.Path = [string]$scriptDef.RepositoryPath } New-PSUScript @newScriptParams | Out-Null } } if ($scriptDef.Type -eq 'Core') { $coreScripts++ } else { $attackPathScripts++ } } [pscustomobject]@{ ManifestPath = $manifestPath PrunedScripts = $prunedScripts SyncedFolders = $syncedFolders CoreScripts = $coreScripts AttackPathScripts = $attackPathScripts RemediationScripts = $attackPathScripts TotalScripts = $coreScripts + $attackPathScripts Status = 'Registered' } } |