Skip to content

[WIP] feat(windows): MSI bootstrap + self-update packaging - #253

Closed
swarit-stepsecurity wants to merge 35 commits into
step-security:mainfrom
swarit-stepsecurity:swarit/feat/wt/win-msi-self-update-boot
Closed

swarit-stepsecurity wants to merge 35 commits into
step-security:mainfrom
swarit-stepsecurity:swarit/feat/wt/win-msi-self-update-boot

Conversation

@swarit-stepsecurity

Copy link
Copy Markdown
Member

Work in progress; opened as a draft to get Windows CI (msi-smoke, windows-native tests). Not for review or merge yet.

  • Launcher bootstrap mode (stepsecurity-dev-machine-guard-bootstrap.exe run|update|adopt-seed): resolves the self-update state pointer (current, previous, seed) and launches versioned binaries; no network or crypto.
  • schtasks: SYSTEM update task, scan task actions through the bootstrap, full uninstall of tasks and self-update state.
  • Product.wxs: bootstrap + seed payload, adopt-seed CA, install CA on repair, uninstall CA skipped on upgrade and Return=ignore, AUTOUPDATE property.
  • msi-smoke: layout/ACL/task assertions, bootstrap run, SYSTEM update tick, repair, N to N+1 upgrade, uninstall.

Builds on the self-update foundation commits (faultinject, updatestate).

Point(name) is an inlinable no-op unless built with selfupdate_faults.
Tagged builds act on DMG_FAULT (merged with the link-time Baked value):
kill, err, hang, diskfull, exit<N>, with optional @user/@System
qualifiers. DMG_FAULT_TRACE records every checkpoint reached.
…ecords

Layout paths rooted at an install root and a data root; strict x.y.z
versions; schema-1 state.json with corrupt-as-missing Load and atomic
Save (temp + fsync + MoveFileEx REPLACE_EXISTING|WRITE_THROUGH, or
rename + dir fsync) with <prefix>.state-temp/-renamed fault checkpoints;
non-blocking LockFileEx/flock update lock; run records capped at 10 with
bad-run classification. Kill-at-checkpoint subprocess tests and fuzzers.
Windows: create the directory with, or re-assert, owner SYSTEM and a
protected DACL (SYSTEM F, Administrators F, Users RX, inherited by
children) via native security APIs, reporting whether drift was fixed.
Rejects files and reparse points. Other platforms only create the dir.
…s and version manifest

Port the latest-binary client, GitHub-then-mirror download and SSHSIG
verifier from step-security#217 for the MSI updater: native arch via IsWow64Process2,
env then WinHTTP machine proxy, strict x.y.z targets, signatures checked
before download and sha256 after. Version dirs get a manifest so staged
versions can be re-verified.
The MSI installs the launcher a second time as
stepsecurity-dev-machine-guard-bootstrap.exe. Under that name it resolves
the self-update state.json pointer (current -> previous -> seed), launches
versions\<v>\task.exe with run-record env, picks the SYSTEM updater with a
seed fallback after 3 consecutive failures, and adopts the MSI seed into
versions\ without ever failing the MSI. No network or crypto code.

The launcher exits 70 when started by the bootstrap and unable to start
the agent, and falls back to a sibling agent.exe in version dirs.
updatestate gains the frozen pointer reader, the run-chain resolver and a
field-preserving state patch.
…ner with our own

WinVerifyTrust (revocation off) on Windows plus a portable PKCS#7 read of
the PE signature for the leaf subject and countersignature timestamp.
SamePublisher pins candidates to the running updater's own signer subject;
an unsigned dev build refuses updates unless a selfupdate_testkeys build
pins a subject.
On an MSI bootstrap install, install points both scan tasks at
`bootstrap.exe run send-telemetry` and (re)creates the SYSTEM task
"StepSecurity Dev Machine Guard (Update)" from a full task XML: hourly
with a 10 min random delay plus at boot after 15 min, StartWhenAvailable,
battery allowed, 30 min limit, IgnoreNew. The registration is verified.

Uninstall of a bootstrap install deletes all three tasks, waits up to 60 s
for the scan mutex, terminates DMG processes under the install root, and
removes versions\, state\ and RunningVersion. Non-bootstrap uninstalls
keep their behaviour and never touch the update task.
Product.wxs installs stepsecurity-dev-machine-guard-bootstrap.exe and
seed\<version>\{agent.exe, task.exe, dmg-linux} in place of the top-level
agent and launcher. A deferred SYSTEM adopt-seed CA (Return=ignore) runs
on install, upgrade and repair; the install CA now runs on repair too
(M-7); the uninstall CA is skipped during a major upgrade and can't block
removal (M-6, M-19).

