diff --git a/.github/workflows/msi-smoke.yml b/.github/workflows/msi-smoke.yml new file mode 100644 index 0000000..9570bae --- /dev/null +++ b/.github/workflows/msi-smoke.yml @@ -0,0 +1,236 @@ +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. 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 + $schtasksExit = $LASTEXITCODE + if ($schtasksExit -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. + + exit 0 + + - 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c194278..ed19a05 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) @@ -156,6 +159,7 @@ jobs: "${{ steps.binaries.outputs.rpm_amd64 }}.bundle" sign_with_retry "${{ steps.binaries.outputs.rpm_arm64 }}" \ "${{ steps.binaries.outputs.rpm_arm64 }}.bundle" + - name: Upload cosign bundles env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -185,3 +189,122 @@ jobs: ${{ steps.binaries.outputs.deb_arm64 }} ${{ steps.binaries.outputs.rpm_amd64 }} ${{ steps.binaries.outputs.rpm_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 + 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: + 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 ` + -ext WixToolset.Util.wixext ` + -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 ` + -ext WixToolset.Util.wixext ` + -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/Makefile b/Makefile index 82ed934..52aaf97 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,40 @@ 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 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 \ + -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 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 \ + -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 +61,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..ba3edbd 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": @@ -175,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/docs/deploying-via-sccm.md b/docs/deploying-via-sccm.md new file mode 100644 index 0000000..9a75109 --- /dev/null +++ b/docs/deploying-via-sccm.md @@ -0,0 +1,253 @@ +# 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) + +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 + +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 +``` + +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 + +| | **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--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--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. + +### 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 +- DPAPI-backed storage is on our roadmap; open a feature request to + register interest + +## 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 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 +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 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--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--x64.msi +``` + +> **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/internal/buildinfo/version.go b/internal/buildinfo/version.go index b2624c5..fdb7416 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" AgentURL = "https://github.com/step-security/dev-machine-guard" ) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index e47f122..4cfa828 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -35,6 +35,26 @@ 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) + + // 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 ...`. @@ -143,6 +163,50 @@ func Parse(args []string) (*Config, error) { i++ } 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) { + 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 +378,21 @@ 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 + --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: - 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..61a6fb5 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,24 @@ func loadExisting() *ConfigFile { } func save(cfg *ConfigFile) error { - dir := configDir() - if err := os.MkdirAll(dir, 0o700); err != nil { + dir := writeConfigDir() + + // 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) + 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 + } + if err := os.MkdirAll(dir, dirMode); err != nil { return err } @@ -328,7 +385,22 @@ func save(cfg *ConfigFile) error { return err } - return os.WriteFile(ConfigFilePath(), data, 0o600) + 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. @@ -425,3 +497,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..57b4e3f --- /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. 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() + 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..52d3dda --- /dev/null +++ b/internal/config/config_other.go @@ -0,0 +1,21 @@ +//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 } + +// 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 a24495d..f9c3886 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -47,10 +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() - origConfigDir := configDir - // Override configDir for test tmpConfigPath := filepath.Join(tmpDir, "config.json") cfg := &ConfigFile{ @@ -89,8 +90,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..9696576 --- /dev/null +++ b/internal/config/config_windows.go @@ -0,0 +1,58 @@ +//go:build windows + +package config + +import ( + "fmt" + "os" + "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 +// 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() +} + +// 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: 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 { + 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", + } + 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 new file mode 100644 index 0000000..a763dbe --- /dev/null +++ b/packaging/windows/Product.wxs @@ -0,0 +1,204 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/windows/README.md b/packaging/windows/README.md new file mode 100644 index 0000000..e1c8b49 --- /dev/null +++ b/packaging/windows/README.md @@ -0,0 +1,130 @@ +# MSI packaging + +This directory holds the WiX 4 manifest used to wrap the Windows binary +into an MSI for SCCM / Intune / Group Policy deployments. + +## Local build + +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 +# 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 +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 | +|------|-----------| +| 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` → +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 + +Two equivalent paths — pick one in your SCCM Application's install +command: + +### Inline (simplest) + +```cmd +msiexec /i stepsecurity-dev-machine-guard--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-dev-machine-guard--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.