Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

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

## [Unreleased]

### Fixed

- Guard targeted scanner reads and symlink targets before accessing excluded protected directories.
- Preserve readable inventory and prior project references when protected locations cannot be scanned.

## [1.17.0] - 2026-09-24

### Added
Expand Down
7 changes: 7 additions & 0 deletions internal/detector/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,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"
"github.com/step-security/dev-machine-guard/internal/versionmeta"
)

Expand Down Expand Up @@ -213,3 +214,9 @@ func isCoworkVersion(version string) bool {
}
return major == 0 && minor >= 7
}

// WithSkipper protects direct and redirected inventory reads.
func (d *AgentDetector) WithSkipper(s *tcc.Skipper) *AgentDetector {
d.exec = tcc.GuardedFiles(d.exec, s, maxLockfileSize)
return d
}
8 changes: 3 additions & 5 deletions internal/detector/aicli.go
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,7 @@ func (d *AICLIDetector) WithLogger(log *progress.Logger) *AICLIDetector {
// everything", the same contract every walking detector honors.
func (d *AICLIDetector) WithSkipper(skipper *tcc.Skipper) *AICLIDetector {
d.skipper = skipper
d.exec = tcc.GuardedFiles(d.exec, skipper, maxLockfileSize, "pnpm", "Application Support/fnm")
return d
}

Expand Down Expand Up @@ -900,12 +901,9 @@ func aiCLIBinaryCandidateDirs(exec executor.Executor, homeDir string) []string {
}

// globDirs expands one glob pattern, newest-looking first (descending lexical,
// the rule nvmNodeBinDirs already uses) and empty on any error.
// the rule nvmNodeBinDirs already uses). Keep readable matches on partial errors.
func globDirs(exec executor.Executor, pattern string) []string {
matches, err := exec.Glob(pattern)
if err != nil || len(matches) == 0 {
return nil
}
matches, _ := exec.Glob(pattern)
sort.Sort(sort.Reverse(sort.StringSlice(matches)))
return matches
}
Expand Down
5 changes: 5 additions & 0 deletions internal/detector/aicli_agents_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3294,3 +3294,8 @@ func TestAICLIAgents2_EmptyFixture(t *testing.T) {
})
}
}

