Skip to content

Commit 7915016

Browse files
authored
Merge branch 'main' into sm/update
2 parents 078df68 + cb9abe5 commit 7915016

31 files changed

Lines changed: 2927 additions & 196 deletions

File tree

.github/workflows/msi-smoke.yml

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
name: MSI Smoke Test
2+
3+
# Runs the full Windows MSI flow (build → install → verify → uninstall)
4+
# on a fresh windows-latest runner. Catches breakage in the WiX manifest,
5+
# the deferred-CA wiring, the configure --non-interactive path, or the
6+
# icacls hardening — all of which only surface on a real install.
7+
#
8+
# Path-filtered so it doesn't run on doc-only PRs.
9+
10+
on:
11+
pull_request:
12+
branches: [main]
13+
paths:
14+
- 'cmd/**'
15+
- 'internal/**'
16+
- 'packaging/windows/**'
17+
- 'Makefile'
18+
- '.github/workflows/msi-smoke.yml'
19+
push:
20+
branches: [main]
21+
paths:
22+
- 'cmd/**'
23+
- 'internal/**'
24+
- 'packaging/windows/**'
25+
- 'Makefile'
26+
- '.github/workflows/msi-smoke.yml'
27+
28+
permissions:
29+
contents: read
30+
31+
jobs:
32+
msi-smoke:
33+
name: Build, install, verify, uninstall
34+
runs-on: windows-latest
35+
timeout-minutes: 15
36+
37+
steps:
38+
- name: Harden the runner (Audit all outbound calls)
39+
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
40+
with:
41+
egress-policy: audit
42+
43+
- name: Checkout repository
44+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
45+
46+
- name: Set up Go
47+
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
48+
with:
49+
go-version-file: go.mod
50+
51+
- name: Install WiX 4 + Util extension
52+
shell: pwsh
53+
run: |
54+
dotnet tool install --global wix --version 4.0.5
55+
wix --version
56+
wix extension add --global WixToolset.Util.wixext/4.0.5
57+
58+
- name: Build Windows .exe
59+
shell: pwsh
60+
run: |
61+
$env:GOOS = "windows"
62+
$env:GOARCH = "amd64"
63+
$env:CGO_ENABLED = "0"
64+
go build -trimpath -ldflags "-s -w" `
65+
-o "$PWD\dmg-smoke.exe" `
66+
./cmd/stepsecurity-dev-machine-guard
67+
Get-Item "$PWD\dmg-smoke.exe" | Select-Object Name, Length
68+
69+
- name: Build MSI
70+
shell: pwsh
71+
run: |
72+
# Read Version from internal/buildinfo/version.go so the MSI
73+
# Package Version matches what the binary reports.
74+
$vline = Select-String -Path internal/buildinfo/version.go `
75+
-Pattern 'Version\s*=\s*"([^"]+)"' | Select-Object -First 1
76+
$version = $vline.Matches[0].Groups[1].Value
77+
Write-Host "Building MSI with Package Version: $version"
78+
79+
New-Item -ItemType Directory -Force -Path dist | Out-Null
80+
wix build packaging/windows/Product.wxs `
81+
-arch x64 `
82+
-ext WixToolset.Util.wixext `
83+
-d Arch=x64 `
84+
-d "Version=$version" `
85+
-d "BinaryPath=$PWD\dmg-smoke.exe" `
86+
-out "dist\dmg-smoke.msi"
87+
Get-Item "dist\dmg-smoke.msi" | Select-Object Name, Length
88+
89+
- name: Install MSI with synthetic tenant config
90+
shell: pwsh
91+
run: |
92+
# Synthetic config: real-looking values but pointed at an
93+
# invalid endpoint. The MSI's install CA passes
94+
# --ignore-telemetry-error so initial telemetry POST failure
95+
# doesn't roll back the install. Exit 0 expected.
96+
$p = Start-Process -FilePath "msiexec.exe" -ArgumentList @(
97+
"/i", "dist\dmg-smoke.msi", "/qn",
98+
"CUSTOMERID=ci-smoke",
99+
"APIENDPOINT=https://api.invalid.example",
100+
"APIKEY=ci-test-fake-api-key",
101+
"SCANFREQUENCY=4",
102+
"/l*v", "dist\dmg-install.log"
103+
) -Wait -PassThru
104+
Write-Host "msiexec exit: $($p.ExitCode)"
105+
if ($p.ExitCode -ne 0) {
106+
Write-Host "::error::msiexec install failed with exit $($p.ExitCode)"
107+
Write-Host "--- last 80 lines of install log ---"
108+
Get-Content dist\dmg-install.log -Tail 80
109+
exit 1
110+
}
111+
112+
- name: Verify install artifacts
113+
shell: pwsh
114+
run: |
115+
$bin = "C:\Program Files\StepSecurity\stepsecurity-dev-machine-guard.exe"
116+
$cfg = "C:\ProgramData\StepSecurity\config.json"
117+
$task = "StepSecurity Dev Machine Guard"
118+
119+
$checks = @()
120+
121+
# 1. Binary on disk
122+
if (Test-Path $bin) {
123+
Write-Host "[OK] Binary present at $bin"
124+
$checks += $true
125+
} else {
126+
Write-Host "::error::Binary missing: $bin"
127+
$checks += $false
128+
}
129+
130+
# 2. Machine-wide config exists and contains our values
131+
if (Test-Path $cfg) {
132+
$content = Get-Content $cfg -Raw
133+
if ($content -match '"customer_id"\s*:\s*"ci-smoke"' -and
134+
$content -match '"api_key"\s*:\s*"ci-test-fake-api-key"') {
135+
Write-Host "[OK] Config written with expected values"
136+
$checks += $true
137+
} else {
138+
Write-Host "::error::Config content unexpected:"
139+
Write-Host $content
140+
$checks += $false
141+
}
142+
} else {
143+
Write-Host "::error::Config missing: $cfg"
144+
$checks += $false
145+
}
146+
147+
# 3. ACL hardening on config.json — Users should be Read only,
148+
# Administrators + SYSTEM should be Full Control, inheritance disabled.
149+
$acl = & icacls $cfg | Out-String
150+
$aclOk = $true
151+
if ($acl -notmatch 'BUILTIN\\Users:\(R\)') { $aclOk = $false; Write-Host "::error::Users(R) ACE missing" }
152+
if ($acl -notmatch 'BUILTIN\\Administrators:\(F\)') { $aclOk = $false; Write-Host "::error::Administrators(F) ACE missing" }
153+
if ($acl -notmatch 'NT AUTHORITY\\SYSTEM:\(F\)') { $aclOk = $false; Write-Host "::error::SYSTEM(F) ACE missing" }
154+
if ($aclOk) {
155+
Write-Host "[OK] config.json ACL hardened (Users:R, Admins:F, SYSTEM:F)"
156+
$checks += $true
157+
} else {
158+
Write-Host "::error::ACL not as expected:"
159+
Write-Host $acl
160+
$checks += $false
161+
}
162+
163+
# 4. Scheduled task registered and runs as INTERACTIVE
164+
$taskInfo = schtasks /query /tn $task /v /fo LIST 2>&1
165+
if ($LASTEXITCODE -eq 0) {
166+
$runAs = ($taskInfo | Select-String -Pattern '^\s*Run As User:').ToString()
167+
if ($runAs -match 'INTERACTIVE') {
168+
Write-Host "[OK] Scheduled task registered, Run As User: INTERACTIVE"
169+
$checks += $true
170+
} else {
171+
Write-Host "::error::Run As User wrong: $runAs"
172+
$checks += $false
173+
}
174+
} else {
175+
Write-Host "::error::schtasks /query failed: $taskInfo"
176+
$checks += $false
177+
}
178+
179+
if ($checks -contains $false) {
180+
Write-Host "::error::One or more install verifications failed"
181+
exit 1
182+
}
183+
Write-Host "All install-time verifications passed."
184+
185+
- name: Uninstall MSI
186+
if: always()
187+
shell: pwsh
188+
run: |
189+
if (Test-Path "dist\dmg-smoke.msi") {
190+
$p = Start-Process -FilePath "msiexec.exe" -ArgumentList @(
191+
"/x", "dist\dmg-smoke.msi", "/qn",
192+
"/l*v", "dist\dmg-uninstall.log"
193+
) -Wait -PassThru
194+
Write-Host "msiexec /x exit: $($p.ExitCode)"
195+
}
196+
197+
- name: Verify uninstall cleanup
198+
shell: pwsh
199+
run: |
200+
$task = "StepSecurity Dev Machine Guard"
201+
202+
# Binary should be gone
203+
if (Test-Path "C:\Program Files\StepSecurity\stepsecurity-dev-machine-guard.exe") {
204+
Write-Host "::error::Binary still present after uninstall"
205+
exit 1
206+
}
207+
Write-Host "[OK] Binary removed"
208+
209+
# Scheduled task should be gone. Capture the exit code into a
210+
# local — PowerShell uses $LASTEXITCODE from the last native call
211+
# as the script's exit code, so we MUST clear it (via 'exit 0'
212+
# below) once we've made our decision, otherwise the step is
213+
# marked failed despite all checks passing.
214+
schtasks /query /tn $task 2>&1 | Out-Null
215+
$schtasksExit = $LASTEXITCODE
216+
if ($schtasksExit -eq 0) {
217+
Write-Host "::error::Scheduled task still registered after uninstall"
218+
exit 1
219+
}
220+
Write-Host "[OK] Scheduled task removed"
221+
222+
# Note: C:\ProgramData\StepSecurity\config.json intentionally persists
223+
# across uninstall — it's tenant config, not MSI-managed payload. This
224+
# mirrors the documented behavior in docs/deploying-via-sccm.md.
225+
226+
exit 0
227+
228+
- name: Upload install logs on failure
229+
if: failure()
230+
uses: actions/upload-artifact@v4
231+
with:
232+
name: msi-smoke-logs
233+
path: |
234+
dist/dmg-install.log
235+
dist/dmg-uninstall.log
236+
if-no-files-found: ignore

.github/workflows/release.yml

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ jobs:
1313
contents: write
1414
id-token: write
1515
attestations: write
16+
outputs:
17+
version: ${{ steps.version.outputs.version }}
18+
release_tag: ${{ steps.release.outputs.tag }}
1619

1720
steps:
1821
- name: Harden the runner (Audit all outbound calls)
@@ -156,6 +159,7 @@ jobs:
156159
"${{ steps.binaries.outputs.rpm_amd64 }}.bundle"
157160
sign_with_retry "${{ steps.binaries.outputs.rpm_arm64 }}" \
158161
"${{ steps.binaries.outputs.rpm_arm64 }}.bundle"
162+
159163
- name: Upload cosign bundles
160164
env:
161165
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -185,3 +189,122 @@ jobs:
185189
${{ steps.binaries.outputs.deb_arm64 }}
186190
${{ steps.binaries.outputs.rpm_amd64 }}
187191
${{ steps.binaries.outputs.rpm_arm64 }}
192+
193+
build-msi:
194+
name: Build & Sign MSIs
195+
needs: release
196+
runs-on: windows-latest
197+
permissions:
198+
contents: write
199+
id-token: write
200+
attestations: write
201+
202+
steps:
203+
- name: Harden the runner (Audit all outbound calls)
204+
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
205+
with:
206+
egress-policy: audit
207+
208+
- name: Checkout repository
209+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
210+
211+
- name: Install WiX 4 + Util extension
212+
# WiX 4 ships as a .NET global tool. The Util extension (WixQuietExec
213+
# and friends) is a separate NuGet package that must be added to the
214+
# global wix tool before referencing util: namespace types.
215+
shell: pwsh
216+
run: |
217+
dotnet tool install --global wix --version 4.0.5
218+
wix --version
219+
wix extension add --global WixToolset.Util.wixext/4.0.5
220+
wix extension list --global
221+
222+
- name: Download Windows .exe assets from draft release
223+
env:
224+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
225+
shell: pwsh
226+
run: |
227+
$tag = "${{ needs.release.outputs.release_tag }}"
228+
New-Item -ItemType Directory -Path dist -Force | Out-Null
229+
# Goreleaser produces archive names like:
230+
# stepsecurity-dev-machine-guard-<version>-windows_amd64.exe
231+
# We download them by exact pattern to dist/.
232+
gh release download "$tag" `
233+
-R "${{ github.repository }}" `
234+
-p "*-windows_amd64.exe" `
235+
-p "*-windows_arm64.exe" `
236+
-D dist
237+
Get-ChildItem dist | Format-Table Name, Length
238+
239+
- name: Build MSIs (x64 + arm64)
240+
shell: pwsh
241+
run: |
242+
$version = "${{ needs.release.outputs.version }}"
243+
$amd64 = Get-ChildItem dist -Filter "*-windows_amd64.exe" | Select-Object -First 1
244+
$arm64 = Get-ChildItem dist -Filter "*-windows_arm64.exe" | Select-Object -First 1
245+
if (-not $amd64 -or -not $arm64) {
246+
Write-Error "Windows .exe assets missing under dist/"
247+
exit 1
248+
}
249+
250+
wix build packaging/windows/Product.wxs `
251+
-arch x64 `
252+
-ext WixToolset.Util.wixext `
253+
-d Arch=x64 `
254+
-d "Version=$version" `
255+
-d "BinaryPath=$($amd64.FullName)" `
256+
-out "dist/stepsecurity-dev-machine-guard-$version-x64.msi"
257+
258+
wix build packaging/windows/Product.wxs `
259+
-arch arm64 `
260+
-ext WixToolset.Util.wixext `
261+
-d Arch=arm64 `
262+
-d "Version=$version" `
263+
-d "BinaryPath=$($arm64.FullName)" `
264+
-out "dist/stepsecurity-dev-machine-guard-$version-arm64.msi"
265+
266+
Get-ChildItem dist -Filter "*.msi" | Format-Table Name, Length
267+
268+
- name: Install cosign
269+
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
270+
271+
- name: Sign MSIs with Sigstore
272+
shell: bash
273+
run: |
274+
set -euo pipefail
275+
version="${{ needs.release.outputs.version }}"
276+
for arch in x64 arm64; do
277+
msi="dist/stepsecurity-dev-machine-guard-${version}-${arch}.msi"
278+
bundle="${msi}.bundle"
279+
for attempt in 1 2 3; do
280+
if cosign sign-blob "$msi" --bundle "$bundle" --yes; then
281+
echo "Signed $msi"
282+
break
283+
fi
284+
echo "::warning::Sign attempt $attempt failed for $msi, retrying in 10s..."
285+
sleep 10
286+
done
287+
test -f "$bundle" || { echo "::error::Failed to sign $msi"; exit 1; }
288+
done
289+
290+
- name: Upload MSIs and bundles to draft release
291+
env:
292+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
293+
shell: bash
294+
run: |
295+
set -euo pipefail
296+
tag="${{ needs.release.outputs.release_tag }}"
297+
version="${{ needs.release.outputs.version }}"
298+
gh release upload "$tag" \
299+
"dist/stepsecurity-dev-machine-guard-${version}-x64.msi" \
300+
"dist/stepsecurity-dev-machine-guard-${version}-arm64.msi" \
301+
"dist/stepsecurity-dev-machine-guard-${version}-x64.msi.bundle" \
302+
"dist/stepsecurity-dev-machine-guard-${version}-arm64.msi.bundle" \
303+
--clobber
304+
305+
- name: Attest MSI build provenance
306+
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
307+
with:
308+
subject-path: |
309+
dist/stepsecurity-dev-machine-guard-${{ needs.release.outputs.version }}-x64.msi
310+
dist/stepsecurity-dev-machine-guard-${{ needs.release.outputs.version }}-arm64.msi

0 commit comments

Comments
 (0)