Automate Inactive User Account Detection with PowerShell

Key takeaways

  • Automated security protocol: The script automates the identification of inactive user accounts, enhancing security.
  • Customizable threshold: Adjust the inactivity threshold to align with organizational policies.
  • Multi-environment compatibility: Capable of handling local, Active Directory, and Azure AD accounts.
  • Time and resource efficient: Automates what would be a time-consuming manual process.
  • Proactive risk management: Helps preempt security breaches by managing potential vulnerabilities.
  • Transparency and flexibility: Being a PowerShell script, it’s transparent and customizable for specific needs.
  • Scheduled audits: Can be set up to run at regular intervals for continuous monitoring.
  • Comprehensive reporting: Generates detailed reports on inactive accounts, aiding decision-making.
  • Integration with management tools: Complements and integrates with IT management solutions like NinjaOne.

In the dynamic landscape of IT management, the security and efficiency of user account management remain paramount. Regularly monitoring and addressing inactive user accounts is not just a best practice but a necessity in maintaining a secure IT environment. This is where PowerShell scripts, like the one we’re discussing today, become invaluable tools for IT professionals and Managed Service Providers (MSPs). 

Background

The script in question is designed to identify and alert administrators about inactive or unused accounts within a Windows environment. Inactive accounts can be a significant security risk, serving as potential entry points for unauthorized access if compromised. For IT professionals and MSPs, such scripts are crucial in preemptively tackling security vulnerabilities and ensuring compliance with various IT policies and regulations.

The script:

<#
.SYNOPSIS
    Alerts when there is an inactive / unused account that has not logged in or has not had their password set in the specified number of days.
.DESCRIPTION
    Alerts when there is an inactive / unused account that has not logged in or has not had their password set in the specified number of days.
.EXAMPLE
    -IncludeDisabled
    
    Action completed: Run Monitor Account Last Logon Result: FAILURE Output: Action: Run Monitor Account Last Logon, Result: Failed
    WARNING: Inactive accounts detected!

    Username       PasswordLastSet       LastLogon              Enabled
    --------       ---------------       ---------              -------
    Administrator  4/12/2023 9:05:18 AM  11/28/2023 10:31:06 AM    True
    Guest                                12/31/1600 4:00:00 PM    False
    DefaultAccount                       12/31/1600 4:00:00 PM    False
    kbohlander     11/29/2023 1:49:51 PM 12/5/2023 1:09:43 PM      True
    tuser1         12/1/2023 5:59:58 PM  12/4/2023 9:34:32 AM      True
    krbtgt         11/27/2023 3:40:20 PM 12/31/1600 4:00:00 PM    False
    tuser2         12/4/2023 3:40:27 PM  12/31/1600 4:00:00 PM     True
.EXAMPLE
    -Days 60
    
    Action completed: Run Monitor Account Last Logon Result: FAILURE Output: Action: Run Monitor Account Last Logon, Result: Failed
    WARNING: Inactive accounts detected!

    Username       PasswordLastSet       LastLogon              Enabled
    --------       ---------------       ---------              -------
    Administrator  4/12/2023 9:05:18 AM  11/28/2023 10:31:06 AM    True
    kbohlander     11/29/2023 1:49:51 PM 12/5/2023 1:09:43 PM      True
    tuser1         12/1/2023 5:59:58 PM  12/4/2023 9:34:32 AM      True
    tuser2         12/4/2023 3:40:27 PM  12/31/1600 4:00:00 PM     True
.OUTPUTS
    None
.NOTES
    Minimum OS Architecture Supported: Windows 7, Windows Server 2012
    Exit code 1: Found users that haven't logged in over X days and are enabled.
    Exit code 2: Calling "net.exe user" or "Get-LocalUser" failed.
    Release Notes: Renamed script, added Script Variable support, improved ad support, removed requires statement.
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).
#>

[CmdletBinding()]
param (
    [Parameter()]
    [int]$Days = 90,
    [Parameter()]
    [switch]$IncludeDisabled = [System.Convert]::ToBoolean($env:includeDisabled)
)