// Keep the recording mock attached when production selects a guarded reader.
func (r *recExec) GuardedFiles(_ []string, _ func(string) string, _ int64) executor.Executor {
return r
}
19 changes: 12 additions & 7 deletions internal/detector/configaudit/bunfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ func (d *BunDetector) WithLogger(log *progress.Logger) *BunDetector {
// WithSkipper attaches a TCC skipper so discovery skips macOS-protected dirs.
func (d *BunDetector) WithSkipper(s *tcc.Skipper) *BunDetector {
d.skipper = s
d.exec = tcc.GuardedFiles(d.exec, s, maxConfigFileSize)
if tcc.ProtectedReadsDisabled(d.exec, s) {
d.ownerLookup = guardedOwner(d.exec)
d.inGitRepo = guardedInGitRepo(d.exec)
}
return d
}

Expand Down Expand Up @@ -144,7 +149,7 @@ func (d *BunDetector) Detect(ctx context.Context, searchDirs []string, loggedInU
// searchDirs walk). The .npmrc walk overlaps with the npm + pnpm audits; if
// scan time becomes a concern, share results across detectors.
func (d *BunDetector) discoverAuthSideChannel(ctx context.Context, searchDirs []string, loggedInUser *user.User) []model.NPMRCFile {
side := NewNPMRCDetector(d.exec)
side := NewNPMRCDetector(d.exec).WithSkipper(d.skipper)
side.skipper = d.skipper
side.ownerLookup = d.ownerLookup
side.gitTracked = d.gitTracked
Expand All @@ -167,7 +172,7 @@ func (d *BunDetector) findProjectBunfigs(dir string) []string {
return nil
}
var results []string
_ = filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err error) error {
_ = d.exec.WalkDir(dir, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return nil
}
Expand Down Expand Up @@ -197,7 +202,7 @@ func (d *BunDetector) findProjectBunfigs(dir string) []string {
func (d *BunDetector) collectFile(ctx context.Context, path, scope string) model.BunConfigFile {
f := model.BunConfigFile{Path: path, Scope: scope}

linfo, err := os.Lstat(path)
linfo, err := auditLstat(d.exec, d.skipper, path)
if err != nil {
if os.IsNotExist(err) {
f.Exists = false
Expand All @@ -209,13 +214,13 @@ func (d *BunDetector) collectFile(ctx context.Context, path, scope string) model
}
f.Exists = true

if linfo.Mode()&os.ModeSymlink != 0 {
if target, err := os.Readlink(path); err == nil {
if linfo.Mode()&os.ModeSymlink != 0 || tcc.ProtectedReadsDisabled(d.exec, d.skipper) {
if target, err := d.exec.Readlink(path); err == nil {
f.SymlinkTo = target
}
}

info, err := os.Stat(path)
info, err := auditStat(d.exec, d.skipper, path)
if err != nil {
f.Readable = false
f.ParseError = "stat: " + err.Error()
Expand All @@ -241,7 +246,7 @@ func (d *BunDetector) collectFile(ctx context.Context, path, scope string) model

// #nosec G304 -- path comes from the detector's own candidate enumeration
// (user-scope well-known locations + project walk).
data, err := os.ReadFile(path)
data, err := auditReadFile(d.exec, d.skipper, path)
if err != nil {
f.Readable = false
f.ParseError = "read: " + err.Error()
Expand Down
56 changes: 56 additions & 0 deletions internal/detector/configaudit/files.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package configaudit

import (
"os"
"path/filepath"

"github.com/step-security/dev-machine-guard/internal/executor"
"github.com/step-security/dev-machine-guard/internal/tcc"
)

const maxConfigFileSize = 32 << 20

func auditLstat(exec executor.Executor, s *tcc.Skipper, path string) (os.FileInfo, error) {
if tcc.ProtectedReadsDisabled(exec, s) {
return exec.Stat(path)
}
return os.Lstat(path)
}

func auditStat(exec executor.Executor, s *tcc.Skipper, path string) (os.FileInfo, error) {
if tcc.ProtectedReadsDisabled(exec, s) {
return exec.Stat(path)
}
return os.Stat(path)
}

func auditReadFile(exec executor.Executor, s *tcc.Skipper, path string) ([]byte, error) {
if tcc.ProtectedReadsDisabled(exec, s) {
return exec.ReadFile(path)
}
// #nosec G304 -- Existing unguarded mode reads scanner-selected config files.
return os.ReadFile(path)
}

func guardedOwner(exec executor.Executor) func(string) ownerInfo {
return func(path string) ownerInfo {
info, err := exec.Stat(path)
if err != nil {
return ownerInfo{}
}
return ownerFromInfo(info)
}
}

func guardedInGitRepo(exec executor.Executor) func(string) bool {
return func(path string) bool {
for dir := filepath.Dir(path); ; dir = filepath.Dir(dir) {
if _, err := exec.Stat(filepath.Join(dir, ".git")); err == nil {
return true
}
if filepath.Dir(dir) == dir {
return false
}
}
}
}
17 changes: 11 additions & 6 deletions internal/detector/configaudit/npmrc.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ func NewNPMRCDetector(exec executor.Executor) *NPMRCDetector {
// directories. A nil skipper is a no-op. Returns the detector for chaining.
func (d *NPMRCDetector) WithSkipper(s *tcc.Skipper) *NPMRCDetector {
d.skipper = s
d.exec = tcc.GuardedFiles(d.exec, s, maxConfigFileSize)
if tcc.ProtectedReadsDisabled(d.exec, s) {
d.ownerLookup = guardedOwner(d.exec)
d.inGitRepo = guardedInGitRepo(d.exec)
}
return d
}

Expand Down Expand Up @@ -170,7 +175,7 @@ func (d *NPMRCDetector) findProjectNPMRCs(dir string) []string {
return nil
}
var results []string
_ = filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err error) error {
_ = d.exec.WalkDir(dir, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return nil
}
Expand Down Expand Up @@ -228,7 +233,7 @@ func (d *NPMRCDetector) collectFile(ctx context.Context, path, scope string) mod
}

// Lstat first so a symlink doesn't get followed silently.
linfo, err := os.Lstat(path)
linfo, err := auditLstat(d.exec, d.skipper, path)
if err != nil {
// Distinguish "not found" from "not readable" so the user can act.
if os.IsNotExist(err) {
Expand All @@ -241,14 +246,14 @@ func (d *NPMRCDetector) collectFile(ctx context.Context, path, scope string) mod
}
f.Exists = true

if linfo.Mode()&os.ModeSymlink != 0 {
if target, err := os.Readlink(path); err == nil {
if linfo.Mode()&os.ModeSymlink != 0 || tcc.ProtectedReadsDisabled(d.exec, d.skipper) {
if target, err := d.exec.Readlink(path); err == nil {
f.SymlinkTo = target
}
}

// Stat (follows symlinks) for size/mtime/mode.
info, err := os.Stat(path)
info, err := auditStat(d.exec, d.skipper, path)
if err != nil {
f.Readable = false
f.ParseError = "stat: " + err.Error()
Expand All @@ -275,7 +280,7 @@ func (d *NPMRCDetector) collectFile(ctx context.Context, path, scope string) mod
// #nosec G304 -- path comes from the detector's own candidate
// enumeration of well-known npmrc locations (built-in/global/user/
// project); not from external input.
data, err := os.ReadFile(path)
data, err := auditReadFile(d.exec, d.skipper, path)
if err != nil {
f.Readable = false
f.ParseError = "read: " + err.Error()
Expand Down
4 changes: 4 additions & 0 deletions internal/detector/configaudit/npmrc_stat_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ func statOwner(path string) ownerInfo {
if err != nil {
return ownerInfo{}
}
return ownerFromInfo(info)
}

func ownerFromInfo(info os.FileInfo) ownerInfo {
st, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return ownerInfo{}
Expand Down
4 changes: 4 additions & 0 deletions internal/detector/configaudit/npmrc_stat_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@

package configaudit

import "os"

// statOwner is a no-op on Windows: getting a meaningful owner string from a
// SID is non-trivial and not actionable for the audit's first cut. The
// detector handles ownerInfo.OK == false by leaving owner fields empty.
func statOwner(_ string) ownerInfo {
return ownerInfo{}
}

func ownerFromInfo(_ os.FileInfo) ownerInfo { return ownerInfo{} }
22 changes: 17 additions & 5 deletions internal/detector/configaudit/pipconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,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"
)

// devNullPaths are values of $PIP_CONFIG_FILE that disable all config-file
Expand Down Expand Up @@ -87,7 +88,8 @@ var pipConfigDebugFileRE = regexp.MustCompile(`^\s+(.+),\s+exists:\s+(True|False

// PipConfigDetector performs the read-only pip config audit.
type PipConfigDetector struct {
exec executor.Executor
skipper *tcc.Skipper
exec executor.Executor

// Hooks for tests; default to platform-specific impls. Owner lookup
// uses syscall.Stat_t on Unix and is a no-op on Windows.
Expand Down Expand Up @@ -574,7 +576,7 @@ func pipConfigFilename(goos string) string {
// --- per-file metadata ------------------------------------------------------

func (d *PipConfigDetector) populateFileMetadata(ctx context.Context, f *model.PipConfigFile) {
info, err := os.Lstat(f.Path)
info, err := auditLstat(d.exec, d.skipper, f.Path)
if err != nil {
if os.IsNotExist(err) {
f.Exists = false
Expand All @@ -590,7 +592,7 @@ func (d *PipConfigDetector) populateFileMetadata(ctx context.Context, f *model.P
// the symlink itself exists; a broken symlink target shouldn't crash
// the audit).
if info.Mode()&os.ModeSymlink != 0 {
stat, statErr := os.Stat(f.Path)
stat, statErr := auditStat(d.exec, d.skipper, f.Path)
if statErr != nil {
f.Readable = false
f.ParseError = "stat (followed symlink): " + statErr.Error()
Expand All @@ -615,7 +617,7 @@ func (d *PipConfigDetector) populateFileMetadata(ctx context.Context, f *model.P
}
}

data, err := os.ReadFile(f.Path)
data, err := auditReadFile(d.exec, d.skipper, f.Path)
if err != nil {
f.Readable = false
f.ParseError = "read: " + err.Error()
Expand Down Expand Up @@ -747,7 +749,7 @@ func (d *PipConfigDetector) probeNetrc(loggedInUser *user.User) *model.PipNetrcS
path = filepath.Join(homeDir, "_netrc")
}
out := &model.PipNetrcStatus{Path: path}
info, err := os.Stat(path)
info, err := auditStat(d.exec, d.skipper, path)
if err != nil {
if !os.IsNotExist(err) {
out.Exists = true // probe error; surface that we tried
Expand All @@ -769,3 +771,13 @@ var _ = func() fs.WalkDirFunc { return nil }
// formatModeOctal is unused today (mode is rendered via fmt.Sprintf in
// populateFileMetadata) but kept for tests; suppress unused warning.
var _ = strconv.FormatUint

func (d *PipConfigDetector) WithSkipper(s *tcc.Skipper) *PipConfigDetector {
d.skipper = s
d.exec = tcc.GuardedFiles(d.exec, s, maxConfigFileSize, "Application Support/pip/pip.conf")
if tcc.ProtectedReadsDisabled(d.exec, s) {
d.ownerLookup = guardedOwner(d.exec)
d.inGitRepo = guardedInGitRepo(d.exec)
}
return d
}
17 changes: 11 additions & 6 deletions internal/detector/configaudit/pnpm.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ func NewPnpmDetector(exec executor.Executor) *PnpmDetector {
// directories. nil is a no-op. Returns the detector for chaining.
func (d *PnpmDetector) WithSkipper(s *tcc.Skipper) *PnpmDetector {
d.skipper = s
d.exec = tcc.GuardedFiles(d.exec, s, maxConfigFileSize)
if tcc.ProtectedReadsDisabled(d.exec, s) {
d.ownerLookup = guardedOwner(d.exec)
d.inGitRepo = guardedInGitRepo(d.exec)
}
return d
}

Expand Down Expand Up @@ -131,7 +136,7 @@ func (d *PnpmDetector) findProjectNPMRCs(dir string) []string {
return nil
}
var results []string
_ = filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err error) error {
_ = d.exec.WalkDir(dir, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return nil
}
Expand Down Expand Up @@ -161,7 +166,7 @@ func (d *PnpmDetector) findProjectNPMRCs(dir string) []string {
func (d *PnpmDetector) collectFile(ctx context.Context, path, scope string) model.NPMRCFile {
f := model.NPMRCFile{Path: path, Scope: scope}

linfo, err := os.Lstat(path)
linfo, err := auditLstat(d.exec, d.skipper, path)
if err != nil {
if os.IsNotExist(err) {
f.Exists = false
Expand All @@ -173,13 +178,13 @@ func (d *PnpmDetector) collectFile(ctx context.Context, path, scope string) mode
}
f.Exists = true

if linfo.Mode()&os.ModeSymlink != 0 {
if target, err := os.Readlink(path); err == nil {
if linfo.Mode()&os.ModeSymlink != 0 || tcc.ProtectedReadsDisabled(d.exec, d.skipper) {
if target, err := d.exec.Readlink(path); err == nil {
f.SymlinkTo = target
}
}

info, err := os.Stat(path)
info, err := auditStat(d.exec, d.skipper, path)
if err != nil {
f.Readable = false
f.ParseError = "stat: " + err.Error()
Expand All @@ -205,7 +210,7 @@ func (d *PnpmDetector) collectFile(ctx context.Context, path, scope string) mode

// #nosec G304 -- path comes from the detector's own candidate enumeration
// of well-known npmrc locations; not external input.
data, err := os.ReadFile(path)
data, err := auditReadFile(d.exec, d.skipper, path)
if err != nil {
f.Readable = false
f.ParseError = "read: " + err.Error()
Expand Down
Loading
Loading