diff options
Diffstat (limited to 'src/Agent.Windows/Security')
| -rw-r--r-- | src/Agent.Windows/Security/AppContainerProfile.cs | 41 | ||||
| -rw-r--r-- | src/Agent.Windows/Security/ExecutablePolicy.cs | 51 | ||||
| -rw-r--r-- | src/Agent.Windows/Security/PathPolicy.cs | 52 | ||||
| -rw-r--r-- | src/Agent.Windows/Security/RestrictedProcessLauncher.cs | 255 | ||||
| -rw-r--r-- | src/Agent.Windows/Security/SecurityAudit.cs | 29 | ||||
| -rw-r--r-- | src/Agent.Windows/Security/TaskAclManager.cs | 76 | ||||
| -rw-r--r-- | src/Agent.Windows/Security/UserOnlyDirectory.cs | 20 |
7 files changed, 524 insertions, 0 deletions
diff --git a/src/Agent.Windows/Security/AppContainerProfile.cs b/src/Agent.Windows/Security/AppContainerProfile.cs new file mode 100644 index 0000000..47aa64e --- /dev/null +++ b/src/Agent.Windows/Security/AppContainerProfile.cs @@ -0,0 +1,41 @@ +using System.Runtime.InteropServices; +using System.Security.Principal; + +namespace Agent.Windows.Security; + +public sealed partial class AppContainerProfile +{ + public const string FixedName = "AgentSandboxMcp.V1"; + private const int AlreadyExists = unchecked((int)0x800700B7); + + private AppContainerProfile(string name, SecurityIdentifier sid) { Name = name; Sid = sid; } + public string Name { get; } + public SecurityIdentifier Sid { get; } + + public static AppContainerProfile EnsureFixed() + { + int result = Native.CreateAppContainerProfile(FixedName, "Agent Sandbox MCP v1", "Fixed sandbox profile for Agent Sandbox MCP", 0, 0, out nint sidPointer); + if (result == AlreadyExists) result = Native.DeriveAppContainerSidFromAppContainerName(FixedName, out sidPointer); + if (result < 0) Marshal.ThrowExceptionForHR(result); + try + { + return new(FixedName, new SecurityIdentifier(sidPointer)); + } + finally + { + if (sidPointer != 0) _ = Native.FreeSid(sidPointer); + } + } + + private static partial class Native + { + [LibraryImport("userenv.dll", StringMarshalling = StringMarshalling.Utf16)] + internal static partial int CreateAppContainerProfile(string name, string displayName, string description, nint capabilities, uint capabilityCount, out nint sid); + + [LibraryImport("userenv.dll", StringMarshalling = StringMarshalling.Utf16)] + internal static partial int DeriveAppContainerSidFromAppContainerName(string name, out nint sid); + + [LibraryImport("advapi32.dll")] + internal static partial nint FreeSid(nint sid); + } +} diff --git a/src/Agent.Windows/Security/ExecutablePolicy.cs b/src/Agent.Windows/Security/ExecutablePolicy.cs new file mode 100644 index 0000000..3d20bfa --- /dev/null +++ b/src/Agent.Windows/Security/ExecutablePolicy.cs @@ -0,0 +1,51 @@ +using System.Security.Cryptography; + +namespace Agent.Windows.Security; + +public sealed record ExecutableApproval(string Path, string Sha256); +public sealed record VerifiedExecutable(string CanonicalPath, string Sha256); + +public sealed class ExecutablePolicy(IEnumerable<ExecutableApproval> approvals, bool allowShell) +{ + private readonly IReadOnlyDictionary<string, string> _approvals = approvals.ToDictionary( + approval => CanonicalPath.Resolve(approval.Path), approval => NormalizeHash(approval.Sha256), StringComparer.OrdinalIgnoreCase); + + public VerifiedExecutable Verify(string executable) + { + string canonical = CanonicalPath.Resolve(executable); + if (!allowShell && IsShell(canonical)) throw new SandboxPolicyException("process.shell_denied", "Shell execution is disabled."); + if (!_approvals.TryGetValue(canonical, out string? approvedHash)) throw new SandboxPolicyException("process.path_denied", "Executable path is not approved."); + using FileStream stream = new(canonical, FileMode.Open, FileAccess.Read, FileShare.Read); + string actualHash = Convert.ToHexString(SHA256.HashData(stream)); + if (!CryptographicOperations.FixedTimeEquals(Convert.FromHexString(approvedHash), Convert.FromHexString(actualHash))) + throw new SandboxPolicyException("process.hash_denied", "Executable SHA-256 does not match its approval."); + return new(canonical, actualHash); + } + + public static string? ManagedEntryPoint(string executable, IReadOnlyList<string> arguments) + { + if (!Path.GetFileName(executable).Equals("dotnet.exe", StringComparison.OrdinalIgnoreCase) || arguments.Count == 0) return null; + if (arguments[0].Equals("exec", StringComparison.OrdinalIgnoreCase)) + { + if (arguments.Count < 2 || arguments[1].StartsWith("-", StringComparison.Ordinal)) + throw new SandboxPolicyException("process.managed_entry_invalid", "dotnet exec requires an explicit managed entry assembly as its next argument."); + return arguments[1]; + } + return arguments[0].EndsWith(".dll", StringComparison.OrdinalIgnoreCase) ? arguments[0] : null; + } + + public static string ComputeSha256(string executable) + { + using FileStream stream = new(CanonicalPath.Resolve(executable), FileMode.Open, FileAccess.Read, FileShare.Read); + return Convert.ToHexString(SHA256.HashData(stream)); + } + + private static bool IsShell(string path) => Path.GetFileName(path) is string name && + (name.Equals("cmd.exe", StringComparison.OrdinalIgnoreCase) || name.Equals("powershell.exe", StringComparison.OrdinalIgnoreCase) || name.Equals("pwsh.exe", StringComparison.OrdinalIgnoreCase)); + private static string NormalizeHash(string hash) + { + string normalized = hash.Replace("-", string.Empty, StringComparison.Ordinal).Trim().ToUpperInvariant(); + if (normalized.Length != 64 || !normalized.All(Uri.IsHexDigit)) throw new ArgumentException("SHA-256 approval must contain 64 hexadecimal characters.", nameof(hash)); + return normalized; + } +} diff --git a/src/Agent.Windows/Security/PathPolicy.cs b/src/Agent.Windows/Security/PathPolicy.cs new file mode 100644 index 0000000..32a949c --- /dev/null +++ b/src/Agent.Windows/Security/PathPolicy.cs @@ -0,0 +1,52 @@ +namespace Agent.Windows.Security; + +[Flags] +public enum FileCapability { None = 0, Read = 1, Write = 2, Execute = 4, Delete = 8 } + +public sealed record FilePolicyEntry(string Path, FileCapability Capabilities); + +public sealed class PathPolicy(IEnumerable<FilePolicyEntry> entries) +{ + private readonly IReadOnlyList<FilePolicyEntry> _entries = entries.Select(entry => entry with { Path = CanonicalPath.Resolve(entry.Path, requireExisting: false) }).ToArray(); + + public bool IsAllowed(string path, FileCapability required) + { + string canonical = CanonicalPath.Resolve(path, requireExisting: required is FileCapability.Read or FileCapability.Execute or FileCapability.Delete); + return _entries.Any(entry => IsAtOrBelow(canonical, entry.Path) && (entry.Capabilities & required) == required); + } + + public void Demand(string path, FileCapability required) + { + if (!IsAllowed(path, required)) throw new SandboxPolicyException("policy.deny", $"'{path}' is not approved for {required}."); + } + + private static bool IsAtOrBelow(string candidate, string root) => + candidate.Equals(root, StringComparison.OrdinalIgnoreCase) || + candidate.StartsWith(root.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); +} + +public static class CanonicalPath +{ + public static string Resolve(string path, bool requireExisting = true) + { + if (string.IsNullOrWhiteSpace(path)) throw new SandboxPolicyException("path.invalid", "Path is required."); + string fullPath = Path.GetFullPath(path); + string? root = Path.GetPathRoot(fullPath); + if (string.IsNullOrEmpty(root)) throw new SandboxPolicyException("path.invalid", "Path has no filesystem root."); + string current = root; + foreach (string segment in fullPath[root.Length..].Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries)) + { + current = Path.Combine(current, segment); + if (!File.Exists(current) && !Directory.Exists(current)) continue; + if ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) + throw new SandboxPolicyException("path.reparse", $"Reparse points are not allowed: '{current}'."); + } + if (requireExisting && !File.Exists(fullPath) && !Directory.Exists(fullPath)) throw new FileNotFoundException("Approved path does not exist.", fullPath); + return Path.TrimEndingDirectorySeparator(fullPath); + } +} + +public sealed class SandboxPolicyException(string code, string message) : UnauthorizedAccessException(message) +{ + public string Code { get; } = code; +} diff --git a/src/Agent.Windows/Security/RestrictedProcessLauncher.cs b/src/Agent.Windows/Security/RestrictedProcessLauncher.cs new file mode 100644 index 0000000..0786150 --- /dev/null +++ b/src/Agent.Windows/Security/RestrictedProcessLauncher.cs @@ -0,0 +1,255 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Security.Principal; +using System.Text; +using Microsoft.Win32.SafeHandles; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.Security; +using Windows.Win32.System.JobObjects; +using Windows.Win32.System.Threading; + +namespace Agent.Windows.Security; + +public sealed record RestrictedProcessResult(uint ProcessId, uint ExitCode, string StandardOutput, string StandardError); +public sealed record SandboxJobLimits(uint MaxProcesses = 16, ulong MaxJobMemoryBytes = 4UL * 1024 * 1024 * 1024); + +public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executablePolicy, ISecurityAuditSink audit, SecurityIdentifier? appContainerSid = null, SandboxJobLimits? jobLimits = null) +{ + private const uint ProcessCreationChildProcessRestricted = 0x1; + private readonly SandboxJobLimits _jobLimits = jobLimits ?? new(); + + public RestrictedProcessResult Run(Guid taskId, string executable, IReadOnlyList<string> arguments, string workingDirectory, TimeSpan timeout, string? standardInput = null) + { + VerifiedExecutable verified; + VerifiedExecutable? managedEntry = null; + try + { + verified = executablePolicy.Verify(executable); + string? managedEntryPath = ExecutablePolicy.ManagedEntryPoint(verified.CanonicalPath, arguments); + if (managedEntryPath is not null) managedEntry = executablePolicy.Verify(managedEntryPath); + } + catch (SandboxPolicyException ex) + { + audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "denied", new Dictionary<string, string> { ["code"] = ex.Code, ["executable"] = executable })); + throw; + } + + string cwd = CanonicalPath.Resolve(workingDirectory); + using FileStream stdin = CaptureFile(); + using FileStream stdout = CaptureFile(); + using FileStream stderr = CaptureFile(); + if (!string.IsNullOrEmpty(standardInput)) + { + byte[] input = Encoding.UTF8.GetBytes(standardInput); + stdin.Write(input); + stdin.Flush(true); + } + stdin.Position = 0; + MakeInheritable(stdin.SafeFileHandle); + MakeInheritable(stdout.SafeFileHandle); + MakeInheritable(stderr.SafeFileHandle); + using SafeFileHandle job = PInvoke.CreateJobObject(null, null); + if (job.IsInvalid) throw LastError("CreateJobObject"); + try + { + JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits = default; + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | + JOB_OBJECT_LIMIT.JOB_OBJECT_LIMIT_ACTIVE_PROCESS | JOB_OBJECT_LIMIT.JOB_OBJECT_LIMIT_JOB_MEMORY; + limits.BasicLimitInformation.ActiveProcessLimit = _jobLimits.MaxProcesses; + limits.JobMemoryLimit = (nuint)_jobLimits.MaxJobMemoryBytes; + if (!PInvoke.SetInformationJobObject(new HANDLE(job.DangerousGetHandle()), JOBOBJECTINFOCLASS.JobObjectExtendedLimitInformation, &limits, (uint)sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION))) + throw LastError("SetInformationJobObject"); + + nuint attributeSize = 0; + uint attributeCount = appContainerSid is null ? 2u : 3u; + _ = PInvoke.InitializeProcThreadAttributeList(default, attributeCount, 0, &attributeSize); + LPPROC_THREAD_ATTRIBUTE_LIST attributes = (LPPROC_THREAD_ATTRIBUTE_LIST)NativeMemory.Alloc(attributeSize); + void* sidBuffer = null; + void* internetClientSidBuffer = null; + void* privateNetworkSidBuffer = null; + try + { + if (!PInvoke.InitializeProcThreadAttributeList(attributes, attributeCount, 0, &attributeSize)) throw LastError("InitializeProcThreadAttributeList"); + uint childPolicy = ProcessCreationChildProcessRestricted; + if (!PInvoke.UpdateProcThreadAttribute(attributes, 0, PInvoke.PROC_THREAD_ATTRIBUTE_CHILD_PROCESS_POLICY, &childPolicy, sizeof(uint), null, null)) + throw LastError("UpdateProcThreadAttribute"); + HANDLE[] inheritedHandles = + [ + new(stdin.SafeFileHandle.DangerousGetHandle()), + new(stdout.SafeFileHandle.DangerousGetHandle()), + new(stderr.SafeFileHandle.DangerousGetHandle()) + ]; + fixed (HANDLE* inheritedHandlePointer = inheritedHandles) + { + if (!PInvoke.UpdateProcThreadAttribute(attributes, 0, PInvoke.PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + inheritedHandlePointer, (nuint)(sizeof(HANDLE) * inheritedHandles.Length), null, null)) + throw LastError("UpdateProcThreadAttribute(HandleList)"); + } + SecurityCapabilities securityCapabilities = default; + if (appContainerSid is not null) + { + byte[] sidBytes = new byte[appContainerSid.BinaryLength]; + appContainerSid.GetBinaryForm(sidBytes, 0); + sidBuffer = NativeMemory.Alloc((nuint)sidBytes.Length); + Marshal.Copy(sidBytes, 0, (nint)sidBuffer, sidBytes.Length); + securityCapabilities.AppContainerSid = (nint)sidBuffer; + internetClientSidBuffer = AllocateSid(new SecurityIdentifier("S-1-15-3-1")); + privateNetworkSidBuffer = AllocateSid(new SecurityIdentifier("S-1-15-3-3")); + SID_AND_ATTRIBUTES* capabilities = stackalloc SID_AND_ATTRIBUTES[2]; + capabilities[0].Sid = new PSID(internetClientSidBuffer); + capabilities[0].Attributes = 0x00000004; + capabilities[1].Sid = new PSID(privateNetworkSidBuffer); + capabilities[1].Attributes = 0x00000004; + securityCapabilities.Capabilities = (nint)capabilities; + securityCapabilities.CapabilityCount = 2; + if (!PInvoke.UpdateProcThreadAttribute(attributes, 0, PInvoke.PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, + &securityCapabilities, (nuint)sizeof(SecurityCapabilities), null, null)) throw LastError("UpdateProcThreadAttribute(SecurityCapabilities)"); + } + STARTUPINFOEXW startup = default; + startup.StartupInfo.cb = (uint)sizeof(STARTUPINFOEXW); + startup.StartupInfo.dwFlags = STARTUPINFOW_FLAGS.STARTF_USESTDHANDLES; + startup.StartupInfo.hStdInput = inheritedHandles[0]; + startup.StartupInfo.hStdOutput = inheritedHandles[1]; + startup.StartupInfo.hStdError = inheritedHandles[2]; + startup.lpAttributeList = attributes; + PROCESS_INFORMATION process = default; + string commandLine = BuildCommandLine(verified.CanonicalPath, arguments); + Span<char> commandBuffer = new char[commandLine.Length + 1]; + commandLine.AsSpan().CopyTo(commandBuffer); + if (!PInvoke.CreateProcess(null, ref commandBuffer, null, null, true, + PROCESS_CREATION_FLAGS.EXTENDED_STARTUPINFO_PRESENT | PROCESS_CREATION_FLAGS.CREATE_SUSPENDED, + null, cwd, in startup.StartupInfo, out process)) throw LastError("CreateProcess"); + try + { + using var processView = new SafeFileHandle(process.hProcess, ownsHandle: false); + if (!PInvoke.AssignProcessToJobObject(job, processView)) throw LastError("AssignProcessToJobObject"); + try + { + VerifiedExecutable postCreate = executablePolicy.Verify(executable); + if (!string.Equals(postCreate.CanonicalPath, verified.CanonicalPath, StringComparison.OrdinalIgnoreCase) || + !string.Equals(postCreate.Sha256, verified.Sha256, StringComparison.Ordinal)) + throw new SandboxPolicyException("process.identity_changed", "Executable identity changed during process creation."); + if (managedEntry is not null) + { + VerifiedExecutable postCreateManaged = executablePolicy.Verify(managedEntry.CanonicalPath); + if (!string.Equals(postCreateManaged.Sha256, managedEntry.Sha256, StringComparison.Ordinal)) + throw new SandboxPolicyException("process.managed_identity_changed", "Managed entry assembly identity changed during process creation."); + } + } + catch (SandboxPolicyException ex) + { + audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "denied", new Dictionary<string, string> + { + ["code"] = ex.Code, ["executable"] = executable, ["stage"] = "post_create_pre_resume" + })); + throw; + } + if (PInvoke.ResumeThread(process.hThread) == uint.MaxValue) throw LastError("ResumeThread"); + var processAudit = new Dictionary<string, string> + { + ["path"] = verified.CanonicalPath, ["sha256"] = verified.Sha256, ["child_process_policy"] = "restricted", + ["app_container_sid"] = appContainerSid?.Value ?? "none", ["network_capabilities"] = appContainerSid is null ? "none" : "internetClient,privateNetworkClientServer", + ["pid"] = process.dwProcessId.ToString() + }; + if (managedEntry is not null) + { + processAudit["managed_entry_path"] = managedEntry.CanonicalPath; + processAudit["managed_entry_sha256"] = managedEntry.Sha256; + } + audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.start", "allowed", processAudit)); + WAIT_EVENT wait = PInvoke.WaitForSingleObject(process.hProcess, checked((uint)Math.Clamp(timeout.TotalMilliseconds, 1, uint.MaxValue - 1))); + if (wait == WAIT_EVENT.WAIT_TIMEOUT) + { + _ = PInvoke.TerminateJobObject(job, 1460); + audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.timeout", "terminated", new Dictionary<string, string> { ["pid"] = process.dwProcessId.ToString() })); + throw new TimeoutException("Sandbox process exceeded its timeout."); + } + if (!PInvoke.GetExitCodeProcess(processView, out uint exitCode)) throw LastError("GetExitCodeProcess"); + audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.exit", "observed", new Dictionary<string, string> { ["pid"] = process.dwProcessId.ToString(), ["exit_code"] = exitCode.ToString() })); + if (exitCode != 0) + { + audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "observed", new Dictionary<string, string> + { + ["code"] = "restricted_process_failure", ["pid"] = process.dwProcessId.ToString(), + ["child_process_policy"] = "restricted", ["exit_code"] = exitCode.ToString() + })); + } + return new(process.dwProcessId, exitCode, ReadCapture(stdout), ReadCapture(stderr)); + } + finally + { + PInvoke.CloseHandle(process.hThread); + PInvoke.CloseHandle(process.hProcess); + } + } + finally + { + if (attributes.Value is not null) PInvoke.DeleteProcThreadAttributeList(attributes); + NativeMemory.Free(sidBuffer); + NativeMemory.Free(internetClientSidBuffer); + NativeMemory.Free(privateNetworkSidBuffer); + NativeMemory.Free(attributes.Value); + } + } + finally { } + } + + private static Win32Exception LastError(string operation) + { + int code = Marshal.GetLastWin32Error(); + return new(code, $"{operation} failed with Win32 error {code}."); + } + private static FileStream CaptureFile() + { + string path = Path.Combine(Path.GetTempPath(), "agent-mcp-process-" + Guid.NewGuid().ToString("N") + ".tmp"); + return new FileStream(path, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.ReadWrite | FileShare.Delete, 4096, + FileOptions.DeleteOnClose | FileOptions.SequentialScan); + } + private static void* AllocateSid(SecurityIdentifier sid) + { + byte[] bytes = new byte[sid.BinaryLength]; + sid.GetBinaryForm(bytes, 0); + void* buffer = NativeMemory.Alloc((nuint)bytes.Length); + Marshal.Copy(bytes, 0, (nint)buffer, bytes.Length); + return buffer; + } + private static void MakeInheritable(SafeFileHandle handle) + { + if (!PInvoke.SetHandleInformation(handle, (uint)HANDLE_FLAGS.HANDLE_FLAG_INHERIT, HANDLE_FLAGS.HANDLE_FLAG_INHERIT)) + throw LastError("SetHandleInformation"); + } + private static string ReadCapture(FileStream stream) + { + const int maxCharacters = 1024 * 1024; + stream.Position = 0; + using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: true); + char[] buffer = new char[maxCharacters + 1]; + int count = reader.ReadBlock(buffer, 0, buffer.Length); + return count > maxCharacters ? new string(buffer, 0, maxCharacters) + "\n[output truncated]" : new string(buffer, 0, count); + } + private static string BuildCommandLine(string executable, IReadOnlyList<string> arguments) => string.Join(' ', [Quote(executable), .. arguments.Select(Quote)]); + private static string Quote(string value) + { + if (value.Length > 0 && !value.Any(static character => char.IsWhiteSpace(character) || character == '"')) return value; + var result = new System.Text.StringBuilder().Append('"'); + int slashes = 0; + foreach (char character in value) + { + if (character == '\\') { slashes++; continue; } + if (character == '"') result.Append('\\', (slashes * 2) + 1).Append('"'); + else result.Append('\\', slashes).Append(character); + slashes = 0; + } + return result.Append('\\', slashes * 2).Append('"').ToString(); + } + + [StructLayout(LayoutKind.Sequential)] + private struct SecurityCapabilities + { + public nint AppContainerSid; + public nint Capabilities; + public uint CapabilityCount; + public uint Reserved; + } +} diff --git a/src/Agent.Windows/Security/SecurityAudit.cs b/src/Agent.Windows/Security/SecurityAudit.cs new file mode 100644 index 0000000..8858c2b --- /dev/null +++ b/src/Agent.Windows/Security/SecurityAudit.cs @@ -0,0 +1,29 @@ +using System.Text.Json; + +namespace Agent.Windows.Security; + +public sealed record SecurityAuditEvent(DateTimeOffset Timestamp, Guid TaskId, string Event, string Outcome, IReadOnlyDictionary<string, string> Data); + +public interface ISecurityAuditSink +{ + void Write(SecurityAuditEvent auditEvent); +} + +public sealed class JsonLinesSecurityAuditSink(string path) : ISecurityAuditSink +{ + private readonly object _sync = new(); + public void Write(SecurityAuditEvent auditEvent) + { + string? directory = Path.GetDirectoryName(Path.GetFullPath(path)); + if (directory is not null) Directory.CreateDirectory(directory); + string line = JsonSerializer.Serialize(auditEvent); + lock (_sync) File.AppendAllText(path, line + Environment.NewLine); + } +} + +public sealed class MemorySecurityAuditSink : ISecurityAuditSink +{ + private readonly List<SecurityAuditEvent> _events = []; + public IReadOnlyList<SecurityAuditEvent> Events => _events; + public void Write(SecurityAuditEvent auditEvent) => _events.Add(auditEvent); +} diff --git a/src/Agent.Windows/Security/TaskAclManager.cs b/src/Agent.Windows/Security/TaskAclManager.cs new file mode 100644 index 0000000..f01e859 --- /dev/null +++ b/src/Agent.Windows/Security/TaskAclManager.cs @@ -0,0 +1,76 @@ +using System.Security.AccessControl; +using System.Security.Principal; +using Agent.Windows.Recovery; + +namespace Agent.Windows.Security; + +public sealed record AclSnapshot(string Path, byte[] SecurityDescriptor, Guid? JournalEntryId = null); + +public sealed class TaskAclManager(SecurityIdentifier appContainerSid, ISecurityAuditSink audit, TaskResourceJournal? journal = null) +{ + public AclSnapshot Grant(Guid taskId, string path, FileCapability capabilities) + { + string canonical = CanonicalPath.Resolve(path); + FileSystemSecurity security = ReadSecurity(canonical); + byte[] original = security.GetSecurityDescriptorBinaryForm(); + Guid? journalEntry = journal?.Prepare("acl", canonical, new Dictionary<string, string> + { + ["security_descriptor"] = Convert.ToBase64String(original) + }); + FileSystemRights rights = ToRights(capabilities); + if (rights == 0) throw new ArgumentOutOfRangeException(nameof(capabilities)); + bool directory = Directory.Exists(canonical); + var rule = new FileSystemAccessRule(appContainerSid, rights, + directory ? InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit : InheritanceFlags.None, + PropagationFlags.None, AccessControlType.Allow); + security.AddAccessRule(rule); + WriteSecurity(canonical, security); + if (journalEntry is not null) journal!.MarkApplied(journalEntry.Value); + audit.Write(new(DateTimeOffset.UtcNow, taskId, "acl.apply", "applied", new Dictionary<string, string> { ["path"] = canonical, ["sid"] = appContainerSid.Value, ["rights"] = rights.ToString() })); + return new(canonical, original, journalEntry); + } + + public void Restore(Guid taskId, AclSnapshot snapshot) + { + FileSystemSecurity security = Directory.Exists(snapshot.Path) ? new DirectorySecurity() : new FileSecurity(); + security.SetSecurityDescriptorBinaryForm(snapshot.SecurityDescriptor, AccessControlSections.Access); + WriteSecurity(snapshot.Path, security); + if (snapshot.JournalEntryId is not null) journal!.MarkCleaned(snapshot.JournalEntryId.Value); + audit.Write(new(DateTimeOffset.UtcNow, taskId, "acl.restore", "restored", new Dictionary<string, string> { ["path"] = snapshot.Path })); + } + + public void NormalizeNewEntry(Guid taskId, string path) + { + string canonical = CanonicalPath.Resolve(path); + FileSystemSecurity security = ReadSecurity(canonical); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + foreach (FileSystemAccessRule rule in security.GetAccessRules(includeExplicit: true, includeInherited: false, typeof(SecurityIdentifier)) + .OfType<FileSystemAccessRule>() + .Where(rule => rule.IdentityReference == appContainerSid) + .ToArray()) + security.RemoveAccessRuleSpecific(rule); + security.SetAccessRuleProtection(isProtected: false, preserveInheritance: false); + WriteSecurity(canonical, security); + audit.Write(new(DateTimeOffset.UtcNow, taskId, "acl.normalize", "normalized", new Dictionary<string, string> { ["path"] = canonical })); + } + + private static FileSystemSecurity ReadSecurity(string path) => Directory.Exists(path) + ? FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(path), AccessControlSections.Access) + : FileSystemAclExtensions.GetAccessControl(new FileInfo(path), AccessControlSections.Access); + + private static void WriteSecurity(string path, FileSystemSecurity security) + { + if (security is DirectorySecurity directorySecurity) FileSystemAclExtensions.SetAccessControl(new DirectoryInfo(path), directorySecurity); + else FileSystemAclExtensions.SetAccessControl(new FileInfo(path), (FileSecurity)security); + } + + private static FileSystemRights ToRights(FileCapability capabilities) + { + FileSystemRights rights = 0; + if ((capabilities & FileCapability.Read) != 0) rights |= FileSystemRights.ReadAndExecute | FileSystemRights.ListDirectory; + if ((capabilities & FileCapability.Write) != 0) rights |= FileSystemRights.Write; + if ((capabilities & FileCapability.Execute) != 0) rights |= FileSystemRights.ReadAndExecute; + if ((capabilities & FileCapability.Delete) != 0) rights |= FileSystemRights.Delete | FileSystemRights.DeleteSubdirectoriesAndFiles; + return rights; + } +} diff --git a/src/Agent.Windows/Security/UserOnlyDirectory.cs b/src/Agent.Windows/Security/UserOnlyDirectory.cs new file mode 100644 index 0000000..a9ae5ae --- /dev/null +++ b/src/Agent.Windows/Security/UserOnlyDirectory.cs @@ -0,0 +1,20 @@ +using System.Security.AccessControl; +using System.Security.Principal; + +namespace Agent.Windows.Security; + +public static class UserOnlyDirectory +{ + public static void Ensure(string path) + { + Directory.CreateDirectory(path); + using WindowsIdentity identity = WindowsIdentity.GetCurrent(); + SecurityIdentifier user = identity.User ?? throw new InvalidOperationException("Current Windows identity has no user SID."); + var security = new DirectorySecurity(); + security.SetOwner(user); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + security.AddAccessRule(new FileSystemAccessRule(user, FileSystemRights.FullControl, + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow)); + FileSystemAclExtensions.SetAccessControl(new DirectoryInfo(path), security); + } +} |
