Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
236 changes: 236 additions & 0 deletions .github/workflows/msi-smoke.yml
Original file line number Diff line number Diff line change
@@ -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
123 changes: 123 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -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-<version>-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
Loading
Loading