From 92b5364c669fbfb99234fc95d898cb9aca368d28 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:29:27 +0000 Subject: [PATCH] Redact tokens, passwords and device codes from credential ToString [patch] HostingCredential and GitHubDeviceCode are records, and the compiler-generated ToString printed every property, so logging or interpolating one wrote a live GitHub or Azure DevOps token in plain text. Both now override ToString to print Token, Password and DeviceCode as *** when present (blank when absent), keeping Kind, Username, UserCode and VerificationUri for diagnostics. Fixes ktsu-dev/GitIntegration#169 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Rt1SUkG3bsTZEWbmGirSx3 --- .../Hosting/CredentialRedactionTests.cs | 75 +++++++++++++++++++ GitIntegration/GitProvider.cs | 10 +++ GitIntegration/Hosting/GitHubDeviceFlow.cs | 10 +++ GitIntegration/Hosting/Redacted.cs | 20 +++++ 4 files changed, 115 insertions(+) create mode 100644 GitIntegration.Test/Hosting/CredentialRedactionTests.cs create mode 100644 GitIntegration/Hosting/Redacted.cs diff --git a/GitIntegration.Test/Hosting/CredentialRedactionTests.cs b/GitIntegration.Test/Hosting/CredentialRedactionTests.cs new file mode 100644 index 0000000..83f602d --- /dev/null +++ b/GitIntegration.Test/Hosting/CredentialRedactionTests.cs @@ -0,0 +1,75 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.GitIntegration.Test; + +using System; + +[TestClass] +public sealed class CredentialRedactionTests +{ + private const string Secret = "gho_s3cretTokenValue"; + + [TestMethod] + public void ATokenCredentialDoesNotPrintItsToken() + { + string printed = HostingCredential.FromToken(Secret).ToString(); + + Assert.DoesNotContain(Secret, printed); + Assert.AreEqual("HostingCredential { Kind = Token, Token = ***, Username = , Password = }", printed); + } + + [TestMethod] + public void ABearerCredentialDoesNotPrintItsToken() + { + string printed = HostingCredential.FromBearerToken(Secret).ToString(); + + Assert.DoesNotContain(Secret, printed); + Assert.Contains("Kind = BearerToken", printed); + } + + [TestMethod] + public void AUsernamePasswordCredentialPrintsTheUsernameButNotThePassword() + { + HostingCredential credential = new() + { + Kind = HostingCredentialKind.UsernamePassword, + Username = "octocat", + Password = Secret, + }; + + string printed = credential.ToString(); + + Assert.DoesNotContain(Secret, printed); + Assert.AreEqual("HostingCredential { Kind = UsernamePassword, Token = , Username = octocat, Password = *** }", printed); + } + + [TestMethod] + public void InterpolatingACredentialDoesNotLeakItsToken() + { + HostingCredential credential = HostingCredential.FromToken(Secret); + + string message = $"Signed in: {credential}"; + + Assert.DoesNotContain(Secret, message); + } + + [TestMethod] + public void ADeviceCodePrintsTheUserCodeButNotTheDeviceCode() + { + GitHubDeviceCode code = new() + { + UserCode = "WXYZ-1234", + DeviceCode = Secret, + VerificationUri = new Uri("https://github.com/login/device"), + ExpiresIn = TimeSpan.FromSeconds(900), + Interval = TimeSpan.FromSeconds(5), + }; + + string printed = code.ToString(); + + Assert.DoesNotContain(Secret, printed); + Assert.Contains("UserCode = WXYZ-1234", printed); + Assert.Contains("DeviceCode = ***", printed); + Assert.Contains("VerificationUri = https://github.com/login/device", printed); + } +} diff --git a/GitIntegration/GitProvider.cs b/GitIntegration/GitProvider.cs index efc29c9..aae19b3 100644 --- a/GitIntegration/GitProvider.cs +++ b/GitIntegration/GitProvider.cs @@ -585,6 +585,16 @@ public sealed record HostingCredential /// Gets the password, when is . public string? Password { get; init; } + /// Returns the record's members with and redacted. + /// + /// The compiler-generated version prints every property, so logging or interpolating a + /// credential wrote a live token in plain text. A secret prints as *** when present and + /// blank when absent, which keeps "is one set" visible for diagnostics without the value. + /// + /// The credential as the compiler would print it, minus the secrets. + public override string ToString() => + $"{nameof(HostingCredential)} {{ Kind = {Kind}, Token = {Redacted.Of(Token)}, Username = {Username}, Password = {Redacted.Of(Password)} }}"; + /// Creates a result carrying a host-native token, such as a personal access token. /// /// Not a bearer token. Azure DevOps sends this kind as Basic with an empty username, the scheme diff --git a/GitIntegration/Hosting/GitHubDeviceFlow.cs b/GitIntegration/Hosting/GitHubDeviceFlow.cs index 6f7b657..eac87a0 100644 --- a/GitIntegration/Hosting/GitHubDeviceFlow.cs +++ b/GitIntegration/Hosting/GitHubDeviceFlow.cs @@ -39,6 +39,16 @@ public sealed record GitHubDeviceCode /// Gets the minimum wait GitHub requires between token requests. public required TimeSpan Interval { get; init; } + + /// Returns the record's members with redacted. + /// + /// The device code is what the token poll exchanges for a credential, so it is as sensitive as + /// the token while it is valid. stays visible: it is shown to the user + /// anyway, and it is what a log needs to match a sign-in attempt. + /// + /// The device code as the compiler would print it, minus the secret. + public override string ToString() => + $"{nameof(GitHubDeviceCode)} {{ UserCode = {UserCode}, DeviceCode = {Redacted.Of(DeviceCode)}, VerificationUri = {VerificationUri}, ExpiresIn = {ExpiresIn}, Interval = {Interval} }}"; } /// diff --git a/GitIntegration/Hosting/Redacted.cs b/GitIntegration/Hosting/Redacted.cs new file mode 100644 index 0000000..6cdd032 --- /dev/null +++ b/GitIntegration/Hosting/Redacted.cs @@ -0,0 +1,20 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.GitIntegration; + +/// +/// The placeholder a record's ToString prints in place of a secret. +/// +internal static class Redacted +{ + /// The text printed for a secret that is present. + internal const string Placeholder = "***"; + + /// + /// Returns for a present secret and an empty string for an absent one, + /// matching how a record prints a member. + /// + /// The secret, or . + /// The text to print in the secret's place. + internal static string Of(string? secret) => secret is null ? string.Empty : Placeholder; +}