Src/Private/Get-AbrIntuneSecurityBaselines.ps1
|
function Get-AbrIntuneSecurityBaselines { <# .SYNOPSIS Documents Intune Security Baselines and Endpoint Security policies. .DESCRIPTION Collects and reports on: - Security Baseline profiles (MDM Security Baseline, Defender for Endpoint, etc.) - Per-profile assignment and compliance state .NOTES Version: 0.1.0 Author: Pai Wei Sing #> [CmdletBinding()] param ( [Parameter(Position = 0, Mandatory)] [string]$TenantId ) begin { Write-PScriboMessage -Message "Collecting Intune Security Baselines for $TenantId." Show-AbrDebugExecutionTime -Start -TitleMessage 'Security Baselines' } process { Section -Style Heading2 'Security Baselines' { Paragraph "The following section documents Security Baseline profiles configured in tenant $TenantId." BlankLine try { Write-Host " - Retrieving security baseline templates..." # /beta required -- deviceManagement/intents is beta-only $BaselineIntentsResp = Invoke-MgGraphRequest -Method GET ` -Uri "$($script:GraphEndpoint)/beta/deviceManagement/intents?`$expand=assignments" ` -ErrorAction SilentlyContinue $BaselineIntents = $BaselineIntentsResp.value if ($BaselineIntents -and @($BaselineIntents).Count -gt 0) { $BaselineObj = [System.Collections.ArrayList]::new() foreach ($Intent in ($BaselineIntents | Sort-Object displayName)) { $assignResolved = Resolve-IntuneAssignments -Assignments $Intent.assignments -CheckMemberCount:$script:CheckEmptyGroups $AssignedTo = $assignResolved.AssignmentSummary $scopeTagStr = if ($script:ResolveScopeTagNames -and $Intent.roleScopeTagIds) { Get-IntuneScopeTagNames -ScopeTagIds $Intent.roleScopeTagIds } else { 'Default' } $baselineInObj = [ordered] @{ 'Baseline Name' = $Intent.displayName 'Template ID' = if ($Intent.templateId) { $Intent.templateId } else { '--' } 'Is Assigned' = ($Intent.isAssigned -eq $true) 'Included Groups' = $assignResolved.IncludedGroups 'Excluded Groups' = if ($script:ShowExcludedGroups) { $assignResolved.ExcludedGroups } else { $null } 'Scope Tags' = $scopeTagStr 'Last Modified' = if ($Intent.lastModifiedDateTime) { ([datetime]$Intent.lastModifiedDateTime).ToString('yyyy-MM-dd') } else { '--' } } $BaselineObj.Add([pscustomobject](ConvertTo-HashToYN $baselineInObj)) | Out-Null } $null = (& { if ($HealthCheck.Intune.SecurityBaselines) { $null = ($BaselineObj | Where-Object { $_.'Included Groups' -eq '--' } | Set-Style -Style Warning | Out-Null) } }) $BaselineTableParams = @{ Name = "Security Baseline Profiles - $TenantId"; ColumnWidths = 24, 18, 9, 22, 12, 8, 7 } if ($Report.ShowTableCaptions) { $BaselineTableParams['Caption'] = "- $($BaselineTableParams.Name)" } $BaselineObj | Table @BaselineTableParams if (Get-IntuneBackupSectionEnabled -SectionKey 'SecurityBaselines') { $script:BackupData['SecurityBaselines'] = $BaselineIntents } if (Get-IntuneExcelSheetEnabled -SheetKey 'SecurityBaselines') { $script:ExcelSheets['Security Baselines'] = $BaselineObj } $null = ($script:TotalSecurityBaselines = @($BaselineIntents).Count) #region InfoLevel 2 -- per-baseline detail + settings if ($InfoLevel.SecurityBaselines -ge 2) { # H-07 fix: per-template definition label cache. # The /settings endpoint returns only definitionId (e.g. "deviceConfiguration--windows10EndpointProtection_defender_blockAdobeReaderChildProcess"). # We resolve display names lazily per-template using the templateId, caching results so the # same definition is not looked up twice across multiple profiles sharing a template. # Key = definitionId, Value = displayName string. $script:BaselineDefLabelCache = [System.Collections.Generic.Dictionary[string,string]]::new([System.StringComparer]::OrdinalIgnoreCase) function Resolve-BaselineSettingLabel { param([string]$DefinitionId, [string]$TemplateId) # Return from cache first if ($script:BaselineDefLabelCache.ContainsKey($DefinitionId)) { return $script:BaselineDefLabelCache[$DefinitionId] } # Try to resolve from the settingDefinitions endpoint for this template if ($TemplateId) { try { $DefsUrl = "$($script:GraphEndpoint)/beta/deviceManagement/templates/$TemplateId/settingDefinitions?`$select=id,displayName&`$top=200" $DefsPage = $null try { $DefsPage = Invoke-MgGraphRequest -Method GET -Uri $DefsUrl -ErrorAction Stop } catch { } while ($DefsPage -and $DefsPage.value) { foreach ($d in $DefsPage.value) { if ($d.id -and $d.displayName -and -not $script:BaselineDefLabelCache.ContainsKey($d.id)) { $script:BaselineDefLabelCache[$d.id] = $d.displayName } } if ($DefsPage.'@odata.nextLink') { try { $DefsPage = Invoke-MgGraphRequest -Method GET -Uri $DefsPage.'@odata.nextLink' -ErrorAction Stop } catch { break } } else { break } } } catch { } } # Return from cache if now populated, else fallback to camelCase parse if ($script:BaselineDefLabelCache.ContainsKey($DefinitionId)) { return $script:BaselineDefLabelCache[$DefinitionId] } return ($DefinitionId -split '_' | Select-Object -Last 1) -creplace '([A-Z])', ' $1' -replace '^\s+', '' } foreach ($Intent in ($BaselineIntents | Sort-Object displayName)) { $assignResolved = Resolve-IntuneAssignments -Assignments $Intent.assignments Section -Style Heading3 $Intent.displayName { BlankLine # Overview $ovObj = [System.Collections.ArrayList]::new() $ovObj.Add([pscustomobject]@{ Setting = 'Baseline Name'; Value = $Intent.displayName }) | Out-Null $ovObj.Add([pscustomobject]@{ Setting = 'Template ID'; Value = if ($Intent.templateId) { $Intent.templateId } else { '--' } }) | Out-Null $ovObj.Add([pscustomobject]@{ Setting = 'Is Assigned'; Value = if ($Intent.isAssigned) { 'Yes' } else { 'No' } }) | Out-Null $ovObj.Add([pscustomobject]@{ Setting = 'Included Groups'; Value = $assignResolved.IncludedGroups }) | Out-Null $ovObj.Add([pscustomobject]@{ Setting = 'Excluded Groups'; Value = $assignResolved.ExcludedGroups }) | Out-Null $ovObj.Add([pscustomobject]@{ Setting = 'Last Modified'; Value = if ($Intent.lastModifiedDateTime) { ([datetime]$Intent.lastModifiedDateTime).ToString('yyyy-MM-dd') } else { '--' } }) | Out-Null $OvParams = @{ Name = "Baseline Overview - $($Intent.displayName)"; ColumnWidths = 30, 70 } if ($Report.ShowTableCaptions) { $OvParams['Caption'] = "- $($OvParams.Name)" } $ovObj | Table @OvParams # MEDIUM gap fix: device compliance state summary per baseline profile try { $StateResp = $null try { $StateResp = Invoke-MgGraphRequest -Method GET ` -Uri "$($script:GraphEndpoint)/beta/deviceManagement/intents/$($Intent.id)/deviceStateSummary" ` -ErrorAction Stop } catch { Write-AbrDebugLog "Baseline deviceStateSummary unavailable for '$($Intent.displayName)': $($_.Exception.Message)" 'WARN' 'SecurityBaselines' } if ($StateResp -and ($StateResp.compliantCount -gt 0 -or $StateResp.errorCount -gt 0 -or $StateResp.conflictCount -gt 0 -or $StateResp.notCompliantCount -gt 0)) { BlankLine $stateRows = [System.Collections.ArrayList]::new() $stateRows.Add([pscustomobject]@{ Setting = 'Device Compliance State'; Value = '' }) | Out-Null if ($null -ne $StateResp.compliantCount) { $stateRows.Add([pscustomobject]@{ Setting = 'Compliant'; Value = "$($StateResp.compliantCount)" }) | Out-Null } if ($null -ne $StateResp.notCompliantCount) { $stateRows.Add([pscustomobject]@{ Setting = 'Non-compliant'; Value = "$($StateResp.notCompliantCount)" }) | Out-Null } if ($null -ne $StateResp.errorCount) { $stateRows.Add([pscustomobject]@{ Setting = 'Error'; Value = "$($StateResp.errorCount)" }) | Out-Null } if ($null -ne $StateResp.conflictCount) { $stateRows.Add([pscustomobject]@{ Setting = 'Conflict'; Value = "$($StateResp.conflictCount)" }) | Out-Null } if ($null -ne $StateResp.notApplicableCount){ $stateRows.Add([pscustomobject]@{ Setting = 'Not Applicable'; Value = "$($StateResp.notApplicableCount)"}) | Out-Null } $null = ($stateRows | Where-Object { $_.Setting -eq 'Device Compliance State' } | Set-Style -Style 'TableSectionHeader') $StateParams = @{ Name = "Device State Summary - $($Intent.displayName)"; ColumnWidths = 55, 45 } if ($Report.ShowTableCaptions) { $StateParams['Caption'] = "- $($StateParams.Name)" } $stateRows | Table @StateParams } } catch { } # Fetch all settings for this baseline via the flat /settings endpoint. # The older two-call approach (categories → per-category settings) is # deprecated and returns BadRequest on newer tenants. try { $SetResp = $null try { $SetResp = Invoke-MgGraphRequest -Method GET ` -Uri "$($script:GraphEndpoint)/beta/deviceManagement/intents/$($Intent.id)/settings" ` -ErrorAction Stop } catch { Write-AbrDebugLog "Baseline settings unavailable for '$($Intent.displayName)': $($_.Exception.Message)" 'WARN' 'SecurityBaselines' } if ($SetResp -and $SetResp.value -and @($SetResp.value).Count -gt 0) { $settingsRows = [System.Collections.ArrayList]::new() foreach ($setting in $SetResp.value) { $val = if ($null -ne $setting.value) { "$($setting.value)" } ` elseif ($setting.valueJson) { $setting.valueJson } ` else { $null } # Skip only genuinely unconfigured values — NOT 'false' # 'false' in a security baseline means "Block" or "Disabled" and must be shown if ($null -eq $val -or $val -in @('notConfigured','notSet','','null')) { continue } $label = if ($setting.definitionId) { Resolve-BaselineSettingLabel -DefinitionId $setting.definitionId -TemplateId $Intent.templateId } else { $setting.id } $settingsRows.Add([pscustomobject]([ordered]@{ 'Setting' = $label 'Value' = if ($val.Length -gt 80) { "$($val.Substring(0,80))..." } else { $val } })) | Out-Null } if ($settingsRows.Count -gt 0) { BlankLine Paragraph "Configured Settings ($($settingsRows.Count) setting(s)):" BlankLine $SetParams = @{ Name = "Settings - $($Intent.displayName)"; ColumnWidths = 50, 50 } if ($Report.ShowTableCaptions) { $SetParams['Caption'] = "- $($SetParams.Name)" } $settingsRows | Table @SetParams } else { Paragraph "No explicitly configured settings found (all values are at template defaults)." } } elseif ($SetResp) { Paragraph "No settings returned for this baseline profile." } } catch { Paragraph "Could not retrieve settings: $($_.Exception.Message)" } } } } #endregion } else { Paragraph "No Security Baseline profiles found in tenant $TenantId." } } catch { if (Test-AbrGraphForbidden -ErrorRecord $_) { Write-AbrPermissionError -Section 'Security Baselines' -RequiredRole 'Intune Service Administrator or Global Administrator' } else { Write-AbrSectionError -Section 'Security Baselines' -Message "$($_.Exception.Message)" } } } } end { Show-AbrDebugExecutionTime -End -TitleMessage 'Security Baselines' } } |