Script Hub – NinjaOne https://www.ninjaone.com Software For IT Ninjas Tue, 31 Dec 2024 09:10:14 +0000 en-US hourly 1 https://wordpress.org/?v=6.7.2 https://www.ninjaone.com/wp-content/uploads/2024/10/favicon-2024-150x150.png Script Hub – NinjaOne https://www.ninjaone.com 32 32 Leveraging PowerShell to Enable or Disable System Restore on Windows Systems https://www.ninjaone.com/script-hub/enable-or-disable-system-restore/ Sat, 14 Dec 2024 00:56:15 +0000 https://www.ninjaone.com/?post_type=script_hub&p=386931 System Restore is a powerful feature in Windows that allows users to revert their system to a previous state, addressing issues caused by faulty updates, software installations, or other changes. For IT professionals and Managed Service Providers (MSPs), configuring System Restore efficiently and programmatically is vital, particularly when managing multiple systems. A robust PowerShell script, like the one provided here, offers a precise, automated way to enable, disable, or manage restore points on Windows devices.

Background

System Restore safeguards system integrity by creating snapshots called restore points. While this feature is a lifesaver in emergencies, its configuration often requires administrative intervention, especially in enterprise environments. Manual configuration is not scalable for large networks, making a PowerShell-based solution indispensable. This script streamlines System Restore management, helping IT teams ensure that systems are configured consistently while avoiding potential issues such as excessive shadow copy deletions.

The Script:

#Requires -Version 5.1

<#
.SYNOPSIS
    Enables or Disables System Restore on System Drive(C:). Use caution when enabling on a system that contains system image backups(VSS).
.DESCRIPTION
    Enables or Disables System Restore on System Drive(C:). Use caution when enabling on a system that contains system image backups(VSS), as it will cause shadow copies to be deleted faster than normal.

    By using this script, you indicate your acceptance of the following legal terms as well as our Terms of Use at https://www.ninjaone.com/terms-of-use.
    Ownership Rights: NinjaOne owns and will continue to own all right, title, and interest in and to the script (including the copyright). NinjaOne is giving you a limited license to use the script in accordance with these legal terms. 
    Use Limitation: You may only use the script for your legitimate personal or internal business purposes, and you may not share the script with another party. 
    Republication Prohibition: Under no circumstances are you permitted to re-publish the script in any script library or website belonging to or under the control of any other software provider. 
    Warranty Disclaimer: The script is provided “as is” and “as available”, without warranty of any kind. NinjaOne makes no promise or guarantee that the script will be free from defects or that it will meet your specific needs or expectations. 
    Assumption of Risk: Your use of the script is at your own risk. You acknowledge that there are certain inherent risks in using the script, and you understand and assume each of those risks. 
    Waiver and Release: You will not hold NinjaOne responsible for any adverse or unintended consequences resulting from your use of the script, and you waive any legal or equitable rights or remedies you may have against NinjaOne relating to your use of the script. 
    EULA: If you are a NinjaOne customer, your use of the script is subject to the End User License Agreement applicable to you (EULA).

.EXAMPLE
    (No Parameters)
    ## EXAMPLE OUTPUT WITHOUT PARAMS ##

PARAMETER: -Action "Enable"
    Enables System Restore.
.EXAMPLE
    -Action "Enable"
    ## EXAMPLE OUTPUT WITH Action ##
    [Info] Enabling System Restore
    [Info] Enabled System Restore

PARAMETER: -Action "Disable"
    Disables System Restore.
.EXAMPLE
    -Action "Disable"
    [Info] Disabling System Restore
    [Info] Disabled System Restore

PARAMETER: -Action "DisableAndRemove"
    Disables System Restore and removes all existing restore points.
.EXAMPLE
    -Action "DisableAndRemove"
    [Info] Disabling System Restore
    [Info] Disabled System Restore
    [Info] Removing Existing Restore Points
    [Info] Removed Existing Restore Points

.NOTES
    Minimum OS Architecture Supported: Windows 10, Windows Server 2016
    Release Notes: Initial Release
#>

[CmdletBinding()]
param (
    [Parameter()]
    [ValidateSet("Enable", "Disable", "DisableAndRemove")]
    [string]$Action
)

begin {
    $EnableSystemRestore = $false
    $DisableSystemRestore = $false
    $RemoveExistingRestorePoints = $false
    function Test-IsElevated {
        $id = [System.Security.Principal.WindowsIdentity]::GetCurrent()
        $p = New-Object System.Security.Principal.WindowsPrincipal($id)
        $p.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
    }

    # If the registry value is 1, System Restore is enabled.
    $RegValue = Get-ItemPropertyValue -Path "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore\" -Name "RPSessionInterval" -ErrorAction SilentlyContinue

    $SystemRestoreStatus = if ($RegValue -ge 1) {
        # If either of the above conditions are met, System Restore is enabled.
        Write-Output "Enabled"
    }
    else {
        Write-Output "Disabled"
    }

    # Check if the action Script Variable was used
    if ($env:action -and $env:action -ne "null") {
        switch ($env:action) {
            "Enable" { $EnableSystemRestore = $true }
            "Disable" { $DisableSystemRestore = $true }
            "Disable and Remove Existing Restore Points" { $RemoveExistingRestorePoints = $true }
            Default {
                Write-Host -Object "[Error] Invalid Action"
                exit 1
            }
        }
        
    }
    # Check if the parameter Action was used
    else {
        switch ($Action) {
            "Enable" { $EnableSystemRestore = $true }
            "Disable" { $DisableSystemRestore = $true }
            "DisableAndRemove" { $RemoveExistingRestorePoints = $true }
            Default {
                Write-Host -Object "[Error] Invalid Action"
                exit 1
            }
        }
    }
    function Remove-ComputerRestorePoint {
        [CmdletBinding(SupportsShouldProcess = $True)]param(
            [Parameter(
                Position = 0,
                Mandatory = $true,
                ValueFromPipeline = $true
            )]
            $RestorePoint
        )
        begin {
            $fullName = "SystemRestore.DeleteRestorePoint"
            #check if the type is already loaded
            $isLoaded = $null -ne ([AppDomain]::CurrentDomain.GetAssemblies() | ForEach-Object { $_.GetTypes() } | Where-Object { $_.FullName -eq $fullName })
            if (!$isLoaded) {
                $SRClient = Add-Type -MemberDefinition @"
[DllImport ("Srclient.dll")]
public static extern int SRRemoveRestorePoint (int index);
"@ -Name DeleteRestorePoint -Namespace SystemRestore -PassThru
            }
        }
        process {
            foreach ($restorePoint in $RestorePoint) {
                if ($PSCmdlet.ShouldProcess("$($restorePoint.Description)", "Deleting Restore Point")) {
                    [SystemRestore.DeleteRestorePoint]::SRRemoveRestorePoint($restorePoint.SequenceNumber) | Out-Null
                }
            }
        }
    }
}
process {
    if (-not (Test-IsElevated)) {
        Write-Host -Object "[Error] Access Denied. Please run with Administrator privileges."
        exit 1
    }

    # Get Windows Install Drive from SystemRoot
    $TargetDrive = "$($env:SystemRoot -split "\\" | Select-Object -First 1)\"

    $ExitCode = 0
    # When the action is Enable
    if ($EnableSystemRestore) {
        if ($SystemRestoreStatus -eq "Enabled") {
            Write-Host -Object "[Info] System Restore is already enabled."
            exit 0
        }

        # Save the current value of the SystemRestorePointCreationFrequency registry key
        $OldValue = try {
            Get-ItemPropertyValue -Path "HKLM:\Software\Microsoft\Windows NT\CurrentVersion\SystemRestore" -Name "SystemRestorePointCreationFrequency" -ErrorAction Stop -WarningAction Stop
        }
        catch {
            # Return the default value of 1440 minutes if the registry key does not exist
            1440
        }
        if ($null -ne $OldValue) {
            Set-ItemProperty -Path "HKLM:\Software\Microsoft\Windows NT\CurrentVersion\SystemRestore" -Name "SystemRestorePointCreationFrequency" -Value "0" -ErrorAction SilentlyContinue
        }

        # Enable System Restore
        try {
            Write-Host -Object "[Info] Enabling System Restore: $TargetDrive"
            Enable-ComputerRestore -Drive "$TargetDrive"
            Write-Host -Object "[Info] Enabled System Restore: $TargetDrive"
        }
        catch {
            Write-Host -Object "[Error] Failed to enable System Restore"
            $ExitCode = 1
        }

        try {
            Write-Host -Object "[Info] Creating restore point."

            # Create a new restore point
            Checkpoint-Computer -Description "Restore Point Created by Enable or Disable System Restore" -RestorePointType "MODIFY_SETTINGS" -ErrorAction Stop -WarningAction Stop

            Write-Host -Object "[Info] Created Restore Point."
        }
        catch {
            Write-Host -Object "[Error] Failed to create restore point."
            $ExitCode = 1
        }

        # Restore the old value of the SystemRestorePointCreationFrequency registry key
        if ($null -ne $OldValue) {
            Set-ItemProperty -Path "HKLM:\Software\Microsoft\Windows NT\CurrentVersion\SystemRestore" -Name "SystemRestorePointCreationFrequency" -Value $OldValue -ErrorAction SilentlyContinue
        }
    }
    # When the action is Disable
    elseif ($DisableSystemRestore) {
        if ($SystemRestoreStatus -eq "Disabled") {
            Write-Host -Object "[Info] System Restore is already disabled."
            exit 0
        }
        # Disable System Restore
        try {
            Write-Host -Object "[Info] Disabling System Restore: $TargetDrive"
            Disable-ComputerRestore -Drive "$TargetDrive"
            Write-Host -Object "[Info] Disabled System Restore: $TargetDrive"
        }
        catch {
            Write-Host -Object "[Error] Failed to disable System Restore"
            $ExitCode = 1
        }
    }
    # When the action is DisableAndRemove / Disable and Remove Existing Restore Points
    elseif ($RemoveExistingRestorePoints) {
        if ($SystemRestoreStatus -eq "Disabled") {
            Write-Host -Object "[Info] System Restore is already disabled."
            exit 0
        }
        # Remove all existing restore points
        try {
            Write-Host -Object "[Info] Removing Existing Restore Points"
            Get-ComputerRestorePoint | Remove-ComputerRestorePoint
            Write-Host -Object "[Info] Removed Existing Restore Points"
        }
        catch {
            Write-Host -Object "[Error] Failed to remove existing restore points"
            $ExitCode = 1
        }
        # Disable System Restore
        try {
            Write-Host -Object "[Info] Disabling System Restore: $TargetDrive"
            Disable-ComputerRestore -Drive "$TargetDrive"
            Write-Host -Object "[Info] Disabled System Restore: $TargetDrive"
        }
        catch {
            Write-Host -Object "[Error] Failed to disable System Restore"
            $ExitCode = 1
        }
    }
    exit $ExitCode
}
end {
    
    
    
}

 

Save time with over 300+ scripts from the NinjaOne Dojo.

Get access today.

Detailed Breakdown

The script is well-structured and versatile, catering to three primary actions:

  1. Enable System Restore: Activates the System Restore functionality.
  2. Disable System Restore: Turns off System Restore without removing existing restore points.
  3. Disable and Remove Restore Points: Disables System Restore and deletes all existing restore points.

Here’s how the script functions step by step:

1. Initialization and Parameter Handling:

a. The script begins by defining parameters (Enable, Disable, and DisableAndRemove) and initializing variables to manage these actions.

b. It checks if the script is run with administrator privileges using the Test-IsElevated function. This ensures the necessary permissions for modifying system settings.

2. Detecting Current System Restore Status:

a. The script retrieves the current status of System Restore from the Windows Registry. If the registry key RPSessionInterval exists and has a value, System Restore is considered enabled.

3. Action Execution:

a. Based on the specified action, the script executes the corresponding tasks:

i. Enable: Activates System Restore on the system drive and creates a new restore point.

ii. Disable: Deactivates System Restore.

iii. DisableAndRemove: Deactivates System Restore and removes all existing restore points using the Remove-ComputerRestorePoint function.

4. Error Handling:

a. Throughout the script, exceptions are caught, and detailed error messages are logged. This provides transparency and helps IT professionals troubleshoot issues effectively.

Potential Use Cases

Case Study: Automated Endpoint Management

Imagine an MSP managing hundreds of endpoints for a client. To ensure consistent rollback capabilities, the MSP uses this script to enable System Restore on all machines during deployment. Later, to free up disk space, they run the script with the DisableAndRemove parameter on devices nearing storage limits. This automated approach saves time and reduces errors compared to manual configurations.

Comparisons

Other methods to manage System Restore include using the Windows GUI or Group Policy. While the GUI offers simplicity for individual users, it is inefficient for large-scale operations. Group Policy provides centralized control but lacks the granularity and immediate feedback of PowerShell. This script bridges the gap, offering scalability, precision, and immediate execution feedback.

FAQs

1. Can this script be used on older versions of Windows?

The script is designed for Windows 10 and Windows Server 2016 or later. For older versions, adjustments to registry keys or commands may be required.

2. What happens if I run the script without administrator privileges?

The script checks for administrative rights and terminates if insufficient privileges are detected, preventing partial execution.

3. Will enabling System Restore affect existing system image backups?

Yes, enabling System Restore can increase shadow copy deletions, which may interfere with system image backups.

Implications

Using this script correctly can significantly enhance endpoint resilience, but mismanagement could lead to data loss, especially when deleting restore points. IT administrators must balance the benefits of System Restore with potential storage constraints and backup strategies.

Recommendations

  • Test in a Controlled Environment: Always test scripts in a lab or staging environment before deploying them to production systems.
  • Document Changes: Keep a record of when and where the script is executed to maintain a clear audit trail.
  • Integrate with Backup Solutions: Combine System Restore configurations with comprehensive backup strategies to ensure optimal protection.

Final Thoughts

This PowerShell script is a valuable tool for IT professionals seeking to manage System Restore efficiently. It enables automation, consistency, and precision, addressing the challenges of manual configurations. For those looking to streamline endpoint management further, solutions like NinjaOne can offer integrated tools for monitoring, automation, and backup management, ensuring a robust and secure IT environment.

]]>
Securing SSH Access by Disabling PermitEmptyPasswords with a Bash Script  https://www.ninjaone.com/script-hub/disable-permitemptypasswords-in-linux/ Sat, 14 Dec 2024 00:50:16 +0000 https://www.ninjaone.com/?post_type=script_hub&p=386929 Ensuring secure access to servers is a top priority for IT professionals and Managed Service Providers (MSPs). A significant aspect of server security is managing SSH configurations to prevent unauthorized access. One critical security measure is to disable the PermitEmptyPasswords option in OpenSSH, which prevents users from logging in with an empty password. This post explores a Bash script designed to enforce this configuration, ensuring enhanced security for Linux systems.

Background

OpenSSH is a widely used protocol for secure remote server management. By default, the PermitEmptyPasswords option is set to “no,” but it’s essential to verify and enforce this setting to mitigate risks associated with misconfigured or overlooked setups. IT professionals managing multiple systems might overlook this detail, leaving servers vulnerable. This script provides an automated solution, ensuring compliance with best practices by explicitly disabling empty password logins.

For MSPs and administrators handling numerous servers, this script simplifies SSH hardening, offering a quick, reliable way to enhance security without manual intervention.

The Script:

#!/usr/bin/env bash

# Description: Explicitly disables PermitEmptyPasswords in OpenSSH.
# By using this script, you indicate your acceptance of the following legal terms as well as our Terms of Use at https://www.ninjaone.com/terms-of-use.
# Ownership Rights: NinjaOne owns and will continue to own all right, title, and interest in and to the script (including the copyright). NinjaOne is giving you a limited license to use the script in accordance with these legal terms. 
# Use Limitation: You may only use the script for your legitimate personal or internal business purposes, and you may not share the script with another party. 
# Republication Prohibition: Under no circumstances are you permitted to re-publish the script in any script library or website belonging to or under the control of any other software provider. 
# Warranty Disclaimer: The script is provided “as is” and “as available”, without warranty of any kind. NinjaOne makes no promise or guarantee that the script will be free from defects or that it will meet your specific needs or expectations. 
# Assumption of Risk: Your use of the script is at your own risk. You acknowledge that there are certain inherent risks in using the script, and you understand and assume each of those risks. 
# Waiver and Release: You will not hold NinjaOne responsible for any adverse or unintended consequences resulting from your use of the script, and you waive any legal or equitable rights or remedies you may have against NinjaOne relating to your use of the script. 
# EULA: If you are a NinjaOne customer, your use of the script is subject to the End User License Agreement applicable to you (EULA).
#
# PermitEmptyPasswords defaults to no when not specified in the sshd_config file.
# This script will ensure that it is set to no to prevent SSH from accepting empty passwords.
#
# Links: https://man.openbsd.org/sshd_config#PermitEmptyPasswords
#
# Release Notes: Initial Release

# Logs an error message and exits with the specified exit code
die() {
    local _ret="${2:-1}"
    echo "$1" >&2
    exit "${_ret}"
}

# Check that we are running as root
if [[ $EUID -ne 0 ]]; then
    die "[Error] This script must be run as root." 1
fi

_should_reload="false"

# Check if the sshd_config file exists
if [[ -f /etc/ssh/sshd_config ]]; then
    # Check if the PermitEmptyPasswords option is already set to no
    if grep -q "^PermitEmptyPasswords no" /etc/ssh/sshd_config; then
        echo "[Info] PermitEmptyPasswords is already set to no."
        _should_reload="false"
    elif grep -q "^PermitEmptyPasswords yes" /etc/ssh/sshd_config; then
        # First check if the option is not commented out and set to yes
        # Then set the PermitEmptyPasswords option to no
        sed -i 's/^PermitEmptyPasswords.*/PermitEmptyPasswords no/' /etc/ssh/sshd_config
        echo "[Info] PermitEmptyPasswords set to no."
        _should_reload="true"
    elif grep -q "^#PermitEmptyPasswords" /etc/ssh/sshd_config; then
        # First check if the option is commented out
        # Then set the PermitEmptyPasswords option to no
        sed -i 's/^#PermitEmptyPasswords.*/PermitEmptyPasswords no/' /etc/ssh/sshd_config
        echo "[Info] PermitEmptyPasswords set to no, as it was commented out."
        _should_reload="true"
    else
        # Append the PermitEmptyPasswords option to the end of the sshd_config file
        # If the past checks have not found the option, appending it will ensure that it is set to no
        echo "PermitEmptyPasswords no" >>/etc/ssh/sshd_config
        echo "[Info] PermitEmptyPasswords set to no at the end of the sshd_config file."
        _should_reload="true"
    fi

    # Check that this system is running systemd-based
    _type=$(
        # Get the type of init system
        file /sbin/init 2>/dev/null | awk -F/ '{print $NF}' 2>/dev/null
    )
    if [[ "${_type}" == "systemd" ]] && [ "$(command -v systemctl)" ]; then
        echo "[Info] Reloading ${sshd_service} service..."
        # Find the sshd service
        sshd_service=$(
            # Get the ssh service, if two are found use the first one. Likely the first one is a symlink to the actual service file.
            systemctl list-unit-files | grep -E "^(sshd|ssh|openssh-server)\.service" | awk -F' ' '{print $1}' | head -n 1
        )
        if [[ -z "${sshd_service}" ]]; then
            die "[Error] sshd service is not available. Please install it and try again." 1
        fi
        # Check that ssh service is enabled
        if systemctl is-enabled "${sshd_service}" >/dev/null; then
            echo "[Info] ${sshd_service} is enabled."
        else
            die "[Info] ${sshd_service} is not enabled. When enabled and started, PermitEmptyPasswords will be set to no." 0
        fi
        # Check that ssh service is running
        if systemctl is-active "${sshd_service}" >/dev/null; then
            echo "[Info] ${sshd_service} is running."
            if [[ "${_should_reload}" == "true" ]]; then
                # Reload sshd.service
                if systemctl reload "${sshd_service}"; then
                    echo "[Info] sshd service configuration reloaded."
                else
                    die "[Error] Failed to reload ${sshd_service}. Please try again." 1
                fi
            else
                echo "[Info] sshd service configuration will not be reloaded as there is no need to do so."
            fi
        else
            echo "[Info] ${sshd_service} is not running."
        fi
    else
        echo "[Info] Restarting sshd service..."
        # Check that the service command is available
        if ! [ "$(command -v service)" ]; then
            die "[Error] The service command is not available. Is this an initd type system (e.g. SysV)? Please try again." 1
        fi
        # Find the sshd service
        sshd_service=$(
            # Get the list of services
            service --status-all | awk -F' ' '{print $NF}' | grep sshd
        )
        if [[ -z "${sshd_service}" ]]; then
            die "[Error] sshd service is not available. Please install it and try again." 1
        fi
        if [[ "${_should_reload}" == "true" ]]; then
            # Restart sshd service
            if service "${sshd_service}" restart; then
                echo "[Info] sshd service restarted."
            else
                die "[Error] Failed to restart sshd service. Please try again." 1
            fi
        else
            echo "[Info] sshd service configuration will not be restarted as there is no need to do so."
        fi
    fi
else
    die "[Error] The sshd_config file does not exist." 1
fi

 

Save time with over 300+ scripts from the NinjaOne Dojo.

Get access today.

Detailed Breakdown

This Bash script automates the process of explicitly setting PermitEmptyPasswords to “no” in the SSH configuration file (/etc/ssh/sshd_config). Below is a step-by-step breakdown of how the script operates:

1. Check for Root Privileges

The script begins by verifying if it is being run as the root user, as modifying SSH configurations requires elevated permissions. If not, it terminates with an error.

bash

Copy code

if [[ $EUID -ne 0 ]]; then
die “[Error] This script must be run as root.” 1
fi

2. Identify the Configuration File

The script checks for the existence of /etc/ssh/sshd_config. If this file is missing, it exits with an error, as the script cannot proceed without the SSH configuration file.

3. Modify PermitEmptyPasswords Setting

The script inspects the file for the PermitEmptyPasswords directive:

  • If the directive is set to “no,” no changes are made.
  • If set to “yes,” it is replaced with “no.”
  • If commented out, the script uncomments and sets it to “no.”
  • If the directive is missing entirely, it appends PermitEmptyPasswords no to the file.

4. Reload SSH Service

The script determines the system’s init system (systemd or init.d) to reload or restart the SSH service appropriately. It ensures the changes are applied without disrupting ongoing SSH sessions.

5. Error Handling

Comprehensive error handling ensures that the script gracefully exits if it encounters issues such as missing commands, disabled services, or unsupported init systems.

Potential Use Cases

Hypothetical Scenario

An IT administrator managing a fleet of servers notices that one server is allowing SSH access with empty passwords. Manually inspecting and modifying configuration files across all servers would be time-consuming. By deploying this script through automation tools like Ansible or directly via SSH, the administrator ensures that all servers enforce the PermitEmptyPasswords no setting consistently and efficiently.

Comparisons

Manual Configuration

Manually editing the sshd_config file and restarting the SSH service is straightforward but error-prone and inefficient for multiple servers.

