From 9a3f006721d66488c1d663a105420618227436e5 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 19 Aug 2026 15:01:11 -0400 Subject: [PATCH] fix(management)+fix(cli): neutralize CSV formula injection + align export columns; deadline + Ctrl-C on audit export streaming (arch-review 2026-08-19 P6-7 + P6-8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P6-7 (CWE-1236): AuditEndpoints CSV export quoted cells on comma/quote/CR/LF but never neutralized a leading =, +, -, @, TAB, or CR — inbound-API-caller text landing in Target/ErrorMessage/RequestSummary/ResponseSummary/Extra could execute as a formula/DDE trigger when opened in Excel. AppendField now prefixes a literal single quote on free-text columns only (never the GUID/enum/numeric/date/bool columns the exporter formats itself), applied before RFC 4180 quoting. The CSV was also missing ExecutionId/ ParentExecutionId/SourceNode that the JSONL export already carried; both are now 24 columns in the same AuditExportRow order. A second, independently maintained CSV writer (CentralUI's AuditLogExportService, feeding the Audit Log page's bulk-export button) had the identical gap and got the identical fix + column alignment. P6-8: `audit export` was the one CLI HTTP path with no deadline at all — a residual gap from the ManagementHttpClient timeout-honesty fix, which made HttpClient.Timeout infinite and the per-call token the single deadline everywhere except this streamed GET. RunExportAsync now takes a headers- received deadline (CancelAfter, reusing ManagementHttpClient. DefaultConnectTimeout) around the initial GetAsync, an inactivity deadline around the streaming copy (rearmed on every successful read, so large exports are bounded by silence, not total size), and an external CancellationToken wired to Console.CancelKeyPress in AuditCommands so Ctrl-C cancels promptly. A stalled/faulted stream now produces a friendly error noting the partial output file instead of hanging or dumping a stack trace. --- .../Commands/AuditCommands.cs | 18 +- .../Commands/AuditExportHelpers.cs | 117 +++++++- .../Services/AuditLogExportService.cs | 67 +++-- .../AuditEndpoints.cs | 65 ++++- .../Commands/AuditExportCommandTests.cs | 253 ++++++++++++++++++ .../Services/AuditLogExportServiceTests.cs | 106 +++++++- .../AuditEndpointsTests.cs | 166 ++++++++++++ 7 files changed, 737 insertions(+), 55 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditCommands.cs index fdfc6968..112550ea 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditCommands.cs @@ -218,16 +218,32 @@ public static class AuditCommands Actor = result.GetValue(actorOption), }; + // Ctrl-C is the one way to abort an in-flight export other than the + // headers/inactivity deadlines below — mirrors the CancellationTokenSource + // owned by DebugCommands' streaming debug-view command (arch-review + // 2026-08-19 P6-8: this was the one CLI HTTP path with NO deadline at all). + using var cts = new CancellationTokenSource(); + ConsoleCancelEventHandler onCancel = (_, e) => + { + e.Cancel = true; + cts.Cancel(); + }; + Console.CancelKeyPress += onCancel; + try { using var client = new ManagementHttpClient(connection.Url!, connection.Username!, connection.Password!); - return await AuditExportHelpers.RunExportAsync(client, args, Console.Out, DateTimeOffset.UtcNow); + return await AuditExportHelpers.RunExportAsync(client, args, Console.Out, DateTimeOffset.UtcNow, cts.Token); } catch (FormatException ex) { OutputFormatter.WriteError(ex.Message, "INVALID_ARGUMENT"); return 1; } + finally + { + Console.CancelKeyPress -= onCancel; + } }); return cmd; } diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditExportHelpers.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditExportHelpers.cs index 72e76a3b..08dbf343 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditExportHelpers.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditExportHelpers.cs @@ -106,10 +106,34 @@ public static class AuditExportHelpers return "?" + string.Join("&", parts); } + /// + /// Deadline for receiving the export response's headers (i.e. the server has + /// started responding), measured from the start of the request. This is the + /// one bound does NOT + /// carry on its own — 's HttpClient.Timeout + /// is deliberately (see its doc comment) + /// and bounds only the TCP/TLS + /// connect phase, not a connected-but-silent server. Reuses + /// as the standard CLI + /// per-call default (arch-review 2026-08-19 P6-8). + /// + public static readonly TimeSpan DefaultHeadersTimeout = ManagementHttpClient.DefaultConnectTimeout; + + /// + /// Inactivity deadline for the streaming body copy: reset on every chunk + /// successfully read, so a large export keeps streaming indefinitely as long + /// as bytes keep arriving, but a stream that goes silent mid-transfer (a + /// hung/crashed server holding the connection open) is bounded rather than + /// hanging the CLI forever — the residual gap the timeout-honesty fix + /// (ManagementHttpClient.DefaultConnectTimeout doc comment) left in this + /// one path (arch-review 2026-08-19 P6-8). + /// + public static readonly TimeSpan DefaultInactivityTimeout = ManagementHttpClient.DefaultConnectTimeout; + /// /// Executes the export: GETs /api/audit/export and copies the response body /// stream directly to . The body is never fully - /// buffered — streams in fixed-size chunks. + /// buffered — the copy streams in fixed-size chunks via . /// A 501 Not Implemented (parquet not yet supported server-side) prints the /// server message and returns a non-zero exit code. /// @@ -117,16 +141,43 @@ public static class AuditExportHelpers /// The export arguments containing filters and output file path. /// Text writer for command output messages. /// The current time for resolving relative time specifications. + /// + /// External cancellation (e.g. the CLI's Ctrl-C handler) — cancelling this + /// stops the export promptly at either the headers wait or the next stream + /// read, with a "cancelled" (not timed-out) message. + /// + /// + /// Override for — test seam. + /// + /// + /// Override for — test seam. + /// /// 0 on success, 1 on general error, or 2 on authorization failure. public static async Task RunExportAsync( - ManagementHttpClient client, AuditExportArgs args, TextWriter output, DateTimeOffset now) + ManagementHttpClient client, AuditExportArgs args, TextWriter output, DateTimeOffset now, + CancellationToken cancellationToken = default, + TimeSpan? headersTimeout = null, + TimeSpan? inactivityTimeout = null) { var qs = BuildQueryString(args, now); + using var headersCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + headersCts.CancelAfter(headersTimeout ?? DefaultHeadersTimeout); + HttpResponseMessage response; try { - response = await client.SendGetStreamAsync("api/audit/export" + qs, CancellationToken.None); + response = await client.SendGetStreamAsync("api/audit/export" + qs, headersCts.Token); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + OutputFormatter.WriteError("Export cancelled.", "CANCELLED"); + return 1; + } + catch (OperationCanceledException) + { + OutputFormatter.WriteError("Request timed out waiting for the server to respond.", "TIMEOUT"); + return 1; } catch (HttpRequestException ex) { @@ -138,7 +189,7 @@ public static class AuditExportHelpers { if (response.StatusCode == HttpStatusCode.NotImplemented) { - var message = await response.Content.ReadAsStringAsync(); + var message = await response.Content.ReadAsStringAsync(cancellationToken); OutputFormatter.WriteError( string.IsNullOrWhiteSpace(message) ? "Export format not implemented by the server." @@ -149,7 +200,7 @@ public static class AuditExportHelpers if (!response.IsSuccessStatusCode) { - var message = await response.Content.ReadAsStringAsync(); + var message = await response.Content.ReadAsStringAsync(cancellationToken); // Honour the documented "authorization failure → exit 2" // contract on the REST audit surface as well. HTTP 403 is the // primary signal; the server may also surface UNAUTHORIZED / @@ -164,17 +215,69 @@ public static class AuditExportHelpers return isAuthFailure ? 2 : 1; } - await using var source = await response.Content.ReadAsStreamAsync(); + await using var source = await response.Content.ReadAsStreamAsync(cancellationToken); await using var destination = new FileStream( args.Output, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 81920, useAsync: true); - await source.CopyToAsync(destination); + + try + { + await CopyWithInactivityTimeoutAsync( + source, destination, inactivityTimeout ?? DefaultInactivityTimeout, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + OutputFormatter.WriteError($"Export cancelled; partial file written to {args.Output}.", "CANCELLED"); + return 1; + } + catch (OperationCanceledException) + { + OutputFormatter.WriteError( + "Export stalled — no data received from the server for " + + $"{(inactivityTimeout ?? DefaultInactivityTimeout).TotalSeconds:0}s; " + + $"partial file written to {args.Output}.", + "TIMEOUT"); + return 1; + } + catch (Exception ex) when (ex is IOException or HttpRequestException) + { + OutputFormatter.WriteError( + $"Export failed while streaming the response: {ex.Message}; partial file written to {args.Output}.", + "STREAM_FAILED"); + return 1; + } } output.WriteLine($"Exported audit log to {args.Output}"); return 0; } + /// + /// Copies to in fixed-size + /// chunks, rearming an inactivity deadline before every read so the copy is bounded + /// only by silence, not by total size — a multi-hundred-MB export keeps streaming as + /// long as bytes keep arriving. (external, e.g. + /// Ctrl-C) and the inactivity deadline share one linked token; callers distinguish the + /// two causes via cancellationToken.IsCancellationRequested after catching + /// . + /// + private static async Task CopyWithInactivityTimeoutAsync( + Stream source, Stream destination, TimeSpan inactivityTimeout, CancellationToken cancellationToken) + { + using var readCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var buffer = new byte[81920]; + while (true) + { + readCts.CancelAfter(inactivityTimeout); + var bytesRead = await source.ReadAsync(buffer.AsMemory(), readCts.Token); + if (bytesRead == 0) + { + break; + } + await destination.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken); + } + } + /// /// Best-effort parse of the server's JSON error envelope ({ "error": ..., "code": ... }) /// to extract the code field. Returns null if the body is empty, not valid JSON, or diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/AuditLogExportService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/AuditLogExportService.cs index c046ce89..ab1c917e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/AuditLogExportService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/AuditLogExportService.cs @@ -20,8 +20,9 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; /// Output is capped at a caller-supplied maxRows ceiling; when the cap /// is hit the service appends a # Capped at … rows. Use the CLI for larger /// exports. footer line so an operator can tell a truncated download from -/// a complete one. The header row contains the 21 columns of -/// in declaration order. +/// a complete one. The header row contains the same 24 columns, in the same +/// order, as the ManagementService CLI's CSV export (arch-review 2026-08-19 +/// P6-7 — the two CSV writers were found to have drifted and are kept aligned). /// /// public interface IAuditLogExportService @@ -92,7 +93,7 @@ public sealed class AuditLogExportService : IAuditLogExportService leaveOpen: true); writer.NewLine = "\r\n"; // RFC 4180 - // Header — 21 columns in AuditEvent declaration order. + // Header — 24 columns in AuditEventView declaration order. await writer.WriteLineAsync(Header); int written = 0; @@ -160,13 +161,14 @@ public sealed class AuditLogExportService : IAuditLogExportService } // ───────────────────────────────────────────────────────────────────── - // CSV helpers + // CSV helpers — 24 columns, kept aligned with the ManagementService CLI's + // CSV export (arch-review 2026-08-19 P6-7) // ───────────────────────────────────────────────────────────────────── - /// The 21 column names in declaration order. + /// The 24 column names, in declaration order. internal const string Header = - "EventId,OccurredAtUtc,IngestedAtUtc,Channel,Kind,CorrelationId," + - "SourceSiteId,SourceInstanceId,SourceScript,Actor,Target,Status," + + "EventId,OccurredAtUtc,IngestedAtUtc,Channel,Kind,CorrelationId,ExecutionId,ParentExecutionId," + + "SourceSiteId,SourceNode,SourceInstanceId,SourceScript,Actor,Target,Status," + "HttpStatus,DurationMs,ErrorMessage,ErrorDetail,RequestSummary," + "ResponseSummary,PayloadTruncated,Extra,ForwardState"; @@ -174,7 +176,12 @@ public sealed class AuditLogExportService : IAuditLogExportService /// Serialises one as a CSV row (no trailing newline). /// Each nullable column renders as the empty string when null; non-null /// scalars use invariant culture so an export taken on one locale parses - /// cleanly on another. + /// cleanly on another. Free-text columns are run through + /// before RFC 4180 quoting so a + /// leading =/+/-/@/TAB/CR can't be interpreted as + /// a formula or DDE trigger by a spreadsheet application (CWE-1236; + /// arch-review 2026-08-19 P6-7). GUID/enum/numeric/date/bool columns this + /// formatter generates itself are never neutralized. /// /// The audit event view to format as a CSV row. /// An RFC 4180 CSV row string (no trailing newline) for the supplied event. @@ -187,20 +194,23 @@ public sealed class AuditLogExportService : IAuditLogExportService AppendField(sb, evt.Channel.ToString()); AppendField(sb, evt.Kind.ToString()); AppendField(sb, evt.CorrelationId?.ToString()); - AppendField(sb, evt.SourceSiteId); - AppendField(sb, evt.SourceInstanceId); - AppendField(sb, evt.SourceScript); - AppendField(sb, evt.Actor); - AppendField(sb, evt.Target); + AppendField(sb, evt.ExecutionId?.ToString()); + AppendField(sb, evt.ParentExecutionId?.ToString()); + AppendField(sb, evt.SourceSiteId, freeText: true); + AppendField(sb, evt.SourceNode, freeText: true); + AppendField(sb, evt.SourceInstanceId, freeText: true); + AppendField(sb, evt.SourceScript, freeText: true); + AppendField(sb, evt.Actor, freeText: true); + AppendField(sb, evt.Target, freeText: true); AppendField(sb, evt.Status.ToString()); AppendField(sb, evt.HttpStatus?.ToString(CultureInfo.InvariantCulture)); AppendField(sb, evt.DurationMs?.ToString(CultureInfo.InvariantCulture)); - AppendField(sb, evt.ErrorMessage); - AppendField(sb, evt.ErrorDetail); - AppendField(sb, evt.RequestSummary); - AppendField(sb, evt.ResponseSummary); + AppendField(sb, evt.ErrorMessage, freeText: true); + AppendField(sb, evt.ErrorDetail, freeText: true); + AppendField(sb, evt.RequestSummary, freeText: true); + AppendField(sb, evt.ResponseSummary, freeText: true); AppendField(sb, evt.PayloadTruncated.ToString()); - AppendField(sb, evt.Extra); + AppendField(sb, evt.Extra, freeText: true); AppendField(sb, evt.ForwardState?.ToString()); return sb.ToString(); } @@ -211,7 +221,7 @@ public sealed class AuditLogExportService : IAuditLogExportService private static string FormatDate(DateTime value) => value.ToString("O", CultureInfo.InvariantCulture); - private static void AppendField(StringBuilder sb, string? value, bool first = false) + private static void AppendField(StringBuilder sb, string? value, bool first = false, bool freeText = false) { if (!first) sb.Append(','); if (string.IsNullOrEmpty(value)) @@ -219,6 +229,14 @@ public sealed class AuditLogExportService : IAuditLogExportService return; } + // OWASP CSV-injection neutralization (CWE-1236): prefix a literal single + // quote before RFC 4180 quoting so a free-text cell starting with a + // formula/DDE trigger character renders as inert text in Excel. + if (freeText && NeedsFormulaNeutralization(value)) + { + value = "'" + value; + } + // RFC 4180: quote on comma / quote / CR / LF; double-up embedded quotes. bool needsQuoting = value.IndexOfAny(s_quoteTriggers) >= 0; if (!needsQuoting) @@ -242,5 +260,16 @@ public sealed class AuditLogExportService : IAuditLogExportService sb.Append('"'); } + /// + /// True when starts with a character a spreadsheet + /// application would treat as a formula/DDE-launch trigger — the OWASP CSV + /// Injection standard set: =, +, -, @, TAB, CR. + /// + private static bool NeedsFormulaNeutralization(string value) + { + var c = value[0]; + return c == '=' || c == '+' || c == '-' || c == '@' || c == '\t' || c == '\r'; + } + private static readonly char[] s_quoteTriggers = { ',', '"', '\r', '\n' }; } diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditEndpoints.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditEndpoints.cs index cd086194..ac7f3c12 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditEndpoints.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditEndpoints.cs @@ -760,17 +760,30 @@ public static class AuditEndpoints } // ───────────────────────────────────────────────────────────────────── - // CSV helpers — 21 columns in AuditEvent declaration order + // CSV helpers — 24 columns, same order/naming as the JSONL export + // (AuditExportRow property declaration order; arch-review 2026-08-19 P6-7) // ───────────────────────────────────────────────────────────────────── public const string CsvHeader = - "EventId,OccurredAtUtc,IngestedAtUtc,Channel,Kind,CorrelationId," + - "SourceSiteId,SourceInstanceId,SourceScript,Actor,Target,Status," + + "EventId,OccurredAtUtc,IngestedAtUtc,Channel,Kind,CorrelationId,ExecutionId,ParentExecutionId," + + "SourceSiteId,SourceNode,SourceInstanceId,SourceScript,Actor,Target,Status," + "HttpStatus,DurationMs,ErrorMessage,ErrorDetail,RequestSummary," + "ResponseSummary,PayloadTruncated,Extra,ForwardState"; /// /// Formats a single as an RFC 4180 CSV row matching . + /// Free-text columns (anything that can carry inbound-API-caller-controlled or + /// site-script-controlled text — , + /// , , + /// , , + /// , , + /// , , + /// , ) are run + /// through before RFC 4180 quoting so a leading + /// =/+/-/@/TAB/CR can't be interpreted as a formula or DDE + /// trigger by a spreadsheet application (CWE-1236). GUID/enum/numeric/date/bool columns the + /// exporter formats itself are never neutralized — none of them can start with a trigger + /// character, and neutralizing a signed numeric column would corrupt it. /// /// The audit event to format. /// An RFC 4180 CSV row string (no trailing newline) for the supplied event. @@ -783,20 +796,23 @@ public static class AuditEndpoints AppendField(sb, evt.Channel.ToString()); AppendField(sb, evt.Kind.ToString()); AppendField(sb, evt.CorrelationId?.ToString()); - AppendField(sb, evt.SourceSiteId); - AppendField(sb, evt.SourceInstanceId); - AppendField(sb, evt.SourceScript); - AppendField(sb, evt.Actor); - AppendField(sb, evt.Target); + AppendField(sb, evt.ExecutionId?.ToString()); + AppendField(sb, evt.ParentExecutionId?.ToString()); + AppendField(sb, evt.SourceSiteId, freeText: true); + AppendField(sb, evt.SourceNode, freeText: true); + AppendField(sb, evt.SourceInstanceId, freeText: true); + AppendField(sb, evt.SourceScript, freeText: true); + AppendField(sb, evt.Actor, freeText: true); + AppendField(sb, evt.Target, freeText: true); AppendField(sb, evt.Status.ToString()); AppendField(sb, evt.HttpStatus?.ToString(CultureInfo.InvariantCulture)); AppendField(sb, evt.DurationMs?.ToString(CultureInfo.InvariantCulture)); - AppendField(sb, evt.ErrorMessage); - AppendField(sb, evt.ErrorDetail); - AppendField(sb, evt.RequestSummary); - AppendField(sb, evt.ResponseSummary); + AppendField(sb, evt.ErrorMessage, freeText: true); + AppendField(sb, evt.ErrorDetail, freeText: true); + AppendField(sb, evt.RequestSummary, freeText: true); + AppendField(sb, evt.ResponseSummary, freeText: true); AppendField(sb, evt.PayloadTruncated.ToString()); - AppendField(sb, evt.Extra); + AppendField(sb, evt.Extra, freeText: true); AppendField(sb, evt.ForwardState?.ToString()); return sb.ToString(); } @@ -807,7 +823,7 @@ public static class AuditEndpoints private static string FormatDate(DateTime value) => value.ToString("O", CultureInfo.InvariantCulture); - private static void AppendField(StringBuilder sb, string? value, bool first = false) + private static void AppendField(StringBuilder sb, string? value, bool first = false, bool freeText = false) { if (!first) sb.Append(','); if (string.IsNullOrEmpty(value)) @@ -815,6 +831,16 @@ public static class AuditEndpoints return; } + // OWASP CSV-injection neutralization (CWE-1236): a free-text cell whose + // first character would be interpreted by a spreadsheet application as a + // formula/DDE trigger is prefixed with a literal single quote. Applied + // BEFORE RFC 4180 quoting, and only to free-text columns — never to the + // GUID/enum/numeric/date/bool columns this formatter generates itself. + if (freeText && NeedsFormulaNeutralization(value)) + { + value = "'" + value; + } + // RFC 4180: quote on comma / quote / CR / LF; double-up embedded quotes. if (value.IndexOfAny(s_quoteTriggers) < 0) { @@ -837,5 +863,16 @@ public static class AuditEndpoints sb.Append('"'); } + /// + /// True when starts with a character a spreadsheet + /// application would treat as a formula/DDE-launch trigger — the OWASP CSV + /// Injection standard set: =, +, -, @, TAB, CR. + /// + private static bool NeedsFormulaNeutralization(string value) + { + var c = value[0]; + return c == '=' || c == '+' || c == '-' || c == '@' || c == '\t' || c == '\r'; + } + private static readonly char[] s_quoteTriggers = { ',', '"', '\r', '\n' }; } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditExportCommandTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditExportCommandTests.cs index 3d23316a..fc97dc64 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditExportCommandTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditExportCommandTests.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Net; using System.Text; using System.Web; @@ -376,4 +377,256 @@ public class AuditExportCommandTests public override void SetLength(long value) => throw new NotSupportedException(); public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); } + + // ───────────────────────────────────────────────────────────────────── + // Deadline + Ctrl-C cancellation (arch-review 2026-08-19 P6-8: audit export + // was the one CLI HTTP path with NO deadline — a hung server hung the CLI + // forever). Every test below bounds its own wait well under what the old, + // deadline-less implementation would have done (indefinite hang) — a + // regression back to that state fails these on a timeout, proving the fix. + // ───────────────────────────────────────────────────────────────────── + + /// A handler that never completes the request until its fires — simulates a server that accepted the connection but never answers. + private sealed class HangingHandler : HttpMessageHandler + { + public int CallCount { get; private set; } + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + CallCount++; + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + throw new InvalidOperationException("unreachable — Task.Delay(Infinite) only returns via cancellation"); + } + } + + /// + /// A stream that serves one small chunk, then goes silent — any subsequent + /// blocks until the + /// caller's token cancels. Simulates a connection that stalls mid-transfer. + /// + private sealed class StallingStream : Stream + { + private readonly byte[] _firstChunk; + private bool _servedFirstChunk; + + public StallingStream(byte[] firstChunk) => _firstChunk = firstChunk; + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + + public override int Read(byte[] buffer, int offset, int count) => + ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (!_servedFirstChunk) + { + _servedFirstChunk = true; + var n = Math.Min(buffer.Length, _firstChunk.Length); + _firstChunk.AsSpan(0, n).CopyTo(buffer.Span); + return n; + } + + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return 0; // unreachable — Task.Delay(Infinite) only returns via cancellation + } + + public override void Flush() { } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } + + /// A stream that serves one small chunk, then throws — simulates a connection reset mid-transfer. + private sealed class FaultingStream : Stream + { + private readonly byte[] _firstChunk; + private bool _servedFirstChunk; + + public FaultingStream(byte[] firstChunk) => _firstChunk = firstChunk; + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + + public override int Read(byte[] buffer, int offset, int count) => + ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (!_servedFirstChunk) + { + _servedFirstChunk = true; + var n = Math.Min(buffer.Length, _firstChunk.Length); + _firstChunk.AsSpan(0, n).CopyTo(buffer.Span); + return new ValueTask(n); + } + + throw new IOException("connection reset by peer (simulated)"); + } + + public override void Flush() { } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } + + /// Redirects for the duration of the callback and returns what was written. + private static async Task CaptureStderrAsync(Func action) + { + var original = Console.Error; + var capture = new StringWriter(); + Console.SetError(capture); + try + { + await action(); + } + finally + { + Console.SetError(original); + } + return capture.ToString(); + } + + [Fact] + public async Task RunExport_ServerNeverSendsHeaders_TimesOutWithFriendlyError() + { + var path = Path.Combine(Path.GetTempPath(), $"audit-export-hang-{Guid.NewGuid():N}.csv"); + var handler = new HangingHandler(); + var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p"); + var output = new StringWriter(); + var sw = Stopwatch.StartNew(); + + var exit = 0; + var stderr = await CaptureStderrAsync(async () => + { + exit = await AuditExportHelpers.RunExportAsync( + client, + new AuditExportArgs { Since = "1h", Until = "0h", Format = "csv", Output = path }, + output, DateTimeOffset.UtcNow, + headersTimeout: TimeSpan.FromMilliseconds(200)); + }); + sw.Stop(); + + Assert.Equal(1, exit); + // Bounded well under the old infinite-hang behaviour. + Assert.True(sw.Elapsed < TimeSpan.FromSeconds(5), $"took {sw.Elapsed}"); + Assert.Contains("TIMEOUT", stderr); + Assert.False(File.Exists(path)); + } + + [Fact] + public async Task RunExport_StreamStallsMidTransfer_InactivityTimeoutFires() + { + var path = Path.Combine(Path.GetTempPath(), $"audit-export-stall-{Guid.NewGuid():N}.csv"); + try + { + var firstChunk = Encoding.UTF8.GetBytes("EventId,OccurredAtUtc\n"); + var handler = new BodyHandler(HttpStatusCode.OK, + () => new StreamContent(new StallingStream(firstChunk))); + var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p"); + var output = new StringWriter(); + var sw = Stopwatch.StartNew(); + + var exit = 0; + var stderr = await CaptureStderrAsync(async () => + { + exit = await AuditExportHelpers.RunExportAsync( + client, + new AuditExportArgs { Since = "1h", Until = "0h", Format = "csv", Output = path }, + output, DateTimeOffset.UtcNow, + inactivityTimeout: TimeSpan.FromMilliseconds(200)); + }); + sw.Stop(); + + Assert.Equal(1, exit); + Assert.True(sw.Elapsed < TimeSpan.FromSeconds(5), $"took {sw.Elapsed}"); + Assert.Contains("TIMEOUT", stderr); + // Clean message, not a raw stack trace, and it notes the partial file. + Assert.DoesNotContain(" at ", stderr); + Assert.Contains(path, stderr); + // The first chunk that DID arrive before the stall must have been + // flushed to disk — this is a partial, not empty, export. + Assert.True(File.Exists(path)); + Assert.Equal(firstChunk, await File.ReadAllBytesAsync(path)); + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } + + [Fact] + public async Task RunExport_MidStreamIOException_ProducesCleanMessageNotStackTrace() + { + var path = Path.Combine(Path.GetTempPath(), $"audit-export-fault-{Guid.NewGuid():N}.csv"); + try + { + var firstChunk = Encoding.UTF8.GetBytes("EventId,OccurredAtUtc\n"); + var handler = new BodyHandler(HttpStatusCode.OK, + () => new StreamContent(new FaultingStream(firstChunk))); + var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p"); + var output = new StringWriter(); + + var exit = 0; + var stderr = await CaptureStderrAsync(async () => + { + exit = await AuditExportHelpers.RunExportAsync( + client, + new AuditExportArgs { Since = "1h", Until = "0h", Format = "csv", Output = path }, + output, DateTimeOffset.UtcNow); + }); + + Assert.Equal(1, exit); + Assert.Contains("STREAM_FAILED", stderr); + Assert.Contains("connection reset by peer", stderr); + Assert.Contains(path, stderr); + Assert.DoesNotContain(" at ", stderr); + Assert.DoesNotContain(nameof(FaultingStream), stderr); + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } + + [Fact] + public async Task RunExport_CtrlCToken_CancelsPromptlyWithCleanMessage() + { + var path = Path.Combine(Path.GetTempPath(), $"audit-export-ctrlc-{Guid.NewGuid():N}.csv"); + var handler = new HangingHandler(); + var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p"); + var output = new StringWriter(); + using var cts = new CancellationTokenSource(); + var sw = Stopwatch.StartNew(); + + var exit = 0; + var stderr = await CaptureStderrAsync(async () => + { + var task = AuditExportHelpers.RunExportAsync( + client, + new AuditExportArgs { Since = "1h", Until = "0h", Format = "csv", Output = path }, + output, DateTimeOffset.UtcNow, + cancellationToken: cts.Token, + // Deliberately much longer than the Ctrl-C delay below, so a pass + // here proves cancellation — not the timeout — ended the call. + headersTimeout: TimeSpan.FromSeconds(30)); + + await Task.Delay(50); + cts.Cancel(); + exit = await task; + }); + sw.Stop(); + + Assert.Equal(1, exit); + Assert.True(sw.Elapsed < TimeSpan.FromSeconds(5), $"took {sw.Elapsed}"); + Assert.Contains("CANCELLED", stderr); + Assert.False(File.Exists(path)); + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AuditLogExportServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AuditLogExportServiceTests.cs index c78633b0..72c1c76e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AuditLogExportServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AuditLogExportServiceTests.cs @@ -68,14 +68,14 @@ public class AuditLogExportServiceTests // 1 header + 5 rows + trailing empty token from final \r\n = 7 entries. Assert.Equal(7, lines.Length); - Assert.StartsWith("EventId,OccurredAtUtc,IngestedAtUtc,Channel,Kind,CorrelationId,SourceSiteId,", lines[0]); + Assert.StartsWith("EventId,OccurredAtUtc,IngestedAtUtc,Channel,Kind,CorrelationId,ExecutionId,ParentExecutionId,SourceSiteId,SourceNode,", lines[0]); Assert.StartsWith("11111111-1111-1111-1111-111111111111,", lines[1]); Assert.StartsWith("55555555-5555-5555-5555-555555555555,", lines[5]); Assert.Equal(string.Empty, lines[6]); } [Fact] - public async Task ExportAsync_HeaderHasAll21Columns_InSpecOrder() + public async Task ExportAsync_HeaderHasAll24Columns_InSpecOrder() { var repo = Substitute.For(); repo.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) @@ -89,11 +89,14 @@ public class AuditLogExportServiceTests var header = csv.Split("\r\n")[0]; var columns = header.Split(','); - Assert.Equal(21, columns.Length); + // arch-review 2026-08-19 P6-7: ExecutionId/ParentExecutionId/SourceNode + // were missing from this CSV (JSON/JSONL exports already carried them). + Assert.Equal(24, columns.Length); Assert.Equal(new[] { "EventId", "OccurredAtUtc", "IngestedAtUtc", "Channel", "Kind", - "CorrelationId", "SourceSiteId", "SourceInstanceId", "SourceScript", + "CorrelationId", "ExecutionId", "ParentExecutionId", + "SourceSiteId", "SourceNode", "SourceInstanceId", "SourceScript", "Actor", "Target", "Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail", "RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra", "ForwardState", @@ -136,6 +139,77 @@ public class AuditLogExportServiceTests Assert.Contains("\"boom\r\nthen again\"", csv); } + [Fact] + public async Task ExportAsync_FreeTextLeadingFormulaCharacters_ArePrefixedWithQuote() + { + // arch-review 2026-08-19 P6-7 (CWE-1236): a free-text cell starting with + // =, +, -, @, or TAB must be prefixed with a literal single quote so a + // spreadsheet application renders it as inert text, not a formula/DDE + // launch. Target, ErrorMessage, ResponseSummary, and Extra are + // inbound-API-caller-controlled and are the primary attack surface. + var row = ScadaBridgeAuditEventFactory.Create( + channel: AuditChannel.ApiOutbound, + kind: AuditKind.ApiCall, + status: AuditStatus.Delivered, + eventId: Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"), + occurredAtUtc: new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), + target: "=HYPERLINK(\"http://evil\",\"click\")", + errorMessage: "+cmd|'/c calc'!A1", + responseSummary: "-2+3", + extra: "@SUM(A1:A9)"); + var repo = Substitute.For(); + repo.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns( + Task.FromResult>(new[] { row }), + Task.FromResult>(Array.Empty())); + + var sut = new AuditLogExportService(repo); + using var ms = new MemoryStream(); + await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 10, ms, CancellationToken.None); + + var csv = Encoding.UTF8.GetString(ms.ToArray()); + var dataLine = csv.Split("\r\n")[1]; + + Assert.Contains("'=HYPERLINK(\"\"http://evil\"\",\"\"click\"\")", dataLine); + Assert.Contains("'+cmd|'/c calc'!A1", dataLine); + Assert.Contains(",'-2+3,", dataLine); + Assert.Contains(",'@SUM(A1:A9)", dataLine); + } + + [Fact] + public async Task ExportAsync_NumericAndDateColumns_NeverPrefixed() + { + // HttpStatus/DurationMs/OccurredAtUtc are formatted by the exporter + // itself (never free text) and must render unmangled even when a + // negative DurationMs would otherwise look like a formula-neutralized + // "-" leader if the neutralization were mistakenly applied there. + var row = ScadaBridgeAuditEventFactory.Create( + channel: AuditChannel.ApiOutbound, + kind: AuditKind.ApiCall, + status: AuditStatus.Delivered, + eventId: Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"), + occurredAtUtc: new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), + httpStatus: 200, + durationMs: -5); + var repo = Substitute.For(); + repo.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns( + Task.FromResult>(new[] { row }), + Task.FromResult>(Array.Empty())); + + var sut = new AuditLogExportService(repo); + using var ms = new MemoryStream(); + await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 10, ms, CancellationToken.None); + + var csv = Encoding.UTF8.GetString(ms.ToArray()); + var dataLine = csv.Split("\r\n")[1]; + var fields = dataLine.Split(','); + + Assert.Equal("200", fields[15]); // HttpStatus, unprefixed + Assert.Equal("-5", fields[16]); // DurationMs, unprefixed despite leading '-' + Assert.StartsWith("2026-05-20", fields[1]); // OccurredAtUtc, unprefixed + } + [Fact] public async Task ExportAsync_NullField_WrittenAsEmpty() { @@ -161,19 +235,23 @@ public class AuditLogExportServiceTests var fields = dataLine.Split(','); // EventId(0), OccurredAtUtc(1), IngestedAtUtc(2), Channel(3), Kind(4), - // CorrelationId(5), SourceSiteId(6), SourceInstanceId(7), SourceScript(8), - // Actor(9), Target(10), Status(11), HttpStatus(12), DurationMs(13), - // ErrorMessage(14), ErrorDetail(15), RequestSummary(16), ResponseSummary(17), - // PayloadTruncated(18), Extra(19), ForwardState(20) - Assert.Equal(21, fields.Length); + // CorrelationId(5), ExecutionId(6), ParentExecutionId(7), SourceSiteId(8), + // SourceNode(9), SourceInstanceId(10), SourceScript(11), Actor(12), + // Target(13), Status(14), HttpStatus(15), DurationMs(16), ErrorMessage(17), + // ErrorDetail(18), RequestSummary(19), ResponseSummary(20), + // PayloadTruncated(21), Extra(22), ForwardState(23) + Assert.Equal(24, fields.Length); Assert.Equal("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", fields[0]); Assert.Equal(string.Empty, fields[2]); // IngestedAtUtc null Assert.Equal(string.Empty, fields[5]); // CorrelationId null - Assert.Equal(string.Empty, fields[6]); // SourceSiteId null - Assert.Equal(string.Empty, fields[12]); // HttpStatus null - Assert.Equal(string.Empty, fields[14]); // ErrorMessage null - Assert.Equal("False", fields[18]); // PayloadTruncated - Assert.Equal(string.Empty, fields[20]); // ForwardState null + Assert.Equal(string.Empty, fields[6]); // ExecutionId null + Assert.Equal(string.Empty, fields[7]); // ParentExecutionId null + Assert.Equal(string.Empty, fields[8]); // SourceSiteId null + Assert.Equal(string.Empty, fields[9]); // SourceNode null + Assert.Equal(string.Empty, fields[15]); // HttpStatus null + Assert.Equal(string.Empty, fields[17]); // ErrorMessage null + Assert.Equal("False", fields[21]); // PayloadTruncated + Assert.Equal(string.Empty, fields[23]); // ForwardState null } [Fact] diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/AuditEndpointsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/AuditEndpointsTests.cs index 20f6f5d3..49069e87 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/AuditEndpointsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/AuditEndpointsTests.cs @@ -315,6 +315,172 @@ public class AuditEndpointsTests } } + // ───────────────────────────────────────────────────────────────────── + // CSV export — formula-injection neutralization + column alignment + // (arch-review 2026-08-19 P6-7) + // ───────────────────────────────────────────────────────────────────── + + [Fact] + public void CsvHeader_Has24Columns_SameOrderAsAuditExportRowProperties() + { + // The JSONL export serializes AuditExportRow directly, so its field + // order IS the AuditExportRow property-declaration order; the CSV + // header must match it exactly (finding: the CSV was missing + // ExecutionId/ParentExecutionId/SourceNode that JSONL already carried). + var columns = AuditEndpoints.CsvHeader.Split(','); + + Assert.Equal(24, columns.Length); + Assert.Equal(new[] + { + "EventId", "OccurredAtUtc", "IngestedAtUtc", "Channel", "Kind", + "CorrelationId", "ExecutionId", "ParentExecutionId", + "SourceSiteId", "SourceNode", "SourceInstanceId", "SourceScript", + "Actor", "Target", "Status", "HttpStatus", "DurationMs", + "ErrorMessage", "ErrorDetail", "RequestSummary", "ResponseSummary", + "PayloadTruncated", "Extra", "ForwardState", + }, columns); + } + + [Fact] + public void FormatCsvRow_IncludesExecutionParentExecutionAndSourceNode() + { + var evt = ScadaBridgeAuditEventFactory.Create( + channel: AuditChannel.ApiOutbound, + kind: AuditKind.ApiCall, + status: AuditStatus.Delivered, + eventId: Guid.Parse("11111111-1111-1111-1111-111111111111"), + occurredAtUtc: new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), + sourceNode: "site-a-node-a", + executionId: Guid.Parse("22222222-2222-2222-2222-222222222222"), + parentExecutionId: Guid.Parse("33333333-3333-3333-3333-333333333333")); + + var row = AuditEndpoints.FormatCsvRow(AuditExportRow.From(evt)); + var fields = row.Split(','); + + Assert.Equal(24, fields.Length); + Assert.Equal("22222222-2222-2222-2222-222222222222", fields[6]); // ExecutionId + Assert.Equal("33333333-3333-3333-3333-333333333333", fields[7]); // ParentExecutionId + Assert.Equal("site-a-node-a", fields[9]); // SourceNode + } + + [Theory] + [InlineData("=HYPERLINK(\"http://evil\",\"click\")", "'=HYPERLINK(")] + [InlineData("+cmd|'/c calc'!A1", "'+cmd|")] + [InlineData("-2+3", "'-2+3")] + [InlineData("@SUM(1,1)", "'@SUM(1,1)")] + [InlineData("\tsneaky", "'\tsneaky")] + public async Task Export_Csv_FreeTextLeadingFormulaCharacter_IsPrefixedWithQuote(string malicious, string expectedPrefix) + { + // CWE-1236: Target is inbound-API-caller-controlled free text. A raw + // leading =, +, -, @, or TAB must never reach the CSV cell unescaped. + var evt = ScadaBridgeAuditEventFactory.Create( + channel: AuditChannel.ApiInbound, + kind: AuditKind.InboundRequest, + status: AuditStatus.Delivered, + eventId: Guid.Parse("44444444-4444-4444-4444-444444444444"), + occurredAtUtc: new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), + target: malicious); + + var (client, _, host) = await BuildHostAsync( + roles: new[] { "Administrator" }, + queryPages: new[] + { + (IReadOnlyList)new[] { evt }, + (IReadOnlyList)Array.Empty(), + }); + using (host) + { + var response = await client.SendAsync(Get("/api/audit/export?format=csv")); + var body = await response.Content.ReadAsStringAsync(); + + Assert.Contains(expectedPrefix, body); + // The raw, un-neutralized value must never appear on its own — + // every occurrence must be the neutralized ('-prefixed) form. + Assert.DoesNotContain("," + malicious + ",", body); + } + } + + [Fact] + public void FormatCsvRow_ErrorMessageResponseSummaryAndExtra_AreNeutralized() + { + // Covers the finding's named secondary carriers beyond Target. + var evt = ScadaBridgeAuditEventFactory.Create( + channel: AuditChannel.DbOutbound, + kind: AuditKind.DbWrite, + status: AuditStatus.Failed, + eventId: Guid.Parse("55555555-5555-5555-5555-555555555555"), + occurredAtUtc: new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), + errorMessage: "=cmd()", + responseSummary: "+1+1", + extra: "@evil"); + + var row = AuditEndpoints.FormatCsvRow(AuditExportRow.From(evt)); + var fields = row.Split(','); + + Assert.Equal("'=cmd()", fields[17]); // ErrorMessage + Assert.Equal("'+1+1", fields[20]); // ResponseSummary + Assert.Equal("'@evil", fields[22]); // Extra + } + + [Fact] + public void FormatCsvRow_NegativeDurationAndDateColumns_NeverNeutralized() + { + // DurationMs/OccurredAtUtc are formatted by the exporter itself, never + // free text — a negative duration's leading '-' must render as-is, not + // be mistaken for a formula trigger and quote-prefixed. + var evt = ScadaBridgeAuditEventFactory.Create( + channel: AuditChannel.ApiOutbound, + kind: AuditKind.ApiCall, + status: AuditStatus.Delivered, + eventId: Guid.Parse("66666666-6666-6666-6666-666666666666"), + occurredAtUtc: new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), + httpStatus: 200, + durationMs: -7); + + var row = AuditEndpoints.FormatCsvRow(AuditExportRow.From(evt)); + var fields = row.Split(','); + + Assert.Equal("-7", fields[16]); // DurationMs, unprefixed + Assert.StartsWith("2026-05-20", fields[1]); // OccurredAtUtc, unprefixed + Assert.Equal("200", fields[15]); // HttpStatus, unprefixed + } + + [Fact] + public void FormatCsvRow_BenignFreeTextField_IsNotPrefixed() + { + var evt = ScadaBridgeAuditEventFactory.Create( + channel: AuditChannel.ApiOutbound, + kind: AuditKind.ApiCall, + status: AuditStatus.Delivered, + eventId: Guid.Parse("77777777-7777-7777-7777-777777777777"), + occurredAtUtc: new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), + target: "plant-a-external-system"); + + var row = AuditEndpoints.FormatCsvRow(AuditExportRow.From(evt)); + var fields = row.Split(','); + + Assert.Equal("plant-a-external-system", fields[13]); // Target + } + + [Fact] + public void CsvHeader_ColumnsMatchAuditExportRowPropertyOrder() + { + // The JSONL export serializes AuditExportRow verbatim (property + // declaration order = JSON field order once camelCased); assert the + // CSV header names + order are identical to that property surface, so + // a column can never again silently drift between the two formats. + // (Reflection, not a live JSONL round-trip: JsonOptions drops null + // properties on write — e.g. ForwardState is always null on the + // central read path — which would otherwise hide a real column from + // a JSON-based comparison.) + var propertyNames = typeof(AuditExportRow) + .GetProperties() + .Select(p => p.Name) + .ToArray(); + + Assert.Equal(propertyNames, AuditEndpoints.CsvHeader.Split(',')); + } + [Fact] public async Task Export_Parquet_Returns501() {