feat(scripting): ScriptWorker isolation on Windows: restricted worker identity, pipe ACL, firewall + PeCacheDir installer (lmxopcua#780)
v2-ci / unit-tests (push) Has been cancelled

Owner decisions §14 #5/#6 as built (design §12.5):

- Scripting:Worker:Identity (default unset, Windows only, fail-closed validation): each worker
  starts under a restricted token derived from the host's. Every privilege but SeChangeNotify is
  removed; the restricting SIDs are NT SERVICE\OtOpcUaScriptWorker's SID + Everyone + Users +
  RESTRICTED; the token and default DACLs are host + SYSTEM + identity + RESTRICTED. The worker
  is created suspended, put in its Job Object before it runs, inherits only its stdio pipes, gets
  an ACL'd work dir as TEMP, and runs with DOTNET_EnableDiagnostics=0. The identity is granted on
  the host's window station and desktop, because USER32 init made coreclr.dll fail with
  ERROR_DLL_INIT_FAILED on windev. The logon SID is not used because the host's process DACL
  gives it PROCESS_VM_READ.
- The worker pipe on Windows always has an explicit protected DACL: host account + identity
  only. There is no owner ACE, so an elevated host's Administrators gets no access.
- The worker treats access denied on its parent-pid poll as alive; the Job Object covers that case.
- scripts/install/Install-ScriptWorkerIsolation.ps1 + ScriptWorkerIsolation.psm1 are idempotent
  and support -WhatIf, -Uninstall and -PlanOnly. They create the disabled anchor service, the
  PeCacheDir ACL (protected, no Users, key and markers host-only, pe\ Modify for the identity),
  read+execute on script-worker\, a ReadData deny on the install root, the outbound firewall
  block on the worker exe, and optionally the host-service env.
- Tests: the dry-run harness scripts/install/tests/Test-ScriptWorkerIsolation.ps1 (46
  assertions, mutation-checked); ScriptWorkerIdentityTests; WorkerIsolationTests, which cover
  the pipe ACL, token access and a real restricted worker running a script, all green on windev.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-10 08:46:23 -05:00
parent 0e96dd08a2
commit 2a8e2981fe
20 changed files with 1898 additions and 37 deletions
@@ -138,6 +138,17 @@ public sealed class ScriptWorkerOptions
/// </summary>
public string? ExecutablePath { get; set; }
/// <summary>
/// The worker's identity on Windows (design §6, owner decision §14 #5): <c>NT SERVICE\&lt;name&gt;</c> — the virtual
/// account <c>scripts/install/Install-ScriptWorkerIsolation.ps1</c> registers, by default
/// <c>NT SERVICE\OtOpcUaScriptWorker</c> — or a SID string. When set, each worker starts under a restricted token
/// derived from the host's (see <see cref="ScriptWorkerIdentity"/>): every privilege removed, and every access check
/// must ALSO pass for this identity, <c>Everyone</c>, <c>BUILTIN\Users</c> or <c>RESTRICTED</c>. The pipe ACL then
/// admits the host account and this identity only. Null (the default) keeps the host's own token. Windows only:
/// startup refuses it elsewhere.
/// </summary>
public string? Identity { get; set; }
/// <summary>The hard grace that applies to a run with soft budget <paramref name="softTimeout"/>.</summary>
/// <param name="softTimeout">The run's soft deadline <c>T</c>.</param>
/// <returns><see cref="HardGrace"/> when set, otherwise <c>max(1 s, T)</c>.</returns>
@@ -228,6 +239,7 @@ public sealed class ScriptWorkerOptions
IsolationProbationRuns = probation,
PeCacheDir = PeCacheDir,
ExecutablePath = ExecutablePath,
Identity = Identity,
};
}
@@ -252,6 +264,21 @@ public sealed class ScriptWorkerOptions
return $"{key} '{PeCacheDir}' is inside the content root '{contentRootPath}'; it must live outside it.";
return null;
}
/// <summary>
/// Fail-closed identity validation: <see cref="Identity"/>, when set, must be <c>NT SERVICE\&lt;name&gt;</c> or a SID
/// string, and the host must run on Windows.
/// </summary>
/// <param name="isWindows">Whether the host runs on Windows (callers pass <see cref="OperatingSystem.IsWindows"/>).</param>
/// <returns>The violation, or null when the identity is unset or acceptable.</returns>
public string? ValidateIdentity(bool isWindows)
{
if (string.IsNullOrWhiteSpace(Identity)) return null;
const string key = ScriptingOptions.SectionName + ":Worker:" + nameof(Identity);
if (!isWindows)
return $"{key} '{Identity}' is only supported on Windows (it is a restricted token); unset it on this host.";
return ScriptWorkerIdentity.TryResolveSid(Identity, out _, out var error) ? null : $"{key} '{Identity}': {error}";
}
}
/// <summary>
@@ -0,0 +1,123 @@
using System.Buffers.Binary;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
/// <summary>
/// The ScriptWorker's Windows identity (<c>Scripting:Worker:Identity</c>, lmxopcua#780 design §6, owner decision §14 #5).
/// </summary>
/// <remarks>
/// <para>
/// <b>Why a restricted token and not a logon.</b> Windows starts a process under a virtual account
/// (<c>NT SERVICE\name</c>) only when the Service Control Manager starts that service; a host cannot launch K + 1
/// short-lived children, each with its own nonce, pipe and Job Object, that way. A separate local user would need
/// a stored password and <c>SeAssignPrimaryTokenPrivilege</c> on the host account. Instead the host derives a
/// <b>restricted token</b> from its own (the Windows Service Hardening / browser-sandbox technique): every
/// privilege is removed, and the virtual account's SID is one of the token's <i>restricting SIDs</i>, so every
/// access check must pass twice — once for the host account and once for the restricting set
/// (<see cref="RestrictingSids"/>). The worker can therefore reach only what BOTH the host account AND the worker
/// identity (or <c>Everyone</c> / <c>BUILTIN\Users</c> / <c>RESTRICTED</c>, which the runtime needs to load from
/// <c>System32</c>) may reach: in effect a standard user bounded by the host account, with no privileges.
/// </para>
/// <para>
/// The virtual account's SID is computable from its name (<see cref="ServiceSid"/>), so the identity works for ACLs
/// and the firewall rule whether or not the installer's anchor service exists; the anchor only makes the name
/// resolvable in ACL editors.
/// </para>
/// </remarks>
public static partial class ScriptWorkerIdentity
{
/// <summary>The installer's default virtual account name (the anchor service's name).</summary>
public const string DefaultServiceName = "OtOpcUaScriptWorker";
/// <summary>The installer's default identity, as configured.</summary>
public const string DefaultIdentity = @"NT SERVICE\" + DefaultServiceName;
/// <summary>Everyone.</summary>
public const string EveryoneSid = "S-1-1-0";
/// <summary>BUILTIN\Users.</summary>
public const string BuiltinUsersSid = "S-1-5-32-545";
/// <summary>NT AUTHORITY\RESTRICTED (the SID many system objects grant for restricted tokens).</summary>
public const string RestrictedSid = "S-1-5-12";
private const string ServicePrefix = @"NT SERVICE\";
/// <summary>
/// The restricting SIDs of a worker token for <paramref name="workerSid"/>: the identity itself, then
/// <c>Everyone</c>, <c>BUILTIN\Users</c> and <c>RESTRICTED</c>. <c>Authenticated Users</c> and the logon SID are
/// deliberately absent (the first is granted Modify on many data folders; the second reaches the host's own
/// processes in an interactive session).
/// </summary>
/// <param name="workerSid">The worker identity's SID string.</param>
/// <returns>The restricting SID strings, identity first.</returns>
public static IReadOnlyList<string> RestrictingSids(string workerSid) => [workerSid, EveryoneSid, BuiltinUsersSid, RestrictedSid];
/// <summary>
/// The SID Windows assigns the virtual account <c>NT SERVICE\<paramref name="serviceName"/></c>:
/// <c>S-1-5-80-</c> followed by the SHA-1 of the upper-cased name in UTF-16LE, read as five little-endian
/// 32-bit values (the algorithm behind <c>sc.exe showsid</c>).
/// </summary>
/// <param name="serviceName">The service name (no <c>NT SERVICE\</c> prefix).</param>
/// <returns>The SID string.</returns>
public static string ServiceSid(string serviceName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(serviceName);
var hash = SHA1.HashData(Encoding.Unicode.GetBytes(serviceName.ToUpperInvariant()));
var sb = new StringBuilder("S-1-5-80");
for (var i = 0; i < 5; i++)
sb.Append('-').Append(BinaryPrimitives.ReadUInt32LittleEndian(hash.AsSpan(i * 4, 4)).ToString(CultureInfo.InvariantCulture));
return sb.ToString();
}
/// <summary>Resolves a configured identity to a SID string without touching the OS.</summary>
/// <param name="identity"><c>NT SERVICE\name</c> or a SID string (<c>S-1-…</c>).</param>
/// <param name="sid">The SID string.</param>
/// <param name="error">Why it was refused.</param>
/// <returns>True when resolved.</returns>
public static bool TryResolveSid(string? identity, out string sid, out string? error)
{
sid = string.Empty;
error = null;
var value = identity?.Trim();
if (string.IsNullOrEmpty(value))
{
error = "no identity is set";
return false;
}
if (value.StartsWith(ServicePrefix, StringComparison.OrdinalIgnoreCase))
{
var name = value[ServicePrefix.Length..];
if (!ServiceNamePattern().IsMatch(name))
{
error = "the virtual account name must be 1–80 letters, digits, '_', '-' or '.'";
return false;
}
sid = ServiceSid(name);
return true;
}
if (SidPattern().IsMatch(value))
{
if (value is EveryoneSid or BuiltinUsersSid or RestrictedSid || value.StartsWith("S-1-5-32-", StringComparison.Ordinal)
|| value == "S-1-5-18" || value == "S-1-5-11")
{
error = "a well-known group or SYSTEM is not a worker identity; use a dedicated virtual account";
return false;
}
sid = value.ToUpperInvariant();
return true;
}
error = @"expected 'NT SERVICE\<name>' (the installer's virtual account) or a SID string 'S-1-…'";
return false;
}
[GeneratedRegex(@"^[A-Za-z0-9_.\-]{1,80}$", RegexOptions.CultureInvariant)]
private static partial Regex ServiceNamePattern();
[GeneratedRegex(@"^[Ss]-1-\d+(-\d+){1,14}$", RegexOptions.CultureInvariant)]
private static partial Regex SidPattern();
}