Private/ScriptGeneration.ps1
|
# ScriptGeneration.ps1 # Script generation functions for Win32 apps and Proactive Remediations function Test-ProactiveRemediationLicense { <# .SYNOPSIS Checks if the tenant has proper licensing for Proactive Remediations (Device Health Scripts). .DESCRIPTION Proactive Remediations require one of: - Microsoft Intune Plan 2 - Microsoft Intune Suite - Windows 365 Enterprise This function attempts to access the deviceHealthScripts endpoint to verify access. .OUTPUTS Returns $true if licensed, $false otherwise. #> [cmdletbinding()] param() try { # Try to query the deviceHealthScripts endpoint - this will fail if not licensed $response = Invoke-MgGraphRequest -Uri "beta/deviceManagement/deviceHealthScripts?`$top=1" -Method GET -ErrorAction Stop Write-Host "Proactive Remediation license check: PASSED" -ForegroundColor Green Write-Verbose "Proactive Remediation license check passed" return $true } catch { $errorMessage = $_.Exception.Message if ($errorMessage -match "403" -or $errorMessage -match "Forbidden" -or $errorMessage -match "license" -or $errorMessage -match "not enabled") { Write-Host "Proactive Remediation license check: FAILED - Feature requires Intune Plan 2 or Windows 365 Enterprise license" -ForegroundColor Yellow Write-Verbose "Proactive Remediation license check failed - requires Intune Plan 2 or Windows 365 Enterprise" } else { Write-Host "Proactive Remediation license check: FAILED - $errorMessage" -ForegroundColor Yellow Write-Verbose "Proactive Remediation license check failed: $errorMessage" } return $false } } function New-WinGetScript { <# .SYNOPSIS Generates PowerShell scripts for Winget app installation, uninstallation, and detection. .PARAMETER AppId The Winget package ID. .PARAMETER AppName The display name of the application. .PARAMETER ScriptType Type of script to generate: Install, Uninstall, Detection, Remediation, or DetectionRemediation. #> [cmdletbinding()] param ( [Parameter(Mandatory = $true)] [string]$AppId, [Parameter(Mandatory = $true)] [string]$AppName, [Parameter(Mandatory = $true)] [ValidateSet("Install", "Uninstall", "Detection", "Remediation", "DetectionRemediation")] [string]$ScriptType ) # Common winget bootstrap code - uses 7zip extraction for reliable SYSTEM context $wingetBootstrap = @' # WinGet Bootstrap - Downloads and extracts winget using 7zip for SYSTEM context $WingetPath = "$env:ProgramData\Microsoft.DesktopAppInstaller" $WingetExe = "$WingetPath\winget.exe" $7zipFolder = "$env:WinDir\Temp\7zip" $StagingFolder = "$env:WinDir\Temp\WinGet-Stage" function Install-VisualCpp { # Install Visual C++ Redistributable (required for winget) $vcPath = "$env:TEMP\vc_redist.x64.exe" try { Invoke-WebRequest -Uri 'https://aka.ms/vs/17/release/vc_redist.x64.exe' -OutFile $vcPath -UseBasicParsing $result = Start-Process $vcPath -ArgumentList "/q /norestart" -Wait -PassThru # 0 = success, 1638 = already installed, 3010 = success but reboot needed if ($result.ExitCode -notin @(0, 1638, 3010)) { Write-Host "VC++ install returned: $($result.ExitCode)" } Remove-Item $vcPath -Force -ErrorAction SilentlyContinue } catch { Write-Host "VC++ download/install failed: $_" } } function Install-WingetWith7Zip { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $ProgressPreference = 'SilentlyContinue' # Install VC++ first Install-VisualCpp # Download WinGet msixbundle try { New-Item -ItemType Directory -Path $StagingFolder -Force | Out-Null Invoke-WebRequest -Uri 'https://aka.ms/getwinget' -OutFile "$StagingFolder\Microsoft.DesktopAppInstaller.msixbundle" -UseBasicParsing } catch { Write-Host "Failed to download WinGet: $_" return } # Download 7zip CLI try { New-Item -ItemType Directory -Path $7zipFolder -Force | Out-Null Invoke-WebRequest -Uri 'https://www.7-zip.org/a/7zr.exe' -OutFile "$7zipFolder\7zr.exe" -UseBasicParsing Invoke-WebRequest -Uri 'https://www.7-zip.org/a/7z2408-extra.7z' -OutFile "$7zipFolder\7zr-extra.7z" -UseBasicParsing & "$7zipFolder\7zr.exe" x "$7zipFolder\7zr-extra.7z" -o"$7zipFolder" -y | Out-Null } catch { Write-Host "Failed to download 7zip: $_" return } # Extract WinGet using 7zip try { New-Item -ItemType Directory -Path $WingetPath -Force | Out-Null & "$7zipFolder\7za.exe" x "$StagingFolder\Microsoft.DesktopAppInstaller.msixbundle" -o"$StagingFolder" -y | Out-Null & "$7zipFolder\7za.exe" x "$StagingFolder\AppInstaller_x64.msix" -o"$WingetPath" -y | Out-Null } catch { Write-Host "Failed to extract WinGet: $_" return } # Cleanup Remove-Item $StagingFolder -Recurse -Force -ErrorAction SilentlyContinue Remove-Item $7zipFolder -Recurse -Force -ErrorAction SilentlyContinue } # Clean up old broken extraction path if it exists $oldWingetPath = "$env:ProgramData\WinGet" if (Test-Path "$oldWingetPath\winget.exe") { Remove-Item $oldWingetPath -Recurse -Force -ErrorAction SilentlyContinue } # Check if running as SYSTEM (WindowsApps winget doesn't work in SYSTEM context) $isSystem = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name -eq "NT AUTHORITY\SYSTEM" # Check if winget is available - try multiple locations $Winget = $null # Option 1: Extracted winget in ProgramData (preferred for SYSTEM context) if (Test-Path $WingetExe) { $Winget = $WingetExe } # Option 2: WindowsApps folder - ONLY for non-SYSTEM context (UWP apps don't work as SYSTEM) if (-not $Winget -and -not $isSystem) { $wingetFolders = Get-ChildItem -Path "$env:ProgramFiles\WindowsApps" -Directory -Filter "Microsoft.DesktopAppInstaller_*_x64__8wekyb3d8bbwe" -ErrorAction SilentlyContinue | Sort-Object Name -Descending foreach ($folder in $wingetFolders) { $testPath = Join-Path $folder.FullName "winget.exe" if (Test-Path $testPath) { $Winget = $testPath break } } } # Option 3: User context path if (-not $Winget) { $userPath = "$env:LOCALAPPDATA\Microsoft\WindowsApps\winget.exe" if (Test-Path $userPath) { $Winget = $userPath } } # If winget not found, install it using 7zip extraction if (-not $Winget -or -not (Test-Path $Winget)) { Install-WingetWith7Zip if (Test-Path $WingetExe) { $Winget = $WingetExe } } # Verify winget exists if (-not $Winget -or -not (Test-Path $Winget)) { Write-Host "ERROR: WinGet not available" exit 1 } # Test that winget actually runs (catches DLL issues) try { $testOutput = & $Winget --version 2>&1 if ($LASTEXITCODE -ne 0 -and $testOutput -notmatch 'v\d') { # Winget exists but doesn't run - reinstall Write-Host "WinGet found but not functional, reinstalling..." Install-WingetWith7Zip if (Test-Path $WingetExe) { $Winget = $WingetExe } } } catch { Write-Host "WinGet test failed, reinstalling..." Install-WingetWith7Zip if (Test-Path $WingetExe) { $Winget = $WingetExe } } '@ # Escape AppId and AppName for safe interpolation in generated scripts $safeAppId = $AppId.Replace("'", "''").Replace('"', '""').Replace('`', '``') $safeAppName = $AppName.Replace("'", "''").Replace('"', '""').Replace('`', '``') # Shared logging function for Install/Uninstall scripts $writeLogFunction = @' function Write-Log { param([string]$Message) $Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" "$Timestamp - $Message" | Out-File -FilePath $LogFile -Append -Encoding utf8 Write-Host $Message } '@ switch ($ScriptType) { "Detection" { # Detection script for Proactive Remediation - checks if update is available # Escape regex special characters in AppId for -match operator $escapedAppId = [regex]::Escape($AppId) return @" $wingetBootstrap `$upgrades = & `$Winget upgrade --source winget --accept-source-agreements 2>&1 if (`$upgrades -match "$escapedAppId") { Write-Host "Upgrade available for $safeAppName" exit 1 } else { Write-Host "$safeAppName is up to date" exit 0 } "@ } "Remediation" { # Remediation script for Proactive Remediation - upgrades the app return @" $wingetBootstrap & `$Winget upgrade --id "$safeAppId" --source winget --silent --force --accept-package-agreements --accept-source-agreements exit `$LASTEXITCODE "@ } "DetectionRemediation" { # Detection script for Win32 app - checks if app is installed # Escape regex special characters in AppId for -match operator $escapedAppId = [regex]::Escape($AppId) return @" $wingetBootstrap `$installed = & `$Winget list --id "$safeAppId" --source winget --accept-source-agreements 2>&1 if (`$installed -match "$escapedAppId") { Write-Host "$safeAppName is installed" exit 0 } else { Write-Host "$safeAppName is not installed" exit 1 } "@ } "Install" { $safeLogName = $AppId -replace '[^a-zA-Z0-9]', '_' return @" # Install script using extracted winget.exe for reliable SYSTEM context `$LogPath = "C:\ProgramData\Microsoft\IntuneManagementExtension\Logs" if (-not (Test-Path `$LogPath)) { New-Item -Path `$LogPath -ItemType Directory -Force | Out-Null } `$LogFile = Join-Path `$LogPath "${safeLogName}_Install.log" $writeLogFunction Write-Log "Starting installation of $safeAppName ($safeAppId)" Write-Log "Running as: `$([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)" $wingetBootstrap Write-Log "WinGet executable: `$Winget" # Run the installation Write-Log "Installing $safeAppId..." `$output = & `$Winget install --id "$safeAppId" --source winget --silent --force --accept-package-agreements --accept-source-agreements --scope machine 2>&1 `$exitCode = `$LASTEXITCODE Write-Log "Output: `$output" Write-Log "Exit code: `$exitCode" switch (`$exitCode) { 0 { Write-Log "Installation completed successfully" } -1978335189 { Write-Log "No applicable update found (app may already be installed)"; `$exitCode = 0 } -1978335215 { Write-Log "No package found matching the criteria" } default { `$hexCode = "0x{0:X8}" -f (`$exitCode -band 0xFFFFFFFF) Write-Log "Installation completed with exit code: `$exitCode (`$hexCode)" } } exit `$exitCode "@ } "Uninstall" { $safeLogName = $AppId -replace '[^a-zA-Z0-9]', '_' return @" # Uninstall script using extracted winget.exe for reliable SYSTEM context `$LogPath = "C:\ProgramData\Microsoft\IntuneManagementExtension\Logs" if (-not (Test-Path `$LogPath)) { New-Item -Path `$LogPath -ItemType Directory -Force | Out-Null } `$LogFile = Join-Path `$LogPath "${safeLogName}_Uninstall.log" $writeLogFunction Write-Log "Starting uninstallation of $safeAppName ($safeAppId)" Write-Log "Running as: `$([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)" $wingetBootstrap Write-Log "WinGet executable: `$Winget" # Run the uninstallation Write-Log "Uninstalling $safeAppId..." `$output = & `$Winget uninstall --id "$safeAppId" --source winget --silent --force --accept-source-agreements 2>&1 `$exitCode = `$LASTEXITCODE Write-Log "Output: `$output" Write-Log "Exit code: `$exitCode" switch (`$exitCode) { 0 { Write-Log "Uninstallation completed successfully" } -1978335212 { Write-Log "Package not found (may already be uninstalled)"; `$exitCode = 0 } default { `$hexCode = "0x{0:X8}" -f (`$exitCode -band 0xFFFFFFFF) Write-Log "Uninstallation completed with exit code: `$exitCode (`$hexCode)" } } exit `$exitCode "@ } } } function New-ProactiveRemediation { <# .SYNOPSIS Creates a Proactive Remediation (Device Health Script) in Intune for automatic app updates. .PARAMETER AppId The Winget package ID. .PARAMETER AppName The display name of the application. .PARAMETER GroupId The Azure AD group ID to assign the remediation to. #> [cmdletbinding()] param ( [Parameter(Mandatory = $true)] [string]$AppId, [Parameter(Mandatory = $true)] [string]$AppName, [Parameter(Mandatory = $true)] [string]$GroupId ) $remediationName = "$AppName Proactive Update" # Check for existing remediation $escapedName = $remediationName.Replace("'", "''") $filter = [uri]::EscapeDataString("displayName eq '$escapedName'") $existing = (Invoke-MgGraphRequest -Uri "beta/deviceManagement/deviceHealthScripts?`$filter=$filter" -Method GET -ErrorAction Stop).value if ($existing) { Write-Host "Found existing remediation: $remediationName — skipping creation" -ForegroundColor Green Write-Verbose "Existing remediation ID: $($existing[0].id)" return $existing[0].id } $detectionScript = New-WinGetScript -AppId $AppId -AppName $AppName -ScriptType "Detection" $remediationScript = New-WinGetScript -AppId $AppId -AppName $AppName -ScriptType "Remediation" $detectionBase64 = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($detectionScript)) $remediationBase64 = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($remediationScript)) $descriptionSuffix = $script:PublisherTag $body = @{ "@odata.type" = "#microsoft.graph.deviceHealthScript" publisher = "Winget" displayName = $remediationName description = "Auto-update remediation for $AppName - $descriptionSuffix" detectionScriptContent = $detectionBase64 remediationScriptContent = $remediationBase64 runAs32Bit = $false runAsAccount = "system" enforceSignatureCheck = $false roleScopeTagIds = @("0") isGlobalScript = $false detectionScriptParameters = @() remediationScriptParameters = @() } $result = Invoke-MgGraphRequest -Uri "beta/deviceManagement/deviceHealthScripts" -Method POST -Body ($body | ConvertTo-Json) -ErrorAction Stop # Assign to group $assignBody = @{ deviceHealthScriptAssignments = @( @{ "@odata.type" = "#microsoft.graph.deviceHealthScriptAssignment" runRemediationScript = $true runSchedule = @{ "@odata.type" = "#microsoft.graph.deviceHealthScriptDailySchedule" interval = 1 time = "09:00" useUtc = $false } target = @{ "@odata.type" = "#microsoft.graph.groupAssignmentTarget" groupId = $GroupId } } ) } $assignUri = "beta/deviceManagement/deviceHealthScripts/$($result.id)/assign" Invoke-MgGraphRequest -Uri $assignUri -Method POST -Body ($assignBody | ConvertTo-Json -Depth 10) -ErrorAction Stop | Out-Null Write-Host "Created proactive remediation: $remediationName" -ForegroundColor Green Write-Verbose "Created proactive remediation: $($result.displayName) $($result.id)" return $result.id } |