diff options
Diffstat (limited to 'tests/Agent.Windows.Tests')
| -rw-r--r-- | tests/Agent.Windows.Tests/ReadmeOperationalTests.cs | 446 | ||||
| -rw-r--r-- | tests/Agent.Windows.Tests/SecurityPolicyTests.cs | 77 |
2 files changed, 518 insertions, 5 deletions
diff --git a/tests/Agent.Windows.Tests/ReadmeOperationalTests.cs b/tests/Agent.Windows.Tests/ReadmeOperationalTests.cs new file mode 100644 index 0000000..fd70f06 --- /dev/null +++ b/tests/Agent.Windows.Tests/ReadmeOperationalTests.cs @@ -0,0 +1,446 @@ +using System.Net; +using System.Net.Sockets; +using System.Security.AccessControl; +using System.Text; +using Agent.Windows.Network; +using Agent.Windows.Recovery; +using Agent.Windows.Security; + +namespace Agent.Windows.Tests; + +public sealed class ReadmeOperationalTests +{ + [Theory(Timeout = 120_000)] + [InlineData("powershell")] + [InlineData("cmd")] + [InlineData("venv-python")] + public void Readme_script_runs_in_the_configured_runtime(string executable) + { + RuntimeFixture runtime = RuntimeFixture.FromEnvironment(); + using var fixture = new TemporaryDirectory(runtime.TestRoot); + string marker = Path.Combine(fixture.Path, executable + ".txt"); + string script; + IReadOnlyList<string> arguments; + string expected; + if (executable == "powershell") + { + script = Path.Combine(fixture.Path, "probe.ps1"); + File.WriteAllText(script, $"Set-Content -LiteralPath '{marker}' -Value 'POWERSHELL_OK'", new UTF8Encoding(false)); + arguments = ["-NoLogo", "-NoProfile", "-NonInteractive", "-File", script]; + expected = "POWERSHELL_OK"; + } + else if (executable == "cmd") + { + script = Path.Combine(fixture.Path, "probe.cmd"); + File.WriteAllText(script, $"@echo off\r\n>\"{marker}\" echo CMD_OK\r\n", Encoding.ASCII); + arguments = ["/d", "/c", "call", script]; + expected = "CMD_OK"; + } + else + { + script = Path.Combine(fixture.Path, "probe.py"); + File.WriteAllText(script, + $"import pathlib,sys\npathlib.Path({PythonLiteral(marker)}).write_text('PYTHON_OK|'+sys.prefix, encoding='utf-8')\n", + new UTF8Encoding(false)); + arguments = [script]; + expected = "PYTHON_OK|"; + } + using SandboxScope scope = runtime.CreateScope(fixture.Path, allowNetwork: false); + RestrictedProcessResult result = scope.Run(executable, arguments, fixture.Path); + + Assert.True(result.ExitCode == 0u, $"{executable} exited {result.ExitCode}. stdout={result.StandardOutput}; stderr={result.StandardError}"); + string output = File.ReadAllText(marker).Trim(); + Assert.StartsWith(expected, output, StringComparison.Ordinal); + if (executable == "venv-python") + Assert.Contains(Path.GetFullPath(runtime.VenvRoot), output, StringComparison.OrdinalIgnoreCase); + } + + [Theory(Timeout = 120_000)] + [InlineData("powershell")] + [InlineData("cmd")] + [InlineData("venv-python")] + public void Network_none_blocks_powershell_cmd_and_python(string executable) + { + RuntimeFixture runtime = RuntimeFixture.FromEnvironment(); + using var fixture = new TemporaryDirectory(runtime.TestRoot); + using var deniedServer = new OneShotHttpServer(); + using SandboxScope deniedScope = runtime.CreateScope(fixture.Path, allowNetwork: false); + RestrictedProcessResult denied = RunNetworkProbe(deniedScope, executable, deniedServer.Url, fixture.Path); + Assert.DoesNotContain("not recognized as an internal or external command", denied.StandardError, StringComparison.OrdinalIgnoreCase); + Assert.NotEqual(0u, denied.ExitCode); + Assert.False(deniedServer.Accepted); + } + + [Theory(Timeout = 180_000)] + [InlineData("powershell")] + [InlineData("cmd")] + [InlineData("venv-python")] + public void Network_local_allows_only_the_configured_port(string executable) + { + RuntimeFixture runtime = RuntimeFixture.FromEnvironment(); + using var fixture = new TemporaryDirectory(runtime.TestRoot); + using var allowedServer = new OneShotHttpServer(); + using var wrongPortServer = new OneShotHttpServer(); + using (SandboxScope localScope = runtime.CreateScope(fixture.Path, allowNetwork: true)) + { + NetworkEndpoint[] endpoints = [new(IPAddress.Loopback, checked((ushort)allowedServer.Port))]; + using NetworkLease localPolicy = NetworkLease.Apply(localScope, endpoints); + RestrictedProcessResult allowed = RunNetworkProbe(localScope, executable, allowedServer.Url, fixture.Path); + RestrictedProcessResult wrongPort = RunNetworkProbe(localScope, executable, wrongPortServer.Url, fixture.Path); + Assert.True(allowed.ExitCode == 0u, + $"{executable} could not access its allowed loopback port. stdout={allowed.StandardOutput}; stderr={allowed.StandardError}"); + Assert.True(allowedServer.Accepted); + Assert.NotEqual(0u, wrongPort.ExitCode); + Assert.False(wrongPortServer.Accepted); + } + } + + [Theory(Timeout = 120_000)] + [InlineData("powershell", "cmd")] + [InlineData("powershell", "curl")] + [InlineData("powershell", "venv-python")] + [InlineData("cmd", "cmd")] + [InlineData("cmd", "curl")] + [InlineData("cmd", "venv-python")] + [InlineData("venv-python", "cmd")] + [InlineData("venv-python", "curl")] + [InlineData("venv-python", "venv-python")] + public void Lpac_descendant_compatibility_matrix(string parent, string child) + { + RuntimeFixture runtime = RuntimeFixture.FromEnvironment(); + using var fixture = new TemporaryDirectory(runtime.TestRoot); + string pythonProbe = Path.Combine(fixture.Path, "descendant-probe.py"); + File.WriteAllText(pythonProbe, "raise SystemExit(0)\n", new UTF8Encoding(false)); + using SandboxScope scope = runtime.CreateScope(fixture.Path, allowNetwork: false); + string childPath = child switch + { + "cmd" => runtime.Cmd, + "curl" => Path.Combine(Environment.SystemDirectory, "curl.exe"), + "venv-python" => runtime.Python, + _ => throw new ArgumentOutOfRangeException(nameof(child)) + }; + string[] childArguments = child switch + { + "cmd" => ["/d", "/c", "exit 0"], + "curl" => ["--version"], + "venv-python" => [pythonProbe], + _ => throw new ArgumentOutOfRangeException(nameof(child)) + }; + RestrictedProcessResult result = parent switch + { + "powershell" => scope.Run(parent, + ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", + $"& '{childPath}' {string.Join(' ', childArguments.Select(PowerShellLiteral))}; exit $LASTEXITCODE"], fixture.Path), + "cmd" => scope.Run(parent, ["/d", "/c", child switch + { + "cmd" => "cmd.exe /d /c exit 0", + "curl" => "curl.exe --version", + "venv-python" => $"{Path.GetRelativePath(fixture.Path, runtime.Python)} {Path.GetFileName(pythonProbe)}", + _ => throw new ArgumentOutOfRangeException(nameof(child)) + }], fixture.Path), + "venv-python" => scope.Run(parent, + ["-c", $"import subprocess; raise SystemExit(subprocess.run({System.Text.Json.JsonSerializer.Serialize(new[] { childPath }.Concat(childArguments).ToArray())}).returncode)"], fixture.Path), + _ => throw new ArgumentOutOfRangeException(nameof(parent)) + }; + Assert.True(result.ExitCode == 0u, + $"LPAC descendant pair {parent}->{child} failed. stdout={result.StandardOutput}; stderr={result.StandardError}"); + } + + [Fact(Timeout = 180_000)] + public void Acl_monkey_round_trips_randomized_directory_and_file_descriptors() + { + string testRoot = RequiredDirectory("AGENT_MCP_MONKEY_ROOT"); + AppContainerProfile profile = AppContainerProfile.EnsureFixed(); + var audit = new MemorySecurityAuditSink(); + var acl = new TaskAclManager(profile.Sid, audit); + var random = new Random(0x5A17); + + for (int iteration = 0; iteration < 64; iteration++) + { + using var fixture = new TemporaryDirectory(testRoot); + string nested = Path.Combine(fixture.Path, $"d-{iteration:D2}", "nested"); + Directory.CreateDirectory(nested); + string file = Path.Combine(nested, $"f-{random.Next():X8}.txt"); + File.WriteAllText(file, "acl-monkey", new UTF8Encoding(false)); + + if ((iteration & 1) != 0) + { + DirectorySecurity security = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(fixture.Path), AccessControlSections.Access); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: true); + FileSystemAclExtensions.SetAccessControl(new DirectoryInfo(fixture.Path), security); + } + + Dictionary<string, byte[]> before = SnapshotTree(fixture.Path); + FileCapability directoryCapability = RandomDirectoryCapability(random); + FileCapability fileCapability = RandomFileCapability(random); + Guid taskId = Guid.NewGuid(); + AclSnapshot directorySnapshot = acl.Grant(taskId, fixture.Path, directoryCapability); + AclSnapshot fileSnapshot = acl.Grant(taskId, file, fileCapability); + try + { + Assert.NotEmpty(AccessRules(fixture.Path, profile.Sid)); + Assert.NotEmpty(AccessRules(file, profile.Sid)); + } + finally + { + acl.Restore(taskId, fileSnapshot); + acl.Restore(taskId, directorySnapshot); + } + + Dictionary<string, byte[]> after = SnapshotTree(fixture.Path); + Assert.Equal(before.Keys.Order(), after.Keys.Order()); + foreach ((string path, byte[] descriptor) in before) + { + string beforeSddl = ToSddl(descriptor); + string afterSddl = ToSddl(after[path]); + Assert.DoesNotContain(profile.Sid.Value, afterSddl, StringComparison.OrdinalIgnoreCase); + if (path.Equals(fixture.Path, StringComparison.OrdinalIgnoreCase)) + Assert.True(descriptor.AsSpan().SequenceEqual(after[path]), $"Modified root descriptor did not round-trip at iteration {iteration}: {path}"); + else + Assert.Equal(AclSemantics(path, beforeSddl), AclSemantics(path, afterSddl)); + } + } + + Assert.Equal(128, audit.Events.Count(item => item.Event == "acl.apply")); + Assert.Equal(128, audit.Events.Count(item => item.Event == "acl.restore")); + } + + private static RestrictedProcessResult RunNetworkProbe(SandboxScope scope, string executable, string url, string cwd) => executable switch + { + "powershell" => scope.Run(executable, + ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", $"try {{ (Invoke-WebRequest -UseBasicParsing -TimeoutSec 2 -Uri '{url}').Content | Out-Null; exit 0 }} catch {{ exit 71 }}"], cwd), + "cmd" => scope.Run(executable, + ["/d", "/c", $"curl.exe --noproxy * --silent --show-error --max-time 2 {url} >nul"], cwd), + "venv-python" => scope.Run(executable, + ["-c", $"import urllib.request; urllib.request.urlopen({PythonLiteral(url)}, timeout=2).read()"], cwd), + _ => throw new ArgumentOutOfRangeException(nameof(executable)) + }; + + private static string PythonLiteral(string value) => System.Text.Json.JsonSerializer.Serialize(value); + private static string PowerShellLiteral(string value) => "'" + value.Replace("'", "''", StringComparison.Ordinal) + "'"; + + private static FileCapability RandomDirectoryCapability(Random random) + { + FileCapability[] values = + [ + FileCapability.Read, + FileCapability.Write, + FileCapability.Read | FileCapability.Write, + FileCapability.Read | FileCapability.Execute, + FileCapability.Read | FileCapability.Write | FileCapability.Delete, + FileCapability.Read | FileCapability.Write | FileCapability.Execute | FileCapability.Delete + ]; + return values[random.Next(values.Length)]; + } + + private static FileCapability RandomFileCapability(Random random) + { + FileCapability[] values = + [ + FileCapability.Read, + FileCapability.Write, + FileCapability.Execute, + FileCapability.Read | FileCapability.Write, + FileCapability.Read | FileCapability.Execute, + FileCapability.Read | FileCapability.Write | FileCapability.Execute | FileCapability.Delete + ]; + return values[random.Next(values.Length)]; + } + + private static Dictionary<string, byte[]> SnapshotTree(string root) => + new[] { root }.Concat(Directory.EnumerateFileSystemEntries(root, "*", SearchOption.AllDirectories)) + .ToDictionary(path => path, AccessDescriptor, StringComparer.OrdinalIgnoreCase); + + private static byte[] AccessDescriptor(string path) + { + FileSystemSecurity security = Directory.Exists(path) + ? FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(path), AccessControlSections.Access) + : FileSystemAclExtensions.GetAccessControl(new FileInfo(path), AccessControlSections.Access); + return security.GetSecurityDescriptorBinaryForm(); + } + + private static string ToSddl(byte[] descriptor) + { + var raw = new System.Security.AccessControl.RawSecurityDescriptor(descriptor, 0); + return raw.GetSddlForm(AccessControlSections.All); + } + + private static string[] AclSemantics(string path, string sddl) + { + var raw = new System.Security.AccessControl.RawSecurityDescriptor(sddl); + string flags = $"protected={raw.ControlFlags.HasFlag(ControlFlags.DiscretionaryAclProtected)}"; + IEnumerable<string> aces = (raw.DiscretionaryAcl?.Cast<System.Security.AccessControl.GenericAce>() ?? []) + .Select(ace => + { + byte[] binary = new byte[ace.BinaryLength]; + ace.GetBinaryForm(binary, 0); + return Convert.ToHexString(binary); + }) + .Order(StringComparer.Ordinal); + return [path, flags, .. aces]; + } + + private static FileSystemAccessRule[] AccessRules(string path, System.Security.Principal.SecurityIdentifier sid) + { + FileSystemSecurity security = Directory.Exists(path) + ? FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(path), AccessControlSections.Access) + : FileSystemAclExtensions.GetAccessControl(new FileInfo(path), AccessControlSections.Access); + return security.GetAccessRules(true, true, typeof(System.Security.Principal.SecurityIdentifier)) + .OfType<FileSystemAccessRule>() + .Where(rule => rule.IdentityReference == sid && rule.AccessControlType == AccessControlType.Allow) + .ToArray(); + } + + private static string RequiredDirectory(string name) + { + string? value = Environment.GetEnvironmentVariable(name); + if (string.IsNullOrWhiteSpace(value) || !Directory.Exists(value)) + throw new InvalidOperationException($"{name} must name an existing directory."); + return Path.GetFullPath(value); + } + + private sealed record RuntimeFixture(string TestRoot, string VenvRoot, string Python, string PythonRuntimeRoot, string PowerShell, string? PowerShellRuntimeRoot, string Cmd) + { + public static RuntimeFixture FromEnvironment() + { + string testRoot = RequiredDirectory("AGENT_MCP_MONKEY_ROOT"); + string python = RequiredFile("AGENT_MCP_TEST_VENV_PYTHON"); + string venvRoot = Directory.GetParent(Path.GetDirectoryName(python)!)!.FullName; + string configuration = File.ReadAllText(Path.Combine(venvRoot, "pyvenv.cfg")); + string homeLine = configuration.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries) + .Single(line => line.StartsWith("home = ", StringComparison.OrdinalIgnoreCase)); + string pythonRuntimeRoot = Path.GetFullPath(homeLine[7..].Trim()); + string powershell = RequiredFile("AGENT_MCP_TEST_POWERSHELL"); + string? powershellRuntimeRoot = Environment.GetEnvironmentVariable("AGENT_MCP_TEST_POWERSHELL_RUNTIME"); + if (!string.IsNullOrWhiteSpace(powershellRuntimeRoot)) powershellRuntimeRoot = RequiredDirectory("AGENT_MCP_TEST_POWERSHELL_RUNTIME"); + else powershellRuntimeRoot = null; + string cmd = RequiredFile("AGENT_MCP_TEST_CMD"); + return new(testRoot, venvRoot, python, pythonRuntimeRoot, powershell, powershellRuntimeRoot, cmd); + } + + public SandboxScope CreateScope(string workspace, bool allowNetwork) => new(this, workspace, allowNetwork); + + private static string RequiredFile(string name) + { + string? value = Environment.GetEnvironmentVariable(name); + if (string.IsNullOrWhiteSpace(value) || !File.Exists(value)) + throw new InvalidOperationException($"{name} must name an existing file."); + return ResolvedPath.Resolve(value); + } + } + + private sealed class SandboxScope : IDisposable + { + private readonly TaskAclManager _acl; + private readonly List<AclSnapshot> _snapshots = []; + public SandboxScope(RuntimeFixture runtime, string workspace, bool allowNetwork) + { + Profile = AppContainerProfile.EnsureFixed(); + Audit = new MemorySecurityAuditSink(); + TaskId = Guid.NewGuid(); + _acl = new TaskAclManager(Profile.Sid, Audit); + _snapshots.Add(_acl.Grant(TaskId, workspace, FileCapability.Read | FileCapability.Write | FileCapability.Execute | FileCapability.Delete)); + _snapshots.Add(_acl.Grant(TaskId, runtime.VenvRoot, FileCapability.Read | FileCapability.Execute)); + _snapshots.Add(_acl.Grant(TaskId, runtime.PythonRuntimeRoot, FileCapability.Read | FileCapability.Execute)); + if (runtime.PowerShellRuntimeRoot is not null) + _snapshots.Add(_acl.Grant(TaskId, runtime.PowerShellRuntimeRoot, FileCapability.Read | FileCapability.Execute)); + ApprovedEntryExecutable[] approvals = + [ + new("powershell", runtime.PowerShell, EntryExecutablePolicy.ComputeSha256(runtime.PowerShell), true, runtime.PowerShellRuntimeRoot), + new("cmd", runtime.Cmd, EntryExecutablePolicy.ComputeSha256(runtime.Cmd), true, null), + new("venv-python", runtime.Python, EntryExecutablePolicy.ComputeSha256(runtime.Python), false, runtime.PythonRuntimeRoot) + ]; + string[] runtimeRoots = new[] { runtime.VenvRoot, runtime.PythonRuntimeRoot, runtime.PowerShellRuntimeRoot } + .OfType<string>().ToArray(); + var policy = new EntryExecutablePolicy(approvals, + runtimeRoots, [workspace]); + Launcher = new RestrictedProcessLauncher(policy, Audit, Profile.Sid, + new SandboxJobLimits(8, 1024UL * 1024 * 1024), allowNetwork); + } + + public AppContainerProfile Profile { get; } + public MemorySecurityAuditSink Audit { get; } + public Guid TaskId { get; } + public RestrictedProcessLauncher Launcher { get; } + public RestrictedProcessResult Run(string executable, IReadOnlyList<string> args, string cwd) => + Launcher.Run(TaskId, executable, args, cwd, TimeSpan.FromSeconds(15)); + + public void Dispose() + { + foreach (AclSnapshot snapshot in _snapshots.AsEnumerable().Reverse()) _acl.Restore(TaskId, snapshot); + } + } + + private sealed class NetworkLease : IDisposable + { + private readonly TaskResourceJournal _journal; + private readonly TaskNetworkPolicy _policy; + private NetworkLease(TaskResourceJournal journal, TaskNetworkPolicy policy) { _journal = journal; _policy = policy; } + public static NetworkLease Apply(SandboxScope scope, IReadOnlyList<NetworkEndpoint> endpoints) + { + string journalRoot = Path.Combine(Path.GetTempPath(), "agent-mcp-readme-network", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(journalRoot); + TaskResourceJournal journal = TaskResourceJournal.Create(journalRoot, scope.TaskId); + return new(journal, TaskNetworkPolicy.Apply(journal, scope.Profile.Sid, endpoints, scope.Audit)); + } + public void Dispose() + { + _policy.Dispose(); + _journal.CompleteAndDelete(); + string? root = Path.GetDirectoryName(_journal.Path); + if (root is not null && Directory.Exists(root)) Directory.Delete(root, true); + } + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory(string root) + { + Path = System.IO.Path.Combine(root, "agent-mcp-readme-test-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + public string Path { get; } + public void Dispose() { if (Directory.Exists(Path)) Directory.Delete(Path, true); } + } + + private sealed class OneShotHttpServer : IDisposable + { + private readonly TcpListener _listener = new(IPAddress.Loopback, 0); + private readonly CancellationTokenSource _stop = new(); + private readonly Task _serve; + private int _accepted; + public OneShotHttpServer() + { + _listener.Start(); + Port = ((IPEndPoint)_listener.LocalEndpoint).Port; + Url = $"http://127.0.0.1:{Port}/"; + _serve = ServeAsync(); + } + public int Port { get; } + public string Url { get; } + public bool Accepted => Volatile.Read(ref _accepted) != 0; + private async Task ServeAsync() + { + try + { + using TcpClient client = await _listener.AcceptTcpClientAsync(_stop.Token); + Interlocked.Exchange(ref _accepted, 1); + using NetworkStream stream = client.GetStream(); + byte[] buffer = new byte[4096]; + _ = await stream.ReadAsync(buffer, _stop.Token); + byte[] response = Encoding.ASCII.GetBytes("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nOK"); + await stream.WriteAsync(response, _stop.Token); + } + catch (OperationCanceledException) { } + catch (ObjectDisposedException) { } + catch (SocketException) when (_stop.IsCancellationRequested) { } + } + public void Dispose() + { + _stop.Cancel(); + _listener.Stop(); + try { _serve.Wait(TimeSpan.FromSeconds(2)); } catch (AggregateException) { } + _stop.Dispose(); + } + } +} diff --git a/tests/Agent.Windows.Tests/SecurityPolicyTests.cs b/tests/Agent.Windows.Tests/SecurityPolicyTests.cs index 09ed61d..d0c32a8 100644 --- a/tests/Agent.Windows.Tests/SecurityPolicyTests.cs +++ b/tests/Agent.Windows.Tests/SecurityPolicyTests.cs @@ -431,8 +431,10 @@ public sealed class SecurityPolicyTests } } - [Fact] - public void Lpac_baseline_allows_registry_read_but_denies_registry_write_and_com_activation() + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Lpac_capability_matrix_preserves_registry_and_com_denials(bool allowNetwork) { AppContainerProfile profile = AppContainerProfile.EnsureFixed(); using TestDirectory directory = new(); @@ -448,7 +450,7 @@ public sealed class SecurityPolicyTests { var launcher = new RestrictedProcessLauncher( new EntryExecutablePolicy([new ApprovedEntryExecutable("powershell", powershell, EntryExecutablePolicy.ComputeSha256(powershell), true, null)], [], []), - audit, profile.Sid, new SandboxJobLimits(2, 256UL * 1024 * 1024)); + audit, profile.Sid, new SandboxJobLimits(2, 256UL * 1024 * 1024), allowNetwork); string script = $"$ErrorActionPreference='Stop'; $p=(Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion').ProductName; " + $"$writeDenied=$false; try {{ New-Item -Path 'HKCU:\\Software\\{registryName}' -Force | Out-Null }} catch {{ $writeDenied=$true }}; " + "$comDenied=$false; try { $null=New-Object -ComObject Scripting.FileSystemObject } catch { $comDenied=$true }; " + @@ -464,6 +466,9 @@ public sealed class SecurityPolicyTests Assert.Contains("REGISTRY_WRITE_DENIED=True", result.StandardOutput, StringComparison.Ordinal); Assert.Contains("COM_DENIED=True", result.StandardOutput, StringComparison.Ordinal); Assert.Contains("BROKER_SECRET_INHERITED=False", result.StandardOutput, StringComparison.Ordinal); + Assert.Contains(audit.Events, item => item.Event == "process.start" && + item.Data["baseline_capabilities"] == "registryRead,lpacInstrumentation" && + item.Data["network_capabilities"] == (allowNetwork ? "internetClient,privateNetworkClientServer" : "none")); } finally { @@ -507,6 +512,31 @@ public sealed class SecurityPolicyTests } [Fact] + public void Approved_cmd_starts_unapproved_descendant_by_unqualified_name() + { + AppContainerProfile profile = AppContainerProfile.EnsureFixed(); + using TestDirectory directory = new(); + string cmd = Environment.GetEnvironmentVariable("ComSpec")!; + var audit = new MemorySecurityAuditSink(); + TaskAclManager acl = new(profile.Sid, audit); + Guid taskId = Guid.NewGuid(); + AclSnapshot snapshot = acl.Grant(taskId, directory.Path, FileCapability.Read | FileCapability.Write); + try + { + var launcher = new RestrictedProcessLauncher( + new EntryExecutablePolicy([new ApprovedEntryExecutable("cmd", cmd, EntryExecutablePolicy.ComputeSha256(cmd), true, null)], [], []), + audit, profile.Sid, new SandboxJobLimits(4, 512UL * 1024 * 1024)); + RestrictedProcessResult result = launcher.Run(taskId, "cmd", ["/d", "/c", "cmd.exe /d /c exit 0"], + directory.Path, TimeSpan.FromSeconds(30), null, null, TestContext.Current.CancellationToken); + Assert.Equal(0u, result.ExitCode); + Assert.Contains(audit.Events, item => item.Event == "process.start" && + item.Data["token_is_app_container"] == bool.TrueString && item.Data["process_in_job"] == bool.TrueString && + item.Data["descendant_policy"] == "lpac_inherited"); + } + finally { acl.Restore(taskId, snapshot); } + } + + [Fact] public void Approved_python314_starts_python_subprocess_inside_lpac_job() { AppContainerProfile profile = AppContainerProfile.EnsureFixed(); @@ -656,8 +686,8 @@ public sealed class SecurityPolicyTests snapshots.Add(acl.Grant(taskId, directory.Path, FileCapability.Read | FileCapability.Write | FileCapability.Delete | FileCapability.Execute)); snapshots.Add(acl.Grant(taskId, runtimeRoot, FileCapability.Read | FileCapability.Execute)); - // System32 owns a protected DACL. Do not rewrite it; successful cmd descendants below - // are the enforcement check for its existing LPAC read/execute access. + // System32 owns an OS-managed protected DACL. Do not rewrite it; descendant + // execution relies on its existing LPAC read/execute access. var launcher = new RestrictedProcessLauncher( new EntryExecutablePolicy([new ApprovedEntryExecutable("dotnet", dotnet, EntryExecutablePolicy.ComputeSha256(dotnet), false, runtimeRoot)], [runtimeRoot], [directory.Path]), audit, profile.Sid, new SandboxJobLimits(16, 1024UL * 1024 * 1024)); @@ -975,6 +1005,43 @@ public sealed class SecurityPolicyTests } [Fact] + public async Task Os_runtime_root_protected_dacl_is_never_modified() + { + using TestDirectory directory = new(); + string system32 = ResolvedPath.Resolve(Environment.SystemDirectory); + string cmd = ResolvedPath.Resolve(Environment.GetEnvironmentVariable("ComSpec")!); + DirectorySecurity security = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(system32), AccessControlSections.Access); + var raw = new RawSecurityDescriptor(security.GetSecurityDescriptorBinaryForm(), 0); + Assert.True(raw.ControlFlags.HasFlag(ControlFlags.DiscretionaryAclProtected)); + string before = Convert.ToHexString(security.GetSecurityDescriptorBinaryForm()); + string cmdBefore = Convert.ToHexString(FileSystemAclExtensions.GetAccessControl( + new FileInfo(cmd), AccessControlSections.Access).GetSecurityDescriptorBinaryForm()); + var audit = new MemorySecurityAuditSink(); + var settings = new EffectiveSandboxPolicy( + [new FilePolicyEntry(cmd, FileCapability.Read | FileCapability.Execute, "approved_shell_executable"), + new FilePolicyEntry(system32, FileCapability.Read | FileCapability.Execute, "os_runtime_root")], + [new ApprovedEntryExecutable("cmd", cmd, EntryExecutablePolicy.ComputeSha256(cmd), true, null)], [], [], + new SandboxJobLimits(), "none", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"), + 16 * 1024 * 1024, 1024 * 1024, 1000, 10000, BaselineCapabilities: ["registryRead", "lpacInstrumentation"]); + var backend = new WindowsToolBackend(settings, audit); + Guid taskId = Guid.NewGuid(); + + await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken); + await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken); + + string after = Convert.ToHexString(FileSystemAclExtensions.GetAccessControl( + new DirectoryInfo(system32), AccessControlSections.Access).GetSecurityDescriptorBinaryForm()); + string cmdAfter = Convert.ToHexString(FileSystemAclExtensions.GetAccessControl( + new FileInfo(cmd), AccessControlSections.Access).GetSecurityDescriptorBinaryForm()); + Assert.Equal(before, after); + Assert.Equal(cmdBefore, cmdAfter); + Assert.DoesNotContain(audit.Events, item => item.Event is ("acl.apply" or "acl.restore") && + item.Data.TryGetValue("path", out string? path) && + (string.Equals(path, system32, StringComparison.OrdinalIgnoreCase) || + string.Equals(path, cmd, StringComparison.OrdinalIgnoreCase))); + } + + [Fact] public async Task Dir_mkdir_creates_directory_within_approved_root_and_cleans_up() { using TestDirectory directory = new(); |
