548bb9a725
verify-chain was a no-op stub that printed "not enabled" and exited 0 without contacting the server. It now sends VerifyAuditChain(Month) over POST /management and maps the verdict to the exit code: 0 intact or not chained, 3 broken (first break: partition, ChainSeq, EventId, kind), 1 error, 2 unauthorized. Table and JSON renderings. ManagementActor: new HandleVerifyAuditChain arm, gated on the OperationalAudit roles (Administrator, Viewer) and refusing a site-scoped caller because a month's chain spans every site; INVALID_ARGUMENT for a bad month, CHAIN_BUSY when the chain lock is held; long-running Ask timeout. Result enums serialise as names. Parity: RequiredRoleMatrixTests freezes the gate; ManagementSurfaceParityTests now probes `audit verify-chain` (the one audit leaf that is a management contract) instead of skipping the whole audit group. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
87 lines
4.4 KiB
C#
87 lines
4.4 KiB
C#
using System.Globalization;
|
|
using System.Text.Json;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
|
|
|
/// <summary>
|
|
/// Helpers for the <c>audit verify-chain</c> subcommand (ScadaBridge#41). The command sends
|
|
/// the <c>VerifyAuditChain</c> management command for one UTC calendar month and reports
|
|
/// whether that month's AuditLog hash chain is intact, or the first break (chain partition,
|
|
/// <c>ChainSeq</c>, <c>EventId</c>, kind).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Exit codes: <c>0</c> intact (or nothing chained yet), <see cref="BrokenExitCode"/> when
|
|
/// the chain is broken, <c>1</c> on a request error, <c>2</c> on an authorization failure.
|
|
/// </remarks>
|
|
public static class AuditVerifyChainHelpers
|
|
{
|
|
/// <summary>Process exit code when verification found a break (tamper evidence).</summary>
|
|
public const int BrokenExitCode = 3;
|
|
|
|
/// <summary>
|
|
/// Client deadline. The server bounds the call with the long-running management Ask
|
|
/// (5 min); the client waits a little longer so the server's answer always arrives.
|
|
/// </summary>
|
|
public static readonly TimeSpan RequestTimeout = TimeSpan.FromMinutes(6);
|
|
|
|
/// <summary>
|
|
/// Returns true if <paramref name="month"/> is a well-formed <c>YYYY-MM</c> value
|
|
/// with a real month (01-12). A malformed month (e.g. <c>2026-13</c>) is rejected.
|
|
/// </summary>
|
|
/// <param name="month">The month string to validate in YYYY-MM format.</param>
|
|
/// <returns><c>true</c> if the string is a well-formed YYYY-MM value with a real month; otherwise <c>false</c>.</returns>
|
|
public static bool IsValidMonth(string? month)
|
|
=> !string.IsNullOrWhiteSpace(month)
|
|
&& DateTime.TryParseExact(month, "yyyy-MM", CultureInfo.InvariantCulture,
|
|
DateTimeStyles.None, out _);
|
|
|
|
/// <summary>
|
|
/// Renders a <c>VerifyAuditChain</c> result and maps it to the process exit code.
|
|
/// <c>json</c> prints the server body verbatim; <c>table</c> prints a short report.
|
|
/// </summary>
|
|
/// <param name="json">The success body (an <c>AuditChainVerificationResult</c>, camelCase).</param>
|
|
/// <param name="format">Resolved output format.</param>
|
|
/// <param name="output">Writer for the report.</param>
|
|
/// <returns><c>0</c> when intact / not chained, <see cref="BrokenExitCode"/> when broken.</returns>
|
|
public static int Render(string json, string format, TextWriter output)
|
|
{
|
|
using var doc = JsonDocument.Parse(json);
|
|
var root = doc.RootElement;
|
|
var status = root.TryGetProperty("status", out var s) ? s.GetString() ?? "" : "";
|
|
var broken = string.Equals(status, "Broken", StringComparison.OrdinalIgnoreCase);
|
|
|
|
if (!string.Equals(format, "table", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
output.WriteLine(json);
|
|
output.Flush();
|
|
return broken ? BrokenExitCode : 0;
|
|
}
|
|
|
|
output.WriteLine($"Partition : {Str(root, "partition")}");
|
|
output.WriteLine($"Status : {status}");
|
|
output.WriteLine($"Rows verified : {Num(root, "rowsVerified")}");
|
|
output.WriteLine($"Tombstones applied: {Num(root, "tombstonesApplied")}");
|
|
output.WriteLine($"Purged through : {Num(root, "purgedThroughSeq", "-")}");
|
|
output.WriteLine($"Head sequence : {Num(root, "headSeq", "-")}");
|
|
output.WriteLine($"Unchained rows : {Num(root, "unchainedRows")}");
|
|
if (root.TryGetProperty("firstBreak", out var brk) && brk.ValueKind == JsonValueKind.Object)
|
|
{
|
|
output.WriteLine("First break:");
|
|
output.WriteLine($" partition : {Str(brk, "partition")}");
|
|
output.WriteLine($" chainSeq : {Num(brk, "chainSeq")}");
|
|
output.WriteLine($" eventId : {Str(brk, "eventId", "-")}");
|
|
output.WriteLine($" kind : {Str(brk, "kind")}");
|
|
output.WriteLine($" detail : {Str(brk, "detail")}");
|
|
}
|
|
|
|
output.Flush();
|
|
return broken ? BrokenExitCode : 0;
|
|
}
|
|
|
|
private static string Str(JsonElement e, string name, string missing = "")
|
|
=> e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() ?? missing : missing;
|
|
|
|
private static string Num(JsonElement e, string name, string missing = "0")
|
|
=> e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number ? v.GetInt64().ToString(CultureInfo.InvariantCulture) : missing;
|
|
}
|