f75ee7d089
v2-ci / unit-tests (push) Waiting to run
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
230 lines
14 KiB
C#
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><deploy>\{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)));
|
|
}
|
|
}
|