Skip to content

Commit 3f3f74a

Browse files
authored
Merge pull request #108 from shubham-stepsecurity/sm/test
chore(mdm): skip macOS TCC-protected directories
2 parents dcd8cc2 + a5359b0 commit 3f3f74a

15 files changed

Lines changed: 560 additions & 51 deletions

File tree

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ func main() {
8484
if cfg.EnablePythonScan == nil && config.EnablePythonScan != nil {
8585
cfg.EnablePythonScan = config.EnablePythonScan
8686
}
87+
if cfg.IncludeTCCProtected == nil && config.IncludeTCCProtected != nil {
88+
cfg.IncludeTCCProtected = config.IncludeTCCProtected
89+
}
8790
if cfg.ColorMode == "auto" && config.ColorMode != "" {
8891
cfg.ColorMode = config.ColorMode
8992
}

internal/cli/cli.go

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,22 +17,29 @@ import (
1717
// failure, so the hot path bypasses cli.Parse entirely — see main.go's
1818
// early-return and internal/aiagents/cli.RunHook.
1919
type Config struct {
20-
Command string // "", "install", "uninstall", "send-telemetry", "configure", "configure show", "hooks install", "hooks uninstall"
21-
OutputFormat string // "pretty", "json", "html"
22-
OutputFormatSet bool // true if --pretty/--json/--html was explicitly passed (not persisted)
23-
HTMLOutputFile string // set by --html (not persisted)
24-
ColorMode string // "auto", "always", "never"
25-
Verbose bool // --verbose (shortcut for --log-level=debug)
26-
LogLevel string // "" = unset; one of "error", "warn", "info", "debug"
27-
InstallDir string // --install-dir=DIR base install directory; all non-bootstrap files (logs, hook errors, binary placement) live under this dir. "" w/ InstallDirSet=true means "explicitly disabled" (no file logging).
28-
InstallDirSet bool // true if --install-dir was passed (empty value = disable file logging for this run)
29-
EnableNPMScan *bool // nil=auto, true/false=explicit
30-
EnableBrewScan *bool // nil=auto, true/false=explicit
31-
EnablePythonScan *bool // nil=auto, true/false=explicit
32-
IncludeBundledPlugins bool // --include-bundled-plugins: include bundled/platform plugins in output
33-
NPMRCOnly bool // --npmrc: run only the npmrc audit and render verbose pretty output
34-
PipConfigOnly bool // --pipconfig: run only the pip config audit and render verbose pretty output
35-
SearchDirs []string // defaults to ["$HOME"]
20+
Command string // "", "install", "uninstall", "send-telemetry", "configure", "configure show", "hooks install", "hooks uninstall"
21+
OutputFormat string // "pretty", "json", "html"
22+
OutputFormatSet bool // true if --pretty/--json/--html was explicitly passed (not persisted)
23+
HTMLOutputFile string // set by --html (not persisted)
24+
ColorMode string // "auto", "always", "never"
25+
Verbose bool // --verbose (shortcut for --log-level=debug)
26+
LogLevel string // "" = unset; one of "error", "warn", "info", "debug"
27+
InstallDir string // --install-dir=DIR base install directory; all non-bootstrap files (logs, hook errors, binary placement) live under this dir. "" w/ InstallDirSet=true means "explicitly disabled" (no file logging).
28+
InstallDirSet bool // true if --install-dir was passed (empty value = disable file logging for this run)
29+
EnableNPMScan *bool // nil=auto, true/false=explicit
30+
EnableBrewScan *bool // nil=auto, true/false=explicit
31+
EnablePythonScan *bool // nil=auto, true/false=explicit
32+
IncludeBundledPlugins bool // --include-bundled-plugins: include bundled/platform plugins in output
33+
// IncludeTCCProtected is tristate: nil = use the runtime default
34+
// (skip only when running under launchd, where TCC prompts actually
35+
// fire), true = always include the protected dirs (skipper off),
36+
// false = always exclude them (skipper on). Wired via
37+
// --include-tcc-protected / --no-include-tcc-protected and the
38+
// matching field in config.ConfigFile.
39+
IncludeTCCProtected *bool
40+
NPMRCOnly bool // --npmrc: run only the npmrc audit and render verbose pretty output
41+
PipConfigOnly bool // --pipconfig: run only the pip config audit and render verbose pretty output
42+
SearchDirs []string // defaults to ["$HOME"]
3643

3744
// HooksAgent is the --agent value on `hooks install` / `hooks uninstall`;
3845
// "" means "every detected agent".
@@ -146,6 +153,12 @@ func Parse(args []string) (*Config, error) {
146153
cfg.EnablePythonScan = &v
147154
case arg == "--include-bundled-plugins":
148155
cfg.IncludeBundledPlugins = true
156+
case arg == "--include-tcc-protected":
157+
v := true
158+
cfg.IncludeTCCProtected = &v
159+
case arg == "--no-include-tcc-protected":
160+
v := false
161+
cfg.IncludeTCCProtected = &v
149162
case arg == "--npmrc":
150163
cfg.NPMRCOnly = true
151164
case arg == "--pipconfig":
@@ -404,6 +417,12 @@ Options:
404417
--enable-python-scan Enable Python package scanning
405418
--disable-python-scan Disable Python package scanning
406419
--include-bundled-plugins Include bundled/platform plugins in output (Windows)
420+
--include-tcc-protected Scan macOS TCC-protected dirs (Documents, Downloads,
421+
~/Library/Mail, etc.). Default: skipped only when
422+
running under launchd (where permission prompts
423+
fire); direct CLI runs scan them.
424+
--no-include-tcc-protected Force-skip macOS TCC-protected dirs even on direct
425+
CLI runs.
407426
--npmrc Run ONLY the npm config audit (verbose pretty view; --json supported)
408427
--pipconfig Run ONLY the pip config audit (verbose pretty view; --json supported)
409428
--log-level=LEVEL Log level: error | warn | info | debug (default: info)

internal/config/config.go

Lines changed: 31 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -11,36 +11,38 @@ import (
1111

1212
// Default placeholders (replaced by backend for enterprise installation scripts).
1313
var (
14-
CustomerID = "{{CUSTOMER_ID}}"
15-
APIEndpoint = "{{API_ENDPOINT}}"
16-
APIKey = "{{API_KEY}}"
17-
ScanFrequencyHours = "{{SCAN_FREQUENCY_HOURS}}"
18-
SearchDirs []string
19-
EnableNPMScan *bool // nil=auto
20-
EnableBrewScan *bool // nil=auto
21-
EnablePythonScan *bool // nil=auto
22-
ColorMode string // "" means auto
23-
OutputFormat string // "" means default (pretty)
24-
HTMLOutputFile string // "" means not set
25-
LogLevel string // "" means default (info); one of error/warn/info/debug
26-
InstallDir string // "" means default (~/.stepsecurity); non-empty makes the agent put all its files (logs, hook errors, future state) under this directory. Bootstrap config.json itself stays at the legacy location. Per-run opt-out is the CLI flag --install-dir=. Resolution: --install-dir flag > STEPSECURITY_HOME env > this field > default — see internal/paths.
14+
CustomerID = "{{CUSTOMER_ID}}"
15+
APIEndpoint = "{{API_ENDPOINT}}"
16+
APIKey = "{{API_KEY}}" //#nosec G101 -- build-time placeholder substituted by the backend installer; the literal is not a real credential.
17+
ScanFrequencyHours = "{{SCAN_FREQUENCY_HOURS}}"
18+
SearchDirs []string
19+
EnableNPMScan *bool // nil=auto
20+
EnableBrewScan *bool // nil=auto
21+
EnablePythonScan *bool // nil=auto
22+
IncludeTCCProtected *bool // nil=auto (skip when running under macOS launchd, scan otherwise)
23+
ColorMode string // "" means auto
24+
OutputFormat string // "" means default (pretty)
25+
HTMLOutputFile string // "" means not set
26+
LogLevel string // "" means default (info); one of error/warn/info/debug
27+
InstallDir string // "" means default (~/.stepsecurity); non-empty makes the agent put all its files (logs, hook errors, future state) under this directory. Bootstrap config.json itself stays at the legacy location. Per-run opt-out is the CLI flag --install-dir=. Resolution: --install-dir flag > STEPSECURITY_HOME env > this field > default — see internal/paths.
2728
)
2829

2930
// ConfigFile is the JSON structure persisted to ~/.stepsecurity/config.json.
3031
type ConfigFile struct {
31-
CustomerID string `json:"customer_id,omitempty"`
32-
APIEndpoint string `json:"api_endpoint,omitempty"`
33-
APIKey string `json:"api_key,omitempty"`
34-
ScanFrequencyHours string `json:"scan_frequency_hours,omitempty"`
35-
SearchDirs []string `json:"search_dirs,omitempty"`
36-
EnableNPMScan *bool `json:"enable_npm_scan,omitempty"`
37-
EnableBrewScan *bool `json:"enable_brew_scan,omitempty"`
38-
EnablePythonScan *bool `json:"enable_python_scan,omitempty"`
39-
ColorMode string `json:"color_mode,omitempty"`
40-
OutputFormat string `json:"output_format,omitempty"`
41-
HTMLOutputFile string `json:"html_output_file,omitempty"`
42-
LogLevel string `json:"log_level,omitempty"`
43-
InstallDir string `json:"install_dir,omitempty"`
32+
CustomerID string `json:"customer_id,omitempty"`
33+
APIEndpoint string `json:"api_endpoint,omitempty"`
34+
APIKey string `json:"api_key,omitempty"`
35+
ScanFrequencyHours string `json:"scan_frequency_hours,omitempty"`
36+
SearchDirs []string `json:"search_dirs,omitempty"`
37+
EnableNPMScan *bool `json:"enable_npm_scan,omitempty"`
38+
EnableBrewScan *bool `json:"enable_brew_scan,omitempty"`
39+
EnablePythonScan *bool `json:"enable_python_scan,omitempty"`
40+
IncludeTCCProtected *bool `json:"include_tcc_protected,omitempty"`
41+
ColorMode string `json:"color_mode,omitempty"`
42+
OutputFormat string `json:"output_format,omitempty"`
43+
HTMLOutputFile string `json:"html_output_file,omitempty"`
44+
LogLevel string `json:"log_level,omitempty"`
45+
InstallDir string `json:"install_dir,omitempty"`
4446
}
4547

4648
// userConfigDir returns ~/.stepsecurity — the per-user config location.
@@ -152,6 +154,9 @@ func Load() {
152154
if cfg.EnablePythonScan != nil && EnablePythonScan == nil {
153155
EnablePythonScan = cfg.EnablePythonScan
154156
}
157+
if cfg.IncludeTCCProtected != nil && IncludeTCCProtected == nil {
158+
IncludeTCCProtected = cfg.IncludeTCCProtected
159+
}
155160
if cfg.ColorMode != "" && ColorMode == "" {
156161
ColorMode = cfg.ColorMode
157162
}

internal/detector/configaudit/npmrc.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616

1717
"github.com/step-security/dev-machine-guard/internal/executor"
1818
"github.com/step-security/dev-machine-guard/internal/model"
19+
"github.com/step-security/dev-machine-guard/internal/tcc"
1920
)
2021

2122
// maxNPMRCFiles caps the number of .npmrc files we report. Even on big
@@ -53,7 +54,8 @@ var secretEnvNamePattern = regexp.MustCompile(`(?i)(token|password|secret|_auth|
5354
// hashes) and git-tracking checks pluggable so unit tests don't need real
5455
// syscalls or a git binary.
5556
type NPMRCDetector struct {
56-
exec executor.Executor
57+
exec executor.Executor
58+
skipper *tcc.Skipper
5759

5860
// ownerLookup returns owner info for a path. Defaults to the real
5961
// platform-specific impl in npmrc_stat_*.go; tests can override.
@@ -84,6 +86,13 @@ func NewNPMRCDetector(exec executor.Executor) *NPMRCDetector {
8486
return d
8587
}
8688

89+
// WithSkipper attaches a TCC skipper so .npmrc discovery skips macOS-protected
90+
// directories. A nil skipper is a no-op. Returns the detector for chaining.
91+
func (d *NPMRCDetector) WithSkipper(s *tcc.Skipper) *NPMRCDetector {
92+
d.skipper = s
93+
return d
94+
}
95+
8796
// Detect runs the full audit. searchDirs are the dirs to walk for project-
8897
// level .npmrc files (typically the user's $HOME plus any extra dirs
8998
// configured by the operator). loggedInUser is the username whose ~/.npmrc
@@ -174,6 +183,9 @@ func (d *NPMRCDetector) findProjectNPMRCs(dir string) []string {
174183
return nil
175184
}
176185
if entry.IsDir() {
186+
if d.skipper.ShouldSkip(path, dir) {
187+
return filepath.SkipDir
188+
}
177189
if shouldSkipNPMRCDir(path, entry.Name(), dir) {
178190
return filepath.SkipDir
179191
}

internal/detector/nodeproject.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,28 @@ import (
99

1010
"github.com/step-security/dev-machine-guard/internal/executor"
1111
"github.com/step-security/dev-machine-guard/internal/model"
12+
"github.com/step-security/dev-machine-guard/internal/tcc"
1213
)
1314

1415
const maxNodeProjects = 1000
1516

1617
// NodeProjectDetector scans for Node.js projects.
1718
type NodeProjectDetector struct {
18-
exec executor.Executor
19+
exec executor.Executor
20+
skipper *tcc.Skipper
1921
}
2022

2123
func NewNodeProjectDetector(exec executor.Executor) *NodeProjectDetector {
2224
return &NodeProjectDetector{exec: exec}
2325
}
2426

27+
// WithSkipper attaches a TCC skipper so the walk skips macOS-protected
28+
// directories. A nil skipper is a no-op. Returns the detector for chaining.
29+
func (d *NodeProjectDetector) WithSkipper(s *tcc.Skipper) *NodeProjectDetector {
30+
d.skipper = s
31+
return d
32+
}
33+
2534
// CountProjects counts the number of Node.js projects found under the given directories.
2635
func (d *NodeProjectDetector) CountProjects(_ context.Context, searchDirs []string) int {
2736
return len(d.ListProjects(searchDirs))
@@ -47,6 +56,9 @@ func (d *NodeProjectDetector) listInDir(dir string) []model.ProjectInfo {
4756
return nil
4857
}
4958
if entry.IsDir() {
59+
if d.skipper.ShouldSkip(path, dir) {
60+
return filepath.SkipDir
61+
}
5062
name := entry.Name()
5163
if name == "node_modules" || name == ".git" || name == ".cache" ||
5264
strings.HasPrefix(name, ".") {

internal/detector/nodescan.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/step-security/dev-machine-guard/internal/executor"
1515
"github.com/step-security/dev-machine-guard/internal/model"
1616
"github.com/step-security/dev-machine-guard/internal/progress"
17+
"github.com/step-security/dev-machine-guard/internal/tcc"
1718
)
1819

1920
const defaultMaxProjectScanBytes = 500 * 1024 * 1024 // 500MB total limit
@@ -34,6 +35,7 @@ type NodeScanner struct {
3435
exec executor.Executor
3536
log *progress.Logger
3637
loggedInUser string // when non-empty and running as root, commands run as this user
38+
skipper *tcc.Skipper
3739
// ProgressHook, when non-nil, is invoked from inside ScanProjects /
3840
// ScanGlobalPackages with a short human-readable detail string ("project
3941
// 12 of 47", "scanning yarn", ...). Telemetry plumbs this into
@@ -45,6 +47,13 @@ func NewNodeScanner(exec executor.Executor, log *progress.Logger, loggedInUser s
4547
return &NodeScanner{exec: exec, log: log, loggedInUser: loggedInUser}
4648
}
4749

50+
// WithSkipper attaches a TCC skipper so the discovery walk skips
51+
// macOS-protected directories. A nil skipper is a no-op.
52+
func (s *NodeScanner) WithSkipper(skipper *tcc.Skipper) *NodeScanner {
53+
s.skipper = skipper
54+
return s
55+
}
56+
4857
func (s *NodeScanner) emitProgress(detail string) {
4958
if s.ProgressHook != nil {
5059
s.ProgressHook(detail)
@@ -332,6 +341,9 @@ func (s *NodeScanner) ScanProjects(ctx context.Context, searchDirs []string) []m
332341
return nil
333342
}
334343
if entry.IsDir() {
344+
if s.skipper.ShouldSkip(path, dir) {
345+
return filepath.SkipDir
346+
}
335347
name := entry.Name()
336348
if name == "node_modules" || name == ".git" || name == ".cache" ||
337349
strings.HasPrefix(name, ".") {

internal/detector/pythonproject.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,28 @@ import (
1010

1111
"github.com/step-security/dev-machine-guard/internal/executor"
1212
"github.com/step-security/dev-machine-guard/internal/model"
13+
"github.com/step-security/dev-machine-guard/internal/tcc"
1314
)
1415

1516
const maxPythonProjects = 1000
1617

1718
// PythonProjectDetector scans for Python projects with virtual environments.
1819
type PythonProjectDetector struct {
19-
exec executor.Executor
20+
exec executor.Executor
21+
skipper *tcc.Skipper
2022
}
2123

2224
func NewPythonProjectDetector(exec executor.Executor) *PythonProjectDetector {
2325
return &PythonProjectDetector{exec: exec}
2426
}
2527

28+
// WithSkipper attaches a TCC skipper so the walk skips macOS-protected
29+
// directories. A nil skipper is a no-op. Returns the detector for chaining.
30+
func (d *PythonProjectDetector) WithSkipper(s *tcc.Skipper) *PythonProjectDetector {
31+
d.skipper = s
32+
return d
33+
}
34+
2635
// CountProjects counts Python projects with virtual environments.
2736
func (d *PythonProjectDetector) CountProjects(_ context.Context, searchDirs []string) int {
2837
return len(d.ListProjects(searchDirs))
@@ -129,6 +138,9 @@ func (d *PythonProjectDetector) listInDir(dir string) []model.ProjectInfo {
129138
if !entry.IsDir() {
130139
return nil
131140
}
141+
if d.skipper.ShouldSkip(path, dir) {
142+
return filepath.SkipDir
143+
}
132144
name := entry.Name()
133145
if name == "node_modules" || name == ".git" || name == ".cache" ||
134146
name == "__pycache__" || name == ".tox" || name == "site-packages" ||

internal/executor/home.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package executor
2+
3+
// ResolveHome returns the home directory of the console (GUI) user when
4+
// present, falling back to the process's current user. Returns an empty
5+
// string when neither resolves — callers degrade gracefully.
6+
//
7+
// On macOS the LoggedInUser path uses `stat /dev/console` to find the
8+
// real GUI user even when the agent runs as root via launchd (issue #63),
9+
// so this is the correct anchor for per-user paths like the TCC skip
10+
// list. Shared by both community-mode scan and enterprise telemetry to
11+
// keep them in lock-step.
12+
func ResolveHome(exec Executor) string {
13+
if u, err := exec.LoggedInUser(); err == nil {
14+
return u.HomeDir
15+
}
16+
if u, err := exec.CurrentUser(); err == nil {
17+
return u.HomeDir
18+
}
19+
return ""
20+
}

0 commit comments

Comments
 (0)