begin {
    # Use script variables if available.
    if ($env:days -and $env:days -notlike "null") {
        $Days = $env:Days
    }

    # Change negative days to the expected positive days
    if ($Days -lt 0) {
        $Days = 0 - $Days
    }

    # Date where an account is considered inactive.
    $InactivityCutOff = (Get-Date).AddDays(-$Days)

    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 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
        }
    }

    # We'll want to warn that we're unable to check the actual Azure AD account 
    # (it requires O365 credentials and a powershell module both of which are not supported by this script).
    function Test-IsAzureJoined {
        try {
            $dsreg = dsregcmd.exe /status | Select-String "AzureAdJoined : YES"
        }
        catch {
            return $False
        }
        if ($dsreg) { return $True }
    }

    # For Non-Domain Controllers we'll check net user for the information we need.
    function Get-NetAccountInfo {
        [CmdletBinding()]
        param(
            [Parameter()]
            [string]$User,
            [Parameter()]
            [switch]$Domain
        )
        process {
            # Get user info from net.exe user
            $netuser = if ($Domain) { net.exe user $user /Domain }else { net.exe user $user }

            $success = $netuser | Select-String 'The command completed successfully.'
            if (-not $success) {
                throw "Failed to retrieve account info for user $user!"
            }

            #Pre-formatted Object
            $Object = New-Object psobject -Property @{
                Username        = $User
                Name            = "$(($netuser | Select-String 'Full Name') -split '  ' | Select-Object -Last 1)".Trim()
                Enabled         = "$(($netuser | Select-String 'Account active') -split '  ' | Select-Object -Last 1)".Trim()
                LastLogon       = "$(($netuser | Select-String 'Last logon') -split '  ' | Select-Object -Last 1)".Trim()
                PasswordLastSet = "$(($netuser | Select-String 'Password last set') -split '  ' | Select-Object -Last 1)".Trim()
            }

            # Formatted object using PowerShell datatypes (for easier parsing later).
            New-Object psobject -Property @{
                Username        = $Object.Username
                Name            = $Object.Name
                Enabled         = if ($Object.Enabled -eq "Yes") { $True }elseif ($Object.Enabled -eq "No") { $False }else { $Object.Enabled }
                LastLogon       = try { Get-Date $Object.LastLogon }catch { $Object.LastLogon };
                PasswordLastSet = try { Get-Date $Object.PasswordLastSet }catch { $Object.PasswordLastSet }
            }
        }
    }
}
process {

    # If it's an azure joined machine we should warn that we're not checking any of the Azure AD Accounts.
    if (Test-IsAzureJoined) {
        Write-Warning -Message "This script is unable to check Azure AD accounts however this script will check the local accounts on this machine."
    }

    $Report = New-Object System.Collections.Generic.List[object]

    # Warn if ad accounts are not able to be checked.
    if (-not (Test-IsDomainController) -and (Test-IsDomainJoined) -and -not (Test-ComputerSecureChannel)) {
        Write-Warning "Domain is not reachable! We'll be unable to check active directory accounts!"
    }

    # There are two ways this script will check for an inactive account one of which uses the Active Directory PowerShell module which is only availalbe on Domain Controllers / machines with RSAT installed.
    if (-not (Test-IsDomainController)) {

        # Compile a list of users to check.
        $Users = if ($PSVersionTable.PSVersion.Major -lt 5) {
            Get-WmiObject -Class "win32_UserAccount"        
        }
        else {
            Get-CimInstance -Class "win32_UserAccount"
        }

        # Grab the account info using net user (which is slightly different if it's a domain account or not).
        $Accounts = foreach ($User in $Users) {
            if ($User.Domain -ne $env:COMPUTERNAME ) {
                Get-NetAccountInfo -User $User.Name -Domain
            }
            else {
                Get-NetAccountInfo -User $User.Name
            }
        }
    }
    else {
        # Older OS's need to have the module manually imported.
        try {
            Import-Module ActiveDirectory
        }
        catch {
            Write-Error -Message "[Error] Failed to import PowerShell Active Directory Module. Is RSAT installed?" -Category DeviceError -Exception (New-Object System.Exception)
        }

        # Compile a list of users with the relevant attributes
        $Users = Get-AdUser -Filter * -Properties SamAccountName, DisplayName, PasswordLastSet, lastLogonTimestamp, lastLogon, Enabled
        
        # Convert that into a more parseable object
        $Accounts = foreach ($User in $Users) {
            $LastLogon = [datetime]::FromFileTime($User.lastLogon)
            $LastLogonTimeStamp = [datetime]::FromFileTime($User.LastLogonTimestamp)
            New-Object psobject -Property @{
                Username        = $User.SamAccountName
                Name            = $User.DisplayName
                Enabled         = $User.Enabled
                LastLogon       = if ($LastLogon -gt $LastLogonTimeStamp) { $LastLogon }else { $LastLogonTimeStamp }
                PasswordLastSet = $User.PasswordLastSet
            }
        }
    }

    # Compile a list of accounts that could be considered inactive
    $InactiveAccounts = $Accounts | Where-Object { ($_.LastLogon -eq "Never" -or $_.LastLogon -lt $InactivityCutOff) -and $_.PasswordLastSet -lt $InactivityCutOff }

    # Filter out disabled accounts if we're asked to.
    if ($IncludeDisabled) {
        $InactiveAccounts | ForEach-Object { $Report.Add($_) }
    }
    else {
        $InactiveAccounts | Where-Object { $_.Enabled -notlike $False } | ForEach-Object { $Report.Add($_) }
    }

    # If no inactive accounts exit
    if (-not $Report) {
        Write-Host "No inactive accounts detected!"
        exit 0
    }

    Write-Warning "Inactive accounts detected!"
    $Report | Format-Table -AutoSize -Property Username, PasswordLastSet, LastLogon, Enabled | Out-String | Write-Host
    exit 1
}
end {
    
    
    
}

 

