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
@@ -20,8 +20,9 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// Output is capped at a caller-supplied <c>maxRows</c> ceiling; when the cap
/// is hit the service appends a <c># Capped at … rows. Use the CLI for larger
/// exports.</c> footer line so an operator can tell a truncated download from
/// a complete one. The header row contains the 21 columns of
/// <see cref="AuditEvent"/> 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).
/// </para>
/// </summary>
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)
// ─────────────────────────────────────────────────────────────────────
/// <summary>The 21 column names in <see cref="AuditEvent"/> declaration order.</summary>
/// <summary>The 24 column names, in <see cref="AuditEventView"/> declaration order.</summary>
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 <see cref="AuditEventView"/> 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
/// <see cref="NeedsFormulaNeutralization"/> before RFC 4180 quoting so a
/// leading <c>=</c>/<c>+</c>/<c>-</c>/<c>@</c>/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.
/// </summary>
/// <param name="evt">The audit event view to format as a CSV row.</param>
/// <returns>An RFC 4180 CSV row string (no trailing newline) for the supplied event.</returns>
@@ -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('"');
}
/// <summary>
/// True when <paramref name="value"/> starts with a character a spreadsheet
/// application would treat as a formula/DDE-launch trigger — the OWASP CSV
/// Injection standard set: <c>=</c>, <c>+</c>, <c>-</c>, <c>@</c>, TAB, CR.
/// </summary>
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' };
}