Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions GitIntegration.Test/Hosting/CredentialRedactionTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
10 changes: 10 additions & 0 deletions GitIntegration/GitProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,16 @@ public sealed record HostingCredential
/// <summary>Gets the password, when <see cref="Kind"/> is <see cref="HostingCredentialKind.UsernamePassword"/>.</summary>
public string? Password { get; init; }

/// <summary>Returns the record's members with <see cref="Token"/> and <see cref="Password"/> redacted.</summary>
/// <remarks>
/// The compiler-generated version prints every property, so logging or interpolating a
/// credential wrote a live token in plain text. A secret prints as <c>***</c> when present and
/// blank when absent, which keeps "is one set" visible for diagnostics without the value.
/// </remarks>
/// <returns>The credential as the compiler would print it, minus the secrets.</returns>
public override string ToString() =>
$"{nameof(HostingCredential)} {{ Kind = {Kind}, Token = {Redacted.Of(Token)}, Username = {Username}, Password = {Redacted.Of(Password)} }}";

/// <summary>Creates a result carrying a host-native token, such as a personal access token.</summary>
/// <remarks>
/// Not a bearer token. Azure DevOps sends this kind as Basic with an empty username, the scheme
Expand Down
10 changes: 10 additions & 0 deletions GitIntegration/Hosting/GitHubDeviceFlow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@ public sealed record GitHubDeviceCode

/// <summary>Gets the minimum wait GitHub requires between token requests.</summary>
public required TimeSpan Interval { get; init; }

/// <summary>Returns the record's members with <see cref="DeviceCode"/> redacted.</summary>
/// <remarks>
/// The device code is what the token poll exchanges for a credential, so it is as sensitive as
/// the token while it is valid. <see cref="UserCode"/> stays visible: it is shown to the user
/// anyway, and it is what a log needs to match a sign-in attempt.
/// </remarks>
/// <returns>The device code as the compiler would print it, minus the secret.</returns>
public override string ToString() =>
$"{nameof(GitHubDeviceCode)} {{ UserCode = {UserCode}, DeviceCode = {Redacted.Of(DeviceCode)}, VerificationUri = {VerificationUri}, ExpiresIn = {ExpiresIn}, Interval = {Interval} }}";
}

/// <summary>
Expand Down
20 changes: 20 additions & 0 deletions GitIntegration/Hosting/Redacted.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.GitIntegration;

/// <summary>
/// The placeholder a record's <c>ToString</c> prints in place of a secret.
/// </summary>
internal static class Redacted
{
/// <summary>The text printed for a secret that is present.</summary>
internal const string Placeholder = "***";

/// <summary>
/// Returns <see cref="Placeholder"/> for a present secret and an empty string for an absent one,
/// matching how a record prints a <see langword="null"/> member.
/// </summary>
/// <param name="secret">The secret, or <see langword="null"/>.</param>
/// <returns>The text to print in the secret's place.</returns>
internal static string Of(string? secret) => secret is null ? string.Empty : Placeholder;
}
Loading