diff --git a/PolyPilot.Tests/AuditLogTests.cs b/PolyPilot.Tests/AuditLogTests.cs new file mode 100644 index 0000000000..5bcf64fe7d --- /dev/null +++ b/PolyPilot.Tests/AuditLogTests.cs @@ -0,0 +1,341 @@ +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()); + } + + [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] + 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 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"); + + // 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 (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] + 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("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); + 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.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/MauiProgram.cs b/PolyPilot/MauiProgram.cs index 846a5b4c0b..551f95b9cf 100644 --- a/PolyPilot/MauiProgram.cs +++ b/PolyPilot/MauiProgram.cs @@ -101,6 +101,9 @@ public static MauiApp CreateMauiApp() builder.Services.AddSingleton(); 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(); diff --git a/PolyPilot/Models/AuditLogEntry.cs b/PolyPilot/Models/AuditLogEntry.cs new file mode 100644 index 0000000000..5c587d4463 --- /dev/null +++ b/PolyPilot/Models/AuditLogEntry.cs @@ -0,0 +1,60 @@ +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, AuditLogJsonContext.Default.AuditLogEntry); + } +} + +/// +/// 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 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"; + public const string SessionClosed = "SESSION_CLOSED"; + public const string SessionError = "SESSION_ERROR"; +} + +// 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)] +internal partial class AuditLogJsonContext : JsonSerializerContext { } diff --git a/PolyPilot/Services/AuditLogService.cs b/PolyPilot/Services/AuditLogService.cs new file mode 100644 index 0000000000..8cc1feae56 --- /dev/null +++ b/PolyPilot/Services/AuditLogService.cs @@ -0,0 +1,348 @@ +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 + { + // 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 */ } + } + } + 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 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) + { + 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..a14ed0d5d2 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?.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/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..8a4a3f98dc 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; @@ -194,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(); @@ -235,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"); @@ -263,12 +266,15 @@ public async Task HostAsync(int copilotPort) _bridge.AccessToken = _accessToken; SetState(TunnelState.Running); + 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; } } @@ -437,6 +443,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) @@ -449,9 +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, cleanClose ? "DevTunnel stopped" : "DevTunnel stopped after error"); try { if (_hostProcess != null && !_hostProcess.HasExited)