homotechsual.dev Open in urlscan Pro
2a06:98c1:3121::3  Public Scan

Submitted URL: http://homotechsual.dev/
Effective URL: https://homotechsual.dev/
Submission: On March 27 via api from US — Scanned from NL

Form analysis 0 forms found in the DOM

Text Content

Skip to main content
All original scripts published on this site are licensed under a Creative
Commons Attribution-ShareAlike 4.0 International License

DocumentationArchive
Sponsor
GitHub Projects
 * Blog Scripts
 * MSGraphMail
 * HaloAPI
 * NinjaOne


ctrlK

Recent posts
 * Initialise PSResourceGet (PowerShellGetv3) and PackageManagement
 * CVE Detection / Monitoring with NinjaOne Custom Fields
 * Deploying New Teams with PowerShell
 * Disabling (and Clearing) Browser Password Managers with PowerShell
 * Monitoring Time Drift with PowerShell
 * Targeting Windows Versions for Feature Updates
 * Deploying Printix Client with NinjaOne
 * Downloading CVE-2022-41099 Patch and SSU files
 * Sending Toast Notifications in Windows 10 and 11
 * Updating Drivers from Microsoft Update


INITIALISE PSRESOURCEGET (POWERSHELLGETV3) AND PACKAGEMANAGEMENT

March 26, 2024 · One min read
Mikey O'Toole

PowerShell 5.1 as shipped with Windows 10 and 11 includes versions 1.0.0.1 of
PackageManagement and PowerShellGet this old version cannot install most modern
modules, nor can it self update properly.

In most cases fixing this runs into numerous issues with conflicting versions or
files in use. This script is an adaptation of a script by Chris Taylor which
takes a different approach to downloading the modules, has a bit more error
checking and further installs the new PSResourceGet module which is the
replacement for PowerShellGet.


THE SCRIPT

Initialise-PowerShellGet.ps1