Centralized Configuration Tools

Configuration management tools like Puppet or Chef can enforce SSH settings across infrastructure. However, these tools require setup and are more complex than this lightweight script for small-scale deployments.

The script offers a middle ground—simple, targeted, and effective for immediate implementation.

FAQs

  1. What happens if PermitEmptyPasswords is not specified in the configuration file?
    The script appends PermitEmptyPasswords no to the file, explicitly disabling empty password logins.
  2. Can this script break SSH access?
    No, it only modifies a specific directive and reloads/restarts the SSH service without disrupting active sessions.
  3. Is this script compatible with all Linux distributions?
    It is designed for distributions using OpenSSH and supports both systemd and init.d systems.
  4. Do I need to restart the SSH service manually?
    No, the script handles service reloads or restarts as required.

Implications

By enforcing PermitEmptyPasswords no, this script mitigates a critical security risk. Misconfigured SSH settings can expose servers to brute-force attacks and unauthorized access. This script ensures compliance with security best practices, reducing the attack surface and safeguarding sensitive systems.

Recommendations

  • Test Before Deployment: Run the script on a test environment to verify its behavior.
  • Automate Implementation: Use tools like Ansible to deploy this script across multiple servers.
  • Monitor Logs: Check SSH logs to ensure the configuration changes are effective.
  • Document Changes: Maintain records of applied configurations for auditing purposes.

Final Thoughts

Maintaining a secure SSH environment is vital for IT operations. Scripts like this one simplify the process of enforcing critical security measures, ensuring compliance and protecting systems from unauthorized access. For IT professionals managing large infrastructures, leveraging automation tools like NinjaOne enhances operational efficiency, offering centralized management and monitoring solutions tailored to their needs.

]]>
Identifying and Managing Orphaned User Accounts in Windows Using PowerShell https://www.ninjaone.com/script-hub/report-orphaned-user-accounts/ Sat, 14 Dec 2024 00:39:53 +0000 https://www.ninjaone.com/?post_type=script_hub&p=386926 Managing user accounts is a fundamental responsibility for IT administrators. Orphaned user accounts—those no longer associated with an active user—pose risks such as unnecessary resource usage, potential data leaks, and security vulnerabilities. The PowerShell script analyzed here offers an automated approach to identify and manage these accounts efficiently.

Background

In multi-user environments, especially in enterprises or organizations managed by Managed Service Providers (MSPs), user accounts often get left behind when employees leave, change roles, or systems are restructured. These orphaned profiles accumulate in the system, taking up valuable disk space and potentially exposing sensitive information. This script not only detects orphaned profiles but also provides detailed reports, helping IT professionals keep their systems clean and secure.

The Script:

#Requires -Version 5.1

<#
.SYNOPSIS
    Looks for user profile folders that do not have an associated user account.
.DESCRIPTION
    Looks for user profile folders that do not have an associated user account.

    By using this script, you indicate your acceptance of the following legal terms as well as our Terms of Use at https://www.ninjaone.com/terms-of-use.
    Ownership Rights: NinjaOne owns and will continue to own all right, title, and interest in and to the script (including the copyright). NinjaOne is giving you a limited license to use the script in accordance with these legal terms. 
    Use Limitation: You may only use the script for your legitimate personal or internal business purposes, and you may not share the script with another party. 
    Republication Prohibition: Under no circumstances are you permitted to re-publish the script in any script library or website belonging to or under the control of any other software provider. 
    Warranty Disclaimer: The script is provided “as is” and “as available”, without warranty of any kind. NinjaOne makes no promise or guarantee that the script will be free from defects or that it will meet your specific needs or expectations. 
    Assumption of Risk: Your use of the script is at your own risk. You acknowledge that there are certain inherent risks in using the script, and you understand and assume each of those risks. 
    Waiver and Release: You will not hold NinjaOne responsible for any adverse or unintended consequences resulting from your use of the script, and you waive any legal or equitable rights or remedies you may have against NinjaOne relating to your use of the script. 
    EULA: If you are a NinjaOne customer, your use of the script is subject to the End User License Agreement applicable to you (EULA).
.EXAMPLE
    (No Parameters)
    
    [Alert] Orphaned profiles found!

    Username Size      Path             SID                                           
    -------- ----      ----             ---                                           
    UNKNOWN  554.97 MB C:\Users\tuser1  S-1-5-21-1255570301-2732419320-3119746845-1104
    UNKNOWN  86.74 MB  C:\Users\tuser22 S-1-5-21-3797121902-2219393589-2867574441-1001


PARAMETER: -CustomField "ReplaceMeWithTheNameOfaWYSIWYGcustomField"
    Specify the name of an optional WYSIWYG Custom Field to save the results in.

.NOTES
    Minimum OS Architecture Supported: Windows 10, Windows Server 2016
    Release Notes: Initial Release
#>

[CmdletBinding()]
param (
    [Parameter()]
    [String[]]$DirectoriesToIgnore = ("All Users", "Default", "Default User", "Public"),
    [Parameter()]
    [String]$CustomField
)

begin {
    # Set parameters using dynamic script variables.
    if ($env:wysiwygCustomFieldName -and $env:wysiwygCustomFieldName -notlike "null") {
        $CustomField = $env:wysiwygCustomFieldName
    }

    # Function to convert a size in bytes to a more human-readable format
    function Get-FriendlySize {
        param($Bytes)
        # Array of size units
        $Sizes = 'Bytes,KB,MB,GB,TB,PB,EB,ZB' -split ','
        # Loop to find the appropriate size unit
        for ($i = 0; ($Bytes -ge 1kb) -and ($i -lt $Sizes.Count); $i++) { $Bytes /= 1kb }
        # Number of decimal places to show
        $decimalPlaces = 2
        # If the size is in bytes, no decimal places are needed
        if ($i -eq 0) { $decimalPlaces = 0 }
        # Return the rounded size with the appropriate unit
        if ($Bytes) { "$([System.Math]::Round($Bytes,$decimalPlaces)) $($Sizes[$i])" }else { "0 B" }
    }
    
    # Function to retrieve user profile hives from the registry
    function Get-UserHives {
        param (
            [Parameter()]
            [ValidateSet('AzureAD', 'DomainAndLocal', 'All')]
            [String]$Type = "All",
            [Parameter()]
            [String[]]$ExcludedUsers,
            [Parameter()]
            [switch]$IncludeDefault
        )

        # Define patterns to match user SIDs based on the Type parameter
        $Patterns = switch ($Type) {
            "AzureAD" { "S-1-12-1-(\d+-?){4}$" }
            "DomainAndLocal" { "S-1-5-21-(\d+-?){4}$" }
            "All" { "S-1-12-1-(\d+-?){4}$" ; "S-1-5-21-(\d+-?){4}$" } 
        }

        # Retrieve user profiles from the registry
        $UserProfiles = Foreach ($Pattern in $Patterns) { 
            Get-ItemProperty "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\*" |
                Where-Object { $_.PSChildName -match $Pattern } | 
                Select-Object @{Name = "SID"; Expression = { $_.PSChildName } },
                @{Name = "Username"; Expression = { "$($_.ProfileImagePath | Split-Path -Leaf)" } }, 
                @{Name = "UserHive"; Expression = { "$($_.ProfileImagePath)\NTuser.dat" } }, 
                @{Name = "Path"; Expression = { $_.ProfileImagePath } },
                @{Name = "Size"; Expression = { $(Get-ChildItem -Path $_.ProfileImagePath -Recurse -Force -ErrorAction SilentlyContinue | Measure-Object -Sum Length -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Sum -ErrorAction SilentlyContinue) } }
        }

        # Include the default profile if specified
        switch ($IncludeDefault) {
            $True {
                $DefaultProfile = "" | Select-Object Username, SID, UserHive, Path
                $DefaultProfile.Username = "Default"
                $DefaultProfile.SID = "DefaultProfile"
                $DefaultProfile.Userhive = "$env:SystemDrive\Users\Default\NTUSER.DAT"
                $DefaultProfile.Path = "C:\Users\Default"

                $DefaultProfile | Where-Object { $ExcludedUsers -notcontains $_.Username }
            }
        }

        # Iterate over the user profiles to resolve user names from SIDs
        $UserProfiles | ForEach-Object {
            $SidObject = $Null
            $NewUsername = $Null

            # Convert SID to user name
            if ($_.SID) {
                try {
                    $SidObject = New-Object System.Security.Principal.SecurityIdentifier($_.SID)
                    $NewUsername = $SidObject.Translate([System.Security.Principal.NTAccount])
                }
                catch {
                    $NewUsername = $Null
                }
            }

            # Assign the resolved user name or "UNKNOWN" if resolution fails
            if ($NewUsername.Value) {
                $_.Username = $NewUsername.Value
            }
            else {
                $_.Username = "UNKNOWN"
            }
        }

        $UserProfiles | Where-Object { $ExcludedUsers -notcontains $_.Username }
    }

    function Set-NinjaProperty {
        [CmdletBinding()]
        Param(
            [Parameter(Mandatory = $True)]
            [String]$Name,
            [Parameter()]
            [String]$Type,
            [Parameter(Mandatory = $True, ValueFromPipeline = $True)]
            $Value,
            [Parameter()]
            [String]$DocumentName
        )
        
        $Characters = $Value | Out-String | Measure-Object -Character | Select-Object -ExpandProperty Characters
        if ($Characters -ge 200000) {
            throw [System.ArgumentOutOfRangeException]::New("Character limit exceeded: the value is greater than or equal to 200,000 characters.")
        }
            
        # If requested to set the field value for a Ninja document, specify it here.
        $DocumentationParams = @{}
        if ($DocumentName) { $DocumentationParams["DocumentName"] = $DocumentName }
            
        # This is a list of valid fields that can be set. If no type is specified, assume that the input does not need to be changed.
        $ValidFields = "Attachment", "Checkbox", "Date", "Date or Date Time", "Decimal", "Dropdown", "Email", "Integer", "IP Address", "MultiLine", "MultiSelect", "Phone", "Secure", "Text", "Time", "URL", "WYSIWYG"
        if ($Type -and $ValidFields -notcontains $Type) { Write-Warning "$Type is an invalid type. Please check here for valid types: https://ninjarmm.zendesk.com/hc/en-us/articles/16973443979789-Command-Line-Interface-CLI-Supported-Fields-and-Functionality" }
            
        # The field below requires additional information to set.
        $NeedsOptions = "Dropdown"
        if ($DocumentName) {
            if ($NeedsOptions -contains $Type) {
                # Redirect error output to the success stream to handle errors more easily if nothing is found or something else goes wrong.
                $NinjaPropertyOptions = Ninja-Property-Docs-Options -AttributeName $Name @DocumentationParams 2>&1
            }
        }
        else {
            if ($NeedsOptions -contains $Type) {
                $NinjaPropertyOptions = Ninja-Property-Options -Name $Name 2>&1
            }
        }
            
        # If an error is received with an exception property, exit the function with that error information.
        if ($NinjaPropertyOptions.Exception) { throw $NinjaPropertyOptions }
            
        # The types below require values not typically given to be set. The code below will convert whatever we're given into a format ninjarmm-cli supports.
        switch ($Type) {
            "Checkbox" {
                # Although it's highly likely we were given a value like "True" or a boolean data type, it's better to be safe than sorry.
                $NinjaValue = [System.Convert]::ToBoolean($Value)
            }
            "Date or Date Time" {
                # Ninjarmm-cli expects the GUID of the option to be selected. Therefore, match the given value with a GUID.
                $Date = (Get-Date $Value).ToUniversalTime()
                $TimeSpan = New-TimeSpan (Get-Date "1970-01-01 00:00:00") $Date
                $NinjaValue = $TimeSpan.TotalSeconds
            }
            "Dropdown" {
                # Ninjarmm-cli expects the GUID of the option we're trying to select, so match the value we were given with a GUID.
                $Options = $NinjaPropertyOptions -replace '=', ',' | ConvertFrom-Csv -Header "GUID", "Name"
                $Selection = $Options | Where-Object { $_.Name -eq $Value } | Select-Object -ExpandProperty GUID
            
                if (-not $Selection) {
                    throw [System.ArgumentOutOfRangeException]::New("Value is not present in dropdown options.")
                }
            
                $NinjaValue = $Selection
            }
            default {
                # All the other types shouldn't require additional work on the input.
                $NinjaValue = $Value
            }
        }
            
        # Set the field differently depending on whether it's a field in a Ninja Document or not.
        if ($DocumentName) {
            $CustomField = Ninja-Property-Docs-Set -AttributeName $Name -AttributeValue $NinjaValue @DocumentationParams 2>&1
        }
        else {
            $CustomField = $NinjaValue | Ninja-Property-Set-Piped -Name $Name 2>&1
        }
            
        if ($CustomField.Exception) {
            throw $CustomField
        }
    }

    # Function to check if the computer is joined to a domain
    function Test-IsDomainJoined {
        if ($PSVersionTable.PSVersion.Major -lt 5) {
            return $(Get-WmiObject -Class Win32_ComputerSystem).PartOfDomain
        }
        else {
            return $(Get-CimInstance -Class Win32_ComputerSystem).PartOfDomain
        }
    }

    # Function to check if the computer is a domain controller
    function Test-IsDomainController {
        $OS = if ($PSVersionTable.PSVersion.Major -lt 5) {
            Get-WmiObject -Class Win32_OperatingSystem
        }
        else {
            Get-CimInstance -ClassName Win32_OperatingSystem
        }
    
        if ($OS.ProductType -eq "2") {
            return $true
        }
    }

    function Test-IsElevated {
        $id = [System.Security.Principal.WindowsIdentity]::GetCurrent()
        $p = New-Object System.Security.Principal.WindowsPrincipal($id)
        $p.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
    }

    function Test-IsDomainReachable {
        try {
            $searcher = [adsisearcher]"(&(objectCategory=computer)(name=$env:ComputerName))"
            $searcher.FindOne()
        }
        catch {
            Write-Host -Object "[Error] Failed to connect to the domain!"
            Write-Host -Object "[Error] $($_.Exception.Message)"
            $False
        }
    }

    if (!$ExitCode) {
        $ExitCode = 0
    }
}
process {
    # Check if the script is running with elevated privileges
    if (!(Test-IsElevated)) {
        Write-Host -Object "[Error] Access Denied. Please run with Administrator privileges."
        exit 1
    }

    # Check if the computer is domain joined, can connect to the domain controller, and is not itself a domain controller
    if ((Test-IsDomainJoined) -and !(Test-IsDomainReachable) -and !(Test-IsDomainController)) {
        Write-Host -Object "[Error] Unable to connect to the domain controller! The domain must be reachable to confirm which profiles are orphaned."
        exit 1
    }

    # Initialize a list to hold user profiles
    $UserProfiles = New-Object System.Collections.Generic.List[Object]
    # Retrieve user hives and add them to the list of user profiles
    try {
        $UserHives = Get-UserHives -Type "All"
    }
    catch {
        Write-Host -Object "[Error] Failed to retrieve the user profile information from the registry!"
        Write-Host -Object "$($_.Exception.Message)"
        exit 1
    }

    $UserHives | ForEach-Object {
        if ((Test-IsDomainJoined) -and $_.Username -notmatch [Regex]::Escape("$env:ComputerName") -and $_.Username -notmatch "AzureAD" -and $_.Username -ne "UNKNOWN") {
            try {
                $ADSIsearch = [adsisearcher]"(objectSid=$($_.SID))"
                if (!($ADSIsearch.FindOne())) {
                    $_.Username = "UNKNOWN"
                }
            }
            catch {
                Write-Host -Object "[Error] Failed to connect to the domain to verify the account with the sid $($_.SID) is active."
                Write-Host -Object "[Error] $($_.Exception.Message)"
                exit 1
            }
        }

        try {
            $UserProfiles.Add(
                [PSCustomObject]@{
                    SID          = $_.SID
                    Username     = $_.Username
                    UserHive     = $_.UserHive
                    Path         = $_.Path
                    Size         = $_.Size
                    FriendlySize = (Get-FriendlySize -Bytes $_.Size)
                }
            )
        }
        catch {
            Write-Host -Object "[Error] Failed to add the profile with the SID $($_.SID) to the profile list!"
            Write-Host -Object "[Error] $($_.Exception.Message)"
            exit 1
        }
    }
    
    # Attempt to retrieve the user profiles directory from the registry (typically C:\Users)
    try {
        $ProfilesDirectory = (Get-ItemProperty -Path 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList' -ErrorAction Stop).ProfilesDirectory
    }
    catch {
        Write-Host -Object "[Error] Unable to find the user profiles directory!"
        Write-Host -Object "[Error] $($_.Exception.Message)"
        exit 1
    }

    # Attempt to get information about directories in the profiles directory that have not already been identified/found
    try {
        $ProfilesDirectory | Get-ChildItem -Directory -Force -ErrorAction Stop | Where-Object { $DirectoriesToIgnore -notcontains $_.Name -and $UserProfiles.Path -notcontains $_.FullName } | ForEach-Object {
            $FriendlySize = $Null
            $Size = $Null
            $Hive = $Null

            # Check if the NTuser.dat file exists in the profile directory
            if (Test-Path -Path "$($_.FullName)\NTuser.dat" -ErrorAction SilentlyContinue) {
                $Hive = "$($_.FullName)\NTuser.dat"
            }
            else {
                $Hive = "UNKNOWN"
            }

            # Calculate the size of the profile directory
            $Size = $(Get-ChildItem -Path $_.FullName -Recurse -Force -ErrorAction SilentlyContinue | Measure-Object -Sum Length -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Sum -ErrorAction SilentlyContinue)
            if ($Size) {
                $FriendlySize = Get-FriendlySize -Bytes $Size
            }

            # Add the profile information to the list of user profiles
            $UserProfiles.Add(
                [PSCustomObject]@{
                    SID          = "UNKNOWN"
                    Username     = "UNKNOWN"
                    UserHive     = $Hive
                    Path         = $_.FullName
                    Size         = $Size
                    FriendlySize = $FriendlySize
                }
            )
        }
    }
    catch {
        Write-Host -Object "[Error] Unable to get all of the user profile information for $($_.FullName)!"
        Write-Host -Object "[Error] $($_.Exception.Message)"
        exit 1
    }

    # Filter the list to find orphaned profiles (those with "UNKNOWN" as a username)
    $OrphanedProfiles = $UserProfiles | Where-Object { $_.Username -eq "UNKNOWN" }

    # If a custom field is specified, generate an HTML report and set the custom field
    if ($CustomField) {
        $HTMLreport = $UserProfiles | Sort-Object Size -Descending | Select-Object Username, @{Label = "Size"; Expression = { $_.FriendlySize } }, Path, SID | ConvertTo-Html -Fragment

        # Highlight orphaned profiles in the HTML report
        $HTMLreport = $HTMLreport | ForEach-Object {
            $_ -replace '<tr><td>UNKNOWN</td>', '<tr class="danger"><td>UNKNOWN</td>'
        }

        try {
            Write-Host "Attempting to set Custom Field '$CustomField'."
            Set-NinjaProperty -Name $CustomField -Value $HTMLreport
            Write-Host "Successfully set Custom Field '$CustomField'!"
        }
        catch {
            Write-Host "[Error] $($_.Exception.Message)"
            $ExitCode = 1
        }

        Write-Host ""
    }
    
    # Display a message about orphaned profiles
    if ($OrphanedProfiles) {
        Write-Host -Object "[Alert] Orphaned profiles found!"
        $OrphanedProfiles | Sort-Object Size -Descending | Format-Table Username, @{Label = "Size"; Expression = { $_.FriendlySize } }, Path, SID | Out-String | Write-Host
    }
    else {
        Write-Host -Object "No orphaned profiles found!"
    }

    exit $ExitCode
}
end {
    
    
    
}

 

Save time with over 300+ scripts from the NinjaOne Dojo.

Get access today.

Detailed Breakdown

Key Components and Workflow

1. Initialization and Parameters

a.The script begins by defining parameters such as $DirectoriesToIgnore to exclude specific default folders (e.g., “Public” or “Default”) and $CustomField, which integrates with NinjaOne for reporting purposes.

b. A series of helper functions like Test-IsDomainJoined and Get-FriendlySize prepare the environment by ensuring proper privileges and formatting output.

2. Profile Discovery

a. The core functionality lies in Get-UserHives, which queries the Windows registry for user profile information. It retrieves details such as the username, Security Identifier (SID), profile path, and size of each user profile.

b. Profiles are cross-checked against active domain users using their SIDs. Any profile that does not resolve to an active user is flagged as “UNKNOWN.”

3. Report Generation

a. The script compiles all orphaned profiles into a report. If a custom field is specified, it converts the data into an HTML report for integration with external tools like NinjaOne.

4. Output and Alerts

a. If orphaned profiles are found, a formatted table is displayed, showing usernames, profile sizes, paths, and SIDs.

b. If no orphaned profiles exist, the script reports a clean slate.

Potential Use Cases

Case Study: Cleaning Up a Corporate Network

Imagine an MSP tasked with managing an organization of 500 employees. Over the years, multiple accounts are deactivated as employees leave, but their profiles remain. Using this script, the IT team can:

  • Identify orphaned profiles across all systems.
  • Analyze disk usage attributed to these profiles.
  • Integrate the results into a centralized reporting system via the $CustomField parameter.
  • Use the output to inform decisions about archiving, deletion, or further investigation.

Comparisons

Manual Cleanup

Manually reviewing user profiles is a time-consuming and error-prone process, requiring registry lookups and cross-referencing with active user directories. This script automates the process, ensuring consistency and saving time.

Third-Party Tools

While some software solutions specialize in user profile management, they often come with high costs. This PowerShell script provides a cost-effective, customizable alternative, especially for organizations already utilizing NinjaOne for IT management.

FAQs

How does the script identify orphaned accounts?

It compares user profile information retrieved from the Windows registry with active domain accounts by resolving SIDs. Any profile not associated with an active account is flagged as orphaned.

Does the script delete orphaned profiles?

No, it only identifies and reports orphaned profiles. Administrators can manually review and decide the next steps.

Can this script run on non-domain systems?

Yes, but its ability to validate accounts against a domain is unavailable in such environments.

What permissions are required to run the script?

Administrator privileges are required to access the registry and profile directories.

Implications

Identifying orphaned user profiles has far-reaching implications for IT security and efficiency:

  • Improved Security: Reduces the risk of sensitive data exposure from unused profiles.
  • Optimized Resources: Frees up disk space and system resources.
  • Compliance Assurance: Helps organizations meet data governance and audit requirements.

Recommendations

  1. Test Before Implementation
    Always run the script in a test environment to understand its output and ensure it aligns with your organization’s requirements.
  2. Review Results Carefully
    Orphaned profiles flagged as “UNKNOWN” should be double-checked to confirm they’re not associated with critical accounts.
  3. Integrate Reporting
    Leverage the $CustomField parameter to integrate results with tools like NinjaOne for centralized management and documentation.
  4. Schedule Regular Checks
    Automate the script to run periodically, ensuring systems stay clean and secure.

Final Thoughts

Efficient user profile management is crucial for maintaining secure and optimized IT environments. This PowerShell script provides a robust, automated solution to identify orphaned profiles, offering detailed insights and seamless integration with tools like NinjaOne. For IT administrators and MSPs, implementing such solutions ensures systems remain secure, compliant, and resource-efficient.

By combining this script with NinjaOne’s broader IT management capabilities, organizations can achieve unparalleled efficiency in handling user accounts and profiles.

]]>
Adding or Overriding Device Antivirus Information with macOS Bash Script  https://www.ninjaone.com/script-hub/set-antivirus-override-on-macos/ Sat, 14 Dec 2024 00:33:42 +0000 https://www.ninjaone.com/?post_type=script_hub&p=386672 Managing antivirus configurations across devices is a critical task for IT professionals and Managed Service Providers (MSPs). Ensuring accurate and up-to-date antivirus information in device records is essential for maintaining a secure and compliant IT environment.