AUTOUPDATE (default 1) is passed to configure, which gains
--auto-update=<bool> and writes auto_update into the machine config.

msi-smoke asserts the layout, the versions/state ACLs, all three tasks,
a bootstrap run, a real SYSTEM update tick, repair, an N to N+1 upgrade
without downgrade, and a clean uninstall.
One tick of S0-S7: lock, sweep and GC (tolerating in-use dirs), ACL
re-assert, probation health with rollback and quarantine (permanent, or
seven days for an inconclusive probation), target check every 6 h or when
a probation closes, verified staging into versions\<v>, SYSTEM smoke
test, mutex-guarded switch with deferral reporting. Events are written to
a state.json outbox in the same atomic write as the change they report and
retried until the backend takes them.
Comment thread internal/launcher/bootstrap.go Fixed
Comment thread internal/launcher/spawn_other.go Fixed
Comment thread internal/updatestate/acl_other.go Fixed
Comment thread internal/updatestate/runs.go Fixed
Update events go to run-status as status=update with retries; 4xx drops
the event, anything else stays in the updater's outbox. The SYSTEM
updater resolves the device id from the hardware and rewrites the
device-id file. Scans mark the bootstrap's run record once run-status
started is accepted and report update_pointer_invalid when the bootstrap
fell back. Adds the agent.prestart checkpoint, configure --auto-update
and the auto_update/update_mirror_url/lag/cooldown config fields.
Overlapping launches (hourly and logon tasks on wake) updated whichever
record was newest, so the run that scanned could stay open forever and
another run could take its verdict. Records now carry the bootstrap's
run_id and MarkRunStarted/FinalizeRun match it; the agent reads
STEPSEC_RUN_ID.
The previous updater runs during every probation and rewrote state.json
from its own struct, erasing whatever a newer version had added. State,
probation, quarantine entries and updater now carry unknown keys through
Load and Save verbatim.
A valid state that omitted or nulled them loaded with nil maps, so the
first write into them would panic.
An unknown @qualifier, an unknown action or a colon-bearing checkpoint
without an action left the rule silently unmatched, so a typo in a fault
test passed without injecting anything. Only in selfupdate_faults builds.
…ndows

CreateFile without FILE_FLAG_BACKUP_SEMANTICS fails on a directory with
access denied, so Load returned an I/O error instead of corrupt and the
updater skipped its rebuild path.
…vent fields

Each update event keeps the execution_id minted when it was queued, so
only its own retries reuse it. Events agent-api would refuse (no
install_channel, rollback without update_from) are not posted.
A clean run of each scenario (update, rollback) under DMG_FAULT_TRACE
lists the checkpoints of every tick; each traced checkpoint is then hit
with kill, err, hang and diskfull in a subprocess, followed by clean
ticks, asserting convergence and its event, hygiene, no flip-flop and the
frozen bootstrap read against an in-process fake API. A state corruption
matrix covers the updater side of the bootstrap cases. The matrix found
that a rolled-back version's dir survived until the next tick, so GC now
also runs right after a rollback. Runs on Linux and windows-latest.
A second os=linux call could resolve a different version if the release
gate moved between calls. dmg-linux now uses linux_checksum and
signed_linux_checksum from the windows response; missing fields fail
verification.
… backend

During an outage the primary updater exits 1 three times and the
fallback would then report a broken updater that was never broken. The
fallback now reports it only on a tick where its own latest-binary call
succeeded.
…/win-msi-self-update-boot

Brings in the core updater (C1-C4) so the bootstrap builds against its
updatestate API. Conflicts in cli/config (--auto-update) and tests.yml
resolved in favour of core; the bootstrap is adapted in the merge so it
compiles: release file names (updatestate.AgentFile/LauncherFile), run
records keyed by run_id (STEPSEC_RUN_ID), unknown-field-preserving Save
instead of the local patch helper, and a manifest for the adopted seed.
…te=false

The seed holds stepsecurity-dev-machine-guard.exe,
stepsecurity-dev-machine-guard-task.exe and dmg-linux, the same names as
versions\<v>\. AUTOUPDATE=0 runs configure --auto-update=false on
install, upgrade and repair; the default writes nothing.

msi-smoke waits only for msiexec itself with a bound, dumps the log and
DMG processes on a hang, checks the adopted seed's manifest, and uploads
logs when cancelled too.
…reboot

A running scan holds the bootstrap open, so a quiet repair or upgrade saw
a file in use and Windows Installer rebooted the machine. util:CloseApplication
now terminates the bootstrap, launcher and agent before InstallValidate,
and the Intune wrappers and msi-smoke pass /norestart.

