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:
+92
-14
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user