feat(hardening)!: choose the process-mitigation level at install time — and the child-attribute escape hatch that Windows does not allow - #94
Open
EliorMachlev wants to merge 1 commit into
Conversation
ProcessMitigations documented itself as affecting "only this process, never the spawned java.exe child, so builds are unaffected". That was wrong. Mitigation policies are inherited by child processes, so the image-load policy reaches java.exe and every compiler, test runner and script the agent spawns beneath it. NoRemoteImages blocks DLL loads from UNC paths and mapped drives, so a build step that runs tooling from a network share fails under the service in a way it does not fail by hand, with a loader error naming nothing to do with this product. Issue #82 proposed launching the agent child with PROC_THREAD_ATTRIBUTE_MITIGATION_ POLICY to clear the inherited policy, keeping the service hardened and the build untouched. Measured, that does not work: ALWAYS_OFF overrides the SYSTEM default, not an inherited one. On Windows 11 26200 x64, with the parent at image-load = 7, a child created with every ALWAYS_OFF bit still reads back 7 - while the same attribute list with ALWAYS_ON against a clean parent correctly yields 7, so the attribute is honoured and inheritance simply wins. AgentProcessLauncher is therefore untouched: the STARTUPINFOEX rewrite it would have needed buys nothing. Sparing the build tree a mitigation means not taking it in the service either, so Jenkins:Hardening:ProcessMitigations is a dial on the service: Full (default) NoRemote | NoLowLabel | PreferSystem32, as before AllowNetworkImages drops only NoRemote, the one with build-visible consequences Off applies nothing Offered as a horizontal radio group on Advanced Options, not a drop-down: the middle level is what an operator with a broken build should reach for, and behind a collapsed list they would never learn it exists and would pick Off instead. A level below Full is logged as a warning at every start, so a reduced posture is visible on the machine it applies to rather than only in a config file. Also measured, and recorded rather than changed: extension-point-disable is already enabled before the process runs, and SetProcessMitigationPolicy returns ERROR_ACCESS_DENIED for it, not the Win32 87 the issue guessed. The warning at startup is expected noise. BREAKING CHANGE: a supplied option value that cannot be used now fails update-secret instead of being ignored. This was a silent-drop bug in the same family as the one above and covers every typed option, not just the new one: --max-retries fifty installed the default retry count and reported success, --via-file maybe left the field unchanged, and an option at the end of the argument list with no value printed "requires a value" and then exited 0. These run as deferred custom actions with the default Return="check", so exiting 0 after ignoring a typo installs a configuration nobody asked for and calls it success. Missing and empty still mean "unset" and are still silent - every [PROPERTY] expansion is quoted precisely so an untouched installer field arrives as "", and rejecting that would fail an install that did nothing wrong. Parsing continues past the first rejection so a scripted install reports every bad option at once. The four enum parsers, which had disagreed about the empty case, now share one body. The wizard's two numeric fields gain Integer="yes". They were the only free-text inputs parsed as something other than a string, so the only way the UI could reach the new hard-error path; without it a typo there would surface as a 1603 rollback at the end of the wizard rather than a field that refuses the keystroke. It does not replace the parser check, which still covers msiexec JENKINS_MAX_RETRIES=fifty. Verification. 365 unit tests (+42). The mitigation levels are asserted through the pure ImageLoadFlags rather than by applying and reading back the real policy: a policy cannot be lowered once set and is inherited by everything the test host spawns afterwards, so that test would depend on execution order and permanently harden the runner. Paired theories pin both halves of the unset rule across all ten typed options, because fixing one half alone is the dangerous outcome - make empty fatal and every silent install dies on its first untouched property. A rejected run is asserted to leave no appsettings.json behind. Upgrade behaviour is asserted directly (adds the key at Full when absent, preserves an explicit level). Each new guard was mutation-checked against a deliberately broken build. All three MSIs rebuilt and passing Test-MsiPlatform.ps1; the radio rows, the Property default, the SetProperty restore and Integer="yes" (attrs 0x13) were read back from the built package rather than inferred from the .wxs. The false "never the spawned child" claim is corrected in ProcessMitigations.cs, api-reference.html, Security.md, README.md and overview.html; configuration.html gains the setting and a Process mitigations section carrying the measurement. Closes #82 Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #82.
ProcessMitigationsdocumented itself as affecting "only this process, never the spawnedjava.exechild, so builds are unaffected." That is not true — mitigation policies are inherited by child processes — and the issue proposed clearing the inherited policy on the child. Measured, that cannot be done. So this ships the dial instead, and fixes a silent-drop bug in the same family that the work uncovered.Important
Two behaviour changes.
Hardening:ProcessMitigationsdefaults toFull, so hardening is unchanged unless an operator opts out. But a mistyped option value now failsupdate-secretinstead of being ignored — see the CLI half.The child-attribute approach does not work
The issue's preferred design was to launch the agent with
PROC_THREAD_ATTRIBUTE_MITIGATION_POLICYcarrying theALWAYS_OFFbits, keeping the service hardened and the build untouched.ALWAYS_OFFoverrides the system default, not an inherited policy:Windows 11 26200 x64. Step 2b is the part that makes this conclusive rather than a failed experiment: the same attribute list, built the same way, demonstrably takes effect against a clean parent. Inheritance simply wins.
AgentProcessLauncheris therefore untouched. The rewrite that approach needed —STARTUPINFOEX, own pipes, own environment block, own tree-kill, in place ofSystem.Diagnostics.Process— would have bought nothing.Jenkins:Hardening:ProcessMitigationsSparing the build tree a mitigation means not taking it in the service either, so this is one dial on the service rather than a service setting plus a child setting:
Full(default)NoRemote | NoLowLabel | PreferSystem32AllowNetworkImagesNoRemoteOffOnly
NoRemoteImageshas build-visible consequences, which is why it gets its own level instead of forcing all-or-nothing. Any level belowFulllogs a warning at every start, so a reduced posture is visible on the machine it applies to and not only in a config file somebody has to think to go and read.Radio buttons, laid out horizontally — deliberately not a drop-down.
AllowNetworkImagesis what an operator with a suddenly-broken build should reach for; behind a collapsed list they would never learn it exists and would pickOff. It also matches the three selectors already on the config pages, and fits the one row the page had left.A supplied value that cannot be used is now fatal — BREAKING
Chasing the empty-vs-invalid question turned up the same silent-drop shape across every typed option, not just the new one:
--max-retries fiftyinstalled the default retry count and reported success.--via-file maybeleft the field unchanged.requires a valueand then exited 0.These run as deferred custom actions with the default
Return="check", so exiting 0 after ignoring a typo installs a configuration nobody asked for and calls it success — worse than a failed install, because nothing prompts anyone to look.Empty still means unset and is still silent. That half is not incidental: every
[PROPERTY]expansion is quoted precisely so an untouched installer field arrives as"", and making empty fatal would kill every silent install on its first unset option. Both halves are pinned by paired theories over all ten typed options.Parsing continues past the first rejection, so a scripted install reports every bad option at once rather than one per run. The four enum parsers — which had disagreed about the empty case — now share one body.
The wizard's two numeric fields gain
Integer="yes". They were the only free-text inputs parsed as something other than a string, so the only way the UI could reach the new hard-error path; without it a typo would surface as a 1603 rollback at the end of the wizard rather than a field that refuses the keystroke. It does not replace the parser check, which still coversmsiexec JENKINS_MAX_RETRIES=fifty.Verification
365 unit tests (+42). Every new guard was mutation-checked against a deliberately broken build, so none is vacuous.
The levels are asserted through the pure
ImageLoadFlags, not by applying a policy and reading it back — a mitigation policy cannot be lowered once set and is inherited by everything the test host spawns afterwards, so the "real" test would depend on execution order and permanently harden the runner.Read back from the built packages rather than inferred from the
.wxs: the radio rows and their order,JENKINS_MITIGATIONS = Full, theSetJENKINS_MITIGATIONSrestore row,Integer="yes"(attrs=0x13), and no control overlap on the re-flowed dialog. All three MSIs rebuilt and passingTest-MsiPlatform.ps1— architecture, no byte-identical pair, CA sequence window, every property expansion quoted.Upgrade behaviour is asserted directly (adds the key at
Fullwhen absent, preserves an explicitly chosen level), and a rejected run is asserted to leave noappsettings.jsonbehind.Docs: 8 pages, 185 links, 47 search-index entries all resolve; PSScriptAnalyzer 0 findings.
Incidental finding, recorded not changed
extension-point-disableis already enabled before the process runs, andSetProcessMitigationPolicyreturns ERROR_ACCESS_DENIED (5) for it — not the Win32 87 the issue guessed. The warning at startup is expected noise against a policy that is already in force.Not verified here
The measurement is from one machine (Windows 11 26200, x64). The conclusion matches the documented meaning of
ALWAYS_OFF, but no ARM64 or Server SKU was tested — and nothing in CI exercises a build that actually loads a DLL over UNC, soAllowNetworkImagesis verified as configuration, not as a fix for an observed broken pipeline.🤖 Generated with Claude Code