Skip to content

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
mainfrom
feat/optional-process-mitigations
Open

feat(hardening)!: choose the process-mitigation level at install time — and the child-attribute escape hatch that Windows does not allow#94
EliorMachlev wants to merge 1 commit into
mainfrom
feat/optional-process-mitigations

Conversation

@EliorMachlev

Copy link
Copy Markdown
Owner

Closes #82.

ProcessMitigations documented itself as affecting "only this process, never the spawned java.exe child, 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:ProcessMitigations defaults to Full, so hardening is unchanged unless an operator opts out. But a mistyped option value now fails update-secret instead 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_POLICY carrying the ALWAYS_OFF bits, keeping the service hardened and the build untouched. ALWAYS_OFF overrides the system default, not an inherited policy:

[2b] child, ALWAYS_ON   (clean parent)  : image-load=7   <- control: the attribute IS honoured
[4]  child, no attribute (parent=7)     : image-load=7   <- inheritance
[5]  child, ALWAYS_OFF   (parent=7)     : image-load=7   <- ignored

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.

AgentProcessLauncher is therefore untouched. The rewrite that approach needed — STARTUPINFOEX, own pipes, own environment block, own tree-kill, in place of System.Diagnostics.Process — would have bought nothing.

Jenkins:Hardening:ProcessMitigations

Sparing 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:

Level Applies For
Full (default) NoRemote | NoLowLabel | PreferSystem32 unchanged behaviour
AllowNetworkImages drops only NoRemote a build loads tooling or a native DLL from a UNC path or mapped drive
Off nothing last resort

Only NoRemoteImages has build-visible consequences, which is why it gets its own level instead of forcing all-or-nothing. Any level below Full logs 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. AllowNetworkImages is what an operator with a suddenly-broken build should reach for; behind a collapsed list they would never learn it exists and would pick Off. 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 fifty installed the default retry count and reported success.
  • --via-file maybe left the field unchanged.
  • 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 — 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.

--method "" --dpapi-scope "" --via-file "" --max-retries ""   -> exit 0   (untouched wizard fields)
--max-retries "fifty"                                          -> exit 1
     Error: --max-retries expects a whole number, got 'fifty'.

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 covers msiexec 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, the SetJENKINS_MITIGATIONS restore row, Integer="yes" (attrs=0x13), and no control overlap on the re-flowed dialog. All three MSIs rebuilt and passing Test-MsiPlatform.ps1 — architecture, no byte-identical pair, CA sequence window, every property expansion quoted.

Upgrade behaviour is asserted directly (adds the key at Full when absent, preserves an explicitly chosen level), and a rejected run is asserted to leave no appsettings.json behind.

Docs: 8 pages, 185 links, 47 search-index entries all resolve; PSScriptAnalyzer 0 findings.

Incidental finding, recorded not changed

extension-point-disable is already enabled before the process runs, and SetProcessMitigationPolicy returns 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, so AllowNetworkImages is verified as configuration, not as a fix for an observed broken pipeline.

🤖 Generated with Claude Code

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>
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.

enhancement: make process mitigations an install option (on by default) — they are inherited by the build tree

1 participant