forked from step-security/dev-machine-guard
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathexecutor.go
More file actions
280 lines (252 loc) · 9.77 KB
/
executor.go
File metadata and controls
280 lines (252 loc) · 9.77 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
package executor
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"github.com/step-security/dev-machine-guard/internal/winproc"
)
// Executor defines the interface for all OS interactions.
// Every detector depends on this interface, enabling full unit-test coverage via mocks.
type Executor interface {
// Run executes a command and returns stdout, stderr, and exit code.
Run(ctx context.Context, name string, args ...string) (stdout, stderr string, exitCode int, err error)
// RunWithTimeout executes a command with a timeout.
RunWithTimeout(ctx context.Context, timeout time.Duration, name string, args ...string) (stdout, stderr string, exitCode int, err error)
// RunInDir executes a command with a working directory and timeout.
// Avoids shell quoting issues with cd on Windows.
RunInDir(ctx context.Context, dir string, timeout time.Duration, name string, args ...string) (stdout, stderr string, exitCode int, err error)
// RunAsUser runs a shell command as a specific user (for root -> user delegation).
RunAsUser(ctx context.Context, username, command string) (string, error)
// LookPath searches for an executable in PATH.
LookPath(name string) (string, error)
// FileExists checks if a file exists and is not a directory.
FileExists(path string) bool
// DirExists checks if a directory exists.
DirExists(path string) bool
// ReadFile reads a file's contents.
ReadFile(path string) ([]byte, error)
// ReadDir lists directory entries.
ReadDir(path string) ([]os.DirEntry, error)
// Stat returns file info.
Stat(path string) (os.FileInfo, error)
// Hostname returns the system hostname.
Hostname() (string, error)
// Getenv reads an environment variable.
Getenv(key string) string
// IsRoot returns true if the process is running as root.
IsRoot() bool
// CurrentUser returns the current OS user.
CurrentUser() (*user.User, error)
// HomeDir returns the home directory for a given username.
HomeDir(username string) (string, error)
// Glob returns filenames matching a pattern.
Glob(pattern string) ([]string, error)
// EvalSymlinks resolves symbolic links in a path. Returns the resolved
// canonical path. If the path is not a symlink, returns it unchanged.
EvalSymlinks(path string) (string, error)
// LoggedInUser returns the actual logged-in console user.
// When running as root on macOS (e.g., via LaunchDaemon), this detects the
// real console user via /dev/console rather than returning root.
// Falls back to CurrentUser() when not root or on non-macOS platforms.
LoggedInUser() (*user.User, error)
// GOOS returns the runtime operating system.
GOOS() string
// IsAppleCLTStub reports whether binPath is an Apple Command Line Tools
// shim (under /usr/bin/) that would trigger the macOS "install developer
// tools" GUI prompt when invoked. Returns false on non-darwin systems, for
// paths outside /usr/bin/, or when CLT is installed. Detectors must guard
// every Run/RunWithTimeout on /usr/bin/-resolved binaries with this check
// to avoid disrupting end users on machines without CLT installed.
IsAppleCLTStub(ctx context.Context, binPath string) bool
// DiskCapacityBytes returns the total bytes on the filesystem containing
// path. Returns 0 on any error (lookup failures shouldn't block a scan).
DiskCapacityBytes(path string) uint64
}
// Real implements Executor using actual OS calls.
type Real struct {
cltOnce sync.Once
cltPresent bool
}
func NewReal() *Real { return &Real{} }
func (r *Real) Run(ctx context.Context, name string, args ...string) (string, string, int, error) {
cmd := exec.CommandContext(ctx, name, args...)
winproc.HideWindow(cmd)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
exitCode := 0
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
} else {
return stdout.String(), stderr.String(), -1, err
}
}
return stdout.String(), stderr.String(), exitCode, nil
}
func (r *Real) RunWithTimeout(ctx context.Context, timeout time.Duration, name string, args ...string) (string, string, int, error) {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
stdout, stderr, code, err := r.Run(ctx, name, args...)
if ctx.Err() == context.DeadlineExceeded {
return stdout, stderr, 124, fmt.Errorf("command timed out after %s", timeout)
}
return stdout, stderr, code, err
}
func (r *Real) RunInDir(ctx context.Context, dir string, timeout time.Duration, name string, args ...string) (string, string, int, error) {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
winproc.HideWindow(cmd)
cmd.Dir = dir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
exitCode := 0
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
} else {
return stdout.String(), stderr.String(), -1, err
}
}
if ctx.Err() == context.DeadlineExceeded {
return stdout.String(), stderr.String(), 124, fmt.Errorf("command timed out after %s", timeout)
}
return stdout.String(), stderr.String(), exitCode, nil
}
func (r *Real) LookPath(name string) (string, error) {
return exec.LookPath(name)
}
func (r *Real) FileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
func (r *Real) DirExists(path string) bool {
info, err := os.Stat(path)
return err == nil && info.IsDir()
}
func (r *Real) ReadFile(path string) ([]byte, error) {
return os.ReadFile(path)
}
func (r *Real) ReadDir(path string) ([]os.DirEntry, error) {
return os.ReadDir(path)
}
func (r *Real) Stat(path string) (os.FileInfo, error) {
return os.Stat(path)
}
func (r *Real) Hostname() (string, error) {
return os.Hostname()
}
func (r *Real) Getenv(key string) string {
return os.Getenv(key)
}
func (r *Real) CurrentUser() (*user.User, error) {
return user.Current()
}
func (r *Real) HomeDir(username string) (string, error) {
u, err := user.Lookup(username)
if err != nil {
return "", err
}
return u.HomeDir, nil
}
func (r *Real) Glob(pattern string) ([]string, error) {
return filepath.Glob(pattern)
}
func (r *Real) EvalSymlinks(path string) (string, error) {
return filepath.EvalSymlinks(path)
}
func (r *Real) LoggedInUser() (*user.User, error) {
if runtime.GOOS != "darwin" || !r.IsRoot() {
return r.CurrentUser()
}
// On macOS running as root, detect the console user via
// stat -f%Su /dev/console (mirrors the bash script's
// get_logged_in_user_info()).
//
// When the lookup can't yield a real GUI user — stat errored, console
// is owned by root or _windowserver, or the resolved name doesn't
// exist in Directory Services — we must NOT fall back to
// r.CurrentUser(). Under a LaunchDaemon that returns the root user
// with err == nil, callers treat that as "root is the developer", and
// the telemetry pipeline ships user_identity="root" with
// no_user_logged_in=false (issue #63). Surface the absence as an
// error so callers can flag the run as no-user instead.
ctx := context.Background()
stdout, _, _, err := r.Run(ctx, "stat", "-f%Su", "/dev/console")
if err != nil {
return nil, fmt.Errorf("stat /dev/console failed: %w", err)
}
username := strings.TrimSpace(stdout)
if username == "" || username == "root" || username == "_windowserver" {
return nil, fmt.Errorf("no GUI console user (owner=%q)", username)
}
u, err := user.Lookup(username)
if err != nil {
return nil, fmt.Errorf("console user %q not in directory services: %w", username, err)
}
return u, nil
}
func (r *Real) GOOS() string {
return runtime.GOOS
}
// appleCLTStubBinaries is the explicit set of /usr/bin/ paths that Apple
// ships as Command Line Tools shims. Invoking any of these on a Mac without
// CLT installed pops a GUI install prompt. A "/usr/bin/" prefix check alone
// would over-match: /usr/bin/ssh, /usr/bin/ls, /usr/bin/env etc. are base
// system binaries that work fine without CLT.
//
// Extend this set when introducing a detector that invokes another /usr/bin/
// binary Apple wraps as a CLT shim (common examples: git, make, clang,
// clang++, cc, c++, gcc, g++, swift, swiftc, gdb, lldb).
var appleCLTStubBinaries = map[string]struct{}{
"/usr/bin/python3": {},
"/usr/bin/pip3": {},
}
// IsAppleCLTStub reports whether binPath is an Apple Command Line Tools shim
// that would trigger the macOS "install developer tools" GUI prompt when
// invoked. Returns true iff:
// 1. GOOS is darwin,
// 2. binPath is a known Apple CLT shim (see appleCLTStubBinaries), AND
// 3. Xcode Command Line Tools are not installed (`xcode-select -p` fails).
//
// The CLT-presence result is cached per Real instance via sync.Once. The
// probe deliberately uses context.Background() (with its own 5 s timeout)
// rather than the caller-provided ctx: sync.Once consumes the slot on the
// first call, so a caller arriving with a canceled or near-deadline ctx
// could otherwise poison the cache with cltPresent=false and make every
// subsequent check treat real binaries as stubs. The caller's ctx is
// retained in the signature for symmetry with the Executor interface.
//
// `xcode-select -p` itself does NOT trigger the install prompt — it just
// prints the developer-dir path or exits non-zero when CLT is absent.
func (r *Real) IsAppleCLTStub(_ context.Context, binPath string) bool {
if runtime.GOOS != "darwin" {
return false
}
if _, ok := appleCLTStubBinaries[binPath]; !ok {
return false
}
r.cltOnce.Do(func() {
probeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cmd := exec.CommandContext(probeCtx, "xcode-select", "-p")
winproc.HideWindow(cmd)
var stdout bytes.Buffer
cmd.Stdout = &stdout
err := cmd.Run()
r.cltPresent = err == nil && strings.TrimSpace(stdout.String()) != ""
})
return !r.cltPresent
}