From 6c3458a5ec37a5b69957ad17c84de58984698f25 Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Tue, 11 Aug 2026 21:44:53 +0900 Subject: Audit and align v1 runtime design --- src/Agent.Windows/Security/ExecutablePolicy.cs | 4 ++-- src/Agent.Windows/Security/PathPolicy.cs | 9 +++++++-- src/Agent.Windows/Security/RestrictedProcessLauncher.cs | 15 ++++++++------- src/Agent.Windows/Security/SecurityAudit.cs | 6 ++++-- 4 files changed, 21 insertions(+), 13 deletions(-) (limited to 'src/Agent.Windows/Security') diff --git a/src/Agent.Windows/Security/ExecutablePolicy.cs b/src/Agent.Windows/Security/ExecutablePolicy.cs index 3d20bfa..1f36164 100644 --- a/src/Agent.Windows/Security/ExecutablePolicy.cs +++ b/src/Agent.Windows/Security/ExecutablePolicy.cs @@ -14,11 +14,11 @@ public sealed class ExecutablePolicy(IEnumerable approvals, { 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."); + 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."); 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."); + throw new SandboxPolicyException("process.hash_denied", $"Executable SHA-256 does not match its approval: '{canonical}'. Recalculate SHA-256 and update sandbox.process.approvals."); return new(canonical, actualHash); } diff --git a/src/Agent.Windows/Security/PathPolicy.cs b/src/Agent.Windows/Security/PathPolicy.cs index 32a949c..4f6eec4 100644 --- a/src/Agent.Windows/Security/PathPolicy.cs +++ b/src/Agent.Windows/Security/PathPolicy.cs @@ -17,7 +17,8 @@ public sealed class PathPolicy(IEnumerable entries) public void Demand(string path, FileCapability required) { - if (!IsAllowed(path, required)) throw new SandboxPolicyException("policy.deny", $"'{path}' is not approved for {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."); } private static bool IsAtOrBelow(string candidate, string root) => @@ -39,7 +40,11 @@ public static class CanonicalPath 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}'."); + { + 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."); + } } if (requireExisting && !File.Exists(fullPath) && !Directory.Exists(fullPath)) throw new FileNotFoundException("Approved path does not exist.", fullPath); return Path.TrimEndingDirectorySeparator(fullPath); diff --git a/src/Agent.Windows/Security/RestrictedProcessLauncher.cs b/src/Agent.Windows/Security/RestrictedProcessLauncher.cs index 0786150..687af94 100644 --- a/src/Agent.Windows/Security/RestrictedProcessLauncher.cs +++ b/src/Agent.Windows/Security/RestrictedProcessLauncher.cs @@ -19,7 +19,8 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable private const uint ProcessCreationChildProcessRestricted = 0x1; private readonly SandboxJobLimits _jobLimits = jobLimits ?? new(); - public RestrictedProcessResult Run(Guid taskId, string executable, IReadOnlyList arguments, string workingDirectory, TimeSpan timeout, string? standardInput = null) + public RestrictedProcessResult Run(Guid taskId, string executable, IReadOnlyList arguments, string workingDirectory, TimeSpan timeout, + string? standardInput = null, Guid? sessionId = null) { VerifiedExecutable verified; VerifiedExecutable? managedEntry = null; @@ -31,7 +32,7 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable } catch (SandboxPolicyException ex) { - audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "denied", new Dictionary { ["code"] = ex.Code, ["executable"] = executable })); + audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "denied", new Dictionary { ["code"] = ex.Code, ["executable"] = executable }, SessionId: sessionId, ErrorCode: ex.Code)); throw; } @@ -142,7 +143,7 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "denied", new Dictionary { ["code"] = ex.Code, ["executable"] = executable, ["stage"] = "post_create_pre_resume" - })); + }, SessionId: sessionId, ErrorCode: ex.Code)); throw; } if (PInvoke.ResumeThread(process.hThread) == uint.MaxValue) throw LastError("ResumeThread"); @@ -157,23 +158,23 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable processAudit["managed_entry_path"] = managedEntry.CanonicalPath; processAudit["managed_entry_sha256"] = managedEntry.Sha256; } - audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.start", "allowed", processAudit)); + 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))); if (wait == WAIT_EVENT.WAIT_TIMEOUT) { _ = PInvoke.TerminateJobObject(job, 1460); - audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.timeout", "terminated", new Dictionary { ["pid"] = process.dwProcessId.ToString() })); + audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.timeout", "terminated", new Dictionary { ["pid"] = process.dwProcessId.ToString() }, SessionId: sessionId)); 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 { ["pid"] = process.dwProcessId.ToString(), ["exit_code"] = exitCode.ToString() })); + audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.exit", "observed", new Dictionary { ["pid"] = process.dwProcessId.ToString(), ["exit_code"] = exitCode.ToString() }, SessionId: sessionId)); if (exitCode != 0) { audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "observed", new Dictionary { ["code"] = "restricted_process_failure", ["pid"] = process.dwProcessId.ToString(), ["child_process_policy"] = "restricted", ["exit_code"] = exitCode.ToString() - })); + }, SessionId: sessionId, ErrorCode: "process.child_denied")); } return new(process.dwProcessId, exitCode, ReadCapture(stdout), ReadCapture(stderr)); } diff --git a/src/Agent.Windows/Security/SecurityAudit.cs b/src/Agent.Windows/Security/SecurityAudit.cs index 8858c2b..616b08f 100644 --- a/src/Agent.Windows/Security/SecurityAudit.cs +++ b/src/Agent.Windows/Security/SecurityAudit.cs @@ -2,7 +2,8 @@ using System.Text.Json; namespace Agent.Windows.Security; -public sealed record SecurityAuditEvent(DateTimeOffset Timestamp, Guid TaskId, string Event, string Outcome, IReadOnlyDictionary Data); +public sealed record SecurityAuditEvent(DateTimeOffset Timestamp, Guid TaskId, string Event, string Outcome, + IReadOnlyDictionary Data, Guid? SessionId = null, string? ErrorCode = null); public interface ISecurityAuditSink { @@ -12,11 +13,12 @@ public interface ISecurityAuditSink public sealed class JsonLinesSecurityAuditSink(string path) : ISecurityAuditSink { private readonly object _sync = new(); + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); 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); + string line = JsonSerializer.Serialize(auditEvent, Json); lock (_sync) File.AppendAllText(path, line + Environment.NewLine); } } -- cgit v1.2.3