File: Utils\PackageSourceRedactor.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
namespace Aspire.Cli.Utils;
 
/// <summary>
/// Helpers for rendering NuGet package sources in user-visible output without leaking credentials.
/// </summary>
internal static class PackageSourceRedactor
{
    private const string UnparseableHttpSentinel = "<unparseable http source>";
 
    /// <summary>
    /// Returns a display-safe form of a NuGet source for inclusion in user-visible output (error
    /// footers, debug logs, bug reports). For http/https feeds we strip the UserInfo, query, and
    /// fragment because users commonly pass <c>https://user:pat@host/...</c> or SAS-token URLs
    /// (<c>?sv=...&amp;sig=...</c>). Local paths and other source forms (file://, bare paths on
    /// Windows/Unix) pass through unchanged — they don't carry credentials.
    /// </summary>
    /// <remarks>
    /// Fails closed for HTTP-shaped inputs that <see cref="Uri.TryCreate(string, UriKind, out Uri)"/>
    /// cannot parse (for example <c>https://user:p@ss@host/path</c> or
    /// <c>https://user:p#word@host/</c>): returns a sentinel rather than the raw input. Leading
    /// and trailing whitespace is ignored for HTTP detection so indented feed URLs are still
    /// protected. Plain non-HTTP-looking inputs (local paths, file://, etc.) still pass through
    /// unchanged.
    /// </remarks>
    public static string RedactForDisplay(string source)
    {
        if (string.IsNullOrEmpty(source))
        {
            return source;
        }
 
        var sourceToParse = source.Trim();
        if (sourceToParse.Length == 0)
        {
            return source;
        }
 
        // Detect HTTP-shaped inputs before attempting to parse so malformed URLs that look like
        // an HTTP feed fail closed instead of leaking credentials through the parse-failure
        // branch below. Trim first because NuGet sources in config/output can be indented.
        var looksHttp =
            sourceToParse.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
            sourceToParse.StartsWith("https://", StringComparison.OrdinalIgnoreCase);
 
        if (!Uri.TryCreate(sourceToParse, UriKind.Absolute, out var uri) ||
            (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
        {
            return looksHttp ? UnparseableHttpSentinel : source;
        }
 
        var hasUserInfo = !string.IsNullOrEmpty(uri.UserInfo);
        var hasQuery = !string.IsNullOrEmpty(uri.Query);
        var hasFragment = !string.IsNullOrEmpty(uri.Fragment);
        if (!hasUserInfo && !hasQuery && !hasFragment)
        {
            return sourceToParse;
        }
 
        var builder = new UriBuilder(uri)
        {
            UserName = hasUserInfo ? "***" : string.Empty,
            Password = string.Empty,
            Query = string.Empty,
            Fragment = string.Empty
        };
 
        return builder.Uri.ToString();
    }
 
    /// <summary>
    /// Replaces credential-bearing package source occurrences in captured process output with
    /// display-safe forms.
    /// </summary>
    public static string RedactOccurrences(string value, IReadOnlyList<string> sensitiveSources)
    {
        if (string.IsNullOrEmpty(value) || sensitiveSources.Count == 0)
        {
            return value;
        }
 
        var replacements = sensitiveSources
            .SelectMany(GetDiagnosticReplacements)
            .DistinctBy(static replacement => replacement.Spelling, StringComparer.Ordinal)
            .OrderByDescending(static replacement => replacement.Spelling.Length);
 
        foreach (var (spelling, replacement) in replacements)
        {
            value = value.Replace(spelling, replacement, StringComparison.Ordinal);
        }
 
        return value;
    }
 
    private static IEnumerable<(string Spelling, string Replacement)> GetDiagnosticReplacements(string source)
    {
        var displaySource = RedactForDisplay(source);
        foreach (var spelling in GetDiagnosticSpellings(source))
        {
            yield return (spelling, displaySource);
 
            foreach (var replacement in GetCredentialComponentReplacements(spelling))
            {
                yield return replacement;
            }
        }
    }
 
    private static IEnumerable<string> GetDiagnosticSpellings(string source)
    {
        if (source.Length > 0)
        {
            yield return source;
        }
 
        var trimmedSource = source.Trim();
        if (trimmedSource.Length == 0)
        {
            yield break;
        }
 
        yield return trimmedSource;
 
        // NuGet diagnostics can render the parsed URI rather than the original configuration text.
        // For example, `HTTPS://user:secret@HOST/Feed?sig=secret` can be emitted as
        // `https://user:secret@host/Feed?sig=secret`.
        if (Uri.TryCreate(trimmedSource, UriKind.Absolute, out var uri) &&
            (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
        {
            yield return uri.AbsoluteUri;
        }
    }
 
    private static IEnumerable<(string Spelling, string Replacement)> GetCredentialComponentReplacements(
        string sourceSpelling)
    {
        var trimmedSource = sourceSpelling.Trim();
        if (!Uri.TryCreate(trimmedSource, UriKind.Absolute, out var uri) ||
            (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
        {
            yield break;
        }
 
        // Extract delimiter-qualified components from a validated source such as:
        //   https://user:password@host/v3/index.json?sig=secret&se=expiry#fragment
        // Matching the delimiters avoids replacing bare credential values in unrelated text while
        // still protecting the same material when NuGet reports another protocol resource URL.
        var schemeDelimiter = trimmedSource.IndexOf("://", StringComparison.Ordinal);
        var authorityStart = schemeDelimiter >= 0 ? schemeDelimiter + 3 : 0;
        var authorityEnd = trimmedSource.IndexOfAny(['/', '?', '#'], authorityStart);
        if (authorityEnd < 0)
        {
            authorityEnd = trimmedSource.Length;
        }
 
        if (schemeDelimiter >= 0 && !string.IsNullOrEmpty(uri.UserInfo))
        {
            var authority = trimmedSource[authorityStart..authorityEnd];
            var userInfoEnd = authority.LastIndexOf('@');
            if (userInfoEnd > 0)
            {
                yield return ($"://{authority[..userInfoEnd]}@", "://***@");
            }
        }
 
        var queryStart = trimmedSource.IndexOf('?', authorityEnd);
        var fragmentStart = trimmedSource.IndexOf('#', authorityEnd);
        if (queryStart >= 0 && (fragmentStart < 0 || queryStart < fragmentStart))
        {
            var queryEnd = fragmentStart >= 0 ? fragmentStart : trimmedSource.Length;
            foreach (var parameter in trimmedSource[(queryStart + 1)..queryEnd]
                .Split('&', StringSplitOptions.RemoveEmptyEntries))
            {
                yield return ($"?{parameter}", "?***");
                yield return ($"&{parameter}", "&***");
            }
        }
 
        if (fragmentStart >= 0 && fragmentStart + 1 < trimmedSource.Length)
        {
            yield return (trimmedSource[fragmentStart..], "#***");
        }
    }
}