function Initialise-PSResourceGet {
    <#
    .SYNOPSIS
        Fixes common issues with PowerShellGet and PackageManagement and then installs PSResourceGet.

    .DESCRIPTION
        This script will check for common issues with PowerShellGet and PackageManagement and then install PSResourceGet.

    .NOTES
        Version:        2.0
        Author:         Mikey O'Toole
        Creation Date:  2024/03.26
        Purpose/Change: Updated to also install PSResourceGet aka PowerShellGet v3. Now downloads the PackageManagmenet and PowerShellGet modules from the PowerShell Gallery directly rather than a zip on GitHub.
        --------------------------------------------------------------------
        Version:        1.0
        Author:         Chris Taylor
        Creation Date:  2020/01/20
        Purpose/Change: Initial script development

    #>
    [cmdletbinding()]
    Param(
        [System.IO.DirectoryInfo]$StagingPath = 'C:\RMM\PowerShellStaging\'
    )
    $NuGetMinVersion = [System.Version]'3.0.0.1'
    $PackageManagementMinVersion = [System.Version]'1.4.8'
    $GalleryURL = 'https://www.powershellgallery.com/api/v2/'
    function Register-PSGallery {
        if ($Host.Version.Major -gt 4) {
            Register-PSRepository -Default
        } else {
            Import-Module PowerShellGet
            Register-PSRepository -Name PSGallery -SourceLocation $GalleryURL -InstallationPolicy Trusted
        }
    }
    function Redo-PowerShellGet {
        Write-Verbose 'Issue with PowerShellGet, Reinstalling.'
        $Module = 'PowerShellGet'
        foreach ($ProfilePath in $env:PSModulePath.Split(';')) {
            $FullPath = Join-Path $ProfilePath $Module
            Get-ChildItem $FullPath -Exclude '1.0.0.1' -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force
        }
        Register-PSGallery
        $null = Install-Module $Module -Force -AllowClobber
        Import-Module $Module -Force
    }
    function Invoke-DownloadedModuleCleaner ([String]$ModulePath) {
        if (!(Test-Path -Path $ModulePath)) {
            throw ('Module path {0} does not exist.' -f $ModulePath)
        }
        Get-ChildItem -Path $ModulePath -Filter '*.nuspec' -Recurse | Remove-Item -Force -Recurse
        Get-ChildItem -Path $ModulePath -Filter '[Content_Types].xml' -Recurse | Remove-Item -Force -Recurse
        Get-ChildItem -Path $ModulePath -Filter '_rels' -Recurse -Directory | Remove-Item -Force -Recurse
        Get-ChildItem -Path $ModulePath -Filter 'package' -Recurse -Directory | Remove-Item -Force -Recurse
    }
    function Save-ModuleFromGallery ([String[]]$ModuleNames) {
        foreach ($ModuleName in $ModuleNames) {
            $Module = Invoke-RestMethod -Uri ("{0}/FindPackagesById()?id='{1}'&`$filter=IsLatestVersion and Id eq '{1}'" -f $GalleryURL,  $ModuleName) -ErrorAction Stop
            $ModuleVersion = $Module.Properties.NormalizedVersion
            $ModuleURL = ('https://www.powershellgallery.com/api/v2/package/{0}/{1}' -f $ModuleName, $ModuleVersion)
            $WebClient = [System.Net.WebClient]::new()
            $ModuleFileName = ('{0}-{1}.zip' -f $ModuleName, $ModuleVersion)
            $ModuleDownloadPath = Join-Path -Path $StagingPath -ChildPath $ModuleFileName
            $WebClient.DownloadFile($ModuleURL, $ModuleDownloadPath)
            $ModuleExtractPath = Join-Path -Path $StagingPath -ChildPath $ModuleName
            $ModuleVersionedExtractPath = Join-Path -Path $ModuleExtractPath -ChildPath $ModuleVersion
            Expand-Archive -Path $ModuleDownloadPath -Destination $ModuleVersionedExtractPath -Force
            Invoke-DownloadedModuleCleaner -ModulePath $ModuleVersionedExtractPath
        }
    }
    if (!(Test-Path -Path $StagingPath)) {
        New-Item -Path $StagingPath -ItemType Directory | Out-Null
    }
    if ($PSVersionTable.PSVersion.Major -lt 3) {
        Write-Error 'Requires PowerShell version 3 or greater.' -ErrorAction Stop
    }
    try {
        [version]$DotNetVersion = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full' -Name Version).Version
        if ($DotNetVersion -lt [version]4.5) {
            throw
        }
    } catch {
        Write-Error '.NET version 4.5 or greater is needed.' -ErrorAction Stop
    }
    try {
        [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
    } catch {
        Write-Error 'TLS 1.2 Not supported.' -ErrorAction Stop
    }
    $WinmgmtService = Get-Service Winmgmt
    if ($WinmgmtService.StartType -eq 'Disabled') {
        Set-Service Winmgmt -StartupType Manual
    }

    if ($ENV:PSModulePath -split ';' -notcontains "$ENV:ProgramFiles\WindowsPowerShell\Modules") {
        [Environment]::SetEnvironmentVariable(
            'PSModulePath',
            ((([Environment]::GetEnvironmentVariable('PSModulePath', 'Machine') -split ';') + "$ENV:ProgramFiles\WindowsPowerShell\Modules") -join ';'),
            'Machine'
        )
    }
    # Remove Package Management Preview
    Start-Process -FilePath 'msiexec.exe' -ArgumentList @('/X', '"{57E5A8BB-41EB-4F09-B332-B535C5954A28}"', '/qn')
    # Set Execution Policy
    Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process -Confirm:$false -Force -ErrorAction SilentlyContinue
    try {
        $null = Get-Command Install-PackageProvider -ErrorAction Stop
        $null = Get-Command Install-Module -ErrorAction Stop
        $PackageManagement = Get-Module PackageManagement -ListAvailable -ErrorAction Stop | Sort-Object Version -Descending | Select-Object -First 1
        if ($PackageManagement.Version -lt $PackageManagementMinVersion) {
            throw
        }
    } catch {
        Write-Verbose 'Missing Package Manager, installing'
        $NeededModules = 'PowerShellGet', 'PackageManagement'
        Save-ModuleFromGallery -ModuleNames $NeededModules
        foreach ($Module in $NeededModules) {
            Write-Verbose ('Processing {0}' -f $Module)
            $DownloadedModulePath = Join-Path -Path $StagingPath -ChildPath $Module
            $ModulePath = Join-Path -Path $env:ProgramFiles -ChildPath 'WindowsPowerShell\Modules'
            $InstalledModulePath = Join-Path -Path $ModulePath -ChildPath $Module
            if ($Host.Version.Major -lt 5) {
                # These versions of PoSh want the files in the root of the drive not version sub folders
                Write-Verbose ('Removing {0}' -f $InstalledModulePath)
                Remove-Module -Name $Module -Force -ErrorAction SilentlyContinue
                Remove-Item -Path $InstalledModulePath -Recurse -Force
                Write-Verbose ('Copying {0} to {1}' -f $DownloadedModulePath, $ModulePath)
                Get-ChildItem -Path $DownloadedModulePath | Get-ChildItem -Recurse | ForEach-Object {
                    Copy-Item -Path $_.FullName -Destination $DownloadedModulePath -Force
                }
            } else {
                Write-Verbose ('Removing {0}' -f $InstalledModulePath)
                # If the folder name matches our target version don't remove it.
                Remove-Module -Name $Module -Force -ErrorAction SilentlyContinue
                Remove-Item -Path $InstalledModulePath -Recurse -Force
                New-Item -Path $InstalledModulePath -ItemType Directory -ErrorAction SilentlyContinue
                Write-Verbose ('Copying {0} to {1}' -f $DownloadedModulePath, $ModulePath)
                Copy-Item -Path $DownloadedModulePath -Destination $ModulePath -Recurse -Force
            }
        }
        Remove-Item $StagingPath -Force -Recurse -ErrorAction SilentlyContinue
        foreach ($Module in $NeededModules) {
            $Found = $false
            $ModulePaths = $ENV:PSModulePath -split ';'
            foreach ($ModulePath in $ModulePaths) {
                $Path = (Join-Path -Path $ModulePath -ChildPath $Module)
                if ((Test-Path $Path)) {
                    $Found = $true
                    $FoundModulePath = Get-ChildItem $Path -Recurse | Where-Object { $_.Name -eq ('{0}.psd1' -f $Module) } | Select-Object -First 1
                    Import-Module $FoundModulePath.FullName
                }
            }
            if (!$Found) {
                Write-Error ('Unable to find {0}' -f $Module) -ErrorAction Stop
            }
        }
    }
    # Reset the modules in our environment.
    $null = Remove-Module -Name PackageManagement -Force -ErrorAction SilentlyContinue
    $null = Remove-Module -Name PowerShellGet -Force -ErrorAction SilentlyContinue
    $null = Import-Module PackageManagement -Force -ErrorAction SilentlyContinue
    $null = Import-Module PowerShellGet -Force -ErrorAction SilentlyContinue
    # Ensure PowerShellGet is working.
    try {
        $null = Get-Command Get-PackageProvider -ErrorAction Stop
    } catch {
        Redo-PowerShellGet
    }
    # Ensure we have the Nuget provider and test `Update-Module` and `Install-Module` are working.
    try {
        $Nuget = Get-PackageProvider NuGet -ListAvailable -ErrorAction Stop | Where-Object { $_.Version -ge $NuGetMinVersion }
        try {
            Update-Module PowerShellGet -Force -Confirm:$false -ErrorAction Stop
        } catch {
            Install-Module PowerShellGet -Force -Confirm:$false
        }
    } catch {
        $null = Install-PackageProvider NuGet -MinimumVersion $NuGetMinVersion -Force -Confirm:$false
        $null = Install-Module PowershellGet -Force -Confirm:$false
    }
    # Ensure we have NuGet.
    if (!$Nuget) {
        $null = Install-PackageProvider NuGet -MinimumVersion $NuGetMinVersion -Force -Confirm:$false
    }
    # Ensure we have PSNuGet.
    try {
        $null = Get-PackageSource -Name PSNuGet -ErrorAction Stop
    } catch {
        $null = Register-PackageSource -Name PSNuGet -Location $GalleryURL -ProviderName NuGet -Force
    }
    # Ensure we have PSGallery.
    try {
        $null = Get-PSRepository 'PSGallery' -ErrorAction Stop
    } catch {
        if ($_.exception.message -eq 'Invalid class') {
            Redo-PowerShellGet
        } else {
            Write-Verbose 'Registering PSGallery.'
            $PSRepositoriesPath = (Join-Path -Path $ENV:LocalAppData -ChildPath 'Microsoft\windows\PowerShell\PowerShellGet\PSRepositories.xml')
            Remove-Item -Path $PSRepositoriesPath -ErrorAction SilentlyContinue
            Register-PSGallery
        }
    }
    # Ensure PSGallery is trusted.
    if ((Get-PSRepository 'PSGallery').InstallationPolicy -ne 'Trusted') {
        Set-PSRepository -Name 'PSGallery' -InstallationPolicy Trusted
    }
    # Ensure we have PSResourceGet.
    try {
        $null = Get-Command Install-PSResource -ErrorAction Stop
    } catch {
        Install-Module -Name 'Microsoft.PowerShell.PSResourceGet' -Force -Confirm:$false
    }
}
Initialise-PSResourceGet -ErrorAction Stop



View on GitHub


USAGE

.\Initialise-PowerShellGet.ps1




Nice and simple on this one, just run the script and it will do the rest. It
does expect a close-to-vanilla install of Windows 10 or 11, so if you've been
messing around with the default modules before running this script, it may not
work as expected.

Tags:
 * CVE
 * Security
 * Vulnerability
 * NinjaOne
 * Custom Fields
 * PowerShell


CVE DETECTION / MONITORING WITH NINJAONE CUSTOM FIELDS

February 13, 2024 · 9 min read
Mikey O'Toole

This post will hold detection scripts for any serious CVE vulnerability that we
write detection scripts for in the future. It will be updated and added to as
new vulnerability detection scripts are written.


CVE-2022-41099

This script has been compiled using information from the following Microsoft
sources:

 * CVE-2022-41099: Security Update Guide
 * Add an update package to Windows RE

security

This article relates to CVE-2022-41099 which is a vulnerability in the Windows
Recovery Environment (WinRE) which could allow a successful attacker to bypass
the BitLocker Device Encryption feature on the system storage device. An
attacker with physical access to the target could exploit this vulnerability to
gain access to encrypted data.

Fixed a Bug

Thanks to DTGBilly from the NinjaOne Users Discord for pointing out that in
altogether far too many places I had typo'd the CVE as CVE-4022-41099 instead of
CVE-2022-41099 🤦‍♂️ this included field names and labels so please check yours
are correct as now shown in the post.

Parameters!

Since version 1.2.0 (2023-03-21) this script now requires one of two mandatory
parameters.

 * If you are checking for the presence of the small "Safe OS Dynamic Update
   (SODU)" which is the minimum required change to mitigate the vulnerability
   use the -CheckPackage parameter and if required alter the -MountDirectory and
   -LogDirectory parameters (defaults to C:\RMM\WinRE).

 * If you are checking for the presence of the larger "Servicing Stack Update
   (SSU)" or "Dynamic Cumulative Update" which updates more than is required to
   mitigate the vulnerability, but may offer other benefits including new WinRE
   functionality or more reliable reset/restore behaviours use the -CheckImage
   parameter which checks the image build version.

If you were passing these in NinjaOne your parameter preset might look like
this:

-CheckPackage -MountDirectory C:\RMM\WinRE -LogDirectory C:\RMM\WinRE

or this:

-CheckImage

Windows Recovery Environment (WinRE) Not Enabled

Before version 1.3.0 the script did not check if WinRE was enabled which could
lead to confusing error output in the event WinRE was disabled. Now if you get
the WinRE not enabled warning you are clear on why the script isn't executing.

A simple reagentc /enable should enable WinRE or at least provide some useful
troubleshooting output.


CREATING FIELDS

Creating custom fields in NinjaOne
To create a custom field in NinjaOne go to Administration > Devices and select
either Role Custom Fields or Global Custom Fields then select Add.


 * Role Custom Fields are custom fields that are specific to a device role.
 * Global Custom Fields are custom fields that are applicable to all devices
   and/or to a location and/or organisation

Make sure you add the fields to the roles you want to use them in at
Administration > Devices > Roles (for role custom fields).

When you create your custom field you need to make sure that you set the Scripts
permission to ensure that you can read or write to the field from your scripts -
as appropriate for the script you're using.

We're adding one role custom field for devices with the Windows Desktop or
Laptop and Windows Server roles, note that we've customised slightly the
autogenerated machine name here, if you use the default adjust the field name in
the script appropriately.

Field LabelField NameField
TypeDescriptionCVE-2022-41099CVE202241099CheckboxWhether the device has a WinRE
image vulnerable to CVE-2022-41099


THE SCRIPT

This Script Was Updated

This script was updated after being published, if you're using it please compare
the version you have with the version available here.

This script was last updated on 2023/04/13.

Detect-CVE202241099.ps1

<#
    .SYNOPSIS
        CVE Detection - CVE-2022-41099
    .DESCRIPTION
        This script mounts the Windows Recovery Environment (WinRE) image and tests whether it is vulnerable to CVE-2022-41099 or whether it is of sufficient version or contains the correct packages to be considered patched against the CVE.
    .NOTES
        2023-04-13: Checks for the vulnerability status are now more explicity and the script will equivocate if it cannot determine the status.
        2023-03-24: Now checks whether Windows Recovery Environment (WinRE) is enabled before attempting to check the WinRE image.
        2023-03-21: Major refactor, now supports checking for the presence of the SafeOS Dynamic Update packages.
        2023-01-17: Better logic, more versions of Windows supported.
        2023-01-13: Initial version
    .LINK
        Blog post: https://homotechsual.dev/2023/03/15/CVE-Monitoring-NinjaOne/
#>
[CmdletBinding()]
param (
    [Parameter(ParameterSetName = 'Package', Mandatory = $true)]
    [Switch]$CheckPackage,
    [Parameter(ParameterSetName = 'Image', Mandatory = $true)]
    [Switch]$CheckImage,
    [Parameter(ParameterSetName = 'Package')]
    [System.IO.DirectoryInfo]$MountDirectory = 'C:\RMM\WinRE\Mount',
    [Parameter(ParameterSetName = 'Package')]
    [System.IO.DirectoryInfo]$LogDirectory = 'C:\RMM\WinRE\Logs'
)
$WinREEnabled = (reagentc /info | findstr 'Enabled').Replace('Windows RE status: ', '').Trim()
if (-not ($WinREEnabled)) {
    Write-Warning 'Windows RE is disabled - exiting...'
    return $false
}
$WinREImagePath = (reagentc /info | findstr '\\?\GLOBALROOT\device').Replace('Windows RE location: ', '').Trim() + '\winre.wim'
$WinREBuild = (Get-WindowsImage -ImagePath $WinREImagePath -Index 1).SPBuild
# $WinREModified = (Get-WindowsImage -ImagePath $WinREImagePath -Index 1).ModifiedTime
$WinOSBuild = [System.Environment]::OSVersion.Version.Build
$BuildtoKBMap = @{
    22623 = 5023527
    22621 = 5023527
    22000 = 5021040
    19045 = 5021043
    19044 = 5021043
    19043 = 5021043
    19042 = 5021043
}
function Mount-WinRE {
    if (-not(Test-Path $MountDirectory)) {
        New-Item $MountDirectory -ItemType Directory
    } else {
        $MountDirectoryContents = Get-ChildItem $MountDirectory 
        if ($MountDirectoryContents) {
            Write-Warning "Mount directory isn't empty - exiting..."
            return $false
        }
    }
    if ((Get-WindowsImage -Mounted).count -ge 1) {
        Write-Warning 'There is at least one other image mounted already = exiting...'
        return $false
    }
    $Mount = ReAgentC.exe /mountre /path $MountDirectory
    if ($Mount) {
        if ($Mount[0] -notmatch '.*\d+.*' -and (Get-WindowsImage -Mounted).count -ge 1 -and $LASTEXITCODE -eq 0) {
            return $true
        }
    } else {
        Write-Warning 'Could not mount WinRE image.'
        Write-Warning "$Mount"
        return $false
    }
}

function Dismount-WinRE {
    $DismountImageLogFile = Join-Path -Path $LogDirectory -ChildPath ('Dismount-WindowsImage_{0}.log' -f $DateTime)
    $DismountWinRECommonParameters = @{
        Path     = $MountDirectory
        LogLevel = 'WarningsInfo'
    }
    $UnmountDiscard = ReAgentC.exe /unmountre /path $($MountDirectory) /discard
    if (($UnmountDiscard[0] -match '.*\d+.*') -or $LASTEXITCODE -ne 0) {
        Write-Warning 'Attempting to unmount and discard failed - trying alternative method'
        Dismount-WindowsImage @DismountWinRECommonParameters -LogPath $DismountImageLogFile -Discard
        if ($(Get-WindowsImage -Mounted).count -ge 1) {
            Write-Warning 'Unmounting failed, including alternative methods.'
            return $false
        } else {
            return $true
        }
    } else {
        return $true
    }
}

if ($CheckPackage) {
    if (-not (Mount-WinRE)) {
        Write-Warning 'Could not mount WinRE image - exiting...'
        exit 1
    }
    $KB = ('KB{0}' -f $BuildtoKBMap[$WinOSBuild])
    $PackageApplied = (Get-WindowsPackage -Path $MountDirectory | Where-Object { $_.PackageName -like "*$KB*" }).PackageState -eq 'Installed'
    if (-not (Dismount-WinRE)) {
        Write-Warning 'Could not dismount WinRE image - exiting...'
        exit 1
    }
    if (-not ($PackageApplied)) {
        Write-Warning 'SafeOS Dynamic Update Package not present in WinRE image.'
        $Vulnerable = $true
    } else {
        Write-Output 'SafeOS Dynamic Update Package present in WinRE image.'
        $Vulnerable = $false
    }
}

if ($CheckImage) {
    if (($WinOSBuild -in @(22623, 22621)) -and ($WinREBuild -lt 1105)) {
        $Vulnerable = $true
    } elseif (($WinOSBuild -eq 22000) -and ($WinREBuild -lt 1455)) {
        $Vulnerable = $true
    } elseif (($WinOSBuild -in @(19045, 19044, 19042)) -and ($WinREBuild -lt 2486)) {
        $Vulnerable = $true
    } elseif (($WinOSBuild -eq 19043) -and ($WinREBuild -lt 2364)) {
        $Vulnerable = $true
    } else {
        $Vulnerable = $false
    }
}
if ($true -eq $Vulnerable) {
    Write-Warning 'Vulnerable to CVE-2022-41099'
    Ninja-Property-Set CVE202241099 1
} elseif ($false -eq $Vulnerable) {
    Write-Output 'Not vulnerable to CVE-2022-41099'
    Ninja-Property-Set CVE202241099 0
} else {
    Write-Warning 'Could not determine vulnerability status.'
}



View on GitHub


THE RESULTS



We run this script daily and have a corresponding monitor setup to check CVE
fields with a value of "Yes" and alert us if any are found. You'll find
information on remediating this vulnerability in this followup post.


CVE-2023-23397

This script has been compiled using information from the following Microsoft
sources:

 * Release notes for Microsoft Office security updates
 * Update history for Microsoft 365 Apps (by date)
 * Update history for Office Beta Channel

Thanks to:
 * Concentus on the NinjaOne Users Discord for helping me run down and test
   different versions of Office to ensure this script was as accurate as
   possible.
 * Wisecompany on the One Man Band MSP Discord for reminding me to add an exit
   code and not overuse Write-Warning!
 * Thanks to KennyW on the MSPGeek Discord for helping find an error where
   certain versions were incorrectly detected as not vulnerable!
 * Thanks to Alkerayn on the NinjaOne Users Discord for helping find an error
   where certain channels were incorrectly detected as not vulnerable and
   identifying that we needed to first check the GPO-configured update channel!
 * Thanks to Tanner - MO on the MSPs R Us Discord for pointing out that version
   comparisons should all use -lt instead of -ne to ensure future compatibility
   / accuracy.
 * Thanks to DarrenWhite99 on the MSPGeek Discord for pointing out that the
   check for the GPO UpdateChannel was completely nonsensical and incompletely
   written.
 * Thanks to JSanz on the NinjaOne Users Discord for pointing out the GUID
   matching issue/bug.
 * Thanks to Jhn - TS on the NinjaOne Users Discord for discovering the issue
   with empty registry props causing the script to error.

This has only been tested against M365 Apps and Office 2021 VL versions "en
masse" and only 64-bit office - if it doesn't work for you let me know on the
NinjaOne Users Discord and I'll see what I can do to fix it!

security

This article relates to CVE-2023-23397 which is a vulnerability in Microsoft
Outlook whereby an attacker could access a user's Net-NTLMv2 hash which could be
used as a basis of an NTLM Relay attack against another service to authenticate
as the user.


CREATING FIELDS

Creating custom fields in NinjaOne
To create a custom field in NinjaOne go to Administration > Devices and select
either Role Custom Fields or Global Custom Fields then select Add.


 * Role Custom Fields are custom fields that are specific to a device role.
 * Global Custom Fields are custom fields that are applicable to all devices
   and/or to a location and/or organisation

Make sure you add the fields to the roles you want to use them in at
Administration > Devices > Roles (for role custom fields).

When you create your custom field you need to make sure that you set the Scripts
permission to ensure that you can read or write to the field from your scripts -
as appropriate for the script you're using.

We're adding one role custom field for devices with the Windows Desktop or
Laptop role, note that we've customised slightly the autogenerated machine name
here, if you use the default adjust the field name in the script appropriately.

Field LabelField NameField
TypeDescriptionCVE-2023-23397CVE202323397CheckboxWhether the device has an
Office or Microsoft 365 Apps version vulnerable to CVE-2023-23397.


THE SCRIPT

This Script Was Updated

This script was updated after being published, if you're using it please compare
the version you have with the version available here.

This script was last updated on 2023/04/13.

Detect-CVE202323397.ps1

<#
    .SYNOPSIS
        CVE Detection - CVE-2023-23397.ps1
    .DESCRIPTION
        This script checks the installed version of Office "Click-to-Run" installations to ascertain whether the installed version is vulnerable to CVE-2023-23397 which affects Outlook for Windows.
    .NOTES
        2023-04-13: Checks for the vulnerability status are now more explicity and the script will equivocate if it cannot determine the status.
        2023-03-17: Fix URL handling to avoid errors when the key exists with a null value.
        2023-03-17: Improved output, fixed a bug where the update channel GUID was failing to match despite actually matching!
        2023-03-17: Silently continue if missing registry properties.
        2023-03-17: Handle more Office update channel configuration locations. Fix incorrect channel detection logic when using the GPO UpdateChannel.
        2023-03-16: Check versions using a "less than" comparison for vulnerability to allow future proof usage.
        2023-03-16: Check GPO channel config, adjust target version for Semi-Annual Enterprise (Preview) channel, fix Write-Warning/Write-Output mixup, more output info.
        2023-03-16: Fix O365 app misdetection, better error handling, don't omit warning on success.
        2023-03-15: Initial version
    .LINK
        Blog post: https://homotechsual.dev/2023/03/15/CVE-Monitoring-NinjaOne/
#>
$IsC2R = Test-Path 'HKLM:\SOFTWARE\Microsoft\Office\ClickToRun'

if ($IsC2R) {
    # Get the installed Office Version
    $OfficeVersion = [version]( Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Office\ClickToRun\Configuration' | Select-Object -ExpandProperty VersionToReport )
    # Get the installed Office Product IDs
    $OfficeProductIds = ( Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Office\ClickToRun\Configuration' | Select-Object -ExpandProperty ProductReleaseIds )
} else {
    Write-Error 'No Click-to-Run Office installation detected. This script only works with Click-to-Run Office installations.'
    Exit 1
}

$IsO365 = $OfficeProductIds -like '*O365*'

if ($IsO365) {
    # Check the Office GPO settings for the update channel.
    $OfficeUpdateChannelGPO = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Office\16.0\Common\OfficeUpdate' -ErrorAction 'SilentlyContinue' | Select-Object -ExpandProperty UpdateBranch -ErrorAction 'SilentlyContinue')
    if ($OfficeUpdateChannelGPO) {
        Write-Output 'Office is configured to use a GPO update channel.'
        # Define the Office Update Channels
        $Channels = @(
            @{
                ID = 'Current'
                Name = 'Current'
                PatchedVersion = [version]'16.0.16130.20306'
            },
            @{
                ID = 'FirstReleaseCurrent'
                Name = 'Current (Preview)'
                PatchedVersion = [version]'16.0.16227.20094'
            },
            @{
                ID = 'MonthlyEnterprise'
                Name = 'Monthly Enterprise'
                PatchedVersion = [version]'16.0.16026.20238'
            },
            @{
                ID = 'Deferred'
                Name = 'Semi-Annual Enterprise'
                PatchedVersion = [version]'16.0.15601.20578'
            },
            @{
                # This does not match Microsoft's documented version but is the latest available update on tested SAE-Preview channel installations.
                ID = 'FirstReleaseDeferred'
                Name = 'Semi-Annual Enterprise (Preview)'
                PatchedVersion = [version]'16.0.16026.20238'
            },
            @{ 
                ID = 'InsiderFast'
                Name = 'Beta'
                PatchedVersion = [version]'16.0.16310.20000'
            }
        )
        foreach ($Channel in $Channels) {
            if ($OfficeUpdateChannelGPO -eq $Channel.ID) {
                $OfficeChannel = $Channel
            }
        }
    } else {
        $C2RConfigurationPath = 'HKLM:\SOFTWARE\Microsoft\Office\ClickToRun\Configuration'
        Write-Output 'Office is not configured to use a GPO update channel.'
        # Get the UpdateUrl if set
        $OfficeUpdateURL = [System.Uri](Get-ItemProperty -Path $C2RConfigurationPath -ErrorAction 'SilentlyContinue' | Select-Object -ExpandProperty UpdateURL -ErrorAction 'SilentlyContinue')
        # Get the UnmanagedUpdateUrl if set
        $OfficeUnmanagedUpdateURL = [System.Uri](Get-ItemProperty -Path $C2RConfigurationPath -ErrorAction 'SilentlyContinue' | Select-Object -ExpandProperty UnmanagedUpdateURL -ErrorAction 'SilentlyContinue')
        # Get the Office Update CDN URL
        $OfficeUpdateChannelCDNURL = [System.Uri](Get-ItemProperty -Path $C2RConfigurationPath -ErrorAction 'SilentlyContinue' | Select-Object -ExpandProperty CDNBaseUrl -ErrorAction 'SilentlyContinue')
        # Get just the channel GUID
        if ($OfficeUpdateURL.IsAbsoluteUri) {
            $OfficeUpdateGUID = $OfficeUpdateURL.Segments[2]
        } elseif ($OfficeUnmanagedUpdateURL.IsAbsoluteUri) {
            $OfficeUpdateGUID = $OfficeUnmanagedUpdateURL.Segments[2]
        } elseif ($OfficeUpdateChannelCDNURL.IsAbsoluteUri) {
            $OfficeUpdateGUID = $OfficeUpdateChannelCDNURL.Segments[2]
        } else {
            Write-Error 'Unable to determine Office update channel URL.'
            Exit 1
        }
        # Define the Office Update Channels
        $Channels = @(
            @{
                ID = '492350f6-3a01-4f97-b9c0-c7c6ddf67d60'
                Name = 'Current'
                PatchedVersion = [version]'16.0.16130.20306'
            },
            @{
                ID = '64256afe-f5d9-4f86-8936-8840a6a4f5be'
                Name = 'Current (Preview)'
                PatchedVersion = [version]'16.0.16227.20094'
            },
            @{
                ID = '55336b82-a18d-4dd6-b5f6-9e5095c314a6'
                Name = 'Monthly Enterprise'
                PatchedVersion = [version]'16.0.16026.20238'
            },
            @{
                ID = '7ffbc6bf-bc32-4f92-8982-f9dd17fd3114'
                Name = 'Semi-Annual Enterprise'
                PatchedVersion = [version]'16.0.15601.20578'
            },
            @{
                # This does not match Microsoft's documented version but is the latest available update on tested SAE-Preview channel installations.
                ID = 'b8f9b850-328d-4355-9145-c59439a0c4cf'
                Name = 'Semi-Annual Enterprise (Preview)'
                PatchedVersion = [version]'16.0.16026.20238'
            },
            @{ 
                ID = '5440fd1f-7ecb-4221-8110-145efaa6372f'
                Name = 'Beta'
                PatchedVersion = [version]'16.0.16310.20000'
            }
        )
        foreach ($Channel in $Channels) {
            if ($OfficeUpdateGUID -eq $Channel.ID) {
                $OfficeChannel = $Channel
            }
        }
    }
    if (-not $OfficeChannel) {
        Write-Error 'Unable to determine Office update channel.'
        Exit 1
    } else {
        Write-Output ("{0} found using the {1} update channel. `r`nChannel ID: {2}. `r`nTarget Version: {3}. `r`nDetected Version: {4}" -f 'Microsoft 365 Apps', $OfficeChannel.Name, $OfficeChannel.ID, $OfficeChannel.PatchedVersion, $OfficeVersion)
    }
}

if ( $OfficeVersion.Major -eq '16' ) {
    if ( ( $OfficeVersion.Build -ge 7571 ) -and ( $OfficeVersion.Build -le 16130 ) -and $IsO365 ) {
        
        # Handle Microsoft 365 Apps
        if ($OfficeVersion -lt $OfficeChannel.PatchedVersion) {
            $Vulnerable = $true
        }
    } elseif ( ( $OfficeVersion.Build -ge 10356) -and ( $OfficeVersion.Build -le 10396 ) -and ( $OfficeProductIds -like '*2019Volume*' ) -and ( $OfficeProductIds -like '*2019Volume*' ) ) {
        # Handle VL Office 2019
        if ( ( $OfficeVersion.Build -lt 10396 ) -and ( $OfficeVersion.Revision -lt 20023 ) ) {
            Write-Output ("{0} found. `r`nTarget Version: {1}. `r`nDetected Version: {2}" -f 'Office 2019 VL', [Version]'16.0.10396.20023', $OfficeVersion)
            $Vulnerable = $true
        }
    } elseif ( ( $OfficeVersion.Build -ge 12527 ) -and ( $OfficeVersion.Build -le 16130 ) -and ( $OfficeProductIds -like '*Retail*' ) ) {
        # Handle Office 2021 Retail, Office 2019 Retail and Office 2016 Retail
        if ( ( $OfficeVersion.Build -lt 16130 ) -and ( $OfficeVersion.Revision -lt 20306 ) ) {
            Write-Output ("{0} found. `r`nTarget Version: {1}. `r`nDetected Version: {2}" -f 'Office 2021, 2019 or 2016 Retail', [Version]'16.0.16130.20306', $OfficeVersion)
            $Vulnerable = $true
        }
    } elseif ( ( $OfficeVersion.Build -eq 14332 ) -and ( $OfficeProductIds -like '*2021Volume*' ) ) {
        # Handle VL Office LTSC 2021
        if ( ( $OfficeVersion.Build -ne 14332 ) -and ( $OfficeVersion.Revision -lt 20481 ) ) {
            Write-Output ("{0} found. `r`nTarget Version: {1}. `r`nDetected Version: {2}" -f 'Office LTSC 2021', [Version]'16.0.14332.20481', $OfficeVersion)
            $Vulnerable = $true
        }
    }
} elseif ( $OfficeVersion.Major -eq '15' ) {
    if ( [version]'15.0.5537.1000' -gt $OfficeVersion ) {
        Write-Output ("{0} found. `r`nTarget Version: {1}. `r`nDetected Version: {2}" -f 'Office 2013', [Version]'15.0.5537.1000', $OfficeVersion)
        $Vulnerable = $true
    }
}

if ($true -eq $Vulnerable) {
    Write-Warning 'This version of Office is vulnerable to CVE-2023-23397.'
    Ninja-Property-Set CVE202323397 1
} elseif ($false -eq $Vulnerable) {
    Write-Output 'This version of Office is not vulnerable to CVE-2023-23397.'
    Ninja-Property-Set CVE202323397 0
} else {
    Write-Warning 'Could not determine vulnerability status.'
}



View on GitHub


THE RESULTS



We run this script daily and have a corresponding monitor setup to check CVE
fields with a value of "Yes" and alert us if any are found. To remediate this
vulnerability update Microsoft Office by running something like this:

This Script Was Updated

This script was updated after being published, if you're using it please compare
the version you have with the version available here.

This script was last updated on 2023/03/16.

Update-C2ROffice.ps1

<#
    .SYNOPSIS
        Update Management - Update Click-to-Run Office
    .DESCRIPTION
        This script forces through an update of Click-to-Run installations of Microsoft Office or Microsoft 365 apps.
    .NOTES
        2023-03-15: Exit if the Click-to-Run executable can't be found
        2023-03-15: Initial version
    .LINK
        Blog post: https://homotechsual.dev/2023/03/15/CVE-Monitoring-NinjaOne/
#>
[CmdletBinding()]
param ()

if ([System.Environment]::Is64BitOperatingSystem) {
    $C2RPaths = @(
        (Join-Path -Path $ENV:SystemDrive -ChildPath 'Program Files (x86)\Common Files\Microsoft Shared\ClickToRun\OfficeC2RClient.exe'),
        (Join-Path -Path $ENV:SystemDrive -ChildPath 'Program Files\Common Files\Microsoft Shared\ClickToRun\OfficeC2RClient.exe')
    )
} else {
    $C2RPaths = (Join-Path -Path $ENV:SystemDrive -ChildPath 'Program Files\Common Files\Microsoft Shared\ClickToRun\OfficeC2RClient.exe')
}
$C2RPaths | ForEach-Object {
    if (Test-Path -Path $_) {
        $C2RPath = $_
    }
}
if ($C2RPath) {
    Write-Verbose "C2RPath: $C2RPath"
    Start-Process -FilePath $C2RPath -ArgumentList '/update user displaylevel=false forceappshutdown=true' -Wait
} else {
    Write-Error 'No Click-to-Run Office installation detected. This script only works with Click-to-Run Office installations.'
    Exit 1
}



View on GitHub

This update script will force restart Office apps - it should restore open files
automatically but if you want a softer approach replace the Start-Process line
with:

Start-Process -FilePath $C2RPath -ArgumentList '/update user forceappshutdown=true updatepromptuser=true' -Wait




Prejay on the MSPGeek Discord has helpfully suggested the following to update
C2R Office builds without a user logged in or as system:

Start-Process -FilePath $C2RPath -ArgumentList '/frequentupdate SCHEDULEDTASK displaylevel=false' -Wait




Mark Hodges (also on the MSPGeek Discord) has also helpfully suggested this more
comprehensive update script which will update Office 2016 and 2019 as well as
C2R Office.


CVE-2023-21554

This script has been compiled using information from the following Microsoft
sources:

 * CVE-2023-21554: Security Update Guide

security

This article relates to CVE-2023-21554 which is a vulnerability in the Microsoft
Message Queuing system which could allow remote code execution.


CREATING FIELDS

Creating custom fields in NinjaOne
To create a custom field in NinjaOne go to Administration > Devices and select
either Role Custom Fields or Global Custom Fields then select Add.


 * Role Custom Fields are custom fields that are specific to a device role.
 * Global Custom Fields are custom fields that are applicable to all devices
   and/or to a location and/or organisation

Make sure you add the fields to the roles you want to use them in at
Administration > Devices > Roles (for role custom fields).

When you create your custom field you need to make sure that you set the Scripts
permission to ensure that you can read or write to the field from your scripts -
as appropriate for the script you're using.

We're adding one role custom field for devices with the Windows Desktop or
Laptop role, note that we've customised slightly the autogenerated machine name
here, if you use the default adjust the field name in the script appropriately.

Field LabelField NameField
TypeDescriptionCVE-2023-21554CVE202321554CheckboxWhether the device has the MSMQ
features installed and is missing the April 2023 Security Update.


THE SCRIPT

This Script Was Updated

This script was updated after being published, if you're using it please compare
the version you have with the version available here.

This script was last updated on 2023/03/16.

Detect-CVE202321554.ps1

<#
    .SYNOPSIS
        CVE Detection - CVE-2023-21554
    .DESCRIPTION
        This script checks whether the Microsoft Message Queuing (MSMQ) service is installed and then checks whether the April 2023 security update KBs have been installed patching for CVE-2023-21554.
    .NOTES
        2023-04-13: Change the comparison logic when testing if the update is installed to use `Compare-Object`.
        2023-04-13: Initial version
    .LINK
        Blog post: https://homotechsual.dev/2023/03/15/CVE-Monitoring-NinjaOne/
#>
[CmdletBinding()]
param ()
# Prepare variables and data sources.
$April2023SecurityUpdateKBs = @(
    'KB5025285',
    'KB5025288',
    'KB5025287',
    'KB5025272',
    'KB5025279',
    'KB5025277',
    'KB5025271',
    'KB5025228',
    'KB5025234',
    'KB5025221',
    'KB5025239',
    'KB5025224',
    'KB5025230'
)
$MSMQServices = Get-WindowsOptionalFeature -FeatureName 'MSMQ*' -Online | Where-Object -Property 'State' -EQ 'Enabled'
$InstalledKBs = [System.Collections.Generic.List[string]]::New()
$Hotfixes = Get-HotFix | Select-Object -ExpandProperty HotFixID
$WUSession = New-Object -ComObject 'Microsoft.Update.Session'
$WUSearcher = $WUSession.CreateUpdateSearcher()
$WUHistoryCount = $WUSearcher.GetTotalHistoryCount()
# Logic loops
if (-not ($MSMQServices)) {
    Write-Output 'MSMQ services not installed'
    $Vulnerable = $false
}
if ($null -eq $Vulnerable) {
    if ($Hotfixes.count -gt 0) {
        foreach ($Hotfix in $Hotfixes) {
            $InstalledKBs.Add($Hotfix)
        }
    }
    if ($WUHistoryCount -gt 0) {
        $UpdateHistory = $WUSearcher.QueryHistory(0, $WUHistoryCount) | ForEach-Object { [regex]::match($_.Title, '(KB\d+)').Value }
        $UpdateHistory = $UpdateHistory | Where-Object { $_ -Match '\S' } | Sort-Object -Unique
        foreach ($Update in $UpdateHistory) {
            if ($Update.HistoryID -match 'KB\d+') {
                $InstalledKBs.Add($Matches[0])
            }
        }
    }
    Write-Output $InstalledKBs
    $InstalledAprilSecurityUpdates = Compare-Object -ReferenceObject $April2023SecurityUpdateKBs -DifferenceObject $InstalledKBs -IncludeEqual -ExcludeDifferent
    if ($InstalledAprilSecurityUpdates) {
        Write-Output ('Found April 2023 security update.')
        $Vulnerable = $false
    } else {
        $Vulnerable = $true
    }
}
if ($true -eq $Vulnerable) {
    Write-Warning 'Vulnerable to CVE-2023-21554'
    Ninja-Property-Set CVE202321554 1
} elseif ($false -eq $Vulnerable) {
    Write-Output 'Not vulnerable to CVE-2023-21554'
    Ninja-Property-Set CVE202321554 0
} else {
    Write-Warning 'Could not determine vulnerability status.'
}



View on GitHub


THE RESULTS



We run this script daily and have a corresponding monitor setup to check CVE
fields with a value of "Yes" and alert us if any are found. To remediate install
the April 2023 Security Update.


CVE-2023-35628

This script has been compiled using information from the following Microsoft
sources:

 * CVE-2023-35628: Security Update Guide

security

This article relates to CVE-2023-35628 which is a vulnerability affecting
Microsoft Outlook's email rendering system which could allow remote code
execution.


CREATING FIELDS

Creating custom fields in NinjaOne
To create a custom field in NinjaOne go to Administration > Devices and select
either Role Custom Fields or Global Custom Fields then select Add.


 * Role Custom Fields are custom fields that are specific to a device role.
 * Global Custom Fields are custom fields that are applicable to all devices
   and/or to a location and/or organisation

Make sure you add the fields to the roles you want to use them in at
Administration > Devices > Roles (for role custom fields).

When you create your custom field you need to make sure that you set the Scripts
permission to ensure that you can read or write to the field from your scripts -
as appropriate for the script you're using.

We're adding one role custom field for devices with the Windows Desktop or
Laptop and/or Windows Server role, note that we've customised slightly the
autogenerated machine name here, if you use the default adjust the field name in
the script appropriately.

Thanks to Gavsto for stopping me doing down the rabbit hole of checking KB
numbers by pointing out that it wouldn't be future proof once the next
cumulative update was released!

Field LabelField NameField
TypeDescriptionCVE-2023-35628CVE202335628CheckboxWhether the device is
updated/patched for CVE-2023-35628.


THE SCRIPT

Detect-CVE202335628.ps1

<#
    .SYNOPSIS
        CVE Detection - CVE-2023-35628
    .DESCRIPTION
        This script checks whether the Microsoft Outlook or Microsoft Outlook for Windows apps are installed and then checks whether the December 2023 cumulative update has been installed patching for CVE-2023-35628.
    .NOTES
        2023-12-14: Initial version
    .LINK
        Blog post: https://homotechsual.dev/2023/03/15/CVE-Monitoring-NinjaOne/
#>
[CmdletBinding()]
param ()
# Prepare variables and data sources.
$MinimumOSBuilds = [System.Collections.Generic.List[version]]@(
    '6.3.9600.21715',
    '6.2.9200.24614',
    '6.1.7601.26864',
    '10.0.25398.584',
    '10.0.22631.2861',
    '10.0.22621.2861',
    '10.0.22000.2652',
    '10.0.20348.2159',
    '10.0.20348.2144',
    '10.0.19045.3803',
    '10.0.19041.3803',
    '10.0.17763.5206',
    '10.0.14393.6529',
    '10.0.10240.20345'
)
# Logic loops
$OutlookClassesPresent = (Get-ItemProperty HKLM:\SOFTWARE\Classes\Outlook.Application -ErrorAction SilentlyContinue)
$OutlookDesktopInstalled = (Get-Item -Path (Join-Path -Path $ENV:SystemDrive -ChildPath 'Program Files*\Microsoft Office\root\Office*\OUTLOOK.EXE') -ErrorAction SilentlyContinue)
$OutlookNewInstalled = (Get-AppxPackage -AllUsers -Name 'Microsoft.OutlookForWindows' -ErrorAction SilentlyContinue)
if ((-not $OutlookClassesPresent) -and (-not $OutlookDesktopInstalled) -and (-not $OutlookNewInstalled)) {
    Write-Output 'Outlook is probably not installed.'
    Ninja-Property-Set CVE202335628 0
    return
} else {
    Write-Warning 'Outlook is probably installed. Checking for CVE-2023-35628 patch.'
}
$OSVersion = [System.Environment]::OSVersion.Version
$OSMajorMinorBuild = [Version]('{0}.{1}.{2}' -f $OSVersion.Major, $OSVersion.Minor, $OSVersion.Build)
$MinimumApplicableOSBuilds = $MinimumOSBuilds | Where-Object { $_ -ge $OSMajorMinorBuild }
$BuildsToTest = $MinimumApplicableOSBuilds | Where-Object { $_.Build -eq $OSMajorMinorBuild.Build }
foreach ($Build in $BuildsToTest) {
    if ($Build -lt $OSVersion) {
        Write-Warning ('Minimum OS build requirement not met. Minimum OS build: {0}' -f $Build)
        $Vulnerable = $true
    } else {
        Write-Output ('Minimum OS build requirement met. Minimum OS build: {0}' -f $Build)
        $Vulnerable = $false
    }
}
if ($true -eq $Vulnerable) {
    Write-Warning 'Vulnerable to CVE-2023-35628'
    Ninja-Property-Set CVE202335628 1
} elseif ($false -eq $Vulnerable) {
    Write-Output 'Not vulnerable to CVE-2023-35628'
    Ninja-Property-Set CVE202335628 0
} else {
    Write-Warning 'Could not determine vulnerability status.'
}



View on GitHub


THE RESULTS



We run this script daily and have a corresponding monitor setup to check CVE
fields with a value of "Yes" and alert us if any are found. To remediate install
the December 2023 Cumulative Update.


CVE-2024-21413

This Script Was Updated

This script was updated after being published, if you're using it please compare
the version you have with the version available here.

This script was last updated on 2023/02/16.

This script has been compiled using information from the following Microsoft
sources:

 * CVE-2024-21413: Security Update Guide

security

This article relates to CVE-2023-35628 which is a vulnerability affecting
Microsoft Outlook's preview pane system which could allow remote code execution.

The updates include changes/corrections to the targetted versions - check your
version please!


CREATING FIELDS

Creating custom fields in NinjaOne
To create a custom field in NinjaOne go to Administration > Devices and select
either Role Custom Fields or Global Custom Fields then select Add.


 * Role Custom Fields are custom fields that are specific to a device role.
 * Global Custom Fields are custom fields that are applicable to all devices
   and/or to a location and/or organisation

Make sure you add the fields to the roles you want to use them in at
Administration > Devices > Roles (for role custom fields).

When you create your custom field you need to make sure that you set the Scripts
permission to ensure that you can read or write to the field from your scripts -
as appropriate for the script you're using.

We're adding one role custom field for devices with the Windows Desktop or
Laptop and/or Windows Server role, note that we've customised slightly the
autogenerated machine name here, if you use the default adjust the field name in
the script appropriately.

Field LabelField NameField
TypeDescriptionCVE-2024-21413CVE202421413CheckboxWhether the device is
updated/patched for CVE-2024-21413.


THE SCRIPT

Detect-CVE202421413.ps1

<#
    .SYNOPSIS
        CVE Detection - CVE-2024-21413
    .DESCRIPTION
        This script checks whether the Microsoft Outlook is installed and then checks whether the February 2024 security update has been installed patching for CVE-2024-21413.
    .NOTES
        2024-03-01: 
        2024-02-15: Fix incorrect target versions for some M365 channels.
        2024-02-15: Fail early if we can't match to a valid M365 apps version. Correct target versions for Office 2016, 2019 and 2021.
        2024-02-14: Fix incorrect M365 version build upper limit.
        2024-02-13: Initial version
    .LINK
        Blog post: https://homotechsual.dev/2024/02/13/CVE-Monitoring-NinjaOne/
#>
$IsC2R = Test-Path 'HKLM:\SOFTWARE\Microsoft\Office\ClickToRun'

if ($IsC2R -contains $true) {
    # Get the installed Office Version
    $OfficeVersion = [version]( Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Office\ClickToRun\Configuration' | Select-Object -ExpandProperty VersionToReport )
    # Get the installed Office Product IDs
    $OfficeProductIds = ( Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Office\ClickToRun\Configuration' | Select-Object -ExpandProperty ProductReleaseIds )
} else {
    Write-Error 'No Click-to-Run Office installation detected. This script only works with Click-to-Run Office installations.'
    Exit 1
}

$IsO365 = $OfficeProductIds -like '*O365*'
$M365AppsChannels = @(
    @{
        ID    = 'Current'
        GUID  = '492350f6-3a01-4f97-b9c0-c7c6ddf67d60'
        Name  = 'Current'
        17231 = @{
            PatchedVersion = [version]'16.0.17231.20236'
        }
    },
    @{
        ID    = 'FirstReleaseCurrent'
        GUID  = '64256afe-f5d9-4f86-8936-8840a6a4f5be'
        Name  = 'Current (Preview)'
        17328 = @{
            PatchedVersion = [version]'16.0.17328.20068'
        }
    },
    @{
        ID    = 'MonthlyEnterprise'
        GUID  = '55336b82-a18d-4dd6-b5f6-9e5095c314a6'
        Name  = 'Monthly Enterprise'
        17126 = @{
            PatchedVersion = [version]'16.0.17126.20190'
        }
        17029 = @{
            PatchedVersion = [version]'16.0.17029.20178'
        }
    },
    @{
        ID    = 'Deferred'
        GUID  = '7ffbc6bf-bc32-4f92-8982-f9dd17fd3114'
        Name  = 'Semi-Annual Enterprise'
        15601 = @{
            PatchedVersion = [version]'16.0.15601.20870'
        }
        16130 = @{
            PatchedVersion = [version]'16.0.16130.20916'
        }
        16731 = @{
            PatchedVersion = [version]'16.0.16731.20550'
        }
    },
    @{
        ID    = 'FirstReleaseDeferred'
        GUID  = 'b8f9b850-328d-4355-9145-c59439a0c4cf'
        Name  = 'Semi-Annual Enterprise (Preview)'
        16731 = @{
            PatchedVersion = [version]'16.0.16731.20550'
        }
    },
    @{
        ID    = 'InsiderFast'
        GUID  = '5440fd1f-7ecb-4221-8110-145efaa6372f'
        Name  = 'Beta'
        17404 = @{
            PatchedVersion = [version]'16.0.17404.20000'
        }
    }
)

if ($IsO365) {
    Write-Output 'Detected M365 Apps installation.'
    # Check the Office GPO settings for the update channel.
    $OfficeUpdateChannelGPO = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Office\16.0\Common\OfficeUpdate' -ErrorAction 'SilentlyContinue' | Select-Object -ExpandProperty UpdateBranch -ErrorAction 'SilentlyContinue')
    if ($OfficeUpdateChannelGPO) {
        Write-Output 'Office is configured to use a GPO update channel.'
        foreach ($Channel in $M365AppsChannels) {
            if ($OfficeUpdateChannelGPO -eq $Channel.ID) {
                $OfficeChannel = $Channel
            }
        }
    } else {
        $C2RConfigurationPath = 'HKLM:\SOFTWARE\Microsoft\Office\ClickToRun\Configuration'
        Write-Output 'Office is not configured to use a GPO update channel.'
        # Get the UpdateUrl if set
        $OfficeUpdateURL = [System.Uri](Get-ItemProperty -Path $C2RConfigurationPath -ErrorAction 'SilentlyContinue' | Select-Object -ExpandProperty UpdateURL -ErrorAction 'SilentlyContinue')
        # Get the UnmanagedUpdateUrl if set
        $OfficeUnmanagedUpdateURL = [System.Uri](Get-ItemProperty -Path $C2RConfigurationPath -ErrorAction 'SilentlyContinue' | Select-Object -ExpandProperty UnmanagedUpdateURL -ErrorAction 'SilentlyContinue')
        # Get the Office Update CDN URL
        $OfficeUpdateChannelCDNURL = [System.Uri](Get-ItemProperty -Path $C2RConfigurationPath -ErrorAction 'SilentlyContinue' | Select-Object -ExpandProperty CDNBaseUrl -ErrorAction 'SilentlyContinue')
        # Get just the channel GUID
        if ($OfficeUpdateURL.IsAbsoluteUri) {
            $OfficeUpdateGUID = $OfficeUpdateURL.Segments[2]
        } elseif ($OfficeUnmanagedUpdateURL.IsAbsoluteUri) {
            $OfficeUpdateGUID = $OfficeUnmanagedUpdateURL.Segments[2]
        } elseif ($OfficeUpdateChannelCDNURL.IsAbsoluteUri) {
            $OfficeUpdateGUID = $OfficeUpdateChannelCDNURL.Segments[2]
        } else {
            Write-Error 'Unable to determine Office update channel URL.'
            Exit 1
        }
        # Define the Office Update Channels
        foreach ($Channel in $M365AppsChannels) {
            if ($OfficeUpdateGUID -eq $Channel.GUID) {
                $OfficeChannel = $Channel
            }
        }
    }
    if (-not $OfficeChannel) {
        Write-Error 'Unable to determine Office update channel.'
        Exit 1
    } else {
        Write-Output ("{0} found using the {1} update channel. `r`nChannel ID: {2}. `r`nTarget Version: {3}. `r`nDetected Version: {4}" -f 'Microsoft 365 Apps', $OfficeChannel.Name, $OfficeChannel.ID, $OfficeChannel[$OfficeVersion.Build].PatchedVersion, $OfficeVersion)
    }
}

# Catch installations on builds older than supported
if ( $IsO365 ) {
    if ($OfficeChannel[$OfficeVersion.Build].PatchedVersion ) {
        Write-Output 'Target version detected, continuing with script'
    } else {
        Write-Output 'No value for Targeted version, install is probably on an old build, marking as vunerable'
        Write-Warning 'This version of Office is vulnerable.'
        $Vulnerable = $true
    }
}

if ( $OfficeVersion.Major -eq '16' -and (!$Vulnerable) ) {
    if ( ( $OfficeVersion.Build -ge 7571 ) -and ( $OfficeVersion.Build -le 17404 ) -and $IsO365 ) {
        # Handle Microsoft 365 Apps
        if ($OfficeVersion -lt $OfficeChannel[$OfficeVersion.Build].PatchedVersion) {
            $Vulnerable = $true
        }
    } elseif ( ( $OfficeVersion.Build -ge 10356) -and ( $OfficeVersion.Build -le 10407 ) -and ( $OfficeProductIds -like '*2019Volume*' ) -and ( $OfficeProductIds -like '*2019Volume*' ) ) {
        # Handle VL Office 2019
        if ( ( $OfficeVersion.Build -lt 10407 ) -and ( $OfficeVersion.Revision -lt 20032 ) ) {
            Write-Output ("{0} found. `r`nTarget Version: {1}. `r`nDetected Version: {2}" -f 'Office 2019 VL', [Version]'16.0.10407.20023', $OfficeVersion)
            $Vulnerable = $true
        }
    } elseif ( ( $OfficeVersion.Build -ge 12527 ) -and ( $OfficeVersion.Build -le 17231 ) -and ( $OfficeProductIds -like '*Retail*' ) ) {
        # Handle Office 2021 Retail, Office 2019 Retail and Office 2016 Retail
        if ( ( $OfficeVersion.Build -lt 16130 ) -and ( $OfficeVersion.Revision -lt 20236 ) ) {
            Write-Output ("{0} found. `r`nTarget Version: {1}. `r`nDetected Version: {2}" -f 'Office 2021, 2019 or 2016 Retail', [Version]'16.0.1.20306', $OfficeVersion)
            $Vulnerable = $true
        }
    } elseif ( ( $OfficeVersion.Build -eq 14332 ) -and ( $OfficeProductIds -like '*2021Volume*' ) ) {
        # Handle VL Office LTSC 2021
        if ( ( $OfficeVersion.Build -ne 14332 ) -and ( $OfficeVersion.Revision -lt 20637 ) ) {
            Write-Output ("{0} found. `r`nTarget Version: {1}. `r`nDetected Version: {2}" -f 'Office LTSC 2021', [Version]'16.0.14332.20637', $OfficeVersion)
            $Vulnerable = $true
        }
    }
}

if ($Vulnerable) {
    Write-Warning 'This version of Office is vulnerable to CVE-2024-21413.'
    Ninja-Property-Set CVE202421413 1
} else {
    Write-Output 'This version of Office is not vulnerable to CVE-2024-21413.'
    Ninja-Property-Set CVE202421413 0
}



View on GitHub


THE RESULTS



We run this script daily and have a corresponding monitor setup to check CVE
fields with a value of "Yes" and alert us if any are found. To remediate install
the applicable Office / M365 Apps February 2024 Security Update.

Tags:
 * CVE
 * Security
 * Vulnerability
 * NinjaOne
 * Custom Fields
 * PowerShell


DEPLOYING NEW TEAMS WITH POWERSHELL

January 10, 2024 · One min read
Mikey O'Toole

This post will show you how to deploy the New Teams client using a PowerShell
script.


THE SCRIPT

Install-NewTeams.ps1

<#
    .SYNOPSIS
        Software Deployment - Generic - New Teams
    .DESCRIPTION
        Uses the Teams Bootstrapper executable to install New Teams machine-wide. Use the `-Offline` switch to install using the MSIX File. Use `-Uninstall` to remove machine-wide provisioning of New Teams.
    .NOTES
        2024-02-07: I know what bloody year it is thanks to Geckojas...
        2024-01-11: Fix incorrect syntax for if condition (thanks Jayrod) and silence progress bar for Invoke-WebRequest.
        2024-01-10: Initial version
    .LINK
        Blog post: https://homotechsual.dev/2024/01/10/Deploy-New-Teams/
#>
[CmdletBinding()]
param(
    [System.IO.DirectoryInfo]$TeamsFolder = 'C:\RMM\Teams',
    [System.IO.FileInfo]$MSIXPath,
    [Switch]$Offline,
    [Switch]$Uninstall
)
begin {
    $ProgressPreference = 'SilentlyContinue'
    # Make sure the folder exists to hold the downloaded files.
    if (-not (Test-Path -Path $TeamsFolder)) {
        New-Item -Path $TeamsFolder -ItemType Directory | Out-Null
    }
    # Source: https://learn.microsoft.com/en-us/microsoftteams/new-teams-bulk-install-client
    $TeamsInstallerDownloadId = 2243204
    if ($Offline) {
        $TeamsMSIXDownloadIds = @{
            'x86' = 2196060
            'x64' = 2196106
            'ARM64' = 2196207
        }
        $Is64Bit = [System.Environment]::Is64BitOperatingSystem
        $IsArm = [System.Environment]::GetEnvironmentVariable('PROCESSOR_ARCHITECTURE') -like 'ARM*'
        if ($IsArm) {
            $TeamsMSIXDownloadId = $TeamsMSIXDownloadIds['ARM64']
        } elseif ($Is64Bit) {
            $TeamsMSIXDownloadId = $TeamsMSIXDownloadIds['x64']
        } else {
            $TeamsMSIXDownloadId = $TeamsMSIXDownloadIds['x86']
        }
        $TeamsMSIXDownloadUri = ('https://go.microsoft.com/fwlink/p/?linkid={0}' -f $TeamsMSIXDownloadId)
    }
    $TeamsInstallerDownloadUri = ('https://go.microsoft.com/fwlink/p/?linkid={0}' -f $TeamsInstallerDownloadId)
    # Define our ProcessInvoker function.
    function ProcessInvoker ([System.IO.FileInfo]$CommandPath, [String[]]$CommandArguments) {
        $PSI = New-Object System.Diagnostics.ProcessStartInfo
        $PSI.FileName = (Resolve-Path $CommandPath)
        $PSI.RedirectStandardError = $true
        $PSI.RedirectStandardOutput = $true
        $PSI.UseShellExecute = $false
        $PSI.Arguments = $CommandArguments -join ' '
        $Process = New-Object System.Diagnostics.Process
        $Process.StartInfo = $PSI
        $Process.Start() | Out-Null
        $ProcessOutput = [PSCustomObject]@{
            STDOut = $Process.StandardOutput.ReadToEnd()
            STDErr = $Process.StandardError.ReadToEnd()
            ExitCode = $Process.ExitCode
        }
        $Process.WaitForExit()
        return $ProcessOutput
    }
}
process {
    # Download Teams installer
    $TeamsInstallerFile = Join-Path -Path $TeamsFolder -ChildPath 'TeamsBootstrapper.exe'
    Write-Verbose ('Downloading Teams installer to {0}' -f $TeamsInstallerFile)
    Invoke-WebRequest -Uri $TeamsInstallerDownloadUri -OutFile $TeamsInstallerFile
    if ($Offline -and (-not $MSIXPath)) {
        # Download Teams MSIX
        $TeamsMSIXFile = Join-Path -Path $TeamsFolder -ChildPath 'Teams.msixbundle'
        if (-not (Test-Path -Path $TeamsMSIXFile)) {
            Write-Verbose ('Downloading Teams MSIX to {0}' -f $TeamsMSIXFile)
            Invoke-WebRequest -Uri $TeamsMSIXDownloadUri -OutFile $TeamsMSIXFile
        }
    } elseif ($Offline -and $MSIXPath) {
        $TeamsMSIXFile = Resolve-Path $MSIXPath
    }
    if ($Offline) {
        # Install Teams MSIX with the bootstrapper
        Write-Verbose 'Installing Teams MSIX with the bootstrapper'
        $Output = ProcessInvoker -CommandPath $TeamsInstallerFile -CommandArguments @('-p', '-o', ('{0}' -f $TeamsMSIXFile))
    } else {
        # Install Teams with the bootstrapper
        Write-Verbose 'Installing Teams with the bootstrapper'
        $Output = ProcessInvoker -CommandPath $TeamsInstallerFile -CommandArguments @('-p') -Wait -NoNewWindow
    }
    if ($Uninstall) {
        # Uninstall Teams
        Write-Verbose 'Uninstalling Teams'
        $Output = ProcessInvoker -CommandPath $TeamsInstallerFile -CommandArguments @('-x') -Wait -NoNewWindow
    }
    $OutputObject = $Output.STDOut | ConvertFrom-Json
    Write-Verbose $Output
    if ($OutputObject.Success) {
        Write-Verbose 'Teams installed successfully'
    } else {
        Write-Error ('Teams installation failed with error code {0}' -f $OutputObject.ErrorCode)
    }
}
end {
    $ProgressPreference = 'Continue'
}



View on GitHub
Parameters

When you run this script you need to pass some parameters. Use -Offline to
install using the MSIX file (script will download this if you don't provide it)
if you're providing the MSIX provide the path with -MSIXPath. Use -Uninstall to
remove the provisioned New Teams package. You can specify your own staging
folder with -TeamsFolder.

Tags:
 * Software
 * Teams
 * Deployment
 * PowerShell


DISABLING (AND CLEARING) BROWSER PASSWORD MANAGERS WITH POWERSHELL

December 18, 2023 · 2 min read
Mikey O'Toole

When deploying a password manager, one of the first things you'll want to do is
disable the built-in password manager in your browsers. This is a pretty simple
task, but it's also one that's easy to forget. It's also a good idea to clear
out any passwords that may have been saved before you deployed your password
manager.

We automate this for the two browsers we support on managed Windows devices
(Edge and Firefox) using PowerShell. Here's how we do it.


EDGE

For Edge we're going to be setting the registry key at
HKLM:\SOFTWARE\Policies\Microsoft\Edge\PasswordManagerEnabled to 0. This will
disable the password manager for all users on the device.

Then we're going to clear out any passwords that may have been saved by deleting
the contents of the Login Data file in the user's Edge profile. We'll do this by
removing the file entirely.

Now in some cases you only want to do the first part (disabling the password
manager) and not the second (clearing out any saved passwords). For that reason
the script functionality is controlled with two switch parameters:
-DisablePasswordManager and -RemoveExistingPasswords. If you run the script
without either of these switches, it will do nothing.

Application%20Configuration/EdgePasswordManagerConfig.ps1

<#
    .SYNOPSIS
        Application Configuration - Disable Edge Password Storage
    .DESCRIPTION
        This script disables the Edge password manager using the registry and then clears existing passwords by removing the contents of `$ENV:\SystemDrive\Users\*\AppData\Local\Microsoft\Edge\User Data\*\Login Data`. By necessity this script will force-end any running Edge processes.
    .EXAMPLE
        .\EdgePasswordManagerConfig.ps1 -RemoveExistingPasswords -DisablePasswordManager

        Disables the Edge password manager and removes any existing passwords.
    .EXAMPLE
        .\EdgePasswordManagerConfig.ps1 -RemoveExistingPasswords

        Removes any existing passwords.
    .EXAMPLE
        .\EdgePasswordManagerConfig.ps1 -DisablePasswordManager

        Disables the Edge password manager.
    .NOTES
        2023-12-17: Initial version.
    .LINK
        Blog post: https://homotechsual.dev/2023/12/18/browser-password-manager-configuration/
#>
[CmdletBinding()]
param(
    [Parameter()]
    [switch]$RemoveExistingPasswords,
    [Parameter()]
    [switch]$DisablePasswordManager
)
# Utility Function: Registry.ShouldBe
## This function is used to ensure that a registry value exists and is set to a specific value.
function Registry.ShouldBe {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Path,
        [Parameter(Mandatory)]
        [string]$Name,
        [Parameter(Mandatory)]
        [string]$Value,
        [Parameter(Mandatory)]
        [ValidateSet('String','ExpandString','Binary','DWord','MultiString','QWord')]
        [string]$Type
    )
    begin {
        # Make sure the registry path exists.
        if (!(Test-Path $Path)) {
            Write-Warning ("Registry path '$Path' does not exist. Creating.")
            New-Item -Path $Path -Force | Out-Null
        }
        # Make sure it's actually a registry path.
        if (!(Get-Item $Path).PSProvider.Name -eq 'Registry' -and !(Get-Item $Path).PSIsContainer) {
            throw "Path '$Path' is not a registry path."
        }
    }
    process {
        do {
            # Make sure the registry value exists.
            if (!(Get-ItemProperty -Path $Path -Name $Name -ErrorAction SilentlyContinue)) {
                Write-Warning ("Registry value '$Name' in path '$Path' does not exist. Setting to '$Value'.")
                New-ItemProperty -Path $Path -Name $Name -Value $Value -Force | Out-Null
            }
            # Make sure the registry value is correct.
            if ((Get-ItemProperty -Path $Path -Name $Name).$Name -ne $Value) {
                Write-Warning ("Registry value '$Name' in path '$Path' is not correct. Setting to '$Value'.")
                Set-ItemProperty -Path $Path -Name $Name -Value $Value
            }
        } while ((Get-ItemProperty -Path $Path -Name $Name).$Name -ne $Value)
    }
}
# Disable the Edge password manager.
if ($DisablePasswordManager) {
    # Disable the Edge password manager.
    Write-Host "Disabling the Edge password manager."
    Registry.ShouldBe -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Edge' -Name 'PasswordManagerEnabled' -Value 0 -Type DWord
}
# Remove existing passwords.
if ($RemoveExistingPasswords) {
    # Get the Edge process(es).
    $EdgeProcesses = Get-Process -Name 'msedge' -ErrorAction SilentlyContinue
    # If there are any Edge processes, kill them.
    if ($EdgeProcesses) {
        Write-Host "Killing Edge processes."
        $EdgeProcesses | Stop-Process -Force
    }
    # Get the Edge user data directories.
    $UserPath = Join-Path -Path $ENV:SystemDrive -ChildPath 'Users'
    $UserProfiles = Get-ChildItem -Path $UserPath -Directory -ErrorAction SilentlyContinue
    $EdgePasswordFiles = foreach ($UserProfile in $UserProfiles) {
        $EdgeProfilePath = Join-Path -Path $UserProfile.FullName -ChildPath 'AppData\Local\Microsoft\Edge\User Data\'
        $EdgeStateFile = Join-Path $EdgeProfilePath -ChildPath 'Local State'
        $EdgeState = Get-Content -Path $EdgeStateFile -Raw -ErrorAction SilentlyContinue | ConvertFrom-Json
        if ($EdgeState) {
            $EdgeProfiles = $EdgeState.profile.info_cache.PSObject.Properties | Where-Object { $_.MemberType -eq 'NoteProperty' } | Select-Object -ExpandProperty Name
            foreach ($EdgeProfile in $EdgeProfiles) {
                $EdgeProfilePath = Join-Path -Path $UserProfile.FullName -ChildPath "AppData\Local\Microsoft\Edge\User Data\$EdgeProfile"
                $EdgePasswordFile = Join-Path -Path $EdgeProfilePath -ChildPath 'Login Data'
                if (Test-Path -Path $EdgePasswordFile) {
                    $EdgePasswordFile
                } else {
                    Write-Warning ('User {0} profile {1} does not have a password file.' -f $UserProfile.Name, $EdgeProfile)
                }
            }
        }
    }
    # If there are any Edge password files, remove the contents of the Login Data file.
    if ($EdgePasswordFiles) {
        Write-Host "Removing existing passwords."
        foreach ($EdgePasswordFile in $EdgePasswordFiles) {
            Remove-Item -Force -Path $EdgePasswordFile
        }
    }
}



View on GitHub


FIREFOX

For Firefox we're going to be setting the registry key at
HKLM:\SOFTWARE\Policies\Mozilla\Firefox\PasswordManagerEnabled to 0. This will
disable the password manager for all users on the device.

Then we're going to clear out any passwords that may have been saved by deleting
the contents of the logins.json file in the user's Firefox profile and any
key*.db files. We'll do this by removing the files entirely.

Now in some cases you only want to do the first part (disabling the password
manager) and not the second (clearing out any saved passwords). For that reason
the script functionality is controlled with two switch parameters:
-DisablePasswordManager and -RemoveExistingPasswords. If you run the script
without either of these switches, it will do nothing.

Application%20Configuration/FirefoxPasswordManagerConfig.ps1

<#
    .SYNOPSIS
        Application Configuration - Disable Firefox Password Storage
    .DESCRIPTION
        This script disables the Firefox password manager using the registry and then clears existing passwords by removing the contents of `$ENV:\SystemDrive\Users\*\AppData\Local\Microsoft\Edge\User Data\*\Login Data`. By necessity this script will force-end any running Edge processes.
    .EXAMPLE
        .\FirefoxPasswordManagerConfig.ps1 -RemoveExistingPasswords -DisablePasswordManager

        Disables the Firefox password manager and removes any existing passwords.
    .EXAMPLE
        .\FirefoxPasswordManagerConfig.ps1 -RemoveExistingPasswords

        Removes any existing passwords.
    .EXAMPLE
        .\FirefoxPasswordManagerConfig.ps1 -DisablePasswordManager

        Disables the Firefox password manager.
    .NOTES
        2023-12-18: Initial version.
    .LINK
        Blog post: https://homotechsual.dev/2023/12/18/browser-password-manager-configuration/
#>
[CmdletBinding()]
param(
    [Parameter()]
    [switch]$RemoveExistingPasswords,
    [Parameter()]
    [switch]$DisablePasswordManager
)
# Utility Function: Registry.ShouldBe
## This function is used to ensure that a registry value exists and is set to a specific value.
function Registry.ShouldBe {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Path,
        [Parameter(Mandatory)]
        [string]$Name,
        [Parameter(Mandatory)]
        [string]$Value,
        [Parameter(Mandatory)]
        [ValidateSet('String','ExpandString','Binary','DWord','MultiString','QWord')]
        [string]$Type
    )
    begin {
        # Make sure the registry path exists.
        if (!(Test-Path $Path)) {
            Write-Warning ("Registry path '$Path' does not exist. Creating.")
            New-Item -Path $Path -Force | Out-Null
        }
        # Make sure it's actually a registry path.
        if (!(Get-Item $Path).PSProvider.Name -eq 'Registry' -and !(Get-Item $Path).PSIsContainer) {
            throw "Path '$Path' is not a registry path."
        }
    }
    process {
        do {
            # Make sure the registry value exists.
            if (!(Get-ItemProperty -Path $Path -Name $Name -ErrorAction SilentlyContinue)) {
                Write-Warning ("Registry value '$Name' in path '$Path' does not exist. Setting to '$Value'.")
                New-ItemProperty -Path $Path -Name $Name -Value $Value -Force | Out-Null
            }
            # Make sure the registry value is correct.
            if ((Get-ItemProperty -Path $Path -Name $Name).$Name -ne $Value) {
                Write-Warning ("Registry value '$Name' in path '$Path' is not correct. Setting to '$Value'.")
                Set-ItemProperty -Path $Path -Name $Name -Value $Value
            }
        } while ((Get-ItemProperty -Path $Path -Name $Name).$Name -ne $Value)
    }
}
# Disable the Firefox password manager.
if ($DisablePasswordManager) {
    # Disable the Firefox password manager.
    Write-Host "Disabling the Firefox password manager."
    Registry.ShouldBe -Path 'HKLM:\SOFTWARE\Policies\Mozilla\Firefox' -Name 'PasswordManagerEnabled' -Value 0 -Type DWord
}
# Remove existing passwords.
if ($RemoveExistingPasswords) {
    # Get the Firefox process(es).
    $FirefoxProcesses = Get-Process -Name 'firefox' -ErrorAction SilentlyContinue
    # If there are any Firefox processes, kill them.
    if ($FirefoxProcesses) {
        Write-Host "Killing Firefox processes."
        $FirefoxProcesses | Stop-Process -Force
    }
    # Get the Firefox user data directories.
    $UserPath = Join-Path -Path $ENV:SystemDrive -ChildPath 'Users'
    $UserProfiles = Get-ChildItem -Path $UserPath -Directory -ErrorAction SilentlyContinue
    $FirefoxPasswordFiles = foreach ($UserProfile in $UserProfiles) {
        $FirefoxProfilePath = Join-Path -Path $UserProfile.FullName -ChildPath 'AppData\Roaming\Mozilla\Firefox\Profiles\'
        $FirefoxProfiles = Get-ChildItem -Path $FirefoxProfilePath -Directory -ErrorAction SilentlyContinue
        foreach ($FirefoxProfile in $FirefoxProfiles) {
            $FirefoxLoginsFile = Join-Path -Path $FirefoxProfile.FullName -ChildPath 'logins.json'
            if (Test-Path -Path $FirefoxLoginsFile) {
                $FirefoxLoginsFile
            } else {
                Write-Warning ('User {0} profile {1} does not have a password file.' -f $UserProfile.Name, $FirefoxProfile.Name)
            }
            $FirefoxKeyFiles = Resolve-Path -Path (Join-Path -Path $FirefoxProfile.FullName -ChildPath 'key*')
            if ($FirefoxKeyFiles) {
                foreach ($FirefoxKeyFile in $FirefoxKeyFiles) {
                    $FirefoxKeyFile
                }
            } else {
                Write-Warning ('User {0} profile {1} does not have a key file.' -f $UserProfile.Name, $FirefoxProfile.Name)
            }
        }
    }
    # If there are any Firefox password files, remove them.
    if ($FirefoxPasswordFiles) {
        Write-Host "Removing existing passwords."
        foreach ($FirefoxPasswordFile in $FirefoxPasswordFiles) {
            Remove-Item -Force -Path $FirefoxPasswordFile
        }
    }
}



View on GitHub

As we don't use Chrome, Opera or Safari, we don't have scripts for those
browsers. However for other chromium-based browsers a similar approach to Edge
should work. It is possible to do this with Safari on MacOS as well but we
haven't yet scripted it.

Tags:
 * Configuration
 * Password Managers
 * Browsers
 * PowerShell


MONITORING TIME DRIFT WITH POWERSHELL

March 17, 2023 · 2 min read
Mikey O'Toole

Sometimes I get a script idea put in my head that's so irritatingly pervasive
that the only fix is to write the damned script. David Szpunar from the NinjaOne
Users Discord made a somewhat passing comment about time drift causing issues
with a remote support tool and that let to me thinking... You could probably
monitor for that with a PowerShell one-liner right?

Wrong! Turns out that it's more than one line!


THE SCRIPT

This Script Requires Input
This script requires user input, whether in the form of variables, parameters or
edits to the script itself before you can run it. Areas where you need to
provide input will be indicated with:

### Inline Comments



and / or

'<MARKED STRINGS>'



Parameters will be indicated before the script block.
This Script Was Updated

This script was updated after being published, if you're using it please compare
the version you have with the version available here.

This script was last updated on 2023/03/17.

Test-TimeDrift.ps1

<#
    .SYNOPSIS
        Monitoring - Windows - Time Drift
    .DESCRIPTION
        This script will monitor time drift on the machine vs a provided "source of truth".
    .NOTES
        2023-03-22: Exclude empty lines in the output.
        2023-03-18: Add `-Resync` parameter to force a resync if the time drift exceeds threshold.
        2023-03-17: Initial version
    .LINK
        Original Source: https://kevinholman.com/2017/08/26/monitoring-for-time-drift-in-your-enterprise/
    .LINK
        Blog post: https://homotechsual.dev/2023/03/17/Monitoring-Time-Drift-PowerShell/
#>
[CmdletBinding()]
param (
    # The NTP or local domain controller to use as a reference for time drift.
    [string]$ReferenceServer = 'time.windows.com',
    # The number of samples to take.
    [int]$NumberOfSamples = 1,
    # The allowed time drift in seconds.
    [int]$AllowedTimeDrift = 10,
    # Force a resync of the time if the time drift is greater than the allowed time drift.
    [switch]$ForceResync
)
$Win32TimeExe = Join-Path -Path $ENV:SystemRoot -ChildPath 'System32\w32tm.exe'
$Win32TimeArgs = '/stripchart /computer:{0} /samples:{1} /dataonly' -f $ReferenceServer, $NumberOfSamples
$ProcessInfo = New-Object System.Diagnostics.ProcessStartInfo
$ProcessInfo.FileName = $Win32TimeExe
$ProcessInfo.Arguments = $Win32TimeArgs
$ProcessInfo.RedirectStandardError = $true
$ProcessInfo.RedirectStandardOutput = $true
$ProcessInfo.UseShellExecute = $false
$ProcessInfo.CreateNoWindow = $true
$Process = New-Object System.Diagnostics.Process
$Process.StartInfo = $ProcessInfo
$Process.Start() | Out-Null
$ProcessResult = [PSCustomObject]@{
    ExitCode = $Process.ExitCode
    StdOut   = $Process.StandardOutput.ReadToEnd()
    StdErr   = $Process.StandardError.ReadToEnd()
}
$Process.WaitForExit()
if ($ProcessResult.StdErr) {
    throw "w32tm.exe returned the following error: $($ProcessResult.StdErr)"
} elseif ($ProcessResult.StdOut -contains 'Error') {
    throw "w32tm.exe returned the following error: $($ProcessResult.StdOut)"
} else {
    Write-Debug ('Raw StdOut: {0}' -f $ProcessResult.StdOut)
    $ProcessOutput = $ProcessResult.StdOut.Split("`n") | Where-Object { $_ }
    $Skew = $ProcessOutput[-1..($NumberOfSamples * -1)] | ConvertFrom-Csv -Header @('Time', 'Skew') | Select-Object -ExpandProperty Skew
    Write-Debug ('Raw Skew: {0}' -f $Skew)
    $AverageSkew = $Skew | ForEach-Object { $_ -replace 's', '' } | Measure-Object -Average | Select-Object -ExpandProperty Average
    Write-Debug ('Average Skew: {0}' -f $AverageSkew)
    if ($AverageSkew -lt 0) { $AverageSkew = $AverageSkew * -1 }
    $TimeDriftSeconds = [Math]::Round($AverageSkew, 2)
    if ($TimeDriftSeconds -gt $AllowedTimeDrift) {
        if ($ForceResync) {
            Start-Process -FilePath $Win32TimeExe -ArgumentList '/resync' -Wait
            Write-Warning "Time drift was greater than the allowed time drift of $AllowedTimeDrift seconds. Time drift was $TimeDriftSeconds seconds A resync was forced."
        } else {
            throw "Time drift is greater than the allowed time drift of $AllowedTimeDrift seconds. Time drift is $TimeDriftSeconds seconds."
        }
    } else {
        Write-Verbose "Time drift is within accepted limits. Time drift is $TimeDriftSeconds seconds."
    }
}



View on GitHub


USING THE SCRIPT

We tested this as a "Script Result Condition" in NinjaOne set to trigger the
monitor if a machine's time drifts by more than 10 seconds from uk.pool.ntp.org
(the UK's NTP pool) and it worked like a charm. The script is pretty
self-explanatory but here's a quick rundown of what it does:

 1. It uses a configurable NTP or SNTP server to get the "reference" time.
    (Parameter -ReferenceServer)
 2. It uses the w32tm executable to conduct a number of skew checks against that
    reference server (Parameter -NumberOfSamples)
 3. It averages the samples and compares the result to the threshold (Parameter
    -AllowedTimeDrift)
 4. Optionally you can force a resync if the time drift is greater than the
    threshold (Parameter -ForceResync)

If the average time drift is greater than the threshold, the script returns a
non-zero exit code and the monitor triggers. If the w32tm command errors (non
existent server, network down etc) the script returns a non-zero exit code and
the monitor triggers.


CREDITS

This script borrows ideas and the approach and a little code from the excellent
blog of Kevin Holman.

The formidable Chris Taylor helped with a cool suggestion to suppress empty
lines in the output and his site is well worth a visit.

Tags:
 * Monitoring
 * PowerShell


TARGETING WINDOWS VERSIONS FOR FEATURE UPDATES

February 16, 2023 · One min read
Mikey O'Toole

This post will show you how to use registry keys to test, set and remove target
versions for Windows Feature Updates. This allows you to prevent Windows 10 or
11 from updating past your configured limit.


THE SCRIPT

This Script Was Updated

This script was updated after being published, if you're using it please compare
the version you have with the version available here.

This script was last updated on 2023/04/05.

Invoke-WindowsUpdateTargetVersion.ps1

<#
    .SYNOPSIS
        Update Management - Windows - Set Feature Update Target Version
    .DESCRIPTION
        Sets the various registry keys to set the target version for Windows Update. This can be used to keep machines on Windows 10, instead of upgrading to Windows 11 or to target a specific "maximum" feature update version.
    .NOTES
        2023-04-05: Fix output messages
        2023-02-16: Parameterise the script to allow more control over the target versions
        2022-01-25: Initial version
    .LINK
        Blog post: https://homotechsual.dev/2023/02/16/Targeting-Windows-Versions-for-Feature-Updates/
#>
[CmdletBinding()]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'RMM script - not useful to implement ShouldProcess')]
param (
    [Switch]$Test,
    [Switch]$Unset,
    [String]$TargetProductVersion = '22H2',
    [String]$TargetProduct = 'Windows 11'
)
function Test-UpdateSettings {
    $UpdateSettings = Get-ItemProperty -Path 'HKLM:\Software\Policies\Microsoft\Windows\WindowsUpdate\'
    $Message = [System.Collections.Generic.List[String]]::New()
    if ($UpdateSettings.TargetReleaseVersion -and $UpdateSettings.TargetReleaseVersion -ne 0) {
        $Message.Add('Windows Update is currently set to target a specific release version.')
        if ($UpdateSettings.TargetReleaseVersionInfo) {
            $Message.Add("Target release version: $($UpdateSettings.TargetReleaseVersionInfo.ToString())")
        } else {
            $Message.Add('Target release version is not set.')
        }
        if ($UpdateSettings.ProductVersion) {
            $Message.Add("Product version: $($UpdateSettings.ProductVersion.ToString())")
        } else {
            $Message.Add('Product version is not set.')
        }
    } else {
        $Message.Add('Windows Update is currently set to target all versions.')
    }
    if ($String -is [array] -or $String.Count -gt 0) {
        return $Message.Join(' ')
    } else {
        return $Message
    }
    
}

function Set-UpdateSettings ([switch]$Unset) {
    if ($Unset) {
        try {
            Set-ItemProperty -Path 'HKLM:\Software\Policies\Microsoft\Windows\WindowsUpdate\' -Name 'TargetReleaseVersion' -Value 0 -Type DWord
            if (Test-Path 'HKLM:\Software\Policies\Microsoft\Windows\WindowsUpdate\TargetReleaseVersionInfo') {
                Remove-ItemProperty -Path 'HKLM:\Software\Policies\Microsoft\Windows\WindowsUpdate\' -Name 'TargetReleaseVersionInfo'
            }
            if (Test-Path 'HKLM:\Software\Policies\Microsoft\Windows\WindowsUpdate\ProductVersion') {
                Remove-ItemProperty -Path 'HKLM:\Software\Policies\Microsoft\Windows\WindowsUpdate\' -Name 'ProductVersion'
            }
        } catch {
            Throw $_
        }
        $Message = 'Windows Update is now set to target all versions.'
    } else {
        try {
            Set-ItemProperty -Path 'HKLM:\Software\Policies\Microsoft\Windows\WindowsUpdate\' -Name 'TargetReleaseVersion' -Value 1 -Type DWord
            Set-ItemProperty -Path 'HKLM:\Software\Policies\Microsoft\Windows\WindowsUpdate\' -Name 'TargetReleaseVersionInfo' -Value $TargetProductVersion
            Set-ItemProperty -Path 'HKLM:\Software\Policies\Microsoft\Windows\WindowsUpdate\' -Name 'ProductVersion' -Value $TargetProduct
            $Message = ('Windows Update is now set to target {0}, {1}' -f $TargetProduct, $TargetProductVersion)
        } catch {
            Throw $_
        }
    }
    return $Message
}

if ($Test) {
    $Message = Test-UpdateSettings
    Write-Output $Message
} elseif ($Unset) {
    $Message = Set-UpdateSettings -Unset
    Write-Output $Message
} else {
    $Message = Set-UpdateSettings
    Write-Output $Message
}



View on GitHub
Parameters

When you run this script you might want to pass some parameters - here's what
they do:

 * -Test - This will test the current target version settings and show you the
   results.
 * -Unset - This will remove the target version settings.
 * -TargetProductVersion - Specify the target version to aim for, examples would
   be 21H2 or 22H2.
 * -TargetProduct - Specify the target product to aim for, examples would be
   Windows 10 or Windows 11.

Tags:
 * Software
 * Windows
 * Updates
 * Registry
 * PowerShell


DEPLOYING PRINTIX CLIENT WITH NINJAONE

February 1, 2023 · One min read
Mikey O'Toole

This post will show you how to deploy the Printix client using NinjaOne
Documentation fields and a PowerShell script.


CREATING FIELDS

Creating custom fields in NinjaOne
To create a custom field in NinjaOne go to Administration > Devices and select
either Role Custom Fields or Global Custom Fields then select Add.


 * Role Custom Fields are custom fields that are specific to a device role.
 * Global Custom Fields are custom fields that are applicable to all devices
   and/or to a location and/or organisation

Make sure you add the fields to the roles you want to use them in at
Administration > Devices > Roles (for role custom fields).

When you create your custom field you need to make sure that you set the Scripts
permission to ensure that you can read or write to the field from your scripts -
as appropriate for the script you're using.

We're adding two documentation fields to facilitate this script. You'll need to
note your document template id, in the screenshots / our internal use we have a
template called "Integration Identifiers" which we use to store any integration
identifiers we need to reference in our scripts.

Field LabelField NameField TypeDescriptionPrintix Tenant
IdprintixTenantIdTextHolds the customer's Printix tenant id.Printix Tenant
DomainprintixTenantDomainTextHolds the customer's Printix domain.


THE SCRIPT

Install-PrintixClient.ps1

<#
    .SYNOPSIS
        Software Deployment - NinjaOne - Printix Client
    .DESCRIPTION
        Uses documentation fields to pull client specific Printix information to download that client's installer from Printix and install it on the endpoint.
    .NOTES
        2023-02-01: Initial version
    .LINK
        Blog post: https://homotechsual.dev/2023/02/01/Deploy-Printix-NinjaOne/
#>
[Cmdletbinding()]
param (
    [Parameter(Mandatory = $true)]
    [String]$DocumentTemplate
)
try {
    $PrintixTenantId = Ninja-Property-Docs-Get-Single $DocumentTemplate printixTenantId
    $PrintixTenantDomain = Ninja-Property-Docs-Get-Single $DocumentTemplate printixTenantDomain
    Write-Verbose ('Found Printix Tenant: {0} ({1})' -f $PrintixTenantId, $PrintixTenantDomain)
    if (-not ([String]::IsNullOrEmpty($PrintixTenantId) -and ([String]::IsNullOrEmpty($PrintixTenantDomain)))) {
        $PrintixInstallerURL = ('https://api.printix.net/v1/software/tenants/{0}/appl/CLIENT/os/WIN/type/MSI' -f $PrintixTenantId)
        Write-Verbose ('Built Printix Installer URL: {0}' -f $PrintixInstallerURL)
        $PrintixFileName = "CLIENT_{$PrintixTenantDomain}_{$PrintixTenantId}.msi"
        $PrintixSavePath = 'C:\RMM\Installers'
        if (-not (Test-Path $PrintixSavePath)) {
            New-Item -Path $PrintixSavePath -ItemType Directory | Out-Null
        }
        $PrintixInstallerPath = ('{0}\{1}' -f $PrintixSavePath, $PrintixFileName)
        Invoke-WebRequest -Uri $PrintixInstallerURL -OutFile $PrintixInstallerPath -Headers @{ 'Accept' = 'application/octet-stream' }
        if (Test-Path $PrintixInstallerPath) {
            Start-Process -FilePath 'msiexec.exe' -ArgumentList @(
                '/i',
                ('"{0}"' -f $PrintixInstallerPath),
                '/quiet',
                ('WRAPPED_ARGUMENTS=/id:{0}' -f $PrintixTenantId)
            ) -Wait
        } else {
            Write-Error ('Printix installer not found in {0}' -f $PrintixInstallerPath)
        }
    }
} catch {
    Write-Error ('Failed to install Printix Client: `r`n {0}' -f $_)
}



View on GitHub
Parameters

When you run this script you need to pass your document template id. For
example, sticking with our example above, you'd run the script with the
parameter: -DocumentTemplate Integration Identifiers


THE RESULTS





We run this script on a group of devices which don't have the Printix client
installed.

Tags:
 * Software
 * Printix
 * Deployment
 * NinjaOne
 * Custom Fields
 * PowerShell


DOWNLOADING CVE-2022-41099 PATCH AND SSU FILES

January 17, 2023 · 5 min read
Mikey O'Toole


COLLABORATION WITH MARTIN HIMKEN

This post and the WinRE patching script on Martin's blog at https://manima.de
are the result of a collaboration between Martin and I to help mutually improve
our various efforts towards patching CVE-2022-41099.

Read Martin's blog on WinRE patching
security

This article relates to CVE-2022-41099 which is a vulnerability in the Windows
Recovery Environment (WinRE) which could allow a successful attacker to bypass
the BitLocker Device Encryption feature on the system storage device. An
attacker with physical access to the target could exploit this vulnerability to
gain access to encrypted data.

If you're running Windows 10 or 11 you might have come across CVE-2022-41099
which is a vulnerability in the Windows Recovery Environment (WinRE) which could
allow a successful attacker to bypass BitLocker if they can boot the device to
WinRE. This is a pretty serious vulnerability and Microsoft have released a
patch for it. However, the patch is not applied automatically and you need to
take action to apply it.

Martin Himken has written a script to patch the WinRE drivers and I've written a
script to download and stage the patch and servicing stack update files. The
link to Martin's blog is at the top of this post and will be repeated at the
end.

This script takes a few parameters to control it's behaviour. Parameter
documentation follows:

ParameterTypeDescriptionPatchFolderDirectoryInfoThe folder to download the patch
files to. If not specified, C:\RMM\CVEs\2022-41099\ will be used.AllSwitchIf
specified, the script will download the patch files for all supported versions
and available architechtures of Windows 10 and 11. If not specified, the script
will only download the patch files for the version of Windows that is running on
the device.

-All

Using the -All parameter will download a lot of files and take a long time to
complete. It is recommended that you only use this parameter if you are patching
a large number of devices or want to prepare a cache to serve files from.

Downloading all files consumes roughly 4.9GB of disk space.


THE SCRIPT

info

This new version of the script downloads the Safe OS Dynamic Update (SODU) files
- these are tiny and designed only to patch the vulnerable components.

Safe OS Dynamic Update (SODU) version.
This Script Was Updated

This script was updated after being published, if you're using it please compare
the version you have with the version available here.

This script was last updated on 2023/03/22.

Get-CVE202241099Patches.ps1

<#
.SYNOPSIS
    Update Management - CVE-2022-41099 Downloader (Safe OS Dynamic Update version)
.DESCRIPTION
    This script will download the applicable patch for CVE-2022-41099 from Microsoft Update Catalog. It will detect the OS version and bitness and download the correct patch. This uses the Feb 2023, January 2023 or November 2022 Safe OS DU depending on the OS version.
.PARAMETER PatchFolder
    Accepts the path to a folder to download the patch(es) to.
.EXAMPLE
    This example will download the applicable patch for CVE-2022-41099 to the folder C:\RMM\CVEs\2022-41099\.
    
    Get-CVE202241099Patches.ps1 -PatchFolder 'C:\RMM\CVEs\2022-41099\'
.EXAMPLE
    This example will download all patches for CVE-2022-41099 to the folder C:\RMM\CVEs\2022-41099\. With subfolders for each KB and architecture.

    Get-CVE202241099Patches.ps1 -PatchFolder 'C:\RMM\CVEs\2022-41099\' -All
.NOTES
    2023-03-22: Empty the patch folder if it's not empty. Thanks to Wisecompany for the suggestion.
    2023-03-22: Fixes incorrectly switched URLs for 19042 to 19045 for the x86 and x64 downloads. Thanks to Wisecompany for helping find this.
    2023-03-21: Update to use the Safe OS Dynamic Update packages which are considerably smaller.
    2023-01-18: Use `$ProgressPreference` to speed up execution. Thanks to CodyRWhite (GitHub) for the suggestion.
    2023-01-18: Fix bug accessing hashtable of architectures using Windows build number. Thanks to Sir Loin (WinAdmins) for spotting this.
    2023-01-17: Add support for Windows 10 19044 (21H2)
    2023-01-17: Initial version
.LINK
    Blog post: https://homotechsual.dev/2023/01/17/Download-CVE-2022-41099-Patches/
#>
# We're targetting the January 2023 Safe OS Dynamic Update for Windows 11 22H2 and the November 2022 Safe OS Dynamic Update for Windows 11 21H2 and Windows 10 22H2, 22H1, 21H1 and 20H2.
[CmdletBinding()]
param (
    # The path to the folder to download the patch(es) to.
    [System.IO.DirectoryInfo]$PatchFolder = 'C:\RMM\CVEs\2022-41099\',
    # Download all patches including SSUs useful if you want to populate a staging folder. Will create a subfolder for each.
    [Switch]$All
)
$OriginalProgressPreference = $ProgressPreference
$ProgressPreference = 'SilentlyContinue'
$BuildtoKBMap = @{
    22623 = 5023527
    22621 = 5023527
    22000 = 5021040
    19045 = 5021043
    19044 = 5021043
    19043 = 5021043
    19042 = 5021043
}
$KBtoCABMap = @{
    # KB5023527 - February 2023
    5023527 = @{
        'x64' = 'https://catalog.s.download.windowsupdate.com/c/msdownload/update/software/crup/2023/02/windows11.0-kb5023527-x64_076cd9782ebb8aed56ad5d99c07201035d92e66a.cab'
        'ARM64' = 'https://catalog.s.download.windowsupdate.com/c/msdownload/update/software/crup/2023/02/windows11.0-kb5023527-arm64_bd0a8952aee4f003c26e272a9b804645146e9358.cab'
    }
    # KB5021040 - November 2022
    5021040 = @{
        'x64' = 'https://catalog.s.download.windowsupdate.com/c/msdownload/update/software/crup/2022/11/windows10.0-kb5021040-x64_2216fe185502d04d7a420a115a53613db35c0af9.cab'
        'ARM64' = 'https://catalog.s.download.windowsupdate.com/c/msdownload/update/software/crup/2022/11/windows10.0-kb5021040-arm64_cb0622a2c0ef781826f583eccd16c289597678cc.cab'
    }
    # KB5021043 - January 2023
    5021043 = @{
        'x86' = 'https://catalog.s.download.windowsupdate.com/d/msdownload/update/software/crup/2022/11/windows10.0-kb5021043-x86_484ed491379e442debef6fdfb6860be749145017.cab'
        'x64' = 'https://catalog.s.download.windowsupdate.com/c/msdownload/update/software/crup/2022/11/windows10.0-kb5021043-x64_efa19d2d431c5e782a59daaf2d04d026bb8c8e76.cab'
        'ARM64' = 'https://catalog.s.download.windowsupdate.com/d/msdownload/update/software/crup/2022/11/windows10.0-kb5021043-arm64_59eed783be1d2a514c01cb325cfad83ea65f7515.cab'
    }
}
$WinOSBuild = [System.Environment]::OSVersion.Version.Build
$WinOSArch = if ([System.Environment]::Is64BitOperatingSystem) { 'x64' } else { 'x86' }
if (-not (Test-Path -Path $PatchFolder)) {
    New-Item -Path $PatchFolder -ItemType Directory | Out-Null
} else {
    if (Get-ChildItem -Path $PatchFolder -Recurse) {
        Write-Verbose "Emptying $PatchFolder"
        Get-ChildItem -Path $PatchFolder -Recurse | Remove-Item -Force -Recurse
    }
}
if (-not $All) {
    try {
        if (-not $BuildtoKBMap.ContainsKey($WinOSBuild)) {
            Write-Error "Unsupported Windows version $WinOSBuild"
            exit 1
        } else {
            $KB = $BuildtoKBMap[$WinOSBuild]
            if (-not $KBtoCABMap.ContainsKey($KB)) {
                Write-Error "Did not find patches for KB $KB"
                exit 1
            } else {
                $DownloadUrl = $KBtoCABMap[$KB][$WinOSArch]
                $FileName = ([URI]$DownloadUrl).Segments[-1]
                $TargetPath = Join-Path -Path $PatchFolder -ChildPath $FileName
                if (-not (Test-Path -Path $TargetPath)) {
                    Write-Verbose "Downloading $FileName"
                    Invoke-WebRequest -Uri $DownloadUrl -OutFile $TargetPath
                }
            }
        }
    } catch [System.Net.WebException] {
        Write-Error "Failed to download one or more CAB files for $WinOSBuild $WinOSArch"
        Write-Error $_.Exception.Message
        exit 1
    } catch [System.IO.IOException] {
        Write-Error "Could not write to $PatchFolder"
        Write-Error $_.Exception.Message
    }
} else {
    try {
        $KBtoCABMap.GetEnumerator() | ForEach-Object {
            $PatchSubFolder = Join-Path -Path $PatchFolder -ChildPath $_.Name
            Write-Warning "Downloading Patch CAB files to: $PatchSubFolder."
            if (-not (Test-Path -Path $PatchSubFolder)) {
                New-Item -Path $PatchSubFolder -ItemType Directory | Out-Null
            }
            foreach ($Arch in $_.Value.GetEnumerator()) {
                $ArchSubFolder = Join-Path -Path $PatchSubFolder -ChildPath $Arch.Name
                if (-not (Test-Path -Path $ArchSubFolder)) {
                    New-Item -Path $ArchSubFolder -ItemType Directory | Out-Null
                }
                $DownloadUrl = $Arch.Value
                $FileName = ([URI]$DownloadUrl).Segments[-1]
                $TargetPath = Join-Path -Path $ArchSubFolder -ChildPath $FileName
                if (-not (Test-Path -Path $TargetPath)) {
                    Write-Verbose "Downloading $FileName"
                    Invoke-WebRequest -Uri $DownloadUrl -OutFile $TargetPath
                } else {
                    Write-Verbose "Skipping $FileName as it already exists"
                }
            }
        }
    } catch [System.Net.WebException] {
        Write-Error "Failed to download one or more CAB files for $WinOSBuild $WinOSArch"
        Write-Error $_.Exception.Message
        exit 1
    } catch [System.IO.IOException] {
        Write-Error "Could not write to $PatchFolder"
        Write-Error $_.Exception.Message
    }
}
$ProgressPreference = $OriginalProgressPreference



View on GitHub
Change Logs

VERSION: 1.5

Fixes incorrectly switched URLs for 19042 to 19045 for the x86 and x64
downloads. Thanks to Wisecompany for helping find this.

VERSION: 1.4

Update to use the Safe OS Dynamic Update packages which are considerably
smaller.

VERSION: 1.3

Use $ProgressPreference to speed up execution. Thanks to
https://github.com/CodyRWhite for the suggestion.

VERSION: 1.2

Fix a bug on line 82 where a hashtable of architectures was attempted to be
accessed using the Windows build number. Thanks to Sir Loin of House WinAdmins
for spotting this. (Yes, it's a Game of Thrones reference. So original.)

VERSION: 1.1

Adds handling for 19044.

VERSION: 1.0

Initial release.

info

This version of the script downloads the SSU and Dynamic Cumulative Update files
- these are large and designed to update WinRE completely not just patch the
vulnerability.

Servicing Stack Update (SSU) and Dynamic Cumulative Update (DCU) version.
Large Files

This script downloads the SSU and Dynamic Cumulative Update files - these are
large and designed to update WinRE completely not just patch the vulnerability.
This will require a lot of space both to download them (especially if using
-All) and to apply them to WinRE.

This Script Was Updated

This script was updated after being published, if you're using it please compare
the version you have with the version available here.

This script was last updated on 2023/03/22.

Get-CVE202241099Patches.ps1

<#
.SYNOPSIS
    Update Management - CVE-2022-41099 Downloader
.DESCRIPTION
    This script will download the applicable patch for CVE-2022-41099 from Microsoft Update Catalog. It will detect the OS version and bitness and download the correct patch. This uses the January 2023 Servicing Stack Update (SSU) as the ultimate target.
.NOTES
    2023-03-22: Empty the patch folder if it's not empty. Thanks to Wisecompany for the suggestion.
    2023-01-18: Use `$ProgressPreference` to speed up execution. Thanks to CodyRWhite (GitHub) for the suggestion.
    2023-01-18: Fix bug accessing hashtable of architectures using Windows build number. Thanks to Sir Loin (WinAdmins) for spotting this.
    2023-01-17: Add support for Windows 10 19044 (21H2)
    2023-01-17: Initial version
.LINK
    Blog post: https://homotechsual.dev/2023/01/17/Download-CVE-2022-41099-Patches/
.EXAMPLE
    This example will download the applicable patch for CVE-2022-41099 to the folder C:\RMM\CVEs\2022-41099\.
    
    Get-CVE202241099Patches.ps1 -PatchFolder 'C:\RMM\CVEs\2022-41099\'
.EXAMPLE
    This example will download all patches for CVE-2022-41099 to the folder C:\RMM\CVEs\2022-41099\. With subfolders for each KB and architecture.

    Get-CVE202241099Patches.ps1 -PatchFolder 'C:\RMM\CVEs\2022-41099\' -All
#>
# We're targetting the January 2023 CU as our version to patch to. The CVE page links to the November 2022 CU, but the January 2023 CU is the latest version and refers again to the vulnerability so it seems safer to patch to that version. ARM links are included but the script does not handle ARM detection yet. We also download, where applicable the latest SSU.
[CmdletBinding()]
param (
    # The path to the folder to download the patch(es) to.
    [System.IO.DirectoryInfo]$PatchFolder = 'C:\RMM\CVEs\2022-41099\',
    # Download all patches including SSUs useful if you want to populate a staging folder. Will create a subfolder for each.
    [Switch]$All
)
$OriginalProgressPreference = $ProgressPreference
$ProgressPreference = 'SilentlyContinue'
$BuildtoKBMap = @{
    22623 = 5022303
    22621 = 5022303
    22000 = 5022287
    19045 = 5022282
    19044 = 5022282
    19043 = 5022282
    19042 = 5021233
}
$KBtoMSUMap = @{
    # KB5022303 - January 2023
    5022303 = @{
        'x64' = 'https://catalog.s.download.windowsupdate.com/d/msdownload/update/software/secu/2023/01/windows11.0-kb5022303-x64_87d49704f3f7312cddfe27e45ba493048fdd1517.msu'
        'ARM64' = 'https://catalog.s.download.windowsupdate.com/d/msdownload/update/software/secu/2023/01/windows11.0-kb5022303-arm64_4c207b992ed272bbdbfb35d77f0458548a7b86d1.msu'
    }
    # KB5022287 - January 2023
    5022287 = @{
        'x64' = 'https://catalog.s.download.windowsupdate.com/d/msdownload/update/software/secu/2023/01/windows10.0-kb5022287-x64_55641f1989bae2c2d0f540504fb07400a0f187b3.msu'
        'ARM64' = 'https://catalog.s.download.windowsupdate.com/c/msdownload/update/software/secu/2023/01/windows10.0-kb5022287-arm64_7d26e9ef2c00ce384e19d4ca234052e378747a05.msu'
    }
    # KB5022282 - January 2023
    5022282 = @{
        'x86' = 'https://catalog.s.download.windowsupdate.com/d/msdownload/update/software/secu/2023/01/windows10.0-kb5022282-x86_5fb142aca9e3f8c7ed37df9e7806b7f7f56d9599.msu'
        'x64' = 'https://catalog.s.download.windowsupdate.com/d/msdownload/update/software/secu/2023/01/windows10.0-kb5022282-x64_fdb2ea85e921869f0abe1750ac7cee34876a760c.msu'
        'ARM64' = 'https://catalog.s.download.windowsupdate.com/d/msdownload/update/software/secu/2023/01/windows10.0-kb5022282-arm64_9ccaddc4356ab1db614881e08635bd8959ff97f3.msu'
    }
    # KB5021233 - December 2022
    5021233 = @{
        'x86' = 'https://catalog.s.download.windowsupdate.com/d/msdownload/update/software/secu/2022/12/windows10.0-kb5021233-x86_e21531f654715af20b2aa329d6786080bd798963.msu'
        'x64' = 'https://catalog.s.download.windowsupdate.com/d/msdownload/update/software/secu/2022/12/windows10.0-kb5021233-x64_00bbf75a829a2cb4f37e4a2b876ea9503acfaf4d.msu'
        'ARM64' = 'https://catalog.s.download.windowsupdate.com/c/msdownload/update/software/secu/2022/12/windows10.0-kb5021233-arm64_4bac0de318c939e54fa6a9f537e892272446ae09.msu'
    }
}
$ArchtoSSUMap = @{
    'x86' = 'https://catalog.s.download.windowsupdate.com/c/msdownload/update/software/secu/2022/05/ssu-19041.1704-x86_3cec66c3891a613e6656f141547e573f9d700d35.msu'
    'x64' = 'https://catalog.s.download.windowsupdate.com/c/msdownload/update/software/secu/2022/05/ssu-19041.1704-x64_70e350118b85fdae082ab7fde8165a947341ba1a.msu'
    'ARM64' = 'https://catalog.s.download.windowsupdate.com/c/msdownload/update/software/secu/2022/05/ssu-19041.1704-arm64_dac34c98382f951bd654fe3affe0b3e7100b3745.msu'
}
$SSUKB = '5013942'
$WinOSBuild = [System.Environment]::OSVersion.Version.Build
$WinOSArch = if ([System.Environment]::Is64BitOperatingSystem) { 'x64' } else { 'x86' }
if (-not (Test-Path -Path $PatchFolder)) {
    New-Item -Path $PatchFolder -ItemType Directory | Out-Null
} else {
    if (Get-ChildItem -Path $PatchFolder -Recurse) {
        Write-Verbose "Emptying $PatchFolder"
        Get-ChildItem -Path $PatchFolder -Recurse | Remove-Item -Force -Recurse
    }
}
if (-not $All) {
    try {
        if ($WinOSBuild -lt 22000 -and $WinOSBuild -ge 19042) {
            if ($ArchtoSSUMap.ContainsKey($WinOSArch)) {
                $DownloadUrl = $ArchtoSSUMap[$WinOSArch]
                $FileName = ([URI]$DownloadUrl).Segments[-1]
                $TargetPath = Join-Path -Path $PatchFolder -ChildPath ("1_$FileName")
                if (-not (Test-Path -Path $TargetPath)) {
                    Write-Verbose "Downloading $FileName"
                    Invoke-WebRequest -Uri $DownloadUrl -OutFile $TargetPath
                }
            }
        }
        if (-not $BuildtoKBMap.ContainsKey($WinOSBuild)) {
            Write-Error "Unsupported Windows version $WinOSBuild"
            exit 1
        } else {
            $KB = $BuildtoKBMap[$WinOSBuild]
            if (-not $KBtoMSUMap.ContainsKey($KB)) {
                Write-Error "Did not find patches for KB $KB"
                exit 1
            } else {
                $DownloadUrl = $KBtoMSUMap[$KB][$WinOSArch]
                $FileName = ([URI]$DownloadUrl).Segments[-1]
                $TargetPath = Join-Path -Path $PatchFolder -ChildPath $FileName
                if (-not (Test-Path -Path $TargetPath)) {
                    Write-Verbose "Downloading $FileName"
                    Invoke-WebRequest -Uri $DownloadUrl -OutFile $TargetPath
                }
            }
        }
    } catch [System.Net.WebException] {
        Write-Error "Failed to download one or more MSU files for $WinOSBuild $WinOSArch"
        Write-Error $_.Exception.Message
        exit 1
    } catch [System.IO.IOException] {
        Write-Error "Could not write to $PatchFolder"
        Write-Error $_.Exception.Message
    }
} else {
    try {
        $SSUSubFolder = Join-Path -Path $PatchFolder -ChildPath $SSUKB
        Write-Warning "Downloading SSU MSU files to: $SSUSubFolder."
        if (-not (Test-Path -Path $SSUSubFolder)) {
            New-Item -Path $SSUSubFolder -ItemType Directory | Out-Null
        }
        $ArchtoSSUMap.GetEnumerator() | ForEach-Object {
            $ArchSubFolder = Join-Path -Path $SSUSubFolder -ChildPath $_.Name
            if (-not (Test-Path -Path $ArchSubFolder)) {
                New-Item -Path $ArchSubFolder -ItemType Directory | Out-Null
            }
            $DownloadUrl = $_.Value
            $FileName = ([URI]$DownloadUrl).Segments[-1]
            $TargetPath = Join-Path -Path $ArchSubFolder -ChildPath $FileName
            if (-not (Test-Path -Path $TargetPath)) {
                Write-Verbose "Downloading $FileName"
                Invoke-WebRequest -Uri $DownloadUrl -OutFile $TargetPath
            } else {
                Write-Verbose "Skipping $FileName as it already exists"
            }
        }
        $KBtoMSUMap.GetEnumerator() | ForEach-Object {
            $PatchSubFolder = Join-Path -Path $PatchFolder -ChildPath $_.Name
            Write-Warning "Downloading Patch MSU files to: $PatchSubFolder."
            if (-not (Test-Path -Path $PatchSubFolder)) {
                New-Item -Path $PatchSubFolder -ItemType Directory | Out-Null
            }
            foreach ($Arch in $_.Value.GetEnumerator()) {
                $ArchSubFolder = Join-Path -Path $PatchSubFolder -ChildPath $Arch.Name
                if (-not (Test-Path -Path $ArchSubFolder)) {
                    New-Item -Path $ArchSubFolder -ItemType Directory | Out-Null
                }
                $DownloadUrl = $Arch.Value
                $FileName = ([URI]$DownloadUrl).Segments[-1]
                $TargetPath = Join-Path -Path $ArchSubFolder -ChildPath $FileName
                if (-not (Test-Path -Path $TargetPath)) {
                    Write-Verbose "Downloading $FileName"
                    Invoke-WebRequest -Uri $DownloadUrl -OutFile $TargetPath
                } else {
                    Write-Verbose "Skipping $FileName as it already exists"
                }
            }
        }
    } catch [System.Net.WebException] {
        Write-Error "Failed to download one or more MSU files for $WinOSBuild $WinOSArch"
        Write-Error $_.Exception.Message
        exit 1
    } catch [System.IO.IOException] {
        Write-Error "Could not write to $PatchFolder"
        Write-Error $_.Exception.Message
    }
}
$ProgressPreference = $OriginalProgressPreference



View on GitHub
Change Logs

VERSION: 1.4

Empty the patch folder if it's not empty. Thanks to Wisecompany for the
suggestion.

VERSION: 1.3

Use $ProgressPreference to speed up execution. Thanks to
https://github.com/CodyRWhite for the suggestion.

VERSION: 1.2

Fix a bug on line 82 where a hashtable of architectures was attempted to be
accessed using the Windows build number. Thanks to Sir Loin of House WinAdmins
for spotting this. (Yes, it's a Game of Thrones reference. So original.)

VERSION: 1.1

Adds handling for 19044.

VERSION: 1.0

Initial release.


EXAMPLES

This example will download the applicable patch and SSU (if applicable) for
CVE-2022-41099 to the folder C:\RMM\CVEs\2022-41099.

Get-CVE202241099Patches.ps1 -PatchFolder 'C:\RMM\CVEs\2022-41099\'




This example will download all patches for CVE-2022-41099 to the folder
C:\RMM\CVEs\2022-41099. With subfolders for each KB and architecture.

Get-CVE202241099Patches.ps1 -PatchFolder 'C:\RMM\CVEs\2022-41099\' -All





VALIDATING THE FIX

By popular request you can validate the fix using the principles in the script
used for CVE detection.


COLLABORATION WITH MARTIN HIMKEN

This post and the WinRE patching script on Martin's blog at https://manima.de
are the result of a collaboration between Martin and I to help mutually improve
our various efforts towards patching CVE-2022-41099.

Read Martin's blog on WinRE patching
Tags:
 * Collaboration
 * Vulnerability
 * Security
 * Windows
 * CVE
 * PowerShell


SENDING TOAST NOTIFICATIONS IN WINDOWS 10 AND 11

January 17, 2023 · 8 min read
Mikey O'Toole


THIS POST USES CODE IN PART FROM THE SMSAGENT BLOG.

This post takes a snippet from the SMSAgent Blog and adds some additional magic
along with two new custom functions.

View the original post

If you're a Windows 10 or 11 user you'll be familiar with the toast
notifications that appear in the bottom right of your screen. These are a great
way to get a quick message to the user without interrupting what they're doing.
In this article we'll look at how to send a toast notification from PowerShell.

We could use the excellent BurntToast PowerShell module to send a toast
notification, but in the interests of reducing the number of third-party modules
installed on client machines we'll be using the underlying .NET APIs directly as
our needs are fairly simple.

Sending toast notifications is fairly simple once you get to grips with the
underlying XML schema but we want our Toasts to be next-level so we're going to
make them


CREATING A NOTIFICATION APP

This script takes a few parameters to create a Notification App. The App Name
and Icon are the most important as these are what will appear in the toast
notification.

Parameter documentation follows:

ParameterTypeDescriptionIconURIURIThe URI of the app icon to use for the
notification app registration. This will be downloaded to the working
directory.IconFileNameStringFile name to use for the app icon. Optional. If not
specified, the file name from the URI will be
used.WorkingDirectoryDirectoryInfoThe working directory to use for the app icon.
If not specified, 'C:\RMM\NotificationApp' will be used.AppIdStringThe app ID to
use for the notification app registration. Expected format is something like:
'CompanyName.AppName'.AppDisplayNameStringThe app display name to use for the
notification app registration.AppIconBackgroundColorStringThe background color
to use for the app icon. Optional. If not specified, the background color will
be transparent. Expected format is a hex value like 'FF000000' or '0' for
transparent.ShowInSettingsIntShow the app in the Windows Settings app. Optional.
If not specified, the app will not be shown in the Settings app.


THE SCRIPT

New-NotificationApp.ps1

<#
    .SYNOPSIS
        Utilities - Windows - Notifications - Register Notification App
    .DESCRIPTION
        Registers a notification app into the Windows registry allowing toast notifications to be sent using the App's Id which provides for control over the application display name and the app icon.
    .NOTES
        2023-01-17: Initial version
    .LINK
        Blog post: https://homotechsual.dev/2023/01/17/Toast-Notifications-Windows-10-and-11/
#>
#requires -RunAsAdministrator
[CmdletBinding()]
Param(
    # The URI of the app icon to use for the notification app registration.
    [Parameter(Mandatory)]
    [uri]$IconURI,
    # File name to use for the app icon. Optional. If not specified, the file name from the URI will be used.
    [string]$IconFileName,
    # The working directory to use for the app icon. If not specified, 'C:\RMM\NotificationApp\' will be used.
    [System.IO.DirectoryInfo]$WorkingDirectory = 'C:\RMM\NotificationApp\',
    # The app ID to use for the notification app registration. Expected format is something like: 'CompanyName.AppName'.
    [Parameter(Mandatory)]
    [string]$AppId,
    # The app display name to use for the notification app registration.
    [Parameter(Mandatory)]
    [string]$AppDisplayName,
    # The background color to use for the app icon. Optional. If not specified, the background color will be transparent. Expected format is a hex value like 'FF000000' or '0' for transparent.
    [ValidatePattern('^(0)$|^([A-F0-9]{8})$')]
    [string]$AppIconBackgroundColor = 0,
    # Whether or not to show the app in the Windows Settings app. Optional. If not specified, the app will not be shown in the Settings app. Expected values are 0 or 1 (0 = false, 1 = true).
    [int]$ShowInSettings = 0
)
# Functions
function Get-NotificationApp {
    <#
    .SYNOPSIS
        Gets the notification app registration information.
    #>
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory)]
        [string]$AppId
    )
    $HKCR = Get-PSDrive -Name HKCR -ErrorAction SilentlyContinue
    If (!($HKCR)) {
        $null = New-PSDrive -Name HKCR -PSProvider Registry -Root HKEY_CLASSES_ROOT -Scope Script
    }
    $AppRegPath = 'HKCR:\AppUserModelId'
    $RegPath = "$AppRegPath\$AppID"
    If (!(Test-Path $RegPath)) {
        return $null
    } else {
        $NotificationApp = Get-Item -Path $RegPath
        return $NotificationApp
    }
}
Function Register-NotificationApp {
    <#
    .SYNOPSIS
        Registers an application to receive toast notifications.
    .NOTES
        Original Author: Trevor Jones
        Original Author Link: https://smsagent.blog/author/trevandju/
        Version: 2.0
        Version Date: 2023-01-17
        Version Description: Added AppIcon and AppIconBackground parameters.
        Version Author: Mikey O'Toole
        Version: 1.0
        Version Date: 2020-10-20
        Version Description: Initial release by Trevor Jones.
    .LINK
        https://smsagent.blog/2020/10/20/adding-your-own-caller-app-for-custom-windows-10-toast-notifications/
    #>
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory)]
        [string]$AppId,
        [Parameter(Mandatory)]
        [string]$AppDisplayName,
        [System.IO.FileInfo]$AppIcon = $null,
        [ValidatePattern('^(0)$|^([A-F0-9]{8})$')]
        [string]$AppIconBackgroundColor = $null,
        [int]$ShowInSettings = 0
    )
    $HKCR = Get-PSDrive -Name HKCR -ErrorAction SilentlyContinue
    If (!($HKCR)) {
        $null = New-PSDrive -Name HKCR -PSProvider Registry -Root HKEY_CLASSES_ROOT -Scope Script
    }
    $AppRegPath = 'HKCR:\AppUserModelId'
    $RegPath = "$AppRegPath\$AppId"
    If (!(Test-Path $RegPath)) {
        $null = New-Item -Path $AppRegPath -Name $AppId -Force
    }
    $DisplayName = Get-ItemProperty -Path $RegPath -Name DisplayName -ErrorAction SilentlyContinue | Select-Object -ExpandProperty DisplayName -ErrorAction SilentlyContinue
    If ($DisplayName -ne $AppDisplayName) {
        $null = New-ItemProperty -Path $RegPath -Name DisplayName -Value $AppDisplayName -PropertyType String -Force
    }
    $Icon = Get-ItemProperty -Path $RegPath -Name IconUri -ErrorAction SilentlyContinue | Select-Object -ExpandProperty IconUri -ErrorAction SilentlyContinue
    if ($Icon -ne $AppIcon) {
        $null = New-ItemProperty -Path $RegPath -Name IconUri -Value $AppIcon -PropertyType String -Force
    }
    $BackgroundColor = Get-ItemProperty -Path $RegPath -Name IconBackgroundColor -ErrorAction SilentlyContinue | Select-Object -ExpandProperty IconBackgroundColor -ErrorAction SilentlyContinue
    if ($BackgroundColor -ne $AppIconBackgroundColor) {
        $null = New-ItemProperty -Path $RegPath -Name IconBackgroundColor -Value $AppIconBackgroundColor -PropertyType String -Force
    }
    $ShowInSettingsValue = Get-ItemProperty -Path $RegPath -Name ShowInSettings -ErrorAction SilentlyContinue | Select-Object -ExpandProperty ShowInSettings -ErrorAction SilentlyContinue
    If ($ShowInSettingsValue -ne $ShowInSettings) {
        $null = New-ItemProperty -Path $RegPath -Name ShowInSettings -Value $ShowInSettings -PropertyType DWORD -Force
    }
    $null = Remove-PSDrive -Name HKCR -Force
}
function Get-AppIcon {
    <#
    .SYNOPSIS
        Downloads the app icon from a URI and saves it to a file.
    #>
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory)]
        [uri]$IconURI,
        [string]$IconFileName = $null,
        [Parameter(Mandatory)]
        [System.IO.DirectoryInfo]$WorkingDirectory
    )
    if (!($WorkingDirectory.Exists)) {
        $WorkingDirectory.Create()
    }
    if (!($IconFileName)) {
        $IconFileName = $IconURI.Segments[-1]
    }
    $IconFilePath = Join-Path -Path $WorkingDirectory.FullName -ChildPath $IconFileName
    $IconFile = New-Object System.IO.FileInfo $IconFilePath
    If ($IconFile.Exists) {
        $IconFile.Delete()
    }
    Invoke-WebRequest -Uri $IconURI -OutFile $IconFile.FullName | Out-Null
    return $IconFile.FullName
}
# Main Script
$AppId = $AppId.TrimStart('"').TrimEnd('"')
$AppIcon = Get-AppIcon -IconURI $IconURI -WorkingDirectory $WorkingDirectory
$NotificationAppParams = @{
    AppID = $AppId
    AppDisplayName = $AppDisplayName
    AppIcon = $AppIcon
    AppIconBackgroundColor = $AppIconBackgroundColor
}
if ($ShowInSettings) {
    $NotificationAppParams.Add('ShowInSettings', $ShowInSettings)
}
Register-NotificationApp @NotificationAppParams
$NotificationApp = Get-NotificationApp -AppID $AppId
if (!($NotificationApp)) {
    Write-Error 'Failed to register the notification app.'
    Exit 1
} else {
    Write-Output ('Successfully registered the notification app {0}.' -f $NotificationApp.GetValue('DisplayName'))
    Exit 0
}



