fix(CLI-40): scrub the credential from the redacted error's structured reply, route MXACCESS_FAILURE to MxAccess (Rust), fix Go Subscribe terminal-error drop
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.
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Client;
|
||||
|
||||
/// <summary>
|
||||
@@ -13,9 +15,10 @@ internal static class MxGatewaySecretRedaction
|
||||
private const string Marker = "<redacted>";
|
||||
|
||||
/// <summary>
|
||||
/// Replaces every non-null, non-empty 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.
|
||||
/// 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>
|
||||
@@ -30,7 +33,7 @@ internal static class MxGatewaySecretRedaction
|
||||
string result = message;
|
||||
foreach (string? secret in secrets)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(secret))
|
||||
if (!string.IsNullOrWhiteSpace(secret))
|
||||
{
|
||||
result = result.Replace(secret, Marker, StringComparison.Ordinal);
|
||||
}
|
||||
@@ -39,6 +42,81 @@ internal static class MxGatewaySecretRedaction
|
||||
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
|
||||
@@ -57,28 +135,95 @@ internal static class MxGatewaySecretRedaction
|
||||
ArgumentNullException.ThrowIfNull(ex);
|
||||
|
||||
string redacted = Redact(ex.Message, secrets);
|
||||
if (string.Equals(redacted, ex.Message, StringComparison.Ordinal))
|
||||
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;
|
||||
}
|
||||
|
||||
Exception? cause = ex.InnerException;
|
||||
ProtocolStatus? status = RedactStatus(ex.ProtocolStatus, secrets);
|
||||
IReadOnlyList<MxStatusProxy> statuses = RedactStatuses(ex.Statuses, secrets);
|
||||
return ex switch
|
||||
{
|
||||
MxAccessException access => new MxAccessException(redacted, access.Reply, cause),
|
||||
MxGatewaySessionException => new MxGatewaySessionException(
|
||||
redacted, ex.SessionId, ex.CorrelationId, ex.ProtocolStatus, ex.HResultCode, ex.Statuses, cause),
|
||||
redacted, ex.SessionId, ex.CorrelationId, status, ex.HResultCode, statuses, cause),
|
||||
MxGatewayWorkerException => new MxGatewayWorkerException(
|
||||
redacted, ex.SessionId, ex.CorrelationId, ex.ProtocolStatus, ex.HResultCode, ex.Statuses, cause),
|
||||
redacted, ex.SessionId, ex.CorrelationId, status, ex.HResultCode, statuses, cause),
|
||||
MxGatewayAuthenticationException => new MxGatewayAuthenticationException(
|
||||
redacted, ex.SessionId, ex.CorrelationId, ex.ProtocolStatus, ex.HResultCode, ex.Statuses, cause),
|
||||
redacted, ex.SessionId, ex.CorrelationId, status, ex.HResultCode, statuses, cause),
|
||||
MxGatewayAuthorizationException => new MxGatewayAuthorizationException(
|
||||
redacted, ex.SessionId, ex.CorrelationId, ex.ProtocolStatus, ex.HResultCode, ex.Statuses, cause),
|
||||
redacted, ex.SessionId, ex.CorrelationId, status, ex.HResultCode, statuses, cause),
|
||||
MxGatewayMalformedReplyException => new MxGatewayMalformedReplyException(
|
||||
redacted, ex.SessionId, ex.CorrelationId, ex.ProtocolStatus, ex.HResultCode, ex.Statuses, cause),
|
||||
redacted, ex.SessionId, ex.CorrelationId, status, ex.HResultCode, statuses, cause),
|
||||
MxGatewayCommandException => new MxGatewayCommandException(
|
||||
redacted, ex.SessionId, ex.CorrelationId, ex.ProtocolStatus, ex.HResultCode, ex.Statuses, cause),
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user