Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
f349508
feat(faultinject): add build-tagged self-update fault checkpoints
swarit-stepsecurity Oct 19, 2026
2942701
feat(updatestate): add self-update layout, state.json, lock and run r…
swarit-stepsecurity Oct 19, 2026
0bdc787
feat(updatestate): add EnsureProtectedDir for versions and state ACLs
swarit-stepsecurity Oct 19, 2026
967163d
feat(selfupdate): add Windows latest-binary client, verified download…
swarit-stepsecurity Oct 19, 2026
6b24aee
feat(launcher): add bootstrap mode (run, update, adopt-seed)
swarit-stepsecurity Oct 19, 2026
5717b68
feat(authenticode): verify timestamped signatures and compare the sig…
swarit-stepsecurity Oct 19, 2026
d3144ae
feat(schtasks): add the SYSTEM update task and bootstrap scan actions
swarit-stepsecurity Oct 19, 2026
5effb3e
feat(msi): ship the bootstrap and seed, adopt-seed on every install
swarit-stepsecurity Oct 19, 2026
350f83c
test(authenticode): run Windows PowerShell without the runner's PSMod…
swarit-stepsecurity Oct 19, 2026
a1e8417
feat(selfupdate): add the SYSTEM updater state machine
swarit-stepsecurity Oct 19, 2026
35cd006
feat(windows): wire self-update --system, self-check and update events
swarit-stepsecurity Oct 19, 2026
9a101c9
fix(updatestate): match run records by run_id
swarit-stepsecurity Oct 19, 2026
23ede4d
fix(updatestate): keep unknown state.json keys across Save
swarit-stepsecurity Oct 19, 2026
3d53163
fix(updatestate): never return nil quarantine or dedupe maps from Load
swarit-stepsecurity Oct 19, 2026
ff52fbb
fix(faultinject): panic on malformed fault specs
swarit-stepsecurity Oct 19, 2026
e01e0f9
fix(updatestate): read a directory at the state path as corrupt on Wi…
swarit-stepsecurity Oct 19, 2026
0a68278
test(selfupdate): assert per-event execution ids and guard required e…
swarit-stepsecurity Oct 19, 2026
5cd48fe
test(selfupdate): add the trace-driven U0 kill matrix
swarit-stepsecurity Oct 19, 2026
8446de7
fix(selfupdate): take dmg-linux from the same latest-binary response
swarit-stepsecurity Oct 19, 2026
9925c44
fix(selfupdate): report update_updater_failed only after reaching the…
swarit-stepsecurity Oct 19, 2026
ad9f030
Merge branch 'swarit/feat/wt/win-msi-self-update' into swarit/feat/wt…
swarit-stepsecurity Oct 19, 2026
d61e623
fix(msi): use release file names in the seed and only write auto_upda…
swarit-stepsecurity Oct 19, 2026
3845cb3
fix(msi): stop running DMG processes before files change; never auto-…
swarit-stepsecurity Oct 19, 2026
09bbb4d
feat(msi): let AUTOUPDATE=1 re-enable self-update; unset leaves it alone
swarit-stepsecurity Oct 19, 2026
97a6672
fix(bootstrap): read only schema/current/previous; never replace an e…
swarit-stepsecurity Oct 19, 2026
544d1ee
fix(bootstrap): lock runs\<v>.json around every run-record write
swarit-stepsecurity Oct 19, 2026
56adb3a
test(msi-smoke): assert the versions/state DACL exactly
swarit-stepsecurity Oct 19, 2026
1a10a60
feat(bootstrap): set RunningVersion whenever adopt-seed sets current
swarit-stepsecurity Oct 19, 2026
c9c1f4b
test(windows): cover the bootstrap tree kill and uninstall's process …
swarit-stepsecurity Oct 19, 2026
b3c510d
fix(bootstrap): let an expired seed quarantine stop blocking adopt-seed
swarit-stepsecurity Oct 19, 2026
a528ef9
test(msi-smoke): cover AUTOUPDATE against a false baseline and the SC…
swarit-stepsecurity Oct 19, 2026
4985efc
test(msi-smoke): call AutoUpdate before comparing it
swarit-stepsecurity Oct 19, 2026
024641e
fix(schtasks): count an already-exiting DMG process as stopped on uni…
swarit-stepsecurity Oct 19, 2026
edaa83d
fix(bootstrap): record updater failures through the tolerant state read
swarit-stepsecurity Oct 19, 2026
431f414
fix(bootstrap): count updater failures on newer-schema states too
swarit-stepsecurity Oct 19, 2026
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
1 change: 1 addition & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ jobs:
- ./internal/detector/configaudit
- ./internal/state
- ./internal/detector/credentials
- ./internal/updatestate
env:
CGO_ENABLED: '0'
# Short campaigns on the PR and merge-queue paths so they don't hold up a
Expand Down
434 changes: 288 additions & 146 deletions .github/workflows/msi-smoke.yml