View on GitHub


GENERIC EXAMPLE

# Setup parameter hashtable.
$NotificationAppParams = @{
  IconURI = 'https://homotechsual.dev/img/Icon.png'
  IconFileName = 'homotechsual.png'
  WorkingDirectory = 'C:\RMM\NotificationApp'
  AppId = 'homotechsual.example'
  AppDisplayName = 'Homotechsual Example'
  AppIconBackgroundColor = 0
  ShowInSettings = 1
}
Register-NotificationApp @NotificationAppParams





RMM EXAMPLE

Unsurprisingly, I'm going to use NinjaOne as the example RMM here. So I've done
the donkeywork that is best documented by NinjaOne themselves on the Dojo and
added the script above to the NinjaOne Script Library - but what next?

OPTION 1: SAME APP FOR ALL CUSTOMERS

Well the chances are that you want to use the same relatively small set of apps
per customer so we'll just store a nice blob of text in the Ninja Script
Library.

We could end up with a "Preset Parameter" set like this:

-IconURI https://homotechsual.dev/img/Icon.png -IconFileName homotechsual.png
-WorkingDirectory C:\RMM\NotificationApp -AppId homotechsual.example
-AppDisplayName "Homotechsual Example" -AppIconBackgroundColor 0 -ShowInSettings
1

