From ae85fcb97fce530adc304c4320e3a727805cd3b7 Mon Sep 17 00:00:00 2001 From: aollivierre Date: Sat, 17 May 2025 03:35:09 -0500 Subject: [PATCH 1/2] feat: Implement initial token-based authentication flow feat: Implement initial token-based authentication flow This commit introduces changes to allow the IntuneManagement tool to utilize a pre-acquired bearer token for authentication, aiming to bypass standard App ID/Secret or interactive login methods. Modifications include: - Added an '-AccessToken' parameter to 'Start-IntuneManagement.ps1'. - Updated 'CloudAPIPowerShellManagement.psm1' in the 'Initialize-CloudAPIManagement' function to receive and globally store the provided access token. - Iteratively modified 'Invoke-EMAuthenticateToMSAL' in 'Extensions/EndpointManager.psm1' to: - Detect and parse the global raw access token. - Populate '$global:MSALToken' with the token details. - Attempt to configure '$global:EMViewObject.AppInfo' and call 'Set-MSALCurrentApp' with a placeholder ClientId to satisfy MSAL.PS library expectations. - Restructure control flow to prevent fallback to interactive login when a token is supplied. Note: This is an ongoing effort. Current attempts to fully bypass interactive login prompts when a token is used are still under investigation. Fix: Prioritize pre-acquired token and prevent re-authentication This commit addresses an issue where the interactive login prompt was incorrectly triggered even when a pre-acquired bearer token was provided. The following changes were made: - Modified `Invoke-GraphRequest` in `Extensions/MSGraph.psm1` to check for a valid existing `$global:MSALToken` (not expired) before attempting to call `Connect-MSALUser`. This prevents unnecessary re-authentication attempts. - Updated `Connect-MSALUser` in `Extensions/MSALAuthentication.psm1` to include an early return if `$global:MSALToken` exists, is derived from `$global:RawAccessToken`, and is not expired. This ensures that if a valid token has been set via the `AccessToken` parameter, the function will not proceed with further (interactive) authentication steps. These changes ensure that the token-based authentication flow is correctly prioritized, preventing the interactive login from appearing when a valid token is already available. --- CloudAPIPowerShellManagement.psm1 | 10 +- Extensions/EndpointManager.psm1 | 83 +- Extensions/MSALAuthentication.psm1 | 10 + Extensions/MSGraph.psm1 | 1410 ++++++++++++++++- Start-IntuneManagement.ps1 | 4 +- Xaml/BulkImportForm.xaml | 1 - Xaml/DocumentationWordOptions.xaml | 4 +- Xaml/ImportForm.xaml | 1 - examples/Intune/ConnectToIntune.ps1 | 236 +++ .../Deploy-ServiceMonitor-Enhanced.ps1 | 102 ++ .../ServiceMonitor/Deploy-ServiceMonitor.ps1 | 112 ++ .../ServiceMonitor/Detect-CriticalService.ps1 | 39 + .../Remediate-CriticalService.ps1 | 65 + .../IntuneRemediation/IntuneRemediation.psd1 | 65 + .../IntuneRemediation/IntuneRemediation.psm1 | 16 + .../Private/Test-AuthToken.ps1 | 68 + .../Public/Connect-IntuneWithToken.ps1 | 85 + .../Public/Initialize-IntuneConnection.ps1 | 165 ++ .../Public/New-IntuneRemediationScript.ps1 | 136 ++ .../Public/Test-IntuneRemediationScript.ps1 | 222 +++ examples/Intune/IntuneRemediation/README.md | 151 ++ 21 files changed, 2916 insertions(+), 69 deletions(-) create mode 100644 examples/Intune/ConnectToIntune.ps1 create mode 100644 examples/Intune/IntuneRemediation/Examples/ServiceMonitor/Deploy-ServiceMonitor-Enhanced.ps1 create mode 100644 examples/Intune/IntuneRemediation/Examples/ServiceMonitor/Deploy-ServiceMonitor.ps1 create mode 100644 examples/Intune/IntuneRemediation/Examples/ServiceMonitor/Detect-CriticalService.ps1 create mode 100644 examples/Intune/IntuneRemediation/Examples/ServiceMonitor/Remediate-CriticalService.ps1 create mode 100644 examples/Intune/IntuneRemediation/IntuneRemediation.psd1 create mode 100644 examples/Intune/IntuneRemediation/IntuneRemediation.psm1 create mode 100644 examples/Intune/IntuneRemediation/Private/Test-AuthToken.ps1 create mode 100644 examples/Intune/IntuneRemediation/Public/Connect-IntuneWithToken.ps1 create mode 100644 examples/Intune/IntuneRemediation/Public/Initialize-IntuneConnection.ps1 create mode 100644 examples/Intune/IntuneRemediation/Public/New-IntuneRemediationScript.ps1 create mode 100644 examples/Intune/IntuneRemediation/Public/Test-IntuneRemediationScript.ps1 create mode 100644 examples/Intune/IntuneRemediation/README.md diff --git a/CloudAPIPowerShellManagement.psm1 b/CloudAPIPowerShellManagement.psm1 index 7e3f1a7..b48bd81 100644 --- a/CloudAPIPowerShellManagement.psm1 +++ b/CloudAPIPowerShellManagement.psm1 @@ -85,7 +85,9 @@ function Initialize-CloudAPIManagement [string] $secret, [string] - $certificate + $certificate, + [string] + $AccessToken ) $PSModuleAutoloadingPreference = "none" @@ -97,6 +99,12 @@ function Initialize-CloudAPIManagement [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + if (-not [string]::IsNullOrEmpty($AccessToken)) + { + $global:RawAccessToken = $AccessToken + Write-Verbose "Using provided AccessToken for authentication." + } + $global:hideUI = ($Silent -eq $true) $global:SilentBatchFile = $SilentBatchFile diff --git a/Extensions/EndpointManager.psm1 b/Extensions/EndpointManager.psm1 index 314860f..e8fdfcb 100644 --- a/Extensions/EndpointManager.psm1 +++ b/Extensions/EndpointManager.psm1 @@ -838,9 +838,86 @@ function Invoke-EMAuthenticateToMSAL { param($params = @{}) - $global:EMViewObject.AppInfo = Get-GraphAppInfo "EMAzureApp" $global:DefaultAzureApp "EM" - Set-MSALCurrentApp $global:EMViewObject.AppInfo - & $global:msalAuthenticator.Login -Account (?? $global:MSALToken.Account.UserName (Get-Setting "" "LastLoggedOnUser")) @params + if ($global:RawAccessToken) { + Write-Log "Invoke-EMAuthenticateToMSAL: Attempting to use pre-provided RawAccessToken." + try { + $jwtTokenPayload = (Get-JWTtoken -token $global:RawAccessToken).Payload + if (-not $jwtTokenPayload) { + Write-LogError "Invoke-EMAuthenticateToMSAL: Failed to parse RawAccessToken." + $global:MSALToken = $null + $global:EMViewObject.AppInfo = Get-GraphAppInfo "EMAzureApp" $global:DefaultAzureApp "EM" + if ($global:EMViewObject.AppInfo) { Set-MSALCurrentApp $global:EMViewObject.AppInfo } + Invoke-ModuleFunction "Invoke-GraphAuthenticationUpdated" + return $false # Exit function, token processing failed + } + + $tokenExpiresOn = [datetime]::new(1970, 1, 1, 0, 0, 0, [System.DateTimeKind]::Utc).AddSeconds($jwtTokenPayload.exp) + $userNameFromToken = $jwtTokenPayload.upn + if (-not $userNameFromToken) { $userNameFromToken = $jwtTokenPayload.unique_name } + if (-not $userNameFromToken) { $userNameFromToken = "UserFromToken" } + + $tenantIdFromToken = $jwtTokenPayload.tid + $accountEnvironment = "login.microsoftonline.com" + + $global:MSALToken = [PSCustomObject]@{ + AccessToken = $global:RawAccessToken + ExpiresOn = $tokenExpiresOn + TenantId = $tenantIdFromToken + Account = [PSCustomObject]@{ + UserName = $userNameFromToken + HomeAccountId = "$($jwtTokenPayload.oid).$($tenantIdFromToken)" + Environment = $accountEnvironment + } + Scopes = if ($jwtTokenPayload.scp) { $jwtTokenPayload.scp -split " " } else { @(".default") } + TokenType = "Bearer" + } + + $global:Organization = [PSCustomObject]@{ + Id = $tenantIdFromToken + displayName = "Tenant ($($tenantIdFromToken)) (Token Auth)" + } + + $appInfoAuthority = "https://login.microsoftonline.com/$($tenantIdFromToken)" + + $global:EMViewObject.AppInfo = [PSCustomObject]@{ + ClientId = "00000000-0000-0000-0000-000000000000" # Placeholder for token auth + ClientSecret = $null + TenantId = $tenantIdFromToken + Authority = $appInfoAuthority + RedirectUri = "urn:ietf:wg:oauth:2.0:oob" + Name = "Token Authentication Mode" + Type = "Custom" + Scopes = $global:MSALToken.Scopes + } + Set-MSALCurrentApp $global:EMViewObject.AppInfo + + Write-Log "Invoke-EMAuthenticateToMSAL: Successfully configured session with RawAccessToken for user $($global:MSALToken.Account.UserName). AppInfo Authority set to $($appInfoAuthority)" + + Show-AuthenticationInfo + Set-EnvironmentInfo $global:Organization.displayName + Invoke-ModuleFunction "Invoke-GraphAuthenticationUpdated" + + return $true # Token processing successful, exit function + } + catch { + Write-LogError "Invoke-EMAuthenticateToMSAL: Error processing RawAccessToken. $($_.Exception.Message)" + $global:MSALToken = $null + $global:EMViewObject.AppInfo = Get-GraphAppInfo "EMAzureApp" $global:DefaultAzureApp "EM" + if ($global:EMViewObject.AppInfo) { Set-MSALCurrentApp $global:EMViewObject.AppInfo } + Invoke-ModuleFunction "Invoke-GraphAuthenticationUpdated" + return $false # Token processing failed, exit function + } + } # End of RawAccessToken processing + else { + # Original logic if $global:RawAccessToken is NOT set + Write-LogDebug "Invoke-EMAuthenticateToMSAL: No RawAccessToken provided, proceeding with standard MSAL authentication." + $global:EMViewObject.AppInfo = Get-GraphAppInfo "EMAzureApp" $global:DefaultAzureApp "EM" + Set-MSALCurrentApp $global:EMViewObject.AppInfo + + # This is the standard MSAL login call + # It should only be reached if no AccessToken was provided to Start-IntuneManagement.ps1 + return (& $global:msalAuthenticator.Login -Account (?? $global:MSALToken.Account.UserName (Get-Setting "" "LastLoggedOnUser")) @params) + } } function Invoke-EMDeactivateView diff --git a/Extensions/MSALAuthentication.psm1 b/Extensions/MSALAuthentication.psm1 index 5eb049b..0767789 100644 --- a/Extensions/MSALAuthentication.psm1 +++ b/Extensions/MSALAuthentication.psm1 @@ -832,6 +832,16 @@ function Connect-MSALUser $Tenant ) + # If we already have a valid token from RawAccessToken, no need to authenticate again + if ($global:MSALToken -and + $global:MSALToken.ExpiresOn -gt (Get-Date) -and + $global:RawAccessToken -and + $global:MSALToken.AccessToken -eq $global:RawAccessToken) + { + Write-LogDebug "Using existing token from RawAccessToken that is still valid" + return $true + } + if($global:hideUI -eq $true) { if($global:AzureAppId -and $global:ClientSecret -and $global:TenantId) diff --git a/Extensions/MSGraph.psm1 b/Extensions/MSGraph.psm1 index 0b07e62..b921915 100644 --- a/Extensions/MSGraph.psm1 +++ b/Extensions/MSGraph.psm1 @@ -43,10 +43,6 @@ function Invoke-InitializeModule Name = "Replace (Preview)" Value = "replace" }, - [PSCustomObject]@{ - Name = "Replace with assignments (Preview)" - Value = "replace_with_assignments" - }, [PSCustomObject]@{ Name = "Update (Preview)" Value = "update" @@ -231,72 +227,1369 @@ function Invoke-InitializeModule Description = "Expand assignments when listing objects. This can be used in custom columns based on assignment info" }) "GraphGeneral" - Add-SettingsObject (New-Object PSObject -Property @{ - Title = "Use Graph 1.0 (Not Recommended)" - Key = "UseGraphV1" - Type = "Boolean" - DefaultValue = $false - Description = "This will use production verionof graph, v1.0. Note: Thot officially supported since this can have unpredicted results. Some parts will require Beta version of Graph." - }) "GraphGeneral" + Add-SettingsObject (New-Object PSObject -Property @{ + Title = "Use Graph 1.0 (Not Recommended)" + Key = "UseGraphV1" + Type = "Boolean" + DefaultValue = $false + Description = "This will use production verionof graph, v1.0. Note: Thot officially supported since this can have unpredicted results. Some parts will require Beta version of Graph." + }) "GraphGeneral" + +} + +function Get-GraphAppInfo +{ + param($settingId, $defaultAppId, $prefix) + + if($global:hideUI -eq $true) + { + # Taken care of by authentication function + return + } + + $graphAppId = Get-SettingValue $settingId + + if($graphAppId) + { + # Check if an app in the list is selected + $appObj = $global:MSGraphGlobalApps | Where ClientId -eq $graphAppId + } + + if(-not $appObj) + { + # Set app info from custom settings + $appObj = New-Object PSObject -Property @{ + ClientId = Get-SettingValue "$($PreFix)CustomAppId" + TenantId = Get-SettingValue "$($PreFix)CustomTenantId" + RedirectUri = Get-SettingValue "$($PreFix)CustomAppRedirect" + Authority = Get-SettingValue "$($PreFix)CustomAuthority" + } + } + + if(-not $appObj.ClientId -and $defaultAppId) + { + # No app info found. Use default + $appObj = $global:MSGraphGlobalApps | Where ClientId -eq $defaultAppId + } + + $appObj +} + +function Invoke-GraphAuthenticationUpdated +{ + Write-Log "Clear cached values" + $global:MigrationTableCache = $null + $global:MigrationTableCacheId = $null + $global:LoadedDependencyObjects = $null + $global:migFileObj = $null + $global:AADObjectCache = $null +} + +function Invoke-SettingsUpdated +{ + Initialize-GraphSettings +} + +function Initialize-GraphSettings +{ + $script:defaultVersion = "" +} + +function Invoke-GraphRequest +{ + param ( + [Parameter(Mandatory)] + $Url, + + [Alias("Body")] + $Content, + + $Headers, + + [ValidateSet("GET","POST","OPTIONS","DELETE", "PATCH","PUT")] + [Alias("Method")] + $HttpMethod = "GET", + + $AdditionalHeaders, + + [string]$Outfile = "", + + [Switch]$SkipAuthentication, + + $ODataMetadata = "full", # full, minimal, none or skip + + [ValidateSet("beta","v1.0")] + $GraphVersion = "", + + [switch] + $AllPages, + + [int] + $PageSize = -1, + + [switch] + $Batch, + + [switch] + $NoError + ) + + if($SkipAuthentication -ne $true) + { + # Only call Connect-MSALUser if we don't have a valid token + if($null -eq $global:MSALToken -or + (Get-Date) -ge $global:MSALToken.ExpiresOn) + { + Connect-MSALUser + } + } + + if(-not $GraphVersion) + { + if(-not $script:defaultVersion) + { + if((Get-SettingValue "UseGraphV1") -eq $true) + { + $script:defaultVersion = "v1.0" + } + else + { + $script:defaultVersion = "beta" + } + } + $GraphVersion = $script:defaultVersion + } + + $params = @{} + + $requestId = [Guid]::NewGuid().guid + + if(-not $Headers) + { + $Headers = @{ + 'Content-Type' = 'application/json; charset=utf-8' + 'Authorization' = "Bearer " + $global:MSALToken.AccessToken + 'ExpiresOn' = $global:MSALToken.ExpiresOn + 'x-ms-client-request-id' = $requestId + } + } + + if($HttpMethod -eq "GET" -and $ODataMetadata -ne "Skip") + { + # Note: odata.metadata=full in Accept + # @odata.type is not always included with default (minimum). + # That is required to identify the object type in some functions + # It does include a lot of info we don't need... + $Headers.Add("Accept","application/json;odata.metadata=$ODataMetadata") + } + #elseif($Content) + #{ + # # Upload content as UTF8 to support international and extended characters + # $Content = [System.Text.Encoding]::UTF8.GetBytes($Content) + #} + + if($AdditionalHeaders -is [HashTable]) + { + foreach($key in $AdditionalHeaders.Keys) + { + if($Headers.ContainsKey($key)) { continue } + + $Headers.Add($key, $AdditionalHeaders[$key]) + } + } + + if($Content) { $params.Add("Body", [System.Text.Encoding]::UTF8.GetBytes($Content)) } + if($Headers) { $params.Add("Headers", $Headers) } + if($Outfile) + { + $dirName = [IO.Path]::GetDirectoryName($Outfile) + try { + [IO.Directory]::CreateDirectory($dirName) | Out-Null + } + catch { + + } + if([IO.Directory]::Exists($dirName)) + { + $params.Add("OutFile", $OutFile) + } + else { + Write-Log "Failed to create directory for OutFile $Outfile" 3 + } + } + + if(($Url -notmatch "^http://|^https://")) + { + $Url = "https://$((?? $global:MSALGraphEnvironment "graph.microsoft.com"))/$GraphVersion/" + $Url.TrimStart('/') + $Url = $Url -replace "%OrganizationId%", $global:Organization.Id + } + + if($PageSize -gt 0 -and $url.IndexOf("`$top=") -eq -1) + { + if(($url.IndexOf('?')) -eq -1) + { + $url = "$($url.Trim())?" + } + else + { + $url = "$($url.Trim())&" + } + $url = "$($url.Trim())`$top=$($PageSize)" + } + + $proxyURI = Get-ProxyURI + if($proxyURI) + { + $params.Add("proxy", $proxyURI) + $params.Add("UseBasicParsing", $true) + } + + $ret = $null + + $retryCount = 0 + $retryMax = 10 + do + { + $retryRequest = $false + try + { + Write-LogDebug "Invoke graph API: $Url (Request ID: $requestId)" + $allValues = @() + do + { + $ret = Invoke-RestMethod -Uri $Url -Method $HttpMethod @params + if($? -eq $false) + { + throw $global:error[0] + } + + if($HttpMethod -eq "PATCH" -and [String]::IsNullOrempty($ret)) + { + $ret = $true; + break; + } + elseif($AllPages -eq $true -and $HttpMethod -eq "GET" -and $ret.value -is [Array]) + { + $allValues += $ret.value + if($ret.'@odata.nextLink') + { + $Url = $ret.'@odata.nextLink' + } + } + else + { + break + } + } while($ret.'@odata.nextLink') + + if($allValues.Count -gt 0 -and $ret.value -is [Array]) + { + $ret.value = $allValues + } + } + catch + { + $retryCount++ + if($NoError -eq $true) { return } + if($_.Exception.Response.StatusCode -eq 429 -and $retryCount -le $retryMax) + { + # NOT OK - Should use the date property but could not replicate the issue + $retryCount++ + $retryRequest = $true + Write-Log "429 - Too many requests received. Wait 5 s before retry" 2 + Start-Sleep -Seconds 5 + } + else + { + $extMessage = $null + try + { + $reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream()) + $reader.BaseStream.Position = 0 + $reader.DiscardBufferedData() + $response = $reader.ReadToEnd() | ConvertFrom-Json + if($response.Error.Message) + { + $extMessage = $response.Error.Message + try + { + if($response.Error.Message.StartsWith("{") -and $response.Error.Message.EndsWith("}")) + { + $message = $response.Error.Message | ConvertFrom-Json + if($message.Message) + { + $extMessage = ". Response message: $($message.Message)" + } + } + } + catch {} + + $extMessage = ". Response message: $($extMessage)" + } + } + catch{} + + Write-LogError "Failed to invoke MS Graph with URL $Url (Request ID: $requestId). Status code: $($_.Exception.Response.StatusCode)$extMessage" $_.Exception + } + } + } while($retryRequest -eq $true) + + Write-Debug "$(($ret | Select *))" + + $ret +} + +function Get-GraphObjects +{ + param( + [String] + $Url, + [Array] + $property = $null, + [Array] + $exclude, + $SortProperty = "displayName", + $objectType, + [string] + $select, + [switch] + $SinglePage, + [switch] + $AllPages, + [switch] + $SingleObject, + [string] + $filter) + + $params = @{} + if($objectType.ODataMetadata) + { + $params.Add('ODataMetadata',$objectType.ODataMetadata) + } + + if(-not $url) + { + $url = $objectType.API + } + + if($SingleObject -ne $true -and $objectType.QUERYLIST) + { + if(($url.IndexOf('?')) -eq -1) + { + $url = "$($url.Trim())?$($objectType.QUERYLIST.Trim())" + } + else + { + $url = "$($url.Trim())&$($objectType.QUERYLIST.Trim())" # Risky...does not check that the parameter is already in use + } + } + + if(($url.IndexOf("`$select=")) -eq -1 -and $select) + { + $url += (?: (($url.IndexOf('?')) -eq -1) "?" "&") + $url += "`$select=$select" + } + + if($SinglePage -eq $true) + { + #Use default page size or use below for a specific page size for testing + #$params.Add("pageSize",5) #!!! + } + elseif($SingleObject -ne $true -and $SinglePage -ne $true) + { + $params.Add('AllPages',$true) + } + + if($script:nextGraphPage -and ($SinglePage -eq $true -or $AllPages -eq $true)) + { + $url = $script:nextGraphPage + } + + if($SingleObject -ne $true -and (Get-SettingValue "ExpandAssignments") -eq $true -and $objectType.ExpandAssignmentsList -ne $false) + { + # Expand assignments so they can be used in custom columns + if(($url.IndexOf('expand',[System.StringComparison]::InvariantCultureIgnoreCase)) -eq -1) + { + $url += (?: (($url.IndexOf('?')) -eq -1) "?" "&") + $url = "$($url)`$expand=assignments" + } + } + + if($script:multipleGraphPages -eq $true -and $SingleObject -ne $true -and $filter -and $objectType.QuerySearch -eq $true) + { + # QuerySearch is only reqired when there are more pages to load + if(($url.IndexOf('search',[System.StringComparison]::InvariantCultureIgnoreCase)) -eq -1) + { + $url += (?: (($url.IndexOf('?')) -eq -1) "?" "&") + $url = "$($url)`$search=`"$($filter)`"" + } + } + + $graphObjects = Invoke-GraphRequest -Url $url @params + if($SinglePage -eq $true -or $AllPages -eq $true) + { + $script:nextGraphPage = $graphObjects.'@odata.nextLink' + if($null -eq $script:multipleGraphPages) + { + $script:multipleGraphPages = $null -ne $script:nextGraphPage + } + } + + if($graphObjects -and ($graphObjects | GM -Name Value -MemberType NoteProperty)) + { + $retObjects = $graphObjects.Value + } + else + { + $retObjects = $graphObjects + } + + if($retObjects) + { + $graphObjects = Add-GraphObjectProperties $retObjects $objectType $property $exclude $SortProperty + + if($SingleObject -ne $true -and $objectType.PostListCommand) + { + $graphObjects = & $objectType.PostListCommand $graphObjects $objectType + } + } + else + { + $graphObjects = $null + } + + if(($graphObjects | measure).Count -gt 0) + { + $graphObjects + } +} + +function Add-GraphObjectProperties +{ + param($graphObjects, + $objectType, + [Array] + $property = $null, + [Array] + $exclude = $null, + $SortProperty = "displayName") + + if($property -isnot [Object[]]) { $property = @('displayName', 'description', 'id')} + + $objects = @() + + if($graphObjects -and ($graphObjects | GM -Name Value -MemberType NoteProperty)) + { + $retObjects = $graphObjects.Value + } + else + { + $retObjects = $graphObjects + } + + $getAssignmentInfo = ((Get-SettingValue "ExpandAssignments") -eq $true -and $objectType.ExpandAssignmentsList -ne $false) + + foreach($graphObject in $retObjects) + { + $params = @{} + if($property) { $params.Add("Property", $property) } + if($exclude) { $params.Add("ExcludeProperty", $exclude) } + foreach($objTmp in ($graphObject | Select-Object @params)) + { + $objTmp | Add-Member -NotePropertyName "IsSelected" -NotePropertyValue $false + $objTmp | Add-Member -NotePropertyName "Object" -NotePropertyValue $graphObject + $objTmp | Add-Member -NotePropertyName "ObjectType" -NotePropertyValue $objectType + $objects += $objTmp + } + + if($null -ne $graphObject.isAssigned) + { + $objTmp | Add-Member -NotePropertyName "IsAssigned" -NotePropertyValue $graphObject.isAssigned + } + elseif($getAssignmentInfo) + { + $objTmp | Add-Member -NotePropertyName "IsAssigned" -NotePropertyValue (($graphObject.assignments | measure).Count -gt 0) + } + } + + if($objects.Count -gt 0 -and $SortProperty -and ($objects[0] | GM -MemberType NoteProperty -Name $SortProperty)) + { + $objects = $objects | sort -Property $SortProperty + } + + $objects +} + +function Show-GraphObjects +{ + param($filter, [switch]$ObjectTypeChanged) + + $global:curObjectType = $global:lstMenuItems.SelectedItem + + if($ObjectTypeChanged -eq $true) + { + $script:multipleGraphPages = $null + } + + Clear-GraphObjects + + if(-not $global:MSALToken) + { + $global:txtNotLoggedIn.Content = "Not logged in. Please login to view objects" + $global:grdNotLoggedIn.Visibility = "Visible" + $global:grdData.Visibility = "Collapsed" + return + } + elseif($global:curObjectType.'@AccessType' -eq "None") + { + $requiredPermissions = ($global:curObjectType.Permissons -join ",") + $missingScopes = ?? $global:curObjectType.'@MissingScopes' $requiredPermissions + if($requiredPermissions -ne $missingScopes) + { + $requiredPermissions = "`nRequired permissions: $requiredPermissions" + } + else + { + $requiredPermissions = "" + } + $global:txtNotLoggedIn.Content = "You don't have the required permissons to access $($global:curObjectType.Title).$($requiredPermissions)`n`Missing perimssons: $missingScopes`n`nRequest consent from the 'Request Consent' link in the user login info`nor`nDisable the 'Use Default Permissions' setting to trigger consent prompt.`nNote: Changing the 'Use Default Permissions' setting will require a restart of the app`nand a 'manual' login" + $global:grdNotLoggedIn.Visibility = "Visible" + $global:grdData.Visibility = "Collapsed" + return + } + $global:grdNotLoggedIn.Visibility = "Collapsed" + $global:grdData.Visibility = "Visible" + + # Always show Import if an item is selected + $global:btnImport.IsEnabled = $global:lstMenuItems.SelectedItem -ne $null + + if(-not $global:lstMenuItems.SelectedItem) { return } + + Write-Status "Loading $($global:curObjectType.Title) objects" + + if($global:lstMenuItems.SelectedItem.ShowForm -ne $false) + { + $viewItem = $global:lstMenuItems.SelectedItem + if($viewItem.Icon -or [IO.File]::Exists(($global:AppRootFolder + "\Xaml\Icons\$($viewItem.Id).xaml"))) + { + $global:ccIcon.Content = Get-XamlObject ($global:AppRootFolder + "\Xaml\Icons\$((?? $viewItem.Icon $viewItem.Id)).xaml") + } + + $global:txtFormTitle.Text = $global:lstMenuItems.SelectedItem.Title + $global:grdTitle.Visibility = "Visible" + } + + $script:nextGraphPage = $null + + [array]$graphObjects = Get-GraphObjects -property $global:curObjectType.ViewProperties -objectType $global:curObjectType -SinglePage -Filter $filter + + $dgObjects.AutoGenerateColumns = $false + $dgObjects.Columns.Clear() + + if($graphObjects) + { + $tmpObj = $graphObjects | Select -First 1 + + $prop = $tmpObj.PSObject.Properties | Where Name -eq "IsSelected" + if($prop) + { + $column = Get-GridCheckboxColumn "IsSelected" + $dgObjects.Columns.Add($column) + + $column.Header.add_Click({ + foreach($item in $global:dgObjects.ItemsSource) + { + $item.IsSelected = $this.IsChecked + } + $global:dgObjects.Items.Refresh() + Invoke-ModuleFunction "Invoke-EMSelectedItemsChanged" + }) + } + + $tableColumns = @() + + $additionalColumns = @() + $additionalColsStr = ?? (Get-Setting "EndpointManager\ObjectColumns" "$($global:curObjectType.Id)") $global:curObjectType.DefaultColumns + if($additionalColsStr) + { + $additionalColumns += $additionalColsStr.Split(',') + } + + if($additionalColumns.Count -eq 0 -or $additionalColumns[0] -ne "0") + { + # Add default columns + foreach($prop in ($tmpObj.PSObject.Properties | Where {$_.Name -notin @("IsSelected","Object","ObjectType")})) + { + $binding = [System.Windows.Data.Binding]::new($prop.Name) + $column = [System.Windows.Controls.DataGridTextColumn]::new() + $column.Header = $prop.Name + $column.IsReadOnly = $true + $column.Binding = $binding + + $tableColumns += $prop.Name + + $dgObjects.Columns.Add($column) + } + } + + # Add custom columns + foreach($additionalCol in $additionalColumns) + { + if($additionalCol -eq "0" -or $additionalCol -eq "1") { continue } + + $bindingProp,$colHeader = $additionalCol.Split('=') + + if(-not $colHeader) + { + $colHeader = $bindingProp + } + + $binding = [System.Windows.Data.Binding]::new("Object.$($bindingProp)") + $column = [System.Windows.Controls.DataGridTextColumn]::new() + $column.Header = $colHeader + $column.IsReadOnly = $true + $column.Binding = $binding + + $tableColumns += $colHeader + $dgObjects.Columns.Add($column) + } + + $ocList = [System.Collections.ObjectModel.ObservableCollection[object]]::new($graphObjects) + $dgObjects.ItemsSource = [System.Windows.Data.CollectionViewSource]::GetDefaultView($ocList) + } + else + { + $dgObjects.ItemsSource = $null + } + + + # Show/Hide buttons based on object type + foreach($ctrl in $spSubMenu.Children) + { + if($ctrl.Name -eq "btnDelete") + { + $allowDelete = Get-SettingValue "EMAllowDelete" + if($global:currentViewObject.ViewInfo.AllowDelete -eq $false) { $allowDelete = $false } + $ctrl.Visibility = (?: ($allowDelete -eq $true) "Visible" "Collapsed") + } + elseif(-not $global:curObjectType.ShowButtons -or ($global:curObjectType.ShowButtons | Where-Object { $ctrl.Name -like "*$($_)" } )) + { + Write-LogDebug "Show $($ctrl.Name)" + $ctrl.Visibility = "Visible" + } + else + { + Write-LogDebug "Hide $($ctrl.Name)" + $ctrl.Visibility = "Collapsed" + } + } + + Set-GraphPagesButtonStatus +} + +function Set-GraphPagesButtonStatus +{ + $global:btnLoadAllPages.Visibility = (?: ($script:nextGraphPage) "Visible" "Collapsed") + $global:btnLoadNextPage.Visibility = (?: ($script:nextGraphPage) "Visible" "Collapsed") + $global:btnLoadAllPages.Tag = $script:nextGraphPage + $global:btnLoadNextPage.Tag = $script:nextGraphPage +} + +function Clear-GraphObjects +{ + $global:txtFormTitle.Text = "" + $global:txtEMObjects.Text = "" + $global:grdTitle.Visibility = "Collapsed" + $global:grdObject.Children.Clear() + $global:dgObjects.ItemsSource = $null + Set-ObjectGrid + + [System.Windows.Forms.Application]::DoEvents() +} + +function Get-GraphObject +{ + param($obj, $objectType, [switch]$SkipAssignments, [switch]$GetAPI) + + Write-Status "Loading $((Get-GraphObjectName $obj $objectType))" + + if($objectType.PreGetCommand) + { + $preConfig = & $objectType.PreGetCommand $obj $objectType + } + + if($preConfig -isnot [Hashtable]) { $preConfig = @{} } + + if($preConfig.ContainsKey("API") -and $preConfig["API"]) + { + $api = $preConfig["API"] + } + elseif(-not $objectType.APIGET) + { + $api = ("$($objectType.API)/$($obj.Id)") + } + else + { + $api = $graphObject.APIGET -replace "%id%", (Get-GraphObjectId $obj $objectType) + } + + $expand = @() + if($obj.'assignments@odata.navigationLink' -and $SkipAssignments -ne $true -and $objectType.ExpandAssignments -ne $false) + { + $expand += "assignments" + } + + if($obj.'apps@odata.navigationLink') + { + $expand += "apps" + } + + if($obj.'settings@odata.navigationLink') + { + $expand += "settings" + } + + if($obj.'roleAssignments@odata.navigationLink') + { + $expand += "roleAssignments" + } + + if($obj.'privacyAccessControls@odata.associationLink') + { + $expand += "microsoft.graph.windows10GeneralConfiguration/privacyAccessControls" + } + + if($objectType.Expand) + { + foreach($objExpand in $objectType.Expand.Split(",")) + { + if($objExpand -notin $expand) { $expand += $objExpand} + } + } + + if($expand.Count -gt 0) + { + if($api.IndexOf('?') -eq -1) + { + $api = ($api + "?`$expand=") + } + elseif($api.IndexOf("`$expand") -gt 1) + { + $api = ($api + ",") # A bit risky...assumes that expand is last in the existing query + } + else + { + $api = ($api + "&`$expand=") + } + + $api = ($api + ($expand -join ",")) + } + + if($global:Organization.Id) + { + $api = $api -replace "%OrganizationId%", $global:Organization.Id + } + + if($GetAPI -eq $true) + { + return $api + } + + $objInfo = Get-GraphObjects -Url $api -property $objectType.ViewProperties -objectType $objectType -SingleObject + + if($objInfo -and $objectType.PostGetCommand) + { + & $objectType.PostGetCommand $objInfo $objectType + } + $objInfo +} + +# Generic Pre-Import function for all imports +function Start-GraphPreImport +{ + param($obj, $objectType) + + if($objectType.SkipRemovingProperties -eq $true) { return } + + $removeProperties = $objectType.PropertiesToRemove + + if($removeProperties -isnot [Object[]]) + { + $removeProperties = @() + } + + if($removeProperties.Count -eq 0 -or $objectType.SkipRemoveDefaultProperties -ne $true) + { + # Default properties to delete + $removeProperties += @('lastModifiedDateTime','createdDateTime','supportsScopeTags','id','modifiedDateTime') + } + + # Remove OData properties + foreach($odataProp in ($obj.PSObject.Properties | Where { $_.Name -like "*@Odata*Link" -or $_.Name -like "*@odata.context" -or $_.Name -like "*@odata.id" -or ($_.Name -like "*@odata.type" -and $_.Name -ne "@odata.type")})) # -or $_.Name -like "#CustomRef*" + { + $removeProperties += $odataProp.Name + } + + foreach($prop in $removeProperties) + { + # Allow override deleting default propeties e.g. some object types requires the Id property + if($objectType.SkipRemoveProperties -is [Object[]] -and $prop -in $objectType.SkipRemoveProperties) { continue } + Remove-Property $obj $prop + } + + if($objectType.SkipRemovingChildProperties -ne $true) + { + foreach($prop in ($obj.PSObject.Properties)) + { + if($obj."$($prop.Name)"."@odata.type") + { + foreach($childObj in ($obj."$($prop.Name)")) + { + Start-GraphPreImport $childObj $objectType + } + } + } + } +} + +function Get-GraphMetaData +{ + if(-not $global:metaDataXML) + { + # Graph metadata does not support Content-Length in response so size can not be used to check if it is updated + # There also no other version information in response headers. Use file date to update every week + Write-Log "Load Graph MetaData file" + $url = "https://graph.microsoft.com/beta/`$metadata" + $fileFullPath = [Environment]::ExpandEnvironmentVariables("%LOCALAPPDATA%\CloudAPIPowerShellManagement\GraphMetaData.xml") + $fi = [IO.FileInfo]$fileFullPath + $maxAge = (Get-Date).AddDays(-14) + if($fi.Exists -and ($fi.LastWriteTime -gt $maxAge -or $fi.CreationTime -gt $maxAge)) + { + try + { + [xml]$global:metaDataXML = Get-Content $fi.FullName + } + catch { } + } + + if(-not $global:metaDataXML) + { + Write-Log "Download Graph MetaData file" + [void][System.Reflection.Assembly]::LoadWithPartialName("System.Web.Extensions") + $wc = New-Object System.Net.WebClient + $wc.Encoding = [System.Text.Encoding]::UTF8 + $proxyURI = Get-ProxyURI + + try + { + if($proxyURI) + { + $wc.Proxy = [System.Net.WebProxy]::new($proxyURI) + } + + [xml]$global:metaDataXML = $wc.DownloadString($url) + # Download to string and then use Save to format the XML output + $global:metaDataXML.Save($fi.FullName) + } + catch + { + Write-LogError "Failed to download Graph MetaData file" $_.Exception + } + finally + { + $wc.Dispose() + } + } + + if(-not $global:metaDataXML -and $fi.Exists) + { + Write-Log "Using old version of Graph MetaData file" 2 + try + { + [xml]$global:metaDataXML = Get-Content $fi.FullName + } + catch { } + } + } +} + +function Get-GraphObjectClassName +{ + param($type) + + Get-GraphMetaData + + $objectClassName = $null + + $nodes = $global:metaDataXML.SelectNodes("//*[@Type='Collection(graph.$($type))']") + if($nodes -ne $null -and $nodes.Count -gt 0) + { + foreach($node in $nodes) + { + if($node.ParentNode.Name -eq "deviceAppManagement") + { + $objectClassName = $node.Name + break + } + } + } + + $objectClassName +} + +#region Export/Import dialogs + +function Show-GraphExportForm +{ + $script:exportForm = Get-XamlObject ($global:AppRootFolder + "\Xaml\ExportForm.xaml") -AddVariables + if(-not $script:exportForm) { return } + + Set-XamlProperty $script:exportForm "txtExportPath" "Text" (?? (Get-Setting "" "LastUsedRoot") (Get-SettingValue "RootFolder")) + Set-XamlProperty $script:exportForm "chkAddObjectType" "IsChecked" (Get-SettingValue "AddObjectType") + Set-XamlProperty $script:exportForm "chkAddCompanyName" "IsChecked" (Get-SettingValue "AddCompanyName") + Set-XamlProperty $script:exportForm "chkExportAssignments" "IsChecked" (Get-SettingValue "ExportAssignments") + + Set-XamlProperty $script:exportForm "btnExportSelected" "IsEnabled" ($global:dgObjects.SelectedItem -ne $null) + if(($global:dgObjects.ItemsSource | Where IsSelected -eq $true).Count -gt 0) + { + Set-XamlProperty $script:exportForm "lblSelectedObject" "Content" "$(($global:dgObjects.ItemsSource | Where IsSelected -eq $true).Count) selected object(s)" + } + elseif($global:dgObjects.SelectedItem) + { + Set-XamlProperty $script:exportForm "lblSelectedObject" "Content" "Selected object: $((Get-GraphObjectName $global:dgObjects.SelectedItem $global:curObjectType))" + } + Add-XamlEvent $script:exportForm "btnCancel" "add_click" { + $script:exportForm = $null + Show-ModalObject + } + + Add-XamlEvent $script:exportForm "btnExportAll" "add_click" { + + Export-GraphObjects + + $script:exportForm = $null + Show-ModalObject + } + + Add-XamlEvent $script:exportForm "btnExportSelected" "add_click" { + Export-GraphObjects -Selected + + $script:exportForm = $null + Show-ModalObject + } + + Add-XamlEvent $script:exportForm "browseExportPath" "add_click" { + $folder = Get-Folder (Get-XamlProperty $script:exportForm "txtExportPath" "Text") "Select root folder for export" + if($folder) + { + Set-XamlProperty $script:exportForm "txtExportPath" "Text" $folder + } + } + + Add-GraphExportExtensions $script:exportForm 1 $global:curObjectType + + Show-ModalForm "Export $($global:curObjectType.Title) objects" $script:exportForm -HideButtons +} + +function Invoke-InitSilentBatchJob +{ + $global:MSALToken = $null + + if(-not $global:TenantId) + { + Write-Log "Tenant Id is missing. Use -TenantId on the command line to run silent batch jobs" 3 + return + } + + if(-not $global:AzureAppId -or (-not $global:ClientSecret -and -not $global:ClientCert)) + { + # Get login info for silent job from settings + $global:AzureAppId = Get-SettingValue "GraphAzureAppId" -TenantID $global:TenantId + $global:ClientSecret = Get-SettingValue "GraphAzureAppSecret" -TenantID $global:TenantId + $global:ClientCert = Get-SettingValue "GraphAzureAppCert" -TenantID $global:TenantId + } + + if(-not $global:AzureAppId) + { + Write-Log "App Id is missing. Cannot run silent job without App Id. Either specify the AppId in Settings or Command Line (-AppId )" 3 + return + } + + if(-not $global:ClientSecret -and -not $global:ClientCert) + { + Write-Log "Secret or Certificate must be specified. Either specify Secret/Certificate in Settings or Command Line" 3 + return + } + Connect-MSALUser | Out-Null + if(-not $global:MSALToken) + { + Write-Log "Not authenticated. Batch job will be skipped" 3 + } + else + { + $accessToken = Get-JWTtoken $global:MSALToken.AccessToken + if($accessToken) + { + $global:Organization = (MSGraph\Invoke-GraphRequest -Url "Organization" -SkipAuthentication -ODataMetadata "Skip" -NoError).Value + if($global:Organization) + { + if($global:Organization -is [array]) { $global:Organization = $global:Organization[0]} + } + else + { + Write-Log "Could not get Organization info. Verify that the app has permission to read Organization info (at least Organization.Read.All). Organization name wil not be set" 2 + $global:Organization = [PSCustomObject]@{ + Id = $accessToken.Payload.tid + displayName = "" + } + } + if($global:Organization.displayName) + { + $tenantInfo = "$($global:Organization.displayName) ($($global:Organization.id))" + } + else + { + $tenantInfo = $accessToken.Payload.tid + } + + Write-Log "Successfully authenticated to tenant: $tenantInfo" + Write-Log "Azure App (for authentication): $($accessToken.Payload.app_displayname) ($($accessToken.Payload.appid))" + Write-Log "Permissions: $(($accessToken.Payload.roles -join ","))" + } + } +} + +function Invoke-SilentBatchJob +{ + param($settingsObj) + + if(-not $global:MSALToken) { return } # Skip if not authenticated + + if(-not $settingsObj -or (-not $settingsObj.BulkExport -and -not $settingsObj.BulkImport)) + { + return + } + + if($settingsObj.BulkExport) + { + Start-GraphSilentBulkExport $settingsObj + } + + if($settingsObj.BulkImport) + { + Start-GraphSilentBulkImport $settingsObj + } +} + +function Start-GraphSilentBulkExport +{ + param($settingsObj) + + $script:exportForm = Get-XamlObject ($global:AppRootFolder + "\Xaml\BulkExportForm.xaml") -AddVariables + if(-not $script:exportForm) { return } + + $script:exportObjects = Get-GraphBatchObjectTypes $settingsObj.BulkExport + + foreach($viewObj in $script:exportObjects) + { + if(-not $viewObj.Title) { continue } + + if($viewObj.ObjectType.ShowButtons -is [Object[]] -and $viewObj.ObjectType.ShowButtons -notcontains "Export") { continue } + + Add-GraphExportExtensions $script:exportForm 0 $viewObj.ObjectType + } + + Set-BatchProperties $settingsObj.BulkExport $script:exportForm + + $global:dgObjectsToExport.ItemsSource = @($script:exportObjects) + + <# + # Select ObjectTypes based on batch config + $objTypes = $settingsObj.BulkExport | Where Name -eq ObjectTypes + if($objTypes) + { + foreach($objTypeId in $objTypes.ObjectTypes) + { + $obj = $global:dgObjectsToExport.ItemsSource | Where { $_.ObjectType.Id -eq $objTypeId} + if($obj) + { + $obj.Selected = $true + } + else + { + Write-Log "No Object Type with id $objTypeId found" 2 + } + } + } + #> + + Start-GraphObjectExport +} + +function Start-GraphSilentBulkImport +{ + param($settingsObj) + + $script:importForm = Get-XamlObject ($global:AppRootFolder + "\Xaml\BulkImportForm.xaml") -AddVariables + if(-not $script:importForm) { return } + + # Get all objects but not selected + # This will allow dependencies + $script:importObjects = Get-GraphBatchObjectTypes $settingsObj.BulkImport -NotSelected -All + + $objTypes = $settingsObj.BulkImport | Where Name -eq ObjectTypes + if($objTypes) + { + # Select object types from the batch file + foreach($objTypeId in $objTypes.ObjectTypes) + { + $obj = $script:importObjects | Where { $_.ObjectType.Id -eq $objTypeId} + if($obj) + { + $obj.Selected = $true + } + else + { + Write-Log "No Object Type with id $objTypeId found" 2 + } + } + } + + foreach($viewObj in $script:importObjects) + { + if(-not $viewObj.Title) { continue } + + if($viewObj.ObjectType.ShowButtons -is [Object[]] -and $viewObj.ObjectType.ShowButtons -notcontains "Import") { continue } + + Add-GraphImportExtensions $script:importForm 0 $viewObj.ObjectType + } + + Set-BatchProperties $settingsObj.BulkImport $script:importForm + + $global:dgObjectsToImport.ItemsSource = @($script:importObjects) + $importedObjects = Start-GraphObjectImport + + if($importedObjects -eq 0) + { + Write-Log "No objects were imported. Verify import batch file settings" 2 + } } -function Get-GraphAppInfo +function Get-GraphBatchObjectTypes { - param($settingId, $defaultAppId, $prefix) + param($settingsObj, [switch]$NotSelected, [switch]$All) - if($global:hideUI -eq $true) + $silentViewObjects = @() + + $intuneView = $global:viewObjects | Where { $_.ViewInfo.Id -eq "IntuneGraphAPI" } + if($All -ne $true) { - # Taken care of by authentication function - return + $arrObjectTypes = ($settingsObj | Where Name -eq ObjectTypes).ObjectTypes } - - $graphAppId = Get-SettingValue $settingId - - if($graphAppId) + else { - # Check if an app in the list is selected - $appObj = $global:MSGraphGlobalApps | Where ClientId -eq $graphAppId + $arrObjectTypes = $intuneView.ViewItems.Id } - if(-not $appObj) + foreach($objTypeId in $arrObjectTypes) { - # Set app info from custom settings - $appObj = New-Object PSObject -Property @{ - ClientId = Get-SettingValue "$($PreFix)CustomAppId" - TenantId = Get-SettingValue "$($PreFix)CustomTenantId" - RedirectUri = Get-SettingValue "$($PreFix)CustomAppRedirect" - Authority = Get-SettingValue "$($PreFix)CustomAuthority" + $objType = $intuneView.ViewItems | Where Id -eq $objTypeId + if(-not $objType) + { + Write-Log "ViewObject with id $objTypeId not found" 2 + continue } - } - if(-not $appObj.ClientId -and $defaultAppId) + $silentViewObjects += New-Object PSObject -Property @{ + Title = $objType.Title + Selected = ($NotSelected.IsPresent -ne $true) + ObjectType = $objType + } + } + + $silentViewObjects +} + +function Get-GraphObjectType +{ + param($objTypeId) + + $intuneView = $global:viewObjects | Where { $_.ViewInfo.Id -eq "IntuneGraphAPI" } + + if($intuneView) { - # No app info found. Use default - $appObj = $global:MSGraphGlobalApps | Where ClientId -eq $defaultAppId + ($intuneView.ViewItems | Where Id -eq $objTypeId) } - - $appObj } -function Invoke-GraphAuthenticationUpdated +function Start-GraphObjectExport { - Write-Log "Clear cached values" - $global:MigrationTableCache = $null - $global:MigrationTableCacheId = $null - $global:LoadedDependencyObjects = $null - $global:migFileObj = $null + Write-Status "Export objects" -Block + Write-Log "****************************************************************" + Write-Log "Start bulk export" + Write-Log "****************************************************************" + + $script:exportRoot = Expand-FileName (Get-XamlProperty $script:exportForm "txtExportPath" "Text") + Write-Log "Export root folder: $script:exportRoot" + $global:AADObjectCache = $null -} -function Invoke-SettingsUpdated -{ - Initialize-GraphSettings + foreach($item in $script:exportObjects) + { + if($item.Selected -ne $true) { continue } + + Write-Log "----------------------------------------------------------------" + Write-Log "Export $($item.ObjectType.Title) objects" + Write-Log "----------------------------------------------------------------" + + $txtNameFilter = $global:txtExportNameFilter.Text.Trim() + Save-Setting "" "ExportNameFilter" $txtNameFilter + + if($txtNameFilter) { Write-Log "Name filter: $txtNameFilter" } + try + { + $folder = Get-GraphObjectFolder $item.ObjectType $script:exportRoot (Get-XamlProperty $script:exportForm "chkAddObjectType" "IsChecked") (Get-XamlProperty $script:exportForm "chkAddCompanyName" "IsChecked") + + $folder = Expand-FileName $folder + + Write-Status "Get a list of all $($item.ObjectType.Title) objects" -SkipLog -Force + [array]$objects = Get-GraphObjects -property $item.ObjectType.ViewProperties -objectType $item.ObjectType + + if((Get-SettingValue "UseBatchAPI") -eq $true) + { + # Use batch to get details of each object + $batchObjects = Get-GraphBatchObjects $objects $txtNameFilter + $i = 1 + $total = ($batchObjects | measure).Count + foreach($batchResult in $batchObjects) + { + if(-not $batchResult.Object) { continue } + $objName = Get-GraphObjectName $batchResult.Object $batchResult.ObjectType + Write-Status "Export $($item.Title): $objName ($($i)/$($total))" -Force + Export-GraphObject $batchResult.Object $batchResult.ObjectType $folder -IsFullObject + $i++ + } + } + else + { + foreach($obj in $objects) + { + # Export objects one by one + $objName = Get-GraphObjectName $obj.Object $obj.ObjectType + + if($txtNameFilter -and $objName -notmatch [RegEx]::Escape($txtNameFilter)) + { + continue + } + + Write-Status "Export $($item.Title): $objName" -Force + Export-GraphObject $obj.Object $item.ObjectType $folder + } + } + Save-Setting "" "LastUsedFullPath" $folder + } + catch + { + Write-LogError "Failed when exporting $($item.Title) objects" $_.Exception + } + } + Save-Setting "" "LastUsedRoot" (Get-XamlProperty $script:exportForm "txtExportPath" "Text") + + Write-Log "****************************************************************" + Write-Log "Bulk export finished" + Write-Log "****************************************************************" + Write-Status "" } -function Initialize-GraphSettings +function Show-GraphBulkExportForm { - $script:defaultVersion = "" + $script:exportForm = Get-XamlObject ($global:AppRootFolder + "\Xaml\BulkExportForm.xaml") -AddVariables + if(-not $script:exportForm) { return } + + Set-XamlProperty $script:exportForm "txtExportPath" "Text" (?? (Get-Setting "" "LastUsedRoot") (Get-SettingValue "RootFolder")) + Set-XamlProperty $script:exportForm "chkAddCompanyName" "IsChecked" (Get-SettingValue "AddCompanyName") + Set-XamlProperty $script:exportForm "chkExportAssignments" "IsChecked" (Get-SettingValue "ExportAssignments") + #Set-XamlProperty $script:exportForm "txtExportNameFilter" "Text" (Get-Setting "" "ExportNameFilter") + + Add-XamlEvent $script:exportForm "browseExportPath" "add_click" ({ + $folder = Get-Folder (Get-XamlProperty $script:exportForm "txtExportPath" "Text") "Select root folder for export" + if($folder) + { + Set-XamlProperty $script:exportForm "txtExportPath" "Text" $folder + } + }) + + $script:exportObjects = @() + foreach($objType in $global:lstMenuItems.ItemsSource) + { + if(-not $objType.Title) { continue } + + if($objType.ShowButtons -is [Object[]] -and $objType.ShowButtons -notcontains "Export") { continue } + + $script:exportObjects += New-Object PSObject -Property @{ + Title = $objType.Title + Selected = (?? $objType.BulkExport $true) + ObjectType = $objType + } + + Add-GraphExportExtensions $script:exportForm 0 $objType + } + + $column = Get-GridCheckboxColumn "Selected" + $global:dgObjectsToExport.Columns.Add($column) + + $column.Header.IsChecked = $true # All items are checked by default + $column.Header.add_Click({ + foreach($item in $global:dgObjectsToExport.ItemsSource) + { + $item.Selected = $this.IsChecked + } + $global:dgObjectsToExport.Items.Refresh() + } + ) + + # Add Object type column + $binding = [System.Windows.Data.Binding]::new("Title") + $column = [System.Windows.Controls.DataGridTextColumn]::new() + $column.Header = "Object type" + $column.IsReadOnly = $true + $column.Binding = $binding + $global:dgObjectsToExport.Columns.Add($column) + + $global:dgObjectsToExport.ItemsSource = $script:exportObjects + + Add-XamlEvent $script:exportForm "btnClose" "add_click" ({ + $script:exportForm = $null + Show-ModalObject + }) + + Add-XamlEvent $script:exportForm "btnExport" "add_click" ({ + + Start-GraphObjectExport + }) + + Add-XamlEvent $script:exportForm "btnExportSettingsForSilentExport" "add_click" ({ + $sf = [System.Windows.Forms.SaveFileDialog]::new() + $sf.FileName = "BulkExport.json" + $sf.DefaultExt = "*.json" + $sf.Filter = "Json (*.json)|*.json|All files (*.*)|*.*" + if($sf.ShowDialog() -eq "OK") + { + $tmp = [PSCustomObject]@{ + Name = "ObjectTypes" + Type = "Custom" + ObjectTypes = @() + } + foreach($ot in ($script:exportObjects | Where Selected -eq $true)) + { + $tmp.ObjectTypes += $ot.ObjectType.Id + } + Export-GraphBatchSettings $sf.FileName $script:exportForm "BulkExport" @($tmp) + } + }) + + Show-ModalForm "Bulk Export" $script:exportForm -HideButtons } function Invoke-GraphRequest @@ -340,7 +1633,12 @@ function Invoke-GraphRequest if($SkipAuthentication -ne $true) { - Connect-MSALUser + # Only call Connect-MSALUser if we don't have a valid token + if($null -eq $global:MSALToken -or + (Get-Date) -ge $global:MSALToken.ExpiresOn) + { + Connect-MSALUser + } } if(-not $GraphVersion) @@ -2357,10 +3655,7 @@ function Reset-GraphObject # Clone the object before removing properties $obj = $fileObj.Object | ConvertTo-Json -Depth 50 | ConvertFrom-Json Start-GraphPreImport $obj $objectType - if ($global:cbImportType.SelectedValue -ne "replace_with_assignments"){ - # will use the assignments from the file for "replace_with_assignments" type - Remove-Property $obj "Assignments" - } + Remove-Property $obj "Assignments" Remove-Property $obj "isAssigned" if($global:cbImportType.SelectedValue -eq "update") @@ -2424,7 +3719,7 @@ function Reset-GraphObject } return $true } - elseif($global:cbImportType.SelectedValue -in @("replace","replace_with_assignments")) + elseif($global:cbImportType.SelectedValue -eq "replace") { $replace = $true $import = $true @@ -2473,13 +3768,8 @@ function Reset-GraphObject } } } - if ($global:cbImportType.SelectedValue -eq "replace") - { - Import-GraphObjectAssignment $newObj $objectType $curObject.Object.Assignments $fileObj.FileInfo.FullName -CopyAssignments | Out-Null - } - else { - Import-GraphObjectAssignment $newObj $objectType $obj.Assignments $file.FileInfo.FullName | Out-Null - } + + Import-GraphObjectAssignment $newObj $objectType $curObject.Object.Assignments $fileObj.FileInfo.FullName -CopyAssignments | Out-Null if($delete) { diff --git a/Start-IntuneManagement.ps1 b/Start-IntuneManagement.ps1 index 0e514db..3d92335 100644 --- a/Start-IntuneManagement.ps1 +++ b/Start-IntuneManagement.ps1 @@ -17,7 +17,9 @@ param( [string] $Secret, [string] - $Certificate + $Certificate, + [string] + $AccessToken ) Import-Module ($PSScriptRoot + "\CloudAPIPowerShellManagement.psd1") -Force $param = $PSBoundParameters diff --git a/Xaml/BulkImportForm.xaml b/Xaml/BulkImportForm.xaml index daab27f..307fe08 100644 --- a/Xaml/BulkImportForm.xaml +++ b/Xaml/BulkImportForm.xaml @@ -85,7 +85,6 @@ Always import: Always try to import the object. No detction of existing object (Default) Skip if object exists: Skip import if there is an existing object with the same name and type Replace: If an object is detected, it will be deleted. The assignments will be copied to the new imported object - Replace with assignments: If an object is detected, it will be deleted. The assignments will be copied from the imported object Update: If an object is detected, settings will be replaced from the import file diff --git a/Xaml/DocumentationWordOptions.xaml b/Xaml/DocumentationWordOptions.xaml index 414c891..ad39203 100644 --- a/Xaml/DocumentationWordOptions.xaml +++ b/Xaml/DocumentationWordOptions.xaml @@ -248,7 +248,7 @@ @@ -258,4 +258,4 @@ - + \ No newline at end of file diff --git a/Xaml/ImportForm.xaml b/Xaml/ImportForm.xaml index ab2a279..9ac70d7 100644 --- a/Xaml/ImportForm.xaml +++ b/Xaml/ImportForm.xaml @@ -76,7 +76,6 @@ Always import: Always try to import the object. No detction of existing object (Default) Skip if object exists: Skip import if there is an existing object with the same name and type Replace: If an object is detected, it will be deleted. The assignments will be copied to the new imported object - Replace with assignments: If an object is detected, it will be deleted. The assignments will be copied from the imported object Update: If an object is detected, settings will be replaced from the import file diff --git a/examples/Intune/ConnectToIntune.ps1 b/examples/Intune/ConnectToIntune.ps1 new file mode 100644 index 0000000..acdc6d8 --- /dev/null +++ b/examples/Intune/ConnectToIntune.ps1 @@ -0,0 +1,236 @@ +# Enhanced Intune Connection Script +# Uses your browser token with official Microsoft Graph PowerShell modules +# For maximum compatibility and scalability + +# Function to test if the token is valid and can access Intune +function Test-IntuneToken { + param ( + [Parameter(Mandatory = $true)] + [string]$Token + ) + + # Create headers with the token + $headers = @{ + 'Authorization' = "Bearer $Token" + 'Content-Type' = 'application/json' + } + + # Test a basic Intune API call + try { + $uri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$top=1" + $response = Invoke-RestMethod -Uri $uri -Headers $headers -Method Get -ErrorAction Stop + + # Token works! + return $headers + } + catch { + Write-Host "Token is not valid for Intune access: $($_.Exception.Message)" -ForegroundColor Red + return $null + } +} + +# Function to setup Microsoft Graph PowerShell with the token +function Connect-GraphWithToken { + param ( + [Parameter(Mandatory = $true)] + [string]$Token + ) + + # Check if required modules are installed + $requiredModules = @( + "Microsoft.Graph.Authentication", + "Microsoft.Graph.DeviceManagement", + "Microsoft.Graph.Users" + ) + + foreach ($module in $requiredModules) { + if (-not (Get-Module -ListAvailable -Name $module)) { + Write-Host "Installing $module module..." -ForegroundColor Yellow + Install-Module -Name $module -Scope CurrentUser -Force + } + + # Import the module + Write-Host "Importing $module module..." -ForegroundColor Cyan + Import-Module $module -Force + } + + try { + # Convert the string token to a SecureString + $secureToken = ConvertTo-SecureString -String $Token -AsPlainText -Force + + # Disconnect if already connected + Disconnect-MgGraph -ErrorAction SilentlyContinue + + # Connect using the secure token + Write-Host "Connecting to Microsoft Graph PowerShell with token..." -ForegroundColor Cyan + Connect-MgGraph -AccessToken $secureToken -NoWelcome + + # Verify the connection was successful + $context = Get-MgContext + if ($context) { + Write-Host "✓ Successfully connected to Microsoft Graph PowerShell!" -ForegroundColor Green + Write-Host " Account: $($context.Account)" -ForegroundColor Green + + # Test connection by getting a device + try { + $device = Get-MgDeviceManagementManagedDevice -Top 1 -ErrorAction Stop + if ($device) { + Write-Host "✓ Successfully verified access to Intune managed devices!" -ForegroundColor Green + return $true + } + } + catch { + Write-Host "× Could not access Intune devices with Microsoft Graph cmdlets: $($_.Exception.Message)" -ForegroundColor Yellow + return $false + } + } + else { + Write-Host "× Could not establish Microsoft Graph PowerShell session" -ForegroundColor Red + return $false + } + } + catch { + Write-Host "× Error connecting to Microsoft Graph PowerShell: $($_.Exception.Message)" -ForegroundColor Red + Write-Host " If this error persists, try updating the Microsoft.Graph modules:" -ForegroundColor Yellow + Write-Host " Update-Module Microsoft.Graph.Authentication -Force" -ForegroundColor White + return $false + } +} + +# Function to get available Microsoft Graph Intune cmdlets +function Get-IntuneCommands { + $intuneCommands = Get-Command -Module Microsoft.Graph.DeviceManagement | + Where-Object { $_.Name -like "*-MgDeviceManagement*" } | + Select-Object -First 10 | + ForEach-Object { $_.Name } + return $intuneCommands +} + +# Main script +try { + Clear-Host + Write-Host "=== ENHANCED INTUNE CONNECTION SCRIPT ===" -ForegroundColor Cyan + Write-Host "This script provides access to Intune via Microsoft Graph PowerShell modules" -ForegroundColor Cyan + Write-Host "" + + # Instructions for obtaining a token + Write-Host "To get your authentication token:" -ForegroundColor Yellow + Write-Host "1. Open a browser and go to: https://endpoint.microsoft.com/" -ForegroundColor White + Write-Host "2. Sign in with your admin account (if not already signed in)" -ForegroundColor White + Write-Host "3. Press F12 to open developer tools" -ForegroundColor White + Write-Host "4. Go to 'Network' tab" -ForegroundColor White + Write-Host "5. Refresh the page (F5)" -ForegroundColor White + Write-Host "6. Filter requests by typing 'graph.microsoft' in the filter box" -ForegroundColor White + Write-Host "7. Click on any request to graph.microsoft.com" -ForegroundColor White + Write-Host "8. In the Headers tab, scroll to find 'Authorization: Bearer eyJ...'" -ForegroundColor White + Write-Host "9. Copy the entire token (starts with 'eyJ' and is very long)" -ForegroundColor White + Write-Host "" + + # Prompt for the token + Write-Host "Paste your token below and press Enter:" -ForegroundColor Green + $token = Read-Host + + # Save the token globally for reuse + $global:IntuneToken = $token + + # Validate the token format + if (-not $token.StartsWith("eyJ")) { + Write-Host "The token doesn't appear to be valid. It should start with 'eyJ'." -ForegroundColor Red + exit + } + + # Test the token with REST API approach first + Write-Host "`nValidating token with a basic API call..." -ForegroundColor Yellow + $headers = Test-IntuneToken -Token $token + + if ($headers) { + # Save the headers globally for REST API calls (as a fallback) + $global:IntuneHeaders = $headers + + # Display success for validation + Write-Host "✓ Success! The token is valid for Intune access." -ForegroundColor Green + + # Now connect to Microsoft Graph PowerShell with the token + $graphSuccess = Connect-GraphWithToken -Token $token + + if ($graphSuccess) { + # Get available commands + $commands = Get-IntuneCommands + + Write-Host "`nYou now have access to Microsoft Graph PowerShell cmdlets!" -ForegroundColor Green + Write-Host "Here are some examples of available Intune cmdlets:" -ForegroundColor Green + + foreach ($command in $commands) { + Write-Host " • $command" -ForegroundColor White + } + + # Show usage examples + Write-Host "`n====== USAGE EXAMPLES ======" -ForegroundColor Cyan + + # Microsoft Graph PowerShell examples + Write-Host "METHOD 1: Microsoft Graph PowerShell Cmdlets (Recommended)" -ForegroundColor Green + Write-Host "Use the official Microsoft Graph PowerShell cmdlets for maximum functionality:" -ForegroundColor White + Write-Host "" + Write-Host "# Get all devices" -ForegroundColor Yellow + Write-Host 'Get-MgDeviceManagementManagedDevice | Select-Object DeviceName, OperatingSystem, OSVersion' -ForegroundColor White + Write-Host "" + Write-Host "# Get specific devices by filter" -ForegroundColor Yellow + Write-Host '$filter = "startsWith(deviceName,''WIN'')"' -ForegroundColor White + Write-Host 'Get-MgDeviceManagementManagedDevice -Filter $filter' -ForegroundColor White + Write-Host "" + Write-Host "# Get device configurations" -ForegroundColor Yellow + Write-Host 'Get-MgDeviceManagementDeviceConfiguration | Select-Object Id, DisplayName' -ForegroundColor White + Write-Host "" + Write-Host "# Execute actions on devices" -ForegroundColor Yellow + Write-Host '$deviceId = "DEVICE-ID-HERE"' -ForegroundColor White + Write-Host 'Invoke-MgRebootManagedDevice -ManagedDeviceId $deviceId' -ForegroundColor White + Write-Host "" + + # REST API examples (as fallback) + Write-Host "METHOD 2: REST API (Fallback approach)" -ForegroundColor Green + Write-Host "If needed, you can still use REST API calls directly:" -ForegroundColor White + Write-Host "" + Write-Host "# Get all devices" -ForegroundColor Yellow + Write-Host '$uri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices"' -ForegroundColor White + Write-Host '$response = Invoke-RestMethod -Uri $uri -Headers $global:IntuneHeaders -Method Get' -ForegroundColor White + Write-Host '$devices = $response.value' -ForegroundColor White + Write-Host "" + + # Function to reconnect + Write-Host "# Reconnect function (use if token expires)" -ForegroundColor Yellow + Write-Host 'function Reconnect-IntuneGraph {' -ForegroundColor White + Write-Host ' # Update REST API headers with fresh token' -ForegroundColor White + Write-Host ' $global:IntuneHeaders = @{' -ForegroundColor White + Write-Host ' "Authorization" = "Bearer $global:IntuneToken"' -ForegroundColor White + Write-Host ' "Content-Type" = "application/json"' -ForegroundColor White + Write-Host ' }' -ForegroundColor White + Write-Host ' ' -ForegroundColor White + Write-Host ' # Update Graph PowerShell connection' -ForegroundColor White + Write-Host ' $secureToken = ConvertTo-SecureString -String $global:IntuneToken -AsPlainText -Force' -ForegroundColor White + Write-Host ' Disconnect-MgGraph -ErrorAction SilentlyContinue' -ForegroundColor White + Write-Host ' Connect-MgGraph -AccessToken $secureToken -NoWelcome' -ForegroundColor White + Write-Host ' Write-Host "Reconnected to Intune!" -ForegroundColor Green' -ForegroundColor White + Write-Host '}' -ForegroundColor White + Write-Host "" + + Write-Host "NOTE: The token will expire after about 1 hour." -ForegroundColor Yellow + Write-Host " Run this script again when needed to get a fresh token, or use the Reconnect-IntuneGraph function." -ForegroundColor Yellow + } + else { + Write-Host "`nFalling back to REST API approach." -ForegroundColor Yellow + Write-Host "Your token is valid, but there was an issue setting up Microsoft Graph PowerShell." -ForegroundColor Yellow + Write-Host "You can still use the REST API approach with `$global:IntuneHeaders:" -ForegroundColor Yellow + Write-Host "" + Write-Host "# Example: Get all devices" -ForegroundColor Cyan + Write-Host '$uri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices"' -ForegroundColor White + Write-Host '$response = Invoke-RestMethod -Uri $uri -Headers $global:IntuneHeaders -Method Get' -ForegroundColor White + Write-Host '$devices = $response.value' -ForegroundColor White + } + } + else { + Write-Host "`nThe token doesn't have necessary permissions to access Intune." -ForegroundColor Red + } +} +catch { + Write-Host "Error: $_" -ForegroundColor Red +} \ No newline at end of file diff --git a/examples/Intune/IntuneRemediation/Examples/ServiceMonitor/Deploy-ServiceMonitor-Enhanced.ps1 b/examples/Intune/IntuneRemediation/Examples/ServiceMonitor/Deploy-ServiceMonitor-Enhanced.ps1 new file mode 100644 index 0000000..fdb58b4 --- /dev/null +++ b/examples/Intune/IntuneRemediation/Examples/ServiceMonitor/Deploy-ServiceMonitor-Enhanced.ps1 @@ -0,0 +1,102 @@ +<# +.SYNOPSIS + Tests and deploys the Windows Time service monitor remediation scripts to Intune. + +.DESCRIPTION + This script demonstrates how to use the IntuneRemediation module to: + 1. Test the detection and remediation scripts locally + 2. Connect to Intune using integrated browser-token authentication + 3. Upload the scripts to Intune as a remediation script package + +.NOTES + File Name: Deploy-ServiceMonitor-Enhanced.ps1 + Author: Intune Administrator + Created: 2023-11-09 + Version: 1.0 +#> + +# Ensure we are in the script's directory +$scriptPath = $MyInvocation.MyCommand.Path +$scriptDir = Split-Path $scriptPath -Parent +Set-Location -Path $scriptDir + +# Import the IntuneRemediation module +# Assuming the module is already installed or available in a parent directory +try { + $modulePath = (Get-Item -Path $scriptDir).Parent.Parent.FullName + Import-Module -Name "$modulePath\IntuneRemediation.psd1" -Force -ErrorAction Stop + Write-Host "IntuneRemediation module imported successfully." -ForegroundColor Green +} +catch { + Write-Error "Failed to import IntuneRemediation module: $_" + Write-Host "Please ensure the module is installed or adjust the path accordingly." -ForegroundColor Yellow + exit 1 +} + +# Step 1: Test the remediation scripts locally +Write-Host "`n=== TESTING REMEDIATION SCRIPTS LOCALLY ===" -ForegroundColor Cyan +$detectionScriptPath = "$scriptDir\Detect-CriticalService.ps1" +$remediationScriptPath = "$scriptDir\Remediate-CriticalService.ps1" + +$testResults = Test-IntuneRemediationScript -DetectionScriptPath $detectionScriptPath -RemediationScriptPath $remediationScriptPath -Cycles 1 -ShowScriptOutput + +if ($testResults.FinalStatus -eq "Compliant") { + Write-Host "The remediation test completed successfully and the service is in a compliant state." -ForegroundColor Green +} +else { + Write-Host "The remediation test completed, but the service is still in a non-compliant state." -ForegroundColor Red + Write-Host "Review the test output above for more information." -ForegroundColor Yellow + + $proceed = Read-Host "Do you want to proceed with deploying to Intune anyway? (Y/N)" + if ($proceed -ne "Y") { + Write-Host "Deployment cancelled." -ForegroundColor Yellow + exit 0 + } +} + +# Step 2: Connect to Intune using the integrated authentication approach +Write-Host "`n=== CONNECTING TO INTUNE ===" -ForegroundColor Cyan +Write-Host "Using browser token authentication to connect to Intune..." -ForegroundColor Yellow + +# You can optionally pre-supply a token if you have one +# $myToken = "eyJ0eXAiOi..." +# $connected = Initialize-IntuneConnection -Token $myToken + +# Or just call without a token to be prompted +$connected = Initialize-IntuneConnection + +if (-not $connected) { + Write-Error "Failed to connect to Intune. Please check your token and try again." + exit 1 +} + +# Step 3: Upload the scripts to Intune +Write-Host "`n=== UPLOADING REMEDIATION SCRIPTS TO INTUNE ===" -ForegroundColor Cyan + +# Read the script contents +$detectionScriptContent = Get-Content -Path $detectionScriptPath -Raw +$remediationScriptContent = Get-Content -Path $remediationScriptPath -Raw + +# Create the remediation script package in Intune +$remediationScriptParams = @{ + DisplayName = "Windows Time Service Monitor" + Description = "Monitors the Windows Time service and starts it if it's not running." + Publisher = "IT Department" + DetectionScriptContent = $detectionScriptContent + RemediationScriptContent = $remediationScriptContent + RunAsAccount = "System" # Run as system account + RunAs32Bit = $false # Run as 64-bit + EnforceSignatureCheck = $false +} + +$result = New-IntuneRemediationScript @remediationScriptParams + +if ($result) { + Write-Host "`nSuccessfully created remediation script package in Intune!" -ForegroundColor Green + Write-Host "You can now assign this script to device groups through the Intune portal." -ForegroundColor Green + Write-Host "Script ID: $($result.id)" -ForegroundColor Green +} +else { + Write-Host "`nFailed to create remediation script package in Intune." -ForegroundColor Red + Write-Host "Please check the error messages above for more information." -ForegroundColor Yellow +} \ No newline at end of file diff --git a/examples/Intune/IntuneRemediation/Examples/ServiceMonitor/Deploy-ServiceMonitor.ps1 b/examples/Intune/IntuneRemediation/Examples/ServiceMonitor/Deploy-ServiceMonitor.ps1 new file mode 100644 index 0000000..1db9f65 --- /dev/null +++ b/examples/Intune/IntuneRemediation/Examples/ServiceMonitor/Deploy-ServiceMonitor.ps1 @@ -0,0 +1,112 @@ +<# +.SYNOPSIS + Tests and deploys the Windows Time service monitor remediation scripts to Intune. + +.DESCRIPTION + This script demonstrates how to use the IntuneRemediation module to: + 1. Test the detection and remediation scripts locally + 2. Connect to Intune using a browser-acquired token + 3. Upload the scripts to Intune as a remediation script package + +.NOTES + File Name: Deploy-ServiceMonitor.ps1 + Author: Intune Administrator + Created: 2023-11-09 + Version: 1.0 +#> + +# Ensure we are in the script's directory +$scriptPath = $MyInvocation.MyCommand.Path +$scriptDir = Split-Path $scriptPath -Parent +Set-Location -Path $scriptDir + +# Import the IntuneRemediation module +# Assuming the module is already installed or available in a parent directory +try { + $modulePath = (Get-Item -Path $scriptDir).Parent.Parent.FullName + Import-Module -Name "$modulePath\IntuneRemediation.psd1" -Force -ErrorAction Stop + Write-Host "IntuneRemediation module imported successfully." -ForegroundColor Green +} +catch { + Write-Error "Failed to import IntuneRemediation module: $_" + Write-Host "Please ensure the module is installed or adjust the path accordingly." -ForegroundColor Yellow + exit 1 +} + +# Step 1: Test the remediation scripts locally +Write-Host "`n=== TESTING REMEDIATION SCRIPTS LOCALLY ===" -ForegroundColor Cyan +$detectionScriptPath = "$scriptDir\Detect-CriticalService.ps1" +$remediationScriptPath = "$scriptDir\Remediate-CriticalService.ps1" + +$testResults = Test-IntuneRemediationScript -DetectionScriptPath $detectionScriptPath -RemediationScriptPath $remediationScriptPath -Cycles 1 -ShowScriptOutput + +if ($testResults.FinalStatus -eq "Compliant") { + Write-Host "The remediation test completed successfully and the service is in a compliant state." -ForegroundColor Green +} +else { + Write-Host "The remediation test completed, but the service is still in a non-compliant state." -ForegroundColor Red + Write-Host "Review the test output above for more information." -ForegroundColor Yellow + + $proceed = Read-Host "Do you want to proceed with deploying to Intune anyway? (Y/N)" + if ($proceed -ne "Y") { + Write-Host "Deployment cancelled." -ForegroundColor Yellow + exit 0 + } +} + +# Step 2: Prompt for browser-acquired token +Write-Host "`n=== CONNECTING TO INTUNE ===" -ForegroundColor Cyan +Write-Host "To connect to Intune, you need to obtain an authentication token." -ForegroundColor Yellow +Write-Host "1. Open your browser and navigate to https://endpoint.microsoft.com/" -ForegroundColor Yellow +Write-Host "2. Sign in with your admin account (if not already signed in)" -ForegroundColor Yellow +Write-Host "3. Press F12 to open developer tools" -ForegroundColor Yellow +Write-Host "4. Go to the 'Network' tab" -ForegroundColor Yellow +Write-Host "5. Refresh the page (F5)" -ForegroundColor Yellow +Write-Host "6. Filter requests by typing 'graph.microsoft' in the filter box" -ForegroundColor Yellow +Write-Host "7. Click on any request to graph.microsoft.com" -ForegroundColor Yellow +Write-Host "8. In the Headers tab, find 'Authorization: Bearer eyJ...'" -ForegroundColor Yellow +Write-Host "9. Copy the entire token (starts with 'eyJ' and is very long)" -ForegroundColor Yellow + +$token = Read-Host "Paste your authentication token here" + +if ([string]::IsNullOrEmpty($token) -or -not $token.StartsWith("eyJ")) { + Write-Error "Invalid token provided. Token should start with 'eyJ'." + exit 1 +} + +# Connect to Intune with the token +$connected = Connect-IntuneWithToken -Token $token +if (-not $connected) { + Write-Error "Failed to connect to Intune. Please check your token and try again." + exit 1 +} + +# Step 3: Upload the scripts to Intune +Write-Host "`n=== UPLOADING REMEDIATION SCRIPTS TO INTUNE ===" -ForegroundColor Cyan + +# Read the script contents +$detectionScriptContent = Get-Content -Path $detectionScriptPath -Raw +$remediationScriptContent = Get-Content -Path $remediationScriptPath -Raw + +# Create the remediation script package in Intune +$remediationScriptParams = @{ + DisplayName = "Windows Time Service Monitor" + Description = "Monitors the Windows Time service and starts it if it's not running." + Publisher = "IT Department" + DetectionScriptContent = $detectionScriptContent + RemediationScriptContent = $remediationScriptContent + RunAsAccount = "System" # Run as system account + RunAs32Bit = $false # Run as 64-bit + EnforceSignatureCheck = $false +} + +$result = New-IntuneRemediationScript @remediationScriptParams + +if ($result) { + Write-Host "`nSuccessfully created remediation script package in Intune!" -ForegroundColor Green + Write-Host "You can now assign this script to device groups through the Intune portal." -ForegroundColor Green +} +else { + Write-Host "`nFailed to create remediation script package in Intune." -ForegroundColor Red + Write-Host "Please check the error messages above for more information." -ForegroundColor Yellow +} \ No newline at end of file diff --git a/examples/Intune/IntuneRemediation/Examples/ServiceMonitor/Detect-CriticalService.ps1 b/examples/Intune/IntuneRemediation/Examples/ServiceMonitor/Detect-CriticalService.ps1 new file mode 100644 index 0000000..75d11dc --- /dev/null +++ b/examples/Intune/IntuneRemediation/Examples/ServiceMonitor/Detect-CriticalService.ps1 @@ -0,0 +1,39 @@ +<# +.SYNOPSIS + Detects if the Windows Time service is running. + +.DESCRIPTION + This script checks if the Windows Time (w32time) service is running. + Returns compliance state to Intune based on the service status. + +.NOTES + File Name: Detect-CriticalService.ps1 + Author: Intune Administrator + Created: 2023-11-09 + Version: 1.0 +#> + +# Define the service to check +$serviceName = "w32time" + +try { + # Get the service + $service = Get-Service -Name $serviceName -ErrorAction Stop + + # Check if the service is running + if ($service.Status -eq "Running") { + Write-Host "The $serviceName service is running correctly." + # Return compliant (exit code 0) + exit 0 + } + else { + Write-Host "The $serviceName service is NOT running. Current status: $($service.Status)" + # Return non-compliant (exit code 1) + exit 1 + } +} +catch { + Write-Error "Error checking service status: $_" + # Return non-compliant on error + exit 1 +} \ No newline at end of file diff --git a/examples/Intune/IntuneRemediation/Examples/ServiceMonitor/Remediate-CriticalService.ps1 b/examples/Intune/IntuneRemediation/Examples/ServiceMonitor/Remediate-CriticalService.ps1 new file mode 100644 index 0000000..54216c1 --- /dev/null +++ b/examples/Intune/IntuneRemediation/Examples/ServiceMonitor/Remediate-CriticalService.ps1 @@ -0,0 +1,65 @@ +<# +.SYNOPSIS + Remediates the Windows Time service by starting it if not running. + +.DESCRIPTION + This script starts the Windows Time (w32time) service if it's not running. + Attempts to also set the service to automatic startup if it isn't already. + +.NOTES + File Name: Remediate-CriticalService.ps1 + Author: Intune Administrator + Created: 2023-11-09 + Version: 1.0 +#> + +# Define the service to check +$serviceName = "w32time" + +try { + # Get the service + $service = Get-Service -Name $serviceName -ErrorAction Stop + + # Log the current status + Write-Host "Current status of $serviceName service: $($service.Status)" + + # Check if service is not running + if ($service.Status -ne "Running") { + Write-Host "Attempting to start the $serviceName service..." + + # Try to start the service + Start-Service -Name $serviceName -ErrorAction Stop + + # Verify the service started successfully + $service = Get-Service -Name $serviceName -ErrorAction Stop + if ($service.Status -eq "Running") { + Write-Host "The $serviceName service was started successfully." + + # Ensure the service is set to start automatically + $startupType = (Get-WmiObject -Class Win32_Service -Filter "Name='$serviceName'").StartMode + if ($startupType -ne "Auto") { + Write-Host "Setting $serviceName service to start automatically..." + Set-Service -Name $serviceName -StartupType Automatic + Write-Host "Service startup type set to Automatic." + } + + # Return success (exit code 0) + exit 0 + } + else { + Write-Error "Failed to start the $serviceName service. Current status: $($service.Status)" + # Return failure (exit code 1) + exit 1 + } + } + else { + Write-Host "The $serviceName service is already running." + # Return success (exit code 0) + exit 0 + } +} +catch { + Write-Error "Error remediating service: $_" + # Return failure (exit code 1) + exit 1 +} \ No newline at end of file diff --git a/examples/Intune/IntuneRemediation/IntuneRemediation.psd1 b/examples/Intune/IntuneRemediation/IntuneRemediation.psd1 new file mode 100644 index 0000000..4ec05f5 --- /dev/null +++ b/examples/Intune/IntuneRemediation/IntuneRemediation.psd1 @@ -0,0 +1,65 @@ +@{ + # Script module or binary module file associated with this manifest. + RootModule = 'IntuneRemediation.psm1' + + # Version number of this module. + ModuleVersion = '0.1.0' + + # ID used to uniquely identify this module + GUID = '8e4f5a36-6ea0-4fa4-9f8c-5f65429d2419' + + # Author of this module + Author = 'Intune Administrator' + + # Company or vendor of this module + CompanyName = 'Your Company' + + # Copyright statement for this module + Copyright = '(c) 2023 Your Company. All rights reserved.' + + # Description of the functionality provided by this module + Description = 'PowerShell module for creating and managing Microsoft Intune remediation scripts' + + # Minimum version of the PowerShell engine required by this module + PowerShellVersion = '5.1' + + # Modules that must be imported into the global environment prior to importing this module + # We're using dynamic module loading, so we don't require these upfront + # RequiredModules = @( + # @{ + # ModuleName = 'Microsoft.Graph.Authentication' + # ModuleVersion = '1.0.0' + # Guid = '883916f2-d041-46f9-b428-6a7916a6e26c' + # }, + # @{ + # ModuleName = 'Microsoft.Graph.DeviceManagement' + # ModuleVersion = '1.0.0' + # Guid = '60f7c2fb-646a-47bd-9cb3-90af0c32284f' + # } + # ) + + # Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. + FunctionsToExport = @( + 'New-IntuneRemediationScript', + 'Connect-IntuneWithToken', + 'Test-IntuneRemediationScript', + 'Initialize-IntuneConnection' + ) + + # Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. + PrivateData = @{ + PSData = @{ + # Tags applied to this module. These help with module discovery in online galleries. + Tags = @('Intune', 'Remediation', 'MDM') + + # A URL to the license for this module. + LicenseUri = 'https://github.com/YourRepo/IntuneRemediation/blob/main/LICENSE' + + # A URL to the main website for this project. + ProjectUri = 'https://github.com/YourRepo/IntuneRemediation' + + # ReleaseNotes of this module + ReleaseNotes = 'Initial release of IntuneRemediation module' + } + } +} \ No newline at end of file diff --git a/examples/Intune/IntuneRemediation/IntuneRemediation.psm1 b/examples/Intune/IntuneRemediation/IntuneRemediation.psm1 new file mode 100644 index 0000000..9eae30e --- /dev/null +++ b/examples/Intune/IntuneRemediation/IntuneRemediation.psm1 @@ -0,0 +1,16 @@ +# Import all functions from Private and Public folders +$PublicFunctions = @(Get-ChildItem -Path $PSScriptRoot\Public\*.ps1 -ErrorAction SilentlyContinue) +$PrivateFunctions = @(Get-ChildItem -Path $PSScriptRoot\Private\*.ps1 -ErrorAction SilentlyContinue) + +# Dot source the functions +foreach ($Function in @($PrivateFunctions + $PublicFunctions)) { + try { + . $Function.FullName + } + catch { + Write-Error -Message "Failed to import function $($Function.FullName): $_" + } +} + +# Export only the public functions +Export-ModuleMember -Function $PublicFunctions.BaseName diff --git a/examples/Intune/IntuneRemediation/Private/Test-AuthToken.ps1 b/examples/Intune/IntuneRemediation/Private/Test-AuthToken.ps1 new file mode 100644 index 0000000..d982589 --- /dev/null +++ b/examples/Intune/IntuneRemediation/Private/Test-AuthToken.ps1 @@ -0,0 +1,68 @@ +function Test-AuthToken { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [SecureString]$SecureToken + ) + + try { + # Convert SecureString to plain text for validation + $BSTR = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureToken) + $TokenPlainText = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($BSTR) + + # Basic validation - token should be a JWT + if (-not $TokenPlainText.StartsWith('eyJ')) { + Write-Error "The provided token does not appear to be a valid JWT token." + return $false + } + + # Split the token into its components + $TokenParts = $TokenPlainText.Split('.') + if ($TokenParts.Count -ne 3) { + Write-Error "The provided token is not a valid JWT token (should have 3 parts)." + return $false + } + + # Decode the payload + $Payload = $TokenParts[1].Replace('-', '+').Replace('_', '/') + while ($Payload.Length % 4) { $Payload += "=" } + $DecodedPayload = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Payload)) + $PayloadJson = $DecodedPayload | ConvertFrom-Json + + # Check for token expiration + $Now = [DateTime]::UtcNow + $ExpiryTime = [DateTime]::new(1970, 1, 1, 0, 0, 0, [DateTimeKind]::Utc).AddSeconds($PayloadJson.exp) + + if ($Now -ge $ExpiryTime) { + Write-Error "The token has expired on $ExpiryTime UTC. Current time is $Now UTC." + return $false + } + + # Check for required scopes for Intune management + $Scopes = $PayloadJson.scp -split " " + $RequiredScopes = @( + "DeviceManagementManagedDevices.Read.All", + "DeviceManagementConfiguration.Read.All" + ) + + foreach ($Scope in $RequiredScopes) { + if ($Scopes -notcontains $Scope) { + Write-Warning "The token may not have all required permissions. Missing: $Scope" + } + } + + # Token appears valid + Write-Verbose "Token validation successful. Token expires at $ExpiryTime UTC." + return $true + } + catch { + Write-Error "Error validating token: $_" + return $false + } + finally { + if ($BSTR) { + # Clean up the unmanaged memory + [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($BSTR) + } + } +} \ No newline at end of file diff --git a/examples/Intune/IntuneRemediation/Public/Connect-IntuneWithToken.ps1 b/examples/Intune/IntuneRemediation/Public/Connect-IntuneWithToken.ps1 new file mode 100644 index 0000000..489d43d --- /dev/null +++ b/examples/Intune/IntuneRemediation/Public/Connect-IntuneWithToken.ps1 @@ -0,0 +1,85 @@ +function Connect-IntuneWithToken { + <# + .SYNOPSIS + Connects to Microsoft Intune using a browser-acquired token. + + .DESCRIPTION + This function establishes a connection to Microsoft Intune using a token acquired from a browser. + It validates the token, ensures required modules are installed, and establishes both PowerShell cmdlet + and REST API access. + + .PARAMETER Token + The authentication token string obtained from a browser session. + + .EXAMPLE + Connect-IntuneWithToken -Token "eyJ0eXAiOiJKV..." + + .NOTES + Requires Microsoft.Graph.Authentication and Microsoft.Graph.DeviceManagement modules. + The token expires after approximately one hour and will need to be refreshed. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string]$Token + ) + + try { + # Convert token to SecureString + $SecureToken = ConvertTo-SecureString -String $Token -AsPlainText -Force + + # Validate the token + if (-not (Test-AuthToken -SecureToken $SecureToken)) { + Write-Error "Authentication token validation failed. Please acquire a new token." + return $false + } + + # Verify required modules are installed + $RequiredModules = @("Microsoft.Graph.Authentication", "Microsoft.Graph.DeviceManagement") + foreach ($Module in $RequiredModules) { + if (-not (Get-Module -Name $Module -ListAvailable)) { + Write-Warning "$Module module not found. Attempting to install..." + try { + Install-Module -Name $Module -Scope CurrentUser -Force -AllowClobber + } + catch { + Write-Error "Failed to install $Module. Please install it manually using: Install-Module -Name $Module -Scope CurrentUser -Force" + return $false + } + } + } + + # Disconnect from any existing Graph connection + try { + Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null + } + catch { + # Ignore any errors from disconnection + } + + # Connect to Microsoft Graph with the token + try { + Connect-MgGraph -AccessToken $SecureToken -NoWelcome + Write-Host "Successfully connected to Microsoft Graph!" -ForegroundColor Green + + # Store REST API headers for direct API calls if needed + $script:IntuneHeaders = @{ + "Authorization" = "Bearer $Token" + "Content-Type" = "application/json" + } + + # Store the token for potential reconnection + $script:IntuneToken = $Token + + return $true + } + catch { + Write-Error "Failed to connect to Microsoft Graph: $_" + return $false + } + } + catch { + Write-Error "Error in Connect-IntuneWithToken: $_" + return $false + } +} \ No newline at end of file diff --git a/examples/Intune/IntuneRemediation/Public/Initialize-IntuneConnection.ps1 b/examples/Intune/IntuneRemediation/Public/Initialize-IntuneConnection.ps1 new file mode 100644 index 0000000..d258267 --- /dev/null +++ b/examples/Intune/IntuneRemediation/Public/Initialize-IntuneConnection.ps1 @@ -0,0 +1,165 @@ +function Initialize-IntuneConnection { + <# + .SYNOPSIS + Sets up a direct connection to Intune using browser-acquired tokens. + + .DESCRIPTION + This function implements the token-based authentication approach for connecting to Microsoft Intune. + It handles installing required modules, validating tokens, and establishing a connection to Microsoft Graph. + + .PARAMETER Token + Optional. The authentication token string obtained from a browser session. If not provided, the user will be prompted. + + .EXAMPLE + Initialize-IntuneConnection + + .EXAMPLE + Initialize-IntuneConnection -Token "eyJ0eXAiOiJKV..." + + .NOTES + This function directly implements the token authentication approach without requiring external scripts. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $false)] + [string]$Token + ) + + try { + # Check if Microsoft Graph modules are available + $requiredModules = @("Microsoft.Graph.Authentication", "Microsoft.Graph.DeviceManagement") + $modulesAvailable = $true + + foreach ($module in $requiredModules) { + if (-not (Get-Module -ListAvailable -Name $module)) { + $modulesAvailable = $false + Write-Warning "Required module $module is not installed." + } + } + + if (-not $modulesAvailable) { + Write-Host "Installing required Microsoft Graph modules..." -ForegroundColor Yellow + + try { + foreach ($module in $requiredModules) { + Write-Host "Installing $module module..." -ForegroundColor Cyan + Install-Module -Name $module -Scope CurrentUser -Force -AllowClobber + Import-Module -Name $module -Force + } + Write-Host "All required modules installed and imported successfully." -ForegroundColor Green + } + catch { + Write-Error "Failed to install required modules: $_" + Write-Host "Please install the modules manually using:" -ForegroundColor Yellow + foreach ($module in $requiredModules) { + Write-Host "Install-Module -Name $module -Scope CurrentUser -Force" -ForegroundColor White + } + return $false + } + } + + # If token is not provided, prompt for it + if ([string]::IsNullOrEmpty($Token)) { + # Display instructions for obtaining a token + Write-Host "`n=== ACQUIRING AUTHENTICATION TOKEN ===" -ForegroundColor Cyan + Write-Host "To get your authentication token:" -ForegroundColor Yellow + Write-Host "1. Open a browser and go to: https://endpoint.microsoft.com/" -ForegroundColor White + Write-Host "2. Sign in with your admin account (if not already signed in)" -ForegroundColor White + Write-Host "3. Press F12 to open developer tools" -ForegroundColor White + Write-Host "4. Go to 'Network' tab" -ForegroundColor White + Write-Host "5. Refresh the page (F5)" -ForegroundColor White + Write-Host "6. Filter requests by typing 'graph.microsoft' in the filter box" -ForegroundColor White + Write-Host "7. Click on any request to graph.microsoft.com" -ForegroundColor White + Write-Host "8. In the Headers tab, scroll to find 'Authorization: Bearer eyJ...'" -ForegroundColor White + Write-Host "9. Copy the entire token (starts with 'eyJ' and is very long)" -ForegroundColor White + Write-Host "" + + # Prompt for token + Write-Host "Paste your authentication token below and press Enter:" -ForegroundColor Green + $Token = Read-Host + } + + # Validate token format + if (-not $Token.StartsWith("eyJ")) { + Write-Error "The token doesn't appear to be valid. It should start with 'eyJ'." + return $false + } + + # Test the token with a basic API call + $headers = @{ + 'Authorization' = "Bearer $Token" + 'Content-Type' = 'application/json' + } + + try { + $uri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$top=1" + $response = Invoke-RestMethod -Uri $uri -Headers $headers -Method Get -ErrorAction Stop + Write-Host "✓ Token validated successfully with Intune API." -ForegroundColor Green + } + catch { + Write-Error "Token validation failed: $($_.Exception.Message)" + return $false + } + + # Store token for later use + $global:IntuneToken = $Token + $global:IntuneHeaders = $headers + + # Connect to Microsoft Graph PowerShell + try { + # Convert the token to SecureString + $secureToken = ConvertTo-SecureString -String $Token -AsPlainText -Force + + # Disconnect from any existing Graph connection + Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null + + # Connect using the token + Connect-MgGraph -AccessToken $secureToken -NoWelcome + + # Test the connection + $context = Get-MgContext + if ($context) { + Write-Host "✓ Successfully connected to Microsoft Graph as $($context.Account)" -ForegroundColor Green + + # Test a device management command + try { + $device = Get-MgDeviceManagementManagedDevice -Top 1 -ErrorAction Stop + Write-Host "✓ Successfully verified access to Intune managed devices!" -ForegroundColor Green + + # Store a script-scope copy of the token for our module functions + $script:IntuneToken = $Token + $script:IntuneHeaders = $headers + + return $true + } + catch { + Write-Host "× Could not access Intune devices: $($_.Exception.Message)" -ForegroundColor Yellow + Write-Host " This may indicate insufficient permissions, but token authentication was successful." -ForegroundColor Yellow + + # Still store the tokens since basic authentication worked + $script:IntuneToken = $Token + $script:IntuneHeaders = $headers + + return $true + } + } + else { + Write-Error "Could not establish Microsoft Graph PowerShell session" + return $false + } + } + catch { + Write-Error "Error connecting to Microsoft Graph PowerShell: $_" + + # Provide guidance on module updates if needed + Write-Host "If this error persists, try updating the Microsoft Graph modules:" -ForegroundColor Yellow + Write-Host "Update-Module Microsoft.Graph -Force" -ForegroundColor White + + return $false + } + } + catch { + Write-Error "Error in Initialize-IntuneConnection: $_" + return $false + } +} \ No newline at end of file diff --git a/examples/Intune/IntuneRemediation/Public/New-IntuneRemediationScript.ps1 b/examples/Intune/IntuneRemediation/Public/New-IntuneRemediationScript.ps1 new file mode 100644 index 0000000..b3b6f69 --- /dev/null +++ b/examples/Intune/IntuneRemediation/Public/New-IntuneRemediationScript.ps1 @@ -0,0 +1,136 @@ +function New-IntuneRemediationScript { + <# + .SYNOPSIS + Creates a new remediation script package in Microsoft Intune. + + .DESCRIPTION + This function creates a new remediation script package in Microsoft Intune with detection + and remediation scripts. It requires a valid connection to Microsoft Graph. + + .PARAMETER DisplayName + The display name for the remediation script package in Intune. + + .PARAMETER Description + A description for the remediation script package. + + .PARAMETER Publisher + The publisher of the remediation script package. + + .PARAMETER DetectionScriptContent + The content of the detection script as a string. + + .PARAMETER RemediationScriptContent + The content of the remediation script as a string. + + .PARAMETER RunAs32Bit + If specified, runs the scripts in 32-bit PowerShell. + + .PARAMETER EnforceSignatureCheck + If specified, enforces script signature check. + + .PARAMETER RunAsAccount + The account context to run the scripts. Options are: System, User + + .EXAMPLE + $detection = Get-Content -Path ".\DetectionScript.ps1" -Raw + $remediation = Get-Content -Path ".\RemediationScript.ps1" -Raw + New-IntuneRemediationScript -DisplayName "Fix Service State" -Description "Checks and fixes critical service state" -Publisher "IT Department" -DetectionScriptContent $detection -RemediationScriptContent $remediation -RunAsAccount "System" + + .NOTES + Requires a connection to Microsoft Graph using Connect-IntuneWithToken. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string]$DisplayName, + + [Parameter(Mandatory = $false)] + [string]$Description = "", + + [Parameter(Mandatory = $false)] + [string]$Publisher = "IT Department", + + [Parameter(Mandatory = $true)] + [string]$DetectionScriptContent, + + [Parameter(Mandatory = $true)] + [string]$RemediationScriptContent, + + [Parameter(Mandatory = $false)] + [switch]$RunAs32Bit = $false, + + [Parameter(Mandatory = $false)] + [switch]$EnforceSignatureCheck = $false, + + [Parameter(Mandatory = $false)] + [ValidateSet("System", "User")] + [string]$RunAsAccount = "System" + ) + + try { + # Check if connected to Graph + try { + $GraphConnection = Get-MgContext + if (-not $GraphConnection) { + Write-Error "Not connected to Microsoft Graph. Please connect using Connect-IntuneWithToken first." + return $false + } + } + catch { + Write-Error "Not connected to Microsoft Graph. Please connect using Connect-IntuneWithToken first." + return $false + } + + # Convert script contents to Base64 + $DetectionScriptB64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($DetectionScriptContent)) + $RemediationScriptB64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($RemediationScriptContent)) + + # Determine run as account value for API + $RunAsAccountValue = switch ($RunAsAccount) { + "System" { "system" } + "User" { "user" } + default { "system" } + } + + # Create the remediation script object + $RemediationScriptBody = @{ + "@odata.type" = "#microsoft.graph.deviceHealthScript" + "displayName" = $DisplayName + "description" = $Description + "publisher" = $Publisher + "detectionScriptContent" = $DetectionScriptB64 + "remediationScriptContent" = $RemediationScriptB64 + "runAsAccount" = $RunAsAccountValue + "enforceSignatureCheck" = $EnforceSignatureCheck.IsPresent + "runAs32Bit" = $RunAs32Bit.IsPresent + "roleScopeTagIds" = @("0") # Default scope tag + } + + Write-Verbose "Creating remediation script: $DisplayName" + + # POST to Microsoft Graph API + $Url = "https://graph.microsoft.com/beta/deviceManagement/deviceHealthScripts" + $RemediationScriptJson = $RemediationScriptBody | ConvertTo-Json -Depth 10 + + try { + $Response = Invoke-MgGraphRequest -Method POST -Uri $Url -Body $RemediationScriptJson -ContentType "application/json" + + Write-Host "Successfully created remediation script '$DisplayName' with ID: $($Response.id)" -ForegroundColor Green + return $Response + } + catch { + Write-Error "Failed to create remediation script: $_" + + # More detailed error information + $ErrorDetails = $_.ErrorDetails | ConvertFrom-Json -ErrorAction SilentlyContinue + if ($ErrorDetails) { + Write-Error "Error details: $($ErrorDetails.error.message)" + } + return $false + } + } + catch { + Write-Error "Error in New-IntuneRemediationScript: $_" + return $false + } +} \ No newline at end of file diff --git a/examples/Intune/IntuneRemediation/Public/Test-IntuneRemediationScript.ps1 b/examples/Intune/IntuneRemediation/Public/Test-IntuneRemediationScript.ps1 new file mode 100644 index 0000000..854d383 --- /dev/null +++ b/examples/Intune/IntuneRemediation/Public/Test-IntuneRemediationScript.ps1 @@ -0,0 +1,222 @@ +function Test-IntuneRemediationScript { + <# + .SYNOPSIS + Tests a remediation script pair locally before uploading to Intune. + + .DESCRIPTION + This function tests a detection and remediation script pair locally to verify they work as expected. + The detection script should return a boolean value (or exit code 0/1), and the remediation script + should fix the issue if the detection script returns $false. + + .PARAMETER DetectionScriptPath + The path to the detection script file. + + .PARAMETER RemediationScriptPath + The path to the remediation script file. + + .PARAMETER Cycles + The number of detection-remediation cycles to run. Default is 1. + + .PARAMETER NoRemediate + If specified, only runs the detection script without remediation. + + .PARAMETER ShowScriptOutput + If specified, displays the raw script output rather than just success/failure. + + .EXAMPLE + Test-IntuneRemediationScript -DetectionScriptPath ".\Detect.ps1" -RemediationScriptPath ".\Remediate.ps1" -Cycles 2 + + .NOTES + This function does not upload anything to Intune, it only tests the scripts locally. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string]$DetectionScriptPath, + + [Parameter(Mandatory = $false)] + [string]$RemediationScriptPath, + + [Parameter(Mandatory = $false)] + [int]$Cycles = 1, + + [Parameter(Mandatory = $false)] + [switch]$NoRemediate, + + [Parameter(Mandatory = $false)] + [switch]$ShowScriptOutput + ) + + try { + # Verify the detection script exists + if (-not (Test-Path -Path $DetectionScriptPath)) { + Write-Error "Detection script not found at path: $DetectionScriptPath" + return $false + } + + # Verify the remediation script exists (if not using NoRemediate) + if (-not $NoRemediate -and -not [string]::IsNullOrEmpty($RemediationScriptPath)) { + if (-not (Test-Path -Path $RemediationScriptPath)) { + Write-Error "Remediation script not found at path: $RemediationScriptPath" + return $false + } + } + + Write-Host "Starting Intune remediation script test" -ForegroundColor Cyan + Write-Host "Detection script: $DetectionScriptPath" -ForegroundColor Cyan + if (-not $NoRemediate -and -not [string]::IsNullOrEmpty($RemediationScriptPath)) { + Write-Host "Remediation script: $RemediationScriptPath" -ForegroundColor Cyan + } + Write-Host "-------------------------------------------" -ForegroundColor Cyan + + $TestResults = @{ + Cycles = $Cycles + DetectionResults = @() + RemediationResults = @() + FinalStatus = "Unknown" + } + + for ($i = 1; $i -le $Cycles; $i++) { + Write-Host "Cycle $i of $Cycles" -ForegroundColor Yellow + + # Run detection script + Write-Host " Running detection script..." -ForegroundColor Gray + $DetectionOutput = $null + $DetectionSuccess = $false + + try { + # Execute the detection script and capture its output + $DetectionOutput = & $DetectionScriptPath + $DetectionExitCode = $LASTEXITCODE + + # Check if output is boolean or exit code indicates success/failure + if ($DetectionOutput -is [bool]) { + $DetectionSuccess = $DetectionOutput + } + elseif ($DetectionExitCode -eq 0) { + $DetectionSuccess = $true + } + else { + $DetectionSuccess = $false + } + + if ($ShowScriptOutput) { + Write-Host " Output: $DetectionOutput" -ForegroundColor Gray + } + + if ($DetectionSuccess) { + Write-Host " Detection result: " -ForegroundColor Gray -NoNewline + Write-Host "COMPLIANT" -ForegroundColor Green + } + else { + Write-Host " Detection result: " -ForegroundColor Gray -NoNewline + Write-Host "NON-COMPLIANT" -ForegroundColor Red + } + + $TestResults.DetectionResults += @{ + Cycle = $i + Success = $DetectionSuccess + Output = $DetectionOutput + ExitCode = $DetectionExitCode + } + } + catch { + Write-Host " Detection script error: $_" -ForegroundColor Red + $TestResults.DetectionResults += @{ + Cycle = $i + Success = $false + Error = $_ + } + continue + } + + # Run remediation script if detection failed and not using NoRemediate + if (-not $DetectionSuccess -and -not $NoRemediate -and -not [string]::IsNullOrEmpty($RemediationScriptPath)) { + Write-Host " Running remediation script..." -ForegroundColor Gray + $RemediationOutput = $null + + try { + # Execute the remediation script and capture its output + $RemediationOutput = & $RemediationScriptPath + $RemediationExitCode = $LASTEXITCODE + + if ($ShowScriptOutput) { + Write-Host " Output: $RemediationOutput" -ForegroundColor Gray + } + + Write-Host " Remediation completed with exit code: $RemediationExitCode" -ForegroundColor Gray + + $TestResults.RemediationResults += @{ + Cycle = $i + Output = $RemediationOutput + ExitCode = $RemediationExitCode + } + + # Run detection again to see if remediation fixed the issue + Write-Host " Running detection script again to verify remediation..." -ForegroundColor Gray + $VerificationOutput = & $DetectionScriptPath + $VerificationExitCode = $LASTEXITCODE + + # Check if output is boolean or exit code indicates success/failure + if ($VerificationOutput -is [bool]) { + $VerificationSuccess = $VerificationOutput + } + elseif ($VerificationExitCode -eq 0) { + $VerificationSuccess = $true + } + else { + $VerificationSuccess = $false + } + + if ($ShowScriptOutput) { + Write-Host " Output: $VerificationOutput" -ForegroundColor Gray + } + + if ($VerificationSuccess) { + Write-Host " Verification result: " -ForegroundColor Gray -NoNewline + Write-Host "REMEDIATION SUCCESSFUL" -ForegroundColor Green + } + else { + Write-Host " Verification result: " -ForegroundColor Gray -NoNewline + Write-Host "REMEDIATION FAILED" -ForegroundColor Red + } + + $TestResults.DetectionResults += @{ + Cycle = "$i-verification" + Success = $VerificationSuccess + Output = $VerificationOutput + ExitCode = $VerificationExitCode + } + } + catch { + Write-Host " Remediation script error: $_" -ForegroundColor Red + $TestResults.RemediationResults += @{ + Cycle = $i + Error = $_ + } + } + } + + Write-Host "" + } + + # Determine final status + $FinalDetection = $TestResults.DetectionResults | Select-Object -Last 1 + if ($FinalDetection.Success) { + $TestResults.FinalStatus = "Compliant" + Write-Host "Final status: " -ForegroundColor Cyan -NoNewline + Write-Host "COMPLIANT" -ForegroundColor Green + } + else { + $TestResults.FinalStatus = "Non-Compliant" + Write-Host "Final status: " -ForegroundColor Cyan -NoNewline + Write-Host "NON-COMPLIANT" -ForegroundColor Red + } + + return $TestResults + } + catch { + Write-Error "Error in Test-IntuneRemediationScript: $_" + return $false + } +} \ No newline at end of file diff --git a/examples/Intune/IntuneRemediation/README.md b/examples/Intune/IntuneRemediation/README.md new file mode 100644 index 0000000..ca5225b --- /dev/null +++ b/examples/Intune/IntuneRemediation/README.md @@ -0,0 +1,151 @@ +# IntuneRemediation PowerShell Module + +## Overview + +The IntuneRemediation module provides an easy way to create, test, and deploy remediation scripts to Microsoft Intune using PowerShell. It uses browser-acquired tokens for authentication, eliminating the need for app registrations. + +## Features + +- **Token-based Authentication**: Connect to Intune using tokens from your browser session +- **Script Testing**: Test your remediation scripts locally before deploying to Intune +- **Script Deployment**: Easily upload detection and remediation scripts to Intune +- **Proper Structure**: Follows PowerShell best practices with Public/Private function separation +- **Integrated Authentication**: Self-contained implementation of browser token authentication + +## Installation + +### Manual Installation + +1. Clone or download this repository +2. Copy the `IntuneRemediation` folder to one of your PowerShell module paths: + - `$Home\Documents\WindowsPowerShell\Modules` (for current user) + - `$env:ProgramFiles\WindowsPowerShell\Modules` (for all users) + +### PowerShell Gallery (Recommended) + +```powershell +Install-Module -Name IntuneRemediation -Scope CurrentUser +``` + +## Quick Start + +### 1. Import the Module + +```powershell +Import-Module IntuneRemediation +``` + +### 2. Connect to Intune + +```powershell +# This will prompt you for a token with instructions +Initialize-IntuneConnection + +# Or if you already have a token +Initialize-IntuneConnection -Token "eyJ0eXAiOi..." +``` + +### 3. Test a Remediation Script Locally + +```powershell +Test-IntuneRemediationScript -DetectionScriptPath ".\Detect.ps1" -RemediationScriptPath ".\Remediate.ps1" -Cycles 1 +``` + +### 4. Upload a Remediation Script to Intune + +```powershell +$detection = Get-Content -Path ".\Detect.ps1" -Raw +$remediation = Get-Content -Path ".\Remediate.ps1" -Raw + +New-IntuneRemediationScript -DisplayName "My Remediation Script" ` + -Description "Fixes a common issue" ` + -Publisher "IT Department" ` + -DetectionScriptContent $detection ` + -RemediationScriptContent $remediation ` + -RunAsAccount "System" +``` + +## Sample Scripts + +The module includes sample remediation scripts in the `Examples` folder: + +- **ServiceMonitor**: Monitors and fixes the Windows Time service if it's not running +- **Deploy-ServiceMonitor.ps1**: Demonstrates how to test and deploy the scripts to Intune +- **Deploy-ServiceMonitor-Enhanced.ps1**: Demonstrates our simplified approach to token authentication + +## How to Create a Remediation Script + +### 1. Detection Script + +The detection script should: +- Return exit code 0 (or $true) if the system is compliant +- Return exit code 1 (or $false) if the system is non-compliant and requires remediation + +Example: +```powershell +# Check if a service is running +$service = Get-Service -Name "ServiceName" +if ($service.Status -eq "Running") { + # Compliant + exit 0 +} else { + # Non-compliant + exit 1 +} +``` + +### 2. Remediation Script + +The remediation script should: +- Fix the issue identified by the detection script +- Return exit code 0 if remediation was successful +- Return exit code 1 if remediation failed + +Example: +```powershell +# Start the service +try { + Start-Service -Name "ServiceName" -ErrorAction Stop + exit 0 # Success +} catch { + exit 1 # Failed +} +``` + +## Getting Browser Tokens + +The `Initialize-IntuneConnection` function will guide you through the token acquisition process, but here's how to do it manually: + +1. Open a browser and go to https://endpoint.microsoft.com/ +2. Sign in with your admin account +3. Press F12 to open developer tools +4. Go to the Network tab +5. Refresh the page (F5) +6. Filter requests by typing "graph.microsoft" in the filter box +7. Click on any request to graph.microsoft.com +8. In the Headers tab, find "Authorization: Bearer eyJ..." +9. Copy the entire token (starting with "eyJ") + +## Functions + +### Public Functions + +- **Initialize-IntuneConnection**: Connect to Intune using browser token authentication +- **Connect-IntuneWithToken**: Legacy method to connect to Intune using a token +- **New-IntuneRemediationScript**: Create a new remediation script in Intune +- **Test-IntuneRemediationScript**: Test a remediation script locally before deployment + +## Requirements + +- PowerShell 5.1 or later +- Microsoft.Graph.Authentication module (installed automatically if needed) +- Microsoft.Graph.DeviceManagement module (installed automatically if needed) +- Administrative access to Microsoft Intune + +## License + +MIT License + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. \ No newline at end of file From c7dde9cf488ed249527034eadb61adaff0b79898 Mon Sep 17 00:00:00 2001 From: Admin Date: Sat, 26 Jul 2025 09:34:51 -0500 Subject: [PATCH 2/2] Create FrontEnd.md --- FrontEnd.md | 127 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 FrontEnd.md diff --git a/FrontEnd.md b/FrontEnd.md new file mode 100644 index 0000000..7b2d7a6 --- /dev/null +++ b/FrontEnd.md @@ -0,0 +1,127 @@ + +Okay, I can help with that! First, I need to understand the project structure to locate the front-end components. I'll start by listing the contents of the project's root directory. + + +Ran tool +Based on the directory listing, the `Xaml/` directory seems like the most probable location for front-end code, likely using XAML for a WPF or similar Windows UI. The `Start-WithApp.cmd` script might also provide clues on how the UI is launched. + +I'll now look into the `Xaml/` directory. + + +Ran tool +The `Xaml/` directory contains numerous `.xaml` files, which confirms that the front-end is built using XAML. This is typically used with WPF (Windows Presentation Foundation) or UWP (Universal Windows Platform) for creating desktop applications in the Windows ecosystem. + +`MainWindow.xaml` is likely the main window of the application. To understand how the UI is structured and how it interacts with the backend logic (which is likely PowerShell in this project), I will now examine `MainWindow.xaml`. + + +Read file: Xaml/MainWindow.xaml +The `MainWindow.xaml` file defines the main layout of the application. Here's a breakdown: + +* **Window Definition:** It sets up the main window with the title "Cloud API PowerShell Management". +* **Resources:** It includes a `ResourceDictionary` which likely points to `Themes\Default.xaml`. This means the application's look and feel (styles, colors, etc.) are defined in a separate theme file. +* **Main Grid Layout:** The window is structured using a `Grid` with rows and columns to arrange different UI elements. + * **Menu Bar:** There's a `Menu` (`mnuMain`) with "File" and "Views" options. + * The "File" menu includes "Settings", "Tenant Settings", "Release Notes", "About", and "Exit". + * **Environment Label:** A `Label` (`lblEnvironment`) to display environment information, initially collapsed. + * **View Panel:** A `Grid` (`grdViewPanel`) seems to be the main content area where different views or pages of the application will be displayed. + * **Menu Items List:** A `ListBox` (`lstMenuItems`) on the left side, likely used for navigation. It displays items with icons and titles, and their appearance (e.g., text color) can change based on `AccessType` (None, Limited). + * **Popups:** There are several `Grid` elements (`grdPopup`, `grdModal`, `grdStatus`) that are initially collapsed. These are likely used for displaying pop-up dialogs, modal forms, or status messages. + +**How it likely works with PowerShell:** + +XAML defines the UI structure and appearance. The actual logic, data retrieval, and actions performed when you interact with the UI (e.g., click a button, select a menu item) are typically handled by code-behind files (often C# for WPF) or, in this project's case, most likely PowerShell scripts. + +The PowerShell scripts would: + +1. **Load the XAML:** Read the `.xaml` files to create the UI elements. +2. **Populate Data:** Fetch data (e.g., from Intune, Azure AD) and populate the UI elements like list boxes, tables, etc. +3. **Handle Events:** Attach event handlers to UI elements. For example, when a button is clicked in the XAML UI, a corresponding PowerShell function is executed. +4. **Update UI:** Modify the UI based on user actions or data changes. + +The `Start-WithApp.cmd` file probably initiates this process by running a PowerShell script that loads `MainWindow.xaml` and starts the application. + +To give you a more complete picture, I should also look at `Start-WithApp.cmd` and potentially one of the core PowerShell scripts that might be responsible for loading the XAML and managing the UI logic. The `CloudAPIPowerShellManagement.psm1` or `Core.psm1` files seem like good candidates. + +Let's start with `Start-WithApp.cmd`. + + +Read file: Start-WithApp.cmd +The `Start-WithApp.cmd` script executes `Start-IntuneManagement.ps1` using PowerShell 5. It passes parameters like `tenantId`, `appid`, and `secret`, which are placeholders here and would need to be filled in with actual values. + +Now, let's examine `Start-IntuneManagement.ps1` to see how it initializes the application. + + +Read file: Start-IntuneManagement.ps1 +The `Start-IntuneManagement.ps1` script does the following: + +1. **Defines Parameters:** It accepts several parameters, including those passed by `Start-WithApp.cmd` (`TenantId`, `AppId`, `Secret`), and others to control its behavior (e.g., `ShowConsoleWindow`, `JSonSettings`). +2. **Imports Module:** It imports the `CloudAPIPowerShellManagement.psd1` module, which likely contains the core functionality. This psd1 file will point to `CloudAPIPowerShellManagement.psm1`. +3. **Initializes Management:** It then calls `Initialize-CloudAPIManagement` (a function presumably from the imported module) with the parameter `-View "IntuneGraphAPI"` and passes along all the script's bound parameters (`@param`). + +This confirms that the PowerShell module (`CloudAPIPowerShellManagement.psm1` and likely `Core.psm1` which is often imported by the main module) is responsible for setting up the UI. The `-View "IntuneGraphAPI"` parameter suggests that the application starts with a specific view related to Intune Graph API. + +So, the front-end is a XAML-based UI, likely WPF, which is dynamically loaded and managed by PowerShell scripts. The `MainWindow.xaml` defines the main application frame, and different "views" (other XAML files) are loaded into the main content area (`grdViewPanel`). PowerShell handles the logic, data, and event handling. + +To provide a deeper understanding of how PowerShell specifically interacts with the XAML (e.g., loading XAML, attaching event handlers), I should now look into `CloudAPIPowerShellManagement.psm1`. + + +Read file: CloudAPIPowerShellManagement.psm1 +The `CloudAPIPowerShellManagement.psm1` script, specifically the `Initialize-CloudAPIManagement` function, orchestrates the application startup and UI initialization: + +1. **Console Management:** It includes functions (`Show-Console`, `Hide-Console`) to control the visibility of the PowerShell console window. It typically hides the console when the GUI is running. +2. **Unblock Files:** An `Unblock-AllFiles` function is called to ensure that downloaded files (like scripts and XAML) are not blocked by Windows security, which can prevent them from running. +3. **Assembly Loading:** It loads necessary .NET assemblies for WPF (`PresentationFramework`) and Windows Forms (`System.Windows.Forms`, likely for `Application.DoEvents` or other interop). +4. **Splash Screen:** + * If not running in silent mode (`$global:hideUI -ne $true`), it loads and displays `SplashScreen.xaml`. + * It uses `Get-Content` to read the XAML file into an `[xml]` object. + * `[Windows.Markup.XamlReader]::Load(...)` is the key method used to parse the XAML string and create the actual WPF window object. + * It then finds elements within the splash screen (like `txtSplashTitle`, `txtSplashText`) by name using `$global:SplashScreen.FindName("...")` to update their content. + * `$global:SplashScreen.Show()` displays the window. + * `[System.Windows.Forms.Application]::DoEvents()` is used to keep the UI responsive while initialization tasks are performed in the background. +5. **Parameter Handling:** It processes parameters like `TenantId`, `AppId`, `Secret`, `ShowConsoleWindow`, and `JSonSettings`, storing them in global variables for later use. +6. **Core Module Import:** It imports `Core.psm1` using `Import-Module ... -Global`. This `Core.psm1` module likely contains the bulk of the application logic, including how `MainWindow.xaml` is loaded and how different views are managed. +7. **Start Core App:** Finally, it calls `Start-CoreApp $View`. This function, which must be defined in `Core.psm1`, is responsible for loading the main application window and the initial view specified (e.g., "IntuneGraphAPI"). + +**In summary, how the front-end works:** + +1. **Launch:** `Start-WithApp.cmd` calls `Start-IntuneManagement.ps1`. +2. **Initialization Script (`Start-IntuneManagement.ps1`):** + * Imports `CloudAPIPowerShellManagement.psd1` (which loads `CloudAPIPowerShellManagement.psm1`). + * Calls `Initialize-CloudAPIManagement`. +3. **Main Module (`CloudAPIPowerShellManagement.psm1` - `Initialize-CloudAPIManagement` function):** + * Sets up the environment (hides console, loads .NET assemblies). + * Loads and shows `SplashScreen.xaml` by reading the file content and using `[Windows.Markup.XamlReader]::Load()`. + * Updates text elements on the splash screen using `FindName()`. + * Imports `Core.psm1`. + * Calls `Start-CoreApp` (from `Core.psm1`) to load the main application UI. +4. **Core Logic (`Core.psm1` - assumed):** + * The `Start-CoreApp` function will similarly load `MainWindow.xaml` using `XamlReader`. + * It will then populate the `lstMenuItems` and handle navigation by loading other `.xaml` files (views like `EndpointManagerPanel.xaml`, `ImportForm.xaml`, etc.) into the `grdViewPanel` of `MainWindow.xaml`. + * Event handlers for UI elements (buttons, menu items, list selections) will be PowerShell functions or scriptblocks that are connected to the XAML elements, often by naming convention or by explicitly adding event handlers in PowerShell after loading the XAML. + +The front-end is essentially a WPF application where the UI is defined in XAML files, and PowerShell scripts dynamically load these XAML files, interact with the UI elements, and execute the application's logic. This approach allows for a rich graphical user interface powered by PowerShell scripting capabilities for management tasks. + +Do you have any more specific questions about a particular part of the front-end or its interaction with PowerShell? For example, we could look into `Core.psm1` to see `Start-CoreApp` or how event handlers might be attached. + + + + +Based on the evidence so far: + +* **Primarily WPF:** The extensive use of `.xaml` files, the loading mechanism `[Windows.Markup.XamlReader]::Load()`, and the `PresentationFramework` assembly are all hallmarks of Windows Presentation Foundation (WPF). The structure of the XAML files (e.g., ``, ``, ``, ``) is standard WPF. +* **Windows Forms for specific tasks:** The line `[void] [System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms")` and the use of `[System.Windows.Forms.Application]::DoEvents()` in `CloudAPIPowerShellManagement.psm1` indicate that some Windows Forms components or functionalities are being utilized. + * `Application.DoEvents()` is a common (though sometimes debated) practice to process the message queue and keep the UI responsive during long-running operations in the UI thread, especially when integrating non-WPF blocking calls or in single-threaded apartment (STA) scenarios often found with PowerShell GUIs. + * It's possible that some specific dialogs or UI elements might be Windows Forms-based, but the primary application structure and views appear to be WPF. + +* **No clear evidence of WinUI:** + * WinUI is a newer UI framework from Microsoft. While it also uses XAML, the namespaces and some controls differ from WPF. The namespaces seen (`http://schemas.microsoft.com/winfx/2006/xaml/presentation`) are classic WPF. + * There are no explicit references to WinUI-specific assemblies (like `Microsoft.UI.Xaml.dll`) or namespaces in the files we've examined. + +**Conclusion:** + +The front-end is **predominantly WPF**. It leverages Windows Forms for specific functionalities like `Application.DoEvents()` and potentially for some utility dialogs or interoperability needs, but the core UI framework and the way views are constructed and managed point strongly to WPF. + +There is no direct evidence to suggest the use of WinUI in this project from the files reviewed. + +Would you like me to examine `Core.psm1` to see if it reveals more about the UI interactions or confirms the use of any other UI elements? This is where `Start-CoreApp` is defined, which should load the `MainWindow.xaml`. +