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
@@ -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]