You can have multiple preset parameter sets and simply select the one you want
to use when you run the script or when you schedule it against a group (or
however you want to run this) but what about getting more creative?

OPTION 2: PULL APP DETAILS PER CUSTOMER

We can use Documentation fields to store the details of the app per client and
then use the NinjaOne CLI to pull the details when we run the script. This is a
little more work but it's worth it if you want to brand your notifications per
customer.

We'll need to add a few fields to the NinjaOne Documentation tab for each
client:

FieldTypeDescriptionNotificationIconURIURLThe URI of the app icon to use for the
notification app registration. This will be downloaded to the working
directory.NotificationAppIdStringThe app ID to use for the notification app
registration. Expected format is something like:
'CompanyName.AppName'.NotificationAppDisplayNameStringThe app display name to
use for the notification app
registration.NotificationAppBackgroundColorStringThe background color to use for
the app icon. Optional. If not specified, the background color will be
transparent. Expected format is a hex value like 'FF000000' or '0' for
transparent.

We can then use the NinjaOne CLI to pull the details for the client we're
running the script against and use them to create the parameter set for the
script.

This means editing our script a little to accept the parameters from the CLI and
then using the NinjaOne CLI to pull the details from the Documentation tab.
We're going to assume you called your Document Template Example Template and
your fields named as above in the table (with spaces between words if you wish).

