Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.ScriptWorker.Tests/InstallerLayoutIsolationTests.cs
T
Joseph Doherty f75ee7d089
v2-ci / unit-tests (push) Waiting to run
fix(scripting): ScriptWorker isolation denies the worker every sensitive sibling, not just app\ (#890)
The installer denied NT SERVICE\OtOpcUaScriptWorker only on -InstallRoot. On the wonder layout the node's keys and
secrets sit beside it (E:\ApiInstall\OtOpcUa\pki with the application certificate's private key, data\ with
secrets.db and LocalDb), inheriting BUILTIN\Users:RX. Users is a restricting SID of the worker token, so a worker
could read them.

- Install-ScriptWorkerIsolation.ps1 -DenyPaths <dir>,...: each folder gets the same inherited deny as InstallRoot
  (new SensitiveDirDeny steps). Refuses drive/share roots, script-worker\ and the PE cache; folders must exist;
  a comma/semicolon string works for powershell -File.
- The deny is read AND write (0xD0157: ReadData, WriteData, AppendData, WriteEA, DeleteChild, WriteAttributes,
  Delete, WRITE_DAC, WRITE_OWNER). Traverse/Execute, ReadAttributes, ReadControl and Synchronize stay so the
  loader still reaches script-worker\. The write half stops a worker planting a cert in pki\trusted\certs where
  a data drive lets Users create files in subfolders.
- The list is recorded under the anchor service's key (Parameters\DenyPaths, step DenyPathRecord): a re-run
  removes the deny from folders dropped from the list, -Uninstall removes every recorded one without repeating
  -DenyPaths. -WhatIf/-PlanOnly print every step.
- After an apply, WARN for each protected-DACL item under a denied folder that the inherited deny cannot reach;
  without -DenyPaths, WARN for pki/data/logs/backup/config/secrets siblings of InstallRoot.

Tests:
- Dry-run harness 46 -> 86 assertions (QA layout plan, mask bits, refusals, stale removal, record-driven
  rollback, host account never denied). Mutations (no sibling deny, deny->allow, mask back to ReadData, no
  stale removal, rollback ignoring the record, script-worker not refused) each turn it red.
- Test-ScriptWorkerIsolation.Windows.ps1 (Windows PowerShell 5.1, windev): 34/34, incl. -DenyPaths apply,
  11/11 unchanged re-apply, shorter-list re-apply, uninstall from the record, second uninstall unchanged.
- New InstallerLayoutIsolationTests (OTOPCUA_ISOLATION_INSTALLER_E2E=1, elevated Windows): a scratch wonder
  layout under the real restricted worker token. Before install and after uninstall the token reads pki/data/
  logs/backup/appsettings and plants a trust-list file; installed it can do neither, still reads script-worker\,
  a real worker launched from the layout runs a script, and the host still reads everything. Mutating the
  -DenyPaths denies to a no-op mask turns it red on pki\own\private\otopcua.pfx (reproduces #890).

QA vq03/vq04 are NOT re-applied; the command is in docs/deploy/qa-upgrade-b8b4c18f-to-fea7de0a.md.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-12 17:31:28 -05:00

230 lines
14 KiB
C#

using System.Diagnostics;
using System.Runtime.Versioning;
using System.Security.AccessControl;
using System.Security.Principal;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess;
namespace ZB.MOM.WW.OtOpcUa.ScriptWorker.Tests;
/// <summary>
/// lmxopcua#890 — the installer's denies, end to end, against a real restricted worker token. A scratch copy of the
/// wonder layout (<c>&lt;deploy&gt;\{app\script-worker, pki, data, logs, backup}</c>, granting <c>BUILTIN\Users</c> read
/// plus create-files on subfolders, as a data drive does) is isolated by <c>Install-ScriptWorkerIsolation.ps1</c>
/// under Windows PowerShell with <c>-DenyPaths</c>. Under the worker token the test then cannot read <c>pki\</c>,
/// <c>data\</c>, <c>logs\</c>, <c>backup\</c> or <c>app\appsettings.json</c>, nor drop a file into the trust list,
/// while a real worker launched from the layout still runs a script. Before the install and after the uninstall the
/// same token reads and writes them (the positive control: the deny, not the layout, is what refuses).
/// <para>
/// It changes machine state while it runs (a disabled scratch anchor service, the fixed-name firewall rule, ACEs
/// in its scratch folder), so it runs only when <c>OTOPCUA_ISOLATION_INSTALLER_E2E=1</c>, elevated, on Windows,
/// and refuses when the real isolation is installed. Everything is removed in <c>finally</c>.
/// </para>
/// </summary>
[Collection(ScriptWorkerCollection.Name)]
[Trait("Category", "ScriptWorker")]
public sealed class InstallerLayoutIsolationTests
{
public const string GateVariable = "OTOPCUA_ISOLATION_INSTALLER_E2E";
private const string WorkerServiceName = "OtOpcUaIsoE2EWorker";
private const string FirewallRule = "OtOpcUa-ScriptWorker-BlockOutbound";
private static CancellationToken Ct => TestContext.Current.CancellationToken;
[Fact]
public async Task The_installer_denies_the_worker_every_sensitive_sibling_and_the_worker_still_runs()
{
if (!OperatingSystem.IsWindows()) Assert.Skip("Windows installer end-to-end");
else if (Environment.GetEnvironmentVariable(GateVariable) != "1") Assert.Skip($"set {GateVariable}=1 (changes machine state; elevated)");
else await RunEndToEnd();
}
[SupportedOSPlatform("windows")]
private static async Task RunEndToEnd()
{
using (var me = WindowsIdentity.GetCurrent())
new WindowsPrincipal(me).IsInRole(WindowsBuiltInRole.Administrator).ShouldBeTrue("run elevated");
Run("powershell.exe", $"-NoProfile -Command \"if (Get-NetFirewallRule -Name {FirewallRule} -ErrorAction SilentlyContinue) {{ exit 3 }}\"")
.Exit.ShouldBe(0, "the real isolation is installed on this machine (its firewall rule exists); not touching it");
Run("sc.exe", $"query {WorkerServiceName}").Exit.ShouldNotBe(0, $"scratch service {WorkerServiceName} already exists");
var installer = FindInstaller();
var workerSid = ScriptWorkerIdentity.ServiceSid(WorkerServiceName);
var deploy = Path.Combine(Path.GetPathRoot(Environment.SystemDirectory)!, "otopcua-iso-e2e-" + Guid.NewGuid().ToString("N")[..8]);
var app = Path.Combine(deploy, "app");
var workerFolder = Path.Combine(app, OutOfProcessScriptRunner.WorkerFolderName);
var cache = Path.Combine(deploy + "-pecache");
var secrets = new Dictionary<string, string>
{
["pki"] = Path.Combine(deploy, "pki", "own", "private", "otopcua.pfx"),
["data"] = Path.Combine(deploy, "data", "secrets.db"),
["logs"] = Path.Combine(deploy, "logs", "otopcua-20261112.log"),
["backup"] = Path.Combine(deploy, "backup", "data-pre", "secrets.db"),
["appsettings"] = Path.Combine(app, "appsettings.json"),
};
var trustList = Path.Combine(deploy, "pki", "trusted", "certs");
var denyPaths = $"{deploy},{Path.Combine(deploy, "pki")},{Path.Combine(deploy, "data")}";
try
{
// ---- the layout: a copy of the built worker, the secrets beside it, a data-drive ACL on the root ----
CopyDirectory(Path.Combine(AppContext.BaseDirectory, OutOfProcessScriptRunner.WorkerFolderName), workerFolder);
foreach (var file in secrets.Values)
{
Directory.CreateDirectory(Path.GetDirectoryName(file)!);
File.WriteAllText(file, "secret");
}
Directory.CreateDirectory(trustList);
var rootAcl = new DirectoryInfo(deploy).GetAccessControl();
var users = new SecurityIdentifier(WellKnownSidType.BuiltinUsersSid, null);
rootAcl.AddAccessRule(new FileSystemAccessRule(users, FileSystemRights.ReadAndExecute,
InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow));
rootAcl.AddAccessRule(new FileSystemAccessRule(users, FileSystemRights.CreateFiles | FileSystemRights.AppendData,
InheritanceFlags.ContainerInherit, PropagationFlags.None, AccessControlType.Allow));
new DirectoryInfo(deploy).SetAccessControl(rootAcl);
// A protected file the inherited deny cannot reach: the installer must name it.
var island = Path.Combine(deploy, "data", "island.bin");
File.WriteAllText(island, "x");
var islandAcl = new FileInfo(island).GetAccessControl();
islandAcl.SetAccessRuleProtection(true, false);
islandAcl.AddAccessRule(new FileSystemAccessRule(new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null),
FileSystemRights.FullControl, AccessControlType.Allow));
new FileInfo(island).SetAccessControl(islandAcl);
var someWorkerFile = Directory.EnumerateFiles(workerFolder, "*.dll").First();
// ---- before: the worker token reads and writes everything (positive control) ----
WorkerView(workerSid, secrets.Values, trustList, someWorkerFile).ShouldAllBe(kv => kv.Value, "before the install, Users grants the worker token all of it");
// ---- install, and install again ----
var common = $"-InstallRoot \"{app}\" -HostServiceAccount \"{WindowsIdentity.GetCurrent().User!.Value}\" -PeCacheDir \"{cache}\" -WorkerServiceName {WorkerServiceName}";
var first = Ps(installer, $"{common} -DenyPaths \"{denyPaths}\"");
first.Exit.ShouldBe(0, first.Output);
Outcomes(first.Output).Count(o => o.Outcome == "changed").ShouldBe(10, first.Output);
first.Output.ShouldContain(island, Case.Insensitive, "the coverage audit names the protected file");
var second = Ps(installer, $"{common} -DenyPaths \"{denyPaths}\"");
second.Exit.ShouldBe(0, second.Output);
Outcomes(second.Output).ShouldAllBe(o => o.Outcome == "unchanged", second.Output);
Outcomes(second.Output).Count.ShouldBe(10);
foreach (var dir in new[] { deploy, Path.Combine(deploy, "pki"), Path.Combine(deploy, "data"), app })
ExplicitFor(dir, workerSid).ShouldHaveSingleItem().AccessControlType.ShouldBe(AccessControlType.Deny, dir);
// ---- the worker token: denied every sensitive sibling, still reaches its own binaries ----
var after = WorkerView(workerSid, secrets.Values, trustList, someWorkerFile);
foreach (var (what, file) in secrets)
after[file].ShouldBeFalse($"{what}: the worker token must not read {file}");
after[trustList].ShouldBeFalse("the worker token must not drop a file into the trust list");
after[someWorkerFile].ShouldBeTrue("the worker still reads its own binaries");
// The host account is not affected.
foreach (var file in secrets.Values) File.ReadAllText(file).ShouldBe("secret");
// ---- a real worker launched from the isolated layout still runs ----
var options = Scripts.Options(workerPath: OutOfProcessScriptRunner.ResolveWorkerEntry(workerFolder));
options.Worker.Identity = @"NT SERVICE\" + WorkerServiceName;
await using (var runner = Scripts.Runner(options))
{
runner.WorkerSid.ShouldBe(workerSid);
var outcome = await runner.RunAsync(Scripts.Vt("vt", "return (int)ctx.GetTag(\"x\").Value + 1;", inputs: Scripts.Input("x", 41)), Ct);
outcome.Status.ShouldBe(ScriptRunStatus.Ok, outcome.Reason);
outcome.Value.ShouldBe(42);
}
// ---- uninstall WITHOUT repeating -DenyPaths: the record drives it; then again ----
var un = Ps(installer, $"{common} -Uninstall");
un.Exit.ShouldBe(0, un.Output);
Outcomes(un.Output).Count(o => o.Outcome == "changed").ShouldBe(9, un.Output);
foreach (var dir in new[] { deploy, Path.Combine(deploy, "pki"), Path.Combine(deploy, "data"), app, workerFolder })
ExplicitFor(dir, workerSid).ShouldBeEmpty($"no worker ACE left on {dir}");
var un2 = Ps(installer, $"{common} -Uninstall");
un2.Exit.ShouldBe(0, un2.Output);
Outcomes(un2.Output).ShouldAllBe(o => o.Outcome == "unchanged", un2.Output);
// ---- after: back to the layout's own ACL (positive control for the negative assertions) ----
WorkerView(workerSid, secrets.Values, trustList, someWorkerFile).ShouldAllBe(kv => kv.Value, "after the uninstall the deny is gone");
}
finally
{
Run("powershell.exe", $"-NoProfile -Command \"Remove-NetFirewallRule -Name {FirewallRule} -ErrorAction SilentlyContinue\"");
Run("sc.exe", $"delete {WorkerServiceName}");
foreach (var dir in new[] { deploy, cache })
{
if (!Directory.Exists(dir)) continue;
Run("icacls.exe", $"\"{dir}\" /reset /T /C /Q");
try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ }
}
}
}
/// <summary>For each file: can the worker token read it; for the trust-list folder: can it create a file there.</summary>
[SupportedOSPlatform("windows")]
private static Dictionary<string, bool> WorkerView(string workerSid, IEnumerable<string> files, string trustList, string workerFile)
{
var result = new Dictionary<string, bool>();
using var token = RestrictedWorkerLauncher.CreateWorkerToken(workerSid);
WindowsIdentity.RunImpersonated(token, () =>
{
foreach (var file in files.Append(workerFile)) result[file] = Try(() => File.ReadAllBytes(file));
result[trustList] = Try(() =>
{
// Creating the file is the escalation; deleting it needs rights Users never has, so the host cleans up below.
File.WriteAllText(Path.Combine(trustList, "planted-" + Guid.NewGuid().ToString("N")[..6] + ".der"), "x");
});
});
// The trust-list probe's file, if it got in: removed as the host.
foreach (var f in Directory.EnumerateFiles(trustList, "planted-*")) File.Delete(f);
return result;
static bool Try(Action a)
{
try { a(); return true; }
catch (UnauthorizedAccessException) { return false; }
}
}
[SupportedOSPlatform("windows")]
private static FileSystemAccessRule[] ExplicitFor(string dir, string sid)
=> new DirectoryInfo(dir).GetAccessControl().GetAccessRules(true, false, typeof(SecurityIdentifier))
.Cast<FileSystemAccessRule>().Where(r => r.IdentityReference.Value == sid).ToArray();
private static List<(string Outcome, string Id)> Outcomes(string output)
=> System.Text.RegularExpressions.Regex.Matches(output, @"\[(?<o>[a-z]+)\s*\]\s+(?<id>[A-Za-z]+)\s")
.Select(m => (m.Groups["o"].Value, m.Groups["id"].Value)).ToList();
private static string FindInstaller()
{
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
{
var candidate = Path.Combine(dir.FullName, "scripts", "install", "Install-ScriptWorkerIsolation.ps1");
if (File.Exists(candidate)) return candidate;
}
throw new FileNotFoundException("scripts/install/Install-ScriptWorkerIsolation.ps1 not found above the test output");
}
// Windows PowerShell 5.1, as on the wonder boxes (no pwsh there).
private static (int Exit, string Output) Ps(string script, string args)
=> Run("powershell.exe", $"-NoProfile -ExecutionPolicy Bypass -File \"{script}\" {args}");
private static (int Exit, string Output) Run(string exe, string args)
{
var psi = new ProcessStartInfo(exe, args) { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true };
// Windows PowerShell 5.1 started from a .NET process inherits a PSModulePath it cannot load its own modules from
// (Get-Acl: "module could not be loaded"); without it, it rebuilds the default.
psi.Environment.Remove("PSModulePath");
using var p = Process.Start(psi)!;
var stdout = p.StandardOutput.ReadToEndAsync();
var stderr = p.StandardError.ReadToEndAsync();
p.WaitForExit();
return (p.ExitCode, stdout.Result + stderr.Result);
}
private static void CopyDirectory(string from, string to)
{
Directory.CreateDirectory(to);
foreach (var dir in Directory.EnumerateDirectories(from, "*", SearchOption.AllDirectories))
Directory.CreateDirectory(Path.Combine(to, Path.GetRelativePath(from, dir)));
foreach (var file in Directory.EnumerateFiles(from, "*", SearchOption.AllDirectories))
File.Copy(file, Path.Combine(to, Path.GetRelativePath(from, file)));
}
}