Skip to content

Commit 64ef737

Browse files
feat(windows): add scheduled tasks
Signed-off-by: Swarit Pandey <swarit@stepsecurity.io>
1 parent f988cd3 commit 64ef737

4 files changed

Lines changed: 247 additions & 15 deletions

File tree

cmd/stepsecurity-dev-machine-guard/main.go

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/step-security/dev-machine-guard/internal/launchd"
1313
"github.com/step-security/dev-machine-guard/internal/progress"
1414
"github.com/step-security/dev-machine-guard/internal/scan"
15+
"github.com/step-security/dev-machine-guard/internal/schtasks"
1516
"github.com/step-security/dev-machine-guard/internal/telemetry"
1617
)
1718

@@ -85,17 +86,20 @@ func main() {
8586

8687
case "install":
8788
_, _ = fmt.Fprintf(os.Stdout, "StepSecurity Dev Machine Guard v%s\n\n", buildinfo.Version)
88-
if runtime.GOOS == "windows" {
89-
log.Error("Scheduled scanning is not yet supported on Windows. Use the scan command directly.")
90-
os.Exit(1)
91-
}
9289
if !config.IsEnterpriseMode() {
9390
log.Error("Enterprise configuration not found. Run '%s configure' or download the script from your StepSecurity dashboard.", os.Args[0])
9491
os.Exit(1)
9592
}
96-
if err := launchd.Install(exec, log); err != nil {
97-
log.Error("%v", err)
98-
os.Exit(1)
93+
if runtime.GOOS == "windows" {
94+
if err := schtasks.Install(exec, log); err != nil {
95+
log.Error("%v", err)
96+
os.Exit(1)
97+
}
98+
} else {
99+
if err := launchd.Install(exec, log); err != nil {
100+
log.Error("%v", err)
101+
os.Exit(1)
102+
}
99103
}
100104
log.Progress("Sending initial telemetry...")
101105
fmt.Println()
@@ -107,12 +111,15 @@ func main() {
107111
case "uninstall":
108112
_, _ = fmt.Fprintf(os.Stdout, "StepSecurity Dev Machine Guard v%s\n\n", buildinfo.Version)
109113
if runtime.GOOS == "windows" {
110-
log.Error("Scheduled scanning is not yet supported on Windows. Use the scan command directly.")
111-
os.Exit(1)
112-
}
113-
if err := launchd.Uninstall(exec, log); err != nil {
114-
log.Error("%v", err)
115-
os.Exit(1)
114+
if err := schtasks.Uninstall(exec, log); err != nil {
115+
log.Error("%v", err)
116+
os.Exit(1)
117+
}
118+
} else {
119+
if err := launchd.Uninstall(exec, log); err != nil {
120+
log.Error("%v", err)
121+
os.Exit(1)
122+
}
116123
}
117124

118125
default:

internal/cli/cli.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,8 @@ Usage: %s [COMMAND] [OPTIONS]
116116
Commands:
117117
configure Configure enterprise settings and search directories
118118
configure show Show current configuration
119-
install Install launchd for periodic scanning (enterprise)
120-
uninstall Remove launchd configuration (enterprise)
119+
install Install scheduled scanning (enterprise)
120+
uninstall Remove scheduled scanning (enterprise)
121121
send-telemetry Upload scan results to the StepSecurity dashboard (enterprise)
122122
123123
Output formats (community mode, mutually exclusive):

internal/schtasks/schtasks.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package schtasks
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"strconv"
8+
9+
"github.com/step-security/dev-machine-guard/internal/config"
10+
"github.com/step-security/dev-machine-guard/internal/executor"
11+
"github.com/step-security/dev-machine-guard/internal/progress"
12+
)
13+
14+
const taskName = "StepSecurity Agent"
15+
16+
// Install configures Windows Task Scheduler for periodic scanning.
17+
// If already installed, upgrades by removing and re-creating the task.
18+
func Install(exec executor.Executor, log *progress.Logger) error {
19+
ctx := context.Background()
20+
21+
// Check for existing installation and upgrade
22+
if isConfigured(ctx, exec) {
23+
log.Progress("Existing agent installation detected. Upgrading...")
24+
if err := doUninstall(ctx, exec, log); err != nil {
25+
log.Progress("Warning: failed to remove previous installation: %v", err)
26+
}
27+
log.Progress("Previous installation removed. Installing new version...")
28+
}
29+
30+
binaryPath, err := os.Executable()
31+
if err != nil {
32+
return fmt.Errorf("determining binary path: %w", err)
33+
}
34+
35+
hours, _ := strconv.Atoi(config.ScanFrequencyHours)
36+
if hours <= 0 {
37+
hours = 4
38+
}
39+
40+
logDir := resolveLogDir(exec)
41+
if err := os.MkdirAll(logDir, 0o755); err != nil {
42+
return fmt.Errorf("creating log directory: %w", err)
43+
}
44+
45+
// Build the task command with output redirection
46+
taskCmd := fmt.Sprintf(`cmd /c "\"%s\" send-telemetry >> \"%s\agent.log\" 2>> \"%s\agent.error.log\""`,
47+
binaryPath, logDir, logDir)
48+
49+
// Build schtasks arguments
50+
args := []string{"/create", "/tn", taskName, "/tr", taskCmd,
51+
"/sc", "HOURLY", "/mo", strconv.Itoa(hours), "/f"}
52+
if exec.IsRoot() {
53+
args = append(args, "/ru", "SYSTEM")
54+
}
55+
56+
_, stderr, exitCode, err := exec.Run(ctx, "schtasks", args...)
57+
if err != nil || exitCode != 0 {
58+
return fmt.Errorf("failed to create scheduled task: %s", stderr)
59+
}
60+
61+
log.Progress("Windows Task Scheduler configuration completed successfully")
62+
log.Progress(" Task: %s", taskName)
63+
log.Progress(" Logs: %s\\agent.log", logDir)
64+
log.Progress("Installation complete!")
65+
log.Progress("The agent will now run automatically every %d hours", hours)
66+
67+
return nil
68+
}
69+
70+
// Uninstall removes the scheduled task.
71+
func Uninstall(exec executor.Executor, log *progress.Logger) error {
72+
ctx := context.Background()
73+
74+
if !isConfigured(ctx, exec) {
75+
log.Progress("Agent is not currently configured for periodic execution")
76+
return nil
77+
}
78+
79+
return doUninstall(ctx, exec, log)
80+
}
81+
82+
func doUninstall(ctx context.Context, exec executor.Executor, log *progress.Logger) error {
83+
_, stderr, exitCode, err := exec.Run(ctx, "schtasks", "/delete", "/tn", taskName, "/f")
84+
if err != nil || exitCode != 0 {
85+
return fmt.Errorf("failed to delete scheduled task: %s", stderr)
86+
}
87+
88+
log.Progress("Removed scheduled task: %s", taskName)
89+
log.Progress("Windows Task Scheduler configuration removed successfully")
90+
return nil
91+
}
92+
93+
func isConfigured(ctx context.Context, exec executor.Executor) bool {
94+
_, _, exitCode, _ := exec.Run(ctx, "schtasks", "/query", "/tn", taskName)
95+
return exitCode == 0
96+
}
97+
98+
func resolveLogDir(exec executor.Executor) string {
99+
if exec.IsRoot() {
100+
return `C:\ProgramData\StepSecurity`
101+
}
102+
homeDir, _ := exec.CurrentUser()
103+
if homeDir != nil {
104+
return homeDir.HomeDir + `\.stepsecurity`
105+
}
106+
return `C:\ProgramData\StepSecurity`
107+
}

internal/schtasks/schtasks_test.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package schtasks
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/step-security/dev-machine-guard/internal/config"
8+
"github.com/step-security/dev-machine-guard/internal/executor"
9+
"github.com/step-security/dev-machine-guard/internal/progress"
10+
)
11+
12+
func newTestLogger() *progress.Logger {
13+
return progress.NewLogger(false)
14+
}
15+
16+
func TestIsConfigured_True(t *testing.T) {
17+
mock := executor.NewMock()
18+
mock.SetGOOS("windows")
19+
mock.SetCommand("", "", 0, "schtasks", "/query", "/tn", taskName)
20+
21+
got := isConfigured(context.Background(), mock)
22+
if !got {
23+
t.Error("expected isConfigured to return true when task exists")
24+
}
25+
}
26+
27+
func TestIsConfigured_False(t *testing.T) {
28+
mock := executor.NewMock()
29+
mock.SetGOOS("windows")
30+
mock.SetCommand("", "ERROR: The system cannot find the path specified.", 1, "schtasks", "/query", "/tn", taskName)
31+
32+
got := isConfigured(context.Background(), mock)
33+
if got {
34+
t.Error("expected isConfigured to return false when task does not exist")
35+
}
36+
}
37+
38+
func TestUninstall_Configured(t *testing.T) {
39+
mock := executor.NewMock()
40+
mock.SetGOOS("windows")
41+
mock.SetCommand("", "", 0, "schtasks", "/query", "/tn", taskName)
42+
mock.SetCommand("SUCCESS: The scheduled task was successfully deleted.", "", 0, "schtasks", "/delete", "/tn", taskName, "/f")
43+
44+
err := Uninstall(mock, newTestLogger())
45+
if err != nil {
46+
t.Fatalf("expected no error, got %v", err)
47+
}
48+
}
49+
50+
func TestUninstall_NotConfigured(t *testing.T) {
51+
mock := executor.NewMock()
52+
mock.SetGOOS("windows")
53+
mock.SetCommand("", "ERROR: The system cannot find the path specified.", 1, "schtasks", "/query", "/tn", taskName)
54+
55+
err := Uninstall(mock, newTestLogger())
56+
if err != nil {
57+
t.Fatalf("expected no error, got %v", err)
58+
}
59+
}
60+
61+
func TestInstall_CreateFails(t *testing.T) {
62+
mock := executor.NewMock()
63+
mock.SetGOOS("windows")
64+
mock.SetHomeDir(`C:\Users\testuser`)
65+
// Task doesn't exist
66+
mock.SetCommand("", "ERROR: The system cannot find the path specified.", 1, "schtasks", "/query", "/tn", taskName)
67+
68+
// Note: Install calls os.Executable() and os.MkdirAll() which we can't mock,
69+
// but the schtasks /create will fail because we haven't stubbed it.
70+
err := Install(mock, newTestLogger())
71+
if err == nil {
72+
t.Fatal("expected error when schtasks /create is not stubbed")
73+
}
74+
}
75+
76+
func TestResolveLogDir_NonAdmin(t *testing.T) {
77+
mock := executor.NewMock()
78+
mock.SetGOOS("windows")
79+
mock.SetIsRoot(false)
80+
mock.SetHomeDir(`C:\Users\testuser`)
81+
82+
dir := resolveLogDir(mock)
83+
expected := `C:\Users\testuser\.stepsecurity`
84+
if dir != expected {
85+
t.Errorf("expected %s, got %s", expected, dir)
86+
}
87+
}
88+
89+
func TestResolveLogDir_Admin(t *testing.T) {
90+
mock := executor.NewMock()
91+
mock.SetGOOS("windows")
92+
mock.SetIsRoot(true)
93+
94+
dir := resolveLogDir(mock)
95+
expected := `C:\ProgramData\StepSecurity`
96+
if dir != expected {
97+
t.Errorf("expected %s, got %s", expected, dir)
98+
}
99+
}
100+
101+
func TestInstall_CustomFrequency(t *testing.T) {
102+
origFreq := config.ScanFrequencyHours
103+
t.Cleanup(func() { config.ScanFrequencyHours = origFreq })
104+
config.ScanFrequencyHours = "6"
105+
106+
mock := executor.NewMock()
107+
mock.SetGOOS("windows")
108+
mock.SetIsRoot(false)
109+
mock.SetHomeDir(`C:\Users\testuser`)
110+
// Task doesn't exist
111+
mock.SetCommand("", "ERROR: not found", 1, "schtasks", "/query", "/tn", taskName)
112+
113+
// Install will fail at os.Executable or schtasks create, but we're testing
114+
// that the frequency is parsed correctly via the resolveLogDir and config paths.
115+
// A full integration test requires the real binary on Windows.
116+
_ = Install(mock, newTestLogger())
117+
// If we got past the config parsing without panic, the frequency was handled correctly.
118+
}

0 commit comments

Comments
 (0)