diff --git a/docs/checks/commands/check_tasksched.md b/docs/checks/commands/check_tasksched.md index 2594624d..dcf6cd5c 100644 --- a/docs/checks/commands/check_tasksched.md +++ b/docs/checks/commands/check_tasksched.md @@ -56,10 +56,11 @@ Naemon Config | Argument | Description | | --------- | --------------------------------------------------------------------------------------------------------- | -| folder | The folder where the scheduled task is saved. This is used for exact matches, unless recurisive option is enabled. | -| recursive | Include the subfolders of the specified folder as well when searching for scheduled tasks. | +| folder | The folder where the scheduled task is saved. This is used for exact matches, unless recurisive option is enabled. Default: '\' | +| hidden | Include hidden tasks. Default: 'false' | +| recursive | Include the subfolders of the specified folder as well when searching for scheduled tasks. Default: 'true' | | timezone | Sets the timezone for time metrics (default is local time) | -| title | Sets the task to check. This corresonds to the title of the scheduled task, called TaskName in Powershell output. | +| title | Sets the task to check. This corresonds to the title of the scheduled task. Default: '\*' | ## Attributes @@ -89,4 +90,4 @@ these can be used in filters and thresholds (along with the default attributes): | next_run_time | Time when the registered task is next scheduled to run | | parameters | Last actions command line parameters | | execute | Last actions executed program | -| working_dir | Last actions working directory | +| working_directory | Last actions working directory | diff --git a/pkg/snclient/check_tasksched.go b/pkg/snclient/check_tasksched.go index fd0beea8..da0c2f07 100644 --- a/pkg/snclient/check_tasksched.go +++ b/pkg/snclient/check_tasksched.go @@ -14,12 +14,14 @@ type CheckTasksched struct { TaskTitle string Folder string Recursive bool + Hidden bool } const ( CheckTaskschedDefaultTaskTitle string = "*" CheckTaskschedDefaultFolder string = "\\" CheckTaskschedDefaultRecursive bool = true + CheckTaskschedDefaultHidden bool = false ) func NewCheckTasksched() CheckHandler { @@ -27,6 +29,7 @@ func NewCheckTasksched() CheckHandler { TaskTitle: CheckTaskschedDefaultTaskTitle, Folder: CheckTaskschedDefaultFolder, Recursive: CheckTaskschedDefaultRecursive, + Hidden: CheckTaskschedDefaultHidden, } } @@ -39,10 +42,24 @@ func (l *CheckTasksched) Build() *CheckData { State: CheckExitOK, }, args: map[string]CheckArgument{ - "timezone": {description: "Sets the timezone for time metrics (default is local time)"}, - "title": {value: &l.TaskTitle, description: "Sets the task to check. This corresonds to the title of the scheduled task, called TaskName in Powershell output."}, - "folder": {value: &l.Folder, description: "The folder where the scheduled task is saved. This is used for exact matches, unless recurisive option is enabled."}, - "recursive": {value: &l.Recursive, description: "Include the subfolders of the specified folder as well when searching for scheduled tasks."}, + "timezone": {description: "Sets the timezone for time metrics (default is local time)"}, + "title": { + value: &l.TaskTitle, + description: fmt.Sprintf("Sets the task to check. This corresonds to the title of the scheduled task. Default: '%s'", CheckTaskschedDefaultTaskTitle), + }, + "folder": { + value: &l.Folder, + description: fmt.Sprintf("The folder where the scheduled task is saved. This is used for exact matches, unless recurisive option is enabled. Default: '%s'", + CheckTaskschedDefaultFolder), + }, + "recursive": { + value: &l.Recursive, + description: fmt.Sprintf("Include the subfolders of the specified folder as well when searching for scheduled tasks. Default: '%t'", CheckTaskschedDefaultRecursive), + }, + "hidden": { + value: &l.Hidden, + description: fmt.Sprintf("Include hidden tasks. Default: '%t'", CheckTaskschedDefaultHidden), + }, }, defaultFilter: "enabled = true", defaultCritical: "exit_code < 0", @@ -56,24 +73,24 @@ func (l *CheckTasksched) Build() *CheckData { {name: "application", description: "Name of the application that the task is associated with"}, {name: "comment", description: "Comment or description for the work item"}, {name: "creator", description: "Creator of the work item"}, - {name: "enabled", description: "Flag whether this job is enabled (true/false)"}, + {name: "enabled", description: "Flag whether this job is enabled (true/false)", unit: UBool}, {name: "exit_code", description: "The last jobs exit code"}, {name: "exit_string", description: "The last jobs exit code as string"}, {name: "folder", description: "Task folder"}, {name: "uri", description: "Fully qualified path to the task, includes folder and the task title"}, {name: "uri_clean", description: "Remove the leading backslash from the URI, only for tasks directly saved at root and not for ones saved inside folders."}, - {name: "has_run", description: "True if this task has ever been executed"}, + {name: "has_run", description: "True if this task has ever been executed", unit: UBool}, {name: "max_run_time", description: "Maximum length of time the task can run", unit: UDuration}, {name: "most_recent_run_time", description: "Most recent time the work item began running", unit: UDate}, {name: "priority", description: "Task priority"}, {name: "title", description: "Task title"}, - {name: "hidden", description: "Indicates that the task will not be visible in the UI (true/false)"}, + {name: "hidden", description: "Indicates that the task will not be visible in the UI (true/false)", unit: UBool}, {name: "missed_runs", description: "Number of times the registered task has missed a scheduled run"}, {name: "task_status", description: "Task status as string"}, {name: "next_run_time", description: "Time when the registered task is next scheduled to run", unit: UDate}, {name: "parameters", description: "Last actions command line parameters"}, {name: "execute", description: "Last actions executed program"}, - {name: "working_dir", description: "Last actions working directory"}, + {name: "working_directory", description: "Last actions working directory"}, }, exampleDefault: ` check_tasksched diff --git a/pkg/snclient/check_tasksched_windows.go b/pkg/snclient/check_tasksched_windows.go index 6029d68d..a3e200bd 100644 --- a/pkg/snclient/check_tasksched_windows.go +++ b/pkg/snclient/check_tasksched_windows.go @@ -6,6 +6,7 @@ import ( "context" _ "embed" "fmt" + "os/exec" "slices" "strconv" "strings" @@ -18,16 +19,13 @@ import ( //go:embed embed/scripts/windows/scheduled_tasks.ps1 var scheduledTasksPS1 string -//nolint:funlen // function is long, but is simple, should not be dismantled -func (l *CheckTasksched) addTasks(ctx context.Context, snc *Agent, check *CheckData) error { - script := scheduledTasksPS1 +// if the task is not run, this date is reported +// corresponds to 1999-11-30 00:00:00 CET +// the number is unix miliseconds +const notRunDate = "/Date(943916400000)/" - // Add backslash to the beginning of the folder path if it does not exist - if l.Folder != CheckTaskschedDefaultFolder { - if !strings.HasPrefix(l.Folder, "\\") { - l.Folder = "\\" + l.Folder - } - } +func (l *CheckTasksched) addTasks(ctx context.Context, snc *Agent, check *CheckData) error { + l.cleanupArguments() titleRuneBlacklist := []rune{'\\', '/', ':', '*', '?', '"', '<', '>', '|'} if l.TaskTitle != CheckTaskschedDefaultTaskTitle { @@ -44,33 +42,7 @@ func (l *CheckTasksched) addTasks(ctx context.Context, snc *Agent, check *CheckD } } - cmd, err := powerShellCmd( - ctx, script, - PowerShellParameter{ - name: "title", - parameterType: "string", - specifyDefaultValue: true, - defaultValue: CheckTaskschedDefaultTaskTitle, - specifyValue: true, - specifiedValue: l.TaskTitle, - }, - PowerShellParameter{ - name: "folder", - parameterType: "string", - specifyDefaultValue: true, - defaultValue: CheckTaskschedDefaultFolder, - specifyValue: true, - specifiedValue: l.Folder, - }, - PowerShellParameter{ - name: "recursive", - parameterType: "string", - specifyDefaultValue: true, - defaultValue: strconv.FormatBool(CheckTaskschedDefaultRecursive), - specifyValue: true, - specifiedValue: strconv.FormatBool(l.Recursive), - }, - ) + cmd, err := l.buildPowershellCmd(ctx) if err != nil { return fmt.Errorf("error when building a powershell command: %s", err.Error()) } @@ -93,7 +65,7 @@ func (l *CheckTasksched) addTasks(ctx context.Context, snc *Agent, check *CheckD for index := range taskList { task := taskList[index] hasRun := false - if task.LastRunTime != "" { + if task.LastRunTime != notRunDate { hasRun = true } @@ -118,7 +90,7 @@ func (l *CheckTasksched) addTasks(ctx context.Context, snc *Agent, check *CheckD "next_run_time": fmt.Sprintf("%d", l.parseDate(task.NextRunTime).Unix()), "parameters": l.parseParameters(task.Actions), "execute": l.parseExecuteCmd(task.Actions), - "working_dir": l.parseWorkingDir(task.Actions), + "working_directory": l.parseWorkingDir(task.Actions), } check.listData = append(check.listData, entry) } @@ -131,6 +103,63 @@ func (l *CheckTasksched) addTasks(ctx context.Context, snc *Agent, check *CheckD return nil } +func (l *CheckTasksched) cleanupArguments() { + // Add backslash to the beginning of the folder path if it does not exist + if l.Folder != CheckTaskschedDefaultFolder { + if !strings.HasPrefix(l.Folder, "\\") { + l.Folder = "\\" + l.Folder + } + } + + // Remove backslash at the end of the folder path, if it is not exactly root: "\" + // "\Microsoft\" -> "\Microsoft" + if l.Folder != CheckTaskschedDefaultFolder && l.Folder != "\\" { + if cut, cutOk := strings.CutSuffix(l.Folder, "\\"); cutOk { + l.Folder = cut + } + } +} + +func (l *CheckTasksched) buildPowershellCmd(ctx context.Context) (cmd *exec.Cmd, err error) { + cmd, err = powerShellCmd( + ctx, scheduledTasksPS1, + PowerShellParameter{ + name: "title", + parameterType: "string", + specifyDefaultValue: true, + defaultValue: CheckTaskschedDefaultTaskTitle, + specifyValue: true, + specifiedValue: l.TaskTitle, + }, + PowerShellParameter{ + name: "folder", + parameterType: "string", + specifyDefaultValue: true, + defaultValue: CheckTaskschedDefaultFolder, + specifyValue: true, + specifiedValue: l.Folder, + }, + PowerShellParameter{ + name: "recursive", + parameterType: "string", + specifyDefaultValue: true, + defaultValue: strconv.FormatBool(CheckTaskschedDefaultRecursive), + specifyValue: true, + specifiedValue: strconv.FormatBool(l.Recursive), + }, + PowerShellParameter{ + name: "hidden", + parameterType: "string", + specifyDefaultValue: true, + defaultValue: strconv.FormatBool(CheckTaskschedDefaultHidden), + specifyValue: true, + specifiedValue: strconv.FormatBool(l.Hidden), + }, + ) + + return cmd, err +} + func parseURIClean(uri string) string { if strings.Count(uri, "\\") == 1 { if cut, cutOk := strings.CutPrefix(uri, "\\"); cutOk { diff --git a/pkg/snclient/checkdata.go b/pkg/snclient/checkdata.go index 0103c14d..2948ed35 100644 --- a/pkg/snclient/checkdata.go +++ b/pkg/snclient/checkdata.go @@ -82,6 +82,7 @@ const ( UDate UTimestamp UPercent + UBool ) type CheckAttribute struct { diff --git a/pkg/snclient/condition.go b/pkg/snclient/condition.go index ec489c7e..c56dbb85 100644 --- a/pkg/snclient/condition.go +++ b/pkg/snclient/condition.go @@ -16,6 +16,10 @@ import ( ) var ( + // ^ and $ are used to capture the whole string + // (\-?\d+\.\d+|\-?\d+) -> capture group one, two options separated by | first is a decimal with dot , second is a direct number + // \s* -> any number of whitespace between the second group + // (\D+) -> capture group two, one or more non-digit characters reConditionValueUnit = regexp.MustCompile(`^(\-?\d+\.\d+|\-?\d+)\s*(\D+)$`) reCuddleKeyword = regexp.MustCompile(`^([A-Za-z_]+)([!=><~]+)(.*)$`) reCuddleOperator = regexp.MustCompile(`^([!=><~]+)(.*?)$`) @@ -816,13 +820,29 @@ func (c *Condition) expandDateKeyword(str string) bool { return false } +//nolint:funlen // the function is long due to handling all unit types, but it is simple func (c *Condition) expandUnitByType(str string) error { // valid units might be "today", "thisweek", "thismonth", "thisyear" and ":utc" variants unit := c.getUnit(c.keyword) - if unit == UDate || unit == UTimestamp { + + switch unit { + case UDate, UTimestamp: if done := c.expandDateKeyword(str); done { return nil } + case UBool: + // boolean units are not in the form of ' ' + // need to handle them before regex condition checks. + newVal, oldVal, err := utils.ParseAndReplaceBoolAttributes(str) + if err != nil { + return fmt.Errorf("invalid boolean value: %s", err.Error()) + } + c.original = oldVal + c.value = newVal + c.unit = "" + + return nil + default: } match := reConditionValueUnit.FindStringSubmatch(str) @@ -870,6 +890,8 @@ func (c *Condition) expandUnitByType(str string) error { return nil case UPercent: return nil + case UBool: + return nil // handled in the switch above case UNone: // best effort unit expansion return c.expandUnitByName(str) diff --git a/pkg/snclient/embed/scripts/windows/scheduled_tasks.ps1 b/pkg/snclient/embed/scripts/windows/scheduled_tasks.ps1 index 59d4ee16..aa675486 100644 --- a/pkg/snclient/embed/scripts/windows/scheduled_tasks.ps1 +++ b/pkg/snclient/embed/scripts/windows/scheduled_tasks.ps1 @@ -24,6 +24,11 @@ if ($args) { $i++ continue } + if ($args[$i] -eq '-hidden' -and $i + 1 -lt $args.Count) { + $hidden = $args[$i + 1] + $i++ + continue + } } } @@ -31,20 +36,14 @@ if ($args) { if (!$title) { $title = '*' } if (!$folder) { $folder = '\' } if (!$recursive) { $recursive = 'true' } +if (!$hidden) { $hidden = 'false' } # ensure output is utf8 $OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::UTF8 -# Print powershell version -[Console]::Error.WriteLine(('Powershell version table: ' + ($PSVersionTable | ConvertTo-Json -Compress))) - -$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 { $targetFolder = $scheduler.GetFolder($folder) @@ -54,7 +53,12 @@ try { $currentFolder = $folderQueue.Dequeue() # TASK_ENUM_HIDDEN = 1, include hidden tasks # Call GetTasks() using TASK_ENUM_HIDDEN - foreach ($t in $currentFolder.GetTasks(1)) { + if ($hidden -eq 'true'){ + $getTasksArg = 1 + } else { + $getTasksArg = 0 + } + foreach ($t in $currentFolder.GetTasks($getTasksArg)) { $tasks.Add($t) } if ($recursive -eq 'true') { @@ -66,8 +70,6 @@ try { } catch { $tasks = [System.Collections.Generic.List[object]]::new() } -$sw.Stop() -[Console]::Error.WriteLine(('Task enumeration took {0:F2} ms' -f $sw.Elapsed.TotalMilliseconds)) if ($title -ne '*') { $filtered = [System.Collections.Generic.List[object]]::new() @@ -79,7 +81,6 @@ if ($title -ne '*') { $tasks = $filtered } -$sw = [System.Diagnostics.Stopwatch]::StartNew() $results = [System.Collections.Generic.List[object]]::new() foreach ($task in $tasks) { $def = $task.Definition @@ -123,17 +124,11 @@ 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 diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index 78713a8e..c0a251e5 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -81,6 +81,67 @@ func ExpandDuration(val string) (res float64, err error) { return 0, fmt.Errorf("expandDuration: cannot parse duration, unknown format in %s", val) } +var ( + truthyValues = []string{"1", "true", "enabled", "yes", "y", "on", "t"} + falseyValues = []string{"0", "false", "disabled", "no", "n", "off", "f"} +) + +var ( + truthyRegex, truthyReplacements = replacePatterns(truthyValues, "true") + falseyRegex, falseyReplacements = replacePatterns(falseyValues, "false") +) + +// replacePatterns builds a regex that matches any of the given words as whole words, +// and returns the pattern and a replacement map (lowercase match → replacement). +func replacePatterns(words []string, replacement string) (regex *regexp.Regexp, replacements map[string]string) { + escaped := make([]string, len(words)) + replacementMap := make(map[string]string) + for i, word := range words { + lowercase := strings.ToLower(word) + escaped[i] = regexp.QuoteMeta(lowercase) + replacementMap[lowercase] = replacement + } + pattern := `(?i)\b(` + strings.Join(escaped, "|") + `)\b` + + return regexp.MustCompile(pattern), replacementMap +} + +// ParseAndReplaceBoolAttributes replaces all known truthy/falsey tokens in val +// with "true"/"false" (case‑insensitive whole‑word match). +// Returns the modified string, the original string, and an error if any +// unrecognized boolean‑like token is found (optional). +func ParseAndReplaceBoolAttributes(val string) (newVal, oldVal string, err error) { + oldVal = val + foundValues := 0 + + newVal = truthyRegex.ReplaceAllStringFunc(val, func(match string) string { + if replacement, ok := truthyReplacements[strings.ToLower(match)]; ok { + foundValues++ + + return replacement + } + + return "" + }) + + newVal = falseyRegex.ReplaceAllStringFunc(newVal, func(match string) string { + if replacement, ok := falseyReplacements[strings.ToLower(match)]; ok { + foundValues++ + + return replacement + } + + return "" + }) + + if foundValues == 0 { + err = fmt.Errorf("attribute of type bool can not be parsed: '%s' needs to be one of '%s' or '%s'", + val, strings.Join(falseyValues, " "), strings.Join(truthyValues, " ")) + } + + return newVal, oldVal, err +} + // returns time/duration in target unit with given precision func TimeUnitF(num uint64, targetUnit string, precision int) float64 { for _, factor := range TimeFactors {