At the top of the script we're going to replace the entire parameter block:

 * Before
 * After

[CmdletBinding()]
Param(
    # The URI of the app icon to use for the notification app registration.
    [Parameter(Mandatory)]
    [uri]$IconURI,
    # File name to use for the app icon. Optional. If not specified, the file name from the URI will be used.
    [string]$IconFileName,
    # The working directory to use for the app icon. If not specified, 'C:\RMM\NotificationApp\' will be used.
    [System.IO.DirectoryInfo]$WorkingDirectory = 'C:\RMM\NotificationApp\',
    # The app ID to use for the notification app registration. Expected format is something like: 'CompanyName.AppName'.
    [Parameter(Mandatory)]
    [string]$AppId,
    # The app display name to use for the notification app registration.
    [Parameter(Mandatory)]
    [string]$AppDisplayName,
    # The background color to use for the app icon. Optional. If not specified, the background color will be transparent. Expected format is a hex value like 'FF000000' or '0' for transparent.
    [ValidatePattern('^(0)$|^([A-F0-9]{8})$')]
    [string]$AppIconBackgroundColor = 0,
    # Whether or not to show the app in the Windows Settings app. Optional. If not specified, the app will not be shown in the Settings app. Expected values are 0 or 1 (0 = false, 1 = true).
    [int]$ShowInSettings = 0
)