This blog explores a detailed bash script that simplifies adding or overriding antivirus details on macOS devices. Whether you need to update antivirus version data, change its operational status, or reset overrides entirely, this script streamlines the process with robust checks and automated actions.

Background

In dynamic IT environments, antivirus data can often become outdated or misaligned due to changes in software configurations, device migrations, or varying update cadences. MSPs managing large device fleets face challenges in maintaining consistency across antivirus records. Without a standardized way to override or update these details programmatically, manual intervention can lead to errors and inefficiencies.

This script addresses these challenges by offering a clear and structured approach to updating antivirus overrides in macOS environments. It allows IT professionals to:

  • Add or update antivirus details in a JSON-based configuration.
  • Remove outdated overrides from system records.
  • Ensure antivirus status and state remain compliant with organizational policies.

The Script:

#!/usr/bin/env bash
#
# Description: Add an antivirus to the device details or override the existing antivirus information.
#   By using this script, you indicate your acceptance of the following legal terms as well as our Terms of Use at https://www.ninjaone.com/terms-of-use.
#   Ownership Rights: NinjaOne owns and will continue to own all right, title, and interest in and to the script (including the copyright). NinjaOne is giving you a limited license to use the script in accordance with these legal terms. 
#   Use Limitation: You may only use the script for your legitimate personal or internal business purposes, and you may not share the script with another party. 
#   Republication Prohibition: Under no circumstances are you permitted to re-publish the script in any script library or website belonging to or under the control of any other software provider. 
#   Warranty Disclaimer: The script is provided “as is” and “as available”, without warranty of any kind. NinjaOne makes no promise or guarantee that the script will be free from defects or that it will meet your specific needs or expectations. 
#   Assumption of Risk: Your use of the script is at your own risk. You acknowledge that there are certain inherent risks in using the script, and you understand and assume each of those risks. 
#   Waiver and Release: You will not hold NinjaOne responsible for any adverse or unintended consequences resulting from your use of the script, and you waive any legal or equitable rights or remedies you may have against NinjaOne relating to your use of the script. 
#   EULA: If you are a NinjaOne customer, your use of the script is subject to the End User License Agreement applicable to you (EULA).
#
# Preset Parameter: --antivirusName "ReplaceMeWithYourDesiredName"
#   Name of the antivirus you would like to appear in the device details.
#
# Preset Parameter: --antivirusVersion "1.0.2"
#   Specify the version number of the antivirus.
#
# Preset Parameter: --antivirusStatus "Up-to-Date"
#   Specify whether the antivirus definitions are Up-to-Date, Out-of-Date, or Unknown.
#
# Preset Parameter: --antivirusState "ON"
#   Specify the current status of the antivirus.
#
# Preset Parameter: --removeOverride
#   Remove all existing overrides.
#
# Preset Parameter: --antivirusState
#   Append or update an existing override.
#
# Preset Parameter: --help
#		Displays a help menu.
#
# Release Notes: Initial Release

# Initialize variables
_arg_antivirusName=
_arg_antivirusVersion=
_arg_antivirusStatus=
_arg_antivirusState=
_arg_removeOverride="off"
_arg_append="off"

# Function to display help message
print_help() {
  printf '\n\n%s\n\n' 'Usage: [--antivirusName|-n <arg>] [--antivirusVersion|-v <arg>] [--antivirusStatus|--status <arg>] [--antivirusState|--state <arg>] [--removeOverride|-r] [--append|-a] [--help|-h] '
  printf '%s\n' 'Preset Parameter: --antivirusName "ReplaceMeWithYourDesiredName"'
  printf '\t%s\n' "Name of the antivirus you would like to appear in the device details."
  printf '%s\n' 'Preset Parameter: --antivirusVersion "1.0.2"'
  printf '\t%s\n' "Specify the version number of the antivirus."
  printf '%s\n' 'Preset Parameter: --antivirusStatus "Up-to-Date"'
  printf '\t%s\n' "Specify whether the antivirus definitions are Up-to-Date, Out-of-Date, or Unknown."
  printf '%s\n' 'Preset Parameter: --antivirusState "ON"'
  printf '\t%s\n' "Specify the current status of the antivirus."
  printf '%s\n' 'Preset Parameter: --removeOverride'
  printf '\t%s\n' "Remove all existing overrides."
  printf '%s\n' 'Preset Parameter: --antivirusState'
  printf '\t%s\n' "Append or update an existing override."
  printf '\n%s\n' 'Preset Parameter: --help'
  printf '\t%s\n' "Displays this help menu."
}

# Function to print an error message and exit
die() {
  local _ret="${2:-1}"
  echo "$1" >&2
  test "${_PRINT_HELP:-no}" = yes && print_help >&2
  exit "${_ret}"
}

# Function to parse command line arguments
parse_commandline() {
  while test $# -gt 0; do
    _key="$1"
    case "$_key" in
    --antivirusName | --antivirusname | --name | -n)
      test $# -lt 2 && die "Missing value for the argument '$_key'." 1
      _arg_antivirusName=$2
      shift
      ;;
    --antivirusName=*)
      _arg_antivirusName="${_key##--antivirusName=}"
      ;;
    --antivirusVersion | --antivirusversion | --version | -v)
      test $# -lt 2 && die "Missing value for the argument '$_key'." 1
      _arg_antivirusVersion=$2
      shift
      ;;
    --antivirusVersion=*)
      _arg_antivirusVersion="${_key##--antivirusVersion=}"
      ;;
    --antivirusStatus | --antivirusstatus | --status)
      test $# -lt 2 && die "Missing value for the argument '$_key'." 1
      _arg_antivirusStatus=$2
      shift
      ;;
    --antivirusStatus=*)
      _arg_antivirusStatus="${_key##--antivirusStatus=}"
      ;;
    --antivirusState | --antivirusstate | --state)
      test $# -lt 2 && die "Missing value for the argument '$_key'." 1
      _arg_antivirusState=$2
      shift
      ;;
    --antivirusState=*)
      _arg_antivirusState="${_key##--antivirusState=}"
      ;;
    --removeOverride | --remove | -r)
      _arg_removeOverride="on"
      ;;
    --append | --Append | -a)
      _arg_append="on"
      ;;
    --help | -h)
      _PRINT_HELP=yes die 0
      ;;
    *)
      _PRINT_HELP=yes die "[Error] Got an unexpected argument '$1'" 1
      ;;
    esac
    shift
  done
}

# Parse the command line arguments
parse_commandline "$@"

# Replace commandline parameters with script form variables
if [[ -n $avName ]]; then
  _arg_antivirusName="$avName"
fi
if [[ -n $avVersion ]]; then
  _arg_antivirusVersion="$avVersion"
fi
if [[ -n $avStatus ]]; then
  _arg_antivirusStatus="$avStatus"
fi
if [[ -n $avState ]]; then
  _arg_antivirusState="$avState"
fi
if [[ -n $append && $append == "true" ]]; then
  _arg_append="on"
fi
if [[ -n $removeOverride && $removeOverride == "true" ]]; then
  _arg_removeOverride="on"
fi

# Ensure that removing an override and adding/updating an override are not done simultaneously
if [[ $_arg_removeOverride == "on" && (-n $_arg_antivirusName || -n $_arg_antivirusState || -n $_arg_antivirusStatus || -n $_arg_antivirusVersion || $_arg_append == "on") ]]; then
  _PRINT_HELP=no die "[Error] Cannot remove an override and add an override at the same time." 1
fi

# Check for required antivirus name and escape special characters if necessary
reservedCharacters='[\"]'
if [[ -z $_arg_antivirusName && $_arg_removeOverride != "on" ]]; then
  if [[ $_arg_append == "on" ]]; then
    _PRINT_HELP=yes die "[Error] Antivirus name was not given. The antivirus name is required when updating or adding a new override!" 1
  else
    _PRINT_HELP=yes die "[Error] Antivirus name was not given. Antivirus name, state, and status are required when adding a new override!" 1
  fi
elif [[ -n $_arg_antivirusName && $_arg_antivirusName =~ $reservedCharacters ]]; then
  _arg_antivirusName=${_arg_antivirusName//\\/\\\\}
  _arg_antivirusName=${_arg_antivirusName//\"/\\\"}
fi

# Check for required antivirus status
if [[ -z $_arg_antivirusStatus && $_arg_removeOverride != "on" && $_arg_append != "on" ]]; then
  _PRINT_HELP=yes die "[Error] Antivirus status was not given. Antivirus name, state, and status are required!" 1
fi

# Validate antivirus status
if [[ -n $_arg_antivirusStatus && $_arg_antivirusStatus != "Up-to-Date" && $_arg_antivirusStatus != "Out-of-Date" && $_arg_antivirusStatus != "Unknown" ]]; then
  _PRINT_HELP=no die "[Error] An invalid antivirus status of '$_arg_antivirusStatus' was given. Only the following statuses are valid. 'Up-to-Date', 'Out-of-Date', and 'Unknown'." 1
fi

# Check for required antivirus state
if [[ -z $_arg_antivirusState && $_arg_removeOverride != "on" && $_arg_append != "on" ]]; then
  _PRINT_HELP=yes die "[Error] Antivirus state was not given. Antivirus name, state, and status are required!" 1
else
  _arg_antivirusState=$(echo "$_arg_antivirusState" | tr '[:lower:]' '[:upper:]')
fi

# Validate antivirus state
if [[ -n $_arg_antivirusState && $_arg_antivirusState != "ON" && $_arg_antivirusState != "OFF" && $_arg_antivirusState != "EXPIRED" && $_arg_antivirusState != "SNOOZED" && $_arg_antivirusState != "UNKNOWN" ]]; then
  _PRINT_HELP=no die "[Error] An invalid antivirus state of '$_arg_antivirusState' was given. Only the following states are valid. 'ON', 'OFF', 'EXPIRED', 'SNOOZED', and 'UNKNOWN'." 1
fi

# Validate antivirus version
if [[ -n $_arg_antivirusVersion && $_arg_antivirusVersion =~ [^0-9\.] ]]; then
  _PRINT_HELP=no die "[Error] The antivirus version given '$_arg_antivirusVersion' contains an invalid character. Only the following characters are allowed. '0-9' and '.'" 1
fi

# Check if the removeOverride flag is set to "on"
if [[ $_arg_removeOverride == "on" ]]; then
  echo "Removing override as requested."
  # Check if the override file exists
  if [[ ! -f "/Applications/NinjaRMMAgent/programdata/customization/av_override.json" ]]; then
    echo "No override present."
    exit 0
  # Try to remove the override file and capture any error output
  elif output=$(rm "/Applications/NinjaRMMAgent/programdata/customization/av_override.json" 2>&1); then
    echo "Succesfully removed override!"
    exit 0
  # Print an error message if the removal fails
  else
    echo "[Error] Failed to remove override!"
    echo "[Error] $output"
  fi
fi

# Check if the customization directory exists
if [[ ! -d "/Applications/NinjaRMMAgent/programdata/customization" ]]; then
  echo "Creating customization folder at '/Applications/NinjaRMMAgent/programdata/customization'."
  # Try to create the customization directory and capture any error output
  if output=$(mkdir "/Applications/NinjaRMMAgent/programdata/customization" 2>&1); then
    echo "Folder created."
  else
    # Print an error message if the creation fails
    echo "[Error] Unable to create customization folder." >&2
    echo "[Error] $output" >&2
    exit 1
  fi
fi

# Check if the append flag is set to "on" and the override JSON file exists
if [[ $_arg_append == "on" && -f "/Applications/NinjaRMMAgent/programdata/customization/av_override.json" ]]; then
  # Extract antivirus names, versions, statuses and states from the JSON file
  avNames=$(grep "av_name" "/Applications/NinjaRMMAgent/programdata/customization/av_override.json" | tr -s " " | sed s/\"av_name\"://g | sed -e 's/^[[:space:]]*//' | sed 's/[\",]//g' | sed -e 's/[[:space:]]*$//')
  avVersions=$(grep "av_version" "/Applications/NinjaRMMAgent/programdata/customization/av_override.json" | tr -s " " | sed s/\"av_version\"://g | sed -e 's/^[[:space:]]*//' | sed 's/[\",]//g' | sed -e 's/[[:space:]]*$//')
  avStatuses=$(grep "av_status" "/Applications/NinjaRMMAgent/programdata/customization/av_override.json" | tr -s " " | sed s/\"av_status\"://g | sed -e 's/^[[:space:]]*//' | sed 's/[\",]//g' | sed -e 's/[[:space:]]*$//')
  avStates=$(grep "av_state" "/Applications/NinjaRMMAgent/programdata/customization/av_override.json" | tr -s " " | sed s/\"av_state\"://g | sed -e 's/^[[:space:]]*//' | sed 's/[\",]//g' | sed -e 's/[[:space:]]*$//')

  # Find the line number of the existing antivirus entry with the given name
  existingAV=$(echo "$avNames" | grep -n "$_arg_antivirusName" | sed 's/:.*//g')

  # Determine the desired antivirus status
  if [[ -n $_arg_antivirusStatus ]]; then
    desiredStatus=$_arg_antivirusStatus
  elif [[ -n $existingAV ]]; then
    desiredStatus=$(echo "$avStatuses" | sed -n "${existingAV}p")
  fi

  # Determine the desired antivirus state
  if [[ -n $_arg_antivirusState ]]; then
    desiredState=$_arg_antivirusState
  elif [[ -n $existingAV ]]; then
    desiredState=$(echo "$avStates" | sed -n "${existingAV}p")
  fi

  # Check if both status and state are provided
  if [[ -z $desiredStatus || -z $desiredState ]]; then
    _PRINT_HELP=no die "[Error] Antivirus state or status are missing from the override entry. Please provide both in addition to the antivirus name!" 1
  fi

  # Update the existing antivirus entry if found
  if [[ -n $existingAV ]]; then
    echo "Attempting to update override."

    # Update antivirus version if provided
    if [[ -n $_arg_antivirusVersion ]]; then
      modified_json=$(awk -v target="$existingAV" -v value="$_arg_antivirusVersion" 'BEGIN { av_count = 1 }
      /av_version/ { 
        if (av_count == target){ 
          sub( /av_version.*/ , "av_version\":  \""value"\"," ) 
        }
        av_count++
      }{ print }' "/Applications/NinjaRMMAgent/programdata/customization/av_override.json")

      if echo "$modified_json" >"/Applications/NinjaRMMAgent/programdata/customization/av_override.json"; then
        echo "Successfully updated the antivirus version!"
      else
        echo "[Error] Failed to update the antivirus version!" >&2
        exit 1
      fi
    fi

    # Update antivirus status if provided
    if [[ -n $_arg_antivirusStatus ]]; then
      modified_json=$(awk -v target="$existingAV" -v value="$_arg_antivirusStatus" 'BEGIN { av_count = 1 }
      /av_status/ { 
        if (av_count == target){ 
          sub( /av_status.*/ , "av_status\":  \""value"\"," ) 
        }
        av_count++
      }{print}' "/Applications/NinjaRMMAgent/programdata/customization/av_override.json")

      if echo "$modified_json" >"/Applications/NinjaRMMAgent/programdata/customization/av_override.json"; then
        echo "Successfully updated the override status!"
      else
        echo "[Error] Failed to update the override status!" >&2
        exit 1
      fi
    fi

    # Update antivirus state if provided
    if [[ -n $_arg_antivirusState ]]; then
      modified_json=$(awk -v target="$existingAV" -v value="$_arg_antivirusState" 'BEGIN { av_count = 1 }
      /av_state/ { 
        if (av_count == target){ 
          sub( /av_state.*/ , "av_state\":  \""value"\"" ) 
        }
        av_count++
      }{print}' "/Applications/NinjaRMMAgent/programdata/customization/av_override.json")

      if echo "$modified_json" >"/Applications/NinjaRMMAgent/programdata/customization/av_override.json"; then
        echo "Successfully updated the override state!"
      else
        echo "[Error] Failed to update the override state!" >&2
        exit 1
      fi
    fi

    exit
  fi

  # Print a message indicating that the script is attempting to append an override
  echo "Attempting to append override."
  # Initialize a counter for indexing
  i=1
  # Initialize the JSON structure for antivirus overrides
  avOverrides="{
  \"av_override\": [
"
  # Loop through each antivirus name
  for avName in $avNames; do
    # Extract the corresponding antivirus version, status and state for the current index
    avVersion=$(echo "$avVersions" | sed -n "${i}p")
    avStatus=$(echo "$avStatuses" | sed -n "${i}p")
    avState=$(echo "$avStates" | sed -n "${i}p")

    # Append the current antivirus entry to the JSON structure
    avOverrides+="    {
      \"av_name\":  \"${avName}\",
      \"av_version\": \"${avVersion}\",
      \"av_status\":  \"${avStatus}\",
      \"av_state\": \"${avState}\"
    },
"
    # Increment the counter for the next iteration
    i=$((i + 1))
  done

  # Close the JSON structure
  avOverrides+="    {
      \"av_name\":  \"${_arg_antivirusName}\",
      \"av_version\": \"${_arg_antivirusVersion}\",
      \"av_status\":  \"${_arg_antivirusStatus}\",
      \"av_state\": \"${_arg_antivirusState}\"
    }
  ]
}
"

  # Attempt to write the JSON structure containing antivirus overrides to the specified file
  if echo "$avOverrides" >"/Applications/NinjaRMMAgent/programdata/customization/av_override.json"; then
    echo "Succesfully added override."
    exit
  else
    echo "[Error] Failed to add override." >&2
    exit 1
  fi
fi

# Check if the antivirus state or status arguments are missing
if [[ -z $_arg_antivirusState || -z $_arg_antivirusStatus ]]; then
  _PRINT_HELP=no die "[Error] Antivirus name, state and status are required when adding a new override!" 1
fi

# Construct the JSON string for the antivirus override
JSON_STRING="{
  \"av_override\": [
    {
      \"av_name\":  \"${_arg_antivirusName}\",
      \"av_version\":  \"${_arg_antivirusVersion}\",
      \"av_status\":  \"${_arg_antivirusStatus}\",
      \"av_state\":  \"${_arg_antivirusState}\"
    }
  ]
}
"

# Attempt to write the JSON string to the specified file
if echo "$JSON_STRING" >"/Applications/NinjaRMMAgent/programdata/customization/av_override.json"; then
  # If the write operation is successful, print a success message
  echo "Succesfully created override."
else
  # If the write operation fails, print an error message to standard error
  echo "[Error] Failed to create override." >&2
  exit 1
fi

 

Save time with over 300+ scripts from the NinjaOne Dojo.

Get access today.

Detailed Breakdown

Let’s dissect the script and understand its core functionality:

Input Parameters and Validation

The script accepts several preset parameters via command-line arguments:

  • –antivirusName defines the antivirus program name.
  • –antivirusVersion specifies its version number.
  • –antivirusStatus indicates whether the definitions are Up-to-Date, Out-of-Date, or Unknown.
  • –antivirusState describes the program’s operational status (e.g., ON, OFF, EXPIRED).
  • Flags like –removeOverride and –append determine whether to clear or update existing overrides.

The script rigorously validates inputs, ensuring no invalid data, such as unsupported characters or states, corrupts the override configuration.

Help and Error Handling

A print_help function provides detailed usage instructions for users, while error handling ensures incorrect usage or unexpected inputs are met with clear messages. Examples include notifying users about missing arguments or invalid antivirus states.

JSON Overrides Management

The script interacts with an override file located at /Applications/NinjaRMMAgent/programdata/customization/av_override.json. Depending on user actions:

  1. Remove Override: Deletes the override file if present, ensuring no residual configurations affect device behavior.
  2. Add/Update Override: Appends new antivirus data or modifies existing entries. This involves parsing the JSON file, locating specific entries, and updating fields as needed.

Directory Management

If the customization directory does not exist, the script creates it to ensure the override file can be stored reliably. Error messages are logged if directory creation fails.

Output and Feedback

Users receive detailed feedback at every step, such as successful updates or failures during JSON manipulation.

Potential Use Cases

Use Case: Updating Antivirus Details on a Managed macOS Device

Imagine an MSP managing a macOS fleet where a subset of devices has outdated antivirus records. Using this script, the IT administrator can:

  1. Append or update antivirus records with the latest version and status (–antivirusVersion “2.5.3” –antivirusStatus “Up-to-Date”).
  2. Remove obsolete overrides for devices no longer requiring antivirus configurations (–removeOverride).
  3. Automate configuration management across multiple devices by deploying the script via RMM tools like NinjaOne.

This streamlined process minimizes manual intervention and ensures accurate reporting in compliance audits.

Comparisons

Script vs. Manual JSON Editing

Editing JSON files manually is prone to syntax errors and lacks scalability. This script automates the process, reducing human error and enabling batch updates across devices.

Script vs. Custom Automation Tools

While larger enterprises may opt for complex automation platforms, this script provides a lightweight, cost-effective alternative for small to medium-sized IT environments.

FAQs

  1. Can this script be used on non-macOS systems?
    No, this script is designed specifically for macOS, leveraging paths and commands unique to the platform.
  2. What happens if an override file does not exist?
    The script handles this gracefully by creating the required directory and file structure automatically.
  3. Can I update only specific fields without affecting others?
    Yes, the –append flag allows selective updates without overwriting unrelated fields.
  4. How does the script handle conflicting actions, like adding and removing overrides simultaneously?
    It explicitly prevents such conflicts, prompting users to choose one action at a time.

Implications

Accurate antivirus information contributes to a secure IT environment by enabling real-time monitoring and threat detection. By automating overrides, IT teams can focus on proactive security measures rather than manual data corrections. However, misuse of such scripts could inadvertently overwrite critical configurations, underscoring the need for careful use and thorough testing.

Recommendations

  • Test the script in a controlled environment before deploying it widely.
  • Keep a backup of original JSON files to recover from unintended changes.
  • Use clear and consistent naming conventions for antivirus entries to avoid duplication.
  • Leverage RMM tools like NinjaOne to distribute and execute this script across devices seamlessly.

Final Thoughts

This script exemplifies how automation can enhance efficiency and accuracy in IT management. For IT professionals using NinjaOne, the integration of such scripts into the platform’s toolkit offers a powerful way to manage macOS devices at scale. Whether you’re updating antivirus configurations or streamlining compliance processes, this script is a valuable addition to your IT arsenal.

]]>
IT Guide: Customizing Antivirus Overrides with a Bash Script in Linux  https://www.ninjaone.com/script-hub/set-antivirus-override-linux/ Fri, 06 Dec 2024 21:59:44 +0000 https://www.ninjaone.com/?post_type=script_hub&p=386670 Introduction

Maintaining accurate antivirus status information on devices is critical for IT professionals and Managed Service Providers (MSPs). Keeping track of antivirus versions, states, and update statuses ensures security policies are enforced and systems remain protected. For Linux environments, automation can simplify this process. This post examines a Bash script designed to manage antivirus overrides on devices, offering IT teams a practical tool for maintaining their endpoint security information.

Background

In IT environments, particularly for MSPs managing diverse fleets of devices, antivirus status is a key metric. However, discrepancies often arise between the actual state of antivirus software on a device and how it appears in monitoring tools. This script allows IT professionals to manually set or update antivirus information, including its name, version, state, and update status. By enabling customization or removal of overrides, it streamlines endpoint security management in environments relying on tools like NinjaOne.

The Script:

#!/usr/bin/env bash
#
# Description: Add an antivirus to the device details or override the existing antivirus information.
# By using this script, you indicate your acceptance of the following legal terms as well as our Terms of Use at https://www.ninjaone.com/terms-of-use.
# Ownership Rights: NinjaOne owns and will continue to own all right, title, and interest in and to the script (including the copyright). NinjaOne is giving you a limited license to use the script in accordance with these legal terms. 
# Use Limitation: You may only use the script for your legitimate personal or internal business purposes, and you may not share the script with another party. 
# Republication Prohibition: Under no circumstances are you permitted to re-publish the script in any script library or website belonging to or under the control of any other software provider. 
# Warranty Disclaimer: The script is provided “as is” and “as available”, without warranty of any kind. NinjaOne makes no promise or guarantee that the script will be free from defects or that it will meet your specific needs or expectations. 
# Assumption of Risk: Your use of the script is at your own risk. You acknowledge that there are certain inherent risks in using the script, and you understand and assume each of those risks. 
# Waiver and Release: You will not hold NinjaOne responsible for any adverse or unintended consequences resulting from your use of the script, and you waive any legal or equitable rights or remedies you may have against NinjaOne relating to your use of the script. 
# EULA: If you are a NinjaOne customer, your use of the script is subject to the End User License Agreement applicable to you (EULA).
#
# Preset Parameter: --antivirusName "ReplaceMeWithYourDesiredName"
#   Name of the antivirus you would like to appear in the device details.
#
# Preset Parameter: --antivirusVersion "1.0.2"
#   Specify the version number of the antivirus.
#
# Preset Parameter: --antivirusStatus "Up-to-Date"
#   Specify whether the antivirus definitions are Up-to-Date, Out-of-Date, or Unknown.
#
# Preset Parameter: --antivirusState "ON"
#   Specify the current status of the antivirus.
#
# Preset Parameter: --removeOverride
#   Remove all existing overrides.
#
# Preset Parameter: --antivirusState
#   Append or update an existing override.
#
# Preset Parameter: --help
#		Displays a help menu.
#
# Release Notes: Initial Release

# Initialize variables
_arg_antivirusName=
_arg_antivirusVersion=
_arg_antivirusStatus=
_arg_antivirusState=
_arg_removeOverride="off"
_arg_append="off"

# Function to display help message
print_help() {
  printf '\n\n%s\n\n' 'Usage: [--antivirusName|-n <arg>] [--antivirusVersion|-v <arg>] [--antivirusStatus|--status <arg>] [--antivirusState|--state <arg>] [--removeOverride|-r] [--append|-a] [--help|-h] '
  printf '%s\n' 'Preset Parameter: --antivirusName "ReplaceMeWithYourDesiredName"'
  printf '\t%s\n' "Name of the antivirus you would like to appear in the device details."
  printf '%s\n' 'Preset Parameter: --antivirusVersion "1.0.2"'
  printf '\t%s\n' "Specify the version number of the antivirus."
  printf '%s\n' 'Preset Parameter: --antivirusStatus "Up-to-Date"'
  printf '\t%s\n' "Specify whether the antivirus definitions are Up-to-Date, Out-of-Date, or Unknown."
  printf '%s\n' 'Preset Parameter: --antivirusState "ON"'
  printf '\t%s\n' "Specify the current status of the antivirus."
  printf '%s\n' 'Preset Parameter: --removeOverride'
  printf '\t%s\n' "Remove all existing overrides."
  printf '%s\n' 'Preset Parameter: --antivirusState'
  printf '\t%s\n' "Append or update an existing override."
  printf '\n%s\n' 'Preset Parameter: --help'
  printf '\t%s\n' "Displays this help menu."
}

# Function to print an error message and exit
die() {
  local _ret="${2:-1}"
  echo "$1" >&2
  test "${_PRINT_HELP:-no}" = yes && print_help >&2
  exit "${_ret}"
}

# Function to parse command line arguments
parse_commandline() {
  while test $# -gt 0; do
    _key="$1"
    case "$_key" in
    --antivirusName | --antivirusname | --name | -n)
      test $# -lt 2 && die "Missing value for the argument '$_key'." 1
      _arg_antivirusName=$2
      shift
      ;;
    --antivirusName=*)
      _arg_antivirusName="${_key##--antivirusName=}"
      ;;
    --antivirusVersion | --antivirusversion | --version | -v)
      test $# -lt 2 && die "Missing value for the argument '$_key'." 1
      _arg_antivirusVersion=$2
      shift
      ;;
    --antivirusVersion=*)
      _arg_antivirusVersion="${_key##--antivirusVersion=}"
      ;;
    --antivirusStatus | --antivirusstatus | --status)
      test $# -lt 2 && die "Missing value for the argument '$_key'." 1
      _arg_antivirusStatus=$2
      shift
      ;;
    --antivirusStatus=*)
      _arg_antivirusStatus="${_key##--antivirusStatus=}"
      ;;
    --antivirusState | --antivirusstate | --state)
      test $# -lt 2 && die "Missing value for the argument '$_key'." 1
      _arg_antivirusState=$2
      shift
      ;;
    --antivirusState=*)
      _arg_antivirusState="${_key##--antivirusState=}"
      ;;
    --removeOverride | --remove | -r)
      _arg_removeOverride="on"
      ;;
    --append | --Append | -a)
      _arg_append="on"
      ;;
    --help | -h)
      _PRINT_HELP=yes die 0
      ;;
    *)
      _PRINT_HELP=yes die "[Error] Got an unexpected argument '$1'" 1
      ;;
    esac
    shift
  done
}

