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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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-<version>-{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" || \
Expand All @@ -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" || \
Expand All @@ -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:
Expand Down
16 changes: 16 additions & 0 deletions SCAN_COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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\<SID>\...\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 <name>` 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
Expand Down
15 changes: 14 additions & 1 deletion cmd/stepsecurity-dev-machine-guard/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:])
Expand Down Expand Up @@ -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
Comment on lines +707 to +710
return false
}
if res.Detail != "" {
Expand Down
65 changes: 65 additions & 0 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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":
Comment on lines +331 to +333
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="):
Expand Down Expand Up @@ -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 ""
}
18 changes: 18 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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")
}

Expand Down
Loading
Loading