feat(scripting): ScriptWorker isolation on Windows: restricted worker identity, pipe ACL, firewall + PeCacheDir installer (lmxopcua#780)
v2-ci / unit-tests (push) Has been cancelled
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:
@@ -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\<name></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\<name></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>
|
||||
|
||||
Reference in New Issue
Block a user