modules/RolesFeatures/RolesFeatures.psm1
|
<#
RolesFeatures.psm1 Collector module: Windows Roles and Features (System category). Owns the canonical 'RolesFeatures' dataset consumed by the RiskEngine (config/risk-rules.json uses anyRowFieldMatches on the 'Name' field) and by the synthesis/report layers (Test-RoleFeaturePresent / Get-LikelyServerFunctions in Output.psm1). Both of those match on Name/DisplayName WITHOUT inspecting InstallState, so this module places ONLY installed roles/features into the canonical dataset. Notable roles that exist in the catalog but are NOT installed are surfaced separately in 'AvailableRolesFeatures' (informational, Internal) so they never cause false-positive role-presence findings. Enumeration strategy (read-only, defensive): 1. Get-WindowsFeature (ServerManager; Server SKUs) - preferred, already uses canonical Windows role Names. 2. Get-WindowsOptionalFeature -Online (client SKU / Server Core edge). 3. dism.exe /online /get-features /format:table (READ-ONLY get-features only). Design rules: Windows PowerShell 5.1 compatible; read-only; no Set-StrictMode; every collection block wrapped in try/catch; generic collections via ::new(). #> # --------------------------------------------------------------------------- # Notable server-role catalog. Canonical Windows feature Names / fragments. # Used to (a) curate the informational not-installed list and (b) build the # consolidated detected-functions summary finding. # --------------------------------------------------------------------------- $script:RolesFeatures_NotableRolePatterns = @( 'AD-Domain-Services','ADDS-Domain-Controller','^DNS$','^DHCP$', 'AD-Certificate','ADCS','^Web-Server$','^Hyper-V$', 'FS-FileServer','FS-DFS-Namespace','FS-DFS-Replication','FS-iSCSITarget-Server', 'Print-Services','^Print-Server$','RDS-','^Remote-Desktop-Services$', '^NPAS$','Failover-Clustering','^WDS$','WDS-Deployment', 'DirectAccess-VPN','Routing','Remote-Access', 'AD-Federation-Services','^ADFS','^ADLDS$','UpdateServices' ) # Ordered pattern -> friendly server-function label (regex; -match is # case-insensitive by default in PowerShell). $script:RolesFeatures_FunctionMap = @( [pscustomobject]@{ Pattern = 'AD-Domain-Services|ADDS-Domain-Controller'; Label = 'Active Directory Domain Services (Domain Controller)' } [pscustomobject]@{ Pattern = '^DNS$'; Label = 'DNS Server' } [pscustomobject]@{ Pattern = '^DHCP$'; Label = 'DHCP Server' } [pscustomobject]@{ Pattern = 'AD-Certificate|ADCS'; Label = 'Active Directory Certificate Services (CA)' } [pscustomobject]@{ Pattern = '^Web-Server$'; Label = 'IIS Web Server' } [pscustomobject]@{ Pattern = '^Hyper-V$'; Label = 'Hyper-V Virtualization Host' } [pscustomobject]@{ Pattern = '^FS-FileServer$'; Label = 'File Server' } [pscustomobject]@{ Pattern = 'FS-DFS'; Label = 'DFS (Namespaces / Replication)' } [pscustomobject]@{ Pattern = 'Print-Services|^Print-Server$'; Label = 'Print Services' } [pscustomobject]@{ Pattern = 'RDS-|^Remote-Desktop-Services$'; Label = 'Remote Desktop Services (RDS)' } [pscustomobject]@{ Pattern = '^NPAS$'; Label = 'Network Policy and Access Services (NPS / RADIUS)' } [pscustomobject]@{ Pattern = 'Failover-Clustering'; Label = 'Failover Clustering' } [pscustomobject]@{ Pattern = 'UpdateServices'; Label = 'Windows Server Update Services (WSUS)' } [pscustomobject]@{ Pattern = 'AD-Federation-Services|^ADFS'; Label = 'Active Directory Federation Services (ADFS)' } [pscustomobject]@{ Pattern = 'Remote-Access|DirectAccess-VPN|^Routing$'; Label = 'Remote Access (VPN / DirectAccess / Routing)' } ) #region Helpers --------------------------------------------------------------- function ConvertFrom-DismFeatureText { <# Parses the output of 'dism /online /get-features'. Handles both the table format ('Feature Name | State') and the classic pair format ('Feature Name : X' followed by 'State : Y'). Always returns an array. #> [CmdletBinding()] param([AllowNull()][string]$Text) $items = [System.Collections.Generic.List[object]]::new() if ([string]::IsNullOrWhiteSpace($Text)) { return ,@($items) } $lines = $Text -split "`r?`n" # Attempt 1: table format (columns separated by '|'). foreach ($line in $lines) { if ($line -notmatch '\|') { continue } $parts = $line -split '\|' if ($parts.Count -lt 2) { continue } $name = $parts[0].Trim() $state = $parts[1].Trim() if ([string]::IsNullOrWhiteSpace($name)) { continue } if ($name -match '^-+$' -or $state -match '^-+$') { continue } if ($name -match '^Feature Name$') { continue } if ($state -notmatch '^(Enabled|Disabled)') { continue } $items.Add([pscustomobject]@{ Name = $name; State = $state }) } if ($items.Count -gt 0) { return ,@($items) } # Attempt 2: 'Feature Name :' / 'State :' pair format. $currentName = $null foreach ($line in $lines) { if ($line -match '^\s*Feature Name\s*:\s*(.+?)\s*$') { $currentName = $Matches[1].Trim(); continue } if ($line -match '^\s*State\s*:\s*(.+?)\s*$' -and $currentName) { $items.Add([pscustomobject]@{ Name = $currentName; State = $Matches[1].Trim() }) $currentName = $null } } return ,@($items) } function Get-RolesFeaturesDetectedFunctions { <# Maps installed RolesFeatures rows to friendly server-function labels. Returns a (possibly empty) array of unique labels, order preserved. #> [CmdletBinding()] param([object[]]$Rows) $labels = [System.Collections.Generic.List[string]]::new() if (-not $Rows -or $Rows.Count -eq 0) { return ,@($labels) } foreach ($map in $script:RolesFeatures_FunctionMap) { $hit = $false foreach ($r in $Rows) { if ($null -eq $r) { continue } $n = [string]$r.Name $d = [string]$r.DisplayName if (($n -match $map.Pattern) -or ($d -match $map.Pattern)) { $hit = $true; break } } if ($hit -and -not $labels.Contains($map.Label)) { $labels.Add($map.Label) } } return ,@($labels) } #endregion #region Six-function contract ------------------------------------------------- function Get-DiscoveryModuleMetadata { [pscustomobject]@{ ModuleName = 'RolesFeatures' DisplayName = 'Windows Roles and Features' Category = 'System' Version = '1.0.0' DefaultInFast = $true DefaultInDeep = $true RequiresAdmin = $false RequiresDomainContext = $false RequiresRole = $null EstimatedImpact = 'Low' CanRunAsSystem = $true ProducesDatasets = @('RolesFeatures','AvailableRolesFeatures') ProducesRisks = $true ProducesFollowUpQuestions = $true SupportsDeepMode = $true SupportsComplianceLens = $false } } function Test-DiscoveryPrerequisites { param([object]$Context) $canRun = $true $status = 'Ready' $reason = '' $limitations = @() $hasWf = $false; $hasWof = $false; $hasDism = $false try { $hasWf = (Get-CommandAvailable -Name 'Get-WindowsFeature') -or (Test-WindowsPowerShellModule -Name 'ServerManager') } catch { } try { $hasWof = Get-CommandAvailable -Name 'Get-WindowsOptionalFeature' } catch { } try { $hasDism = Get-CommandAvailable -Name 'dism.exe' } catch { } if (-not ($hasWf -or $hasWof -or $hasDism)) { $canRun = $false $status = 'NotApplicable' $reason = 'No roles/features enumeration source available (Get-WindowsFeature, Get-WindowsOptionalFeature, and dism.exe all absent).' } elseif (-not $hasWf) { $limitations = @('Get-WindowsFeature not present (non-Server SKU or ServerManager unavailable); using optional-features / DISM fallback. Feature Names may not match canonical Windows Server role names.') } [pscustomobject]@{ ModuleName='RolesFeatures'; CanRun=$canRun; Status=$status; Reason=$reason; Limitations=@($limitations) } } function Invoke-DiscoveryCollection { param([object]$Context) $module = 'RolesFeatures' $features = [System.Collections.Generic.List[object]]::new() $errors = [System.Collections.Generic.List[string]]::new() $method = 'None' # --- Primary: Get-WindowsFeature (ServerManager; canonical role Names) --- # Under PowerShell 7 on Windows Server 2012 R2 ServerManager cannot load (needs .NET Framework types and the # 5.1-only compatibility session); Windows PowerShell 4 on the same box can run it, so ask that instead. $viaWinPS = (-not (Get-CommandAvailable -Name 'Get-WindowsFeature')) -and (Test-WindowsPowerShellModule -Name 'ServerManager') if ((Get-CommandAvailable -Name 'Get-WindowsFeature') -or $viaWinPS) { try { Write-Log -Level DEBUG -Message ('Enumerating roles/features via Get-WindowsFeature{0}.' -f $(if ($viaWinPS) { ' (in Windows PowerShell)' } else { '' })) -Module $module -Context $Context $wf = @(if ($viaWinPS) { Invoke-WindowsPowerShellJson -Script 'Import-Module ServerManager; Get-WindowsFeature | Select-Object Name,DisplayName,@{n=''InstallState'';e={[string]$_.InstallState}},@{n=''FeatureType'';e={[string]$_.FeatureType}},Parent,Path' } else { Get-WindowsFeature -ErrorAction Stop }) foreach ($f in $wf) { if ($null -eq $f) { continue } $state = [string]$f.InstallState $features.Add([pscustomobject]@{ Name = [string]$f.Name DisplayName = [string]$f.DisplayName InstallState = $state FeatureType = [string]$f.FeatureType Parent = [string]$f.Parent Path = [string]$f.Path IsInstalled = ($state -eq 'Installed') }) } if ($features.Count -gt 0) { $method = 'Get-WindowsFeature' } } catch { $errors.Add(("Get-WindowsFeature failed: {0}" -f $_.Exception.Message)) Write-Log -Level WARN -Message 'Get-WindowsFeature failed; attempting fallback.' -Module $module -Exception $_ -Context $Context } } # --- Fallback 1: Get-WindowsOptionalFeature -Online (read-only) --- if ($method -eq 'None' -and (Get-CommandAvailable -Name 'Get-WindowsOptionalFeature')) { try { Write-Log -Level DEBUG -Message 'Enumerating optional features via Get-WindowsOptionalFeature -Online.' -Module $module -Context $Context $of = @(Get-WindowsOptionalFeature -Online -ErrorAction Stop) foreach ($f in $of) { if ($null -eq $f) { continue } $st = [string]$f.State $installed = ($st -eq 'Enabled') $features.Add([pscustomobject]@{ Name = [string]$f.FeatureName DisplayName = [string]$f.FeatureName InstallState = $(if ($installed) { 'Installed' } else { 'Available' }) FeatureType = 'OptionalFeature' Parent = '' Path = '' IsInstalled = $installed }) } if ($features.Count -gt 0) { $method = 'Get-WindowsOptionalFeature' } } catch { $errors.Add(("Get-WindowsOptionalFeature failed: {0}" -f $_.Exception.Message)) Write-Log -Level WARN -Message 'Get-WindowsOptionalFeature failed; attempting DISM fallback.' -Module $module -Exception $_ -Context $Context } } # --- Fallback 2: dism.exe /online /get-features (READ-ONLY get-features) --- if ($method -eq 'None' -and (Get-CommandAvailable -Name 'dism.exe')) { try { Write-Log -Level DEBUG -Message 'Enumerating optional features via dism.exe /online /get-features.' -Module $module -Context $Context $dism = Invoke-CommandLineSafe -FilePath 'dism.exe' -Arguments @('/online','/get-features','/format:table') -TimeoutSeconds 120 if ($dism -and $dism.StdOut) { $parsed = @(ConvertFrom-DismFeatureText -Text $dism.StdOut) foreach ($p in $parsed) { if ($null -eq $p) { continue } $installed = ([string]$p.State -match '^Enabled') $features.Add([pscustomobject]@{ Name = [string]$p.Name DisplayName = [string]$p.Name InstallState = $(if ($installed) { 'Installed' } else { 'Available' }) FeatureType = 'OptionalFeature' Parent = '' Path = '' IsInstalled = $installed }) } } if ($features.Count -gt 0) { $method = 'DISM' } elseif ($dism -and $dism.TimedOut) { $errors.Add('dism.exe /get-features timed out.') } elseif ($dism -and -not $dism.Succeeded) { $errors.Add(("dism.exe returned exit code {0}." -f $dism.ExitCode)) } } catch { $errors.Add(("DISM enumeration failed: {0}" -f $_.Exception.Message)) Write-Log -Level WARN -Message 'DISM feature enumeration failed.' -Module $module -Exception $_ -Context $Context } } Write-Log -Level INFO -Message ("Roles/features enumeration method: {0} ({1} record(s))." -f $method, $features.Count) -Module $module -Context $Context # Optional raw dump for evidence (read-only file write into toolkit output). if ($Context -and $Context.Paths -and $Context.Paths.Contains('Raw') -and $features.Count -gt 0) { try { $rawFile = Join-Path -Path $Context.Paths['Raw'] -ChildPath 'RolesFeatures.raw.json' (@($features) | ConvertTo-Json -Depth 4) | Out-File -LiteralPath $rawFile -Encoding UTF8 -Force } catch { } } return @{ Method = $method Features = @($features) Errors = @($errors) } } function ConvertTo-DiscoveryDatasets { param([object]$Context, $RawData) $module = 'RolesFeatures' $method = 'None' $features = @() $errors = @() if ($RawData -is [hashtable]) { if ($RawData.ContainsKey('Method')) { $method = [string]$RawData['Method'] } if ($RawData.ContainsKey('Features')) { $features = @($RawData['Features']) } if ($RawData.ContainsKey('Errors')) { $errors = @($RawData['Errors']) } } # --- Canonical dataset: RolesFeatures (INSTALLED ONLY) --- # Rules and role-presence tests match on Name/DisplayName regardless of # InstallState, so only genuinely-installed roles belong here. $installedRows = [System.Collections.Generic.List[object]]::new() foreach ($f in $features) { if ($null -eq $f) { continue } if (-not ($f.IsInstalled -eq $true)) { continue } $installedRows.Add([pscustomobject]@{ Name = [string]$f.Name DisplayName = [string]$f.DisplayName InstallState = [string]$f.InstallState FeatureType = [string]$f.FeatureType Parent = [string]$f.Parent Path = [string]$f.Path }) } Add-DataSet -Context $Context -Name 'RolesFeatures' ` -Description 'Installed Windows roles, role services, and features. Canonical Windows feature Names are preserved so RiskEngine role rules and role-presence detection match accurately.' ` -Rows @($installedRows) -Visibility 'Both' -IncludeInWorkbook $true -IncludeInClientReport $true -SourceModule $module | Out-Null # --- Secondary dataset: notable NOT-installed roles (informational only) --- $availableRows = [System.Collections.Generic.List[object]]::new() foreach ($f in $features) { if ($null -eq $f) { continue } if ($f.IsInstalled -eq $true) { continue } $n = [string]$f.Name if ([string]::IsNullOrWhiteSpace($n)) { continue } $isNotable = $false foreach ($pat in $script:RolesFeatures_NotableRolePatterns) { if ($n -match $pat) { $isNotable = $true; break } } if (-not $isNotable) { continue } $availableRows.Add([pscustomobject]@{ Name = $n DisplayName = [string]$f.DisplayName InstallState = [string]$f.InstallState FeatureType = [string]$f.FeatureType }) } Add-DataSet -Context $Context -Name 'AvailableRolesFeatures' ` -Description 'Notable server roles present in the catalog but NOT installed. Informational only; deliberately excluded from role-presence detection to avoid false-positive role findings.' ` -Rows @($availableRows) -Visibility 'Internal' -IncludeInWorkbook $true -IncludeInClientReport $false -SourceModule $module | Out-Null # --- Limitations --- if ($method -eq 'None' -or $installedRows.Count -eq 0) { $reason = if ($errors.Count -gt 0) { ($errors -join ' | ') } else { 'No roles/features enumeration source returned data.' } Add-Limitation -Context $Context -Module $module ` -Message 'Windows roles/features could not be fully enumerated; role-based findings may be incomplete.' ` -Impact 'Role detection (DC/DNS/DHCP/ADCS/IIS/Hyper-V/RDS/etc.) may be understated.' ` -Reason $reason | Out-Null } if ($method -eq 'Get-WindowsOptionalFeature' -or $method -eq 'DISM') { Add-Limitation -Context $Context -Module $module ` -Message 'Roles enumerated via optional-features fallback (client SKU or ServerManager unavailable); feature Names may not match canonical Windows Server role names.' ` -Impact 'Server-role rules that key off canonical role Names may not match optional-feature Names.' ` -Reason ("Enumeration method: {0}" -f $method) | Out-Null } } function Invoke-DiscoveryRiskAnalysis { param([object]$Context) $module = 'RolesFeatures' if (-not ($Context -and $Context.DataSets -and $Context.DataSets.Contains('RolesFeatures'))) { return } $rows = @($Context.DataSets['RolesFeatures'].Rows) # Helper returns a clean array via ',@(...)'; do NOT re-wrap in @() (that would # double-wrap into a single-element array whose element is the real array). $functions = Get-RolesFeaturesDetectedFunctions -Rows $rows if ($null -eq $functions) { $functions = @() } # Light, consolidated summary. The RiskEngine emits the per-role # (DC/DNS/DHCP/ADCS/IIS/Hyper-V) high-severity findings from this dataset; # this is an informational roll-up only. if ($functions.Count -gt 0) { Add-Finding -Context $Context -Category 'Server Role' -Severity 'Info' -Confidence 'Confirmed' ` -Title ('Server hosts {0} standard infrastructure/application role(s)' -f $functions.Count) ` -EvidenceSource 'Windows roles and features enumeration' ` -Evidence ('Detected roles: ' + ($functions -join ', ')) ` -WhyItMattersForScoping 'Installed server roles indicate infrastructure/application functions that must be inventoried, sequenced, and validated during any migration, refresh, or decommission. The RiskEngine emits per-role detail; this is a consolidated summary.' ` -PotentialProjectImpact @('Architecture Decision','Cutover Complexity','Client Coordination') ` -SuggestedValidationQuestion 'For each detected role, is there redundancy elsewhere, and what is the migration/retirement plan and owner?' ` -SourceModule $module -SourceDataset 'RolesFeatures' | Out-Null } else { Add-Finding -Context $Context -Category 'Server Role' -Severity 'Info' -Confidence 'Likely' ` -Title 'No standard infrastructure/application server roles detected' ` -EvidenceSource 'Windows roles and features enumeration' ` -Evidence ('Installed role/feature record count: {0}' -f $rows.Count) ` -WhyItMattersForScoping 'The server does not appear to host common infrastructure roles (DC/DNS/DHCP/ADCS/IIS/Hyper-V/RDS/etc.); it may be a member or application server whose purpose should be confirmed from other datasets (services, listening ports, installed applications).' ` -SuggestedValidationQuestion 'What is the primary purpose of this server if it hosts no standard Windows roles?' ` -SourceModule $module -SourceDataset 'RolesFeatures' | Out-Null } } function Get-DiscoveryFollowUpQuestions { param([object]$Context) $module = 'RolesFeatures' if (-not ($Context -and $Context.DataSets -and $Context.DataSets.Contains('RolesFeatures'))) { return } $rows = @($Context.DataSets['RolesFeatures'].Rows) # Helper returns a clean array via ',@(...)'; do NOT re-wrap in @() (that would # double-wrap into a single-element array whose element is the real array). $functions = Get-RolesFeaturesDetectedFunctions -Rows $rows if ($null -eq $functions) { $functions = @() } if ($functions.Count -gt 0) { Add-FollowUpQuestion -Context $Context ` -Question ('This server hosts the following roles: {0}. Is each role redundant elsewhere, and who owns the migration/retirement plan for each?' -f ($functions -join ', ')) ` -Category 'Roles & Features' -Module $module -Audience 'Both' | Out-Null } } #endregion Export-ModuleMember -Function ` 'Get-DiscoveryModuleMetadata','Test-DiscoveryPrerequisites','Invoke-DiscoveryCollection', ` 'ConvertTo-DiscoveryDatasets','Invoke-DiscoveryRiskAnalysis','Get-DiscoveryFollowUpQuestions', ` 'ConvertFrom-DismFeatureText','Get-RolesFeaturesDetectedFunctions' |