And replace it with this:

$IconURI = Ninja-Docs-Property-Get "Example Template" "NotificationIconURI"
$IconFileName = 'NotificationIcon.png'
$WorkingDirectory = 'C:\RMM\NotificationApp\'
$AppId = Ninja-Docs-Property-Get "Example Template" "NotificationAppId"
$AppDisplayName = Ninja-Docs-Property-Get "Example Template" "NotificationAppDisplayName"
$AppIconBackgroundColor = Ninja-Docs-Property-Get "Example Template" "NotificationAppBackgroundColor"
$ShowInSettings = 1




If done correctly, you should be able to run the script and have it pull the
details from the Documentation tab for the client you're running it against.
This could be improved by exiting the script if the required fields are not
populated so we could add something like this after $ShowInSettings = 1:

if (($null -eq $IconURI) -or ($null -eq $AppId) -or ($null -eq $AppDisplayName)) {
    Write-Error "Required fields are not populated in the Documentation tab."
    exit
}




Well that's setting up the notification app registration. Now we need to
actually send a notification using the app.


SENDING A NOTIFICATION

So this is an entirely separate script - you can use it with the custom app you
just created or you can use it with the default where notifications come from
Windows PowerShell. It's up to you.

"Runs as User"

This script runs in the user's context it is unlikely it'll work running as
Administrator or as a scheduled task. If you want to run it as a scheduled task,
you'll need to use something like RunAsUser to run it as the user.