Large diffs are not rendered by default.

36 changes: 35 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,28 @@ jobs:
with:
go-version-file: go.mod
- run: make test
- name: Test self-update fault injection (selfupdate_faults tag)
run: go test -race -count=1 -tags selfupdate_faults,selfupdate_testkeys ./internal/faultinject/ ./internal/updatestate/ ./internal/selfupdate/ ./internal/authenticode/
- name: Test bootstrap fault checkpoints (selfupdate_faults tag)
run: go test -race -count=1 -tags selfupdate_faults ./internal/launcher/

# Self-update U0 harness on Linux: the trace-driven kill matrix and the
# state corruption matrix (portable parts; Windows runs them natively in
# test-windows-native).
test-selfupdate-linux:
name: Test (self-update kill matrix, linux)
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit

- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version-file: go.mod
- run: go test -race -count=1 -tags selfupdate_faults,selfupdate_testkeys ./internal/faultinject/ ./internal/updatestate/ ./internal/selfupdate/

# These packages have Windows-only code and Windows-specific behavior whose
# tests the macOS test job can only cross-compile, not run:
Expand All @@ -126,6 +148,9 @@ jobs:
# - executor: Readlink on a real directory junction (ModeIrregular entry,
# NT-namespace target), which the skills detector's junction folding
# depends on
# - updatestate: MoveFileEx state replace, LockFileEx, protected DACLs
# - selfupdate: native arch (IsWow64Process2), WinHTTP machine proxy
# - authenticode: WinVerifyTrust against a test-signed copy (elevated runner)
# - detector TestReal_*: the Kiro CLI registry reader against a scratch
# HKCU key, and skills junction folding over real junctions (the rest of
# the detector suite assumes a POSIX host, hence the -run filter)
Expand All @@ -144,10 +169,19 @@ jobs:
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version-file: go.mod
- run: go test -race -count=1 ./internal/devicepolicy/ ./internal/secureuserfile/ ./internal/detector/credentials/ ./internal/safepath/ ./internal/executor/
- run: go test -race -count=1 ./internal/devicepolicy/ ./internal/secureuserfile/ ./internal/detector/credentials/ ./internal/safepath/ ./internal/executor/ ./internal/updatestate/ ./internal/selfupdate/ ./internal/authenticode/
- run: go test -race -count=1 -run '^TestReal_' ./internal/detector/
- name: Test scheduled scan console suppression
run: go test -race -count=1 -run 'Test(IsTaskRegistered|DetectInvocationMethod_Windows|HideWindow)' ./internal/schtasks/ ./internal/telemetry/ ./internal/winproc/
- name: Test self-update fault injection + U0 kill matrix (selfupdate_faults tag)
run: go test -race -count=1 -tags selfupdate_faults,selfupdate_testkeys ./internal/faultinject/ ./internal/updatestate/ ./internal/selfupdate/ ./internal/authenticode/
- name: Authenticode against test-signed binaries (verbose, shows skips)
run: go test -count=1 -v -run 'TestVerify' ./internal/authenticode/
- name: Test launcher bootstrap mode and self-update tasks
run: |
go test -race -count=1 ./internal/launcher/
go test -race -count=1 -tags selfupdate_faults ./internal/launcher/
go test -race -count=1 -run 'Test(UpdateTask|ResolveBootstrapRoot|BuildCreateArgs_Bootstrap|InstallUpdateTask|Uninstall_|TerminateUnder|WaitForInstanceMutex|StopProcess)' ./internal/schtasks/

smoke:
name: Smoke Tests
Expand Down
69 changes: 33 additions & 36 deletions cmd/stepsecurity-dev-machine-guard-task/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@
// `powershell.exe -File loader.ps1 send-telemetry` in the same
// no-console envelope the MSI flow uses for the agent.
//
// - Bootstrap mode. Invoked under the file name
// stepsecurity-dev-machine-guard-bootstrap.exe (the MSI installs a
// second copy of this launcher under that name), the verbs run /
// update / adopt-seed resolve the self-update state.json pointer and
// launch the versioned binaries. See internal/launcher/bootstrap.go.
//
// When launched by the bootstrap (STEPSEC_BOOTSTRAP_VERSION set), failing to
// resolve or start the agent exits 70 so the run counts as bad (D5); other
// invocations keep their legacy exit codes.
//
// The agent (and any --exec target) stays console-subsystem so
// interactive CLI use continues to work normally.
//
Expand All @@ -40,14 +50,13 @@ import (
"os"
"os/exec"
"syscall"
"unsafe"

"github.com/step-security/dev-machine-guard/internal/faultinject"
"github.com/step-security/dev-machine-guard/internal/launcher"
"github.com/step-security/dev-machine-guard/internal/winproc"
"golang.org/x/sys/windows"
)

