From c838b7cd45d014b33272803503dfbd27b9659bba Mon Sep 17 00:00:00 2001 From: Baptiste Tessiau Date: Wed, 11 Mar 2026 09:29:56 +0100 Subject: [PATCH 1/4] feat(codespaces): add audit logging for security and observability Add structured audit logging for Codespaces integration with 10 event types covering SSH connections, DevTunnel lifecycle, headless Copilot processes, and session errors. New files: - AuditLogEntry.cs: Data model + event type constants - AuditLogService.cs: Thread-safe JSONL writer with sanitization, daily rotation, and 30-day retention - AuditLogTests.cs: 19 tests (file I/O, typed events, sanitization, thread safety, error handling, retention) Integration points: - CodespaceService: SSH tunnel/port-forward lifecycle audit - CodespaceService.Lifecycle: Headless copilot start/failure - DevTunnelService: Token acquisition, tunnel hosting, session close Security: Tokens/keys/passwords never logged. Home paths, ghp_*, gho_*, github_pat_*, and JWT patterns redacted from error messages. Closes #350 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/AuditLogTests.cs | 318 +++++++++++++++++ PolyPilot.Tests/PolyPilot.Tests.csproj | 2 + PolyPilot/MauiProgram.cs | 1 + PolyPilot/Models/AuditLogEntry.cs | 56 +++ PolyPilot/Services/AuditLogService.cs | 322 ++++++++++++++++++ .../Services/CodespaceService.Lifecycle.cs | 2 + PolyPilot/Services/CodespaceService.cs | 20 +- PolyPilot/Services/DevTunnelService.cs | 8 +- 8 files changed, 727 insertions(+), 2 deletions(-) create mode 100644 PolyPilot.Tests/AuditLogTests.cs create mode 100644 PolyPilot/Models/AuditLogEntry.cs create mode 100644 PolyPilot/Services/AuditLogService.cs diff --git a/PolyPilot.Tests/AuditLogTests.cs b/PolyPilot.Tests/AuditLogTests.cs new file mode 100644 index 0000000000..4cc0dc9650 --- /dev/null +++ b/PolyPilot.Tests/AuditLogTests.cs @@ -0,0 +1,318 @@ +using System.Text.Json; +using PolyPilot.Models; +using PolyPilot.Services; + +namespace PolyPilot.Tests; + +// Disable parallel execution — tests share filesystem +[Collection("AuditLogTests")] +public class AuditLogTests : IDisposable +{ + private readonly string _testDir; + private readonly AuditLogService _auditLog; + + public AuditLogTests() + { + _testDir = Path.Combine(Path.GetTempPath(), $"polypilot-audit-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_testDir); + // Pass directory to constructor — no static state needed + _auditLog = new AuditLogService(_testDir); + } + + public void Dispose() + { + _auditLog.Dispose(); + try { Directory.Delete(_testDir, recursive: true); } catch { } + } + + // ── File creation and format ──────────────────────────────────────────── + + [Fact] + public async Task WriteEntry_CreatesJsonlFile() + { + await _auditLog.WriteEntryAsync(new AuditLogEntry + { + EventType = AuditEventTypes.CodespaceConnectionInitiated, + SessionId = "test-session-1", + Details = new() { ["codespace_name"] = "my-cs" } + }); + + var files = Directory.GetFiles(_testDir, "audit_*.jsonl"); + Assert.Single(files); + + var lines = await File.ReadAllLinesAsync(files[0]); + Assert.Single(lines); + + // Verify it's valid JSON + var doc = JsonDocument.Parse(lines[0]); + Assert.Equal("CODESPACE_CONNECTION_INITIATED", doc.RootElement.GetProperty("event_type").GetString()); + Assert.Equal("test-session-1", doc.RootElement.GetProperty("session_id").GetString()); + } + + [Fact] + public async Task WriteEntry_AppendsMultipleLines() + { + await _auditLog.WriteEntryAsync(new AuditLogEntry { EventType = "EVENT_1" }); + await _auditLog.WriteEntryAsync(new AuditLogEntry { EventType = "EVENT_2" }); + await _auditLog.WriteEntryAsync(new AuditLogEntry { EventType = "EVENT_3" }); + + var files = Directory.GetFiles(_testDir, "audit_*.jsonl"); + var lines = (await File.ReadAllLinesAsync(files[0])) + .Where(l => !string.IsNullOrWhiteSpace(l)).ToArray(); + Assert.Equal(3, lines.Length); + } + + [Fact] + public async Task WriteEntry_IncludesTimestamp() + { + var before = DateTime.UtcNow; + await _auditLog.WriteEntryAsync(new AuditLogEntry { EventType = "TEST" }); + var after = DateTime.UtcNow; + + var files = Directory.GetFiles(_testDir, "audit_*.jsonl"); + var doc = JsonDocument.Parse((await File.ReadAllLinesAsync(files[0]))[0]); + var timestamp = doc.RootElement.GetProperty("timestamp").GetDateTime(); + Assert.InRange(timestamp, before, after.AddSeconds(1)); + } + + // ── Typed event methods ───────────────────────────────────────────────── + + [Fact] + public async Task LogCodespaceConnectionInitiated_WritesCorrectFields() + { + await _auditLog.LogCodespaceConnectionInitiated("test-codespace", "sess-1", 4321); + + var entry = await ReadLastEntry(); + Assert.Equal("CODESPACE_CONNECTION_INITIATED", entry.EventType); + Assert.Equal("sess-1", entry.SessionId); + Assert.Equal("test-codespace", entry.Details["codespace_name"]?.ToString()); + } + + [Fact] + public async Task LogSshHandshakeSuccess_WritesCorrectFields() + { + await _auditLog.LogSshHandshakeSuccess("cs-1", "sess-2", 12345, isSshTunnel: true); + + var entry = await ReadLastEntry(); + Assert.Equal("CODESPACE_SSH_HANDSHAKE_SUCCESS", entry.EventType); + Assert.Equal("ssh", entry.Details["tunnel_type"]?.ToString()); + } + + [Fact] + public async Task LogSshHandshakeFailure_SanitizesMessage() + { + await _auditLog.LogSshHandshakeFailure("cs-1", null, "Error with token ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmno"); + + var entry = await ReadLastEntry(); + Assert.Equal("CODESPACE_SSH_HANDSHAKE_FAILURE", entry.EventType); + var reason = entry.Details["failure_reason"]?.ToString()!; + Assert.DoesNotContain("ghp_ABCD", reason); + Assert.Contains("ghp_[redacted]", reason); + } + + [Fact] + public async Task LogDevtunnelTokenAcquired_TruncatesTunnelId() + { + await _auditLog.LogDevtunnelTokenAcquired(null, "abcdefghijklmnop-long-tunnel-id", 256); + + var entry = await ReadLastEntry(); + Assert.Equal("DEVTUNNEL_TOKEN_ACQUIRED", entry.EventType); + var tunnelId = entry.Details["tunnel_id"]?.ToString()!; + Assert.StartsWith("abcdefgh", tunnelId); + Assert.Contains("[redacted]", tunnelId); + } + + [Fact] + public async Task LogSessionError_WritesAllFields() + { + await _auditLog.LogSessionError("sess-err", "SSH", "Connection refused", "at SomeMethod()"); + + var entry = await ReadLastEntry(); + Assert.Equal("SESSION_ERROR", entry.EventType); + Assert.Equal("SSH", entry.Details["error_category"]?.ToString()); + } + + // ── Sanitization ──────────────────────────────────────────────────────── + + [Fact] + public void SanitizeSecret_TruncatesLongValues() + { + Assert.Equal("abcdefgh[redacted]", AuditLogService.SanitizeSecret("abcdefghijklmnop")); + Assert.Equal("[redacted]", AuditLogService.SanitizeSecret("short")); + Assert.Equal("[none]", AuditLogService.SanitizeSecret(null)); + Assert.Equal("[none]", AuditLogService.SanitizeSecret("")); + } + + [Fact] + public void SanitizeErrorMessage_RedactsTokenPatterns() + { + var msg = "Auth failed with token ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmno"; + var sanitized = AuditLogService.SanitizeErrorMessage(msg); + Assert.DoesNotContain("ghp_ABCD", sanitized); + Assert.Contains("ghp_[redacted]", sanitized); + } + + [Fact] + public void SanitizeErrorMessage_RedactsJwtTokens() + { + var msg = "Token: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0"; + var sanitized = AuditLogService.SanitizeErrorMessage(msg); + Assert.DoesNotContain("eyJhbG", sanitized); + Assert.Contains("jwt_[redacted]", sanitized); + } + + [Fact] + public void SanitizeErrorMessage_RedactsGitHubPat() + { + var msg = "Error: github_pat_11AABCDEF0123456789abcdefghijklmnop01234567890"; + var sanitized = AuditLogService.SanitizeErrorMessage(msg); + Assert.Contains("github_pat_[redacted]", sanitized); + } + + [Fact] + public void SanitizeErrorMessage_ReplacesHomePath() + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrEmpty(home)) return; // Skip if no home dir + var msg = $"File not found: {home}/.ssh/id_rsa"; + var sanitized = AuditLogService.SanitizeErrorMessage(msg); + Assert.DoesNotContain(home, sanitized); + Assert.Contains("~/.ssh/id_rsa", sanitized); + } + + [Fact] + public void SanitizeErrorMessage_HandlesNullAndEmpty() + { + Assert.Equal("[empty]", AuditLogService.SanitizeErrorMessage(null)); + Assert.Equal("[empty]", AuditLogService.SanitizeErrorMessage("")); + } + + // ── Thread safety ─────────────────────────────────────────────────────── + + [Fact] + public async Task ConcurrentWrites_NoDataLoss() + { + const int taskCount = 10; + const int entriesPerTask = 20; + + var tasks = Enumerable.Range(0, taskCount).Select(t => + Task.Run(async () => + { + for (int i = 0; i < entriesPerTask; i++) + { + await _auditLog.WriteEntryAsync(new AuditLogEntry + { + EventType = $"CONCURRENT_{t}_{i}", + SessionId = $"task-{t}" + }); + } + }) + ); + + await Task.WhenAll(tasks); + + var files = Directory.GetFiles(_testDir, "audit_*.jsonl"); + var lines = (await File.ReadAllLinesAsync(files[0])) + .Where(l => !string.IsNullOrWhiteSpace(l)).ToArray(); + Assert.Equal(taskCount * entriesPerTask, lines.Length); + + // Verify each line is valid JSON + foreach (var line in lines) + { + var doc = JsonDocument.Parse(line); + Assert.NotNull(doc.RootElement.GetProperty("event_type").GetString()); + } + } + + // ── Error handling ────────────────────────────────────────────────────── + + [Fact] + public async Task WriteEntry_DoesNotThrow_OnInvalidPath() + { + // Create a service pointing at an impossible path + using var badSvc = new AuditLogService("/dev/null/impossible/path"); + + // Should not throw — errors are swallowed + await badSvc.WriteEntryAsync(new AuditLogEntry { EventType = "TEST" }); + } + + // ── Retention ─────────────────────────────────────────────────────────── + + [Fact] + public void PurgeOldLogs_DeletesOldFiles() + { + // Create a "old" file with backdated creation time + var oldFile = Path.Combine(_testDir, "audit_2024-01-01.jsonl"); + File.WriteAllText(oldFile, "{\"event_type\":\"OLD\"}\n"); + File.SetCreationTimeUtc(oldFile, DateTime.UtcNow.AddDays(-31)); + + // Create a "recent" file + var recentFile = Path.Combine(_testDir, "audit_2099-01-01.jsonl"); + File.WriteAllText(recentFile, "{\"event_type\":\"RECENT\"}\n"); + + _auditLog.PurgeOldLogs(); + + Assert.False(File.Exists(oldFile), "Old file should be deleted"); + Assert.True(File.Exists(recentFile), "Recent file should be kept"); + } + + // ── AuditLogEntry serialization ───────────────────────────────────────── + + [Fact] + public void AuditLogEntry_ToJsonLine_ProducesValidJson() + { + var entry = new AuditLogEntry + { + EventType = "TEST_EVENT", + SessionId = "s-123", + Details = new() { ["key1"] = "value1", ["key2"] = 42 } + }; + + var json = entry.ToJsonLine(); + var doc = JsonDocument.Parse(json); + Assert.Equal("TEST_EVENT", doc.RootElement.GetProperty("event_type").GetString()); + Assert.Equal("s-123", doc.RootElement.GetProperty("session_id").GetString()); + } + + [Fact] + public void AuditEventTypes_AllConstantsDefined() + { + Assert.Equal("CODESPACE_CONNECTION_INITIATED", AuditEventTypes.CodespaceConnectionInitiated); + Assert.Equal("CODESPACE_SSH_HANDSHAKE_SUCCESS", AuditEventTypes.CodespaceSshHandshakeSuccess); + Assert.Equal("CODESPACE_SSH_HANDSHAKE_FAILURE", AuditEventTypes.CodespaceSshHandshakeFailure); + Assert.Equal("COPILOT_HEADLESS_START", AuditEventTypes.CopilotHeadlessStart); + Assert.Equal("COPILOT_HEADLESS_FAILURE", AuditEventTypes.CopilotHeadlessFailure); + Assert.Equal("DEVTUNNEL_TOKEN_ACQUIRED", AuditEventTypes.DevtunnelTokenAcquired); + Assert.Equal("DEVTUNNEL_CONNECTION_ESTABLISHED", AuditEventTypes.DevtunnelConnectionEstablished); + Assert.Equal("DEVTUNNEL_CONNECTION_FAILED", AuditEventTypes.DevtunnelConnectionFailed); + Assert.Equal("SESSION_CLOSED", AuditEventTypes.SessionClosed); + Assert.Equal("SESSION_ERROR", AuditEventTypes.SessionError); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private async Task ReadLastEntry() + { + var files = Directory.GetFiles(_testDir, "audit_*.jsonl"); + Assert.Single(files); + var lines = (await File.ReadAllLinesAsync(files[0])) + .Where(l => !string.IsNullOrWhiteSpace(l)).ToArray(); + Assert.NotEmpty(lines); + var json = lines[^1]; + var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var entry = new AuditLogEntry + { + EventType = root.GetProperty("event_type").GetString() ?? "", + SessionId = root.TryGetProperty("session_id", out var sid) ? sid.GetString() : null, + }; + if (root.TryGetProperty("details", out var details)) + { + foreach (var prop in details.EnumerateObject()) + entry.Details[prop.Name] = prop.Value.ValueKind == JsonValueKind.String + ? prop.Value.GetString() + : prop.Value.ToString(); + } + return entry; + } +} diff --git a/PolyPilot.Tests/PolyPilot.Tests.csproj b/PolyPilot.Tests/PolyPilot.Tests.csproj index 8b05a17591..1ec6dde9b7 100644 --- a/PolyPilot.Tests/PolyPilot.Tests.csproj +++ b/PolyPilot.Tests/PolyPilot.Tests.csproj @@ -29,6 +29,7 @@ + @@ -63,6 +64,7 @@ + diff --git a/PolyPilot/MauiProgram.cs b/PolyPilot/MauiProgram.cs index 846a5b4c0b..c2bfb3328d 100644 --- a/PolyPilot/MauiProgram.cs +++ b/PolyPilot/MauiProgram.cs @@ -101,6 +101,7 @@ public static MauiApp CreateMauiApp() builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); diff --git a/PolyPilot/Models/AuditLogEntry.cs b/PolyPilot/Models/AuditLogEntry.cs new file mode 100644 index 0000000000..9a9dd82205 --- /dev/null +++ b/PolyPilot/Models/AuditLogEntry.cs @@ -0,0 +1,56 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace PolyPilot.Models; + +/// +/// A single structured audit log entry. Serialized as one JSON line in the audit JSONL file. +/// All sensitive data (tokens, keys, passwords) must be sanitized before creating an entry. +/// +public class AuditLogEntry +{ + [JsonPropertyName("timestamp")] + public DateTime Timestamp { get; set; } = DateTime.UtcNow; + + [JsonPropertyName("event_type")] + public string EventType { get; set; } = string.Empty; + + [JsonPropertyName("session_id")] + public string? SessionId { get; set; } + + [JsonPropertyName("details")] + public Dictionary Details { get; set; } = new(); + + public string ToJsonLine() + { + return JsonSerializer.Serialize(this, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }); + } +} + +/// +/// Event type constants for audit log entries. +/// +public static class AuditEventTypes +{ + public const string CodespaceConnectionInitiated = "CODESPACE_CONNECTION_INITIATED"; + public const string CodespaceSshHandshakeSuccess = "CODESPACE_SSH_HANDSHAKE_SUCCESS"; + public const string CodespaceSshHandshakeFailure = "CODESPACE_SSH_HANDSHAKE_FAILURE"; + public const string CopilotHeadlessStart = "COPILOT_HEADLESS_START"; + public const string CopilotHeadlessFailure = "COPILOT_HEADLESS_FAILURE"; + public const string DevtunnelTokenAcquired = "DEVTUNNEL_TOKEN_ACQUIRED"; + public const string DevtunnelConnectionEstablished = "DEVTUNNEL_CONNECTION_ESTABLISHED"; + public const string DevtunnelConnectionFailed = "DEVTUNNEL_CONNECTION_FAILED"; + public const string SessionClosed = "SESSION_CLOSED"; + public const string SessionError = "SESSION_ERROR"; +} + +// Source-generated JSON context for trimmer-safe serialization +[JsonSerializable(typeof(AuditLogEntry))] +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +internal partial class AuditLogJsonContext : JsonSerializerContext { } diff --git a/PolyPilot/Services/AuditLogService.cs b/PolyPilot/Services/AuditLogService.cs new file mode 100644 index 0000000000..027a438a86 --- /dev/null +++ b/PolyPilot/Services/AuditLogService.cs @@ -0,0 +1,322 @@ +using System.Text.RegularExpressions; +using PolyPilot.Models; + +namespace PolyPilot.Services; + +/// +/// Structured audit logging for security-sensitive operations (codespace connections, +/// SSH handshakes, tunnel setup, token acquisition). Writes JSON Lines to daily +/// rotated files in ~/.polypilot/audit_logs/. Thread-safe via SemaphoreSlim. +/// +/// Security: All tokens, keys, and passwords are sanitized before writing. +/// Error handling: Logging failures never propagate — they fall back to Console.Error. +/// +public sealed class AuditLogService : IDisposable +{ + private readonly SemaphoreSlim _writeLock = new(1, 1); + private const int RetentionDays = 30; + private const string LogDirName = "audit_logs"; + + // Instance-level override (for testing or custom paths) + private readonly string? _instanceLogDir; + + // Lazy directory resolution matching CopilotService pattern + private static string? _auditLogDir; + private static string DefaultAuditLogDir => _auditLogDir ??= ComputeAuditLogDir(); + + // Effective log dir: instance override takes priority + private string AuditLogDir => _instanceLogDir ?? DefaultAuditLogDir; + + // For testing: allows overriding the default log directory + internal static void SetLogDirForTesting(string dir) => _auditLogDir = dir; + internal static void ResetLogDir() => _auditLogDir = null; + + /// + /// Creates an AuditLogService. Optionally provide a log directory (for testing). + /// When null, uses the default ~/.polypilot/audit_logs/ path. + /// + public AuditLogService(string? logDir = null) + { + _instanceLogDir = logDir; + } + + private static string ComputeAuditLogDir() + { + try + { +#if IOS || ANDROID + return Path.Combine(FileSystem.AppDataDirectory, ".polypilot", LogDirName); +#else + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrEmpty(home)) + home = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + return Path.Combine(home, ".polypilot", LogDirName); +#endif + } + catch + { + return Path.Combine(Path.GetTempPath(), ".polypilot", LogDirName); + } + } + + // ── Core write ────────────────────────────────────────────────────────── + + /// + /// Writes an audit entry as a single JSON line to the current day's log file. + /// Never throws — logging failures are swallowed and reported to Console.Error. + /// + public async Task WriteEntryAsync(AuditLogEntry entry) + { + try + { + var dir = AuditLogDir; + Directory.CreateDirectory(dir); + + var filePath = Path.Combine(dir, $"audit_{DateTime.UtcNow:yyyy-MM-dd}.jsonl"); + var line = entry.ToJsonLine() + Environment.NewLine; + + await _writeLock.WaitAsync().ConfigureAwait(false); + try + { + await File.AppendAllTextAsync(filePath, line).ConfigureAwait(false); + } + finally + { + _writeLock.Release(); + } + } + catch (Exception ex) + { + // Audit logging must never crash the app + Console.Error.WriteLine($"[AuditLog] Failed to write entry: {ex.Message}"); + } + } + + // ── Retention cleanup ─────────────────────────────────────────────────── + + /// + /// Deletes audit log files older than 30 days. Called once on startup. + /// + public void PurgeOldLogs() + { + try + { + var dir = AuditLogDir; + if (!Directory.Exists(dir)) return; + + var cutoff = DateTime.UtcNow.AddDays(-RetentionDays); + foreach (var file in Directory.GetFiles(dir, "audit_*.jsonl")) + { + try + { + if (File.GetCreationTimeUtc(file) < cutoff) + File.Delete(file); + } + catch { /* best-effort cleanup */ } + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"[AuditLog] Failed to purge old logs: {ex.Message}"); + } + } + + // ── Sanitization helpers ──────────────────────────────────────────────── + + /// + /// Truncates a secret to at most characters + /// with a "[redacted]" suffix. Returns "[none]" for null/empty values. + /// + public static string SanitizeSecret(string? value, int visibleChars = 8) + { + if (string.IsNullOrEmpty(value)) return "[none]"; + if (value.Length <= visibleChars) return "[redacted]"; + return value[..visibleChars] + "[redacted]"; + } + + /// + /// Removes file system paths that may reveal the user's home directory + /// and strips potential token-like strings from error messages. + /// + public static string SanitizeErrorMessage(string? message) + { + if (string.IsNullOrEmpty(message)) return "[empty]"; + + // Replace home directory paths + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrEmpty(home)) + message = message.Replace(home, "~"); + + // Redact strings that look like tokens (40+ hex chars or JWT-like patterns) + message = Regex.Replace(message, @"ghp_[A-Za-z0-9]{36,}", "ghp_[redacted]"); + message = Regex.Replace(message, @"gho_[A-Za-z0-9]{36,}", "gho_[redacted]"); + message = Regex.Replace(message, @"github_pat_[A-Za-z0-9_]{40,}", "github_pat_[redacted]"); + message = Regex.Replace(message, @"eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}", "jwt_[redacted]"); + + return message; + } + + // ── Typed event methods ───────────────────────────────────────────────── + + // Event 1: Codespace connection initiated + public Task LogCodespaceConnectionInitiated(string codespaceName, string? sessionId, int remotePort) + { + return WriteEntryAsync(new AuditLogEntry + { + EventType = AuditEventTypes.CodespaceConnectionInitiated, + SessionId = sessionId, + Details = new() + { + ["codespace_name"] = codespaceName, + ["remote_port"] = remotePort, + ["device_info"] = $"{Environment.OSVersion.Platform}/{Environment.OSVersion.Version}" + } + }); + } + + // Event 2: SSH handshake success + public Task LogSshHandshakeSuccess(string codespaceName, string? sessionId, int localPort, bool isSshTunnel) + { + return WriteEntryAsync(new AuditLogEntry + { + EventType = AuditEventTypes.CodespaceSshHandshakeSuccess, + SessionId = sessionId, + Details = new() + { + ["codespace_name"] = codespaceName, + ["local_port"] = localPort, + ["tunnel_type"] = isSshTunnel ? "ssh" : "port_forward", + ["device_info"] = $"{Environment.OSVersion.Platform}/{Environment.OSVersion.Version}" + } + }); + } + + // Event 3: SSH handshake failure + public Task LogSshHandshakeFailure(string codespaceName, string? sessionId, string failureReason, int retryCount = 0) + { + return WriteEntryAsync(new AuditLogEntry + { + EventType = AuditEventTypes.CodespaceSshHandshakeFailure, + SessionId = sessionId, + Details = new() + { + ["codespace_name"] = codespaceName, + ["failure_reason"] = SanitizeErrorMessage(failureReason), + ["retry_count"] = retryCount + } + }); + } + + // Event 4: Copilot headless started successfully + public Task LogCopilotHeadlessStart(string codespaceName, string? sessionId, int remotePort) + { + return WriteEntryAsync(new AuditLogEntry + { + EventType = AuditEventTypes.CopilotHeadlessStart, + SessionId = sessionId, + Details = new() + { + ["codespace_name"] = codespaceName, + ["remote_port"] = remotePort + } + }); + } + + // Event 5: Copilot headless failed to start + public Task LogCopilotHeadlessFailure(string codespaceName, string? sessionId, string errorMessage) + { + return WriteEntryAsync(new AuditLogEntry + { + EventType = AuditEventTypes.CopilotHeadlessFailure, + SessionId = sessionId, + Details = new() + { + ["codespace_name"] = codespaceName, + ["error_message"] = SanitizeErrorMessage(errorMessage) + } + }); + } + + // Event 6: DevTunnel token acquired + public Task LogDevtunnelTokenAcquired(string? sessionId, string? tunnelId, int tokenLength) + { + return WriteEntryAsync(new AuditLogEntry + { + EventType = AuditEventTypes.DevtunnelTokenAcquired, + SessionId = sessionId, + Details = new() + { + ["tunnel_id"] = SanitizeSecret(tunnelId), + ["token_length"] = tokenLength + } + }); + } + + // Event 7: DevTunnel connection established + public Task LogDevtunnelConnectionEstablished(string? sessionId, string? tunnelId, string? tunnelUrl, long latencyMs) + { + return WriteEntryAsync(new AuditLogEntry + { + EventType = AuditEventTypes.DevtunnelConnectionEstablished, + SessionId = sessionId, + Details = new() + { + ["tunnel_id"] = SanitizeSecret(tunnelId), + ["tunnel_url"] = tunnelUrl, + ["connection_latency_ms"] = latencyMs + } + }); + } + + // Event 8: DevTunnel connection failed + public Task LogDevtunnelConnectionFailed(string? sessionId, string? tunnelId, string failureReason) + { + return WriteEntryAsync(new AuditLogEntry + { + EventType = AuditEventTypes.DevtunnelConnectionFailed, + SessionId = sessionId, + Details = new() + { + ["tunnel_id"] = SanitizeSecret(tunnelId), + ["failure_reason"] = SanitizeErrorMessage(failureReason) + } + }); + } + + // Event 9: Session closed + public Task LogSessionClosed(string? sessionId, double durationSeconds, bool cleanClose, string? closeReason) + { + return WriteEntryAsync(new AuditLogEntry + { + EventType = AuditEventTypes.SessionClosed, + SessionId = sessionId, + Details = new() + { + ["duration_seconds"] = Math.Round(durationSeconds, 1), + ["clean_close"] = cleanClose, + ["close_reason"] = closeReason + } + }); + } + + // Event 10: Session error + public Task LogSessionError(string? sessionId, string errorCategory, string errorMessage, string? stackTrace = null) + { + return WriteEntryAsync(new AuditLogEntry + { + EventType = AuditEventTypes.SessionError, + SessionId = sessionId, + Details = new() + { + ["error_category"] = errorCategory, + ["error_message"] = SanitizeErrorMessage(errorMessage), + ["stack_trace"] = stackTrace != null ? SanitizeErrorMessage(stackTrace) : null + } + }); + } + + public void Dispose() + { + _writeLock.Dispose(); + } +} diff --git a/PolyPilot/Services/CodespaceService.Lifecycle.cs b/PolyPilot/Services/CodespaceService.Lifecycle.cs index 292b3649bc..83db683a82 100644 --- a/PolyPilot/Services/CodespaceService.Lifecycle.cs +++ b/PolyPilot/Services/CodespaceService.Lifecycle.cs @@ -190,10 +190,12 @@ public async Task StartCopilotHeadlessAsync(string codespaceName, int remo if (result != null && result.Contains("STARTED")) { Console.WriteLine($"[CodespaceService] Copilot headless started successfully in '{codespaceName}'"); + _ = AuditLog?.LogCopilotHeadlessStart(codespaceName, null, remotePort); return true; } Console.WriteLine($"[CodespaceService] Copilot may have failed to start in '{codespaceName}': {result?.Trim()}"); + _ = AuditLog?.LogCopilotHeadlessFailure(codespaceName, null, result?.Trim() ?? "Unknown failure"); return true; // Still return true (SSH worked) — let the tunnel probe determine if it's listening } diff --git a/PolyPilot/Services/CodespaceService.cs b/PolyPilot/Services/CodespaceService.cs index 9c5cc26334..2c9f30951b 100644 --- a/PolyPilot/Services/CodespaceService.cs +++ b/PolyPilot/Services/CodespaceService.cs @@ -12,6 +12,18 @@ namespace PolyPilot.Services; /// public partial class CodespaceService { + /// + /// Optional audit logger — injected via DI, null in tests or when audit logging is disabled. + /// + internal AuditLogService? AuditLog { get; set; } + + public CodespaceService() { } + + public CodespaceService(AuditLogService auditLog) + { + AuditLog = auditLog; + } + /// /// Holds a running tunnel process (SSH or port-forward) and the local port it forwards. /// @@ -54,7 +66,8 @@ public async ValueTask DisposeAsync() { var localPort = FindFreePort(); - // `gh cs ports forward :` uses GitHub's codespace networking + // Audit: log connection attempt before any network activity + _ = AuditLog?.LogCodespaceConnectionInitiated(codespaceName, null, remotePort); // and does NOT require an SSH server to be installed in the container. var psi = new ProcessStartInfo { @@ -91,7 +104,10 @@ public async ValueTask DisposeAsync() } if (await IsCopilotListeningAsync(localPort)) + { + _ = AuditLog?.LogSshHandshakeSuccess(codespaceName, null, localPort, isSshTunnel: false); return (handle, copilotReady: true); + } await Task.Delay(500); } @@ -105,6 +121,7 @@ public async ValueTask DisposeAsync() // Timed out — kill process and throw await handle.DisposeAsync(); + _ = AuditLog?.LogSshHandshakeFailure(codespaceName, null, "Timeout waiting for copilot"); throw new TimeoutException($"Timed out waiting for copilot --headless in codespace '{codespaceName}' (port {remotePort}). Run 'copilot --headless --port {remotePort}' in the codespace terminal and retry."); } @@ -268,6 +285,7 @@ public async ValueTask DisposeAsync() } Console.WriteLine($"[CodespaceService] SSH tunnel established: localhost:{localPort} → {codespaceName}:{remotePort}"); + _ = AuditLog?.LogSshHandshakeSuccess(codespaceName, null, localPort, isSshTunnel: true); return handle; } diff --git a/PolyPilot/Services/DevTunnelService.cs b/PolyPilot/Services/DevTunnelService.cs index 4e0f6bc5e9..f58ece278c 100644 --- a/PolyPilot/Services/DevTunnelService.cs +++ b/PolyPilot/Services/DevTunnelService.cs @@ -19,6 +19,7 @@ public partial class DevTunnelService : IDisposable private readonly WsBridgeServer _bridge; private readonly CopilotService _copilot; private readonly RepoManager _repoManager; + private readonly AuditLogService? _auditLog; private Process? _hostProcess; private string? _tunnelUrl; private string? _tunnelId; @@ -28,11 +29,12 @@ public partial class DevTunnelService : IDisposable public const int BridgePort = 4322; - public DevTunnelService(WsBridgeServer bridge, CopilotService copilot, RepoManager repoManager) + public DevTunnelService(WsBridgeServer bridge, CopilotService copilot, RepoManager repoManager, AuditLogService? auditLog = null) { _bridge = bridge; _copilot = copilot; _repoManager = repoManager; + _auditLog = auditLog; } public TunnelState State => _state; @@ -263,12 +265,14 @@ public async Task HostAsync(int copilotPort) _bridge.AccessToken = _accessToken; SetState(TunnelState.Running); + _ = _auditLog?.LogDevtunnelConnectionEstablished(null, _tunnelId, _tunnelUrl, 0); return true; } catch (Exception ex) { Stop(); SetError($"Host error: {ex.Message}"); + _ = _auditLog?.LogDevtunnelConnectionFailed(null, _tunnelId, ex.Message); return false; } } @@ -437,6 +441,7 @@ private void TryExtractInfo(string line, TaskCompletionSource urlFound) token = lines.Length > 0 ? lines[^1].Trim() : ""; } Console.WriteLine($"[DevTunnel] Access token issued ({token.Length} chars)"); + _ = _auditLog?.LogDevtunnelTokenAcquired(null, _tunnelId, token.Length); return string.IsNullOrEmpty(token) ? null : token; } catch (Exception ex) @@ -452,6 +457,7 @@ private void TryExtractInfo(string line, TaskCompletionSource urlFound) public void Stop() { SetState(TunnelState.Stopping); + _ = _auditLog?.LogSessionClosed(null, 0, cleanClose: true, closeReason: "DevTunnel stopped"); try { if (_hostProcess != null && !_hostProcess.HasExited) From 55801ce4d6f7b150245689f87bf742cdf5d12d95 Mon Sep 17 00:00:00 2001 From: Baptiste Tessiau Date: Wed, 11 Mar 2026 10:31:36 +0100 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94=20?= =?UTF-8?q?filename-based=20purge,=20cleanClose=20flag,=20indeterminate=20?= =?UTF-8?q?event,=20latency=20measurement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review: https://github.com/PureWeen/PolyPilot/pull/351#pullrequestreview-3927811974 1. PurgeOldLogs: parse date from filename instead of GetCreationTimeUtc (unreliable on Linux where it falls back to mtime) 2. Stop(cleanClose): add parameter, pass false from failure paths so audit trail distinguishes clean vs error shutdowns 3. Add COPILOT_HEADLESS_INDETERMINATE event for cases where SSH worked but startup probe was inconclusive (avoids false-positive failures) 4. Measure actual tunnel setup latency via Stopwatch instead of hardcoded 0 5. Add 2 new tests: unparsable filename retention, indeterminate event Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/AuditLogTests.cs | 31 ++++++++++++++++--- PolyPilot/Models/AuditLogEntry.cs | 1 + PolyPilot/Services/AuditLogService.cs | 28 ++++++++++++++++- .../Services/CodespaceService.Lifecycle.cs | 2 +- PolyPilot/Services/DevTunnelService.cs | 12 ++++--- 5 files changed, 63 insertions(+), 11 deletions(-) diff --git a/PolyPilot.Tests/AuditLogTests.cs b/PolyPilot.Tests/AuditLogTests.cs index 4cc0dc9650..5bcf64fe7d 100644 --- a/PolyPilot.Tests/AuditLogTests.cs +++ b/PolyPilot.Tests/AuditLogTests.cs @@ -132,6 +132,18 @@ public async Task LogSessionError_WritesAllFields() Assert.Equal("SSH", entry.Details["error_category"]?.ToString()); } + [Fact] + public async Task LogCopilotHeadlessIndeterminate_WritesCorrectFields() + { + await _auditLog.LogCopilotHeadlessIndeterminate("cs-1", "sess-3", "FAILED"); + + var entry = await ReadLastEntry(); + Assert.Equal("COPILOT_HEADLESS_INDETERMINATE", entry.EventType); + Assert.Equal("cs-1", entry.Details["codespace_name"]?.ToString()); + Assert.Equal("FAILED", entry.Details["probe_result"]?.ToString()); + Assert.Equal("tunnel_probe", entry.Details["determined_by"]?.ToString()); + } + // ── Sanitization ──────────────────────────────────────────────────────── [Fact] @@ -241,21 +253,31 @@ public async Task WriteEntry_DoesNotThrow_OnInvalidPath() [Fact] public void PurgeOldLogs_DeletesOldFiles() { - // Create a "old" file with backdated creation time + // Create an "old" file with a date >30 days ago in the filename var oldFile = Path.Combine(_testDir, "audit_2024-01-01.jsonl"); File.WriteAllText(oldFile, "{\"event_type\":\"OLD\"}\n"); - File.SetCreationTimeUtc(oldFile, DateTime.UtcNow.AddDays(-31)); - // Create a "recent" file + // Create a "recent" file with a future date in the filename var recentFile = Path.Combine(_testDir, "audit_2099-01-01.jsonl"); File.WriteAllText(recentFile, "{\"event_type\":\"RECENT\"}\n"); _auditLog.PurgeOldLogs(); - Assert.False(File.Exists(oldFile), "Old file should be deleted"); + Assert.False(File.Exists(oldFile), "Old file should be deleted (filename date > 30 days)"); Assert.True(File.Exists(recentFile), "Recent file should be kept"); } + [Fact] + public void PurgeOldLogs_IgnoresFilesWithUnparsableNames() + { + var oddFile = Path.Combine(_testDir, "audit_not-a-date.jsonl"); + File.WriteAllText(oddFile, "{\"event_type\":\"ODD\"}\n"); + + _auditLog.PurgeOldLogs(); + + Assert.True(File.Exists(oddFile), "Files with unparsable names should be kept"); + } + // ── AuditLogEntry serialization ───────────────────────────────────────── [Fact] @@ -282,6 +304,7 @@ public void AuditEventTypes_AllConstantsDefined() Assert.Equal("CODESPACE_SSH_HANDSHAKE_FAILURE", AuditEventTypes.CodespaceSshHandshakeFailure); Assert.Equal("COPILOT_HEADLESS_START", AuditEventTypes.CopilotHeadlessStart); Assert.Equal("COPILOT_HEADLESS_FAILURE", AuditEventTypes.CopilotHeadlessFailure); + Assert.Equal("COPILOT_HEADLESS_INDETERMINATE", AuditEventTypes.CopilotHeadlessIndeterminate); Assert.Equal("DEVTUNNEL_TOKEN_ACQUIRED", AuditEventTypes.DevtunnelTokenAcquired); Assert.Equal("DEVTUNNEL_CONNECTION_ESTABLISHED", AuditEventTypes.DevtunnelConnectionEstablished); Assert.Equal("DEVTUNNEL_CONNECTION_FAILED", AuditEventTypes.DevtunnelConnectionFailed); diff --git a/PolyPilot/Models/AuditLogEntry.cs b/PolyPilot/Models/AuditLogEntry.cs index 9a9dd82205..467ac01a1a 100644 --- a/PolyPilot/Models/AuditLogEntry.cs +++ b/PolyPilot/Models/AuditLogEntry.cs @@ -41,6 +41,7 @@ public static class AuditEventTypes public const string CodespaceSshHandshakeFailure = "CODESPACE_SSH_HANDSHAKE_FAILURE"; public const string CopilotHeadlessStart = "COPILOT_HEADLESS_START"; public const string CopilotHeadlessFailure = "COPILOT_HEADLESS_FAILURE"; + public const string CopilotHeadlessIndeterminate = "COPILOT_HEADLESS_INDETERMINATE"; public const string DevtunnelTokenAcquired = "DEVTUNNEL_TOKEN_ACQUIRED"; public const string DevtunnelConnectionEstablished = "DEVTUNNEL_CONNECTION_ESTABLISHED"; public const string DevtunnelConnectionFailed = "DEVTUNNEL_CONNECTION_FAILED"; diff --git a/PolyPilot/Services/AuditLogService.cs b/PolyPilot/Services/AuditLogService.cs index 027a438a86..8cc1feae56 100644 --- a/PolyPilot/Services/AuditLogService.cs +++ b/PolyPilot/Services/AuditLogService.cs @@ -109,8 +109,17 @@ public void PurgeOldLogs() { try { - if (File.GetCreationTimeUtc(file) < cutoff) + // Parse date from filename (audit_YYYY-MM-DD.jsonl) instead of + // filesystem timestamps — GetCreationTimeUtc is unreliable on Linux + // where it falls back to mtime (always "now" for appended files). + var name = Path.GetFileNameWithoutExtension(file); + if (name.Length >= 16 // "audit_YYYY-MM-DD" + && DateTime.TryParseExact(name[6..], "yyyy-MM-dd", null, + System.Globalization.DateTimeStyles.None, out var fileDate) + && fileDate < cutoff.Date) + { File.Delete(file); + } } catch { /* best-effort cleanup */ } } @@ -237,6 +246,23 @@ public Task LogCopilotHeadlessFailure(string codespaceName, string? sessionId, s }); } + // Event 5b: Copilot headless status indeterminate (SSH worked, but startup + // probe was inconclusive — tunnel probe will determine actual status) + public Task LogCopilotHeadlessIndeterminate(string codespaceName, string? sessionId, string probeResult) + { + return WriteEntryAsync(new AuditLogEntry + { + EventType = AuditEventTypes.CopilotHeadlessIndeterminate, + SessionId = sessionId, + Details = new() + { + ["codespace_name"] = codespaceName, + ["probe_result"] = probeResult, + ["determined_by"] = "tunnel_probe" + } + }); + } + // Event 6: DevTunnel token acquired public Task LogDevtunnelTokenAcquired(string? sessionId, string? tunnelId, int tokenLength) { diff --git a/PolyPilot/Services/CodespaceService.Lifecycle.cs b/PolyPilot/Services/CodespaceService.Lifecycle.cs index 83db683a82..a14ed0d5d2 100644 --- a/PolyPilot/Services/CodespaceService.Lifecycle.cs +++ b/PolyPilot/Services/CodespaceService.Lifecycle.cs @@ -195,7 +195,7 @@ public async Task StartCopilotHeadlessAsync(string codespaceName, int remo } Console.WriteLine($"[CodespaceService] Copilot may have failed to start in '{codespaceName}': {result?.Trim()}"); - _ = AuditLog?.LogCopilotHeadlessFailure(codespaceName, null, result?.Trim() ?? "Unknown failure"); + _ = AuditLog?.LogCopilotHeadlessIndeterminate(codespaceName, null, result?.Trim() ?? "Unknown"); return true; // Still return true (SSH worked) — let the tunnel probe determine if it's listening } diff --git a/PolyPilot/Services/DevTunnelService.cs b/PolyPilot/Services/DevTunnelService.cs index f58ece278c..8a4a3f98dc 100644 --- a/PolyPilot/Services/DevTunnelService.cs +++ b/PolyPilot/Services/DevTunnelService.cs @@ -196,6 +196,7 @@ public async Task HostAsync(int copilotPort) SetState(TunnelState.Starting); _tunnelUrl = null; + var hostStopwatch = Stopwatch.StartNew(); // Load saved tunnel ID for reuse (keeps same URL across restarts) var settings = ConnectionSettings.Load(); @@ -237,7 +238,7 @@ public async Task HostAsync(int copilotPort) if (!success) { var lastError = _errorMessage; - Stop(); + Stop(cleanClose: false); // Stop() clears _errorMessage via SetState(NotStarted). // Restore the error (or a generic fallback) so the user sees what went wrong. SetError(lastError ?? "DevTunnel failed to start"); @@ -265,12 +266,13 @@ public async Task HostAsync(int copilotPort) _bridge.AccessToken = _accessToken; SetState(TunnelState.Running); - _ = _auditLog?.LogDevtunnelConnectionEstablished(null, _tunnelId, _tunnelUrl, 0); + hostStopwatch.Stop(); + _ = _auditLog?.LogDevtunnelConnectionEstablished(null, _tunnelId, _tunnelUrl, hostStopwatch.ElapsedMilliseconds); return true; } catch (Exception ex) { - Stop(); + Stop(cleanClose: false); SetError($"Host error: {ex.Message}"); _ = _auditLog?.LogDevtunnelConnectionFailed(null, _tunnelId, ex.Message); return false; @@ -454,10 +456,10 @@ private void TryExtractInfo(string line, TaskCompletionSource urlFound) /// /// Stop the hosted tunnel /// - public void Stop() + public void Stop(bool cleanClose = true) { SetState(TunnelState.Stopping); - _ = _auditLog?.LogSessionClosed(null, 0, cleanClose: true, closeReason: "DevTunnel stopped"); + _ = _auditLog?.LogSessionClosed(null, 0, cleanClose, cleanClose ? "DevTunnel stopped" : "DevTunnel stopped after error"); try { if (_hostProcess != null && !_hostProcess.HasExited) From c433a9d8bea64e29b2824548cd9fd5171a4326dd Mon Sep 17 00:00:00 2001 From: Baptiste Tessiau Date: Wed, 11 Mar 2026 10:38:30 +0100 Subject: [PATCH 3/4] fix: wire up PurgeOldLogs at startup for 30-day retention PurgeOldLogs() was defined but never called from production code, so audit logs would accumulate indefinitely. Now called eagerly at startup in MauiProgram.cs right after AuditLogService registration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot/MauiProgram.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/PolyPilot/MauiProgram.cs b/PolyPilot/MauiProgram.cs index c2bfb3328d..551f95b9cf 100644 --- a/PolyPilot/MauiProgram.cs +++ b/PolyPilot/MauiProgram.cs @@ -102,6 +102,8 @@ public static MauiApp CreateMauiApp() builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + // Purge audit logs older than 30 days at startup (best-effort, never throws) + try { new AuditLogService().PurgeOldLogs(); } catch { } builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); From d59f3d819ce327cb9048716d4210edcf46be4047 Mon Sep 17 00:00:00 2001 From: Baptiste Tessiau Date: Wed, 11 Mar 2026 10:48:27 +0100 Subject: [PATCH 4/4] fix: use source-gen JSON context + add audit log test isolation 1. ToJsonLine() now uses AuditLogJsonContext (source-generated) instead of allocating JsonSerializerOptions per call. Registered int/long/ double/bool/string/Dictionary types for polymorphic Details values. 2. TestSetup.cs: added AuditLogService.SetLogDirForTesting() to the ModuleInitializer so future tests that construct AuditLogService without an explicit dir arg won't write to ~/.polypilot/audit_logs/. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/TestSetup.cs | 1 + PolyPilot/Models/AuditLogEntry.cs | 15 +++++++++------ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/PolyPilot.Tests/TestSetup.cs b/PolyPilot.Tests/TestSetup.cs index 10574ead53..01847ad005 100644 --- a/PolyPilot.Tests/TestSetup.cs +++ b/PolyPilot.Tests/TestSetup.cs @@ -26,6 +26,7 @@ internal static void Initialize() Directory.CreateDirectory(TestBaseDir); CopilotService.SetBaseDirForTesting(TestBaseDir); RepoManager.SetBaseDirForTesting(TestBaseDir); + AuditLogService.SetLogDirForTesting(Path.Combine(TestBaseDir, "audit_logs")); PromptLibraryService.SetUserPromptsDirForTesting(Path.Combine(TestBaseDir, "prompts")); } } diff --git a/PolyPilot/Models/AuditLogEntry.cs b/PolyPilot/Models/AuditLogEntry.cs index 467ac01a1a..5c587d4463 100644 --- a/PolyPilot/Models/AuditLogEntry.cs +++ b/PolyPilot/Models/AuditLogEntry.cs @@ -23,11 +23,7 @@ public class AuditLogEntry public string ToJsonLine() { - return JsonSerializer.Serialize(this, new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }); + return JsonSerializer.Serialize(this, AuditLogJsonContext.Default.AuditLogEntry); } } @@ -49,8 +45,15 @@ public static class AuditEventTypes public const string SessionError = "SESSION_ERROR"; } -// Source-generated JSON context for trimmer-safe serialization +// Source-generated JSON context for trimmer-safe serialization. +// Must include all value types that appear in Details dictionary. [JsonSerializable(typeof(AuditLogEntry))] +[JsonSerializable(typeof(int))] +[JsonSerializable(typeof(long))] +[JsonSerializable(typeof(double))] +[JsonSerializable(typeof(bool))] +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(Dictionary))] [JsonSourceGenerationOptions( PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]