diff --git a/pkg/collector/collector.go b/pkg/collector/collector.go index 33b6171..0d91d0b 100644 --- a/pkg/collector/collector.go +++ b/pkg/collector/collector.go @@ -15,6 +15,7 @@ import ( "github.com/boostsecurityio/bagel/pkg/models" "github.com/boostsecurityio/bagel/pkg/probe" "github.com/boostsecurityio/bagel/pkg/sysinfo" + "github.com/boostsecurityio/bagel/pkg/wsl" "github.com/mattn/go-isatty" "github.com/rs/zerolog" "github.com/rs/zerolog/log" @@ -133,6 +134,14 @@ func (c *Collector) buildFileIndex(ctx context.Context) (*fileindex.FileIndex, e baseDirs := c.config.FileIndex.BaseDirs + // On Windows, append the home dirs of installed WSL distros so Linux + // secrets behind WSL aren't a blindspot. No-op on other platforms. + if c.config.FileIndex.ScanWSL { + if wslDirs := wsl.Homes(ctx); len(wslDirs) > 0 { + baseDirs = append(append([]string{}, baseDirs...), wslDirs...) + } + } + // Try loading from cache (unless disabled) if !c.noCache { index, err := c.loadFromCache(ctx, baseDirs, patterns) diff --git a/pkg/config/config.go b/pkg/config/config.go index 2240199..99e75fe 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -119,6 +119,9 @@ func setDefaults(v *viper.Viper) { homeDir = "." // Fallback to current directory } v.SetDefault("file_index.base_dirs", []string{homeDir}) + // On Windows, also scan the home dirs of any installed WSL distro so Linux + // secrets aren't a blindspot. No-op on other platforms. + v.SetDefault("file_index.scan_wsl", true) // Default exclude paths — directories we don't expect to find user config // or secrets in but that typically contain millions of files. Entries with diff --git a/pkg/models/config.go b/pkg/models/config.go index 38e3c47..61758b6 100644 --- a/pkg/models/config.go +++ b/pkg/models/config.go @@ -74,6 +74,7 @@ type FileIndexConfig struct { MaxDepth int `yaml:"max_depth" mapstructure:"max_depth"` FollowSymlinks bool `yaml:"follow_symlinks" mapstructure:"follow_symlinks"` BaseDirs []string `yaml:"base_dirs" mapstructure:"base_dirs"` + ScanWSL bool `yaml:"scan_wsl" mapstructure:"scan_wsl"` ExcludePaths []string `yaml:"exclude_paths" mapstructure:"exclude_paths"` Patterns []PatternConfig `yaml:"patterns" mapstructure:"patterns"` Cache CacheConfig `yaml:"cache" mapstructure:"cache"` diff --git a/pkg/wsl/wsl.go b/pkg/wsl/wsl.go new file mode 100644 index 0000000..53e7d2a --- /dev/null +++ b/pkg/wsl/wsl.go @@ -0,0 +1,29 @@ +// Copyright (C) 2026 boostsecurity.io +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package wsl discovers WSL (Windows Subsystem for Linux) distro filesystems +// from a Windows host so their Linux home directories can be scanned for +// secrets. On non-Windows platforms its discovery is a no-op. +package wsl + +import "strings" + +// UNC prefixes used to reach a running distro's filesystem from Windows. +// \\wsl.localhost\ is the modern form (Windows 11+); \\wsl$\ is the legacy +// fallback. Accessing either auto-starts the distro's 9p file server. +const ( + uncLocalhost = `\\wsl.localhost\` + uncDollar = `\\wsl$\` +) + +// uncCandidates returns the UNC roots to probe for a distro, newest form first. +func uncCandidates(distro string) []string { + return []string{uncLocalhost + distro, uncDollar + distro} +} + +// skipDistro reports whether a registered distro is an internal/system distro +// that holds no user secrets — Docker Desktop registers backing distros +// (docker-desktop, docker-desktop-data) we don't want to walk. +func skipDistro(name string) bool { + return strings.HasPrefix(name, "docker-desktop") +} diff --git a/pkg/wsl/wsl_other.go b/pkg/wsl/wsl_other.go new file mode 100644 index 0000000..719a754 --- /dev/null +++ b/pkg/wsl/wsl_other.go @@ -0,0 +1,12 @@ +//go:build !windows + +// Copyright (C) 2026 boostsecurity.io +// SPDX-License-Identifier: GPL-3.0-or-later + +package wsl + +import "context" + +// Homes is a no-op off Windows: WSL filesystems are only reachable from a +// Windows host. ponytail: stub, real discovery lives in wsl_windows.go. +func Homes(_ context.Context) []string { return nil } diff --git a/pkg/wsl/wsl_test.go b/pkg/wsl/wsl_test.go new file mode 100644 index 0000000..47ea138 --- /dev/null +++ b/pkg/wsl/wsl_test.go @@ -0,0 +1,34 @@ +// Copyright (C) 2026 boostsecurity.io +// SPDX-License-Identifier: GPL-3.0-or-later + +package wsl + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSkipDistro(t *testing.T) { + t.Parallel() + tests := []struct { + name string + skip bool + }{ + {"Ubuntu", false}, + {"Ubuntu-22.04", false}, + {"kali-linux", false}, + {"docker-desktop", true}, + {"docker-desktop-data", true}, + } + for _, tt := range tests { + assert.Equal(t, tt.skip, skipDistro(tt.name), tt.name) + } +} + +func TestUNCCandidates(t *testing.T) { + t.Parallel() + got := uncCandidates("Ubuntu") + assert.Equal(t, []string{`\\wsl.localhost\Ubuntu`, `\\wsl$\Ubuntu`}, got, + "modern \\\\wsl.localhost form must be probed before the legacy \\\\wsl$ form") +} diff --git a/pkg/wsl/wsl_windows.go b/pkg/wsl/wsl_windows.go new file mode 100644 index 0000000..4f44642 --- /dev/null +++ b/pkg/wsl/wsl_windows.go @@ -0,0 +1,96 @@ +//go:build windows + +// Copyright (C) 2026 boostsecurity.io +// SPDX-License-Identifier: GPL-3.0-or-later + +package wsl + +import ( + "context" + "os" + "path/filepath" + + "github.com/rs/zerolog/log" + "golang.org/x/sys/windows/registry" +) + +// lxssKey is where WSL records every registered distro, one subkey (a GUID) +// per distro carrying a DistributionName value. +const lxssKey = `Software\Microsoft\Windows\CurrentVersion\Lxss` + +// Homes discovers the Linux home directory roots of every registered WSL +// distro reachable from this Windows host. It returns UNC base directories +// (e.g. \\wsl.localhost\Ubuntu\home and \\wsl.localhost\Ubuntu\root) suitable +// for the file index. Distros that are not currently reachable are skipped +// with a warning rather than failing the scan. +func Homes(ctx context.Context) []string { + distros := registeredDistros(ctx) + if len(distros) == 0 { + return nil + } + + var dirs []string + for _, distro := range distros { + if skipDistro(distro) { + continue + } + root, ok := reachableRoot(distro) + if !ok { + log.Ctx(ctx).Warn(). + Str("distro", distro). + Msg("WSL distro registered but filesystem not reachable; skipping (is it installed/startable?)") + continue + } + // /home holds per-user homes; /root is the root user's home. Both are + // walked relative to these base dirs by the existing patterns. + dirs = append(dirs, filepath.Join(root, "home"), filepath.Join(root, "root")) + log.Ctx(ctx).Info(). + Str("distro", distro). + Str("root", root). + Msg("Discovered WSL distro for scanning") + } + return dirs +} + +// registeredDistros reads the distro names recorded under the Lxss registry key. +func registeredDistros(ctx context.Context) []string { + key, err := registry.OpenKey(registry.CURRENT_USER, lxssKey, registry.READ) + if err != nil { + // No key means WSL has never been installed — expected, not an error. + log.Ctx(ctx).Debug().Err(err).Msg("No WSL registry key; skipping WSL discovery") + return nil + } + defer func() { _ = key.Close() }() + + guids, err := key.ReadSubKeyNames(-1) + if err != nil { + log.Ctx(ctx).Warn().Err(err).Msg("Failed to enumerate WSL distros") + return nil + } + + var names []string + for _, guid := range guids { + sub, err := registry.OpenKey(key, guid, registry.QUERY_VALUE) + if err != nil { + continue + } + name, _, err := sub.GetStringValue("DistributionName") + _ = sub.Close() + if err != nil || name == "" { + continue + } + names = append(names, name) + } + return names +} + +// reachableRoot returns the first UNC root for the distro that exists, +// auto-starting the distro's file server as a side effect of the stat. +func reachableRoot(distro string) (string, bool) { + for _, root := range uncCandidates(distro) { + if info, err := os.Stat(root); err == nil && info.IsDir() { + return root, true + } + } + return "", false +}