summaryrefslogtreecommitdiff
path: root/tests/Agent.Windows.Tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests/Agent.Windows.Tests')
-rw-r--r--tests/Agent.Windows.Tests/ReadmeOperationalTests.cs446
-rw-r--r--tests/Agent.Windows.Tests/SecurityPolicyTests.cs77
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();