Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLogRedactor.cs
T
Joseph Doherty 9354d69bf9 fix(diagnostics): redact WriteSecuredBulk/WriteSecured2Bulk payloads (#193)
Adds both secured bulk kinds to GatewayLogRedactor's credential-bearing set,
plus a contract cross-check test that walks every MxCommand payload descriptor
and fails if a secured or password-bearing command kind is not redacted.
2026-11-06 15:45:50 -05:00

165 lines
6.3 KiB
C#

namespace ZB.MOM.WW.MxGateway.Server.Diagnostics;
/// <summary>
/// Redacts sensitive information from log entries.
/// </summary>
public static class GatewayLogRedactor
{
/// <summary>Placeholder for redacted values.</summary>
public const string RedactedValue = "[redacted]";
private static readonly HashSet<string> SensitiveCommandMethods = new(StringComparer.OrdinalIgnoreCase)
{
"AuthenticateUser",
// Carry a credential-sensitive write value (single item and bulk).
"WriteSecured",
"WriteSecured2",
"WriteSecuredBulk",
"WriteSecured2Bulk",
// Carry operator_password.
"AcknowledgeAlarm",
"AcknowledgeAlarmByName"
};
/// <summary>
/// Authorization schemes whose name may survive redaction. Anything outside this list is
/// dropped whole: an unrecognized leading word is as likely to be credential material as it
/// is to be a scheme, so it is not worth the leak.
/// </summary>
private static readonly string[] KnownAuthorizationSchemes =
[
"Bearer",
"Basic",
"Digest",
"Negotiate",
"NTLM",
"ApiKey",
"Token",
];
/// <summary>Prefix identifying a gateway-issued API key.</summary>
private const string GatewayKeyPrefix = "mxgw_";
/// <summary>Upper bound on a key id kept in the clear; a longer run is treated as secret material.</summary>
private const int MaxKeyIdLength = 64;
/// <summary>
/// Determines whether a command method bears credentials.
/// </summary>
/// <param name="commandMethod">The command method name to check.</param>
/// <returns><see langword="true"/> if the command method carries credentials; otherwise <see langword="false"/>.</returns>
public static bool IsCredentialBearingCommand(string? commandMethod)
{
return commandMethod is not null
&& SensitiveCommandMethods.Contains(commandMethod);
}
/// <summary>
/// Redacts the credential portion of an authorization header value.
/// </summary>
/// <param name="authorizationHeader">The authorization header value to redact.</param>
/// <returns>The header with the credential redacted, or the original value when it is null or blank.</returns>
public static string? RedactApiKey(string? authorizationHeader)
{
return RedactClientIdentity(authorizationHeader);
}
/// <summary>
/// Redacts the credential carried by a client identity. Redaction fails closed: only a
/// gateway-issued API key keeps its <c>mxgw_&lt;key-id&gt;_</c> shape (so operators can tell keys
/// apart in logs), and only a recognized scheme keeps its name. Every other value — a foreign
/// bearer token, a scheme-less string, junk — is replaced whole, because nothing that reaches
/// this method is known to be safe to log.
/// </summary>
/// <param name="clientIdentity">The client identity string to redact.</param>
/// <returns>The redacted client identity, or the original value when it is null or blank.</returns>
public static string? RedactClientIdentity(string? clientIdentity)
{
if (string.IsNullOrWhiteSpace(clientIdentity))
{
return clientIdentity;
}
ReadOnlySpan<char> value = clientIdentity.AsSpan().Trim();
int separatorIndex = value.IndexOf(' ');
if (separatorIndex < 0)
{
// A single token carries no scheme, so the token itself is the credential.
return RedactedValue;
}
ReadOnlySpan<char> scheme = value[..separatorIndex];
ReadOnlySpan<char> credential = value[(separatorIndex + 1)..].Trim();
if (credential.IsEmpty || !IsKnownAuthorizationScheme(scheme))
{
return RedactedValue;
}
return credential.StartsWith(GatewayKeyPrefix, StringComparison.OrdinalIgnoreCase)
? $"{scheme} {GatewayKeyPrefix}{RedactKeyId(credential)}"
: $"{scheme} {RedactedValue}";
}
/// <summary>
/// Renders the trailing portion of a gateway API key: the key id when the key is well formed,
/// otherwise nothing but the placeholder.
/// </summary>
/// <param name="credential">The credential, known to start with the gateway key prefix.</param>
/// <returns>The <c>&lt;key-id&gt;_[redacted]</c> tail, or just the placeholder.</returns>
private static string RedactKeyId(ReadOnlySpan<char> credential)
{
ReadOnlySpan<char> remainder = credential[GatewayKeyPrefix.Length..];
int secretIndex = remainder.IndexOf('_');
// No separator means no secret boundary to trust, so the whole remainder is treated as secret.
return secretIndex is <= 0 or > MaxKeyIdLength
? RedactedValue
: $"{remainder[..secretIndex]}_{RedactedValue}";
}
/// <summary>
/// Determines whether a leading word is a recognized authorization scheme.
/// </summary>
/// <param name="scheme">The candidate scheme word.</param>
/// <returns><see langword="true"/> when the word may survive redaction; otherwise <see langword="false"/>.</returns>
private static bool IsKnownAuthorizationScheme(ReadOnlySpan<char> scheme)
{
foreach (string knownScheme in KnownAuthorizationSchemes)
{
if (scheme.Equals(knownScheme, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
/// <summary>
/// Redacts a command value if it contains credentials or value logging is disabled.
/// </summary>
/// <param name="commandMethod">The command method name to check for credentials.</param>
/// <param name="value">The command value to redact.</param>
/// <param name="valueLoggingEnabled">Whether value logging is enabled.</param>
/// <returns>The redacted placeholder, the original value, or <see langword="null"/> when <paramref name="value"/> is null.</returns>
public static object? RedactCommandValue(
string? commandMethod,
object? value,
bool valueLoggingEnabled = false)
{
if (value is null)
{
return null;
}
if (!valueLoggingEnabled || IsCredentialBearingCommand(commandMethod))
{
return RedactedValue;
}
return value;
}
}