Skip to content

feat(wsl): detect host-side WSL presence and active use (#57) - #198

Open
swarit-stepsecurity wants to merge 10 commits into
step-security:mainfrom
swarit-stepsecurity:swarit/feat/wt/wsl-v1
Open

feat(wsl): detect host-side WSL presence and active use (#57)#198
swarit-stepsecurity wants to merge 10 commits into
step-security:mainfrom
swarit-stepsecurity:swarit/feat/wt/wsl-v1

Conversation

@swarit-stepsecurity

@swarit-stepsecurity swarit-stepsecurity commented Aug 26, 2026

Copy link
Copy Markdown
Member

Adds host-side WSL detection to the Windows agent (#57): reports registered WSL distributions and whether one is running, under device.wsl.

  • Inventory from an HKU registry walk — a SYSTEM-context scan still sees a signed-in user's distros (naive HKCU reads SYSTEM's empty seeded key) — plus the WslService/LxssManager service check. System32\wsl.exe is never a presence signal.
  • WSL1/2 per distro from the Flags & 0x8 bit (the Version DWORD is unreliable — reads 2 on WSL1); active from wsl --list --running (UTF-16LE decoded, since the registry carries no runtime state).
  • Wired into the community scan and enterprise telemetry; rendered in pretty/HTML. Feature gate wsl-detection on.
  • Verified on a real WSL2 host: 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 carries distro_id and default_uid.

  • distro_id is the Lxss subkey GUID — the only stable per-distro identifier. It survives restarts and renames (this WSL build has no --rename; renaming rewrites DistributionName and leaves the key untouched) and changes on unregister/re-import, which genuinely is 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 the uid wsl -d <name> runs as. It is a pointer so 0 — 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 --quiet starts WslService when it is stopped (measured: Stopped before, Running after, 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, unlike mgr.Connect(). Unknown service state still probes, so the gate can only save work, never under-report active. Verified on the same host: Stopped before and after a scan, while a running distro still reports active: true.

Tenant-wide switch for scanning inside distros

run-config already answers before every scan for the cadence gate, and the client ignores sibling blocks in that response, so the switch rides there as wsl_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=1 is the only local escape.

Nothing consumes the directive yet; this is the plumbing plus its tests.

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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Comment thread internal/device/wsl.go
// 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 thread internal/telemetry/telemetry.go Outdated
Comment on lines +1135 to +1138
wslGuest := wslGuestFromConfig(cfg)
deviceIdentity := dev.SerialNumber
if wslGuest != nil {
deviceIdentity = wslGuestDeviceID(wslGuest.HostDeviceID, wslGuest.DistroID)
Comment thread internal/device/wsl.go
Comment on lines +161 to +165
func wslVersionFromFlags(flags uint64) int {
if flags&wslFlagVM != 0 {
return 2
}
return 1
Comment thread internal/model/model.go
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 thread internal/cli/cli.go
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) {
Comment thread internal/executor/executor.go Fixed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants