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:
@@ -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";
|
||||
|
||||
/// <summary>
|
||||
/// Formats a single <see cref="AuditEvent"/> as an RFC 4180 CSV row matching <see cref="CsvHeader"/>.
|
||||
/// Free-text columns (anything that can carry inbound-API-caller-controlled or
|
||||
/// site-script-controlled text — <see cref="AuditExportRow.SourceSiteId"/>,
|
||||
/// <see cref="AuditExportRow.SourceNode"/>, <see cref="AuditExportRow.SourceInstanceId"/>,
|
||||
/// <see cref="AuditExportRow.SourceScript"/>, <see cref="AuditExportRow.Actor"/>,
|
||||
/// <see cref="AuditExportRow.Target"/>, <see cref="AuditExportRow.ErrorMessage"/>,
|
||||
/// <see cref="AuditExportRow.ErrorDetail"/>, <see cref="AuditExportRow.RequestSummary"/>,
|
||||
/// <see cref="AuditExportRow.ResponseSummary"/>, <see cref="AuditExportRow.Extra"/>) 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). 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.
|
||||
/// </summary>
|
||||
/// <param name="evt">The audit event to format.</param>
|
||||
/// <returns>An RFC 4180 CSV row string (no trailing newline) for the supplied event.</returns>
|
||||
@@ -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('"');
|
||||
}
|
||||
|
||||
/// <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' };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user