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:
@@ -0,0 +1,96 @@
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="MxGatewaySecretRedaction"/> — the exact-substring scrub applied to
|
||||
/// diagnostic text and rebuilt exceptions before they leave the client on a failure path.
|
||||
/// </summary>
|
||||
public sealed class MxGatewaySecretRedactionTests
|
||||
{
|
||||
[Fact]
|
||||
public void Redact_ReplacesEveryOccurrenceOfSecret()
|
||||
{
|
||||
string result = MxGatewaySecretRedaction.Redact(
|
||||
"pw=hunter2 retry pw=hunter2 again hunter2",
|
||||
"hunter2");
|
||||
|
||||
Assert.DoesNotContain("hunter2", result, StringComparison.Ordinal);
|
||||
Assert.Equal("pw=<redacted> retry pw=<redacted> again <redacted>", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Redact_ScrubsBothSecretsWhenOneIsSubstringOfTheOther()
|
||||
{
|
||||
// "secret" is a substring of "secretPassword"; both must be fully scrubbed regardless of
|
||||
// supplied order — no residual leak of either verbatim value.
|
||||
string result = MxGatewaySecretRedaction.Redact(
|
||||
"a=secretPassword b=secret",
|
||||
"secret",
|
||||
"secretPassword");
|
||||
|
||||
Assert.DoesNotContain("secretPassword", result, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("secret", result, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Redact_WithNullSecretsArray_ReturnsMessageUnchanged()
|
||||
{
|
||||
const string message = "nothing to scrub here";
|
||||
|
||||
string result = MxGatewaySecretRedaction.Redact(message, null!);
|
||||
|
||||
Assert.Equal(message, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Redact_WithEmptySecretsArray_ReturnsMessageUnchanged()
|
||||
{
|
||||
const string message = "nothing to scrub here";
|
||||
|
||||
string result = MxGatewaySecretRedaction.Redact(message);
|
||||
|
||||
Assert.Equal(message, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Redact_IgnoresWhitespaceOnlySecret()
|
||||
{
|
||||
// A whitespace-only secret must not over-redact the internal spaces of the message.
|
||||
const string message = "user operator logged in";
|
||||
|
||||
string result = MxGatewaySecretRedaction.Redact(message, " ");
|
||||
|
||||
Assert.Equal(message, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Redacted_PreservesConcreteSubtypeAndDoesNotChainSecretBearingOriginal()
|
||||
{
|
||||
const string secret = "hunter2";
|
||||
Exception transportCause = new InvalidOperationException("transport reset");
|
||||
MxGatewaySessionException original = new(
|
||||
$"session rejected credential '{secret}'",
|
||||
"session-1",
|
||||
"correlation-1",
|
||||
new ProtocolStatus { Code = ProtocolStatusCode.SessionNotReady, Message = $"echoed '{secret}'" },
|
||||
hResult: -1,
|
||||
statuses: [new MxStatusProxy { DiagnosticText = $"denied '{secret}'" }],
|
||||
innerException: transportCause);
|
||||
|
||||
MxGatewayException redacted = MxGatewaySecretRedaction.Redacted(original, secret);
|
||||
|
||||
// Concrete runtime type is preserved.
|
||||
Assert.IsType<MxGatewaySessionException>(redacted);
|
||||
// The secret is gone from the message and every structured accessor.
|
||||
Assert.DoesNotContain(secret, redacted.Message, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(secret, redacted.ToString(), StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(secret, redacted.ProtocolStatus!.Message, StringComparison.Ordinal);
|
||||
Assert.All(redacted.Statuses, status =>
|
||||
Assert.DoesNotContain(secret, status.DiagnosticText, StringComparison.Ordinal));
|
||||
Assert.Contains("<redacted>", redacted.Message, StringComparison.Ordinal);
|
||||
// The secret-bearing original is NOT chained; the original's transport cause is carried.
|
||||
Assert.NotSame(original, redacted.InnerException);
|
||||
Assert.Same(transportCause, redacted.InnerException);
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,44 @@ public sealed class MxGatewaySessionReplyContractTests
|
||||
Assert.DoesNotContain(password, exception.ToString(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-40: the redacted exception must not leak the echoed credential through any structured
|
||||
/// accessor either — <see cref="MxAccessException.Reply"/> (protocol message, diagnostic
|
||||
/// message, and each MXSTATUS_PROXY diagnostic text) and <see cref="MxGatewayException.Statuses"/>
|
||||
/// all carry the server-echoed credential verbatim before the fix. Both the OK+negative-HRESULT
|
||||
/// and the MXACCESS_FAILURE reply route to <see cref="MxAccessException"/>, so both must scrub.
|
||||
/// </summary>
|
||||
/// <param name="fixture">The echoed-credential reply fixture to drive.</param>
|
||||
[Theory]
|
||||
[InlineData("authenticate-user.echoed-credential.reply.json")]
|
||||
[InlineData("authenticate-user.echoed-credential-mxaccess-failure.reply.json")]
|
||||
public async Task AuthenticateUserAsync_RedactsEchoedCredentialInStructuredAccessors(string fixture)
|
||||
{
|
||||
const string password = "sup3rSecretVerify9f3a2b";
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.AddInvokeReply(ReadReplyFixture(fixture));
|
||||
await using MxGatewayClient client = CreateClient(transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
|
||||
MxAccessException exception = await Assert.ThrowsAsync<MxAccessException>(
|
||||
async () => await session.AuthenticateUserAsync(12, "operator", password));
|
||||
|
||||
Assert.DoesNotContain(password, exception.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("<redacted>", exception.Message, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(password, exception.ToString(), StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(password, exception.Reply.ProtocolStatus.Message, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(password, exception.Reply.DiagnosticMessage, StringComparison.Ordinal);
|
||||
foreach (MxStatusProxy status in exception.Reply.Statuses)
|
||||
{
|
||||
Assert.DoesNotContain(password, status.DiagnosticText, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
foreach (MxStatusProxy status in exception.Statuses)
|
||||
{
|
||||
Assert.DoesNotContain(password, status.DiagnosticText, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-41: an OK reply that carries neither the typed AuthenticateUser payload nor an
|
||||
/// int32 return_value is a malformed reply, surfaced as a typed exception rather than an NRE.
|
||||
|
||||
Reference in New Issue
Block a user