From 32bc07b63746123cf26c764c0a1311eaa1a9aaa1 Mon Sep 17 00:00:00 2001 From: Ahmet Ozturk Date: Fri, 19 Jun 2026 15:02:16 +0200 Subject: [PATCH 1/5] check_tasksched: speed up the task discovery script call Get-ScheduledTaskInfo once using $tasks array, and save its results into a map. this is faster than calling it for each element in $tasks use a typed list for saving results. adding to this list is faster than calling += on a default array repedately --- .../embed/scripts/windows/scheduled_tasks.ps1 | 53 +++++++++++-------- 1 file changed, 32 insertions(+), 21 deletions(-) diff --git a/pkg/snclient/embed/scripts/windows/scheduled_tasks.ps1 b/pkg/snclient/embed/scripts/windows/scheduled_tasks.ps1 index ee9f5549..2548d748 100644 --- a/pkg/snclient/embed/scripts/windows/scheduled_tasks.ps1 +++ b/pkg/snclient/embed/scripts/windows/scheduled_tasks.ps1 @@ -48,9 +48,18 @@ try { $tasks = @() } -$results = @() +$taskInfos = $tasks | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue + +$infoMap = @{} + +foreach ($info in $taskInfos) { + $key = $info.TaskPath + $info.TaskName + $infoMap[$key] = $info +} + +$results = [System.Collections.Generic.List[object]]::new() foreach ($task in $tasks) { - $taskInfo = Get-ScheduledTaskInfo -TaskName $task.TaskName -TaskPath $task.TaskPath + $taskInfo = $infoMap[$task.TaskPath + $task.TaskName] # Get-ScheduledTask returns a nested object # Subobjects are not fully serialized and sent, only some of their fields are specifically selected @@ -120,25 +129,27 @@ foreach ($task in $tasks) { # Combine task properties with task info properties # Get-ScheduledTask -TaskName "XYZ" | Get-Member -MemberType Property # Get-ScheduledTaskInfo -TaskName "XYZ" | Get-Member -MemberType Property - $results += [PSCustomObject]@{ - TaskName = $task.TaskName - TaskPath = $task.TaskPath - State = $task.State - Description = $task.Description - PSComputerName = $task.PSComputerName - URI = $task.URI - Version = $task.Version - LastRunTime = $taskInfo.LastRunTime - LastTaskResult = $taskInfo.LastTaskResult - NextRunTime = $taskInfo.NextRunTime - NumberOfMissedRuns = $taskInfo.NumberOfMissedRuns - UserId = $task.Principal.UserId - Enabled = $task.Settings.Enabled - Priority = $task.Settings.Priority - Hidden = $task.Settings.Hidden - ExecutionTimeLimit = $task.Settings.ExecutionTimeLimit - Actions = $actions - } + $results.Add( + [PSCustomObject]@{ + TaskName = $task.TaskName + TaskPath = $task.TaskPath + State = $task.State + Description = $task.Description + PSComputerName = $task.PSComputerName + URI = $task.URI + Version = $task.Version + LastRunTime = $taskInfo.LastRunTime + LastTaskResult = $taskInfo.LastTaskResult + NextRunTime = $taskInfo.NextRunTime + NumberOfMissedRuns = $taskInfo.NumberOfMissedRuns + UserId = $task.Principal.UserId + Enabled = $task.Settings.Enabled + Priority = $task.Settings.Priority + Hidden = $task.Settings.Hidden + ExecutionTimeLimit = $task.Settings.ExecutionTimeLimit + Actions = $actions + } + ) } if ($results.Count -gt 0) { From 097c7ca4e20dc5bf084be62da668d6971efff836 Mon Sep 17 00:00:00 2001 From: Ahmet Ozturk Date: Fri, 19 Jun 2026 15:26:38 +0200 Subject: [PATCH 2/5] use windows.CREATE_NO_WINDOW in powershellCmd hideWindow: true was set when creating a new powershell process. this process inherited the parents console. when running snclient on windows terminal, it would minimize it. when running snclient on conhost/powershell, it would close it. I am unsure why. when running through a vscode terminal, it would be uneffected. I am still unsure why. adding windows.CREATE_NO_WINDOW, stops the inheritance of the console window and these side effects. stdout/stderr piping still works. --- pkg/snclient/snclient_windows.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/snclient/snclient_windows.go b/pkg/snclient/snclient_windows.go index f43b1456..3b6840e9 100644 --- a/pkg/snclient/snclient_windows.go +++ b/pkg/snclient/snclient_windows.go @@ -445,8 +445,9 @@ func powerShellCmd(ctx context.Context, command string, parameters ...PowerShell cmdLine := fmt.Sprintf(`%s -Command "& { %s %s }" %s `, POWERSHELL, parameterDefinitionsCmdline, command, parameterSpecificationsCmdline) cmd.SysProcAttr = &syscall.SysProcAttr{ - HideWindow: true, - CmdLine: cmdLine, + HideWindow: true, + CmdLine: cmdLine, + CreationFlags: windows.CREATE_NO_WINDOW, } return cmd, nil From e26ad57ac46132793baead6436b866a31acd5373 Mon Sep 17 00:00:00 2001 From: Ahmet Ozturk Date: Fri, 19 Jun 2026 16:14:13 +0200 Subject: [PATCH 3/5] check_tasksched: use blacklists for title and the folder the blacklist is taken from the error you get when creating a new task with illegal characters on task scheduler --- pkg/snclient/check_tasksched_windows.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pkg/snclient/check_tasksched_windows.go b/pkg/snclient/check_tasksched_windows.go index 65aae067..27f428ba 100644 --- a/pkg/snclient/check_tasksched_windows.go +++ b/pkg/snclient/check_tasksched_windows.go @@ -11,7 +11,6 @@ import ( "strings" "syscall" "time" - "unicode" "github.com/goccy/go-json" ) @@ -30,17 +29,18 @@ func (l *CheckTasksched) addTasks(ctx context.Context, snc *Agent, check *CheckD } } + titleRuneBlacklist := []rune{'\\', '/', ':', '*', '?', '"', '<', '>', '|'} if l.TaskTitle != CheckTaskschedDefaultTaskTitle { - if strings.ContainsFunc(l.TaskTitle, func(r rune) bool { return !unicode.IsLetter(r) }) { - return fmt.Errorf("custom specified title should be all letters, but it isnt: %s", l.TaskTitle) + if strings.ContainsFunc(l.TaskTitle, func(r rune) bool { return slices.Contains(titleRuneBlacklist, r) }) { + return fmt.Errorf("custom specified title: '%s' contains one of the blacklisted runes: '%s' ", l.TaskTitle, string(titleRuneBlacklist)) } } + // allow backslashes when specifying folders, to specify nested paths + folderRuneBlacklist := []rune{'/', ':', '*', '?', '"', '<', '>', '|'} if l.Folder != CheckTaskschedDefaultFolder { - // NTFS characters are generally allowed, expect quotes - allowedRunes := []rune{' ', '-', '\\', '_', '(', ')', '[', ']', '.', ','} - if strings.ContainsFunc(l.Folder, func(r rune) bool { return !unicode.IsLetter(r) && !slices.Contains(allowedRunes, r) }) { - return fmt.Errorf("custom specified folder should be all letters or allowed runes: '%s', but it isnt: %s", string(allowedRunes), l.Folder) + if strings.ContainsFunc(l.Folder, func(r rune) bool { return slices.Contains(folderRuneBlacklist, r) }) { + return fmt.Errorf("custom specified folder: '%s' contains one of the blacklisted runes: '%s' ", l.Folder, string(folderRuneBlacklist)) } } From 218d28be313d0fac1096bd70899baf791b264391 Mon Sep 17 00:00:00 2001 From: Ahmet Ozturk Date: Mon, 22 Jun 2026 16:00:00 +0200 Subject: [PATCH 4/5] check_tasksched: add timers and debug statements to check and script --- pkg/snclient/check_tasksched_windows.go | 1 + .../embed/scripts/windows/scheduled_tasks.ps1 | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/pkg/snclient/check_tasksched_windows.go b/pkg/snclient/check_tasksched_windows.go index 27f428ba..6029d68d 100644 --- a/pkg/snclient/check_tasksched_windows.go +++ b/pkg/snclient/check_tasksched_windows.go @@ -88,6 +88,7 @@ func (l *CheckTasksched) addTasks(ctx context.Context, snc *Agent, check *CheckD if err != nil { return fmt.Errorf("could not unmarshal scheduled tasks: %s", err.Error()) } + log.Debugf("found %d scheduled task(s)", len(taskList)) for index := range taskList { task := taskList[index] diff --git a/pkg/snclient/embed/scripts/windows/scheduled_tasks.ps1 b/pkg/snclient/embed/scripts/windows/scheduled_tasks.ps1 index 2548d748..64718c37 100644 --- a/pkg/snclient/embed/scripts/windows/scheduled_tasks.ps1 +++ b/pkg/snclient/embed/scripts/windows/scheduled_tasks.ps1 @@ -32,6 +32,9 @@ if (!$recursive) { $recursive = 'false' } # ensure output is utf8 $OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::UTF8 +# Print powershell version +[Console]::Error.WriteLine(('Powershell version table: ' + ($PSVersionTable | ConvertTo-Json -Compress))) + $params = @{} if ($title -ne '*') { $params.TaskName = $title @@ -42,13 +45,19 @@ if ($recursive -eq 'true') { $params.TaskPath = $folder } +$sw = [System.Diagnostics.Stopwatch]::StartNew() try { $tasks = Get-ScheduledTask @params -ErrorAction Stop } catch { $tasks = @() } +$sw.Stop() +[Console]::Error.WriteLine(('Get-ScheduledTask took {0:F2} ms' -f $sw.Elapsed.TotalMilliseconds)) +$sw = [System.Diagnostics.Stopwatch]::StartNew() $taskInfos = $tasks | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue +$sw.Stop() +[Console]::Error.WriteLine(('Get-ScheduledTaskInfo took {0:F2} ms' -f $sw.Elapsed.TotalMilliseconds)) $infoMap = @{} @@ -57,6 +66,7 @@ foreach ($info in $taskInfos) { $infoMap[$key] = $info } +$sw = [System.Diagnostics.Stopwatch]::StartNew() $results = [System.Collections.Generic.List[object]]::new() foreach ($task in $tasks) { $taskInfo = $infoMap[$task.TaskPath + $task.TaskName] @@ -151,9 +161,17 @@ foreach ($task in $tasks) { } ) } +$sw.Stop() +[Console]::Error.WriteLine(('Populating results list took {0:F2} ms' -f $sw.Elapsed.TotalMilliseconds)) +[Console]::Error.WriteLine(('Results list has {0} elements' -f $results.Count)) +$sw = [System.Diagnostics.Stopwatch]::StartNew() if ($results.Count -gt 0) { ConvertTo-Json -InputObject $results -Depth 4 } else { '[]' } +$sw.Stop() +[Console]::Error.WriteLine(('Converting to JSON took {0:F2} ms' -f $sw.Elapsed.TotalMilliseconds)) + +exit 0 \ No newline at end of file From 29a648a29e78d2069f166119bbb09a650014f0be Mon Sep 17 00:00:00 2001 From: Ahmet Ozturk Date: Mon, 22 Jun 2026 16:31:26 +0200 Subject: [PATCH 5/5] check_tasksched: switch to using Schedule.Service COM api instead of Get-ScheduledTask in the scheduled task discovery script Get-ScheduledTask would import the module, and importing a module can be intercepted by antivirus. This took around 20 seconds in each call on some machines. Do away with the module imports completely, use the deeper APIs of COM objects. --- .../embed/scripts/windows/scheduled_tasks.ps1 | 186 +++++++----------- 1 file changed, 74 insertions(+), 112 deletions(-) diff --git a/pkg/snclient/embed/scripts/windows/scheduled_tasks.ps1 b/pkg/snclient/embed/scripts/windows/scheduled_tasks.ps1 index 64718c37..59d4ee16 100644 --- a/pkg/snclient/embed/scripts/windows/scheduled_tasks.ps1 +++ b/pkg/snclient/embed/scripts/windows/scheduled_tasks.ps1 @@ -1,9 +1,12 @@ -# list scheduled tasks in json format +# list scheduled tasks in json format +# this version uses the Schedule.Service COM API +# it avoids importing the ScheduledTasks module, which can be extremely slow +# on machines with EDR/antivirus solutions that scan modules via AMSI # usage: .\scheduled_tasks.ps1 [-title ] [-folder ] [-recursive ] # Parse named arguments (for standalone invocation). -# When called via snclient, variables are injected at the top of the script instead, -# so $args will be empty and this loop does nothing. +# When called via snclient, parameters are defined at the top of the script +# the parameters will be parsed without looking at $args if ($args) { for ($i = 0; $i -lt $args.Count; $i++) { if ($args[$i] -eq '-title' -and $i + 1 -lt $args.Count) { @@ -24,10 +27,10 @@ if ($args) { } } -# Apply defaults when variables are not defined (neither by snclient injection nor by args) +# Apply defaults when variables are not defined (neither by snclient parameter injection nor by args) if (!$title) { $title = '*' } if (!$folder) { $folder = '\' } -if (!$recursive) { $recursive = 'false' } +if (!$recursive) { $recursive = 'true' } # ensure output is utf8 $OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::UTF8 @@ -35,129 +38,88 @@ $OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::UTF8 # Print powershell version [Console]::Error.WriteLine(('Powershell version table: ' + ($PSVersionTable | ConvertTo-Json -Compress))) -$params = @{} -if ($title -ne '*') { - $params.TaskName = $title -} -if ($recursive -eq 'true') { - $params.TaskPath = $folder + '*' -} else { - $params.TaskPath = $folder -} +$sw = [System.Diagnostics.Stopwatch]::StartNew() +$scheduler = New-Object -ComObject Schedule.Service +$scheduler.Connect() +$sw.Stop() +[Console]::Error.WriteLine(('COM Schedule.Service connect took {0:F2} ms' -f $sw.Elapsed.TotalMilliseconds)) $sw = [System.Diagnostics.Stopwatch]::StartNew() +$tasks = [System.Collections.Generic.List[object]]::new() try { - $tasks = Get-ScheduledTask @params -ErrorAction Stop + $targetFolder = $scheduler.GetFolder($folder) + $folderQueue = [System.Collections.Queue]::new() + $folderQueue.Enqueue($targetFolder) + while ($folderQueue.Count -gt 0) { + $currentFolder = $folderQueue.Dequeue() + # TASK_ENUM_HIDDEN = 1, include hidden tasks + # Call GetTasks() using TASK_ENUM_HIDDEN + foreach ($t in $currentFolder.GetTasks(1)) { + $tasks.Add($t) + } + if ($recursive -eq 'true') { + foreach ($sub in $currentFolder.GetFolders(0)) { + $folderQueue.Enqueue($sub) + } + } + } } catch { - $tasks = @() + $tasks = [System.Collections.Generic.List[object]]::new() } $sw.Stop() -[Console]::Error.WriteLine(('Get-ScheduledTask took {0:F2} ms' -f $sw.Elapsed.TotalMilliseconds)) - -$sw = [System.Diagnostics.Stopwatch]::StartNew() -$taskInfos = $tasks | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue -$sw.Stop() -[Console]::Error.WriteLine(('Get-ScheduledTaskInfo took {0:F2} ms' -f $sw.Elapsed.TotalMilliseconds)) +[Console]::Error.WriteLine(('Task enumeration took {0:F2} ms' -f $sw.Elapsed.TotalMilliseconds)) -$infoMap = @{} - -foreach ($info in $taskInfos) { - $key = $info.TaskPath + $info.TaskName - $infoMap[$key] = $info +if ($title -ne '*') { + $filtered = [System.Collections.Generic.List[object]]::new() + foreach ($t in $tasks) { + if ($t.Name -eq $title) { + $filtered.Add($t) + } + } + $tasks = $filtered } $sw = [System.Diagnostics.Stopwatch]::StartNew() $results = [System.Collections.Generic.List[object]]::new() foreach ($task in $tasks) { - $taskInfo = $infoMap[$task.TaskPath + $task.TaskName] - - # Get-ScheduledTask returns a nested object - # Subobjects are not fully serialized and sent, only some of their fields are specifically selected - - # Get-ScheduledTask -TaskName "XYZ" | Select-Object -ExpandProperty Actions | Get-Member -MemberType Property - # This one should be exported, as a complete object. It is an array, and only the last ones execute, parameters and working directory are picked - $actions = @($task.Actions | ForEach-Object { - [PSCustomObject]@{ - Arguments = $_.Arguments - Execute = $_.Execute - Id = $_.Id - PSComputerName = $_.PSComputerName - WorkingDirectory = $_.WorkingDirectory + $def = $task.Definition + $taskPath = $task.Path.Substring(0, $task.Path.Length - $task.Name.Length) + + $actions = [System.Collections.Generic.List[object]]::new() + foreach ($action in $def.Actions) { + # COM IAction.Type: 0 = TASK_ACTION_EXEC (the only type with Path/Arguments/WorkingDirectory) + if ($action.Type -eq 0) { + $actions.Add( + [PSCustomObject]@{ + Arguments = [string]$action.Arguments + Execute = [string]$action.Path + Id = [string]$action.Id + PSComputerName = '' + WorkingDirectory = [string]$action.WorkingDirectory + } + ) } - }) - - # Get-ScheduledTask -TaskName "XYZ" | Select-Object -ExpandProperty Triggers | Get-Member -MemberType Property - # $triggers = @($task.Triggers | ForEach-Object { - # [PSCustomObject]@{ - # DaysInterval = $_.DaysInterval - # Enabled = $_.Enabled - # EndBoundary = $_.EndBoundary - # ExecutionTimeLimit = $_.ExecutionTimeLimit - # Id = $_.Id - # RandomDelay = $_.RandomDelay - # Repetition = $_.Repetition - # StartBoundary = $_.StartBoundary - # } - # }) - - # Get-ScheduledTask -TaskName "XYZ" | Select-Object -ExpandProperty Settings | Get-Member -MemberType Property - # $settings = [PSCustomObject]@{ - # AllowDemandStart = $task.Settings.AllowDemandStart - # AllowHardTerminate = $task.Settings.AllowHardTerminate - # DeleteExpiredTaskAfter = $task.Settings.DeleteExpiredTaskAfter - # DisallowStartIfOnBatteries = $task.Settings.DisallowStartIfOnBatteries - # DisallowStartOnRemoteAppSession = $task.Settings.DisallowStartOnRemoteAppSession - # Enabled = $task.Settings.Enabled - # ExecutionTimeLimit = $task.Settings.ExecutionTimeLimit - # Hidden = $task.Settings.Hidden - # IdleSettings = $task.Settings.IdleSettings - # MaintenanceSettings = $task.Settings.MaintenanceSettings - # NetworkSettings = $task.Settings.NetworkSettings - # Priority = $task.Settings.Priority - # PSComputerName = $task.Settings.PSComputerName - # RestartCount = $task.Settings.RestartCount - # RestartInterval = $task.Settings.RestartInterval - # RunOnlyIfIdle = $task.Settings.RunOnlyIfIdle - # RunOnlyIfNetworkAvailable = $task.Settings.RunOnlyIfNetworkAvailable - # StartWhenAvailable = $task.Settings.StartWhenAvailable - # StopIfGoingOnBatteries = $task.Settings.StopIfGoingOnBatteries - # UseUnifiedSchedulingEngine = $task.Settings.UseUnifiedSchedulingEngine - # Volatile = $task.Settings.Volatile - # WakeToRun = $task.Settings.WakeToRun - # } - - # Get-ScheduledTask -TaskName "XYZ" | Select-Object -ExpandProperty Principal | Get-Member -MemberType Property - # $principal = [PSCustomObject]@{ - # DisplayName = $task.Principal.DisplayName - # Id = $task.Principal.Id - # GroupId = $task.Principal.GroupId - # PSComputerName = $task.Principal.PSComputerName - # RequiredPrivilege = $task.Principal.RequiredPrivilege - # UserId = $task.Principal.UserId - # } + } - # Combine task properties with task info properties - # Get-ScheduledTask -TaskName "XYZ" | Get-Member -MemberType Property - # Get-ScheduledTaskInfo -TaskName "XYZ" | Get-Member -MemberType Property $results.Add( [PSCustomObject]@{ - TaskName = $task.TaskName - TaskPath = $task.TaskPath - State = $task.State - Description = $task.Description - PSComputerName = $task.PSComputerName - URI = $task.URI - Version = $task.Version - LastRunTime = $taskInfo.LastRunTime - LastTaskResult = $taskInfo.LastTaskResult - NextRunTime = $taskInfo.NextRunTime - NumberOfMissedRuns = $taskInfo.NumberOfMissedRuns - UserId = $task.Principal.UserId - Enabled = $task.Settings.Enabled - Priority = $task.Settings.Priority - Hidden = $task.Settings.Hidden - ExecutionTimeLimit = $task.Settings.ExecutionTimeLimit - Actions = $actions + TaskName = $task.Name + TaskPath = $taskPath + State = [int]$task.State + Description = [string]$def.RegistrationInfo.Description + PSComputerName = '' + URI = $task.Path + Version = [string]$def.RegistrationInfo.Version + LastRunTime = $task.LastRunTime + LastTaskResult = [BitConverter]::ToUInt32([BitConverter]::GetBytes([int32]$task.LastTaskResult), 0) + NextRunTime = $task.NextRunTime + NumberOfMissedRuns = [int64]$task.NumberOfMissedRuns + UserId = [string]$def.Principal.UserId + Enabled = [bool]$task.Enabled + Priority = [int64]$def.Settings.Priority + Hidden = [bool]$def.Settings.Hidden + ExecutionTimeLimit = [string]$def.Settings.ExecutionTimeLimit + Actions = @($actions) } ) }