diff --git a/IntuneHydrationKit.psd1 b/IntuneHydrationKit.psd1 index a98d332..beeab94 100644 --- a/IntuneHydrationKit.psd1 +++ b/IntuneHydrationKit.psd1 @@ -47,6 +47,7 @@ 'Import-IntuneEnrollmentProfile', 'Import-IntuneMobileApp', 'Import-IntuneNotificationTemplate', + 'Import-IntuneRemediation', 'Import-IntuneWinGetApp', 'Initialize-HydrationLogging', 'Invoke-IntuneHydration', diff --git a/IntuneHydrationKit.psm1 b/IntuneHydrationKit.psm1 index 9a3b31f..9c62e99 100644 --- a/IntuneHydrationKit.psm1 +++ b/IntuneHydrationKit.psm1 @@ -88,6 +88,7 @@ $publicFunctions = @( 'Import-IntuneEnrollmentProfile', 'Import-IntuneMobileApp', 'Import-IntuneNotificationTemplate', + 'Import-IntuneRemediation', 'Import-IntuneWinGetApp', 'Initialize-HydrationLogging', 'Invoke-IntuneHydration', diff --git a/Invoke-IntuneHydration.ps1 b/Invoke-IntuneHydration.ps1 index 8ad830d..c9947c2 100644 --- a/Invoke-IntuneHydration.ps1 +++ b/Invoke-IntuneHydration.ps1 @@ -54,6 +54,8 @@ Process Conditional Access starter pack policies .PARAMETER MobileApps Process mobile app templates +.PARAMETER Remediations + Process bundled, unassigned Proactive Windows Remediations. .PARAMETER CISBaselines Process bundled CIS baseline policies .PARAMETER All @@ -166,6 +168,10 @@ param( [Parameter(ParameterSetName = 'ServicePrincipal')] [switch]$MobileApps, + [Parameter(ParameterSetName = 'Interactive')] + [Parameter(ParameterSetName = 'ServicePrincipal')] + [switch]$Remediations, + [Parameter(ParameterSetName = 'Interactive')] [Parameter(ParameterSetName = 'ServicePrincipal')] [switch]$CISBaselines, diff --git a/Private/Auth/Get-HydrationGraphScopes.ps1 b/Private/Auth/Get-HydrationGraphScopes.ps1 index e840f6c..589dfbf 100644 --- a/Private/Auth/Get-HydrationGraphScopes.ps1 +++ b/Private/Auth/Get-HydrationGraphScopes.ps1 @@ -52,6 +52,7 @@ function Get-HydrationGraphScopes { appProtection = @('DeviceManagementApps.ReadWrite.All') notificationTemplates = @('DeviceManagementServiceConfig.ReadWrite.All') mobileApps = @('DeviceManagementApps.ReadWrite.All') + remediations = @('DeviceManagementConfiguration.ReadWrite.All', 'DeviceManagementScripts.ReadWrite.All') cisBaselines = @('DeviceManagementConfiguration.ReadWrite.All') } diff --git a/Private/Auth/Get-HydrationGraphWorkloadAccessProbe.ps1 b/Private/Auth/Get-HydrationGraphWorkloadAccessProbe.ps1 index ead387c..10cea70 100644 --- a/Private/Auth/Get-HydrationGraphWorkloadAccessProbe.ps1 +++ b/Private/Auth/Get-HydrationGraphWorkloadAccessProbe.ps1 @@ -34,6 +34,7 @@ function Get-HydrationGraphWorkloadAccessProbe { }) } + $requiresWinGetRemediationProbe = $false if ($Imports.ContainsKey('mobileApps') -and $Imports.mobileApps) { $probes.Add(@{ Workload = 'Mobile Apps' @@ -48,15 +49,25 @@ function Get-HydrationGraphWorkloadAccessProbe { $remediationEnabled = [bool]$MobileAppConfiguration.remediationEnabled } - if ($remediationEnabled -and (Test-HydrationMobileAppsIncludeWinGet -Configuration $MobileAppConfiguration -Platforms $MobileAppPlatforms)) { - $probes.Add(@{ - Workload = 'WinGet Proactive Remediations' - Endpoint = 'beta/deviceManagement/deviceHealthScripts' - Uri = 'beta/deviceManagement/deviceHealthScripts?$top=1&$select=id' - RequiredScope = 'DeviceManagementScripts.ReadWrite.All' - RoleHint = 'Use a Global Administrator account with active Intune device script access; PIM-elevated roles can still be rejected by the downstream Intune service.' - }) + $requiresWinGetRemediationProbe = $remediationEnabled -and (Test-HydrationMobileAppsIncludeWinGet -Configuration $MobileAppConfiguration -Platforms $MobileAppPlatforms) + } + + $requiresRemediationProbe = $Imports.ContainsKey('remediations') -and $Imports.remediations + if ($requiresWinGetRemediationProbe -or $requiresRemediationProbe) { + $workloads = [System.Collections.Generic.List[string]]::new() + if ($requiresWinGetRemediationProbe) { + $workloads.Add('WinGet Proactive Remediations') } + if ($requiresRemediationProbe) { + $workloads.Add('Proactive Remediations') + } + $probes.Add(@{ + Workload = $workloads -join ' and ' + Endpoint = 'beta/deviceManagement/deviceHealthScripts' + Uri = 'beta/deviceManagement/deviceHealthScripts?$top=1&$select=id' + RequiredScope = 'DeviceManagementScripts.ReadWrite.All' + RoleHint = 'Use a Global Administrator account with active Intune device script access; PIM-elevated roles can still be rejected by the downstream Intune service.' + }) } $appProtectionProbePlatforms = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) diff --git a/Private/Configuration/Get-HydrationWorkloadCatalog.ps1 b/Private/Configuration/Get-HydrationWorkloadCatalog.ps1 index 86ca5fc..b04cb23 100644 --- a/Private/Configuration/Get-HydrationWorkloadCatalog.ps1 +++ b/Private/Configuration/Get-HydrationWorkloadCatalog.ps1 @@ -57,6 +57,12 @@ function Get-HydrationWorkloadCatalog { Platforms = @('Windows', 'macOS') PlatformNeutral = $false } + [pscustomobject]@{ + ImportKey = 'remediations' + FilterKey = 'Remediations' + Platforms = @('Windows') + PlatformNeutral = $false + } [pscustomobject]@{ ImportKey = 'notificationTemplates' FilterKey = $null diff --git a/Private/Configuration/Resolve-HydrationExecutionSettings.ps1 b/Private/Configuration/Resolve-HydrationExecutionSettings.ps1 index 256192a..5edfcaa 100644 --- a/Private/Configuration/Resolve-HydrationExecutionSettings.ps1 +++ b/Private/Configuration/Resolve-HydrationExecutionSettings.ps1 @@ -73,6 +73,9 @@ function Resolve-HydrationExecutionSettings { [Parameter()] [switch]$MobileApps, + [Parameter()] + [switch]$Remediations, + [Parameter()] [switch]$CISBaselines, @@ -179,6 +182,7 @@ function Resolve-HydrationExecutionSettings { appProtection = $All.IsPresent -or $AppProtection.IsPresent notificationTemplates = $All.IsPresent -or $NotificationTemplates.IsPresent mobileApps = $All.IsPresent -or $MobileApps.IsPresent + remediations = $All.IsPresent -or $Remediations.IsPresent cisBaselines = $All.IsPresent -or $CISBaselines.IsPresent } diff --git a/Private/DeviceHealthScripts/ConvertFrom-HydrationDeviceHealthScriptDescription.ps1 b/Private/DeviceHealthScripts/ConvertFrom-HydrationDeviceHealthScriptDescription.ps1 new file mode 100644 index 0000000..c9261d9 --- /dev/null +++ b/Private/DeviceHealthScripts/ConvertFrom-HydrationDeviceHealthScriptDescription.ps1 @@ -0,0 +1,28 @@ +function ConvertFrom-HydrationDeviceHealthScriptDescription { + <# + .SYNOPSIS + Parses newline-delimited device health script metadata. + #> + [CmdletBinding()] + [OutputType([System.Collections.Generic.Dictionary[string, string]])] + param( + [Parameter()] + [AllowEmptyString()] + [string]$Description + ) + + $metadata = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($line in $Description -split "`r?`n") { + $separatorIndex = $line.IndexOf(':') + if ($separatorIndex -lt 1) { + continue + } + + $key = $line.Substring(0, $separatorIndex).Trim() + if (-not [string]::IsNullOrWhiteSpace($key)) { + $metadata[$key] = $line.Substring($separatorIndex + 1).Trim() + } + } + + return $metadata +} diff --git a/Private/DeviceHealthScripts/Sync-IntuneDeviceHealthScript.ps1 b/Private/DeviceHealthScripts/Sync-IntuneDeviceHealthScript.ps1 new file mode 100644 index 0000000..2687e78 --- /dev/null +++ b/Private/DeviceHealthScripts/Sync-IntuneDeviceHealthScript.ps1 @@ -0,0 +1,155 @@ +function Sync-IntuneDeviceHealthScript { + <# + .SYNOPSIS + Synchronizes one Intune device health script from a declarative definition. + .DESCRIPTION + Every definition requires DisplayName, Type, Path, SourceMarker, and + OwnershipMetadata. Present definitions additionally require + FingerprintMetadataKey, Fingerprint, Status, and BuildBody. + BuildBody receives IncludeCreateOnlyProperties and remains workload-specific. + #> + [CmdletBinding(SupportsShouldProcess)] + [OutputType([PSCustomObject[]])] + param( + [Parameter(Mandatory)] + [hashtable]$Definition, + + [Parameter()] + [ValidateSet('Present', 'Remove')] + [string]$DesiredState = 'Present', + + [Parameter()] + [bool]$WhatIfEnabled = $false + ) + + $requiredKeys = @('DisplayName', 'Type', 'Path', 'SourceMarker', 'OwnershipMetadata') + if ($DesiredState -eq 'Present') { + $requiredKeys += 'FingerprintMetadataKey', 'Fingerprint', 'Status', 'BuildBody' + } + $missingKeys = @($requiredKeys | Where-Object { -not $Definition.ContainsKey($_) }) + if ($missingKeys.Count -gt 0) { + throw "Device health script definition is missing required key(s): $($missingKeys -join ', ')" + } + if ($Definition.OwnershipMetadata -isnot [hashtable] -or $Definition.OwnershipMetadata.Count -eq 0) { + throw 'Device health script definition requires non-empty ownership metadata.' + } + + $escapedDisplayName = $Definition.DisplayName.Replace("'", "''") + $filter = [uri]::EscapeDataString("displayName eq '$escapedDisplayName'") + $response = Invoke-HydrationGraphRequest -Method GET -Uri "beta/deviceManagement/deviceHealthScripts?`$filter=$filter" + $existingScripts = @($response.value | Where-Object { $null -ne $_ }) + $ownedScripts = [System.Collections.Generic.List[object]]::new() + + foreach ($existingScript in $existingScripts) { + $description = [string]$existingScript.description + if (-not (Test-HydrationKitObject -Description $description)) { + continue + } + + $descriptionLines = $description -split "`r?`n" | ForEach-Object { $_.Trim() } + if ($descriptionLines -notcontains $Definition.SourceMarker) { + continue + } + + $metadata = ConvertFrom-HydrationDeviceHealthScriptDescription -Description $description + $isOwned = $true + foreach ($key in $Definition.OwnershipMetadata.Keys) { + if (-not $metadata.ContainsKey($key) -or $metadata[$key] -cne [string]$Definition.OwnershipMetadata[$key]) { + $isOwned = $false + break + } + } + + if ($isOwned) { + $ownedScripts.Add([pscustomobject]@{ + Script = $existingScript + Metadata = $metadata + }) + } + } + + if ($DesiredState -eq 'Remove') { + $results = [System.Collections.Generic.List[object]]::new() + foreach ($ownedScript in $ownedScripts) { + if ($WhatIfEnabled) { + $results.Add((Add-HydrationDryRunResult -Action 'WouldDelete' -Name $Definition.DisplayName -Id $ownedScript.Script.id -Path $Definition.Path -Type $Definition.Type)) + continue + } + + if (-not $PSCmdlet.ShouldProcess($Definition.DisplayName, 'Delete remediation')) { + continue + } + + try { + Invoke-HydrationGraphRequest -Method DELETE -Uri "beta/deviceManagement/deviceHealthScripts/$($ownedScript.Script.id)" | Out-Null + Write-HydrationLog -Message " Deleted: $($Definition.DisplayName)" -Level Info + $results.Add((New-HydrationResult -Name $Definition.DisplayName -Id $ownedScript.Script.id -Path $Definition.Path -Type $Definition.Type -Action 'Deleted' -Status 'Removed')) + } catch { + $errorMessage = Get-GraphErrorMessage -ErrorRecord $_ + Write-HydrationLog -Message " Failed: $($Definition.DisplayName) - $errorMessage" -Level Warning + $results.Add((New-HydrationResult -Name $Definition.DisplayName -Id $ownedScript.Script.id -Path $Definition.Path -Type $Definition.Type -Action 'Failed' -Status $errorMessage)) + } + } + + return @($results) + } + + if ($ownedScripts.Count -gt 1) { + Write-HydrationLog -Message " Failed: $($Definition.DisplayName) - Multiple matching hydration-owned remediations exist; remove them explicitly before importing." -Level Warning + return @(New-HydrationResult -Name $Definition.DisplayName -Path $Definition.Path -Type $Definition.Type -Action 'Failed' -Status 'Multiple owned remediations') + } + + $ownedScript = $ownedScripts | Select-Object -First 1 + if ($existingScripts.Count -gt 0 -and -not $ownedScript) { + Write-HydrationLog -Message " Failed: $($Definition.DisplayName) - A remediation with this name already exists but is not owned by Intune Hydration Kit." -Level Warning + return @(New-HydrationResult -Name $Definition.DisplayName -Path $Definition.Path -Type $Definition.Type -Action 'Failed' -Status 'Name collision') + } + + if ($ownedScript -and $ownedScript.Metadata[$Definition.FingerprintMetadataKey] -ceq $Definition.Fingerprint) { + Write-HydrationLog -Message " Skipped: $($Definition.DisplayName)" -Level Info + return @(New-HydrationResult -Name $Definition.DisplayName -Id $ownedScript.Script.id -Path $Definition.Path -Type $Definition.Type -Action 'Skipped' -Status 'Already current') + } + + if ($ownedScript -and $Definition.ContainsKey('RequireUnassigned') -and $Definition.RequireUnassigned) { + try { + $assignmentResponse = Invoke-HydrationGraphRequest -Method GET -Uri "beta/deviceManagement/deviceHealthScripts/$($ownedScript.Script.id)/assignments?`$top=1" + $assignments = @($assignmentResponse.value | Where-Object { $null -ne $_ }) + } catch { + $errorMessage = Get-GraphErrorMessage -ErrorRecord $_ + Write-HydrationLog -Message " Failed: $($Definition.DisplayName) - Could not verify assignments: $errorMessage" -Level Warning + return @(New-HydrationResult -Name $Definition.DisplayName -Id $ownedScript.Script.id -Path $Definition.Path -Type $Definition.Type -Action 'Failed' -Status "Assignment check failed: $errorMessage") + } + + if ($assignments.Count -gt 0) { + Write-HydrationLog -Message " Failed: $($Definition.DisplayName) - The remediation has assignments and will not be updated." -Level Warning + return @(New-HydrationResult -Name $Definition.DisplayName -Id $ownedScript.Script.id -Path $Definition.Path -Type $Definition.Type -Action 'Failed' -Status 'Assigned') + } + } + + if ($WhatIfEnabled) { + $action = if ($ownedScript) { 'WouldUpdate' } else { 'WouldCreate' } + return @(Add-HydrationDryRunResult -Action $action -Name $Definition.DisplayName -Id $ownedScript.Script.id -Path $Definition.Path -Type $Definition.Type) + } + + $operation = if ($ownedScript) { 'Update remediation' } else { 'Create remediation' } + if (-not $PSCmdlet.ShouldProcess($Definition.DisplayName, $operation)) { + return @() + } + + try { + $body = & $Definition.BuildBody (-not $ownedScript) + if ($ownedScript) { + Invoke-HydrationGraphRequest -Method PATCH -Uri "beta/deviceManagement/deviceHealthScripts/$($ownedScript.Script.id)" -Body $body | Out-Null + Write-HydrationLog -Message " Updated: $($Definition.DisplayName)" -Level Info + return @(New-HydrationResult -Name $Definition.DisplayName -Id $ownedScript.Script.id -Path $Definition.Path -Type $Definition.Type -Action 'Updated' -Status $Definition.Status) + } + + $createdScript = Invoke-HydrationGraphRequest -Method POST -Uri 'beta/deviceManagement/deviceHealthScripts' -Body $body + Write-HydrationLog -Message " Created: $($Definition.DisplayName)" -Level Info + return @(New-HydrationResult -Name $Definition.DisplayName -Id $createdScript.id -Path $Definition.Path -Type $Definition.Type -Action 'Created' -Status $Definition.Status) + } catch { + $errorMessage = Get-GraphErrorMessage -ErrorRecord $_ + Write-HydrationLog -Message " Failed: $($Definition.DisplayName) - $errorMessage" -Level Warning + return @(New-HydrationResult -Name $Definition.DisplayName -Path $Definition.Path -Type $Definition.Type -Action 'Failed' -Status $errorMessage) + } +} diff --git a/Private/Remediations/Get-HydrationRemediationFingerprint.ps1 b/Private/Remediations/Get-HydrationRemediationFingerprint.ps1 new file mode 100644 index 0000000..c49059c --- /dev/null +++ b/Private/Remediations/Get-HydrationRemediationFingerprint.ps1 @@ -0,0 +1,23 @@ +function Get-HydrationRemediationFingerprint { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [psobject]$Template + ) + + $fingerprintInput = [System.Collections.Generic.List[string]]::new() + foreach ($propertyName in @('templateId', 'displayName', 'publisher', 'description', 'runAsAccount', 'runAs32Bit')) { + $fingerprintInput.Add("$propertyName=$($Template.$propertyName)") + } + + foreach ($scriptPath in @($Template.DetectionScriptPath, $Template.RemediationScriptPath)) { + if (-not [string]::IsNullOrWhiteSpace($scriptPath)) { + $fingerprintInput.Add((Get-Content -LiteralPath $scriptPath -Raw -Encoding utf8)) + } + } + + $bytes = [System.Text.Encoding]::UTF8.GetBytes(($fingerprintInput -join "`n")) + $hash = [System.Security.Cryptography.SHA256]::HashData($bytes) + return [Convert]::ToHexString($hash) +} diff --git a/Private/Remediations/Get-HydrationRemediationTemplates.ps1 b/Private/Remediations/Get-HydrationRemediationTemplates.ps1 new file mode 100644 index 0000000..9f4eb95 --- /dev/null +++ b/Private/Remediations/Get-HydrationRemediationTemplates.ps1 @@ -0,0 +1,81 @@ +function Get-HydrationRemediationTemplates { + [CmdletBinding()] + [OutputType([psobject[]])] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$TemplatePath, + + [Parameter()] + [string[]]$TemplateId + ) + + if (-not (Test-Path -LiteralPath $TemplatePath -PathType Container)) { + return @() + } + + $templates = [System.Collections.Generic.List[object]]::new() + $templateIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $displayNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($metadataFile in @(Get-ChildItem -LiteralPath $TemplatePath -Filter 'metadata.json' -File -Recurse | Sort-Object FullName)) { + try { + $metadata = Get-Content -LiteralPath $metadataFile.FullName -Raw -Encoding utf8 | ConvertFrom-Json -AsHashtable + } catch { + throw "Invalid remediation template metadata '$($metadataFile.FullName)': $($_.Exception.Message)" + } + + foreach ($requiredProperty in @('templateId', 'displayName', 'publisher', 'description', 'runAsAccount', 'runAs32Bit', 'detectionScript')) { + if (-not $metadata.Contains($requiredProperty) -or [string]::IsNullOrWhiteSpace([string]$metadata[$requiredProperty])) { + throw "Remediation template '$($metadataFile.FullName)' is missing required property '$requiredProperty'." + } + } + + if ($metadata.runAsAccount -notin @('system', 'user')) { + throw "Remediation template '$($metadataFile.FullName)' has unsupported runAsAccount '$($metadata.runAsAccount)'." + } + if ($metadata.runAs32Bit -isnot [bool]) { + throw "Remediation template '$($metadataFile.FullName)' has non-Boolean runAs32Bit value." + } + + if (-not $templateIds.Add([string]$metadata.templateId)) { + throw "Remediation template '$($metadataFile.FullName)' has duplicate templateId '$($metadata.templateId)'." + } + + if (-not $displayNames.Add([string]$metadata.displayName)) { + throw "Remediation template '$($metadataFile.FullName)' has duplicate displayName '$($metadata.displayName)'." + } + + $templateDirectory = $metadataFile.DirectoryName + $detectionScriptPath = Resolve-HydrationTemplateChildPath -RootPath $templateDirectory -ChildPath ([string]$metadata.detectionScript) -PathLabel "Remediation template '$($metadataFile.FullName)' detection script" + if (-not (Test-Path -LiteralPath $detectionScriptPath -PathType Leaf)) { + throw "Remediation template '$($metadataFile.FullName)' detection script was not found: $detectionScriptPath" + } + + $remediationScriptPath = $null + if ($metadata.Contains('remediationScript') -and -not [string]::IsNullOrWhiteSpace([string]$metadata.remediationScript)) { + $remediationScriptPath = Resolve-HydrationTemplateChildPath -RootPath $templateDirectory -ChildPath ([string]$metadata.remediationScript) -PathLabel "Remediation template '$($metadataFile.FullName)' remediation script" + if (-not (Test-Path -LiteralPath $remediationScriptPath -PathType Leaf)) { + throw "Remediation template '$($metadataFile.FullName)' remediation script was not found: $remediationScriptPath" + } + } + + $templates.Add([pscustomobject]@{ + TemplateId = [string]$metadata.templateId + DisplayName = [string]$metadata.displayName + Publisher = [string]$metadata.publisher + Description = [string]$metadata.description + RunAsAccount = [string]$metadata.runAsAccount + RunAs32Bit = [bool]$metadata.runAs32Bit + SortOrder = if ($metadata.Contains('sortOrder')) { [int]$metadata.sortOrder } else { 1000 } + TemplatePath = $metadataFile.FullName + DetectionScriptPath = $detectionScriptPath + RemediationScriptPath = $remediationScriptPath + }) + } + + if ($TemplateId) { + return @($templates | Where-Object { $_.TemplateId -in $TemplateId } | Sort-Object SortOrder, TemplateId) + } + + return @($templates | Sort-Object SortOrder, TemplateId) +} diff --git a/Private/Remediations/New-HydrationRemediationBody.ps1 b/Private/Remediations/New-HydrationRemediationBody.ps1 new file mode 100644 index 0000000..0f107f9 --- /dev/null +++ b/Private/Remediations/New-HydrationRemediationBody.ps1 @@ -0,0 +1,46 @@ +function New-HydrationRemediationBody { + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory)] + [psobject]$Template, + + [Parameter(Mandatory)] + [string]$DisplayName, + + [Parameter(Mandatory)] + [string]$Description, + + [Parameter(Mandatory)] + [bool]$IncludeCreateOnlyProperties + ) + + $detectionScriptContent = Get-Content -LiteralPath $Template.DetectionScriptPath -Raw -Encoding utf8 + $null = Save-HydrationGeneratedScript -RelativePath "Remediations/$($Template.TemplateId)/$([System.IO.Path]::GetFileName($Template.DetectionScriptPath))" -SourcePath $Template.DetectionScriptPath + + $body = [ordered]@{ + publisher = $Template.Publisher + displayName = $DisplayName + description = $Description + detectionScriptContent = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($detectionScriptContent)) + runAs32Bit = $Template.RunAs32Bit + runAsAccount = $Template.RunAsAccount + enforceSignatureCheck = $false + roleScopeTagIds = @('0') + detectionScriptParameters = @() + remediationScriptParameters = @() + } + + if (-not [string]::IsNullOrWhiteSpace($Template.RemediationScriptPath)) { + $remediationScriptContent = Get-Content -LiteralPath $Template.RemediationScriptPath -Raw -Encoding utf8 + $body['remediationScriptContent'] = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($remediationScriptContent)) + $null = Save-HydrationGeneratedScript -RelativePath "Remediations/$($Template.TemplateId)/$([System.IO.Path]::GetFileName($Template.RemediationScriptPath))" -SourcePath $Template.RemediationScriptPath + } + + if ($IncludeCreateOnlyProperties) { + $body['@odata.type'] = '#microsoft.graph.deviceHealthScript' + $body['isGlobalScript'] = $false + } + + return $body +} diff --git a/Private/Remediations/New-HydrationRemediationDescription.ps1 b/Private/Remediations/New-HydrationRemediationDescription.ps1 new file mode 100644 index 0000000..c7d76bd --- /dev/null +++ b/Private/Remediations/New-HydrationRemediationDescription.ps1 @@ -0,0 +1,20 @@ +function New-HydrationRemediationDescription { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [psobject]$Template, + + [Parameter(Mandatory)] + [string]$Fingerprint + ) + + return @( + $Template.Description + (New-HydrationDescription) + 'Imported from Proactive Remediation Pack' + "RemediationTemplateId: $($Template.TemplateId)" + "RemediationFingerprint: $Fingerprint" + 'Assignments: none' + ) -join "`n" +} diff --git a/Private/Tui/Get-HydrationTuiImportOption.ps1 b/Private/Tui/Get-HydrationTuiImportOption.ps1 index c448a3a..62799c0 100644 --- a/Private/Tui/Get-HydrationTuiImportOption.ps1 +++ b/Private/Tui/Get-HydrationTuiImportOption.ps1 @@ -15,5 +15,6 @@ function Get-HydrationTuiImportOption { [pscustomobject]@{ Number = 9; Key = 'enrollmentProfiles'; Label = 'Enrollment Profiles' } [pscustomobject]@{ Number = 10; Key = 'conditionalAccess'; Label = 'Conditional Access' } [pscustomobject]@{ Number = 11; Key = 'mobileApps'; Label = 'Mobile Apps' } + [pscustomobject]@{ Number = 12; Key = 'remediations'; Label = 'Proactive Remediations' } ) } diff --git a/Private/WinGet/Sync-IntuneWinGetProactiveRemediation.ps1 b/Private/WinGet/Sync-IntuneWinGetProactiveRemediation.ps1 index e5d07bf..6dadc9a 100644 --- a/Private/WinGet/Sync-IntuneWinGetProactiveRemediation.ps1 +++ b/Private/WinGet/Sync-IntuneWinGetProactiveRemediation.ps1 @@ -12,68 +12,6 @@ function Sync-IntuneWinGetProactiveRemediation { [bool]$WhatIfEnabled = $false ) - function Get-ExistingRemediation { - param( - [Parameter(Mandatory)] - [string]$DisplayName - ) - - $escapedDisplayName = $DisplayName.Replace("'", "''") - $filter = [uri]::EscapeDataString("displayName eq '$escapedDisplayName'") - $response = Invoke-HydrationGraphRequest -Method GET -Uri "beta/deviceManagement/deviceHealthScripts?`$filter=$filter" - return @($response.value) - } - - function Test-OwnedWinGetRemediation { - param( - [Parameter(Mandatory)] - [psobject]$ExistingRemediation, - - [Parameter(Mandatory)] - [string]$Scope - ) - - return (Test-HydrationKitObject -Description ([string]$ExistingRemediation.description)) -and - ([string]$ExistingRemediation.description -like '*Imported from WinGet*') -and - ([string]$ExistingRemediation.description -like "*WinGetRemediationScope: $Scope*") - } - - function Remove-OwnedWinGetRemediation { - param( - [Parameter(Mandatory)] - [psobject]$Definition, - - [Parameter(Mandatory)] - [AllowEmptyCollection()] - [object[]]$OwnedExisting - ) - - if ($OwnedExisting.Count -eq 0) { - return $false - } - - if ($WhatIfEnabled) { - foreach ($existingRemediation in $OwnedExisting) { - $results.Add((Add-HydrationDryRunResult -Action 'WouldDelete' -Name $Definition.DisplayName -Id $existingRemediation.id -Type 'WinGetRemediation')) - } - return $true - } - - foreach ($existingRemediation in $OwnedExisting) { - try { - Invoke-HydrationGraphRequest -Method DELETE -Uri "beta/deviceManagement/deviceHealthScripts/$($existingRemediation.id)" | Out-Null - Write-HydrationLog -Message " Deleted: $($Definition.DisplayName)" -Level Info - $results.Add((New-HydrationResult -Name $Definition.DisplayName -Id $existingRemediation.id -Type 'WinGetRemediation' -Action 'Deleted' -Status 'Removed')) - } catch { - $errorMessage = Get-GraphErrorMessage -ErrorRecord $_ - Write-HydrationLog -Message " Failed: $($Definition.DisplayName) - $errorMessage" -Level Warning - $results.Add((New-HydrationResult -Name $Definition.DisplayName -Id $existingRemediation.id -Type 'WinGetRemediation' -Action 'Failed' -Status $errorMessage)) - } - } - - return $true - } - $results = [System.Collections.Generic.List[object]]::new() $definitions = @( Get-WinGetRemediationDefinition -Scope 'system' -TemplateSet $Templates @@ -88,57 +26,34 @@ function Sync-IntuneWinGetProactiveRemediation { ) } - foreach ($definition in $definitions) { - if ($definition.PackageIdentifiers.Count -eq 0 -and -not $RemoveExisting) { - $existingRemediations = Get-ExistingRemediation -DisplayName $definition.DisplayName - $ownedExisting = @($existingRemediations | Where-Object { Test-OwnedWinGetRemediation -ExistingRemediation $_ -Scope $definition.Scope }) - - if (Remove-OwnedWinGetRemediation -Definition $definition -OwnedExisting $ownedExisting) { - continue + foreach ($winGetDefinition in $definitions) { + $hasPackages = $winGetDefinition.PackageIdentifiers.Count -gt 0 + $definition = @{ + DisplayName = $winGetDefinition.DisplayName + Type = 'WinGetRemediation' + Path = $null + SourceMarker = 'Imported from WinGet' + OwnershipMetadata = @{ WinGetRemediationScope = $winGetDefinition.Scope } + } + if (-not $RemoveExisting -and $hasPackages) { + $definition.FingerprintMetadataKey = 'WinGetPackageFingerprint' + $definition.Fingerprint = Get-WinGetRemediationFingerprint -PackageIdentifiers $winGetDefinition.PackageIdentifiers + $definition.Status = "Packages=$($winGetDefinition.PackageIdentifiers.Count)" + $definition.BuildBody = { + param($IncludeCreateOnlyProperties) + New-WinGetRemediationBody -Definition $winGetDefinition -IncludeCreateOnlyProperties $IncludeCreateOnlyProperties } - - Write-HydrationLog -Message " Skipped: $($definition.DisplayName) - no packages for scope." -Level Info - $results.Add((New-HydrationResult -Name $definition.DisplayName -Type 'WinGetRemediation' -Action 'Skipped' -Status 'No packages')) - continue - } - - $existingRemediations = Get-ExistingRemediation -DisplayName $definition.DisplayName - $ownedExisting = @($existingRemediations | Where-Object { Test-OwnedWinGetRemediation -ExistingRemediation $_ -Scope $definition.Scope }) - - if ($RemoveExisting) { - $null = Remove-OwnedWinGetRemediation -Definition $definition -OwnedExisting $ownedExisting - continue - } - - $ownedExistingRemediation = $ownedExisting | Select-Object -First 1 - if ($existingRemediations.Count -gt 0 -and -not $ownedExistingRemediation) { - Write-HydrationLog -Message " Failed: $($definition.DisplayName) - A remediation with this name already exists but is not owned by Intune Hydration Kit." -Level Warning - $results.Add((New-HydrationResult -Name $definition.DisplayName -Type 'WinGetRemediation' -Action 'Failed' -Status 'Name collision')) - continue - } - - $fingerprint = Get-WinGetRemediationFingerprint -PackageIdentifiers $definition.PackageIdentifiers - if ($ownedExistingRemediation -and [string]$ownedExistingRemediation.description -like "*WinGetPackageFingerprint: $fingerprint*") { - Write-HydrationLog -Message " Skipped: $($definition.DisplayName)" -Level Info - $results.Add((New-HydrationResult -Name $definition.DisplayName -Id $ownedExistingRemediation.id -Type 'WinGetRemediation' -Action 'Skipped' -Status 'Already current')) - continue } + $desiredState = if ($RemoveExisting -or -not $hasPackages) { 'Remove' } else { 'Present' } - if ($WhatIfEnabled) { - $action = if ($ownedExistingRemediation) { 'WouldUpdate' } else { 'WouldCreate' } - $results.Add((Add-HydrationDryRunResult -Action $action -Name $definition.DisplayName -Id $ownedExistingRemediation.id -Type 'WinGetRemediation')) - continue + $definitionResults = @(Sync-IntuneDeviceHealthScript -Definition $definition -DesiredState $desiredState -WhatIfEnabled $WhatIfEnabled) + foreach ($result in $definitionResults) { + $results.Add($result) } - $body = New-WinGetRemediationBody -Definition $definition -IncludeCreateOnlyProperties (-not $ownedExistingRemediation) - if ($ownedExistingRemediation) { - Invoke-HydrationGraphRequest -Method PATCH -Uri "beta/deviceManagement/deviceHealthScripts/$($ownedExistingRemediation.id)" -Body $body | Out-Null - Write-HydrationLog -Message " Updated: $($definition.DisplayName)" -Level Info - $results.Add((New-HydrationResult -Name $definition.DisplayName -Id $ownedExistingRemediation.id -Type 'WinGetRemediation' -Action 'Updated' -Status "Packages=$($definition.PackageIdentifiers.Count)")) - } else { - $createdRemediation = Invoke-HydrationGraphRequest -Method POST -Uri 'beta/deviceManagement/deviceHealthScripts' -Body $body - Write-HydrationLog -Message " Created: $($definition.DisplayName)" -Level Info - $results.Add((New-HydrationResult -Name $definition.DisplayName -Id $createdRemediation.id -Type 'WinGetRemediation' -Action 'Created' -Status "Packages=$($definition.PackageIdentifiers.Count)")) + if (-not $RemoveExisting -and -not $hasPackages -and $definitionResults.Count -eq 0) { + Write-HydrationLog -Message " Skipped: $($winGetDefinition.DisplayName) - No packages." -Level Info + $results.Add((New-HydrationResult -Name $winGetDefinition.DisplayName -Type 'WinGetRemediation' -Action 'Skipped' -Status 'No packages')) } } diff --git a/Public/Imports/Import-IntuneRemediation.ps1 b/Public/Imports/Import-IntuneRemediation.ps1 new file mode 100644 index 0000000..2cbb4b3 --- /dev/null +++ b/Public/Imports/Import-IntuneRemediation.ps1 @@ -0,0 +1,82 @@ +function Import-IntuneRemediation { + <# + .SYNOPSIS + Imports bundled Proactive Intune Remediations. + .DESCRIPTION + Creates or updates unassigned Windows remediation packages from the bundled + Proactive remediation templates. Only resources tagged with the matching + template ID and hydration marker are eligible for deletion. + .PARAMETER TemplatePath + Directory containing remediation template metadata and scripts. + .PARAMETER TemplateId + Optional remediation template IDs to import. + .PARAMETER RemoveExisting + Deletes matching hydration-owned remediation packages instead of creating them. + .EXAMPLE + Import-IntuneRemediation + .EXAMPLE + Import-IntuneRemediation -TemplateId 'windows-disk-pressure-cleanup' -WhatIf + #> + [CmdletBinding(SupportsShouldProcess)] + [OutputType([PSCustomObject[]])] + param( + [Parameter()] + [string]$TemplatePath = (Join-Path -Path $script:TemplatesPath -ChildPath 'Remediations'), + + [Parameter()] + [string[]]$TemplateId, + + [Parameter()] + [switch]$RemoveExisting + ) + + $templates = @(Get-HydrationRemediationTemplates -TemplatePath $TemplatePath -TemplateId $TemplateId) + if ($templates.Count -eq 0) { + if ($TemplateId) { + Write-Warning "No remediation templates matched TemplateId value(s): $($TemplateId -join ', ')" + } + return @() + } + + $availability = Get-IntuneProactiveRemediationAvailability + if (-not $availability.IsAvailable) { + Write-HydrationLog -Message " Skipped: Proactive remediations - $($availability.Message)" -Level Warning + return @(New-HydrationResult -Name 'Proactive remediations' -Type 'Remediation' -Action 'Skipped' -Status $availability.Status) + } + + $operation = if ($RemoveExisting) { 'Delete' } else { 'Import' } + if (-not $WhatIfPreference -and -not $PSCmdlet.ShouldProcess("$($templates.Count) remediation package(s)", $operation)) { + return @() + } + + $results = [System.Collections.Generic.List[object]]::new() + foreach ($template in $templates) { + $displayName = "$script:ImportPrefix$($template.DisplayName)" + $definition = @{ + DisplayName = $displayName + Type = 'Remediation' + Path = $template.TemplatePath + SourceMarker = 'Imported from Proactive Remediation Pack' + OwnershipMetadata = @{ RemediationTemplateId = $template.TemplateId } + RequireUnassigned = $true + } + if (-not $RemoveExisting) { + $fingerprint = Get-HydrationRemediationFingerprint -Template $template + $definition.FingerprintMetadataKey = 'RemediationFingerprint' + $definition.Fingerprint = $fingerprint + $definition.Status = "Template=$($template.TemplateId)" + $definition.BuildBody = { + param($IncludeCreateOnlyProperties) + $description = New-HydrationRemediationDescription -Template $template -Fingerprint $fingerprint + New-HydrationRemediationBody -Template $template -DisplayName $displayName -Description $description -IncludeCreateOnlyProperties $IncludeCreateOnlyProperties + } + } + $desiredState = if ($RemoveExisting) { 'Remove' } else { 'Present' } + + foreach ($result in @(Sync-IntuneDeviceHealthScript -Definition $definition -DesiredState $desiredState -WhatIfEnabled $WhatIfPreference -Confirm:$false)) { + $results.Add($result) + } + } + + return @($results) +} diff --git a/Public/Orchestration/Invoke-IntuneHydration.ps1 b/Public/Orchestration/Invoke-IntuneHydration.ps1 index 0335735..63bfa22 100644 --- a/Public/Orchestration/Invoke-IntuneHydration.ps1 +++ b/Public/Orchestration/Invoke-IntuneHydration.ps1 @@ -60,6 +60,8 @@ function Invoke-IntuneHydration { Process Conditional Access starter pack policies .PARAMETER MobileApps Process mobile app templates + .PARAMETER Remediations + Process bundled, unassigned Proactive Windows Remediations. .PARAMETER All Enable all targets .PARAMETER Platform @@ -190,6 +192,10 @@ function Invoke-IntuneHydration { [Parameter(ParameterSetName = 'ServicePrincipal')] [switch]$MobileApps, + [Parameter(ParameterSetName = 'Interactive')] + [Parameter(ParameterSetName = 'ServicePrincipal')] + [switch]$Remediations, + [Parameter(ParameterSetName = 'Interactive')] [Parameter(ParameterSetName = 'ServicePrincipal')] [switch]$CISBaselines, @@ -279,6 +285,7 @@ function Invoke-IntuneHydration { DeviceFilters = $DeviceFilters ConditionalAccess = $ConditionalAccess MobileApps = $MobileApps + Remediations = $Remediations CISBaselines = $CISBaselines All = $All ReportOutputPath = $ReportOutputPath @@ -667,6 +674,15 @@ function Invoke-IntuneHydration { } } + # Step 12: Proactive Remediations + if ($settings.imports.remediations) { + $stepAction = if ($RemoveExisting) { 'Deleting' } else { 'Importing' } + Write-HydrationLog -Message "Step 12: $stepAction Proactive Remediations" -Level Info + + $remediationResults = @((Import-IntuneRemediation -RemoveExisting:$RemoveExisting -WhatIf:$effectiveWhatIfEnabled -Verbose:$effectiveVerboseEnabled) | Where-Object { $null -ne $_ }) + $allResults += $remediationResults + } + $summaryParams = @{ Settings = $settings Results = $allResults diff --git a/Templates/Remediations/Windows/AutomaticTimeZone/Detect-AutomaticTimeZone.ps1 b/Templates/Remediations/Windows/AutomaticTimeZone/Detect-AutomaticTimeZone.ps1 new file mode 100644 index 0000000..85e02ad --- /dev/null +++ b/Templates/Remediations/Windows/AutomaticTimeZone/Detect-AutomaticTimeZone.ps1 @@ -0,0 +1,8 @@ +$service = Get-CimInstance -ClassName Win32_Service -Filter "Name='tzautoupdate'" -ErrorAction Stop +if ($service.StartMode -eq 'Disabled') { + Write-Output 'Automatic time zone service is disabled.' + exit 1 +} + +Write-Output "Compliant: automatic time zone service start mode is $($service.StartMode)." +exit 0 diff --git a/Templates/Remediations/Windows/AutomaticTimeZone/Remediate-AutomaticTimeZone.ps1 b/Templates/Remediations/Windows/AutomaticTimeZone/Remediate-AutomaticTimeZone.ps1 new file mode 100644 index 0000000..ab32cc5 --- /dev/null +++ b/Templates/Remediations/Windows/AutomaticTimeZone/Remediate-AutomaticTimeZone.ps1 @@ -0,0 +1,18 @@ +$service = Get-CimInstance -ClassName Win32_Service -Filter "Name='tzautoupdate'" -ErrorAction Stop +if ($service.StartMode -eq 'Disabled') { + $process = Start-Process -FilePath "$env:SystemRoot\System32\sc.exe" -ArgumentList 'config', 'tzautoupdate', 'start=', 'demand' -Wait -PassThru -NoNewWindow + if ($process.ExitCode -ne 0) { + Write-Error "Failed to enable the automatic time zone service. ExitCode=$($process.ExitCode)" + exit 1 + } +} + +try { + Start-Service -Name 'tzautoupdate' -ErrorAction Stop +} catch { + Write-Error 'Failed to start the automatic time zone service.' + exit 1 +} + +Write-Output 'Automatic time zone service enabled. Location Services policy controls whether Windows can determine the time zone.' +exit 0 diff --git a/Templates/Remediations/Windows/AutomaticTimeZone/metadata.json b/Templates/Remediations/Windows/AutomaticTimeZone/metadata.json new file mode 100644 index 0000000..ad9f0da --- /dev/null +++ b/Templates/Remediations/Windows/AutomaticTimeZone/metadata.json @@ -0,0 +1,11 @@ +{ + "templateId": "windows-automatic-time-zone", + "sortOrder": 30, + "displayName": "Windows - Automatic Time Zone", + "publisher": "Intune Hydration Kit", + "description": "Enables the Windows automatic time-zone service when it is disabled. Requires an approved Location Services configuration. Created unassigned.", + "runAsAccount": "system", + "runAs32Bit": false, + "detectionScript": "Detect-AutomaticTimeZone.ps1", + "remediationScript": "Remediate-AutomaticTimeZone.ps1" +} diff --git a/Templates/Remediations/Windows/BitLockerRecoveryKeyEscrow/Detect-BitLockerRecoveryKeyEscrow.ps1 b/Templates/Remediations/Windows/BitLockerRecoveryKeyEscrow/Detect-BitLockerRecoveryKeyEscrow.ps1 new file mode 100644 index 0000000..1b0cff7 --- /dev/null +++ b/Templates/Remediations/Windows/BitLockerRecoveryKeyEscrow/Detect-BitLockerRecoveryKeyEscrow.ps1 @@ -0,0 +1,28 @@ +$cloudJoinPath = 'HKLM:\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo' + +if (-not (Get-Command -Name Get-BitLockerVolume -ErrorAction SilentlyContinue) -or + -not (Test-Path -LiteralPath $cloudJoinPath)) { + Write-Output 'Skipped: BitLocker or Microsoft Entra join prerequisites are not present.' + exit 0 +} + +try { + $volume = Get-BitLockerVolume -MountPoint $env:SystemDrive -ErrorAction Stop +} catch { + Write-Output 'Skipped: the operating system BitLocker volume could not be queried.' + exit 0 +} + +if ($volume.ProtectionStatus -notin @('On', 1)) { + Write-Output 'Skipped: BitLocker protection is not active on the operating system volume.' + exit 0 +} + +$recoveryProtectors = @($volume.KeyProtector | Where-Object { $_.KeyProtectorType -eq 'RecoveryPassword' }) +if ($recoveryProtectors.Count -eq 0) { + Write-Output 'Skipped: no BitLocker recovery-password protector is configured.' + exit 0 +} + +Write-Output "Recovery key escrow retry required for $($recoveryProtectors.Count) existing protector(s)." +exit 1 diff --git a/Templates/Remediations/Windows/BitLockerRecoveryKeyEscrow/Remediate-BitLockerRecoveryKeyEscrow.ps1 b/Templates/Remediations/Windows/BitLockerRecoveryKeyEscrow/Remediate-BitLockerRecoveryKeyEscrow.ps1 new file mode 100644 index 0000000..d03133e --- /dev/null +++ b/Templates/Remediations/Windows/BitLockerRecoveryKeyEscrow/Remediate-BitLockerRecoveryKeyEscrow.ps1 @@ -0,0 +1,42 @@ +$cloudJoinPath = 'HKLM:\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo' + +if (-not (Get-Command -Name Get-BitLockerVolume -ErrorAction SilentlyContinue) -or + -not (Test-Path -LiteralPath $cloudJoinPath)) { + Write-Output 'Skipped: BitLocker or Microsoft Entra join prerequisites are not present.' + exit 0 +} + +try { + $volume = Get-BitLockerVolume -MountPoint $env:SystemDrive -ErrorAction Stop +} catch { + Write-Error 'The operating system BitLocker volume could not be queried.' + exit 1 +} + +if ($volume.ProtectionStatus -notin @('On', 1)) { + Write-Output 'Skipped: BitLocker protection is not active on the operating system volume.' + exit 0 +} + +$recoveryProtectors = @($volume.KeyProtector | Where-Object { $_.KeyProtectorType -eq 'RecoveryPassword' }) +if ($recoveryProtectors.Count -eq 0) { + Write-Output 'Skipped: no BitLocker recovery-password protector is configured.' + exit 0 +} + +$backupFailures = 0 +foreach ($protector in $recoveryProtectors) { + try { + BackupToAAD-BitLockerKeyProtector -MountPoint $env:SystemDrive -KeyProtectorId $protector.KeyProtectorId -ErrorAction Stop + } catch { + $backupFailures++ + } +} + +if ($backupFailures -gt 0) { + Write-Error "Microsoft Entra recovery-key escrow failed for $backupFailures protector(s)." + exit 1 +} + +Write-Output "Microsoft Entra recovery-key escrow completed for $($recoveryProtectors.Count) existing protector(s)." +exit 0 diff --git a/Templates/Remediations/Windows/BitLockerRecoveryKeyEscrow/metadata.json b/Templates/Remediations/Windows/BitLockerRecoveryKeyEscrow/metadata.json new file mode 100644 index 0000000..c24550a --- /dev/null +++ b/Templates/Remediations/Windows/BitLockerRecoveryKeyEscrow/metadata.json @@ -0,0 +1,11 @@ +{ + "templateId": "windows-bitlocker-recovery-key-escrow", + "sortOrder": 40, + "displayName": "Windows - BitLocker Recovery Key Escrow", + "publisher": "Intune Hydration Kit", + "description": "Retries Microsoft Entra escrow for existing OS-drive BitLocker recovery-password protectors on Entra-joined devices. It never adds, removes, or changes protectors or encryption. Created unassigned.", + "runAsAccount": "system", + "runAs32Bit": false, + "detectionScript": "Detect-BitLockerRecoveryKeyEscrow.ps1", + "remediationScript": "Remediate-BitLockerRecoveryKeyEscrow.ps1" +} diff --git a/Templates/Remediations/Windows/DefenderSignatureFreshness/Detect-DefenderSignatureFreshness.ps1 b/Templates/Remediations/Windows/DefenderSignatureFreshness/Detect-DefenderSignatureFreshness.ps1 new file mode 100644 index 0000000..767e253 --- /dev/null +++ b/Templates/Remediations/Windows/DefenderSignatureFreshness/Detect-DefenderSignatureFreshness.ps1 @@ -0,0 +1,26 @@ +$maximumSignatureAgeDays = 7 + +if (-not (Get-Command -Name Get-MpComputerStatus -ErrorAction SilentlyContinue)) { + Write-Output 'Skipped: Microsoft Defender PowerShell cmdlets are not available.' + exit 0 +} + +try { + $status = Get-MpComputerStatus -ErrorAction Stop +} catch { + Write-Output 'Skipped: Microsoft Defender status could not be queried.' + exit 0 +} + +if (-not $status.AntivirusEnabled -or $status.AMRunningMode -ne 'Normal') { + Write-Output 'Skipped: Microsoft Defender antivirus is not the active antivirus engine.' + exit 0 +} + +if ($null -eq $status.AntivirusSignatureAge -or [int]$status.AntivirusSignatureAge -gt $maximumSignatureAgeDays) { + Write-Output "Microsoft Defender signatures are stale or unavailable. MaximumAgeDays=$maximumSignatureAgeDays" + exit 1 +} + +Write-Output "Compliant: Microsoft Defender signature age is $($status.AntivirusSignatureAge) day(s)." +exit 0 diff --git a/Templates/Remediations/Windows/DefenderSignatureFreshness/Remediate-DefenderSignatureFreshness.ps1 b/Templates/Remediations/Windows/DefenderSignatureFreshness/Remediate-DefenderSignatureFreshness.ps1 new file mode 100644 index 0000000..85f0ec7 --- /dev/null +++ b/Templates/Remediations/Windows/DefenderSignatureFreshness/Remediate-DefenderSignatureFreshness.ps1 @@ -0,0 +1,35 @@ +$maximumSignatureAgeDays = 7 + +if (-not (Get-Command -Name Get-MpComputerStatus -ErrorAction SilentlyContinue) -or + -not (Get-Command -Name Update-MpSignature -ErrorAction SilentlyContinue)) { + Write-Output 'Skipped: Microsoft Defender PowerShell cmdlets are not available.' + exit 0 +} + +try { + $status = Get-MpComputerStatus -ErrorAction Stop +} catch { + Write-Error 'Microsoft Defender status could not be queried.' + exit 1 +} + +if (-not $status.AntivirusEnabled -or $status.AMRunningMode -ne 'Normal') { + Write-Output 'Skipped: Microsoft Defender antivirus is not the active antivirus engine.' + exit 0 +} + +try { + Update-MpSignature -ErrorAction Stop | Out-Null + $status = Get-MpComputerStatus -ErrorAction Stop +} catch { + Write-Error 'Microsoft Defender signature update failed.' + exit 1 +} + +if ($null -eq $status.AntivirusSignatureAge -or [int]$status.AntivirusSignatureAge -gt $maximumSignatureAgeDays) { + Write-Error "Microsoft Defender signatures remain older than $maximumSignatureAgeDays day(s)." + exit 1 +} + +Write-Output "Microsoft Defender signatures are current. AgeDays=$($status.AntivirusSignatureAge)" +exit 0 diff --git a/Templates/Remediations/Windows/DefenderSignatureFreshness/metadata.json b/Templates/Remediations/Windows/DefenderSignatureFreshness/metadata.json new file mode 100644 index 0000000..5237e01 --- /dev/null +++ b/Templates/Remediations/Windows/DefenderSignatureFreshness/metadata.json @@ -0,0 +1,11 @@ +{ + "templateId": "windows-defender-signature-freshness", + "sortOrder": 50, + "displayName": "Windows - Defender Signature Freshness", + "publisher": "Intune Hydration Kit", + "description": "Updates Microsoft Defender signatures only when Defender antivirus is active and signatures are older than seven days. Devices using passive or third-party antivirus are skipped. Created unassigned.", + "runAsAccount": "system", + "runAs32Bit": false, + "detectionScript": "Detect-DefenderSignatureFreshness.ps1", + "remediationScript": "Remediate-DefenderSignatureFreshness.ps1" +} diff --git a/Templates/Remediations/Windows/DeviceHealthReporting/Detect-DeviceHealth.ps1 b/Templates/Remediations/Windows/DeviceHealthReporting/Detect-DeviceHealth.ps1 new file mode 100644 index 0000000..d5265c8 --- /dev/null +++ b/Templates/Remediations/Windows/DeviceHealthReporting/Detect-DeviceHealth.ps1 @@ -0,0 +1,17 @@ +$diskWarnings = @() +if (Get-Command -Name Get-PhysicalDisk -ErrorAction SilentlyContinue) { + $diskWarnings = @(Get-PhysicalDisk -ErrorAction SilentlyContinue | Where-Object { $_.HealthStatus -notin @('Healthy', 'Unknown') }) +} + +$deviceErrors = @(Get-CimInstance -ClassName Win32_PnPEntity -ErrorAction SilentlyContinue | + Where-Object { $_.ConfigManagerErrorCode -ne 0 }) + +if ($diskWarnings.Count -gt 0 -or $deviceErrors.Count -gt 0) { + $diskNames = @($diskWarnings | Select-Object -First 3 -ExpandProperty FriendlyName) -join ', ' + $deviceNames = @($deviceErrors | Select-Object -First 3 -ExpandProperty Name) -join ', ' + Write-Output "Device health attention required: DiskWarnings=$($diskWarnings.Count) [$diskNames]; PnpErrors=$($deviceErrors.Count) [$deviceNames]" + exit 1 +} + +Write-Output 'Compliant: no storage-health warnings or Plug and Play device errors found.' +exit 0 diff --git a/Templates/Remediations/Windows/DeviceHealthReporting/metadata.json b/Templates/Remediations/Windows/DeviceHealthReporting/metadata.json new file mode 100644 index 0000000..d262563 --- /dev/null +++ b/Templates/Remediations/Windows/DeviceHealthReporting/metadata.json @@ -0,0 +1,10 @@ +{ + "templateId": "windows-device-health-reporting", + "sortOrder": 20, + "displayName": "Windows - Device Health Reporting", + "publisher": "Intune Hydration Kit", + "description": "Reports storage-health warnings and Plug and Play device errors for triage. This detection-only package makes no device changes. Created unassigned.", + "runAsAccount": "system", + "runAs32Bit": false, + "detectionScript": "Detect-DeviceHealth.ps1" +} diff --git a/Templates/Remediations/Windows/DiskPressureCleanup/Detect-DiskPressureCleanup.ps1 b/Templates/Remediations/Windows/DiskPressureCleanup/Detect-DiskPressureCleanup.ps1 new file mode 100644 index 0000000..07f1e7d --- /dev/null +++ b/Templates/Remediations/Windows/DiskPressureCleanup/Detect-DiskPressureCleanup.ps1 @@ -0,0 +1,26 @@ +$minimumFreeSpaceGB = 10 +$minimumCleanupSizeMB = 1024 +$minimumAgeDays = 14 +$cutoff = (Get-Date).AddDays(-$minimumAgeDays) + +$systemDrive = Get-CimInstance -ClassName Win32_LogicalDisk -Filter "DeviceID='$($env:SystemDrive)'" +$eligiblePaths = @($env:TEMP, (Join-Path -Path $env:WINDIR -ChildPath 'Temp')) | Select-Object -Unique +$eligibleSize = 0L + +foreach ($path in $eligiblePaths) { + if (Test-Path -LiteralPath $path) { + $eligibleSize += [long](Get-ChildItem -LiteralPath $path -File -Recurse -Force -ErrorAction SilentlyContinue | + Where-Object { $_.LastWriteTime -lt $cutoff -and -not $_.Attributes.HasFlag([IO.FileAttributes]::ReparsePoint) } | + Measure-Object -Property Length -Sum).Sum + } +} + +$freeSpaceGB = [math]::Round($systemDrive.FreeSpace / 1GB, 2) +$eligibleSizeMB = [math]::Round($eligibleSize / 1MB, 2) +if ($freeSpaceGB -lt $minimumFreeSpaceGB -or $eligibleSizeMB -ge $minimumCleanupSizeMB) { + Write-Output "Disk pressure detected: FreeSpaceGB=$freeSpaceGB; EligibleTempMB=$eligibleSizeMB" + exit 1 +} + +Write-Output "Compliant: FreeSpaceGB=$freeSpaceGB; EligibleTempMB=$eligibleSizeMB" +exit 0 diff --git a/Templates/Remediations/Windows/DiskPressureCleanup/Remediate-DiskPressureCleanup.ps1 b/Templates/Remediations/Windows/DiskPressureCleanup/Remediate-DiskPressureCleanup.ps1 new file mode 100644 index 0000000..92b4602 --- /dev/null +++ b/Templates/Remediations/Windows/DiskPressureCleanup/Remediate-DiskPressureCleanup.ps1 @@ -0,0 +1,37 @@ +$minimumFreeSpaceGB = 10 +$minimumAgeDays = 14 +$maximumCleanupMB = 4096 +$cutoff = (Get-Date).AddDays(-$minimumAgeDays) +$maximumCleanupBytes = $maximumCleanupMB * 1MB +$removedBytes = 0L + +$eligiblePaths = @($env:TEMP, (Join-Path -Path $env:WINDIR -ChildPath 'Temp')) | Select-Object -Unique +foreach ($path in $eligiblePaths) { + if (-not (Test-Path -LiteralPath $path)) { + continue + } + + $files = Get-ChildItem -LiteralPath $path -File -Recurse -Force -ErrorAction SilentlyContinue | + Where-Object { $_.LastWriteTime -lt $cutoff -and -not $_.Attributes.HasFlag([IO.FileAttributes]::ReparsePoint) } | + Sort-Object LastWriteTime + + foreach ($file in $files) { + if ($removedBytes -ge $maximumCleanupBytes) { + break + } + + try { + $fileLength = $file.Length + Remove-Item -LiteralPath $file.FullName -Force -ErrorAction Stop + $removedBytes += $fileLength + } catch { + continue + } + } +} + +$systemDrive = Get-CimInstance -ClassName Win32_LogicalDisk -Filter "DeviceID='$($env:SystemDrive)'" +$freeSpaceGB = [math]::Round($systemDrive.FreeSpace / 1GB, 2) +$removedMB = [math]::Round($removedBytes / 1MB, 2) +Write-Output "Disk cleanup completed: RemovedMB=$removedMB; FreeSpaceGB=$freeSpaceGB; TargetFreeSpaceGB=$minimumFreeSpaceGB" +exit 0 diff --git a/Templates/Remediations/Windows/DiskPressureCleanup/metadata.json b/Templates/Remediations/Windows/DiskPressureCleanup/metadata.json new file mode 100644 index 0000000..650f7c5 --- /dev/null +++ b/Templates/Remediations/Windows/DiskPressureCleanup/metadata.json @@ -0,0 +1,11 @@ +{ + "templateId": "windows-disk-pressure-cleanup", + "sortOrder": 10, + "displayName": "Windows - Disk Pressure Cleanup", + "publisher": "Intune Hydration Kit", + "description": "Reclaims aged temporary files when system-drive free space is low or eligible temporary data exceeds the configured threshold. Created unassigned.", + "runAsAccount": "system", + "runAs32Bit": false, + "detectionScript": "Detect-DiskPressureCleanup.ps1", + "remediationScript": "Remediate-DiskPressureCleanup.ps1" +} diff --git a/Templates/Remediations/Windows/WindowsRecoveryEnvironment/Detect-WindowsRecoveryEnvironment.ps1 b/Templates/Remediations/Windows/WindowsRecoveryEnvironment/Detect-WindowsRecoveryEnvironment.ps1 new file mode 100644 index 0000000..0427364 --- /dev/null +++ b/Templates/Remediations/Windows/WindowsRecoveryEnvironment/Detect-WindowsRecoveryEnvironment.ps1 @@ -0,0 +1,28 @@ +$reAgentPath = Join-Path -Path $env:WINDIR -ChildPath 'System32\Recovery\ReAgent.xml' +$localWinReImagePath = Join-Path -Path $env:WINDIR -ChildPath 'System32\Recovery\Winre.wim' + +if (-not (Test-Path -LiteralPath $reAgentPath) -or -not (Test-Path -LiteralPath $localWinReImagePath)) { + Write-Output 'Skipped: no locally repairable Windows Recovery Environment configuration was found.' + exit 0 +} + +try { + [xml]$reAgentConfiguration = Get-Content -LiteralPath $reAgentPath -Raw -ErrorAction Stop + $winReBcdId = [string]$reAgentConfiguration.WindowsRE.WinreBCD.id +} catch { + Write-Output 'Skipped: Windows Recovery Environment configuration could not be read.' + exit 0 +} + +if ($winReBcdId -match '^\{?00000000-0000-0000-0000-000000000000\}?$') { + Write-Output 'Windows Recovery Environment is explicitly disabled and has a local recovery image.' + exit 1 +} + +if ($winReBcdId -match '^\{?[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\}?$') { + Write-Output 'Compliant: Windows Recovery Environment has a configured BCD identifier.' + exit 0 +} + +Write-Output 'Skipped: Windows Recovery Environment state could not be determined safely.' +exit 0 diff --git a/Templates/Remediations/Windows/WindowsRecoveryEnvironment/Remediate-WindowsRecoveryEnvironment.ps1 b/Templates/Remediations/Windows/WindowsRecoveryEnvironment/Remediate-WindowsRecoveryEnvironment.ps1 new file mode 100644 index 0000000..c6f4eba --- /dev/null +++ b/Templates/Remediations/Windows/WindowsRecoveryEnvironment/Remediate-WindowsRecoveryEnvironment.ps1 @@ -0,0 +1,46 @@ +$reAgentPath = Join-Path -Path $env:WINDIR -ChildPath 'System32\Recovery\ReAgent.xml' +$localWinReImagePath = Join-Path -Path $env:WINDIR -ChildPath 'System32\Recovery\Winre.wim' +$reAgentExecutable = Join-Path -Path $env:WINDIR -ChildPath 'System32\reagentc.exe' + +if (-not (Test-Path -LiteralPath $reAgentPath) -or + -not (Test-Path -LiteralPath $localWinReImagePath) -or + -not (Test-Path -LiteralPath $reAgentExecutable)) { + Write-Output 'Skipped: no locally repairable Windows Recovery Environment configuration was found.' + exit 0 +} + +try { + [xml]$reAgentConfiguration = Get-Content -LiteralPath $reAgentPath -Raw -ErrorAction Stop + $winReBcdId = [string]$reAgentConfiguration.WindowsRE.WinreBCD.id +} catch { + Write-Error 'Windows Recovery Environment configuration could not be read.' + exit 1 +} + +if ($winReBcdId -notmatch '^\{?00000000-0000-0000-0000-000000000000\}?$') { + Write-Output 'Skipped: Windows Recovery Environment is not explicitly disabled.' + exit 0 +} + +& $reAgentExecutable /enable +if ($LASTEXITCODE -ne 0) { + Write-Error "Windows Recovery Environment enablement failed. ExitCode=$LASTEXITCODE" + exit 1 +} + +try { + [xml]$reAgentConfiguration = Get-Content -LiteralPath $reAgentPath -Raw -ErrorAction Stop + $winReBcdId = [string]$reAgentConfiguration.WindowsRE.WinreBCD.id +} catch { + Write-Error 'Windows Recovery Environment configuration could not be verified.' + exit 1 +} + +if ($winReBcdId -notmatch '^\{?[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\}?$' -or + $winReBcdId -match '^\{?00000000-0000-0000-0000-000000000000\}?$') { + Write-Error 'Windows Recovery Environment remained disabled after enablement.' + exit 1 +} + +Write-Output 'Windows Recovery Environment enabled using the existing local recovery image.' +exit 0 diff --git a/Templates/Remediations/Windows/WindowsRecoveryEnvironment/metadata.json b/Templates/Remediations/Windows/WindowsRecoveryEnvironment/metadata.json new file mode 100644 index 0000000..8c602ee --- /dev/null +++ b/Templates/Remediations/Windows/WindowsRecoveryEnvironment/metadata.json @@ -0,0 +1,11 @@ +{ + "templateId": "windows-recovery-environment-health", + "sortOrder": 60, + "displayName": "Windows - Recovery Environment Health", + "publisher": "Intune Hydration Kit", + "description": "Enables Windows Recovery Environment only when ReAgent configuration is explicitly disabled and the standard local Winre.wim is available. It does not change recovery partitions, image paths, or BCD entries. Created unassigned.", + "runAsAccount": "system", + "runAs32Bit": false, + "detectionScript": "Detect-WindowsRecoveryEnvironment.ps1", + "remediationScript": "Remediate-WindowsRecoveryEnvironment.ps1" +} diff --git a/Tests/Private/Get-HydrationGraphScopes.Tests.ps1 b/Tests/Private/Get-HydrationGraphScopes.Tests.ps1 index cfce43b..57bfb2c 100644 --- a/Tests/Private/Get-HydrationGraphScopes.Tests.ps1 +++ b/Tests/Private/Get-HydrationGraphScopes.Tests.ps1 @@ -68,4 +68,14 @@ Describe 'Get-HydrationGraphScopes' { $macScopes | Should -Not -Contain 'DeviceManagementScripts.ReadWrite.All' } } + + It 'Should include remediation update and assignment-read scopes when remediations are selected' { + InModuleScope IntuneHydrationKit { + $scopes = Get-HydrationGraphScopes -Imports @{ remediations = $true } -Create + + $scopes | Should -Contain 'DeviceManagementConfiguration.ReadWrite.All' + $scopes | Should -Contain 'DeviceManagementScripts.ReadWrite.All' + $scopes | Should -Not -Contain 'DeviceManagementApps.ReadWrite.All' + } + } } diff --git a/Tests/Private/Get-HydrationRemediationTemplates.Tests.ps1 b/Tests/Private/Get-HydrationRemediationTemplates.Tests.ps1 new file mode 100644 index 0000000..bcd986b --- /dev/null +++ b/Tests/Private/Get-HydrationRemediationTemplates.Tests.ps1 @@ -0,0 +1,131 @@ +#Requires -Modules Pester + +BeforeAll { + . $PSScriptRoot/../../Private/Utilities/Resolve-HydrationTemplateChildPath.ps1 + . $PSScriptRoot/../../Private/Remediations/Get-HydrationRemediationTemplates.ps1 +} + +Describe 'Get-HydrationRemediationTemplates' { + BeforeEach { + $script:templateRoot = Join-Path -Path $TestDrive -ChildPath 'Remediations' + if (Test-Path -LiteralPath $script:templateRoot) { + Remove-Item -LiteralPath $script:templateRoot -Recurse -Force + } + New-Item -Path $script:templateRoot -ItemType Directory -Force | Out-Null + Set-Content -LiteralPath (Join-Path -Path $TestDrive -ChildPath 'outside.ps1') -Value 'Write-Output outside' -Encoding utf8 + } + + It 'Rejects a detection script path that escapes its template directory' { + @' +{ + "templateId": "traversal-test", + "displayName": "Traversal Test", + "publisher": "Test", + "description": "Test", + "runAsAccount": "system", + "runAs32Bit": false, + "detectionScript": "../outside.ps1" +} +'@ | Set-Content -LiteralPath (Join-Path $script:templateRoot 'metadata.json') -Encoding utf8 + + { Get-HydrationRemediationTemplates -TemplatePath $script:templateRoot } | Should -Throw '*resolves outside template root*' + } + + It 'Rejects a remediation script path that escapes its template directory' { + Set-Content -LiteralPath (Join-Path $script:templateRoot 'detect.ps1') -Value 'exit 0' -Encoding utf8 + @' +{ + "templateId": "traversal-test", + "displayName": "Traversal Test", + "publisher": "Test", + "description": "Test", + "runAsAccount": "system", + "runAs32Bit": false, + "detectionScript": "detect.ps1", + "remediationScript": "../outside.ps1" +} +'@ | Set-Content -LiteralPath (Join-Path $script:templateRoot 'metadata.json') -Encoding utf8 + + { Get-HydrationRemediationTemplates -TemplatePath $script:templateRoot } | Should -Throw '*resolves outside template root*' + } + + It 'Rejects duplicate template IDs before synchronization can become ambiguous' { + foreach ($directoryName in @('First', 'Second')) { + $templateDirectory = Join-Path $script:templateRoot $directoryName + New-Item -Path $templateDirectory -ItemType Directory -Force | Out-Null + Set-Content -LiteralPath (Join-Path $templateDirectory 'detect.ps1') -Value 'exit 0' -Encoding utf8 + @" +{ + "templateId": "duplicate-template", + "displayName": "$directoryName", + "publisher": "Test", + "description": "Test", + "runAsAccount": "system", + "runAs32Bit": false, + "detectionScript": "detect.ps1" +} +"@ | Set-Content -LiteralPath (Join-Path $templateDirectory 'metadata.json') -Encoding utf8 + } + + { Get-HydrationRemediationTemplates -TemplatePath $script:templateRoot } | Should -Throw '*duplicate templateId*' + } + + It 'Rejects duplicate display names before they collide in Intune' { + foreach ($directoryName in @('First', 'Second')) { + $templateDirectory = Join-Path $script:templateRoot $directoryName + New-Item -Path $templateDirectory -ItemType Directory -Force | Out-Null + Set-Content -LiteralPath (Join-Path $templateDirectory 'detect.ps1') -Value 'exit 0' -Encoding utf8 + @" +{ + "templateId": "$directoryName-template", + "displayName": "Duplicate Display Name", + "publisher": "Test", + "description": "Test", + "runAsAccount": "system", + "runAs32Bit": false, + "detectionScript": "detect.ps1" +} +"@ | Set-Content -LiteralPath (Join-Path $templateDirectory 'metadata.json') -Encoding utf8 + } + + { Get-HydrationRemediationTemplates -TemplatePath $script:templateRoot } | Should -Throw '*duplicate displayName*' + } + + It 'Rejects a string runAs32Bit value instead of coercing it to true' { + Set-Content -LiteralPath (Join-Path $script:templateRoot 'detect.ps1') -Value 'exit 0' -Encoding utf8 + @' +{ + "templateId": "string-boolean-test", + "displayName": "String Boolean Test", + "publisher": "Test", + "description": "Test", + "runAsAccount": "system", + "runAs32Bit": "false", + "detectionScript": "detect.ps1" +} +'@ | Set-Content -LiteralPath (Join-Path $script:templateRoot 'metadata.json') -Encoding utf8 + + { Get-HydrationRemediationTemplates -TemplatePath $script:templateRoot } | Should -Throw '*non-Boolean runAs32Bit*' + } + + It 'Loads the bundled remediation catalog with parseable scripts' { + $catalogPath = Join-Path $PSScriptRoot '../../Templates/Remediations' + $templates = Get-HydrationRemediationTemplates -TemplatePath $catalogPath + + $templates | Should -HaveCount 6 + $templates.TemplateId | Should -Be @( + 'windows-disk-pressure-cleanup', + 'windows-device-health-reporting', + 'windows-automatic-time-zone', + 'windows-bitlocker-recovery-key-escrow', + 'windows-defender-signature-freshness', + 'windows-recovery-environment-health' + ) + + foreach ($scriptPath in @($templates.DetectionScriptPath) + @($templates.RemediationScriptPath | Where-Object { $_ })) { + $parseErrors = $null + $null = [System.Management.Automation.Language.Parser]::ParseFile($scriptPath, [ref]$null, [ref]$parseErrors) + $parseErrors | Should -BeNullOrEmpty -Because $scriptPath + } + } +} diff --git a/Tests/Private/Resolve-HydrationWorkloadPlan.Tests.ps1 b/Tests/Private/Resolve-HydrationWorkloadPlan.Tests.ps1 index 16683d4..6036f19 100644 --- a/Tests/Private/Resolve-HydrationWorkloadPlan.Tests.ps1 +++ b/Tests/Private/Resolve-HydrationWorkloadPlan.Tests.ps1 @@ -18,6 +18,7 @@ Describe 'Get-HydrationWorkloadCatalog' { 'appProtection' 'enrollmentProfiles' 'mobileApps' + 'remediations' 'notificationTemplates' 'conditionalAccess' ) diff --git a/Tests/Private/Sync-IntuneWinGetProactiveRemediation.Tests.ps1 b/Tests/Private/Sync-IntuneWinGetProactiveRemediation.Tests.ps1 index 93af55e..f812664 100644 --- a/Tests/Private/Sync-IntuneWinGetProactiveRemediation.Tests.ps1 +++ b/Tests/Private/Sync-IntuneWinGetProactiveRemediation.Tests.ps1 @@ -200,6 +200,25 @@ Describe 'Sync-IntuneWinGetProactiveRemediation' { Should -Invoke Invoke-HydrationGraphRequest -Exactly 0 -ModuleName IntuneHydrationKit } + It 'Should require exact ownership metadata before it can remove a remediation' { + $definition = @{ + DisplayName = 'Test remediation' + Type = 'WinGetRemediation' + Path = $null + SourceMarker = 'Imported from WinGet' + OwnershipMetadata = @{} + } + + { + & $script:TestModule { + param($Definition) + Sync-IntuneDeviceHealthScript -Definition $Definition -DesiredState Remove + } $definition + } | Should -Throw '*requires non-empty ownership metadata*' + + Should -Invoke Invoke-HydrationGraphRequest -Exactly 0 -ModuleName IntuneHydrationKit + } + It 'Should update an owned remediation when the package fingerprint changes' { Mock Invoke-HydrationGraphRequest { param($Method, $Uri, $Body) @@ -247,6 +266,37 @@ Describe 'Sync-IntuneWinGetProactiveRemediation' { $script:patchedRemediationBody.ContainsKey('@odata.type') | Should -Be $false } + It 'Should refuse a nondeterministic update when multiple owned remediations match' { + Mock Invoke-HydrationGraphRequest { + param($Method, $Uri) + + if ($Method -eq 'GET' -and $Uri -like '*System*') { + return @{ + value = @( + @{ id = 'first-system'; description = "Imported by Intune Hydration Kit`nImported from WinGet`nWinGetRemediationScope: system" }, + @{ id = 'second-system'; description = "Imported by Intune Hydration Kit`nImported from WinGet`nWinGetRemediationScope: system" } + ) + } + } + + if ($Method -eq 'GET') { + return @{ value = @() } + } + + throw 'No mutation should be attempted for ambiguous ownership.' + } -ModuleName IntuneHydrationKit + + $result = & $script:TestModule { + param([object[]]$Templates) + Sync-IntuneWinGetProactiveRemediation -Templates $Templates + } @($script:testTemplates | Where-Object { $_.package.match.scope -eq 'machine' }) + + $systemResult = $result | Where-Object { $_.Name -eq 'WinGet App Updates (System)' } + $systemResult.Action | Should -Be 'Failed' + $systemResult.Status | Should -Be 'Multiple owned remediations' + Should -Invoke Invoke-HydrationGraphRequest -Exactly 0 -ParameterFilter { $Method -in @('PATCH', 'POST', 'DELETE') } -ModuleName IntuneHydrationKit + } + It 'Should delete an owned remediation when the current template set has no packages for its scope' { Mock Invoke-HydrationGraphRequest { param($Method, $Uri, $Body) diff --git a/Tests/Public/Import-IntuneRemediation.Tests.ps1 b/Tests/Public/Import-IntuneRemediation.Tests.ps1 new file mode 100644 index 0000000..afb58ae --- /dev/null +++ b/Tests/Public/Import-IntuneRemediation.Tests.ps1 @@ -0,0 +1,313 @@ +#Requires -Modules Pester + +BeforeAll { + $modulePath = Join-Path $PSScriptRoot '..\..\' + Get-Module -Name IntuneHydrationKit | Remove-Module -Force -ErrorAction SilentlyContinue + Import-Module (Join-Path $modulePath 'IntuneHydrationKit.psd1') -Force +} + +Describe 'Import-IntuneRemediation' { + Context 'When creating the bundled Proactive remediation pack in WhatIf mode' { + BeforeEach { + Mock Get-IntuneProactiveRemediationAvailability { + [pscustomobject]@{ + IsAvailable = $true + Status = 'Available' + Message = 'Proactive remediations are available.' + } + } -ModuleName IntuneHydrationKit + + Mock Invoke-HydrationGraphRequest { } -ModuleName IntuneHydrationKit + } + + It 'Reports the six unassigned remediations without writing to Graph' { + $result = Import-IntuneRemediation -WhatIf + + $result | Should -HaveCount 6 + $result.Name | Should -Be @( + '[IHD] Windows - Disk Pressure Cleanup', + '[IHD] Windows - Device Health Reporting', + '[IHD] Windows - Automatic Time Zone', + '[IHD] Windows - BitLocker Recovery Key Escrow', + '[IHD] Windows - Defender Signature Freshness', + '[IHD] Windows - Recovery Environment Health' + ) + $result.Action | Should -Be @('WouldCreate', 'WouldCreate', 'WouldCreate', 'WouldCreate', 'WouldCreate', 'WouldCreate') + $result.Type | Should -Be @('Remediation', 'Remediation', 'Remediation', 'Remediation', 'Remediation', 'Remediation') + Should -Invoke Invoke-HydrationGraphRequest -Exactly 6 -ParameterFilter { $Method -eq 'GET' } -ModuleName IntuneHydrationKit + } + } + + Context 'When creating the bundled Proactive remediation pack' { + BeforeEach { + $script:postedBodies = @() + Mock Get-IntuneProactiveRemediationAvailability { + [pscustomobject]@{ + IsAvailable = $true + Status = 'Available' + Message = 'Proactive remediations are available.' + } + } -ModuleName IntuneHydrationKit + + Mock Invoke-HydrationGraphRequest { + param($Method, $Uri, $Body) + if ($Method -eq 'GET') { + return @{ value = @() } + } + + if ($Method -eq 'POST') { + $script:postedBodies += $Body + return @{ id = "remediation-$($script:postedBodies.Count)" } + } + } -ModuleName IntuneHydrationKit + } + + It 'Creates unassigned system packages and preserves detection-only reporting' { + $result = Import-IntuneRemediation + + $result.Action | Should -Be @('Created', 'Created', 'Created', 'Created', 'Created', 'Created') + $script:postedBodies | Should -HaveCount 6 + $script:postedBodies.runAsAccount | Should -Be @('system', 'system', 'system', 'system', 'system', 'system') + $script:postedBodies.runAs32Bit | Should -Be @($false, $false, $false, $false, $false, $false) + $script:postedBodies | ForEach-Object { $_.Contains('assignments') } | Should -Be @($false, $false, $false, $false, $false, $false) + $script:postedBodies[1].Contains('remediationScriptContent') | Should -Be $false + $script:postedBodies[1].description | Should -Match 'This detection-only package makes no device changes' + } + + It 'Constrains the new remediation actions to their intended repair boundaries' { + $null = Import-IntuneRemediation + $bodiesByName = @{} + foreach ($body in $script:postedBodies) { + $bodiesByName[$body.displayName] = $body + } + + $bitLockerRemediation = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($bodiesByName['[IHD] Windows - BitLocker Recovery Key Escrow'].remediationScriptContent)) + $defenderRemediation = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($bodiesByName['[IHD] Windows - Defender Signature Freshness'].remediationScriptContent)) + $automaticTimeZoneRemediation = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($bodiesByName['[IHD] Windows - Automatic Time Zone'].remediationScriptContent)) + $winReRemediation = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($bodiesByName['[IHD] Windows - Recovery Environment Health'].remediationScriptContent)) + + $bitLockerRemediation | Should -Match 'BackupToAAD-BitLockerKeyProtector' + $bitLockerRemediation | Should -Not -Match '(?i)(Add|Remove)-BitLockerKeyProtector' + $defenderRemediation | Should -Match 'Update-MpSignature' + $defenderRemediation | Should -Not -Match '(?i)Set-MpPreference' + $automaticTimeZoneRemediation | Should -Match "Start-Service -Name 'tzautoupdate' -ErrorAction Stop" + $automaticTimeZoneRemediation | Should -Match 'Failed to start the automatic time zone service' + $winReRemediation | Should -Match '\$reAgentExecutable\s+/enable' + $winReRemediation | Should -Not -Match '(?i)(/disable|/setreimage|bcdedit|diskpart)' + } + } + + Context 'When deleting remediation packages' { + BeforeEach { + $script:deletedUris = @() + Mock Get-IntuneProactiveRemediationAvailability { + [pscustomobject]@{ + IsAvailable = $true + Status = 'Available' + Message = 'Proactive remediations are available.' + } + } -ModuleName IntuneHydrationKit + + Mock Invoke-HydrationGraphRequest { + param($Method, $Uri) + if ($Method -eq 'GET' -and $Uri -match 'Disk%20Pressure%20Cleanup') { + return @{ + value = @( + @{ + id = 'owned-disk-cleanup' + displayName = '[IHD] Windows - Disk Pressure Cleanup' + description = "Imported by Intune Hydration Kit`nImported from Proactive Remediation Pack`nRemediationTemplateId: windows-disk-pressure-cleanup" + }, + @{ + id = 'unowned-disk-cleanup' + displayName = '[IHD] Windows - Disk Pressure Cleanup' + description = 'Created outside Intune Hydration Kit' + } + ) + } + } + + if ($Method -eq 'GET') { + return @{ value = @() } + } + + if ($Method -eq 'DELETE') { + $script:deletedUris += $Uri + return @{} + } + } -ModuleName IntuneHydrationKit + } + + It 'Deletes only the matching hydration-owned template package' { + $result = Import-IntuneRemediation -RemoveExisting + + $result | Should -HaveCount 1 + $result[0].Id | Should -Be 'owned-disk-cleanup' + $result[0].Action | Should -Be 'Deleted' + $script:deletedUris | Should -Be @('beta/deviceManagement/deviceHealthScripts/owned-disk-cleanup') + } + + It 'Does not treat a template ID prefix as ownership' { + Mock Invoke-HydrationGraphRequest { + param($Method, $Uri) + if ($Method -eq 'GET' -and $Uri -match 'Disk%20Pressure%20Cleanup') { + return @{ + value = @( + @{ + id = 'suffix-template-id' + displayName = '[IHD] Windows - Disk Pressure Cleanup' + description = "Imported by Intune Hydration Kit`nImported from Proactive Remediation Pack`nRemediationTemplateId: windows-disk-pressure-cleanup-v2" + } + ) + } + } + + if ($Method -eq 'GET') { + return @{ value = @() } + } + + if ($Method -eq 'DELETE') { + $script:deletedUris += $Uri + return @{} + } + } -ModuleName IntuneHydrationKit + + $result = Import-IntuneRemediation -TemplateId 'windows-disk-pressure-cleanup' -RemoveExisting + + $result | Should -BeNullOrEmpty + $script:deletedUris | Should -BeNullOrEmpty + } + } + + Context 'When planning updates in WhatIf mode' { + BeforeEach { + Mock Get-IntuneProactiveRemediationAvailability { + [pscustomobject]@{ + IsAvailable = $true + Status = 'Available' + Message = 'Proactive remediations are available.' + } + } -ModuleName IntuneHydrationKit + + Mock Invoke-HydrationGraphRequest { + param($Method, $Uri) + if ($Method -eq 'GET' -and $Uri -match '/assignments') { + return @{ value = @() } + } + + if ($Method -eq 'GET') { + return @{ + value = @( + @{ + id = 'stale-remediation' + displayName = '[IHD] Windows - Disk Pressure Cleanup' + description = "Imported by Intune Hydration Kit`nImported from Proactive Remediation Pack`nRemediationTemplateId: windows-disk-pressure-cleanup`nRemediationFingerprint: stale" + } + ) + } + } + + throw 'WhatIf must not mutate Graph.' + } -ModuleName IntuneHydrationKit + } + + It 'Resolves existing owned state and reports WouldUpdate' { + $result = Import-IntuneRemediation -TemplateId 'windows-disk-pressure-cleanup' -WhatIf + + $result | Should -HaveCount 1 + $result[0].Action | Should -Be 'WouldUpdate' + $result[0].Id | Should -Be 'stale-remediation' + Should -Invoke Invoke-HydrationGraphRequest -Exactly 2 -ParameterFilter { $Method -eq 'GET' } -ModuleName IntuneHydrationKit + Should -Invoke Invoke-HydrationGraphRequest -Exactly 1 -ParameterFilter { + $Method -eq 'GET' -and $Uri -match '/assignments' + } -ModuleName IntuneHydrationKit + } + } + + Context 'When an owned remediation was assigned after import' { + BeforeEach { + Mock Get-IntuneProactiveRemediationAvailability { + [pscustomobject]@{ + IsAvailable = $true + Status = 'Available' + Message = 'Proactive remediations are available.' + } + } -ModuleName IntuneHydrationKit + + Mock Invoke-HydrationGraphRequest { + param($Method, $Uri) + if ($Method -eq 'GET' -and $Uri -match '/assignments') { + return @{ value = @(@{ id = 'manual-assignment' }) } + } + + if ($Method -eq 'GET') { + return @{ + value = @( + @{ + id = 'stale-remediation' + displayName = '[IHD] Windows - Disk Pressure Cleanup' + description = "Imported by Intune Hydration Kit`nImported from Proactive Remediation Pack`nRemediationTemplateId: windows-disk-pressure-cleanup`nRemediationFingerprint: stale" + } + ) + } + } + + throw 'Assigned remediations must not be updated.' + } -ModuleName IntuneHydrationKit + } + + It 'Refuses to update the assigned package and preserves its deployment' { + $result = Import-IntuneRemediation -TemplateId 'windows-disk-pressure-cleanup' + + $result | Should -HaveCount 1 + $result[0].Action | Should -Be 'Failed' + $result[0].Status | Should -Be 'Assigned' + Should -Invoke Invoke-HydrationGraphRequest -Exactly 0 -ParameterFilter { $Method -eq 'PATCH' } -ModuleName IntuneHydrationKit + } + } + + Context 'When planning an update for an assigned remediation' { + BeforeEach { + Mock Get-IntuneProactiveRemediationAvailability { + [pscustomobject]@{ + IsAvailable = $true + Status = 'Available' + Message = 'Proactive remediations are available.' + } + } -ModuleName IntuneHydrationKit + + Mock Invoke-HydrationGraphRequest { + param($Method, $Uri) + if ($Method -eq 'GET' -and $Uri -match '/assignments') { + return @{ value = @(@{ id = 'manual-assignment' }) } + } + + if ($Method -eq 'GET') { + return @{ + value = @( + @{ + id = 'stale-remediation' + displayName = '[IHD] Windows - Disk Pressure Cleanup' + description = "Imported by Intune Hydration Kit`nImported from Proactive Remediation Pack`nRemediationTemplateId: windows-disk-pressure-cleanup`nRemediationFingerprint: stale" + } + ) + } + } + + throw 'WhatIf must not mutate Graph.' + } -ModuleName IntuneHydrationKit + } + + It 'Reports that the assigned package cannot be updated' { + $result = Import-IntuneRemediation -TemplateId 'windows-disk-pressure-cleanup' -WhatIf + + $result | Should -HaveCount 1 + $result[0].Action | Should -Be 'Failed' + $result[0].Status | Should -Be 'Assigned' + Should -Invoke Invoke-HydrationGraphRequest -Exactly 1 -ParameterFilter { + $Method -eq 'GET' -and $Uri -match '/assignments' + } -ModuleName IntuneHydrationKit + Should -Invoke Invoke-HydrationGraphRequest -Exactly 0 -ParameterFilter { $Method -eq 'PATCH' } -ModuleName IntuneHydrationKit + } + } +} diff --git a/Tests/Public/Invoke-IntuneHydration.Tests.ps1 b/Tests/Public/Invoke-IntuneHydration.Tests.ps1 index 3a84555..ac08311 100644 --- a/Tests/Public/Invoke-IntuneHydration.Tests.ps1 +++ b/Tests/Public/Invoke-IntuneHydration.Tests.ps1 @@ -240,6 +240,14 @@ Describe 'Invoke-IntuneHydration' { $param | Should -Not -BeNullOrEmpty $param.ParameterType | Should -Be ([switch]) } + + It 'Should have Remediations switch parameter' { + $command = Get-Command Invoke-IntuneHydration + $param = $command.Parameters['Remediations'] + + $param | Should -Not -BeNullOrEmpty + $param.ParameterType | Should -Be ([switch]) + } } Context 'Settings File Validation' { @@ -318,6 +326,7 @@ Describe 'Invoke-IntuneHydration' { Mock Import-IntuneEnrollmentProfile -ModuleName IntuneHydrationKit Mock Import-IntuneConditionalAccessPolicy -ModuleName IntuneHydrationKit Mock Import-IntuneMobileApp { @() } -ModuleName IntuneHydrationKit + Mock Import-IntuneRemediation { @() } -ModuleName IntuneHydrationKit Mock Import-IntuneWinGetApp { @() } -ModuleName IntuneHydrationKit Mock New-IntuneDynamicGroup -ModuleName IntuneHydrationKit Mock Get-ChildItem { @() } -ModuleName IntuneHydrationKit @@ -735,6 +744,7 @@ Describe 'Invoke-IntuneHydration' { Mock Import-IntuneEnrollmentProfile { @() } -ModuleName IntuneHydrationKit Mock Import-IntuneConditionalAccessPolicy { @() } -ModuleName IntuneHydrationKit Mock Import-IntuneMobileApp { @() } -ModuleName IntuneHydrationKit + Mock Import-IntuneRemediation { @() } -ModuleName IntuneHydrationKit Mock Import-IntuneWinGetApp { @() } -ModuleName IntuneHydrationKit Mock Import-CISBaseline { @() } -ModuleName IntuneHydrationKit Mock New-IntuneDynamicGroup { @{ Action = 'Created'; Id = 'test-id' } } -ModuleName IntuneHydrationKit @@ -849,6 +859,7 @@ Describe 'Invoke-IntuneHydration' { Should -Invoke Import-IntuneAppProtectionPolicy -ModuleName IntuneHydrationKit -Times 1 Should -Invoke Import-IntuneEnrollmentProfile -ModuleName IntuneHydrationKit -Times 1 Should -Invoke Import-IntuneConditionalAccessPolicy -ModuleName IntuneHydrationKit -Times 1 + Should -Invoke Import-IntuneRemediation -ModuleName IntuneHydrationKit -Times 1 Should -Invoke Import-IntuneWinGetApp -ModuleName IntuneHydrationKit -Times 1 } diff --git a/Tests/Public/Test-IntunePrerequisites.Tests.ps1 b/Tests/Public/Test-IntunePrerequisites.Tests.ps1 index 3a493d4..86f070e 100644 --- a/Tests/Public/Test-IntunePrerequisites.Tests.ps1 +++ b/Tests/Public/Test-IntunePrerequisites.Tests.ps1 @@ -243,6 +243,18 @@ Describe 'Test-IntunePrerequisites' { } } + It 'Should probe device health scripts once when WinGet apps and remediations are selected' { + Set-PrerequisiteGraphRequestMock ` + -MobileAppsResponse { @{ value = @() } } ` + -DeviceHealthScriptsResponse { @{ value = @() } } + + Test-IntunePrerequisites -Imports @{ mobileApps = $true; remediations = $true } | Should -Be $true + + Should -Invoke Invoke-MgGraphRequest -ModuleName IntuneHydrationKit -Exactly 1 -ParameterFilter { + $Method -eq 'GET' -and $Uri -like '*deviceManagement/deviceHealthScripts*' + } + } + It 'Should skip WinGet remediation access probe when remediation is disabled' { Set-PrerequisiteGraphRequestMock -MobileAppsResponse { @{ value = @() } } diff --git a/settings.example.json b/settings.example.json index b9647bc..5b1fafd 100644 --- a/settings.example.json +++ b/settings.example.json @@ -28,6 +28,7 @@ "deviceFilters": true, "conditionalAccess": true, "mobileApps": true, + "remediations": false, "cisBaselines": false }, "mobileApps": { diff --git a/settings.schema.json b/settings.schema.json index b9621db..0f393ee 100644 --- a/settings.schema.json +++ b/settings.schema.json @@ -158,6 +158,11 @@ "default": true, "description": "Import mobile apps. Windows apps use bundled WinGet-backed Win32 apps plus selected legacy fallback templates; macOS apps use bundled mobile app templates." }, + "remediations": { + "type": "boolean", + "default": false, + "description": "Import bundled, unassigned Proactive Windows Remediations." + }, "cisBaselines": { "type": "boolean", "default": false,