# Parse the command line arguments
parse_commandline "$@"

# Replace commandline parameters with script form variables
if [[ -n $avName ]]; then
  _arg_antivirusName="$avName"
fi
if [[ -n $avVersion ]]; then
  _arg_antivirusVersion="$avVersion"
fi
if [[ -n $avStatus ]]; then
  _arg_antivirusStatus="$avStatus"
fi
if [[ -n $avState ]]; then
  _arg_antivirusState="$avState"
fi
if [[ -n $append && $append == "true" ]]; then
  _arg_append="on"
fi
if [[ -n $removeOverride && $removeOverride == "true" ]]; then
  _arg_removeOverride="on"
fi

# Ensure that removing an override and adding/updating an override are not done simultaneously
if [[ $_arg_removeOverride == "on" && (-n $_arg_antivirusName || -n $_arg_antivirusState || -n $_arg_antivirusStatus || -n $_arg_antivirusVersion || $_arg_append == "on") ]]; then
  _PRINT_HELP=no die "[Error] Cannot remove an override and add an override at the same time." 1
fi

# Check for required antivirus name and escape special characters if necessary
reservedCharacters='[\"]'
if [[ -z $_arg_antivirusName && $_arg_removeOverride != "on" ]]; then
  if [[ $_arg_append == "on" ]]; then
    _PRINT_HELP=yes die "[Error] Antivirus name was not given. The antivirus name is required when updating or adding a new override!" 1
  else
    _PRINT_HELP=yes die "[Error] Antivirus name was not given. Antivirus name, state, and status are required when adding a new override!" 1
  fi