const createNoWindow uint32 = 0x08000000

func main() {
os.Exit(run(os.Args[1:]))
}
Expand All @@ -56,6 +65,11 @@ func main() {
// after the program name (os.Args[1:] in main); accepting it explicitly
// keeps the windows-only test path open if we add one later.
func run(argv []string) int {
if me, err := os.Executable(); err == nil && launcher.IsBootstrapBinary(me) {
return launcher.NewBootstrap(me, winproc.IsLocalSystem).Main(argv)
}
underBootstrap := os.Getenv(launcher.EnvBootstrapVersion) != ""

target, childArgs, err := launcher.ResolveTarget(argv)
if err != nil {
// Two distinct failure shapes, matched to the legacy contract:
Expand All @@ -76,16 +90,22 @@ func run(argv []string) int {
fmt.Fprintln(os.Stderr, err)
return 2
}
if underBootstrap {
return launcher.ExitCannotStart
}
return 1
}

cmd := exec.Command(target, childArgs...)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: createNoWindow,
CreationFlags: launcher.CreateNoWindow,
}

if err := cmd.Start(); err != nil {
if underBootstrap {
return launcher.ExitCannotStart
}
return 1
}

Expand All @@ -94,17 +114,18 @@ func run(argv []string) int {
// exit, which fires the kill. Failure here only weakens lifecycle
// (orphan possible on forced termination), not the work the child
// was started to do.
if job, jerr := newKillOnCloseJob(); jerr == nil {
if h, oerr := windows.OpenProcess(
windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE,
false,
uint32(cmd.Process.Pid),
); oerr == nil {
_ = windows.AssignProcessToJobObject(job, h)
_ = windows.CloseHandle(h)
if job, jerr := launcher.NewKillOnCloseJob(); jerr == nil {
if aerr := launcher.AssignToJob(job, cmd.Process.Pid); aerr != nil {
_ = windows.CloseHandle(job)
}
}

// Lets a test build crash a version's scans after launch (harness
// baked fault boot.launched@user:exit2); a no-op in release builds.
if underBootstrap {
_ = faultinject.Point("boot.launched")
}

if err := cmd.Wait(); err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
Expand All @@ -114,27 +135,3 @@ func run(argv []string) int {
}
return 0
}

func newKillOnCloseJob() (windows.Handle, error) {
job, err := windows.CreateJobObject(nil, nil)
if err != nil {
return 0, err
}
info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{
BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{
// BREAKAWAY_OK lets the agent's CREATE_BREAKAWAY_FROM_JOB spawns
// (WSL guest scans) outlive this run instead of being killed.
LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | windows.JOB_OBJECT_LIMIT_BREAKAWAY_OK,
},
}
if _, err := windows.SetInformationJobObject(
job,
windows.JobObjectExtendedLimitInformation,
uintptr(unsafe.Pointer(&info)),
uint32(unsafe.Sizeof(info)),
); err != nil {
_ = windows.CloseHandle(job)
return 0, err
}
return job, nil
}
6 changes: 6 additions & 0 deletions cmd/stepsecurity-dev-machine-guard/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ func main() {
os.Exit(aiagentscli.RunHook(os.Stdin, os.Stdout, os.Stderr, os.Args[2:]))
}

// agent.prestart fault checkpoint and the Windows self-update verbs
// (self-update --system, self-check); they exit here.
runEarlyVerbs(os.Args[1:])

// Load persisted config (~/.stepsecurity/config.json) before parsing CLI
// --config must be honoured before Load(), which runs ahead of flag
// parsing and keeps the first values it reads.
Expand Down Expand Up @@ -238,6 +242,7 @@ func main() {
APIEndpoint: cfg.ConfigAPIEndpoint,
APIKey: cfg.ConfigAPIKey,
ScanFrequency: cfg.ConfigScanFrequency,
AutoUpdate: cfg.ConfigAutoUpdate,
}
if opts.APIKey == "" {
// Env-var fallback keeps the secret off the msiexec command
Expand Down Expand Up @@ -274,6 +279,7 @@ func main() {
log.Error("Enterprise configuration not found. Run '%s configure' or download the script from your StepSecurity dashboard.", os.Args[0])
os.Exit(1)
}
reportBootstrapFallback()
// Server-driven run gate: exit 0 quietly when the backend says this
// invocation isn't due (or another instance is mid-scan). Sits before
// the watchdog and telemetry.Run so a skipped wakeup posts no beacon,
Expand Down
Loading
Loading