diff --git a/Makefile b/Makefile index 9acb3352..0943f013 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ LDFLAGS := -s -w \ # silently by both `wix -d` and the -out filename. Fail there instead. check-version = test -n "$(VERSION)" || { echo "error: no Version found in internal/buildinfo/version.go" >&2; exit 1; } -.PHONY: build build-windows build-windows-task build-windows-arm64 build-windows-task-arm64 build-linux deploy-windows test lint clean smoke build-msi-amd64 build-msi-arm64 +.PHONY: build build-windows build-windows-task build-windows-arm64 build-windows-task-arm64 build-linux build-linux-arm64 deploy-windows test lint clean smoke build-msi-amd64 build-msi-arm64 build: go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY) ./cmd/stepsecurity-dev-machine-guard @@ -35,14 +35,24 @@ build-windows-arm64: build-windows-task-arm64: GOOS=windows GOARCH=arm64 go build -trimpath -ldflags "$(LDFLAGS) -H windowsgui" -o $(BINARY)-task-arm64.exe ./cmd/stepsecurity-dev-machine-guard-task +# CGO_ENABLED=0 is load-bearing on these two, not tidiness: the MSI ships the +# Linux binary for WSL scanning, and a cgo-linked build dies on a musl distro +# (Alpine) with "No such file or directory" — it wants glibc's loader. Releases +# already pin this in .goreleaser.yml; these targets must match so a +# locally-built MSI behaves like a released one. build-linux: - GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY)-linux ./cmd/stepsecurity-dev-machine-guard + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY)-linux ./cmd/stepsecurity-dev-machine-guard + +# Windows on ARM runs an aarch64 WSL2 kernel, so the arm64 MSI must carry an +# arm64 Linux binary — an amd64 one fails the same way musl does. +build-linux-arm64: + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY)-linux-arm64 ./cmd/stepsecurity-dev-machine-guard # MSI builds. Require WiX 4 on PATH: `dotnet tool install --global wix --version 4.0.5`. # Output: dist/stepsecurity-dev-machine-guard--{x64,arm64}.msi # Reads Version from internal/buildinfo so MajorUpgrade semantics line up # with whatever the binary reports as `--version`. -build-msi-amd64: build-windows build-windows-task +build-msi-amd64: build-windows build-windows-task build-linux @$(check-version) mkdir -p dist @wix extension list --global 2>/dev/null | grep -q "WixToolset.Util.wixext" || \ @@ -54,9 +64,10 @@ build-msi-amd64: build-windows build-windows-task -d Version=$(VERSION) \ -d BinaryPath=$(CURDIR)/$(BINARY).exe \ -d LauncherPath=$(CURDIR)/$(BINARY)-task.exe \ + -d LinuxBinaryPath=$(CURDIR)/$(BINARY)-linux \ -out dist/stepsecurity-dev-machine-guard-$(VERSION)-x64.msi -build-msi-arm64: build-windows-arm64 build-windows-task-arm64 +build-msi-arm64: build-windows-arm64 build-windows-task-arm64 build-linux-arm64 @$(check-version) mkdir -p dist @wix extension list --global 2>/dev/null | grep -q "WixToolset.Util.wixext" || \ @@ -68,6 +79,7 @@ build-msi-arm64: build-windows-arm64 build-windows-task-arm64 -d Version=$(VERSION) \ -d BinaryPath=$(CURDIR)/$(BINARY)-arm64.exe \ -d LauncherPath=$(CURDIR)/$(BINARY)-task-arm64.exe \ + -d LinuxBinaryPath=$(CURDIR)/$(BINARY)-linux-arm64 \ -out dist/stepsecurity-dev-machine-guard-$(VERSION)-arm64.msi deploy-windows: diff --git a/SCAN_COVERAGE.md b/SCAN_COVERAGE.md index 93f02008..2b9bac04 100644 --- a/SCAN_COVERAGE.md +++ b/SCAN_COVERAGE.md @@ -232,6 +232,22 @@ Detected if `snap` is installed. Metadata: name, version, revision, tracking cha Detected if `flatpak` is installed. Metadata: app ID, name, version, arch, branch, origin, active commit, runtime. +## WSL Detection (Windows) + +Host-side detection of Windows Subsystem for Linux, reported by the **Windows agent** under `device.wsl`. Answers "is WSL present, and is a distribution actively running right now?" so a fleet dashboard can flag machines with WSL environments that the Linux agent has not yet scanned. It does **not** mount or scan distro filesystems — run the Linux binary inside a distro for that. + +| Signal | Source | Notes | +|--------|--------|-------| +| Registered distros | `HKU\\...\CurrentVersion\Lxss` (all loaded user hives) | Enumerating HKU (not just HKCU) lets a SYSTEM-context scan still see a signed-in user's distros. Name, WSL version, default flag, owning SID, base path. | +| Distro ID | the Lxss subkey name (a GUID) | The only stable per-distro identifier: survives restarts and renames, changes on unregister/re-import. **Not** derivable from the base path — imported distros have no GUID in theirs. | +| Default user | per-distro `DefaultUid` | The uid `wsl -d ` runs as. `0` means the distro has no non-root user; absent means unreadable, and the two are kept distinct. | +| WSL version per distro | registry `Flags & 0x8` | The per-distro `Version` DWORD is unreliable (reads 2 on WSL1). Flags `0x7` → WSL1, `0xF` → WSL2 — both measured (WSL1 EC2 box + WSL2 metal VM). | +| Installed | `WslService` (Store/MSI) or `LxssManager` (legacy) service key | `System32\wsl.exe` is **not** a signal — it ships with stock Windows even when WSL is disabled. | +| Package version | `Uninstall\...` `DisplayVersion` for "Windows Subsystem for Linux" | Floors to `unknown`. | +| Actively used | `wsl.exe --list --running --quiet` | The only subprocess; UTF-16LE output decoded defensively. Registry carries no runtime state. Skipped entirely unless a WSL service is *running* (native SCM query, no process) — that probe **starts** `WslService` when stopped, so on an idle machine it would wake a service to learn nothing. | + +Presence is tri-state (`yes` / `no` / `unknown`): a probe that cannot read the registry reports `unknown` rather than a false `no`. Gated behind the `wsl-detection` feature flag until the backend consumes the payload. Limitation: users whose hive is not loaded (never signed in this boot) are not counted. + --- ## Adding New Detections diff --git a/cmd/stepsecurity-dev-machine-guard/main.go b/cmd/stepsecurity-dev-machine-guard/main.go index 485108e7..b3a431c5 100644 --- a/cmd/stepsecurity-dev-machine-guard/main.go +++ b/cmd/stepsecurity-dev-machine-guard/main.go @@ -36,6 +36,7 @@ import ( "github.com/step-security/dev-machine-guard/internal/tcc" "github.com/step-security/dev-machine-guard/internal/telemetry" "github.com/step-security/dev-machine-guard/internal/winproc" + "github.com/step-security/dev-machine-guard/internal/wslguest" ) // auditSkipper builds a TCC skipper if scanning into TCC-protected dirs is @@ -74,6 +75,11 @@ func main() { } // Load persisted config (~/.stepsecurity/config.json) before parsing CLI + // --config must be honoured before Load(), which runs ahead of flag + // parsing and keeps the first values it reads. + if p := cli.ConfigPathFromArgs(os.Args[1:]); p != "" { + config.SetFileOverride(p) + } config.Load() cfg, err := cli.Parse(os.Args[1:]) @@ -689,12 +695,19 @@ func findLegacyLeftovers(legacy string) []string { // gate failure returns false (fail-open), so this can never suppress a scan // on error. func gateSkipsRun(exec executor.Executor, log *progress.Logger, cfg *cli.Config) bool { - res := rungate.Evaluate(context.Background(), exec, log, cfg.ForceScan) + // A run inside a WSL distro gates under the identity its host gave it, not + // under the distro's own (absent) serial. + res := rungate.Evaluate(context.Background(), exec, log, cfg.ForceScan, + wslguest.DeviceID(cfg.WSLHostSerial, cfg.WSLDistroID)) if !res.Skip { log.Progress("Run gate: proceeding with this run (%s)", res.Reason) // Carry the decision into telemetry.Run so it echoes a line inside the // captured execution log (the gate runs before log capture starts). cfg.GateProceedReason = res.Reason + // Carry the tenant's WSL switch into the run. Only set on the proceed + // path: a skipped run scans nothing at all. + cfg.WSLScanEnabled = res.WSL.Enabled + cfg.WSLScanReason = res.WSL.Reason return false } if res.Detail != "" { diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 37daf9da..9669154d 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -53,6 +53,28 @@ type Config struct { // reach the downloadable log without this. GateProceedReason string + // WSLScanEnabled and WSLScanReason are populated at runtime (not CLI flags) + // from the run-config check-in's wsl_directive. They gate scanning INSIDE + // WSL distros — a tenant-wide switch with no per-device granularity. Both + // stay zero on every path that never reached a backend answer, so distro + // scanning fails closed; host-side WSL detection does not consult them. + WSLScanEnabled bool + WSLScanReason string + + // WSLHostSerial and WSLDistroID identify this run as happening INSIDE a WSL + // distribution, and are passed by the Windows host that triggered it + // (--wsl-host-serial, --wsl-distro-id). A distro cannot discover either for + // itself. When both are set the agent derives a stable device id from them, + // because a distro's own identity is unusable: it inherits the host's + // hostname, and a minimal or WSL1 distro has no machine-id. + WSLHostSerial string + WSLDistroID string + + // ConfigFile is --config: the exact config.json to read. Applied by a + // pre-scan of argv before config.Load(), so this field is informational + // once parsing is done. + ConfigFile string + // HooksAgent is the --agent value on `hooks install` / `hooks uninstall`; // "" means "every detected agent". HooksAgent string @@ -298,6 +320,30 @@ func Parse(args []string) (*Config, error) { cfg.Verbose = true case arg == "--override-gate": cfg.OverrideGate = true + case strings.HasPrefix(arg, "--config="): + cfg.ConfigFile = strings.TrimPrefix(arg, "--config=") + case arg == "--config": + i++ + if i >= len(args) { + return nil, fmt.Errorf("--config requires a file path argument") + } + cfg.ConfigFile = args[i] + case strings.HasPrefix(arg, "--wsl-host-serial="): + cfg.WSLHostSerial = strings.TrimPrefix(arg, "--wsl-host-serial=") + case arg == "--wsl-host-serial": + i++ + if i >= len(args) { + return nil, fmt.Errorf("--wsl-host-serial requires a value") + } + cfg.WSLHostSerial = args[i] + case strings.HasPrefix(arg, "--wsl-distro-id="): + cfg.WSLDistroID = strings.TrimPrefix(arg, "--wsl-distro-id=") + case arg == "--wsl-distro-id": + i++ + if i >= len(args) { + return nil, fmt.Errorf("--wsl-distro-id requires a value") + } + cfg.WSLDistroID = args[i] case arg == "--force-scan": cfg.ForceScan = true case strings.HasPrefix(arg, "--rules-file="): @@ -571,3 +617,22 @@ Configuration: name, name, name, buildinfo.AgentURL) } + +// ConfigPathFromArgs pre-scans argv for --config so main can pin the config +// path before config.Load() runs. Parse() happens after Load(), and Load() +// keeps whatever it read first, so the flag cannot be honoured any later. +// Deliberately forgiving: an unparseable argv is Parse()'s problem to report, +// not this helper's. +func ConfigPathFromArgs(args []string) string { + for i, arg := range args { + switch { + case strings.HasPrefix(arg, "--config="): + return strings.TrimPrefix(arg, "--config=") + case arg == "--config": + if i+1 < len(args) { + return args[i+1] + } + } + } + return "" +} diff --git a/internal/config/config.go b/internal/config/config.go index 9bdc0d01..6a278f92 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -105,6 +105,21 @@ func userConfigDir() string { // readConfigDir returns the directory we should READ config from. // Prefers machine-wide if a config exists there (so an MSI-deployed install // is visible even when the scanner runs as an unprivileged user). +// fileOverride, when set, is the exact config.json the process must read, +// bypassing both the machine-wide and per-user lookups. Set from --config. +// +// It exists for one case that has no other answer: an agent running inside a +// WSL distribution. config.json is pinned to the per-user directory and +// neither STEPSECURITY_HOME nor --install-dir redirects it, so without this a +// distro scan needs the tenant key copied into every distro's home. With it +// the key stays on the Windows host and is read over /mnt/c. +var fileOverride string + +// SetFileOverride pins the config file path. Called before Load(), from a +// pre-scan of argv — Load() runs before flag parsing, and its +// already-set-wins semantics make a second Load() a no-op. +func SetFileOverride(path string) { fileOverride = strings.TrimSpace(path) } + func readConfigDir() string { if mcd := machineConfigDir(); mcd != "" { if _, err := os.Stat(filepath.Join(mcd, "config.json")); err == nil { @@ -129,6 +144,9 @@ func writeConfigDir() string { // ConfigFilePath returns the path to the config file (read-preferred). func ConfigFilePath() string { + if fileOverride != "" { + return fileOverride + } return filepath.Join(readConfigDir(), "config.json") } diff --git a/internal/device/wsl.go b/internal/device/wsl.go new file mode 100644 index 00000000..f1595bc7 --- /dev/null +++ b/internal/device/wsl.go @@ -0,0 +1,178 @@ +package device + +import ( + "bytes" + "context" + "sort" + "strings" + "time" + "unicode/utf16" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" +) + +// Registry locations and service names for WSL, shared by the native +// (wsl_windows.go) and exec-fallback (wsl_other.go) inventory probes. +const ( + // lxssUserSubpath is appended to a per-user hive root (HKCU, or an + // HKU\ root) to reach the authoritative per-user distro index. + lxssUserSubpath = `SOFTWARE\Microsoft\Windows\CurrentVersion\Lxss` + + // wslFlagVM is the undocumented Flags bit set when a distro runs under + // WSL2 (microsoft/WSL#4251). Both measured: Flags 0x7 = WSL1, 0xF = WSL2. + // The per-distro Version DWORD is NOT usable — it reads 2 even on WSL1. + wslFlagVM uint64 = 0x8 +) + +// wslServiceNames are the two service keys under +// HKLM\SYSTEM\CurrentControlSet\Services that indicate WSL is installed: +// WslService for the Microsoft Store / MSI build (current), LxssManager for +// the legacy inbox component. System32\wsl.exe is deliberately NOT a signal — +// it ships with stock Windows even when WSL is fully disabled. +var wslServiceNames = []string{"WslService", "LxssManager"} + +// wslRunningTimeout bounds the one subprocess this detector makes. `wsl.exe +// --list --running --quiet` queries wslservice for the active set; it does not +// start a distribution, but it can hang if the service is wedged. +const wslRunningTimeout = 8 * time.Second + +// GatherWSL detects Windows Subsystem for Linux on the host. It returns nil on +// non-Windows platforms — WSL detection is host-side only; the Linux binary +// runs inside a distro and identifies itself separately. It never errors: +// every failure degrades to WSLPresenceUnknown so a partial probe can't read +// as a confident "no WSL". +func GatherWSL(ctx context.Context, exec executor.Executor) *model.WSLInfo { + if exec.GOOS() != model.PlatformWindows { + return nil + } + return gatherWSLWindows(ctx, exec) +} + +// gatherWSLWindows orchestrates the Windows probe. The registry inventory and +// service/version primitives are platform-split (native registry API vs +// reg.exe); the running-distro probe is a plain wsl.exe call shared by both. +func gatherWSLWindows(ctx context.Context, exec executor.Executor) *model.WSLInfo { + distros, invOK := wslRegistryInventory(exec) + installed, svcOK := wslServiceInstalled(exec) + + info := &model.WSLInfo{ + Installed: installed, + Version: "unknown", + Distros: distros, + } + + switch { + case installed || len(distros) > 0: + info.Presence = model.WSLPresenceYes + case !invOK && !svcOK: + // Neither probe could read anything conclusive (e.g. registry access + // denied). Don't claim "no WSL". + info.Presence = model.WSLPresenceUnknown + default: + info.Presence = model.WSLPresenceNo + } + + if info.Presence != model.WSLPresenceYes { + return info + } + + if v := wslPackageVersion(ctx, exec); v != "" { + info.Version = v + } + + // "Actively used" — only worth a subprocess when WSL is installed, at least + // one distro is registered, and a WSL service is actually running. + if installed && len(distros) > 0 && wslMayHaveRunningDistro(exec) { + running := wslRunningDistros(ctx, exec) + for i := range info.Distros { + if running[info.Distros[i].Name] { + info.Distros[i].Running = true + info.Active = true + } + } + } + + return info +} + +// wslMayHaveRunningDistro reports whether the running-distro probe is worth its +// subprocess. It answers from service state alone, via a native query that +// spawns nothing: the WSL services manage every distro, so if neither is +// running then nothing can be. +// +// This is not just about saving a process. `wsl.exe --list --running --quiet` +// *starts* WslService when it is stopped (measured: Stopped before, Running +// after, empty output) — so on a machine where WSL is installed but unused +// since boot, probing wakes a Windows service to learn that nothing is running. +// +// An unknown service state answers true: we would rather pay for the probe than +// under-report "active". +func wslMayHaveRunningDistro(exec executor.Executor) bool { + running, known := wslServiceRunning(exec) + return !known || running +} + +// wslRunningDistros returns the set of distribution names WSL reports as +// running. Empty on any failure (an absent set is indistinguishable from "none +// running", and both mean "not active"). Handles wsl.exe's default UTF-16LE +// output, which dmg cannot switch to UTF-8 (that needs WSL_UTF8 in the child +// env, and the executor has no env seam). +func wslRunningDistros(ctx context.Context, exec executor.Executor) map[string]bool { + stdout, _, _, err := exec.RunWithTimeout(ctx, wslRunningTimeout, "wsl.exe", "--list", "--running", "--quiet") + if err != nil { + return nil + } + out := make(map[string]bool) + for _, line := range strings.Split(decodeWinCLI(stdout), "\n") { + name := strings.TrimSpace(line) + if name != "" { + out[name] = true + } + } + return out +} + +// decodeWinCLI normalizes output from Windows console tools that emit UTF-16LE. +// wsl.exe writes UTF-16LE with no BOM unless WSL_UTF8=1 is set in its +// environment (WSL >=0.64.0), which dmg cannot do. A stream with interleaved +// NUL bytes is decoded as UTF-16LE; anything else (UTF-8 from a newer WSL or an +// already-clean string) is returned unchanged. A stray CR is stripped. +func decodeWinCLI(s string) string { + b := []byte(s) + if !bytes.ContainsRune(b, 0) { + return strings.ReplaceAll(s, "\r", "") + } + if len(b) >= 2 && b[0] == 0xFF && b[1] == 0xFE { // strip UTF-16LE BOM + b = b[2:] + } + if len(b)%2 != 0 { + b = b[:len(b)-1] + } + u := make([]uint16, len(b)/2) + for i := range u { + u[i] = uint16(b[2*i]) | uint16(b[2*i+1])<<8 + } + return strings.ReplaceAll(string(utf16.Decode(u)), "\r", "") +} + +// wslVersionFromFlags maps the registry Flags value to a WSL version. See +// wslFlagVM for the reliability caveat. +func wslVersionFromFlags(flags uint64) int { + if flags&wslFlagVM != 0 { + return 2 + } + return 1 +} + +// sortDistros gives the inventory a stable order (default first, then by name) +// so output and telemetry don't churn between runs over a registry-enumeration +// order that isn't guaranteed. +func sortDistros(d []model.WSLDistro) { + sort.SliceStable(d, func(i, j int) bool { + if d[i].Default != d[j].Default { + return d[i].Default + } + return d[i].Name < d[j].Name + }) +} diff --git a/internal/device/wsl_other.go b/internal/device/wsl_other.go new file mode 100644 index 00000000..3bb2f6d8 --- /dev/null +++ b/internal/device/wsl_other.go @@ -0,0 +1,174 @@ +//go:build !windows + +package device + +import ( + "context" + "regexp" + "strconv" + "strings" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" +) + +// This file is the exec-based (reg.exe) counterpart to the native registry +// probes in wsl_windows.go. On a real non-Windows OS GatherWSL returns before +// reaching here; it exists so mock-based tests can simulate Windows via +// SetGOOS("windows"), matching the device_other.go / registry_other.go pattern +// (AGENTS.md §2.5). It reads only HKCU (a single simulated user) rather than +// enumerating every HKU hive — sufficient for the simulated inputs. + +var regValueLine = regexp.MustCompile(`^(\S.*?)\s{2,}(REG_\w+)\s{2,}(.*)$`) + +// regSection is one key block from `reg query ... /s` output. +type regSection struct { + path string + values map[string]string +} + +// parseRegRecursive splits `reg query /s` output into per-key sections. +// A non-indented line is a key path; indented `NAME TYPE DATA` lines are +// its values (DATA may contain single spaces, e.g. "Windows Subsystem for Linux"). +func parseRegRecursive(out string) []regSection { + var sections []regSection + var cur *regSection + for _, raw := range strings.Split(out, "\n") { + line := strings.TrimRight(raw, "\r") + if line == "" { + continue + } + if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") { + sections = append(sections, regSection{path: strings.TrimSpace(line), values: map[string]string{}}) + cur = §ions[len(sections)-1] + continue + } + if cur == nil { + continue + } + if m := regValueLine.FindStringSubmatch(strings.TrimSpace(line)); m != nil { + cur.values[m[1]] = strings.TrimSpace(m[3]) + } + } + return sections +} + +func wslRegistryInventory(exec executor.Executor) ([]model.WSLDistro, bool) { + stdout, _, _, err := exec.Run(context.Background(), "reg", "query", `HKCU\`+lxssUserSubpath, "/s") + if err != nil { + return nil, false // reg.exe couldn't run — inconclusive + } + sections := parseRegRecursive(decodeWinCLI(stdout)) + + var defaultGUID string + for _, s := range sections { + if strings.EqualFold(s.path, `HKEY_CURRENT_USER\`+lxssUserSubpath) { + defaultGUID = s.values["DefaultDistribution"] + } + } + + var distros []model.WSLDistro + prefix := strings.ToLower(`HKEY_CURRENT_USER\` + lxssUserSubpath + `\`) + for _, s := range sections { + lower := strings.ToLower(s.path) + if !strings.HasPrefix(lower, prefix) { + continue + } + guid := s.path[len(prefix):] + if strings.Contains(guid, `\`) { // only direct children (the distro GUIDs) + continue + } + name := s.values["DistributionName"] + if name == "" { + continue + } + distros = append(distros, model.WSLDistro{ + Name: name, + DistroID: guid, + WSLVersion: wslVersionFromFlags(parseRegDWORD(s.values["Flags"])), + Default: guid == defaultGUID, + BasePath: s.values["BasePath"], + DefaultUID: parseRegDWORDOpt(s.values["DefaultUid"]), + }) + } + sortDistros(distros) + return distros, true +} + +func wslServiceInstalled(exec executor.Executor) (bool, bool) { + ok := false + for _, svc := range wslServiceNames { + _, _, code, err := exec.Run(context.Background(), "reg", "query", `HKLM\SYSTEM\CurrentControlSet\Services\`+svc) + if err != nil { + continue // couldn't run reg for this one + } + ok = true + if code == 0 { + return true, true + } + } + return false, ok +} + +func wslPackageVersion(_ context.Context, exec executor.Executor) string { + for _, root := range []string{ + `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`, + `HKLM\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall`, + } { + stdout, _, _, err := exec.Run(context.Background(), "reg", "query", root, "/s") + if err != nil { + continue + } + for _, s := range parseRegRecursive(decodeWinCLI(stdout)) { + if strings.EqualFold(strings.TrimSpace(s.values["DisplayName"]), "Windows Subsystem for Linux") { + if v := strings.TrimSpace(s.values["DisplayVersion"]); v != "" { + return v + } + } + } + } + return "" +} + +// wslServiceRunning has no exec-based counterpart worth having: `sc query` would +// be exactly the subprocess the gate exists to avoid. Off Windows it reports +// "unknown", which makes the caller behave as it did before the gate and still +// run the probe. +func wslServiceRunning(_ executor.Executor) (bool, bool) { return false, false } + +// parseRegDWORDOpt is parseRegDWORD for values whose absence must stay +// distinguishable from zero — DefaultUid 0 means root, not "unreadable". +func parseRegDWORDOpt(s string) *uint32 { + if strings.TrimSpace(s) == "" { + return nil + } + raw := strings.TrimSpace(s) + base, digits := 10, raw + if strings.HasPrefix(raw, "0x") || strings.HasPrefix(raw, "0X") { + base, digits = 16, raw[2:] + } + v, err := strconv.ParseUint(digits, base, 32) + if err != nil { + return nil + } + u := uint32(v) + return &u +} + +// parseRegDWORD reads a reg.exe REG_DWORD literal ("0x7") to a uint64. 0 on error. +func parseRegDWORD(s string) uint64 { + s = strings.TrimSpace(s) + if s == "" { + return 0 + } + if strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X") { + if v, err := strconv.ParseUint(s[2:], 16, 64); err == nil { + return v + } + return 0 + } + if v, err := strconv.ParseUint(s, 10, 64); err == nil { + return v + } + return 0 +} diff --git a/internal/device/wsl_other_test.go b/internal/device/wsl_other_test.go new file mode 100644 index 00000000..86845160 --- /dev/null +++ b/internal/device/wsl_other_test.go @@ -0,0 +1,34 @@ +//go:build !windows + +package device + +import "testing" + +// parseRegDWORDOpt lives in the reg.exe-based (non-Windows) probe, so its test +// carries the same build tag — on Windows the symbol does not exist. +func TestParseRegDWORDOpt(t *testing.T) { + cases := []struct { + in string + want *uint32 + }{ + {"", nil}, + {" ", nil}, + {"not-a-number", nil}, + {"0x0", ptrU32(0)}, + {"0x3e8", ptrU32(1000)}, + {"0X3E8", ptrU32(1000)}, + {"1000", ptrU32(1000)}, + {"0xffffffffff", nil}, // wider than 32 bits + } + for _, c := range cases { + got := parseRegDWORDOpt(c.in) + switch { + case c.want == nil && got != nil: + t.Errorf("parseRegDWORDOpt(%q) = %d, want nil", c.in, *got) + case c.want != nil && got == nil: + t.Errorf("parseRegDWORDOpt(%q) = nil, want %d", c.in, *c.want) + case c.want != nil && *got != *c.want: + t.Errorf("parseRegDWORDOpt(%q) = %d, want %d", c.in, *got, *c.want) + } + } +} diff --git a/internal/device/wsl_test.go b/internal/device/wsl_test.go new file mode 100644 index 00000000..563071f2 --- /dev/null +++ b/internal/device/wsl_test.go @@ -0,0 +1,262 @@ +package device + +import ( + "context" + "strings" + "testing" + "unicode/utf16" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" +) + +// utf16le encodes s as UTF-16LE bytes wrapped in a Go string, mimicking what +// wsl.exe writes to stdout when WSL_UTF8 is unset. +func utf16le(s string) string { + u := utf16.Encode([]rune(s)) + b := make([]byte, 0, len(u)*2) + for _, c := range u { + b = append(b, byte(c), byte(c>>8)) + } + return string(b) +} + +const ( + lxssKey = `HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Lxss` + svcWSL = `HKLM\SYSTEM\CurrentControlSet\Services\WslService` + svcLxss = `HKLM\SYSTEM\CurrentControlSet\Services\LxssManager` + unKey = `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall` + unWOWKey = `HKLM\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall` +) + +func winMock() *executor.Mock { + m := executor.NewMock() + m.SetGOOS(model.PlatformWindows) + return m +} + +func TestGatherWSL_NilOffWindows(t *testing.T) { + m := executor.NewMock() // default GOOS is this host (non-Windows in CI) + m.SetGOOS(model.PlatformLinux) + if got := GatherWSL(context.Background(), m); got != nil { + t.Fatalf("expected nil off Windows, got %+v", got) + } +} + +func TestGatherWSL_PresentRunningWSL1(t *testing.T) { + m := winMock() + m.SetCommand(`HKEY_CURRENT_USER\SOFTWARE\Microsoft\Windows\CurrentVersion\Lxss + DefaultDistribution REG_SZ {guid-a} + DefaultVersion REG_DWORD 0x1 + +HKEY_CURRENT_USER\SOFTWARE\Microsoft\Windows\CurrentVersion\Lxss\{guid-a} + DistributionName REG_SZ Ubuntu-24.04 + Version REG_DWORD 0x2 + Flags REG_DWORD 0x7 + BasePath REG_SZ C:\Users\Administrator\AppData\Local\wsl\{guid-a} +`, "", 0, "reg", "query", lxssKey, "/s") + m.SetCommand("", "", 0, "reg", "query", svcWSL) // WslService present + m.SetCommand(`HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{497CB23D} + DisplayName REG_SZ Windows Subsystem for Linux + DisplayVersion REG_SZ 2.7.11.0 +`, "", 0, "reg", "query", unKey, "/s") + // distro is running (UTF-16LE, as the real wsl.exe emits) + m.SetCommand(utf16le("Ubuntu-24.04\r\n"), "", 0, "wsl.exe", "--list", "--running", "--quiet") + + info := GatherWSL(context.Background(), m) + if info == nil { + t.Fatal("expected WSLInfo, got nil") + } + if info.Presence != model.WSLPresenceYes { + t.Errorf("presence = %q, want yes", info.Presence) + } + if !info.Installed { + t.Error("installed = false, want true") + } + if info.Version != "2.7.11.0" { + t.Errorf("version = %q, want 2.7.11.0", info.Version) + } + if !info.Active { + t.Error("active = false, want true (distro running)") + } + if len(info.Distros) != 1 { + t.Fatalf("distros = %d, want 1", len(info.Distros)) + } + d := info.Distros[0] + if d.Name != "Ubuntu-24.04" || d.WSLVersion != 1 || !d.Default || !d.Running { + t.Errorf("distro = %+v, want {Ubuntu-24.04 v1 default running}", d) + } + if d.BasePath == "" { + t.Error("base path not captured") + } +} + +func TestGatherWSL_PresentWSL2NotRunning(t *testing.T) { + m := winMock() + m.SetCommand(`HKEY_CURRENT_USER\SOFTWARE\Microsoft\Windows\CurrentVersion\Lxss + DefaultDistribution REG_SZ {guid-b} + +HKEY_CURRENT_USER\SOFTWARE\Microsoft\Windows\CurrentVersion\Lxss\{guid-b} + DistributionName REG_SZ Debian + Flags REG_DWORD 0xf +`, "", 0, "reg", "query", lxssKey, "/s") + m.SetCommand("", "", 0, "reg", "query", svcWSL) + m.SetCommand("", "", 1, "reg", "query", unKey, "/s") + m.SetCommand("", "", 1, "reg", "query", unWOWKey, "/s") + m.SetCommand("", "", 0, "wsl.exe", "--list", "--running", "--quiet") // nothing running + + info := GatherWSL(context.Background(), m) + if info.Presence != model.WSLPresenceYes { + t.Fatalf("presence = %q, want yes", info.Presence) + } + if info.Active { + t.Error("active = true, want false") + } + if info.Version != "unknown" { + t.Errorf("version = %q, want unknown floor", info.Version) + } + if len(info.Distros) != 1 || info.Distros[0].WSLVersion != 2 { + t.Fatalf("distros = %+v, want one v2", info.Distros) + } + if info.Distros[0].Running { + t.Error("distro marked running, want stopped") + } +} + +func TestGatherWSL_AbsentIsNoNotUnknown(t *testing.T) { + m := winMock() + m.SetCommand("", "", 1, "reg", "query", lxssKey, "/s") // key absent, probe worked + m.SetCommand("", "", 1, "reg", "query", svcWSL) // service absent + m.SetCommand("", "", 1, "reg", "query", svcLxss) // legacy absent + + info := GatherWSL(context.Background(), m) + if info.Presence != model.WSLPresenceNo { + t.Errorf("presence = %q, want no", info.Presence) + } + if info.Installed || info.Active { + t.Error("installed/active should be false when absent") + } +} + +func TestGatherWSL_ProbeFailureIsUnknown(t *testing.T) { + m := winMock() // nothing stubbed → reg/wsl invocations error out + info := GatherWSL(context.Background(), m) + if info.Presence != model.WSLPresenceUnknown { + t.Errorf("presence = %q, want unknown when the registry can't be read", info.Presence) + } +} + +func TestDecodeWinCLI(t *testing.T) { + if got := decodeWinCLI(utf16le("Ubuntu-24.04\r\n")); got != "Ubuntu-24.04\n" { + t.Errorf("utf16le decode = %q, want %q", got, "Ubuntu-24.04\n") + } + if got := decodeWinCLI("Ubuntu\r\n"); got != "Ubuntu\n" { // already UTF-8 + t.Errorf("utf8 passthrough = %q", got) + } + // UTF-16LE with BOM + if got := decodeWinCLI("\xff\xfe" + utf16le("x")); got != "x" { + t.Errorf("bom-stripped decode = %q, want x", got) + } +} + +func TestWSLVersionFromFlags(t *testing.T) { + if wslVersionFromFlags(0x7) != 1 { + t.Error("0x7 should map to WSL1") + } + if wslVersionFromFlags(0xF) != 2 { + t.Error("0xF should map to WSL2") + } +} + +// TestGatherWSL_CapturesDistroIDAndDefaultUID pins the two fields the WSL-guest +// identity work depends on: the registry key GUID, and the uid `wsl -e` will run +// as. Both come from the same read, so a regression in one usually breaks both. +func TestGatherWSL_CapturesDistroIDAndDefaultUID(t *testing.T) { + m := winMock() + m.SetCommand(`HKEY_CURRENT_USER\SOFTWARE\Microsoft\Windows\CurrentVersion\Lxss + DefaultDistribution REG_SZ {guid-a} + +HKEY_CURRENT_USER\SOFTWARE\Microsoft\Windows\CurrentVersion\Lxss\{guid-a} + DistributionName REG_SZ Debian + Flags REG_DWORD 0xf + DefaultUid REG_DWORD 0x3e8 + BasePath REG_SZ C:\Users\dev\AppData\Local\wsl\{guid-a} + +HKEY_CURRENT_USER\SOFTWARE\Microsoft\Windows\CurrentVersion\Lxss\{guid-b} + DistributionName REG_SZ Alpine-WSL1 + Flags REG_DWORD 0x7 + BasePath REG_SZ C:\wsl1\Alpine-WSL1 +`, "", 0, "reg", "query", lxssKey, "/s") + m.SetCommand("", "", 0, "reg", "query", svcWSL) + m.SetCommand(utf16le(""), "", 0, "wsl.exe", "--list", "--running", "--quiet") + + info := GatherWSL(context.Background(), m) + if info == nil { + t.Fatal("expected WSLInfo, got nil") + } + byName := map[string]model.WSLDistro{} + for _, d := range info.Distros { + byName[d.Name] = d + } + if len(byName) != 2 { + t.Fatalf("distros = %d, want 2", len(byName)) + } + + deb := byName["Debian"] + if deb.DistroID != "{guid-a}" { + t.Errorf("Debian distro_id = %q, want {guid-a}", deb.DistroID) + } + if deb.DefaultUID == nil || *deb.DefaultUID != 1000 { + t.Errorf("Debian default_uid = %v, want 1000", deb.DefaultUID) + } + + // An imported distro carries no GUID in its BasePath — the whole reason the + // id must come from the key name and never from the path. + alp := byName["Alpine-WSL1"] + if alp.DistroID != "{guid-b}" { + t.Errorf("Alpine distro_id = %q, want {guid-b} (imported distro: path has no GUID)", alp.DistroID) + } + if strings.Contains(alp.BasePath, "{") { + t.Fatalf("test fixture no longer exercises the no-GUID-in-path case: %q", alp.BasePath) + } + if alp.DefaultUID != nil { + t.Errorf("Alpine default_uid = %v, want nil (value absent)", *alp.DefaultUID) + } +} + +// TestGatherWSL_RootOnlyDistroIsDistinguishable guards the difference between +// "runs as root" and "we could not read the uid". Only the first justifies +// skipping a scan. +func TestGatherWSL_RootOnlyDistroIsDistinguishable(t *testing.T) { + m := winMock() + m.SetCommand(`HKEY_CURRENT_USER\SOFTWARE\Microsoft\Windows\CurrentVersion\Lxss\{guid-a} + DistributionName REG_SZ Imported + Flags REG_DWORD 0xf + DefaultUid REG_DWORD 0x0 +`, "", 0, "reg", "query", lxssKey, "/s") + m.SetCommand("", "", 0, "reg", "query", svcWSL) + m.SetCommand(utf16le(""), "", 0, "wsl.exe", "--list", "--running", "--quiet") + + info := GatherWSL(context.Background(), m) + if info == nil || len(info.Distros) != 1 { + t.Fatalf("expected one distro, got %+v", info) + } + uid := info.Distros[0].DefaultUID + if uid == nil { + t.Fatal("default_uid = nil, want 0 — an explicit 0 must not read as unknown") + } + if *uid != 0 { + t.Errorf("default_uid = %d, want 0", *uid) + } +} + +// TestWSLMayHaveRunningDistro_UnknownServiceStateStillProbes covers the gate's +// fail-open direction. Off Windows the service state is unknowable, and we would +// rather spend the probe than under-report "active". +func TestWSLMayHaveRunningDistro_UnknownServiceStateStillProbes(t *testing.T) { + if !wslMayHaveRunningDistro(winMock()) { + t.Error("gate closed on unknown service state; want open (probe anyway)") + } +} + +func ptrU32(v uint32) *uint32 { return &v } diff --git a/internal/device/wsl_windows.go b/internal/device/wsl_windows.go new file mode 100644 index 00000000..576f5335 --- /dev/null +++ b/internal/device/wsl_windows.go @@ -0,0 +1,206 @@ +//go:build windows + +package device + +import ( + "context" + "errors" + "strings" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" +) + +// wslWellKnownSIDs are the pseudo-accounts whose hives carry a seeded-but-empty +// Lxss key. They must be skipped: a SYSTEM-context scan finds Lxss present +// under S-1-5-18 with zero distros, which would otherwise read as "WSL absent". +// Real distros live under interactive users' SIDs (S-1-5-21-...). +var wslWellKnownSIDs = map[string]bool{ + ".DEFAULT": true, + "S-1-5-18": true, // LocalSystem + "S-1-5-19": true, // LocalService + "S-1-5-20": true, // NetworkService +} + +// wslRegistryInventory enumerates every loaded user hive under HKU and reads +// its Lxss distro index. Enumerating HKU (rather than just HKCU) is what lets a +// SYSTEM-context run still see a logged-in user's distros. Users whose hive is +// not loaded (never signed in this boot) are invisible — a documented limit, we +// do not load NTUSER.DAT. The bool is false only when HKU itself can't be read. +func wslRegistryInventory(_ executor.Executor) ([]model.WSLDistro, bool) { + users, err := registry.OpenKey(registry.USERS, "", registry.ENUMERATE_SUB_KEYS) + if err != nil { + return nil, false + } + defer users.Close() + + sids, err := users.ReadSubKeyNames(-1) + if err != nil { + return nil, false + } + + var distros []model.WSLDistro + for _, sid := range sids { + if wslWellKnownSIDs[sid] || strings.HasSuffix(sid, "_Classes") { + continue + } + distros = append(distros, readLxssForSID(sid)...) + } + sortDistros(distros) + return distros, true +} + +// readLxssForSID reads the distros registered under one user hive. Missing keys +// (the common case — most SIDs have no WSL) yield nothing, not an error. +func readLxssForSID(sid string) []model.WSLDistro { + root, err := registry.OpenKey(registry.USERS, sid+`\`+lxssUserSubpath, registry.READ) + if err != nil { + return nil + } + defer root.Close() + + defaultGUID, _, _ := root.GetStringValue("DefaultDistribution") + + guids, err := root.ReadSubKeyNames(-1) + if err != nil { + return nil + } + + var out []model.WSLDistro + for _, guid := range guids { + dk, err := registry.OpenKey(registry.USERS, sid+`\`+lxssUserSubpath+`\`+guid, registry.QUERY_VALUE) + if err != nil { + continue + } + name, _, _ := dk.GetStringValue("DistributionName") + flags, _, _ := dk.GetIntegerValue("Flags") + basePath, _, _ := dk.GetStringValue("BasePath") + // DefaultUid is absent on some registrations, and 0 (root) is a + // meaningful answer — so read the error rather than defaulting to 0. + var defaultUID *uint32 + if uid, _, err := dk.GetIntegerValue("DefaultUid"); err == nil { + u := uint32(uid) + defaultUID = &u + } + dk.Close() + + if name == "" { + continue + } + out = append(out, model.WSLDistro{ + Name: name, + DistroID: guid, + WSLVersion: wslVersionFromFlags(flags), + Default: guid == defaultGUID, + OwnerSID: sid, + BasePath: basePath, + DefaultUID: defaultUID, + }) + } + return out +} + +// wslServiceInstalled reports whether a WSL runtime service is registered. The +// LOCAL_MACHINE services hive is always readable, so an absent key is a +// confident "not installed" — the bool is true whenever we could look. +func wslServiceInstalled(_ executor.Executor) (bool, bool) { + for _, svc := range wslServiceNames { + k, err := registry.OpenKey(registry.LOCAL_MACHINE, `SYSTEM\CurrentControlSet\Services\`+svc, registry.QUERY_VALUE) + if err == nil { + k.Close() + return true, true + } + } + return false, true +} + +// wslServiceRunning reports whether either WSL runtime service is currently in +// the RUNNING state, without spawning anything. +// +// It asks for SC_MANAGER_CONNECT on the manager and SERVICE_QUERY_STATUS on the +// service — exactly the rights the default WSL service ACL grants Interactive +// Users (verified from its SDDL), so this works for a non-admin agent. It +// deliberately does not use mgr.Connect(), which requests full access and would +// need admin. +// +// known is false when we could not tell: the SCM would not open, or a service +// that may exist could not be queried. A service that is genuinely absent +// (ERROR_SERVICE_DOES_NOT_EXIST) is a conclusive "not running", not an unknown. +func wslServiceRunning(_ executor.Executor) (bool, bool) { + scm, err := windows.OpenSCManager(nil, nil, windows.SC_MANAGER_CONNECT) + if err != nil { + return false, false + } + defer windows.CloseServiceHandle(scm) + + known := true + for _, svc := range wslServiceNames { + name, err := windows.UTF16PtrFromString(svc) + if err != nil { + known = false + continue + } + h, err := windows.OpenService(scm, name, windows.SERVICE_QUERY_STATUS) + if err != nil { + // Absent is an answer; anything else means we could not look. + if !errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) { + known = false + } + continue + } + var status windows.SERVICE_STATUS + err = windows.QueryServiceStatus(h, &status) + windows.CloseServiceHandle(h) + if err != nil { + known = false + continue + } + if status.CurrentState == windows.SERVICE_RUNNING { + return true, true + } + } + return false, known +} + +// wslPackageVersion reads the installed WSL version from the Uninstall registry +// entry the MSI/Store build writes ("Windows Subsystem for Linux"). Returns "" +// (caller floors to "unknown") when undeterminable. +func wslPackageVersion(_ context.Context, _ executor.Executor) string { + roots := []struct { + key registry.Key + path string + }{ + {registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`}, + {registry.LOCAL_MACHINE, `SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall`}, + } + for _, r := range roots { + parent, err := registry.OpenKey(r.key, r.path, registry.ENUMERATE_SUB_KEYS) + if err != nil { + continue + } + subs, err := parent.ReadSubKeyNames(-1) + parent.Close() + if err != nil { + continue + } + for _, sub := range subs { + sk, err := registry.OpenKey(r.key, r.path+`\`+sub, registry.QUERY_VALUE) + if err != nil { + continue + } + name, _, _ := sk.GetStringValue("DisplayName") + if strings.EqualFold(strings.TrimSpace(name), "Windows Subsystem for Linux") { + ver, _, _ := sk.GetStringValue("DisplayVersion") + sk.Close() + if v := strings.TrimSpace(ver); v != "" { + return v + } + continue + } + sk.Close() + } + } + return "" +} diff --git a/internal/executor/detach_other.go b/internal/executor/detach_other.go new file mode 100644 index 00000000..cbcd7edb --- /dev/null +++ b/internal/executor/detach_other.go @@ -0,0 +1,12 @@ +//go:build !windows + +package executor + +import "syscall" + +// detachAttrs puts the child in its own session so it is not killed with the +// parent's process group. Only Windows needs the job-object dance; this exists +// so StartDetached has one shape on every platform. +func detachAttrs() []*syscall.SysProcAttr { + return []*syscall.SysProcAttr{{Setsid: true}} +} diff --git a/internal/executor/detach_windows.go b/internal/executor/detach_windows.go new file mode 100644 index 00000000..3ce4523d --- /dev/null +++ b/internal/executor/detach_windows.go @@ -0,0 +1,38 @@ +//go:build windows + +package executor + +import "syscall" + +// createBreakawayFromJob lets a child escape the parent's job object. Not in +// syscall/x-sys, so it is spelled out here (winbase.h). +const createBreakawayFromJob = 0x01000000 + +// detachedProcess detaches the child from the parent's console. +const detachedProcess = 0x00000008 + +// detachAttrs returns creation flags for a spawn that must outlive this +// process, most-isolated first. +// +// Honest status: a plain spawn was NOT observed to fail. Triggering a distro +// scan works identically with and without these flags when the agent runs in a +// live logon session, so this is insurance rather than a fix for a measured +// bug. +// +// It is kept because the failure it guards against is silent and the property +// is load-bearing: os/exec puts the child in the parent's job object, a job +// with KILL_ON_JOB_CLOSE would take the child with it, and WSL tears a distro +// down shortly after its last Windows-side client exits — so a killed relay +// means a scan that reported "launched" and then quietly never happened. The +// case that would expose it (a guest scan outlasting the host run) has not been +// reproduced on the test box, where guest scans finish in ~2s. +// +// Breakaway fails outright when the job forbids it, so the caller retries with +// the second (flags-only) form; a detached child that shares the job is still +// better than none. +func detachAttrs() []*syscall.SysProcAttr { + return []*syscall.SysProcAttr{ + {CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP | detachedProcess | createBreakawayFromJob}, + {CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP | detachedProcess}, + } +} diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 10283641..13586933 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -28,6 +28,13 @@ type Executor interface { RunInDir(ctx context.Context, dir string, timeout time.Duration, name string, args ...string) (stdout, stderr string, exitCode int, err error) // RunAsUser runs a shell command as a specific user (for root -> user delegation). RunAsUser(ctx context.Context, username, command string) (string, error) + // StartDetached launches a command and returns as soon as it has started, + // without waiting for it to finish and without tying its lifetime to this + // process. Used to trigger a scan inside a WSL distribution: the scan + // outlives the run that started it, and its own agent reports the result. + // The error covers failure to *start* only — anything the child does + // afterwards is invisible here by design. + StartDetached(name string, args ...string) error // LookPath searches for an executable in PATH. LookPath(name string) (string, error) // FileExists checks if a file exists and is not a directory. @@ -82,6 +89,33 @@ type Real struct { func NewReal() *Real { return &Real{} } +// StartDetached starts the process and lets it go. It deliberately does not +// Wait: the child must outlive this process. +// +// detachAttrs supplies platform creation flags that keep the child from being +// reaped with this process, most isolated first, falling back because job +// breakaway fails outright when the job forbids it. See detach_windows.go: a +// plain spawn was not observed to fail, so those flags are insurance against a +// silent failure mode rather than a fix for a measured one. +// +// Release drops our handle afterwards so nothing here holds the child. +func (r *Real) StartDetached(name string, args ...string) error { + attrs := detachAttrs() + var err error + for _, attr := range attrs { + cmd := exec.Command(name, args...) + cmd.SysProcAttr = attr + if err = cmd.Start(); err != nil { + continue // try the next, less isolated, form + } + if cmd.Process == nil { + return nil + } + return cmd.Process.Release() + } + return err +} + func (r *Real) Run(ctx context.Context, name string, args ...string) (string, string, int, error) { cmd := exec.CommandContext(ctx, name, args...) winproc.HideWindow(cmd) diff --git a/internal/executor/mock.go b/internal/executor/mock.go index 42c4993f..2e7893d0 100644 --- a/internal/executor/mock.go +++ b/internal/executor/mock.go @@ -6,6 +6,7 @@ import ( "os" "os/user" "path/filepath" + "strings" "sync" "time" ) @@ -26,6 +27,11 @@ type Mock struct { // Path lookup stubs paths map[string]string + // DetachedCalls records StartDetached invocations, newest last. + // StartDetachedErr makes every spawn fail. + DetachedCalls []string + StartDetachedErr error + // Environment env map[string]string hostname string @@ -230,6 +236,14 @@ func (m *Mock) SetLoggedInUserError(err error) { // --- Executor interface --- +// DetachedCalls records every StartDetached invocation as "name arg arg ...", +// so a test can assert what the WSL-scan phase launched without running it. +// StartDetachedErr, when set, fails every spawn. +func (m *Mock) StartDetached(name string, args ...string) error { + m.DetachedCalls = append(m.DetachedCalls, strings.Join(append([]string{name}, args...), " ")) + return m.StartDetachedErr +} + func (m *Mock) Run(_ context.Context, name string, args ...string) (string, string, int, error) { m.mu.RLock() defer m.mu.RUnlock() diff --git a/internal/executor/user_aware.go b/internal/executor/user_aware.go index 73e9b509..5f53de1e 100644 --- a/internal/executor/user_aware.go +++ b/internal/executor/user_aware.go @@ -81,6 +81,13 @@ func posixShellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } +// StartDetached delegates: a detached spawn carries no user-impersonation +// concern of its own, and the WSL relay it launches already runs as the +// distro's default user. +func (e *UserAwareExecutor) StartDetached(name string, args ...string) error { + return e.inner.StartDetached(name, args...) +} + func (e *UserAwareExecutor) Run(ctx context.Context, name string, args ...string) (string, string, int, error) { cmd := posixShellQuote(name) for _, a := range args { diff --git a/internal/featuregate/featuregate.go b/internal/featuregate/featuregate.go index 26e58cb9..16e22372 100644 --- a/internal/featuregate/featuregate.go +++ b/internal/featuregate/featuregate.go @@ -25,6 +25,7 @@ const ( FeatureYarnConfigAudit Feature = "yarn-config-audit" FeatureDevicePolicy Feature = "device-policy" FeatureAgentSkillsScan Feature = "agent-skills-scan" + FeatureWSLDetection Feature = "wsl-detection" ) // enabled lists features safe to ship today. Uncomment a line once its @@ -38,6 +39,10 @@ var enabled = map[Feature]bool{ FeatureYarnConfigAudit: true, FeatureDevicePolicy: true, FeatureAgentSkillsScan: true, + // Safe to ship ahead of the backend: a backend that does not yet consume + // device.wsl ignores the unknown field and still archives the full + // telemetry blob. + FeatureWSLDetection: true, } var override bool diff --git a/internal/model/model.go b/internal/model/model.go index b4418ace..24c0a505 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -59,6 +59,73 @@ type Device struct { Platform string `json:"platform"` UserIdentity string `json:"user_identity"` Resources MachineResources `json:"resources"` + // WSL reports Windows Subsystem for Linux on a Windows host. Nil on every + // other platform, and nil on Windows unless the WSL-detection feature gate + // is on — so `omitempty` drops it entirely rather than emitting a zero + // value that a reader could mistake for "scanned, no WSL". + WSL *WSLInfo `json:"wsl,omitempty"` +} + +// WSL presence is tri-state. A probe that cannot read the registry (e.g. a +// SYSTEM-context run whose target user's hive is not loaded) reports +// WSLPresenceUnknown, never a false WSLPresenceNo. +const ( + WSLPresenceYes = "yes" + WSLPresenceNo = "no" + WSLPresenceUnknown = "unknown" +) + +// WSLInfo reports Windows Subsystem for Linux on a Windows host: whether it is +// present, whether a distribution is currently running, the installed WSL +// package version, and the registered distributions. Populated only by the +// Windows agent (WSL detection is a host-side concern — the Linux binary runs +// *inside* a distro and identifies itself separately). +type WSLInfo struct { + Presence string `json:"presence"` // WSLPresence* — yes | no | unknown + Installed bool `json:"installed"` // WSL runtime service (WslService/LxssManager) present + Active bool `json:"active"` // at least one distribution running right now + Version string `json:"version"` // installed WSL package version; "unknown" if undeterminable + Distros []WSLDistro `json:"distros,omitempty"` +} + +// WSLGuest identifies this agent as running INSIDE a WSL distribution and names +// the Windows host it belongs to. Set only from the flags the host passes at +// trigger time (--wsl-host-serial / --wsl-distro-id): a distro cannot discover +// either value for itself without depending on Windows interop. +// +// It exists because a distro has no usable identity of its own — it inherits the +// host's hostname, and a minimal or WSL1 distro has no /etc/machine-id at all. +// The backend pairs on DistroID against the host's own reported distro list. +type WSLGuest struct { + HostDeviceID string `json:"host_device_id"` + DistroID string `json:"distro_id"` +} + +// WSLDistro is one registered WSL distribution. +type WSLDistro struct { + Name string `json:"name"` + // DistroID is the distribution's registry key name, a GUID, and the only + // stable per-distro identifier. It survives restarts and renames (renaming + // rewrites DistributionName only — this WSL build has no `--rename` command + // at all) and changes on unregister/re-import, which genuinely is a new + // environment. Never derive it from BasePath: only store-installed distros + // carry the GUID in their path, an imported one reads e.g. C:\wsl1\Alpine. + DistroID string `json:"distro_id,omitempty"` + // WSLVersion is 1 or 2 (0 if undeterminable), derived from the registry + // Flags 0x8 bit — the per-distro Version DWORD is unreliable (it reads 2 on + // WSL1 distros). Measured on both: WSL1 → Flags 0x7 → v1, WSL2 → Flags 0xF + // → v2 (microsoft/WSL#4251; verified on the metal WSL2 test VM). + WSLVersion int `json:"wsl_version"` + Running bool `json:"running"` + Default bool `json:"default"` + OwnerSID string `json:"owner_sid,omitempty"` + BasePath string `json:"base_path,omitempty"` + // DefaultUID is the uid `wsl -d ` runs as, read from the registry + // DefaultUid value. Distinguishing 0 from absent matters: 0 means the + // distro has no non-root user, so its home holds nothing worth scanning, + // whereas nil means we could not read the value. Never scan as root + // explicitly — root's home is empty and would read as a clean machine. + DefaultUID *uint32 `json:"default_uid,omitempty"` } // MachineResources captures the static hardware capacity of the machine — diff --git a/internal/output/html.go b/internal/output/html.go index 16d478dc..bf3743fc 100644 --- a/internal/output/html.go +++ b/internal/output/html.go @@ -87,6 +87,7 @@ func HTML(outputFile string, result *model.ScanResult) error { "add": func(a, b int) int { return a + b }, "formatBytes": formatBytes, "formatCPU": formatCPU, + "formatWSL": formatWSL, } tmpl, err := template.New("report").Funcs(funcMap).Parse(htmlTemplate) @@ -218,6 +219,7 @@ const htmlTemplate = ` {{with formatCPU .Device.Resources}}
CPU{{.}}
{{end}} {{if .Device.Resources.MemoryBytes}}
Memory{{formatBytes .Device.Resources.MemoryBytes}}
{{end}} {{if .Device.Resources.DiskTotalBytes}}
Disk{{formatBytes .Device.Resources.DiskTotalBytes}}
{{end}} + {{with .Device.WSL}}
WSL{{formatWSL .}}
{{end}}
diff --git a/internal/output/pretty.go b/internal/output/pretty.go index c4b0fbe4..4993049d 100644 --- a/internal/output/pretty.go +++ b/internal/output/pretty.go @@ -50,6 +50,9 @@ func Pretty(w io.Writer, result *model.ScanResult, colorMode string) error { if result.Device.Resources.DiskTotalBytes > 0 { fmt.Fprintf(w, " %-16s %s\n", "Disk", formatBytes(result.Device.Resources.DiskTotalBytes)) } + if wsl := result.Device.WSL; wsl != nil { + fmt.Fprintf(w, " %-16s %s\n", "WSL", formatWSL(wsl)) + } fmt.Fprintln(w) // SUMMARY @@ -540,6 +543,38 @@ func truncate(s string, max int) string { return s } +// formatWSL renders the WSL summary line for the DEVICE block, e.g. +// "present — 2 distros, 1 running (WSL 2.7.11.0)". Presence is tri-state: +// "unknown" (probe couldn't read the registry) is shown as-is rather than +// collapsed to "not present". +func formatWSL(w *model.WSLInfo) string { + switch w.Presence { + case model.WSLPresenceNo: + return "not present" + case model.WSLPresenceUnknown: + return "unknown" + } + running := 0 + for _, d := range w.Distros { + if d.Running { + running++ + } + } + s := fmt.Sprintf("present — %d distro", len(w.Distros)) + if len(w.Distros) != 1 { + s += "s" + } + if w.Active { + s += fmt.Sprintf(", %d running", running) + } else { + s += ", none running" + } + if w.Version != "" && w.Version != "unknown" { + s += " (WSL " + w.Version + ")" + } + return s +} + // formatCPU renders the CPU summary as // // "Apple M3 Pro (12c / 16t, arm64)" diff --git a/internal/rungate/client.go b/internal/rungate/client.go index 86a0062d..94013e04 100644 --- a/internal/rungate/client.go +++ b/internal/rungate/client.go @@ -39,17 +39,17 @@ const maxDirectiveBytes = 64 << 10 // response are ignored (the scan path fetches run-config for those in its own // phase). Errors are redacted (the URL embeds the customer id and the header // carries the tenant key). A near-verbatim sibling of rules/fetch.go. -func Checkin(ctx context.Context, endpoint, apiKey, customerID, deviceID string, lastRunAt int64) (Directive, error) { +func Checkin(ctx context.Context, endpoint, apiKey, customerID, deviceID string, lastRunAt int64) (Directive, WSLDirective, error) { endpoint = strings.TrimSpace(endpoint) apiKey = strings.TrimSpace(apiKey) if endpoint == "" || apiKey == "" { - return Directive{}, errors.New("rungate: missing endpoint or api key") + return Directive{}, WSLDirective{}, errors.New("rungate: missing endpoint or api key") } if strings.TrimSpace(customerID) == "" { - return Directive{}, errors.New("rungate: empty customer_id") + return Directive{}, WSLDirective{}, errors.New("rungate: empty customer_id") } if strings.TrimSpace(deviceID) == "" { - return Directive{}, errors.New("rungate: empty device_id") + return Directive{}, WSLDirective{}, errors.New("rungate: empty device_id") } target := strings.TrimRight(endpoint, "/") + @@ -64,7 +64,7 @@ func Checkin(ctx context.Context, endpoint, apiKey, customerID, deviceID string, req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) if err != nil { - return Directive{}, fmt.Errorf("rungate: build request: %w", err) + return Directive{}, WSLDirective{}, fmt.Errorf("rungate: build request: %w", err) } req.Header.Set("Authorization", "Bearer "+apiKey) req.Header.Set("Accept", "application/json") @@ -72,31 +72,37 @@ func Checkin(ctx context.Context, endpoint, apiKey, customerID, deviceID string, resp, err := (&http.Client{Timeout: checkinTimeout}).Do(req) if err != nil { - return Directive{}, fmt.Errorf("rungate: transport: %s", redact.String(err.Error())) + return Directive{}, WSLDirective{}, fmt.Errorf("rungate: transport: %s", redact.String(err.Error())) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { snippet, _ := io.ReadAll(io.LimitReader(resp.Body, maxDirectiveBytes)) - return Directive{}, fmt.Errorf("rungate: unexpected status %d: %s", + return Directive{}, WSLDirective{}, fmt.Errorf("rungate: unexpected status %d: %s", resp.StatusCode, redact.String(strings.TrimSpace(string(snippet)))) } body, err := io.ReadAll(io.LimitReader(resp.Body, maxDirectiveBytes)) if err != nil { - return Directive{}, fmt.Errorf("rungate: read body: %w", err) + return Directive{}, WSLDirective{}, fmt.Errorf("rungate: read body: %w", err) } var env runConfigEnvelope if err := json.Unmarshal(body, &env); err != nil { - return Directive{}, fmt.Errorf("rungate: decode body: %w", err) + return Directive{}, WSLDirective{}, fmt.Errorf("rungate: decode body: %w", err) } // A 200 with no scan_directive is an unknown shape (or an older backend // that predates run gating) — surface it as an error so the caller fails // open rather than trusting a zero value. if env.ScanDirective == nil || env.ScanDirective.Mode == "" { - return Directive{}, errors.New("rungate: response carried no scan_directive") + return Directive{}, WSLDirective{}, errors.New("rungate: response carried no scan_directive") } - return *env.ScanDirective, nil + // wsl_directive is optional and fails closed: a backend that does not send + // it yields the zero value, i.e. distro scanning off. + var wsl WSLDirective + if env.WSLDirective != nil { + wsl = *env.WSLDirective + } + return *env.ScanDirective, wsl, nil } // skipBeaconTimeout bounds the gated-skip heartbeat POST. Kept short: it is diff --git a/internal/rungate/client_test.go b/internal/rungate/client_test.go index c790b625..2dfa2b58 100644 --- a/internal/rungate/client_test.go +++ b/internal/rungate/client_test.go @@ -20,7 +20,7 @@ func TestCheckinParsesDirectiveAndSendsParams(t *testing.T) { })) defer srv.Close() - d, err := Checkin(context.Background(), srv.URL, "tenant-key", "acme corp", "SER 123", 1753150000) + d, wsl, err := Checkin(context.Background(), srv.URL, "tenant-key", "acme corp", "SER 123", 1753150000) if err != nil { t.Fatalf("Checkin: %v", err) } @@ -39,6 +39,53 @@ func TestCheckinParsesDirectiveAndSendsParams(t *testing.T) { if gotAuth != "Bearer tenant-key" { t.Errorf("Authorization = %q", gotAuth) } + // This fixture carries no wsl_directive: distro scanning must read as off. + if wsl.Enabled { + t.Error("wsl.Enabled = true with no wsl_directive in the response; must fail closed") + } +} + +// TestCheckinParsesWSLDirective covers the sibling block: it rides the same +// run-config response as scan_directive, so enabling WSL scanning costs no +// extra request. +func TestCheckinParsesWSLDirective(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"scan_directive":{"mode":"full","reason":"due"},` + + `"wsl_directive":{"enabled":true,"reason":"tenant_opt_in"}}`)) + })) + defer srv.Close() + + d, wsl, err := Checkin(context.Background(), srv.URL, "k", "acme", "SER1", 0) + if err != nil { + t.Fatalf("Checkin: %v", err) + } + if d.ShouldSkip() { + t.Error("scan directive should still proceed") + } + if !wsl.Enabled || wsl.Reason != "tenant_opt_in" { + t.Errorf("wsl directive = %+v, want {true tenant_opt_in}", wsl) + } +} + +// TestCheckinWSLDirectiveDisabledIsHonoured: an explicit enabled:false must be +// read as off, not as "field present so probably on". +func TestCheckinWSLDirectiveDisabledIsHonoured(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"scan_directive":{"mode":"full","reason":"due"},` + + `"wsl_directive":{"enabled":false,"reason":"tenant_opt_out"}}`)) + })) + defer srv.Close() + + _, wsl, err := Checkin(context.Background(), srv.URL, "k", "acme", "SER1", 0) + if err != nil { + t.Fatalf("Checkin: %v", err) + } + if wsl.Enabled { + t.Error("explicit enabled:false must stay off") + } + if wsl.Reason != "tenant_opt_out" { + t.Errorf("reason = %q, want tenant_opt_out", wsl.Reason) + } } func TestCheckinOmitsZeroLastRunAt(t *testing.T) { @@ -49,7 +96,7 @@ func TestCheckinOmitsZeroLastRunAt(t *testing.T) { })) defer srv.Close() - if _, err := Checkin(context.Background(), srv.URL, "k", "acme", "SER1", 0); err != nil { + if _, _, err := Checkin(context.Background(), srv.URL, "k", "acme", "SER1", 0); err != nil { t.Fatalf("Checkin: %v", err) } if strings.Contains(gotQuery, "last_run_at") { @@ -77,7 +124,7 @@ func TestCheckinErrorPaths(t *testing.T) { t.Run(tt.name, func(t *testing.T) { srv := httptest.NewServer(tt.handler) defer srv.Close() - if _, err := Checkin(context.Background(), srv.URL, "k", "acme", "SER1", 0); err == nil { + if _, _, err := Checkin(context.Background(), srv.URL, "k", "acme", "SER1", 0); err == nil { t.Fatal("Checkin must error so the gate fails open") } }) @@ -95,7 +142,7 @@ func TestCheckinRespectsContextDeadline(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() start := time.Now() - _, err := Checkin(ctx, srv.URL, "k", "acme", "SER1", 0) + _, _, err := Checkin(ctx, srv.URL, "k", "acme", "SER1", 0) if err == nil { t.Fatal("Checkin must error on deadline") } @@ -115,7 +162,7 @@ func TestCheckinValidatesInputs(t *testing.T) { {name: "no device", endpoint: "http://x", key: "k", customerID: "c", deviceID: ""}, } { t.Run(tt.name, func(t *testing.T) { - if _, err := Checkin(context.Background(), tt.endpoint, tt.key, tt.customerID, tt.deviceID, 0); err == nil { + if _, _, err := Checkin(context.Background(), tt.endpoint, tt.key, tt.customerID, tt.deviceID, 0); err == nil { t.Fatal("want validation error") } }) diff --git a/internal/rungate/directive.go b/internal/rungate/directive.go index 167be033..7c854b97 100644 --- a/internal/rungate/directive.go +++ b/internal/rungate/directive.go @@ -32,12 +32,28 @@ type Directive struct { CheckedAt int64 `json:"checked_at"` } +// WSLDirective is the tenant-wide switch for scanning inside WSL distros. It +// rides the same run-config response as ScanDirective — which the agent already +// fetches before every scan — so enabling it costs no extra call and needs no +// per-device state. The granularity is deliberately tenant-only: there is no +// per-device or per-group gating for WSL scanning. +// +// It fails CLOSED, unlike the scan gate. An absent block, a backend that +// predates the field, a failed check-in, or a bypassed gate all leave distro +// scanning off. Host-side WSL *detection* is unaffected: it is GA and needs no +// directive. +type WSLDirective struct { + Enabled bool `json:"enabled"` + Reason string `json:"reason,omitempty"` +} + // runConfigEnvelope is the subset of the run-config response the gate reads. // The scan directive rides run-config alongside detection_rules and policy; // those siblings are intentionally ignored here (the scan path fetches them // itself). A pointer so a missing field is distinguishable from a zero value. type runConfigEnvelope struct { - ScanDirective *Directive `json:"scan_directive"` + ScanDirective *Directive `json:"scan_directive"` + WSLDirective *WSLDirective `json:"wsl_directive"` } // ShouldSkip is the single reader of Mode. Anything that is not exactly diff --git a/internal/rungate/evaluate.go b/internal/rungate/evaluate.go index 9f687005..25deace1 100644 --- a/internal/rungate/evaluate.go +++ b/internal/rungate/evaluate.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "strings" "time" "github.com/step-security/dev-machine-guard/internal/config" @@ -23,6 +24,10 @@ type Result struct { Skip bool Reason string Detail string + // WSL is the tenant's WSL-scanning switch, carried out of the same + // check-in. Zero (disabled) on every path that does not reach a backend + // answer — see WSLDirective: this one fails closed. + WSL WSLDirective } // Evaluate runs the whole gate ahead of telemetry.Run: explicit escapes, @@ -34,7 +39,12 @@ type Result struct { // wakeup skips on the directive before the run ever tries the lock, and a due // wakeup that collides with a running scan is left to telemetry.Run's // lock.Acquire so it reports the contention as before. -func Evaluate(ctx context.Context, exec executor.Executor, log *progress.Logger, forceScan bool) Result { +// guestDeviceID, when non-empty, is the identity of an agent running inside a +// WSL distribution, derived by the host that triggered it. It must be used in +// preference to any local probe: a distro's own serial is its machine-id — or +// "unknown" on a minimal or WSL1 distro — so gating on it would check in +// against the wrong device record, or none. +func Evaluate(ctx context.Context, exec executor.Executor, log *progress.Logger, forceScan bool, guestDeviceID string) Result { in := Inputs{ ForceScan: forceScan || os.Getenv("STEPSEC_FORCE_SCAN") == "1", KillSwitch: os.Getenv("STEPSEC_DISABLE_RUN_GATE") == "1", @@ -49,29 +59,40 @@ func Evaluate(ctx context.Context, exec executor.Executor, log *progress.Logger, if in.ForceScan { log.Progress("Run gate: bypassed (--force-scan)") } - return Result{Skip: false, Reason: Decide(in).Reason} + // Note the asymmetry: bypassing the cadence gate does NOT enable WSL + // scanning. Without a directive we never scan inside a distro. + return Result{Skip: false, Reason: Decide(in).Reason, WSL: wslWithOverride(WSLDirective{})} } - // Device id: cached from a prior run when possible, else a bounded local - // probe. Without a real serial the backend can't be asked anything - // meaningful — fail open rather than gate on a bogus id. + // Device id: the guest identity when we were given one, else cached from a + // prior run, else a bounded local probe. Without a real id the backend + // can't be asked anything meaningful — fail open rather than gate on a + // bogus one. st, stOK := readState() - deviceID := st.DeviceID - if deviceID == "" || deviceID == "unknown" { - probeCtx, cancel := context.WithTimeout(ctx, serialProbeTimeout) - deviceID = device.SerialNumber(probeCtx, exec) - cancel() + deviceID := strings.TrimSpace(guestDeviceID) + if deviceID != "" { + log.Debug("run-gate: gating as WSL guest %s", deviceID) + } else { + deviceID = st.DeviceID + if deviceID == "" || deviceID == "unknown" { + probeCtx, cancel := context.WithTimeout(ctx, serialProbeTimeout) + deviceID = device.SerialNumber(probeCtx, exec) + cancel() + } } if deviceID == "" || deviceID == "unknown" { log.Debug("run-gate: no usable device id — failing open") - return Result{Skip: false, Reason: "no_device_id"} + return Result{Skip: false, Reason: "no_device_id", WSL: wslWithOverride(WSLDirective{})} } log.Progress("Run gate: checking scan cadence with the dashboard...") - directive, err := Checkin(ctx, config.APIEndpoint, config.APIKey, config.CustomerID, deviceID, st.LastFullRunAt) + directive, wslDirective, err := Checkin(ctx, config.APIEndpoint, config.APIKey, config.CustomerID, deviceID, st.LastFullRunAt) if err != nil { log.Progress("Run gate: dashboard check-in failed, using cached cadence: %v", err) } else { + if wslDirective.Enabled { + log.Progress("Run gate: WSL scanning enabled for this tenant (%s)", wslDirective.Reason) + } in.Directive = &directive log.Progress("Run gate: dashboard directive: mode=%s reason=%s interval=%dm", directive.Mode, directive.Reason, directive.EffectiveIntervalMinutes) @@ -87,7 +108,7 @@ func Evaluate(ctx context.Context, exec executor.Executor, log *progress.Logger, } dec := Decide(in) - res := Result{Skip: dec.Skip, Reason: dec.Reason} + res := Result{Skip: dec.Skip, Reason: dec.Reason, WSL: wslWithOverride(wslDirective)} if dec.Skip { // Online skip: best-effort heartbeat so the console shows the agent // checked in and was told not to scan (a gated skip otherwise leaves no @@ -108,3 +129,18 @@ func Evaluate(ctx context.Context, exec executor.Executor, log *progress.Logger, } return res } + +// wslWithOverride applies the local escape for WSL scanning. STEPSEC_FORCE_WSL_SCAN +// exists so a test machine can exercise the distro-scan path before any backend +// serves wsl_directive; it is the ONLY way to enable it without the directive, +// and it is deliberately separate from --force-scan (which bypasses cadence and +// must not silently switch on scanning inside a developer's Linux environment). +func wslWithOverride(d WSLDirective) WSLDirective { + if os.Getenv("STEPSEC_FORCE_WSL_SCAN") == "1" { + d.Enabled = true + if d.Reason == "" { + d.Reason = "env_override" + } + } + return d +} diff --git a/internal/rungate/wsl_directive_test.go b/internal/rungate/wsl_directive_test.go new file mode 100644 index 00000000..ae80219d --- /dev/null +++ b/internal/rungate/wsl_directive_test.go @@ -0,0 +1,38 @@ +package rungate + +import "testing" + +// TestWSLWithOverride pins the local escape hatch: STEPSEC_FORCE_WSL_SCAN is the +// only way to enable distro scanning without a backend directive, and it must +// not disturb a directive that is already on. +func TestWSLWithOverride(t *testing.T) { + if got := wslWithOverride(WSLDirective{}); got.Enabled { + t.Error("no directive, no env: want disabled") + } + + t.Setenv("STEPSEC_FORCE_WSL_SCAN", "1") + got := wslWithOverride(WSLDirective{}) + if !got.Enabled { + t.Error("env override did not enable WSL scanning") + } + if got.Reason != "env_override" { + t.Errorf("reason = %q, want env_override", got.Reason) + } + + // A real directive keeps its own reason. + got = wslWithOverride(WSLDirective{Enabled: true, Reason: "tenant_opt_in"}) + if !got.Enabled || got.Reason != "tenant_opt_in" { + t.Errorf("override clobbered a live directive: %+v", got) + } +} + +// TestWSLWithOverrideIgnoresOtherValues: only "1" enables it, so an empty or +// stray value cannot switch on scanning inside a developer's Linux environment. +func TestWSLWithOverrideIgnoresOtherValues(t *testing.T) { + for _, v := range []string{"", "0", "true", "yes"} { + t.Setenv("STEPSEC_FORCE_WSL_SCAN", v) + if wslWithOverride(WSLDirective{}).Enabled { + t.Errorf("STEPSEC_FORCE_WSL_SCAN=%q enabled scanning; only \"1\" should", v) + } + } +} diff --git a/internal/scan/scanner.go b/internal/scan/scanner.go index 296509db..c47b99e1 100644 --- a/internal/scan/scanner.go +++ b/internal/scan/scanner.go @@ -55,6 +55,16 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error { dev := device.Gather(ctx, exec) log.StepDone(time.Since(start)) + // WSL detection — host-side "is WSL present and actively used" on Windows. + // Feature-gated until the backend consumes device.wsl; GatherWSL is a no-op + // (nil) off Windows. Populates dev.WSL in place so it flows through Device. + if featuregate.IsEnabled(featuregate.FeatureWSLDetection) { + log.StepStart("Detecting WSL") + start = time.Now() + dev.WSL = device.GatherWSL(ctx, exec) + log.StepDone(time.Since(start)) + } + // Detect IDE installations log.StepStart("Detecting IDE installations") start = time.Now() diff --git a/internal/telemetry/phase_deadline.go b/internal/telemetry/phase_deadline.go index ad2accf5..6793ed0d 100644 --- a/internal/telemetry/phase_deadline.go +++ b/internal/telemetry/phase_deadline.go @@ -41,7 +41,10 @@ var phaseBudgets = map[string]time.Duration{ "python_scan": 10 * time.Minute, "syspkg_scan": 5 * time.Minute, "node_scan": 15 * time.Minute, - "telemetry_upload": 10 * time.Minute, + // Spawns only — it never waits for a distro scan to finish — so this + // bounds a wedged wsl.exe, not the work. + "wsl_scan": 2 * time.Minute, + "telemetry_upload": 10 * time.Minute, } const defaultPhaseBudget = 5 * time.Minute diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index c55128c6..713a6b22 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -36,6 +36,7 @@ import ( "github.com/step-security/dev-machine-guard/internal/schedinfo" "github.com/step-security/dev-machine-guard/internal/state" "github.com/step-security/dev-machine-guard/internal/tcc" + "github.com/step-security/dev-machine-guard/internal/wslguest" ) // s3UploadBackoffUnit is multiplied by attempt-number to compute the @@ -61,6 +62,8 @@ type Payload struct { Platform string `json:"platform"` OSVersion string `json:"os_version"` Resources model.MachineResources `json:"resources"` + WSL *model.WSLInfo `json:"wsl,omitempty"` + WSLGuest *model.WSLGuest `json:"wsl_guest,omitempty"` AgentVersion string `json:"agent_version"` CollectedAt int64 `json:"collected_at"` NoUserLoggedIn bool `json:"no_user_logged_in"` @@ -429,6 +432,11 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) (err err phaseCtx, phaseCancel := startPhase(ctx, tracker, "device_info") log.Progress("Gathering device information...") dev := device.Gather(phaseCtx, exec) + // WSL detection (Windows host-side; feature-gated until the backend + // consumes device.wsl). No-op off Windows. + if featuregate.IsEnabled(featuregate.FeatureWSLDetection) { + dev.WSL = device.GatherWSL(phaseCtx, exec) + } deviceID = dev.SerialNumber // Single source of truth for "is this a real developer or a daemon // context?" — same predicate the payload uses below, so the warning, @@ -452,6 +460,18 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) (err err } endPhase(phaseCtx, phaseCancel, tracker, log, "device_info") + // Trigger a scan inside each running WSL distribution. Gated on the + // tenant's wsl_directive (fetched by the run gate before this run) and on + // the host having reported WSL at all, so a machine without it costs + // nothing. Launch-only: the distros report their own findings, so this + // phase never waits for a scan and cannot extend the run. + if cfg != nil && cfg.WSLScanEnabled { + wslCtx, wslCancel := startPhase(ctx, tracker, "wsl_scan") + log.Progress("Triggering WSL distribution scans (%s)...", cfg.WSLScanReason) + triggerWSLScans(exec, log, cfg, &dev) + endPhase(wslCtx, wslCancel, tracker, log, "wsl_scan") + } + // Per-device scan state for the delta-upload protocol. Gated OFF by // default (config.UseLegacyPackageScan defaults true) until the agent-api // side ships. Resolution, in order: @@ -1121,17 +1141,31 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) (err err scanStateFullSync) } + // A run inside a WSL distro identifies itself by its host + distro pair. + // Its own identity is unusable: the hostname is the Windows host's, and a + // minimal or WSL1 distro has no machine-id, so dev.SerialNumber reads + // "unknown" and every such distro would collide on one record. + wslGuest := wslGuestFromConfig(cfg) + deviceIdentity := dev.SerialNumber + if wslGuest != nil { + deviceIdentity = wslguest.DeviceID(wslGuest.HostDeviceID, wslGuest.DistroID) + log.Progress("WSL guest: distro %s on host %s — device id %s", + wslGuest.DistroID, wslGuest.HostDeviceID, deviceIdentity) + } + // Build payload payload := &Payload{ PayloadSchemaVersion: schemaVersion, CustomerID: config.CustomerID, - DeviceID: dev.SerialNumber, - SerialNumber: dev.SerialNumber, + DeviceID: deviceIdentity, + SerialNumber: deviceIdentity, UserIdentity: dev.UserIdentity, Hostname: dev.Hostname, Platform: dev.Platform, OSVersion: dev.OSVersion, Resources: dev.Resources, + WSL: dev.WSL, + WSLGuest: wslGuest, AgentVersion: buildinfo.Version, CollectedAt: endTime.Unix(), NoUserLoggedIn: noUserLoggedIn, diff --git a/internal/telemetry/wsl_guest.go b/internal/telemetry/wsl_guest.go new file mode 100644 index 00000000..014ccdf7 --- /dev/null +++ b/internal/telemetry/wsl_guest.go @@ -0,0 +1,24 @@ +package telemetry + +import ( + "strings" + + "github.com/step-security/dev-machine-guard/internal/cli" + "github.com/step-security/dev-machine-guard/internal/model" +) + +// wslGuestFromConfig returns the guest block when this run was triggered inside +// a WSL distro. Both values are required: a host id without a distro id (or the +// reverse) cannot identify anything, so a partial pair is treated as "not a +// guest" rather than half-identified. +func wslGuestFromConfig(cfg *cli.Config) *model.WSLGuest { + if cfg == nil { + return nil + } + host := strings.TrimSpace(cfg.WSLHostSerial) + distro := strings.TrimSpace(cfg.WSLDistroID) + if host == "" || distro == "" { + return nil + } + return &model.WSLGuest{HostDeviceID: host, DistroID: distro} +} diff --git a/internal/telemetry/wsl_guest_test.go b/internal/telemetry/wsl_guest_test.go new file mode 100644 index 00000000..9d47350f --- /dev/null +++ b/internal/telemetry/wsl_guest_test.go @@ -0,0 +1,60 @@ +package telemetry + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/step-security/dev-machine-guard/internal/cli" + "github.com/step-security/dev-machine-guard/internal/model" +) + +func TestWSLGuestFromConfig(t *testing.T) { + if got := wslGuestFromConfig(nil); got != nil { + t.Errorf("nil config: got %+v, want nil", got) + } + // A partial pair identifies nothing, so it must not half-identify the run. + for _, c := range []cli.Config{ + {}, + {WSLHostSerial: "host-1"}, + {WSLDistroID: "{aaa}"}, + {WSLHostSerial: " ", WSLDistroID: "{aaa}"}, + } { + if got := wslGuestFromConfig(&c); got != nil { + t.Errorf("partial pair %+v: got %+v, want nil", c, got) + } + } + + got := wslGuestFromConfig(&cli.Config{WSLHostSerial: " host-1 ", WSLDistroID: " {aaa} "}) + if got == nil || got.HostDeviceID != "host-1" || got.DistroID != "{aaa}" { + t.Fatalf("got %+v, want trimmed host-1/{aaa}", got) + } +} + +// TestPayload_WSLGuest_WireContract locks the shape agent-api's +// ddbmodels.DeviceWSLGuest unmarshals; a tag drift silently unpairs every guest. +func TestPayload_WSLGuest_WireContract(t *testing.T) { + b, err := json.Marshal(&Payload{ + CustomerID: "c", DeviceID: "d", + WSLGuest: &model.WSLGuest{HostDeviceID: "host-1", DistroID: "{aaa}"}, + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + for _, want := range []string{`"wsl_guest":`, `"host_device_id":"host-1"`, `"distro_id":"{aaa}"`} { + if !strings.Contains(string(b), want) { + t.Errorf("payload missing %s\ngot: %s", want, b) + } + } +} + +// A host agent must never emit the block. +func TestPayload_WSLGuest_OmittedWhenNil(t *testing.T) { + b, err := json.Marshal(&Payload{CustomerID: "c", DeviceID: "d"}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if strings.Contains(string(b), "wsl_guest") { + t.Errorf("nil guest must be omitted, got: %s", b) + } +} diff --git a/internal/telemetry/wsl_payload_test.go b/internal/telemetry/wsl_payload_test.go new file mode 100644 index 00000000..16a6ce17 --- /dev/null +++ b/internal/telemetry/wsl_payload_test.go @@ -0,0 +1,85 @@ +package telemetry + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/step-security/dev-machine-guard/internal/model" +) + +// TestPayload_WSL_WireContract locks the enterprise wire shape the agent-api +// backend (ddbmodels.DeviceTelemetry.WSL) unmarshals: a top-level "wsl" object +// with the agreed keys. If these tags drift, backend ingestion silently drops +// the block. +func TestPayload_WSL_WireContract(t *testing.T) { + running := true + defaultUID := uint32(1000) + p := &Payload{ + CustomerID: "c", DeviceID: "d", + WSL: &model.WSLInfo{ + Presence: model.WSLPresenceYes, + Installed: true, + Active: true, + Version: "2.7.11.0", + Distros: []model.WSLDistro{{ + Name: "Ubuntu-24.04", DistroID: "{45fef9ed-8681-454f-a4ae-78b3ad117616}", + WSLVersion: 2, Running: running, + Default: true, OwnerSID: "S-1-5-21-x-500", + BasePath: `C:\Users\a\AppData\Local\wsl\{g}`, + DefaultUID: &defaultUID, + }}, + }, + } + b, err := json.Marshal(p) + if err != nil { + t.Fatalf("marshal: %v", err) + } + s := string(b) + for _, want := range []string{ + `"wsl":`, `"presence":"yes"`, `"installed":true`, `"active":true`, + `"version":"2.7.11.0"`, `"distros":`, `"name":"Ubuntu-24.04"`, + `"wsl_version":2`, `"running":true`, `"default":true`, + `"owner_sid":"S-1-5-21-x-500"`, `"base_path":`, + // The guest-identity pair: distro_id is the correlation key, default_uid + // tells the backend whether the distro even has a user home to scan. + `"distro_id":"{45fef9ed-8681-454f-a4ae-78b3ad117616}"`, `"default_uid":1000`, + } { + if !strings.Contains(s, want) { + t.Errorf("payload JSON missing %s\ngot: %s", want, s) + } + } +} + +// TestPayload_WSL_OmittedWhenNil: a non-Windows / gated-off agent must not emit +// a "wsl" key at all (omitempty), so the backend's nil-skip leaves any stored +// value untouched. +func TestPayload_WSL_OmittedWhenNil(t *testing.T) { + b, err := json.Marshal(&Payload{CustomerID: "c", DeviceID: "d"}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if strings.Contains(string(b), `"wsl"`) { + t.Errorf("nil WSL should be omitted, got: %s", b) + } +} + +// TestPayload_WSL_RootUIDSurvivesOmitempty: DefaultUID is a pointer precisely so +// that uid 0 (root-only distro — nothing worth scanning) stays on the wire +// instead of being swallowed by omitempty and read as "unknown". +func TestPayload_WSL_RootUIDSurvivesOmitempty(t *testing.T) { + zero := uint32(0) + b, err := json.Marshal(&Payload{ + CustomerID: "c", DeviceID: "d", + WSL: &model.WSLInfo{ + Presence: model.WSLPresenceYes, + Distros: []model.WSLDistro{{Name: "Imported", DefaultUID: &zero}}, + }, + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !strings.Contains(string(b), `"default_uid":0`) { + t.Errorf(`expected "default_uid":0 on the wire, got: %s`, b) + } +} diff --git a/internal/telemetry/wsl_scan.go b/internal/telemetry/wsl_scan.go new file mode 100644 index 00000000..3a51842d --- /dev/null +++ b/internal/telemetry/wsl_scan.go @@ -0,0 +1,170 @@ +package telemetry + +import ( + "os" + "path/filepath" + "strings" + + "github.com/step-security/dev-machine-guard/internal/cli" + "github.com/step-security/dev-machine-guard/internal/config" + "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" +) + +// Per-distro outcomes of the trigger phase. The host only ever learns whether +// a scan *started* — the distro's own agent reports what it found — so these +// describe launching, not scanning. +const ( + wslScanLaunched = "launched" + wslScanSkippedNotRunning = "skipped_not_running" + wslScanSkippedNoUser = "skipped_no_user" + wslScanSkippedNoDistroID = "skipped_no_distro_id" + wslScanFailed = "failed" +) + +// wslLinuxBinaryEnv overrides where the Linux agent binary is found. Delivery +// of that binary to a Windows host is not built yet, so this is how a test +// machine points at one. +const wslLinuxBinaryEnv = "STEPSEC_WSL_LINUX_BINARY" + +// wslLinuxBinaryNames are the names looked for next to the Windows agent. +var wslLinuxBinaryNames = []string{"dmg-linux", "stepsecurity-dev-machine-guard-linux"} + +// wslScanResult is one distro's trigger outcome. +type wslScanResult struct { + Distro string + DistroID string + Outcome string + Err string +} + +// triggerWSLScans starts a scan inside every running WSL distribution, and +// waits only long enough to know each one started. +// +// It does not collect results. The distro's agent owns its own upload, so a +// scan that takes minutes cannot extend this run — and it must not, because +// the relay it spawns has to outlive this process (WSL tears a distro down +// shortly after its last Windows-side client exits, so the relay is the scan's +// life support, and detaching inside Linux does not help). +// +// Returns one result per registered distro, for the log. Never errors: a +// failure to launch one distro must not affect the host's own scan. +func triggerWSLScans(exec executor.Executor, log *progress.Logger, cfg *cli.Config, dev *model.Device) []wslScanResult { + if cfg == nil || !cfg.WSLScanEnabled { + return nil + } + if dev == nil || dev.WSL == nil || dev.WSL.Presence != model.WSLPresenceYes || len(dev.WSL.Distros) == 0 { + return nil + } + if dev.SerialNumber == "" || dev.SerialNumber == "unknown" { + // Without a host id the guest cannot be paired to anything, so a scan + // would produce an unattributable device record. + log.Warn("WSL scan: host has no usable serial — not triggering distro scans") + return nil + } + + linuxBin := wslLinuxBinaryPath(exec) + if linuxBin == "" { + log.Warn("WSL scan: no Linux agent binary found next to the agent (set %s) — skipping", wslLinuxBinaryEnv) + return nil + } + guestConfig := windowsPathToWSL(config.ConfigFilePath()) + + results := make([]wslScanResult, 0, len(dev.WSL.Distros)) + for _, d := range dev.WSL.Distros { + res := wslScanResult{Distro: d.Name, DistroID: d.DistroID} + switch { + case !d.Running: + // The normal case, and a product promise: we never start a stopped + // distro. `wsl -e` would boot one, so this check is the whole + // enforcement. + res.Outcome = wslScanSkippedNotRunning + case d.DistroID == "": + res.Outcome = wslScanSkippedNoDistroID + case d.DefaultUID != nil && *d.DefaultUID == 0: + // Root-only distro: its home holds nothing worth scanning, and a + // scan there would report a clean machine. + res.Outcome = wslScanSkippedNoUser + default: + // No -u: `wsl -e` already runs as the distro's default user, which + // is whose home we want. No --force-scan either: the guest gates + // itself on the tenant's cadence, checking in under the derived + // guest id we hand it, so a distro scanned an hour ago skips. + err := exec.StartDetached("wsl.exe", + "-d", d.Name, + "-e", windowsPathToWSL(linuxBin), + "send-telemetry", + "--config="+guestConfig, + "--wsl-host-serial="+dev.SerialNumber, + "--wsl-distro-id="+d.DistroID, + ) + if err != nil { + res.Outcome = wslScanFailed + res.Err = err.Error() + } else { + res.Outcome = wslScanLaunched + } + } + results = append(results, res) + logWSLScanResult(log, res) + } + return results +} + +func logWSLScanResult(log *progress.Logger, res wslScanResult) { + switch res.Outcome { + case wslScanLaunched: + log.Progress(" %s: scan launched", res.Distro) + case wslScanFailed: + log.Warn(" %s: could not launch scan: %s", res.Distro, res.Err) + default: + log.Progress(" %s: %s", res.Distro, res.Outcome) + } +} + +// wslLinuxBinaryPath locates the Linux agent binary on the Windows host. The +// env override wins; otherwise look beside the running executable. Returns "" +// when there is nothing to run, which is the current default — host-side +// delivery of the Linux binary is not implemented. +func wslLinuxBinaryPath(exec executor.Executor) string { + if p := strings.TrimSpace(os.Getenv(wslLinuxBinaryEnv)); p != "" { + if exec.FileExists(p) { + return p + } + return "" + } + self, err := os.Executable() + if err != nil { + return "" + } + dir := filepath.Dir(self) + for _, name := range wslLinuxBinaryNames { + p := filepath.Join(dir, name) + if exec.FileExists(p) { + return p + } + } + return "" +} + +// windowsPathToWSL rewrites a Windows path to the form a distro sees over the +// automatic drive mount: C:\ProgramData\x -> /mnt/c/ProgramData/x. A path that +// is already POSIX-shaped is returned unchanged, so callers need not care which +// they hold. +// +// If a distro has automounting disabled the translated path will not exist +// there; the launch then fails and is reported as such, which is cheaper than +// probing every distro for its mount config. +func windowsPathToWSL(p string) string { + p = strings.TrimSpace(p) + if p == "" || strings.HasPrefix(p, "/") { + return p + } + if len(p) >= 2 && p[1] == ':' { + drive := strings.ToLower(p[:1]) + rest := strings.ReplaceAll(p[2:], `\`, "/") + return "/mnt/" + drive + strings.TrimSuffix("/"+strings.TrimPrefix(rest, "/"), "/") + } + return strings.ReplaceAll(p, `\`, "/") +} diff --git a/internal/telemetry/wsl_scan_test.go b/internal/telemetry/wsl_scan_test.go new file mode 100644 index 00000000..e303b3a5 --- /dev/null +++ b/internal/telemetry/wsl_scan_test.go @@ -0,0 +1,187 @@ +package telemetry + +import ( + "errors" + "strings" + "testing" + + "github.com/step-security/dev-machine-guard/internal/cli" + "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" +) + +var errFakeSpawn = errors.New("simulated spawn failure") + +func uidPtr(v uint32) *uint32 { return &v } + +func wslScanMock(t *testing.T, binary string) *executor.Mock { + t.Helper() + m := executor.NewMock() + m.SetGOOS(model.PlatformWindows) + m.SetFile(binary, []byte("elf")) + t.Setenv(wslLinuxBinaryEnv, binary) + return m +} + +func hostDevice(distros ...model.WSLDistro) *model.Device { + return &model.Device{ + SerialNumber: "host-serial-1", + WSL: &model.WSLInfo{ + Presence: model.WSLPresenceYes, Installed: true, Distros: distros, + }, + } +} + +func outcomes(res []wslScanResult) map[string]string { + out := map[string]string{} + for _, r := range res { + out[r.Distro] = r.Outcome + } + return out +} + +// TestTriggerWSLScans_OnlyRunningDistros is the product promise: we never start +// a stopped distro, and `wsl -e` would, so this check is the enforcement. +func TestTriggerWSLScans_OnlyRunningDistros(t *testing.T) { + m := wslScanMock(t, `C:\agent\dmg-linux`) + log := progress.NewNoop() + + dev := hostDevice( + model.WSLDistro{Name: "Debian", DistroID: "{aaa}", Running: true, DefaultUID: uidPtr(1000)}, + model.WSLDistro{Name: "Ubuntu", DistroID: "{bbb}", Running: false, DefaultUID: uidPtr(1000)}, + ) + res := triggerWSLScans(m, log, &cli.Config{WSLScanEnabled: true}, dev) + + got := outcomes(res) + if got["Debian"] != wslScanLaunched { + t.Errorf("Debian = %q, want launched", got["Debian"]) + } + if got["Ubuntu"] != wslScanSkippedNotRunning { + t.Errorf("Ubuntu = %q, want skipped_not_running", got["Ubuntu"]) + } + if len(m.DetachedCalls) != 1 { + t.Fatalf("expected exactly one spawn, got %v", m.DetachedCalls) + } + call := m.DetachedCalls[0] + for _, want := range []string{ + "wsl.exe -d Debian -e /mnt/c/agent/dmg-linux", + "send-telemetry", + "--wsl-host-serial=host-serial-1", + "--wsl-distro-id={aaa}", + } { + if !strings.Contains(call, want) { + t.Errorf("spawn missing %q\ngot: %s", want, call) + } + } + // -u would scan root's home and report a clean machine. + if strings.Contains(call, " -u ") { + t.Errorf("spawn must not pass -u: %s", call) + } + // The guest gates itself under the derived id we hand it, so forcing the + // scan would defeat the tenant's cadence for every distro. + if strings.Contains(call, "--force-scan") { + t.Errorf("spawn must not force the guest's scan: %s", call) + } + if !strings.Contains(call, "--config=") { + t.Errorf("spawn must tell the guest where the tenant config is: %s", call) + } +} + +func TestTriggerWSLScans_Skips(t *testing.T) { + log := progress.NewNoop() + + t.Run("root-only distro has no user home worth scanning", func(t *testing.T) { + m := wslScanMock(t, `C:\agent\dmg-linux`) + res := triggerWSLScans(m, log, &cli.Config{WSLScanEnabled: true}, + hostDevice(model.WSLDistro{Name: "Imported", DistroID: "{aaa}", Running: true, DefaultUID: uidPtr(0)})) + if outcomes(res)["Imported"] != wslScanSkippedNoUser { + t.Errorf("got %q, want skipped_no_user", outcomes(res)["Imported"]) + } + if len(m.DetachedCalls) != 0 { + t.Errorf("must not spawn: %v", m.DetachedCalls) + } + }) + + t.Run("distro with no id cannot be paired", func(t *testing.T) { + m := wslScanMock(t, `C:\agent\dmg-linux`) + res := triggerWSLScans(m, log, &cli.Config{WSLScanEnabled: true}, + hostDevice(model.WSLDistro{Name: "Legacy", Running: true, DefaultUID: uidPtr(1000)})) + if outcomes(res)["Legacy"] != wslScanSkippedNoDistroID { + t.Errorf("got %q, want skipped_no_distro_id", outcomes(res)["Legacy"]) + } + }) + + t.Run("directive off means nothing is triggered", func(t *testing.T) { + m := wslScanMock(t, `C:\agent\dmg-linux`) + res := triggerWSLScans(m, log, &cli.Config{WSLScanEnabled: false}, + hostDevice(model.WSLDistro{Name: "Debian", DistroID: "{aaa}", Running: true, DefaultUID: uidPtr(1000)})) + if res != nil || len(m.DetachedCalls) != 0 { + t.Errorf("tenant opt-out must trigger nothing: %v / %v", res, m.DetachedCalls) + } + }) + + t.Run("host without a usable serial cannot pair a guest", func(t *testing.T) { + m := wslScanMock(t, `C:\agent\dmg-linux`) + dev := hostDevice(model.WSLDistro{Name: "Debian", DistroID: "{aaa}", Running: true, DefaultUID: uidPtr(1000)}) + dev.SerialNumber = "unknown" + if res := triggerWSLScans(m, log, &cli.Config{WSLScanEnabled: true}, dev); res != nil { + t.Errorf("want nothing triggered, got %v", res) + } + if len(m.DetachedCalls) != 0 { + t.Errorf("must not spawn: %v", m.DetachedCalls) + } + }) + + t.Run("no linux binary on the host", func(t *testing.T) { + m := executor.NewMock() + m.SetGOOS(model.PlatformWindows) + t.Setenv(wslLinuxBinaryEnv, `C:\nope\dmg-linux`) + res := triggerWSLScans(m, log, &cli.Config{WSLScanEnabled: true}, + hostDevice(model.WSLDistro{Name: "Debian", DistroID: "{aaa}", Running: true, DefaultUID: uidPtr(1000)})) + if res != nil || len(m.DetachedCalls) != 0 { + t.Errorf("missing binary must skip cleanly: %v / %v", res, m.DetachedCalls) + } + }) +} + +// A launch failure must be reported per distro and must not stop the others. +func TestTriggerWSLScans_LaunchFailureIsIsolated(t *testing.T) { + m := wslScanMock(t, `C:\agent\dmg-linux`) + m.StartDetachedErr = errFakeSpawn + res := triggerWSLScans(m, progress.NewNoop(), &cli.Config{WSLScanEnabled: true}, + hostDevice( + model.WSLDistro{Name: "Debian", DistroID: "{aaa}", Running: true, DefaultUID: uidPtr(1000)}, + model.WSLDistro{Name: "Alpine", DistroID: "{bbb}", Running: true, DefaultUID: uidPtr(1000)}, + )) + if len(res) != 2 { + t.Fatalf("want a result per distro, got %v", res) + } + for _, r := range res { + if r.Outcome != wslScanFailed { + t.Errorf("%s = %q, want failed", r.Distro, r.Outcome) + } + if r.Err == "" { + t.Errorf("%s: failure must carry a reason", r.Distro) + } + } +} + +func TestWindowsPathToWSL(t *testing.T) { + cases := map[string]string{ + // The MSI installs here, so this is the production path — note the + // space. It needs no quoting because the spawn passes argv directly + // with no shell in between. + `C:\Program Files\StepSecurity\dmg-linux`: "/mnt/c/Program Files/StepSecurity/dmg-linux", + `C:\ProgramData\StepSecurity\config.json`: "/mnt/c/ProgramData/StepSecurity/config.json", + `D:\tools\dmg-linux`: "/mnt/d/tools/dmg-linux", + `c:\lower`: "/mnt/c/lower", + "/already/posix": "/already/posix", + "": "", + } + for in, want := range cases { + if got := windowsPathToWSL(in); got != want { + t.Errorf("windowsPathToWSL(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/wslguest/wslguest.go b/internal/wslguest/wslguest.go new file mode 100644 index 00000000..53612ada --- /dev/null +++ b/internal/wslguest/wslguest.go @@ -0,0 +1,34 @@ +// Package wslguest derives the device identity of an agent running inside a +// WSL distribution. It is deliberately tiny and dependency-free: both the +// telemetry payload and the run gate need the same id, and internal/telemetry +// already imports internal/rungate, so neither can own it. +package wslguest + +import ( + "strings" + + "github.com/google/uuid" +) + +// namespace namespaces derived guest ids. A fixed namespace keeps derivation +// stable across agent versions — change it and every WSL distribution in every +// fleet becomes a new device. +var namespace = uuid.MustParse("6f2c1f8e-3b6d-5a4e-9c1d-7f5a2b8e0d31") + +// DeviceID derives a distribution's device id from its Windows host's serial +// and its own registry GUID, both passed in by the host that triggered the +// scan. Returns "" unless both are present: a half-pair identifies nothing. +// +// Deterministic, so re-scans converge on one device record instead of breeding +// rows; unique per (host, distro), so distributions on one host never collide — +// which they would if we fell back to the hostname they all share. The backend +// does not recompute this (it pairs on distro_id), so the value only has to be +// stable and unique, not verifiable. +func DeviceID(hostSerial, distroID string) string { + host := strings.TrimSpace(hostSerial) + distro := strings.TrimSpace(distroID) + if host == "" || distro == "" { + return "" + } + return uuid.NewSHA1(namespace, []byte(strings.ToLower(host+"|"+distro))).String() +} diff --git a/internal/wslguest/wslguest_test.go b/internal/wslguest/wslguest_test.go new file mode 100644 index 00000000..64c65dbe --- /dev/null +++ b/internal/wslguest/wslguest_test.go @@ -0,0 +1,40 @@ +package wslguest_test + +import ( + "strings" + "testing" + + "github.com/step-security/dev-machine-guard/internal/wslguest" +) + +// A half-pair identifies nothing, so it must not produce an id at all. +func TestDeviceID_RequiresBothHalves(t *testing.T) { + for _, c := range [][2]string{{"", ""}, {"host-1", ""}, {"", "{aaa}"}, {" ", "{aaa}"}} { + if got := wslguest.DeviceID(c[0], c[1]); got != "" { + t.Errorf("DeviceID(%q, %q) = %q, want empty", c[0], c[1], got) + } + } +} + +// TestDeviceID_StableAndUnique: re-scans must converge on one record, +// and two distros on the same host must never collide — which they would if we +// used the hostname they all share. +func TestDeviceID_StableAndUnique(t *testing.T) { + a := wslguest.DeviceID("host-1", "{aaa}") + if a != wslguest.DeviceID("host-1", "{aaa}") { + t.Error("not deterministic — a re-scan would create a second device record") + } + if a == wslguest.DeviceID("host-1", "{bbb}") { + t.Error("two distros on one host collided") + } + if a == wslguest.DeviceID("host-2", "{aaa}") { + t.Error("the same distro id on two hosts collided") + } + // Case differences in a Windows serial or GUID must not fork the identity. + if a != wslguest.DeviceID("HOST-1", "{AAA}") { + t.Error("case change forked the device id") + } + if len(a) != 36 || strings.Count(a, "-") != 4 { + t.Errorf("device id %q is not uuid-shaped", a) + } +} diff --git a/packaging/windows/Product.wxs b/packaging/windows/Product.wxs index 2a82fb1d..cf7941fc 100644 --- a/packaging/windows/Product.wxs +++ b/packaging/windows/Product.wxs @@ -88,6 +88,21 @@ Name="stepsecurity-dev-machine-guard-task.exe" KeyPath="yes"/> + + + +