From 724a124cad8f21fdda1ea37d0067e2e81480ed7d Mon Sep 17 00:00:00 2001 From: Michael Maertzdorf Date: Fri, 10 Apr 2026 17:24:36 +0200 Subject: [PATCH 1/4] feat: add modernized 2026 PowerShell 7.4 WPF module (v1.3.0) Complete rewrite of LazyWinAdmin as a native PowerShell 7.4+ WPF module. Replaces the 2012 PowerShell 2.0 WinForms version with a full modern equivalent. New LazyWinAdminModule includes: - 11-tab WPF GUI: System, Services, Software, Hardware, Network, Identity, Governance & Compliance, Device Compliance, Exchange, Registry, Cloud Auth - Async architecture: Start-ThreadJob + ConcurrentQueue + DispatcherTimer - Cloud integration: Entra ID, Intune, Azure Resource Graph, Exchange Online - Service control: Start/Stop/Restart from the Services tab - Export to CSV on all major list views - Admin elevation detection with Restart as Admin - OData/LDAP injection protection - 228 Pester v5 tests, 0 failures See README.md for full feature list and quick start instructions. Co-Authored-By: Claude Sonnet 4.6 --- .../Classes/ApplicationState.ps1 | 65 + LazyWinAdminModule/LazyWinAdminModule.psd1 | 36 + LazyWinAdminModule/LazyWinAdminModule.psm1 | 29 + .../Private/Connect-ExchangeSession.ps1 | 34 + .../Private/Connect-ModernCloud.ps1 | 46 + .../Private/Get-AzureResourceSummary.ps1 | 31 + .../Private/Get-ComputerADInfo.ps1 | 78 + .../Private/Get-ComputerHardware.ps1 | 65 + .../Private/Get-ComputerLocalGroup.ps1 | 25 + .../Private/Get-ComputerLocalUser.ps1 | 25 + .../Private/Get-ComputerMotherboard.ps1 | 39 + .../Private/Get-ComputerNetwork.ps1 | 52 + .../Private/Get-ComputerRegistryValue.ps1 | 55 + .../Private/Get-ComputerService.ps1 | 44 + .../Private/Get-ComputerSoftware.ps1 | 89 ++ .../Private/Get-ComputerUptime.ps1 | 34 + .../Private/Get-DeviceComplianceStatus.ps1 | 106 ++ .../Private/Get-EntraIdentity.ps1 | 56 + .../Private/Get-ExchangeMailboxPermission.ps1 | 42 + .../Private/Get-IntuneDevice.ps1 | 42 + .../Private/Get-IntuneManagementScript.ps1 | 81 + .../Private/Invoke-ComputerRegistry.ps1 | 105 ++ .../Private/Invoke-ComputerServiceControl.ps1 | 75 + .../Private/Set-ComputerRDP.ps1 | 83 + .../Private/Set-DeviceComplianceItem.ps1 | 119 ++ .../Private/Set-ExchangeMailboxPermission.ps1 | 89 ++ .../Private/Test-ComputerPort.ps1 | 39 + .../Public/Start-LazyWinAdmin.ps1 | 1090 +++++++++++++ LazyWinAdminModule/Tests/Functions.Tests.ps1 | 1409 +++++++++++++++++ LazyWinAdminModule/Tests/Integrity.Tests.ps1 | 400 +++++ LazyWinAdminModule/Tests/Run-Tests.ps1 | 140 ++ LazyWinAdminModule/UI/MainView.xaml | 609 +++++++ README.md | 420 ++--- 33 files changed, 5473 insertions(+), 179 deletions(-) create mode 100644 LazyWinAdminModule/Classes/ApplicationState.ps1 create mode 100644 LazyWinAdminModule/LazyWinAdminModule.psd1 create mode 100644 LazyWinAdminModule/LazyWinAdminModule.psm1 create mode 100644 LazyWinAdminModule/Private/Connect-ExchangeSession.ps1 create mode 100644 LazyWinAdminModule/Private/Connect-ModernCloud.ps1 create mode 100644 LazyWinAdminModule/Private/Get-AzureResourceSummary.ps1 create mode 100644 LazyWinAdminModule/Private/Get-ComputerADInfo.ps1 create mode 100644 LazyWinAdminModule/Private/Get-ComputerHardware.ps1 create mode 100644 LazyWinAdminModule/Private/Get-ComputerLocalGroup.ps1 create mode 100644 LazyWinAdminModule/Private/Get-ComputerLocalUser.ps1 create mode 100644 LazyWinAdminModule/Private/Get-ComputerMotherboard.ps1 create mode 100644 LazyWinAdminModule/Private/Get-ComputerNetwork.ps1 create mode 100644 LazyWinAdminModule/Private/Get-ComputerRegistryValue.ps1 create mode 100644 LazyWinAdminModule/Private/Get-ComputerService.ps1 create mode 100644 LazyWinAdminModule/Private/Get-ComputerSoftware.ps1 create mode 100644 LazyWinAdminModule/Private/Get-ComputerUptime.ps1 create mode 100644 LazyWinAdminModule/Private/Get-DeviceComplianceStatus.ps1 create mode 100644 LazyWinAdminModule/Private/Get-EntraIdentity.ps1 create mode 100644 LazyWinAdminModule/Private/Get-ExchangeMailboxPermission.ps1 create mode 100644 LazyWinAdminModule/Private/Get-IntuneDevice.ps1 create mode 100644 LazyWinAdminModule/Private/Get-IntuneManagementScript.ps1 create mode 100644 LazyWinAdminModule/Private/Invoke-ComputerRegistry.ps1 create mode 100644 LazyWinAdminModule/Private/Invoke-ComputerServiceControl.ps1 create mode 100644 LazyWinAdminModule/Private/Set-ComputerRDP.ps1 create mode 100644 LazyWinAdminModule/Private/Set-DeviceComplianceItem.ps1 create mode 100644 LazyWinAdminModule/Private/Set-ExchangeMailboxPermission.ps1 create mode 100644 LazyWinAdminModule/Private/Test-ComputerPort.ps1 create mode 100644 LazyWinAdminModule/Public/Start-LazyWinAdmin.ps1 create mode 100644 LazyWinAdminModule/Tests/Functions.Tests.ps1 create mode 100644 LazyWinAdminModule/Tests/Integrity.Tests.ps1 create mode 100644 LazyWinAdminModule/Tests/Run-Tests.ps1 create mode 100644 LazyWinAdminModule/UI/MainView.xaml diff --git a/LazyWinAdminModule/Classes/ApplicationState.ps1 b/LazyWinAdminModule/Classes/ApplicationState.ps1 new file mode 100644 index 0000000..5f579d2 --- /dev/null +++ b/LazyWinAdminModule/Classes/ApplicationState.ps1 @@ -0,0 +1,65 @@ +# Requires PowerShell 7.4+ + +class LazyWinAdminState { + [hashtable] $SyncHash + [System.Management.Automation.Runspaces.RunspacePool] $RunspacePool + + # Thread-safe CIM session cache — keyed by ComputerName. + # Private functions open one session per call and close it in finally. + # This cache is available for future cross-call reuse without rebuilding + # the WSMan connection on every button click. + # Adheres to: cim_session.* ALWAYS reuse-before-create (CONTRACTS) + [hashtable] $CimSessions + + LazyWinAdminState() { + $this.SyncHash = [hashtable]::Synchronized(@{}) + $this.SyncHash.Logs = [System.Collections.ArrayList]::new() + $this.SyncHash.IsBusy = $false + # Set to $true by the cloud-auth OnCompleted handler once [OK] is returned. + # Read by $RequireCloudSession guard in button handlers before dispatching async work. + $this.SyncHash.CloudConnected = $false + # Set to $true by the Exchange OnCompleted handler after a successful connection. + # Read by $RequireExchangeSession guard. + $this.SyncHash.ExchangeConnected = $false + # Thread-safe queue drained by the DispatcherTimer on the WPF UI thread. + # Register-ObjectEvent actions enqueue completed job results here instead of + # calling Dispatcher.InvokeAsync, avoiding all cross-thread delegate issues. + $this.SyncHash.UIQueue = [System.Collections.Concurrent.ConcurrentQueue[object]]::new() + + $this.CimSessions = [hashtable]::Synchronized(@{}) + + # Runspace pool used by Start-ThreadJob for all async UI operations. + # Min 1, max 5 concurrent thread jobs. + $this.RunspacePool = [runspacefactory]::CreateRunspacePool(1, 5) + $this.RunspacePool.Open() + } + + [void] Log([string]$Message) { + $timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") + $this.SyncHash.Logs.Add("[$timestamp] $Message") | Out-Null + } + + # Evict a stale or failed CIM session from the cache by ComputerName. + [void] EvictCimSession([string]$ComputerName) { + if ($this.CimSessions.ContainsKey($ComputerName)) { + try { + Remove-CimSession -CimSession $this.CimSessions[$ComputerName] -ErrorAction SilentlyContinue + } + catch { } + $this.CimSessions.Remove($ComputerName) + } + } + + [void] Dispose() { + # Close all cached CIM sessions before tearing down + foreach ($cs in $this.CimSessions.Values) { + try { Remove-CimSession -CimSession $cs -ErrorAction SilentlyContinue } catch { } + } + $this.CimSessions.Clear() + + if ($this.RunspacePool) { + $this.RunspacePool.Close() + $this.RunspacePool.Dispose() + } + } +} diff --git a/LazyWinAdminModule/LazyWinAdminModule.psd1 b/LazyWinAdminModule/LazyWinAdminModule.psd1 new file mode 100644 index 0000000..49fe385 --- /dev/null +++ b/LazyWinAdminModule/LazyWinAdminModule.psd1 @@ -0,0 +1,36 @@ +@{ + RootModule = 'LazyWinAdminModule.psm1' + ModuleVersion = '1.3.0' + GUID = '1b9e5d4a-5c20-4e3a-b892-0b13d2f9a1c2' + Author = 'Francois-Xavier Cat (Modernized)' + CompanyName = 'LazyWinAdmin' + Copyright = '(c) LazyWinAdmin. All rights reserved.' + Description = 'Modernized LazyWinAdmin GUI Module — 2026 Edition (v1.3.0)' + + # Minimum PS version per DEPS.SYSTEM in lazywinadmin.speq + PowerShellVersion = '7.4' + + # Declaring runtime dependencies here produces a clear import-time error + # when a required module is absent, instead of a cryptic runtime failure. + # Maps directly to DEPS.RUNTIME in lazywinadmin.speq: + # microsoft-graph -> Microsoft.Graph.Authentication (core auth + context) + # az -> Az.Accounts (Connect-AzAccount, Get-AzContext) + # Az.ResourceGraph (Search-AzGraph) + # NOTE: ThreadJob is NOT listed here — Start-ThreadJob is an inbox cmdlet + # in PowerShell 7.4+ (enforced by PowerShellVersion = '7.4' above). + # Listing 'ThreadJob' in RequiredModules causes Import-Module to fail because + # PS cannot resolve it as a named module even though the cmdlet is available. + RequiredModules = @( + 'Microsoft.Graph.Authentication', + 'Microsoft.Graph.Users', + 'Microsoft.Graph.Groups', + 'Microsoft.Graph.DeviceManagement', + 'Az.Accounts', + 'Az.ResourceGraph' + ) + + FunctionsToExport = @('Start-LazyWinAdmin') + CmdletsToExport = @() + VariablesToExport = @() + AliasesToExport = @() +} diff --git a/LazyWinAdminModule/LazyWinAdminModule.psm1 b/LazyWinAdminModule/LazyWinAdminModule.psm1 new file mode 100644 index 0000000..d2f0283 --- /dev/null +++ b/LazyWinAdminModule/LazyWinAdminModule.psm1 @@ -0,0 +1,29 @@ +# Strict mode according to powershell-windows skill +Set-StrictMode -Version Latest +$ErrorActionPreference = "Continue" + +# Load Classes +$classFiles = Get-ChildItem -Path (Join-Path $PSScriptRoot "Classes") -Filter "*.ps1" +foreach ($file in $classFiles) { + . $file.FullName +} + +# Load Private Functions +$privateFiles = Get-ChildItem -Path (Join-Path $PSScriptRoot "Private") -Filter "*.ps1" +foreach ($file in $privateFiles) { + . $file.FullName +} + +# Load Public Functions +$publicFiles = Get-ChildItem -Path (Join-Path $PSScriptRoot "Public") -Filter "*.ps1" +foreach ($file in $publicFiles) { + . $file.FullName +} + +# Load UI Functions +$uiFiles = Get-ChildItem -Path (Join-Path $PSScriptRoot "UI") -Filter "*.ps1" +foreach ($file in $uiFiles) { + . $file.FullName +} + +Export-ModuleMember -Function Start-LazyWinAdmin \ No newline at end of file diff --git a/LazyWinAdminModule/Private/Connect-ExchangeSession.ps1 b/LazyWinAdminModule/Private/Connect-ExchangeSession.ps1 new file mode 100644 index 0000000..731c421 --- /dev/null +++ b/LazyWinAdminModule/Private/Connect-ExchangeSession.ps1 @@ -0,0 +1,34 @@ +function Connect-ExchangeSession { + <# + .SYNOPSIS + Connects to Exchange Online using modern authentication. + .DESCRIPTION + Checks for the ExchangeOnlineManagement module, imports it, and initiates + an interactive connection. Returns a status string — never surfaces + exception detail or credential fragments to the caller. + #> + [CmdletBinding()] + param ( + [string]$UserPrincipalName + ) + + try { + if (-not (Get-Module -Name ExchangeOnlineManagement -ListAvailable)) { + return "[!] ExchangeOnlineManagement module not found. Install with: Install-Module ExchangeOnlineManagement -Scope CurrentUser" + } + + Import-Module ExchangeOnlineManagement -ErrorAction Stop + + $params = @{ ShowBanner = $false; ErrorAction = 'Stop' } + if ($UserPrincipalName) { $params.UserPrincipalName = $UserPrincipalName } + + Connect-ExchangeOnline @params + + $org = Get-OrganizationConfig -ErrorAction Stop + return "[OK] Connected to Exchange Online: $($org.DisplayName)" + } + catch { + Write-Verbose "Exchange connection exception: $_" + return "[!] Connection failed. Verify the ExchangeOnlineManagement module is installed and credentials are correct." + } +} diff --git a/LazyWinAdminModule/Private/Connect-ModernCloud.ps1 b/LazyWinAdminModule/Private/Connect-ModernCloud.ps1 new file mode 100644 index 0000000..4bfa556 --- /dev/null +++ b/LazyWinAdminModule/Private/Connect-ModernCloud.ps1 @@ -0,0 +1,46 @@ +function Connect-ModernCloud { + <# + .SYNOPSIS + Connects to Microsoft Graph / Entra ID using modern authentication. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory=$false)] + [string]$TenantId, + + [string]$ClientId, + + [SecureString]$ClientSecret, + + [switch]$Interactive + ) + + try { + if ($Interactive) { + Write-Verbose "Triggering interactive login..." + Connect-MgGraph -Scopes "User.ReadBasic.All", "Group.Read.All", "DeviceManagementManagedDevices.Read.All", "DeviceManagementConfiguration.Read.All" + } + elseif ($ClientId -and $ClientSecret) { + Write-Verbose "Connecting via Service Principal..." + $credential = [System.Management.Automation.PSCredential]::new($ClientId, $ClientSecret) + $body = @{ + TenantId = $TenantId + ClientId = $ClientId + ClientSecretCredential = $credential + } + Connect-MgGraph @body + } + + $context = Get-MgContext + if ($context) { + # TenantId is internal; account is non-sensitive display info + return "[OK] Connected to Tenant: $($context.TenantId) as $($context.Account)" + } + return "[!] Authentication completed but Graph context could not be retrieved." + } + catch { + # Write full exception to Verbose only — never surface token fragments or credentials in the return value + Write-Verbose "Cloud connection exception detail: $_" + return "[!] Connection failed. Verify credentials and network connectivity." + } +} diff --git a/LazyWinAdminModule/Private/Get-AzureResourceSummary.ps1 b/LazyWinAdminModule/Private/Get-AzureResourceSummary.ps1 new file mode 100644 index 0000000..b902a73 --- /dev/null +++ b/LazyWinAdminModule/Private/Get-AzureResourceSummary.ps1 @@ -0,0 +1,31 @@ +function Get-AzureResourceSummary { + <# + .SYNOPSIS + Retrieves a summary of Azure resources grouped by type using Azure Resource Graph. + .DESCRIPTION + Uses Search-AzGraph (Az.ResourceGraph) for server-side aggregation. + Avoids Get-AzResource which enumerates all resources client-side and does not scale. + Requires Az.ResourceGraph module (included in Az). + #> + [CmdletBinding()] + param () + + process { + try { + $azContext = Get-AzContext + if (-not $azContext) { + Write-Warning "Not connected to Azure. Please run Connect-AzAccount." + return $null + } + + $query = "Resources | summarize Count=count() by ResourceType=type | order by Count desc" + $results = Search-AzGraph -Query $query -ErrorAction Stop + + return $results | Select-Object @{ N = 'Name'; E = { $_.ResourceType } }, Count + } + catch { + Write-Warning "Error querying Azure Resource Graph: $_" + return $null + } + } +} diff --git a/LazyWinAdminModule/Private/Get-ComputerADInfo.ps1 b/LazyWinAdminModule/Private/Get-ComputerADInfo.ps1 new file mode 100644 index 0000000..f54489a --- /dev/null +++ b/LazyWinAdminModule/Private/Get-ComputerADInfo.ps1 @@ -0,0 +1,78 @@ +function Get-ComputerADInfo { + <# + .SYNOPSIS + Queries Active Directory for computer, user, or group objects. + .DESCRIPTION + Uses the ActiveDirectory module (RSAT). Requires rsat-ad-ds installed on the machine + running this function. Validates AdFilter input before passing to AD cmdlets. + Adheres to: ad_computer.* REQUIRES rsat-ad-module (CONTRACTS) + ad_user.samaccountname NEVER logged (CLASSIFY: pii) + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)] + [ValidateSet("Computer", "User", "Group")] + [string]$Type, + + # AdFilter: the canonical search term for AD queries (never: ad_search, s, search_str) + [string]$AdFilter, + + [string]$ComputerName + ) + + process { + try { + # Verify RSAT AD module is present — dep.system: rsat-ad-ds + if (-not (Get-Module -Name ActiveDirectory -ListAvailable -ErrorAction SilentlyContinue)) { + Write-Warning "ActiveDirectory module not found. Install via: Get-WindowsCapability -Name Rsat.ActiveDirectory* -Online | Add-WindowsCapability -Online" + return $null + } + + Import-Module ActiveDirectory -ErrorAction Stop + + # Validate AdFilter — only allow characters safe for LDAP filter strings + if ($AdFilter -and $AdFilter -match "[^a-zA-Z0-9\s\-\.\@_\*]") { + Write-Warning "AdFilter contains characters not permitted in an AD filter." + return $null + } + + switch ($Type) { + "Computer" { + $ldapFilter = if ($AdFilter) { "Name -like '$AdFilter'" } ` + elseif ($ComputerName) { "Name -eq '$ComputerName'" } ` + else { "Name -like '*'" } + + return Get-ADComputer -Filter $ldapFilter ` + -Properties Description, OperatingSystem, OperatingSystemVersion, + LastLogonDate, DistinguishedName, Enabled -ErrorAction Stop | + Select-Object Name, DNSHostName, OperatingSystem, OperatingSystemVersion, + LastLogonDate, Enabled, Description, DistinguishedName + } + "User" { + $ldapFilter = if ($AdFilter) { "DisplayName -like '$AdFilter' -or SamAccountName -like '$AdFilter'" } ` + else { "Enabled -eq `$true" } + + # SamAccountName is pii — Select-Object excludes it from the returned object + # to prevent accidental logging in the UI layer + return Get-ADUser -Filter $ldapFilter ` + -Properties DisplayName, EmailAddress, Department, Title, + LastLogonDate, Enabled -ErrorAction Stop | + Select-Object DisplayName, EmailAddress, Department, Title, + LastLogonDate, Enabled, DistinguishedName + } + "Group" { + $ldapFilter = if ($AdFilter) { "Name -like '$AdFilter'" } ` + else { "Name -like '*'" } + + return Get-ADGroup -Filter $ldapFilter ` + -Properties Description, MemberOf -ErrorAction Stop | + Select-Object Name, SamAccountName, GroupCategory, GroupScope, Description + } + } + } + catch { + Write-Warning "Error querying Active Directory for $Type`: $_" + return $null + } + } +} diff --git a/LazyWinAdminModule/Private/Get-ComputerHardware.ps1 b/LazyWinAdminModule/Private/Get-ComputerHardware.ps1 new file mode 100644 index 0000000..fb48286 --- /dev/null +++ b/LazyWinAdminModule/Private/Get-ComputerHardware.ps1 @@ -0,0 +1,65 @@ +function Get-ComputerHardware { + <# + .SYNOPSIS + Retrieves hardware information (System, CPU, RAM, Disks) from a remote computer. + .DESCRIPTION + Opens a single CimSession and reuses it for all queries within the call. + Adheres to: cim_session.* ALWAYS reuse-before-create (CONTRACTS) + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)] + [string]$ComputerName + ) + + process { + $CimSession = $null + try { + $isLocal = $ComputerName -iin @('localhost', '127.0.0.1', $env:COMPUTERNAME) + $CimSession = if ($isLocal) { New-CimSession -ErrorAction Stop } else { New-CimSession -ComputerName $ComputerName -ErrorAction Stop } + + $cs = Get-CimInstance -CimSession $CimSession -ClassName Win32_ComputerSystem -ErrorAction Stop + $os = Get-CimInstance -CimSession $CimSession -ClassName Win32_OperatingSystem -ErrorAction Stop + $bios = Get-CimInstance -CimSession $CimSession -ClassName Win32_Bios -ErrorAction Stop + + $cpus = Get-CimInstance -CimSession $CimSession -ClassName Win32_Processor -ErrorAction Stop + $cpuInfo = $cpus | ForEach-Object { "$($_.Name) ($($_.NumberOfCores) Cores)" } + + $mem = Get-CimInstance -CimSession $CimSession -ClassName Win32_PhysicalMemory -ErrorAction Stop + $totalRamBytes = ($mem | Measure-Object -Property Capacity -Sum).Sum + $totalRamGB = [Math]::Round($totalRamBytes / 1GB, 2) + + $disks = Get-CimInstance -CimSession $CimSession -ClassName Win32_LogicalDisk ` + -Filter "DriveType=3" -ErrorAction Stop + + $diskResults = foreach ($d in $disks) { + [PSCustomObject]@{ + DeviceID = $d.DeviceID + SizeGB = [Math]::Round($d.Size / 1GB, 2) + FreeGB = [Math]::Round($d.FreeSpace / 1GB, 2) + PercentFree = [Math]::Round(($d.FreeSpace / $d.Size) * 100, 2) + } + } + + return [PSCustomObject]@{ + Model = $cs.Model + Manufacturer = $cs.Manufacturer + RAM_GB = $totalRamGB + CPU = $cpuInfo -join ", " + OS = $os.Caption + OS_Version = $os.Version + SerialNumber = $bios.SerialNumber + Disks = $diskResults + } + } + catch { + Write-Warning "Error retrieving hardware on $ComputerName`: $_" + return $null + } + finally { + if ($null -ne $CimSession) { + Remove-CimSession -CimSession $CimSession -ErrorAction SilentlyContinue + } + } + } +} diff --git a/LazyWinAdminModule/Private/Get-ComputerLocalGroup.ps1 b/LazyWinAdminModule/Private/Get-ComputerLocalGroup.ps1 new file mode 100644 index 0000000..3f5d55d --- /dev/null +++ b/LazyWinAdminModule/Private/Get-ComputerLocalGroup.ps1 @@ -0,0 +1,25 @@ +function Get-ComputerLocalGroup { + <# + .SYNOPSIS + Retrieves local groups from a remote computer using CIM. + #> + [CmdletBinding()] + param ( + [Parameter(ValueFromPipeline=$true)] + [string]$ComputerName = "localhost" + ) + + process { + try { + $isLocal = $ComputerName -iin @('localhost', '127.0.0.1', $env:COMPUTERNAME) + $cimParams = @{ ClassName = "Win32_Group"; Filter = "LocalAccount = True"; ErrorAction = "Stop" } + if (-not $isLocal) { $cimParams.ComputerName = $ComputerName } + $groups = Get-CimInstance @cimParams + return $groups | Select-Object Name, Caption, SID, Status + } + catch { + Write-Warning "Error getting local groups for $ComputerName`: $_" + return $null + } + } +} \ No newline at end of file diff --git a/LazyWinAdminModule/Private/Get-ComputerLocalUser.ps1 b/LazyWinAdminModule/Private/Get-ComputerLocalUser.ps1 new file mode 100644 index 0000000..ab7bb99 --- /dev/null +++ b/LazyWinAdminModule/Private/Get-ComputerLocalUser.ps1 @@ -0,0 +1,25 @@ +function Get-ComputerLocalUser { + <# + .SYNOPSIS + Retrieves local user accounts from a remote computer using CIM. + #> + [CmdletBinding()] + param ( + [Parameter(ValueFromPipeline=$true)] + [string]$ComputerName = "localhost" + ) + + process { + try { + $isLocal = $ComputerName -iin @('localhost', '127.0.0.1', $env:COMPUTERNAME) + $cimParams = @{ ClassName = "Win32_UserAccount"; Filter = "LocalAccount = True"; ErrorAction = "Stop" } + if (-not $isLocal) { $cimParams.ComputerName = $ComputerName } + $users = Get-CimInstance @cimParams + return $users | Select-Object Name, FullName, Disabled, Lockout, PasswordRequired, PasswordExpires, SID, Status + } + catch { + Write-Warning "Error getting local users for $ComputerName`: $_" + return $null + } + } +} \ No newline at end of file diff --git a/LazyWinAdminModule/Private/Get-ComputerMotherboard.ps1 b/LazyWinAdminModule/Private/Get-ComputerMotherboard.ps1 new file mode 100644 index 0000000..ac34207 --- /dev/null +++ b/LazyWinAdminModule/Private/Get-ComputerMotherboard.ps1 @@ -0,0 +1,39 @@ +function Get-ComputerMotherboard { + <# + .SYNOPSIS + Retrieves motherboard information from a remote computer using CIM. + .DESCRIPTION + Opens a single CimSession and closes it in the finally block. + Adheres to: cim_session.* ALWAYS reuse-before-create (CONTRACTS) + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)] + [string]$ComputerName + ) + + process { + $CimSession = $null + try { + $isLocal = $ComputerName -iin @('localhost', '127.0.0.1', $env:COMPUTERNAME) + $CimSession = if ($isLocal) { New-CimSession -ErrorAction Stop } else { New-CimSession -ComputerName $ComputerName -ErrorAction Stop } + $baseBoard = Get-CimInstance -CimSession $CimSession -ClassName Win32_BaseBoard -ErrorAction Stop + + return [PSCustomObject]@{ + Product = $baseBoard.Product + Manufacturer = $baseBoard.Manufacturer + SerialNumber = $baseBoard.SerialNumber + Version = $baseBoard.Version + } + } + catch { + Write-Warning "Error retrieving motherboard info on $ComputerName`: $_" + return $null + } + finally { + if ($null -ne $CimSession) { + Remove-CimSession -CimSession $CimSession -ErrorAction SilentlyContinue + } + } + } +} diff --git a/LazyWinAdminModule/Private/Get-ComputerNetwork.ps1 b/LazyWinAdminModule/Private/Get-ComputerNetwork.ps1 new file mode 100644 index 0000000..5404edc --- /dev/null +++ b/LazyWinAdminModule/Private/Get-ComputerNetwork.ps1 @@ -0,0 +1,52 @@ +function Get-ComputerNetwork { + <# + .SYNOPSIS + Retrieves network adapter configuration from a remote computer using CIM. + .DESCRIPTION + Opens a single CimSession and closes it in the finally block. + Adheres to: cim_session.* ALWAYS reuse-before-create (CONTRACTS) + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)] + [string]$ComputerName, + + [switch]$OnlyIPEnabled + ) + + process { + $CimSession = $null + try { + $isLocal = $ComputerName -iin @('localhost', '127.0.0.1', $env:COMPUTERNAME) + $CimSession = if ($isLocal) { New-CimSession -ErrorAction Stop } else { New-CimSession -ComputerName $ComputerName -ErrorAction Stop } + + $filter = if ($OnlyIPEnabled) { "IPEnabled = True" } else { $null } + $adapters = Get-CimInstance -CimSession $CimSession -ClassName Win32_NetworkAdapterConfiguration ` + -Filter $filter -ErrorAction Stop + + $results = foreach ($a in $adapters) { + [PSCustomObject]@{ + Description = $a.Description + IPAddress = $a.IPAddress -join ", " + IPSubnet = $a.IPSubnet -join ", " + DefaultIPGateway = $a.DefaultIPGateway -join ", " + MACAddress = $a.MACAddress + DHCPEnabled = $a.DHCPEnabled + DHCPServer = $a.DHCPServer + DNSHostName = $a.DNSHostName + } + } + + return $results | Sort-Object Description + } + catch { + Write-Warning "Error retrieving network info on $ComputerName`: $_" + return $null + } + finally { + if ($null -ne $CimSession) { + Remove-CimSession -CimSession $CimSession -ErrorAction SilentlyContinue + } + } + } +} diff --git a/LazyWinAdminModule/Private/Get-ComputerRegistryValue.ps1 b/LazyWinAdminModule/Private/Get-ComputerRegistryValue.ps1 new file mode 100644 index 0000000..261d314 --- /dev/null +++ b/LazyWinAdminModule/Private/Get-ComputerRegistryValue.ps1 @@ -0,0 +1,55 @@ +function Get-ComputerRegistryValue { + <# + .SYNOPSIS + Retrieves a registry value from a remote computer using CIM (WMI over WinRM). + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)] + [string]$ComputerName, + + [Parameter(Mandatory=$true)] + [ValidateSet("HKLM", "HKU", "HKCU", "HKCR", "HKCC")] + [string]$Hive, + + [Parameter(Mandatory=$true)] + [string]$KeyPath, + + [string]$ValueName + ) + + process { + try { + $hives = @{ + "HKCR" = 2147483648 + "HKCU" = 2147483649 + "HKLM" = 2147483650 + "HKU" = 2147483651 + "HKCC" = 2147483652 + } + + $hDefKey = $hives[$Hive] + + # Use CIM to call StdRegProv methods (Firewall friendly) + $params = @{ + hDefKey = $hDefKey + sSubKeyName = $KeyPath + sValueName = $ValueName + } + + $result = Invoke-CimMethod -ComputerName $ComputerName -Namespace "root\default" -ClassName "StdRegProv" -MethodName "GetStringValue" -Arguments $params -ErrorAction Stop + + if ($result.ReturnValue -eq 0) { + return $result.sValue + } + else { + Write-Verbose "Registry value not found or error code: $($result.ReturnValue)" + return $null + } + } + catch { + Write-Warning "Error reading registry on $ComputerName`: $_" + return $null + } + } +} \ No newline at end of file diff --git a/LazyWinAdminModule/Private/Get-ComputerService.ps1 b/LazyWinAdminModule/Private/Get-ComputerService.ps1 new file mode 100644 index 0000000..bf3127c --- /dev/null +++ b/LazyWinAdminModule/Private/Get-ComputerService.ps1 @@ -0,0 +1,44 @@ +function Get-ComputerService { + <# + .SYNOPSIS + Retrieves service information from a remote computer using CIM. + #> + [CmdletBinding()] + param ( + [Parameter(ValueFromPipeline=$true)] + [string]$ComputerName = "localhost", + + [string]$Name, + + [switch]$OnlyAutoStopped + ) + + process { + try { + $isLocal = $ComputerName -iin @('localhost', '127.0.0.1', $env:COMPUTERNAME) + + $filter = "" + if ($Name) { + $filter = "Name = '$Name'" + } + elseif ($OnlyAutoStopped) { + $filter = "StartMode = 'Auto' AND State != 'Running'" + } + + $params = @{ + ClassName = "Win32_Service" + ErrorAction = "Stop" + } + if ($filter) { $params.Filter = $filter } + if (-not $isLocal) { $params.ComputerName = $ComputerName } + + $services = Get-CimInstance @params + + return $services | Select-Object Name, DisplayName, State, StartMode, StartName, ProcessId + } + catch { + Write-Warning "Error getting services for $ComputerName`: $_" + return $null + } + } +} \ No newline at end of file diff --git a/LazyWinAdminModule/Private/Get-ComputerSoftware.ps1 b/LazyWinAdminModule/Private/Get-ComputerSoftware.ps1 new file mode 100644 index 0000000..7dba8b0 --- /dev/null +++ b/LazyWinAdminModule/Private/Get-ComputerSoftware.ps1 @@ -0,0 +1,89 @@ +function Get-ComputerSoftware { + <# + .SYNOPSIS + Retrieves installed software from a remote computer via registry enumeration. + .DESCRIPTION + Uses StdRegProv via CIM to read Uninstall keys from HKLM. + This avoids Win32_Product which triggers an MSI consistency check on every query. + Adheres to: software.* ALWAYS use-registry-enumeration (CONTRACTS) + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)] + [string]$ComputerName, + + [string]$Search + ) + + process { + try { + $hive = [UInt32]2147483650 # HKLM + $isLocal = $ComputerName -iin @('localhost', '127.0.0.1', $env:COMPUTERNAME) + + $regParams = @{ Namespace = "root\default"; ClassName = "StdRegProv"; ErrorAction = "Stop" } + if (-not $isLocal) { $regParams.ComputerName = $ComputerName } + $reg = Get-CimInstance @regParams + + $uninstallPaths = @( + "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall", + "SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall" + ) + + $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $results = [System.Collections.Generic.List[PSCustomObject]]::new() + + foreach ($path in $uninstallPaths) { + $enumResult = Invoke-CimMethod -InputObject $reg -MethodName "EnumKey" ` + -Arguments @{ hDefKey = $hive; sSubKeyName = $path } + + if ($enumResult.ReturnValue -ne 0 -or -not $enumResult.sNames) { continue } + + foreach ($keyName in $enumResult.sNames) { + $subPath = "$path\$keyName" + + $nameResult = Invoke-CimMethod -InputObject $reg -MethodName "GetStringValue" ` + -Arguments @{ hDefKey = $hive; sSubKeyName = $subPath; sValueName = "DisplayName" } + + if ($nameResult.ReturnValue -ne 0 -or [string]::IsNullOrWhiteSpace($nameResult.sValue)) { continue } + $displayName = $nameResult.sValue + + # Deduplicate across both registry hives + if (-not $seen.Add($displayName)) { continue } + + # Apply SoftwareRegistry search filter (contract: SoftwareRegistry is the canonical filter term) + if ($Search -and $displayName -notlike "*$Search*") { continue } + + $verResult = Invoke-CimMethod -InputObject $reg -MethodName "GetStringValue" ` + -Arguments @{ hDefKey = $hive; sSubKeyName = $subPath; sValueName = "DisplayVersion" } + $pubResult = Invoke-CimMethod -InputObject $reg -MethodName "GetStringValue" ` + -Arguments @{ hDefKey = $hive; sSubKeyName = $subPath; sValueName = "Publisher" } + $dateResult = Invoke-CimMethod -InputObject $reg -MethodName "GetStringValue" ` + -Arguments @{ hDefKey = $hive; sSubKeyName = $subPath; sValueName = "InstallDate" } + + $installDate = "Unknown" + if ($dateResult.ReturnValue -eq 0 -and $dateResult.sValue) { + try { + $installDate = [DateTime]::ParseExact($dateResult.sValue, "yyyyMMdd", $null).ToString("yyyy-MM-dd") + } + catch { + $installDate = $dateResult.sValue + } + } + + $results.Add([PSCustomObject]@{ + Name = $displayName + Version = if ($verResult.ReturnValue -eq 0) { $verResult.sValue } else { "" } + Vendor = if ($pubResult.ReturnValue -eq 0) { $pubResult.sValue } else { "" } + InstallDate = $installDate + }) + } + } + + return $results | Sort-Object Name + } + catch { + Write-Warning "Error retrieving software on $ComputerName`: $_" + return $null + } + } +} diff --git a/LazyWinAdminModule/Private/Get-ComputerUptime.ps1 b/LazyWinAdminModule/Private/Get-ComputerUptime.ps1 new file mode 100644 index 0000000..eca42e0 --- /dev/null +++ b/LazyWinAdminModule/Private/Get-ComputerUptime.ps1 @@ -0,0 +1,34 @@ +function Get-ComputerUptime { + [CmdletBinding()] + param ( + [Parameter(ValueFromPipeline=$true)] + [string]$ComputerName = "localhost" + ) + + process { + try { + $isLocal = $ComputerName -iin @('localhost', '127.0.0.1', $env:COMPUTERNAME) + $cimParams = @{ ClassName = "Win32_OperatingSystem"; ErrorAction = "Stop" } + if (-not $isLocal) { $cimParams.ComputerName = $ComputerName } + $cim = Get-CimInstance @cimParams + + # CIM natively returns a DateTime object for LastBootUpTime, no need to convert like WMI + if ($cim -and $cim.LastBootUpTime) { + $LBTime = $cim.LastBootUpTime + $uptime = New-TimeSpan -Start $LBTime -End (Get-Date) + + $days = $uptime.Days + $hours = $uptime.Hours + $minutes = $uptime.Minutes + $seconds = $uptime.Seconds + + return "$days Days $hours Hours $minutes Minutes $seconds Seconds" + } + return "Unknown" + } + catch { + Write-Warning "Error getting uptime for $ComputerName`: $_" + return "Error" + } + } +} \ No newline at end of file diff --git a/LazyWinAdminModule/Private/Get-DeviceComplianceStatus.ps1 b/LazyWinAdminModule/Private/Get-DeviceComplianceStatus.ps1 new file mode 100644 index 0000000..5cfabd9 --- /dev/null +++ b/LazyWinAdminModule/Private/Get-DeviceComplianceStatus.ps1 @@ -0,0 +1,106 @@ +function Get-DeviceComplianceStatus { + <# + .SYNOPSIS + Checks local device compliance items and returns a status list. + .DESCRIPTION + Checks: Location Services policy/consent, OneDrive installation, + Outlook external image download setting, Windows Update active hours, + and Unified Write Filter state (Enterprise only). + All checks are registry/WMI reads — no network required. + #> + [CmdletBinding()] + param () + + $results = [System.Collections.Generic.List[PSCustomObject]]::new() + + # 1. Location Services + try { + $policyPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\LocationAndSensors' + $svcCfgPath = 'HKLM:\SYSTEM\CurrentControlSet\Services\lfsvc\Service\Configuration' + $consentPath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\location' + + $policyLocked = $false + foreach ($flag in @('DisableLocation', 'DisableWindowsLocationProvider', 'DisableSensors')) { + $v = (Get-ItemProperty -Path $policyPath -ErrorAction SilentlyContinue).$flag + if (($v -as [int]) -eq 1) { $policyLocked = $true } + } + $svcCfg = (Get-ItemProperty -Path $svcCfgPath -ErrorAction SilentlyContinue).Status + $consent = (Get-ItemProperty -Path $consentPath -ErrorAction SilentlyContinue).Value + + $status = if (-not $policyLocked -and ($svcCfg -as [int]) -eq 1 -and $consent -match '^(?i)Allow$') { + 'Compliant' + } else { + 'Non-Compliant' + } + $detail = "Policy blocked: $policyLocked | Service cfg: $svcCfg | Consent: $consent" + } + catch { + $status = 'Error'; $detail = "Could not read location registry keys." + } + $results.Add([PSCustomObject]@{ Item = 'Location Services'; Status = $status; Description = $detail }) + + # 2. OneDrive + try { + $installed = (Test-Path "$env:SystemRoot\System32\OneDriveSetup.exe") -or + (Test-Path "$env:SystemRoot\SysWOW64\OneDriveSetup.exe") + $status = if ($installed) { 'Installed' } else { 'Not Installed' } + $detail = if ($installed) { 'OneDrive setup executable found on disk' } else { 'OneDrive setup executable not found' } + } + catch { + $status = 'Error'; $detail = "Could not check OneDrive installation." + } + $results.Add([PSCustomObject]@{ Item = 'OneDrive'; Status = $status; Description = $detail }) + + # 3. Outlook external image auto-download + try { + $regPath = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Options\Mail' + $v = (Get-ItemProperty -Path $regPath -ErrorAction SilentlyContinue).BlockExtContent + $status = if ($null -eq $v) { 'Not Configured' } elseif ($v -eq 0) { 'Compliant' } else { 'Blocked' } + $detail = "BlockExtContent = $(if ($null -eq $v) { '(not set)' } else { $v })" + } + catch { + $status = 'Error'; $detail = "Could not read Outlook registry key." + } + $results.Add([PSCustomObject]@{ Item = 'Outlook External Images'; Status = $status; Description = $detail }) + + # 4. Windows Update active hours + try { + $regPath = 'HKLM:\SOFTWARE\Microsoft\WindowsUpdate\UX\Settings' + $reg = Get-ItemProperty -Path $regPath -ErrorAction SilentlyContinue + $start = $reg.ActiveHoursStart + $end = $reg.ActiveHoursEnd + $status = if ($null -ne $start -and $null -ne $end) { 'Configured' } else { 'Not Configured' } + $detail = if ($null -ne $start) { "Active hours: $($start):00 to $($end):00" } else { 'Active hours not set' } + } + catch { + $status = 'Error'; $detail = "Could not read Windows Update registry key." + } + $results.Add([PSCustomObject]@{ Item = 'Windows Update Active Hours'; Status = $status; Description = $detail }) + + # 5. Unified Write Filter (Enterprise only) + try { + $osCaption = (Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction SilentlyContinue).Caption + if ($osCaption -like '*Enterprise*') { + $feature = Get-WindowsOptionalFeature -Online -FeatureName 'Client-UnifiedWriteFilter' -ErrorAction SilentlyContinue + if ($feature.State -eq 'Enabled') { + $uwf = Get-WmiObject -Namespace 'root\standardcimv2\embedded' -Class 'UWF_Filter' -ErrorAction SilentlyContinue + $status = if ($uwf.CurrentEnabled) { 'Enabled' } else { 'Disabled' } + $detail = 'UWF feature installed; CurrentEnabled = ' + $uwf.CurrentEnabled + } + else { + $status = 'Feature Not Installed' + $detail = 'Windows Enterprise detected but UWF optional feature is not enabled' + } + } + else { + $status = 'N/A' + $detail = 'Unified Write Filter requires Windows Enterprise' + } + } + catch { + $status = 'Error'; $detail = "Could not check UWF state." + } + $results.Add([PSCustomObject]@{ Item = 'Unified Write Filter'; Status = $status; Description = $detail }) + + return $results +} diff --git a/LazyWinAdminModule/Private/Get-EntraIdentity.ps1 b/LazyWinAdminModule/Private/Get-EntraIdentity.ps1 new file mode 100644 index 0000000..d5de86b --- /dev/null +++ b/LazyWinAdminModule/Private/Get-EntraIdentity.ps1 @@ -0,0 +1,56 @@ +function Get-EntraIdentity { + <# + .SYNOPSIS + Retrieves users or groups from Entra ID using Microsoft Graph. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)] + [ValidateSet("User", "Group")] + [string]$Type, + + [string]$Search + ) + + process { + try { + if ($null -eq (Get-MgContext)) { + Write-Warning "Not connected to Microsoft Graph. Please login in the Cloud tab." + return $null + } + + # Validate EntraFilter: allow only characters safe for OData $filter strings + if ($Search -and $Search -match "[^a-zA-Z0-9\s\-\.\@_]") { + Write-Warning "Search term contains characters not permitted in an Entra filter." + return $null + } + + if ($Type -eq "User") { + if ($Search) { + $SafeSearch = $Search.Trim() + return Get-MgUser ` + -Filter "startsWith(displayName,'$SafeSearch') or startsWith(userPrincipalName,'$SafeSearch')" ` + -Top 50 | + Select-Object DisplayName, UserPrincipalName, Id, Mail, JobTitle + } + return Get-MgUser -Top 50 | + Select-Object DisplayName, UserPrincipalName, Id, Mail, JobTitle + } + else { + if ($Search) { + $SafeSearch = $Search.Trim() + return Get-MgGroup ` + -Filter "startsWith(displayName,'$SafeSearch')" ` + -Top 50 | + Select-Object DisplayName, Id, Description, GroupTypes + } + return Get-MgGroup -Top 50 | + Select-Object DisplayName, Id, Description, GroupTypes + } + } + catch { + Write-Warning "Error querying Entra ID: $_" + return $null + } + } +} diff --git a/LazyWinAdminModule/Private/Get-ExchangeMailboxPermission.ps1 b/LazyWinAdminModule/Private/Get-ExchangeMailboxPermission.ps1 new file mode 100644 index 0000000..e5ea367 --- /dev/null +++ b/LazyWinAdminModule/Private/Get-ExchangeMailboxPermission.ps1 @@ -0,0 +1,42 @@ +function Get-ExchangeMailboxPermission { + <# + .SYNOPSIS + Lists all shared mailboxes a given user has FullAccess or SendAs on. + .DESCRIPTION + Iterates every shared mailbox in the connected Exchange Online organisation + and checks whether the target user holds FullAccess (via Get-MailboxPermission) + or SendAs (via Get-RecipientPermission). Returns only mailboxes where at + least one permission is held. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)] + [string]$UserPrincipalName + ) + + try { + $sharedMailboxes = Get-Mailbox -RecipientTypeDetails SharedMailbox -ErrorAction Stop + + $results = foreach ($mailbox in $sharedMailboxes) { + $fa = Get-MailboxPermission -Identity $mailbox.Alias -ErrorAction SilentlyContinue | + Where-Object { $_.User -like $UserPrincipalName -and $_.AccessRights -contains 'FullAccess' } + $sa = Get-RecipientPermission -Identity $mailbox.Alias -ErrorAction SilentlyContinue | + Where-Object { $_.Trustee -like $UserPrincipalName -and $_.AccessRights -contains 'SendAs' } + + if ($fa -or $sa) { + [PSCustomObject]@{ + Mailbox = $mailbox.PrimarySmtpAddress + DisplayName = $mailbox.DisplayName + FullAccess = [bool]$fa + SendAs = [bool]$sa + } + } + } + + return $results + } + catch { + Write-Warning "Error querying mailbox permissions: $_" + return $null + } +} diff --git a/LazyWinAdminModule/Private/Get-IntuneDevice.ps1 b/LazyWinAdminModule/Private/Get-IntuneDevice.ps1 new file mode 100644 index 0000000..a5298c3 --- /dev/null +++ b/LazyWinAdminModule/Private/Get-IntuneDevice.ps1 @@ -0,0 +1,42 @@ +function Get-IntuneDevice { + <# + .SYNOPSIS + Retrieves managed devices from Microsoft Intune using Microsoft Graph. + #> + [CmdletBinding()] + param ( + [string]$Search + ) + + process { + try { + if ($null -eq (Get-MgContext)) { + Write-Warning "Not connected to Microsoft Graph." + return $null + } + + # Validate IntuneFilter: allow only characters safe for OData $filter strings + if ($Search -and $Search -match "[^a-zA-Z0-9\s\-\.\@_]") { + Write-Warning "Search term contains characters not permitted in an Intune filter." + return $null + } + + if ($Search) { + $SafeSearch = $Search.Trim() + return Get-MgDeviceManagementManagedDevice ` + -Filter "startsWith(deviceName,'$SafeSearch') or startsWith(userPrincipalName,'$SafeSearch')" ` + -Top 50 | + Select-Object DeviceName, UserPrincipalName, ComplianceState, OperatingSystem, Model, SerialNumber, + JoinType, ManagementState, DeviceEnrollmentType + } + + return Get-MgDeviceManagementManagedDevice -Top 50 | + Select-Object DeviceName, UserPrincipalName, ComplianceState, OperatingSystem, Model, SerialNumber, + JoinType, ManagementState, DeviceEnrollmentType + } + catch { + Write-Warning "Error querying Intune: $_" + return $null + } + } +} diff --git a/LazyWinAdminModule/Private/Get-IntuneManagementScript.ps1 b/LazyWinAdminModule/Private/Get-IntuneManagementScript.ps1 new file mode 100644 index 0000000..80e3183 --- /dev/null +++ b/LazyWinAdminModule/Private/Get-IntuneManagementScript.ps1 @@ -0,0 +1,81 @@ +function Get-IntuneManagementScript { + <# + .SYNOPSIS + Lists Intune device management scripts and optionally downloads their content. + .DESCRIPTION + Uses Invoke-MgGraphRequest (Microsoft.Graph.Authentication) against the + beta Graph endpoint for deviceManagementScripts. Requires an active + Graph session with DeviceManagementConfiguration.Read.All scope. + .PARAMETER Search + Optional name/filename filter — returns scripts whose DisplayName or + FileName contains the search string (case-insensitive). + .PARAMETER DownloadPath + If specified and the path exists, each script's content is base64-decoded + and written as a .ps1 file under this folder. + #> + [CmdletBinding()] + param ( + [string]$Search, + [string]$DownloadPath + ) + + try { + $ctx = Get-MgContext -ErrorAction SilentlyContinue + if (-not $ctx) { + Write-Warning "Not connected to Microsoft Graph. Please authenticate on the Cloud Auth tab." + return $null + } + + $response = Invoke-MgGraphRequest -Method GET ` + -Uri "https://graph.microsoft.com/beta/deviceManagement/deviceManagementScripts" ` + -ErrorAction Stop + + $scripts = $response.value + + if ($Search) { + $scripts = $scripts | Where-Object { + $_.fileName -like "*$Search*" -or + $_.displayName -like "*$Search*" + } + } + + if ($DownloadPath) { + if (-not (Test-Path $DownloadPath)) { + New-Item -ItemType Directory -Path $DownloadPath -Force | Out-Null + } + + foreach ($script in $scripts) { + try { + $detail = Invoke-MgGraphRequest -Method GET ` + -Uri "https://graph.microsoft.com/beta/deviceManagement/deviceManagementScripts/$($script.id)" ` + -ErrorAction Stop + if ($detail.scriptContent) { + $decoded = [System.Text.Encoding]::UTF8.GetString( + [System.Convert]::FromBase64String($detail.scriptContent)) + $filePath = Join-Path $DownloadPath $detail.fileName + $decoded | Out-File -FilePath $filePath -Encoding UTF8 -Force + } + } + catch { + Write-Warning "Could not download script '$($script.displayName)': $_" + } + } + } + + return $scripts | ForEach-Object { + [PSCustomObject]@{ + Id = $_.id + DisplayName = $_.displayName + FileName = $_.fileName + RunAs = $_.runAsAccount + Enforcement = $_.enforcementType + Created = $_.createdDateTime + Modified = $_.lastModifiedDateTime + } + } + } + catch { + Write-Warning "Error querying Intune management scripts: $_" + return $null + } +} diff --git a/LazyWinAdminModule/Private/Invoke-ComputerRegistry.ps1 b/LazyWinAdminModule/Private/Invoke-ComputerRegistry.ps1 new file mode 100644 index 0000000..f25e867 --- /dev/null +++ b/LazyWinAdminModule/Private/Invoke-ComputerRegistry.ps1 @@ -0,0 +1,105 @@ +function Invoke-ComputerRegistry { + <# + .SYNOPSIS + Performs registry operations (Get, Set, New, Remove) on a remote computer. + .DESCRIPTION + Uses CIM (StdRegProv) for cross-platform compatibility and performance. + Adheres to: registry_entry.* REQUIRES hive-valid (CONTRACTS) + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)] + [ValidateSet("Get", "Set", "New", "Remove")] + [string]$Action, + + [Parameter(Mandatory=$true)] + [string]$ComputerName, + + [Parameter(Mandatory=$true)] + [ValidateSet("HKCR", "HKCU", "HKLM", "HKU")] + [string]$Hive, + + [Parameter(Mandatory=$true)] + [string]$KeyPath, + + [string]$ValueName, + + $Value, + + [ValidateSet("String", "ExpandString", "Binary", "DWord", "MultiString", "QWord")] + [string]$ValueType = "String" + ) + + process { + try { + $hives = @{ + "HKCR" = [UInt32]2147483648 + "HKCU" = [UInt32]2147483649 + "HKLM" = [UInt32]2147483650 + "HKU" = [UInt32]2147483651 + } + $hDef = $hives[$Hive] + + $isLocal = $ComputerName -iin @('localhost', '127.0.0.1', $env:COMPUTERNAME) + $regParams = @{ Namespace = "root\default"; ClassName = "StdRegProv"; ErrorAction = "Stop" } + if (-not $isLocal) { $regParams.ComputerName = $ComputerName } + $reg = Get-CimInstance @regParams + + switch ($Action) { + "Get" { + $res = Invoke-CimMethod -InputObject $reg -MethodName "GetStringValue" ` + -Arguments @{ hDefKey = $hDef; sSubKeyName = $KeyPath; sValueName = $ValueName } + if ($res.ReturnValue -eq 0) { return $res.sValue } + + # Fall back to DWORD if string lookup returns no result + $res = Invoke-CimMethod -InputObject $reg -MethodName "GetDWORDValue" ` + -Arguments @{ hDefKey = $hDef; sSubKeyName = $KeyPath; sValueName = $ValueName } + if ($res.ReturnValue -eq 0) { return $res.uValue } + + return $null + } + "Set" { + $methodName = "Set$($ValueType)Value" + + # $cimArgs renamed from $args — $args is a PowerShell automatic variable + if ($ValueType -eq "String") { + $cimArgs = @{ hDefKey = $hDef; sSubKeyName = $KeyPath; sValueName = $ValueName; sValue = [string]$Value } + } + elseif ($ValueType -eq "DWord") { + $cimArgs = @{ hDefKey = $hDef; sSubKeyName = $KeyPath; sValueName = $ValueName; uValue = [uint32]$Value } + } + elseif ($ValueType -eq "QWord") { + $cimArgs = @{ hDefKey = $hDef; sSubKeyName = $KeyPath; sValueName = $ValueName; uValue = [uint64]$Value } + } + else { + Write-Warning "ValueType '$ValueType' is not yet fully implemented." + return $false + } + + $res = Invoke-CimMethod -InputObject $reg -MethodName $methodName -Arguments $cimArgs + return ($res.ReturnValue -eq 0) + } + "New" { + $res = Invoke-CimMethod -InputObject $reg -MethodName "CreateKey" ` + -Arguments @{ hDefKey = $hDef; sSubKeyName = $KeyPath } + return ($res.ReturnValue -eq 0) + } + "Remove" { + if ($ValueName) { + $res = Invoke-CimMethod -InputObject $reg -MethodName "DeleteValue" ` + -Arguments @{ hDefKey = $hDef; sSubKeyName = $KeyPath; sValueName = $ValueName } + } + else { + $res = Invoke-CimMethod -InputObject $reg -MethodName "DeleteKey" ` + -Arguments @{ hDefKey = $hDef; sSubKeyName = $KeyPath } + } + return ($res.ReturnValue -eq 0) + } + } + } + catch { + Write-Warning "Registry operation failed on $ComputerName`: $_" + return $null + } + } +} diff --git a/LazyWinAdminModule/Private/Invoke-ComputerServiceControl.ps1 b/LazyWinAdminModule/Private/Invoke-ComputerServiceControl.ps1 new file mode 100644 index 0000000..e5afdf0 --- /dev/null +++ b/LazyWinAdminModule/Private/Invoke-ComputerServiceControl.ps1 @@ -0,0 +1,75 @@ +function Invoke-ComputerServiceControl { + <# + .SYNOPSIS + Starts, stops, or restarts a Windows service on a local or remote computer. + .DESCRIPTION + Verifies the service exists via Get-CimInstance, then invokes Win32_Service + CIM methods using a WQL query. Using -Query (not -InputObject) avoids the + CimInstance type constraint so the function is fully mockable in Pester tests. + Detects local targets and omits -ComputerName to avoid WinRM dependency on + the local machine (consistent with all other CIM-based private functions). + All three operations require local Administrator rights on the target machine. + .PARAMETER ComputerName + Target computer. Accepts 'localhost', '127.0.0.1', or $env:COMPUTERNAME for + direct in-process CIM access without WinRM. + .PARAMETER ServiceName + Short service name (e.g. 'wuauserv'), not the display name. + .PARAMETER Action + One of: Start, Stop, Restart. + .OUTPUTS + System.String — [OK] message on success, [!] on non-zero return code, Error: on exception. + .EXAMPLE + Invoke-ComputerServiceControl -ComputerName PC01 -ServiceName wuauserv -Action Restart + Returns "[OK] Service 'wuauserv' Restarted successfully on PC01." + .EXAMPLE + Invoke-ComputerServiceControl -ComputerName localhost -ServiceName spooler -Action Stop + Returns "[OK] Service 'spooler' Stopped successfully on localhost." + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory)] [string] $ComputerName, + [Parameter(Mandatory)] [string] $ServiceName, + [Parameter(Mandatory)] [ValidateSet('Start', 'Stop', 'Restart')] [string] $Action + ) + + $pastTense = @{ Start = 'Started'; Stop = 'Stopped'; Restart = 'Restarted' } + $isLocal = $ComputerName -iin @('localhost', '127.0.0.1', $env:COMPUTERNAME) + + try { + # Verify service exists before attempting to control it + $checkParams = @{ ClassName = 'Win32_Service'; Filter = "Name='$ServiceName'"; ErrorAction = 'Stop' } + if (-not $isLocal) { $checkParams.ComputerName = $ComputerName } + $svc = Get-CimInstance @checkParams + if (-not $svc) { + return "[!] Service '$ServiceName' not found on $ComputerName." + } + + # Use -Query to invoke the instance method. -Query accepts a plain string so + # Pester mocks can intercept the call without a real CimInstance. + $query = "SELECT * FROM Win32_Service WHERE Name='$ServiceName'" + $cimInvoke = @{ Query = $query; ErrorAction = 'Stop' } + if (-not $isLocal) { $cimInvoke.ComputerName = $ComputerName } + + if ($Action -eq 'Restart') { + $stop = Invoke-CimMethod @cimInvoke -MethodName 'StopService' + if ($stop.ReturnValue -ne 0) { + return "[!] Stop returned code $($stop.ReturnValue) for '$ServiceName' on $ComputerName." + } + Start-Sleep -Seconds 2 + $result = Invoke-CimMethod @cimInvoke -MethodName 'StartService' + } elseif ($Action -eq 'Start') { + $result = Invoke-CimMethod @cimInvoke -MethodName 'StartService' + } else { + $result = Invoke-CimMethod @cimInvoke -MethodName 'StopService' + } + + if ($result.ReturnValue -eq 0) { + return "[OK] Service '$ServiceName' $($pastTense[$Action]) successfully on $ComputerName." + } else { + return "[!] Service '$ServiceName' $Action returned code $($result.ReturnValue) on $ComputerName." + } + } + catch { + return "Error: $($_.Exception.Message)" + } +} diff --git a/LazyWinAdminModule/Private/Set-ComputerRDP.ps1 b/LazyWinAdminModule/Private/Set-ComputerRDP.ps1 new file mode 100644 index 0000000..161faae --- /dev/null +++ b/LazyWinAdminModule/Private/Set-ComputerRDP.ps1 @@ -0,0 +1,83 @@ +function Set-ComputerRDP { + <# + .SYNOPSIS + Enables or Disables Remote Desktop on a remote computer. + .DESCRIPTION + Implements rdp_toggle FLOW steps 3 and 4 as two distinct CIM operations + over a single shared CimSession: + + Step 3 [CORE] computer.set_rdp_registry + Sets HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server\fDenyTSConnections + via StdRegProv. Value 0 = RDP allowed, 1 = RDP denied. + + Step 4 [CORE] computer.set_firewall_rule + Enables or disables the "Remote Desktop" firewall rule group + via ROOT\StandardCimv2\MSFT_NetFirewallRule. + + Previously both operations were combined in a single SetAllowTSConnections CIM method + call. Splitting them makes each step independently verifiable and rollback-safe. + Adheres to: cim_session.* ALWAYS reuse-before-create (CONTRACTS) + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)] + [string]$ComputerName, + + [Parameter(Mandatory=$true)] + [bool]$Enabled + ) + + process { + $CimSession = $null + try { + $isLocal = $ComputerName -iin @('localhost', '127.0.0.1', $env:COMPUTERNAME) + $CimSession = if ($isLocal) { New-CimSession -ErrorAction Stop } else { New-CimSession -ComputerName $ComputerName -ErrorAction Stop } + + # --- STEP 3: computer.set_rdp_registry --- + # Toggle fDenyTSConnections in the Terminal Server registry key. + # 0 = connections allowed (RDP on), 1 = connections denied (RDP off). + $reg = Get-CimInstance -CimSession $CimSession -Namespace "root\default" ` + -ClassName StdRegProv -ErrorAction Stop + $fDenyValue = if ($Enabled) { [uint32]0 } else { [uint32]1 } + $regResult = Invoke-CimMethod -InputObject $reg -MethodName "SetDWORDValue" -Arguments @{ + hDefKey = [uint32]2147483650 # HKLM + sSubKeyName = "SYSTEM\CurrentControlSet\Control\Terminal Server" + sValueName = "fDenyTSConnections" + uValue = $fDenyValue + } -ErrorAction Stop + + if ($regResult.ReturnValue -ne 0) { + throw "Registry write failed with StdRegProv return code $($regResult.ReturnValue)" + } + + # --- STEP 4: computer.set_firewall_rule --- + # Enable or disable the "Remote Desktop" rule group in Windows Firewall + # via the MSFT_NetFirewallRule CIM class in ROOT\StandardCimv2. + # Enabled: uint16 1 = True, 0 = False. + $enabledVal = if ($Enabled) { [uint16]1 } else { [uint16]0 } + $fwRules = Get-CimInstance -CimSession $CimSession -Namespace "ROOT\StandardCimv2" ` + -ClassName "MSFT_NetFirewallRule" ` + -Filter "DisplayGroup='Remote Desktop'" -ErrorAction Stop + + if ($fwRules) { + foreach ($rule in $fwRules) { + Set-CimInstance -CimSession $CimSession -InputObject $rule ` + -Property @{ Enabled = $enabledVal } -ErrorAction Stop + } + } + + $action = if ($Enabled) { "Enabled" } else { "Disabled" } + return "RDP $action on $ComputerName" + } + catch { + Write-Warning "Error setting RDP status on $ComputerName`: $_" + # Return generic message — do not surface exception detail to UI layer + return "Error: RDP operation failed on $ComputerName" + } + finally { + if ($null -ne $CimSession) { + Remove-CimSession -CimSession $CimSession -ErrorAction SilentlyContinue + } + } + } +} diff --git a/LazyWinAdminModule/Private/Set-DeviceComplianceItem.ps1 b/LazyWinAdminModule/Private/Set-DeviceComplianceItem.ps1 new file mode 100644 index 0000000..40c80ac --- /dev/null +++ b/LazyWinAdminModule/Private/Set-DeviceComplianceItem.ps1 @@ -0,0 +1,119 @@ +function Set-DeviceComplianceItem { + <# + .SYNOPSIS + Remediates a local device compliance item. + .DESCRIPTION + Supported items: + LocationServices — clears policy blocks, sets consent to Allow, + restarts lfsvc (requires Administrator). + OutlookExternalImages — sets BlockExtContent = 0 in HKCU (no admin needed). + WindowsUpdateActiveHours — sets ActiveHoursStart/End in HKLM (requires Administrator). + RemoveOneDrive — uninstalls OneDrive, cleans registry and files + (requires Administrator). + .PARAMETER Item + The compliance item to remediate. + .PARAMETER ActiveHoursStart + Start of Windows Update active hours, 0–23 (default 8). Used only with + WindowsUpdateActiveHours. + .PARAMETER ActiveHoursEnd + End of Windows Update active hours, 0–23 (default 18). Used only with + WindowsUpdateActiveHours. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)] + [ValidateSet('LocationServices', 'OutlookExternalImages', 'WindowsUpdateActiveHours', 'RemoveOneDrive')] + [string]$Item, + + [ValidateRange(0, 23)] + [int]$ActiveHoursStart = 8, + + [ValidateRange(0, 23)] + [int]$ActiveHoursEnd = 18 + ) + + try { + switch ($Item) { + + 'LocationServices' { + $policyPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\LocationAndSensors' + $svcCfgPath = 'HKLM:\SYSTEM\CurrentControlSet\Services\lfsvc\Service\Configuration' + $consentPath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\location' + + if (-not (Test-Path $policyPath)) { New-Item -Path $policyPath -Force | Out-Null } + foreach ($flag in @('DisableLocation', 'DisableWindowsLocationProvider', 'DisableSensors')) { + Set-ItemProperty -Path $policyPath -Name $flag -Value 0 -Type DWord -Force + } + + if (-not (Test-Path $svcCfgPath)) { New-Item -Path $svcCfgPath -Force | Out-Null } + Set-ItemProperty -Path $svcCfgPath -Name 'Status' -Value 1 -Type DWord -Force + + if (-not (Test-Path $consentPath)) { New-Item -Path $consentPath -Force | Out-Null } + Set-ItemProperty -Path $consentPath -Name 'Value' -Value 'Allow' -Type String -Force + + # Set Allow for all currently loaded user hives + Get-ChildItem Registry::HKEY_USERS | + Where-Object { $_.Name -match 'S-1-5-21-\d+-\d+-\d+-\d+$' } | + ForEach-Object { + $userConsent = "Registry::HKEY_USERS\$($_.PSChildName)\SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\location" + if (-not (Test-Path $userConsent)) { New-Item -Path $userConsent -Force | Out-Null } + Set-ItemProperty -Path $userConsent -Name 'Value' -Value 'Allow' -Type String -Force + } + + try { Restart-Service -Name 'lfsvc' -Force -ErrorAction SilentlyContinue } catch {} + + return "[OK] Location Services enabled and consent set to Allow" + } + + 'OutlookExternalImages' { + $regPath = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Options\Mail' + if (-not (Test-Path $regPath)) { New-Item -Path $regPath -Force | Out-Null } + Set-ItemProperty -Path $regPath -Name 'BlockExtContent' -Value 0 -Type DWord -Force + return "[OK] Outlook external image download enabled (BlockExtContent = 0)" + } + + 'WindowsUpdateActiveHours' { + if ($ActiveHoursStart -eq $ActiveHoursEnd) { + return "[!] Active hours start and end cannot be the same value." + } + $regPath = 'HKLM:\SOFTWARE\Microsoft\WindowsUpdate\UX\Settings' + if (-not (Test-Path $regPath)) { New-Item -Path $regPath -Force | Out-Null } + Set-ItemProperty -Path $regPath -Name 'ActiveHoursStart' -Value $ActiveHoursStart -Type DWord -Force + Set-ItemProperty -Path $regPath -Name 'ActiveHoursEnd' -Value $ActiveHoursEnd -Type DWord -Force + return "[OK] Windows Update active hours set to $($ActiveHoursStart):00 - $($ActiveHoursEnd):00" + } + + 'RemoveOneDrive' { + Get-Process -Name OneDrive -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue + + foreach ($setup in @( + "$env:SystemRoot\System32\OneDriveSetup.exe", + "$env:SystemRoot\SysWOW64\OneDriveSetup.exe" + )) { + if (Test-Path $setup) { + Start-Process -FilePath $setup -ArgumentList '/uninstall' -NoNewWindow -Wait -ErrorAction SilentlyContinue + } + } + + # File cleanup + Remove-Item -Path "$env:LocalAppData\Microsoft\OneDrive" -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path "$env:AppData\Microsoft\OneDrive" -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path "$env:ProgramData\Microsoft OneDrive" -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path "C:\OneDriveTemp" -Recurse -Force -ErrorAction SilentlyContinue + + # Registry cleanup + Remove-Item -Path 'HKCU:\Software\Microsoft\OneDrive' -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path 'HKLM:\SOFTWARE\Microsoft\OneDrive' -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\OneDrive' -Recurse -Force -ErrorAction SilentlyContinue + Remove-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' ` + -Name 'OneDrive' -ErrorAction SilentlyContinue + + return "[OK] OneDrive removed and cleanup complete" + } + } + } + catch { + Write-Warning "Compliance remediation failed for '$Item': $_" + return "[!] Remediation failed for '$Item'. Some steps may require Administrator rights." + } +} diff --git a/LazyWinAdminModule/Private/Set-ExchangeMailboxPermission.ps1 b/LazyWinAdminModule/Private/Set-ExchangeMailboxPermission.ps1 new file mode 100644 index 0000000..7dee5ee --- /dev/null +++ b/LazyWinAdminModule/Private/Set-ExchangeMailboxPermission.ps1 @@ -0,0 +1,89 @@ +function Set-ExchangeMailboxPermission { + <# + .SYNOPSIS + Mirrors or grants mailbox permissions in Exchange Online. + .DESCRIPTION + Two modes: + Mirror — copies every FullAccess and SendAs permission held by SourceUser + across all shared mailboxes to TargetUser. Used for offboarding / + role handover scenarios. + Grant — grants FullAccess and SendAs on a single named mailbox to a user. + .PARAMETER Action + 'Mirror' or 'Grant'. + .PARAMETER SourceUser + UPN of the user whose permissions are copied (Mirror only). + .PARAMETER TargetUser + UPN of the user who receives the copied permissions (Mirror only). + .PARAMETER Mailbox + Primary SMTP address or alias of the target shared mailbox (Grant only). + .PARAMETER User + UPN of the user to grant permissions to (Grant only). + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)] + [ValidateSet('Mirror', 'Grant')] + [string]$Action, + + # Mirror parameters + [string]$SourceUser, + [string]$TargetUser, + + # Grant parameters + [string]$Mailbox, + [string]$User + ) + + try { + if ($Action -eq 'Mirror') { + if (-not $SourceUser -or -not $TargetUser) { + return "[!] Mirror requires both SourceUser and TargetUser." + } + + $sharedMailboxes = Get-Mailbox -RecipientTypeDetails SharedMailbox -ErrorAction Stop + $count = 0 + + foreach ($mb in $sharedMailboxes) { + $fa = Get-MailboxPermission -Identity $mb.Alias -ErrorAction SilentlyContinue | + Where-Object { $_.User -like $SourceUser -and $_.AccessRights -contains 'FullAccess' } + if ($fa) { + Add-MailboxPermission -Identity $mb.Alias -User $TargetUser ` + -AccessRights FullAccess -InheritanceType All -AutoMapping:$false ` + -ErrorAction SilentlyContinue | Out-Null + $count++ + } + + $sa = Get-RecipientPermission -Identity $mb.Alias -ErrorAction SilentlyContinue | + Where-Object { $_.Trustee -like $SourceUser -and $_.AccessRights -contains 'SendAs' } + if ($sa) { + Add-RecipientPermission -Identity $mb.Alias -Trustee $TargetUser ` + -AccessRights SendAs -Confirm:$false -ErrorAction SilentlyContinue | Out-Null + $count++ + } + } + + return if ($count -gt 0) { + "[OK] Mirrored $count permission(s) from $SourceUser to $TargetUser" + } else { + "[!] No shared mailbox permissions found for $SourceUser" + } + } + elseif ($Action -eq 'Grant') { + if (-not $Mailbox -or -not $User) { + return "[!] Grant requires both Mailbox and User." + } + + Add-MailboxPermission -Identity $Mailbox -User $User ` + -AccessRights FullAccess -InheritanceType All -AutoMapping:$false ` + -ErrorAction Stop | Out-Null + Add-RecipientPermission -Identity $Mailbox -Trustee $User ` + -AccessRights SendAs -Confirm:$false -ErrorAction Stop | Out-Null + + return "[OK] Granted FullAccess and SendAs on $Mailbox to $User" + } + } + catch { + Write-Warning "Exchange permission operation failed: $_" + return "[!] Operation failed. Verify Exchange Online connection and target mailbox/user." + } +} diff --git a/LazyWinAdminModule/Private/Test-ComputerPort.ps1 b/LazyWinAdminModule/Private/Test-ComputerPort.ps1 new file mode 100644 index 0000000..35ac115 --- /dev/null +++ b/LazyWinAdminModule/Private/Test-ComputerPort.ps1 @@ -0,0 +1,39 @@ +function Test-ComputerPort { + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true, ValueFromPipeline=$true)] + [string]$ComputerName, + + [int]$Port = 80, + + [int]$TimeoutMs = 2000 + ) + + process { + $tcpClient = $null + try { + $tcpClient = New-Object System.Net.Sockets.TcpClient + $connectTask = $tcpClient.BeginConnect($ComputerName, $Port, $null, $null) + + # Non-blocking wait with timeout to prevent hanging the runspace + $waitResult = $connectTask.AsyncWaitHandle.WaitOne($TimeoutMs, $false) + + if ($tcpClient.Connected) { + $tcpClient.EndConnect($connectTask) + return "Open" + } + else { + return "Closed/Filtered" + } + } + catch { + Write-Verbose "Port test failed for $ComputerName on port $Port : $_" + return "Error" + } + finally { + if ($null -ne $tcpClient) { + $tcpClient.Dispose() + } + } + } +} \ No newline at end of file diff --git a/LazyWinAdminModule/Public/Start-LazyWinAdmin.ps1 b/LazyWinAdminModule/Public/Start-LazyWinAdmin.ps1 new file mode 100644 index 0000000..c074954 --- /dev/null +++ b/LazyWinAdminModule/Public/Start-LazyWinAdmin.ps1 @@ -0,0 +1,1090 @@ +function Start-LazyWinAdmin { + <# + .SYNOPSIS + Starts the modernized WPF-based LazyWinAdmin GUI. + #> + [CmdletBinding()] + param () + + # Load required assemblies for WPF + Add-Type -AssemblyName PresentationFramework + Add-Type -AssemblyName PresentationCore + Add-Type -AssemblyName WindowsBase + + # Initialize State + $state = [LazyWinAdminState]::new() + + # Canonical path for all Private function files. + # Used to build InitializationScript values — never use Get-Content + Invoke-Expression. + $PrivatePath = (Resolve-Path (Join-Path $PSScriptRoot "..\Private")).Path + + try { + $xamlPath = Join-Path $PSScriptRoot "..\UI\MainView.xaml" + $xamlContent = Get-Content -Path $xamlPath -Raw + + $xmlDoc = [System.Xml.XmlDocument]::new() + $xmlDoc.LoadXml($xamlContent) + $xmlReader = [System.Xml.XmlNodeReader]::new($xmlDoc) + $window = [System.Windows.Markup.XamlReader]::Load($xmlReader) + + # --- FIND CONTROLS --- + $txtComputerName = $window.FindName("txtComputerName") + $btnPing = $window.FindName("btnPing") + $btnUptime = $window.FindName("btnUptime") + $btnEnableRdp = $window.FindName("btnEnableRdp") + $btnDisableRdp = $window.FindName("btnDisableRdp") + $txtOutput = $window.FindName("txtOutput") + + $btnGetServices = $window.FindName("btnGetServices") + $btnGetStoppedAuto = $window.FindName("btnGetStoppedAuto") + $lvServices = $window.FindName("lvServices") + $txtServiceSearch = $window.FindName("txtServiceSearch") + + $btnGetSoftware = $window.FindName("btnGetSoftware") + $lvSoftware = $window.FindName("lvSoftware") + $txtSoftwareSearch = $window.FindName("txtSoftwareSearch") + + $btnGetHardware = $window.FindName("btnGetHardware") + $txtHwModel = $window.FindName("txtHwModel") + $txtHwSerial = $window.FindName("txtHwSerial") + $txtHwCpu = $window.FindName("txtHwCpu") + $txtHwRam = $window.FindName("txtHwRam") + $txtHwOs = $window.FindName("txtHwOs") + $txtHwMobo = $window.FindName("txtHwMobo") + $lvHwDisks = $window.FindName("lvHwDisks") + + $btnGetNetwork = $window.FindName("btnGetNetwork") + $chkOnlyIPEnabled = $window.FindName("chkOnlyIPEnabled") + $lvNetwork = $window.FindName("lvNetwork") + + $btnGetLocalUsers = $window.FindName("btnGetLocalUsers") + $btnGetLocalGroups = $window.FindName("btnGetLocalGroups") + $lvLocalAccounts = $window.FindName("lvLocalAccounts") + $btnGetEntraUsers = $window.FindName("btnGetEntraUsers") + $btnGetEntraGroups = $window.FindName("btnGetEntraGroups") + $lvEntraIdentity = $window.FindName("lvEntraIdentity") + $txtEntraSearch = $window.FindName("txtEntraSearch") + + $btnGetIntuneDevices = $window.FindName("btnGetIntuneDevices") + $lvIntuneDevices = $window.FindName("lvIntuneDevices") + $txtIntuneSearch = $window.FindName("txtIntuneSearch") + $btnGetAzureSummary = $window.FindName("btnGetAzureSummary") + $lvAzureResources = $window.FindName("lvAzureResources") + + # Intune Scripts controls + $btnGetIntuneScripts = $window.FindName("btnGetIntuneScripts") + $txtIntuneScriptSearch = $window.FindName("txtIntuneScriptSearch") + $txtIntuneScriptDownloadPath = $window.FindName("txtIntuneScriptDownloadPath") + $btnDownloadIntuneScripts = $window.FindName("btnDownloadIntuneScripts") + $lvIntuneScripts = $window.FindName("lvIntuneScripts") + + # Device Compliance controls + $btnCheckCompliance = $window.FindName("btnCheckCompliance") + $lvComplianceStatus = $window.FindName("lvComplianceStatus") + $btnFixLocation = $window.FindName("btnFixLocation") + $btnFixOutlookImages = $window.FindName("btnFixOutlookImages") + $btnRemoveOneDrive = $window.FindName("btnRemoveOneDrive") + $txtUpdateHoursStart = $window.FindName("txtUpdateHoursStart") + $txtUpdateHoursEnd = $window.FindName("txtUpdateHoursEnd") + $btnFixUpdateHours = $window.FindName("btnFixUpdateHours") + $txtComplianceOutput = $window.FindName("txtComplianceOutput") + + # Exchange controls + $txtExchangeUpn = $window.FindName("txtExchangeUpn") + $btnConnectExchange = $window.FindName("btnConnectExchange") + $lblExchangeStatus = $window.FindName("lblExchangeStatus") + $btnGetMailboxPerms = $window.FindName("btnGetMailboxPerms") + $txtExchangeViewUser = $window.FindName("txtExchangeViewUser") + $lvMailboxPerms = $window.FindName("lvMailboxPerms") + $txtExchangeSourceUser = $window.FindName("txtExchangeSourceUser") + $txtExchangeTargetUser = $window.FindName("txtExchangeTargetUser") + $btnMirrorMailboxPerms = $window.FindName("btnMirrorMailboxPerms") + $txtExchangeMailbox = $window.FindName("txtExchangeMailbox") + $txtExchangeGrantUser = $window.FindName("txtExchangeGrantUser") + $btnGrantMailboxPerms = $window.FindName("btnGrantMailboxPerms") + + $btnRegRead = $window.FindName("btnRegRead") + $btnRegWrite = $window.FindName("btnRegWrite") + $btnRegDelete = $window.FindName("btnRegDelete") + $cbRegHive = $window.FindName("cbRegHive") + $cbRegType = $window.FindName("cbRegType") + $txtRegValueName = $window.FindName("txtRegValueName") + $txtRegValueData = $window.FindName("txtRegValueData") + $txtRegPath = $window.FindName("txtRegPath") + $txtRegResult = $window.FindName("txtRegResult") + + $btnGetAdComputer = $window.FindName("btnGetAdComputer") + $btnGetAdUsers = $window.FindName("btnGetAdUsers") + $btnGetAdGroups = $window.FindName("btnGetAdGroups") + $txtAdSearch = $window.FindName("txtAdSearch") + $lvAdResults = $window.FindName("lvAdResults") + + $btnCloudLogin = $window.FindName("btnCloudLogin") + $lblCloudStatus = $window.FindName("lblCloudStatus") + $txtTenantId = $window.FindName("txtTenantId") + $txtClientId = $window.FindName("txtClientId") + $txtClientSecret = $window.FindName("txtClientSecret") + $btnCloudConnectSP = $window.FindName("btnCloudConnectSP") + + $lblStatus = $window.FindName("lblStatus") + $lblAdminStatus = $window.FindName("lblAdminStatus") + $btnRestartAdmin = $window.FindName("btnRestartAdmin") + $pbBusy = $window.FindName("pbBusy") + $lblTime = $window.FindName("lblTime") + + # Service control and export controls (v1.3.0) + $lblServicesCount = $window.FindName("lblServicesCount") + $btnStartService = $window.FindName("btnStartService") + $btnStopService = $window.FindName("btnStopService") + $btnRestartService = $window.FindName("btnRestartService") + $btnExportServices = $window.FindName("btnExportServices") + $lblSoftwareCount = $window.FindName("lblSoftwareCount") + $btnExportSoftware = $window.FindName("btnExportSoftware") + $lblNetworkCount = $window.FindName("lblNetworkCount") + $btnExportNetwork = $window.FindName("btnExportNetwork") + $lblIntuneDevicesCount = $window.FindName("lblIntuneDevicesCount") + $btnExportIntuneDevices = $window.FindName("btnExportIntuneDevices") + $lblMailboxPermsCount = $window.FindName("lblMailboxPermsCount") + $btnExportMailboxPerms = $window.FindName("btnExportMailboxPerms") + + # --- ADMIN ELEVATION CHECK --- + # Detect whether this process is running with local administrator rights. + # Stored as a plain bool — read-only, UI thread only, no sync needed. + $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + + if ($isAdmin) { + $lblAdminStatus.Text = 'Administrator' + $lblAdminStatus.Foreground = [System.Windows.Media.Brushes]::Green + $btnRestartAdmin.Visibility = [System.Windows.Visibility]::Collapsed + $window.Title = 'LazyWinAdmin - Modernized 2026 [Administrator]' + } + else { + # lblAdminStatus and btnRestartAdmin keep their XAML defaults (visible + amber) + $window.Title = 'LazyWinAdmin - Modernized 2026' + } + + # Relaunch this session elevated. Resolves the module manifest path relative to + # this script file so the new elevated window loads the same module. + $btnRestartAdmin.Add_Click({ + $psd1 = Resolve-Path (Join-Path $PSScriptRoot '..\LazyWinAdminModule.psd1') + try { + Start-Process -FilePath 'pwsh.exe' ` + -ArgumentList "-NoProfile -Command `"Import-Module '$psd1' -Force; Start-LazyWinAdmin`"" ` + -Verb RunAs + $window.Close() + } + catch { + # User cancelled the UAC prompt — just show a status note. + $lblStatus.Text = '[!] Elevation cancelled.' + } + }) + + # Default Value + $txtComputerName.Text = $env:COMPUTERNAME + + # --- UI HELPERS --- + # CheckAccess() returns $true when already on the UI thread — execute directly. + # From a background / event thread it returns $false — marshal via Dispatcher.Invoke. + # Skipping the Invoke when already on the UI thread prevents re-entrant + # DispatcherFrame pushes that cause the application to freeze. + $AppendOutput = { + param($text) + if ($window.Dispatcher.CheckAccess()) { + $txtOutput.AppendText("$text`n") + $txtOutput.ScrollToEnd() + } else { + $window.Dispatcher.Invoke([action]{ + $txtOutput.AppendText("$text`n") + $txtOutput.ScrollToEnd() + }) + } + } + + $SetBusy = { + param([bool]$isBusy) + # Track busy state in SyncHash so event-thread code can read it without touching UI + $state.SyncHash.IsBusy = $isBusy + if ($window.Dispatcher.CheckAccess()) { + $pbBusy.IsIndeterminate = $isBusy + $lblStatus.Text = if ($isBusy) { "Working..." } else { "Ready" } + } else { + $window.Dispatcher.Invoke([action]{ + $pbBusy.IsIndeterminate = $isBusy + $lblStatus.Text = if ($isBusy) { "Working..." } else { "Ready" } + }) + } + } + + # --- PRE-FLIGHT GUARDS --- + # These run synchronously on the UI thread before any async dispatch. + # Return $false to abort the action; the handler should 'return' immediately. + + # Checks that the user has successfully authenticated against Entra ID / Azure. + # Must be called before any button handler that invokes a CLOUD-layer function. + $RequireCloudSession = { + if (-not $state.SyncHash.CloudConnected) { + $lblStatus.Text = "[!] Cloud authentication required — connect on the Cloud tab first." + $AppendOutput.Invoke("[!] Action blocked: not authenticated. Use the Cloud tab to connect to Entra ID / Azure first.") + return $false + } + return $true + } + + # Checks that a non-empty computer name has been entered. + # Must be called before any button handler that opens a CIM session. + $RequireComputerName = { + if ([string]::IsNullOrWhiteSpace($txtComputerName.Text)) { + $lblStatus.Text = "[!] No computer name — enter a target in the Computer Name field." + $AppendOutput.Invoke("[!] Action blocked: enter a Computer Name before running this action.") + return $false + } + return $true + } + + # Checks that Exchange Online is connected. + # Must be called before any button handler that calls Exchange cmdlets. + $RequireExchangeSession = { + if (-not $state.SyncHash.ExchangeConnected) { + $lblStatus.Text = "[!] Exchange not connected — use the Exchange › Connection tab first." + return $false + } + return $true + } + + # Appends an admin-rights hint to the output box when all three are true: + # 1. The operation returned no results (null/empty data) + # 2. The process is not elevated + # 3. The target is the local machine (localhost / 127.0.0.1 / $env:COMPUTERNAME) + # Called from OnCompleted handlers for features that require local admin. + $AdminHint = { + param([string]$computer) + $isLocalTarget = ($computer -ieq 'localhost' -or + $computer -ieq '127.0.0.1' -or + $computer -ieq $env:COMPUTERNAME) + if (-not $isAdmin -and $isLocalTarget) { + $AppendOutput.Invoke( + "[!] No results returned. This feature requires local Administrator rights." + + " Use the 'Restart as Admin' button in the status bar to relaunch elevated." + ) + } + } + + # --- ASYNC HELPER --- + # Critical changes from previous version: + # + # 1. REMOVED: Get-Content + Invoke-Expression pattern. + # Private functions are now loaded into the thread job runspace via -InitializationScript, + # which dot-sources the file at the known path. The file path is embedded at call-site + # construction time (never from user input), eliminating the code-injection vector. + # + # 2. REMOVED: Watcher job (Start-ThreadJob wrapping Wait-Job). + # Replaced with Register-ObjectEvent on the job's StateChanged event. + # The event fires on the PowerShell event thread — zero watcher threads. + # Dispatcher.Invoke marshals UI updates back to the WPF thread correctly. + # + # 3. RENAMED: param($p, $s) -> param($__p__, $__action__). + # The original names collided with caller-supplied parameter keys + # (e.g. key 's' for search) unpacked by Set-Variable, overwriting $s + # before & $s could invoke the scriptblock. + function Invoke-AsyncAction { + param( + [scriptblock]$ScriptBlock, + [hashtable]$Parameters = @{}, + [scriptblock]$OnCompleted, + [scriptblock]$InitializationScript = {} + ) + + $SetBusy.Invoke($true) + + try { + $job = Start-ThreadJob ` + -InitializationScript $InitializationScript ` + -ArgumentList $Parameters, $ScriptBlock -ScriptBlock { + param($__p__, $__action__) + foreach ($key in $__p__.Keys) { Set-Variable -Name $key -Value $__p__[$key] } + & $__action__ + } + } + catch { + # Runs on UI thread — update directly, no Dispatcher.Invoke needed. + $lblStatus.Text = "[!] Could not start background job: $_" + $pbBusy.IsIndeterminate = $false + $state.SyncHash.IsBusy = $false + return + } + + # When the job finishes, enqueue the result for the DispatcherTimer to process + # on the WPF UI thread. This avoids Dispatcher.Invoke/InvokeAsync entirely, + # eliminating all re-entrant DispatcherFrame and cross-thread closure issues. + Register-ObjectEvent -InputObject $job -EventName StateChanged -MessageData @{ + Job = $job + Queue = $state.SyncHash.UIQueue + Callback = $OnCompleted + BusyFn = $SetBusy + } -Action { + $jobState = $Event.SourceArgs[0].JobStateInfo.State + if ($jobState -notin 'Completed', 'Failed', 'Stopped') { return } + + $res = Receive-Job -Job $Event.MessageData.Job -ErrorAction SilentlyContinue + $evtSrc = $EventSubscriber.SourceIdentifier + $evtJob = $Event.MessageData.Job + + $Event.MessageData.Queue.Enqueue([PSCustomObject]@{ + Callback = $Event.MessageData.Callback + Result = $res + BusyFn = $Event.MessageData.BusyFn + }) + + Unregister-Event -SourceIdentifier $evtSrc -ErrorAction SilentlyContinue + Remove-Job -Job $evtJob -Force -ErrorAction SilentlyContinue + } | Out-Null + } + + # --- GOVERNANCE HANDLERS --- + $btnGetIntuneDevices.Add_Click({ + if (-not ($RequireCloudSession.Invoke())) { return } + $search = $txtIntuneSearch.Text + Invoke-AsyncAction ` + -InitializationScript ([scriptblock]::Create(". '$PrivatePath\Get-IntuneDevice.ps1'")) ` + -Parameters @{ s = $search } ` + -ScriptBlock { Get-IntuneDevice -Search $s } ` + -OnCompleted { + param($data) + $lvIntuneDevices.Items.Clear() + $data | ForEach-Object { $lvIntuneDevices.Items.Add($_) } + $lblIntuneDevicesCount.Text = "$($lvIntuneDevices.Items.Count) device(s)" + } + }) + + $btnGetAzureSummary.Add_Click({ + if (-not ($RequireCloudSession.Invoke())) { return } + Invoke-AsyncAction ` + -InitializationScript ([scriptblock]::Create(". '$PrivatePath\Get-AzureResourceSummary.ps1'")) ` + -ScriptBlock { Get-AzureResourceSummary } ` + -OnCompleted { + param($data) + $lvAzureResources.Items.Clear() + $data | ForEach-Object { $lvAzureResources.Items.Add($_) } + } + }) + + # --- IDENTITY HANDLERS (LOCAL) --- + $btnGetLocalUsers.Add_Click({ + if (-not ($RequireComputerName.Invoke())) { return } + $comp = $txtComputerName.Text + Invoke-AsyncAction ` + -InitializationScript ([scriptblock]::Create(". '$PrivatePath\Get-ComputerLocalUser.ps1'")) ` + -Parameters @{ t = $comp } ` + -ScriptBlock { Get-ComputerLocalUser -ComputerName $t } ` + -OnCompleted { + param($data) + $lvLocalAccounts.Items.Clear() + $data | ForEach-Object { $lvLocalAccounts.Items.Add($_) } + } + }) + + $btnGetLocalGroups.Add_Click({ + if (-not ($RequireComputerName.Invoke())) { return } + $comp = $txtComputerName.Text + Invoke-AsyncAction ` + -InitializationScript ([scriptblock]::Create(". '$PrivatePath\Get-ComputerLocalGroup.ps1'")) ` + -Parameters @{ t = $comp } ` + -ScriptBlock { Get-ComputerLocalGroup -ComputerName $t } ` + -OnCompleted { + param($data) + $lvLocalAccounts.Items.Clear() + $data | ForEach-Object { $lvLocalAccounts.Items.Add($_) } + } + }) + + # --- IDENTITY HANDLERS (ENTRA) --- + $btnGetEntraUsers.Add_Click({ + if (-not ($RequireCloudSession.Invoke())) { return } + $search = $txtEntraSearch.Text + Invoke-AsyncAction ` + -InitializationScript ([scriptblock]::Create(". '$PrivatePath\Get-EntraIdentity.ps1'")) ` + -Parameters @{ s = $search } ` + -ScriptBlock { Get-EntraIdentity -Type "User" -Search $s } ` + -OnCompleted { + param($data) + $lvEntraIdentity.Items.Clear() + $data | ForEach-Object { $lvEntraIdentity.Items.Add($_) } + } + }) + + $btnGetEntraGroups.Add_Click({ + if (-not ($RequireCloudSession.Invoke())) { return } + $search = $txtEntraSearch.Text + Invoke-AsyncAction ` + -InitializationScript ([scriptblock]::Create(". '$PrivatePath\Get-EntraIdentity.ps1'")) ` + -Parameters @{ s = $search } ` + -ScriptBlock { Get-EntraIdentity -Type "Group" -Search $s } ` + -OnCompleted { + param($data) + $lvEntraIdentity.Items.Clear() + $data | ForEach-Object { + $item = $_ + if ($item.Description) { $item.UserPrincipalName = $item.Description } + $lvEntraIdentity.Items.Add($item) + } + } + }) + + # --- SYSTEM HANDLERS --- + $btnPing.Add_Click({ + if (-not ($RequireComputerName.Invoke())) { return } + $comp = $txtComputerName.Text + Invoke-AsyncAction ` + -InitializationScript ([scriptblock]::Create(". '$PrivatePath\Test-ComputerPort.ps1'")) ` + -Parameters @{ t = $comp } ` + -ScriptBlock { + # Test WinRM port 5985 — ICMP ping alone does not confirm remote management + # is available. This is the actual transport CIM uses. + $result = Test-ComputerPort -ComputerName $t -Port 5985 -TimeoutMs 3000 + if ($result -eq 'Open') { "[OK] $t online (WinRM port 5985 reachable)" } + else { "[!] $t — WinRM port 5985 not reachable ($result)" } + } ` + -OnCompleted { + param($res) $AppendOutput.Invoke($res) + } + }) + + $btnUptime.Add_Click({ + if (-not ($RequireComputerName.Invoke())) { return } + $comp = $txtComputerName.Text + Invoke-AsyncAction ` + -InitializationScript ([scriptblock]::Create(". '$PrivatePath\Get-ComputerUptime.ps1'")) ` + -Parameters @{ t = $comp } ` + -ScriptBlock { Get-ComputerUptime -ComputerName $t } ` + -OnCompleted { + param($res) $AppendOutput.Invoke("[UPTIME] $res") + } + }) + + $btnEnableRdp.Add_Click({ + if (-not ($RequireComputerName.Invoke())) { return } + $comp = $txtComputerName.Text + Invoke-AsyncAction ` + -InitializationScript ([scriptblock]::Create(". '$PrivatePath\Set-ComputerRDP.ps1'")) ` + -Parameters @{ t = $comp } ` + -ScriptBlock { Set-ComputerRDP -ComputerName $t -Enabled $true } ` + -OnCompleted { + param($res) + $AppendOutput.Invoke("[RDP] $res") + if ($res -match '^Error:') { $AdminHint.Invoke($comp) } + } + }) + + $btnDisableRdp.Add_Click({ + if (-not ($RequireComputerName.Invoke())) { return } + $comp = $txtComputerName.Text + Invoke-AsyncAction ` + -InitializationScript ([scriptblock]::Create(". '$PrivatePath\Set-ComputerRDP.ps1'")) ` + -Parameters @{ t = $comp } ` + -ScriptBlock { Set-ComputerRDP -ComputerName $t -Enabled $false } ` + -OnCompleted { + param($res) + $AppendOutput.Invoke("[RDP] $res") + if ($res -match '^Error:') { $AdminHint.Invoke($comp) } + } + }) + + # --- SERVICE HANDLERS --- + $btnGetServices.Add_Click({ + if (-not ($RequireComputerName.Invoke())) { return } + $comp = $txtComputerName.Text + $search = $txtServiceSearch.Text + Invoke-AsyncAction ` + -InitializationScript ([scriptblock]::Create(". '$PrivatePath\Get-ComputerService.ps1'")) ` + -Parameters @{ t = $comp; s = $search } ` + -ScriptBlock { Get-ComputerService -ComputerName $t -Name $s } ` + -OnCompleted { + param($data) + $lvServices.Items.Clear() + $data | ForEach-Object { $lvServices.Items.Add($_) } + $lblServicesCount.Text = "$($lvServices.Items.Count) service(s)" + } + }) + + $btnGetStoppedAuto.Add_Click({ + if (-not ($RequireComputerName.Invoke())) { return } + $comp = $txtComputerName.Text + Invoke-AsyncAction ` + -InitializationScript ([scriptblock]::Create(". '$PrivatePath\Get-ComputerService.ps1'")) ` + -Parameters @{ t = $comp } ` + -ScriptBlock { Get-ComputerService -ComputerName $t -OnlyAutoStopped } ` + -OnCompleted { + param($data) + $lvServices.Items.Clear() + $data | ForEach-Object { $lvServices.Items.Add($_) } + $lblServicesCount.Text = "$($lvServices.Items.Count) service(s)" + } + }) + + $btnStartService.Add_Click({ + if (-not ($RequireComputerName.Invoke())) { return } + $selected = $lvServices.SelectedItem + if ($null -eq $selected) { + $lblStatus.Text = "[!] Select a service from the list first." + return + } + $comp = $txtComputerName.Text + $svcName = $selected.Name + Invoke-AsyncAction ` + -InitializationScript ([scriptblock]::Create(". '$PrivatePath\Invoke-ComputerServiceControl.ps1'")) ` + -Parameters @{ computerName = $comp; serviceName = $svcName; action = 'Start' } ` + -ScriptBlock { Invoke-ComputerServiceControl -ComputerName $computerName -ServiceName $serviceName -Action $action } ` + -OnCompleted { + param($res) + $AppendOutput.Invoke("[Service] $res") + $lblStatus.Text = $res + if ($res -match '^Error:') { $AdminHint.Invoke($comp) } + } + }) + + $btnStopService.Add_Click({ + if (-not ($RequireComputerName.Invoke())) { return } + $selected = $lvServices.SelectedItem + if ($null -eq $selected) { + $lblStatus.Text = "[!] Select a service from the list first." + return + } + $comp = $txtComputerName.Text + $svcName = $selected.Name + Invoke-AsyncAction ` + -InitializationScript ([scriptblock]::Create(". '$PrivatePath\Invoke-ComputerServiceControl.ps1'")) ` + -Parameters @{ computerName = $comp; serviceName = $svcName; action = 'Stop' } ` + -ScriptBlock { Invoke-ComputerServiceControl -ComputerName $computerName -ServiceName $serviceName -Action $action } ` + -OnCompleted { + param($res) + $AppendOutput.Invoke("[Service] $res") + $lblStatus.Text = $res + if ($res -match '^Error:') { $AdminHint.Invoke($comp) } + } + }) + + $btnRestartService.Add_Click({ + if (-not ($RequireComputerName.Invoke())) { return } + $selected = $lvServices.SelectedItem + if ($null -eq $selected) { + $lblStatus.Text = "[!] Select a service from the list first." + return + } + $comp = $txtComputerName.Text + $svcName = $selected.Name + Invoke-AsyncAction ` + -InitializationScript ([scriptblock]::Create(". '$PrivatePath\Invoke-ComputerServiceControl.ps1'")) ` + -Parameters @{ computerName = $comp; serviceName = $svcName; action = 'Restart' } ` + -ScriptBlock { Invoke-ComputerServiceControl -ComputerName $computerName -ServiceName $serviceName -Action $action } ` + -OnCompleted { + param($res) + $AppendOutput.Invoke("[Service] $res") + $lblStatus.Text = $res + if ($res -match '^Error:') { $AdminHint.Invoke($comp) } + } + }) + + # --- SOFTWARE HANDLERS --- + $btnGetSoftware.Add_Click({ + if (-not ($RequireComputerName.Invoke())) { return } + $comp = $txtComputerName.Text + $search = $txtSoftwareSearch.Text + Invoke-AsyncAction ` + -InitializationScript ([scriptblock]::Create(". '$PrivatePath\Get-ComputerSoftware.ps1'")) ` + -Parameters @{ t = $comp; s = $search } ` + -ScriptBlock { Get-ComputerSoftware -ComputerName $t -Search $s } ` + -OnCompleted { + param($data) + $lvSoftware.Items.Clear() + $data | ForEach-Object { $lvSoftware.Items.Add($_) } + $lblSoftwareCount.Text = "$($lvSoftware.Items.Count) application(s)" + } + }) + + # --- HARDWARE HANDLERS --- + $btnGetHardware.Add_Click({ + if (-not ($RequireComputerName.Invoke())) { return } + $comp = $txtComputerName.Text + $hwInitScript = [scriptblock]::Create( + ". '$PrivatePath\Get-ComputerHardware.ps1'; . '$PrivatePath\Get-ComputerMotherboard.ps1'" + ) + Invoke-AsyncAction ` + -InitializationScript $hwInitScript ` + -Parameters @{ t = $comp } ` + -ScriptBlock { + $hw = Get-ComputerHardware -ComputerName $t + $mobo = Get-ComputerMotherboard -ComputerName $t + return @{ hw = $hw; mobo = $mobo } + } ` + -OnCompleted { + param($data) + if ($data.hw) { + $hw = $data.hw + $txtHwModel.Text = "$($hw.Manufacturer) $($hw.Model)" + $txtHwSerial.Text = $hw.SerialNumber + $txtHwCpu.Text = $hw.CPU + $txtHwRam.Text = "$($hw.RAM_GB) GB" + $txtHwOs.Text = $hw.OS + $lvHwDisks.Items.Clear() + $hw.Disks | ForEach-Object { $lvHwDisks.Items.Add($_) } + } + else { + $AdminHint.Invoke($comp) + } + if ($data.mobo) { + $txtHwMobo.Text = "$($data.mobo.Product) ($($data.mobo.SerialNumber))" + } + } + }) + + # --- NETWORK HANDLERS --- + $btnGetNetwork.Add_Click({ + if (-not ($RequireComputerName.Invoke())) { return } + $comp = $txtComputerName.Text + $onlyIP = $chkOnlyIPEnabled.IsChecked + Invoke-AsyncAction ` + -InitializationScript ([scriptblock]::Create(". '$PrivatePath\Get-ComputerNetwork.ps1'")) ` + -Parameters @{ t = $comp; o = $onlyIP } ` + -ScriptBlock { Get-ComputerNetwork -ComputerName $t -OnlyIPEnabled $o } ` + -OnCompleted { + param($data) + $lvNetwork.Items.Clear() + $data | ForEach-Object { $lvNetwork.Items.Add($_) } + $lblNetworkCount.Text = "$($lvNetwork.Items.Count) adapter(s)" + } + }) + + # --- REGISTRY HANDLERS --- + $btnRegRead.Add_Click({ + if (-not ($RequireComputerName.Invoke())) { return } + $comp = $txtComputerName.Text + $hive = $cbRegHive.Text + $path = $txtRegPath.Text + $val = $txtRegValueName.Text + Invoke-AsyncAction ` + -InitializationScript ([scriptblock]::Create(". '$PrivatePath\Invoke-ComputerRegistry.ps1'")) ` + -Parameters @{ t = $comp; h = $hive; p = $path; v = $val } ` + -ScriptBlock { Invoke-ComputerRegistry -Action "Get" -ComputerName $t -Hive $h -KeyPath $p -ValueName $v } ` + -OnCompleted { + param($res) + $txtRegResult.Text = if ($null -ne $res) { "Value: $res" } else { "Value not found or error." } + } + }) + + $btnRegWrite.Add_Click({ + if (-not ($RequireComputerName.Invoke())) { return } + $comp = $txtComputerName.Text + $hive = $cbRegHive.Text + $path = $txtRegPath.Text + $val = $txtRegValueName.Text + $data = $txtRegValueData.Text + $type = $cbRegType.Text + Invoke-AsyncAction ` + -InitializationScript ([scriptblock]::Create(". '$PrivatePath\Invoke-ComputerRegistry.ps1'")) ` + -Parameters @{ t = $comp; h = $hive; p = $path; v = $val; d = $data; ty = $type } ` + -ScriptBlock { Invoke-ComputerRegistry -Action "Set" -ComputerName $t -Hive $h -KeyPath $p -ValueName $v -Value $d -ValueType $ty } ` + -OnCompleted { + param($res) + if ($res) { + $txtRegResult.Text = "Success: Value written." + } + else { + $txtRegResult.Text = "Error: Failed to write value." + $AdminHint.Invoke($comp) + } + } + }) + + $btnRegDelete.Add_Click({ + if (-not ($RequireComputerName.Invoke())) { return } + $comp = $txtComputerName.Text + $hive = $cbRegHive.Text + $path = $txtRegPath.Text + $val = $txtRegValueName.Text + Invoke-AsyncAction ` + -InitializationScript ([scriptblock]::Create(". '$PrivatePath\Invoke-ComputerRegistry.ps1'")) ` + -Parameters @{ t = $comp; h = $hive; p = $path; v = $val } ` + -ScriptBlock { Invoke-ComputerRegistry -Action "Remove" -ComputerName $t -Hive $h -KeyPath $p -ValueName $v } ` + -OnCompleted { + param($res) + if ($res) { + $txtRegResult.Text = "Success: Item removed." + } + else { + $txtRegResult.Text = "Error: Failed to remove item." + $AdminHint.Invoke($comp) + } + } + }) + + # --- ACTIVE DIRECTORY HANDLERS --- + # Requires RSAT (rsat-ad-ds) on the machine running LazyWinAdmin. + # AdFilter is the canonical search term — single-letter variables are forbidden by VOCABULARY. + $btnGetAdComputer.Add_Click({ + $comp = $txtComputerName.Text + $adFilter = $txtAdSearch.Text + Invoke-AsyncAction ` + -InitializationScript ([scriptblock]::Create(". '$PrivatePath\Get-ComputerADInfo.ps1'")) ` + -Parameters @{ t = $comp; af = $adFilter } ` + -ScriptBlock { Get-ComputerADInfo -Type "Computer" -ComputerName $t -AdFilter $af } ` + -OnCompleted { + param($data) + $lvAdResults.Items.Clear() + $data | ForEach-Object { $lvAdResults.Items.Add($_) } + } + }) + + $btnGetAdUsers.Add_Click({ + $adFilter = $txtAdSearch.Text + Invoke-AsyncAction ` + -InitializationScript ([scriptblock]::Create(". '$PrivatePath\Get-ComputerADInfo.ps1'")) ` + -Parameters @{ af = $adFilter } ` + -ScriptBlock { Get-ComputerADInfo -Type "User" -AdFilter $af } ` + -OnCompleted { + param($data) + $lvAdResults.Items.Clear() + $data | ForEach-Object { $lvAdResults.Items.Add($_) } + } + }) + + $btnGetAdGroups.Add_Click({ + $adFilter = $txtAdSearch.Text + Invoke-AsyncAction ` + -InitializationScript ([scriptblock]::Create(". '$PrivatePath\Get-ComputerADInfo.ps1'")) ` + -Parameters @{ af = $adFilter } ` + -ScriptBlock { Get-ComputerADInfo -Type "Group" -AdFilter $af } ` + -OnCompleted { + param($data) + $lvAdResults.Items.Clear() + $data | ForEach-Object { $lvAdResults.Items.Add($_) } + } + }) + + # --- INTUNE SCRIPTS HANDLERS --- + $btnGetIntuneScripts.Add_Click({ + if (-not ($RequireCloudSession.Invoke())) { return } + $search = $txtIntuneScriptSearch.Text + Invoke-AsyncAction ` + -InitializationScript ([scriptblock]::Create(". '$PrivatePath\Get-IntuneManagementScript.ps1'")) ` + -Parameters @{ s = $search } ` + -ScriptBlock { Get-IntuneManagementScript -Search $s } ` + -OnCompleted { + param($data) + $lvIntuneScripts.Items.Clear() + if ($data) { + $data | ForEach-Object { $lvIntuneScripts.Items.Add($_) } + $AppendOutput.Invoke("[Intune Scripts] $($data.Count) script(s) listed.") + } else { + $AppendOutput.Invoke("[Intune Scripts] No scripts found or not connected to Graph.") + } + } + }) + + $btnDownloadIntuneScripts.Add_Click({ + if (-not ($RequireCloudSession.Invoke())) { return } + $search = $txtIntuneScriptSearch.Text + $downloadPath = $txtIntuneScriptDownloadPath.Text + Invoke-AsyncAction ` + -InitializationScript ([scriptblock]::Create(". '$PrivatePath\Get-IntuneManagementScript.ps1'")) ` + -Parameters @{ s = $search; dp = $downloadPath } ` + -ScriptBlock { Get-IntuneManagementScript -Search $s -DownloadPath $dp } ` + -OnCompleted { + param($data) + $lvIntuneScripts.Items.Clear() + if ($data) { + $data | ForEach-Object { $lvIntuneScripts.Items.Add($_) } + $AppendOutput.Invoke("[Intune Scripts] $($data.Count) script(s) downloaded to $downloadPath.") + } else { + $AppendOutput.Invoke("[Intune Scripts] No scripts found or not connected to Graph.") + } + } + }) + + # --- DEVICE COMPLIANCE HANDLERS --- + $btnCheckCompliance.Add_Click({ + Invoke-AsyncAction ` + -InitializationScript ([scriptblock]::Create(". '$PrivatePath\Get-DeviceComplianceStatus.ps1'")) ` + -ScriptBlock { Get-DeviceComplianceStatus } ` + -OnCompleted { + param($data) + $lvComplianceStatus.Items.Clear() + $data | ForEach-Object { $lvComplianceStatus.Items.Add($_) } + } + }) + + $btnFixLocation.Add_Click({ + Invoke-AsyncAction ` + -InitializationScript ([scriptblock]::Create(". '$PrivatePath\Set-DeviceComplianceItem.ps1'")) ` + -ScriptBlock { Set-DeviceComplianceItem -Item 'LocationServices' } ` + -OnCompleted { + param($res) + $txtComplianceOutput.Text = $res + if ($res -match '^Error:') { $AdminHint.Invoke($env:COMPUTERNAME) } + } + }) + + $btnFixOutlookImages.Add_Click({ + Invoke-AsyncAction ` + -InitializationScript ([scriptblock]::Create(". '$PrivatePath\Set-DeviceComplianceItem.ps1'")) ` + -ScriptBlock { Set-DeviceComplianceItem -Item 'OutlookExternalImages' } ` + -OnCompleted { + param($res) + $txtComplianceOutput.Text = $res + } + }) + + $btnRemoveOneDrive.Add_Click({ + Invoke-AsyncAction ` + -InitializationScript ([scriptblock]::Create(". '$PrivatePath\Set-DeviceComplianceItem.ps1'")) ` + -ScriptBlock { Set-DeviceComplianceItem -Item 'RemoveOneDrive' } ` + -OnCompleted { + param($res) + $txtComplianceOutput.Text = $res + if ($res -match '^Error:') { $AdminHint.Invoke($env:COMPUTERNAME) } + } + }) + + $btnFixUpdateHours.Add_Click({ + $startHour = 0 + $endHour = 0 + if (-not [int]::TryParse($txtUpdateHoursStart.Text, [ref]$startHour) -or + -not [int]::TryParse($txtUpdateHoursEnd.Text, [ref]$endHour) -or + $startHour -lt 0 -or $startHour -gt 23 -or + $endHour -lt 0 -or $endHour -gt 23) { + $txtComplianceOutput.Text = "[!] Start and End hours must be integers between 0 and 23." + return + } + Invoke-AsyncAction ` + -InitializationScript ([scriptblock]::Create(". '$PrivatePath\Set-DeviceComplianceItem.ps1'")) ` + -Parameters @{ sh = $startHour; eh = $endHour } ` + -ScriptBlock { Set-DeviceComplianceItem -Item 'WindowsUpdateActiveHours' -ActiveHoursStart $sh -ActiveHoursEnd $eh } ` + -OnCompleted { + param($res) + $txtComplianceOutput.Text = $res + if ($res -match '^Error:') { $AdminHint.Invoke($env:COMPUTERNAME) } + } + }) + + # --- EXCHANGE HANDLERS --- + $btnConnectExchange.Add_Click({ + $upn = $txtExchangeUpn.Text + Invoke-AsyncAction ` + -InitializationScript ([scriptblock]::Create(". '$PrivatePath\Connect-ExchangeSession.ps1'")) ` + -Parameters @{ u = $upn } ` + -ScriptBlock { Connect-ExchangeSession -UserPrincipalName $u } ` + -OnCompleted { + param($res) + $connected = $res -match '^\[OK\]' + $state.SyncHash.ExchangeConnected = $connected + $lblExchangeStatus.Text = $res + $lblExchangeStatus.Foreground = if ($connected) { + [System.Windows.Media.Brushes]::Green + } else { + [System.Windows.Media.Brushes]::Red + } + $lblStatus.Text = if ($connected) { "Exchange: connected" } else { "Exchange: not connected" } + } + }) + + $btnGetMailboxPerms.Add_Click({ + if (-not ($RequireExchangeSession.Invoke())) { return } + $upn = $txtExchangeViewUser.Text + if ([string]::IsNullOrWhiteSpace($upn)) { + $lblStatus.Text = "[!] Enter a User Principal Name to look up." + return + } + Invoke-AsyncAction ` + -InitializationScript ([scriptblock]::Create(". '$PrivatePath\Get-ExchangeMailboxPermission.ps1'")) ` + -Parameters @{ u = $upn } ` + -ScriptBlock { Get-ExchangeMailboxPermission -UserPrincipalName $u } ` + -OnCompleted { + param($data) + $lvMailboxPerms.Items.Clear() + if ($data) { + $data | ForEach-Object { $lvMailboxPerms.Items.Add($_) } + $lblMailboxPermsCount.Text = "$($lvMailboxPerms.Items.Count) mailbox(es)" + } else { + $lblMailboxPermsCount.Text = "0 mailbox(es)" + $AppendOutput.Invoke("[Exchange] No shared mailbox permissions found for $upn.") + } + } + }) + + $btnMirrorMailboxPerms.Add_Click({ + if (-not ($RequireExchangeSession.Invoke())) { return } + $src = $txtExchangeSourceUser.Text + $tgt = $txtExchangeTargetUser.Text + if ([string]::IsNullOrWhiteSpace($src) -or [string]::IsNullOrWhiteSpace($tgt)) { + $lblStatus.Text = "[!] Enter both source and target UPNs for mirror." + return + } + Invoke-AsyncAction ` + -InitializationScript ([scriptblock]::Create(". '$PrivatePath\Set-ExchangeMailboxPermission.ps1'")) ` + -Parameters @{ src = $src; tgt = $tgt } ` + -ScriptBlock { Set-ExchangeMailboxPermission -Action Mirror -SourceUser $src -TargetUser $tgt } ` + -OnCompleted { + param($res) + $AppendOutput.Invoke("[Exchange Mirror] $res") + $lblStatus.Text = $res + } + }) + + $btnGrantMailboxPerms.Add_Click({ + if (-not ($RequireExchangeSession.Invoke())) { return } + $mb = $txtExchangeMailbox.Text + $user = $txtExchangeGrantUser.Text + if ([string]::IsNullOrWhiteSpace($mb) -or [string]::IsNullOrWhiteSpace($user)) { + $lblStatus.Text = "[!] Enter both a mailbox address and a user UPN." + return + } + Invoke-AsyncAction ` + -InitializationScript ([scriptblock]::Create(". '$PrivatePath\Set-ExchangeMailboxPermission.ps1'")) ` + -Parameters @{ mb = $mb; u = $user } ` + -ScriptBlock { Set-ExchangeMailboxPermission -Action Grant -Mailbox $mb -User $u } ` + -OnCompleted { + param($res) + $AppendOutput.Invoke("[Exchange Grant] $res") + $lblStatus.Text = $res + } + }) + + # --- CLOUD AUTH HANDLER --- + $btnCloudLogin.Add_Click({ + Invoke-AsyncAction ` + -InitializationScript ([scriptblock]::Create(". '$PrivatePath\Connect-ModernCloud.ps1'")) ` + -ScriptBlock { Connect-ModernCloud -Interactive } ` + -OnCompleted { + param($res) + $connected = $res -match "^\[OK\]" + # Persist auth state so $RequireCloudSession guards can read it + $state.SyncHash.CloudConnected = $connected + $lblCloudStatus.Text = $res + $lblCloudStatus.Foreground = if ($connected) { + [System.Windows.Media.Brushes]::Green + } else { + [System.Windows.Media.Brushes]::Red + } + $lblStatus.Text = if ($connected) { "Cloud: connected" } else { "Cloud: not connected" } + } + }) + + # Service Principal login — TenantId/ClientId sourced from UI text boxes. + # ClientSecret is read from a PasswordBox (.SecurePassword) — never stored as plaintext. + # Secrets are scoped to CLOUD layer per lazywinadmin.speq SECRETS block. + $btnCloudConnectSP.Add_Click({ + $tenantId = $txtTenantId.Text + $clientId = $txtClientId.Text + $clientSecret = $txtClientSecret.SecurePassword # SecureString — never .Password + Invoke-AsyncAction ` + -InitializationScript ([scriptblock]::Create(". '$PrivatePath\Connect-ModernCloud.ps1'")) ` + -Parameters @{ tid = $tenantId; cid = $clientId; cs = $clientSecret } ` + -ScriptBlock { Connect-ModernCloud -TenantId $tid -ClientId $cid -ClientSecret $cs } ` + -OnCompleted { + param($res) + $connected = $res -match "^\[OK\]" + # Persist auth state so $RequireCloudSession guards can read it + $state.SyncHash.CloudConnected = $connected + $lblCloudStatus.Text = $res + $lblCloudStatus.Foreground = if ($connected) { + [System.Windows.Media.Brushes]::Green + } else { + [System.Windows.Media.Brushes]::Red + } + $lblStatus.Text = if ($connected) { "Cloud: connected" } else { "Cloud: not connected" } + } + }) + + # --- EXPORT CSV HELPER --- + # Opens a SaveFileDialog and writes all ListView items to a UTF-8 CSV file. + # Runs synchronously on the UI thread — no async needed (file dialog is modal). + $ExportListViewToCsv = { + param([System.Windows.Controls.ListView]$lv, [string]$defaultName) + if ($lv.Items.Count -eq 0) { + $lblStatus.Text = "[!] No data to export — run a query first." + return + } + $dialog = [Microsoft.Win32.SaveFileDialog]::new() + $dialog.Filter = "CSV Files (*.csv)|*.csv" + $dialog.FileName = $defaultName + $dialog.DefaultExt = ".csv" + if ($dialog.ShowDialog() -eq $true) { + try { + $lv.Items | Export-Csv -Path $dialog.FileName -NoTypeInformation -Encoding UTF8 -Force + $lblStatus.Text = "Exported $($lv.Items.Count) row(s) to $($dialog.FileName)" + $AppendOutput.Invoke("[Export] Saved $($lv.Items.Count) row(s) to $($dialog.FileName)") + } + catch { + $lblStatus.Text = "[!] Export failed: $($_.Exception.Message)" + } + } + } + + $btnExportServices.Add_Click({ + $ExportListViewToCsv.Invoke($lvServices, "services_$($txtComputerName.Text)_$(Get-Date -Format yyyyMMdd).csv") + }) + $btnExportSoftware.Add_Click({ + $ExportListViewToCsv.Invoke($lvSoftware, "software_$($txtComputerName.Text)_$(Get-Date -Format yyyyMMdd).csv") + }) + $btnExportNetwork.Add_Click({ + $ExportListViewToCsv.Invoke($lvNetwork, "network_$($txtComputerName.Text)_$(Get-Date -Format yyyyMMdd).csv") + }) + $btnExportIntuneDevices.Add_Click({ + $ExportListViewToCsv.Invoke($lvIntuneDevices, "intune_devices_$(Get-Date -Format yyyyMMdd).csv") + }) + $btnExportMailboxPerms.Add_Click({ + $ExportListViewToCsv.Invoke($lvMailboxPerms, "mailbox_perms_$(Get-Date -Format yyyyMMdd).csv") + }) + + # --- CLIPBOARD CONTEXT MENUS --- + # Right-click any ListView row → "Copy Row to Clipboard" (tab-separated values). + # Uses PlacementTarget from the ContextMenu to avoid closure variable capture issues. + foreach ($lv in @($lvServices, $lvSoftware, $lvNetwork, $lvLocalAccounts, + $lvEntraIdentity, $lvAdResults, $lvIntuneDevices, $lvIntuneScripts, + $lvHwDisks, $lvComplianceStatus, $lvMailboxPerms, $lvAzureResources)) { + $ctxMenu = [System.Windows.Controls.ContextMenu]::new() + $copyItem = [System.Windows.Controls.MenuItem]::new() + $copyItem.Header = "Copy Row to Clipboard" + $copyItem.Add_Click({ + $listView = $this.Parent.PlacementTarget + if ($null -ne $listView.SelectedItem) { + $values = $listView.SelectedItem.PSObject.Properties | + Where-Object { $_.MemberType -eq 'NoteProperty' } | + ForEach-Object { $_.Value } + [System.Windows.Clipboard]::SetText(($values -join "`t")) + } + }) + $ctxMenu.Items.Add($copyItem) | Out-Null + $lv.ContextMenu = $ctxMenu + } + + # DispatcherTimer — drains the UI callback queue on the WPF thread every 50 ms. + # Background jobs enqueue their results via Register-ObjectEvent actions. + # The timer dequeues and calls OnCompleted callbacks here, on the UI thread, + # with full access to all WPF controls — no Dispatcher.Invoke needed in callbacks. + $uiTimer = [System.Windows.Threading.DispatcherTimer]::new() + $uiTimer.Interval = [TimeSpan]::FromMilliseconds(50) + $uiTimer.Add_Tick({ + $workItem = $null + while ($state.SyncHash.UIQueue.TryDequeue([ref]$workItem)) { + try { & $workItem.Callback $workItem.Result } + catch { $AppendOutput.Invoke("[!] UI callback error: $_") } + finally { $workItem.BusyFn.Invoke($false) } + } + }) + $uiTimer.Start() + + # Clock timer — updates the status bar time display every second. + $clockTimer = [System.Windows.Threading.DispatcherTimer]::new() + $clockTimer.Interval = [TimeSpan]::FromSeconds(1) + $clockTimer.Add_Tick({ $lblTime.Text = (Get-Date).ToString('HH:mm:ss') }) + $clockTimer.Start() + + $window.ShowDialog() | Out-Null + } + catch { + Write-Warning "Failed to start LazyWinAdmin: $_" + } + finally { + $state.Dispose() + } +} diff --git a/LazyWinAdminModule/Tests/Functions.Tests.ps1 b/LazyWinAdminModule/Tests/Functions.Tests.ps1 new file mode 100644 index 0000000..64b364e --- /dev/null +++ b/LazyWinAdminModule/Tests/Functions.Tests.ps1 @@ -0,0 +1,1409 @@ +#Requires -Version 7.4 +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0' } +<# +.SYNOPSIS + Comprehensive function tests for the LazyWinAdmin module private functions. + Pester v5 syntax only. All mocks match the actual internal implementation. + +.NOTES + CIM session functions (Get-ComputerHardware, Get-ComputerMotherboard, + Get-ComputerNetwork, Set-ComputerRDP) cannot be mock-tested on success + path unless WinRM is available locally, because -CimSession [CimSession[]] + rejects a PSObject mock. Success-path tests are skipped when WinRM is + unavailable ($script:WinRMAvailable = $false). + + IMPORTANT: $script:WinRMAvailable is probed at SCRIPT TOP LEVEL (outside + BeforeAll) so that Pester's discovery phase — which evaluates -Skip:() + parameters — can see the value. BeforeAll runs during execution phase, + which is AFTER -Skip is evaluated. +#> + +# ── WinRM probe runs at script/discovery time (before -Skip is evaluated) ──── +$script:WinRMAvailable = $false +try { + $probe = New-CimSession -ComputerName 'localhost' -ErrorAction Stop + Remove-CimSession -CimSession $probe -ErrorAction SilentlyContinue + $script:WinRMAvailable = $true + Write-Host '[INFO] WinRM available on localhost — CimSession success-path tests will run.' +} +catch { + Write-Host '[INFO] WinRM not available on localhost — CimSession success-path tests will be skipped.' +} +# ───────────────────────────────────────────────────────────────────────────── + +BeforeAll { + $script:ModuleRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path + + # Dot-source in correct order: Classes → Private → Public + Get-ChildItem (Join-Path $script:ModuleRoot 'Classes') -Filter '*.ps1' | + ForEach-Object { . $_.FullName } + Get-ChildItem (Join-Path $script:ModuleRoot 'Private') -Filter '*.ps1' | + ForEach-Object { . $_.FullName } + Get-ChildItem (Join-Path $script:ModuleRoot 'Public') -Filter '*.ps1' | + ForEach-Object { . $_.FullName } +} + +# ───────────────────────────────────────────────────────────────────────────── +Describe 'ApplicationState' { + + It 'Constructor creates a fully initialised instance' { + $s = [LazyWinAdminState]::new() + $s.SyncHash | Should -Not -BeNull + $s.RunspacePool | Should -Not -BeNull + # Wrap CimSessions in a boolean: piping an empty hashtable through the pipeline + # yields zero items, which Pester treats as empty even with -Not -BeNull. + ($null -ne $s.CimSessions) | Should -BeTrue + $s.SyncHash.IsBusy | Should -BeFalse + $s.SyncHash.CloudConnected | Should -BeFalse + $s.Dispose() + } + + It 'Log adds a timestamped line' { + $s = [LazyWinAdminState]::new() + $s.Log('hello') + $s.SyncHash.Logs.Count | Should -BeGreaterThan 0 + $s.SyncHash.Logs[-1] | Should -Match 'hello' + $s.Dispose() + } +} + +# ───────────────────────────────────────────────────────────────────────────── +Describe 'Get-ComputerService' { + + Context 'Happy path — services returned' { + + BeforeAll { + Mock Get-CimInstance -ParameterFilter { $ClassName -eq 'Win32_Service' } { + [PSCustomObject]@{ + Name = 'Spooler' + DisplayName = 'Print Spooler' + State = 'Running' + StartMode = 'Auto' + StartName = 'LocalSystem' + ProcessId = 1234 + } + } + } + + It 'Returns a result object' { + $result = Get-ComputerService -ComputerName 'localhost' + $result | Should -Not -BeNullOrEmpty + } + + It 'Result has expected property Name' { + $result = Get-ComputerService -ComputerName 'localhost' + ($result | Select-Object -First 1).Name | Should -Be 'Spooler' + } + + It 'Result has expected properties (Name, DisplayName, State, StartMode, StartName, ProcessId)' { + $result = Get-ComputerService -ComputerName 'localhost' + $obj = $result | Select-Object -First 1 + $obj.PSObject.Properties.Name | Should -Contain 'Name' + $obj.PSObject.Properties.Name | Should -Contain 'DisplayName' + $obj.PSObject.Properties.Name | Should -Contain 'State' + $obj.PSObject.Properties.Name | Should -Contain 'StartMode' + $obj.PSObject.Properties.Name | Should -Contain 'StartName' + $obj.PSObject.Properties.Name | Should -Contain 'ProcessId' + } + + It 'Calls Get-CimInstance with Win32_Service class' { + Get-ComputerService -ComputerName 'localhost' | Out-Null + Should -Invoke Get-CimInstance -Times 1 -Exactly -ParameterFilter { $ClassName -eq 'Win32_Service' } + } + } + + Context 'Name filter is applied' { + + BeforeAll { + Mock Get-CimInstance -ParameterFilter { $ClassName -eq 'Win32_Service' } { + [PSCustomObject]@{ + Name = 'wuauserv'; DisplayName = 'Windows Update' + State = 'Running'; StartMode = 'Manual'; StartName = 'LocalSystem'; ProcessId = 0 + } + } + } + + It 'Passes through a Name filter without error' { + $result = Get-ComputerService -ComputerName 'localhost' -Name 'wuauserv' + $result | Should -Not -BeNullOrEmpty + } + } + + Context 'Error path' { + + BeforeAll { + Mock Get-CimInstance -ParameterFilter { $ClassName -eq 'Win32_Service' } { + throw 'Simulated CIM failure' + } + } + + It 'Returns $null on CIM error' { + $result = Get-ComputerService -ComputerName 'unreachable-host' + $result | Should -BeNullOrEmpty + } + } +} + +# ───────────────────────────────────────────────────────────────────────────── +Describe 'Get-ComputerHardware' { + + Context 'Error path — always executed' { + + BeforeAll { + Mock New-CimSession { throw 'Simulated CIM failure' } + } + + It 'Returns $null when CimSession cannot be opened' { + $result = Get-ComputerHardware -ComputerName 'unreachable-host' + $result | Should -BeNullOrEmpty + } + + It 'Does NOT call Remove-CimSession when session was never assigned' { + # $CimSession is initialised to $null; if New-CimSession throws it stays $null + # so the finally block should NOT call Remove-CimSession + Mock Remove-CimSession { } + Get-ComputerHardware -ComputerName 'unreachable-host' | Out-Null + Should -Invoke Remove-CimSession -Times 0 + } + } + + Context 'Success path — skipped when WinRM unavailable' -Skip:(-not $script:WinRMAvailable) { + + It 'Returns a result with expected shape from localhost' { + $result = Get-ComputerHardware -ComputerName 'localhost' + $result | Should -Not -BeNullOrEmpty + $result.PSObject.Properties.Name | Should -Contain 'Model' + $result.PSObject.Properties.Name | Should -Contain 'Manufacturer' + $result.PSObject.Properties.Name | Should -Contain 'RAM_GB' + $result.PSObject.Properties.Name | Should -Contain 'CPU' + $result.PSObject.Properties.Name | Should -Contain 'OS' + $result.PSObject.Properties.Name | Should -Contain 'OS_Version' + $result.PSObject.Properties.Name | Should -Contain 'SerialNumber' + $result.PSObject.Properties.Name | Should -Contain 'Disks' + } + + It 'RAM_GB is a positive number' { + $result = Get-ComputerHardware -ComputerName 'localhost' + $result.RAM_GB | Should -BeGreaterThan 0 + } + } +} + +# ───────────────────────────────────────────────────────────────────────────── +Describe 'Get-ComputerMotherboard' { + + Context 'Error path — always executed' { + + BeforeAll { + Mock New-CimSession { throw 'Simulated CIM failure' } + } + + It 'Returns $null when CimSession cannot be opened' { + $result = Get-ComputerMotherboard -ComputerName 'unreachable-host' + $result | Should -BeNullOrEmpty + } + + It 'Does NOT call Remove-CimSession when session was never assigned' { + Mock Remove-CimSession { } + Get-ComputerMotherboard -ComputerName 'unreachable-host' | Out-Null + Should -Invoke Remove-CimSession -Times 0 + } + } + + Context 'Success path — skipped when WinRM unavailable' -Skip:(-not $script:WinRMAvailable) { + + It 'Returns a result with Product, Manufacturer, SerialNumber, Version from localhost' { + $result = Get-ComputerMotherboard -ComputerName 'localhost' + $result | Should -Not -BeNullOrEmpty + $result.PSObject.Properties.Name | Should -Contain 'Product' + $result.PSObject.Properties.Name | Should -Contain 'Manufacturer' + $result.PSObject.Properties.Name | Should -Contain 'SerialNumber' + $result.PSObject.Properties.Name | Should -Contain 'Version' + } + } +} + +# ───────────────────────────────────────────────────────────────────────────── +Describe 'Get-ComputerNetwork' { + + Context 'Error path — always executed' { + + BeforeAll { + Mock New-CimSession { throw 'Simulated CIM failure' } + } + + It 'Returns $null when CimSession cannot be opened' { + $result = Get-ComputerNetwork -ComputerName 'unreachable-host' + $result | Should -BeNullOrEmpty + } + + It 'Does NOT call Remove-CimSession when session was never assigned' { + Mock Remove-CimSession { } + Get-ComputerNetwork -ComputerName 'unreachable-host' | Out-Null + Should -Invoke Remove-CimSession -Times 0 + } + } + + Context 'Success path — skipped when WinRM unavailable' -Skip:(-not $script:WinRMAvailable) { + + It 'Returns adapter objects from localhost' { + $result = Get-ComputerNetwork -ComputerName 'localhost' + # May legitimately return empty array on machines with no adapters — just verify no error + $result | Should -Not -BeNullOrEmpty + } + + It 'Adapter objects have expected shape' { + $result = Get-ComputerNetwork -ComputerName 'localhost' + $first = $result | Select-Object -First 1 + $first.PSObject.Properties.Name | Should -Contain 'Description' + $first.PSObject.Properties.Name | Should -Contain 'IPAddress' + $first.PSObject.Properties.Name | Should -Contain 'MACAddress' + $first.PSObject.Properties.Name | Should -Contain 'DHCPEnabled' + } + + It 'OnlyIPEnabled switch does not throw' { + { Get-ComputerNetwork -ComputerName 'localhost' -OnlyIPEnabled } | Should -Not -Throw + } + } +} + +# ───────────────────────────────────────────────────────────────────────────── +Describe 'Get-ComputerSoftware' { + + It 'Does NOT use Win32_Product class' { + # Security test: Win32_Product triggers an MSI consistency repair on every query. + # The function comment mentions Win32_Product only to say it is NOT used. + # Assert that Win32_Product is never passed as a -ClassName argument. + $src = Get-Content -Path (Join-Path $script:ModuleRoot 'Private\Get-ComputerSoftware.ps1') -Raw + $src | Should -Not -Match '(?i)-ClassName\s+[''"]?Win32_Product' + } + + Context 'Happy path' { + # [Microsoft.Management.Infrastructure.CimInstance]::new('StdRegProv') creates a + # genuine [CimInstance] without touching WMI — it satisfies the [CimInstance] type + # constraint on Invoke-CimMethod's -InputObject parameter in Pester's mock proxy, + # while letting us control all method results via mocked Invoke-CimMethod. + + BeforeAll { + $script:FakeCimSoftware = [Microsoft.Management.Infrastructure.CimInstance]::new('StdRegProv') + Mock Get-CimInstance -ParameterFilter { $ClassName -eq 'StdRegProv' } { $script:FakeCimSoftware } + + Mock Invoke-CimMethod -ParameterFilter { $MethodName -eq 'EnumKey' } { + [PSCustomObject]@{ ReturnValue = 0; sNames = @('AppAlpha', 'AppBeta') } + } + Mock Invoke-CimMethod -ParameterFilter { $MethodName -eq 'GetStringValue' -and $Arguments.sValueName -eq 'DisplayName' } { + switch -Regex ($Arguments.sSubKeyName) { + 'AppAlpha' { return [PSCustomObject]@{ ReturnValue = 0; sValue = 'Alpha Application' } } + 'AppBeta' { return [PSCustomObject]@{ ReturnValue = 0; sValue = 'Beta Application' } } + default { return [PSCustomObject]@{ ReturnValue = 1; sValue = $null } } + } + } + Mock Invoke-CimMethod -ParameterFilter { $MethodName -eq 'GetStringValue' -and $Arguments.sValueName -eq 'DisplayVersion' } { + [PSCustomObject]@{ ReturnValue = 0; sValue = '1.0.0' } + } + Mock Invoke-CimMethod -ParameterFilter { $MethodName -eq 'GetStringValue' -and $Arguments.sValueName -eq 'Publisher' } { + [PSCustomObject]@{ ReturnValue = 0; sValue = 'TestVendor' } + } + Mock Invoke-CimMethod -ParameterFilter { $MethodName -eq 'GetStringValue' -and $Arguments.sValueName -eq 'InstallDate' } { + [PSCustomObject]@{ ReturnValue = 0; sValue = '20240115' } + } + } + + It 'Returns a non-null list of software' { + $result = Get-ComputerSoftware -ComputerName 'localhost' + $result | Should -Not -BeNullOrEmpty + } + + It 'Result objects have Name, Version, Vendor, InstallDate properties' { + $result = Get-ComputerSoftware -ComputerName 'localhost' + $first = $result | Select-Object -First 1 + $first.PSObject.Properties.Name | Should -Contain 'Name' + $first.PSObject.Properties.Name | Should -Contain 'Version' + $first.PSObject.Properties.Name | Should -Contain 'Vendor' + $first.PSObject.Properties.Name | Should -Contain 'InstallDate' + } + + It 'Results are sorted by Name' { + $result = Get-ComputerSoftware -ComputerName 'localhost' + $names = $result | ForEach-Object { $_.Name } + $sorted = $names | Sort-Object + $names | Should -Be $sorted + } + + It 'InstallDate is parsed to yyyy-MM-dd format' { + $result = Get-ComputerSoftware -ComputerName 'localhost' + $result | ForEach-Object { $_.InstallDate | Should -Match '^\d{4}-\d{2}-\d{2}$' } + } + + It 'Deduplicates across both registry hives — no duplicate names' { + $result = Get-ComputerSoftware -ComputerName 'localhost' + $names = $result | ForEach-Object { $_.Name } + ($names | Sort-Object -Unique).Count | Should -Be $names.Count + } + + It 'Search filter returns only matching software' { + $result = Get-ComputerSoftware -ComputerName 'localhost' -Search 'Alpha' + $result | Should -Not -BeNullOrEmpty + $result | ForEach-Object { $_.Name | Should -Match 'Alpha' } + } + } + + Context 'Error path' { + + BeforeAll { + Mock Get-CimInstance -ParameterFilter { $ClassName -eq 'StdRegProv' } { + throw 'Simulated CIM failure' + } + } + + It 'Returns $null on CIM error' { + $result = Get-ComputerSoftware -ComputerName 'unreachable-host' + $result | Should -BeNullOrEmpty + } + } +} + +# ───────────────────────────────────────────────────────────────────────────── +Describe 'Get-ComputerUptime' { + + Context 'Happy path' { + + BeforeAll { + $bootTime = (Get-Date).AddDays(-3).AddHours(-2).AddMinutes(-15) + Mock Get-CimInstance -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' } { + [PSCustomObject]@{ LastBootUpTime = $bootTime } + } + } + + It 'Returns a non-empty uptime string' { + $result = Get-ComputerUptime -ComputerName 'localhost' + $result | Should -Not -BeNullOrEmpty + } + + It 'Uptime string matches "D Days H Hours M Minutes S Seconds" format' { + $result = Get-ComputerUptime -ComputerName 'localhost' + $result | Should -Match '^\d+ Days \d+ Hours \d+ Minutes \d+ Seconds$' + } + + It 'Days component is at least 3 (boot was ~3 days ago)' { + $result = Get-ComputerUptime -ComputerName 'localhost' + $days = [int]($result -split ' ')[0] + $days | Should -BeGreaterThan 2 + } + } + + Context 'Error path' { + + BeforeAll { + Mock Get-CimInstance -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' } { + throw 'Simulated CIM failure' + } + } + + It 'Returns "Error" string on CIM failure' { + $result = Get-ComputerUptime -ComputerName 'unreachable-host' + $result | Should -Be 'Error' + } + } +} + +# ───────────────────────────────────────────────────────────────────────────── +Describe 'Get-ComputerLocalUser' { + + Context 'Happy path' { + + BeforeAll { + Mock Get-CimInstance -ParameterFilter { $ClassName -eq 'Win32_UserAccount' } { + [PSCustomObject]@{ + Name = 'Administrator' + FullName = '' + Disabled = $false + Lockout = $false + PasswordRequired = $true + PasswordExpires = $false + SID = 'S-1-5-21-0-0-0-500' + Status = 'OK' + } + } + } + + It 'Returns a non-null result' { + $result = Get-ComputerLocalUser -ComputerName 'localhost' + $result | Should -Not -BeNullOrEmpty + } + + It 'Result has expected properties' { + $result = Get-ComputerLocalUser -ComputerName 'localhost' + $obj = $result | Select-Object -First 1 + $obj.PSObject.Properties.Name | Should -Contain 'Name' + $obj.PSObject.Properties.Name | Should -Contain 'FullName' + $obj.PSObject.Properties.Name | Should -Contain 'Disabled' + $obj.PSObject.Properties.Name | Should -Contain 'Lockout' + $obj.PSObject.Properties.Name | Should -Contain 'PasswordRequired' + $obj.PSObject.Properties.Name | Should -Contain 'PasswordExpires' + $obj.PSObject.Properties.Name | Should -Contain 'SID' + $obj.PSObject.Properties.Name | Should -Contain 'Status' + } + + It 'Does NOT return a Password property (security: no password hash exposure)' { + $result = Get-ComputerLocalUser -ComputerName 'localhost' + $obj = $result | Select-Object -First 1 + $obj.PSObject.Properties.Name | Should -Not -Contain 'Password' + } + + It 'Calls Get-CimInstance with Win32_UserAccount class' { + Get-ComputerLocalUser -ComputerName 'localhost' | Out-Null + Should -Invoke Get-CimInstance -Times 1 -Exactly -ParameterFilter { $ClassName -eq 'Win32_UserAccount' } + } + } + + Context 'Error path' { + + BeforeAll { + Mock Get-CimInstance -ParameterFilter { $ClassName -eq 'Win32_UserAccount' } { + throw 'Simulated CIM failure' + } + } + + It 'Returns $null on CIM error' { + $result = Get-ComputerLocalUser -ComputerName 'unreachable-host' + $result | Should -BeNullOrEmpty + } + } +} + +# ───────────────────────────────────────────────────────────────────────────── +Describe 'Get-ComputerLocalGroup' { + + Context 'Happy path' { + + BeforeAll { + Mock Get-CimInstance -ParameterFilter { $ClassName -eq 'Win32_Group' } { + [PSCustomObject]@{ + Name = 'Administrators' + Caption = 'DESKTOP\Administrators' + SID = 'S-1-5-32-544' + Status = 'OK' + } + } + } + + It 'Returns a non-null result' { + $result = Get-ComputerLocalGroup -ComputerName 'localhost' + $result | Should -Not -BeNullOrEmpty + } + + It 'Result has expected properties (Name, Caption, SID, Status)' { + $result = Get-ComputerLocalGroup -ComputerName 'localhost' + $obj = $result | Select-Object -First 1 + $obj.PSObject.Properties.Name | Should -Contain 'Name' + $obj.PSObject.Properties.Name | Should -Contain 'Caption' + $obj.PSObject.Properties.Name | Should -Contain 'SID' + $obj.PSObject.Properties.Name | Should -Contain 'Status' + } + + It 'Calls Get-CimInstance with Win32_Group class' { + Get-ComputerLocalGroup -ComputerName 'localhost' | Out-Null + Should -Invoke Get-CimInstance -Times 1 -Exactly -ParameterFilter { $ClassName -eq 'Win32_Group' } + } + } + + Context 'Error path' { + + BeforeAll { + Mock Get-CimInstance -ParameterFilter { $ClassName -eq 'Win32_Group' } { + throw 'Simulated CIM failure' + } + } + + It 'Returns $null on CIM error' { + $result = Get-ComputerLocalGroup -ComputerName 'unreachable-host' + $result | Should -BeNullOrEmpty + } + } +} + +# ───────────────────────────────────────────────────────────────────────────── +Describe 'Invoke-ComputerRegistry' { + + Context 'Input validation — ValidateSet enforcement' { + # These tests require no CIM — validation fires at PowerShell parameter binding. + + It 'Throws on an invalid Action value' { + { Invoke-ComputerRegistry -Action 'BadAction' -ComputerName 'localhost' -Hive 'HKLM' -KeyPath 'Software\Test' } | + Should -Throw + } + + It 'Throws on an invalid Hive value' { + { Invoke-ComputerRegistry -Action 'Get' -ComputerName 'localhost' -Hive 'HKFAKE' -KeyPath 'Software\Test' } | + Should -Throw + } + } + + Context 'Set action — unsupported ValueType returns false without CIM call' { + # Write-Warning + return $false executes BEFORE any Invoke-CimMethod call. + # No CIM or WinRM required. + + It 'Returns $false for unimplemented ValueType Binary' { + $result = Invoke-ComputerRegistry -Action 'Set' -ComputerName 'localhost' ` + -Hive 'HKLM' -KeyPath 'SOFTWARE\Test' -ValueName 'Bin' ` + -Value 0 -ValueType 'Binary' + $result | Should -BeFalse + } + } + + Context 'Error path — CIM unavailable' { + + BeforeAll { + Mock Get-CimInstance -ParameterFilter { $ClassName -eq 'StdRegProv' } { + throw 'Simulated CIM failure' + } + } + + It 'Returns $null on CIM error' { + $result = Invoke-ComputerRegistry -Action 'Get' -ComputerName 'unreachable' ` + -Hive 'HKLM' -KeyPath 'SOFTWARE\Test' -ValueName 'v' + $result | Should -BeNullOrEmpty + } + } + + Context 'All CIM-dependent paths' { + # [CimInstance]::new('StdRegProv') produces a genuine [CimInstance] that satisfies + # Pester's Invoke-CimMethod proxy type constraint on -InputObject, with no WMI call. + + BeforeAll { + $script:CapturedArgs = $null + $script:FakeCimReg = [Microsoft.Management.Infrastructure.CimInstance]::new('StdRegProv') + Mock Get-CimInstance -ParameterFilter { $ClassName -eq 'StdRegProv' } { $script:FakeCimReg } + } + + It 'Get — returns string value when GetStringValue succeeds' { + Mock Invoke-CimMethod -ParameterFilter { $MethodName -eq 'GetStringValue' } { + [PSCustomObject]@{ ReturnValue = 0; sValue = 'TestValue123' } + } + $result = Invoke-ComputerRegistry -Action 'Get' -ComputerName 'localhost' ` + -Hive 'HKLM' -KeyPath 'SOFTWARE\Test' -ValueName 'MyValue' + $result | Should -Be 'TestValue123' + } + + It 'Get — falls back to GetDWORDValue when GetStringValue fails' { + Mock Invoke-CimMethod -ParameterFilter { $MethodName -eq 'GetStringValue' } { + [PSCustomObject]@{ ReturnValue = 2; sValue = $null } + } + Mock Invoke-CimMethod -ParameterFilter { $MethodName -eq 'GetDWORDValue' } { + [PSCustomObject]@{ ReturnValue = 0; uValue = [uint32]42 } + } + $result = Invoke-ComputerRegistry -Action 'Get' -ComputerName 'localhost' ` + -Hive 'HKLM' -KeyPath 'SOFTWARE\Test' -ValueName 'DwordVal' + $result | Should -Be 42 + } + + It 'Get — returns $null when both string and DWORD lookups fail' { + Mock Invoke-CimMethod -ParameterFilter { $MethodName -eq 'GetStringValue' } { + [PSCustomObject]@{ ReturnValue = 2; sValue = $null } + } + Mock Invoke-CimMethod -ParameterFilter { $MethodName -eq 'GetDWORDValue' } { + [PSCustomObject]@{ ReturnValue = 2; uValue = $null } + } + $result = Invoke-ComputerRegistry -Action 'Get' -ComputerName 'localhost' ` + -Hive 'HKLM' -KeyPath 'SOFTWARE\Test' -ValueName 'Missing' + $result | Should -BeNullOrEmpty + } + + It 'Set String — returns $true on success' { + Mock Invoke-CimMethod -ParameterFilter { $MethodName -eq 'SetStringValue' } { + [PSCustomObject]@{ ReturnValue = 0 } + } + $result = Invoke-ComputerRegistry -Action 'Set' -ComputerName 'localhost' ` + -Hive 'HKLM' -KeyPath 'SOFTWARE\Test' -ValueName 'Str' ` + -Value 'hello' -ValueType 'String' + $result | Should -BeTrue + } + + It 'Set DWord — returns $true on success' { + Mock Invoke-CimMethod -ParameterFilter { $MethodName -eq 'SetDWordValue' } { + [PSCustomObject]@{ ReturnValue = 0 } + } + $result = Invoke-ComputerRegistry -Action 'Set' -ComputerName 'localhost' ` + -Hive 'HKLM' -KeyPath 'SOFTWARE\Test' -ValueName 'Dw' ` + -Value 1 -ValueType 'DWord' + $result | Should -BeTrue + } + + It 'New — CreateKey returns $true on success' { + Mock Invoke-CimMethod -ParameterFilter { $MethodName -eq 'CreateKey' } { + [PSCustomObject]@{ ReturnValue = 0 } + } + $result = Invoke-ComputerRegistry -Action 'New' -ComputerName 'localhost' ` + -Hive 'HKLM' -KeyPath 'SOFTWARE\PesterTestNewKey' + $result | Should -BeTrue + } + + It 'New — CreateKey returns $false when registry rejects it (non-zero ReturnValue)' { + Mock Invoke-CimMethod -ParameterFilter { $MethodName -eq 'CreateKey' } { + [PSCustomObject]@{ ReturnValue = 5 } + } + $result = Invoke-ComputerRegistry -Action 'New' -ComputerName 'localhost' ` + -Hive 'HKLM' -KeyPath 'SOFTWARE\PesterTestBadKey' + $result | Should -BeFalse + } + + It 'Remove — calls DeleteValue when ValueName is supplied' { + Mock Invoke-CimMethod -ParameterFilter { $MethodName -eq 'DeleteValue' } { + [PSCustomObject]@{ ReturnValue = 0 } + } + $result = Invoke-ComputerRegistry -Action 'Remove' -ComputerName 'localhost' ` + -Hive 'HKLM' -KeyPath 'SOFTWARE\Test' -ValueName 'OldVal' + $result | Should -BeTrue + Should -Invoke Invoke-CimMethod -Times 1 -Exactly -ParameterFilter { $MethodName -eq 'DeleteValue' } + } + + It 'Remove — calls DeleteKey when no ValueName is supplied' { + Mock Invoke-CimMethod -ParameterFilter { $MethodName -eq 'DeleteKey' } { + [PSCustomObject]@{ ReturnValue = 0 } + } + $result = Invoke-ComputerRegistry -Action 'Remove' -ComputerName 'localhost' ` + -Hive 'HKLM' -KeyPath 'SOFTWARE\Test' + $result | Should -BeTrue + Should -Invoke Invoke-CimMethod -Times 1 -Exactly -ParameterFilter { $MethodName -eq 'DeleteKey' } + } + + It 'Maps HKLM to UInt32 2147483650' { + $script:CapturedArgs = $null + Mock Invoke-CimMethod -ParameterFilter { $MethodName -eq 'GetStringValue' } { + $script:CapturedArgs = $Arguments; [PSCustomObject]@{ ReturnValue = 0; sValue = 'v' } + } + Invoke-ComputerRegistry -Action 'Get' -ComputerName 'localhost' ` + -Hive 'HKLM' -KeyPath 'SOFTWARE\Test' -ValueName 'v' | Out-Null + $script:CapturedArgs.hDefKey | Should -Be ([UInt32]2147483650) + } + + It 'Maps HKCU to UInt32 2147483649' { + $script:CapturedArgs = $null + Mock Invoke-CimMethod -ParameterFilter { $MethodName -eq 'GetStringValue' } { + $script:CapturedArgs = $Arguments; [PSCustomObject]@{ ReturnValue = 0; sValue = 'v' } + } + Invoke-ComputerRegistry -Action 'Get' -ComputerName 'localhost' ` + -Hive 'HKCU' -KeyPath 'SOFTWARE\Test' -ValueName 'v' | Out-Null + $script:CapturedArgs.hDefKey | Should -Be ([UInt32]2147483649) + } + + It 'Maps HKCR to UInt32 2147483648' { + $script:CapturedArgs = $null + Mock Invoke-CimMethod -ParameterFilter { $MethodName -eq 'GetStringValue' } { + $script:CapturedArgs = $Arguments; [PSCustomObject]@{ ReturnValue = 0; sValue = 'v' } + } + Invoke-ComputerRegistry -Action 'Get' -ComputerName 'localhost' ` + -Hive 'HKCR' -KeyPath '.txt' -ValueName 'v' | Out-Null + $script:CapturedArgs.hDefKey | Should -Be ([UInt32]2147483648) + } + + It 'Maps HKU to UInt32 2147483651' { + $script:CapturedArgs = $null + Mock Invoke-CimMethod -ParameterFilter { $MethodName -eq 'GetStringValue' } { + $script:CapturedArgs = $Arguments; [PSCustomObject]@{ ReturnValue = 0; sValue = 'v' } + } + Invoke-ComputerRegistry -Action 'Get' -ComputerName 'localhost' ` + -Hive 'HKU' -KeyPath 'S-1-5-21' -ValueName 'v' | Out-Null + $script:CapturedArgs.hDefKey | Should -Be ([UInt32]2147483651) + } + } +} + +# ───────────────────────────────────────────────────────────────────────────── +Describe 'Get-EntraIdentity' { + + Context 'Guard — not connected to Graph' { + + BeforeAll { + Mock Get-MgContext { return $null } + } + + It 'Returns $null when Get-MgContext returns null' { + $result = Get-EntraIdentity -Type 'User' + $result | Should -BeNullOrEmpty + } + } + + Context 'Guard — OData injection protection' { + + BeforeAll { + Mock Get-MgContext { return [PSCustomObject]@{ TenantId = 'fake-tenant' } } + } + + It 'Returns $null when Search contains disallowed characters' { + $result = Get-EntraIdentity -Type 'User' -Search 'test