From 3e119c3a2db839db1fdfbbd1dc1ff50ddfd3a9d0 Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Thu, 21 May 2026 13:48:48 +0530 Subject: [PATCH 01/14] feat(windows): integrate msi based releases Signed-off-by: Swarit Pandey --- .github/workflows/release.yml | 59 +++++- Makefile | 30 ++- README.md | 1 + cmd/stepsecurity-dev-machine-guard/main.go | 35 +++- docs/deploying-via-sccm.md | 214 +++++++++++++++++++++ internal/cli/cli.go | 67 ++++++- internal/config/config.go | 151 ++++++++++++++- internal/config/config_nonint_test.go | 152 +++++++++++++++ internal/config/config_other.go | 15 ++ internal/config/config_test.go | 5 +- internal/config/config_windows.go | 19 ++ packaging/windows/Product.wxs | 162 ++++++++++++++++ packaging/windows/README.md | 119 ++++++++++++ 13 files changed, 1010 insertions(+), 19 deletions(-) create mode 100644 docs/deploying-via-sccm.md create mode 100644 internal/config/config_nonint_test.go create mode 100644 internal/config/config_other.go create mode 100644 internal/config/config_windows.go create mode 100644 packaging/windows/Product.wxs create mode 100644 packaging/windows/README.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c194278..061361d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -85,6 +85,40 @@ jobs: - name: Install cosign uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 + - name: Build MSIs (x64 + arm64) + # WiX 4 runs on .NET, so we install it on the same ubuntu-latest + # runner. Output: dist/stepsecurity-dev-machine-guard--x64.msi + # and the arm64 counterpart. Both wrap the goreleaser-produced + # .exe and embed it via CAB compression. No PowerShell anywhere. + run: | + dotnet tool install --global wix --version 4.0.5 + export PATH="$PATH:$HOME/.dotnet/tools" + wix --version + + version="${{ steps.version.outputs.version }}" + + win_amd64_exe=$(find dist -type f -name '*.exe' -path '*windows_amd64*' | head -1) + win_arm64_exe=$(find dist -type f -name '*.exe' -path '*windows_arm64*' | head -1) + if [ ! -f "$win_amd64_exe" ] || [ ! -f "$win_arm64_exe" ]; then + echo "::error::Windows .exe artifacts not found under dist/" + find dist -type f -name '*.exe' + exit 1 + fi + + wix build packaging/windows/Product.wxs \ + -arch x64 \ + -d Arch=x64 \ + -d Version="$version" \ + -d BinaryPath="$PWD/$win_amd64_exe" \ + -out "dist/stepsecurity-dev-machine-guard-${version}-x64.msi" + + wix build packaging/windows/Product.wxs \ + -arch arm64 \ + -d Arch=arm64 \ + -d Version="$version" \ + -d BinaryPath="$PWD/$win_arm64_exe" \ + -out "dist/stepsecurity-dev-machine-guard-${version}-arm64.msi" + - name: Locate binaries and packages id: binaries run: | @@ -99,7 +133,10 @@ jobs: RPM_AMD64=$(find dist -type f -name '*-amd64.rpm' | head -1) RPM_ARM64=$(find dist -type f -name '*-arm64.rpm' | head -1) - for label in "darwin:${DARWIN}" "windows_amd64:${WIN_AMD64}" "windows_arm64:${WIN_ARM64}" "linux_amd64:${LINUX_AMD64}" "linux_arm64:${LINUX_ARM64}" "deb_amd64:${DEB_AMD64}" "deb_arm64:${DEB_ARM64}" "rpm_amd64:${RPM_AMD64}" "rpm_arm64:${RPM_ARM64}"; do + MSI_X64=$(find dist -type f -name '*-x64.msi' | head -1) + MSI_ARM64=$(find dist -type f -name '*-arm64.msi' | head -1) + + for label in "darwin:${DARWIN}" "windows_amd64:${WIN_AMD64}" "windows_arm64:${WIN_ARM64}" "linux_amd64:${LINUX_AMD64}" "linux_arm64:${LINUX_ARM64}" "deb_amd64:${DEB_AMD64}" "deb_arm64:${DEB_ARM64}" "rpm_amd64:${RPM_AMD64}" "rpm_arm64:${RPM_ARM64}" "msi_x64:${MSI_X64}" "msi_arm64:${MSI_ARM64}"; do name="${label%%:*}" path="${label#*:}" if [ -z "$path" ] || [ ! -f "$path" ]; then @@ -118,6 +155,8 @@ jobs: echo "deb_arm64=$DEB_ARM64" >> "$GITHUB_OUTPUT" echo "rpm_amd64=$RPM_AMD64" >> "$GITHUB_OUTPUT" echo "rpm_arm64=$RPM_ARM64" >> "$GITHUB_OUTPUT" + echo "msi_x64=$MSI_X64" >> "$GITHUB_OUTPUT" + echo "msi_arm64=$MSI_ARM64" >> "$GITHUB_OUTPUT" - name: Sign artifacts with Sigstore shell: bash @@ -156,6 +195,20 @@ jobs: "${{ steps.binaries.outputs.rpm_amd64 }}.bundle" sign_with_retry "${{ steps.binaries.outputs.rpm_arm64 }}" \ "${{ steps.binaries.outputs.rpm_arm64 }}.bundle" + sign_with_retry "${{ steps.binaries.outputs.msi_x64 }}" \ + "${{ steps.binaries.outputs.msi_x64 }}.bundle" + sign_with_retry "${{ steps.binaries.outputs.msi_arm64 }}" \ + "${{ steps.binaries.outputs.msi_arm64 }}.bundle" + + - name: Upload MSIs to draft release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "${{ steps.release.outputs.tag }}" \ + "${{ steps.binaries.outputs.msi_x64 }}" \ + "${{ steps.binaries.outputs.msi_arm64 }}" \ + --clobber + - name: Upload cosign bundles env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -170,6 +223,8 @@ jobs: "${{ steps.binaries.outputs.deb_arm64 }}.bundle" \ "${{ steps.binaries.outputs.rpm_amd64 }}.bundle" \ "${{ steps.binaries.outputs.rpm_arm64 }}.bundle" \ + "${{ steps.binaries.outputs.msi_x64 }}.bundle" \ + "${{ steps.binaries.outputs.msi_arm64 }}.bundle" \ --clobber - name: Attest build provenance @@ -185,3 +240,5 @@ jobs: ${{ steps.binaries.outputs.deb_arm64 }} ${{ steps.binaries.outputs.rpm_amd64 }} ${{ steps.binaries.outputs.rpm_arm64 }} + ${{ steps.binaries.outputs.msi_x64 }} + ${{ steps.binaries.outputs.msi_arm64 }} diff --git a/Makefile b/Makefile index 82ed934..7942ab6 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ LDFLAGS := -s -w \ -X $(MODULE)/internal/buildinfo.ReleaseTag=$(TAG) \ -X $(MODULE)/internal/buildinfo.ReleaseBranch=$(BRANCH) -.PHONY: build build-windows build-linux deploy-windows test lint clean smoke +.PHONY: build build-windows build-windows-arm64 build-linux 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 @@ -17,9 +17,34 @@ build: build-windows: GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY).exe ./cmd/stepsecurity-dev-machine-guard +build-windows-arm64: + GOOS=windows GOARCH=arm64 go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY)-arm64.exe ./cmd/stepsecurity-dev-machine-guard + build-linux: GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY)-linux ./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--{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 + mkdir -p dist + wix build packaging/windows/Product.wxs \ + -arch x64 \ + -d Arch=x64 \ + -d Version=$(VERSION) \ + -d BinaryPath=$(CURDIR)/$(BINARY).exe \ + -out dist/stepsecurity-dev-machine-guard-$(VERSION)-x64.msi + +build-msi-arm64: build-windows-arm64 + mkdir -p dist + wix build packaging/windows/Product.wxs \ + -arch arm64 \ + -d Arch=arm64 \ + -d Version=$(VERSION) \ + -d BinaryPath=$(CURDIR)/$(BINARY)-arm64.exe \ + -out dist/stepsecurity-dev-machine-guard-$(VERSION)-arm64.msi + deploy-windows: @bash scripts/deploy-windows.sh $(DEPLOY_ARGS) @@ -30,7 +55,8 @@ lint: golangci-lint run ./... clean: - rm -f $(BINARY) $(BINARY).exe $(BINARY)-linux + rm -f $(BINARY) $(BINARY).exe $(BINARY)-arm64.exe $(BINARY)-linux + rm -rf dist/ smoke: build bash tests/test_smoke_go.sh diff --git a/README.md b/README.md index 3af3f72..663c049 100644 --- a/README.md +++ b/README.md @@ -469,6 +469,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. - [Changelog](CHANGELOG.md) - [Scan Coverage](SCAN_COVERAGE.md) — full catalog of detections - [Release Process](docs/release-process.md) — how releases are signed and verified +- [Deploying via SCCM](docs/deploying-via-sccm.md) — Windows fleet rollout via Microsoft Configuration Manager (signed MSI, no PowerShell) - [Versioning](VERSIONING.md) — why the version starts at 1.8.1 - [Security Policy](SECURITY.md) — reporting vulnerabilities - [Code of Conduct](CODE_OF_CONDUCT.md) diff --git a/cmd/stepsecurity-dev-machine-guard/main.go b/cmd/stepsecurity-dev-machine-guard/main.go index 91fd067..8f68d15 100644 --- a/cmd/stepsecurity-dev-machine-guard/main.go +++ b/cmd/stepsecurity-dev-machine-guard/main.go @@ -113,9 +113,38 @@ func main() { switch cfg.Command { case "configure": - if err := config.RunConfigure(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) + // Non-interactive path: any explicit config flag, an explicit + // --non-interactive, OR the DMG_API_KEY env var route configure + // through the no-prompt code path. This is how MSI/SCCM/Intune + // custom actions drive configuration — they can't talk to stdin. + opts := config.NonInteractiveOptions{ + FromFile: cfg.ConfigFromFile, + CustomerID: cfg.ConfigCustomerID, + APIEndpoint: cfg.ConfigAPIEndpoint, + APIKey: cfg.ConfigAPIKey, + ScanFrequency: cfg.ConfigScanFrequency, + } + if opts.APIKey == "" { + // Env-var fallback keeps the secret off the msiexec command + // line (which lands in AppEnforce.log on every endpoint). + opts.APIKey = os.Getenv("DMG_API_KEY") + } + // Only forward --search-dirs to configure when the user actually + // passed it on this invocation. (cli.Parse defaults SearchDirs to + // ["$HOME"] for the scan path, which we must not persist here.) + if len(cfg.SearchDirs) > 0 && !(len(cfg.SearchDirs) == 1 && cfg.SearchDirs[0] == "$HOME") { + opts.SearchDirs = cfg.SearchDirs + } + if cfg.NonInteractive || opts.HasAny() { + if err := config.RunConfigureNonInteractive(opts); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + } else { + if err := config.RunConfigure(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } } case "configure show": diff --git a/docs/deploying-via-sccm.md b/docs/deploying-via-sccm.md new file mode 100644 index 0000000..ff0f0ae --- /dev/null +++ b/docs/deploying-via-sccm.md @@ -0,0 +1,214 @@ +# Deploying Dev Machine Guard via Microsoft Configuration Manager (SCCM) + +This guide is for IT admins deploying Dev Machine Guard to a fleet of +Windows endpoints through **Microsoft Configuration Manager** (formerly +SCCM, now part of Microsoft Intune family as MEMCM / ConfigMgr). + +## What ships + +- `stepsecurity-dev-machine-guard--x64.msi` (Windows on Intel/AMD) +- `stepsecurity-dev-machine-guard--arm64.msi` (Windows on ARM) + +Both are signed Windows Installer packages. SCCM consumes them natively +as **Application** deployment type "Windows Installer (`*.msi`)". Detection +rule and uninstall command are auto-derived from the MSI `ProductCode` +— no scripting required on your side. + +## Why MSI and not a script + +The customer environments we built this for typically have an EDR rule +that blocks `powershell.exe` from making outbound network calls. Our MSI +install/upgrade/uninstall flows **never spawn PowerShell**. The chain is: + +``` +SCCM → msiexec.exe → stepsecurity-dev-machine-guard.exe → schtasks.exe +``` + +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`. + +## Two ways to pass tenant credentials + +| | **Inline properties** | **Pre-staged bootstrap file** | +|---|---|---| +| **Set up** | One step (MSI deploy) | Two steps (drop config, then MSI deploy) | +| **API key in logs** | Appears in `AppEnforce.log` if `/l*v` is on | Never on command line — safe under any logging | +| **Multi-tenant** | One Application per tenant (different command line) | One Application; per-tenant config via GPO/Intune File preferences | +| **Recommended** | OK for small / lab deployments | **Yes for production** | + +### Option A — Inline properties + +Use this in the SCCM Application's **Installation program** field: + +```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" +``` + +| Property | Required | Description | +|----------|----------|-------------| +| `CUSTOMERID` | yes | Your StepSecurity tenant ID | +| `APIENDPOINT` | yes | StepSecurity backend URL (typically `https://api.stepsecurity.io`) | +| `APIKEY` | yes | Tenant API key from your StepSecurity dashboard | +| `SCANFREQUENCY` | no | Scheduled scan frequency in hours (default `4`) | + +### Option B — Pre-staged bootstrap file (recommended) + +**Step 1**: deploy a JSON config to every target endpoint via GPO File +Preferences, Intune Settings Catalog → Files, or any other config +distribution channel. Path: + +``` +C:\ProgramData\StepSecurity\bootstrap.json +``` + +Contents: + +```json +{ + "customer_id": "acme-corp", + "api_endpoint": "https://api.stepsecurity.io", + "api_key": "sk_live_xxxxxxxxxxxxxxxx", + "scan_frequency_hours": "4" +} +``` + +**Step 2**: deploy the MSI with the `BOOTSTRAPFILE` property pointing at +that path: + +```cmd +msiexec /i "stepsecurity-dev-machine-guard-1.8.2-x64.msi" /qn ^ + BOOTSTRAPFILE="C:\ProgramData\StepSecurity\bootstrap.json" ^ + /l*v "C:\Windows\Temp\dmg-install.log" +``` + +The API key never appears on the msiexec command line, so it stays out +of `AppEnforce.log` even with verbose logging enabled. The bootstrap +file can be ACL-restricted to SYSTEM + Administrators if you want +defense-in-depth. + +## SCCM Application setup, step by step + +1. **Software Library → Applications → Create Application** +2. **Manually specify the application information**: + - Name: `StepSecurity Dev Machine Guard` + - Publisher: `StepSecurity` + - Software version: matches MSI (e.g. `1.8.2`) +3. **Add Deployment Type** → **Windows Installer (`*.msi`)** +4. **Content**: point at the `.msi` file on a share that the + Distribution Points can pull from +5. **Programs** tab: + - **Installation program**: see Option A or B above + - **Uninstall program**: SCCM auto-fills from the MSI `ProductCode`, + usually `msiexec /x {PRODUCT-CODE} /qn` +6. **Detection method**: SCCM offers to use the MSI's product code by + default — **accept it**. No custom script needed. +7. **User Experience** tab: + - **Installation behavior**: Install for system + - **Logon requirement**: Whether or not a user is logged on + - **Installation program visibility**: Hidden +8. **Requirements** tab: + - For x64 MSI: `Operating system → Windows → All Windows 10/11 (64-bit) + and Windows Server 2016+ (64-bit)` + - For arm64 MSI: same but ARM64 variant +9. **Deploy** to a test collection first (5-10 machines), then expand. + +## Validating a successful deployment + +After SCCM reports the install as complete, on a target endpoint: + +```cmd +:: 1. The binary is on disk +dir "C:\Program Files\StepSecurity\stepsecurity-dev-machine-guard.exe" + +:: 2. The scheduled task is registered +schtasks /query /tn "StepSecurity Dev Machine Guard" + +:: 3. The config landed where the scanner can read it +type "C:\ProgramData\StepSecurity\config.json" + +:: 4. (Optional) trigger an immediate scan to confirm end-to-end +"C:\Program Files\StepSecurity\stepsecurity-dev-machine-guard.exe" send-telemetry +``` + +The configured tenant should see the endpoint in the StepSecurity +dashboard within a few minutes. + +## Upgrades + +When a new version ships, **create a new Application** in SCCM with the +new MSI and mark it as **superseding** the previous Application: + +1. New Application → **Supersedence** tab → **Add** → pick the old + Application +2. Choose **Uninstall** for the old app (the new MSI's MajorUpgrade will + do it atomically — but SCCM needs the supersedence link to track + which endpoints to push the upgrade to) +3. Deploy the new Application to the same collection + +On each endpoint: +- SCCM pushes the new MSI on its next policy cycle (default 60 min) +- Windows Installer recognizes the upgrade (same `UpgradeCode`, higher + `Version`) → atomically uninstalls the old version (removes the + scheduled task via our `uninstall` custom action), installs the new + one (re-registers the task with the new binary) +- The per-tenant config at `C:\ProgramData\StepSecurity\config.json` + is **preserved across upgrades** — tenant stays configured + +## Uninstall + +SCCM uninstall fires the `msiexec /x {ProductCode}` command. Our custom +action runs **before** file removal and calls +`stepsecurity-dev-machine-guard.exe uninstall`, which removes the +scheduled task via `schtasks /delete`. Then MSI removes the .exe and +empties `C:\Program Files\StepSecurity\`. + +The config at `C:\ProgramData\StepSecurity\config.json` is **not +removed** by MSI (it lives outside the install scope). If you want a +clean uninstall: + +```cmd +rmdir /s /q "C:\ProgramData\StepSecurity" +``` + +…as a post-uninstall cleanup step in SCCM, or via GPO. + +## Troubleshooting + +| Symptom | Likely cause | Where to look | +|---------|-------------|---------------| +| MSI exit code 1603 | Custom action failed (bad creds, schtasks denied) | `C:\Windows\Temp\dmg-install.log` (msiexec verbose log) | +| Scheduled task missing | `install` custom action skipped or failed | Same log; search for `RunInstallScheduledTask` | +| Endpoint not reporting to dashboard | Wrong API key / endpoint | `type C:\ProgramData\StepSecurity\config.json` | +| Endpoint config still under `%USERPROFILE%\.stepsecurity\` | MSI ran without elevation (shouldn't happen via SCCM) | Verify SCCM Application is set to "Install for system" | + +When opening a support case, attach: + +``` +C:\Windows\Temp\dmg-install.log (msiexec verbose log) +C:\ProgramData\StepSecurity\agent.log (scanner output) +C:\ProgramData\StepSecurity\agent.error.log +``` + +## Signature verification + +Each MSI release is signed via Sigstore and the bundle is published next +to the artifact. To verify before deploying to your fleet: + +```bash +# In a Linux/macOS environment with cosign installed +cosign verify-blob \ + --bundle stepsecurity-dev-machine-guard-1.8.2-x64.msi.bundle \ + --certificate-identity-regexp 'https://github.com/step-security/dev-machine-guard/.*' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + stepsecurity-dev-machine-guard-1.8.2-x64.msi +``` + +A passing verification confirms the MSI was built by our GitHub release +workflow from a tagged commit in this repo. diff --git a/internal/cli/cli.go b/internal/cli/cli.go index e47f122..b6745fe 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -35,6 +35,18 @@ type Config struct { // HooksAgent is the --agent value on `hooks install` / `hooks uninstall`; // "" means "every detected agent". HooksAgent string + + // Non-interactive `configure` inputs. Used by MSI/SCCM and other + // orchestrators that can't drive a stdin prompt loop. NonInteractive + // is implied when any of ConfigFromFile / ConfigCustomerID / + // ConfigAPIEndpoint / ConfigAPIKey / ConfigScanFrequency is set, + // and main.go routes accordingly. + NonInteractive bool + ConfigFromFile string // --from-file: read full config JSON from path + ConfigCustomerID string // --customer-id + ConfigAPIEndpoint string // --api-endpoint + ConfigAPIKey string // --api-key (also accepts env var DMG_API_KEY) + ConfigScanFrequency string // --scan-frequency (hours) } // supportedHookAgents lists the agent names accepted by `hooks --agent ` and `_hook ...`. @@ -143,6 +155,48 @@ func Parse(args []string) (*Config, error) { i++ } continue // skip the i++ at the bottom + case arg == "--non-interactive": + cfg.NonInteractive = true + case arg == "--from-file": + i++ + if i >= len(args) { + return nil, fmt.Errorf("--from-file requires a file path argument") + } + cfg.ConfigFromFile = args[i] + case strings.HasPrefix(arg, "--from-file="): + cfg.ConfigFromFile = strings.TrimPrefix(arg, "--from-file=") + case arg == "--customer-id": + i++ + if i >= len(args) { + return nil, fmt.Errorf("--customer-id requires a value") + } + cfg.ConfigCustomerID = args[i] + case strings.HasPrefix(arg, "--customer-id="): + cfg.ConfigCustomerID = strings.TrimPrefix(arg, "--customer-id=") + case arg == "--api-endpoint": + i++ + if i >= len(args) { + return nil, fmt.Errorf("--api-endpoint requires a value") + } + cfg.ConfigAPIEndpoint = args[i] + case strings.HasPrefix(arg, "--api-endpoint="): + cfg.ConfigAPIEndpoint = strings.TrimPrefix(arg, "--api-endpoint=") + case arg == "--api-key": + i++ + if i >= len(args) { + return nil, fmt.Errorf("--api-key requires a value") + } + cfg.ConfigAPIKey = args[i] + case strings.HasPrefix(arg, "--api-key="): + cfg.ConfigAPIKey = strings.TrimPrefix(arg, "--api-key=") + case arg == "--scan-frequency": + i++ + if i >= len(args) { + return nil, fmt.Errorf("--scan-frequency requires a value (hours)") + } + cfg.ConfigScanFrequency = args[i] + case strings.HasPrefix(arg, "--scan-frequency="): + cfg.ConfigScanFrequency = strings.TrimPrefix(arg, "--scan-frequency=") case arg == "--verbose": cfg.Verbose = true case strings.HasPrefix(arg, "--log-level="): @@ -314,9 +368,18 @@ Examples: %s configure # Set up enterprise config and search dirs %s send-telemetry # Enterprise telemetry +Non-interactive configure (for MSI / SCCM / Intune deployments): + --non-interactive Skip prompts; require values via flags or --from-file + --from-file PATH Read full config JSON from PATH (preferred for MSI) + --customer-id ID Customer identifier + --api-endpoint URL StepSecurity backend URL + --api-key KEY Authentication key (or set DMG_API_KEY env var) + --scan-frequency HOURS Scheduled scan frequency + Configuration: - Config file: ~/.stepsecurity/config.json - Run '%s configure' to set enterprise credentials and search directories interactively. + Per-user config: ~/.stepsecurity/config.json + Machine-wide (Windows, admin): C:\ProgramData\StepSecurity\config.json + Run '%s configure' to set credentials and search directories interactively. %s `, buildinfo.Version, name, name, diff --git a/internal/config/config.go b/internal/config/config.go index 27c4b2c..0376a0a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -41,15 +41,56 @@ type ConfigFile struct { LogLevel string `json:"log_level,omitempty"` } -// configDir returns ~/.stepsecurity. -func configDir() string { +// userConfigDir returns ~/.stepsecurity — the per-user config location. +// Used by community installs and by enterprise installs done via a +// developer's own login (not via MSI/SCCM). +func userConfigDir() string { home, _ := os.UserHomeDir() return filepath.Join(home, ".stepsecurity") } -// ConfigFilePath returns the path to the config file. +// machineConfigDir returns the machine-wide config dir on Windows (and "" +// elsewhere). Defined in config_windows.go / config_other.go. +// +// On Windows, MSI custom actions run as SYSTEM and the scheduled task runs +// as the logged-in user — the two never share a $HOME, so config has to +// live somewhere both can read. C:\ProgramData is that place. + +// 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). +func readConfigDir() string { + if mcd := machineConfigDir(); mcd != "" { + if _, err := os.Stat(filepath.Join(mcd, "config.json")); err == nil { + return mcd + } + } + return userConfigDir() +} + +// writeConfigDir returns the directory we should WRITE config to. +// Elevated/admin/SYSTEM context → machine-wide (Windows only). Otherwise +// per-user. This is what makes `configure` invoked from an MSI custom +// action put the config where the scheduled task can later read it. +func writeConfigDir() string { + if isElevated() { + if mcd := machineConfigDir(); mcd != "" { + return mcd + } + } + return userConfigDir() +} + +// ConfigFilePath returns the path to the config file (read-preferred). func ConfigFilePath() string { - return filepath.Join(configDir(), "config.json") + return filepath.Join(readConfigDir(), "config.json") +} + +// WriteConfigFilePath returns the path config would be written to under +// the current process's privilege level. Surfaced so `configure show` / +// install messages can name the exact file the next save will touch. +func WriteConfigFilePath() string { + return filepath.Join(writeConfigDir(), "config.json") } // Load reads the config file and applies values to the package-level variables. @@ -318,8 +359,17 @@ func loadExisting() *ConfigFile { } func save(cfg *ConfigFile) error { - dir := configDir() - if err := os.MkdirAll(dir, 0o700); err != nil { + dir := writeConfigDir() + // 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 + } + if err := os.MkdirAll(dir, dirMode); err != nil { return err } @@ -328,7 +378,7 @@ func save(cfg *ConfigFile) error { return err } - return os.WriteFile(ConfigFilePath(), data, 0o600) + return os.WriteFile(filepath.Join(dir, "config.json"), data, fileMode) } // ShowConfigure prints the current configuration to stdout. @@ -425,3 +475,90 @@ func displayLogLevel(level string) string { func isPlaceholder(v string) bool { return strings.Contains(v, "{{") } + +// NonInteractiveOptions captures every input the non-interactive configure +// path accepts. Populated by main.go from cli.Config plus the DMG_API_KEY +// env var fallback. Empty fields preserve the existing on-disk value +// (merge semantics — same as the interactive prompt's "press Enter to keep"). +type NonInteractiveOptions struct { + FromFile string // path to a complete ConfigFile JSON; wins over inline values + CustomerID string // --customer-id + APIEndpoint string // --api-endpoint + APIKey string // --api-key (or DMG_API_KEY env) + ScanFrequency string // --scan-frequency (hours, as string) + SearchDirs []string // --search-dirs (re-used from scan flag; empty = unchanged) +} + +// HasAny reports whether the caller supplied any inline value. Used by +// main.go to decide whether `configure` was invoked non-interactively +// even without the explicit --non-interactive flag (e.g. an MSI that only +// passes --from-file). +func (o NonInteractiveOptions) HasAny() bool { + return o.FromFile != "" || + o.CustomerID != "" || + o.APIEndpoint != "" || + o.APIKey != "" || + o.ScanFrequency != "" || + len(o.SearchDirs) > 0 +} + +// RunConfigureNonInteractive applies the supplied options to the existing +// on-disk config and saves. Designed for MSI custom actions, CI, and any +// orchestrator that can't drive stdin prompts. Always writes to +// writeConfigDir() — so an MSI running as SYSTEM lands config under +// C:\ProgramData\StepSecurity where the scheduled task (running as the +// logged-in user) can read it. +func RunConfigureNonInteractive(opts NonInteractiveOptions) error { + existing := loadExisting() + + // --from-file: replace base. Inline flags still override individual + // fields below, so customers can ship a base config via the file and + // inject the per-tenant API key via an env var on the command line. + if opts.FromFile != "" { + data, err := os.ReadFile(opts.FromFile) + if err != nil { + return fmt.Errorf("reading --from-file %q: %w", opts.FromFile, err) + } + var fromFile ConfigFile + if err := json.Unmarshal(data, &fromFile); err != nil { + return fmt.Errorf("parsing --from-file %q: %w", opts.FromFile, err) + } + existing = &fromFile + } + + if opts.CustomerID != "" { + existing.CustomerID = opts.CustomerID + } + if opts.APIEndpoint != "" { + existing.APIEndpoint = opts.APIEndpoint + } + if opts.APIKey != "" { + existing.APIKey = opts.APIKey + } + if opts.ScanFrequency != "" { + existing.ScanFrequencyHours = opts.ScanFrequency + } + if len(opts.SearchDirs) > 0 { + existing.SearchDirs = opts.SearchDirs + } + + // Validation: an MSI deploy with no creds is almost certainly a bug. + // Fail loud so the MSI transaction rolls back instead of silently + // installing a half-configured agent. + if existing.APIKey == "" { + return fmt.Errorf("api_key is required (pass --api-key, --from-file, or DMG_API_KEY env var)") + } + if existing.CustomerID == "" { + return fmt.Errorf("customer_id is required (pass --customer-id or --from-file)") + } + if existing.APIEndpoint == "" { + return fmt.Errorf("api_endpoint is required (pass --api-endpoint or --from-file)") + } + + if err := save(existing); err != nil { + return fmt.Errorf("saving configuration: %w", err) + } + + fmt.Printf("Configuration saved to %s\n", WriteConfigFilePath()) + return nil +} diff --git a/internal/config/config_nonint_test.go b/internal/config/config_nonint_test.go new file mode 100644 index 0000000..578a28b --- /dev/null +++ b/internal/config/config_nonint_test.go @@ -0,0 +1,152 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// withHome redirects HOME (and USERPROFILE on Windows) so save/load operate +// on a clean per-test directory. configDir resolution falls back to +// userConfigDir when no machine-wide config exists, which is exactly the +// path this exercises on non-root test runs. +func withHome(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) // Windows + return dir +} + +func TestRunConfigureNonInteractive_RequiresAPIKey(t *testing.T) { + withHome(t) + err := RunConfigureNonInteractive(NonInteractiveOptions{ + CustomerID: "cust-1", + APIEndpoint: "https://api.example.com", + }) + if err == nil || !strings.Contains(err.Error(), "api_key is required") { + t.Fatalf("expected api_key required error, got %v", err) + } +} + +func TestRunConfigureNonInteractive_RequiresCustomerID(t *testing.T) { + withHome(t) + err := RunConfigureNonInteractive(NonInteractiveOptions{ + APIKey: "sk-1", + APIEndpoint: "https://api.example.com", + }) + if err == nil || !strings.Contains(err.Error(), "customer_id is required") { + t.Fatalf("expected customer_id required error, got %v", err) + } +} + +func TestRunConfigureNonInteractive_RequiresAPIEndpoint(t *testing.T) { + withHome(t) + err := RunConfigureNonInteractive(NonInteractiveOptions{ + APIKey: "sk-1", + CustomerID: "cust-1", + }) + if err == nil || !strings.Contains(err.Error(), "api_endpoint is required") { + t.Fatalf("expected api_endpoint required error, got %v", err) + } +} + +func TestRunConfigureNonInteractive_Inline(t *testing.T) { + withHome(t) + err := RunConfigureNonInteractive(NonInteractiveOptions{ + CustomerID: "cust-1", + APIEndpoint: "https://api.example.com", + APIKey: "sk-abcdef", + ScanFrequency: "6", + SearchDirs: []string{"/opt", "/usr/local"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + cfg := loadExisting() + if cfg.CustomerID != "cust-1" { + t.Errorf("CustomerID: got %q", cfg.CustomerID) + } + if cfg.APIKey != "sk-abcdef" { + t.Errorf("APIKey: got %q", cfg.APIKey) + } + if cfg.ScanFrequencyHours != "6" { + t.Errorf("ScanFrequencyHours: got %q", cfg.ScanFrequencyHours) + } + if len(cfg.SearchDirs) != 2 || cfg.SearchDirs[0] != "/opt" { + t.Errorf("SearchDirs: got %v", cfg.SearchDirs) + } +} + +func TestRunConfigureNonInteractive_FromFile(t *testing.T) { + withHome(t) + src := &ConfigFile{ + CustomerID: "from-file-cust", + APIEndpoint: "https://from-file.example.com", + APIKey: "sk-from-file", + ScanFrequencyHours: "12", + SearchDirs: []string{"/from-file"}, + } + srcPath := filepath.Join(t.TempDir(), "bootstrap.json") + data, _ := json.Marshal(src) + if err := os.WriteFile(srcPath, data, 0o600); err != nil { + t.Fatal(err) + } + + err := RunConfigureNonInteractive(NonInteractiveOptions{FromFile: srcPath}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + cfg := loadExisting() + if cfg.CustomerID != "from-file-cust" || cfg.APIKey != "sk-from-file" { + t.Errorf("from-file values not applied: %+v", cfg) + } +} + +func TestRunConfigureNonInteractive_InlineOverridesFromFile(t *testing.T) { + withHome(t) + src := &ConfigFile{ + CustomerID: "from-file-cust", + APIEndpoint: "https://from-file.example.com", + APIKey: "sk-old", + } + srcPath := filepath.Join(t.TempDir(), "bootstrap.json") + data, _ := json.Marshal(src) + if err := os.WriteFile(srcPath, data, 0o600); err != nil { + t.Fatal(err) + } + + // Inline --api-key should win over the file's value (this is how + // customers inject per-tenant keys without committing them). + err := RunConfigureNonInteractive(NonInteractiveOptions{ + FromFile: srcPath, + APIKey: "sk-inline-wins", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + cfg := loadExisting() + if cfg.APIKey != "sk-inline-wins" { + t.Errorf("inline APIKey should override file: got %q", cfg.APIKey) + } + if cfg.CustomerID != "from-file-cust" { + t.Errorf("non-overridden file value lost: got %q", cfg.CustomerID) + } +} + +func TestNonInteractiveOptions_HasAny(t *testing.T) { + if (NonInteractiveOptions{}).HasAny() { + t.Error("empty opts should not report HasAny") + } + if !(NonInteractiveOptions{APIKey: "x"}).HasAny() { + t.Error("APIKey alone should be enough") + } + if !(NonInteractiveOptions{FromFile: "p"}).HasAny() { + t.Error("FromFile alone should be enough") + } +} diff --git a/internal/config/config_other.go b/internal/config/config_other.go new file mode 100644 index 0000000..c8d17dc --- /dev/null +++ b/internal/config/config_other.go @@ -0,0 +1,15 @@ +//go:build !windows + +package config + +import "os" + +// machineConfigDir has no equivalent on non-Windows hosts — community/dev +// flows use per-user paths everywhere. Returning "" signals "no machine +// path; always read/write under the user's home." +func machineConfigDir() string { return "" } + +// isElevated mirrors the Windows admin/SYSTEM check using POSIX euid==0 +// so non-Windows test runs of save() / RunConfigureNonInteractive() can +// exercise the elevated branch deterministically when relevant. +func isElevated() bool { return os.Geteuid() == 0 } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a24495d..dfbe492 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -49,8 +49,7 @@ func TestIsPlaceholder(t *testing.T) { 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") cfg := &ConfigFile{ @@ -89,8 +88,6 @@ func TestSaveAndLoad(t *testing.T) { if len(loaded.SearchDirs) != 2 { t.Errorf("search_dirs: expected 2 dirs, got %d", len(loaded.SearchDirs)) } - - _ = origConfigDir } func TestConfigFile_JSON(t *testing.T) { diff --git a/internal/config/config_windows.go b/internal/config/config_windows.go new file mode 100644 index 0000000..7456efa --- /dev/null +++ b/internal/config/config_windows.go @@ -0,0 +1,19 @@ +//go:build windows + +package config + +import "golang.org/x/sys/windows" + +// machineConfigDir is the machine-wide config location on Windows. The +// path is hardcoded (not derived from %PROGRAMDATA%) so it matches what +// the MSI WiX manifest hardcodes — keeping installer and binary in sync. +func machineConfigDir() string { + return `C:\ProgramData\StepSecurity` +} + +// isElevated reports whether the current process holds an elevated token +// (admin rights / UAC-elevated). MSI custom actions running deferred with +// Impersonate=no execute under LocalSystem, which is elevated. +func isElevated() bool { + return windows.GetCurrentProcessToken().IsElevated() +} diff --git a/packaging/windows/Product.wxs b/packaging/windows/Product.wxs new file mode 100644 index 0000000..e3b908c --- /dev/null +++ b/packaging/windows/Product.wxs @@ -0,0 +1,162 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/windows/README.md b/packaging/windows/README.md new file mode 100644 index 0000000..0512e75 --- /dev/null +++ b/packaging/windows/README.md @@ -0,0 +1,119 @@ +# MSI packaging + +This directory holds the WiX 4 manifest used to wrap the Windows binary +into a signed MSI for SCCM / Intune / Group Policy deployments. + +## Local build + +Requires **WiX 4** (cross-platform .NET tool — works on macOS / Linux / +Windows): + +```bash +dotnet tool install --global wix --version 4.0.5 +``` + +Then from the repo root: + +```bash +# Build the Windows binaries first. +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 +make build-msi-arm64 +``` + +## What the MSI does + +| Step | Mechanism | +|------|-----------| +| Drop `.exe` to `C:\Program Files\StepSecurity\` | MSI standard `InstallFiles` | +| Write tenant config to `C:\ProgramData\StepSecurity\config.json` | Custom action invokes `.exe configure --non-interactive ...` | +| Register Windows scheduled task | Custom action invokes `.exe install` (which shells `schtasks.exe`) | +| On uninstall: remove scheduled task | Custom action invokes `.exe uninstall` | + +**`powershell.exe` is never spawned.** All work flows: `msiexec` → +`stepsecurity-dev-machine-guard.exe` → `schtasks.exe`. This matters in +customer environments where EDR blocks PowerShell egress. + +## How tenants pass credentials + +Two equivalent paths — pick one in your SCCM Application's install +command: + +### Inline (simplest) + +```cmd +msiexec /i StepSecurity-DMG-x64.msi /qn ^ + CUSTOMERID="acme-corp" ^ + APIENDPOINT="https://api.stepsecurity.io" ^ + APIKEY="sk_..." ^ + SCANFREQUENCY=4 ^ + /l*v "%TEMP%\dmg-install.log" +``` + +Caveat: the API key appears in `AppEnforce.log` on every endpoint when +`/l*v` is enabled. SCCM's default logging usually keeps it out, but +verbose troubleshooting will surface it. + +### Pre-staged bootstrap file (recommended for prod) + +Drop a JSON config via GPO / Intune File preferences first: + +```json +{ + "customer_id": "acme-corp", + "api_endpoint": "https://api.stepsecurity.io", + "api_key": "sk_...", + "scan_frequency_hours": "4" +} +``` + +…at `C:\ProgramData\StepSecurity\bootstrap.json`, then deploy the MSI: + +```cmd +msiexec /i StepSecurity-DMG-x64.msi /qn ^ + BOOTSTRAPFILE="C:\ProgramData\StepSecurity\bootstrap.json" +``` + +Key never traverses the msiexec command line — survives `/l*v` logging +clean. + +## Upgrades + +Each release ships a new MSI with the **same `UpgradeCode`** and a +higher `Version`. Windows Installer treats the install as a Major +Upgrade: silent uninstall of the old version (scheduled task removed +via our `uninstall` custom action) followed by install of the new one, +atomically. SCCM admins use the **supersedence** flow to point the new +Application at the old one — no scripting required. + +The per-tenant `config.json` is **not** touched by upgrades — it lives +under `C:\ProgramData\StepSecurity\` which MSI doesn't manage. Tenants +stay configured across version bumps. + +## Detection method + +SCCM auto-derives the detection rule from the MSI's `ProductCode`. No +custom script needed. The `UpgradeCode` is stable across versions; the +`ProductCode` rotates per build (WiX generates it automatically). + +## GUIDs + +The `UpgradeCode`s in `Product.wxs` are **load-bearing constants** — +never change them. They identify the product family across all current +and future versions: + +| Platform | UpgradeCode | +|----------|-------------| +| x64 | `65AE0FC0-2070-4F40-B0CA-413637F94121` | +| arm64 | `99C4A108-6A71-4006-8AA7-F3D14DA045A9` | + +If either ever changes, every existing deployment will see the new MSI +as an unrelated product and refuse to upgrade. From 343faf0c8b840bdf420acd8f18dbbbc1e49d625e Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Thu, 21 May 2026 14:23:35 +0530 Subject: [PATCH 02/14] chore: bump version for MSI release pipeline test on fork --- internal/buildinfo/version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/buildinfo/version.go b/internal/buildinfo/version.go index b2624c5..0d469b5 100644 --- a/internal/buildinfo/version.go +++ b/internal/buildinfo/version.go @@ -3,7 +3,7 @@ package buildinfo import "fmt" const ( - Version = "1.11.1" + Version = "1.11.2-msi-test1" AgentURL = "https://github.com/step-security/dev-machine-guard" ) From 12354157866e82d0ec428bc51a6ea6dceec009ea Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Thu, 21 May 2026 14:28:31 +0530 Subject: [PATCH 03/14] ci: build MSIs on windows-latest (WiX 4 has Linux path-parsing bug) --- .github/workflows/release.yml | 177 +++++++++++++++++++++++----------- internal/buildinfo/version.go | 2 +- 2 files changed, 121 insertions(+), 58 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 061361d..f9839bd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,9 @@ jobs: contents: write id-token: write attestations: write + outputs: + version: ${{ steps.version.outputs.version }} + release_tag: ${{ steps.release.outputs.tag }} steps: - name: Harden the runner (Audit all outbound calls) @@ -85,40 +88,6 @@ jobs: - name: Install cosign uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 - - name: Build MSIs (x64 + arm64) - # WiX 4 runs on .NET, so we install it on the same ubuntu-latest - # runner. Output: dist/stepsecurity-dev-machine-guard--x64.msi - # and the arm64 counterpart. Both wrap the goreleaser-produced - # .exe and embed it via CAB compression. No PowerShell anywhere. - run: | - dotnet tool install --global wix --version 4.0.5 - export PATH="$PATH:$HOME/.dotnet/tools" - wix --version - - version="${{ steps.version.outputs.version }}" - - win_amd64_exe=$(find dist -type f -name '*.exe' -path '*windows_amd64*' | head -1) - win_arm64_exe=$(find dist -type f -name '*.exe' -path '*windows_arm64*' | head -1) - if [ ! -f "$win_amd64_exe" ] || [ ! -f "$win_arm64_exe" ]; then - echo "::error::Windows .exe artifacts not found under dist/" - find dist -type f -name '*.exe' - exit 1 - fi - - wix build packaging/windows/Product.wxs \ - -arch x64 \ - -d Arch=x64 \ - -d Version="$version" \ - -d BinaryPath="$PWD/$win_amd64_exe" \ - -out "dist/stepsecurity-dev-machine-guard-${version}-x64.msi" - - wix build packaging/windows/Product.wxs \ - -arch arm64 \ - -d Arch=arm64 \ - -d Version="$version" \ - -d BinaryPath="$PWD/$win_arm64_exe" \ - -out "dist/stepsecurity-dev-machine-guard-${version}-arm64.msi" - - name: Locate binaries and packages id: binaries run: | @@ -133,10 +102,7 @@ jobs: RPM_AMD64=$(find dist -type f -name '*-amd64.rpm' | head -1) RPM_ARM64=$(find dist -type f -name '*-arm64.rpm' | head -1) - MSI_X64=$(find dist -type f -name '*-x64.msi' | head -1) - MSI_ARM64=$(find dist -type f -name '*-arm64.msi' | head -1) - - for label in "darwin:${DARWIN}" "windows_amd64:${WIN_AMD64}" "windows_arm64:${WIN_ARM64}" "linux_amd64:${LINUX_AMD64}" "linux_arm64:${LINUX_ARM64}" "deb_amd64:${DEB_AMD64}" "deb_arm64:${DEB_ARM64}" "rpm_amd64:${RPM_AMD64}" "rpm_arm64:${RPM_ARM64}" "msi_x64:${MSI_X64}" "msi_arm64:${MSI_ARM64}"; do + for label in "darwin:${DARWIN}" "windows_amd64:${WIN_AMD64}" "windows_arm64:${WIN_ARM64}" "linux_amd64:${LINUX_AMD64}" "linux_arm64:${LINUX_ARM64}" "deb_amd64:${DEB_AMD64}" "deb_arm64:${DEB_ARM64}" "rpm_amd64:${RPM_AMD64}" "rpm_arm64:${RPM_ARM64}"; do name="${label%%:*}" path="${label#*:}" if [ -z "$path" ] || [ ! -f "$path" ]; then @@ -155,8 +121,6 @@ jobs: echo "deb_arm64=$DEB_ARM64" >> "$GITHUB_OUTPUT" echo "rpm_amd64=$RPM_AMD64" >> "$GITHUB_OUTPUT" echo "rpm_arm64=$RPM_ARM64" >> "$GITHUB_OUTPUT" - echo "msi_x64=$MSI_X64" >> "$GITHUB_OUTPUT" - echo "msi_arm64=$MSI_ARM64" >> "$GITHUB_OUTPUT" - name: Sign artifacts with Sigstore shell: bash @@ -195,19 +159,6 @@ jobs: "${{ steps.binaries.outputs.rpm_amd64 }}.bundle" sign_with_retry "${{ steps.binaries.outputs.rpm_arm64 }}" \ "${{ steps.binaries.outputs.rpm_arm64 }}.bundle" - sign_with_retry "${{ steps.binaries.outputs.msi_x64 }}" \ - "${{ steps.binaries.outputs.msi_x64 }}.bundle" - sign_with_retry "${{ steps.binaries.outputs.msi_arm64 }}" \ - "${{ steps.binaries.outputs.msi_arm64 }}.bundle" - - - name: Upload MSIs to draft release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release upload "${{ steps.release.outputs.tag }}" \ - "${{ steps.binaries.outputs.msi_x64 }}" \ - "${{ steps.binaries.outputs.msi_arm64 }}" \ - --clobber - name: Upload cosign bundles env: @@ -223,8 +174,6 @@ jobs: "${{ steps.binaries.outputs.deb_arm64 }}.bundle" \ "${{ steps.binaries.outputs.rpm_amd64 }}.bundle" \ "${{ steps.binaries.outputs.rpm_arm64 }}.bundle" \ - "${{ steps.binaries.outputs.msi_x64 }}.bundle" \ - "${{ steps.binaries.outputs.msi_arm64 }}.bundle" \ --clobber - name: Attest build provenance @@ -240,5 +189,119 @@ jobs: ${{ steps.binaries.outputs.deb_arm64 }} ${{ steps.binaries.outputs.rpm_amd64 }} ${{ steps.binaries.outputs.rpm_arm64 }} - ${{ steps.binaries.outputs.msi_x64 }} - ${{ steps.binaries.outputs.msi_arm64 }} + + build-msi: + name: Build & Sign MSIs + needs: release + runs-on: windows-latest + permissions: + contents: write + id-token: write + attestations: write + + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install WiX 4 + # WiX 4 ships as a .NET global tool. windows-latest has the .NET SDK + # preinstalled, so this resolves quickly. We pin to a known-working + # version. Linux/macOS hosts have WiX 4 issues with Directory/@Name + # path validation — we always build the MSI on Windows. + shell: pwsh + run: | + dotnet tool install --global wix --version 4.0.5 + wix --version + + - name: Download Windows .exe assets from draft release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: pwsh + run: | + $tag = "${{ needs.release.outputs.release_tag }}" + New-Item -ItemType Directory -Path dist -Force | Out-Null + # Goreleaser produces archive names like: + # stepsecurity-dev-machine-guard--windows_amd64.exe + # We download them by exact pattern to dist/. + gh release download "$tag" ` + -R "${{ github.repository }}" ` + -p "*-windows_amd64.exe" ` + -p "*-windows_arm64.exe" ` + -D dist + Get-ChildItem dist | Format-Table Name, Length + + - name: Build MSIs (x64 + arm64) + shell: pwsh + run: | + $version = "${{ needs.release.outputs.version }}" + $amd64 = Get-ChildItem dist -Filter "*-windows_amd64.exe" | Select-Object -First 1 + $arm64 = Get-ChildItem dist -Filter "*-windows_arm64.exe" | Select-Object -First 1 + if (-not $amd64 -or -not $arm64) { + Write-Error "Windows .exe assets missing under dist/" + exit 1 + } + + wix build packaging/windows/Product.wxs ` + -arch x64 ` + -d Arch=x64 ` + -d "Version=$version" ` + -d "BinaryPath=$($amd64.FullName)" ` + -out "dist/stepsecurity-dev-machine-guard-$version-x64.msi" + + wix build packaging/windows/Product.wxs ` + -arch arm64 ` + -d Arch=arm64 ` + -d "Version=$version" ` + -d "BinaryPath=$($arm64.FullName)" ` + -out "dist/stepsecurity-dev-machine-guard-$version-arm64.msi" + + Get-ChildItem dist -Filter "*.msi" | Format-Table Name, Length + + - name: Install cosign + uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 + + - name: Sign MSIs with Sigstore + shell: bash + run: | + set -euo pipefail + version="${{ needs.release.outputs.version }}" + for arch in x64 arm64; do + msi="dist/stepsecurity-dev-machine-guard-${version}-${arch}.msi" + bundle="${msi}.bundle" + for attempt in 1 2 3; do + if cosign sign-blob "$msi" --bundle "$bundle" --yes; then + echo "Signed $msi" + break + fi + echo "::warning::Sign attempt $attempt failed for $msi, retrying in 10s..." + sleep 10 + done + test -f "$bundle" || { echo "::error::Failed to sign $msi"; exit 1; } + done + + - name: Upload MSIs and bundles to draft release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + tag="${{ needs.release.outputs.release_tag }}" + version="${{ needs.release.outputs.version }}" + gh release upload "$tag" \ + "dist/stepsecurity-dev-machine-guard-${version}-x64.msi" \ + "dist/stepsecurity-dev-machine-guard-${version}-arm64.msi" \ + "dist/stepsecurity-dev-machine-guard-${version}-x64.msi.bundle" \ + "dist/stepsecurity-dev-machine-guard-${version}-arm64.msi.bundle" \ + --clobber + + - name: Attest MSI build provenance + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-path: | + dist/stepsecurity-dev-machine-guard-${{ needs.release.outputs.version }}-x64.msi + dist/stepsecurity-dev-machine-guard-${{ needs.release.outputs.version }}-arm64.msi diff --git a/internal/buildinfo/version.go b/internal/buildinfo/version.go index 0d469b5..c311fac 100644 --- a/internal/buildinfo/version.go +++ b/internal/buildinfo/version.go @@ -3,7 +3,7 @@ package buildinfo import "fmt" const ( - Version = "1.11.2-msi-test1" + Version = "1.11.2-msi-test2" AgentURL = "https://github.com/step-security/dev-machine-guard" ) From 9a3a90b55421e3e9ee636f1ae6003e37fb2160e0 Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Thu, 21 May 2026 15:45:02 +0530 Subject: [PATCH 04/14] fix(msi): switch custom actions to deferred + SetProperty CustomActionData 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. --- internal/buildinfo/version.go | 2 +- packaging/windows/Product.wxs | 84 ++++++++++++++++++++++++----------- 2 files changed, 60 insertions(+), 26 deletions(-) diff --git a/internal/buildinfo/version.go b/internal/buildinfo/version.go index c311fac..bd65f4a 100644 --- a/internal/buildinfo/version.go +++ b/internal/buildinfo/version.go @@ -3,7 +3,7 @@ package buildinfo import "fmt" const ( - Version = "1.11.2-msi-test2" + Version = "1.11.2-msi-test3" AgentURL = "https://github.com/step-security/dev-machine-guard" ) diff --git a/packaging/windows/Product.wxs b/packaging/windows/Product.wxs index e3b908c..fa4d0ce 100644 --- a/packaging/windows/Product.wxs +++ b/packaging/windows/Product.wxs @@ -85,50 +85,84 @@ ================================================================ Custom Actions - All three CAs are immediate (run in msiexec's own context, which is - elevated/SYSTEM under SCCM) so we avoid the CustomActionData dance - that deferred CAs require. ExeCommand interpolates [PROPERTY] tokens - at sequence time. Return="check" makes a non-zero exit roll back - the install transaction. + All four CAs run DEFERRED with Impersonate=no — they execute + during the install script phase (after InstallFiles physically + writes the .exe to disk) in SYSTEM context. Immediate CAs were + tried first but failed with MSI error 1721 ("program required + could not be run") because immediate CAs run during sequence + *building*, before the binary is on disk. + + Deferred CAs can't read MSI properties directly. The standard + workaround is paired SetProperty elements that populate the + CustomActionData property which the deferred CA then reads as + its command line. ================================================================ --> - + + - - - + + + + + + + + + + - + - + From c5143c87c52262b265d72d6b645e89786a0bd1cb Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Thu, 21 May 2026 16:48:54 +0530 Subject: [PATCH 06/14] fix(msi): switch to Property-attribute custom actions (MSI Type 50) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/buildinfo/version.go | 2 +- packaging/windows/Product.wxs | 41 ++++++++++++++++++----------------- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/internal/buildinfo/version.go b/internal/buildinfo/version.go index ebe1675..f3bbeeb 100644 --- a/internal/buildinfo/version.go +++ b/internal/buildinfo/version.go @@ -3,7 +3,7 @@ package buildinfo import "fmt" const ( - Version = "1.11.2-msi-test4" + Version = "1.11.2-msi-test5" AgentURL = "https://github.com/step-security/dev-machine-guard" ) diff --git a/packaging/windows/Product.wxs b/packaging/windows/Product.wxs index d36dd9c..5906109 100644 --- a/packaging/windows/Product.wxs +++ b/packaging/windows/Product.wxs @@ -99,64 +99,65 @@ ================================================================ --> - + - + From 900e7afa5dd764d818f2aa2dd950af09f4742287 Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Thu, 21 May 2026 16:54:17 +0530 Subject: [PATCH 07/14] fix(msi): use WixQuietExec from WixToolset.Util.wixext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/release.yml | 13 ++++++++----- Makefile | 4 ++++ internal/buildinfo/version.go | 2 +- packaging/windows/Product.wxs | 35 ++++++++++++++++++++++------------- 4 files changed, 35 insertions(+), 19 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f9839bd..ed19a05 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -208,15 +208,16 @@ jobs: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Install WiX 4 - # WiX 4 ships as a .NET global tool. windows-latest has the .NET SDK - # preinstalled, so this resolves quickly. We pin to a known-working - # version. Linux/macOS hosts have WiX 4 issues with Directory/@Name - # path validation — we always build the MSI on Windows. + - name: Install WiX 4 + Util extension + # WiX 4 ships as a .NET global tool. The Util extension (WixQuietExec + # and friends) is a separate NuGet package that must be added to the + # global wix tool before referencing util: namespace types. shell: pwsh run: | dotnet tool install --global wix --version 4.0.5 wix --version + wix extension add --global WixToolset.Util.wixext/4.0.5 + wix extension list --global - name: Download Windows .exe assets from draft release env: @@ -248,6 +249,7 @@ jobs: wix build packaging/windows/Product.wxs ` -arch x64 ` + -ext WixToolset.Util.wixext ` -d Arch=x64 ` -d "Version=$version" ` -d "BinaryPath=$($amd64.FullName)" ` @@ -255,6 +257,7 @@ jobs: wix build packaging/windows/Product.wxs ` -arch arm64 ` + -ext WixToolset.Util.wixext ` -d Arch=arm64 ` -d "Version=$version" ` -d "BinaryPath=$($arm64.FullName)" ` diff --git a/Makefile b/Makefile index 7942ab6..ff4d8ad 100644 --- a/Makefile +++ b/Makefile @@ -29,8 +29,10 @@ build-linux: # with whatever the binary reports as `--version`. build-msi-amd64: build-windows mkdir -p dist + wix extension add --global WixToolset.Util.wixext/4.0.5 || true wix build packaging/windows/Product.wxs \ -arch x64 \ + -ext WixToolset.Util.wixext \ -d Arch=x64 \ -d Version=$(VERSION) \ -d BinaryPath=$(CURDIR)/$(BINARY).exe \ @@ -38,8 +40,10 @@ build-msi-amd64: build-windows build-msi-arm64: build-windows-arm64 mkdir -p dist + wix extension add --global WixToolset.Util.wixext/4.0.5 || true wix build packaging/windows/Product.wxs \ -arch arm64 \ + -ext WixToolset.Util.wixext \ -d Arch=arm64 \ -d Version=$(VERSION) \ -d BinaryPath=$(CURDIR)/$(BINARY)-arm64.exe \ diff --git a/internal/buildinfo/version.go b/internal/buildinfo/version.go index f3bbeeb..8c387de 100644 --- a/internal/buildinfo/version.go +++ b/internal/buildinfo/version.go @@ -3,7 +3,7 @@ package buildinfo import "fmt" const ( - Version = "1.11.2-msi-test5" + Version = "1.11.2-msi-test6" AgentURL = "https://github.com/step-security/dev-machine-guard" ) diff --git a/packaging/windows/Product.wxs b/packaging/windows/Product.wxs index 5906109..5ae1605 100644 --- a/packaging/windows/Product.wxs +++ b/packaging/windows/Product.wxs @@ -14,7 +14,8 @@ msiexec — powershell.exe is never spawned anywhere in install/upgrade/ uninstall flows. --> - + @@ -99,35 +100,43 @@ ================================================================ --> - + From 16f869a19f280feb29487d7d991f344648f3411d Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Thu, 21 May 2026 17:04:57 +0530 Subject: [PATCH 08/14] feat(install): add --ignore-telemetry-error opt-in for MSI/SCCM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cmd/stepsecurity-dev-machine-guard/main.go | 14 ++++++++++++-- internal/buildinfo/version.go | 2 +- internal/cli/cli.go | 13 +++++++++++++ packaging/windows/Product.wxs | 2 +- 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/cmd/stepsecurity-dev-machine-guard/main.go b/cmd/stepsecurity-dev-machine-guard/main.go index 8f68d15..ba3edbd 100644 --- a/cmd/stepsecurity-dev-machine-guard/main.go +++ b/cmd/stepsecurity-dev-machine-guard/main.go @@ -204,8 +204,18 @@ func main() { } if telemetryErr != nil { - log.Error("%v", telemetryErr) - os.Exit(1) + if cfg.IgnoreTelemetryError { + // Opt-in tolerance for MSI/SCCM/Intune deployments: the + // scheduled task is already registered and will retry + // telemetry on its next firing, so a transient first-run + // network hiccup shouldn't roll back the whole install. + // Default (dev-workflow) behavior remains exit non-zero + // to surface real misconfigurations during interactive use. + log.Warn("initial telemetry failed (%v) — the scheduled task will retry on its next firing", telemetryErr) + } else { + log.Error("%v", telemetryErr) + os.Exit(1) + } } runHookStateReconcile(exec, log) diff --git a/internal/buildinfo/version.go b/internal/buildinfo/version.go index 8c387de..e57be40 100644 --- a/internal/buildinfo/version.go +++ b/internal/buildinfo/version.go @@ -3,7 +3,7 @@ package buildinfo import "fmt" const ( - Version = "1.11.2-msi-test6" + Version = "1.11.2-msi-test8" AgentURL = "https://github.com/step-security/dev-machine-guard" ) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index b6745fe..4cfa828 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -47,6 +47,14 @@ type Config struct { ConfigAPIEndpoint string // --api-endpoint ConfigAPIKey string // --api-key (also accepts env var DMG_API_KEY) ConfigScanFrequency string // --scan-frequency (hours) + + // IgnoreTelemetryError opts the `install` subcommand into treating an + // initial-telemetry POST failure as a warning rather than a fatal exit. + // Default behavior (flag absent) preserves dev-workflow ergonomics — + // a failed first telemetry surfaces misconfigurations immediately. MSI + // custom actions and other unattended orchestrators set this so a + // transient network hiccup doesn't roll back the whole install. + IgnoreTelemetryError bool } // supportedHookAgents lists the agent names accepted by `hooks --agent ` and `_hook ...`. @@ -157,6 +165,8 @@ func Parse(args []string) (*Config, error) { continue // skip the i++ at the bottom case arg == "--non-interactive": cfg.NonInteractive = true + case arg == "--ignore-telemetry-error": + cfg.IgnoreTelemetryError = true case arg == "--from-file": i++ if i >= len(args) { @@ -375,6 +385,9 @@ Non-interactive configure (for MSI / SCCM / Intune deployments): --api-endpoint URL StepSecurity backend URL --api-key KEY Authentication key (or set DMG_API_KEY env var) --scan-frequency HOURS Scheduled scan frequency + --ignore-telemetry-error On 'install', treat a failed initial telemetry POST + as a warning instead of a fatal exit (use in MSI + custom actions to avoid rollback on transient network) Configuration: Per-user config: ~/.stepsecurity/config.json diff --git a/packaging/windows/Product.wxs b/packaging/windows/Product.wxs index 5ae1605..b82f192 100644 --- a/packaging/windows/Product.wxs +++ b/packaging/windows/Product.wxs @@ -160,7 +160,7 @@ Condition="BOOTSTRAPFILE AND NOT Installed"/> From 860c0182932ededc416adc18fd72965063cf16fe Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Thu, 21 May 2026 19:30:55 +0530 Subject: [PATCH 09/14] address PR #92 review comments from Copilot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- docs/deploying-via-sccm.md | 38 ++++++++++++++++++++++++++++--- internal/buildinfo/version.go | 2 +- internal/config/config.go | 32 ++++++++++++++++++++++---- internal/config/config_other.go | 6 +++++ internal/config/config_test.go | 6 +++-- internal/config/config_windows.go | 30 +++++++++++++++++++++++- packaging/windows/README.md | 14 +++++++----- 7 files changed, 110 insertions(+), 18 deletions(-) diff --git a/docs/deploying-via-sccm.md b/docs/deploying-via-sccm.md index ff0f0ae..f963cde 100644 --- a/docs/deploying-via-sccm.md +++ b/docs/deploying-via-sccm.md @@ -24,9 +24,12 @@ install/upgrade/uninstall flows **never spawn PowerShell**. The chain is: SCCM → msiexec.exe → stepsecurity-dev-machine-guard.exe → schtasks.exe ``` -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 +Custom actions run inside `msiexec`'s process tree as **deferred** actions +in SYSTEM context (`Execute="deferred" Impersonate="no"`). Deferred is +required because immediate actions execute during MSI script-building, +before the binary is actually on disk. They invoke our binary via +WiX's `WixQuietExec` (from `WixToolset.Util.wixext`); the binary then +shells out to `schtasks.exe` for task registration. Nothing in the install path touches `powershell.exe`. ## Two ways to pass tenant credentials @@ -93,6 +96,35 @@ of `AppEnforce.log` even with verbose logging enabled. The bootstrap file can be ACL-restricted to SYSTEM + Administrators if you want defense-in-depth. +### A note on the persisted `config.json` and multi-user machines + +Either deployment path above writes the resolved config — including +`api_key` in plaintext — to `C:\ProgramData\StepSecurity\config.json` +on each endpoint. This is required because the scheduled task runs +under the **logged-in user's** context (see "Why MSI and not a script" +above for rationale) and needs to read the config at scan time. + +The installer hardens the file's ACL on write to: + +- `NT AUTHORITY\SYSTEM` — Full Control +- `BUILTIN\Administrators` — Full Control +- `BUILTIN\Users` — Read + +Inheritance is disabled. So any logged-in user CAN read the API key +(necessary for the scanner), but cannot modify it. On a **single-user +developer workstation** this is the expected security posture. + +On a **shared multi-user machine** (e.g., a kiosk, a lab workstation, +RDS host) this means every interactive user can read each others' +tenant API key. If that's not acceptable for your environment: + +- Use the `BOOTSTRAPFILE` path and tighten the bootstrap file's ACL + yourself (the installer only manages `config.json`'s ACL) +- Or scope deployment to single-user machines via SCCM collection + requirements +- Or open an issue if you'd like first-class support for DPAPI-encrypted + storage; we'll prioritize based on demand + ## SCCM Application setup, step by step 1. **Software Library → Applications → Create Application** diff --git a/internal/buildinfo/version.go b/internal/buildinfo/version.go index e57be40..fdb7416 100644 --- a/internal/buildinfo/version.go +++ b/internal/buildinfo/version.go @@ -3,7 +3,7 @@ package buildinfo import "fmt" const ( - Version = "1.11.2-msi-test8" + Version = "1.11.2" AgentURL = "https://github.com/step-security/dev-machine-guard" ) diff --git a/internal/config/config.go b/internal/config/config.go index 0376a0a..61a6fb5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -360,12 +360,19 @@ func loadExisting() *ConfigFile { func save(cfg *ConfigFile) error { dir := writeConfigDir() - // 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. + + // File-mode bits below ARE meaningful on POSIX (per-user community installs + // on macOS/Linux); on Windows they're ignored by the OS — access is + // controlled exclusively by ACLs. We set the mode for POSIX correctness + // and harden Windows access separately via hardenMachineConfigACL below. dirMode := os.FileMode(0o700) fileMode := os.FileMode(0o600) - if isElevated() && machineConfigDir() != "" && dir == machineConfigDir() { + machineWide := isElevated() && machineConfigDir() != "" && dir == machineConfigDir() + if machineWide { + // Machine-wide install: the scheduled task fires under a less-privileged + // logged-in user account (see schtasks.go's /ru INTERACTIVE), so the + // file must be READABLE by that user — but should not be writable by + // non-admins. hardenMachineConfigACL handles the Windows-specific ACL. dirMode = 0o755 fileMode = 0o644 } @@ -378,7 +385,22 @@ func save(cfg *ConfigFile) error { return err } - return os.WriteFile(filepath.Join(dir, "config.json"), data, fileMode) + configPath := filepath.Join(dir, "config.json") + if err := os.WriteFile(configPath, data, fileMode); err != nil { + return err + } + + if machineWide { + // Best-effort ACL hardening. Failure does not block the install — the + // config is still functional, just inheriting default ProgramData ACLs. + // Note: api_key persists in plaintext on disk. On multi-user dev + // machines this is a known tradeoff, documented in deploying-via-sccm.md. + // Customers needing stronger isolation should use the --from-file + // bootstrap pattern and lock down the bootstrap file separately. + _ = hardenMachineConfigACL(configPath) + } + + return nil } // ShowConfigure prints the current configuration to stdout. diff --git a/internal/config/config_other.go b/internal/config/config_other.go index c8d17dc..52d3dda 100644 --- a/internal/config/config_other.go +++ b/internal/config/config_other.go @@ -13,3 +13,9 @@ func machineConfigDir() string { return "" } // so non-Windows test runs of save() / RunConfigureNonInteractive() can // exercise the elevated branch deterministically when relevant. func isElevated() bool { return os.Geteuid() == 0 } + +// hardenMachineConfigACL is a no-op on non-Windows: file-mode bits we set +// in save() are the actual access control on POSIX hosts, so there is no +// equivalent ACL step. machineConfigDir() returns "" off-Windows, so the +// caller in save() only invokes this from the Windows code path in practice. +func hardenMachineConfigACL(path string) error { return nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index dfbe492..f9c3886 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -47,9 +47,11 @@ func TestIsPlaceholder(t *testing.T) { } func TestSaveAndLoad(t *testing.T) { - // Use a temp directory + // This test exercises the ConfigFile JSON marshal/unmarshal contract + // against a plain temp file — it does NOT cover save()/ConfigFilePath(), + // which depend on $HOME resolution and (on Windows) elevation checks. + // See config_nonint_test.go for tests that go through those helpers. tmpDir := t.TempDir() - // Override the config dir resolution for this test by redirecting HOME. tmpConfigPath := filepath.Join(tmpDir, "config.json") cfg := &ConfigFile{ diff --git a/internal/config/config_windows.go b/internal/config/config_windows.go index 7456efa..04e0433 100644 --- a/internal/config/config_windows.go +++ b/internal/config/config_windows.go @@ -2,7 +2,11 @@ package config -import "golang.org/x/sys/windows" +import ( + "os/exec" + + "golang.org/x/sys/windows" +) // machineConfigDir is the machine-wide config location on Windows. The // path is hardcoded (not derived from %PROGRAMDATA%) so it matches what @@ -17,3 +21,27 @@ func machineConfigDir() string { func isElevated() bool { return windows.GetCurrentProcessToken().IsElevated() } + +// hardenMachineConfigACL locks down the machine-wide config.json with an +// explicit ACL: SYSTEM + Administrators get Full; BUILTIN\Users gets Read +// (so the scheduled task running as the logged-in user can still load it), +// inheritance is disabled, and the file is not writable by non-admins. +// POSIX file-mode bits we set in save() don't actually enforce anything on +// Windows; this is what does. Mirrors the icacls pattern used in +// internal/schtasks/schtasks.go for the agent.log directory. +// +// 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 { + args := []string{ + path, + "/inheritance:r", // remove inherited ACEs + "/grant:r", "*S-1-5-18:F", // NT AUTHORITY\SYSTEM = Full + "/grant:r", "*S-1-5-32-544:F", // BUILTIN\Administrators = Full + "/grant:r", "*S-1-5-32-545:R", // BUILTIN\Users = Read + "/Q", + } + return exec.Command("icacls", args...).Run() +} diff --git a/packaging/windows/README.md b/packaging/windows/README.md index 0512e75..9545151 100644 --- a/packaging/windows/README.md +++ b/packaging/windows/README.md @@ -15,20 +15,22 @@ dotnet tool install --global wix --version 4.0.5 Then from the repo root: ```bash -# Build the Windows binaries first. -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 +# Build the x64 MSI (the build-windows-amd64 .exe is built as a dependency). +make build-msi-amd64 +# produces dist/stepsecurity-dev-machine-guard--x64.msi ``` For arm64 (less common but supported): ```bash -GOARCH=arm64 make build-windows make build-msi-arm64 +# produces dist/stepsecurity-dev-machine-guard--arm64.msi ``` +(Each MSI target has its own arch-specific `build-windows*` prerequisite — the +top-level `build-windows` target is hardcoded amd64 only, so don't try +`GOARCH=arm64 make build-windows`. Use the dedicated targets above.) + ## What the MSI does | Step | Mechanism | From fe8f42ca0b62e09520c1d40b80e0814717a42f0a Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Thu, 21 May 2026 19:48:06 +0530 Subject: [PATCH 10/14] ci: go fmt Signed-off-by: Swarit Pandey --- internal/config/config_windows.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/config/config_windows.go b/internal/config/config_windows.go index 04e0433..d49852d 100644 --- a/internal/config/config_windows.go +++ b/internal/config/config_windows.go @@ -37,10 +37,10 @@ func isElevated() bool { func hardenMachineConfigACL(path string) error { args := []string{ path, - "/inheritance:r", // remove inherited ACEs - "/grant:r", "*S-1-5-18:F", // NT AUTHORITY\SYSTEM = Full - "/grant:r", "*S-1-5-32-544:F", // BUILTIN\Administrators = Full - "/grant:r", "*S-1-5-32-545:R", // BUILTIN\Users = Read + "/inheritance:r", // remove inherited ACEs + "/grant:r", "*S-1-5-18:F", // NT AUTHORITY\SYSTEM = Full + "/grant:r", "*S-1-5-32-544:F", // BUILTIN\Administrators = Full + "/grant:r", "*S-1-5-32-545:R", // BUILTIN\Users = Read "/Q", } return exec.Command("icacls", args...).Run() From b334bb6c87996076153824d3fcabff429fd0471c Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Thu, 21 May 2026 20:02:48 +0530 Subject: [PATCH 11/14] ci: add MSI build/install/verify/uninstall smoke test on every PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/msi-smoke.yml | 229 ++++++++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 .github/workflows/msi-smoke.yml diff --git a/.github/workflows/msi-smoke.yml b/.github/workflows/msi-smoke.yml new file mode 100644 index 0000000..91e4ebd --- /dev/null +++ b/.github/workflows/msi-smoke.yml @@ -0,0 +1,229 @@ +name: MSI Smoke Test + +# Runs the full Windows MSI flow (build → install → verify → uninstall) +# on a fresh windows-latest runner. Catches breakage in the WiX manifest, +# the deferred-CA wiring, the configure --non-interactive path, or the +# icacls hardening — all of which only surface on a real install. +# +# Path-filtered so it doesn't run on doc-only PRs. + +on: + pull_request: + branches: [main] + paths: + - 'cmd/**' + - 'internal/**' + - 'packaging/windows/**' + - 'Makefile' + - '.github/workflows/msi-smoke.yml' + push: + branches: [main] + paths: + - 'cmd/**' + - 'internal/**' + - 'packaging/windows/**' + - 'Makefile' + - '.github/workflows/msi-smoke.yml' + +permissions: + contents: read + +jobs: + msi-smoke: + name: Build, install, verify, uninstall + runs-on: windows-latest + timeout-minutes: 15 + + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + go-version-file: go.mod + + - name: Install WiX 4 + Util extension + shell: pwsh + run: | + dotnet tool install --global wix --version 4.0.5 + wix --version + wix extension add --global WixToolset.Util.wixext/4.0.5 + + - name: Build Windows .exe + shell: pwsh + run: | + $env:GOOS = "windows" + $env:GOARCH = "amd64" + $env:CGO_ENABLED = "0" + go build -trimpath -ldflags "-s -w" ` + -o "$PWD\dmg-smoke.exe" ` + ./cmd/stepsecurity-dev-machine-guard + Get-Item "$PWD\dmg-smoke.exe" | Select-Object Name, Length + + - name: Build MSI + shell: pwsh + run: | + # Read Version from internal/buildinfo/version.go so the MSI + # Package Version matches what the binary reports. + $vline = Select-String -Path internal/buildinfo/version.go ` + -Pattern 'Version\s*=\s*"([^"]+)"' | Select-Object -First 1 + $version = $vline.Matches[0].Groups[1].Value + Write-Host "Building MSI with Package Version: $version" + + New-Item -ItemType Directory -Force -Path dist | Out-Null + wix build packaging/windows/Product.wxs ` + -arch x64 ` + -ext WixToolset.Util.wixext ` + -d Arch=x64 ` + -d "Version=$version" ` + -d "BinaryPath=$PWD\dmg-smoke.exe" ` + -out "dist\dmg-smoke.msi" + Get-Item "dist\dmg-smoke.msi" | Select-Object Name, Length + + - name: Install MSI with synthetic tenant config + shell: pwsh + run: | + # Synthetic config: real-looking values but pointed at an + # invalid endpoint. The MSI's install CA passes + # --ignore-telemetry-error so initial telemetry POST failure + # doesn't roll back the install. Exit 0 expected. + $p = Start-Process -FilePath "msiexec.exe" -ArgumentList @( + "/i", "dist\dmg-smoke.msi", "/qn", + "CUSTOMERID=ci-smoke", + "APIENDPOINT=https://api.invalid.example", + "APIKEY=ci-test-fake-api-key", + "SCANFREQUENCY=4", + "/l*v", "dist\dmg-install.log" + ) -Wait -PassThru + Write-Host "msiexec exit: $($p.ExitCode)" + if ($p.ExitCode -ne 0) { + Write-Host "::error::msiexec install failed with exit $($p.ExitCode)" + Write-Host "--- last 80 lines of install log ---" + Get-Content dist\dmg-install.log -Tail 80 + exit 1 + } + + - name: Verify install artifacts + shell: pwsh + run: | + $bin = "C:\Program Files\StepSecurity\stepsecurity-dev-machine-guard.exe" + $cfg = "C:\ProgramData\StepSecurity\config.json" + $task = "StepSecurity Dev Machine Guard" + + $checks = @() + + # 1. Binary on disk + if (Test-Path $bin) { + Write-Host "[OK] Binary present at $bin" + $checks += $true + } else { + Write-Host "::error::Binary missing: $bin" + $checks += $false + } + + # 2. Machine-wide config exists and contains our values + if (Test-Path $cfg) { + $content = Get-Content $cfg -Raw + if ($content -match '"customer_id"\s*:\s*"ci-smoke"' -and + $content -match '"api_key"\s*:\s*"ci-test-fake-api-key"') { + Write-Host "[OK] Config written with expected values" + $checks += $true + } else { + Write-Host "::error::Config content unexpected:" + Write-Host $content + $checks += $false + } + } else { + Write-Host "::error::Config missing: $cfg" + $checks += $false + } + + # 3. ACL hardening on config.json — Users should be Read only, + # Administrators + SYSTEM should be Full Control, inheritance disabled. + $acl = & icacls $cfg | Out-String + $aclOk = $true + if ($acl -notmatch 'BUILTIN\\Users:\(R\)') { $aclOk = $false; Write-Host "::error::Users(R) ACE missing" } + if ($acl -notmatch 'BUILTIN\\Administrators:\(F\)') { $aclOk = $false; Write-Host "::error::Administrators(F) ACE missing" } + if ($acl -notmatch 'NT AUTHORITY\\SYSTEM:\(F\)') { $aclOk = $false; Write-Host "::error::SYSTEM(F) ACE missing" } + if ($aclOk) { + Write-Host "[OK] config.json ACL hardened (Users:R, Admins:F, SYSTEM:F)" + $checks += $true + } else { + Write-Host "::error::ACL not as expected:" + Write-Host $acl + $checks += $false + } + + # 4. Scheduled task registered and runs as INTERACTIVE + $taskInfo = schtasks /query /tn $task /v /fo LIST 2>&1 + if ($LASTEXITCODE -eq 0) { + $runAs = ($taskInfo | Select-String -Pattern '^\s*Run As User:').ToString() + if ($runAs -match 'INTERACTIVE') { + Write-Host "[OK] Scheduled task registered, Run As User: INTERACTIVE" + $checks += $true + } else { + Write-Host "::error::Run As User wrong: $runAs" + $checks += $false + } + } else { + Write-Host "::error::schtasks /query failed: $taskInfo" + $checks += $false + } + + if ($checks -contains $false) { + Write-Host "::error::One or more install verifications failed" + exit 1 + } + Write-Host "All install-time verifications passed." + + - name: Uninstall MSI + if: always() + shell: pwsh + run: | + if (Test-Path "dist\dmg-smoke.msi") { + $p = Start-Process -FilePath "msiexec.exe" -ArgumentList @( + "/x", "dist\dmg-smoke.msi", "/qn", + "/l*v", "dist\dmg-uninstall.log" + ) -Wait -PassThru + Write-Host "msiexec /x exit: $($p.ExitCode)" + } + + - name: Verify uninstall cleanup + shell: pwsh + run: | + $task = "StepSecurity Dev Machine Guard" + + # Binary should be gone + if (Test-Path "C:\Program Files\StepSecurity\stepsecurity-dev-machine-guard.exe") { + Write-Host "::error::Binary still present after uninstall" + exit 1 + } + Write-Host "[OK] Binary removed" + + # Scheduled task should be gone + schtasks /query /tn $task 2>&1 | Out-Null + if ($LASTEXITCODE -eq 0) { + Write-Host "::error::Scheduled task still registered after uninstall" + exit 1 + } + Write-Host "[OK] Scheduled task removed" + + # Note: C:\ProgramData\StepSecurity\config.json intentionally persists + # across uninstall — it's tenant config, not MSI-managed payload. This + # mirrors the documented behavior in docs/deploying-via-sccm.md. + + - name: Upload install logs on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: msi-smoke-logs + path: | + dist/dmg-install.log + dist/dmg-uninstall.log + if-no-files-found: ignore From 5e8272800c4b5a64e0da05e9f82282bedd4e870b Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Thu, 21 May 2026 20:10:05 +0530 Subject: [PATCH 12/14] ci(msi-smoke): explicit exit 0 after uninstall cleanup checks 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. --- .github/workflows/msi-smoke.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/msi-smoke.yml b/.github/workflows/msi-smoke.yml index 91e4ebd..9570bae 100644 --- a/.github/workflows/msi-smoke.yml +++ b/.github/workflows/msi-smoke.yml @@ -206,9 +206,14 @@ jobs: } Write-Host "[OK] Binary removed" - # Scheduled task should be gone + # Scheduled task should be gone. Capture the exit code into a + # local — PowerShell uses $LASTEXITCODE from the last native call + # as the script's exit code, so we MUST clear it (via 'exit 0' + # below) once we've made our decision, otherwise the step is + # marked failed despite all checks passing. schtasks /query /tn $task 2>&1 | Out-Null - if ($LASTEXITCODE -eq 0) { + $schtasksExit = $LASTEXITCODE + if ($schtasksExit -eq 0) { Write-Host "::error::Scheduled task still registered after uninstall" exit 1 } @@ -218,6 +223,8 @@ jobs: # across uninstall — it's tenant config, not MSI-managed payload. This # mirrors the documented behavior in docs/deploying-via-sccm.md. + exit 0 + - name: Upload install logs on failure if: failure() uses: actions/upload-artifact@v4 From 40ca150ae3ad2778eef3fd0ba299ae732142eb83 Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Thu, 21 May 2026 21:01:07 +0530 Subject: [PATCH 13/14] docs: tighten MSI deployment + packaging guides * Stale '1.8.2' version baked into examples replaced with placeholders so they don't drift each release. * Be precise about what 'signed' means: the Sigstore bundle proves build provenance; it is NOT Authenticode (Windows publisher signing). Note Authenticode as a tracked roadmap item so the omission isn't taken as 'we forgot'. * Drop the 'customer environments we built this for' framing in favor of neutral 'many enterprise environments block PowerShell egress'. * packaging/windows/README.md: correct the WiX 4 cross-platform claim - the 4.0.5 release we pin has Linux path-handling bugs that break our manifest, so the supported build host is Windows (which is also what the release workflow uses). Add explicit Util-extension install step since the manifest's WixQuietExec needs it. * Fix the wrong reference name 'StepSecurity-DMG-x64.msi' -> match the actual artifact name produced by the build. --- docs/deploying-via-sccm.md | 43 +++++++++++++++++++++---------------- packaging/windows/README.md | 23 ++++++++++++++------ 2 files changed, 41 insertions(+), 25 deletions(-) diff --git a/docs/deploying-via-sccm.md b/docs/deploying-via-sccm.md index f963cde..9a75109 100644 --- a/docs/deploying-via-sccm.md +++ b/docs/deploying-via-sccm.md @@ -9,16 +9,17 @@ SCCM, now part of Microsoft Intune family as MEMCM / ConfigMgr). - `stepsecurity-dev-machine-guard--x64.msi` (Windows on Intel/AMD) - `stepsecurity-dev-machine-guard--arm64.msi` (Windows on ARM) -Both are signed Windows Installer packages. SCCM consumes them natively -as **Application** deployment type "Windows Installer (`*.msi`)". Detection -rule and uninstall command are auto-derived from the MSI `ProductCode` -— no scripting required on your side. +SCCM consumes them natively as **Application** deployment type "Windows +Installer (`*.msi`)". Detection rule and uninstall command are auto-derived +from the MSI `ProductCode` — no scripting required on your side. Each MSI +ships with a Sigstore (cosign) bundle alongside it for supply-chain +verification (see [Signature verification](#signature-verification) below). ## Why MSI and not a script -The customer environments we built this for typically have an EDR rule -that blocks `powershell.exe` from making outbound network calls. Our MSI -install/upgrade/uninstall flows **never spawn PowerShell**. The chain is: +Many enterprise environments block PowerShell from making outbound +network calls via EDR. Our MSI install/upgrade/uninstall flows **never +spawn PowerShell**. The chain is: ``` SCCM → msiexec.exe → stepsecurity-dev-machine-guard.exe → schtasks.exe @@ -46,7 +47,7 @@ install path touches `powershell.exe`. Use this in the SCCM Application's **Installation program** field: ```cmd -msiexec /i "stepsecurity-dev-machine-guard-1.8.2-x64.msi" /qn ^ +msiexec /i "stepsecurity-dev-machine-guard--x64.msi" /qn ^ CUSTOMERID="acme-corp" ^ APIENDPOINT="https://api.stepsecurity.io" ^ APIKEY="sk_live_xxxxxxxxxxxxxxxx" ^ @@ -86,7 +87,7 @@ Contents: that path: ```cmd -msiexec /i "stepsecurity-dev-machine-guard-1.8.2-x64.msi" /qn ^ +msiexec /i "stepsecurity-dev-machine-guard--x64.msi" /qn ^ BOOTSTRAPFILE="C:\ProgramData\StepSecurity\bootstrap.json" ^ /l*v "C:\Windows\Temp\dmg-install.log" ``` @@ -122,8 +123,8 @@ tenant API key. If that's not acceptable for your environment: yourself (the installer only manages `config.json`'s ACL) - Or scope deployment to single-user machines via SCCM collection requirements -- Or open an issue if you'd like first-class support for DPAPI-encrypted - storage; we'll prioritize based on demand +- DPAPI-backed storage is on our roadmap; open a feature request to + register interest ## SCCM Application setup, step by step @@ -131,7 +132,7 @@ tenant API key. If that's not acceptable for your environment: 2. **Manually specify the application information**: - Name: `StepSecurity Dev Machine Guard` - Publisher: `StepSecurity` - - Software version: matches MSI (e.g. `1.8.2`) + - Software version: matches the MSI you're deploying (e.g. `1.11.2`) 3. **Add Deployment Type** → **Windows Installer (`*.msi`)** 4. **Content**: point at the `.msi` file on a share that the Distribution Points can pull from @@ -230,17 +231,23 @@ C:\ProgramData\StepSecurity\agent.error.log ## Signature verification -Each MSI release is signed via Sigstore and the bundle is published next -to the artifact. To verify before deploying to your fleet: +Each MSI release ships with a **Sigstore (cosign) bundle** for provenance +— it proves the artifact was built by this repo's GitHub Actions release +workflow from a tagged commit, with no out-of-band tampering. To verify +before deploying to your fleet: ```bash # In a Linux/macOS environment with cosign installed cosign verify-blob \ - --bundle stepsecurity-dev-machine-guard-1.8.2-x64.msi.bundle \ + --bundle stepsecurity-dev-machine-guard--x64.msi.bundle \ --certificate-identity-regexp 'https://github.com/step-security/dev-machine-guard/.*' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ - stepsecurity-dev-machine-guard-1.8.2-x64.msi + stepsecurity-dev-machine-guard--x64.msi ``` -A passing verification confirms the MSI was built by our GitHub release -workflow from a tagged commit in this repo. +> **Authenticode (Windows publisher signature)**: not yet applied. The +> Sigstore bundle above is build-provenance signing, which is a different +> system from Authenticode (the certificate-based signature Windows checks +> in UAC / SmartScreen / WDAC). If your environment requires +> Authenticode-signed installers as a deployment precondition, please open +> an issue — it's a tracked roadmap item. diff --git a/packaging/windows/README.md b/packaging/windows/README.md index 9545151..e1c8b49 100644 --- a/packaging/windows/README.md +++ b/packaging/windows/README.md @@ -1,17 +1,25 @@ # MSI packaging This directory holds the WiX 4 manifest used to wrap the Windows binary -into a signed MSI for SCCM / Intune / Group Policy deployments. +into an MSI for SCCM / Intune / Group Policy deployments. ## Local build -Requires **WiX 4** (cross-platform .NET tool — works on macOS / Linux / -Windows): +Requires **WiX 4** plus the `WixToolset.Util.wixext` extension (the +manifest uses `WixQuietExec` from the Util extension to drive custom +actions in deferred context): ```bash dotnet tool install --global wix --version 4.0.5 +wix extension add --global WixToolset.Util.wixext/4.0.5 ``` +WiX 4 nominally supports macOS / Linux as well as Windows, but at the +4.0.5 version we pin, the non-Windows hosts have bugs in path-handling +that break our manifest (Directory/@Name validation). The release +workflow in this repo runs the MSI build on `windows-latest` for that +reason; local development on Windows is the supported path. + Then from the repo root: ```bash @@ -41,8 +49,9 @@ top-level `build-windows` target is hardcoded amd64 only, so don't try | On uninstall: remove scheduled task | Custom action invokes `.exe uninstall` | **`powershell.exe` is never spawned.** All work flows: `msiexec` → -`stepsecurity-dev-machine-guard.exe` → `schtasks.exe`. This matters in -customer environments where EDR blocks PowerShell egress. +WiX's `WixQuietExec` (Util extension) → `stepsecurity-dev-machine-guard.exe` +→ `schtasks.exe`. This matters in environments where EDR blocks +PowerShell egress — a common enterprise posture. ## How tenants pass credentials @@ -52,7 +61,7 @@ command: ### Inline (simplest) ```cmd -msiexec /i StepSecurity-DMG-x64.msi /qn ^ +msiexec /i stepsecurity-dev-machine-guard--x64.msi /qn ^ CUSTOMERID="acme-corp" ^ APIENDPOINT="https://api.stepsecurity.io" ^ APIKEY="sk_..." ^ @@ -80,7 +89,7 @@ Drop a JSON config via GPO / Intune File preferences first: …at `C:\ProgramData\StepSecurity\bootstrap.json`, then deploy the MSI: ```cmd -msiexec /i StepSecurity-DMG-x64.msi /qn ^ +msiexec /i stepsecurity-dev-machine-guard--x64.msi /qn ^ BOOTSTRAPFILE="C:\ProgramData\StepSecurity\bootstrap.json" ``` From 785d6b86a0dfffa23b85272c9ebc07ad8e7c18e3 Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Thu, 21 May 2026 21:13:21 +0530 Subject: [PATCH 14/14] address remaining Copilot review comments on PR #92 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * internal/config/config_nonint_test.go: fix stale comment that referred to 'configDir resolution' — that helper was renamed during the multi-machine-wide refactor. Comment now points at the actual current helpers (readConfigDir / writeConfigDir). * internal/config/config_windows.go: hardenMachineConfigACL now actually logs icacls failures to stderr (the WixQuietExec custom action captures stderr into the MSI install log, so SCCM admins see the warning when troubleshooting). Previously the comment claimed 'logged' but the caller discarded the error — code and comment now agree. * packaging/windows/Product.wxs: RunUninstallScheduledTask uses Return="check" instead of Return="ignore". The binary's Uninstall() already returns nil when the task is absent (see internal/schtasks/schtasks.go:85-88), so 'check' only surfaces genuine failures — better signal to SCCM and to the customer's troubleshooting flow. * Makefile: build-msi-{amd64,arm64} previously used 'wix extension add ... || true' which swallowed real failures (NuGet outage etc.). Switch to an idempotent check-then-add pattern: skip if extension is already installed, otherwise add and propagate any error. --- Makefile | 6 ++++-- internal/config/config_nonint_test.go | 6 +++--- internal/config/config_windows.go | 17 ++++++++++++++--- packaging/windows/Product.wxs | 2 +- 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/Makefile b/Makefile index ff4d8ad..52aaf97 100644 --- a/Makefile +++ b/Makefile @@ -29,7 +29,8 @@ build-linux: # with whatever the binary reports as `--version`. build-msi-amd64: build-windows mkdir -p dist - wix extension add --global WixToolset.Util.wixext/4.0.5 || true + @wix extension list --global 2>/dev/null | grep -q "WixToolset.Util.wixext" || \ + wix extension add --global WixToolset.Util.wixext/4.0.5 wix build packaging/windows/Product.wxs \ -arch x64 \ -ext WixToolset.Util.wixext \ @@ -40,7 +41,8 @@ build-msi-amd64: build-windows build-msi-arm64: build-windows-arm64 mkdir -p dist - wix extension add --global WixToolset.Util.wixext/4.0.5 || true + @wix extension list --global 2>/dev/null | grep -q "WixToolset.Util.wixext" || \ + wix extension add --global WixToolset.Util.wixext/4.0.5 wix build packaging/windows/Product.wxs \ -arch arm64 \ -ext WixToolset.Util.wixext \ diff --git a/internal/config/config_nonint_test.go b/internal/config/config_nonint_test.go index 578a28b..57b4e3f 100644 --- a/internal/config/config_nonint_test.go +++ b/internal/config/config_nonint_test.go @@ -9,9 +9,9 @@ import ( ) // withHome redirects HOME (and USERPROFILE on Windows) so save/load operate -// on a clean per-test directory. configDir resolution falls back to -// userConfigDir when no machine-wide config exists, which is exactly the -// path this exercises on non-root test runs. +// on a clean per-test directory. readConfigDir / writeConfigDir fall back +// to userConfigDir when no machine-wide config exists, which is exactly +// the path this exercises on non-root test runs. func withHome(t *testing.T) string { t.Helper() dir := t.TempDir() diff --git a/internal/config/config_windows.go b/internal/config/config_windows.go index d49852d..9696576 100644 --- a/internal/config/config_windows.go +++ b/internal/config/config_windows.go @@ -3,6 +3,8 @@ package config import ( + "fmt" + "os" "os/exec" "golang.org/x/sys/windows" @@ -30,8 +32,11 @@ func isElevated() bool { // Windows; this is what does. Mirrors the icacls pattern used in // internal/schtasks/schtasks.go for the agent.log directory. // -// Best-effort: a non-zero icacls exit is logged but does not fail the -// configure call — the config is still functional with default inherited +// Best-effort: on failure we emit a warning to stderr (which the MSI's +// WixQuietExec custom action captures into the install log, making it +// visible to SCCM admins during troubleshooting) AND return the error so +// callers MAY surface it further if they choose. The install does not +// abort — 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 { @@ -43,5 +48,11 @@ func hardenMachineConfigACL(path string) error { "/grant:r", "*S-1-5-32-545:R", // BUILTIN\Users = Read "/Q", } - return exec.Command("icacls", args...).Run() + output, err := exec.Command("icacls", args...).CombinedOutput() + if err != nil { + fmt.Fprintf(os.Stderr, + "warning: icacls hardening of %q failed: %v\nicacls output:\n%s\n", + path, err, output) + } + return err } diff --git a/packaging/windows/Product.wxs b/packaging/windows/Product.wxs index b82f192..a763dbe 100644 --- a/packaging/windows/Product.wxs +++ b/packaging/windows/Product.wxs @@ -139,7 +139,7 @@ DllEntry="WixQuietExec64" Execute="deferred" Impersonate="no" - Return="ignore"/> + Return="check"/>