elif [[ -n $_arg_antivirusName && $_arg_antivirusName =~ $reservedCharacters ]]; then
  _arg_antivirusName=${_arg_antivirusName//\\/\\\\}
  _arg_antivirusName=${_arg_antivirusName//\"/\\\"}
fi

# Check for required antivirus status
if [[ -z $_arg_antivirusStatus && $_arg_removeOverride != "on" && $_arg_append != "on" ]]; then
  _PRINT_HELP=yes die "[Error] Antivirus status was not given. Antivirus name, state, and status are required!" 1
fi

# Validate antivirus status
if [[ -n $_arg_antivirusStatus && $_arg_antivirusStatus != "Up-to-Date" && $_arg_antivirusStatus != "Out-of-Date" && $_arg_antivirusStatus != "Unknown" ]]; then
  _PRINT_HELP=no die "[Error] An invalid antivirus status of '$_arg_antivirusStatus' was given. Only the following statuses are valid. 'Up-to-Date', 'Out-of-Date', and 'Unknown'." 1
fi

# Check for required antivirus state
if [[ -z $_arg_antivirusState && $_arg_removeOverride != "on" && $_arg_append != "on" ]]; then
  _PRINT_HELP=yes die "[Error] Antivirus state was not given. Antivirus name, state, and status are required!" 1
else
  _arg_antivirusState=$(echo "$_arg_antivirusState" | tr '[:lower:]' '[:upper:]')
fi

# Validate antivirus state
if [[ -n $_arg_antivirusState && $_arg_antivirusState != "ON" && $_arg_antivirusState != "OFF" && $_arg_antivirusState != "EXPIRED" && $_arg_antivirusState != "SNOOZED" && $_arg_antivirusState != "UNKNOWN" ]]; then
  _PRINT_HELP=no die "[Error] An invalid antivirus state of '$_arg_antivirusState' was given. Only the following states are valid. 'ON', 'OFF', 'EXPIRED', 'SNOOZED', and 'UNKNOWN'." 1
fi

# Validate antivirus version
if [[ -n $_arg_antivirusVersion && $_arg_antivirusVersion =~ [^0-9\.] ]]; then
  _PRINT_HELP=no die "[Error] The antivirus version given '$_arg_antivirusVersion' contains an invalid character. Only the following characters are allowed. '0-9' and '.'" 1
fi

# Check if the removeOverride flag is set to "on"
if [[ $_arg_removeOverride == "on" ]]; then
  echo "Removing override as requested."
  # Check if the override file exists
  if [[ ! -f "/opt/NinjaRMMAgent/programdata/customization/av_override.json" ]]; then
    echo "No override present."
    exit 0
  # Try to remove the override file and capture any error output
  elif output=$(rm "/opt/NinjaRMMAgent/programdata/customization/av_override.json" 2>&1); then
    echo "Succesfully removed override!"
    exit 0
  # Print an error message if the removal fails
  else
    echo "[Error] Failed to remove override!"
    echo "[Error] $output"
  fi
fi

# Check if the customization directory exists
if [[ ! -d "/opt/NinjaRMMAgent/programdata/customization" ]]; then
  echo "Creating customization folder at '/opt/NinjaRMMAgent/programdata/customization'."
  # Try to create the customization directory and capture any error output
  if output=$(mkdir "/opt/NinjaRMMAgent/programdata/customization" 2>&1); then
    echo "Folder created."
  else
    # Print an error message if the creation fails
    echo "[Error] Unable to create customization folder." >&2
    echo "[Error] $output" >&2
    exit 1
  fi
fi

# Check if the append flag is set to "on" and the override JSON file exists
if [[ $_arg_append == "on" && -f "/opt/NinjaRMMAgent/programdata/customization/av_override.json" ]]; then
  # Extract antivirus names, versions, statuses and states from the JSON file
  avNames=$(grep "av_name" "/opt/NinjaRMMAgent/programdata/customization/av_override.json" | tr -s " " | sed s/\"av_name\"://g | sed -e 's/^[[:space:]]*//' | sed 's/[\",]//g' | sed -e 's/[[:space:]]*$//')
  avVersions=$(grep "av_version" "/opt/NinjaRMMAgent/programdata/customization/av_override.json" | tr -s " " | sed s/\"av_version\"://g | sed -e 's/^[[:space:]]*//' | sed 's/[\",]//g' | sed -e 's/[[:space:]]*$//')
  avStatuses=$(grep "av_status" "/opt/NinjaRMMAgent/programdata/customization/av_override.json" | tr -s " " | sed s/\"av_status\"://g | sed -e 's/^[[:space:]]*//' | sed 's/[\",]//g' | sed -e 's/[[:space:]]*$//')
  avStates=$(grep "av_state" "/opt/NinjaRMMAgent/programdata/customization/av_override.json" | tr -s " " | sed s/\"av_state\"://g | sed -e 's/^[[:space:]]*//' | sed 's/[\",]//g' | sed -e 's/[[:space:]]*$//')

  # Find the line number of the existing antivirus entry with the given name
  existingAV=$(echo "$avNames" | grep -n "$_arg_antivirusName" | sed 's/:.*//g')

  # Determine the desired antivirus status
  if [[ -n $_arg_antivirusStatus ]]; then
    desiredStatus=$_arg_antivirusStatus
  elif [[ -n $existingAV ]]; then
    desiredStatus=$(echo "$avStatuses" | sed -n "${existingAV}p")
  fi

  # Determine the desired antivirus state
  if [[ -n $_arg_antivirusState ]]; then
    desiredState=$_arg_antivirusState
  elif [[ -n $existingAV ]]; then
    desiredState=$(echo "$avStates" | sed -n "${existingAV}p")
  fi

  # Check if both status and state are provided
  if [[ -z $desiredStatus || -z $desiredState ]]; then
    _PRINT_HELP=no die "[Error] Antivirus state or status are missing from the override entry. Please provide both in addition to the antivirus name!" 1
  fi

  # Update the existing antivirus entry if found
  if [[ -n $existingAV ]]; then
    echo "Attempting to update override."

    # Update antivirus version if provided
    if [[ -n $_arg_antivirusVersion ]]; then
      modified_json=$(awk -v target="$existingAV" -v value="$_arg_antivirusVersion" 'BEGIN { av_count = 1 }
      /av_version/ { 
        if (av_count == target){ 
          sub( /av_version.*/ , "av_version\":  \""value"\"," ) 
        }
        av_count++
      }{ print }' "/opt/NinjaRMMAgent/programdata/customization/av_override.json")

      if echo "$modified_json" >"/opt/NinjaRMMAgent/programdata/customization/av_override.json"; then
        echo "Successfully updated the antivirus version!"
      else
        echo "[Error] Failed to update the antivirus version!" >&2
        exit 1
      fi
    fi

    # Update antivirus status if provided
    if [[ -n $_arg_antivirusStatus ]]; then
      modified_json=$(awk -v target="$existingAV" -v value="$_arg_antivirusStatus" 'BEGIN { av_count = 1 }
      /av_status/ { 
        if (av_count == target){ 
          sub( /av_status.*/ , "av_status\":  \""value"\"," ) 
        }
        av_count++
      }{print}' "/opt/NinjaRMMAgent/programdata/customization/av_override.json")

      if echo "$modified_json" >"/opt/NinjaRMMAgent/programdata/customization/av_override.json"; then
        echo "Successfully updated the override status!"
      else
        echo "[Error] Failed to update the override status!" >&2
        exit 1
      fi
    fi

    # Update antivirus state if provided
    if [[ -n $_arg_antivirusState ]]; then
      modified_json=$(awk -v target="$existingAV" -v value="$_arg_antivirusState" 'BEGIN { av_count = 1 }
      /av_state/ { 
        if (av_count == target){ 
          sub( /av_state.*/ , "av_state\":  \""value"\"" ) 
        }
        av_count++
      }{print}' "/opt/NinjaRMMAgent/programdata/customization/av_override.json")

      if echo "$modified_json" >"/opt/NinjaRMMAgent/programdata/customization/av_override.json"; then
        echo "Successfully updated the override state!"
      else
        echo "[Error] Failed to update the override state!" >&2
        exit 1
      fi
    fi

    exit
  fi

  # Print a message indicating that the script is attempting to append an override
  echo "Attempting to append override."
  # Initialize a counter for indexing
  i=1
  # Initialize the JSON structure for antivirus overrides
  avOverrides="{
  \"av_override\": [
"
  # Loop through each antivirus name
  for avName in $avNames; do
    # Extract the corresponding antivirus version, status and state for the current index
    avVersion=$(echo "$avVersions" | sed -n "${i}p")
    avStatus=$(echo "$avStatuses" | sed -n "${i}p")
    avState=$(echo "$avStates" | sed -n "${i}p")

    # Append the current antivirus entry to the JSON structure
    avOverrides+="    {
      \"av_name\":  \"${avName}\",
      \"av_version\": \"${avVersion}\",
      \"av_status\":  \"${avStatus}\",
      \"av_state\": \"${avState}\"
    },
"
    # Increment the counter for the next iteration
    i=$((i + 1))
  done

  # Close the JSON structure
  avOverrides+="    {
      \"av_name\":  \"${_arg_antivirusName}\",
      \"av_version\": \"${_arg_antivirusVersion}\",
      \"av_status\":  \"${_arg_antivirusStatus}\",
      \"av_state\": \"${_arg_antivirusState}\"
    }
  ]
}
"

  # Attempt to write the JSON structure containing antivirus overrides to the specified file
  if echo "$avOverrides" >"/opt/NinjaRMMAgent/programdata/customization/av_override.json"; then
    echo "Succesfully added override."
    exit
  else
    echo "[Error] Failed to add override." >&2
    exit 1
  fi
fi

# Check if the antivirus state or status arguments are missing
if [[ -z $_arg_antivirusState || -z $_arg_antivirusStatus ]]; then
  _PRINT_HELP=no die "[Error] Antivirus name, state and status are required when adding a new override!" 1
fi

# Construct the JSON string for the antivirus override
JSON_STRING="{
  \"av_override\": [
    {
      \"av_name\":  \"${_arg_antivirusName}\",
      \"av_version\":  \"${_arg_antivirusVersion}\",
      \"av_status\":  \"${_arg_antivirusStatus}\",
      \"av_state\":  \"${_arg_antivirusState}\"
    }
  ]
}
"

# Attempt to write the JSON string to the specified file
if echo "$JSON_STRING" >"/opt/NinjaRMMAgent/programdata/customization/av_override.json"; then
  # If the write operation is successful, print a success message
  echo "Succesfully created override."
else
  # If the write operation fails, print an error message to standard error
  echo "[Error] Failed to create override." >&2
  exit 1
fi

 

Save time with over 300+ scripts from the NinjaOne Dojo.

Get access today.

Detailed Breakdown

This script automates adding or updating antivirus overrides in a JSON-based configuration file. Here’s how it works step by step:

1. Initialize Parameters

a. The script begins by defining variables for antivirus name, version, status, and state. Flags like –removeOverride and –append are also initialized to handle specific actions.

2. Command-Line Parsing

a. The parse_commandline function processes arguments provided when the script is executed. For example, –antivirusName “MyAntivirus” sets the name of the antivirus.

3. Validation

a. The script ensures mandatory parameters are provided when required. It checks for valid statuses (Up-to-Date, Out-of-Date, or Unknown) and states (ON, OFF, EXPIRED, SNOOZED, or UNKNOWN).

4. Removing Overrides

a. If the –removeOverride flag is set, the script deletes the existing override JSON file. If no such file exists, it exits gracefully.

5. Appending or Updating Overrides

a. When the –append flag is used, the script checks for existing entries in the JSON file. It either updates the details of an existing antivirus entry or adds a new one.

6. Writing Overrides

a. If the –append flag is not set, the script writes a new JSON structure with the specified antivirus details. This structure is saved to a designated location.

Potential Use Cases

Scenario: An MSP Managing Client Devices

Imagine an MSP monitors devices for multiple clients. A new antivirus update causes delays in state synchronization with their monitoring tool. By deploying this script across affected devices, the MSP can temporarily override the status, marking the antivirus as “Up-to-Date” and “ON,” ensuring compliance reports remain accurate until the issue is resolved.

Comparisons

Other methods for managing antivirus status include manual JSON file edits or relying on built-in tools within Remote Monitoring and Management (RMM) platforms. Compared to manual edits, this script reduces human error and speeds up the process. Unlike platform-specific tools, it offers flexibility for Linux environments where such integrations might not exist.

FAQs

  • Can this script work with all antivirus software?
    It doesn’t interact with antivirus software directly but modifies the JSON file containing overrides, making it agnostic to specific antivirus solutions.
  • What happens if I run it without required arguments?
    The script provides detailed error messages and usage instructions to guide proper execution.
  • How do I revert changes made by this script?
    Use the –removeOverride flag to delete the override configuration file.
  • Can I use this script on non-Linux systems?
    It is designed for Linux environments due to its reliance on Bash and Linux file paths.

Implications

Effective use of this script ensures accurate representation of antivirus statuses across devices. This reduces false positives or negatives in security monitoring, bolstering organizational cybersecurity. However, improper usage or invalid overrides could lead to discrepancies, highlighting the need for careful management.

Recommendations

  • Always test the script on a non-production device to verify its behavior.
  • Use clear and consistent naming conventions for antivirus solutions to avoid conflicts.
  • Document overrides applied to devices for future reference.
  • Limit access to this script to prevent unauthorized modifications.

Final Thoughts

This script exemplifies how automation enhances efficiency and accuracy in IT management. For users of NinjaOne or similar RMM platforms, leveraging tools like this script alongside advanced endpoint management features ensures a robust security posture. NinjaOne simplifies endpoint visibility and management, providing a unified platform to streamline IT operations while addressing challenges like antivirus overrides with precision.

]]>
Adding and Managing Antivirus Overrides with PowerShell  https://www.ninjaone.com/script-hub/set-antivirus-override-with-powershell/ Fri, 06 Dec 2024 21:55:01 +0000 https://www.ninjaone.com/?post_type=script_hub&p=386655 Antivirus management is a critical aspect of IT administration, particularly for managed service providers (MSPs) and IT professionals tasked with overseeing large fleets of devices. Efficiently configuring or overriding antivirus settings across multiple endpoints can save time, ensure compliance, and maintain security posture. The provided PowerShell script offers a robust solution for setting antivirus overrides or removing existing ones in a structured and automated way.

Background

In IT environments, administrators often encounter scenarios where they need to standardize antivirus configurations across endpoints. This can include specifying antivirus versions, statuses, and states to reflect the actual security state of devices or to override reported information for compliance reasons. The script serves as a tool to either add or update antivirus details or remove overrides altogether. It is particularly useful for MSPs managing diverse client environments through tools like NinjaOne.

By leveraging PowerShell, this script streamlines operations, eliminates manual errors, and ensures consistency in antivirus management. Its ability to handle overrides programmatically makes it an asset for IT professionals who manage numerous devices or those seeking to enforce organization-wide security standards.

The Script:

#Requires -Version 5.1

<#
.SYNOPSIS
    Add an antivirus to the device details or override the existing antivirus information.
.DESCRIPTION
    Add an antivirus to the device details or override the existing antivirus information.

    By using this script, you indicate your acceptance of the following legal terms as well as our Terms of Use at https://www.ninjaone.com/terms-of-use.
    Ownership Rights: NinjaOne owns and will continue to own all right, title, and interest in and to the script (including the copyright). NinjaOne is giving you a limited license to use the script in accordance with these legal terms. 
    Use Limitation: You may only use the script for your legitimate personal or internal business purposes, and you may not share the script with another party. 
    Republication Prohibition: Under no circumstances are you permitted to re-publish the script in any script library or website belonging to or under the control of any other software provider. 
    Warranty Disclaimer: The script is provided “as is” and “as available”, without warranty of any kind. NinjaOne makes no promise or guarantee that the script will be free from defects or that it will meet your specific needs or expectations. 
    Assumption of Risk: Your use of the script is at your own risk. You acknowledge that there are certain inherent risks in using the script, and you understand and assume each of those risks. 
    Waiver and Release: You will not hold NinjaOne responsible for any adverse or unintended consequences resulting from your use of the script, and you waive any legal or equitable rights or remedies you may have against NinjaOne relating to your use of the script. 
    EULA: If you are a NinjaOne customer, your use of the script is subject to the End User License Agreement applicable to you (EULA).
.EXAMPLE
    -AntivirusName "My AV" -AntivirusVersion "1.0.1" -AntivirusStatus "Out-of-Date" -AntivirusState "ON"

    Creating customization folder.


        Directory: C:\ProgramData\NinjaRMMAgent


    Mode                 LastWriteTime         Length Name                                                                 
    ----                 -------------         ------ ----                                                                 
    d-----         6/19/2024   4:09 PM                Customization                                                        
    Successfully created customization folder.

    Applying override.
    Successfully applied override.

PARAMETER: -AntivirusName "ReplaceMeWithNameOfAnAntivirus"
    Name of the antivirus you would like to appear in the device details.

PARAMETER: -AntivirusVersion "1.0.2"
    Specify the version number of the antivirus.

PARAMETER: -AntivirusStatus "Up-to-Date"
    Specify whether the antivirus definitions are up-to-date, out-of-date, or unknown.

PARAMETER: -AntivirusState "ON"
    Specify the current status of the antivirus.

PARAMETER: -Append
    Append or update an existing override.
    
PARAMETER: -RemoveOverride
    Remove all existing overrides.

.NOTES
    Minimum OS Architecture Supported: Windows 10, Windows Server 2016
    Release Notes: Initial Release
#>

[CmdletBinding()]
param (
    [Parameter()]
    [String]$AntivirusName,
    [Parameter()]
    [String]$AntivirusVersion,
    [Parameter()]
    [String]$AntivirusStatus,
    [Parameter()]
    [String]$AntivirusState,
    [Parameter()]
    [Switch]$Append = [System.Convert]::ToBoolean($env:append),
    [Parameter()]
    [Switch]$RemoveOverride = [System.Convert]::ToBoolean($env:removeOverride)
)

begin {
    # Replace command line paramets with the form variables if used.
    if ($env:avName -and $env:avName -notlike "null") { $AntivirusName = $env:avName }
    if ($env:avVersion -and $env:avVersion -notlike "null") { $AntivirusVersion = $env:avVersion }
    if ($env:avStatus -and $env:avStatus -notlike "null") { $AntivirusStatus = $env:avStatus }
    if ($env:avState -and $env:avState -notlike "null") { $AntivirusState = $env:avState }

    # Check if RemoveOverride is set and any of the other parameters are also set
    if ($RemoveOverride -and ($AntivirusState -or $AntivirusStatus -or $AntivirusVersion -or $AntivirusName -or $Append)) {
        Write-Host -Object "[Error] Cannot remove an override and add an override at the same time."
        exit 1
    }

    # Check if AntivirusName is not provided and RemoveOverride is not set
    if (!$AntivirusName -and !$RemoveOverride) {
        Write-Host $RemoveOverride
        if ($Append) {
            Write-Host -Object "[Error] Antivirus name was not given. The antivirus name is required when updating or adding a new override!"
        }
        else {
            Write-Host -Object "[Error] Antivirus name was not given. Antivirus name, state, and status are required when adding a new override!"
        }

        exit 1
    }

    # Validate AntivirusVersion for invalid characters
    if ($AntivirusVersion -and $AntivirusVersion -match '[^0-9\.]') {
        Write-Host -Object "[Error] The antivirus version given contains an invalid character. Only the following characters are allowed: '0-9' and '.'"
        exit 1
    }

    # Check if AntivirusStatus is not provided and neither RemoveOverride nor Append is set
    if (!$AntivirusStatus -and !$RemoveOverride -and !$Append) {
        Write-Host -Object "[Error] Antivirus status was not given. Antivirus name, state, and status are required!"
        exit 1
    }

    # Define valid antivirus statuses
    $ValidStatus = "Up-to-Date", "Out-of-Date", "Unknown"
    # Check if the provided AntivirusStatus is valid
    if ($AntivirusStatus -and $ValidStatus -notcontains $AntivirusStatus) {
        Write-Host -Object "[Error] An invalid antivirus status was given. Only the following statuses are valid: 'Up-to-Date', 'Out-of-Date', and 'Unknown'."
        exit 1
    }

    # Check if AntivirusState is not provided and neither RemoveOverride nor Append is set
    if (!$AntivirusState -and !$RemoveOverride -and !$Append) {
        Write-Host -Object "[Error] Antivirus state was not given. Antivirus name, state, and status are required!"
        exit 1
    }

    # Define valid antivirus states
    $ValidState = "ON", "OFF", "EXPIRED", "SNOOZED", "UNKNOWN"
    # Check if the provided AntivirusState is valid
    if ($AntivirusState -and $ValidState -notcontains $AntivirusState) {
        Write-Host -Object "[Error] An invalid antivirus state was given. Only the following states are valid: 'ON', 'OFF', 'EXPIRED', 'SNOOZED', and 'UNKNOWN'."
        exit 1
    }

    # Check if the NinjaRMMAgent directory exists
    if (!(Test-Path -Path "$env:ProgramData\NinjaRMMAgent")) {
        Write-Host -Object "[Error] Ninja Agent is not present at '$env:ProgramData\NinjaRMMAgent'."
        exit 1
    }

    # Function to check if the script is running with elevated privileges
    function Test-IsElevated {
        $id = [System.Security.Principal.WindowsIdentity]::GetCurrent()
        $p = New-Object System.Security.Principal.WindowsPrincipal($id)
        $p.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
    }

    # Set ExitCode to 0 if it is not already set
    if (!$ExitCode) {
        $ExitCode = 0
    }
}
process {
    # Check if the script is running with elevated privileges
    if (!(Test-IsElevated)) {
        Write-Host -Object "[Error] Access denied. Please run with administrator privileges."
        exit 1
    }

    # Check if RemoveOverride is set
    if ($RemoveOverride) {
        # Check if the antivirus override file exists
        if (Test-Path -Path "$env:ProgramData\NinjaRMMAgent\Customization\av_override.json" -ErrorAction SilentlyContinue) {
            Write-Host -Object "Removing $env:ProgramData\NinjaRMMAgent\Customization\av_override.json file."

            # Attempt to remove the antivirus override file
            try {
                Remove-Item -Path "$env:ProgramData\NinjaRMMAgent\Customization\av_override.json" -ErrorAction Stop
            }
            catch {
                Write-Host -Object "[Error] Failed to remove antivirus override."
                Write-Host -Object "[Error] $($_.Exception.Message)"
                exit 1
            }
        }
        else {
            Write-Host -Object "Antivirus override is not currently set."
        }

        exit $ExitCode
    }

    # Check if the Customization directory exists, if not, create it
    if (!(Test-Path -Path "$env:ProgramData\NinjaRMMAgent\Customization" -ErrorAction SilentlyContinue)) {
        try {
            Write-Host -Object "Creating customization folder."
            New-Item -Path "$env:ProgramData\NinjaRMMAgent\Customization" -ItemType Directory -Force -ErrorAction Stop
            Write-Host -Object "Successfully created customization folder.`n"
        }
        catch {
            Write-Host -Object "[Error] Unable to create customization folder."
            Write-Host -Object "[Error] $($_.Exception.Message)"
            exit 1
        }
    }

    # Initialize a list to hold antivirus overrides
    $AntivirusOverrides = New-Object System.Collections.Generic.List[Object]

    # If Append is set and the antivirus override file exists, retrieve current overrides
    if ($Append -and (Test-Path -Path "$env:ProgramData\NinjaRMMAgent\Customization\av_override.json" -ErrorAction SilentlyContinue)) {
        try {
            $CurrentOverrides = Get-Content -Path "$env:ProgramData\NinjaRMMAgent\Customization\av_override.json" -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop | Select-Object -ExpandProperty "av_override" -ErrorAction Stop
        }
        catch {
            Write-Host -Object "[Error] Failed to retrieve current overrides."
            Write-Host -Object "[Error] $($_.Exception.Message)"
            exit 1
        }

        # Iterate over current overrides to update or add new overrides
        $CurrentOverrides | ForEach-Object {
            if ($AntivirusName -notmatch [Regex]::Escape($_.av_name)) {
                $AntivirusOverrides.Add($_)
                return
            }

            Write-Host -Object "An existing antivirus with the same name was detected. Updating the existing entry.`n"

            $AntivirusOverrides.Add(
                [PSCustomObject]@{
                    av_name    = $AntivirusName
                    av_version = if ($AntivirusVersion) { $AntivirusVersion }else { $_.av_version }
                    av_status  = if ($AntivirusStatus) { $AntivirusStatus }else { $_.av_status }
                    av_state   = if ($AntivirusState) { $AntivirusState }else { $_.av_state }
                }
            )

            $UpdatedOverride = $True
        }
    }

    # If Append is set but no override was updated, check for required parameters
    if ($Append -and !$UpdatedOverride -and (!$AntivirusStatus -or !$AntivirusState)) {
        Write-Host -Object "[Error] Antivirus name, state, and status are required when adding a new override!"
        exit 1
    }
    elseif ($Append) {
        Write-Host -Object "Adding override to the existing list of overrides.`n"
    }

    # If no override was updated, add a new override
    if (!$UpdatedOverride) {
        $AntivirusOverrides.Add(
            [PSCustomObject]@{
                av_name    = $AntivirusName
                av_version = $AntivirusVersion
                av_status  = $AntivirusStatus
                av_state   = $AntivirusState
            }
        )
    }

    # Attempt to apply the override by writing to the override file
    try {
        Write-Host -Object "Applying override."
        $AntivirusOverrideJSON = [PSCustomObject]@{
            av_override = $AntivirusOverrides
        } | ConvertTo-Json -ErrorAction Stop

        $AntivirusOverrideJSON | Out-File -FilePath "$env:ProgramData\NinjaRMMAgent\Customization\av_override.json" -Encoding "utf8" -Force -ErrorAction Stop
        Write-Host -Object "Successfully applied override."
    }
    catch {
        Write-Host -Object "[Error] Unable to create override."
        Write-Host -Object "[Error] $($_.Exception.Message)"
        exit 1
    }

    exit $ExitCode
}
end {
    
    
    
}

 

Save time with over 300+ scripts from the NinjaOne Dojo.

Get access today.

Detailed Breakdown of the Script

Initial Validation and Parameters

The script begins by accepting several parameters:

  • AntivirusName: Name of the antivirus to appear in the device details.
  • AntivirusVersion: Version number of the antivirus.
  • AntivirusStatus: Status of the antivirus, such as “Up-to-Date” or “Out-of-Date”.
  • AntivirusState: Operational state of the antivirus, such as “ON” or “OFF”.
  • Append: A switch to add or update an existing override.
  • RemoveOverride: A switch to remove all overrides.

The script validates these inputs, ensuring the antivirus version contains only numeric characters and dots and that the status and state fall within predefined valid values.

Privilege Checks

To prevent unauthorized changes, the script ensures it is running with administrator privileges. If not, it terminates with an error message.

Override Management

  1. Remove Overrides: If RemoveOverride is specified, the script deletes any existing override file (av_override.json) in the NinjaRMMAgent directory.
  2. Add or Update Overrides: For other operations, the script:
  3. Checks for the existence of the customization folder and creates it if necessary.
  4. Reads existing overrides if Append is specified, updating them if the antivirus name matches or adding a new entry if it does not.
  5. Saves the new or updated override information in JSON format.

Error Handling and Logging

The script incorporates robust error handling, logging any issues encountered during file or directory operations and providing clear error messages to guide users.

Potential Use Cases

Hypothetical Scenario

An MSP managing a fleet of 500 devices notices that some endpoints report outdated antivirus definitions incorrectly due to compatibility issues. Using this script, the MSP can:

  • Override the reported status to “Up-to-Date” for affected devices.
  • Ensure compliance reports accurately reflect the organization’s security posture.
  • Automate the process by integrating the script into their device management workflows.

This approach saves hours of manual updates while maintaining a standardized security configuration.

Comparisons to Other Methods

The script’s approach provides several advantages over manual or GUI-based configurations:

  • Efficiency: Changes can be applied to multiple devices in seconds.
  • Repeatability: The script can be reused with different parameters for various scenarios.
  • Automation-Friendly: It can be integrated into broader IT management workflows, unlike manual methods.

Compared to advanced remote monitoring and management (RMM) tools, the script offers a lightweight alternative that doesn’t require specialized software for basic antivirus override tasks.

FAQs

  1. What happens if I don’t specify an antivirus name?
    The script will terminate with an error, as the antivirus name is required unless removing overrides.
  2. Can this script be used on older Windows versions?
    No, the script is designed for Windows 10 and Windows Server 2016 or newer.
  3. What should I do if I encounter a file permission error?
    Ensure the script is executed with administrator privileges.
  4. Is the override permanent?
    The override persists until it is removed using the RemoveOverride parameter or manually deleted.

Implications of the Script’s Results

By accurately managing antivirus details, this script ensures that device security states are consistently reported. It mitigates the risk of compliance failures due to incorrect status reporting and helps organizations maintain a strong security posture. However, misusing overrides could result in devices being reported as secure when they are not, highlighting the need for careful and informed usage.

Recommendations for Best Practices

  • Always verify the antivirus information before applying overrides.
  • Use Append cautiously to avoid overwriting important details inadvertently.
  • Run the script in a controlled environment or test its impact on a single device before deploying it across the fleet.
  • Regularly review and update overrides to reflect the current state of devices accurately.

Final Thoughts

This PowerShell script provides IT professionals with a practical tool for managing antivirus configurations across endpoints. It is particularly valuable for environments managed via NinjaOne, where consistency and efficiency are paramount. By integrating this script into their workflows, MSPs and IT admins can streamline operations, ensure compliance, and maintain accurate security reporting.

]]>
Exporting Ninja Log Files on macOS with a Bash Script https://www.ninjaone.com/script-hub/export-ninja-log-files-macos/ Fri, 06 Dec 2024 21:48:03 +0000 https://www.ninjaone.com/?post_type=script_hub&p=386654 Efficient log management is critical for IT professionals and managed service providers (MSPs). Whether troubleshooting issues or ensuring compliance, having access to relevant logs can be a lifesaver. The provided Bash script streamlines the process of exporting Ninja log files on macOS, making it a valuable tool for IT environments. This post breaks down the script, its use cases, and best practices for deploying it.

Background on Ninja Log Management

NinjaOne is a comprehensive IT management solution, and its agents generate logs that can provide crucial insights into system performance, policies, and configurations. For macOS users, manually locating and exporting these logs can be tedious. This script automates the process, ensuring that logs are exported to a specified directory in a consistent format.

IT professionals and MSPs frequently need these logs for diagnostics, audits, or compliance purposes. By automating log exports, the script reduces manual effort and minimizes the risk of human error.

The Script

#!/usr/bin/env bash

# Description: Exports the Ninja Support Logs to the specified directory for Mac.
#   By using this script, you indicate your acceptance of the following legal terms as well as our Terms of Use at https://www.ninjaone.com/terms-of-use.
#   Ownership Rights: NinjaOne owns and will continue to own all right, title, and interest in and to the script (including the copyright). NinjaOne is giving you a limited license to use the script in accordance with these legal terms. 
#   Use Limitation: You may only use the script for your legitimate personal or internal business purposes, and you may not share the script with another party. 
#   Republication Prohibition: Under no circumstances are you permitted to re-publish the script in any script library or website belonging to or under the control of any other software provider. 
#   Warranty Disclaimer: The script is provided “as is” and “as available”, without warranty of any kind. NinjaOne makes no promise or guarantee that the script will be free from defects or that it will meet your specific needs or expectations. 
#   Assumption of Risk: Your use of the script is at your own risk. You acknowledge that there are certain inherent risks in using the script, and you understand and assume each of those risks. 
#   Waiver and Release: You will not hold NinjaOne responsible for any adverse or unintended consequences resulting from your use of the script, and you waive any legal or equitable rights or remedies you may have against NinjaOne relating to your use of the script. 
#   EULA: If you are a NinjaOne customer, your use of the script is subject to the End User License Agreement applicable to you (EULA).
#
# Preset Parameter: --destination "/private/tmp"
#   The directory to export the logs to.
#
# Preset Parameter: --help
#   Displays some help text.

# These are all our preset parameter defaults. You can set these = to something if you would prefer the script defaults to a certain parameter value.
_arg_destination="/private/tmp"

# Help text function for when invalid input is encountered
print_help() {
    printf '\n\n%s\n\n' 'Usage: [--destination|-d <arg>] [--createPath|-c] [--help|-h]'
    printf '%s\n' 'Preset Parameter: --destination "/private/tmp/ninjaLogs" --createPath'
    printf '\t%s\n' "Replace the text encased in quotes with the directory to export the logs to."
    printf '\n%s\n' 'Preset Parameter: --help'
    printf '\t%s\n' "Displays this help menu."
}

# Determines whether or not help text is necessary and routes the output to stderr
die() {
    local _ret="${2:-1}"
    echo "$1" >&2
    test "${_PRINT_HELP:-no}" = yes && print_help >&2
    exit "${_ret}"
}

# Grabbing the parameters and parsing through them.
parse_commandline() {
    while test $# -gt 0; do
        _key="$1"
        case "$_key" in
        --destination | -d)
            test $# -lt 2 && die "Missing value for the optional argument '$_key'." 1
            _arg_destination=$2
            shift
            ;;
        --destination=*)
            _arg_destination="${_key##--destination=}"
            ;;
        --createPath | -c)
            _arg_createPath="true"
            ;;
        --help | -h)
            _PRINT_HELP=yes die 0
            ;;
        *)
            _PRINT_HELP=yes die "FATAL ERROR: Got an unexpected argument '$1'" 1
            ;;
        esac
        shift
    done
}

parse_commandline "$@"

# If script form is used override commandline arguments
if [[ -n $destination ]] && [[ "${destination}" != "null" ]]; then
    _arg_destination="$destination"
fi
if [[ -n $createPath ]] && [[ "${createPath}" == "true" ]]; then
    _arg_createPath="true"
fi

# Get the path to the NinjaRMMAgent from the environment variable NINJA_DATA_PATH
_data_path=$(printenv | grep -i NINJA_DATA_PATH | awk -F = '{print $2}')
if [[ -z "${_data_path}" ]]; then
    # If the environment variable NINJA_DATA_PATH is not set, try to find the NinjaRMMAgent in the Applications folder
    _data_path="/Applications/NinjaRMMAgent/programdata"
    if [[ -z "${_data_path}" ]]; then
        echo "[Error] No NinjaRMMAgent found. Please make sure you have the NinjaRMMAgent installed and that it is running."
        exit 1
    fi
fi

# Get the current date
cur_date="$(date +%Y-%m-%d)"

# Trim the trailing slash from the destination path and remove any duplicate slashes from the destination path
dest_path=$(echo "$_arg_destination" | sed 's/\/$//' | sed 's/\/\+/\//g')

if [ -e "${dest_path}" ]; then
    echo "[Info] The destination path (${dest_path}) exists."
else
    echo "[Warn] The destination path (${dest_path}) does not exist."
    if [[ "${_arg_createPath}" == "true" ]]; then
        echo "[Info] Creating the destination path (${dest_path})"
        mkdir -p "${dest_path}"
    else
        echo "[Error] The destination path (${dest_path}) does not exist."
        exit 1
    fi
fi

echo "[Info] Exporting logs to $dest_path/NinjaSupportLogs.zip"

# Collect the logs from the following directories
if zip -r -q "$dest_path/$cur_date-NinjaLogs.zip" "$_data_path/logs" "$_data_path/policy" "$_data_path/jsonoutput" "$_data_path/jsoninput" "$_data_path/patch"; then
    echo "[Info] Logs exported to $dest_path/$cur_date-NinjaLogs.zip"
else
    echo "[Error] Failed to export logs to $dest_path/$cur_date-NinjaLogs.zip"
    exit 1
fi

 

Save time with over 300+ scripts from the NinjaOne Dojo.

Get access today.

Step-by-Step Breakdown of the Script

Overview of Parameters

The script begins by defining and parsing user-provided parameters:

  • –destination or -d: Specifies the directory where logs will be exported. Defaults to /private/tmp.
  • –createPath or -c: Indicates whether to create the destination directory if it doesn’t exist.
  • –help or -h: Displays usage instructions.

Help Functionality

The print_help function provides guidance for users unfamiliar with the script’s parameters. It details the purpose of each flag, ensuring clarity.

Parsing Command-Line Arguments

The parse_commandline function processes the input arguments. It validates required inputs, checks for unexpected arguments, and assigns values to the script’s variables.

Resolving the Ninja Data Path

The script identifies the location of Ninja logs using:

  1. The NINJA_DATA_PATH environment variable, if set.
  2. A default fallback to /Applications/NinjaRMMAgent/programdata.

If neither path is available, the script exits with an error, ensuring users are informed about the missing NinjaOne agent.

Preparing the Destination Directory

The script cleans and validates the destination path. If the directory doesn’t exist:

  • It creates the directory if the –createPath flag is set.
  • Otherwise, it exits with an error.

Exporting Logs

Using the zip utility, the script collects logs from predefined Ninja directories (logs, policy, jsonoutput, jsoninput, patch) and compresses them into a timestamped ZIP file. This ensures easy retrieval and organization of exported logs.

Potential Use Cases

Hypothetical Scenario: Resolving Endpoint Performance Issues

Imagine an MSP managing multiple endpoints using NinjaOne. A client reports slow performance on a macOS device. Instead of manually sifting through logs, the IT technician runs this script:

  • Exports logs to a predefined directory.
  • Reviews the compressed logs for patterns or errors.
  • Pinpoints the root cause, such as policy misconfiguration or patching issues.

The automation provided by the script saves time and ensures accurate diagnostics.

Comparisons to Alternative Methods

Manual log export involves navigating directories, selecting relevant files, and compressing them. This approach is prone to errors and is time-consuming.

Using the NinjaOne web interface for log retrieval is another option, but it may lack flexibility or automation capabilities on macOS. The script’s command-line approach offers precision and repeatability, making it ideal for IT professionals comfortable with scripting.

