File: Utils\AppHostArgumentRedactor.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>
/// Redacts the arguments a user forwards to an AppHost so they never reach CLI logs or
/// profiling traces.
/// </summary>
/// <remarks>
/// Everything after the first <c>--</c> separator is application input the CLI never
/// interprets, so it routinely carries connection strings, API keys, and other secrets:
/// <code>
/// aspire run -- --ConnectionStrings:db "Server=...;Password=hunter2"
/// dotnet run --project AppHost.csproj -- --ApiKey sk-live-...
/// </code>
/// The VS Code extension already redacts the same values before logging a debug
/// configuration (see <c>extension/src/debugger/AspireDebugSession.ts</c>); this keeps the
/// CLI consistent with it. One placeholder is emitted per token so log readers can still
/// diagnose argument-count and boundary problems.
/// <para>
/// This type only handles command lines the CLI itself projected or built, where a literal
/// <c>--</c> always precedes AppHost input. Raw user arguments carry no such guarantee — the
/// <c>run</c> and <c>start</c> commands also forward unmatched tokens — so those must be
/// redacted with <c>ParseResultHelper.GetLoggableArguments</c> instead.
/// </para>
/// </remarks>
internal static class AppHostArgumentRedactor
{
    /// <summary>
    /// Placeholder substituted for each argument that follows the separator.
    /// </summary>
    internal const string RedactedToken = "<redacted>";
 
    private const string ArgumentSeparator = "--";
 
    /// <summary>
    /// Returns a copy of <paramref name="args"/> that is safe to log: tokens up to and
    /// including the first <c>--</c> separator are preserved verbatim and every later token is
    /// replaced with <see cref="RedactedToken"/>. When there is no separator the arguments are
    /// entirely CLI-owned and are returned unchanged.
    /// </summary>
    internal static IReadOnlyList<string> Redact(IReadOnlyList<string> args)
    {
        var separatorIndex = -1;
        for (var i = 0; i < args.Count; i++)
        {
            if (string.Equals(args[i], ArgumentSeparator, StringComparison.Ordinal))
            {
                separatorIndex = i;
                break;
            }
        }
 
        if (separatorIndex < 0)
        {
            return args;
        }
 
        // Every token past the separator is redacted, including a literal "--" that appears after
        // the first one: only the first separator is a boundary, later ones are AppHost input.
        return RedactFrom(args, separatorIndex + 1);
    }
 
    /// <summary>
    /// Returns a copy of <paramref name="args"/> in which every token from
    /// <paramref name="firstRedactedIndex"/> onward is replaced with <see cref="RedactedToken"/>.
    /// </summary>
    /// <remarks>
    /// Some invocations carry AppHost input without a separator to key off. Launching a built
    /// AppHost directly is one of them, because the executable is invoked without the
    /// <c>dotnet run</c> boundary:
    /// <code>
    /// bin/Debug/net10.0/MyApp.AppHost --ApiKey sk-live-...
    /// </code>
    /// Those callers know where CLI-owned arguments stop and pass that index explicitly.
    /// </remarks>
    internal static IReadOnlyList<string> RedactFrom(IReadOnlyList<string> args, int firstRedactedIndex)
    {
        if (firstRedactedIndex >= args.Count)
        {
            return args;
        }
 
        var redacted = new List<string>(args.Count);
        for (var i = 0; i < args.Count; i++)
        {
            redacted.Add(i < firstRedactedIndex ? args[i] : RedactedToken);
        }
 
        return redacted;
    }
 
    /// <summary>
    /// Returns the space-joined, redacted form of <paramref name="args"/> for message templates
    /// that log a whole command line.
    /// </summary>
    internal static string RedactToString(IReadOnlyList<string> args) => string.Join(' ', Redact(args));
 
    /// <summary>
    /// Returns the space-joined form of <paramref name="args"/> with every token from
    /// <paramref name="firstRedactedIndex"/> onward replaced by <see cref="RedactedToken"/>.
    /// </summary>
    internal static string RedactFromToString(IReadOnlyList<string> args, int firstRedactedIndex)
        => string.Join(' ', RedactFrom(args, firstRedactedIndex));
}