diff options
Diffstat (limited to 'src/Agent.Windows/Security')
| -rw-r--r-- | src/Agent.Windows/Security/ExecutablePolicy.cs | 87 | ||||
| -rw-r--r-- | src/Agent.Windows/Security/PathPolicy.cs | 37 | ||||
| -rw-r--r-- | src/Agent.Windows/Security/RestrictedProcessLauncher.cs | 208 | ||||
| -rw-r--r-- | src/Agent.Windows/Security/TaskAclManager.cs | 74 |
4 files changed, 315 insertions, 91 deletions
diff --git a/src/Agent.Windows/Security/ExecutablePolicy.cs b/src/Agent.Windows/Security/ExecutablePolicy.cs index 6c72724..02306fd 100644 --- a/src/Agent.Windows/Security/ExecutablePolicy.cs +++ b/src/Agent.Windows/Security/ExecutablePolicy.cs @@ -2,46 +2,91 @@ 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 enum EntryExecutableClassification { ApprovedEntryExecutable, ApprovedShellExecutable, RuntimeComponent, WorkspaceArtifact } -public sealed class ExecutablePolicy(IEnumerable<ExecutableApproval> approvals, bool allowShell) +public sealed record ApprovedEntryExecutable(string Alias, string ResolvedPath, string Sha256, bool IsShell, string? RuntimeRoot); +public sealed record VerifiedEntryExecutable(string ResolvedPath, string Sha256, EntryExecutableClassification Classification, string? Alias = null); + +public sealed class EntryExecutablePolicy( + IEnumerable<ApprovedEntryExecutable> approvals, + IEnumerable<string> runtimeRoots, + IEnumerable<string> artifactRoots) { - private readonly IReadOnlyDictionary<string, string> _approvals = approvals.ToDictionary( - approval => CanonicalPath.Resolve(approval.Path), approval => NormalizeHash(approval.Sha256), StringComparer.OrdinalIgnoreCase); + private readonly IReadOnlyDictionary<string, ApprovedEntryExecutable> _aliases = approvals.ToDictionary( + approval => approval.Alias, Normalize, StringComparer.OrdinalIgnoreCase); + private readonly string[] _runtimeRoots = runtimeRoots.Select(path => ResolvedPath.Resolve(path)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + private readonly string[] _artifactRoots = artifactRoots.Select(path => ResolvedPath.Resolve(path)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); - public VerifiedExecutable Verify(string executable) + public VerifiedEntryExecutable VerifyEntry(string executable, string? baseDirectory = null) { - string canonical = CanonicalPath.Resolve(executable); - if (!allowShell && IsShell(canonical)) throw new SandboxPolicyException("process.shell_denied", "Shell execution is disabled.", "process", canonical, "execute", "sandbox.process.allow_shell"); - if (!_approvals.TryGetValue(canonical, out string? approvedHash)) throw new SandboxPolicyException("process.path_denied", $"Executable path is not approved: '{canonical}'. Add its canonical path and SHA-256 to sandbox.process.approvals.", "process", canonical, "execute", "sandbox.process.approvals"); - 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: '{canonical}'. Recalculate SHA-256 and update sandbox.process.approvals.", "process", canonical, "execute", "sandbox.process.approvals"); - return new(canonical, actualHash); + if (_aliases.TryGetValue(executable, out ApprovedEntryExecutable? byAlias)) return VerifyApproved(byAlias); + string candidate = Path.IsPathFullyQualified(executable) ? executable : Path.Combine(baseDirectory ?? Environment.CurrentDirectory, executable); + string resolved; + try { resolved = ResolvedPath.Resolve(candidate); } + catch (FileNotFoundException) + { + throw new SandboxPolicyException("process.alias_or_path_unknown", $"Executable Alias or Logical Path does not resolve to an existing file: '{executable}'.", + "process", Path.GetFullPath(candidate), "execute", "Use a configured Executable Alias or an existing Runtime Component/development Workspace Artifact path."); + } + if (_runtimeRoots.Any(root => IsAtOrBelow(resolved, root))) + return new(resolved, ComputeSha256(resolved), EntryExecutableClassification.RuntimeComponent); + if (_artifactRoots.Any(root => IsAtOrBelow(resolved, root))) + return new(resolved, ComputeSha256(resolved), EntryExecutableClassification.WorkspaceArtifact); + throw new SandboxPolicyException("process.entry_unknown", $"External Unknown Executable cannot be started as an Entry Process: '{resolved}'.", + "process", resolved, "execute", "Configure an Executable Alias and approve it, or place the Workspace Artifact in a development Workspace."); } - public static string? ManagedEntryPoint(string executable, IReadOnlyList<string> arguments) + public string? ManagedEntryPoint(string executable, IReadOnlyList<string> arguments, string? baseDirectory = null) { - if (!Path.GetFileName(executable).Equals("dotnet.exe", StringComparison.OrdinalIgnoreCase) || arguments.Count == 0) return null; + string executablePath = Path.IsPathFullyQualified(executable) ? executable : Path.Combine(baseDirectory ?? Environment.CurrentDirectory, executable); + string resolved = _aliases.TryGetValue(executable, out ApprovedEntryExecutable? approval) ? approval.ResolvedPath : ResolvedPath.Resolve(executablePath); + if (!Path.GetFileName(resolved).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 ResolveArgument(arguments[1], baseDirectory); } - return arguments[0].EndsWith(".dll", StringComparison.OrdinalIgnoreCase) ? arguments[0] : null; + return arguments[0].EndsWith(".dll", StringComparison.OrdinalIgnoreCase) ? ResolveArgument(arguments[0], baseDirectory) : null; + } + + public void VerifyManagedEntryPoint(string path) + { + string resolved = ResolvedPath.Resolve(path); + if (_runtimeRoots.Any(root => IsAtOrBelow(resolved, root)) || _artifactRoots.Any(root => IsAtOrBelow(resolved, root))) return; + throw new SandboxPolicyException("process.managed_entry_unknown", $"Managed Entry Artifact is outside every confirmed Runtime Root and development Workspace: '{resolved}'.", + "process", resolved, "execute", "Place the artifact in a development Workspace or confirm its Runtime Root."); } public static string ComputeSha256(string executable) { - using FileStream stream = new(CanonicalPath.Resolve(executable), FileMode.Open, FileAccess.Read, FileShare.Read); + using FileStream stream = new(ResolvedPath.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 ApprovedEntryExecutable Normalize(ApprovedEntryExecutable approval) => approval with + { + ResolvedPath = ResolvedPath.Resolve(approval.ResolvedPath), + Sha256 = NormalizeHash(approval.Sha256), + RuntimeRoot = approval.RuntimeRoot is null ? null : ResolvedPath.Resolve(approval.RuntimeRoot) + }; + + private static VerifiedEntryExecutable VerifyApproved(ApprovedEntryExecutable approval) + { + string resolved = ResolvedPath.Resolve(approval.ResolvedPath); + string actualHash = ComputeSha256(resolved); + if (!CryptographicOperations.FixedTimeEquals(Convert.FromHexString(approval.Sha256), Convert.FromHexString(actualHash))) + throw new SandboxPolicyException("process.hash_denied", $"Approved Entry Executable SHA-256 changed: '{resolved}'.", + "process", resolved, "execute", $"Re-run executable approve for Alias '{approval.Alias}'."); + return new(resolved, actualHash, approval.IsShell ? EntryExecutableClassification.ApprovedShellExecutable : EntryExecutableClassification.ApprovedEntryExecutable, approval.Alias); + } + + private static bool IsAtOrBelow(string candidate, string root) => + candidate.Equals(root, StringComparison.OrdinalIgnoreCase) || + candidate.StartsWith(root.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + private static string ResolveArgument(string path, string? baseDirectory) => Path.IsPathFullyQualified(path) + ? path : Path.GetFullPath(path, baseDirectory ?? Environment.CurrentDirectory); + private static string NormalizeHash(string hash) { string normalized = hash.Replace("-", string.Empty, StringComparison.Ordinal).Trim().ToUpperInvariant(); diff --git a/src/Agent.Windows/Security/PathPolicy.cs b/src/Agent.Windows/Security/PathPolicy.cs index 3de4804..67c7340 100644 --- a/src/Agent.Windows/Security/PathPolicy.cs +++ b/src/Agent.Windows/Security/PathPolicy.cs @@ -3,23 +3,23 @@ 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 record FilePolicyEntry(string Path, FileCapability Capabilities, string Origin = "unspecified"); 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(); + private readonly IReadOnlyList<FilePolicyEntry> _entries = entries.Select(entry => entry with { Path = ResolvedPath.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); + string canonical = ResolvedPath.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}. Add the canonical path to sandbox.filesystem.{required.ToString().ToLowerInvariant()} and restart the server.", - "filesystem", Path.GetFullPath(path), required.ToString().ToLowerInvariant(), $"sandbox.filesystem.{required.ToString().ToLowerInvariant()}"); + throw new SandboxPolicyException("policy.deny", $"Resolved Path '{path}' is outside the Effective Sandbox Policy for {required}.", + "filesystem", Path.GetFullPath(path), required.ToString().ToLowerInvariant(), "Confirm a Workspace or Mount that grants the required Filesystem Capability, then restart the server."); } private static bool IsAtOrBelow(string candidate, string root) => @@ -27,7 +27,7 @@ public sealed class PathPolicy(IEnumerable<FilePolicyEntry> entries) candidate.StartsWith(root.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); } -public static class CanonicalPath +public static class ResolvedPath { public static string Resolve(string path, bool requireExisting = true) { @@ -35,21 +35,34 @@ public static class CanonicalPath string fullPath = Path.GetFullPath(path); string? root = Path.GetPathRoot(fullPath); if (string.IsNullOrEmpty(root)) throw new SandboxPolicyException("path.invalid", "Path has no filesystem root."); + if (root.StartsWith("\\\\", StringComparison.Ordinal)) + throw new SandboxPolicyException("path.non_local", "Only local filesystem paths are supported.", "filesystem", fullPath, null, "Select a local Workspace, Mount, Runtime Root, or Executable."); string current = root; + var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase); 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) + while ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) { + string reparsePath = Path.GetFullPath(current); + if (!visited.Add(reparsePath)) + throw new SandboxPolicyException("path.reparse_cycle", $"Reparse resolution cycle detected at '{reparsePath}'.", "filesystem", reparsePath, null, "Remove the reparse cycle."); FileSystemInfo info = Directory.Exists(current) ? new DirectoryInfo(current) : new FileInfo(current); - string? target = info.ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? info.LinkTarget; - throw new SandboxPolicyException("path.reparse", $"Reparse points are not allowed: component='{current}'. Approve the resolved canonical target{(string.IsNullOrWhiteSpace(target) ? string.Empty : $" '{Path.GetFullPath(target)}'")} instead.", - "filesystem", current, null, string.IsNullOrWhiteSpace(target) ? "sandbox.filesystem" : Path.GetFullPath(target)); + string? target; + try { target = info.ResolveLinkTarget(returnFinalTarget: false)?.FullName; } + catch (IOException ex) + { + throw new SandboxPolicyException("path.reparse_unresolved", $"Could not resolve reparse component '{current}': {ex.Message}", + "filesystem", current, null, "Repair or remove the reparse point."); + } + if (string.IsNullOrWhiteSpace(target)) + throw new SandboxPolicyException("path.reparse_unresolved", $"Could not resolve reparse component '{current}'.", "filesystem", current, null, "Repair or remove the reparse point."); + current = Path.GetFullPath(target); } } - if (requireExisting && !File.Exists(fullPath) && !Directory.Exists(fullPath)) throw new FileNotFoundException("Approved path does not exist.", fullPath); - return Path.TrimEndingDirectorySeparator(fullPath); + if (requireExisting && !File.Exists(current) && !Directory.Exists(current)) throw new FileNotFoundException("Resolved Path does not exist.", current); + return Path.TrimEndingDirectorySeparator(current); } } diff --git a/src/Agent.Windows/Security/RestrictedProcessLauncher.cs b/src/Agent.Windows/Security/RestrictedProcessLauncher.cs index 687af94..c3a19da 100644 --- a/src/Agent.Windows/Security/RestrictedProcessLauncher.cs +++ b/src/Agent.Windows/Security/RestrictedProcessLauncher.cs @@ -14,21 +14,27 @@ 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) +public sealed unsafe class RestrictedProcessLauncher(EntryExecutablePolicy executablePolicy, ISecurityAuditSink audit, SecurityIdentifier? appContainerSid = null, + SandboxJobLimits? jobLimits = null, bool allowNetwork = false) { - private const uint ProcessCreationChildProcessRestricted = 0x1; + private const uint ProcessCreationAllApplicationPackagesOptOut = 0x1; private readonly SandboxJobLimits _jobLimits = jobLimits ?? new(); public RestrictedProcessResult Run(Guid taskId, string executable, IReadOnlyList<string> arguments, string workingDirectory, TimeSpan timeout, - string? standardInput = null, Guid? sessionId = null) + string? standardInput = null, Guid? sessionId = null) => + Run(taskId, executable, arguments, workingDirectory, timeout, standardInput, sessionId, CancellationToken.None); + + public RestrictedProcessResult Run(Guid taskId, string executable, IReadOnlyList<string> arguments, string workingDirectory, TimeSpan timeout, + string? standardInput, Guid? sessionId, CancellationToken cancellationToken) { - VerifiedExecutable verified; - VerifiedExecutable? managedEntry = null; + string cwd = ResolvedPath.Resolve(workingDirectory); + VerifiedEntryExecutable verified; + string? managedEntry = null; try { - verified = executablePolicy.Verify(executable); - string? managedEntryPath = ExecutablePolicy.ManagedEntryPoint(verified.CanonicalPath, arguments); - if (managedEntryPath is not null) managedEntry = executablePolicy.Verify(managedEntryPath); + verified = executablePolicy.VerifyEntry(executable, cwd); + managedEntry = executablePolicy.ManagedEntryPoint(verified.ResolvedPath, arguments, cwd); + if (managedEntry is not null) executablePolicy.VerifyManagedEntryPoint(managedEntry); } catch (SandboxPolicyException ex) { @@ -36,7 +42,6 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable throw; } - string cwd = CanonicalPath.Resolve(workingDirectory); using FileStream stdin = CaptureFile(); using FileStream stdout = CaptureFile(); using FileStream stderr = CaptureFile(); @@ -63,18 +68,14 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable throw LastError("SetInformationJobObject"); nuint attributeSize = 0; - uint attributeCount = appContainerSid is null ? 2u : 3u; + uint attributeCount = appContainerSid is null ? 1u : 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; + var capabilityBuffers = new List<nint>(); 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()), @@ -95,17 +96,24 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable 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; + SecurityIdentifier[] capabilitySids = allowNetwork + ? [DeriveCapabilitySid("registryRead"), DeriveCapabilitySid("lpacInstrumentation"), new("S-1-15-3-1"), new("S-1-15-3-3")] + : [DeriveCapabilitySid("registryRead"), DeriveCapabilitySid("lpacInstrumentation")]; + SID_AND_ATTRIBUTES* capabilities = stackalloc SID_AND_ATTRIBUTES[capabilitySids.Length]; + for (int i = 0; i < capabilitySids.Length; i++) + { + void* buffer = AllocateSid(capabilitySids[i]); + capabilityBuffers.Add((nint)buffer); + capabilities[i].Sid = new PSID(buffer); + capabilities[i].Attributes = 0x00000004; + } securityCapabilities.Capabilities = (nint)capabilities; - securityCapabilities.CapabilityCount = 2; + securityCapabilities.CapabilityCount = (uint)capabilitySids.Length; if (!PInvoke.UpdateProcThreadAttribute(attributes, 0, PInvoke.PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, - &securityCapabilities, (nuint)sizeof(SecurityCapabilities), null, null)) throw LastError("UpdateProcThreadAttribute(SecurityCapabilities)"); + &securityCapabilities, (nuint)sizeof(SecurityCapabilities), null, null)) throw LpacFailure("UpdateProcThreadAttribute(SecurityCapabilities)", verified.ResolvedPath); + uint allApplicationPackagesPolicy = ProcessCreationAllApplicationPackagesOptOut; + if (!PInvoke.UpdateProcThreadAttribute(attributes, 0, PInvoke.PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY, + &allApplicationPackagesPolicy, sizeof(uint), null, null)) throw LpacFailure("UpdateProcThreadAttribute(AllApplicationPackagesPolicy)", verified.ResolvedPath); } STARTUPINFOEXW startup = default; startup.StartupInfo.cb = (uint)sizeof(STARTUPINFOEXW); @@ -115,28 +123,29 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable startup.StartupInfo.hStdError = inheritedHandles[2]; startup.lpAttributeList = attributes; PROCESS_INFORMATION process = default; - string commandLine = BuildCommandLine(verified.CanonicalPath, arguments); + string commandLine = BuildCommandLine(verified.ResolvedPath, 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"); + char[] environmentBlock = BuildEnvironmentBlock(cwd); + fixed (char* environment = environmentBlock) + { + if (!PInvoke.CreateProcess(null, ref commandBuffer, null, null, true, + PROCESS_CREATION_FLAGS.EXTENDED_STARTUPINFO_PRESENT | PROCESS_CREATION_FLAGS.CREATE_SUSPENDED | + PROCESS_CREATION_FLAGS.CREATE_UNICODE_ENVIRONMENT, + environment, 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) || + VerifiedEntryExecutable postCreate = executablePolicy.VerifyEntry(executable, cwd); + if (!string.Equals(postCreate.ResolvedPath, verified.ResolvedPath, 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."); - } + executablePolicy.VerifyManagedEntryPoint(managedEntry); } catch (SandboxPolicyException ex) { @@ -146,20 +155,41 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable }, SessionId: sessionId, ErrorCode: ex.Code)); throw; } + (bool tokenIsAppContainer, string? tokenAppContainerSid, bool processInJob) = InspectConfinement(processView, job); + if (appContainerSid is not null && (!tokenIsAppContainer || + !string.Equals(tokenAppContainerSid, appContainerSid.Value, StringComparison.Ordinal) || !processInJob)) + throw new SandboxPolicyException("process.lpac_mismatch", + "Entry Process did not receive the required LPAC identity and Job Object confinement.", + "process", verified.ResolvedPath, "execute", "Verify LPAC support on this Windows host; no fallback is permitted."); 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", + ["path"] = verified.ResolvedPath, ["sha256"] = verified.Sha256, ["entry_classification"] = verified.Classification.ToString(), + ["alias"] = verified.Alias ?? string.Empty, ["descendant_policy"] = "lpac_inherited", + ["app_container_sid"] = appContainerSid?.Value ?? "none", ["lpac"] = (appContainerSid is not null).ToString(), + ["token_is_app_container"] = tokenIsAppContainer.ToString(), ["token_app_container_sid"] = tokenAppContainerSid ?? "none", + ["process_in_job"] = processInJob.ToString(), ["all_application_packages_opt_out"] = (appContainerSid is not null).ToString(), + ["network_capabilities"] = appContainerSid is null || !allowNetwork ? "none" : "internetClient,privateNetworkClientServer", + ["baseline_capabilities"] = appContainerSid is null ? "none" : "registryRead,lpacInstrumentation", + ["environment_policy"] = "minimal_no_broker_credentials", + ["arguments"] = "redacted", ["argument_count"] = arguments.Count.ToString(System.Globalization.CultureInfo.InvariantCulture), ["pid"] = process.dwProcessId.ToString() }; if (managedEntry is not null) { - processAudit["managed_entry_path"] = managedEntry.CanonicalPath; - processAudit["managed_entry_sha256"] = managedEntry.Sha256; + processAudit["managed_entry_path"] = ResolvedPath.Resolve(managedEntry); } audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.start", "allowed", processAudit, SessionId: sessionId)); - WAIT_EVENT wait = PInvoke.WaitForSingleObject(process.hProcess, checked((uint)Math.Clamp(timeout.TotalMilliseconds, 1, uint.MaxValue - 1))); + long deadline = Environment.TickCount64 + checked((long)Math.Clamp(timeout.TotalMilliseconds, 1, int.MaxValue)); + WAIT_EVENT wait; + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + long remaining = deadline - Environment.TickCount64; + if (remaining <= 0) { wait = WAIT_EVENT.WAIT_TIMEOUT; break; } + wait = PInvoke.WaitForSingleObject(process.hProcess, (uint)Math.Min(remaining, 100)); + if (wait != WAIT_EVENT.WAIT_TIMEOUT) break; + } if (wait == WAIT_EVENT.WAIT_TIMEOUT) { _ = PInvoke.TerminateJobObject(job, 1460); @@ -172,12 +202,20 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable { 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() - }, SessionId: sessionId, ErrorCode: "process.child_denied")); + ["code"] = "sandbox_process_failure", ["pid"] = process.dwProcessId.ToString(), + ["descendant_policy"] = "lpac_inherited", ["exit_code"] = exitCode.ToString() + }, SessionId: sessionId, ErrorCode: "process.nonzero_exit")); } return new(process.dwProcessId, exitCode, ReadCapture(stdout), ReadCapture(stderr)); } + catch (OperationCanceledException) + { + _ = PInvoke.TerminateJobObject(job, 1223); + _ = PInvoke.WaitForSingleObject(process.hProcess, 5000); + audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.cancel", "terminated", + new Dictionary<string, string> { ["pid"] = process.dwProcessId.ToString() }, SessionId: sessionId)); + throw; + } finally { PInvoke.CloseHandle(process.hThread); @@ -188,8 +226,7 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable { if (attributes.Value is not null) PInvoke.DeleteProcThreadAttributeList(attributes); NativeMemory.Free(sidBuffer); - NativeMemory.Free(internetClientSidBuffer); - NativeMemory.Free(privateNetworkSidBuffer); + foreach (nint buffer in capabilityBuffers) NativeMemory.Free((void*)buffer); NativeMemory.Free(attributes.Value); } } @@ -201,6 +238,34 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable int code = Marshal.GetLastWin32Error(); return new(code, $"{operation} failed with Win32 error {code}."); } + + private static SandboxPolicyException LpacFailure(string operation, string executable) + { + int code = Marshal.GetLastWin32Error(); + return new("process.lpac_unsupported", $"{operation} failed with Win32 error {code}; no fallback is permitted.", + "process", executable, "execute", "Use a supported Windows 11 host and verify LPAC capability derivation and process-attribute support."); + } + + private static (bool IsAppContainer, string? AppContainerSid, bool InJob) InspectConfinement(SafeFileHandle process, SafeFileHandle job) + { + if (!PInvoke.IsProcessInJob(process, job, out BOOL inJob)) throw LastError("IsProcessInJob"); + if (!PInvoke.OpenProcessToken(process, TOKEN_ACCESS_MASK.TOKEN_QUERY, out SafeFileHandle token)) throw LastError("OpenProcessToken"); + using (token) + { + Span<byte> flag = stackalloc byte[sizeof(uint)]; + if (!PInvoke.GetTokenInformation(token, TOKEN_INFORMATION_CLASS.TokenIsAppContainer, flag, out _)) + throw LastError("GetTokenInformation(TokenIsAppContainer)"); + bool isAppContainer = MemoryMarshal.Read<uint>(flag) != 0; + _ = PInvoke.GetTokenInformation(token, TOKEN_INFORMATION_CLASS.TokenAppContainerSid, Span<byte>.Empty, out uint sidInformationLength); + if (sidInformationLength < IntPtr.Size) throw LastError("GetTokenInformation(TokenAppContainerSid size)"); + Span<byte> sidInformation = sidInformationLength <= 256 ? stackalloc byte[(int)sidInformationLength] : new byte[sidInformationLength]; + if (!PInvoke.GetTokenInformation(token, TOKEN_INFORMATION_CLASS.TokenAppContainerSid, sidInformation, out _)) + throw LastError("GetTokenInformation(TokenAppContainerSid)"); + nint sidPointer = MemoryMarshal.Read<nint>(sidInformation); + string? sid = sidPointer == 0 ? null : new SecurityIdentifier(sidPointer).Value; + return (isAppContainer, sid, inJob); + } + } private static FileStream CaptureFile() { string path = Path.Combine(Path.GetTempPath(), "agent-mcp-process-" + Guid.NewGuid().ToString("N") + ".tmp"); @@ -215,6 +280,20 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable Marshal.Copy(bytes, 0, (nint)buffer, bytes.Length); return buffer; } + + private static SecurityIdentifier DeriveCapabilitySid(string name) + { + if (!PInvoke.DeriveCapabilitySidsFromName(name, out PSID* groupSids, out uint groupCount, out PSID* capabilitySids, out uint capabilityCount) || capabilityCount != 1) + throw LastError($"DeriveCapabilitySidsFromName({name})"); + try { return new SecurityIdentifier((nint)capabilitySids[0].Value); } + finally + { + for (uint i = 0; i < groupCount; i++) _ = PInvoke.LocalFree(new HLOCAL((nint)groupSids[i].Value)); + for (uint i = 0; i < capabilityCount; i++) _ = PInvoke.LocalFree(new HLOCAL((nint)capabilitySids[i].Value)); + _ = PInvoke.LocalFree(new HLOCAL((nint)groupSids)); + _ = PInvoke.LocalFree(new HLOCAL((nint)capabilitySids)); + } + } private static void MakeInheritable(SafeFileHandle handle) { if (!PInvoke.SetHandleInformation(handle, (uint)HANDLE_FLAGS.HANDLE_FLAG_INHERIT, HANDLE_FLAGS.HANDLE_FLAG_INHERIT)) @@ -230,6 +309,45 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable 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 char[] BuildEnvironmentBlock(string cwd) + { + string systemRoot = Environment.GetFolderPath(Environment.SpecialFolder.Windows); + var values = new SortedDictionary<string, string>(StringComparer.OrdinalIgnoreCase) + { + ["ComSpec"] = Path.Combine(systemRoot, "System32", "cmd.exe"), + ["DOTNET_CLI_HOME"] = cwd, + ["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1", + ["DOTNET_CLI_USE_MSBUILD_SERVER"] = "0", + ["DOTNET_CLI_WORKLOAD_UPDATE_NOTIFY_DISABLE"] = "true", + ["DOTNET_NOLOGO"] = "1", + ["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1", + ["DOTNET_SKIP_WORKLOAD_INTEGRITY_CHECK"] = "true", + ["MSBuildEnableWorkloadResolver"] = "false", + ["MSBUILDDISABLENODEREUSE"] = "1", + ["NUMBER_OF_PROCESSORS"] = Environment.ProcessorCount.ToString(System.Globalization.CultureInfo.InvariantCulture), + ["OS"] = "Windows_NT", + ["PATH"] = Path.Combine(systemRoot, "System32"), + ["PATHEXT"] = ".COM;.EXE;.BAT;.CMD", + ["PROCESSOR_ARCHITECTURE"] = RuntimeInformation.ProcessArchitecture.ToString().ToUpperInvariant(), + ["SystemDrive"] = Path.GetPathRoot(systemRoot)!.TrimEnd(Path.DirectorySeparatorChar), + ["SystemRoot"] = systemRoot, + ["TEMP"] = cwd, + ["TMP"] = cwd, + ["windir"] = systemRoot + }; + foreach (string name in new[] + { + "ALLUSERSPROFILE", "APPDATA", "CommonProgramFiles", "CommonProgramFiles(x86)", "CommonProgramW6432", + "HOMEDRIVE", "HOMEPATH", "LOCALAPPDATA", "ProgramData", "ProgramFiles", "ProgramFiles(x86)", + "ProgramW6432", "PUBLIC", "USERDOMAIN", "USERNAME", "USERPROFILE" + }) + { + string? value = Environment.GetEnvironmentVariable(name); + if (!string.IsNullOrEmpty(value)) values[name] = value; + } + string block = string.Concat(values.Select(pair => $"{pair.Key}={pair.Value}\0")) + "\0"; + return block.ToCharArray(); + } private static string Quote(string value) { if (value.Length > 0 && !value.Any(static character => char.IsWhiteSpace(character) || character == '"')) return value; diff --git a/src/Agent.Windows/Security/TaskAclManager.cs b/src/Agent.Windows/Security/TaskAclManager.cs index 2b091a6..be0080d 100644 --- a/src/Agent.Windows/Security/TaskAclManager.cs +++ b/src/Agent.Windows/Security/TaskAclManager.cs @@ -10,27 +10,64 @@ public sealed class TaskAclManager(SecurityIdentifier appContainerSid, ISecurity { public AclSnapshot Grant(Guid taskId, string path, FileCapability capabilities) { - string canonical = CanonicalPath.Resolve(path); + string canonical = ResolvedPath.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); + FileSystemRights auditRights; + if (directory) + { + FileSystemRights directoryRights = ToDirectoryRights(capabilities); + FileSystemRights fileRights = ToFileRights(capabilities); + if (directoryRights == 0 || fileRights == 0) throw new ArgumentOutOfRangeException(nameof(capabilities)); + security.AddAccessRule(new FileSystemAccessRule(appContainerSid, directoryRights, + InheritanceFlags.ContainerInherit, PropagationFlags.None, AccessControlType.Allow)); + security.AddAccessRule(new FileSystemAccessRule(appContainerSid, fileRights, + InheritanceFlags.ObjectInherit, PropagationFlags.InheritOnly, AccessControlType.Allow)); + auditRights = directoryRights | fileRights; + } + else + { + auditRights = ToFileRights(capabilities); + if (auditRights == 0) throw new ArgumentOutOfRangeException(nameof(capabilities)); + security.AddAccessRule(new FileSystemAccessRule(appContainerSid, auditRights, + InheritanceFlags.None, PropagationFlags.None, AccessControlType.Allow)); + } + try + { + WriteSecurity(canonical, security); + if (journalEntry is not null) journal!.MarkApplied(journalEntry.Value); + } + catch (Exception applyFailure) + { + try + { + byte[] current = ReadSecurity(canonical).GetSecurityDescriptorBinaryForm(); + if (!current.AsSpan().SequenceEqual(original)) + { + FileSystemSecurity rollback = Directory.Exists(canonical) ? new DirectorySecurity() : new FileSecurity(); + rollback.SetSecurityDescriptorBinaryForm(original, AccessControlSections.Access); + WriteSecurity(canonical, rollback); + byte[] restored = ReadSecurity(canonical).GetSecurityDescriptorBinaryForm(); + if (!restored.AsSpan().SequenceEqual(original)) throw new InvalidOperationException("ACL rollback verification failed."); + } + if (journalEntry is not null) journal!.MarkCleaned(journalEntry.Value); + } + catch (Exception rollbackFailure) + { + throw new AggregateException("ACL apply failed and immediate rollback did not reach a verified clean state.", applyFailure, rollbackFailure); + } + throw; + } audit.Write(new(DateTimeOffset.UtcNow, taskId, "acl.apply", "applied", new Dictionary<string, string> { ["path"] = canonical, ["sid"] = appContainerSid.Value, - ["rights"] = rights.ToString(), + ["rights"] = auditRights.ToString(), ["scope"] = directory ? "root_inheritance" : "file" })); return new(canonical, original, journalEntry); @@ -55,12 +92,23 @@ public sealed class TaskAclManager(SecurityIdentifier appContainerSid, ISecurity else FileSystemAclExtensions.SetAccessControl(new FileInfo(path), (FileSecurity)security); } - private static FileSystemRights ToRights(FileCapability capabilities) + private static FileSystemRights ToFileRights(FileCapability capabilities) { FileSystemRights rights = 0; - if ((capabilities & FileCapability.Read) != 0) rights |= FileSystemRights.ReadAndExecute | FileSystemRights.ListDirectory; + if ((capabilities & FileCapability.Read) != 0) + rights |= FileSystemRights.ReadData | FileSystemRights.ReadAttributes | FileSystemRights.ReadExtendedAttributes | FileSystemRights.ReadPermissions; if ((capabilities & FileCapability.Write) != 0) rights |= FileSystemRights.Write; - if ((capabilities & FileCapability.Execute) != 0) rights |= FileSystemRights.ReadAndExecute; + if ((capabilities & FileCapability.Execute) != 0) rights |= FileSystemRights.ExecuteFile | FileSystemRights.ReadData | FileSystemRights.ReadAttributes; + if ((capabilities & FileCapability.Delete) != 0) rights |= FileSystemRights.Delete; + return rights; + } + + private static FileSystemRights ToDirectoryRights(FileCapability capabilities) + { + FileSystemRights rights = FileSystemRights.Traverse | FileSystemRights.ReadAttributes | FileSystemRights.ReadPermissions; + if ((capabilities & FileCapability.Read) != 0) rights |= FileSystemRights.ListDirectory | FileSystemRights.ReadExtendedAttributes; + if ((capabilities & FileCapability.Write) != 0) rights |= FileSystemRights.CreateFiles | FileSystemRights.CreateDirectories | + FileSystemRights.WriteAttributes | FileSystemRights.WriteExtendedAttributes; if ((capabilities & FileCapability.Delete) != 0) rights |= FileSystemRights.Delete | FileSystemRights.DeleteSubdirectoriesAndFiles; return rights; } |