FAQs

What happens if the destination directory doesn’t exist?
The script can create the directory if the –createPath flag is used. Otherwise, it exits with an error.

Can this script run on Linux or Windows?
While designed for macOS, it could be adapted for Linux. Windows would require a different script or approach due to system differences.

What logs are included in the ZIP file?
The script collects logs from directories such as logs, policy, jsonoutput, jsoninput, and patch.

Implications for IT Security

Exported logs contain sensitive information, such as configurations and system states. Mishandling these files could expose vulnerabilities. IT teams should enforce strict access controls and encrypt exported logs when storing or transmitting them.

By automating log exports, the script also reduces the time windows in which logs might be inaccessible during incidents, bolstering operational resilience.

Recommendations for Using the Script

  1. Run in a Secure Environment: Ensure only authorized personnel have access to the script and exported logs.
  2. Test in Non-Production Systems: Validate the script on non-critical systems before deploying it widely.
  3. Schedule Regular Exports: Integrate the script with a task scheduler like cron for periodic log exports.

Final Thoughts

The ability to export Ninja log files on macOS efficiently is a significant advantage for IT professionals and MSPs. This Bash script not only simplifies the process but also ensures consistency and accuracy. NinjaOne users can leverage such automation to enhance their troubleshooting and compliance efforts, further underscoring the platform’s value in IT management.

]]>
How to Send Snooze & Dismiss Notifications with PowerShell https://www.ninjaone.com/script-hub/send-snooze-notification-powershell/ Fri, 06 Dec 2024 21:41:17 +0000 https://www.ninjaone.com/?post_type=script_hub&p=386579 Efficient communication is a cornerstone of IT management, especially in managed services and enterprise environments. Notifications and alerts play a pivotal role in keeping teams informed and ensuring system reliability. This blog post explores a versatile PowerShell script that empowers IT professionals to create and send toast notifications with customizable snooze and dismiss options. This functionality is particularly useful for Managed Service Providers (MSPs) and IT teams looking for lightweight, user-friendly ways to deliver critical messages to end-users.

Background

In Windows 10 and beyond, toast notifications are a modern way to alert users directly within the operating system. While third-party tools can offer similar functionalities, they often come with licensing costs, unnecessary overhead, or limited customization. This PowerShell script provides a streamlined, cost-effective alternative, allowing IT professionals to create snooze and dismiss notifications tailored to specific needs.

MSPs and IT administrators benefit significantly from this tool. Whether it’s reminding users about pending updates, notifying them of critical outages, or providing status updates during system maintenance, the ability to deliver actionable messages directly enhances operational efficiency and user engagement.

The Script:

#Requires -Version 5.1

<#
.SYNOPSIS
    Sends a toast snooze/dismiss notification to the currently signed in user. Please run as the Current Logged-on User. The script defaults to using NinjaOne's logo if none is provided.
.DESCRIPTION
    Sends a toast snooze/dismiss notification to the currently signed in user. Please run as 'Current Logged on User'.
    This defaults to using NinjaOne's logo in the Toast Message, but you can specify any png formatted image from a url.
    You can also specify the "ApplicationId" to any string. The default is "NinjaOne RMM".

    By using this script, you indicate your acceptance of the following legal terms as well as our Terms of Use at https://www.ninjaone.com/terms-of-use.
    Ownership Rights: NinjaOne owns and will continue to own all right, title, and interest in and to the script (including the copyright). NinjaOne is giving you a limited license to use the script in accordance with these legal terms. 
    Use Limitation: You may only use the script for your legitimate personal or internal business purposes, and you may not share the script with another party. 
    Republication Prohibition: Under no circumstances are you permitted to re-publish the script in any script library or website belonging to or under the control of any other software provider. 
    Warranty Disclaimer: The script is provided “as is” and “as available”, without warranty of any kind. NinjaOne makes no promise or guarantee that the script will be free from defects or that it will meet your specific needs or expectations. 
    Assumption of Risk: Your use of the script is at your own risk. You acknowledge that there are certain inherent risks in using the script, and you understand and assume each of those risks. 
    Waiver and Release: You will not hold NinjaOne responsible for any adverse or unintended consequences resulting from your use of the script, and you waive any legal or equitable rights or remedies you may have against NinjaOne relating to your use of the script. 
    EULA: If you are a NinjaOne customer, your use of the script is subject to the End User License Agreement applicable to you (EULA).

.EXAMPLE
    -Title "My Title Here" -Message "My Message Here"
    Sends the title "My Title Here" and message "My Message Here" as a Toast message/notification to the currently signed in user.
.EXAMPLE
    -Title "My Title Here" -Message "My Message Here" -ApplicationId "MyCompany"
    Sends the title "My Title Here" and message "My Message Here" as a Toast message/notification to the currently signed in user.
        ApplicationId: Creates a registry entry for your toasts called "MyCompany".
        PathToImageFile: Downloads a png image for the icon in the toast message/notification.
        SnoozeTimeOptionsInMinutes: Adds a dropdown to the toast message/notification with options for snoozing the message.
.OUTPUTS
    None
.NOTES
    If you want to change the defaults then with in the param block.
    ImagePath uses C:\Users\Public\ as that is accessible by all users.
    If you want to customize the application name to show your company name,
        then look for $ApplicationId and change the content between the double quotes.

    Minimum OS Architecture Supported: Windows 10 (IoT editions are not supported due to lack of shell)
    Release Notes: Renamed script, Updated Script Variables
#>

[CmdletBinding()]
param
(
    [string]$Title,
    [string]$Message,
    [string]$ApplicationId,
    [string]$SnoozeTimeOptionsInMinutes,
    [string]$PathToImageFile
)

