How to Create a Scheduled Task Alert with PowerShell

Scheduled tasks are a cornerstone of automated systems administration on Windows machines. From running updates to executing scripts, they are vital tools for managing infrastructure efficiently. But not all scheduled tasks are benign—some may be planted by malicious actors seeking persistence on compromised systems. That’s why the ability to monitor newly created scheduled tasks is critical for maintaining security and operational transparency, especially for Managed Service Providers (MSPs) and enterprise IT teams. In this post, we’ll explore how to create a scheduled task alert with PowerShell using a script that integrates seamlessly with NinjaOne’s custom fields, offering a practical and scalable approach to task auditing.

Background

The Microsoft-Windows-TaskScheduler/Operational log captures detailed information about task-related events, including the creation of new tasks. However, it’s often disabled by default and not easily accessible without additional configuration. For MSPs managing a distributed fleet of endpoints, manually enabling and monitoring these logs isn’t scalable. This PowerShell script automates the discovery and reporting of newly created scheduled tasks within a specified time window and pushes the results to NinjaOne custom fields for easy visibility and alerting.

This script solves a core security challenge: tracking unauthorized or suspicious scheduled tasks in real time, which is essential for proactive threat detection and response. It ensures IT teams can remain informed, without having to manually comb through event logs or write extensive custom tooling from scratch.

The Script

#Requires -Version 5.1

<#
.SYNOPSIS
    Alert on new scheduled tasks created in the last X hours.

.DESCRIPTION
    This script will query the Windows Event Log for new scheduled tasks created in the last X hours. It will save the results to a multiline custom field and/or a WYSIWYG custom field.
    This does require the Microsoft-Windows-TaskScheduler/Operational event log to be enabled first before results can be retrieved.
    If enabled the default maximum log size is 10MB and the default retention method is Overwrite oldest events as needed.

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).

.PARAMETER Hours
    The number of hours to look back for new scheduled tasks.

.PARAMETER MultilineCustomField
    The name of the multiline custom field to save the results to.

.PARAMETER WYSIWYGCustomField
    The name of the WYSIWYG custom field to save the results to.

.PARAMETER EnableEventLog
    Enable the Microsoft-Windows-TaskScheduler/Operational event log if it is not already enabled.

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

