Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions IntuneHydrationKit.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
'Import-IntuneEnrollmentProfile',
'Import-IntuneMobileApp',
'Import-IntuneNotificationTemplate',
'Import-IntuneRemediation',
'Import-IntuneWinGetApp',
'Initialize-HydrationLogging',
'Invoke-IntuneHydration',
Expand Down
1 change: 1 addition & 0 deletions IntuneHydrationKit.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ $publicFunctions = @(
'Import-IntuneEnrollmentProfile',
'Import-IntuneMobileApp',
'Import-IntuneNotificationTemplate',
'Import-IntuneRemediation',
'Import-IntuneWinGetApp',
'Initialize-HydrationLogging',
'Invoke-IntuneHydration',
Expand Down
6 changes: 6 additions & 0 deletions Invoke-IntuneHydration.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions Private/Auth/Get-HydrationGraphScopes.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}

Expand Down
27 changes: 19 additions & 8 deletions Private/Auth/Get-HydrationGraphWorkloadAccessProbe.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ function Get-HydrationGraphWorkloadAccessProbe {
})
}

$requiresWinGetRemediationProbe = $false
if ($Imports.ContainsKey('mobileApps') -and $Imports.mobileApps) {
$probes.Add(@{
Workload = 'Mobile Apps'
Expand All @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions Private/Configuration/Get-HydrationWorkloadCatalog.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions Private/Configuration/Resolve-HydrationExecutionSettings.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ function Resolve-HydrationExecutionSettings {
[Parameter()]
[switch]$MobileApps,

[Parameter()]
[switch]$Remediations,

[Parameter()]
[switch]$CISBaselines,

Expand Down Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
155 changes: 155 additions & 0 deletions Private/DeviceHealthScripts/Sync-IntuneDeviceHealthScript.ps1
Original file line number Diff line number Diff line change
@@ -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)
}
}
23 changes: 23 additions & 0 deletions Private/Remediations/Get-HydrationRemediationFingerprint.ps1
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading