Skip to content

feat(windows): integrate msi based releases and sccm deployment#92

Open
swarit-stepsecurity wants to merge 12 commits into
step-security:mainfrom
swarit-stepsecurity:swarit/feat/msi-integration
Open

feat(windows): integrate msi based releases and sccm deployment#92
swarit-stepsecurity wants to merge 12 commits into
step-security:mainfrom
swarit-stepsecurity:swarit/feat/msi-integration

Conversation

@swarit-stepsecurity
Copy link
Copy Markdown
Member

What does this PR do?

Type of change

  • Bug fix
  • Enhancement
  • Documentation

Testing

  • Tested on macOS (version: ___)
  • Binary runs without errors: ./stepsecurity-dev-machine-guard --verbose
  • JSON output is valid: ./stepsecurity-dev-machine-guard --json | python3 -m json.tool
  • No secrets or credentials included
  • Lint passes: make lint
  • Tests pass: make test

Related Issues

Signed-off-by: Swarit Pandey <swarit@stepsecurity.io>
@swarit-stepsecurity swarit-stepsecurity changed the title feat(windows): integrate msi based releases feat(windows): integrate msi based releases and sccm deployment May 21, 2026
…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.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 configure inputs 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"
Comment thread packaging/windows/README.md Outdated
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
Comment thread packaging/windows/README.md Outdated
For arm64 (less common but supported):

```bash
GOARCH=arm64 make build-windows
Comment thread docs/deploying-via-sccm.md Outdated
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 thread internal/config/config.go Outdated
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 thread internal/config/config.go
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.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated 5 comments.

Comment thread Makefile
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"
```
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.

2 participants