Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion internal/schtasks/schtasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ func Install(exec executor.Executor, log *progress.Logger) error {
stepHome := logDir

taskBinary := resolveTaskBinary(exec, binaryPath)
hourlyArgs := buildCreateArgs(taskName, taskBinary, stepHome, []string{"/sc", "HOURLY", "/mo", strconv.Itoa(hours)}, exec.IsRoot())
hourlyArgs := buildCreateArgs(taskName, taskBinary, stepHome, scheduleFor(hours), exec.IsRoot())
log.Debug("schtasks create: task_binary=%q agent=%q install_dir=%q hours=%d is_admin=%v", taskBinary, binaryPath, stepHome, hours, exec.IsRoot())

_, stderr, exitCode, err := exec.Run(ctx, "schtasks", hourlyArgs...)
Expand Down Expand Up @@ -236,6 +236,27 @@ func buildCreateArgs(name, binaryPath, stepHome string, schedule []string, isAdm
return args
}

// scheduleFor maps a desired scan interval in hours to the schtasks schedule
// spec (the /sc + /mo flags) for the periodic task. schtasks caps the HOURLY
// modifier at 23: `/sc HOURLY /mo 24` is rejected with "Invalid value for /MO
// option", which rolls back MSI/Intune installs configured with the dashboard's
// daily "24". An interval of 24h or more is therefore emitted as a DAILY
// schedule with the interval floored to whole days (24→1, 48→2); a remainder is
// dropped since schtasks cannot express a mixed day+hour recurrence and MINUTE
// itself tops out at 1439 = 23h59m. Sub-24h intervals keep the original HOURLY
// behavior unchanged. /mo is clamped to each schedule's valid ceiling (HOURLY
// 23, DAILY 365) and floored at 1 so no scan-frequency value can ever produce
// an invalid /mo and fail the install.
func scheduleFor(hours int) []string {
if hours >= 24 {
return []string{"/sc", "DAILY", "/mo", strconv.Itoa(min(hours/24, 365))}
}
if hours < 1 {
hours = 1
}
return []string{"/sc", "HOURLY", "/mo", strconv.Itoa(hours)}
}

func resolveLogDir(exec executor.Executor) string {
if exec.IsRoot() {
return `C:\ProgramData\StepSecurity`
Expand Down
61 changes: 61 additions & 0 deletions internal/schtasks/schtasks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -399,3 +399,64 @@ func TestDecodeTaskXML_Paths(t *testing.T) {
})
}
}

// scheduleFor must keep sub-24h intervals on the unchanged HOURLY path and
// switch 24h+ intervals to DAILY. schtasks rejects `/sc HOURLY /mo 24`
// ("Invalid value for /MO option"), which rolled back the Coveo MSI/Intune
// install. /mo must never fall outside a schedule's valid range (HOURLY 1-23,
// DAILY 1-365).
func TestScheduleFor(t *testing.T) {
cases := []struct {
hours int
wantSched string
wantMod string
}{
{1, "HOURLY", "1"}, // floor of the HOURLY range
{4, "HOURLY", "4"}, // built-in default frequency
{12, "HOURLY", "12"},
{23, "HOURLY", "23"}, // ceiling of the HOURLY range
{24, "DAILY", "1"}, // the Coveo case: 24h must become a daily task
{48, "DAILY", "2"},
{72, "DAILY", "3"},
{0, "HOURLY", "1"}, // pathological: never emit /mo 0
{100000, "DAILY", "365"}, // pathological: clamp to the DAILY ceiling
}
for _, c := range cases {
spec := scheduleFor(c.hours)
if !argPairPresent(spec, "/sc", c.wantSched) {
t.Errorf("scheduleFor(%d) = %v, want /sc %s", c.hours, spec, c.wantSched)
}
if !argPairPresent(spec, "/mo", c.wantMod) {
t.Errorf("scheduleFor(%d) = %v, want /mo %s", c.hours, spec, c.wantMod)
}
}
}

// The 24h+ daily switch must reach the actual schtasks /create arguments,
// and must not disturb the admin /ru INTERACTIVE binding.
func TestBuildCreateArgs_DailyForTwentyFourHours(t *testing.T) {
args := buildCreateArgs(taskName, `C:\agent.exe`, `C:\ProgramData\StepSecurity`, scheduleFor(24), true)
if !argPairPresent(args, "/sc", "DAILY") {
t.Errorf("expected /sc DAILY for 24h: %v", args)
}
if !argPairPresent(args, "/mo", "1") {
t.Errorf("expected /mo 1 for 24h: %v", args)
}
if !argPairPresent(args, "/ru", "INTERACTIVE") {
t.Errorf("expected /ru INTERACTIVE preserved on daily schedule: %v", args)
}
}

// Regression guard: sub-24h intervals must still emit the original
// /sc HOURLY /mo <hours> form untouched.
func TestBuildCreateArgs_HourlyUnchanged(t *testing.T) {
for _, h := range []int{1, 4, 12, 23} {
args := buildCreateArgs(taskName, `C:\agent.exe`, `C:\logs`, scheduleFor(h), false)
if !argPairPresent(args, "/sc", "HOURLY") {
t.Errorf("hours=%d: expected /sc HOURLY: %v", h, args)
}
if !argPairPresent(args, "/mo", strconv.Itoa(h)) {
t.Errorf("hours=%d: expected /mo %d: %v", h, h, args)
}
}
}
Loading