[CmdletBinding()]
param (
    [Parameter()]
    [int]$CreatedInLastXHours,

    [Parameter()]
    [string]$MultilineCustomField,

    [Parameter()]
    [string]$WYSIWYGCustomField,

    [Parameter()]
    [switch]$EnableEventLog
)
begin {

    # Check which Script Variables are used
    if ($env:createdInLastXHours -and $env:createdInLastXHours -notlike "null") {
        if ($env:createdInLastXHours -match '\D') {
            Write-Host "[Error] CreatedInLastXHours must be an integer."
            exit 1
        }
        [int]$CreatedInLastXHours = $env:createdInLastXHours
    }
    if ($env:multilineCustomField -and $env:multilineCustomField -notlike "null") {
        $MultilineCustomField = $env:multilineCustomField
    }
    if ($env:WYSIWYGCustomField -and $env:wysiwygCustomField -notlike "null") {
        $WYSIWYGCustomField = $env:wysiwygCustomField
    }
    if ($env:enableEventLog -and $env:enableEventLog -like "true") {
        $EnableEventLog = $true
    }

    # Check if CreatedInLastXHours is not 0 nor negative
    if ($CreatedInLastXHours -le 0) {
        Write-Host "[Error] CreatedInLastXHours must be greater than 0."
        exit 1
    }

    # Define the event log name, selected date, and event ID to query
    $EventLogName = 'Microsoft-Windows-TaskScheduler/Operational'
    $SelectedDate = $($(Get-Date).AddHours(0 - $CreatedInLastXHours))
    $EventID = 106

    # Check if the event log is enabled
    try {
        $EventLogEnabled = Get-WinEvent -ListLog $EventLogName -ErrorAction Stop
    }
    catch {
        Write-Host "[Error] Failed to retrieve event log '$EventLogName'."
        Write-Host $_.Exception.Message
        exit 1
    }

    # If the event log is not found, exit the script
    if ($EventLogEnabled.IsEnabled) {
        Write-Host "[Info] Event log '$EventLogName' is enabled."
    }
    else {
        # Enable the event log if the switch is provided
        if ($EnableEventLog) {
            Write-Host "[Info] Enabling event log '$EventLogName'."
            try {
                $log = New-Object System.Diagnostics.Eventing.Reader.EventLogConfiguration $EventLogName
                $log.IsEnabled = $true
                $log.SaveChanges()
            }
            catch {
                Write-Host "[Error] Failed to enable event log '$EventLogName'."
                exit 1
            }
        }
        else {
            Write-Host "[Error] Event log '$EventLogName' is not enabled."
            exit 1
        }
    }

    function Set-NinjaProperty {
        [CmdletBinding()]
        Param(
            [Parameter(Mandatory = $True)]
            [String]$Name,
            [Parameter()]
            [String]$Type,
            [Parameter(Mandatory = $True, ValueFromPipeline = $True)]
            $Value,
            [Parameter()]
            [String]$DocumentName,
            [Parameter()]
            [Switch]$Piped
        )
        # Remove the non-breaking space character
        if ($Type -eq "WYSIWYG") {
            $Value = $Value -replace ' ', '&nbsp;'
        }

        # Measure the number of characters in the provided value
        $Characters = $Value | ConvertTo-Json | Measure-Object -Character | Select-Object -ExpandProperty Characters

        # Throw an error if the value exceeds the character limit of 200,000 characters
        if ($Piped -and $Characters -ge 200000) {
            throw [System.ArgumentOutOfRangeException]::New("Character limit exceeded: the value is greater than or equal to 200,000 characters.")
        }

        if (!$Piped -and $Characters -ge 45000) {
            throw [System.ArgumentOutOfRangeException]::New("Character limit exceeded: the value is greater than or equal to 45,000 characters.")
        }

        # Initialize a hashtable for additional documentation parameters
        $DocumentationParams = @{}

        # If a document name is provided, add it to the documentation parameters
        if ($DocumentName) { $DocumentationParams["DocumentName"] = $DocumentName }

        # Define a list of valid field types
        $ValidFields = "Attachment", "Checkbox", "Date", "Date or Date Time", "Decimal", "Dropdown", "Email", "Integer", "IP Address", "MultiLine", "MultiSelect", "Phone", "Secure", "Text", "Time", "URL", "WYSIWYG"

        # Warn the user if the provided type is not valid
        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" }
    
        # Define types that require options to be retrieved
        $NeedsOptions = "Dropdown"

        # If the property is being set in a document or field and the type needs options, retrieve them
        if ($DocumentName) {
            if ($NeedsOptions -contains $Type) {
                $NinjaPropertyOptions = Ninja-Property-Docs-Options -AttributeName $Name @DocumentationParams 2>&1
            }
        }
        else {
            if ($NeedsOptions -contains $Type) {
                $NinjaPropertyOptions = Ninja-Property-Options -Name $Name 2>&1
            }
        }

        # Throw an error if there was an issue retrieving the property options
        if ($NinjaPropertyOptions.Exception) { throw $NinjaPropertyOptions }
        
        # Process the property value based on its type
        switch ($Type) {
            "Checkbox" {
                # Convert the value to a boolean for Checkbox type
                $NinjaValue = [System.Convert]::ToBoolean($Value)
            }
            "Date or Date Time" {
                # Convert the value to a Unix timestamp for Date or Date Time type
                $Date = (Get-Date $Value).ToUniversalTime()
                $TimeSpan = New-TimeSpan (Get-Date "1970-01-01 00:00:00") $Date
                $NinjaValue = $TimeSpan.TotalSeconds
            }
            "Dropdown" {
                # Convert the dropdown value to its corresponding GUID
                $Options = $NinjaPropertyOptions -replace '=', ',' | ConvertFrom-Csv -Header "GUID", "Name"
                $Selection = $Options | Where-Object { $_.Name -eq $Value } | Select-Object -ExpandProperty GUID

                # Throw an error if the value is not present in the dropdown options
                if (!($Selection)) {
                    throw [System.ArgumentOutOfRangeException]::New("Value is not present in dropdown options.")
                }

                $NinjaValue = $Selection
            }
            default {
                # For other types, use the value as is
                $NinjaValue = $Value
            }
        }

        # Set the property value in the document if a document name is provided
        if ($DocumentName) {
            $CustomField = Ninja-Property-Docs-Set -AttributeName $Name -AttributeValue $NinjaValue @DocumentationParams 2>&1
        }
        else {
            try {
                # Otherwise, set the standard property value
                if ($Piped) {
                    $CustomField = $NinjaValue | Ninja-Property-Set-Piped -Name $Name 2>&1
                }
                else {
                    $CustomField = Ninja-Property-Set -Name $Name -Value $NinjaValue 2>&1
                }
            }
            catch {
                Write-Host -Object "[Error] Failed to set custom field."
                throw $_.Exception.Message
            }
        }

        # Throw an error if setting the property failed
        if ($CustomField.Exception) {
            throw $CustomField
        }
    }

    $ExitCode = 0
}

