2a8e2981fe
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
77 lines
3.6 KiB
C#
77 lines
3.6 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
|
|
|
/// <summary>
|
|
/// <c>Scripting:Worker:Identity</c> (lmxopcua#780 design §6, §14 #5): the virtual account's SID is computed exactly as
|
|
/// Windows computes it, so the host's restricted token, the installer's ACLs (which compute the same SID in
|
|
/// PowerShell, <c>scripts/install/tests/Test-ScriptWorkerIsolation.ps1</c> pins the same value) and the pipe ACL agree.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class ScriptWorkerIdentityTests
|
|
{
|
|
/// <summary>The installer's default identity; the PowerShell harness pins the same string.</summary>
|
|
private const string OtOpcUaScriptWorkerSid = "S-1-5-80-1846135827-3903057317-1998105452-3938448374-3023753135";
|
|
|
|
[Fact]
|
|
public void The_service_sid_algorithm_reproduces_a_well_known_virtual_account()
|
|
=> ScriptWorkerIdentity.ServiceSid("TrustedInstaller")
|
|
.ShouldBe("S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464");
|
|
|
|
[Fact]
|
|
public void The_default_identity_resolves_to_the_pinned_sid_case_insensitively()
|
|
{
|
|
ScriptWorkerIdentity.TryResolveSid(ScriptWorkerIdentity.DefaultIdentity, out var sid, out var error).ShouldBeTrue(error);
|
|
sid.ShouldBe(OtOpcUaScriptWorkerSid);
|
|
ScriptWorkerIdentity.TryResolveSid(@"nt service\otopcuascriptworker", out var lower, out _).ShouldBeTrue();
|
|
lower.ShouldBe(OtOpcUaScriptWorkerSid);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_sid_string_passes_through()
|
|
{
|
|
ScriptWorkerIdentity.TryResolveSid("S-1-5-21-1-2-3-1001", out var sid, out _).ShouldBeTrue();
|
|
sid.ShouldBe("S-1-5-21-1-2-3-1001");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("", "no identity")]
|
|
[InlineData(@"OTOPCUA\svc-worker", "expected 'NT SERVICE")]
|
|
[InlineData(@"NT SERVICE\bad name", "virtual account name")]
|
|
[InlineData("S-1-1-0", "well-known group")]
|
|
[InlineData("S-1-5-32-545", "well-known group")]
|
|
[InlineData("S-1-5-18", "well-known group")]
|
|
[InlineData("S-1-5-11", "well-known group")]
|
|
public void A_domain_account_a_well_known_group_or_garbage_is_refused(string identity, string why)
|
|
{
|
|
ScriptWorkerIdentity.TryResolveSid(identity, out _, out var error).ShouldBeFalse();
|
|
error!.ShouldContain(why);
|
|
}
|
|
|
|
[Fact]
|
|
public void The_restricting_set_is_the_identity_then_everyone_users_and_restricted_and_never_authenticated_users()
|
|
{
|
|
var set = ScriptWorkerIdentity.RestrictingSids(OtOpcUaScriptWorkerSid);
|
|
set.ShouldBe([OtOpcUaScriptWorkerSid, "S-1-1-0", "S-1-5-32-545", "S-1-5-12"]);
|
|
set.ShouldNotContain("S-1-5-11");
|
|
}
|
|
|
|
[Fact]
|
|
public void Validation_refuses_an_identity_off_windows_and_a_malformed_one_on_windows()
|
|
{
|
|
new ScriptWorkerOptions().ValidateIdentity(isWindows: false).ShouldBeNull("unset is always fine");
|
|
new ScriptWorkerOptions { Identity = ScriptWorkerIdentity.DefaultIdentity }.ValidateIdentity(isWindows: true)
|
|
.ShouldBeNull("positive control");
|
|
new ScriptWorkerOptions { Identity = ScriptWorkerIdentity.DefaultIdentity }.ValidateIdentity(isWindows: false)
|
|
.ShouldNotBeNull().ShouldContain("only supported on Windows");
|
|
new ScriptWorkerOptions { Identity = @"OTOPCUA\svc" }.ValidateIdentity(isWindows: true)
|
|
.ShouldNotBeNull().ShouldContain("Scripting:Worker:Identity");
|
|
}
|
|
|
|
[Fact]
|
|
public void Sanitized_keeps_the_identity()
|
|
=> new ScriptWorkerOptions { Identity = ScriptWorkerIdentity.DefaultIdentity }.Sanitized(out _).Identity
|
|
.ShouldBe(ScriptWorkerIdentity.DefaultIdentity);
|
|
}
|