Skip to content

Commit 0e394e3

Browse files
committed
Add proactive remediation workload
1 parent d44ec05 commit 0e394e3

43 files changed

Lines changed: 1414 additions & 116 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎IntuneHydrationKit.psd1‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
'Import-IntuneEnrollmentProfile',
4848
'Import-IntuneMobileApp',
4949
'Import-IntuneNotificationTemplate',
50+
'Import-IntuneRemediation',
5051
'Import-IntuneWinGetApp',
5152
'Initialize-HydrationLogging',
5253
'Invoke-IntuneHydration',

‎IntuneHydrationKit.psm1‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ $publicFunctions = @(
8888
'Import-IntuneEnrollmentProfile',
8989
'Import-IntuneMobileApp',
9090
'Import-IntuneNotificationTemplate',
91+
'Import-IntuneRemediation',
9192
'Import-IntuneWinGetApp',
9293
'Initialize-HydrationLogging',
9394
'Invoke-IntuneHydration',

‎Invoke-IntuneHydration.ps1‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@
5454
Process Conditional Access starter pack policies
5555
.PARAMETER MobileApps
5656
Process mobile app templates
57+
.PARAMETER Remediations
58+
Process bundled, unassigned Proactive Windows Remediations.
5759
.PARAMETER CISBaselines
5860
Process bundled CIS baseline policies
5961
.PARAMETER All
@@ -166,6 +168,10 @@ param(
166168
[Parameter(ParameterSetName = 'ServicePrincipal')]
167169
[switch]$MobileApps,
168170

171+
[Parameter(ParameterSetName = 'Interactive')]
172+
[Parameter(ParameterSetName = 'ServicePrincipal')]
173+
[switch]$Remediations,
174+
169175
[Parameter(ParameterSetName = 'Interactive')]
170176
[Parameter(ParameterSetName = 'ServicePrincipal')]
171177
[switch]$CISBaselines,

‎Private/Auth/Get-HydrationGraphScopes.ps1‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ function Get-HydrationGraphScopes {
5252
appProtection = @('DeviceManagementApps.ReadWrite.All')
5353
notificationTemplates = @('DeviceManagementServiceConfig.ReadWrite.All')
5454
mobileApps = @('DeviceManagementApps.ReadWrite.All')
55+
remediations = @('DeviceManagementConfiguration.ReadWrite.All', 'DeviceManagementScripts.ReadWrite.All')
5556
cisBaselines = @('DeviceManagementConfiguration.ReadWrite.All')
5657
}
5758

‎Private/Auth/Get-HydrationGraphWorkloadAccessProbe.ps1‎

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ function Get-HydrationGraphWorkloadAccessProbe {
3434
})
3535
}
3636