This script takes a few parameters to create a Notification App. The App Name
and Icon are the most important as these are what will appear in the toast
notification.

Parameter documentation follows:

ParameterTypeDescriptionAppIdStringThe app ID to use for the notification app
registration. Expected format is something like:
'CompanyName.AppName'.NotificationImageStringThe path to the image to use for
the notification.NotificationTitleStringThe title to use for the
notification.NotificationMessageStringThe message text to use for the
notification.NotificationTypeStringThe type of notification to send. Expected
values are alarm, reminder, incomingcall or default. Details on what each type
looks like can be found in Microsoft's Toast schema.


THE SCRIPT

Send-SimpleNotification.ps1

#requires -version 5.1
<#
    .SYNOPSIS
        Utilities - Windows - Notifications - Send Notification (Simple)
    .DESCRIPTION
        Sends a simple toast notification to the Windows notification center.
    .NOTES
        2023-01-17: Initial version
    .LINK
        Blog post: https://homotechsual.dev/2023/01/17/Toast-Notifications-Windows-10-and-11/
#>
[CmdletBinding()]
Param(
    [string]$AppID = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe',
    [string]$NotificationImage,
    [Parameter(Mandatory)]
    [string]$NotificationTitle,
    [Parameter(Mandatory)]
    [string]$NotificationMessage,
    [ValidateSet('alarm', 'reminder', 'incomingcall', 'default')]
    [string]$NotificationType = 'default'
)
$null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]
$null = [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime]
$NotificationTemplate = [xml]@"
<toast scenario="$NotificationType">
    <visual>
        <binding template="ToastGeneric">
            <text>$NotificationTitle</text>
            <text>$NotificationMessage</text>
            <image placement="appLogoOverride" src="$NotificationImage"/>
        </binding>
    </visual>
