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)

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.
This commit is contained in:
Joseph Doherty
2026-08-19 15:01:11 -04:00
parent 50a77a8170
commit 9a3f006721
7 changed files with 737 additions and 55 deletions
@@ -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.
// ─────────────────────────────────────────────────────────────────────
/// <summary>A handler that never completes the request until its <see cref="CancellationToken"/> fires — simulates a server that accepted the connection but never answers.</summary>
private sealed class HangingHandler : HttpMessageHandler
{
public int CallCount { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
CallCount++;
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
throw new InvalidOperationException("unreachable — Task.Delay(Infinite) only returns via cancellation");
}
}
/// <summary>
/// A stream that serves one small chunk, then goes silent — any subsequent
/// <see cref="ReadAsync(Memory{byte}, CancellationToken)"/> blocks until the
/// caller's token cancels. Simulates a connection that stalls mid-transfer.
/// </summary>
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<int> ReadAsync(Memory<byte> 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();
}
/// <summary>A stream that serves one small chunk, then throws <see cref="IOException"/> — simulates a connection reset mid-transfer.</summary>
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<int> ReadAsync(Memory<byte> 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<int>(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();
}
/// <summary>Redirects <see cref="Console.Error"/> for the duration of the callback and returns what was written.</summary>
private static async Task<string> CaptureStderrAsync(Func<Task> 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));
}
}
@@ -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<IAuditLogRepository>();
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
@@ -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<IAuditLogRepository>();
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
.Returns(
Task.FromResult<IReadOnlyList<AuditEvent>>(new[] { row }),
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
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<IAuditLogRepository>();
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
.Returns(
Task.FromResult<IReadOnlyList<AuditEvent>>(new[] { row }),
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
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]
@@ -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<AuditEvent>)new[] { evt },
(IReadOnlyList<AuditEvent>)Array.Empty<AuditEvent>(),
});
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()
{