Skip to content

Commit 2ec0cd5

Browse files
authored
Merge branch 'main' into swarit/chore/up-ver-1.11.5
2 parents 6ed54f0 + 2c625d3 commit 2ec0cd5

3 files changed

Lines changed: 318 additions & 29 deletions

File tree

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

Lines changed: 57 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,85 @@
11
//go:build windows
22

33
// Command stepsecurity-dev-machine-guard-task is a GUI-subsystem
4-
// launcher that invokes the console-subsystem agent under
4+
// launcher that invokes a console-subsystem child under
55
// CREATE_NO_WINDOW. Built with `-ldflags "-H windowsgui"`.
66
//
77
// Why a separate binary: Windows allocates a console for any
8-
// console-subsystem process whose parent has none. Task Scheduler
9-
// under /ru INTERACTIVE is such a parent, so the agent itself would
10-
// always flash a window. The only fully-reliable suppression is for
11-
// the parent CreateProcess call to pass CREATE_NO_WINDOW, and the
12-
// only way to be that parent without flashing our own console is to
13-
// be GUI-subsystem. The agent stays console-subsystem so interactive
14-
// CLI use (install, configure, manual scans) still works normally.
8+
// console-subsystem process whose parent has none. Task Scheduler under
9+
// /ru INTERACTIVE is such a parent, so a console-subsystem child
10+
// invoked directly would always flash a window. The only fully reliable
11+
// suppression is for the parent CreateProcess call to pass
12+
// CREATE_NO_WINDOW, and the only way to be that parent without flashing
13+
// our own console is to be GUI-subsystem.
1514
//
16-
// Layout: both binaries sit in the same directory. The scheduled task
17-
// points at this launcher; arguments forward unchanged.
15+
// Two operating modes (target-resolution lives in internal/launcher so
16+
// it can be unit-tested cross-platform):
1817
//
19-
// Lifecycle: the agent is assigned to a Job Object with
20-
// JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE so it dies when the launcher
21-
// does — including under Stop-ScheduledTask, which only terminates
22-
// the registered action's PID.
18+
// - Default. Invoked without --exec, the launcher spawns its sibling
19+
// stepsecurity-dev-machine-guard.exe and forwards argv unchanged.
20+
// This is what the MSI install layout's scheduled-task action uses.
21+
//
22+
// - --exec mode. Invoked as `task.exe --exec <exe> [args...]`, the
23+
// launcher spawns <exe> (exec.LookPath resolved) with the remaining
24+
// args. Used by the PowerShell loader's scheduled task to wrap
25+
// `powershell.exe -File loader.ps1 send-telemetry` in the same
26+
// no-console envelope the MSI flow uses for the agent.
27+
//
28+
// The agent (and any --exec target) stays console-subsystem so
29+
// interactive CLI use continues to work normally.
30+
//
31+
// Lifecycle: the child is assigned to a Job Object with
32+
// JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE so it dies when the launcher does
33+
// — including under Stop-ScheduledTask, which only terminates the
34+
// registered action's PID.
2335
package main
2436

2537
import (
2638
"errors"
39+
"fmt"
2740
"os"
2841
"os/exec"
29-
"path/filepath"
3042
"syscall"
3143
"unsafe"
3244

45+
"github.com/step-security/dev-machine-guard/internal/launcher"
3346
"golang.org/x/sys/windows"
3447
)
3548

36-
const (
37-
createNoWindow uint32 = 0x08000000
38-
agentBinary = "stepsecurity-dev-machine-guard.exe"
39-
)
49+
const createNoWindow uint32 = 0x08000000
4050

4151
func main() {
42-
os.Exit(run())
52+
os.Exit(run(os.Args[1:]))
4353
}
4454