</toast>
"@
$NotificationXML = [Windows.Data.XML.DOM.XMLDocument]::New()
$NotificationXML.LoadXml($NotificationTemplate.OuterXml)
$Toast = [Windows.UI.Notifications.ToastNotification]::new($NotificationXML)
$ToastNotifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($AppID)
$ToastNotifier.Show($Toast)



View on GitHub


GENERIC EXAMPLE

# Setup parameter hashtable.
$NotificationParams = @{
  AppId = 'homotechsual.example'
  NotificationImage = 'C:\RMM\NotificationApp\NotificationIcon.png'
  NotificationTitle = 'Example Notification'
  NotificationMessage = 'This is an example notification.'
  NotificationType = 'reminder'
}
Send-Notification @NotificationParams





SCRIPT EXAMPLE

It's probably most useful to use this script as part of a larger script. For
example, you could use it to send a notification when a script has finished
running. You could also use it to send a notification when a scheduled task has
finished running.

NotificationExample.ps1

<#
    .SYNOPSIS
        Utilities - Windows - Notifications - Example Usage
    .DESCRIPTION
        Demonstrates usage of the Send-Notification function within a larger script using a pre-registered app.
    .NOTES
        2023-01-17: Initial version
    .LINK
        Blog post: https://homotechsual.dev/2023/01/17/Toast-Notifications-Windows-10-and-11/
#>
# Functions
function Send-Notification {
    [CmdletBinding()]
    Param(
        [string]$AppID = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe',
        [string]$NotificationImage,
        [Parameter(Mandatory)]
        [string]$NotificationTitle,
        [Parameter(Mandatory)]
        [string]$NotificationMessage,
        [ValidateSet('alarm', 'reminder', 'incomingcall', 'default')]
        [string]$NotificationType = 'default'
    )
    $null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]
    $null = [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime]
    $NotificationTemplate = [xml]@"
<toast scenario="$NotificationType">
    <visual>
        <binding template="ToastGeneric">
            <text>$NotificationTitle</text>
            <text>$NotificationMessage</text>
            <image placement="appLogoOverride" src="$NotificationImage"/>
        </binding>
    </visual>
</toast>
"@
    $NotificationXML = [Windows.Data.XML.DOM.XMLDocument]::New()
    $NotificationXML.LoadXml($NotificationTemplate.OuterXml)
    $Toast = [Windows.UI.Notifications.ToastNotification]::new($NotificationXML)
    $ToastNotifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($AppID)
    $ToastNotifier.Show($Toast)
}
# Main Loop
$SpoolerService = Get-Service -Name 'Spooler'
if ($SpoolerService.Status -eq 'Running') {
    try {
        Restart-Service -Name 'Spooler'
        $NotificationParams = @{
            AppId = 'homotechsual.example'
            NotificationImage = 'C:\RMM\NotificationApp\NotificationIcon.png'
            NotificationTitle = 'Printer Spooler Restarted'
            NotificationMessage = 'The printer spooler service was restarted.'
            NotificationType = 'reminder'
        }
        Send-Notification @NotificationParams
    } catch {
        $NotificationParams = @{
            AppId = 'homotechsual.example'
            NotificationImage = 'C:\RMM\NotificationApp\NotificationIcon.png'
            NotificationTitle = 'Printer Spooler Failed to Restart'
            NotificationMessage = 'The printer spooler service failed to restart.'
            NotificationType = 'reminder'
        }
        Send-Notification @NotificationParams
    }
}



View on GitHub

This would output something like this:



There are many other ways you could use notifications and you can add (and
handle) actions within notifications but I'm spoiling some killer upcoming blog
posts.

Tags:
 * Notifications
 * User Experience
 * Windows
 * Client Management
 * PowerShell


UPDATING DRIVERS FROM MICROSOFT UPDATE

January 10, 2023 · One min read
Mikey O'Toole


CREATING FIELDS

Creating custom fields in NinjaOne
To create a custom field in NinjaOne go to Administration > Devices and select
either Role Custom Fields or Global Custom Fields then select Add.


 * Role Custom Fields are custom fields that are specific to a device role.
 * Global Custom Fields are custom fields that are applicable to all devices
   and/or to a location and/or organisation

Make sure you add the fields to the roles you want to use them in at
Administration > Devices > Roles (for role custom fields).

When you create your custom field you need to make sure that you set the Scripts
permission to ensure that you can read or write to the field from your scripts -
as appropriate for the script you're using.

We're adding three role custom fields for devices with the Windows Laptop role:

Field NameField TypeDescriptionDriver Update: Reboot RequiredCheckboxWhether the
latest driver update run requires a reboot to finalise.Driver Update: Last
RunDate/TimeThe date and time the driver update script last ran
successfully.Driver Update: Number Installed on Last RunIntegerThe number of
driver updates installed on last script run.


THE SCRIPT

Invoke-MUDriverUpdater.ps1

<#
    .SYNOPSIS
        Update Management - Microsoft Update Driver Updater
    .DESCRIPTION
        Downloads and installs the latest drivers using Microsoft Update.
    .NOTES
        2023-01-10: Initial version
    .LINK
        Blog post: https://homotechsual.dev/2023/01/10/Updating-Drivers-from-Microsoft-Update/
#>
[CmdletBinding()]
param ()
try {
    # Create a new update service manager COM object.
    $UpdateService = New-Object -ComObject Microsoft.Update.ServiceManager
    # If the Microsoft Update service is not enabled, enable it.
    $MicrosoftUpdateService = $UpdateService.Services | Where-Object { $_.ServiceId -eq '7971f918-a847-4430-9279-4a52d1efe18d' }
    if (!$MicrosoftUpdateService) {
        $UpdateService.AddService2('7971f918-a847-4430-9279-4a52d1efe18d', 7, '')
    }
    # Create a new update session COM object.
    $UpdateSession = New-Object -ComObject Microsoft.Update.Session
    # Create a new update searcher in the update session.
    $UpdateSearcher = $UpdateSession.CreateUpdateSearcher()
    # Configure the update searcher to search for driver updates from Microsoft Update.
    ## Set the update searcher 
    $UpdateSearcher.ServiceID = '7971f918-a847-4430-9279-4a52d1efe18d'
    ## Set the update searcher to search for per-machine updates only.
    $UpdateSearcher.SearchScope = 1
    ## Set the update searcher to search non-Microsoft sources only (no WSUS, no Windows Update) so Microsoft Update and Manufacturers only.
    $UpdateSearcher.ServerSelection = 3
    # Set our search criteria to only search for driver updates.
    $SearchCriteria = "IsInstalled=0 and Type='Driver'"
    # Search for driver updates.
    Write-Verbose 'Searching for driver updates...'
    $UpdateSearchResult = $UpdateSearcher.Search($SearchCriteria)
    $UpdatesAvailable = $UpdateSearchResult.Updates
    # If no updates are available, output a message and exit.
    if (($UpdatesAvailable.Count -eq 0) -or ([string]::IsNullOrEmpty($UpdatesAvailable))) {
        Write-Warning 'No driver updates are available.'
        Ninja-Property-Set driverUpdateRebootRequired 0 # Adjust for RMM
        Ninja-Property-Set driverUpdateLastRun (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss') # Adjust for RMM
        Ninja-Property-Set driverUpdateNumberInstalledOnLastRun 0 # Adjust for RMM
        exit 0
    } else {
        Write-Verbose "Found $($UpdatesAvailable.Count) driver updates."
        # Output available updates.
        $UpdatesAvailable | Select-Object -Property Title, DriverModel, DriverVerDate, DriverClass, DriverManufacturer | Format-Table
        # Create a new update collection to hold the updates we want to download.
        $UpdatesToDownload = New-Object -ComObject Microsoft.Update.UpdateColl
        $UpdatesAvailable | ForEach-Object {
            # Add the update to the update collection.
            $UpdatesToDownload.Add($_) | Out-Null
        }
        # If there are updates to download, download them.
        if (($UpdatesToDownload.count -gt 0) -or (![string]::IsNullOrEmpty($UpdatesToDownload))) {
            # Create a fresh session to download and install updates.
            $UpdaterSession = New-Object -ComObject Microsoft.Update.Session
            $UpdateDownloader = $UpdaterSession.CreateUpdateDownloader()
            # Add the updates to the downloader.
            $UpdateDownloader.Updates = $UpdatesToDownload
            # Download the updates.
            Write-Verbose 'Downloading driver updates...'
            $UpdateDownloader.Download()
        }
        # Create a new update collection to hold the updates we want to install.
        $UpdatesToInstall = New-Object -ComObject Microsoft.Update.UpdateColl
        # Add downloaded updates to the update collection.
        $UpdatesToDownload | ForEach-Object { 
            if ($_.IsDownloaded) {
                # Add the update to the update collection if it has been downloaded.
                $UpdatesToInstall.Add($_) | Out-Null
            }
        }
        # If there are updates to install, install them.
        if (($UpdatesToInstall.count -gt 0) -or (![string]::IsNullOrEmpty($UpdatesToInstall))) {
            # Create an update installer.
            $UpdateInstaller = $UpdaterSession.CreateUpdateInstaller()
            # Add the updates to the installer.
            $UpdateInstaller.Updates = $UpdatesToInstall
            # Install the updates.
            Write-Verbose 'Installing driver updates...'
            $InstallationResult = $UpdateInstaller.Install()
            # If we need to reboot flag that information.
            if ($InstallationResult.RebootRequired) {
                Write-Warning 'Reboot required to complete driver updates.'
                Ninja-Property-Set driverUpdateRebootRequired 1 # Adjust for RMM
            }
        
            # Output the results of the installation.
            ## Result codes: 0 = Not Started, 1 = In Progress, 2 = Succeeded, 3 = Succeeded with Errors, 4 = Failed, 5 = Aborted
            ## We consider 1, 2, and 3 to be successful here.
            if (($InstallationResult.ResultCode -eq 1) -or ($InstallationResult.ResultCode -eq 2) -or ($InstallationResult.ResultCode -eq 3)) {
                Write-Verbose 'Driver updates installed successfully.'
                Ninja-Property-Set driverUpdateRebootRequired 0 # Adjust for RMM
                Ninja-Property-Set driverUpdateLastRun (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss') # Adjust for RMM
                Ninja-Property-Set driverUpdateNumberInstalledOnLastRun $UpdatesToInstall.Count # Adjust for RMM
            } else {
                Write-Warning "Driver updates failed to install. Result code: $($InstallationResult.ResultCode.ToString())"
                exit 1
            }
        }
    }
} catch {
    Write-Error $_.Exception.Message
    exit 1
}



View on GitHub


THE RESULTS



You can set this up to run on a schedule - we run this script immediately on
machine onboarding and then every 7 days on a Tuesday. This doesn't always have
anything to do as our Windows Update run usually handles these updates, but it's
a good way to ensure that we're always up to date with the latest drivers from
Microsoft Update.

Tags:
 * Updates
 * NinjaOne
 * Custom Fields
 * PowerShell

Older Entries
Useful Links and Communities
 * CIPP
 * MSPGeek
 * WinAdmins

More Homotechsual
 * RSS Feed
 * Mastodon
 * GitHub
 * Twitter

Other Useful Blogs
 * CyberDrain
 * MSPP
 * Gavsto
 * TechFoundry
 * MendyOnline

Copyright © 2023 Mikey O'Toole | Built with Docusaurus version 3.0.1