0d874f91ee
Code-review follow-up on the CLI-40/41/44 branch. ISSUE 1 (all five, critical): the message-only scrub still leaked the server-echoed credential through the redacted error's structured reply accessor (.NET Reply/Statuses, Java reply()/protocolStatus(), Go MxAccessError.Reply via errors.As, Rust reply()/into_reply(), Python raw_reply). The redacted error now carries a scrubbed clone of the reply (protocol_status.message, diagnostic_message, statuses[].diagnostic_text), with per-language tests asserting the reply accessor no longer contains the credential. ISSUE 2 (Rust, critical): ensure_command_success routed MXACCESS_FAILURE to Error::Command (unlike the other four clients), bypassing attach_secrets and leaking via derived Debug/Display. MXACCESS_FAILURE now routes to Error::MxAccess, fixing the cross-client inconsistency. ISSUE 3 (Go, important): the CLI-44 terminal send was unconditionally non-blocking, dropping a genuine terminal error under a full buffer on the never-drop SubscribeEvents path. It is now reserved-slot-non-blocking only for the cancel-on-overflow path and blocking for the never-drop path. New shared fixture authenticate-user.echoed-credential-mxaccess-failure.reply.json wired into all five suites. Minors: whitespace-secret guard on .NET/Java redact helpers; Java preserves exception subtype on redaction; redaction-helper unit tests (Go/Java/.NET). Docs (ClientBehaviorFixtures.md, ClientLibrariesDesign.md) updated to make the structured-field claim true.
230 lines
9.4 KiB
C#
230 lines
9.4 KiB
C#
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Client;
|
|
|
|
/// <summary>
|
|
/// Scrubs exact secret substrings out of diagnostic text before it leaves the client on an
|
|
/// exception path. MXAccess can echo a submitted credential or secured value back inside a
|
|
/// failure diagnostic (protocol message, MXSTATUS_PROXY diagnostic text, HRESULT description);
|
|
/// this helper replaces any such verbatim occurrence with <c><redacted></c> so the raw
|
|
/// request payload never reaches a caught exception's message. The marker matches the Go, Rust,
|
|
/// and Java clients.
|
|
/// </summary>
|
|
internal static class MxGatewaySecretRedaction
|
|
{
|
|
private const string Marker = "<redacted>";
|
|
|
|
/// <summary>
|
|
/// Replaces every usable secret in <paramref name="secrets"/> with the redaction marker
|
|
/// (ordinal comparison). Returns the message unchanged when it is null or empty, or when no
|
|
/// usable secret is supplied. A secret that is null, empty, or whitespace-only is ignored so
|
|
/// it cannot over-redact ordinary separator characters in the message.
|
|
/// </summary>
|
|
/// <param name="message">The diagnostic message to scrub.</param>
|
|
/// <param name="secrets">The secret values to remove from the message.</param>
|
|
/// <returns>The scrubbed message.</returns>
|
|
internal static string Redact(string message, params string?[] secrets)
|
|
{
|
|
if (string.IsNullOrEmpty(message) || secrets is null)
|
|
{
|
|
return message;
|
|
}
|
|
|
|
string result = message;
|
|
foreach (string? secret in secrets)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(secret))
|
|
{
|
|
result = result.Replace(secret, Marker, StringComparison.Ordinal);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns a scrubbed clone of <paramref name="reply"/>: the protocol-status message, the
|
|
/// reply-level diagnostic message, and each MXSTATUS_PROXY diagnostic text have every verbatim
|
|
/// secret replaced with the redaction marker. The original is left untouched. MXAccess can echo
|
|
/// a submitted credential into any of these fields, so a redacted exception must carry the
|
|
/// scrubbed reply rather than the secret-bearing original.
|
|
/// </summary>
|
|
/// <param name="reply">The reply to clone and scrub.</param>
|
|
/// <param name="secrets">The secret values to remove.</param>
|
|
/// <returns>A scrubbed clone of the reply.</returns>
|
|
internal static MxCommandReply RedactReply(MxCommandReply reply, params string?[] secrets)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(reply);
|
|
|
|
MxCommandReply clone = reply.Clone();
|
|
if (clone.ProtocolStatus is not null)
|
|
{
|
|
clone.ProtocolStatus.Message = Redact(clone.ProtocolStatus.Message, secrets);
|
|
}
|
|
|
|
clone.DiagnosticMessage = Redact(clone.DiagnosticMessage, secrets);
|
|
foreach (MxStatusProxy status in clone.Statuses)
|
|
{
|
|
status.DiagnosticText = Redact(status.DiagnosticText, secrets);
|
|
}
|
|
|
|
return clone;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns a scrubbed clone of <paramref name="status"/> (its message with every verbatim
|
|
/// secret removed), or <see langword="null"/> when the input is null.
|
|
/// </summary>
|
|
/// <param name="status">The protocol status to clone and scrub.</param>
|
|
/// <param name="secrets">The secret values to remove.</param>
|
|
/// <returns>A scrubbed clone, or <see langword="null"/>.</returns>
|
|
internal static ProtocolStatus? RedactStatus(ProtocolStatus? status, params string?[] secrets)
|
|
{
|
|
if (status is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
ProtocolStatus clone = status.Clone();
|
|
clone.Message = Redact(clone.Message, secrets);
|
|
return clone;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns a list of scrubbed clones of <paramref name="statuses"/> — each MXSTATUS_PROXY's
|
|
/// diagnostic text has every verbatim secret removed. The originals are left untouched.
|
|
/// </summary>
|
|
/// <param name="statuses">The statuses to clone and scrub.</param>
|
|
/// <param name="secrets">The secret values to remove.</param>
|
|
/// <returns>A list of scrubbed clones.</returns>
|
|
internal static IReadOnlyList<MxStatusProxy> RedactStatuses(
|
|
IReadOnlyList<MxStatusProxy> statuses,
|
|
params string?[] secrets)
|
|
{
|
|
if (statuses is null || statuses.Count is 0)
|
|
{
|
|
return statuses ?? [];
|
|
}
|
|
|
|
MxStatusProxy[] result = new MxStatusProxy[statuses.Count];
|
|
for (int i = 0; i < statuses.Count; i++)
|
|
{
|
|
MxStatusProxy clone = statuses[i].Clone();
|
|
clone.DiagnosticText = Redact(clone.DiagnosticText, secrets);
|
|
result[i] = clone;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns an exception equivalent to <paramref name="ex"/> but with any verbatim secret
|
|
/// scrubbed from its message. When nothing changes, the original exception is returned
|
|
/// unchanged; otherwise a new exception of the same concrete runtime type is built and the
|
|
/// original reply/status context is preserved. The secret-bearing original is deliberately
|
|
/// <b>not</b> chained as the inner exception — doing so would let its unredacted message
|
|
/// re-surface through <see cref="Exception.ToString"/> (which logging frameworks call). The
|
|
/// original's own inner cause (a transport error, never the request payload) is carried
|
|
/// forward instead.
|
|
/// </summary>
|
|
/// <param name="ex">The exception to redact.</param>
|
|
/// <param name="secrets">The secret values to remove from the message.</param>
|
|
/// <returns>The redacted exception, or the original when no change was needed.</returns>
|
|
internal static MxGatewayException Redacted(MxGatewayException ex, params string?[] secrets)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(ex);
|
|
|
|
string redacted = Redact(ex.Message, secrets);
|
|
bool messageChanged = !string.Equals(redacted, ex.Message, StringComparison.Ordinal);
|
|
Exception? cause = ex.InnerException;
|
|
|
|
// MxAccessException derives its structured fields from the raw reply, so scrubbing must
|
|
// clone and redact that reply — the message alone changing is not enough, because the reply
|
|
// can carry the echoed secret even when the message does not.
|
|
if (ex is MxAccessException access)
|
|
{
|
|
if (!messageChanged && !ReplyContainsSecret(access.Reply, secrets))
|
|
{
|
|
return ex;
|
|
}
|
|
|
|
return new MxAccessException(redacted, RedactReply(access.Reply, secrets), cause);
|
|
}
|
|
|
|
// Other subtypes carry the secret through ProtocolStatus.Message and Statuses[].DiagnosticText.
|
|
if (!messageChanged
|
|
&& !ContainsSecret(ex.ProtocolStatus?.Message, secrets)
|
|
&& !StatusesContainSecret(ex.Statuses, secrets))
|
|
{
|
|
return ex;
|
|
}
|
|
|
|
ProtocolStatus? status = RedactStatus(ex.ProtocolStatus, secrets);
|
|
IReadOnlyList<MxStatusProxy> statuses = RedactStatuses(ex.Statuses, secrets);
|
|
return ex switch
|
|
{
|
|
MxGatewaySessionException => new MxGatewaySessionException(
|
|
redacted, ex.SessionId, ex.CorrelationId, status, ex.HResultCode, statuses, cause),
|
|
MxGatewayWorkerException => new MxGatewayWorkerException(
|
|
redacted, ex.SessionId, ex.CorrelationId, status, ex.HResultCode, statuses, cause),
|
|
MxGatewayAuthenticationException => new MxGatewayAuthenticationException(
|
|
redacted, ex.SessionId, ex.CorrelationId, status, ex.HResultCode, statuses, cause),
|
|
MxGatewayAuthorizationException => new MxGatewayAuthorizationException(
|
|
redacted, ex.SessionId, ex.CorrelationId, status, ex.HResultCode, statuses, cause),
|
|
MxGatewayMalformedReplyException => new MxGatewayMalformedReplyException(
|
|
redacted, ex.SessionId, ex.CorrelationId, status, ex.HResultCode, statuses, cause),
|
|
MxGatewayCommandException => new MxGatewayCommandException(
|
|
redacted, ex.SessionId, ex.CorrelationId, status, ex.HResultCode, statuses, cause),
|
|
_ => new MxGatewayException(redacted, cause),
|
|
};
|
|
}
|
|
|
|
private static bool ContainsSecret(string? text, string?[] secrets)
|
|
{
|
|
if (string.IsNullOrEmpty(text) || secrets is null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
foreach (string? secret in secrets)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(secret) && text.Contains(secret, StringComparison.Ordinal))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static bool StatusesContainSecret(IReadOnlyList<MxStatusProxy> statuses, string?[] secrets)
|
|
{
|
|
if (statuses is null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
foreach (MxStatusProxy status in statuses)
|
|
{
|
|
if (ContainsSecret(status.DiagnosticText, secrets))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static bool ReplyContainsSecret(MxCommandReply reply, string?[] secrets)
|
|
{
|
|
if (reply is null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return ContainsSecret(reply.ProtocolStatus?.Message, secrets)
|
|
|| ContainsSecret(reply.DiagnosticMessage, secrets)
|
|
|| StatusesContainSecret(reply.Statuses, secrets);
|
|
}
|
|
}
|