Skip to content

Commit 7263c43

Browse files
authored
Merge pull request #20 from shubham-stepsecurity/sm/feat/migrate
fix(mdm): detect logged-in console user when running as root
2 parents c231c20 + cf90d50 commit 7263c43

18 files changed

Lines changed: 209 additions & 74 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
See [VERSIONING.md](VERSIONING.md) for why the version starts at 1.8.1.
99

10+
## [1.9.2] - 2026-04-15
11+
12+
### Fixed
13+
14+
- LaunchDaemon now sets `HOME` in the plist environment so `configDir()` resolves correctly at runtime (fixes "Enterprise configuration not found" error in periodic scans).
15+
- Progress and error log lines now include timestamps for easier debugging.
16+
1017
## [1.9.1] - 2026-04-07
1118

1219
### Fixed
@@ -65,6 +72,7 @@ First open-source release. The scanning engine was previously an internal enterp
6572
- Execution log capture and base64 encoding
6673
- Instance locking to prevent concurrent runs
6774

75+
[1.9.2]: https://github.com/step-security/dev-machine-guard/compare/v1.9.1...v1.9.2
6876
[1.9.1]: https://github.com/step-security/dev-machine-guard/compare/v1.9.0...v1.9.1
6977
[1.9.0]: https://github.com/step-security/dev-machine-guard/compare/v1.8.2...v1.9.0
7078
[1.8.2]: https://github.com/step-security/dev-machine-guard/compare/v1.8.1...v1.8.2

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
<a href="https://github.com/step-security/dev-machine-guard/actions/workflows/go.yml"><img src="https://github.com/step-security/dev-machine-guard/actions/workflows/go.yml/badge.svg" alt="Go CI"></a>
1313
<a href="https://github.com/step-security/dev-machine-guard/actions/workflows/shellcheck.yml"><img src="https://github.com/step-security/dev-machine-guard/actions/workflows/shellcheck.yml/badge.svg" alt="ShellCheck CI"></a>
1414
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License: Apache 2.0"></a>
15-
<a href="https://github.com/step-security/dev-machine-guard/releases"><img src="https://img.shields.io/badge/version-1.9.1-purple.svg" alt="Version 1.9.1"></a>
15+
<a href="https://github.com/step-security/dev-machine-guard/releases"><img src="https://img.shields.io/badge/version-1.9.2-purple.svg" alt="Version 1.9.2"></a>
1616
</p>
1717

1818
<p align="center">

