feat(windows): integrate msi based releases and sccm deployment#92
Open
swarit-stepsecurity wants to merge 12 commits into
Open
feat(windows): integrate msi based releases and sccm deployment#92swarit-stepsecurity wants to merge 12 commits into
swarit-stepsecurity wants to merge 12 commits into
Conversation
Signed-off-by: Swarit Pandey <swarit@stepsecurity.io>
…nData pattern
Immediate CAs were running during MSI's sequence-building phase, before
InstallFiles physically writes the .exe to disk. msiexec then failed with
error 1721 ("program required could not be run") when trying to invoke
the binary. Switching to deferred CAs ensures they run during the script
execution phase, after the binary is on disk. CustomActionData pattern
relays property values across the immediate/deferred boundary.
WiX 4 silently strips ExeCommand="[CustomActionData]" — the compiled MSI has an empty Target column, so msiexec fires the CA with no command line and aborts with error 1721. Both [INSTALLFOLDER] and [CustomActionData] are deferred-safe properties, so embed the exe path via [INSTALLFOLDER] in the literal ExeCommand and move only the args into CustomActionData.
WiX 4 silently strips [CustomActionData] tokens from ExeCommand at compile time, so Directory+ExeCommand based CAs end up with the args missing — binary gets invoked with just the exe path, no subcommand. Switching to the Property attribute (Type 50: exe-from-property) bypasses ExeCommand entirely: MSI reads the full command line directly from the property whose name matches the CA Id, auto-populated as CustomActionData at deferred execution time.
WiX 4 has a documented bug (wixtoolset discussion #9143) where [CustomActionData] is silently stripped from ExeCommand at compile time, leaving deferred CAs with no command line. The bare Property attribute is also invalid — WiX requires one of DllEntry/ExeCommand/Value/etc. The WiX-blessed pattern for this exact use case (run an exe with property- substituted args from a deferred CA) is WixQuietExec from the Util extension. It's a DLL custom action that reads its command line from the property whose Id matches the CA Id, which is auto-populated as CustomActionData by MSI. Adds the util: namespace to Product.wxs, wires the Util extension install into CI (workflow + Makefile), uses Wix4UtilCA_$(sys.BUILDARCHSHORT) so the binary auto-selects per arch.
The 'install' subcommand's primary contract is to register the scheduled task; initial telemetry is a best-effort follow-up. Exiting non-zero on telemetry failure is the right default for interactive dev workflows (surfaces real misconfigs immediately) but the wrong failure mode for MSI/SCCM/Intune deployments — a transient network hiccup rolls back the entire install over a side-effect that the scheduled task will retry on its next firing anyway. Adds opt-in flag --ignore-telemetry-error: when set, treats telemetry errors as warnings. Default behavior unchanged. The WiX manifest passes the flag automatically when MSI invokes 'install' via WixQuietExec.
There was a problem hiding this comment.
Pull request overview
This PR adds first-class Windows enterprise distribution support by introducing WiX-based MSI packaging, SCCM deployment documentation, and non-interactive configuration/install paths to support unattended fleet rollouts.
Changes:
- Add WiX v4 MSI packaging (x64/arm64) plus Makefile targets to build MSIs locally.
- Add non-interactive
configureinputs and an install-mode flag to avoid rollback on transient telemetry failures during unattended installs. - Extend the release workflow to build, sign (Sigstore), upload, and attest MSI artifacts; add SCCM deployment documentation.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| README.md | Adds a documentation entry pointing to SCCM deployment instructions. |
| packaging/windows/README.md | Documents local MSI build and how SCCM/Intune tenants provide config inputs. |
| packaging/windows/Product.wxs | New WiX manifest defining MSI payload, properties, and custom actions for configure/install/uninstall. |
| Makefile | Adds Windows arm64 build target and MSI build targets producing versioned MSI artifacts. |
| internal/config/config.go | Adds machine-wide config support on Windows, read/write dir selection, and non-interactive configure flow. |
| internal/config/config_windows.go | Implements Windows machine config path and elevation detection. |
| internal/config/config_other.go | Non-Windows stubs for machine config and elevation check. |
| internal/config/config_test.go | Updates an existing config test (comment/structure changes). |
| internal/config/config_nonint_test.go | Adds unit tests for non-interactive configure behavior and validation. |
| internal/cli/cli.go | Adds new CLI flags for non-interactive configure and install error-tolerance; updates help text. |
| internal/buildinfo/version.go | Updates the embedded version constant used by Makefile/workflows. |
| docs/deploying-via-sccm.md | New SCCM deployment guide for MSI-based rollout and upgrades. |
| cmd/stepsecurity-dev-machine-guard/main.go | Routes configure to non-interactive mode when flags/env are present; adds telemetry-error tolerance for install. |
| .github/workflows/release.yml | Adds a Windows MSI build/sign/upload/attestation job and exports release outputs for downstream jobs. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
5
to
7
| const ( | ||
| Version = "1.11.1" | ||
| Version = "1.11.2-msi-test8" | ||
| AgentURL = "https://github.com/step-security/dev-machine-guard" |
| make build-windows # produces stepsecurity-dev-machine-guard.exe | ||
|
|
||
| # Build the x64 MSI. | ||
| make build-msi-amd64 # produces dist/stepsecurity-dev-machine-guard-x64.msi |
| For arm64 (less common but supported): | ||
|
|
||
| ```bash | ||
| GOARCH=arm64 make build-windows |
Comment on lines
+27
to
+30
| Custom actions run inside `msiexec`'s process tree as immediate actions | ||
| (elevated under SCCM's SYSTEM context). The binary itself uses Windows | ||
| Task Scheduler's native CLI to register the scan job. Nothing in the | ||
| install path touches `powershell.exe`. |
Comment on lines
+363
to
+371
| // Machine-wide on Windows lives under C:\ProgramData and must remain | ||
| // world-readable (the scheduled task runs as a less-privileged user). | ||
| // Per-user keeps 0700 to stay private to the owner. | ||
| dirMode := os.FileMode(0o700) | ||
| fileMode := os.FileMode(0o600) | ||
| if isElevated() && machineConfigDir() != "" && dir == machineConfigDir() { | ||
| dirMode = 0o755 | ||
| fileMode = 0o644 | ||
| } |
Comment on lines
+366
to
+371
| dirMode := os.FileMode(0o700) | ||
| fileMode := os.FileMode(0o600) | ||
| if isElevated() && machineConfigDir() != "" && dir == machineConfigDir() { | ||
| dirMode = 0o755 | ||
| fileMode = 0o644 | ||
| } |
Comment on lines
49
to
54
| func TestSaveAndLoad(t *testing.T) { | ||
| // Use a temp directory | ||
| tmpDir := t.TempDir() | ||
| origConfigDir := configDir | ||
| // Override configDir for test | ||
| // Override the config dir resolution for this test by redirecting HOME. | ||
| tmpConfigPath := filepath.Join(tmpDir, "config.json") | ||
|
|
* internal/buildinfo/version.go: reset to a clean semver (1.11.2) — drop the -msi-testN suffix we used during lab iteration so the released MSI's Package Version is canonical. * packaging/windows/README.md: fix local build instructions. The Makefile output paths include $(VERSION), and 'GOARCH=arm64 make build-windows' is a no-op (the target hardcodes amd64). Use the dedicated build-msi-amd64 / build-msi-arm64 targets. * docs/deploying-via-sccm.md: custom actions are 'deferred', not 'immediate'. Discovered the hard way during lab testing — immediate CAs fire before InstallFiles physically writes the binary to disk. * internal/config/config.go: clarify in save() that POSIX file-mode bits are not enforced on Windows; access is controlled by ACLs. Added hardenMachineConfigACL() which invokes icacls to set explicit ACLs on machine-wide config.json (SYSTEM/Admins Full, Users Read, inheritance disabled). Best-effort; non-zero icacls exit doesn't fail the install. Mirrors the existing icacls pattern in internal/schtasks/schtasks.go. * config_windows.go / config_other.go: platform-split hardenMachineConfigACL (icacls call on Windows, no-op elsewhere). * docs/deploying-via-sccm.md: document the multi-user-machine API-key tradeoff explicitly. The ACL hardening makes the file read-only to non-admins but still readable by all interactive users (required so the scheduled task can load it). Single-user dev workstations are the expected deployment context; multi-user environments should use --from-file with their own ACL on the bootstrap file. * internal/config/config_test.go: fix the misleading 'redirects HOME' comment — the test actually exercises ConfigFile JSON marshaling against a temp file directly, doesn't go through save/load helpers. Pointed to config_nonint_test.go which does cover those.
Signed-off-by: Swarit Pandey <swarit@stepsecurity.io>
Catches breakage in the WiX manifest, deferred-CA wiring, configure
--non-interactive code path, and icacls hardening — none of which the
existing 'go test' suite touches. Runs on windows-latest in ~5 min:
1. go build Windows .exe
2. wix build the MSI (WixToolset.Util extension for WixQuietExec)
3. msiexec /i /qn with synthetic CI config
4. verify: binary in Program Files, config.json with expected values,
icacls ACL on config.json (Users:R, Admins:F, SYSTEM:F),
scheduled task registered with /ru INTERACTIVE
5. msiexec /x /qn to uninstall
6. verify: binary gone, scheduled task gone
(config.json intentionally persists — documented behavior)
Path-filtered: only triggers on changes that could plausibly affect the
MSI, not on doc-only changes. On failure, both install and uninstall
verbose logs are uploaded as a workflow artifact for inspection.
The smoke test uses --ignore-telemetry-error implicitly via the MSI
install CA so the synthetic api.invalid.example endpoint doesn't cause
rollback.
The 'schtasks /query' negative-existence check returns non-zero (correctly - the task is gone). PowerShell propagates the last native command's $LASTEXITCODE as the script exit code, so the step was failing despite all assertions passing.
Comment on lines
+31
to
+33
| mkdir -p dist | ||
| wix extension add --global WixToolset.Util.wixext/4.0.5 || true | ||
| wix build packaging/windows/Product.wxs \ |
Comment on lines
+33
to
+37
| // Best-effort: a non-zero icacls exit is logged but does not fail the | ||
| // configure call — the config is still functional with default inherited | ||
| // ProgramData ACLs (which are also Administrators/SYSTEM full + Users | ||
| // read-and-execute on existing files, just not as tightly scoped). | ||
| func hardenMachineConfigACL(path string) error { |
| DllEntry="WixQuietExec64" | ||
| Execute="deferred" | ||
| Impersonate="no" | ||
| Return="ignore"/> |
| ) | ||
|
|
||
| // withHome redirects HOME (and USERPROFILE on Windows) so save/load operate | ||
| // on a clean per-test directory. configDir resolution falls back to |
Comment on lines
+48
to
+55
| ```cmd | ||
| msiexec /i "stepsecurity-dev-machine-guard-1.8.2-x64.msi" /qn ^ | ||
| CUSTOMERID="acme-corp" ^ | ||
| APIENDPOINT="https://api.stepsecurity.io" ^ | ||
| APIKEY="sk_live_xxxxxxxxxxxxxxxx" ^ | ||
| SCANFREQUENCY=4 ^ | ||
| /l*v "C:\Windows\Temp\dmg-install.log" | ||
| ``` |
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.
What does this PR do?
Type of change
Testing
./stepsecurity-dev-machine-guard --verbose./stepsecurity-dev-machine-guard --json | python3 -m json.toolmake lintmake testRelated Issues