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 ' ', ' ' } # 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.