begin {
    $Base64 = 'iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAMAAAD04JH5AAAAJFBMVEUARF0Apc0AmL8ApM0Aos0Aps7///8Am8ia1ug9rtLd8/jw+/2tMDHwAAAABXRSTlMBrBTIcce4nvwAAAIeSURBVHic7dvrcoMgEAXgiOAivv/7Fm+JBpCLwk7bsz86rcNkPw+Y0Gl5vd4lGtbLKSG7vmF18mwQnWpe3YcghP2Z1svU8OtbIOihm8op25M2gWBov9UqYJj/vSRzAGsEkhMglxngWINbdbxLAAAAAAAAAAAAAKAI8Oz2KRtApPWThEyAbT8NZwDZGpeav6sLIKXNMBwAtuGotTGTvTpMRms9qkxEBsDe/dz+A7B3rufeS/utrCKPkAywzfYmK8BeOHY+lBkzBImALfwDgA4XnNLphCTA4e43AKmL9vNMJD8pCQAna20nP5D+SfkQgJyp1qS9PYsEKQDnpVP627WYJCgBmGj+GRmUAFIraSXWBAwDcwJJk1AXMIzcgHgElQHxCGoDohHcBsybgIvPpei70S2A0csuaNkTBRBTbA7uAOb271E0+gWxOSgHfG87yD+wGsCz7fGONNf9iwGTb89DnlkwkUVQCPD2t1sXz9A6gMDT5YsgsggKARljI/vTMkDo7cU3B1USCL+oOwdVAMGF5RlcAxB+tBoBwq/JDlDcAPYEAGgDuPiNBwkgASSABJAAEkACSAAJIAEkgASQABL4JwlcA9w/9N4GTOZcl1OQMTgRoEannhv9O/+PCAAAAAAAAAAAAACAPwhgP+7HeOCR1jOfjBHI9dBrz9W/34/d9jyHLvvPweP2GdCx/3zyvLlAfZ8+l13LktJzAJ+nfgAP50EVLvPsRgAAAABJRU5ErkJggg=='
    [string]$ImagePath = "$($env:SystemDrive)\Users\Public\PowerShellToastSnoozeImage.png"

    # Set the default ApplicationId if it's not provided. Use the Company Name if available, otherwise use the default.
    $ApplicationId = if ($env:NINJA_COMPANY_NAME) { $env:NINJA_COMPANY_NAME } else { "NinjaOne RMM" }

    Write-Host "[Info] Using ApplicationId: $($ApplicationId -replace '\s+','.')"

    if ($env:title -and $env:title -notlike "null") { $Title = $env:title }
    if ($env:message -and $env:message -notlike "null") { $Message = $env:message }
    if ($env:applicationId -and $env:applicationId -notlike "null") { $ApplicationId = $env:applicationId }
    if ($env:pathToImageFile -and $env:pathToImageFile -notlike "null") { $PathToImageFile = $env:pathToImageFile }
    if ($env:snoozeTimeOptionsInMinutes -and $env:snoozeTimeOptionsInMinutes -notlike "null") { $SnoozeTimeOptionsInMinutes = $env:snoozeTimeOptionsInMinutes }

    if ([String]::IsNullOrWhiteSpace($Title)) {
        Write-Host "[Error] A Title is required."
        exit 1
    }
    if ([String]::IsNullOrWhiteSpace($Message)) {
        Write-Host "[Error] A Message is required."
        exit 1
    }

    if ($Title.Length -gt 82) {
        Write-Host "[Warn] The Title is longer than 82 characters. The title will be truncated by the Windows API to 82 characters."
    }
    if ($Message.Length -gt 160) {
        Write-Host "[Warn] The Message is longer than 160 characters. The message might get truncated by the Windows API."
    }

    function Test-IsSystem {
        $id = [System.Security.Principal.WindowsIdentity]::GetCurrent()
        return $id.Name -like "NT AUTHORITY*" -or $id.IsSystem
    }

    if (Test-IsSystem) {
        Write-Host "[Error] Please run this script as 'Current Logged on User'."
        Exit 1
    }

    function Set-RegKey {
        param (
            $Path,
            $Name,
            $Value,
            [ValidateSet("DWord", "QWord", "String", "ExpandedString", "Binary", "MultiString", "Unknown")]
            $PropertyType = "DWord"
        )
        if (-not $(Test-Path -Path $Path)) {
            # Check if path does not exist and create the path
            New-Item -Path $Path -Force | Out-Null
        }
        if ((Get-ItemProperty -Path $Path -Name $Name -ErrorAction Ignore)) {
            # Update property and print out what it was changed from and changed to
            $CurrentValue = (Get-ItemProperty -Path $Path -Name $Name -ErrorAction Ignore).$Name
            try {
                Set-ItemProperty -Path $Path -Name $Name -Value $Value -Force -Confirm:$false -ErrorAction Stop | Out-Null
            }
            catch {
                Write-Host "[Error] Unable to Set registry key for $Name please see below error!"
                Write-Host $_.Exception.Message
                exit 1
            }
            Write-Host "[Info] $Path\$Name changed from:"
            Write-Host " $CurrentValue to:"
            Write-Host " $($(Get-ItemProperty -Path $Path -Name $Name -ErrorAction Ignore).$Name)"
        }
        else {
            # Create property with value
            try {
                New-ItemProperty -Path $Path -Name $Name -Value $Value -PropertyType $PropertyType -Force -Confirm:$false -ErrorAction Stop | Out-Null
            }
            catch {
                Write-Host "[Error] Unable to Set registry key for $Name please see below error!"
                Write-Host $_.Exception.Message
                exit 1
            }
            Write-Host "[Info] Set $Path\$Name to:"
            Write-Host " $($(Get-ItemProperty -Path $Path -Name $Name -ErrorAction Ignore).$Name)"
        }
    }

    function ConvertFrom-Base64 {
        param(
            $Base64,
            $Path
        )
        $bytes = [Convert]::FromBase64String($Base64)

        $ErrorActionPreference = [System.Management.Automation.ActionPreference]::SilentlyContinue
        [IO.File]::WriteAllBytes($Path, $bytes)
        $ErrorActionPreference = [System.Management.Automation.ActionPreference]::Continue
    }

    # Utility function for downloading files.
    function Invoke-Download {
        param(
            [Parameter()]
            [String]$URL,
            [Parameter()]
            [String]$Path,
            [Parameter()]
            [int]$Attempts = 3,
            [Parameter()]
            [Switch]$SkipSleep
        )
        Write-Host "[Info] Used $PathToImageFile for the image and saving to $ImagePath"

        $SupportedTLSversions = [enum]::GetValues('Net.SecurityProtocolType')
        if ( ($SupportedTLSversions -contains 'Tls13') -and ($SupportedTLSversions -contains 'Tls12') ) {
            [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol::Tls13 -bor [System.Net.SecurityProtocolType]::Tls12
        }
        elseif ( $SupportedTLSversions -contains 'Tls12' ) {
            [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
        }
        else {
            # Not everything requires TLS 1.2, but we'll try anyways.
            Write-Host "[Warn] TLS 1.2 and or TLS 1.3 isn't supported on this system. This download may fail!"
            if ($PSVersionTable.PSVersion.Major -lt 3) {
                Write-Host "[Warn] PowerShell 2 / .NET 2.0 doesn't support TLS 1.2."
            }
        }

        $i = 1
        While ($i -le $Attempts) {
            # Some cloud services have rate-limiting
            if (-not ($SkipSleep)) {
                $SleepTime = Get-Random -Minimum 1 -Maximum 7
                Write-Host "[Info] Waiting for $SleepTime seconds."
                Start-Sleep -Seconds $SleepTime
            }
            if ($i -ne 1) { Write-Host "" }
            Write-Host "[Info] Download Attempt $i"

            $PreviousProgressPreference = $ProgressPreference
            $ProgressPreference = 'SilentlyContinue'
            try {
                # Invoke-WebRequest is preferred because it supports links that redirect, e.g., https://t.ly
                # Standard options
                $WebRequestArgs = @{
                    Uri                = $URL
                    MaximumRedirection = 10
                    UseBasicParsing    = $true
                    OutFile            = $Path
                }

                # Download The File
                Invoke-WebRequest @WebRequestArgs

                $ProgressPreference = $PreviousProgressPreference
                $File = Test-Path -Path $Path -ErrorAction SilentlyContinue
            }
            catch {
                Write-Host "[Error] An error has occurred while downloading!"
                Write-Warning $_.Exception.Message

                if (Test-Path -Path $Path -ErrorAction SilentlyContinue) {
                    Remove-Item $Path -Force -Confirm:$false -ErrorAction SilentlyContinue
                }

                $File = $False
            }

            if ($File) {
                $i = $Attempts
            }
            else {
                Write-Host "[Error] File failed to download."
                Write-Host ""
            }

            $i++
        }

        if (-not (Test-Path $Path)) {
            Write-Host "[Error] Failed to download file!"
            exit 1
        }
        else {
            return $Path
        }
    }

    function Show-Notification {
        [CmdletBinding()]
        Param (
            [string]
            $ApplicationId,
            [string]
            $ToastTitle,
            [string]
            [Parameter(ValueFromPipeline)]
            $ToastText,
            [string]
            $SnoozeOptions
        )

        # Import all the needed libraries
        [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
        [Windows.UI.Notifications.ToastNotification, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
        [Windows.System.User, Windows.System, ContentType = WindowsRuntime] > $null
        [Windows.System.UserType, Windows.System, ContentType = WindowsRuntime] > $null
        [Windows.System.UserAuthenticationStatus, Windows.System, ContentType = WindowsRuntime] > $null
        [Windows.Storage.ApplicationData, Windows.Storage, ContentType = WindowsRuntime] > $null

        # Make sure that we can use the toast manager, also checks if the service is running and responding
        try {
            $ToastNotifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("$ApplicationId")
        }
        catch {
            Write-Host $_.Exception.Message
            Write-Host "[Error] Failed to create notification."
        }

        # Create the xml for the snooze options
        $IsFirst = $true
        if ($($SnoozeOptions -split ',').Count -gt 5) {
            Write-Host "[Error] Too many snooze options provided. Maximum is 5."
            exit 1
        }
        $SnoozeXml = $SnoozeOptions -split ',' | ForEach-Object {
            # Trim whitespace
            $Option = "$_".Trim()
            if ($IsFirst) {
                # Add the input and default selection
                "<input id='snoozeTime' type='selection' defaultInput='$Option'>"
                $IsFirst = $false
            }
            # Check if the option is a number
            if ([int]::TryParse($Option, [ref]$null)) {
                # Convert the number to an integer
                $Option = [int]$Option
            }
            else {
                # If not a number, exit with an error
                Write-Host "[Error] Invalid snooze time option '$Option' provided."
                exit 1
            }
            # Add the selection
            if ($Option -ge 60) {
                # Get the number of hours and minutes
                # Round the number of hours to the nearest hour
                $Hours = [math]::Round($Option / 60, 0)
                # Get the number of minutes
                $MinutesMod = $Option % 60
                # Format the number of minutes
                $Minutes = if ($MinutesMod -eq 0) {
                    # If the number of minutes is 0, don't display anything
                    ""
                }
                elseif ($MinutesMod -gt 1) {
                    # If the number of minutes is greater than 1, format it as ' 2 Minutes'
                    " $MinutesMod Minutes"
                }
                elseif ($MinutesMod -eq 1) {
                    # If the number of minutes is 1, format it as ' 1 Minute'
                    " $MinutesMod Minute"
                }
                # Format the number of hours
                $Unit = if ($Hours -gt 1) { 'Hours' }else { 'Hour' }
                "<selection id='$($Option)' content='$($Hours) $($Unit)$($Minutes)'/>"
            }
            elseif ($Option -lt 60) {
                # Format the number of minutes when it's less than 60 minutes
                $Minutes = $Option
                $Unit = if ($Minutes -gt 1) { 'Minutes' }else { 'Minute' }
                "<selection id='$($Option)' content='$($Minutes) $($Unit)'/>"
            }
        }

        # Create a new toast notification
        $RawXml = [xml] @"
<toast>
    <visual>
        <binding template='ToastGeneric'>
            <image placement='appLogoOverride' src='$ImagePath'/>
            <text id='1'>$ToastTitle</text>
            <text id='2'>$ToastText</text>
        </binding>
    </visual>
    <actions>
        $SnoozeXml
        </input>
        <action activationType="system" arguments="snooze" hint-inputId="snoozeTime" content="" />
        <action activationType="system" arguments="dismiss" content=""/>
    </actions>
</toast>
"@

        # Serialized Xml for later consumption
        $SerializedXml = New-Object Windows.Data.Xml.Dom.XmlDocument
        $SerializedXml.LoadXml($RawXml.OuterXml)

        # Setup how are toast will act, such as expiration time
        $Toast = $null
        $Toast = [Windows.UI.Notifications.ToastNotification]::new($SerializedXml)
        $Toast.Tag = "PowerShell"
        $Toast.Group = "PowerShell"

        # Show our message to the user
        $ToastNotifier.Show($Toast)
    }
}
process {
    Write-Host "ApplicationID: $ApplicationId"

    if (-not $(Split-Path -Path $ImagePath -Parent | Test-Path -ErrorAction SilentlyContinue)) {
        try {
            New-Item "$(Split-Path -Path $ImagePath -Parent)" -ItemType Directory -ErrorAction Stop
            Write-Host "[Info] Created folder: $(Split-Path -Path $ImagePath -Parent)"
        }
        catch {
            Write-Host "[Error] Failed to create folder: $(Split-Path -Path $ImagePath -Parent)"
            exit 1
        }
    }

    $DownloadArguments = @{
        URL  = $PathToImageFile
        Path = $ImagePath
    }

    Set-RegKey -Path "HKCU:\SOFTWARE\Classes\AppUserModelId\$($ApplicationId -replace '\s+','.')" -Name "DisplayName" -Value $ApplicationId -PropertyType String
    if ($PathToImageFile -like "http*") {
        Invoke-Download @DownloadArguments
    }
    elseif ($PathToImageFile -match "^[a-zA-Z]:\\" -and $(Test-Path -Path $PathToImageFile -ErrorAction SilentlyContinue)) {
        Write-Host "[Info] Image is a local file, copying to $ImagePath"
        Copy-Item -Path $PathToImageFile -Destination $ImagePath
    }
    elseif ($PathToImageFile -match "^[a-zA-Z]:\\" -and -not $(Test-Path -Path $PathToImageFile -ErrorAction SilentlyContinue)) {
        Write-Host "[Error] Image does not exist at $PathToImageFile"
        exit 1
    }
    else {
        Write-Host "[Info] No image given, converting base64 on line 43 and saving to $ImagePath."
        Write-Host "[Info] Image will be used for the toast message."
        ConvertFrom-Base64 -Base64 $Base64 -Path $ImagePath
    }

    Set-RegKey -Path "HKCU:\SOFTWARE\Classes\AppUserModelId\$($ApplicationId -replace '\s+','.')" -Name "IconUri" -Value "$ImagePath" -PropertyType String

    Write-Host "[Info] System is ready to send Toast Messages to the currently logged on user."

    try {
        Write-Host "[Info] Attempting to send message to user..."
        $NotificationParams = @{
            ToastTitle    = $Title
            ToastText     = $Message
            ApplicationId = "$($ApplicationId -replace '\s+','.')"
            SnoozeOptions = $SnoozeTimeOptionsInMinutes
        }
        Show-Notification @NotificationParams -ErrorAction Stop
        Write-Host "[Info] Message sent to user."
    }
    catch {
        Write-Host "[Error] Failed to send message to user."
        Write-Host $_.Exception.Message
        exit 1
    }
    exit 0
}
end {
    
    
    
}

 

Save time with over 300+ scripts from the NinjaOne Dojo.

Get access today.

Detailed Breakdown

The provided PowerShell script includes several key components designed for flexibility and functionality. Here’s a step-by-step breakdown of how it works:

1. Initialization and Parameters

  • The script accepts parameters for notification title, message, application ID, snooze options, and an optional image file path.
  • It validates input and sets defaults where parameters are not provided. For example, the default ApplicationId is “NinjaOne RMM,” ensuring identification consistency in the Windows notification center.

2. User Validation

  • The script checks if it’s being executed as the “Current Logged-on User.” This ensures that notifications reach the intended recipient rather than running in an elevated system context where toast notifications aren’t supported.

3. Registry Configuration

  • Using the Set-RegKey function, the script creates or updates registry entries to register the toast application ID and associate an icon with the notification.

4. Image Handling

Notifications can include a custom image. The script supports three scenarios:

  • A URL to download an image.
  • A local file path to copy.
  • A default base64-encoded NinjaOne logo, converted and saved as an image file if no custom image is provided.

5. Notification XML Generation

  • The script generates XML code defining the toast message structure, including title, text, image, and snooze options. The Show-Notification function uses the Windows Runtime API to display the toast notification.

6. Snooze and Dismiss Functionality

  • Users can snooze notifications for pre-configured durations or dismiss them entirely. These options are defined in the XML and enable user interaction directly from the notification.

Potential Use Cases

Case Study: System Update Notifications

Imagine an IT administrator in a corporate environment planning to deploy a critical system update overnight. To ensure users are informed, they use this script to send a toast notification titled “System Maintenance Notification” with the message “Your system will reboot at 2:00 AM for updates. Click to snooze or dismiss.”

  • Customization: The notification includes the organization’s logo.
  • Snooze Options: Users can delay the reminder by 15, 30, or 60 minutes.
  • Outcome: The team ensures users are well-informed without manual follow-ups.

Comparisons

Alternative Methods:

  1. Third-Party Notification Tools: While robust, they may lack integration capabilities and incur costs.
  2. Built-In Windows Group Policy Notifications: Limited customization and no snooze functionality.

Compared to these methods, this PowerShell script offers:

  • Greater customization with image support.
  • Enhanced user experience with snooze/dismiss options.
  • Cost-efficiency, leveraging native Windows APIs.

FAQs

Q: Can the script run as an administrator?

No, it must be run as the currently logged-on user to display notifications properly.

Q: What happens if no image is provided?

A default NinjaOne logo is used, ensuring every notification is branded.

Q: How are snooze options configured?

Snooze times are defined as a comma-separated list (e.g., “15,30,60” for 15, 30, and 60 minutes).

Q: Is this compatible with Windows IoT editions?

No, due to the lack of a shell in IoT editions.

Implications

Using this script, IT teams can standardize notification delivery, ensuring messages are actionable and relevant. In larger environments, this improves user awareness and reduces downtime caused by missed alerts. Additionally, branding options help MSPs reinforce their identity while maintaining professionalism.

Recommendations

  • Test in a Non-Production Environment: Validate the script’s behavior in a controlled setting before deploying widely.
  • Customize Application IDs: Use organization-specific identifiers for better management and visibility.
  • Monitor User Feedback: Gather input to refine snooze options and notification content for maximum user engagement.

Final Thoughts

This script exemplifies how NinjaOne and similar IT management solutions can empower IT professionals. By combining simplicity with advanced customization, the script enables efficient communication and enhances the user experience. Whether you’re an MSP or an in-house IT team, leveraging tools like this aligns with the best practices for IT operations management.

For more tailored solutions, NinjaOne provides a suite of tools designed to streamline IT workflows and optimize system reliability.

]]>
Creating a PowerShell Popup Message with an Image for Windows Notifications  https://www.ninjaone.com/script-hub/powershell-popup-messages-with-images/ Fri, 06 Dec 2024 21:32:09 +0000 https://www.ninjaone.com/?post_type=script_hub&p=386945 Effective communication with users is critical for IT professionals, especially in managed service provider (MSP) environments. One practical way to interact with end-users is through system notifications. This PowerShell script enables IT administrators to send toast messages with optional hero images, offering a user-friendly method for delivering important updates, reminders, or alerts. Here’s an in-depth look at this script, its functionality, and its potential applications.

Background and Importance

System notifications serve as a direct line of communication between IT administrators and users, especially in environments where email or chat notifications might be missed or ignored. This script stands out because it not only delivers customizable toast messages but also allows for the inclusion of hero images, making notifications visually engaging.

MSPs and IT departments often use tools like this to enhance user communication, whether it’s to announce scheduled maintenance, alert users of critical issues, or provide step-by-step guidance. By leveraging the native Windows 10 notification system, this script integrates seamlessly into the user’s workflow without requiring additional software installations.

The Script:

#Requires -Version 5.1

<#
.SYNOPSIS
    Sends a toast message/notification with a hero image to the currently signed in user. Please run as the Current Logged-on User. The script defaults to not using an image if none is provided.
.DESCRIPTION
    Sends a toast message/notification with a hero image to the currently signed in user. Please run as 'Current Logged on User'.
    This defaults to no image in the Toast Message, but you can specify any png formatted image from a url.
    You can also specify the "ApplicationId" to any string.
    The default ApplicationId is your company name found in the NINJA_COMPANY_NAME environment variable, but will fallback to "NinjaOne RMM" if it happens to not be set.

    The URL image should be less than 2MB in size or less than 1MB on a metered connection.

    By using this script, you indicate your acceptance of the following legal terms as well as our Terms of Use at https://www.ninjaone.com/terms-of-use.
    Ownership Rights: NinjaOne owns and will continue to own all right, title, and interest in and to the script (including the copyright). NinjaOne is giving you a limited license to use the script in accordance with these legal terms. 
    Use Limitation: You may only use the script for your legitimate personal or internal business purposes, and you may not share the script with another party. 
    Republication Prohibition: Under no circumstances are you permitted to re-publish the script in any script library or website belonging to or under the control of any other software provider. 
    Warranty Disclaimer: The script is provided “as is” and “as available”, without warranty of any kind. NinjaOne makes no promise or guarantee that the script will be free from defects or that it will meet your specific needs or expectations. 
    Assumption of Risk: Your use of the script is at your own risk. You acknowledge that there are certain inherent risks in using the script, and you understand and assume each of those risks. 
    Waiver and Release: You will not hold NinjaOne responsible for any adverse or unintended consequences resulting from your use of the script, and you waive any legal or equitable rights or remedies you may have against NinjaOne relating to your use of the script. 
    EULA: If you are a NinjaOne customer, your use of the script is subject to the End User License Agreement applicable to you (EULA).

.EXAMPLE
    -Title "My Title Here" -Message "My Message Here"
    Sends the title "My Title Here" and message "My Message Here" as a Toast message/notification to the currently signed in user.
.EXAMPLE
    -Title "My Title Here" -Message "My Message Here" -ApplicationId "MyCompany"
    Sends the title "My Title Here" and message "My Message Here" as a Toast message/notification to the currently signed in user.
        ApplicationId: Creates a registry entry for your toasts called "MyCompany".
        PathToImageFile: Downloads a png image for the icon in the toast message/notification.
.OUTPUTS
    None
.NOTES
    If you want to change the defaults then with in the param block.
    ImagePath uses C:\Users\Public\ as that is accessible by all users.
    If you want to customize the application name to show your company name,
        then look for $ApplicationId and change the content between the double quotes.

    Minimum OS Architecture Supported: Windows 10 (IoT editions are not supported due to lack of shell)
    Release Notes: Initial Release
#>

[CmdletBinding()]
param
(
    [string]$Title,
    [string]$Message,
    [string]$ApplicationId,
    [string]$PathToImageFile
)

begin {
    [string]$ImagePath = "$($env:SystemDrive)\Users\Public\PowerShellToastHeroImage.png"

    # Set the default ApplicationId if it's not provided. Use the Company Name if available, otherwise use the default.
    $ApplicationId = if ($env:NINJA_COMPANY_NAME) { $env:NINJA_COMPANY_NAME } else { "NinjaOne RMM" }

    Write-Host "[Info] Using ApplicationId: $($ApplicationId -replace '\s+','.')"

    if ($env:title -and $env:title -notlike "null") { $Title = $env:title }
    if ($env:message -and $env:message -notlike "null") { $Message = $env:message }
    if ($env:applicationId -and $env:applicationId -notlike "null") { $ApplicationId = $env:applicationId }
    if ($env:pathToImageFile -and $env:pathToImageFile -notlike "null") { $PathToImageFile = $env:pathToImageFile }

    if ([String]::IsNullOrWhiteSpace($Title)) {
        Write-Host "[Error] A Title is required."
        exit 1
    }
    if ([String]::IsNullOrWhiteSpace($Message)) {
        Write-Host "[Error] A Message is required."
        exit 1
    }

    if ($Title.Length -gt 64) {
        Write-Host "[Warn] The Title is longer than 64 characters. The title will be truncated by the Windows API to 64 characters."
    }
    if ($Message.Length -gt 200) {
        Write-Host "[Warn] The Message is longer than 200 characters. The message might get truncated by the Windows API."
    }

    function Test-IsSystem {
        $id = [System.Security.Principal.WindowsIdentity]::GetCurrent()
        return $id.Name -like "NT AUTHORITY*" -or $id.IsSystem
    }

    if (Test-IsSystem) {
        Write-Host "[Error] Please run this script as 'Current Logged on User'."
        Exit 1
    }

    function Set-RegKey {
        param (
            $Path,
            $Name,
            $Value,
            [ValidateSet("DWord", "QWord", "String", "ExpandedString", "Binary", "MultiString", "Unknown")]
            $PropertyType = "DWord"
        )
        if (-not $(Test-Path -Path $Path)) {
            # Check if path does not exist and create the path
            New-Item -Path $Path -Force | Out-Null
        }
        if ((Get-ItemProperty -Path $Path -Name $Name -ErrorAction Ignore)) {
            # Update property and print out what it was changed from and changed to
            $CurrentValue = (Get-ItemProperty -Path $Path -Name $Name -ErrorAction Ignore).$Name
            try {
                Set-ItemProperty -Path $Path -Name $Name -Value $Value -Force -Confirm:$false -ErrorAction Stop | Out-Null
            }
            catch {
                Write-Host "[Error] Unable to Set registry key for $Name please see below error!"
                Write-Host "$($_.Exception.Message)"
                exit 1
            }
            Write-Host "[Info] $Path\$Name changed from:"
            Write-Host " $CurrentValue to:"
            Write-Host " $($(Get-ItemProperty -Path $Path -Name $Name -ErrorAction Ignore).$Name)"
        }
        else {
            # Create property with value
            try {
                New-ItemProperty -Path $Path -Name $Name -Value $Value -PropertyType $PropertyType -Force -Confirm:$false -ErrorAction Stop | Out-Null
            }
            catch {
                Write-Host "[Error] Unable to Set registry key for $Name please see below error!"
                Write-Host "$($_.Exception.Message)"
                exit 1
            }
            Write-Host "[Info] Set $Path\$Name to:"
            Write-Host " $($(Get-ItemProperty -Path $Path -Name $Name -ErrorAction Ignore).$Name)"
        }
    }

    # Utility function for downloading files.
    function Invoke-Download {
        param(
            [Parameter()]
            [String]$URL,
            [Parameter()]
            [String]$Path,
            [Parameter()]
            [int]$Attempts = 3,
            [Parameter()]
            [Switch]$SkipSleep
        )
        Write-Host "[Info] Used $PathToImageFile for the image and saving to $ImagePath"
    
        $SupportedTLSversions = [enum]::GetValues('Net.SecurityProtocolType')
        if ( ($SupportedTLSversions -contains 'Tls13') -and ($SupportedTLSversions -contains 'Tls12') ) {
            [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol::Tls13 -bor [System.Net.SecurityProtocolType]::Tls12
        }
        elseif ( $SupportedTLSversions -contains 'Tls12' ) {
            [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
        }
        else {
            # Not everything requires TLS 1.2, but we'll try anyways.
            Write-Host "[Warn] TLS 1.2 and or TLS 1.3 isn't supported on this system. This download may fail!"
            if ($PSVersionTable.PSVersion.Major -lt 3) {
                Write-Host "[Warn] PowerShell 2 / .NET 2.0 doesn't support TLS 1.2."
            }
        }
    
        $i = 1
        While ($i -le $Attempts) {
            # Some cloud services have rate-limiting
            if (-not ($SkipSleep)) {
                $SleepTime = Get-Random -Minimum 1 -Maximum 7
                Write-Host "[Info] Waiting for $SleepTime seconds."
                Start-Sleep -Seconds $SleepTime
            }
            if ($i -ne 1) { Write-Host "" }
            Write-Host "[Info] Download Attempt $i"
    
            $PreviousProgressPreference = $ProgressPreference
            $ProgressPreference = 'SilentlyContinue'
            try {
                # Invoke-WebRequest is preferred because it supports links that redirect, e.g., https://t.ly
                # Standard options
                $WebRequestArgs = @{
                    Uri                = $URL
                    MaximumRedirection = 10
                    UseBasicParsing    = $true
                    OutFile            = $Path
                }
    
                # Download The File
                Invoke-WebRequest @WebRequestArgs
    
                $ProgressPreference = $PreviousProgressPreference
                $File = Test-Path -Path $Path -ErrorAction SilentlyContinue
            }
            catch {
                Write-Host "[Error] An error has occurred while downloading!"
                Write-Warning $_.Exception.Message
    
                if (Test-Path -Path $Path -ErrorAction SilentlyContinue) {
                    Remove-Item $Path -Force -Confirm:$false -ErrorAction SilentlyContinue
                }
    
                $File = $False
            }
    
            if ($File) {
                $i = $Attempts
            }
            else {
                Write-Host "[Error] File failed to download."
                Write-Host ""
            }
    
            $i++
        }
    
        if (-not (Test-Path $Path)) {
            Write-Host "[Error] Failed to download file!"
            exit 1
        }
        else {
            return $Path
        }
    }

    function Show-Notification {
        [CmdletBinding()]
        Param (
            [string]
            $ApplicationId,
            [string]
            $ToastTitle,
            [string]
            [Parameter(ValueFromPipeline)]
            $ToastText
        )

        # Import all the needed libraries
        [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
        [Windows.UI.Notifications.ToastNotification, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
        [Windows.System.User, Windows.System, ContentType = WindowsRuntime] > $null
        [Windows.System.UserType, Windows.System, ContentType = WindowsRuntime] > $null
        [Windows.System.UserAuthenticationStatus, Windows.System, ContentType = WindowsRuntime] > $null
        [Windows.Storage.ApplicationData, Windows.Storage, ContentType = WindowsRuntime] > $null

        # Make sure that we can use the toast manager, also checks if the service is running and responding
        try {
            $ToastNotifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("$ApplicationId")
        }
        catch {
            Write-Host "$($_.Exception.Message)"
            Write-Host "[Error] Failed to create notification."
        }

        # Create a new toast notification
        $RawXml = [xml] @"
<toast>
    <visual>
    <binding template='ToastGeneric'>
        <text id='1'>$ToastTitle</text>
        <text id='2'>$ToastText</text>
        $(if($PathToImageFile){"<image placement='hero' src='$ImagePath' />"})
    </binding>
    </visual>
</toast>
"@

        # Serialized Xml for later consumption
        $SerializedXml = New-Object Windows.Data.Xml.Dom.XmlDocument
        $SerializedXml.LoadXml($RawXml.OuterXml)

        # Setup how are toast will act, such as expiration time
        $Toast = $null
        $Toast = [Windows.UI.Notifications.ToastNotification]::new($SerializedXml)
        $Toast.Tag = "PowerShell"
        $Toast.Group = "PowerShell"
        $Toast.ExpirationTime = [DateTimeOffset]::Now.AddMinutes(1)

        # Show our message to the user
        $ToastNotifier.Show($Toast)
    }
}
process {
    Write-Host "ApplicationID: $ApplicationId"

    if (-not $(Split-Path -Path $ImagePath -Parent | Test-Path -ErrorAction SilentlyContinue)) {
        try {
            New-Item "$(Split-Path -Path $ImagePath -Parent)" -ItemType Directory -ErrorAction Stop
            Write-Host "[Info] Created folder: $(Split-Path -Path $ImagePath -Parent)"
        }
        catch {
            Write-Host "[Error] Failed to create folder: $(Split-Path -Path $ImagePath -Parent)"
            exit 1
        }
    }

    $DownloadArguments = @{
        URL  = $PathToImageFile
        Path = $ImagePath
    }

    Set-RegKey -Path "HKCU:\SOFTWARE\Classes\AppUserModelId\$($ApplicationId -replace '\s+','.')" -Name "DisplayName" -Value $ApplicationId -PropertyType String
    if ($PathToImageFile -like "http*") {
        Invoke-Download @DownloadArguments
    }
    elseif ($PathToImageFile -match "^[a-zA-Z]:\\" -and $(Test-Path -Path $PathToImageFile -ErrorAction SilentlyContinue)) {
        Write-Host "[Info] Image is a local file, copying to $ImagePath"
        try {
            Copy-Item -Path $PathToImageFile -Destination $ImagePath -Force -ErrorAction Stop
            Set-RegKey -Path "HKCU:\SOFTWARE\Classes\AppUserModelId\$($ApplicationId -replace '\s+','.')" -Name "IconUri" -Value "$ImagePath" -PropertyType String
            Write-Host "[Info] System is ready to send Toast Messages to the currently logged on user."
        }
        catch {
            Write-Host "[Error] Failed to copy image file: $PathToImageFile"
            exit 1
        }
    }
    elseif ($PathToImageFile -match "^[a-zA-Z]:\\" -and -not $(Test-Path -Path $PathToImageFile -ErrorAction SilentlyContinue)) {
        Write-Host "[Error] Image does not exist at $PathToImageFile"
        exit 1
    }
    else {
        if ($PathToImageFile) {
            Write-Host "[Warn] Provided image is not a local file or a valid URL."
        }
        Write-Host "[Info] No image will be used."
    }

    try {
        Write-Host "[Info] Attempting to send message to user..."
        $NotificationParams = @{
            ToastTitle    = $Title
            ToastText     = $Message
            ApplicationId = "$($ApplicationId -replace '\s+','.')"
        }
        Show-Notification @NotificationParams -ErrorAction Stop
        Write-Host "[Info] Message sent to user."
    }
    catch {
        Write-Host "[Error] Failed to send message to user."
        Write-Host "$($_.Exception.Message)"
        exit 1
    }
    exit 0
}
end {
    
    
    
}

 

Save time with over 300+ scripts from the NinjaOne Dojo.

Get access today.

Detailed Breakdown of the Script

This PowerShell script is designed to be run by the currently logged-on user. Here’s a step-by-step explanation of its workflow:

1.Parameters and Default Settings

a.The script accepts parameters for the title, message, application ID, and an optional path to an image file.

b. If the application ID is not specified, the script defaults to the company name stored in the NINJA_COMPANY_NAME environment variable or falls back to “NinjaOne RMM.”

c. If an image is not provided, the notification will display without one.

2. Environment Validation

a. The script checks if it is being run as the current logged-on user. If not, it exits with an error.

3. Registry Management

a. The script uses a helper function to create or update registry keys for the application’s display name and optional icon. This ensures proper attribution and branding for the notifications.

4. Image Handling

a. The script can download an image from a URL or use a local file. It ensures the image is saved in a public folder for accessibility.

b. If no valid image is provided, the script proceeds without one.

5. Notification Creation

a. Using Windows APIs, the script constructs and displays a toast notification.

b. The notification includes the specified title and message and incorporates the image if provided.

6. Error Handling

a. The script features robust error handling, including warnings for unsupported configurations, download failures, and missing parameters.

Potential Use Cases

Case Study: IT Maintenance Announcement

An IT administrator managing a corporate network needs to notify users about scheduled maintenance. Using this script, the admin sends a toast notification with the following details:

  • Title: “Scheduled Maintenance at 10 PM”
  • Message: “Please save your work. Systems will be down for two hours starting at 10 PM.”
  • Image: A corporate logo hosted on a public URL.

This approach ensures users receive a clear and branded message directly on their desktop.

Comparisons to Other Methods

Built-in Notification Tools

While Windows has built-in notification features, they often require complex setup or third-party software. This script simplifies the process and provides more customization.

Custom Software Solutions

Custom notification software may offer similar features but at a cost. This script provides a lightweight, cost-effective alternative tailored for PowerShell-savvy professionals.

Frequently Asked Questions

  1. Can I use this script on Windows 7 or older versions?
    No, the script is designed for Windows 10 and higher due to the dependency on modern Windows APIs.
  2. What image formats are supported?
    The script supports .png files. Ensure the file size is under 2MB for optimal performance.
  3. Is it possible to automate this script for multiple users?
    Yes, you can integrate this script into a larger automation framework, but it must run in the context of each logged-on user.

Implications of Using This Script

Deploying notifications with images improves user engagement, ensuring that critical information is seen. However, administrators must exercise caution to avoid overuse, which could lead to notification fatigue. Additionally, this script highlights the importance of secure and efficient communication channels in IT operations.

Best Practices When Using This Script

  1. Customize Notifications
    Tailor the title and message to ensure relevance and clarity for the end-user.
  2. Use Compressed Images
    Optimize images to reduce file size and ensure quick loading, especially on metered connections.
  3. Test in a Controlled Environment
    Before deploying widely, test the script to ensure compatibility and effectiveness.
  4. Maintain a Clean Registry
    Regularly review registry entries created by the script to prevent clutter.

Final Thoughts

PowerShell remains a versatile tool for IT professionals, and this script exemplifies its potential in streamlining user communication. NinjaOne complements tools like this by offering a robust suite of IT management solutions, enabling MSPs and IT departments to deliver seamless, efficient support. Whether you’re an IT administrator or an MSP, integrating such scripts into your workflow can significantly enhance your operational efficiency and user satisfaction.

]]>
Checking and Managing Exchange Server Versions with PowerShell  https://www.ninjaone.com/script-hub/powershell-check-exchange-version/ Thu, 28 Nov 2024 04:37:21 +0000 https://www.ninjaone.com/?post_type=script_hub&p=386652 Introduction

For IT administrators and Managed Service Providers (MSPs), managing Exchange Server environments effectively is critical. Staying on top of server versions ensures compatibility, performance, and security within the enterprise ecosystem. This PowerShell script offers a streamlined way to check Exchange Server versions, validate their compliance against supported versions, and take actionable steps to maintain an up-to-date environment.

Background

Microsoft Exchange remains a cornerstone in enterprise communication systems. However, outdated versions can lead to security vulnerabilities, compatibility issues, and performance degradation. Identifying the exact version of Exchange installed across environments can be cumbersome, especially in large-scale deployments.

This script automates that process, enabling administrators to check installed Exchange versions against specified benchmarks for versions 2010, 2013, 2016, and 2019. Additionally, it provides flexibility for managing custom fields, enabling seamless integration with management tools like NinjaOne.

The Script:

<#
.SYNOPSIS
    Checks the version of Exchange installed and if it is outdated against the supplied versions.
.DESCRIPTION
    Checks the version of Exchange installed and if it is outdated against the supplied versions.
    The version of Exchange that can be checked against are the following versions: 2010, 2013, 2016, and 2019.
    
    If the version is outdated, a warning is displayed.
    The script will not alert if the version is up to date.

    CheckboxCustomField can be used to skip the check, and the custom field must be a checkbox or a text field with a value of "true" or "1".

    CustomFieldName can be used to save the version to a text custom field.

    Exchange 2010 starts with 14.3, 2013 starts with 15.0, 2016 starts with 15.1, and 2019 starts with 15.2.

    By using this script, you indicate your acceptance of the following legal terms as well as our Terms of Use at https://www.ninjaone.com/terms-of-use.
    Ownership Rights: NinjaOne owns and will continue to own all right, title, and interest in and to the script (including the copyright). NinjaOne is giving you a limited license to use the script in accordance with these legal terms. 
    Use Limitation: You may only use the script for your legitimate personal or internal business purposes, and you may not share the script with another party. 
    Republication Prohibition: Under no circumstances are you permitted to re-publish the script in any script library or website belonging to or under the control of any other software provider. 
    Warranty Disclaimer: The script is provided “as is” and “as available”, without warranty of any kind. NinjaOne makes no promise or guarantee that the script will be free from defects or that it will meet your specific needs or expectations. 
    Assumption of Risk: Your use of the script is at your own risk. You acknowledge that there are certain inherent risks in using the script, and you understand and assume each of those risks. 
    Waiver and Release: You will not hold NinjaOne responsible for any adverse or unintended consequences resulting from your use of the script, and you waive any legal or equitable rights or remedies you may have against NinjaOne relating to your use of the script. 
    EULA: If you are a NinjaOne customer, your use of the script is subject to the End User License Agreement applicable to you (EULA).

.EXAMPLE
    (No Parameters)
    ## EXAMPLE OUTPUT WITHOUT PARAMS ##
    [Error] Exchange 2010, 2013, 2016, and 2019 versions are required to run this script.

PARAMETER: -CustomFieldName "ExchangeVersion" -Exchange2010Version "14.3.513.0" -Exchange2013Version "15.0.1497.48" -Exchange2016Version "15.1.0.0" -Exchange2019Version "15.2.0.0"
    Saves the Exchange version to a custom field named "ExchangeVersion"
.EXAMPLE
    -CustomFieldName "ExchangeVersion" -Exchange2010Version "14.3.513.0" -Exchange2013Version "15.0.1497.48" -Exchange2016Version "15.1.0.0" -Exchange2019Version "15.2.0.0"
    ## EXAMPLE OUTPUT WITH CustomFieldName ##
    [Info] Exchange version is up to date. Found version: 15.2.0.0
    [Info] Attempting to set Custom Field 'ExchangeVersion'.
    [Info] Successfully set Custom Field 'ExchangeVersion'!

PARAMETER: -CheckboxCustomField "SkipExchangeCheck" -Exchange2010Version "14.3.513.0" -Exchange2013Version "15.0.1497.48" -Exchange2016Version "15.1.0.0" -Exchange2019Version "15.2.0.0"
    Skips the Exchange version check if the checkbox custom field "SkipExchangeCheck" is checked or if a text custom field is set to "true" or "1".
.EXAMPLE
    -CheckboxCustomField "SkipExchangeCheck" -Exchange2010Version "14.3.513.0" -Exchange2013Version "15.0.1497.48" -Exchange2016Version "15.1.0.0" -Exchange2019Version "15.2.0.0"
    ## EXAMPLE OUTPUT WITH CustomFieldName ##
    [Info] Skipping Exchange version check.

.NOTES
    Minimum OS Architecture Supported: Windows Server 2008 R2
    Release Notes: Initial Release
#>

[CmdletBinding()]
param (
    [String]$CustomFieldName,
    [String]$Exchange2010Version,
    [String]$Exchange2013Version,
    [String]$Exchange2016Version,
    [String]$Exchange2019Version,
    [String]$CheckboxCustomField
)

begin {
    function Test-IsElevated {
        $id = [System.Security.Principal.WindowsIdentity]::GetCurrent()
        $p = New-Object System.Security.Principal.WindowsPrincipal($id)
        $p.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
    }
    function Test-IsWorkstation {
        $OS = Get-WmiObject -Class Win32_OperatingSystem
        return $OS.ProductType -eq 1
    }
    function Set-NinjaProperty {
        [CmdletBinding()]
        Param(
            [Parameter(Mandatory = $True)]
            [String]$Name,
            [Parameter()]
            [String]$Type,
            [Parameter(Mandatory = $True, ValueFromPipeline = $True)]
            $Value,
            [Parameter()]
            [String]$DocumentName
        )

        $Characters = $Value | Measure-Object -Character | Select-Object -ExpandProperty Characters
        if ($Characters -ge 10000) {
            throw [System.ArgumentOutOfRangeException]::New("Character limit exceeded, value is greater than 10,000 characters.")
        }

        # If we're requested to set the field value for a Ninja document we'll specify it here.
        $DocumentationParams = @{}
        if ($DocumentName) { $DocumentationParams["DocumentName"] = $DocumentName }

        # This is a list of valid fields that can be set. If no type is given, it will be assumed that the input doesn't need to be changed.
        $ValidFields = "Attachment", "Checkbox", "Date", "Date or Date Time", "Decimal", "Dropdown", "Email", "Integer", "IP Address", "MultiLine", "MultiSelect", "Phone", "Secure", "Text", "Time", "URL", "WYSIWYG"
        if ($Type -and $ValidFields -notcontains $Type) { Write-Warning "$Type is an invalid type! Please check here for valid types. https://ninjarmm.zendesk.com/hc/en-us/articles/16973443979789-Command-Line-Interface-CLI-Supported-Fields-and-Functionality" }

        # The field below requires additional information to be set
        $NeedsOptions = "Dropdown"
        if ($DocumentName) {
            if ($NeedsOptions -contains $Type) {
                # We'll redirect the error output to the success stream to make it easier to error out if nothing was found or something else went wrong.
                $NinjaPropertyOptions = Ninja-Property-Docs-Options -AttributeName $Name @DocumentationParams 2>&1
            }
        }
        else {
            if ($NeedsOptions -contains $Type) {
                $NinjaPropertyOptions = Ninja-Property-Options -Name $Name 2>&1
            }
        }

        # If an error is received it will have an exception property, the function will exit with that error information.
        if ($NinjaPropertyOptions.Exception) { throw $NinjaPropertyOptions }

        # The below types require values not typically given in order to be set. The below code will convert whatever we're given into a format ninjarmm-cli supports.
        switch ($Type) {
            "Checkbox" {
                # While it's highly likely we were given a value like "True" or a boolean datatype it's better to be safe than sorry.
                $NinjaValue = [System.Convert]::ToBoolean($Value)
            }
            "Date or Date Time" {
                # Ninjarmm-cli expects the GUID of the option to be selected. Therefore, the given value will be matched with a GUID.
                $Date = (Get-Date $Value).ToUniversalTime()
                $TimeSpan = New-TimeSpan (Get-Date "1970-01-01 00:00:00") $Date
                $NinjaValue = $TimeSpan.TotalSeconds
            }
            "Dropdown" {
                # Ninjarmm-cli is expecting the guid of the option we're trying to select. So we'll match up the value we were given with a guid.
                $Options = $NinjaPropertyOptions -replace '=', ',' | ConvertFrom-Csv -Header "GUID", "Name"
                $Selection = $Options | Where-Object { $_.Name -eq $Value } | Select-Object -ExpandProperty GUID

                if (-not $Selection) {
                    throw [System.ArgumentOutOfRangeException]::New("Value is not present in dropdown")
                }

                $NinjaValue = $Selection
            }
            default {
                # All the other types shouldn't require additional work on the input.
                $NinjaValue = $Value
            }
        }

        # We'll need to set the field differently depending on if its a field in a Ninja Document or not.
        if ($DocumentName) {
            $CustomField = Ninja-Property-Docs-Set -AttributeName $Name -AttributeValue $NinjaValue @DocumentationParams 2>&1
        }
        else {
            $CustomField = Ninja-Property-Set -Name $Name -Value $NinjaValue 2>&1
        }

        if ($CustomField.Exception) {
            throw $CustomField
        }
    }
    function Get-NinjaProperty {
        [CmdletBinding()]
        Param(
            [Parameter(Mandatory = $True, ValueFromPipeline = $True)]
            [String]$Name,
            [Parameter()]
            [String]$Type,
            [Parameter()]
            [String]$DocumentName
        )

        # If we're requested to get the field value from a Ninja document we'll specify it here.
        $DocumentationParams = @{}
        if ($DocumentName) { $DocumentationParams["DocumentName"] = $DocumentName }

        # These two types require more information to parse.
        $NeedsOptions = "DropDown", "MultiSelect"

        # Grabbing document values requires a slightly different command.
        if ($DocumentName) {
            # Secure fields are only readable when they're a device custom field
            if ($Type -Like "Secure") { throw [System.ArgumentOutOfRangeException]::New("$Type is an invalid type! Please check here for valid types. https://ninjarmm.zendesk.com/hc/en-us/articles/16973443979789-Command-Line-Interface-CLI-Supported-Fields-and-Functionality") }

            # We'll redirect the error output to the success stream to make it easier to error out if nothing was found or something else went wrong.
            Write-Host "Retrieving value from Ninja Document..."
            $NinjaPropertyValue = Ninja-Property-Docs-Get -AttributeName $Name @DocumentationParams 2>&1

            # Certain fields require more information to parse.
            if ($NeedsOptions -contains $Type) {
                $NinjaPropertyOptions = Ninja-Property-Docs-Options -AttributeName $Name @DocumentationParams 2>&1
            }
        }
        else {
            # We'll redirect error output to the success stream to make it easier to error out if nothing was found or something else went wrong.
            $NinjaPropertyValue = Ninja-Property-Get -Name $Name 2>&1

            # Certain fields require more information to parse.
            if ($NeedsOptions -contains $Type) {
                $NinjaPropertyOptions = Ninja-Property-Options -Name $Name 2>&1
            }
        }

        # If we received some sort of error it should have an exception property and we'll exit the function with that error information.
        if ($NinjaPropertyValue.Exception) { throw $NinjaPropertyValue }
        if ($NinjaPropertyOptions.Exception) { throw $NinjaPropertyOptions }

        if (-not $NinjaPropertyValue) {
            throw [System.NullReferenceException]::New("The Custom Field '$Name' is empty!")
        }

        # This switch will compare the type given with the quoted string. If it matches, it'll parse it further; otherwise, the default option will be selected.
        switch ($Type) {
            "Attachment" {
                # Attachments come in a JSON format this will convert it into a PowerShell Object.
                $NinjaPropertyValue | ConvertFrom-Json
            }
            "Checkbox" {
                # Checkbox's come in as a string representing an integer. We'll need to cast that string into an integer and then convert it to a more traditional boolean.
                [System.Convert]::ToBoolean([int]$NinjaPropertyValue)
            }
            "Date or Date Time" {
                # In Ninja Date and Date/Time fields are in Unix Epoch time in the UTC timezone the below should convert it into local time as a DateTime object.
                $UnixTimeStamp = $NinjaPropertyValue
                $UTC = (Get-Date "1970-01-01 00:00:00").AddSeconds($UnixTimeStamp)
                $TimeZone = [TimeZoneInfo]::Local
                [TimeZoneInfo]::ConvertTimeFromUtc($UTC, $TimeZone)
            }
            "Decimal" {
                # In ninja decimals are strings that represent a decimal this will cast it into a double data type.
                [double]$NinjaPropertyValue
            }
            "Device Dropdown" {
                # Device Drop-Downs Fields come in a JSON format this will convert it into a PowerShell Object.
                $NinjaPropertyValue | ConvertFrom-Json
            }
            "Device MultiSelect" {
                # Device Multi-Select Fields come in a JSON format this will convert it into a PowerShell Object.
                $NinjaPropertyValue | ConvertFrom-Json
            }
            "Dropdown" {
                # Drop-Down custom fields come in as a comma-separated list of GUIDs; we'll compare these with all the options and return just the option values selected instead of a GUID.
                $Options = $NinjaPropertyOptions -replace '=', ',' | ConvertFrom-Csv -Header "GUID", "Name"
                $Options | Where-Object { $_.GUID -eq $NinjaPropertyValue } | Select-Object -ExpandProperty Name
            }
            "Integer" {
                # Casts the Ninja provided string into an integer.
                [int]$NinjaPropertyValue
            }
            "MultiSelect" {
                # Multi-Select custom fields come in as a comma-separated list of GUIDs we'll compare these with all the options and return just the option values selected instead of a guid.
                $Options = $NinjaPropertyOptions -replace '=', ',' | ConvertFrom-Csv -Header "GUID", "Name"
                $Selection = ($NinjaPropertyValue -split ',').trim()

                foreach ($Item in $Selection) {
                    $Options | Where-Object { $_.GUID -eq $Item } | Select-Object -ExpandProperty Name
                }
            }
            "Organization Dropdown" {
                # Turns the Ninja provided JSON into a PowerShell Object.
                $NinjaPropertyValue | ConvertFrom-Json
            }
            "Organization Location Dropdown" {
                # Turns the Ninja provided JSON into a PowerShell Object.
                $NinjaPropertyValue | ConvertFrom-Json
            }
            "Organization Location MultiSelect" {
                # Turns the Ninja provided JSON into a PowerShell Object.
                $NinjaPropertyValue | ConvertFrom-Json
            }
            "Organization MultiSelect" {
                # Turns the Ninja provided JSON into a PowerShell Object.
                $NinjaPropertyValue | ConvertFrom-Json
            }
            "Time" {
                # Time fields are given as a number of seconds starting from midnight. This will convert it into a DateTime object.
                $Seconds = $NinjaPropertyValue
                $UTC = ([TimeSpan]::FromSeconds($Seconds)).ToString("hh\:mm\:ss")
                $TimeZone = [TimeZoneInfo]::Local
                $ConvertedTime = [TimeZoneInfo]::ConvertTimeFromUtc($UTC, $TimeZone)

                Get-Date $ConvertedTime -DisplayHint Time
            }
            default {
                # If no type was given or not one that matches the above types just output what we retrieved.
                $NinjaPropertyValue
            }
        }
    }

    $ExitCode = 0
}
process {
    if (-not (Test-IsElevated)) {
        Write-Host "[Error] Access Denied. Please run with Administrator privileges."
        exit 1
    }

    # Check that the NT Version is at least 6.1 for Windows Server 2008 R2
    $WindowsVersion = $(Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion" -Name CurrentVersion).CurrentVersion
    if ($WindowsVersion -lt 6.1 -or $(Test-IsWorkstation)) {
        Write-Host "[Error] This script requires Windows Server 2008 R2 or higher."
        exit 1
    }

    # Get parameters from Script Variables
    if ($env:customFieldName -and $env:customFieldName -notlike "null") {
        $CustomFieldName = $env:customFieldName
    }
    if ($env:exchange2010Version -and $env:exchange2010Version -notlike "null") {
        $Exchange2010Version = $env:exchange2010Version
    }
    if ($env:exchange2013Version -and $env:exchange2013Version -notlike "null") {
        $Exchange2013Version = $env:exchange2013Version
    }
    if ($env:exchange2016Version -and $env:exchange2016Version -notlike "null") {
        $Exchange2016Version = $env:exchange2016Version
    }
    if ($env:exchange2019Version -and $env:exchange2019Version -notlike "null") {
        $Exchange2019Version = $env:exchange2019Version
    }
    if ($env:checkboxCustomField -and $env:checkboxCustomField -notlike "null") {
        $CheckboxCustomField = $env:checkboxCustomField
    }

    # Required Parameters
    if ($Exchange2010Version -and $Exchange2013Version -and $Exchange2016Version -and $Exchange2019Version) {
        # Check if the versions are valid
        if ([System.Version]::TryParse($Exchange2010Version, [ref]$null) -eq $false) {
            Write-Host "[Error] Exchange 2010 version is not a valid version: $Exchange2010Version"
            exit 1
        }
        if ([System.Version]::TryParse($Exchange2013Version, [ref]$null) -eq $false) {
            Write-Host "[Error] Exchange 2013 version is not a valid version: $Exchange2013Version"
            exit 1
        }
        if ([System.Version]::TryParse($Exchange2016Version, [ref]$null) -eq $false) {
            Write-Host "[Error] Exchange 2016 version is not a valid version: $Exchange2016Version"
            exit 1
        }
        if ([System.Version]::TryParse($Exchange2019Version, [ref]$null) -eq $false) {
            Write-Host "[Error] Exchange 2019 version is not a valid version: $Exchange2019Version"
            exit 1
        }
        # Requirements are met
    }
    else {
        Write-Host "[Error] Exchange 2010, 2013, 2016, and 2019 versions are required to run this script."
        exit 1
    }

    if ($CheckboxCustomField -and $CheckboxCustomField -notlike "null") {
        # Check if the text custom field is set to 1 as a check box or true as a string
        try {
            $boolCheck = Get-NinjaProperty -Name $CheckboxCustomField -Type "Checkbox"
            $stringCheck = Get-NinjaProperty -Name $CheckboxCustomField
            if (
                $true -eq $boolCheck -or
                $stringCheck -eq "1"
            ) {
                Write-Host "[Info] Skipping Exchange version check."
                exit 0
            }
        }
        catch {
            Write-Host "[Warn] Failed to get the value of the checkbox custom field."
            Write-Host "[Info] Continuing with Exchange version check."
        }
    }

    # Check if Exchange is installed
    if (-not $(Get-Service -Name MSExchangeServiceHost -ErrorAction SilentlyContinue)) {
        Write-Host "[Error] This script requires Exchange to be installed and running."
        exit 1
    }

    # Find the location of ExSetup.exe
    $ExSetupPath = Get-ChildItem -Path "C:\Program Files\Microsoft\Exchange Server\*" -Recurse -Filter "ExSetup.exe" | Select-Object -ExpandProperty FullName
    # Check if this is an Exchange server
    if (-not (Test-Path -Path $ExSetupPath)) {
        Write-Host "[Error] Exchange Server is not installed."
        exit 1
    }

    # Get the installed Exchange version from ExSetup.exe
    $ExchangeFileVersion = Get-Command $ExSetupPath | ForEach-Object { $_.FileVersionInfo }
    # Determine the edition of Exchange
    $ExchangeYearVersion = switch ($ExchangeFileVersion.FileVersionRaw.Major) {
        14 {
            "2010"
        }
        15 {
            switch ($ExchangeFileVersion.FileVersionRaw.Minor) {
                0 { "2013" }
                1 { "2016" }
                2 { "2019" }
                Default { "Unknown" }
            }
        }
        Default { "Unknown" }
    }

    # Check if the Exchange version is outdated
    switch ($ExchangeYearVersion) {
        "2010" {
            if ($ExchangeFileVersion.FileVersionRaw -lt $Exchange2010Version) {
                Write-Host "[Warn] Exchange 2010 version is outdated. Found version: $($ExchangeFileVersion.FileVersionRaw)"
            }
            else {
                Write-Host "[Info] Exchange 2010 version is up to date. Found version: $($ExchangeFileVersion.FileVersionRaw)"
            }
        }
        "2013" {
            if ($ExchangeFileVersion.FileVersionRaw -lt $Exchange2013Version) {
                Write-Host "[Warn] Exchange 2013 version is outdated. Found version: $($ExchangeFileVersion.FileVersionRaw)"
            }
            else {
                Write-Host "[Info] Exchange 2013 version is up to date. Found version: $($ExchangeFileVersion.FileVersionRaw)"
            }
        }
        "2016" {
            if ($ExchangeFileVersion.FileVersionRaw -lt $Exchange2016Version) {
                Write-Host "[Warn] Exchange 2016 version is outdated. Found version: $($ExchangeFileVersion.FileVersionRaw)"
            }
            else {
                Write-Host "[Info] Exchange 2016 version is up to date. Found version: $($ExchangeFileVersion.FileVersionRaw)"
            }
        }
        "2019" {
            if ($ExchangeFileVersion.FileVersionRaw -lt $Exchange2019Version) {
                Write-Host "[Warn] Exchange 2019 version is outdated. Found version: $($ExchangeFileVersion.FileVersionRaw)"
            }
            else {
                Write-Host "[Info] Exchange 2019 version is up to date. Found version: $($ExchangeFileVersion.FileVersionRaw)"
            }
        }
        Default {
            Write-Host "[Error] Unknown Exchange version."
            exit 1
        }
    }

    if ($CustomFieldName -and $CustomFieldName -notlike "null") {
        try {
            Write-Host "[Info] Attempting to set Custom Field '$CustomFieldName'."
            Set-NinjaProperty -Name $CustomFieldName -Value $($ExchangeFileVersion.FileVersion | Out-String)
            Write-Host "[Info] Successfully set Custom Field '$CustomFieldName'!"
        }
        catch {
            Write-Host "[Error] Failed to set Custom Field '$CustomFieldName'."
            $ExitCode = 1
        }
    }

    if ($ExitCode -gt 0) {
        exit $ExitCode
    }
}
end {
    
    
    
}

 

Save time with over 300+ scripts from the NinjaOne Dojo.

Get access today.

Detailed Breakdown

This PowerShell script is structured into several key components that work together to check, validate, and act upon Exchange Server version information. Here’s a step-by-step look:

1. Initialization and Validation

a. The script ensures it is executed with administrative privileges.

b. It checks the system’s compatibility by validating the OS version (Windows Server 2008 R2 or higher) and confirms that Exchange is installed by locating the ExSetup.exe file.

2. Parameter Input

a. The script accepts version benchmarks for Exchange 2010, 2013, 2016, and 2019. For example, you can specify -Exchange2019Version “15.2.0.0” to define the minimum acceptable version for Exchange 2019.

b. Optional parameters like -CustomFieldName and -CheckboxCustomField allow administrators to manage custom fields in NinjaOne or skip the version check if a certain condition is met.

3. Version Retrieval

a. It retrieves the version of Exchange installed by analyzing the file version of ExSetup.exe. Based on the major and minor version numbers, it determines the Exchange edition (e.g., 2010, 2013).

4. Version Comparison

a. The script compares the retrieved version with the specified benchmarks. If the version is outdated, a warning is displayed. Up-to-date versions are acknowledged with an informational message.

5. Custom Field Integration

a. If the -CustomFieldName parameter is provided, the script sets the version information in a custom field, enabling easy tracking within tools like NinjaOne.

6. Error Handling and Exit Codes

a. The script is designed to gracefully handle errors, such as missing Exchange installations or invalid version inputs, and exits with appropriate status codes.

Potential Use Cases

Case Study: Managing Exchange Versions in a Multi-Site Environment

Imagine an IT administrator overseeing multiple Exchange deployments across regional offices. The administrator uses this script as part of a scheduled maintenance task to check Exchange versions, validate compliance with the organization’s policies, and update NinjaOne’s custom fields with the latest version data. If any versions are outdated, the admin is alerted to prioritize updates, ensuring the environment remains secure and compliant.

Comparisons

This script’s automated approach offers distinct advantages over manual methods or GUI-based tools:

  • Manual Methods: Manually checking Exchange versions involves logging into each server and running individual commands—a time-consuming and error-prone process.
  • GUI Tools: While some management tools provide graphical insights, they often lack the customization and automation capabilities that this script provides, especially for MSPs managing multiple environments.

FAQs

Q: What happens if Exchange is not installed?
The script terminates and displays an error message, ensuring administrators are aware of the issue.

Q: Can I use this script to check Exchange versions in bulk?
Yes, when integrated with tools like NinjaOne, you can automate version checks across multiple servers.

Q: What if the provided version benchmarks are invalid?
The script validates each version input and terminates with an error if any benchmarks are not valid version formats.

Q: How do custom fields work in this script?
Custom fields allow administrators to save version information within management systems. The script interacts with NinjaOne to set these fields dynamically.

Implications

By identifying outdated Exchange Server versions, this script helps prevent potential security vulnerabilities and ensures compliance with supported configurations. Regular use of such automation reduces the risk of human error, improves efficiency, and enables proactive IT management.

Recommendations

  • Test in a Non-Production Environment: Always test scripts in a staging environment to ensure compatibility with your setup.
  • Schedule Regular Checks: Use task scheduling to automate periodic checks, keeping your Exchange environments up to date.
  • Leverage Custom Fields Wisely: Utilize the -CustomFieldName parameter to maintain comprehensive records of your environment.

Final Thoughts

Managing Exchange Server environments is a critical responsibility for IT professionals. This PowerShell script simplifies the process of checking and validating Exchange versions, enabling organizations to maintain secure, compliant, and high-performing systems. For those using NinjaOne, the script’s integration capabilities enhance operational efficiency, making it an invaluable tool for modern IT management.

]]>