Skip to content
Merged
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
9 changes: 9 additions & 0 deletions pkg/collector/collector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions pkg/models/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
29 changes: 29 additions & 0 deletions pkg/wsl/wsl.go
Original file line number Diff line number Diff line change
@@ -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")
}
12 changes: 12 additions & 0 deletions pkg/wsl/wsl_other.go
Original file line number Diff line number Diff line change
@@ -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 }
34 changes: 34 additions & 0 deletions pkg/wsl/wsl_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
96 changes: 96 additions & 0 deletions pkg/wsl/wsl_windows.go
Original file line number Diff line number Diff line change
@@ -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() }()
Comment thread
SUSTAPLE117 marked this conversation as resolved.

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
}
Comment thread
SUSTAPLE117 marked this conversation as resolved.
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
}
Loading