37+
$requiresWinGetRemediationProbe = $false
3738
if ($Imports.ContainsKey('mobileApps') -and $Imports.mobileApps) {
3839
$probes.Add(@{
3940
Workload = 'Mobile Apps'
@@ -48,15 +49,25 @@ function Get-HydrationGraphWorkloadAccessProbe {
4849
$remediationEnabled = [bool]$MobileAppConfiguration.remediationEnabled
4950
}
5051

51-
if ($remediationEnabled -and (Test-HydrationMobileAppsIncludeWinGet -Configuration $MobileAppConfiguration -Platforms $MobileAppPlatforms)) {
52-
$probes.Add(@{
53-
Workload = 'WinGet Proactive Remediations'
54-
Endpoint = 'beta/deviceManagement/deviceHealthScripts'
55-
Uri = 'beta/deviceManagement/deviceHealthScripts?$top=1&$select=id'
56-
RequiredScope = 'DeviceManagementScripts.ReadWrite.All'
57-
RoleHint = 'Use a Global Administrator account with active Intune device script access; PIM-elevated roles can still be rejected by the downstream Intune service.'
58-
})
52+
$requiresWinGetRemediationProbe = $remediationEnabled -and (Test-HydrationMobileAppsIncludeWinGet -Configuration $MobileAppConfiguration -Platforms $MobileAppPlatforms)
53+
}
54+
55+
$requiresRemediationProbe = $Imports.ContainsKey('remediations') -and $Imports.remediations
56+
if ($requiresWinGetRemediationProbe -or $requiresRemediationProbe) {
57+
$workloads = [System.Collections.Generic.List[string]]::new()
58+
if ($requiresWinGetRemediationProbe) {
59+
$workloads.Add('WinGet Proactive Remediations')
5960
}
61+
if ($requiresRemediationProbe) {
62+
$workloads.Add('Proactive Remediations')
63+
}
64+
$probes.Add(@{
65+
Workload = $workloads -join ' and '
66+
Endpoint = 'beta/deviceManagement/deviceHealthScripts'
67+
Uri = 'beta/deviceManagement/deviceHealthScripts?$top=1&$select=id'
68+
RequiredScope = 'DeviceManagementScripts.ReadWrite.All'
69+
RoleHint = 'Use a Global Administrator account with active Intune device script access; PIM-elevated roles can still be rejected by the downstream Intune service.'
70+
})
6071
}
6172

6273
$appProtectionProbePlatforms = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)

‎Private/Configuration/Get-HydrationWorkloadCatalog.ps1‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ function Get-HydrationWorkloadCatalog {
5757
Platforms = @('Windows', 'macOS')
5858
PlatformNeutral = $false
5959
}
60+
[pscustomobject]@{
61+
ImportKey = 'remediations'
62+
FilterKey = 'Remediations'
63+
Platforms = @('Windows')
64+
PlatformNeutral = $false
65+
}
6066
[pscustomobject]@{
6167
ImportKey = 'notificationTemplates'
6268
FilterKey = $null

‎Private/Configuration/Resolve-HydrationExecutionSettings.ps1‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ function Resolve-HydrationExecutionSettings {
7373
[Parameter()]
7474
[switch]$MobileApps,
7575

76+
[Parameter()]
77+
[switch]$Remediations,
78+
7679
[Parameter()]
7780
[switch]$CISBaselines,
7881

@@ -179,6 +182,7 @@ function Resolve-HydrationExecutionSettings {
179182
appProtection = $All.IsPresent -or $AppProtection.IsPresent
180183
notificationTemplates = $All.IsPresent -or $NotificationTemplates.IsPresent
181184
mobileApps = $All.IsPresent -or $MobileApps.IsPresent
185+
remediations = $All.IsPresent -or $Remediations.IsPresent
182186
cisBaselines = $All.IsPresent -or $CISBaselines.IsPresent
183187
}
184188

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
function ConvertFrom-HydrationDeviceHealthScriptDescription {
2+
<#
3+
.SYNOPSIS
4+
Parses newline-delimited device health script metadata.
5+
#>
6+
[CmdletBinding()]
7+
[OutputType([System.Collections.Generic.Dictionary[string, string]])]
8+
param(
9+
[Parameter()]
10+
[AllowEmptyString()]
11+
[string]$Description
12+
)
13+
14+
$metadata = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase)
15+
foreach ($line in $Description -split "`r?`n") {
16+
$separatorIndex = $line.IndexOf(':')
17+
if ($separatorIndex -lt 1) {
18+
continue
19+
}
20+
21+
$key = $line.Substring(0, $separatorIndex).Trim()
22+
if (-not [string]::IsNullOrWhiteSpace($key)) {
23+
$metadata[$key] = $line.Substring($separatorIndex + 1).Trim()
24+
}
25+
}
26+
27+
return $metadata
28+
}
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
function Sync-IntuneDeviceHealthScript {
2+
<#
3+
.SYNOPSIS
4+
Synchronizes one Intune device health script from a declarative definition.
5+
.DESCRIPTION
6+
Every definition requires DisplayName, Type, Path, SourceMarker, and
7+
OwnershipMetadata. Present definitions additionally require
8+
FingerprintMetadataKey, Fingerprint, Status, and BuildBody.
9+
BuildBody receives IncludeCreateOnlyProperties and remains workload-specific.
10+
#>
11+
[CmdletBinding(SupportsShouldProcess)]
12+
[OutputType([PSCustomObject[]])]
13+
param(
14+
[Parameter(Mandatory)]
15+
[hashtable]$Definition,
16+
17+
[Parameter()]
18+
[ValidateSet('Present', 'Remove')]
19+
[string]$DesiredState = 'Present',
20+
21+
[Parameter()]
22+
[bool]$WhatIfEnabled = $false
23+
)
24+
25+
$requiredKeys = @('DisplayName', 'Type', 'Path', 'SourceMarker', 'OwnershipMetadata')
26+
if ($DesiredState -eq 'Present') {
27+
$requiredKeys += 'FingerprintMetadataKey', 'Fingerprint', 'Status', 'BuildBody'
28+
}
29+
$missingKeys = @($requiredKeys | Where-Object { -not $Definition.ContainsKey($_) })
30+
if ($missingKeys.Count -gt 0) {
31+
throw "Device health script definition is missing required key(s): $($missingKeys -join ', ')"
32+
}
33+
if ($Definition.OwnershipMetadata -isnot [hashtable] -or $Definition.OwnershipMetadata.Count -eq 0) {
34+
throw 'Device health script definition requires non-empty ownership metadata.'
35+
}
36+
37+
$escapedDisplayName = $Definition.DisplayName.Replace("'", "''")
38+
$filter = [uri]::EscapeDataString("displayName eq '$escapedDisplayName'")
39+
$response = Invoke-HydrationGraphRequest -Method GET -Uri "beta/deviceManagement/deviceHealthScripts?`$filter=$filter"
40+
$existingScripts = @($response.value | Where-Object { $null -ne $_ })
41+
$ownedScripts = [System.Collections.Generic.List[object]]::new()
42+
43+
foreach ($existingScript in $existingScripts) {
44+
$description = [string]$existingScript.description
45+
if (-not (Test-HydrationKitObject -Description $description)) {
46+
continue
47+
}
48+
49+
$descriptionLines = $description -split "`r?`n" | ForEach-Object { $_.Trim() }
50+
if ($descriptionLines -notcontains $Definition.SourceMarker) {
51+
continue
52+
}
53+
54+
$metadata = ConvertFrom-HydrationDeviceHealthScriptDescription -Description $description
55+
$isOwned = $true
56+
foreach ($key in $Definition.OwnershipMetadata.Keys) {
57+
if (-not $metadata.ContainsKey($key) -or $metadata[$key] -cne [string]$Definition.OwnershipMetadata[$key]) {
58+
$isOwned = $false
59+
break
60+
}
61+
}
62+
63+
if ($isOwned) {
64+
$ownedScripts.Add([pscustomobject]@{
65+
Script = $existingScript
66+
Metadata = $metadata
67+
})
68+
}
69+
}
70+
71+
if ($DesiredState -eq 'Remove') {
72+
$results = [System.Collections.Generic.List[object]]::new()
73+
foreach ($ownedScript in $ownedScripts) {
74+
if ($WhatIfEnabled) {
75+
$results.Add((Add-HydrationDryRunResult -Action 'WouldDelete' -Name $Definition.DisplayName -Id $ownedScript.Script.id -Path $Definition.Path -Type $Definition.Type))
76+
continue
77+
}
78+
79+
if (-not $PSCmdlet.ShouldProcess($Definition.DisplayName, 'Delete remediation')) {
80+
continue
81+
}
82+
83+
try {
84+
Invoke-HydrationGraphRequest -Method DELETE -Uri "beta/deviceManagement/deviceHealthScripts/$($ownedScript.Script.id)" | Out-Null
85+
Write-HydrationLog -Message " Deleted: $($Definition.DisplayName)" -Level Info
86+
$results.Add((New-HydrationResult -Name $Definition.DisplayName -Id $ownedScript.Script.id -Path $Definition.Path -Type $Definition.Type -Action 'Deleted' -Status 'Removed'))
87+
} catch {
88+
$errorMessage = Get-GraphErrorMessage -ErrorRecord $_
89+
Write-HydrationLog -Message " Failed: $($Definition.DisplayName) - $errorMessage" -Level Warning
90+
$results.Add((New-HydrationResult -Name $Definition.DisplayName -Id $ownedScript.Script.id -Path $Definition.Path -Type $Definition.Type -Action 'Failed' -Status $errorMessage))
91+
}
92+
}
93+
94+
return @($results)
95+
}
96+
97+
if ($ownedScripts.Count -gt 1) {
98+
Write-HydrationLog -Message " Failed: $($Definition.DisplayName) - Multiple matching hydration-owned remediations exist; remove them explicitly before importing." -Level Warning
99+
return @(New-HydrationResult -Name $Definition.DisplayName -Path $Definition.Path -Type $Definition.Type -Action 'Failed' -Status 'Multiple owned remediations')
100+
}
101+
102+
$ownedScript = $ownedScripts | Select-Object -First 1
103+
if ($existingScripts.Count -gt 0 -and -not $ownedScript) {
104+
Write-HydrationLog -Message " Failed: $($Definition.DisplayName) - A remediation with this name already exists but is not owned by Intune Hydration Kit." -Level Warning
105+
return @(New-HydrationResult -Name $Definition.DisplayName -Path $Definition.Path -Type $Definition.Type -Action 'Failed' -Status 'Name collision')
106+
}
107+
108+
if ($ownedScript -and $ownedScript.Metadata[$Definition.FingerprintMetadataKey] -ceq $Definition.Fingerprint) {
109+
Write-HydrationLog -Message " Skipped: $($Definition.DisplayName)" -Level Info
110+
return @(New-HydrationResult -Name $Definition.DisplayName -Id $ownedScript.Script.id -Path $Definition.Path -Type $Definition.Type -Action 'Skipped' -Status 'Already current')
111+
}
112+
113+
if ($ownedScript -and $Definition.ContainsKey('RequireUnassigned') -and $Definition.RequireUnassigned) {
114+
try {
115+
$assignmentResponse = Invoke-HydrationGraphRequest -Method GET -Uri "beta/deviceManagement/deviceHealthScripts/$($ownedScript.Script.id)/assignments?`$top=1"
116+
$assignments = @($assignmentResponse.value | Where-Object { $null -ne $_ })
117+
} catch {
118+
$errorMessage = Get-GraphErrorMessage -ErrorRecord $_
119+
Write-HydrationLog -Message " Failed: $($Definition.DisplayName) - Could not verify assignments: $errorMessage" -Level Warning
120+
return @(New-HydrationResult -Name $Definition.DisplayName -Id $ownedScript.Script.id -Path $Definition.Path -Type $Definition.Type -Action 'Failed' -Status "Assignment check failed: $errorMessage")
121+
}
122+
123+
if ($assignments.Count -gt 0) {
124+
Write-HydrationLog -Message " Failed: $($Definition.DisplayName) - The remediation has assignments and will not be updated." -Level Warning
125+
return @(New-HydrationResult -Name $Definition.DisplayName -Id $ownedScript.Script.id -Path $Definition.Path -Type $Definition.Type -Action 'Failed' -Status 'Assigned')
126+
}
127+
}
128+
129+
if ($WhatIfEnabled) {
130+
$action = if ($ownedScript) { 'WouldUpdate' } else { 'WouldCreate' }
131+
return @(Add-HydrationDryRunResult -Action $action -Name $Definition.DisplayName -Id $ownedScript.Script.id -Path $Definition.Path -Type $Definition.Type)
132+
}
133+
134+
$operation = if ($ownedScript) { 'Update remediation' } else { 'Create remediation' }
135+
if (-not $PSCmdlet.ShouldProcess($Definition.DisplayName, $operation)) {
136+
return @()
137+
}
138+
139+
try {
140+
$body = & $Definition.BuildBody (-not $ownedScript)
141+
if ($ownedScript) {
142+
Invoke-HydrationGraphRequest -Method PATCH -Uri "beta/deviceManagement/deviceHealthScripts/$($ownedScript.Script.id)" -Body $body | Out-Null
143+
Write-HydrationLog -Message " Updated: $($Definition.DisplayName)" -Level Info
144+
return @(New-HydrationResult -Name $Definition.DisplayName -Id $ownedScript.Script.id -Path $Definition.Path -Type $Definition.Type -Action 'Updated' -Status $Definition.Status)
145+
}
146+
147+
$createdScript = Invoke-HydrationGraphRequest -Method POST -Uri 'beta/deviceManagement/deviceHealthScripts' -Body $body
148+
Write-HydrationLog -Message " Created: $($Definition.DisplayName)" -Level Info
149+
return @(New-HydrationResult -Name $Definition.DisplayName -Id $createdScript.id -Path $Definition.Path -Type $Definition.Type -Action 'Created' -Status $Definition.Status)
150+
} catch {
151+
$errorMessage = Get-GraphErrorMessage -ErrorRecord $_
152+
Write-HydrationLog -Message " Failed: $($Definition.DisplayName) - $errorMessage" -Level Warning
153+
return @(New-HydrationResult -Name $Definition.DisplayName -Path $Definition.Path -Type $Definition.Type -Action 'Failed' -Status $errorMessage)
154+
}
155+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
function Get-HydrationRemediationFingerprint {
2+
[CmdletBinding()]
3+
[OutputType([string])]
4+
param(
5+
[Parameter(Mandatory)]
6+
[psobject]$Template
7+
)
8+
9+
$fingerprintInput = [System.Collections.Generic.List[string]]::new()
10+
foreach ($propertyName in @('templateId', 'displayName', 'publisher', 'description', 'runAsAccount', 'runAs32Bit')) {
11+
$fingerprintInput.Add("$propertyName=$($Template.$propertyName)")
12+
}
13+
14+
foreach ($scriptPath in @($Template.DetectionScriptPath, $Template.RemediationScriptPath)) {
15+
if (-not [string]::IsNullOrWhiteSpace($scriptPath)) {
16+
$fingerprintInput.Add((Get-Content -LiteralPath $scriptPath -Raw -Encoding utf8))
17+
}
18+
}
19+
20+
$bytes = [System.Text.Encoding]::UTF8.GetBytes(($fingerprintInput -join "`n"))
21+
$hash = [System.Security.Cryptography.SHA256]::HashData($bytes)
22+
return [Convert]::ToHexString($hash)
23+
}

0 commit comments

Comments
 (0)