Also: bootstrap run launches without STEPSEC_RUN_ID when the run record
can't be appended (D13), and drops an unused helper.
AUTOUPDATE has no default. 0 or 1 runs configure --auto-update=<value>
on install, upgrade and repair; unset touches nothing. msi-smoke installs
with 0, repairs with 1 and upgrades without the property, asserting
false, true and unchanged.
@swarit-stepsecurity
swarit-stepsecurity force-pushed the swarit/feat/wt/win-msi-self-update-boot branch from 59f2fa5 to 09bbb4d Compare October 19, 2026 14:01
…xisting state

R-B-1. LoadPointers no longer runs the full schema-1 Validate: only schema
(an integer >= 1) and a strict current decide whether state.json is
usable, an invalid previous is dropped, and probation/updater are read
best-effort. A future writer that puts RC versions or new shapes in other
fields can't strand first-generation bootstraps on the seed.

adopt-seed only creates a state when none exists. An existing state is
changed through the preserving Save, or left byte-for-byte alone when
this build can't round-trip it.
R-B-2. AppendRun and FinalizeRun now run under runs\<v>.json.lock
(LockFileEx/flock, bounded 5 s wait), so the hourly and Logon tasks
firing together no longer lose each other's records. On timeout the
bookkeeping is skipped and the scan still launches. The test runs two
real processes against one file; without the lock it lost 10-40 of 40
records per process.
R-B-3. The check compared each expected ACE with -contains, so an extra
ACE such as Users:Modify still passed. It now requires the owner SYSTEM,
a protected DACL and exactly the three explicit ACEs (type, identity,
rights, inheritance).
R-B-4. adopt-seed writes HKLM\Software\StepSecurity\RunningVersion
(64-bit view) right after it saves a new current, so the value exists
from install and the uninstall check that removes it is no longer
vacuous. msi-smoke asserts it after install, before uninstall and gone
after. A Windows test covers the registry write.
…stop

R-B-5. Windows-native tests: SpawnHidden's deadline terminates a child
and its grandchild through the job with exit 124; terminateUnder stops a
process whose image is under the install root and leaves others alone;
waitForInstanceMutex returns at once without a mutex, waits for a scan
to release it, and gives up after its bound. The mutex name is a var so
the test uses its own.
R-B-6. adopt-seed only refuses a newer seed when the seed's own version
has a quarantine entry that is permanent or whose until is still in the
future. An expired D5 inconclusive quarantine no longer blocks an MSI
push of that version, and other versions' entries never did.
…CM /fa repair

R-B-8 and follow-ups:
- install with AUTOUPDATE=0 (false); plain SCCM repair msiexec /fa keeps
  current, restores tasks and leaves it false; upgrade with it unset keeps
  it false; the README's REINSTALL=ALL REINSTALLMODE=omus AUTOUPDATE=1
  re-enables it; a fresh install with it unset doesn't write false.
- any other AUTOUPDATE value now reaches configure, which rejects it; the
  rejection is in the MSI log and Return=ignore keeps the MSI going with
  the setting unchanged (smoke asserts both).
`(AutoUpdate -eq 'false')` parses as AutoUpdate called with the
arguments -eq and 'false', so it returned the value itself and the
check passed for any non-empty value. Wrap the call: ((AutoUpdate) -eq …).
…nstall

msi-smoke showed uninstall leaving versions\: killing the bootstrap
first made its kill-on-close job end the launcher and agent, so
TerminateProcess on them failed with ACCESS_DENIED, terminateUnder
reported an error without waiting, and RemoveAll ran while their images
were still mapped. stopProcess now waits for the exit and only fails if
the process is still running; removal of versions\ and state\ retries
briefly. Windows test for the exiting-process case.
D18. recordUpdater used the full Load, so a state holding values this
build rejects (RC versions in quarantine or probation) stopped
consecutive_failures from counting, and the seed-updater fallback never
triggered. updatestate.SaveUpdaterRecord reads the state like
LoadPointers, rewrites only updater.version/consecutive_failures and keeps
every other byte, under the update lock. Test: such a state with three
failing updater exits picks the seed on the fourth tick.
D18 follow-up. SaveUpdaterRecord refused any schema other than 1, so on
a schema-2 state the failure count was skipped and the seed fallback
never fired. Under the additive-only contract updater.version and
consecutive_failures keep their meaning in every schema: it now patches
them for any schema >= 1 and keeps every other byte, schema included.
LoadPointers reads probation/updater best-effort for any schema, so the
updater pick honours the counter. Test: a schema-2 state with extra
fields and three failing exits picks the seed on the fourth tick.
@swarit-stepsecurity

Copy link
Copy Markdown
Member Author

Superseded by #252, which contains this branch (merged in) plus all follow-up fixes. Closing; this draft only carried Windows CI for the bootstrap/MSI work.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants