Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ All of `docs/*.html` were reviewed against the code on 2026-08-02 and are curren
- **Secrets:** `ISecretResolver.cs`/`SecretResolver.cs`, `SecretWriter.cs`, `UpdateSecretCommand.cs` (the `update-secret` CLI — dispatch, arg parsing, silent/upgrade flows), `InteractiveConfigPrompts.cs` (console prompt primitives for the interactive flow, incl. masked input), `ImpersonationRunner.cs` (`LogonUser` + `RunImpersonated` for Credential-Manager/User-DPAPI writes; isolates the native `extern` surface), `TpmSecretProtector.cs`, `AgentSecretFile.cs` (`-secret @<file>` off-argv), `SecretRedactor.cs` (plain/URL-encoded/XML-escaped/Base64 forms, longest-first), `CertificateThumbprintValidator.cs`
- **Agent process:** `AgentProcessLauncher.cs` (the `IAgentProcessLauncher`/`IAgentProcess` seam — production wraps `System.Diagnostics.Process`; fakes drive the watchdog in tests), `AgentArgumentParser.cs`, `AgentTransport.cs` (pure transport-selection: `InitialMethod`/`Toggle`/`ShouldFallback`), `JavaPathResolver.cs`, `ServiceSettingsValidator.cs`
- **Networking:** `IJarDownloader.cs`/`HttpJarDownloader.cs` (conditional GET; temp-file + atomic move so a truncated download can't sit behind a valid validator; records the jar's SHA-256 on download and re-verifies the cached jar on each 304 — a mismatch forces an unconditional re-download. TOFU/local-integrity, not upstream authenticity — that's `Connection:ControllerCertThumbprint`), `JarCacheValidator.cs` (the freshness token: `ETag` → `If-None-Match` preferred, else `Last-Modified` → `If-Modified-Since`. **a controller may send `Last-Modified` and no `ETag`** (observed on a live one), and against those an ETag-only cache never populates — every start re-downloads the full jar. That was issue #80. One file per kind, ETag-sidecar and Modified-sidecar; writing either deletes the other. A stored value is screened for control characters and size-capped before it is replayed — it goes out via `TryAddWithoutValidation`, and the cache dir is writable by the account build steps run as), `JarCachePaths.cs` (cache-directory paths derived once, passed as one value), `AgentJar.cs` (**the** definition of `agent.jar` + the `.etag`/`.modified`/`.sha256` sidecar names — the downloader writes and the worker launches the same file, so don't re-declare it), `IConnectivityChecker.cs`/`TcpConnectivityChecker.cs`, `ProxyResolver.cs` (`ProxyMode` + `Connection:Proxy`/`ProxyBypass` parsing; bypass entries become **whole-URI** anchored regexes)
- **Hardening:** `ProcessMitigations.cs`, `EnvironmentSanitizer.cs`, `ConfigAclHardener.cs`, `DataDirectoryAcl.cs` + `GrantDataAccessCommand.cs` and `ServiceRecoveryConfigurator.cs` + `ConfigureRecoveryCommand.cs` (the `grant-data-access` / `configure-recovery` verbs the MSI calls as EXE custom actions in place of the WiX Util extension — see the installer note below. `DataDirectoryAcl` is deliberately the **inverse** of `ConfigAclHardener`: the config file's DACL is replaced with inheritance *off* because it may hold the secret, the data folder's is added to with inheritance *on* so it keeps its inherited SYSTEM/Administrators full control. Both are idempotent — they run on install, upgrade **and** repair), `EventLogSourceInstaller.cs`, `DataPaths.cs` (binary/data split; `ComputeDataDirectory` is the **non-creating** resolve — cleanup must not re-create the tree it is deleting), `UninstallCleanup.cs` + `PurgeCommand.cs` + `SecretPurger.cs` (the top-level `purge` verb — **not** a flag on `update-secret`, which updates no secret: deletes the data tree, the config, **and the secret from its store** — TPM key / Credential Manager entry / machine env var, since only `Unprotected`+`Dpapi` keep the material in the config and the rest store a pointer. `SecretStoreNames.cs` holds those names **once**, shared with `SecretWriter` — two copies could drift and uninstall would report success while leaving a usable credential. `SecretPurger.Purge` returns a `SecretStoreOutcome` so callers/tests assert the result, not log wording; `IsSafeToDelete` refuses drive roots, non-fully-qualified paths and system folders — this is a recursive delete running as SYSTEM off an operator-supplied path)
- **Hardening:** `ProcessMitigations.cs` + `MitigationLevel.cs`, `EnvironmentSanitizer.cs`, `ConfigAclHardener.cs`, `DataDirectoryAcl.cs` + `GrantDataAccessCommand.cs` and `ServiceRecoveryConfigurator.cs` + `ConfigureRecoveryCommand.cs` (the `grant-data-access` / `configure-recovery` verbs the MSI calls as EXE custom actions in place of the WiX Util extension — see the installer note below. `DataDirectoryAcl` is deliberately the **inverse** of `ConfigAclHardener`: the config file's DACL is replaced with inheritance *off* because it may hold the secret, the data folder's is added to with inheritance *on* so it keeps its inherited SYSTEM/Administrators full control. Both are idempotent — they run on install, upgrade **and** repair), `EventLogSourceInstaller.cs`, `DataPaths.cs` (binary/data split; `ComputeDataDirectory` is the **non-creating** resolve — cleanup must not re-create the tree it is deleting), `UninstallCleanup.cs` + `PurgeCommand.cs` + `SecretPurger.cs` (the top-level `purge` verb — **not** a flag on `update-secret`, which updates no secret: deletes the data tree, the config, **and the secret from its store** — TPM key / Credential Manager entry / machine env var, since only `Unprotected`+`Dpapi` keep the material in the config and the rest store a pointer. `SecretStoreNames.cs` holds those names **once**, shared with `SecretWriter` — two copies could drift and uninstall would report success while leaving a usable credential. `SecretPurger.Purge` returns a `SecretStoreOutcome` so callers/tests assert the result, not log wording; `IsSafeToDelete` refuses drive roots, non-fully-qualified paths and system folders — this is a recursive delete running as SYSTEM off an operator-supplied path)
- **Other:** `Properties/AssemblyInfo.cs` (explicit assembly attributes — required for Codacy; do not delete)

`src/JenkinsAsService.Installer/` — WiX v5 MSI (`.wxs` files + `License.rtf`). `tests/JenkinsAsService.Tests/` — xUnit suite.
Expand Down Expand Up @@ -78,6 +78,7 @@ Settings live under the `Jenkins` section of `appsettings.json`, grouped into su
- **`Connection:Proxy`/`ProxyBypass`** cover the **.NET jar download only** — the Java agent's own controller connection is the JVM's and needs `-Dhttps.proxyHost=…` via `Agent:CustomArguments`. Empty = system proxy, `direct`/`none` = bypass, else an address (bare `host:port` gets `http://`). Parsed at startup so a bad value fails loudly instead of looking like an unreachable controller. `ProxyBypass` entries become **anchored URI-shaped regexes** — `WebProxy.BypassList` is regex (not wildcards) and matches the *whole URI*, so a host-only pattern silently never matches.
- **`Logging:DebugMode` is live.** `LogLevelController` (hosted service) re-points a Serilog `LoggingLevelSwitch` from `IOptionsMonitor`; everything else is still startup-only. Restarting to enable debug logging kills the agent and destroys the state being diagnosed, which is the whole point.
- **Config is pinned to the install folder**, not the process working directory: `BuildHost` adds `AddJsonFile(Path.Combine(basePath, ...), reloadOnChange: true)`. The host's default content root is the CWD, which for an SCM-started service is `%SystemRoot%\system32` — without this the bound settings come back empty and the service dies claiming `Connection:Url` is missing *while naming a config file that is fully populated*. Don't remove it; the `reloadOnChange` is also what makes DebugMode live.
- **`Hardening:ProcessMitigations`** (`Full` default / `AllowNetworkImages` / `Off`) is a dial on what the **service** applies to itself, because mitigation policies are **inherited by child processes** and Windows gives no way to drop one for a child that the parent holds. `PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY` looks like the answer and is not: its `ALWAYS_OFF` bits override the *system default*, not an inherited policy - measured on 11 26200, parent at `image-load = 7`, child created with every `ALWAYS_OFF` bit still reads 7, while the same attribute list with `ALWAYS_ON` against a clean parent correctly yields 7 (so the attribute is honoured; inheritance simply wins). Don't re-propose launching the agent with cleared mitigations. Only `NoRemoteImages` has build-visible consequences - a build step loading tooling or a native DLL from a UNC path or mapped drive - so it gets its own level rather than forcing all-or-nothing; the wizard shows the three **horizontally as radio buttons, not a drop-down**, so an operator whose build just broke can see the middle option exists instead of reaching for `Off`. The old code comment claiming mitigations "affect only this process, never the spawned `java.exe` child" was **wrong**; that was issue #82. Also measured there: extension-point-disable is already on by default and `SetProcessMitigationPolicy` returns `ERROR_ACCESS_DENIED` for it, so that warning at startup is expected noise.
- Key axes: `Connection:Method` (`Auto`/`WebSocket`/`Https` — `Https` means direct TCP inbound, not literal HTTPS), `Secret:Mode` (`Unprotected`/`Dpapi`/`Tpm`/`EnvironmentVariable`/`CredentialManager`), `Secret:ViaFile`, `Secret:DpapiScope`, `Hardening:SanitizeEnvironment`, `Agent:DataDirectory`, `Recovery:MaxRetries` (0 = infinite).
- Runtime data lives in `%ProgramData%\JenkinsAsService`, **separate** from the read-only install folder in `Program Files`. Within it: logs + secret file at the root, cached `agent.jar` (+ `.etag` or `.modified`, and `.sha256`) under `agent\`, and the Jenkins `-workDir` under `work\` — the cache is isolated from workspace churn so a build step can't clobber the binary the watchdog launches. `DataPaths.ResolveAgentDirectory`/`ResolveWorkDirectory` derive the subdirs; don't put the jar back in the workdir root.
- On MSI upgrade, `update-secret --upgrade` reconciles `appsettings.json` to the schema (add-missing + prune-unknown) via `ServiceSettingsNormalizer`, preserving existing values and the secret; the secret-presence check is decryption-free/identity-independent (raw-string read of `Secret:Value`, works as SYSTEM regardless of which service identity the secret is bound to).
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ dotnet build src/JenkinsAsService.Installer -c Release `

- TLS 1.2+ enforced by default (.NET 10), with optional controller certificate pinning (`ControllerCertThumbprint`)
- Secrets encrypted at rest (TPM 2.0 hardware-backed key, DPAPI machine/user scope, or CredMgr), redacted from all logs, and passed to the agent off the command line via `-secret @<file>` so they never appear in the process table
- Least-privilege virtual service account, deny-by-default environment block for the agent child, and Win32 process-mitigation policies (no remote/low-IL/non-System32 DLL loads, extension-point injection disabled)
- Least-privilege virtual service account, deny-by-default environment block for the agent child, and Win32 process-mitigation policies (no remote/low-IL/non-System32 DLL loads, extension-point injection disabled). Mitigation policies are **inherited by the build tree**; `Hardening:ProcessMitigations` selects how much is applied
- Binary/data separation: read-only binaries in `Program Files`, writable runtime data in `ProgramData` — a malicious pipeline can't overwrite the service `.exe`. The cached `agent.jar` (+ SHA-256) lives in an `agent\` subfolder isolated from the build `work\` dir and is integrity-checked before each launch, so a build step can't swap the binary the watchdog runs
- Deterministic builds with locked NuGet restore and embedded PDB symbols
- CycloneDX **SBOM** generated in CI and attached to every release (with SHA-256 checksum)
Expand Down
2 changes: 1 addition & 1 deletion Security.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ At runtime the resolved secret is written to a separate ACL-restricted file and
### Process and Filesystem Hardening

