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; /// /// lmxopcua#890 — the installer's denies, end to end, against a real restricted worker token. A scratch copy of the /// wonder layout (<deploy>\{app\script-worker, pki, data, logs, backup}, granting BUILTIN\Users read /// plus create-files on subfolders, as a data drive does) is isolated by Install-ScriptWorkerIsolation.ps1 /// under Windows PowerShell with -DenyPaths. Under the worker token the test then cannot read pki\, /// data\, logs\, backup\ or app\appsettings.json, 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). /// /// 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 OTOPCUA_ISOLATION_INSTALLER_E2E=1, elevated, on Windows, /// and refuses when the real isolation is installed. Everything is removed in finally. /// /// [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 { ["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 */ } } } } /// For each file: can the worker token read it; for the trust-list folder: can it create a file there. [SupportedOSPlatform("windows")] private static Dictionary WorkerView(string workerSid, IEnumerable files, string trustList, string workerFile) { var result = new Dictionary(); 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().Where(r => r.IdentityReference.Value == sid).ToArray(); private static List<(string Outcome, string Id)> Outcomes(string output) => System.Text.RegularExpressions.Regex.Matches(output, @"\[(?[a-z]+)\s*\]\s+(?[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))); } }