feat(wsl): detect host-side WSL presence and active use (#57) - #198
Open
swarit-stepsecurity wants to merge 10 commits into
Open
feat(wsl): detect host-side WSL presence and active use (#57)#198swarit-stepsecurity wants to merge 10 commits into
swarit-stepsecurity wants to merge 10 commits into
Conversation
The Windows agent reports registered WSL distributions and whether one is running, under device.wsl. Inventory comes from an HKU registry walk (so a SYSTEM-context scan still sees a signed-in user's distros) plus the WslService/LxssManager service check; per-distro WSL1/2 is read from the Flags 0x8 bit (the Version DWORD is unreliable), and "active" from `wsl --list --running`. Wired into the community scan and telemetry. Ref step-security#57. Signed-off-by: Swarit Pandey <swarit@stepsecurity.io>
…robe Two host-side additions the WSL-guest work needs, plus a fix to a side effect we measured. device.wsl.distros[] now carries distro_id (the Lxss subkey GUID) and default_uid. The GUID is the only stable per-distro identifier: it survives restarts and renames — this WSL build has no --rename, only a DistributionName edit that leaves the key untouched — and changes on unregister/re-import, which is genuinely a new environment. It must not be parsed out of base_path: only store-installed distros carry the GUID there, an imported one reads C:\wsl1\Alpine. default_uid is a pointer so uid 0 (root-only distro, nothing worth scanning) stays distinguishable from an unreadable value. The running-distro probe is now gated on service state, queried natively via SC_MANAGER_CONNECT + SERVICE_QUERY_STATUS — the two rights the WSL service ACL grants Interactive Users, so it works unprivileged, unlike mgr.Connect(). The gate exists because `wsl --list --running --quiet` *starts* WslService when it is stopped: on a machine with WSL installed but unused since boot we were waking a service to learn that nothing was running. Unknown service state still probes, so the gate can only save work, never under-report "active". Ref step-security#57. Signed-off-by: Swarit Pandey <swarit@stepsecurity.io>
…eck-in The agent already fetches GET /developer-mdm-agent/run-config before every scan for the cadence gate, and the client explicitly ignores sibling blocks in that response. The WSL switch rides there as wsl_directive, so turning distro scanning on for a tenant costs no extra request and needs no per-device state — matching the decision that this is tenant-wide only, with no per-device or per-group gating. It fails CLOSED, deliberately unlike the cadence gate: an absent block, a backend that predates the field, an explicit enabled:false, a failed check-in, or a bypassed gate all leave distro scanning off. Note the asymmetry with --force-scan: bypassing cadence must not silently start scanning inside a developer's Linux environment. STEPSEC_FORCE_WSL_SCAN=1 is the only local escape, so a test box can exercise the path before any backend serves the field; only the exact value "1" counts. Checkin now returns both blocks from the one response. Host-side WSL detection does not consult the directive — it is GA and stays on. Ref step-security#57. Signed-off-by: Swarit Pandey <swarit@stepsecurity.io>
…omment The gate comment named a private repo's branch, which means nothing to anyone reading this repository. Keep the fact that matters — the field is safe to send before the backend consumes it. Signed-off-by: Swarit Pandey <swarit@stepsecurity.io>
An agent running inside a distro now reports which Windows host and which distribution it is, so the backend can pair it to its host. --wsl-host-serial and --wsl-distro-id are passed in by the host that triggered the scan; a distro cannot discover either for itself without Windows interop. Both are required — a partial pair identifies nothing, so it is treated as "not a guest" rather than half-identified — and together they populate a wsl_guest block on the payload. They also replace the device id. A distro's own identity is unusable: it inherits the host's hostname, and a minimal or WSL1 distro has no /etc/machine-id, so its serial reads "unknown" and every such distro on a fleet would collide onto one record. The id is uuidv5(namespace, host|distro), lowercased, so re-scans converge on one record and two distros on the same host never collide. The backend does not recompute it — it pairs on distro_id — so the value only has to be stable and unique, not verifiable. Ref step-security#57. Signed-off-by: Swarit Pandey <swarit@stepsecurity.io>
The tenant directive was being read and then ignored — nothing acted on it.
This is the phase that does, and it is the last piece of the host side.
A wsl_scan phase runs right after device_info, where the distro inventory and
each distro's running state are already in hand. For every distro that is
already running it spawns
wsl.exe -d <distro> -e <binary over /mnt/c> send-telemetry \
--config=<host config over /mnt/c> --wsl-host-serial=… --wsl-distro-id=…
and waits only long enough to know it started. It never collects results: the
distro's own agent owns its upload, so a scan taking minutes cannot extend the
host's run. That also means the relay must outlive this process, hence a new
executor seam — StartDetached, which starts and releases rather than waiting.
A spawned child does survive its parent and its scheduled task ending
(measured), so no job-object breakaway is needed.
Three refusals are enforced here because nothing else can:
- a stopped distro is never started, though `wsl -e` would start one, so the
running check IS the promise;
- no -u, because `wsl -e` already runs as the distro's default user and
root's home would read as a clean machine;
- a distro whose DefaultUid is 0 is skipped: no user home, nothing to find.
Each distro gets an outcome (launched / skipped_not_running / skipped_no_user /
skipped_no_distro_id / failed) and a launch failure is isolated to its own
distro.
--config comes with it, because a distro cannot otherwise read the tenant
config: config.json is pinned to the per-user directory and neither
STEPSECURITY_HOME nor --install-dir redirects it. Pointing it at the host's
file over /mnt/c keeps the tenant key on the host instead of copying it into
every distro. It is applied from a pre-scan of argv, since config.Load() runs
before flag parsing and keeps the first values it reads.
--force-scan is passed to the guest deliberately: its own run gate would check
in under the distro's local serial rather than the derived guest id, and gate
against the wrong device record. Teaching the gate that id is follow-up work.
Host-side delivery of the Linux binary is NOT built. The phase looks beside the
agent executable and honours STEPSEC_WSL_LINUX_BINARY, and skips cleanly with a
warning when there is nothing to run — so this is inert until a binary is
delivered and a tenant opts in.
Ref step-security#57.
Signed-off-by: Swarit Pandey <swarit@stepsecurity.io>
There was a problem hiding this comment.
Pull request overview
Adds Windows host-side WSL inventory and activity detection, plus groundwork for tenant-controlled guest scanning and stable guest identity.
Changes:
- Detects registered WSL distributions, versions, default users, and running state.
- Adds WSL data to community output and enterprise telemetry.
- Introduces run-config directives and WSL guest identity plumbing.
Reviewed changes
Copilot reviewed 22 out of 22 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
SCAN_COVERAGE.md |
Documents WSL detection coverage. |
cmd/stepsecurity-dev-machine-guard/main.go |
Carries WSL directives into runtime config. |
internal/cli/cli.go |
Adds hidden WSL guest identity flags. |
internal/device/wsl.go |
Implements shared WSL detection logic. |
internal/device/wsl_windows.go |
Adds native Windows registry and SCM probes. |
internal/device/wsl_other.go |
Provides mock-compatible non-Windows probes. |
internal/device/wsl_test.go |
Tests shared detection behavior. |
internal/device/wsl_other_test.go |
Tests registry DWORD parsing. |
internal/featuregate/featuregate.go |
Enables WSL detection. |
internal/model/model.go |
Defines WSL wire models. |
internal/output/html.go |
Renders WSL in HTML reports. |
internal/output/pretty.go |
Renders console WSL summaries. |
internal/rungate/client.go |
Parses the WSL run directive. |
internal/rungate/client_test.go |
Tests directive responses. |
internal/rungate/directive.go |
Defines the WSL directive model. |
internal/rungate/evaluate.go |
Propagates directives and local overrides. |
internal/rungate/wsl_directive_test.go |
Tests override behavior. |
internal/scan/scanner.go |
Adds WSL to community scans. |
internal/telemetry/telemetry.go |
Adds host and guest WSL telemetry. |
internal/telemetry/wsl_guest.go |
Derives stable guest identities. |
internal/telemetry/wsl_guest_test.go |
Tests guest identity and wire shape. |
internal/telemetry/wsl_payload_test.go |
Tests host WSL payload shape. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+698
to
+701
| // 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 |
| // 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") |
Comment on lines
+1135
to
+1138
| wslGuest := wslGuestFromConfig(cfg) | ||
| deviceIdentity := dev.SerialNumber | ||
| if wslGuest != nil { | ||
| deviceIdentity = wslGuestDeviceID(wslGuest.HostDeviceID, wslGuest.DistroID) |
Comment on lines
+161
to
+165
| func wslVersionFromFlags(flags uint64) int { | ||
| if flags&wslFlagVM != 0 { | ||
| return 2 | ||
| } | ||
| return 1 |
| 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"` |
Comment on lines
+318
to
+320
| case strings.HasPrefix(arg, "--wsl-host-serial="): | ||
| cfg.WSLHostSerial = strings.TrimPrefix(arg, "--wsl-host-serial=") | ||
| case arg == "--wsl-host-serial": |
| // 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) { |
Adds Windows creation flags to the detached spawn — DETACHED_PROCESS, a new process group, and CREATE_BREAKAWAY_FROM_JOB — with a fallback to the flags-only form, since breakaway fails outright when the job forbids it. On non-Windows the child gets its own session. Stated plainly: 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, verified by observing the relay and the in-distro agent process rather than by looking for files. They are kept because the failure they guard 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 relay with it, and WSL tears a distro down shortly after its last Windows-side client exits — so a reaped relay means a scan the host reported as "launched" that then quietly never ran. The case that would expose it, a guest scan outlasting the host run, could not be reproduced on the test box: guest scans finish in ~2s there and 8,000 planted files did not slow them. Signed-off-by: Swarit Pandey <swarit@stepsecurity.io>
| attrs := detachAttrs() | ||
| var err error | ||
| for _, attr := range attrs { | ||
| cmd := exec.Command(name, args...) |
A triggered distro scan no longer gets --force-scan. It now gates itself like any other device, so a tenant's cadence applies to distributions too and a distro scanned an hour ago skips instead of rescanning on every host run. The reason it needed forcing was the run gate: it resolves a device id from a local probe, and inside a distro that is /etc/machine-id — or "unknown" on a minimal or WSL1 distro — so the check-in named the wrong device record, or none. Evaluate now takes the guest identity the host passed in and prefers it over any probe. That id is derived in both the telemetry payload and the gate, and internal/telemetry already imports internal/rungate, so neither could own the derivation: it moves to internal/wslguest, which is tiny and dependency-free. Signed-off-by: Swarit Pandey <swarit@stepsecurity.io>
The WSL scan phase runs the Linux build of the agent inside a distribution over its /mnt/c mount, and looks for it beside its own executable. Nothing put it there, so the phase was inert on every real machine. Ships it as a third MSI component named dmg-linux, alongside the agent and the launcher. Chosen over a loader download because the loader verifies a signed checksum (<asset>.sha256.sig) before running anything, and the Linux release artifacts are signed as sigstore bundles instead — that path would need a release-pipeline change to publish signed checksums, plus agent-api and loader changes, before it could verify anything. Inside the MSI the binary inherits the installer's signature and needs none of that. Cost is ~11-12 MB per MSI on machines that may have no WSL. Nothing is installed into a distribution, and Windows never executes this binary itself; it is inert without WSL. Adds build-linux-arm64, because Windows on ARM runs an aarch64 WSL2 kernel and an amd64 binary fails there the same way a glibc-linked one fails on musl. Both Linux targets now pin CGO_ENABLED=0 for that reason — releases already pin it in .goreleaser.yml, so a locally built MSI now matches a released one. The install path contains a space (C:\Program Files\StepSecurity), which needs no quoting since the spawn passes argv with no shell in between; pinned in the path-translation test. Signed-off-by: Swarit Pandey <swarit@stepsecurity.io>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds host-side WSL detection to the Windows agent (#57): reports registered WSL distributions and whether one is running, under
device.wsl.WslService/LxssManagerservice check.System32\wsl.exeis never a presence signal.Flags & 0x8bit (theVersionDWORD is unreliable — reads 2 on WSL1); active fromwsl --list --running(UTF-16LE decoded, since the registry carries no runtime state).wsl-detectionon.Flags 0xF, the active flag flips with a running distro, and the SYSTEM-context HKU walk finds the distro.Per-distro identity and default user
device.wsl.distros[]now also carriesdistro_idanddefault_uid.distro_idis the Lxss subkey GUID — the only stable per-distro identifier. It survives restarts and renames (this WSL build has no--rename; renaming rewritesDistributionNameand leaves the key untouched) and changes on unregister/re-import, which genuinely is a new environment. It must not be parsed out ofbase_path: only store-installed distros carry the GUID there, an imported one readsC:\wsl1\Alpine.default_uidis the uidwsl -d <name>runs as. It is a pointer so0— a distro with no non-root user, whose home holds nothing worth scanning — stays distinguishable from a value we could not read.The running-distro probe no longer wakes a service
wsl --list --running --quietstartsWslServicewhen it is stopped (measured:Stoppedbefore,Runningafter, empty output). On a machine with WSL installed but unused since boot, that woke a Windows service to learn that nothing was running.The probe is now gated on service state, queried natively with
SC_MANAGER_CONNECT+SERVICE_QUERY_STATUS— the two rights the WSL service ACL grants Interactive Users, so it works unprivileged, unlikemgr.Connect(). Unknown service state still probes, so the gate can only save work, never under-reportactive. Verified on the same host:Stoppedbefore and after a scan, while a running distro still reportsactive: true.Tenant-wide switch for scanning inside distros
run-configalready answers before every scan for the cadence gate, and the client ignores sibling blocks in that response, so the switch rides there aswsl_directive— no extra request, no per-device state. Granularity is tenant-only by design.It fails closed: an absent block, a backend predating the field, an explicit
enabled:false, a failed check-in, or a bypassed gate all leave distro scanning off. Deliberate asymmetry with--force-scan— bypassing cadence must not silently start scanning inside a developer's Linux environment.STEPSEC_FORCE_WSL_SCAN=1is the only local escape.Nothing consumes the directive yet; this is the plumbing plus its tests.