45-
func run() int {
46-
me, err := os.Executable()
55+
// run is split out so the entrypoint stays one line. argv is the slice
56+
// after the program name (os.Args[1:] in main); accepting it explicitly
57+
// keeps the windows-only test path open if we add one later.
58+
func run(argv []string) int {
59+
target, childArgs, err := launcher.ResolveTarget(argv)
4760
if err != nil {
48-
return 1
49-
}
50-
agent := filepath.Join(filepath.Dir(me), agentBinary)
51-
if _, err := os.Stat(agent); err != nil {
61+
// Two distinct failure shapes, matched to the legacy contract:
62+
//
63+
// - Default mode (no --exec): the launcher silently exits 1.
64+
// This preserves byte-for-byte compatibility with MSI installs
65+
// that the pre-1.11.5 launcher served. Task Scheduler records
66+
// "LastTaskResult=1" — same value MSI deployments have always
67+
// observed when the sibling agent is absent. A behavioral
68+
// change here would shift downstream dashboards/alerts that
69+
// key on the result code.
70+
//
71+
// - --exec mode: the caller asked for a feature; surface the
72+
// concrete misuse (missing target, unresolved PATH, etc.) on
73+
// stderr with a distinct exit code so dispatch failures are
74+
// diagnosable.
75+
if len(argv) > 0 && argv[0] == launcher.ExecFlag {
76+
fmt.Fprintln(os.Stderr, err)
77+
return 2
78+
}
5279
return 1
5380
}
5481

55-
cmd := exec.Command(agent, os.Args[1:]...)
82+
cmd := exec.Command(target, childArgs...)
5683
cmd.SysProcAttr = &syscall.SysProcAttr{
5784
HideWindow: true,
5885
CreationFlags: createNoWindow,
@@ -62,10 +89,11 @@ func run() int {
6289
return 1
6390
}
6491

65-
// Best-effort: bind the agent to a kill-on-close job. The job
92+
// Best-effort: bind the child to a kill-on-close job. The job
6693
// handle stays open in this process; the kernel closes it on our
6794
// exit, which fires the kill. Failure here only weakens lifecycle
68-
// (orphan possible on forced termination), not the scan itself.
95+
// (orphan possible on forced termination), not the work the child
96+
// was started to do.
6997
if job, jerr := newKillOnCloseJob(); jerr == nil {
7098
if h, oerr := windows.OpenProcess(
7199
windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE,

internal/launcher/launcher.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Package launcher resolves the child process the GUI-subsystem
2+
// launcher (cmd/stepsecurity-dev-machine-guard-task) should spawn for a
3+
// given invocation. The launcher itself lives in cmd/ and is Windows-only
4+
// because it depends on Job Objects and CREATE_NO_WINDOW; the resolution
5+
// logic is platform-agnostic and lives here so it can be unit-tested on
6+
// the macOS CI runners that drive the rest of the test suite.
7+
package launcher
8+
9+
import (
10+
"fmt"
11+
"os"
12+
"os/exec"
13+
"path/filepath"
14+
)
15+
16+
const (
17+
// AgentBinary is the default child the launcher invokes when no
18+
// --exec flag is present. Sits next to the launcher in the install
19+
// directory (MSI: C:\Program Files\StepSecurity\, PowerShell:
20+
// %USERPROFILE%\.stepsecurity\bin\).
21+
AgentBinary = "stepsecurity-dev-machine-guard.exe"
22+
23+
// ExecFlag opts into the generic-target launch mode used by the
24+
// PowerShell loader. See ResolveTarget for the contract.
25+
ExecFlag = "--exec"
26+
)
27+
28+
// ResolveTarget returns the child executable's absolute path and the
29+
// argv slice to forward to it, derived from the launcher's own argv
30+
// (i.e. os.Args[1:], not including the program name).
31+
//
32+
// Two modes, dispatched on the first argument:
33+
//
34+
// - "--exec" mode. argv begins with --exec; the next element is the
35+
// child binary (resolved via exec.LookPath so bare basenames like
36+
// "powershell.exe" are accepted alongside fully-qualified paths),
37+
// and the rest of argv is forwarded to it. Used by the PowerShell
38+
// loader's scheduled task to wrap `powershell.exe -File loader.ps1
39+
// send-telemetry` under the launcher's no-console envelope.
40+
//
41+
// - Default (legacy / MSI) mode. argv does not begin with --exec; the
42+
// child is the sibling AgentBinary in the launcher's own directory,
43+
// and all of argv is forwarded to it. Preserves byte-for-byte
44+
// compatibility with the launcher's pre-1.11.5 behaviour, which is
45+
// what the MSI install layout's scheduled task action uses.
46+
//
47+
// Errors (all returned from this function; the caller in cmd/.../main.go
48+
// is responsible for mapping them to exit codes + stderr output per the
49+
// contract below):
50+
//
51+
// - --exec without a target: malformed task action.
52+
// - --exec target not on PATH: visible at install time rather than
53+
// silently exiting.
54+
// - Default mode with no sibling agent: most commonly indicates the
55+
// launcher was deployed without its companion.
56+
//
57+
// The launcher entrypoint distinguishes the two error contexts:
58+
// --exec errors are written to stderr and the process exits 2, while
59+
// default-mode errors stay silent and exit 1 (preserving byte-for-byte
60+
// compatibility with the pre-1.11.5 launcher that MSI installs rely on).
61+
func ResolveTarget(argv []string) (string, []string, error) {
62+
if len(argv) > 0 && argv[0] == ExecFlag {
63+
if len(argv) < 2 {
64+
return "", nil, fmt.Errorf("%s requires a target executable", ExecFlag)
65+
}
66+
target, err := exec.LookPath(argv[1])
67+
if err != nil {
68+
return "", nil, fmt.Errorf("%s: cannot resolve %q: %w", ExecFlag, argv[1], err)
69+
}
70+
return target, argv[2:], nil
71+
}
72+
73+
me, err := os.Executable()
74+
if err != nil {
75+
return "", nil, err
76+
}
77+
agent := filepath.Join(filepath.Dir(me), AgentBinary)
78+
if _, err := os.Stat(agent); err != nil {
79+
return "", nil, err
80+
}
81+
return agent, argv, nil
82+
}

internal/launcher/launcher_test.go

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
package launcher
2+
3+
import (
4+
"errors"
5+
"io/fs"
6+
"os"
7+
"os/exec"
8+
"path/filepath"
9+
"runtime"
10+
"strings"
11+
"testing"
12+
)
13+
14+
// resolvableTarget picks a binary we know is on PATH for the host running
15+
// the test, so the exec.LookPath inside ResolveTarget actually succeeds.
16+
// macOS CI doesn't have powershell.exe; Windows local devs don't have
17+
// /bin/sh. Pick per-OS so the cross-platform tests stay honest about
18+
// covering the resolve path end-to-end rather than just the argv parsing.
19+
func resolvableTarget(t *testing.T) (basename, wantSuffix string) {
20+
t.Helper()
21+
if runtime.GOOS == "windows" {
22+
// cmd.exe ships on every Windows host the test would ever run on.
23+
return "cmd.exe", "cmd.exe"
24+
}
25+
return "sh", "/sh"
26+
}
27+
28+
func TestResolveTarget_ExecMode_ForwardsArgs(t *testing.T) {
29+
basename, wantSuffix := resolvableTarget(t)
30+
target, args, err := ResolveTarget([]string{"--exec", basename, "-c", "echo hi"})
31+
if err != nil {
32+
t.Fatalf("ResolveTarget returned error: %v", err)
33+
}
34+
if !strings.HasSuffix(target, wantSuffix) {
35+
t.Errorf("target = %q, want suffix %q", target, wantSuffix)
36+
}
37+
if len(args) != 2 || args[0] != "-c" || args[1] != "echo hi" {
38+
t.Errorf("args = %v, want [-c, \"echo hi\"]", args)
39+
}
40+
}
41+
42+
// --exec with no further args is the misuse case: a customer-supplied
43+
// task definition that includes the flag but no target. Must error
44+
// rather than fall through to the default agent-sibling path — that
45+
// would silently swallow a malformed task action.
46+
func TestResolveTarget_ExecMode_MissingTarget(t *testing.T) {
47+
_, _, err := ResolveTarget([]string{"--exec"})
48+
if err == nil {
49+
t.Fatal("expected error for --exec with no target, got nil")
50+
}
51+
if !strings.Contains(err.Error(), ExecFlag) {
52+
t.Errorf("error %q does not mention %q", err, ExecFlag)
53+
}
54+
}
55+
56+
// An --exec target that doesn't resolve through LookPath must produce a
57+
// distinct error rather than silently exiting (which Task Scheduler
58+
// would record as a generic non-zero exit, indistinguishable from a
59+
// transient failure). exec.LookPath is wrapped, so the inner error is
60+
// preserved via errors.Is for diagnostic chains.
61+
func TestResolveTarget_ExecMode_TargetNotFound(t *testing.T) {
62+
bogus := "this-binary-definitely-does-not-exist-on-PATH-xyzzy.exe"
63+
_, _, err := ResolveTarget([]string{"--exec", bogus})
64+
if err == nil {
65+
t.Fatalf("expected error resolving %q, got nil", bogus)
66+
}
67+
if !errors.Is(err, exec.ErrNotFound) {
68+
t.Errorf("expected error chain to include exec.ErrNotFound, got %v", err)
69+
}
70+
}
71+
72+
// --exec with no further child args means "spawn target with empty argv"
73+
// — useful for the trivial case (launcher.exe --exec foo.exe) where the
74+
// child doesn't need flags. Forwards an empty slice, not a nil slice
75+
// (callers passing this directly into exec.Command must get a usable
76+
// value).
77+
func TestResolveTarget_ExecMode_NoChildArgs(t *testing.T) {
78+
basename, _ := resolvableTarget(t)
79+
_, args, err := ResolveTarget([]string{"--exec", basename})
80+
if err != nil {
81+
t.Fatalf("ResolveTarget returned error: %v", err)
82+
}
83+
if args == nil {
84+
t.Error("args is nil; expected empty (but non-nil) slice")
85+
}
86+
if len(args) != 0 {
87+
t.Errorf("args = %v, want []", args)
88+
}
89+
}
90+
91+
// Default mode (no --exec) computes the sibling agent path relative to
92+
// os.Executable(). During `go test` os.Executable() returns the compiled
93+
// test binary, so we can stage a fake agent next to it and assert
94+
// ResolveTarget picks it up. This is the lever the MSI install relies
95+
// on — the launcher's directory dictates where the agent comes from.
96+
func TestResolveTarget_DefaultMode_FindsSibling(t *testing.T) {
97+
exePath, err := os.Executable()
98+
if err != nil {
99+
t.Skipf("os.Executable not available on this platform: %v", err)
100+
}
101+
siblingDir := filepath.Dir(exePath)
102+
siblingPath := filepath.Join(siblingDir, AgentBinary)
103+
104+
// Don't clobber a real agent that might be sitting next to the test
105+
// binary in a developer's checkout. Three cases to handle correctly:
106+
//
107+
// - File doesn't exist (ErrNotExist) — seed the fake, delete on
108+
// cleanup. Normal CI path.
109+
// - File exists and is readable — capture bytes + mode, seed the
110+
// fake, restore both on cleanup.
111+
// - File exists but can't be read (permission, locked) — we'd
112+
// destroy the developer's checkout if we overwrote it. Skip
113+
// the test instead.
114+
prev, readErr := os.ReadFile(siblingPath)
115+
var prevMode os.FileMode = 0o644
116+
if readErr == nil {
117+
// Capture the original mode so the file is restored byte-for-
118+
// byte AND mode-for-mode. Without this, a real Authenticode-
119+
// signed .exe with the executable bit set on POSIX (rare on
120+
// CI but possible in a dual-platform checkout) would come back
121+
// non-executable.
122+
if info, statErr := os.Stat(siblingPath); statErr == nil {
123+
prevMode = info.Mode().Perm()
124+
}
125+
} else if !errors.Is(readErr, fs.ErrNotExist) {
126+
t.Skipf("cannot read existing sibling %q without risking developer state: %v", siblingPath, readErr)
127+
}
128+
129+
if err := os.WriteFile(siblingPath, []byte("fake-agent"), 0o644); err != nil {
130+
t.Fatalf("seeding fake agent at %q: %v", siblingPath, err)
131+
}
132+
t.Cleanup(func() {
133+
if readErr == nil {
134+
// Restore both bytes and mode.
135+
_ = os.WriteFile(siblingPath, prev, prevMode)
136+
} else {
137+
// Original was absent; remove our seed.
138+
_ = os.Remove(siblingPath)
139+
}
140+
})
141+
142+
target, args, err := ResolveTarget([]string{"send-telemetry", "--install-dir=C:\\fake"})
143+
if err != nil {
144+
t.Fatalf("ResolveTarget returned error: %v", err)
145+
}
146+
if target != siblingPath {
147+
t.Errorf("target = %q, want %q", target, siblingPath)
148+
}
149+
if len(args) != 2 || args[0] != "send-telemetry" || args[1] != "--install-dir=C:\\fake" {
150+
t.Errorf("args = %v, did not pass through unchanged", args)
151+
}
152+
}
153+
154+
// When the sibling agent isn't on disk, ResolveTarget must return an
155+
// error rather than a non-existent path. The launcher exit-1's on this;
156+
// surfacing it as an explicit error here means callers (and tests) can
157+
// distinguish "no agent installed" from other failure modes.
158+
func TestResolveTarget_DefaultMode_NoSibling(t *testing.T) {
159+
exePath, err := os.Executable()
160+
if err != nil {
161+
t.Skipf("os.Executable not available on this platform: %v", err)
162+
}
163+
siblingPath := filepath.Join(filepath.Dir(exePath), AgentBinary)
164+
if _, statErr := os.Stat(siblingPath); statErr == nil {
165+
// A real or stale sibling agent is in the way of this assertion.
166+
// Move it aside for the test and restore on cleanup so we don't
167+
// leave a half-broken dev checkout behind.
168+
shadow := siblingPath + ".test-shadow"
169+
if renameErr := os.Rename(siblingPath, shadow); renameErr != nil {
170+
t.Skipf("could not move existing sibling agent %q out of the way: %v", siblingPath, renameErr)
171+
}
172+
t.Cleanup(func() { _ = os.Rename(shadow, siblingPath) })
173+
}
174+
175+
_, _, err = ResolveTarget(nil)
176+
if err == nil {
177+
t.Fatal("expected error when sibling agent is absent, got nil")
178+
}
179+
}

0 commit comments

Comments
 (0)