diff --git a/src/frontend/src/data/pkgs/Aspire.Hosting.PostgreSQL.13.4.6.json b/src/frontend/src/data/pkgs/Aspire.Hosting.PostgreSQL.13.4.6.json index 96a15d9a6..b6f0d5147 100644 --- a/src/frontend/src/data/pkgs/Aspire.Hosting.PostgreSQL.13.4.6.json +++ b/src/frontend/src/data/pkgs/Aspire.Hosting.PostgreSQL.13.4.6.json @@ -1861,7 +1861,7 @@ "returns": [ { "kind": "text", - "text": "A connection string for the PostgreSQL server in the form \"Host=host;Port=port;Username=postgres;Password=password\"." + "text": "A connection string for the PostgreSQL server in the form \"Host=host;Port=port;Username=postgres;Password=Placeholder\"." } ], "parameters": { diff --git a/src/frontend/src/data/pkgs/Aspire.Hosting.SqlServer.13.4.6.json b/src/frontend/src/data/pkgs/Aspire.Hosting.SqlServer.13.4.6.json index a329ee105..1dc3911ab 100644 --- a/src/frontend/src/data/pkgs/Aspire.Hosting.SqlServer.13.4.6.json +++ b/src/frontend/src/data/pkgs/Aspire.Hosting.SqlServer.13.4.6.json @@ -1029,7 +1029,7 @@ "returns": [ { "kind": "text", - "text": "A connection string for the SQL Server in the form \"Server=host,port;User ID=sa;Password=password;TrustServerCertificate=true\"." + "text": "A connection string for the SQL Server in the form \"Server=host,port;User ID=sa;Password=Placeholder;TrustServerCertificate=true\"." } ], "parameters": { diff --git a/src/frontend/src/data/pkgs/CommunityToolkit.Aspire.Hosting.Minio.13.4.0.json b/src/frontend/src/data/pkgs/CommunityToolkit.Aspire.Hosting.Minio.13.4.0.json index 1ba93800b..587438bac 100644 --- a/src/frontend/src/data/pkgs/CommunityToolkit.Aspire.Hosting.Minio.13.4.0.json +++ b/src/frontend/src/data/pkgs/CommunityToolkit.Aspire.Hosting.Minio.13.4.0.json @@ -638,7 +638,7 @@ "returns": [ { "kind": "text", - "text": "A connection string for the MinIO server in the form \"Host=host;Port=port;Username=postgres;Password=password\"." + "text": "A connection string for the MinIO server in the form \"Host=host;Port=port;Username=postgres;Password=Placeholder\"." } ], "parameters": { diff --git a/src/frontend/src/data/pkgs/CommunityToolkit.Aspire.Hosting.SurrealDb.13.4.0.json b/src/frontend/src/data/pkgs/CommunityToolkit.Aspire.Hosting.SurrealDb.13.4.0.json index c1cc775a1..5e7a0741d 100644 --- a/src/frontend/src/data/pkgs/CommunityToolkit.Aspire.Hosting.SurrealDb.13.4.0.json +++ b/src/frontend/src/data/pkgs/CommunityToolkit.Aspire.Hosting.SurrealDb.13.4.0.json @@ -1450,7 +1450,7 @@ "returns": [ { "kind": "text", - "text": "A connection string for the SurrealDB instance in the form \"Server=scheme://host:port;User=username;Password=password\"." + "text": "A connection string for the SurrealDB instance in the form \"Server=scheme://host:port;User=username;Password=Placeholder\"." } ], "parameters": { diff --git a/src/tools/PackageJsonGenerator/Helpers/CanonicalModelBuilder.cs b/src/tools/PackageJsonGenerator/Helpers/CanonicalModelBuilder.cs index 7ebab60c1..adc69bfc6 100644 --- a/src/tools/PackageJsonGenerator/Helpers/CanonicalModelBuilder.cs +++ b/src/tools/PackageJsonGenerator/Helpers/CanonicalModelBuilder.cs @@ -81,7 +81,7 @@ private CanonicalType BuildType(INamedTypeSymbol symbol) { Name = f.Name, Value = Convert.ToInt64(f.ConstantValue), - Description = ExtractSummary(f), + Description = DocumentationSanitizer.RedactConnectionStringPasswords(ExtractSummary(f)), })]; } @@ -705,6 +705,7 @@ private static List CollectNodes(XElement element) // Normalize whitespace within text runs (newlines → spaces, collapse runs) text = text.Replace("\r\n", " ").Replace("\n", " ").Replace("\r", " "); while (text.Contains(" ")) text = text.Replace(" ", " "); + text = DocumentationSanitizer.RedactConnectionStringPasswords(text); if (!string.IsNullOrEmpty(text)) { nodes.Add(new DocNode { Kind = "text", Text = text }); @@ -737,7 +738,7 @@ private static List CollectNodes(XElement element) { Kind = "href", Value = href, - Text = child.Value.Trim() is { Length: > 0 } t ? t : null, + Text = child.Value.Trim() is { Length: > 0 } t ? DocumentationSanitizer.RedactConnectionStringPasswords(t) : null, }); } } @@ -766,7 +767,7 @@ private static List CollectNodes(XElement element) } case "c": - nodes.Add(new DocNode { Kind = "code", Text = child.Value }); + nodes.Add(new DocNode { Kind = "code", Text = DocumentationSanitizer.RedactConnectionStringPasswords(child.Value) }); break; case "code": @@ -779,7 +780,7 @@ private static List CollectNodes(XElement element) nodes.Add(new DocNode { Kind = "codeblock", - Text = child.Value, + Text = DocumentationSanitizer.RedactConnectionStringPasswords(child.Value), Language = lang, Value = region, // reuse value for region }); @@ -839,7 +840,7 @@ private static List CollectNodes(XElement element) { Kind = "href", Value = href, - Text = child.Value.Trim() is { Length: > 0 } t ? t : null, + Text = child.Value.Trim() is { Length: > 0 } t ? DocumentationSanitizer.RedactConnectionStringPasswords(t) : null, }); } break; @@ -898,7 +899,9 @@ private static DocListItem BuildDocListItem(XElement element) { // No code block — try to use the whole content as code var plainText = ExtractPlainText(exampleElement); - return plainText is not null ? new DocExample { Code = plainText } : null; + return plainText is not null + ? new DocExample { Code = DocumentationSanitizer.RedactConnectionStringPasswords(plainText)! } + : null; } var lang = NormalizeLanguage( @@ -920,7 +923,7 @@ private static DocListItem BuildDocListItem(XElement element) while (text.Contains(" ")) text = text.Replace(" ", " "); if (!string.IsNullOrWhiteSpace(text)) { - descNodes.Add(new DocNode { Kind = "text", Text = text.Trim() }); + descNodes.Add(new DocNode { Kind = "text", Text = DocumentationSanitizer.RedactConnectionStringPasswords(text.Trim()) }); } break; @@ -941,7 +944,7 @@ private static DocListItem BuildDocListItem(XElement element) return new DocExample { - Code = codeElement.Value, + Code = DocumentationSanitizer.RedactConnectionStringPasswords(codeElement.Value)!, Language = lang, Description = descNodes.Count > 0 ? descNodes : null, Region = region, diff --git a/src/tools/PackageJsonGenerator/Helpers/DocumentationSanitizer.cs b/src/tools/PackageJsonGenerator/Helpers/DocumentationSanitizer.cs new file mode 100644 index 000000000..dcda74d62 --- /dev/null +++ b/src/tools/PackageJsonGenerator/Helpers/DocumentationSanitizer.cs @@ -0,0 +1,70 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.RegularExpressions; + +namespace PackageJsonGenerator.Helpers; + +/// +/// Sanitizes documentation text copied verbatim from package XML docs before it +/// is emitted into the generated JSON data files. +/// +public static class DocumentationSanitizer +{ + private const string PasswordPlaceholder = "Placeholder"; + + // Matches connection-string style "key=value" credential pairs such as + // "User ID=sa;Password=password" or "Pwd=hunter2". The '=' is intentionally + // required with no surrounding whitespace so that C# default parameter + // values ("string? password = null") are never matched. The value (capture + // group 2) stops at connection-string / JSON delimiters (';', quotes, comma, + // backslash), at markdown / URI delimiters ('`', ')', ']', '&', '|') so a + // trailing token or inline-code fence is not swallowed, and at the '{'/'}' + // (and legacy '<'/'>') markers. A trailing '.' is trimmed off the value in + // the replacement callback (sentence terminators) rather than excluded from + // the class, which would truncate dotted values. Re-running over + // already-redacted text reproduces identical output, so replacement is + // idempotent. + private static readonly Regex ConnectionStringPasswordRegex = new( + "\\b(password|pwd)=([^;\"'{}<>\\s\\\\,`)\\]&|]+)", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); + + /// + /// Replaces literal password values inside connection-string style + /// "key=value" pairs with a Placeholder token. + /// + /// + /// Documentation is copied verbatim from package XML docs and often contains + /// example connection strings (for example the SqlServer + /// GetConnectionString returns text + /// "Server=host,port;User ID=sa;Password=password;TrustServerCertificate=true"). + /// Those literal credential tokens are not real secrets, but they trip + /// push-time secret scanners such as CredScan SqlLegacyCredentials + /// (SEC101/037) when the generated JSON is mirrored to a protected remote. + /// The Placeholder token contains no markup or brace characters, so the + /// example still renders correctly when doc nodes are emitted as Markdown; it + /// is the redaction value recommended by 1ES for scrubbed credential examples. + /// + /// The documentation text to sanitize. + /// The text with connection-string password values redacted. + public static string? RedactConnectionStringPasswords(string? text) + { + if (string.IsNullOrEmpty(text) || !text.Contains('=')) + { + return text; + } + + return ConnectionStringPasswordRegex.Replace( + text, + static match => + { + // Preserve a trailing sentence period that the value class + // intentionally consumes (dots are valid inside values, so they + // are trimmed here rather than excluded from the character class). + var value = match.Groups[2].Value; + var redacted = value.TrimEnd('.'); + var trailingDots = value[redacted.Length..]; + return $"{match.Groups[1].Value}={PasswordPlaceholder}{trailingDots}"; + }); + } +} diff --git a/tests/PackageJsonGenerator.Tests/DocumentationSanitizerTests.cs b/tests/PackageJsonGenerator.Tests/DocumentationSanitizerTests.cs new file mode 100644 index 000000000..b46744286 --- /dev/null +++ b/tests/PackageJsonGenerator.Tests/DocumentationSanitizerTests.cs @@ -0,0 +1,86 @@ +using PackageJsonGenerator.Helpers; + +namespace PackageJsonGenerator.Tests; + +public sealed class DocumentationSanitizerTests +{ + [Fact] + public void RedactConnectionStringPasswords_RedactsSqlServerConnectionString() + { + var input = "A connection string for the SQL Server in the form \"Server=host,port;User ID=sa;Password=password;TrustServerCertificate=true\"."; + + var result = DocumentationSanitizer.RedactConnectionStringPasswords(input); + + Assert.Equal( + "A connection string for the SQL Server in the form \"Server=host,port;User ID=sa;Password=Placeholder;TrustServerCertificate=true\".", + result); + } + + [Theory] + // Password at the end of the example (no trailing ';'). + [InlineData( + "Host=host;Port=port;Username=postgres;Password=password", + "Host=host;Port=port;Username=postgres;Password=Placeholder")] + // Keyword casing is preserved and the 'Pwd' alias is handled. + [InlineData("Server=s;Pwd=hunter2;Uid=admin", "Server=s;Pwd=Placeholder;Uid=admin")] + [InlineData("server=s;PASSWORD=S0meThing!;uid=a", "server=s;PASSWORD=Placeholder;uid=a")] + public void RedactConnectionStringPasswords_RedactsVariousFormats(string input, string expected) + { + Assert.Equal(expected, DocumentationSanitizer.RedactConnectionStringPasswords(input)); + } + + [Fact] + public void RedactConnectionStringPasswords_IsIdempotent() + { + var alreadyRedacted = "Server=host,port;User ID=sa;Password=Placeholder;TrustServerCertificate=true"; + + var result = DocumentationSanitizer.RedactConnectionStringPasswords(alreadyRedacted); + + Assert.Equal(alreadyRedacted, result); + } + + [Fact] + public void RedactConnectionStringPasswords_UsesMarkdownSafePlaceholder() + { + // The placeholder must not contain angle brackets, which would be + // dropped as raw HTML when doc nodes are rendered to Markdown. + var result = DocumentationSanitizer.RedactConnectionStringPasswords("Password=password"); + + Assert.Equal("Password=Placeholder", result); + Assert.DoesNotContain('<', result!); + Assert.DoesNotContain('>', result!); + } + + [Theory] + // C# default parameter values use spaces around '=' and must not be touched. + [InlineData("public static IResourceBuilder WithPassword(this IResourceBuilder b, string? password = null)")] + [InlineData("The password used to authenticate. Defaults to a generated value.")] + [InlineData("Gets the connection string for the resource.")] + public void RedactConnectionStringPasswords_LeavesNonConnectionStringTextUntouched(string input) + { + Assert.Equal(input, DocumentationSanitizer.RedactConnectionStringPasswords(input)); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void RedactConnectionStringPasswords_HandlesEmptyAndNull(string? input) + { + Assert.Equal(input, DocumentationSanitizer.RedactConnectionStringPasswords(input)); + } + + [Theory] + // A following inline-code fence, link paren, table pipe, or bracket must not + // be swallowed into the redacted value; they delimit the value's end. + [InlineData("Use `Password=secret` in config.", "Use `Password=Placeholder` in config.")] + [InlineData("[docs](https://h/api?password=secret)", "[docs](https://h/api?password=Placeholder)")] + [InlineData("https://h/api?password=secret&uid=sa", "https://h/api?password=Placeholder&uid=sa")] + [InlineData("|Password=secret|Uid=sa|", "|Password=Placeholder|Uid=sa|")] + [InlineData("[Password=secret]", "[Password=Placeholder]")] + // A trailing sentence period is preserved, not consumed into the value. + [InlineData("The default is Password=secret.", "The default is Password=Placeholder.")] + public void RedactConnectionStringPasswords_StopsAtMarkdownAndUriDelimiters(string input, string expected) + { + Assert.Equal(expected, DocumentationSanitizer.RedactConnectionStringPasswords(input)); + } +} diff --git a/tests/PackageJsonGenerator.Tests/PackageJsonGeneratorTests.cs b/tests/PackageJsonGenerator.Tests/PackageJsonGeneratorTests.cs index 29d50b41b..56a9c2533 100644 --- a/tests/PackageJsonGenerator.Tests/PackageJsonGeneratorTests.cs +++ b/tests/PackageJsonGenerator.Tests/PackageJsonGeneratorTests.cs @@ -286,6 +286,48 @@ public sealed class Widget Assert.False(widget.TryGetProperty("nestedTypes", out _)); } + [Fact] + public void GeneratePackageJson_RedactsConnectionStringPasswordsInEnumMemberDescriptions() + { + using var assembly = TestAssembly.Create( + """ + namespace Sample.Library; + + public enum ConnectionMode + { + /// + /// Connects using Server=host,port;User ID=sa;Password=hunter2. + /// + Direct = 0, + } + """); + + var outputPath = Path.Combine(assembly.DirectoryPath, "Package.json"); + + PackageJsonGenerator.GeneratePackageJson( + assembly.AssemblyPath, + assembly.References, + outputPath, + versionOverride: "1.2.3", + packageNameOverride: "Sample.Package", + targetFrameworkOverride: "net8.0"); + + using var document = JsonDocument.Parse(File.ReadAllText(outputPath)); + var member = document.RootElement + .GetProperty("types") + .EnumerateArray() + .Single(t => t.GetProperty("name").GetString() == "ConnectionMode") + .GetProperty("enumMembers") + .EnumerateArray() + .Single(m => m.GetProperty("name").GetString() == "Direct"); + + var description = member.GetProperty("description").GetString(); + + Assert.NotNull(description); + Assert.DoesNotContain("hunter2", description); + Assert.Contains("Placeholder", description!); + } + private sealed class TestAssembly : IDisposable { private TestAssembly(string directoryPath, string assemblyPath, string[] references)