examples/sample-output.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"agent_version": "1.9.1",
2+
"agent_version": "1.9.2",
33
"scan_timestamp": 1741305600,
44
"scan_timestamp_iso": "2026-03-07T00:00:00Z",
55
"device": {

internal/buildinfo/version.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ package buildinfo
33
import "fmt"
44

55
const (
6-
Version = "1.9.1"
6+
Version = "1.9.2"
77
AgentURL = "https://github.com/step-security/dev-machine-guard"
88
)
99

internal/detector/aicli.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,10 @@ var cliToolDefinitions = []cliToolSpec{
5454
},
5555
},
5656
{
57-
Name: "github-copilot-cli",
58-
Vendor: "Microsoft",
59-
Binaries: []string{"copilot", "gh-copilot"},
60-
ConfigDirs: []string{"~/.config/github-copilot"},
57+
Name: "github-copilot-cli",
58+
Vendor: "Microsoft",
59+
Binaries: []string{"copilot", "gh-copilot"},
60+
ConfigDirs: []string{"~/.config/github-copilot"},
6161
},
6262
{
6363
Name: "microsoft-ai-shell",
@@ -196,7 +196,7 @@ func expandTilde(path, homeDir string) string {
196196
}
197197

198198
func getHomeDir(exec executor.Executor) string {
199-
u, err := exec.CurrentUser()
199+
u, err := exec.LoggedInUser()
200200
if err != nil {
201201
return os.TempDir()
202202
}

internal/detector/ide.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,12 @@ var ideDefinitions = []ideSpec{
5353
},
5454
{
5555
AppName: "Claude", IDEType: "claude_desktop", Vendor: "Anthropic",
56-
AppPath: "/Applications/Claude.app",
56+
AppPath: "/Applications/Claude.app",
5757
WinPaths: []string{`%LOCALAPPDATA%\Programs\Claude`},
5858
},
5959
{
6060
AppName: "Microsoft Copilot", IDEType: "microsoft_copilot_desktop", Vendor: "Microsoft",
61-
AppPath: "/Applications/Copilot.app",
61+
AppPath: "/Applications/Copilot.app",
6262
WinPaths: []string{`%LOCALAPPDATA%\Programs\Copilot`},
6363
},
6464
}

internal/detector/nodescan.go

Lines changed: 74 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package detector
33
import (
44
"context"
55
"encoding/base64"
6+
"fmt"
67
"os"
78
"path/filepath"
89
"sort"
@@ -30,12 +31,72 @@ func getMaxProjectScanBytes() int64 {
3031

3132
// NodeScanner performs enterprise-mode node scanning (raw output, base64 encoded).
3233
type NodeScanner struct {
33-
exec executor.Executor
34-
log *progress.Logger
34+
exec executor.Executor
35+
log *progress.Logger
36+
loggedInUser string // when non-empty and running as root, commands run as this user
3537
}
3638

37-
func NewNodeScanner(exec executor.Executor, log *progress.Logger) *NodeScanner {
38-
return &NodeScanner{exec: exec, log: log}
39+
func NewNodeScanner(exec executor.Executor, log *progress.Logger, loggedInUser string) *NodeScanner {
40+
return &NodeScanner{exec: exec, log: log, loggedInUser: loggedInUser}
41+
}
42+
43+
// shouldRunAsUser returns true when commands should be delegated to the logged-in user.
44+
// Only applies on Unix — RunAsUser uses sudo which is not available on Windows.
45+
func (s *NodeScanner) shouldRunAsUser() bool {
46+
return s.exec.GOOS() != "windows" && s.exec.IsRoot() && s.loggedInUser != ""
47+
}
48+
49+
// runCmd runs a command, delegating to the logged-in user when running as root.
50+
// This ensures package manager commands use the real user's PATH and config.
51+
func (s *NodeScanner) runCmd(ctx context.Context, timeout time.Duration, name string, args ...string) (string, string, int, error) {
52+
if s.shouldRunAsUser() {
53+
ctx, cancel := context.WithTimeout(ctx, timeout)
54+
defer cancel()
55+
cmd := name
56+
for _, a := range args {
57+
cmd += " " + a
58+
}
59+
stdout, err := s.exec.RunAsUser(ctx, s.loggedInUser, cmd)
60+
if err != nil {
61+
if ctx.Err() == context.DeadlineExceeded {
62+
return stdout, "", 124, fmt.Errorf("command timed out after %s", timeout)
63+
}
64+
return stdout, "", 1, err
65+
}
66+
return stdout, "", 0, nil
67+
}
68+
return s.exec.RunWithTimeout(ctx, timeout, name, args...)
69+
}
70+
71+
// runShellCmd runs a shell command string, delegating to the logged-in user when running as root.
72+
// Falls through to the platform-aware free function for the normal (non-delegation) path.
73+
func (s *NodeScanner) runShellCmd(ctx context.Context, timeout time.Duration, shellCmd string) (string, string, int, error) {
74+
if s.shouldRunAsUser() {
75+
ctx, cancel := context.WithTimeout(ctx, timeout)
76+
defer cancel()
77+
stdout, err := s.exec.RunAsUser(ctx, s.loggedInUser, shellCmd)
78+
if err != nil {
79+
if ctx.Err() == context.DeadlineExceeded {
80+
return stdout, "", 124, fmt.Errorf("command timed out after %s", timeout)
81+
}
82+
return stdout, "", 1, err
83+
}
84+
return stdout, "", 0, nil
85+
}
86+
return runShellCmd(ctx, s.exec, timeout, shellCmd)
87+
}
88+
89+
// checkPath checks if a binary is available, using the logged-in user's PATH when running as root.
90+
func (s *NodeScanner) checkPath(ctx context.Context, name string) error {
91+
if s.shouldRunAsUser() {
92+
path, err := s.exec.RunAsUser(ctx, s.loggedInUser, "which "+name)
93+
if err != nil || path == "" {
94+
return fmt.Errorf("%s not found in user PATH", name)
95+
}
96+
return nil
97+
}
98+
_, err := s.exec.LookPath(name)
99+
return err
39100
}
40101

41102
// ScanGlobalPackages runs npm/yarn/pnpm list -g and returns raw base64-encoded results.
@@ -61,7 +122,7 @@ func (s *NodeScanner) ScanGlobalPackages(ctx context.Context) []model.NodeScanRe
61122
}
62123

63124
func (s *NodeScanner) scanNPMGlobal(ctx context.Context) (model.NodeScanResult, bool) {
64-
if _, err := s.exec.LookPath("npm"); err != nil {
125+
if err := s.checkPath(ctx, "npm"); err != nil {
65126
return model.NodeScanResult{}, false
66127
}
67128

@@ -72,7 +133,7 @@ func (s *NodeScanner) scanNPMGlobal(ctx context.Context) (model.NodeScanResult,
72133
}
73134

74135
start := time.Now()
75-
stdout, stderr, exitCode, _ := s.exec.RunWithTimeout(ctx, 60*time.Second, "npm", "list", "-g", "--json", "--depth=3")
136+
stdout, stderr, exitCode, _ := s.runCmd(ctx, 60*time.Second, "npm", "list", "-g", "--json", "--depth=3")
76137
duration := time.Since(start).Milliseconds()
77138

78139
errMsg := ""
@@ -94,7 +155,7 @@ func (s *NodeScanner) scanNPMGlobal(ctx context.Context) (model.NodeScanResult,
94155
}
95156

96157
func (s *NodeScanner) scanYarnGlobal(ctx context.Context) (model.NodeScanResult, bool) {
97-
if _, err := s.exec.LookPath("yarn"); err != nil {
158+
if err := s.checkPath(ctx, "yarn"); err != nil {
98159
return model.NodeScanResult{}, false
99160
}
100161

@@ -106,7 +167,7 @@ func (s *NodeScanner) scanYarnGlobal(ctx context.Context) (model.NodeScanResult,
106167

107168
start := time.Now()
108169
shellCmd := "cd " + platformShellQuote(s.exec, globalDir) + " && yarn list --json --depth=0"
109-
stdout, stderr, exitCode, _ := runShellCmd(ctx, s.exec, 60*time.Second, shellCmd)
170+
stdout, stderr, exitCode, _ := s.runShellCmd(ctx, 60*time.Second, shellCmd)
110171
duration := time.Since(start).Milliseconds()
111172

112173
errMsg := ""
@@ -128,7 +189,7 @@ func (s *NodeScanner) scanYarnGlobal(ctx context.Context) (model.NodeScanResult,
128189
}
129190

130191
func (s *NodeScanner) scanPnpmGlobal(ctx context.Context) (model.NodeScanResult, bool) {
131-
if _, err := s.exec.LookPath("pnpm"); err != nil {
192+
if err := s.checkPath(ctx, "pnpm"); err != nil {
132193
return model.NodeScanResult{}, false
133194
}
134195

@@ -140,7 +201,7 @@ func (s *NodeScanner) scanPnpmGlobal(ctx context.Context) (model.NodeScanResult,
140201
globalDir = filepath.Dir(globalDir)
141202

142203
start := time.Now()
143-
stdout, stderr, exitCode, _ := s.exec.RunWithTimeout(ctx, 60*time.Second, "pnpm", "list", "-g", "--json", "--depth=3")
204+
stdout, stderr, exitCode, _ := s.runCmd(ctx, 60*time.Second, "pnpm", "list", "-g", "--json", "--depth=3")
144205
duration := time.Since(start).Milliseconds()
145206

146207
errMsg := ""
@@ -286,7 +347,7 @@ func (s *NodeScanner) scanProject(ctx context.Context, projectDir string) model.
286347
for _, a := range args {
287348
cmdStr += " " + a
288349
}
289-
stdout, stderr, exitCode, _ := runShellCmd(ctx, s.exec, 30*time.Second, cmdStr)
350+
stdout, stderr, exitCode, _ := s.runShellCmd(ctx, 30*time.Second, cmdStr)
290351
duration := time.Since(start).Milliseconds()
291352

292353
errMsg := ""
@@ -308,15 +369,15 @@ func (s *NodeScanner) scanProject(ctx context.Context, projectDir string) model.
308369
}
309370

310371
func (s *NodeScanner) getVersion(ctx context.Context, binary, flag string) string {
311-
stdout, _, _, err := s.exec.RunWithTimeout(ctx, 10*time.Second, binary, flag)
372+
stdout, _, _, err := s.runCmd(ctx, 10*time.Second, binary, flag)
312373
if err != nil {
313374
return "unknown"
314375
}
315376
return strings.TrimSpace(stdout)
316377
}
317378

318379
func (s *NodeScanner) getOutput(ctx context.Context, binary string, args ...string) string {
319-
stdout, _, _, err := s.exec.RunWithTimeout(ctx, 10*time.Second, binary, args...)
380+
stdout, _, _, err := s.runCmd(ctx, 10*time.Second, binary, args...)
320381
if err != nil {
321382
return ""
322383
}

internal/detector/nodescan_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import (
1212

1313
func newTestScanner(exec *executor.Mock) *NodeScanner {
1414
log := progress.NewLogger(false)
15-
return NewNodeScanner(exec, log)
15+
return NewNodeScanner(exec, log, "")
1616
}
1717

1818
func TestNodeScanner_ScanNPMGlobal(t *testing.T) {

internal/device/device.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,8 @@ func getDeveloperIdentity(exec executor.Executor) string {
124124
return v
125125
}
126126
}
127-
// Fallback to current username
128-
u, err := exec.CurrentUser()
127+
// Fallback to logged-in username (detects console user when running as root)
128+
u, err := exec.LoggedInUser()
129129
if err == nil {
130130
return u.Username
131131
}

internal/executor/executor.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"os/user"
1010
"path/filepath"
1111
"runtime"
12+
"strings"
1213
"time"
1314
)
1415

@@ -45,6 +46,11 @@ type Executor interface {
4546
HomeDir(username string) (string, error)
4647
// Glob returns filenames matching a pattern.
4748
Glob(pattern string) ([]string, error)
49+
// LoggedInUser returns the actual logged-in console user.
50+
// When running as root on macOS (e.g., via LaunchDaemon), this detects the
51+
// real console user via /dev/console rather than returning root.
52+
// Falls back to CurrentUser() when not root or on non-macOS platforms.
53+
LoggedInUser() (*user.User, error)
4854
// GOOS returns the runtime operating system.
4955
GOOS() string
5056
}
@@ -131,6 +137,33 @@ func (r *Real) Glob(pattern string) ([]string, error) {
131137
return filepath.Glob(pattern)
132138
}
133139

140+
func (r *Real) LoggedInUser() (*user.User, error) {
141+
if runtime.GOOS != "darwin" || !r.IsRoot() {
142+
return r.CurrentUser()
143+
}
144+
145+
// On macOS running as root, detect the console user.
146+
// This mirrors the bash script's get_logged_in_user_info() which uses
147+
// stat -f%Su /dev/console to find who is actually logged in.
148+
ctx := context.Background()
149+
stdout, _, _, err := r.Run(ctx, "stat", "-f%Su", "/dev/console")
150+
if err != nil {
151+
return r.CurrentUser()
152+
}
153+
154+
username := strings.TrimSpace(stdout)
155+
if username == "" || username == "root" || username == "_windowserver" {
156+
return r.CurrentUser()
157+
}
158+
159+
u, err := user.Lookup(username)
160+
if err != nil {
161+
return r.CurrentUser()
162+
}
163+
164+
return u, nil
165+
}
166+
134167
func (r *Real) GOOS() string {
135168
return runtime.GOOS
136169
}

0 commit comments

Comments
 (0)