Skip to content
Open
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
24 changes: 23 additions & 1 deletion internal/schtasks/schtasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,9 @@ func resolveTaskBinary(exec executor.Executor, agentPath string) string {
// source of a visible cmd.exe flash on every fire.
func buildCreateArgs(binaryPath, stepHome string, hours int, isAdmin bool) []string {
taskCmd := fmt.Sprintf(`"%s" send-telemetry --install-dir="%s"`, binaryPath, stepHome)
schedule, modifier := scheduleFor(hours)
args := []string{"/create", "/tn", taskName, "/tr", taskCmd,
"/sc", "HOURLY", "/mo", strconv.Itoa(hours), "/f"}
"/sc", schedule, "/mo", strconv.Itoa(modifier), "/f"}
if isAdmin {
// /ru INTERACTIVE binds the task to the NT AUTHORITY\INTERACTIVE
// well-known group (SID S-1-5-4) so it fires under the security
Expand All @@ -196,6 +197,27 @@ func buildCreateArgs(binaryPath, stepHome string, hours int, isAdmin bool) []str
return args
}

// scheduleFor maps a desired scan interval in hours to a schtasks
// (/sc, /mo) pair. 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) (schedule string, modifier int) {
if hours >= 24 {
return "DAILY", min(hours/24, 365)
}
if hours < 1 {
hours = 1
}
return "HOURLY", hours
}

func resolveLogDir(exec executor.Executor) string {
if exec.IsRoot() {
return `C:\ProgramData\StepSecurity`
Expand Down
70 changes: 70 additions & 0 deletions internal/schtasks/schtasks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,25 @@ package schtasks

import (
"context"
"strconv"
"strings"
"testing"

"github.com/step-security/dev-machine-guard/internal/executor"
"github.com/step-security/dev-machine-guard/internal/progress"
)

// flagValue returns the argument immediately following the given flag in a
// schtasks argument slice, or "" when the flag is absent.
func flagValue(args []string, flag string) string {
for i, a := range args {
if a == flag && i+1 < len(args) {
return args[i+1]
}
}
return ""
}

func newTestLogger() *progress.Logger {
return progress.NewLogger(progress.LevelInfo)
}
Expand Down Expand Up @@ -235,3 +247,61 @@ func TestBuildCreateArgs_TaskCommandFormat(t *testing.T) {
t.Errorf("task command must not redirect output or set env vars: %q", taskCmd)
}
}

// 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 int
}{
{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 {
sched, mod := scheduleFor(c.hours)
if sched != c.wantSched || mod != c.wantMod {
t.Errorf("scheduleFor(%d) = (%s, %d), want (%s, %d)", c.hours, sched, mod, c.wantSched, 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(`C:\agent.exe`, `C:\ProgramData\StepSecurity`, 24, true)
if got := flagValue(args, "/sc"); got != "DAILY" {
t.Errorf("expected /sc DAILY for 24h, got /sc %s", got)
}
if got := flagValue(args, "/mo"); got != "1" {
t.Errorf("expected /mo 1 for 24h, got /mo %s", got)
}
if got := flagValue(args, "/ru"); got != "INTERACTIVE" {
t.Errorf("expected /ru INTERACTIVE preserved on daily schedule, got /ru %s", got)
}
}

// 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(`C:\agent.exe`, `C:\logs`, h, false)
if got := flagValue(args, "/sc"); got != "HOURLY" {
t.Errorf("hours=%d: expected /sc HOURLY, got /sc %s", h, got)
}
if got := flagValue(args, "/mo"); got != strconv.Itoa(h) {
t.Errorf("hours=%d: expected /mo %d, got /mo %s", h, h, got)
}
}
}
Loading