- The Java agent child is launched with a **deny-by-default environment** — only a curated allow-list plus explicitly configured additions — so the service's own environment block cannot leak into untrusted pipeline scripts
- Win32 **process-mitigation policies** are applied to the service process: no remote, low-integrity or non-System32 DLL loads, and legacy extension-point injection disabled
- Win32 **process-mitigation policies** are applied to the service process: no remote, low-integrity or non-System32 DLL loads, and legacy extension-point injection disabled. Windows **inherits these into child processes**, so they cover `java.exe` and the build tree beneath it too, and that cannot be split — a child cannot hold fewer mitigations than its parent. `Hardening:ProcessMitigations` therefore tunes what the *service* takes: `Full` (default), `AllowNetworkImages` (drops only the UNC/network DLL block, the one with build-visible consequences), or `Off`. See [Process mitigations](https://jenkinsasservice.machlev.org/configuration.html#process-mitigations) for the measurement behind that constraint
- **Binaries and runtime data are separated**: the install folder stays read-only to the agent identity, while logs, the secret file, the jar cache and the build work directory live under `%ProgramData%`. A malicious pipeline therefore cannot overwrite the service binary and wait for a restart
- The cached `agent.jar` lives in its own subfolder, isolated from the build workspace, and its **SHA-256 is re-verified** before reuse — trust-on-first-use local integrity, complementing controller certificate pinning for upstream authenticity
- The service runs under a least-privilege **virtual service account** (`NT SERVICE\Jenkins`) by default, and the runtime data folder is granted to that account explicitly (inheritable `Modify`, with inheritance left on so SYSTEM and Administrators keep full control)
Expand Down
4 changes: 2 additions & 2 deletions docs/api-reference.html
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ <h2 id="hardening">Hardening</h2>
<tr>
<td><code>ProcessMitigations</code></td>
<td><code>Apply(warn?)</code></td>
<td>Sets Win32 mitigation policies on the <em>service</em> process: no remote, low-integrity or non-System32 DLL loads, and legacy extension-point injection disabled. Affects future <code>LoadLibrary</code> calls in this process only, not the Java child. Best-effort &mdash; never blocks startup.</td>
<td>Sets Win32 mitigation policies on the service process at the level given by <code>Hardening:ProcessMitigations</code>: no remote, low-integrity or non-System32 DLL loads, and legacy extension-point injection disabled. <strong>These policies are inherited by the Java child and everything the build spawns beneath it</strong>, which is what makes the level configurable &mdash; see <a href="configuration.html#process-mitigations">Process mitigations</a>. Best-effort &mdash; never blocks startup.</td>
</tr>
<tr>
<td><code>EnvironmentSanitizer</code></td>
Expand Down Expand Up @@ -361,7 +361,7 @@ <h2 id="program-cs"><code>Program.cs</code></h2>
<li><strong>CLI dispatch</strong> &mdash; <code>CliCommands.TryResolve(args, out var command)</code> maps <code>args[0]</code> to a handler and exits; anything unrecognised falls through to service mode. Deliberately <em>before</em> Serilog init, since <code>Environment.Exit</code> skips the flush block. The verb table lives in <code>CliCommands</code> rather than inline so it can be tested: an unregistered verb still compiles, and its only symptom is the exe silently starting in <em>service</em> mode &mdash; which, for a verb invoked by an MSI custom action, is a failed install. <code>CliCommandsTests</code> asserts by reflection that every command type declaring a <code>Name</code> is dispatchable. The four verbs are <code>update-secret</code>, <code>purge</code>, <code>grant-data-access</code> and <code>configure-recovery</code>.</li>
<li><strong>Boot config</strong> &mdash; a throwaway <code>ConfigurationBuilder</code> reads <code>Logging:DebugMode</code>, <code>Logging:CompactLog</code>, <code>Logging:RetainedLogs</code> and <code>Agent:DataDirectory</code> before the host exists.</li>
<li><strong>Serilog</strong> &mdash; rolling file sink in the <em>data</em> directory (10&nbsp;MB, <code>RetainedLogs</code> kept; <code>agent.log</code> or <code>agent.clef</code>), Event Log sink at Warning+ when the source is usable, enrichers <code>ProcessId</code> / <code>MachineName</code> / <code>EnvironmentName</code>, and level overrides muting <code>Microsoft</code>, <code>System.Net.Http</code> and <code>Polly</code> below Warning.</li>
<li><strong><code>ProcessMitigations.Apply</code></strong> &mdash; hardens the service process itself.</li>
<li><strong><code>ProcessMitigations.Apply</code></strong> &mdash; hardens the service process, and by inheritance everything it launches.</li>
<li><strong>Host</strong> &mdash; <code>BuildHost(args, basePath)</code>, split into four single-purpose steps:
<ul>
<li><strong>Config source</strong> &mdash; <code>AddJsonFile(Path.Combine(basePath, "appsettings.json"), reloadOnChange: true)</code>, then <code>AddWindowsService(ServiceName = "Jenkins")</code> and <code>Configure&lt;ServiceSettings&gt;</code>.</li>
Expand Down
4 changes: 2 additions & 2 deletions docs/architecture.html
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ <h2 id="project-structure">Project Structure</h2>
&#x2502; &#x2502; &#x251C;&#x2500;&#x2500; ProxyResolver.cs # Connection:Proxy/ProxyBypass parsing
&#x2502; &#x2502; &#x251C;&#x2500;&#x2500; CertificateThumbprintValidator.cs # Controller cert pinning
&#x2502; &#x2502; &#x251C;&#x2500;&#x2500; AgentProcessLauncher.cs # IAgentProcessLauncher/IAgentProcess seam
&#x2502; &#x2502; &#x251C;&#x2500;&#x2500; ProcessMitigations.cs, EnvironmentSanitizer.cs, ConfigAclHardener.cs,
&#x2502; &#x2502; &#x251C;&#x2500;&#x2500; ProcessMitigations.cs, MitigationLevel.cs, EnvironmentSanitizer.cs, ConfigAclHardener.cs,
&#x2502; &#x2502; &#x2502; EventLogSourceInstaller.cs # Hardening
&#x2502; &#x2502; &#x251C;&#x2500;&#x2500; DataDirectoryAcl.cs, GrantDataAccessCommand.cs, # data-folder ACL grant, and
&#x2502; &#x2502; &#x2502; ServiceRecoveryConfigurator.cs, # the SCM failure actions - both
Expand Down Expand Up @@ -408,7 +408,7 @@ <h2 id="testing">Testing</h2>
</tr>
<tr>
<td><code>CertificateThumbprintValidatorTests</code> / <code>ProcessMitigationsTests</code> / <code>DataPathsTests</code></td>
<td>Thumbprint normalization/pinning match, mitigation policy application, data-directory resolution/expansion</td>
<td>Thumbprint normalization/pinning match, mitigation flag set per level, data-directory resolution/expansion</td>
</tr>
</tbody>
</table>
Expand Down
Loading
Loading