Access over 300+ scripts in the NinjaOne Dojo

Get Access

Detailed breakdown

The script operates in a multi-step process:

  • Parameter initialization: It begins by defining parameters, including the number of days to consider an account inactive and an option to include disabled accounts.
  • Environment checks: The script checks whether the machine is domain-joined, a domain controller, or Azure AD-joined. This determines the scope of account checks – local, Active Directory, or Azure.
  • Account retrieval: For non-domain controllers, it retrieves account information using net.exe user or WMI/CIM instances, while for domain controllers, it utilizes the Active Directory PowerShell module.
  • Activity assessment: The core function involves assessing user accounts based on their last login and password set dates against the inactivity threshold.
  • Report generation: Inactive accounts are compiled into a report, with an option to exclude disabled accounts.
  • Alerts and exit codes: The script concludes by alerting administrators of any inactive accounts and exiting with a code that indicates the presence or absence of such accounts.

Potential use cases

Consider a scenario where an IT admin in a large corporation uses this script to routinely audit user accounts. They could schedule the script to run monthly, alerting them to accounts that have not been active for over 90 days. This enables proactive management of user accounts, reducing the risk of security breaches through dormant accounts.

Comparisons

Alternative approaches, such as manual audits or using third-party tools, exist. However, this PowerShell script offers a more direct, customizable, and cost-effective solution. Unlike manual checks, it automates the process, saving time and reducing human error. Compared to third-party tools, it offers transparency and flexibility, as IT professionals can modify the script to suit their specific needs.

FAQs

Q1: Can the script differentiate between types of user accounts?
A1: Yes, it can distinguish between local, Active Directory, and Azure AD accounts.

Q2: Is it possible to schedule this script to run automatically?
A2: Absolutely, this can be done using Windows Task Scheduler or similar automation tools.

Q3: Does this script handle accounts in a cloud environment?
A3: It’s primarily designed for local and Active Directory accounts; Azure AD accounts require additional modules and credentials.

Implications

The implications of using this script are far-reaching. By identifying inactive accounts, it significantly reduces the attack surface for potential security breaches. However, IT professionals must handle the output responsibly, ensuring that the disabling or deletion of accounts aligns with organizational policies and user needs.

Recommendations

  • Regular audits: Schedule the script to run at regular intervals.
  • Customization: Tailor the script parameters to fit organizational policies.
  • Follow-up actions: Establish a protocol for handling inactive accounts.

Final thoughts

In conclusion, scripts like these, when used effectively, significantly contribute to the overall security and efficiency of IT operations. They complement solutions like NinjaOne, which provides comprehensive IT management tools, helping administrators maintain control over their IT environments. NinjaOne’s ability to integrate with PowerShell scripts enhances its utility, making it an ideal choice for proactive IT management.

Next Steps

Building an efficient and effective IT team requires a centralized solution that acts as your core service deliver tool. NinjaOne enables IT teams to monitor, manage, secure, and support all their devices, wherever they are, without the need for complex on-premises infrastructure.

Learn more about NinjaOne Remote Script Deployment, check out a live tour, or start your free trial of the NinjaOne platform.

Categories:

You might also like

×

See NinjaOne in action!

By submitting this form, I accept NinjaOne's privacy policy.

NinjaOne Terms & Conditions

By clicking the “I Accept” button below, you indicate your acceptance of the following legal terms as well as our 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 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).