diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 54a3c856..4c510b07 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -65,9 +65,11 @@ body: Windows (PowerShell): $env:ARTIFACTS_CREDENTIALPROVIDER_LOG_PATH = "$PWD\\artifacts-credprovider.log" + $env:ARTIFACTS_CREDENTIALPROVIDER_REDACT_ENABLED = "true" # Optional: redact sensitive info Linux/macOS (bash/zsh): export ARTIFACTS_CREDENTIALPROVIDER_LOG_PATH="$PWD/artifacts-credprovider.log" + export ARTIFACTS_CREDENTIALPROVIDER_REDACT_ENABLED="true" # Optional: redact sensitive info 2) Run your failing command with detailed verbosity: @@ -83,7 +85,8 @@ body: 3) Provide the log output below with any urls or secrets redacted. Note: - Legacy log path variable is NUGET_CREDENTIALPROVIDER_LOG_PATH. - - Remove/redact urls before posting logs. + - Setting ARTIFACTS_CREDENTIALPROVIDER_REDACT_ENABLED=true provides best-effort automatic redaction. + - Redaction is not guaranteed to catch everything. ALWAYS review logs before posting to ensure no sensitive data is exposed. validations: required: false diff --git a/CredentialProvider.Microsoft.Tests/Logging/LogEveryMessageFileLoggerTests.cs b/CredentialProvider.Microsoft.Tests/Logging/LogEveryMessageFileLoggerTests.cs new file mode 100644 index 00000000..a7db9188 --- /dev/null +++ b/CredentialProvider.Microsoft.Tests/Logging/LogEveryMessageFileLoggerTests.cs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft. All rights reserved. +// +// Licensed under the MIT license. + +using System; +using System.IO; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NuGet.Common; +using NuGetCredentialProvider.Logging; + +namespace CredentialProvider.Microsoft.Tests.Logging +{ + [TestClass] + public class LogEveryMessageFileLoggerTests + { + private string tempLogFile; + + [TestInitialize] + public void Setup() + { + tempLogFile = Path.GetTempFileName(); + } + + [TestCleanup] + public void Cleanup() + { + if (File.Exists(tempLogFile)) + { + try + { + File.Delete(tempLogFile); + } + catch + { + // Ignore cleanup errors + } + } + } + + [TestMethod] + public void Log_WritesMessageToFile() + { + // Arrange + var logger = new LogEveryMessageFileLogger(tempLogFile); + var message = "Test message"; + + // Act + logger.Log(LogLevel.Information, allowOnConsole: false, message); + + // Assert + var logContent = File.ReadAllText(tempLogFile); + Assert.IsTrue(logContent.Contains("Test message"), "Message should be written to file"); + } + + [TestMethod] + public void Log_PreservesNonSensitiveContent() + { + // Arrange + var logger = new LogEveryMessageFileLogger(tempLogFile); + var message = "Successfully authenticated to service"; + + // Act + logger.Log(LogLevel.Information, allowOnConsole: false, message); + + // Assert + var logContent = File.ReadAllText(tempLogFile); + Assert.IsTrue(logContent.Contains("Successfully authenticated to service"), "Non-sensitive content should be preserved"); + } + + [TestMethod] + public void Log_HandlesNullMessage() + { + // Arrange + var logger = new LogEveryMessageFileLogger(tempLogFile); + + // Act & Assert - should not throw + logger.Log(LogLevel.Verbose, allowOnConsole: false, null); + } + } +} \ No newline at end of file diff --git a/CredentialProvider.Microsoft.Tests/Util/RedactionUtilTests.cs b/CredentialProvider.Microsoft.Tests/Util/RedactionUtilTests.cs new file mode 100644 index 00000000..9108769e --- /dev/null +++ b/CredentialProvider.Microsoft.Tests/Util/RedactionUtilTests.cs @@ -0,0 +1,220 @@ +// Copyright (c) Microsoft. All rights reserved. +// +// Licensed under the MIT license. + +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NuGetCredentialProvider.Util; + +namespace CredentialProvider.Microsoft.Tests.Util +{ + [TestClass] + public class RedactionUtilTests + { + [TestInitialize] + public void Setup() + { + // Reset redaction flag before each test + RedactionUtil.ShouldRedact = false; + } + + [TestMethod] + public void RedactFeedUrl_WhenRedactionDisabled_ReturnsOriginalUrl() + { + // Arrange + RedactionUtil.ShouldRedact = false; + var url = "https://pkgs.dev.azure.com/myorg/_packaging/myfeed/nuget/v3/index.json"; + + // Act + var result = RedactionUtil.RedactFeedUrl(url); + + // Assert + Assert.AreEqual(url, result); + } + + [TestMethod] + public void RedactFeedUrl_WhenRedactionEnabled_RedactsAzureDevOpsUrl() + { + // Arrange + RedactionUtil.ShouldRedact = true; + var url = "https://pkgs.dev.azure.com/myorg/_packaging/myfeed/nuget/v3/index.json"; + + // Act + var result = RedactionUtil.RedactFeedUrl(url); + + // Assert + Assert.IsTrue(result.Contains("[REDACTED_FEED_URL]")); + Assert.IsFalse(result.Contains("myorg")); + Assert.IsFalse(result.Contains("myfeed")); + } + + [TestMethod] + public void RedactFeedUrl_WhenRedactionEnabled_RedactsVisualStudioUrl() + { + // Arrange + RedactionUtil.ShouldRedact = true; + var url = "https://myorg.pkgs.visualstudio.com/_packaging/myfeed/nuget/v3/index.json"; + + // Act + var result = RedactionUtil.RedactFeedUrl(url); + + // Assert + Assert.IsTrue(result.Contains("[REDACTED_FEED_URL]")); + Assert.IsFalse(result.Contains("myorg")); + Assert.IsFalse(result.Contains("myfeed")); + } + + [TestMethod] + public void RedactFeedUrl_WhenRedactionEnabled_RedactsDevAzureComUrl() + { + // Arrange + RedactionUtil.ShouldRedact = true; + var url = "https://dev.azure.com/myorg/_packaging/myfeed/nuget/v3/index.json"; + + // Act + var result = RedactionUtil.RedactFeedUrl(url); + + // Assert + Assert.IsTrue(result.Contains("[REDACTED_FEED_URL]")); + Assert.IsFalse(result.Contains("myorg")); + Assert.IsFalse(result.Contains("myfeed")); + } + + [TestMethod] + public void RedactFeedUrl_WhenRedactionEnabled_RedactsOnPremUrl() + { + // Arrange + RedactionUtil.ShouldRedact = true; + var url = "https://myserver.company.com/tfs/DefaultCollection/_packaging/myfeed/nuget/v3/index.json"; + + // Act + var result = RedactionUtil.RedactFeedUrl(url); + + // Assert + Assert.IsTrue(result.Contains("[REDACTED_FEED_URL]")); + Assert.IsFalse(result.Contains("myserver")); + Assert.IsFalse(result.Contains("DefaultCollection")); + Assert.IsFalse(result.Contains("myfeed")); + } + + [TestMethod] + public void RedactFeedUrl_WithUri_WhenRedactionEnabled_RedactsUrl() + { + // Arrange + RedactionUtil.ShouldRedact = true; + var uri = new Uri("https://pkgs.dev.azure.com/myorg/_packaging/myfeed/nuget/v3/index.json"); + + // Act + var result = RedactionUtil.RedactFeedUrl(uri); + + // Assert + Assert.IsTrue(result.Contains("[REDACTED_FEED_URL]")); + Assert.IsFalse(result.Contains("myorg")); + } + + [TestMethod] + public void RedactFeedUrl_WithNullUri_ReturnsNull() + { + // Arrange + RedactionUtil.ShouldRedact = true; + Uri uri = null; + + // Act + var result = RedactionUtil.RedactFeedUrl(uri); + + // Assert + Assert.IsNull(result); + } + + [TestMethod] + public void ShouldRedact_CanBeSetAndRetrieved() + { + // Arrange & Act + RedactionUtil.ShouldRedact = true; + + // Assert + Assert.IsTrue(RedactionUtil.ShouldRedact); + + // Arrange & Act + RedactionUtil.ShouldRedact = false; + + // Assert + Assert.IsFalse(RedactionUtil.ShouldRedact); + } + + [TestMethod] + public void RedactFeedUrl_WhenRedactionEnabled_RedactsAllUrls() + { + // Arrange + RedactionUtil.ShouldRedact = true; + + // Act & Assert - Various feed URLs should all be redacted + var nugetOrg = RedactionUtil.RedactFeedUrl("https://api.nuget.org/v3/index.json"); + Assert.AreEqual("https://[REDACTED_FEED_URL]", nugetOrg); + + var github = RedactionUtil.RedactFeedUrl("https://nuget.pkg.github.com/myorg/index.json"); + Assert.AreEqual("https://[REDACTED_FEED_URL]", github); + + var myget = RedactionUtil.RedactFeedUrl("https://www.myget.org/F/myfeed/api/v3/index.json"); + Assert.AreEqual("https://[REDACTED_FEED_URL]", myget); + + var privateServer = RedactionUtil.RedactFeedUrl("https://packages.internal.company.com/nuget/v3/index.json"); + Assert.AreEqual("https://[REDACTED_FEED_URL]", privateServer); + } + + [TestMethod] + public void RedactPassword_WhenRedactionDisabled_ReturnsOriginalPassword() + { + // Arrange + RedactionUtil.ShouldRedact = false; + var password = "abc123secretToken"; + + // Act + var result = RedactionUtil.RedactPassword(password); + + // Assert + Assert.AreEqual(password, result); + } + + [TestMethod] + public void RedactPassword_WhenRedactionEnabled_RedactsPassword() + { + // Arrange + RedactionUtil.ShouldRedact = true; + var password = "abc123secretToken"; + + // Act + var result = RedactionUtil.RedactPassword(password); + + // Assert + Assert.AreEqual("[REDACTED]", result); + Assert.AreNotEqual(password, result); + } + + [TestMethod] + public void RedactPassword_WithNull_ReturnsNull() + { + // Arrange + RedactionUtil.ShouldRedact = true; + + // Act + var result = RedactionUtil.RedactPassword(null); + + // Assert + Assert.IsNull(result); + } + + [TestMethod] + public void RedactPassword_WithEmptyString_ReturnsEmptyString() + { + // Arrange + RedactionUtil.ShouldRedact = true; + + // Act + var result = RedactionUtil.RedactPassword(""); + + // Assert + Assert.AreEqual("", result); + } + } +} diff --git a/CredentialProvider.Microsoft/CredentialProvider.Microsoft.csproj b/CredentialProvider.Microsoft/CredentialProvider.Microsoft.csproj index 34195cd5..aa2be8bd 100644 --- a/CredentialProvider.Microsoft/CredentialProvider.Microsoft.csproj +++ b/CredentialProvider.Microsoft/CredentialProvider.Microsoft.csproj @@ -38,6 +38,12 @@ $(DefineConstants);TRACE + + + <_Parameter1>CredentialProvider.Microsoft.Tests + + + diff --git a/CredentialProvider.Microsoft/CredentialProviderArgs.cs b/CredentialProvider.Microsoft/CredentialProviderArgs.cs index efbdf57c..02404d06 100644 --- a/CredentialProvider.Microsoft/CredentialProviderArgs.cs +++ b/CredentialProvider.Microsoft/CredentialProviderArgs.cs @@ -31,7 +31,7 @@ internal class CredentialProviderArgs [ArgDescription("Display this amount of detail in the output")] public LogLevel Verbosity { get; set; } - [ArgDescription("Prevents writing the password to standard output (for troubleshooting purposes)")] + [ArgDescription("Redacts sensitive information from output: passwords/tokens and feed URLs. This is a best-effort feature. Always review logs before sharing.")] public bool RedactPassword { get; set; } [ArgShortcut("?")] diff --git a/CredentialProvider.Microsoft/CredentialProviders/Vsts/IAuthUtil.cs b/CredentialProvider.Microsoft/CredentialProviders/Vsts/IAuthUtil.cs index 01e4986f..4797f6b1 100644 --- a/CredentialProvider.Microsoft/CredentialProviders/Vsts/IAuthUtil.cs +++ b/CredentialProvider.Microsoft/CredentialProviders/Vsts/IAuthUtil.cs @@ -102,7 +102,7 @@ public async Task GetAuthorizationEndpoint(Uri uri, CancellationToken cance logger.Warning(e.StackTrace); } - logger.Warning(string.Format(Resources.SPSAuthEndpointNotFound, uri.ToString())); + logger.Warning(string.Format(Resources.SPSAuthEndpointNotFound, RedactionUtil.RedactFeedUrl(uri.ToString()))); return null; } @@ -117,7 +117,7 @@ protected virtual async Task GetResponseHeadersAsync(Uri ur using (var request = new HttpRequestMessage(HttpMethod.Get, uri)) { - logger.Verbose($"GET {uri}"); + logger.Verbose($"GET {RedactionUtil.RedactFeedUrl(uri)}"); using (var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)) { cache[uri] = response.Headers; diff --git a/CredentialProvider.Microsoft/CredentialProviders/Vsts/VstsCredentialProvider.cs b/CredentialProvider.Microsoft/CredentialProviders/Vsts/VstsCredentialProvider.cs index 6db5178e..3ef1102e 100644 --- a/CredentialProvider.Microsoft/CredentialProviders/Vsts/VstsCredentialProvider.cs +++ b/CredentialProvider.Microsoft/CredentialProviders/Vsts/VstsCredentialProvider.cs @@ -116,7 +116,7 @@ public override async Task HandleRequestAs InteractiveTimeout = TimeSpan.FromSeconds(EnvUtil.GetDeviceFlowTimeoutFromEnvironmentInSeconds(Logger)), DeviceCodeResultCallback = (DeviceCodeResult deviceCodeResult) => { - Logger.Minimal(string.Format(Resources.DeviceFlowRequestedResource, request.Uri.ToString())); + Logger.Minimal(string.Format(Resources.DeviceFlowRequestedResource, RedactionUtil.RedactFeedUrl(request.Uri.ToString()))); Logger.Minimal(string.Format(Resources.DeviceFlowMessage, deviceCodeResult.VerificationUrl, deviceCodeResult.UserCode)); return Task.CompletedTask; @@ -172,7 +172,7 @@ public override async Task HandleRequestAs if (!string.IsNullOrWhiteSpace(sessionToken)) { - Verbose(string.Format(Resources.VSTSSessionTokenCreated, request.Uri.AbsoluteUri)); + Verbose(string.Format(Resources.VSTSSessionTokenCreated, RedactionUtil.RedactFeedUrl(request.Uri))); return new GetAuthenticationCredentialsResponse( Username, sessionToken, @@ -183,11 +183,11 @@ public override async Task HandleRequestAs } catch (Exception e) { - Verbose(string.Format(Resources.VSTSCreateSessionException, request.Uri.AbsoluteUri, e.Message, e.StackTrace)); + Verbose(string.Format(Resources.VSTSCreateSessionException, RedactionUtil.RedactFeedUrl(request.Uri), e.Message, e.StackTrace)); } } - Verbose(string.Format(Resources.VSTSCredentialsNotFound, request.Uri.AbsoluteUri)); + Verbose(string.Format(Resources.VSTSCredentialsNotFound, RedactionUtil.RedactFeedUrl(request.Uri))); return null; } } diff --git a/CredentialProvider.Microsoft/CredentialProviders/VstsBuildTask/VstsBuildTaskCredentialProvider.cs b/CredentialProvider.Microsoft/CredentialProviders/VstsBuildTask/VstsBuildTaskCredentialProvider.cs index cf1b5a62..0e54a990 100644 --- a/CredentialProvider.Microsoft/CredentialProviders/VstsBuildTask/VstsBuildTaskCredentialProvider.cs +++ b/CredentialProvider.Microsoft/CredentialProviders/VstsBuildTask/VstsBuildTaskCredentialProvider.cs @@ -60,7 +60,7 @@ public override Task HandleRequestAsync(Ge string uriString = request.Uri.AbsoluteUri; string matchedPrefix = uriPrefixes.FirstOrDefault(prefix => uriString.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); - Verbose(string.Format(Resources.BuildTaskMatchedPrefix, matchedPrefix != null ? matchedPrefix : Resources.BuildTaskNoMatchingPrefixes)); + Verbose(string.Format(Resources.BuildTaskMatchedPrefix, matchedPrefix != null ? RedactionUtil.RedactFeedUrl(matchedPrefix) : Resources.BuildTaskNoMatchingPrefixes)); if (matchedPrefix == null) { @@ -72,7 +72,7 @@ public override Task HandleRequestAsync(Ge MessageResponseCode.Error); } - Verbose(string.Format(Resources.BuildTaskEndpointMatchingUrlFound, uriString)); + Verbose(string.Format(Resources.BuildTaskEndpointMatchingUrlFound, RedactionUtil.RedactFeedUrl(uriString))); return this.GetResponse( Username, accessToken, diff --git a/CredentialProvider.Microsoft/CredentialProviders/VstsBuildTaskServiceEndpoint/VstsBuildTaskServiceEndpointCredentialProvider.cs b/CredentialProvider.Microsoft/CredentialProviders/VstsBuildTaskServiceEndpoint/VstsBuildTaskServiceEndpointCredentialProvider.cs index a770252f..d8fce1ae 100644 --- a/CredentialProvider.Microsoft/CredentialProviders/VstsBuildTaskServiceEndpoint/VstsBuildTaskServiceEndpointCredentialProvider.cs +++ b/CredentialProvider.Microsoft/CredentialProviders/VstsBuildTaskServiceEndpoint/VstsBuildTaskServiceEndpointCredentialProvider.cs @@ -69,7 +69,7 @@ public override async Task HandleRequestAs bool externalEndpointFound = ExternalCredentials.TryGetValue(uriString, out ExternalEndpointCredentials matchingExternalEndpoint); if (externalEndpointFound && !string.IsNullOrWhiteSpace(matchingExternalEndpoint.Password)) { - Verbose(string.Format(Resources.BuildTaskEndpointMatchingUrlFound, uriString)); + Verbose(string.Format(Resources.BuildTaskEndpointMatchingUrlFound, RedactionUtil.RedactFeedUrl(uriString))); return GetResponse( matchingExternalEndpoint.Username, matchingExternalEndpoint.Password, @@ -142,11 +142,11 @@ public override async Task HandleRequestAs } } - Verbose(string.Format(Resources.BuildTaskEndpointNoMatchingUrl, uriString)); + Verbose(string.Format(Resources.BuildTaskEndpointNoMatchingUrl, RedactionUtil.RedactFeedUrl(uriString))); return GetResponse( null, null, - string.Format(Resources.BuildTaskFailedToAuthenticate, uriString), + string.Format(Resources.BuildTaskFailedToAuthenticate, RedactionUtil.RedactFeedUrl(uriString)), MessageResponseCode.Error); } diff --git a/CredentialProvider.Microsoft/Program.cs b/CredentialProvider.Microsoft/Program.cs index 67b7053d..ab2d5c9c 100644 --- a/CredentialProvider.Microsoft/Program.cs +++ b/CredentialProvider.Microsoft/Program.cs @@ -59,6 +59,9 @@ public static async Task BackgroundWork(string[] args) CancellationTokenSource tokenSource = new CancellationTokenSource(); var parsedArgs = await Args.ParseAsync(args); + // Set redaction flag: command-line arg takes precedence, then env var + RedactionUtil.ShouldRedact = parsedArgs.RedactPassword || EnvUtil.GetRedactionEnabledFromEnvironment(); + var multiLogger = new MultiLogger(); var fileLogger = GetFileLogger(); if (fileLogger != null) @@ -142,7 +145,8 @@ public static async Task BackgroundWork(string[] args) EnvUtil.MsalFileCacheEnvVar, EnvUtil.LegacyMsalFileCacheEnvVar, EnvUtil.MsalFileCacheLocationEnvVar, - EnvUtil.LegacyMsalFileCacheLocationEnvVar + EnvUtil.LegacyMsalFileCacheLocationEnvVar, + EnvUtil.RedactEnabledEnvVar )); return 0; } @@ -208,7 +212,7 @@ public static async Task BackgroundWork(string[] args) } string resultUsername = response?.Username; - string resultPassword = parsedArgs.RedactPassword ? Resources.Redacted : response?.Password; + string resultPassword = RedactionUtil.RedactPassword(response?.Password); if (parsedArgs.OutputFormat == OutputFormat.Json) { // Manually write the JSON output, since we don't use ConsoleLogger in JSON mode (see above) diff --git a/CredentialProvider.Microsoft/RequestHandlers/GetAuthenticationCredentialsRequestHandler.cs b/CredentialProvider.Microsoft/RequestHandlers/GetAuthenticationCredentialsRequestHandler.cs index f47888b0..8e0da814 100644 --- a/CredentialProvider.Microsoft/RequestHandlers/GetAuthenticationCredentialsRequestHandler.cs +++ b/CredentialProvider.Microsoft/RequestHandlers/GetAuthenticationCredentialsRequestHandler.cs @@ -43,7 +43,7 @@ public GetAuthenticationCredentialsRequestHandler(ILogger logger, IReadOnlyColle public override async Task HandleRequestAsync(GetAuthenticationCredentialsRequest request) { - Logger.Verbose(string.Format(Resources.HandlingAuthRequest, request.Uri.AbsoluteUri, request.IsRetry, request.IsNonInteractive, request.CanShowDialog)); + Logger.Verbose(string.Format(Resources.HandlingAuthRequest, RedactionUtil.RedactFeedUrl(request.Uri), request.IsRetry, request.IsNonInteractive, request.CanShowDialog)); if (request?.Uri == null) { @@ -56,16 +56,16 @@ public override async Task HandleRequestAs responseCode: MessageResponseCode.Error); } - Logger.Verbose(string.Format(Resources.Uri, request.Uri.AbsoluteUri)); + Logger.Verbose(string.Format(Resources.Uri, RedactionUtil.RedactFeedUrl(request.Uri))); foreach (ICredentialProvider credentialProvider in credentialProviders) { if (await credentialProvider.CanProvideCredentialsAsync(request.Uri) == false) { - Logger.Verbose(string.Format(Resources.SkippingCredentialProvider, credentialProvider, request.Uri.AbsoluteUri)); + Logger.Verbose(string.Format(Resources.SkippingCredentialProvider, credentialProvider, RedactionUtil.RedactFeedUrl(request.Uri))); continue; } - Logger.Verbose(string.Format(Resources.UsingCredentialProvider, credentialProvider, request.Uri.AbsoluteUri)); + Logger.Verbose(string.Format(Resources.UsingCredentialProvider, credentialProvider, RedactionUtil.RedactFeedUrl(request.Uri))); if (credentialProvider.IsCachable && TryCache(request, out string cachedToken)) { @@ -84,7 +84,7 @@ public override async Task HandleRequestAs { if (cache != null && credentialProvider.IsCachable) { - Logger.Verbose(string.Format(Resources.CachingSessionToken, request.Uri.AbsoluteUri)); + Logger.Verbose(string.Format(Resources.CachingSessionToken, RedactionUtil.RedactFeedUrl(request.Uri))); cache[request.Uri] = response.Password; } @@ -149,18 +149,18 @@ private bool TryCache(GetAuthenticationCredentialsRequest request, out string ca Logger.Verbose(string.Format(Resources.IsRetry, request.IsRetry)); if (request.IsRetry) { - Logger.Verbose(string.Format(Resources.InvalidatingCachedSessionToken, request.Uri.AbsoluteUri)); + Logger.Verbose(string.Format(Resources.InvalidatingCachedSessionToken, RedactionUtil.RedactFeedUrl(request.Uri))); cache?.Remove(request.Uri); return false; } else if (cache.TryGetValue(request.Uri, out string password)) { - Logger.Verbose(string.Format(Resources.FoundCachedSessionToken, request.Uri.AbsoluteUri)); + Logger.Verbose(string.Format(Resources.FoundCachedSessionToken, RedactionUtil.RedactFeedUrl(request.Uri))); cachedToken = password; return true; } - Logger.Verbose(string.Format(Resources.CachedSessionTokenNotFound, request.Uri.AbsoluteUri)); + Logger.Verbose(string.Format(Resources.CachedSessionTokenNotFound, RedactionUtil.RedactFeedUrl(request.Uri))); return false; } } diff --git a/CredentialProvider.Microsoft/Resources.resx b/CredentialProvider.Microsoft/Resources.resx index f43285aa..e4675553 100644 --- a/CredentialProvider.Microsoft/Resources.resx +++ b/CredentialProvider.Microsoft/Resources.resx @@ -214,6 +214,9 @@ [REDACTED] + + [REDACTED_FEED_URL] + Request uri cannot be null @@ -376,7 +379,13 @@ MSAL Token File Cache Enabled Provide MSAL Cache Location {29} (Preferred) {30} (Legacy) - Provide the location where the MSAL cache should be read and written to. + Provide the location where the MSAL cache should be read and written to. + +Redact Enabled + {31} + Boolean to enable redaction of passwords/tokens and feed URLs in output. + This is a best-effort feature. Always review logs before sharing. + Default is false. Can also be controlled via the -R command-line flag. Failed to parse credentials diff --git a/CredentialProvider.Microsoft/Util/EnvUtil.cs b/CredentialProvider.Microsoft/Util/EnvUtil.cs index 47ab1605..ae634c56 100644 --- a/CredentialProvider.Microsoft/Util/EnvUtil.cs +++ b/CredentialProvider.Microsoft/Util/EnvUtil.cs @@ -56,6 +56,7 @@ public static class EnvUtil public const string ProgramContext = "ARTIFACTS_CREDENTIALPROVIDER_PROGRAM_CONTEXT"; public const string MsalBrokerWindowEnvVar = "ARTIFACTS_CREDENTIALPROVIDER_MSAL_BROKER_WINDOW"; public const string EntraTokenOptInEnvVar = "ARTIFACTS_CREDENTIALPROVIDER_RETURN_ENTRA_TOKENS"; + public const string RedactEnabledEnvVar = "ARTIFACTS_CREDENTIALPROVIDER_REDACT_ENABLED"; // Map of new environment variables to their legacy equivalents private static readonly Dictionary EnvVarLegacyMap = new Dictionary @@ -259,6 +260,16 @@ public static void SetProgramContextInEnvironment(Context context) Environment.SetEnvironmentVariable(ProgramContext, context.ToString()); } + public static bool GetRedactionEnabledFromEnvironment() + { + var val = GetEnvironmentVariable(RedactEnabledEnvVar); + if (bool.TryParse(val, out bool result)) + { + return result; + } + return false; + } + private static bool GetEnabledFromEnvironment(string artifactsVar, bool defaultValue = true) { var val = GetEnvironmentVariable(artifactsVar); diff --git a/CredentialProvider.Microsoft/Util/RedactionUtil.cs b/CredentialProvider.Microsoft/Util/RedactionUtil.cs new file mode 100644 index 00000000..3261a821 --- /dev/null +++ b/CredentialProvider.Microsoft/Util/RedactionUtil.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft. All rights reserved. +// +// Licensed under the MIT license. + +using System; + +namespace NuGetCredentialProvider.Util +{ + /// + /// Utility class for redacting sensitive information in logs. + /// + public static class RedactionUtil + { + private static bool? shouldRedact; + + public static bool ShouldRedact + { + get => shouldRedact ?? (shouldRedact = EnvUtil.GetRedactionEnabledFromEnvironment()).Value; + set => shouldRedact = value; + } + + /// + /// Redacts a feed URL if redaction is enabled + /// + public static string RedactFeedUrl(Uri uri) => + uri == null ? null : RedactFeedUrl(uri.AbsoluteUri); + + /// + /// Redacts a feed URL if redaction is enabled + /// + public static string RedactFeedUrl(string uriString) + { + if (!ShouldRedact || string.IsNullOrEmpty(uriString)) + { + return uriString; + } + + try + { + var uri = new Uri(uriString); + return $"{uri.Scheme}://[REDACTED_FEED_URL]"; + } + catch + { + return Resources.RedactedFeedUrl; + } + } + + /// + /// Redacts a password/token if redaction is enabled + /// + public static string RedactPassword(string password) => + !ShouldRedact || string.IsNullOrEmpty(password) ? password : Resources.Redacted; + } +} diff --git a/README.md b/README.md index 949132d1..802bfd94 100644 --- a/README.md +++ b/README.md @@ -321,7 +321,8 @@ Verbosity (-V) Display this amount of detail in the output [Default='Info Minimal Warning Error -RedactPassword (-R) Prevents writing the password to standard output (for troubleshooting purposes) +RedactPassword (-R) Redacts sensitive information from output: passwords/tokens and feed URLs. This is a best-effort + feature. Always review logs before sharing. Help (-?, -h) Prints this help message CanShowDialog (-C) If true, user can be prompted with credentials through UI, if false, device flow must be used [Default='True'] OutputFormat (-F) In standalone mode, format the results for human readability or as JSON. If JSON is selected, then logging (which may include Device @@ -449,11 +450,15 @@ You can also capture credential provider logs directly to a file by setting the ```shell # Windows (PowerShell) $env:ARTIFACTS_CREDENTIALPROVIDER_LOG_PATH = "$PWD\credprovider.log" +$env:ARTIFACTS_CREDENTIALPROVIDER_REDACT_ENABLED = "true" # Optional: redact sensitive info # Linux/macOS export ARTIFACTS_CREDENTIALPROVIDER_LOG_PATH="$PWD/credprovider.log" +export ARTIFACTS_CREDENTIALPROVIDER_REDACT_ENABLED="true" # Optional: redact sensitive info ``` +> **Tip:** Set `ARTIFACTS_CREDENTIALPROVIDER_REDACT_ENABLED=true` to automatically redact passwords, tokens, and feed URLs from logs. This is a best-effort feature—always review logs before sharing to ensure no sensitive data is exposed. + #### How do I find out if my issue is a real 401? Run the credential provider directly with the following command: `C:\Users\\.nuget\plugins\netfx\CredentialProvider.Microsoft\CredentialProvider.Microsoft.exe -I -V Verbose -U "https://pkgs.dev.azure.com/{organization}/{project-if-feed-is-project-scoped}/_packaging/{feed}/nuget/v3/index.json"`. Check you have the right permissions from the [feed permissions](https://docs.microsoft.com/en-us/azure/devops/artifacts/feeds/feed-permissions?view=azure-devops).