Devolutions.CIEM.psm1
|
$script:ModuleRoot = $PSScriptRoot # --- Resolve data root (outside module version dir so data survives upgrades) --- if ($PSScriptRoot -match '(.*[/\\])Repository[/\\]Modules[/\\]') { $script:DataRoot = Join-Path $Matches[1] 'data' } else { $script:DataRoot = Join-Path $PSScriptRoot 'data' } if (-not (Test-Path $script:DataRoot)) { New-Item -Path $script:DataRoot -ItemType Directory -Force | Out-Null } # --- Bootstrap logger (used before Write-CIEMLog is dot-sourced) --- $script:_BootLogPath = Join-Path $script:DataRoot 'ciem.log' function _BootLog([string]$Msg, [string]$Sev = 'INFO') { $entry = "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff')] [$Sev] [ModuleInit] $Msg" try { Add-Content -Path $script:_BootLogPath -Value $entry -Encoding UTF8 -ErrorAction SilentlyContinue } catch { Write-Verbose "Boot log write failed: $_" } } _BootLog "Module loading from: $PSScriptRoot" # --- Sub-module directory roots (for runtime file discovery) --- $script:GraphRoot = Join-Path $PSScriptRoot 'modules/Devolutions.CIEM.Graph' $script:AzureRoot = Join-Path $PSScriptRoot 'modules/Azure/Infrastructure' $script:AzureDiscoveryRoot = Join-Path $PSScriptRoot 'modules/Azure/Discovery' $script:AWSRoot = Join-Path $PSScriptRoot 'modules/AWS/Infrastructure' $script:ChecksRoot = Join-Path $PSScriptRoot 'modules/Devolutions.CIEM.Checks' $script:EffectivePermissionsRoot = Join-Path $PSScriptRoot 'modules/Devolutions.CIEM.EffectivePermissions' $script:PSURoot = Join-Path $PSScriptRoot 'modules/Devolutions.CIEM.PSU' # All sub-module roots in load order $subModuleRoots = @( $script:GraphRoot $script:AzureRoot $script:AzureDiscoveryRoot $script:AWSRoot $script:ChecksRoot $script:EffectivePermissionsRoot $script:PSURoot ) # --- Severity catalog (single source of truth for name/rank/color/label) --- $script:SeverityByName = @{} $script:SeverityCatalog = @( Get-Content (Join-Path $script:PSURoot 'Data/severity_catalog.json') -Raw | ConvertFrom-Json ) foreach ($s in $script:SeverityCatalog) { $script:SeverityByName[$s.name] = $s } # --- Status catalog (single source of truth for status name/color/label) --- $script:StatusByName = @{} $script:StatusCatalog = @( Get-Content (Join-Path $script:PSURoot 'Data/status_catalog.json') -Raw | ConvertFrom-Json ) foreach ($s in $script:StatusCatalog) { $script:StatusByName[$s.name] = $s } # --- Import PSUSQLite (bundled dependency) --- _BootLog "Importing PSUSQLite..." Import-Module (Join-Path $PSScriptRoot 'modules/PSUSQLite/PSUSQLite.psd1') -Global _BootLog "PSUSQLite imported" # --- Load classes in dependency order --- # IMPORTANT: All dot-source calls MUST remain at the psm1 top level. # Wrapping dot-source in a helper function scopes class and function # definitions to that function, making them invisible to the module. # Base classes (must load first - other classes depend on these) _BootLog "Loading base classes..." foreach ($className in @('CIEMAuthenticationContext', 'CIEMProvider')) { $classPath = Join-Path $PSScriptRoot "Classes/$className.ps1" try { . $classPath } catch { _BootLog "FAILED to load class $className : $_" 'ERROR'; throw } } # Checks classes (explicit order: base types before derived) _BootLog "Loading Checks classes..." foreach ($className in @('CIEMServiceCache', 'CIEMProviderService', 'CIEMCheck', 'CIEMScanResult')) { $classFile = Join-Path $script:ChecksRoot "Classes/$className.ps1" if (-not (Test-Path $classFile)) { _BootLog "FAILED to load class $className : file not found at $classFile" 'ERROR' throw "Required class file not found: $classFile" } try { . $classFile } catch { _BootLog "FAILED to load class $className : $_" 'ERROR'; throw } } # Unordered classes (Graph, Azure, Azure Discovery, AWS - no interdependencies) _BootLog "Loading provider classes..." foreach ($root in @($script:GraphRoot, $script:AzureRoot, $script:AzureDiscoveryRoot, $script:AWSRoot, $script:EffectivePermissionsRoot)) { foreach ($file in (Get-ChildItem (Join-Path $root 'Classes/*.ps1') -ErrorAction SilentlyContinue)) { try { . $file.FullName } catch { _BootLog "FAILED to load class $($file.Name) : $_" 'ERROR'; throw } } } # --- Load private and public functions (base + all sub-modules) --- $_loadedCount = 0 $_failedCount = 0 foreach ($subdir in @('Private', 'Public')) { foreach ($file in (Get-ChildItem "$PSScriptRoot/$subdir/*.ps1" -ErrorAction SilentlyContinue)) { try { . $file.FullName; $_loadedCount++ } catch { _BootLog "FAILED to load $subdir/$($file.Name) : $_" 'ERROR'; $_failedCount++; throw } } foreach ($root in $subModuleRoots) { $rootName = Split-Path $root -Leaf foreach ($file in (Get-ChildItem (Join-Path $root "$subdir/*.ps1") -ErrorAction SilentlyContinue)) { try { . $file.FullName; $_loadedCount++ } catch { _BootLog "FAILED to load $rootName/$subdir/$($file.Name) : $_" 'ERROR'; $_failedCount++; throw } } } } # Switch to Write-CIEMLog now that it's available Write-CIEMLog -Message "Loaded $_loadedCount functions ($_failedCount failures)" -Component 'ModuleInit' # --- Load PSU page functions (must be exported for PSU's scriptblock resolution) --- foreach ($file in (Get-ChildItem "$script:PSURoot/Pages/*.ps1" -ErrorAction SilentlyContinue)) { try { . $file.FullName; $_loadedCount++ } catch { Write-CIEMLog "FAILED to load Page $($file.Name) : $_" -Severity ERROR -Component 'ModuleInit'; $_failedCount++; throw } } Write-CIEMLog -Message "PSU pages loaded (total: $_loadedCount functions, $_failedCount failures)" -Component 'ModuleInit' # --- Module-scoped state --- # Base $script:Config = $null $script:AuthContext = @{} $script:PSUEnvironment = $null $script:DatabasePath = $null # Azure $script:AzureAuthContext = $null # [CIEMAzureAuthContext] — set by Connect-CIEMAzure $script:AzureAuthProfilesCacheKey = 'CIEM:AuthProfiles:Azure' $script:AWSAuthProfileCacheKey = 'CIEM:AuthProfile:AWS' $script:CIEMConfigCacheKey = 'CIEM:Config' $script:ScanConfigCacheKey = 'CIEM:ScanConfig' # AWS $script:AWSAuthContext = $null # PSU $script:RelationshipColors = @{} (Get-Content (Join-Path $script:ModuleRoot 'Data/relationship_colors.json') -Raw | ConvertFrom-Json).PSObject.Properties | ForEach-Object { $script:RelationshipColors[$_.Name] = $_.Value } # Risk policy constants $script:DormantPermissionThresholdDays = 90 $script:MediumEntitlementThreshold = 5 $script:PrivilegedRoleNames = @((Get-Content (Join-Path $script:AzureDiscoveryRoot 'Data/privileged_roles.json') -Raw | ConvertFrom-Json).name) $script:CIEMSaveTablesConfig = Import-PowerShellDataFile -Path (Join-Path $script:AzureDiscoveryRoot 'Data/save-tables.psd1') # Tunables (script-scope, all defined in one place for discoverability) # CIEMParallelThrottleLimit*: ForEach-Object -Parallel throttle for discovery vs scan workloads. # CIEMSqlBatchSize: cap on rows per multi-row INSERT before InvokeCIEMBatchInsert further sub-divides # to stay under SQLite's 999 SQL parameter limit (chunk = floor(999 / column count)). # CIEMGraphBatchSize: max sub-requests in a single Microsoft Graph $batch POST (Graph hard cap is 20). # CIEMGraphBatchWallClockSeconds: total budget for retry-loop wall-clock time on a single Graph batch # chunk before throwing. Prevents indefinite stalls when sub-requests stay 429. $script:CIEMParallelThrottleLimitDiscovery = 5 $script:CIEMParallelThrottleLimitScan = 10 $script:CIEMSqlBatchSize = 500 $script:CIEMGraphBatchSize = 20 $script:CIEMGraphBatchWallClockSeconds = 300 # --- Initialize database (base + provider schemas) --- Write-CIEMLog -Message "Initializing database..." -Component 'ModuleInit' try { New-CIEMDatabase Write-CIEMLog -Message "Database initialized at: $(Get-CIEMDatabasePath)" -Component 'ModuleInit' } catch { Write-CIEMLog -Message "Database initialization failed: $($_.Exception.Message)" -Severity ERROR -Component 'ModuleInit' throw } # Apply provider-specific schemas foreach ($schema in @( @{ Path = Join-Path $script:AzureRoot 'Data/azure_schema.sql'; Label = 'Azure' } @{ Path = Join-Path $script:AzureDiscoveryRoot 'Data/discovery_schema.sql'; Label = 'AzureDiscovery' } @{ Path = Join-Path $script:GraphRoot 'Data/graph_schema.sql'; Label = 'Graph' } )) { try { $dbPath = Get-CIEMDatabasePath if (-not $dbPath) { throw "Database path not resolved for $($schema.Label) schema." } if (-not (Test-Path $schema.Path)) { throw "Schema file not found: $($schema.Path)" } $conn = Open-PSUSQLiteConnection -Database $dbPath try { $schemaSql = Get-Content -Path $schema.Path -Raw foreach ($statement in ($schemaSql -split ';\s*\n' | Where-Object { $_.Trim() })) { Invoke-PSUSQLiteQuery -Connection $conn -Query $statement.Trim() -AsNonQuery | Out-Null } } finally { $conn.Dispose() } } catch { Write-CIEMLog -Message "$($schema.Label) schema failed: $($_.Exception.Message)" -Severity ERROR -Component 'ModuleInit' throw } } try { UpdateCIEMAttackPathStorageSchema } catch { Write-CIEMLog -Message "Attack path storage schema migration failed: $($_.Exception.Message)" -Severity ERROR -Component 'ModuleInit' throw } try { $attackPathRuleSync = Sync-CIEMAttackPathRuleCatalog Write-CIEMLog -Message "Attack path rules synced: $($attackPathRuleSync.RuleCount)" -Component 'ModuleInit' } catch { Write-CIEMLog -Message "Attack path rule sync failed: $($_.Exception.Message)" -Severity ERROR -Component 'ModuleInit' throw } # --- Argument completers --- RegisterCIEMArgumentCompleters # --- Export all public + page functions --- $exportDirs = @("$PSScriptRoot/Public") foreach ($root in $subModuleRoots) { $exportDirs += Join-Path $root 'Public' } $exportFunctions = @() foreach ($dir in $exportDirs) { $files = Get-ChildItem "$dir/*.ps1" -ErrorAction SilentlyContinue if ($files) { $exportFunctions += $files.BaseName } } # Page files may define multiple functions per file — extract all function names # (required because [scriptblock]::Create() in PSU tab/onClick only sees exported functions) foreach ($pageFile in (Get-ChildItem "$script:PSURoot/Pages/*.ps1" -ErrorAction SilentlyContinue)) { $content = Get-Content $pageFile.FullName -Raw $fnMatches = [regex]::Matches($content, '(?m)^function\s+([\w-]+)') foreach ($m in $fnMatches) { $exportFunctions += $m.Groups[1].Value } } Write-CIEMLog -Message "Exporting $($exportFunctions.Count) functions" -Component 'ModuleInit' Export-ModuleMember -Function $exportFunctions Write-CIEMLog -Message "Module load complete" -Component 'ModuleInit' |