9a3f006721
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.
385 lines
18 KiB
C#
385 lines
18 KiB
C#
using System.CommandLine;
|
|
using System.CommandLine.Parsing;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
|
|
|
/// <summary>
|
|
/// The <c>scadabridge audit</c> command group. Provides read access to
|
|
/// the centralized append-only Audit Log via the REST endpoints
|
|
/// (<c>GET /api/audit/query</c>, <c>GET /api/audit/export</c>,
|
|
/// <c>GET /api/audit/tree</c>), plus a v1 no-op <c>verify-chain</c> placeholder
|
|
/// for the deferred hash-chain tamper-evidence feature.
|
|
/// </summary>
|
|
public static class AuditCommands
|
|
{
|
|
/// <summary>
|
|
/// Builds the <c>audit</c> command group with query, export, tree, and verify-chain
|
|
/// sub-commands.
|
|
/// </summary>
|
|
/// <param name="urlOption">Global <c>--url</c> option for the management API endpoint.</param>
|
|
/// <param name="formatOption">Global <c>--format</c> option for output format.</param>
|
|
/// <param name="usernameOption">Global <c>--username</c> option for authentication.</param>
|
|
/// <param name="passwordOption">Global <c>--password</c> option for authentication.</param>
|
|
/// <returns>The configured <c>audit</c> <see cref="Command"/> with all sub-commands attached.</returns>
|
|
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
|
{
|
|
var command = new Command("audit") { Description = "Query and export the centralized audit log" };
|
|
|
|
command.Add(BuildQuery(urlOption, formatOption, usernameOption, passwordOption));
|
|
command.Add(BuildExport(urlOption, formatOption, usernameOption, passwordOption));
|
|
command.Add(BuildTree(urlOption, formatOption, usernameOption, passwordOption));
|
|
command.Add(BuildVerifyChain(urlOption, formatOption, usernameOption, passwordOption));
|
|
command.Add(BuildBackfillSourceNode(urlOption, formatOption, usernameOption, passwordOption));
|
|
|
|
return command;
|
|
}
|
|
|
|
private static Command BuildQuery(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
|
{
|
|
var sinceOption = new Option<string?>("--since") { Description = "Start time: relative (1h, 24h, 7d) or ISO-8601" };
|
|
var untilOption = new Option<string?>("--until") { Description = "End time: relative (1h, 24h, 7d) or ISO-8601" };
|
|
// --channel/--kind/--status/--site are multi-valued: System.CommandLine accepts
|
|
// both repeated tokens (--channel A --channel B) and, with
|
|
// AllowMultipleArgumentsPerToken, a single token carrying several values
|
|
// (--channel A B).
|
|
//
|
|
// The valid sets are named in the help text rather than enforced with
|
|
// AcceptOnlyFromAmong: that check is case-SENSITIVE, so `--status delivered`
|
|
// failed client-side even though the server has always parsed these
|
|
// case-insensitively (register row 99 — same treatment row 88 gave
|
|
// `deploy status`). The server rejects an unrecognised value with a 400 naming
|
|
// the same list and is the authoritative check; HTTP API callers never pass
|
|
// through these options at all.
|
|
var channelOption = new Option<string[]>("--channel")
|
|
{
|
|
Description = "Filter by channel (ApiOutbound, DbOutbound, Notification, ApiInbound; case-insensitive); repeatable",
|
|
AllowMultipleArgumentsPerToken = true,
|
|
};
|
|
var kindOption = new Option<string[]>("--kind")
|
|
{
|
|
Description = "Filter by event kind (ApiCall, ApiCallCached, DbWrite, DbWriteCached, NotifySend, NotifyDeliver, InboundRequest, InboundAuthFailure, CachedSubmit, CachedResolve; case-insensitive); repeatable",
|
|
AllowMultipleArgumentsPerToken = true,
|
|
};
|
|
var statusOption = new Option<string[]>("--status")
|
|
{
|
|
Description = "Filter by status (Submitted, Forwarded, Attempted, Delivered, Failed, Parked, Discarded, Skipped; case-insensitive); repeatable",
|
|
AllowMultipleArgumentsPerToken = true,
|
|
};
|
|
var siteOption = new Option<string[]>("--site")
|
|
{
|
|
Description = "Filter by source site ID; repeatable",
|
|
AllowMultipleArgumentsPerToken = true,
|
|
};
|
|
var targetOption = new Option<string?>("--target") { Description = "Filter by target (external system, DB connection, notification list)" };
|
|
var actorOption = new Option<string?>("--actor") { Description = "Filter by actor" };
|
|
var correlationIdOption = new Option<string?>("--correlation-id") { Description = "Filter by correlation ID" };
|
|
var executionIdOption = new Option<string?>("--execution-id") { Description = "Filter by execution ID" };
|
|
var parentExecutionIdOption = new Option<string?>("--parent-execution-id") { Description = "Filter by parent execution ID" };
|
|
var errorsOnlyOption = new Option<bool>("--errors-only") { Description = "Show only failed events (status=Failed; overrides --status)" };
|
|
var pageSizeOption = new Option<int>("--page-size") { Description = "Events per page (1-1000)" };
|
|
pageSizeOption.DefaultValueFactory = _ => 100;
|
|
var allOption = new Option<bool>("--all") { Description = "Fetch every page, following the keyset cursor" };
|
|
|
|
var cmd = new Command("query") { Description = "Query audit log events" };
|
|
cmd.Add(sinceOption);
|
|
cmd.Add(untilOption);
|
|
cmd.Add(channelOption);
|
|
cmd.Add(kindOption);
|
|
cmd.Add(statusOption);
|
|
cmd.Add(siteOption);
|
|
cmd.Add(targetOption);
|
|
cmd.Add(actorOption);
|
|
cmd.Add(correlationIdOption);
|
|
cmd.Add(executionIdOption);
|
|
cmd.Add(parentExecutionIdOption);
|
|
cmd.Add(errorsOnlyOption);
|
|
cmd.Add(pageSizeOption);
|
|
cmd.Add(allOption);
|
|
|
|
cmd.SetAction(async (ParseResult result) =>
|
|
{
|
|
var connection = AuditCommandHelpers.ResolveConnection(result, urlOption, usernameOption, passwordOption);
|
|
if (connection.Error != null)
|
|
{
|
|
OutputFormatter.WriteError(connection.Error, connection.ErrorCode!);
|
|
return 1;
|
|
}
|
|
|
|
var format = AuditCommandHelpers.ResolveFormat(result, formatOption);
|
|
var formatter = AuditFormatterFactory.Create(format, Console.Error);
|
|
|
|
var args = new AuditQueryArgs
|
|
{
|
|
Since = result.GetValue(sinceOption),
|
|
Until = result.GetValue(untilOption),
|
|
Channel = result.GetValue(channelOption) ?? Array.Empty<string>(),
|
|
Kind = result.GetValue(kindOption) ?? Array.Empty<string>(),
|
|
Status = result.GetValue(statusOption) ?? Array.Empty<string>(),
|
|
Site = result.GetValue(siteOption) ?? Array.Empty<string>(),
|
|
Target = result.GetValue(targetOption),
|
|
Actor = result.GetValue(actorOption),
|
|
CorrelationId = result.GetValue(correlationIdOption),
|
|
ExecutionId = result.GetValue(executionIdOption),
|
|
ParentExecutionId = result.GetValue(parentExecutionIdOption),
|
|
ErrorsOnly = result.GetValue(errorsOnlyOption),
|
|
PageSize = result.GetValue(pageSizeOption),
|
|
};
|
|
var fetchAll = result.GetValue(allOption);
|
|
|
|
try
|
|
{
|
|
using var client = new ManagementHttpClient(connection.Url!, connection.Username!, connection.Password!);
|
|
return await AuditQueryHelpers.RunQueryAsync(
|
|
client, args, fetchAll, formatter, Console.Out, DateTimeOffset.UtcNow);
|
|
}
|
|
catch (FormatException ex)
|
|
{
|
|
OutputFormatter.WriteError(ex.Message, "INVALID_ARGUMENT");
|
|
return 1;
|
|
}
|
|
});
|
|
return cmd;
|
|
}
|
|
|
|
private static Command BuildExport(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
|
{
|
|
var sinceOption = new Option<string>("--since") { Description = "Start time: relative (1h, 24h, 7d) or ISO-8601", Required = true };
|
|
var untilOption = new Option<string>("--until") { Description = "End time: relative (1h, 24h, 7d) or ISO-8601", Required = true };
|
|
// Same rationale as --channel/--kind/--status below and on `query` (register
|
|
// row 99): AcceptOnlyFromAmong is case-SENSITIVE, so it rejected `--format CSV`
|
|
// even though the server lowercases the value before matching. The server
|
|
// returns a 400 naming csv/jsonl/parquet and is the authoritative check.
|
|
var formatExportOption = new Option<string>("--format")
|
|
{
|
|
Description = "Export format (csv, jsonl, parquet; case-insensitive)",
|
|
Required = true,
|
|
};
|
|
var outputOption = new Option<string>("--output") { Description = "Destination file path", Required = true };
|
|
// --channel/--kind/--status/--site are multi-valued — same shape as the
|
|
// `query` subcommand: repeated tokens (--channel A --channel B) and, with
|
|
// AllowMultipleArgumentsPerToken, a single token carrying several values
|
|
// (--channel A B). Valid sets are named in the help text and enforced
|
|
// server-side, case-insensitively — see the `query` builder for the full note.
|
|
var channelOption = new Option<string[]>("--channel")
|
|
{
|
|
Description = "Filter by channel (ApiOutbound, DbOutbound, Notification, ApiInbound; case-insensitive); repeatable",
|
|
AllowMultipleArgumentsPerToken = true,
|
|
};
|
|
var kindOption = new Option<string[]>("--kind")
|
|
{
|
|
Description = "Filter by event kind (ApiCall, ApiCallCached, DbWrite, DbWriteCached, NotifySend, NotifyDeliver, InboundRequest, InboundAuthFailure, CachedSubmit, CachedResolve; case-insensitive); repeatable",
|
|
AllowMultipleArgumentsPerToken = true,
|
|
};
|
|
var statusOption = new Option<string[]>("--status")
|
|
{
|
|
Description = "Filter by status (Submitted, Forwarded, Attempted, Delivered, Failed, Parked, Discarded, Skipped; case-insensitive); repeatable",
|
|
AllowMultipleArgumentsPerToken = true,
|
|
};
|
|
var siteOption = new Option<string[]>("--site")
|
|
{
|
|
Description = "Filter by source site ID; repeatable",
|
|
AllowMultipleArgumentsPerToken = true,
|
|
};
|
|
var targetOption = new Option<string?>("--target") { Description = "Filter by target" };
|
|
var actorOption = new Option<string?>("--actor") { Description = "Filter by actor" };
|
|
|
|
var cmd = new Command("export") { Description = "Export audit log events to a file" };
|
|
cmd.Add(sinceOption);
|
|
cmd.Add(untilOption);
|
|
cmd.Add(formatExportOption);
|
|
cmd.Add(outputOption);
|
|
cmd.Add(channelOption);
|
|
cmd.Add(kindOption);
|
|
cmd.Add(statusOption);
|
|
cmd.Add(siteOption);
|
|
cmd.Add(targetOption);
|
|
cmd.Add(actorOption);
|
|
|
|
cmd.SetAction(async (ParseResult result) =>
|
|
{
|
|
var connection = AuditCommandHelpers.ResolveConnection(result, urlOption, usernameOption, passwordOption);
|
|
if (connection.Error != null)
|
|
{
|
|
OutputFormatter.WriteError(connection.Error, connection.ErrorCode!);
|
|
return 1;
|
|
}
|
|
|
|
var args = new AuditExportArgs
|
|
{
|
|
Since = result.GetValue(sinceOption)!,
|
|
Until = result.GetValue(untilOption)!,
|
|
Format = result.GetValue(formatExportOption)!,
|
|
Output = result.GetValue(outputOption)!,
|
|
Channel = result.GetValue(channelOption) ?? Array.Empty<string>(),
|
|
Kind = result.GetValue(kindOption) ?? Array.Empty<string>(),
|
|
Status = result.GetValue(statusOption) ?? Array.Empty<string>(),
|
|
Site = result.GetValue(siteOption) ?? Array.Empty<string>(),
|
|
Target = result.GetValue(targetOption),
|
|
Actor = result.GetValue(actorOption),
|
|
};
|
|
|
|
// Ctrl-C is the one way to abort an in-flight export other than the
|
|
// headers/inactivity deadlines below — mirrors the CancellationTokenSource
|
|
// owned by DebugCommands' streaming debug-view command (arch-review
|
|
// 2026-08-19 P6-8: this was the one CLI HTTP path with NO deadline at all).
|
|
using var cts = new CancellationTokenSource();
|
|
ConsoleCancelEventHandler onCancel = (_, e) =>
|
|
{
|
|
e.Cancel = true;
|
|
cts.Cancel();
|
|
};
|
|
Console.CancelKeyPress += onCancel;
|
|
|
|
try
|
|
{
|
|
using var client = new ManagementHttpClient(connection.Url!, connection.Username!, connection.Password!);
|
|
return await AuditExportHelpers.RunExportAsync(client, args, Console.Out, DateTimeOffset.UtcNow, cts.Token);
|
|
}
|
|
catch (FormatException ex)
|
|
{
|
|
OutputFormatter.WriteError(ex.Message, "INVALID_ARGUMENT");
|
|
return 1;
|
|
}
|
|
finally
|
|
{
|
|
Console.CancelKeyPress -= onCancel;
|
|
}
|
|
});
|
|
return cmd;
|
|
}
|
|
|
|
private static Command BuildTree(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
|
{
|
|
var executionIdOption = new Option<string>("--execution-id")
|
|
{
|
|
Description = "Execution ID (GUID) to look up — may be any node in the chain",
|
|
Required = true,
|
|
};
|
|
|
|
var cmd = new Command("tree") { Description = "Display the full execution-chain tree for an audit execution" };
|
|
cmd.Add(executionIdOption);
|
|
|
|
cmd.SetAction(async (ParseResult result) =>
|
|
{
|
|
var connection = AuditCommandHelpers.ResolveConnection(result, urlOption, usernameOption, passwordOption);
|
|
if (connection.Error != null)
|
|
{
|
|
OutputFormatter.WriteError(connection.Error, connection.ErrorCode!);
|
|
return 1;
|
|
}
|
|
|
|
var rawId = result.GetValue(executionIdOption);
|
|
if (!Guid.TryParse(rawId, out var executionId))
|
|
{
|
|
OutputFormatter.WriteError(
|
|
$"Invalid execution ID '{rawId}'. Expected a GUID (e.g. 11111111-1111-1111-1111-111111111111).",
|
|
"INVALID_ARGUMENT");
|
|
return 1;
|
|
}
|
|
|
|
var format = AuditCommandHelpers.ResolveFormat(result, formatOption);
|
|
|
|
using var client = new ManagementHttpClient(connection.Url!, connection.Username!, connection.Password!);
|
|
return await AuditTreeHelpers.RunTreeAsync(client, executionId, format, Console.Out);
|
|
});
|
|
|
|
return cmd;
|
|
}
|
|
|
|
private static Command BuildVerifyChain(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
|
{
|
|
var monthOption = new Option<string>("--month") { Description = "Month to verify (YYYY-MM)", Required = true };
|
|
|
|
var cmd = new Command("verify-chain") { Description = "Verify the audit log hash chain for a month" };
|
|
cmd.Add(monthOption);
|
|
cmd.SetAction((ParseResult result) =>
|
|
{
|
|
var month = result.GetValue(monthOption)!;
|
|
if (!AuditVerifyChainHelpers.IsValidMonth(month))
|
|
{
|
|
OutputFormatter.WriteError(
|
|
$"Invalid month '{month}'. Expected YYYY-MM (e.g. 2026-05).", "INVALID_ARGUMENT");
|
|
return 1;
|
|
}
|
|
|
|
Console.WriteLine(
|
|
"Hash-chain tamper-evidence is not enabled in this release. "
|
|
+ "See Component-AuditLog.md (Security & Tamper-Evidence) for the v1.x roadmap.");
|
|
return 0;
|
|
});
|
|
return cmd;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the <c>audit backfill-source-node</c> sub-command.
|
|
/// Sets <c>SourceNode</c> on historical pre-feature rows whose <c>SourceNode IS NULL</c>
|
|
/// and <c>OccurredAtUtc</c> is older than <c>--before</c>, in batches. Admin-only.
|
|
/// </summary>
|
|
private static Command BuildBackfillSourceNode(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
|
{
|
|
var sentinelOption = new Option<string>("--sentinel")
|
|
{
|
|
Description = "Value to write for pre-feature rows whose node-of-origin is unknown (default: unknown)",
|
|
};
|
|
sentinelOption.DefaultValueFactory = _ => "unknown";
|
|
|
|
var beforeOption = new Option<string>("--before")
|
|
{
|
|
Description = "ISO-8601 UTC datetime; only rows older than this date are eligible (required)",
|
|
Required = true,
|
|
};
|
|
|
|
var batchOption = new Option<int>("--batch")
|
|
{
|
|
Description = "Max rows updated per batch (default: 5000)",
|
|
};
|
|
batchOption.DefaultValueFactory = _ => 5000;
|
|
|
|
var cmd = new Command("backfill-source-node")
|
|
{
|
|
Description = "Set SourceNode to a sentinel value on pre-feature rows where it is NULL (admin-only, maintenance path)",
|
|
};
|
|
cmd.Add(sentinelOption);
|
|
cmd.Add(beforeOption);
|
|
cmd.Add(batchOption);
|
|
|
|
cmd.SetAction(async (ParseResult result) =>
|
|
{
|
|
var connection = AuditCommandHelpers.ResolveConnection(result, urlOption, usernameOption, passwordOption);
|
|
if (connection.Error != null)
|
|
{
|
|
OutputFormatter.WriteError(connection.Error, connection.ErrorCode!);
|
|
return 1;
|
|
}
|
|
|
|
var sentinel = result.GetValue(sentinelOption) ?? "unknown";
|
|
var before = result.GetValue(beforeOption)!;
|
|
var batch = result.GetValue(batchOption);
|
|
|
|
if (string.IsNullOrWhiteSpace(sentinel))
|
|
{
|
|
OutputFormatter.WriteError("--sentinel must be a non-empty string.", "INVALID_ARGUMENT");
|
|
return 1;
|
|
}
|
|
|
|
if (batch <= 0)
|
|
{
|
|
OutputFormatter.WriteError("--batch must be > 0.", "INVALID_ARGUMENT");
|
|
return 1;
|
|
}
|
|
|
|
var args = new AuditBackfillSourceNodeArgs
|
|
{
|
|
Sentinel = sentinel,
|
|
Before = before,
|
|
BatchSize = batch,
|
|
};
|
|
|
|
using var client = new ManagementHttpClient(connection.Url!, connection.Username!, connection.Password!);
|
|
return await AuditBackfillHelpers.RunBackfillAsync(client, args, Console.Out);
|
|
});
|
|
|
|
return cmd;
|
|
}
|
|
}
|