Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rule S2068: detect hard-coded passwords in web.config files #6182

Merged
merged 12 commits into from
Oct 12, 2022
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ protected override void Initialize(SonarAnalysisContext context)

private void CheckWebConfig(SonarAnalysisContext context, CompilationAnalysisContext c)
{
foreach (var fullPath in context.GetWebConfig(c))
foreach (var fullPath in context.WebConfigFiles(c))
{
var webConfig = File.ReadAllText(fullPath);
if (webConfig.Contains("<connectionStrings>") && XmlHelper.ParseXDocument(webConfig) is { } doc)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ internal void RegisterSyntaxNodeAction<TLanguageKindEnum>(Action<SyntaxNodeAnaly
where TLanguageKindEnum : struct =>
RegisterContextAction(x => context.RegisterSyntaxNodeAction(x, syntaxKinds), action, c => c.GetSyntaxTree(), c => c.Compilation, c => c.Options);

internal IEnumerable<string> GetWebConfig(CompilationAnalysisContext c)
internal IEnumerable<string> WebConfigFiles(CompilationAnalysisContext c)
{
return ProjectConfiguration(c.Options).FilesToAnalyze.FindFiles(WebConfigRegex).Where(ShouldProcess);

Expand Down
16 changes: 12 additions & 4 deletions analyzers/src/SonarAnalyzer.Common/Helpers/XmlHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,19 +46,27 @@ public static XDocument ParseXDocument(string text)
? attribute
: null;

public static Location CreateLocation(this XAttribute attribute, string path)
public static Location CreateLocation(this XAttribute attribute, string path) =>
CreateLocation(attribute, path, attribute.Name);

public static Location CreateLocation(this XElement element, string path) =>
CreateLocation(element, path, element.Name);

private static Location CreateLocation(IXmlLineInfo startPos, string path, XName name)
{
// IXmlLineInfo is 1-based, whereas Roslyn is zero-based
var startPos = (IXmlLineInfo)attribute;
if (startPos.HasLineInfo())
{
// LoadOptions.PreserveWhitespace doesn't preserve whitespace inside nodes and attributes => there's no easy way to find full length of a XAttribute.
var length = attribute.Name.ToString().Length;
var length = name.ToString().Length;
var start = new LinePosition(startPos.LineNumber - 1, startPos.LinePosition - 1);
var end = new LinePosition(startPos.LineNumber - 1, startPos.LinePosition - 1 + length);
return Location.Create(path, new TextSpan(start.Line, length), new LinePositionSpan(start, end));
}
return null;
else
{
return null;
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ private void CheckWebConfig(SonarAnalysisContext context, CompilationAnalysisCon
return;
}

foreach (var fullPath in context.GetWebConfig(c))
foreach (var fullPath in context.WebConfigFiles(c))
{
var webConfig = File.ReadAllText(fullPath);
if (webConfig.Contains("<system.web>") && XmlHelper.ParseXDocument(webConfig) is { } doc)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
using SonarAnalyzer.Common;
Expand All @@ -43,6 +45,8 @@ public abstract class DoNotHardcodeCredentialsBase<TSyntaxKind> : ParameterLoadi

private readonly IAnalyzerConfiguration configuration;
private readonly DiagnosticDescriptor rule;
private readonly Regex validCredentialPattern = new(@"^(\?|:\w+|\{\d+[^}]*\}|""|')$", RegexOptions.IgnoreCase);
private readonly Regex uriUserInfoPattern;
private string credentialWords;
private IEnumerable<string> splitCredentialWords;
private Regex passwordValuePattern;
Expand Down Expand Up @@ -71,6 +75,13 @@ public string CredentialWords

protected DoNotHardcodeCredentialsBase(IAnalyzerConfiguration configuration)
{
const string Rfc3986_Unreserved = "-._~"; // Numbers and letters are embedded in regex itself without escaping
const string Rfc3986_Pct = "%";
const string Rfc3986_SubDelims = "!$&'()*+,;=";
const string UriPasswordSpecialCharacters = Rfc3986_Unreserved + Rfc3986_Pct + Rfc3986_SubDelims;
// See https://tools.ietf.org/html/rfc3986 Userinfo can contain groups: unreserved | pct-encoded | sub-delims
var uriUserInfoPart = @"[\w\d" + Regex.Escape(UriPasswordSpecialCharacters) + "]+";
uriUserInfoPattern = new Regex(@"\w+:\/\/(?<Login>" + uriUserInfoPart + "):(?<Password>" + uriUserInfoPart + ")@", RegexOptions.Compiled);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to rfc3986 more than one : is allowed in the userinfo. I assume, that the password group should also accept :.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand the comment. Can you provide more detailed explanation?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did not really changed anything of this logic, I just moved it to different place

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a valid userinfo that does not match the regex:
https://user:passwordWith:isOkay@example.org/ This is how I read the grammar in the RFC, but I could be mistaken. At least .Net thinks so too sharplab.io

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need to extract the full password. So changing it will make the regex more expensive, without affecting the results.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem is that the : is not in the <Password> group and because of the trailing @, the regex doesn't match at all. The password group must be "[\w\d" + Regex.Escape(UriPasswordSpecialCharacters) + ":]+".

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll try in another PR

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See #6199

this.configuration = configuration;
rule = Language.CreateDescriptor(DiagnosticId, MessageFormat);
CredentialWords = DefaultCredentialWords; // Property will initialize multiple state variables
Expand Down Expand Up @@ -98,6 +109,7 @@ protected sealed override void Initialize(ParameterLoadingAnalysisContext contex
pa.MatchProperty(new MemberDescriptor(KnownType.System_Net_NetworkCredential, "Password")));

InitializeActions(context);
context.Context.RegisterCompilationAction(c => CheckWebConfig(context.Context, c));
}

protected bool IsEnabled(AnalyzerOptions options)
Expand All @@ -106,89 +118,115 @@ protected bool IsEnabled(AnalyzerOptions options)
return configuration.IsEnabled(DiagnosticId);
}

private void CheckWebConfig(SonarAnalysisContext context, CompilationAnalysisContext c)
{
foreach (var path in context.WebConfigFiles(c))
{
if (XmlHelper.ParseXDocument(File.ReadAllText(path)) is { } doc)
{
CheckWebConfig(c, path, doc.Descendants());
}
}
}

private void CheckWebConfig(CompilationAnalysisContext c, string path, IEnumerable<XElement> elements)
{
foreach (var element in elements)
{
if (!element.HasElements && IssueMessage(element.Name.LocalName, element.Value) is { } elementMessage && element.CreateLocation(path) is { } elementLocation)
{
c.ReportIssue(Diagnostic.Create(rule, elementLocation, elementMessage));
}
foreach (var attribute in element.Attributes())
{
if (IssueMessage(attribute.Name.LocalName, attribute.Value) is { } attributeMessage && attribute.CreateLocation(path) is { } attributeLocation)
{
c.ReportIssue(Diagnostic.Create(rule, attributeLocation, attributeMessage));
}
}
}
}

private string IssueMessage(string variableName, string variableValue)
{
var bannedWords = FindCredentialWords(variableName, variableValue);
if (bannedWords.Any())
{
return string.Format(MessageFormatCredential, bannedWords.JoinAnd());
}
else if (ContainsUriUserInfo(variableValue))
{
return MessageUriUserInfo;
}
else
{
return null;
}
}

private IEnumerable<string> FindCredentialWords(string variableName, string variableValue)
{
var credentialWordsFound = variableName
.SplitCamelCaseToWords()
.Intersect(splitCredentialWords)
.ToHashSet(StringComparer.OrdinalIgnoreCase);

if (credentialWordsFound.Any(x => variableValue.IndexOf(x, StringComparison.InvariantCultureIgnoreCase) >= 0))
{
// See https://github.com/SonarSource/sonar-dotnet/issues/2868
return Enumerable.Empty<string>();
}

var match = passwordValuePattern.Match(variableValue);
if (match.Success && !IsValidCredential(match.Groups["suffix"].Value))
{
credentialWordsFound.Add(match.Groups["credential"].Value);
}

// Rule was initially implemented with everything lower (which is wrong) so we have to force lower before reporting to avoid new issues to appear on SQ/SC.
return credentialWordsFound.Select(x => x.ToLowerInvariant());
}

private bool IsValidCredential(string suffix)
{
var candidateCredential = suffix.Split(CredentialSeparator).First().Trim();
return string.IsNullOrWhiteSpace(candidateCredential) || validCredentialPattern.IsMatch(candidateCredential);
}

private bool ContainsUriUserInfo(string variableValue)
{
var match = uriUserInfoPattern.Match(variableValue);
return match.Success
&& match.Groups["Password"].Value is { } password
&& !string.Equals(match.Groups["Login"].Value, password, StringComparison.OrdinalIgnoreCase)
&& password != CredentialSeparator.ToString()
&& !validCredentialPattern.IsMatch(password);
}

protected abstract class CredentialWordsFinderBase<TSyntaxNode>
where TSyntaxNode : SyntaxNode
{
private readonly Regex validCredentialPattern = new(@"^\?|:\w+|\{\d+[^}]*\}|""|'$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private readonly Regex uriUserInfoPattern;
private readonly DoNotHardcodeCredentialsBase<TSyntaxKind> analyzer;

protected abstract bool ShouldHandle(TSyntaxNode syntaxNode, SemanticModel semanticModel);
protected abstract string GetVariableName(TSyntaxNode syntaxNode);
protected abstract string GetAssignedValue(TSyntaxNode syntaxNode, SemanticModel semanticModel);

protected CredentialWordsFinderBase(DoNotHardcodeCredentialsBase<TSyntaxKind> analyzer)
{
// See https://tools.ietf.org/html/rfc3986 Userinfo can contain groups: unreserved | pct-encoded | sub-delims
const string Rfc3986_Unreserved = "-._~"; // Numbers and letters are embeded in regex itself without escaping
const string Rfc3986_Pct = "%";
const string Rfc3986_SubDelims = "!$&'()*+,;=";
const string UriPasswordSpecialCharacters = Rfc3986_Unreserved + Rfc3986_Pct + Rfc3986_SubDelims;
var uriUserInfoPart = @"[\w\d" + Regex.Escape(UriPasswordSpecialCharacters) + "]+";

protected CredentialWordsFinderBase(DoNotHardcodeCredentialsBase<TSyntaxKind> analyzer) =>
this.analyzer = analyzer;
uriUserInfoPattern = new Regex(@"\w+:\/\/(?<Login>" + uriUserInfoPart + "):(?<Password>" + uriUserInfoPart + ")@", RegexOptions.Compiled);
}

public Action<SyntaxNodeAnalysisContext> AnalysisAction() =>
context =>
{
var declarator = (TSyntaxNode)context.Node;
if (ShouldHandle(declarator, context.SemanticModel)
&& GetAssignedValue(declarator, context.SemanticModel) is { } variableValue
&& !string.IsNullOrWhiteSpace(variableValue))
&& !string.IsNullOrWhiteSpace(variableValue)
&& analyzer.IssueMessage(GetVariableName(declarator), variableValue) is { } message)
{
var bannedWords = FindCredentialWords(GetVariableName(declarator), variableValue);
if (bannedWords.Any())
{
context.ReportIssue(Diagnostic.Create(analyzer.rule, declarator.GetLocation(), string.Format(MessageFormatCredential, bannedWords.JoinAnd())));
}
else if (ContainsUriUserInfo(variableValue))
{
context.ReportIssue(Diagnostic.Create(analyzer.rule, declarator.GetLocation(), MessageUriUserInfo));
}
context.ReportIssue(Diagnostic.Create(analyzer.rule, declarator.GetLocation(), message));
}
};

private IEnumerable<string> FindCredentialWords(string variableName, string variableValue)
{
var credentialWordsFound = variableName
.SplitCamelCaseToWords()
.Intersect(analyzer.splitCredentialWords)
.ToHashSet(StringComparer.OrdinalIgnoreCase);

if (credentialWordsFound.Any(x => variableValue.IndexOf(x, StringComparison.InvariantCultureIgnoreCase) >= 0))
{
// See https://github.com/SonarSource/sonar-dotnet/issues/2868
return Enumerable.Empty<string>();
}

var match = analyzer.passwordValuePattern.Match(variableValue);
if (match.Success && !IsValidCredential(match.Groups["suffix"].Value))
{
credentialWordsFound.Add(match.Groups["credential"].Value);
}

// Rule was initially implemented with everything lower (which is wrong) so we have to force lower
// before reporting to avoid new issues to appear on SQ/SC.
return credentialWordsFound.Select(x => x.ToLowerInvariant());
}

private bool IsValidCredential(string suffix)
{
var candidateCredential = suffix.Split(CredentialSeparator).First().Trim();
return string.IsNullOrWhiteSpace(candidateCredential) || validCredentialPattern.IsMatch(candidateCredential);
}

private bool ContainsUriUserInfo(string variableValue)
{
var match = uriUserInfoPattern.Match(variableValue);
return match.Success
&& match.Groups["Password"].Value is { } password
&& !string.Equals(match.Groups["Login"].Value, password, StringComparison.OrdinalIgnoreCase)
&& password != CredentialSeparator.ToString()
&& !validCredentialPattern.IsMatch(password);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ private bool IsEnabled(AnalyzerOptions options)

private void CheckWebConfig(SonarAnalysisContext context, CompilationAnalysisContext c)
{
foreach (var fullPath in context.GetWebConfig(c))
foreach (var fullPath in context.WebConfigFiles(c))
{
var webConfig = File.ReadAllText(fullPath);
if (webConfig.Contains("<system.web") && XmlHelper.ParseXDocument(webConfig) is { } doc)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ analyzerName switch
"ConfiguringLoggers" => "ConfiguringLoggers_Log4Net",
"CookieShouldBeHttpOnly" => "CookieShouldBeHttpOnly_Nancy",
"CookieShouldBeSecure" => "CookieShouldBeSecure_Nancy",
"DoNotHardcodeCredentials" => "DoNotHardcodeCredentials_DefaultValues",
"DoNotHardcodeCredentials" => "DoNotHardcodeCredentials.DefaultValues",
"DeliveringDebugFeaturesInProduction" => "DeliveringDebugFeaturesInProduction.NetCore2",
#if NETFRAMEWORK
"ExecutingSqlQueries" => "ExecutingSqlQueries_Net46",
Expand Down