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
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
19 changes: 11 additions & 8 deletions src/tools/PackageJsonGenerator/Helpers/CanonicalModelBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
})];
}

Expand Down Expand Up @@ -705,6 +705,7 @@ private static List<DocNode> 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);
Comment thread
radical marked this conversation as resolved.
if (!string.IsNullOrEmpty(text))
{
nodes.Add(new DocNode { Kind = "text", Text = text });
Expand Down Expand Up @@ -737,7 +738,7 @@ private static List<DocNode> 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,
});
}
}
Expand Down Expand Up @@ -766,7 +767,7 @@ private static List<DocNode> 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":
Expand All @@ -779,7 +780,7 @@ private static List<DocNode> 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
});
Expand Down Expand Up @@ -839,7 +840,7 @@ private static List<DocNode> 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;
Expand Down Expand Up @@ -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(
Expand All @@ -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;

Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Sanitizes documentation text copied verbatim from package XML docs before it
/// is emitted into the generated JSON data files.
/// </summary>
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);

/// <summary>
/// Replaces literal password values inside connection-string style
/// "key=value" pairs with a <c>Placeholder</c> token.
/// </summary>
/// <remarks>
/// Documentation is copied verbatim from package XML docs and often contains
/// example connection strings (for example the SqlServer
/// <c>GetConnectionString</c> returns text
/// <c>"Server=host,port;User ID=sa;Password=password;TrustServerCertificate=true"</c>).
/// Those literal credential tokens are not real secrets, but they trip
/// push-time secret scanners such as CredScan <c>SqlLegacyCredentials</c>
/// (SEC101/037) when the generated JSON is mirrored to a protected remote.
/// The <c>Placeholder</c> 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.
/// </remarks>
/// <param name="text">The documentation text to sanitize.</param>
/// <returns>The text with connection-string password values redacted.</returns>
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}";
});
}
}
86 changes: 86 additions & 0 deletions tests/PackageJsonGenerator.Tests/DocumentationSanitizerTests.cs
Original file line number Diff line number Diff line change
@@ -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<T> WithPassword<T>(this IResourceBuilder<T> 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));
}
}
42 changes: 42 additions & 0 deletions tests/PackageJsonGenerator.Tests/PackageJsonGeneratorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
/// <summary>
/// Connects using <c>Server=host,port;User ID=sa;Password=hunter2</c>.
/// </summary>
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)
Expand Down
Loading