process {
    # Get the scheduled tasks from the event log
    try {
        $EventLogEntries = Get-WinEvent -FilterHashtable @{
            LogName = $EventLogName
            ID      = $EventID
        } -ErrorAction Stop | Where-Object { $_.TimeCreated -ge $SelectedDate }
    }
    catch {
        Write-Host "[Error] Failed to retrieve event log '$EventLogName'."
        Write-Host $_.Exception.Message
        exit 1
    }

    # If there are no scheduled tasks, exit the script
    if (-not $EventLogEntries) {
        Write-Host "[Info] No scheduled tasks created in the last $CreatedInLastXHours hours."
        exit 0
    }

    # Get the details of the scheduled tasks
    $ScheduledTasks = $EventLogEntries | ForEach-Object {
        $_Event = $_

        if ($_Event) {
            # Get the task path and name from the event properties
            $TaskPath = $_Event | Select-Object -ExpandProperty Properties | Select-Object -ExpandProperty Value -First 1 # First property value is the task path
            # Get the parent path of the task
            $ParentPath = ("$TaskPath" -split '\\' | Select-Object -SkipLast 1) -join '\'
            # If the task path is empty, set the parent path to the root
            if ($ParentPath -eq "") { $ParentPath = "\" }
            # Get the task name from the task path
            $TaskName = "$TaskPath" -split '\\' | Select-Object -Last 1
            if ($TaskName -like "" -or $ParentPath -like "") { 
                Write-Host "[Error] Failed to get task path or name from event:"
                Write-Host $TaskPath
            }
            else {
                # Get the scheduled task details
                $Task = Get-ScheduledTask -TaskPath "$ParentPath" -TaskName "$TaskName" -ErrorAction SilentlyContinue
                # Get the last run time of the scheduled task
                $LastRunTime = try {
                    Get-ScheduledTaskInfo -TaskPath "$ParentPath" -TaskName "$TaskName" -ErrorAction Stop | Select-Object -ExpandProperty LastRunTime 
                }
                catch { [datetime]::MinValue }

                # Return the scheduled task details
                [PSCustomObject]@{
                    TimeCreated      = $_Event.TimeCreated
                    TaskName         = $TaskName
                    TaskCreationDate = $(if ($Task.Date) { $Task.Date } else { $_Event.TimeCreated })
                    TaskPath         = $TaskPath | Select-Object -First 1
                    TaskLastRunTime  = $(if ($LastRunTime.Year -lt 2000) { "Never" } else { $LastRunTime }) # If the last run time is before 2000, it has never run
                }
            }
        }
    }

    # Sort the scheduled tasks by TimeCreated in descending order
    $ScheduledTasks = $ScheduledTasks | Sort-Object -Property TimeCreated -Descending

    # Output the scheduled tasks to the multiline custom field
    if ($MultilineCustomField) {
        try {
            Write-Host "[Info] Attempting to set Custom Field '$MultilineCustomField'."
            $ScheduledTasks | Format-List | Out-String -Width 4000 | Set-NinjaProperty -Name $MultilineCustomField -Type "MultiLine" -Piped
            Write-Host "[Info] Successfully set Custom Field '$MultilineCustomField'!"
        }
        catch {
            Write-Host "[Error] Failed to set multiline custom field."
            $ExitCode = 1
        }
    }

    # Output the scheduled tasks to the WYSIWYG custom field
    if ($WYSIWYGCustomField) {
        try {
            Write-Host "[Info] Attempting to set Custom Field '$WYSIWYGCustomField'."
            Set-NinjaProperty -Name $WYSIWYGCustomField -Value $($ScheduledTasks | ConvertTo-Html -Fragment) -Type "WYSIWYG" -Piped
            Write-Host "[Info] Successfully set Custom Field '$WYSIWYGCustomField'!"
        }
        catch {
            Write-Host "[Error] Failed to set WYSIWYG custom field."
            $ExitCode = 1
        }
    }

    # Output the scheduled tasks to the Activity Feed
    $ScheduledTasks | Format-List | Out-String -Width 4000 | Write-Host

    exit $ExitCode
}

end {
    
    
    
}

 

Detailed Breakdown

The script is modular, beginning with parameter definitions and environment variable parsing. Here’s a walkthrough of its core functionality:

1. Parameter Handling

The script accepts inputs for:

  • CreatedInLastXHours: Timeframe to search for new scheduled tasks.
  • MultilineCustomField and WYSIWYGCustomField: NinjaOne field names where results will be output.
  • EnableEventLog: Optional flag to activate the Task Scheduler Operational Log.

2. Environment Variables and Validation

Environment variables are parsed first to allow flexible usage across different automation environments. The script ensures all input values are valid and properly formatted before proceeding.

3. Event Log Checks and Activation

The script verifies whether the Microsoft-Windows-TaskScheduler/Operational log is enabled. If not, and the EnableEventLog switch is provided, it enables the log on-the-fly. This is particularly useful in environments where centralized policies may have left the log disabled.

4. Data Collection

Using Get-WinEvent, the script filters for Event ID 106, which corresponds to scheduled task creation. It then parses out meaningful properties like:

  • Task name
  • Task path
  • Last run time
  • Creation timestamp

5. Output and Integration with NinjaOne

The script formats the results into both a human-readable multiline string and an HTML fragment. These outputs are then uploaded to the specified NinjaOne custom fields using the Set-NinjaProperty helper function. This function is robust, handling field type validation, character limits, and formatting quirks.

6. Activity Feed Logging

Finally, results are printed to the host console (or activity feed), providing immediate visibility for script outputs.

Visual Aid Suggestion: A flowchart showing:

  • Start → Validate Inputs → Check/Enable Event Log → Query Log → Format Data → Push to NinjaOne Fields → Exit

Potential Use Cases

Case Study: Threat Detection in an MSP Environment

Imagine an MSP managing 1,000 endpoints across 20 clients. A technician notices ransomware-related IOCs involving scheduled tasks. By deploying this script via NinjaOne, the MSP configures a policy to check every endpoint for new scheduled tasks created in the last 24 hours. They flag unusual task names like Updater123 or SystemPatch4. With data centralized in NinjaOne, the security team can immediately triage suspicious entries, isolate affected machines, and respond before payloads execute.

Comparisons

Scripted vs. Manual Approaches

  • Manual Log Browsing: Time-consuming and error-prone.
  • Built-in Windows Tools: Task Scheduler GUI offers no alerting or central visibility.
  • Third-Party Tools: Often require agents and licenses, increasing overhead.
  • This Script: Agentless, scalable via NinjaOne, and easy to integrate with incident response workflows.

FAQs

1. What if the event log is disabled by default?

Use the -EnableEventLog flag to activate it automatically.

2. How often should this script be run?

Depending on your security policy, every 4 to 24 hours is reasonable for production environments.

3. What happens if too many tasks are found?

The script includes safeguards to prevent overloading NinjaOne fields by checking character limits before submission.

4. Can I modify it to detect task changes or deletions?

Yes, by adjusting the Event ID in the filter (Event ID 141 for task updates or Event ID 142 for deletions).

Implications

Monitoring scheduled task creation isn’t just good hygiene—it’s essential for security. Many malware strains create persistence by adding scheduled tasks. Failing to detect them early can allow attackers to maintain control and exfiltrate data unnoticed. Regularly scanning for task creation, especially across multiple endpoints, helps build a proactive defense posture.

Recommendations

  • Enable Task Scheduler logs in baseline images to avoid having to toggle them post-deployment.
  • Integrate this script into your RMM platform’s scheduled tasks for recurring execution.
  • Monitor trends in scheduled task creation over time to detect anomalies.
  • Limit user permissions to prevent unnecessary task creation by non-admins.

Final Thoughts

This PowerShell script exemplifies how automation and smart integration can significantly enhance security visibility in IT operations. By pushing critical task data into NinjaOne’s custom fields, MSPs and IT professionals can act swiftly, with full context, when suspicious activity arises.

If you’re looking to improve your security monitoring capabilities, consider leveraging NinjaOne’s extensibility and this PowerShell-based approach to create scheduled task alerts. It’s a practical, lightweight, and powerful addition to your defensive toolkit.

Next Steps

Building an efficient and effective IT team requires a centralized solution that acts as your core service delivery 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).