From 935132823103e1884aee185b5227c0c8bc7aad3d Mon Sep 17 00:00:00 2001 From: Shubham Malik Date: Mon, 25 May 2026 09:07:39 +0530 Subject: [PATCH 1/3] chore(mdm): skip macOS TCC-protected directories --- internal/cli/cli.go | 6 ++ internal/detector/configaudit/npmrc.go | 14 ++++- internal/detector/nodeproject.go | 14 ++++- internal/detector/nodescan.go | 12 ++++ internal/detector/pythonproject.go | 14 ++++- internal/scan/scanner.go | 33 ++++++++++- internal/tcc/tcc.go | 71 ++++++++++++++++++++++++ internal/tcc/tcc_darwin.go | 67 +++++++++++++++++++++++ internal/tcc/tcc_darwin_test.go | 76 ++++++++++++++++++++++++++ internal/tcc/tcc_other.go | 11 ++++ internal/tcc/tcc_other_test.go | 15 +++++ internal/telemetry/telemetry.go | 31 ++++++++++- 12 files changed, 355 insertions(+), 9 deletions(-) create mode 100644 internal/tcc/tcc.go create mode 100644 internal/tcc/tcc_darwin.go create mode 100644 internal/tcc/tcc_darwin_test.go create mode 100644 internal/tcc/tcc_other.go create mode 100644 internal/tcc/tcc_other_test.go diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 30dc8bb..986e73c 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -30,6 +30,7 @@ type Config struct { EnableBrewScan *bool // nil=auto, true/false=explicit EnablePythonScan *bool // nil=auto, true/false=explicit IncludeBundledPlugins bool // --include-bundled-plugins: include bundled/platform plugins in output + IncludeTCCProtected bool // --include-tcc-protected: on macOS, scan inside well-known TCC-protected dirs (Documents, Downloads, ~/Library/Mail, ...) even though doing so triggers permission prompts. Default off. NPMRCOnly bool // --npmrc: run only the npmrc audit and render verbose pretty output PipConfigOnly bool // --pipconfig: run only the pip config audit and render verbose pretty output SearchDirs []string // defaults to ["$HOME"] @@ -146,6 +147,8 @@ func Parse(args []string) (*Config, error) { cfg.EnablePythonScan = &v case arg == "--include-bundled-plugins": cfg.IncludeBundledPlugins = true + case arg == "--include-tcc-protected": + cfg.IncludeTCCProtected = true case arg == "--npmrc": cfg.NPMRCOnly = true case arg == "--pipconfig": @@ -393,6 +396,9 @@ Options: --enable-python-scan Enable Python package scanning --disable-python-scan Disable Python package scanning --include-bundled-plugins Include bundled/platform plugins in output (Windows) + --include-tcc-protected Scan macOS TCC-protected dirs (Documents, Downloads, + ~/Library/Mail, etc.). Default: skipped to avoid + permission prompts. --npmrc Run ONLY the npm config audit (verbose pretty view; --json supported) --pipconfig Run ONLY the pip config audit (verbose pretty view; --json supported) --log-level=LEVEL Log level: error | warn | info | debug (default: info) diff --git a/internal/detector/configaudit/npmrc.go b/internal/detector/configaudit/npmrc.go index a457fde..b5df96a 100644 --- a/internal/detector/configaudit/npmrc.go +++ b/internal/detector/configaudit/npmrc.go @@ -16,6 +16,7 @@ import ( "github.com/step-security/dev-machine-guard/internal/executor" "github.com/step-security/dev-machine-guard/internal/model" + "github.com/step-security/dev-machine-guard/internal/tcc" ) // 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| // hashes) and git-tracking checks pluggable so unit tests don't need real // syscalls or a git binary. type NPMRCDetector struct { - exec executor.Executor + exec executor.Executor + skipper *tcc.Skipper // ownerLookup returns owner info for a path. Defaults to the real // platform-specific impl in npmrc_stat_*.go; tests can override. @@ -84,6 +86,13 @@ func NewNPMRCDetector(exec executor.Executor) *NPMRCDetector { return d } +// WithSkipper attaches a TCC skipper so .npmrc discovery skips macOS-protected +// directories. A nil skipper is a no-op. Returns the detector for chaining. +func (d *NPMRCDetector) WithSkipper(s *tcc.Skipper) *NPMRCDetector { + d.skipper = s + return d +} + // Detect runs the full audit. searchDirs are the dirs to walk for project- // level .npmrc files (typically the user's $HOME plus any extra dirs // configured by the operator). loggedInUser is the username whose ~/.npmrc @@ -174,6 +183,9 @@ func (d *NPMRCDetector) findProjectNPMRCs(dir string) []string { return nil } if entry.IsDir() { + if d.skipper.ShouldSkip(path, dir) { + return filepath.SkipDir + } if shouldSkipNPMRCDir(path, entry.Name(), dir) { return filepath.SkipDir } diff --git a/internal/detector/nodeproject.go b/internal/detector/nodeproject.go index eaca8ae..1e1936a 100644 --- a/internal/detector/nodeproject.go +++ b/internal/detector/nodeproject.go @@ -9,19 +9,28 @@ import ( "github.com/step-security/dev-machine-guard/internal/executor" "github.com/step-security/dev-machine-guard/internal/model" + "github.com/step-security/dev-machine-guard/internal/tcc" ) const maxNodeProjects = 1000 // NodeProjectDetector scans for Node.js projects. type NodeProjectDetector struct { - exec executor.Executor + exec executor.Executor + skipper *tcc.Skipper } func NewNodeProjectDetector(exec executor.Executor) *NodeProjectDetector { return &NodeProjectDetector{exec: exec} } +// WithSkipper attaches a TCC skipper so the walk skips macOS-protected +// directories. A nil skipper is a no-op. Returns the detector for chaining. +func (d *NodeProjectDetector) WithSkipper(s *tcc.Skipper) *NodeProjectDetector { + d.skipper = s + return d +} + // CountProjects counts the number of Node.js projects found under the given directories. func (d *NodeProjectDetector) CountProjects(_ context.Context, searchDirs []string) int { return len(d.ListProjects(searchDirs)) @@ -47,6 +56,9 @@ func (d *NodeProjectDetector) listInDir(dir string) []model.ProjectInfo { return nil } if entry.IsDir() { + if d.skipper.ShouldSkip(path, dir) { + return filepath.SkipDir + } name := entry.Name() if name == "node_modules" || name == ".git" || name == ".cache" || strings.HasPrefix(name, ".") { diff --git a/internal/detector/nodescan.go b/internal/detector/nodescan.go index 12b26a9..05b7bd6 100644 --- a/internal/detector/nodescan.go +++ b/internal/detector/nodescan.go @@ -14,6 +14,7 @@ import ( "github.com/step-security/dev-machine-guard/internal/executor" "github.com/step-security/dev-machine-guard/internal/model" "github.com/step-security/dev-machine-guard/internal/progress" + "github.com/step-security/dev-machine-guard/internal/tcc" ) const defaultMaxProjectScanBytes = 500 * 1024 * 1024 // 500MB total limit @@ -34,6 +35,7 @@ type NodeScanner struct { exec executor.Executor log *progress.Logger loggedInUser string // when non-empty and running as root, commands run as this user + skipper *tcc.Skipper // ProgressHook, when non-nil, is invoked from inside ScanProjects / // ScanGlobalPackages with a short human-readable detail string ("project // 12 of 47", "scanning yarn", ...). Telemetry plumbs this into @@ -45,6 +47,13 @@ func NewNodeScanner(exec executor.Executor, log *progress.Logger, loggedInUser s return &NodeScanner{exec: exec, log: log, loggedInUser: loggedInUser} } +// WithSkipper attaches a TCC skipper so the discovery walk skips +// macOS-protected directories. A nil skipper is a no-op. +func (s *NodeScanner) WithSkipper(skipper *tcc.Skipper) *NodeScanner { + s.skipper = skipper + return s +} + func (s *NodeScanner) emitProgress(detail string) { if s.ProgressHook != nil { s.ProgressHook(detail) @@ -332,6 +341,9 @@ func (s *NodeScanner) ScanProjects(ctx context.Context, searchDirs []string) []m return nil } if entry.IsDir() { + if s.skipper.ShouldSkip(path, dir) { + return filepath.SkipDir + } name := entry.Name() if name == "node_modules" || name == ".git" || name == ".cache" || strings.HasPrefix(name, ".") { diff --git a/internal/detector/pythonproject.go b/internal/detector/pythonproject.go index e9f3140..022a47c 100644 --- a/internal/detector/pythonproject.go +++ b/internal/detector/pythonproject.go @@ -10,19 +10,28 @@ import ( "github.com/step-security/dev-machine-guard/internal/executor" "github.com/step-security/dev-machine-guard/internal/model" + "github.com/step-security/dev-machine-guard/internal/tcc" ) const maxPythonProjects = 1000 // PythonProjectDetector scans for Python projects with virtual environments. type PythonProjectDetector struct { - exec executor.Executor + exec executor.Executor + skipper *tcc.Skipper } func NewPythonProjectDetector(exec executor.Executor) *PythonProjectDetector { return &PythonProjectDetector{exec: exec} } +// WithSkipper attaches a TCC skipper so the walk skips macOS-protected +// directories. A nil skipper is a no-op. Returns the detector for chaining. +func (d *PythonProjectDetector) WithSkipper(s *tcc.Skipper) *PythonProjectDetector { + d.skipper = s + return d +} + // CountProjects counts Python projects with virtual environments. func (d *PythonProjectDetector) CountProjects(_ context.Context, searchDirs []string) int { return len(d.ListProjects(searchDirs)) @@ -129,6 +138,9 @@ func (d *PythonProjectDetector) listInDir(dir string) []model.ProjectInfo { if !entry.IsDir() { return nil } + if d.skipper.ShouldSkip(path, dir) { + return filepath.SkipDir + } name := entry.Name() if name == "node_modules" || name == ".git" || name == ".cache" || name == "__pycache__" || name == ".tox" || name == "site-packages" || diff --git a/internal/scan/scanner.go b/internal/scan/scanner.go index 84a6d15..769335a 100644 --- a/internal/scan/scanner.go +++ b/internal/scan/scanner.go @@ -15,6 +15,7 @@ import ( "github.com/step-security/dev-machine-guard/internal/model" "github.com/step-security/dev-machine-guard/internal/output" "github.com/step-security/dev-machine-guard/internal/progress" + "github.com/step-security/dev-machine-guard/internal/tcc" ) // Run executes a community-mode scan and outputs results. @@ -32,6 +33,19 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error { } } + // Build the TCC skipper so directory walks avoid macOS-protected dirs + // (Documents, Downloads, ~/Library/Mail, ...) and don't trigger system + // permission prompts. Nil when --include-tcc-protected is set; the + // skipper's ShouldSkip is nil-safe so downstream callers don't branch. + var tccSkipper *tcc.Skipper + if !cfg.IncludeTCCProtected { + tccSkipper = tcc.New(resolveHome(exec)) + if cands := tccSkipper.Candidates(); len(cands) > 0 { + log.Warn("macOS TCC: skipping %d protected dirs (Documents, Downloads, ~/Library/Mail, ...) to avoid permission prompts. Pass --include-tcc-protected to scan them.", len(cands)) + log.Debug("tcc skip list: %v", cands) + } + } + // Gather device info log.StepStart("Gathering device information") start := time.Now() @@ -107,7 +121,7 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error { log.StepStart("Scanning Node.js projects") start = time.Now() - projectDetector := detector.NewNodeProjectDetector(exec) + projectDetector := detector.NewNodeProjectDetector(exec).WithSkipper(tccSkipper) nodeProjects = projectDetector.ListProjects(searchDirs) log.StepDone(time.Since(start)) } else { @@ -200,7 +214,7 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error { log.StepStart("Scanning Python projects") start = time.Now() - pyProjectDetector := detector.NewPythonProjectDetector(exec) + pyProjectDetector := detector.NewPythonProjectDetector(exec).WithSkipper(tccSkipper) pythonProjects = pyProjectDetector.ListProjects(searchDirs) log.StepDone(time.Since(start)) } else { @@ -218,7 +232,7 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error { if featuregate.IsEnabled(featuregate.FeatureNPMRCAudit) { log.StepStart("Auditing npm configuration") start = time.Now() - npmrcAudit = configaudit.NewNPMRCDetector(exec).Detect(ctx, searchDirs, loggedInUser) + npmrcAudit = configaudit.NewNPMRCDetector(exec).WithSkipper(tccSkipper).Detect(ctx, searchDirs, loggedInUser) log.StepDone(time.Since(start)) } @@ -349,6 +363,19 @@ func resolveSearchDirs(exec executor.Executor, dirs []string) []string { return resolved } +// resolveHome returns the home directory of the console user when present, +// falling back to the process's current user (issue #63 fallback). Empty +// string when neither resolves — callers degrade gracefully. +func resolveHome(exec executor.Executor) string { + if u, err := exec.LoggedInUser(); err == nil { + return u.HomeDir + } + if u, err := exec.CurrentUser(); err == nil { + return u.HomeDir + } + return "" +} + func mergeAITools(cli, agents, frameworks []model.AITool) []model.AITool { result := make([]model.AITool, 0, len(cli)+len(agents)+len(frameworks)) result = append(result, cli...) diff --git a/internal/tcc/tcc.go b/internal/tcc/tcc.go new file mode 100644 index 0000000..1b7f370 --- /dev/null +++ b/internal/tcc/tcc.go @@ -0,0 +1,71 @@ +// Package tcc identifies macOS TCC (Transparency, Consent, and Control) +// protected directories so filesystem walks can skip them and avoid +// triggering system permission prompts on a user's machine. +// +// On non-darwin builds the Skipper is a no-op: ShouldSkip always returns +// false and Candidates returns nil, so callers can wire it unconditionally. +package tcc + +import ( + "path/filepath" + "sort" + "strings" +) + +// Skipper is an immutable matcher for TCC-protected directories. Build one +// per scan via New; share across detectors. +type Skipper struct { + paths map[string]struct{} + prefixes []string +} + +// New builds a Skipper anchored at home. home == "" produces a degraded +// Skipper that only matches absolute-prefix entries (e.g. Time Machine +// snapshot mounts) — useful when the agent runs without a console user. +func New(home string) *Skipper { + return &Skipper{ + paths: buildProtectedPaths(home), + prefixes: protectedPrefixes(), + } +} + +// ShouldSkip reports whether path is a TCC-protected directory whose walk +// should be short-circuited. When path equals walkRoot the result is always +// false: passing --search-dirs ~/Documents is an explicit opt-in, and the +// walk root must be entered for anything to happen. +// +// Safe to call on a nil receiver (returns false), which is what callers +// pass when --include-tcc-protected is set. +func (s *Skipper) ShouldSkip(path, walkRoot string) bool { + if s == nil { + return false + } + cleaned := filepath.Clean(path) + if filepath.Clean(walkRoot) == cleaned { + return false + } + if _, ok := s.paths[cleaned]; ok { + return true + } + for _, p := range s.prefixes { + if strings.HasPrefix(cleaned, p) { + return true + } + } + return false +} + +// Candidates returns the exact-match protected paths the Skipper would +// skip, sorted lexicographically. Useful for surfacing in logs. Returns nil +// on a nil receiver or on non-darwin builds. +func (s *Skipper) Candidates() []string { + if s == nil || len(s.paths) == 0 { + return nil + } + out := make([]string, 0, len(s.paths)) + for p := range s.paths { + out = append(out, p) + } + sort.Strings(out) + return out +} diff --git a/internal/tcc/tcc_darwin.go b/internal/tcc/tcc_darwin.go new file mode 100644 index 0000000..4288fe0 --- /dev/null +++ b/internal/tcc/tcc_darwin.go @@ -0,0 +1,67 @@ +//go:build darwin + +package tcc + +import "path/filepath" + +// protectedSuffixes are paths relative to the user's home directory that +// macOS gates behind TCC permission prompts. Categories: +// - Files & Folders (Catalina+): Desktop, Documents, Downloads +// - Removable / Network (Catalina+): handled via opt-in search dirs +// - Photos / Music / Movies (Sequoia hardened): Pictures, Movies, Music +// - Full Disk Access subtrees: ~/Library/Mail, Messages, Safari, etc. +// - Cloud sync (Sonoma+): Mobile Documents, CloudStorage +var protectedSuffixes = []string{ + "Desktop", + "Documents", + "Downloads", + "Pictures", + "Movies", + "Music", + "Public", + ".Trash", + + "Library/Mail", + "Library/Messages", + "Library/Safari", + "Library/Calendars", + "Library/Reminders", + "Library/HomeKit", + "Library/Suggestions", + "Library/Application Support/AddressBook", + "Library/Application Support/CallHistoryDB", + "Library/Application Support/CallHistoryTransactions", + "Library/IdentityServices", + "Library/Metadata/CoreSpotlight", + "Library/PersonalizationPortrait", + "Library/Containers/com.apple.mail", + "Library/Group Containers/group.com.apple.calendar", + "Library/Group Containers/group.com.apple.notes", + + "Library/Mobile Documents", + "Library/CloudStorage", +} + +// protectedAbsolutePrefixes are matched with strings.HasPrefix. Time +// Machine local-snapshot mounts use names like +// /Volumes/.timemachine.donottouch. which vary by macOS version, so +// a prefix is more robust than an exact path. +var protectedAbsolutePrefixes = []string{ + "/Volumes/.timemachine", +} + +func buildProtectedPaths(home string) map[string]struct{} { + if home == "" { + return nil + } + cleanedHome := filepath.Clean(home) + paths := make(map[string]struct{}, len(protectedSuffixes)) + for _, suffix := range protectedSuffixes { + paths[filepath.Join(cleanedHome, suffix)] = struct{}{} + } + return paths +} + +func protectedPrefixes() []string { + return protectedAbsolutePrefixes +} diff --git a/internal/tcc/tcc_darwin_test.go b/internal/tcc/tcc_darwin_test.go new file mode 100644 index 0000000..d18a8b8 --- /dev/null +++ b/internal/tcc/tcc_darwin_test.go @@ -0,0 +1,76 @@ +//go:build darwin + +package tcc + +import "testing" + +func TestSkipper_ShouldSkip(t *testing.T) { + home := "/Users/alice" + s := New(home) + + tests := []struct { + name string + path string + walkRoot string + want bool + }{ + {"documents skipped", "/Users/alice/Documents", "/Users/alice", true}, + {"documents trailing slash", "/Users/alice/Documents/", "/Users/alice", true}, + {"downloads skipped", "/Users/alice/Downloads", "/Users/alice", true}, + {"desktop skipped", "/Users/alice/Desktop", "/Users/alice", true}, + {"library mail skipped", "/Users/alice/Library/Mail", "/Users/alice", true}, + {"icloud drive skipped", "/Users/alice/Library/Mobile Documents", "/Users/alice", true}, + {"trash skipped", "/Users/alice/.Trash", "/Users/alice", true}, + {"random code dir not skipped", "/Users/alice/code", "/Users/alice", false}, + {"vscode dotdir not skipped", "/Users/alice/.vscode", "/Users/alice", false}, + {"walk root opt-in", "/Users/alice/Documents", "/Users/alice/Documents", false}, + {"walk root opt-in trailing slash", "/Users/alice/Documents", "/Users/alice/Documents/", false}, + {"timemachine prefix matched", "/Volumes/.timemachine.donottouch/2026-05-25", "/Volumes/MyDrive", true}, + {"other volume not matched", "/Volumes/MyDrive/code", "/Volumes/MyDrive", false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := s.ShouldSkip(tc.path, tc.walkRoot) + if got != tc.want { + t.Errorf("ShouldSkip(%q, %q) = %v, want %v", tc.path, tc.walkRoot, got, tc.want) + } + }) + } +} + +func TestSkipper_NilSafe(t *testing.T) { + var s *Skipper + if s.ShouldSkip("/Users/alice/Documents", "/Users/alice") { + t.Error("nil Skipper ShouldSkip should return false") + } + if s.Candidates() != nil { + t.Error("nil Skipper Candidates should return nil") + } +} + +func TestSkipper_EmptyHome(t *testing.T) { + s := New("") + if s.ShouldSkip("/Users/alice/Documents", "/Users/alice") { + t.Error("Skipper with empty home should not match home-anchored paths") + } + if !s.ShouldSkip("/Volumes/.timemachine.donottouch/snap", "/Volumes/MyDrive") { + t.Error("Skipper with empty home should still match absolute-prefix entries") + } + if s.Candidates() != nil { + t.Error("Skipper with empty home should have nil Candidates") + } +} + +func TestSkipper_CandidatesSorted(t *testing.T) { + s := New("/Users/alice") + cands := s.Candidates() + if len(cands) == 0 { + t.Fatal("expected non-empty candidates on darwin") + } + for i := 1; i < len(cands); i++ { + if cands[i-1] > cands[i] { + t.Errorf("candidates not sorted: %q > %q at index %d", cands[i-1], cands[i], i) + } + } +} diff --git a/internal/tcc/tcc_other.go b/internal/tcc/tcc_other.go new file mode 100644 index 0000000..ca2754a --- /dev/null +++ b/internal/tcc/tcc_other.go @@ -0,0 +1,11 @@ +//go:build !darwin + +package tcc + +func buildProtectedPaths(_ string) map[string]struct{} { + return nil +} + +func protectedPrefixes() []string { + return nil +} diff --git a/internal/tcc/tcc_other_test.go b/internal/tcc/tcc_other_test.go new file mode 100644 index 0000000..df9bf69 --- /dev/null +++ b/internal/tcc/tcc_other_test.go @@ -0,0 +1,15 @@ +//go:build !darwin + +package tcc + +import "testing" + +func TestSkipper_NoOpOnNonDarwin(t *testing.T) { + s := New("/home/alice") + if s.ShouldSkip("/home/alice/Documents", "/home/alice") { + t.Error("Skipper should be a no-op on non-darwin") + } + if s.Candidates() != nil { + t.Error("Candidates should be nil on non-darwin") + } +} diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 3269912..658fea0 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -25,6 +25,7 @@ import ( "github.com/step-security/dev-machine-guard/internal/lock" "github.com/step-security/dev-machine-guard/internal/model" "github.com/step-security/dev-machine-guard/internal/progress" + "github.com/step-security/dev-machine-guard/internal/tcc" ) // s3UploadBackoffUnit is multiplied by attempt-number to compute the @@ -380,6 +381,18 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) (err err log.Debug("search directories resolved: %v", searchDirs) fmt.Fprintln(os.Stderr) + // Build a TCC skipper so directory walks avoid macOS-protected dirs and + // don't trigger system permission prompts when the agent runs without + // Full Disk Access. Nil when --include-tcc-protected is set; ShouldSkip + // is nil-safe. + var tccSkipper *tcc.Skipper + if !cfg.IncludeTCCProtected { + tccSkipper = tcc.New(resolveHome(exec)) + if cands := tccSkipper.Candidates(); len(cands) > 0 { + log.Debug("tcc skip list (%d): %v", len(cands), cands) + } + } + // Detect IDEs tracker.Start("ide_scan") log.Progress("Detecting IDE and AI desktop app installations...") @@ -554,7 +567,7 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) (err err log.Progress(" Found %d Python global package source(s)", len(pythonGlobalPkgs)) log.Progress("Searching for Python projects...") - pyProjectDetector := detector.NewPythonProjectDetector(exec) + pyProjectDetector := detector.NewPythonProjectDetector(exec).WithSkipper(tccSkipper) pythonProjects = pyProjectDetector.ListProjects(searchDirs) log.Progress(" Found %d Python projects", len(pythonProjects)) fmt.Fprintln(os.Stderr) @@ -653,7 +666,7 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) (err err fmt.Fprintln(os.Stderr) log.Progress("Scanning globally installed packages...") - nodeScanner := detector.NewNodeScanner(exec, log, loggedInUsername) + nodeScanner := detector.NewNodeScanner(exec, log, loggedInUsername).WithSkipper(tccSkipper) // Stream sub-progress so heartbeats show "project 12 of 47" / // "global: yarn" during the long-running node phase. Both // ScanGlobalPackages and ScanProjects share this hook. @@ -705,7 +718,7 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) (err err // pyenv / asdf / brew installs that root's PATH wouldn't see). log.Progress("Auditing npm configuration...") npmrcLoggedIn, _ := exec.LoggedInUser() - npmrcAudit := configaudit.NewNPMRCDetector(userExec).Detect(ctx, searchDirs, npmrcLoggedIn) + npmrcAudit := configaudit.NewNPMRCDetector(userExec).WithSkipper(tccSkipper).Detect(ctx, searchDirs, npmrcLoggedIn) log.Progress(" npm available: %v, files discovered: %d", npmrcAudit.Available, len(npmrcAudit.Files)) fmt.Fprintln(os.Stderr) @@ -1124,6 +1137,18 @@ func resolveSearchDirs(exec executor.Executor, dirs []string) []string { return resolved } +// resolveHome returns the home directory of the console user, falling back +// to the process's current user. Empty string when neither resolves. +func resolveHome(exec executor.Executor) string { + if u, err := exec.LoggedInUser(); err == nil { + return u.HomeDir + } + if u, err := exec.CurrentUser(); err == nil { + return u.HomeDir + } + return "" +} + func ideDisplayName(ideType string) string { switch ideType { case "vscode": From 8b0fdfe50c8db715fa2cc99453aec2d79a4be2b6 Mon Sep 17 00:00:00 2001 From: Shubham Malik Date: Mon, 25 May 2026 09:26:06 +0530 Subject: [PATCH 2/3] chore(mdm): log TCC skip hits encountered during walks --- internal/scan/scanner.go | 18 +++++++++++++++ internal/tcc/tcc.go | 40 +++++++++++++++++++++++++++++++-- internal/telemetry/telemetry.go | 18 +++++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/internal/scan/scanner.go b/internal/scan/scanner.go index 769335a..aa9fa70 100644 --- a/internal/scan/scanner.go +++ b/internal/scan/scanner.go @@ -3,6 +3,7 @@ package scan import ( "context" "os" + "sort" "time" "github.com/step-security/dev-machine-guard/internal/buildinfo" @@ -333,6 +334,7 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error { log.Debug("scan complete: ais=%d ides=%d extensions=%d mcp=%d node_projects=%d brew_formulae=%d brew_casks=%d python_projects=%d", len(aiTools), len(ides), len(extensions), len(mcpConfigs), len(nodeProjects), len(brewFormulae), len(brewCasks), len(pythonProjects)) + logTCCHits(log, tccSkipper) // Output switch cfg.OutputFormat { @@ -376,6 +378,22 @@ func resolveHome(exec executor.Executor) string { return "" } +// logTCCHits surfaces which TCC-protected paths were actually encountered +// (and short-circuited) during the scan's directory walks. Quiet when +// nothing was matched. +func logTCCHits(log *progress.Logger, s *tcc.Skipper) { + hits := s.Hits() + if len(hits) == 0 { + return + } + paths := make([]string, 0, len(hits)) + for p := range hits { + paths = append(paths, p) + } + sort.Strings(paths) + log.Warn("macOS TCC: encountered and skipped %d protected path(s) during walks: %v", len(paths), paths) +} + func mergeAITools(cli, agents, frameworks []model.AITool) []model.AITool { result := make([]model.AITool, 0, len(cli)+len(agents)+len(frameworks)) result = append(result, cli...) diff --git a/internal/tcc/tcc.go b/internal/tcc/tcc.go index 1b7f370..1d4b24e 100644 --- a/internal/tcc/tcc.go +++ b/internal/tcc/tcc.go @@ -10,13 +10,18 @@ import ( "path/filepath" "sort" "strings" + "sync" ) -// Skipper is an immutable matcher for TCC-protected directories. Build one -// per scan via New; share across detectors. +// Skipper matches TCC-protected directories. Build one per scan via New; +// share across detectors. Hits are tracked so callers can prove from logs +// which protected paths were actually encountered during the walks. type Skipper struct { paths map[string]struct{} prefixes []string + + mu sync.Mutex + hits map[string]int } // New builds a Skipper anchored at home. home == "" produces a degraded @@ -45,16 +50,47 @@ func (s *Skipper) ShouldSkip(path, walkRoot string) bool { return false } if _, ok := s.paths[cleaned]; ok { + s.recordHit(cleaned) return true } for _, p := range s.prefixes { if strings.HasPrefix(cleaned, p) { + s.recordHit(cleaned) return true } } return false } +func (s *Skipper) recordHit(path string) { + s.mu.Lock() + defer s.mu.Unlock() + if s.hits == nil { + s.hits = make(map[string]int) + } + s.hits[path]++ +} + +// Hits returns the set of TCC-protected paths that were encountered during +// walks, with the count of times each was matched. Returns nil if nothing +// was skipped (or on a nil receiver). Safe to call concurrently with +// ShouldSkip, though callers typically only read after walks complete. +func (s *Skipper) Hits() map[string]int { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + if len(s.hits) == 0 { + return nil + } + out := make(map[string]int, len(s.hits)) + for k, v := range s.hits { + out[k] = v + } + return out +} + // Candidates returns the exact-match protected paths the Skipper would // skip, sorted lexicographically. Useful for surfacing in logs. Returns nil // on a nil receiver or on non-darwin builds. diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 658fea0..d03cd40 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -10,6 +10,7 @@ import ( "net/http" "os" "os/signal" + "sort" "sync" "sync/atomic" "syscall" @@ -808,9 +809,26 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) (err err fmt.Fprintln(os.Stderr) log.Progress("Telemetry collection completed successfully") + logTCCHits(log, tccSkipper) return nil } +// logTCCHits surfaces which TCC-protected paths were actually encountered +// (and short-circuited) during the enterprise scan's directory walks. +// Quiet when nothing was matched. +func logTCCHits(log *progress.Logger, s *tcc.Skipper) { + hits := s.Hits() + if len(hits) == 0 { + return + } + paths := make([]string, 0, len(hits)) + for p := range hits { + paths = append(paths, p) + } + sort.Strings(paths) + log.Debug("tcc: encountered and skipped %d protected path(s) during walks: %v", len(paths), paths) +} + func brewFormulaeCount(scans []model.BrewScanResult) int { for _, s := range scans { if s.ScanType == "formulae" { From 40d782c588997a8a2dd263d94337d85114aa955a Mon Sep 17 00:00:00 2001 From: Shubham Malik Date: Tue, 26 May 2026 00:17:30 +0530 Subject: [PATCH 3/3] chore(mdm): scope TCC skip to launchd, tighten prefix, dedupe helpers --- cmd/stepsecurity-dev-machine-guard/main.go | 3 + internal/cli/cli.go | 53 +++++++++------ internal/config/config.go | 57 ++++++++-------- internal/executor/home.go | 20 ++++++ internal/scan/scanner.go | 36 +---------- internal/tcc/tcc.go | 75 +++++++++++++++++++++- internal/tcc/tcc_darwin_test.go | 36 +++++++++++ internal/telemetry/telemetry.go | 35 +--------- 8 files changed, 203 insertions(+), 112 deletions(-) create mode 100644 internal/executor/home.go diff --git a/cmd/stepsecurity-dev-machine-guard/main.go b/cmd/stepsecurity-dev-machine-guard/main.go index d5a5db6..6830a52 100644 --- a/cmd/stepsecurity-dev-machine-guard/main.go +++ b/cmd/stepsecurity-dev-machine-guard/main.go @@ -84,6 +84,9 @@ func main() { if cfg.EnablePythonScan == nil && config.EnablePythonScan != nil { cfg.EnablePythonScan = config.EnablePythonScan } + if cfg.IncludeTCCProtected == nil && config.IncludeTCCProtected != nil { + cfg.IncludeTCCProtected = config.IncludeTCCProtected + } if cfg.ColorMode == "auto" && config.ColorMode != "" { cfg.ColorMode = config.ColorMode } diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 986e73c..f15970e 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -17,23 +17,29 @@ import ( // failure, so the hot path bypasses cli.Parse entirely — see main.go's // early-return and internal/aiagents/cli.RunHook. type Config struct { - Command string // "", "install", "uninstall", "send-telemetry", "configure", "configure show", "hooks install", "hooks uninstall" - OutputFormat string // "pretty", "json", "html" - OutputFormatSet bool // true if --pretty/--json/--html was explicitly passed (not persisted) - HTMLOutputFile string // set by --html (not persisted) - ColorMode string // "auto", "always", "never" - Verbose bool // --verbose (shortcut for --log-level=debug) - LogLevel string // "" = unset; one of "error", "warn", "info", "debug" - 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). - InstallDirSet bool // true if --install-dir was passed (empty value = disable file logging for this run) - EnableNPMScan *bool // nil=auto, true/false=explicit - EnableBrewScan *bool // nil=auto, true/false=explicit - EnablePythonScan *bool // nil=auto, true/false=explicit - IncludeBundledPlugins bool // --include-bundled-plugins: include bundled/platform plugins in output - IncludeTCCProtected bool // --include-tcc-protected: on macOS, scan inside well-known TCC-protected dirs (Documents, Downloads, ~/Library/Mail, ...) even though doing so triggers permission prompts. Default off. - NPMRCOnly bool // --npmrc: run only the npmrc audit and render verbose pretty output - PipConfigOnly bool // --pipconfig: run only the pip config audit and render verbose pretty output - SearchDirs []string // defaults to ["$HOME"] + Command string // "", "install", "uninstall", "send-telemetry", "configure", "configure show", "hooks install", "hooks uninstall" + OutputFormat string // "pretty", "json", "html" + OutputFormatSet bool // true if --pretty/--json/--html was explicitly passed (not persisted) + HTMLOutputFile string // set by --html (not persisted) + ColorMode string // "auto", "always", "never" + Verbose bool // --verbose (shortcut for --log-level=debug) + LogLevel string // "" = unset; one of "error", "warn", "info", "debug" + 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). + InstallDirSet bool // true if --install-dir was passed (empty value = disable file logging for this run) + EnableNPMScan *bool // nil=auto, true/false=explicit + EnableBrewScan *bool // nil=auto, true/false=explicit + EnablePythonScan *bool // nil=auto, true/false=explicit + IncludeBundledPlugins bool // --include-bundled-plugins: include bundled/platform plugins in output + // IncludeTCCProtected is tristate: nil = use the runtime default + // (skip only when running under launchd, where TCC prompts actually + // fire), true = always include the protected dirs (skipper off), + // false = always exclude them (skipper on). Wired via + // --include-tcc-protected / --no-include-tcc-protected and the + // matching field in config.ConfigFile. + IncludeTCCProtected *bool + NPMRCOnly bool // --npmrc: run only the npmrc audit and render verbose pretty output + PipConfigOnly bool // --pipconfig: run only the pip config audit and render verbose pretty output + SearchDirs []string // defaults to ["$HOME"] // HooksAgent is the --agent value on `hooks install` / `hooks uninstall`; // "" means "every detected agent". @@ -148,7 +154,11 @@ func Parse(args []string) (*Config, error) { case arg == "--include-bundled-plugins": cfg.IncludeBundledPlugins = true case arg == "--include-tcc-protected": - cfg.IncludeTCCProtected = true + v := true + cfg.IncludeTCCProtected = &v + case arg == "--no-include-tcc-protected": + v := false + cfg.IncludeTCCProtected = &v case arg == "--npmrc": cfg.NPMRCOnly = true case arg == "--pipconfig": @@ -397,8 +407,11 @@ Options: --disable-python-scan Disable Python package scanning --include-bundled-plugins Include bundled/platform plugins in output (Windows) --include-tcc-protected Scan macOS TCC-protected dirs (Documents, Downloads, - ~/Library/Mail, etc.). Default: skipped to avoid - permission prompts. + ~/Library/Mail, etc.). Default: skipped only when + running under launchd (where permission prompts + fire); direct CLI runs scan them. + --no-include-tcc-protected Force-skip macOS TCC-protected dirs even on direct + CLI runs. --npmrc Run ONLY the npm config audit (verbose pretty view; --json supported) --pipconfig Run ONLY the pip config audit (verbose pretty view; --json supported) --log-level=LEVEL Log level: error | warn | info | debug (default: info) diff --git a/internal/config/config.go b/internal/config/config.go index d72f36e..9258e3e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,36 +11,38 @@ import ( // Default placeholders (replaced by backend for enterprise installation scripts). var ( - CustomerID = "{{CUSTOMER_ID}}" - APIEndpoint = "{{API_ENDPOINT}}" - APIKey = "{{API_KEY}}" - ScanFrequencyHours = "{{SCAN_FREQUENCY_HOURS}}" - SearchDirs []string - EnableNPMScan *bool // nil=auto - EnableBrewScan *bool // nil=auto - EnablePythonScan *bool // nil=auto - ColorMode string // "" means auto - OutputFormat string // "" means default (pretty) - HTMLOutputFile string // "" means not set - LogLevel string // "" means default (info); one of error/warn/info/debug - 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. + CustomerID = "{{CUSTOMER_ID}}" + APIEndpoint = "{{API_ENDPOINT}}" + APIKey = "{{API_KEY}}" //#nosec G101 -- build-time placeholder substituted by the backend installer; the literal is not a real credential. + ScanFrequencyHours = "{{SCAN_FREQUENCY_HOURS}}" + SearchDirs []string + EnableNPMScan *bool // nil=auto + EnableBrewScan *bool // nil=auto + EnablePythonScan *bool // nil=auto + IncludeTCCProtected *bool // nil=auto (skip when running under macOS launchd, scan otherwise) + ColorMode string // "" means auto + OutputFormat string // "" means default (pretty) + HTMLOutputFile string // "" means not set + LogLevel string // "" means default (info); one of error/warn/info/debug + 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. ) // ConfigFile is the JSON structure persisted to ~/.stepsecurity/config.json. type ConfigFile struct { - CustomerID string `json:"customer_id,omitempty"` - APIEndpoint string `json:"api_endpoint,omitempty"` - APIKey string `json:"api_key,omitempty"` - ScanFrequencyHours string `json:"scan_frequency_hours,omitempty"` - SearchDirs []string `json:"search_dirs,omitempty"` - EnableNPMScan *bool `json:"enable_npm_scan,omitempty"` - EnableBrewScan *bool `json:"enable_brew_scan,omitempty"` - EnablePythonScan *bool `json:"enable_python_scan,omitempty"` - ColorMode string `json:"color_mode,omitempty"` - OutputFormat string `json:"output_format,omitempty"` - HTMLOutputFile string `json:"html_output_file,omitempty"` - LogLevel string `json:"log_level,omitempty"` - InstallDir string `json:"install_dir,omitempty"` + CustomerID string `json:"customer_id,omitempty"` + APIEndpoint string `json:"api_endpoint,omitempty"` + APIKey string `json:"api_key,omitempty"` + ScanFrequencyHours string `json:"scan_frequency_hours,omitempty"` + SearchDirs []string `json:"search_dirs,omitempty"` + EnableNPMScan *bool `json:"enable_npm_scan,omitempty"` + EnableBrewScan *bool `json:"enable_brew_scan,omitempty"` + EnablePythonScan *bool `json:"enable_python_scan,omitempty"` + IncludeTCCProtected *bool `json:"include_tcc_protected,omitempty"` + ColorMode string `json:"color_mode,omitempty"` + OutputFormat string `json:"output_format,omitempty"` + HTMLOutputFile string `json:"html_output_file,omitempty"` + LogLevel string `json:"log_level,omitempty"` + InstallDir string `json:"install_dir,omitempty"` } // userConfigDir returns ~/.stepsecurity — the per-user config location. @@ -152,6 +154,9 @@ func Load() { if cfg.EnablePythonScan != nil && EnablePythonScan == nil { EnablePythonScan = cfg.EnablePythonScan } + if cfg.IncludeTCCProtected != nil && IncludeTCCProtected == nil { + IncludeTCCProtected = cfg.IncludeTCCProtected + } if cfg.ColorMode != "" && ColorMode == "" { ColorMode = cfg.ColorMode } diff --git a/internal/executor/home.go b/internal/executor/home.go new file mode 100644 index 0000000..73b8ddd --- /dev/null +++ b/internal/executor/home.go @@ -0,0 +1,20 @@ +package executor + +// ResolveHome returns the home directory of the console (GUI) user when +// present, falling back to the process's current user. Returns an empty +// string when neither resolves — callers degrade gracefully. +// +// On macOS the LoggedInUser path uses `stat /dev/console` to find the +// real GUI user even when the agent runs as root via launchd (issue #63), +// so this is the correct anchor for per-user paths like the TCC skip +// list. Shared by both community-mode scan and enterprise telemetry to +// keep them in lock-step. +func ResolveHome(exec Executor) string { + if u, err := exec.LoggedInUser(); err == nil { + return u.HomeDir + } + if u, err := exec.CurrentUser(); err == nil { + return u.HomeDir + } + return "" +} diff --git a/internal/scan/scanner.go b/internal/scan/scanner.go index aa9fa70..247ebe5 100644 --- a/internal/scan/scanner.go +++ b/internal/scan/scanner.go @@ -3,7 +3,6 @@ package scan import ( "context" "os" - "sort" "time" "github.com/step-security/dev-machine-guard/internal/buildinfo" @@ -39,8 +38,8 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error { // permission prompts. Nil when --include-tcc-protected is set; the // skipper's ShouldSkip is nil-safe so downstream callers don't branch. var tccSkipper *tcc.Skipper - if !cfg.IncludeTCCProtected { - tccSkipper = tcc.New(resolveHome(exec)) + if tcc.Enabled(cfg.IncludeTCCProtected) { + tccSkipper = tcc.New(executor.ResolveHome(exec)) if cands := tccSkipper.Candidates(); len(cands) > 0 { log.Warn("macOS TCC: skipping %d protected dirs (Documents, Downloads, ~/Library/Mail, ...) to avoid permission prompts. Pass --include-tcc-protected to scan them.", len(cands)) log.Debug("tcc skip list: %v", cands) @@ -334,7 +333,7 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error { log.Debug("scan complete: ais=%d ides=%d extensions=%d mcp=%d node_projects=%d brew_formulae=%d brew_casks=%d python_projects=%d", len(aiTools), len(ides), len(extensions), len(mcpConfigs), len(nodeProjects), len(brewFormulae), len(brewCasks), len(pythonProjects)) - logTCCHits(log, tccSkipper) + tccSkipper.LogHits(log.Warn) // Output switch cfg.OutputFormat { @@ -365,35 +364,6 @@ func resolveSearchDirs(exec executor.Executor, dirs []string) []string { return resolved } -// resolveHome returns the home directory of the console user when present, -// falling back to the process's current user (issue #63 fallback). Empty -// string when neither resolves — callers degrade gracefully. -func resolveHome(exec executor.Executor) string { - if u, err := exec.LoggedInUser(); err == nil { - return u.HomeDir - } - if u, err := exec.CurrentUser(); err == nil { - return u.HomeDir - } - return "" -} - -// logTCCHits surfaces which TCC-protected paths were actually encountered -// (and short-circuited) during the scan's directory walks. Quiet when -// nothing was matched. -func logTCCHits(log *progress.Logger, s *tcc.Skipper) { - hits := s.Hits() - if len(hits) == 0 { - return - } - paths := make([]string, 0, len(hits)) - for p := range hits { - paths = append(paths, p) - } - sort.Strings(paths) - log.Warn("macOS TCC: encountered and skipped %d protected path(s) during walks: %v", len(paths), paths) -} - func mergeAITools(cli, agents, frameworks []model.AITool) []model.AITool { result := make([]model.AITool, 0, len(cli)+len(agents)+len(frameworks)) result = append(result, cli...) diff --git a/internal/tcc/tcc.go b/internal/tcc/tcc.go index 1d4b24e..da30d73 100644 --- a/internal/tcc/tcc.go +++ b/internal/tcc/tcc.go @@ -7,12 +7,47 @@ package tcc import ( + "os" "path/filepath" + "runtime" "sort" "strings" "sync" ) +// Enabled reports whether the TCC skipper should be active for this run. +// The override is the resolved tri-state cfg/config value: nil to defer to +// the runtime default, true to explicitly include TCC paths (don't skip), +// false to explicitly exclude (always skip). +// +// The default (nil override) only skips when the process is running under +// macOS launchd: that's the context in which TCC permission prompts +// actually fire and there's nobody at a keyboard to dismiss them. Direct +// CLI invocations typically inherit Terminal.app's TCC grants and don't +// see prompts, so they default to full scan coverage. +func Enabled(override *bool) bool { + if override != nil { + // Override semantics: true = "include TCC paths" = skipper OFF. + return !*override + } + return IsRunningUnderLaunchd() +} + +// IsRunningUnderLaunchd reports whether the process appears to be running +// as a macOS launchd-managed daemon or agent. Detection signals (in +// order): an explicit STEPSEC_VIA_LAUNCHD=1 env var (escape hatch for +// tests and forward-compat plist changes), then PPID==1 on darwin +// (launchd is PID 1 and reparents its jobs). Returns false off darwin. +func IsRunningUnderLaunchd() bool { + if os.Getenv("STEPSEC_VIA_LAUNCHD") == "1" { + return true + } + if runtime.GOOS != "darwin" { + return false + } + return os.Getppid() == 1 +} + // Skipper matches TCC-protected directories. Build one per scan via New; // share across detectors. Hits are tracked so callers can prove from logs // which protected paths were actually encountered during the walks. @@ -54,7 +89,7 @@ func (s *Skipper) ShouldSkip(path, walkRoot string) bool { return true } for _, p := range s.prefixes { - if strings.HasPrefix(cleaned, p) { + if hasPathPrefix(cleaned, p) { s.recordHit(cleaned) return true } @@ -62,6 +97,22 @@ func (s *Skipper) ShouldSkip(path, walkRoot string) bool { return false } +// hasPathPrefix returns true when s starts with prefix AND the character +// immediately after is a path separator, a dot, or end-of-string. This +// keeps a sentinel like "/Volumes/.timemachine" from matching unrelated +// paths such as "/Volumes/.timemachine_backup", while still matching the +// real Time Machine mount form "/Volumes/.timemachine.donottouch.". +func hasPathPrefix(s, prefix string) bool { + if !strings.HasPrefix(s, prefix) { + return false + } + if len(s) == len(prefix) { + return true + } + c := s[len(prefix)] + return c == '/' || c == '.' +} + func (s *Skipper) recordHit(path string) { s.mu.Lock() defer s.mu.Unlock() @@ -91,6 +142,28 @@ func (s *Skipper) Hits() map[string]int { return out } +// LogHits emits a single summary line listing the protected paths that +// were actually encountered during walks. Quiet when nothing was matched +// (or on a nil receiver). The emit callback decouples this from any +// specific logger — pass log.Warn (interactive) or log.Debug (daemon) to +// pick the level. Single source of truth for both community scan and +// enterprise telemetry. +func (s *Skipper) LogHits(emit func(format string, args ...any)) { + if s == nil || emit == nil { + return + } + hits := s.Hits() + if len(hits) == 0 { + return + } + paths := make([]string, 0, len(hits)) + for p := range hits { + paths = append(paths, p) + } + sort.Strings(paths) + emit("macOS TCC: encountered and skipped %d protected path(s) during walks: %v", len(paths), paths) +} + // Candidates returns the exact-match protected paths the Skipper would // skip, sorted lexicographically. Useful for surfacing in logs. Returns nil // on a nil receiver or on non-darwin builds. diff --git a/internal/tcc/tcc_darwin_test.go b/internal/tcc/tcc_darwin_test.go index d18a8b8..eb3e09a 100644 --- a/internal/tcc/tcc_darwin_test.go +++ b/internal/tcc/tcc_darwin_test.go @@ -26,6 +26,10 @@ func TestSkipper_ShouldSkip(t *testing.T) { {"walk root opt-in", "/Users/alice/Documents", "/Users/alice/Documents", false}, {"walk root opt-in trailing slash", "/Users/alice/Documents", "/Users/alice/Documents/", false}, {"timemachine prefix matched", "/Volumes/.timemachine.donottouch/2026-05-25", "/Volumes/MyDrive", true}, + {"timemachine exact prefix", "/Volumes/.timemachine", "/Volumes/MyDrive", true}, + {"timemachine subdir slash", "/Volumes/.timemachine/snap", "/Volumes/MyDrive", true}, + {"timemachine_backup not matched", "/Volumes/.timemachine_backup", "/Volumes/MyDrive", false}, + {"timemachineuser not matched", "/Volumes/.timemachineuser/foo", "/Volumes/MyDrive", false}, {"other volume not matched", "/Volumes/MyDrive/code", "/Volumes/MyDrive", false}, } @@ -62,6 +66,38 @@ func TestSkipper_EmptyHome(t *testing.T) { } } +func TestEnabled(t *testing.T) { + t.Setenv("STEPSEC_VIA_LAUNCHD", "1") // force launchd-context for the nil-override branch + + trueVal := true + falseVal := false + + tests := []struct { + name string + override *bool + want bool + }{ + {"nil override under launchd → skipper on", nil, true}, + {"explicit include (true) → skipper off", &trueVal, false}, + {"explicit exclude (false) → skipper on", &falseVal, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := Enabled(tc.override); got != tc.want { + t.Errorf("Enabled(%v) = %v, want %v", tc.override, got, tc.want) + } + }) + } +} + +func TestEnabled_DirectInvocationDefault(t *testing.T) { + t.Setenv("STEPSEC_VIA_LAUNCHD", "") + // On a test runner PPID is the go test process, not launchd, so IsRunningUnderLaunchd is false. + if Enabled(nil) { + t.Error("Enabled(nil) should be false when not running under launchd") + } +} + func TestSkipper_CandidatesSorted(t *testing.T) { s := New("/Users/alice") cands := s.Candidates() diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index d03cd40..e859835 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -10,7 +10,6 @@ import ( "net/http" "os" "os/signal" - "sort" "sync" "sync/atomic" "syscall" @@ -387,8 +386,8 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) (err err // Full Disk Access. Nil when --include-tcc-protected is set; ShouldSkip // is nil-safe. var tccSkipper *tcc.Skipper - if !cfg.IncludeTCCProtected { - tccSkipper = tcc.New(resolveHome(exec)) + if tcc.Enabled(cfg.IncludeTCCProtected) { + tccSkipper = tcc.New(executor.ResolveHome(exec)) if cands := tccSkipper.Candidates(); len(cands) > 0 { log.Debug("tcc skip list (%d): %v", len(cands), cands) } @@ -809,26 +808,10 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) (err err fmt.Fprintln(os.Stderr) log.Progress("Telemetry collection completed successfully") - logTCCHits(log, tccSkipper) + tccSkipper.LogHits(log.Debug) return nil } -// logTCCHits surfaces which TCC-protected paths were actually encountered -// (and short-circuited) during the enterprise scan's directory walks. -// Quiet when nothing was matched. -func logTCCHits(log *progress.Logger, s *tcc.Skipper) { - hits := s.Hits() - if len(hits) == 0 { - return - } - paths := make([]string, 0, len(hits)) - for p := range hits { - paths = append(paths, p) - } - sort.Strings(paths) - log.Debug("tcc: encountered and skipped %d protected path(s) during walks: %v", len(paths), paths) -} - func brewFormulaeCount(scans []model.BrewScanResult) int { for _, s := range scans { if s.ScanType == "formulae" { @@ -1155,18 +1138,6 @@ func resolveSearchDirs(exec executor.Executor, dirs []string) []string { return resolved } -// resolveHome returns the home directory of the console user, falling back -// to the process's current user. Empty string when neither resolves. -func resolveHome(exec executor.Executor) string { - if u, err := exec.LoggedInUser(); err == nil { - return u.HomeDir - } - if u, err := exec.CurrentUser(); err == nil { - return u.HomeDir - } - return "" -} - func ideDisplayName(ideType string) string { switch ideType { case "vscode":