From 83a8f73833299ffa295c57dac807ebc82bac68e2 Mon Sep 17 00:00:00 2001 From: Subham Ray Date: Tue, 23 Jun 2026 22:57:41 +0530 Subject: [PATCH] fix(schtasks): use DAILY schedule for scan frequencies of 24h+ schtasks caps the HOURLY /mo modifier at 23, so a 24-hour scan frequency produced `/sc HOURLY /mo 24`, which schtasks rejects with "Invalid value for /MO option". The install custom action then exits non-zero and the MSI rolls back, surfacing under Intune as 0x80070643 (and failing identically on a manual MSI install, since the deferred custom action runs the same command). Map intervals of 24h or more to `/sc DAILY /mo ` (24->1, 48->2), clamped to the DAILY ceiling of 365 and floored at 1, so no scan-frequency value can produce an invalid /mo. Sub-24h intervals keep the existing HOURLY behavior unchanged. Add scheduleFor() with table-driven unit coverage (boundaries 23/24, the 24h case, and pathological 0/100000 inputs) plus a regression guard that the hourly path and the admin /ru INTERACTIVE binding are untouched. --- internal/schtasks/schtasks.go | 23 ++++++++++- internal/schtasks/schtasks_test.go | 61 ++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/internal/schtasks/schtasks.go b/internal/schtasks/schtasks.go index b373270..d3999c9 100644 --- a/internal/schtasks/schtasks.go +++ b/internal/schtasks/schtasks.go @@ -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...) @@ -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` diff --git a/internal/schtasks/schtasks_test.go b/internal/schtasks/schtasks_test.go index 4532260..6d72204 100644 --- a/internal/schtasks/schtasks_test.go +++ b/internal/schtasks/schtasks_test.go @@ -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 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) + } + } +}