diff options
Diffstat (limited to 'tests/Agent.Windows.Tests/SecurityPolicyTests.cs')
| -rw-r--r-- | tests/Agent.Windows.Tests/SecurityPolicyTests.cs | 579 |
1 files changed, 579 insertions, 0 deletions
diff --git a/tests/Agent.Windows.Tests/SecurityPolicyTests.cs b/tests/Agent.Windows.Tests/SecurityPolicyTests.cs new file mode 100644 index 0000000..d54ab13 --- /dev/null +++ b/tests/Agent.Windows.Tests/SecurityPolicyTests.cs @@ -0,0 +1,579 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Security.AccessControl; +using System.Text; +using Agent.Windows.Recovery; +using Agent.Windows.Security; +using Agent.Windows.Network; +using Agent.Windows.Tools; +using Agent.Core.Tools; +using System.Text.Json; + +namespace Agent.Windows.Tests; + +public sealed class SecurityPolicyTests +{ + [Fact] + public void Read_write_and_delete_capabilities_are_independent() + { + using TestDirectory directory = new(); + string file = Path.Combine(directory.Path, "file.txt"); + File.WriteAllText(file, "data"); + PathPolicy policy = new([new FilePolicyEntry(directory.Path, FileCapability.Read | FileCapability.Write)]); + + Assert.True(policy.IsAllowed(file, FileCapability.Read)); + Assert.True(policy.IsAllowed(Path.Combine(directory.Path, "new.txt"), FileCapability.Write)); + Assert.False(policy.IsAllowed(file, FileCapability.Delete)); + Assert.Throws<SandboxPolicyException>(() => policy.Demand(file, FileCapability.Delete)); + } + + [Fact] + public void Path_outside_approved_root_is_denied() + { + using TestDirectory approved = new(); + using TestDirectory outside = new(); + string secret = Path.Combine(outside.Path, "secret.txt"); + File.WriteAllText(secret, "secret"); + PathPolicy policy = new([new FilePolicyEntry(approved.Path, FileCapability.Read)]); + + Assert.False(policy.IsAllowed(secret, FileCapability.Read)); + } + + [Fact] + public void Reparse_escape_is_rejected() + { + using TestDirectory approved = new(); + using TestDirectory outside = new(); + string link = Path.Combine(approved.Path, "escape"); + using Process junction = Process.Start(new ProcessStartInfo + { + FileName = Environment.GetEnvironmentVariable("ComSpec")!, + UseShellExecute = false, + CreateNoWindow = true, + ArgumentList = { "/d", "/c", "mklink", "/J", link, outside.Path } + })!; + junction.WaitForExit(); + Assert.Equal(0, junction.ExitCode); + PathPolicy policy = new([new FilePolicyEntry(approved.Path, FileCapability.Read)]); + + try + { + SandboxPolicyException error = Assert.Throws<SandboxPolicyException>(() => policy.IsAllowed(Path.Combine(link, "secret.txt"), FileCapability.Read)); + Assert.Equal("path.reparse", error.Code); + } + finally + { + Directory.Delete(link); + } + } + + [Fact] + public void Symlink_chain_escape_is_rejected() + { + using TestDirectory approved = new(); + using TestDirectory outside = new(); + string second = Path.Combine(approved.Path, "second-link"); + string first = Path.Combine(approved.Path, "first-link"); + Directory.CreateSymbolicLink(second, outside.Path); + Directory.CreateSymbolicLink(first, second); + PathPolicy policy = new([new FilePolicyEntry(approved.Path, FileCapability.Read)]); + + SandboxPolicyException error = Assert.Throws<SandboxPolicyException>(() => + policy.IsAllowed(Path.Combine(first, "secret.txt"), FileCapability.Read)); + + Assert.Equal("path.reparse", error.Code); + Directory.Delete(first); + Directory.Delete(second); + } + + [Fact] + public void Executable_requires_approved_path_and_hash_and_shell_toggle() + { + string executable = Environment.ProcessPath!; + string hash = ExecutablePolicy.ComputeSha256(executable); + Assert.Equal(hash, new ExecutablePolicy([new ExecutableApproval(executable, hash)], true).Verify(executable).Sha256); + Assert.Throws<SandboxPolicyException>(() => new ExecutablePolicy([], true).Verify(executable)); + string badHash = new string(hash[0] == '0' ? '1' : '0', 1) + hash[1..]; + Assert.Throws<SandboxPolicyException>(() => new ExecutablePolicy([new ExecutableApproval(executable, badHash)], true).Verify(executable)); + + string powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell", "v1.0", "powershell.exe"); + Assert.Throws<SandboxPolicyException>(() => new ExecutablePolicy([new ExecutableApproval(powershell, ExecutablePolicy.ComputeSha256(powershell))], false).Verify(powershell)); + } + + [Fact] + public void Restricted_parent_cannot_start_unbrokered_descendant() + { + using TestDirectory directory = new(); + string powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell", "v1.0", "powershell.exe"); + var audit = new MemorySecurityAuditSink(); + var launcher = new RestrictedProcessLauncher( + new ExecutablePolicy([new ExecutableApproval(powershell, ExecutablePolicy.ComputeSha256(powershell))], true), audit); + string copied = Path.Combine(directory.Path, "renamed-child.exe"); + File.Copy(Environment.GetEnvironmentVariable("ComSpec")!, copied); + string[] markers = Enumerable.Range(1, 4).Select(index => Path.Combine(directory.Path, $"descendant-{index}.txt")).ToArray(); + string[] commands = + [ + $"$ErrorActionPreference='Stop'; Start-Process -FilePath $env:ComSpec -ArgumentList '/d','/c','echo compromised > \"{markers[0]}\"' -Wait", + $"$ErrorActionPreference='Stop'; [Diagnostics.Process]::Start($env:ComSpec, '/d /c echo compromised ^> \"{markers[1]}\"').WaitForExit()", + $"$ErrorActionPreference='Stop'; & $env:ComSpec /d /c 'echo compromised > \"{markers[2]}\"'", + $"$ErrorActionPreference='Stop'; Start-Process -FilePath '{copied}' -ArgumentList '/d','/c','echo compromised > \"{markers[3]}\"' -Wait" + ]; + + foreach (string command in commands) + { + RestrictedProcessResult result = launcher.Run(Guid.NewGuid(), powershell, + ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], directory.Path, TimeSpan.FromSeconds(30)); + Assert.NotEqual(0u, result.ExitCode); + } + + Assert.All(markers, marker => Assert.False(File.Exists(marker))); + Assert.Contains(audit.Events, item => item.Event == "process.start" && item.Data["child_process_policy"] == "restricted"); + Assert.Contains(audit.Events, item => item.Event == "process.exit" && item.Data["exit_code"] != "0"); + Assert.Equal(commands.Length, audit.Events.Count(item => item.Event == "policy.deny" && item.Data["child_process_policy"] == "restricted")); + } + + [Fact] + public void Job_timeout_terminates_the_entire_sandbox_job_without_orphan() + { + string powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell", "v1.0", "powershell.exe"); + var audit = new MemorySecurityAuditSink(); + var launcher = new RestrictedProcessLauncher( + new ExecutablePolicy([new ExecutableApproval(powershell, ExecutablePolicy.ComputeSha256(powershell))], true), audit); + + Assert.Throws<TimeoutException>(() => launcher.Run(Guid.NewGuid(), powershell, + ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "Start-Sleep -Seconds 30"], + Environment.SystemDirectory, TimeSpan.FromMilliseconds(250))); + + SecurityAuditEvent timeout = Assert.Single(audit.Events, item => item.Event == "process.timeout"); + int pid = int.Parse(timeout.Data["pid"], System.Globalization.CultureInfo.InvariantCulture); + Assert.Throws<ArgumentException>(() => Process.GetProcessById(pid)); + } + + [Fact] + public void Dotnet_managed_entry_requires_its_own_path_and_hash_approval() + { + string dotnet = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "dotnet", "dotnet.exe"); + string managedAssembly = typeof(SecurityPolicyTests).Assembly.Location; + var audit = new MemorySecurityAuditSink(); + var launcher = new RestrictedProcessLauncher( + new ExecutablePolicy([new ExecutableApproval(dotnet, ExecutablePolicy.ComputeSha256(dotnet))], true), audit); + + SandboxPolicyException denied = Assert.Throws<SandboxPolicyException>(() => launcher.Run( + Guid.NewGuid(), dotnet, [managedAssembly], Path.GetDirectoryName(managedAssembly)!, TimeSpan.FromSeconds(5))); + + Assert.Equal("process.path_denied", denied.Code); + Assert.Contains(audit.Events, item => item.Event == "policy.deny" && item.Data["code"] == "process.path_denied"); + } + + [Fact] + public void Fixed_app_container_is_stable_and_acl_is_restored_exactly() + { + AppContainerProfile first = AppContainerProfile.EnsureFixed(); + AppContainerProfile second = AppContainerProfile.EnsureFixed(); + Assert.Equal(AppContainerProfile.FixedName, first.Name); + Assert.Equal(first.Sid.Value, second.Sid.Value); + + using TestDirectory directory = new(); + byte[] before = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(directory.Path), AccessControlSections.Access).GetSecurityDescriptorBinaryForm(); + var audit = new MemorySecurityAuditSink(); + TaskAclManager manager = new(first.Sid, audit); + Guid taskId = Guid.NewGuid(); + AclSnapshot snapshot = manager.Grant(taskId, directory.Path, FileCapability.Read | FileCapability.Write); + byte[] during = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(directory.Path), AccessControlSections.Access).GetSecurityDescriptorBinaryForm(); + Assert.NotEqual(Convert.ToHexString(before), Convert.ToHexString(during)); + + manager.Restore(taskId, snapshot); + + byte[] after = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(directory.Path), AccessControlSections.Access).GetSecurityDescriptorBinaryForm(); + Assert.Equal(Convert.ToHexString(before), Convert.ToHexString(after)); + Assert.Contains(audit.Events, item => item.Event == "acl.apply"); + Assert.Contains(audit.Events, item => item.Event == "acl.restore"); + } + + [Fact] + public void Brokered_process_runs_in_fixed_app_container_with_task_acl() + { + AppContainerProfile profile = AppContainerProfile.EnsureFixed(); + using TestDirectory directory = new(); + string marker = Path.Combine(directory.Path, "appcontainer-write.txt"); + string commandProcessor = 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 ExecutablePolicy([new ExecutableApproval(commandProcessor, ExecutablePolicy.ComputeSha256(commandProcessor))], true), + audit, profile.Sid, new SandboxJobLimits(2, 256UL * 1024 * 1024)); + + RestrictedProcessResult result = launcher.Run(taskId, commandProcessor, ["/d", "/c", $"echo APP_OK>{marker} & echo CAPTURE_OK"], directory.Path, TimeSpan.FromSeconds(30)); + + Assert.Equal(0u, result.ExitCode); + Assert.Equal("APP_OK", File.ReadAllText(marker).Trim()); + Assert.Contains("CAPTURE_OK", result.StandardOutput, StringComparison.Ordinal); + Assert.Contains(audit.Events, item => item.Event == "process.start" && item.Data["app_container_sid"] == profile.Sid.Value); + } + finally + { + acl.Restore(taskId, snapshot); + } + } + + [Fact] + public void File_transaction_commit_rollback_and_hash_guard_leave_no_journal() + { + using TestDirectory directory = new(); + string journals = Path.Combine(directory.Path, "journals"); + string backups = Path.Combine(directory.Path, "backups"); + string target = Path.Combine(directory.Path, "target.txt"); + File.WriteAllText(target, "original"); + TaskResourceJournal journal = TaskResourceJournal.Create(journals, Guid.NewGuid()); + TransactionalFileEditor editor = new(journal, backups, memoryBackupLimitBytes: 1); + + FileEditTransaction rollback = editor.Replace(target, "replacement"); + Assert.Equal("replacement", File.ReadAllText(target)); + rollback.Rollback(); + Assert.Equal("original", File.ReadAllText(target)); + + FileEditTransaction commit = editor.Replace(target, "committed"); + commit.Commit(); + journal.CompleteAndDelete(); + + Assert.Equal("committed", File.ReadAllText(target)); + Assert.Empty(Directory.EnumerateFiles(journals)); + Assert.False(Directory.Exists(Path.Combine(backups, journal.TaskId.ToString("N")))); + } + + [Fact] + public void Recovery_rolls_back_applied_file_in_reverse_cleanup_path() + { + using TestDirectory directory = new(); + string journals = Path.Combine(directory.Path, "journals"); + string backups = Path.Combine(directory.Path, "backups"); + string target = Path.Combine(directory.Path, "target.txt"); + File.WriteAllText(target, "before-crash"); + TaskResourceJournal journal = TaskResourceJournal.Create(journals, Guid.NewGuid()); + _ = new TransactionalFileEditor(journal, backups, memoryBackupLimitBytes: 1).Replace(target, "after-crash"); + + RecoveryResult result = new RecoveryManager(journals, new MemorySecurityAuditSink()).RecoverAll(); + + Assert.True(result.IsClean); + Assert.Equal("before-crash", File.ReadAllText(target)); + Assert.Empty(Directory.EnumerateFiles(journals)); + Assert.False(Directory.Exists(Path.Combine(backups, journal.TaskId.ToString("N")))); + } + + [Fact] + public void Recovery_after_crash_restores_acl_loopback_and_dynamic_wfp_state() + { + AppContainerProfile profile = AppContainerProfile.EnsureFixed(); + using TestDirectory directory = new(); + string journals = Path.Combine(directory.Path, "journals"); + string resource = Path.Combine(directory.Path, "resource"); + Directory.CreateDirectory(resource); + byte[] aclBefore = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(resource), AccessControlSections.Access) + .GetSecurityDescriptorBinaryForm(); + var audit = new MemorySecurityAuditSink(); + Guid taskId = Guid.NewGuid(); + TaskResourceJournal journal = TaskResourceJournal.Create(journals, taskId); + + var acl = new TaskAclManager(profile.Sid, audit, journal); + _ = acl.Grant(taskId, resource, FileCapability.Read | FileCapability.Write); + + Assert.False(LoopbackExemption.Contains(profile.Sid)); + Guid loopbackEntry = journal.Prepare("network.loopback_exemption", profile.Sid.Value, + new Dictionary<string, string> { ["originally_present"] = bool.FalseString }); + Assert.True(LoopbackExemption.Ensure(profile.Sid)); + journal.MarkApplied(loopbackEntry, new Dictionary<string, string> { ["added"] = bool.TrueString }); + + Guid wfpEntry = journal.Prepare("wfp.dynamic", profile.Sid.Value, + new Dictionary<string, string> { ["session"] = "dynamic", ["allowances"] = string.Empty }); + using (WfpDynamicSession crashedSession = WfpDynamicSession.Open()) + { + IReadOnlyList<ulong> filterIds = crashedSession.ApplyDefaultBlock(profile.Sid); + journal.MarkApplied(wfpEntry, new Dictionary<string, string> { ["filter_ids"] = string.Join(',', filterIds) }); + // Closing the engine without RemovePolicy models abrupt process exit: + // BFE owns and deletes every object in this dynamic session. + } + + RecoveryResult recovered = new RecoveryManager(journals, audit).RecoverAll(); + + Assert.True(recovered.IsClean); + Assert.Equal(1, recovered.JournalsRecovered); + Assert.Equal(3, recovered.ResourcesRecovered); + Assert.False(LoopbackExemption.Contains(profile.Sid)); + byte[] aclAfter = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(resource), AccessControlSections.Access) + .GetSecurityDescriptorBinaryForm(); + Assert.Equal(Convert.ToHexString(aclBefore), Convert.ToHexString(aclAfter)); + Assert.Empty(Directory.EnumerateFiles(journals)); + } + + [Theory] + [InlineData("acl")] + [InlineData("acl-before")] + [InlineData("file-before")] + [InlineData("file-replace")] + [InlineData("process-after")] + [InlineData("cleanup-mid")] + [InlineData("wfp")] + public void Independent_process_crash_is_fully_recovered_on_next_start(string scenario) + { + using TestDirectory directory = new(); + string resourceDirectory = Path.Combine(directory.Path, "resource"); + string resourceFile = Path.Combine(directory.Path, "resource.txt"); + Directory.CreateDirectory(resourceDirectory); + File.WriteAllText(resourceFile, "before-crash"); + byte[] aclBefore = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(resourceDirectory), AccessControlSections.Access) + .GetSecurityDescriptorBinaryForm(); + Guid taskId = Guid.NewGuid(); + using var child = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = Environment.ProcessPath!, + UseShellExecute = false, + CreateNoWindow = true, + Environment = + { + ["AGENT_MCP_CRASH_SCENARIO"] = scenario, + ["AGENT_MCP_CRASH_ROOT"] = directory.Path, + ["AGENT_MCP_CRASH_TASK"] = taskId.ToString("D") + } + } + }; + Assert.True(child.Start()); + Assert.True(child.WaitForExit(60_000)); + Assert.NotEqual(0, child.ExitCode); + Assert.False(File.Exists(Path.Combine(directory.Path, "crash-error.txt")), + File.Exists(Path.Combine(directory.Path, "crash-error.txt")) ? File.ReadAllText(Path.Combine(directory.Path, "crash-error.txt")) : string.Empty); + Assert.Single(Directory.EnumerateFiles(Path.Combine(directory.Path, "journals"))); + + RecoveryResult recovered = new RecoveryManager(Path.Combine(directory.Path, "journals"), new MemorySecurityAuditSink()).RecoverAll(); + + Assert.True(recovered.IsClean, string.Join(Environment.NewLine, recovered.Errors)); + Assert.Empty(Directory.EnumerateFiles(Path.Combine(directory.Path, "journals"))); + Assert.Equal("before-crash", File.ReadAllText(resourceFile)); + byte[] aclAfter = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(resourceDirectory), AccessControlSections.Access) + .GetSecurityDescriptorBinaryForm(); + Assert.Equal(Convert.ToHexString(aclBefore), Convert.ToHexString(aclAfter)); + Assert.False(LoopbackExemption.Contains(AppContainerProfile.EnsureFixed().Sid)); + string backups = Path.Combine(directory.Path, "backups"); + Assert.True(!Directory.Exists(backups) || !Directory.EnumerateFileSystemEntries(backups).Any()); + if (scenario == "process-after") + { + string auditPath = Path.Combine(directory.Path, "crash-audit.jsonl"); + using JsonDocument audit = JsonDocument.Parse(File.ReadLines(auditPath).Single(line => line.Contains("process.start", StringComparison.Ordinal))); + int sandboxPid = int.Parse(audit.RootElement.GetProperty("Data").GetProperty("pid").GetString()!, + System.Globalization.CultureInfo.InvariantCulture); + Assert.True(SpinWait.SpinUntil(() => !ProcessExists(sandboxPid), TimeSpan.FromSeconds(5)), $"Orphan sandbox PID {sandboxPid} survived broker crash."); + } + } + + private static bool ProcessExists(int pid) + { + try { using Process process = Process.GetProcessById(pid); return !process.HasExited; } + catch (ArgumentException) { return false; } + } + + [Fact] + public void Rollback_refuses_to_overwrite_post_transaction_external_change() + { + using TestDirectory directory = new(); + string target = Path.Combine(directory.Path, "target.txt"); + File.WriteAllText(target, "before"); + TaskResourceJournal journal = TaskResourceJournal.Create(Path.Combine(directory.Path, "journals"), Guid.NewGuid()); + FileEditTransaction transaction = new TransactionalFileEditor(journal, Path.Combine(directory.Path, "backups")).Replace(target, "replacement"); + File.WriteAllText(target, "external-change"); + + Assert.Throws<InvalidOperationException>(() => transaction.Rollback()); + + File.WriteAllText(target, "replacement"); + transaction.Rollback(); + journal.CompleteAndDelete(); + } + + [Fact] + public void Wfp_dynamic_session_adds_and_explicitly_removes_ipv4_ipv6_package_filters() + { + AppContainerProfile profile = AppContainerProfile.EnsureFixed(); + using WfpDynamicSession session = WfpDynamicSession.Open(); + session.ProbeWriteTransaction(); + + IReadOnlyList<ulong> ids = session.ApplyDefaultBlock(profile.Sid, + [new NetworkEndpoint(IPAddress.Loopback, 11434), new NetworkEndpoint(IPAddress.IPv6Loopback, 11434)]); + + Assert.Equal(4, ids.Count); + Assert.All(ids, id => Assert.NotEqual(0UL, id)); + session.RemovePolicy(); + } + + [Fact] + public async Task Windows_backend_applies_file_effect_and_cleans_acl_wfp_temp_and_journal() + { + using TestDirectory directory = new(); + string workspace = Path.Combine(directory.Path, "workspace"); + Directory.CreateDirectory(workspace); + string target = Path.Combine(workspace, "target.txt"); + File.WriteAllText(target, "before"); + byte[] aclBefore = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(workspace), AccessControlSections.Access).GetSecurityDescriptorBinaryForm(); + var audit = new MemorySecurityAuditSink(); + var settings = new WindowsSandboxSettings( + [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], false, + new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"), + 16 * 1024 * 1024, 1024 * 1024, 1000, 10000); + var backend = new WindowsToolBackend(settings, audit); + Guid taskId = Guid.NewGuid(); + await backend.BeginTaskAsync(taskId, TestContext.Current.CancellationToken); + using JsonDocument edit = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(target)}},"operation":"overwrite","content":"after"}"""); + + ToolExecutionResult result = await backend.ExecuteAsync(taskId, "file_edit", edit.RootElement, TestContext.Current.CancellationToken); + await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.True(result.ExternalEffect); + Assert.Equal("after", File.ReadAllText(target)); + byte[] aclAfter = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(workspace), AccessControlSections.Access).GetSecurityDescriptorBinaryForm(); + Assert.Equal(Convert.ToHexString(aclBefore), Convert.ToHexString(aclAfter)); + Assert.Empty(Directory.EnumerateFiles(Path.Combine(directory.Path, "journals"))); + string backups = Path.Combine(directory.Path, "backups"); + Assert.True(!Directory.Exists(backups) || !Directory.EnumerateFileSystemEntries(backups).Any()); + Assert.Contains(audit.Events, item => item.Event == "wfp.remove" && item.Data["residual_filters"] == "0"); + Assert.Contains(audit.Events, item => item.Event == "acl.restore"); + } + + [Fact(Timeout = 60_000)] + public async Task Wfp_enforces_actual_appcontainer_loopback_deny_allow_and_port_scope() + { + AppContainerProfile profile = AppContainerProfile.EnsureFixed(); + string curl = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "curl.exe"); + var audit = new MemorySecurityAuditSink(); + var launcher = new RestrictedProcessLauncher( + new ExecutablePolicy([new ExecutableApproval(curl, ExecutablePolicy.ComputeSha256(curl))], true), + audit, profile.Sid, new SandboxJobLimits(2, 256UL * 1024 * 1024)); + using TestDirectory directory = new(); + + bool diagnosticExemptionAdded = LoopbackExemption.Ensure(profile.Sid); + try + { + using var isolationControl = new OneShotHttpServer(IPAddress.Loopback); + RestrictedProcessResult isolationResult = Curl(launcher, Guid.NewGuid(), curl, isolationControl.Url, Environment.SystemDirectory); + Assert.True(isolationResult.ExitCode == 0u, + $"Loopback exemption did not enable AppContainer traffic before WFP was applied. exit {isolationResult.ExitCode}, stderr: {isolationResult.StandardError}"); + Assert.True(isolationControl.Accepted); + } + finally + { + if (diagnosticExemptionAdded) LoopbackExemption.Remove(profile.Sid); + } + + using (var deniedServer = new OneShotHttpServer(IPAddress.Loopback)) + { + Guid taskId = Guid.NewGuid(); + TaskResourceJournal journal = TaskResourceJournal.Create(Path.Combine(directory.Path, "deny-journal"), taskId); + using (TaskNetworkPolicy.Apply(journal, profile.Sid, [], audit)) + { + RestrictedProcessResult denied = Curl(launcher, taskId, curl, deniedServer.Url, Environment.SystemDirectory); + Assert.NotEqual(0u, denied.ExitCode); + Assert.False(deniedServer.Accepted); + } + journal.CompleteAndDelete(); + } + + IPAddress allowedAddress = IPAddress.Loopback; + using (var controlServer = new OneShotHttpServer(allowedAddress)) + using (var controlClient = new HttpClient { Timeout = TimeSpan.FromSeconds(2) }) + { + string controlBody = await controlClient.GetStringAsync(controlServer.Url, TestContext.Current.CancellationToken); + Assert.Equal("WFP_OK", controlBody); + Assert.True(controlServer.Accepted); + } + using var allowedV4 = new OneShotHttpServer(allowedAddress); + using var deniedPort = new OneShotHttpServer(allowedAddress); + Guid allowTaskId = Guid.NewGuid(); + TaskResourceJournal allowJournal = TaskResourceJournal.Create(Path.Combine(directory.Path, "allow-journal"), allowTaskId); + IReadOnlyList<NetworkEndpoint> allowances = + [ + new(allowedAddress, checked((ushort)allowedV4.Port)) + ]; + using (TaskNetworkPolicy.Apply(allowJournal, profile.Sid, allowances, audit)) + { + RestrictedProcessResult v4 = Curl(launcher, allowTaskId, curl, allowedV4.Url, Environment.SystemDirectory); + RestrictedProcessResult wrongPort = Curl(launcher, allowTaskId, curl, deniedPort.Url, Environment.SystemDirectory); + Assert.True(v4.ExitCode == 0u, $"Allowed AppContainer curl failed with exit {v4.ExitCode}. stderr: {v4.StandardError}"); + Assert.Contains("WFP_OK", v4.StandardOutput, StringComparison.Ordinal); + Assert.True(allowedV4.Accepted); + Assert.NotEqual(0u, wrongPort.ExitCode); + Assert.False(deniedPort.Accepted); + } + allowJournal.CompleteAndDelete(); + Assert.Contains(audit.Events, item => item.Event == "wfp.remove" && item.Data["residual_filters"] == "0"); + } + + private static RestrictedProcessResult Curl(RestrictedProcessLauncher launcher, Guid taskId, string curl, string url, string cwd) => + launcher.Run(taskId, curl, ["--noproxy", "*", "--silent", "--show-error", "--max-time", "2", url], cwd, TimeSpan.FromSeconds(10)); + + private sealed class TestDirectory : IDisposable + { + public TestDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "agent-mcp-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + public string Path { get; } + public void Dispose() => Directory.Delete(Path, recursive: true); + } + + private sealed class OneShotHttpServer : IDisposable + { + private readonly TcpListener _listener; + private readonly CancellationTokenSource _stop = new(); + private readonly Task _serve; + private int _accepted; + + public OneShotHttpServer(IPAddress address) + { + _listener = new TcpListener(address, 0); + _listener.Start(); + Port = ((IPEndPoint)_listener.LocalEndpoint).Port; + string host = address.AddressFamily == AddressFamily.InterNetworkV6 ? $"[{address}]" : address.ToString(); + Url = $"http://{host}:{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[] request = new byte[4096]; + int received = 0; + while (received < request.Length) + { + int count = await stream.ReadAsync(request.AsMemory(received), _stop.Token); + if (count == 0) break; + received += count; + if (received >= 4 && request.AsSpan(0, received).IndexOf("\r\n\r\n"u8) >= 0) break; + } + byte[] response = Encoding.ASCII.GetBytes("HTTP/1.1 200 OK\r\nContent-Length: 6\r\nConnection: close\r\n\r\nWFP_OK"); + await stream.WriteAsync(response, _stop.Token); + } + catch (Exception ex) when (ex is OperationCanceledException or SocketException or ObjectDisposedException) { } + } + + public void Dispose() + { + _stop.Cancel(); + _listener.Stop(); + try { _serve.Wait(TimeSpan.FromSeconds(2)); } catch (AggregateException) { } + _stop.Dispose(); + } + } +} |
