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

Fix NRE when custom rules omit optional properties in diagnostics #1715

Merged
merged 3 commits into from
Sep 28, 2021
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
14 changes: 14 additions & 0 deletions Engine/Extensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -178,5 +178,19 @@ public static bool GetValue(this NamedAttributeArgumentAst attrAst, out Expressi

return false;
}

internal static bool TryGetPropertyValue(this PSObject psObject, string propertyName, out object value)
{
PSMemberInfo property = psObject.Properties[propertyName];

if (property is null)
{
value = default;
return false;
}

value = property.Value;
return true;
}
}
}
82 changes: 54 additions & 28 deletions Engine/ScriptAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
using System.Collections;
using System.Diagnostics;
using System.Text;
using Microsoft.Windows.PowerShell.ScriptAnalyzer.Extensions;

namespace Microsoft.Windows.PowerShell.ScriptAnalyzer
{
Expand Down Expand Up @@ -1263,13 +1264,6 @@ internal IEnumerable<DiagnosticRecord> GetExternalRecord(Ast ast, Token[] token,

foreach (var psobject in psobjects)
{
DiagnosticSeverity severity;
IScriptExtent extent;
string message = string.Empty;
string ruleName = string.Empty;
string ruleSuppressionID = string.Empty;
IEnumerable<CorrectionExtent> suggestedCorrections;

if (psobject != null && psobject.ImmediateBaseObject != null)
{
// Because error stream is merged to output stream,
Expand All @@ -1282,28 +1276,9 @@ internal IEnumerable<DiagnosticRecord> GetExternalRecord(Ast ast, Token[] token,
}

// DiagnosticRecord may not be correctly returned from external rule.
try
if (TryConvertPSObjectToDiagnostic(psobject, filePath, out DiagnosticRecord diagnostic))
{
severity = (DiagnosticSeverity)Enum.Parse(typeof(DiagnosticSeverity), psobject.Properties["Severity"].Value.ToString());
message = psobject.Properties["Message"].Value.ToString();
extent = (IScriptExtent)psobject.Properties["Extent"].Value;
ruleName = psobject.Properties["RuleName"].Value.ToString();
ruleSuppressionID = psobject.Properties["RuleSuppressionID"].Value?.ToString();
suggestedCorrections = (IEnumerable<CorrectionExtent>)psobject.Properties["SuggestedCorrections"].Value;
}
catch (Exception ex)
{
this.outputWriter.WriteError(new ErrorRecord(ex, ex.HResult.ToString("X"), ErrorCategory.NotSpecified, this));
continue;
}

if (!string.IsNullOrEmpty(message))
{
diagnostics.Add(new DiagnosticRecord(message, extent, ruleName, severity, filePath)
{
SuggestedCorrections = suggestedCorrections,
RuleSuppressionID = ruleSuppressionID,
});
diagnostics.Add(diagnostic);
}
}
}
Expand Down Expand Up @@ -1660,6 +1635,57 @@ public EditableText Fix(EditableText text, Range range, bool skipParsing, out Ra
return text;
}

private bool TryConvertPSObjectToDiagnostic(PSObject psObject, string filePath, out DiagnosticRecord diagnostic)
{
string message = psObject.Properties["Message"]?.Value?.ToString();
object extentValue = psObject.Properties["Extent"]?.Value;
string ruleName = psObject.Properties["RuleName"]?.Value?.ToString();
string ruleSuppressionID = psObject.Properties["RuleSuppressionID"]?.Value?.ToString();
CorrectionExtent[] suggestedCorrections = psObject.TryGetPropertyValue("SuggestedCorrections", out object correctionsValue)
? LanguagePrimitives.ConvertTo<CorrectionExtent[]>(correctionsValue)
: null;
DiagnosticSeverity severity = psObject.TryGetPropertyValue("Severity", out object severityValue)
? LanguagePrimitives.ConvertTo<DiagnosticSeverity>(severityValue)
: DiagnosticSeverity.Warning;

bool isValid = true;
isValid &= CheckHasRequiredProperty("Message", message);
isValid &= CheckHasRequiredProperty("RuleName", ruleName);

if (extentValue is not null && extentValue is not IScriptExtent)
{
this.outputWriter.WriteError(
new ErrorRecord(
new ArgumentException($"Property 'Extent' is expected to be of type '{typeof(IScriptExtent)}' but was instead of type '{extentValue.GetType()}'"),
"CustomRuleDiagnosticPropertyInvalidType",
ErrorCategory.InvalidArgument,
this));
isValid = false;
}

if (!isValid)
{
diagnostic = null;
return false;
}

diagnostic = new DiagnosticRecord(message, (IScriptExtent)extentValue, ruleName, severity, filePath, ruleSuppressionID, suggestedCorrections);
return true;
}

private bool CheckHasRequiredProperty(string propertyName, object propertyValue)
{
if (propertyValue is null)
{
var exception = new ArgumentNullException(propertyName, $"The '{propertyName}' property is required on custom rule diagnostics");
this.outputWriter.WriteError(new ErrorRecord(exception, "CustomRuleDiagnosticPropertyMissing", ErrorCategory.InvalidArgument, this));
return false;
}

return true;
}


private static Encoding GetFileEncoding(string path)
{
using (var stream = new FileStream(path, FileMode.Open))
Expand Down
7 changes: 7 additions & 0 deletions Tests/Engine/CustomRuleNREAssets/CLMTest.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
write-host "test"
$a = "asdf"
$a = $a.replace('s','ssssssss')
[math]::abs(-1)
Function ASDF1234{
"asdf"
}
48 changes: 48 additions & 0 deletions Tests/Engine/CustomRuleNREAssets/MyCustom.psm1
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<#
.SYNOPSIS
Static methods are not allowed in constrained language mode.
.DESCRIPTION
Static methods are not allowed in constrained language mode.
To fix a violation of this rule, use a cmdlet or function instead of a static method.
.EXAMPLE
Test-StaticMethod -CommandAst $CommandAst
.INPUTS
[System.Management.Automation.Language.ScriptBlockAst]
.OUTPUTS
[PSCustomObject[]]
.NOTES
Reference: Output, CLM info.
#>
function Test-StaticMethod
{
[CmdletBinding()]
[OutputType([PSCustomObject[]])]
Param
(
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[System.Management.Automation.Language.ScriptBlockAst]
$ScriptBlockAst
)

Process
{
try
{
# Gets methods

$invokedMethods = $ScriptBlockAst.FindAll({$args[0] -is [System.Management.Automation.Language.CommandExpressionAst] -and $args[0].Expression -match "^\[.*\]::" },$true)
foreach ($invokedMethod in $invokedMethods)
{
[PSCustomObject]@{Message = "Avoid Using Static Methods";
Extent = $invokedMethod.Extent;
RuleName = $PSCmdlet.MyInvocation.InvocationName;
Severity = "Warning"}
}
}
catch
{
$PSCmdlet.ThrowTerminatingError($PSItem)
}
}
}
13 changes: 13 additions & 0 deletions Tests/Engine/CustomizedRule.tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,19 @@ Describe "Test importing correct customized rules" {
$violations[0].RuleSuppressionID | Should -Be "MyRuleSuppressionID"
}

It "Does not throw an exception when optional diagnostic properties are not present" {
$violations = Invoke-ScriptAnalyzer -Path "$PSScriptRoot\CustomRuleNREAssets\CLMTest.ps1" -CustomizedRulePath "$PSScriptRoot\CustomRuleNREAssets\MyCustom.psm1"

$violations.Count | Should -Be 1
$violations[0].RuleName | Should -BeExactly 'MyCustom\Test-StaticMethod'
$violations[0].Severity | Should -Be 'Warning'
$violations[0].ScriptName | Should -BeExactly 'CLMTest.ps1'
$violations[0].Extent.StartLineNumber | Should -Be 4
$violations[0].Message | Should -BeExactly 'Avoid Using Static Methods'
$violations[0].RuleSuppressionID | Should -BeNullOrEmpty
$violations[0].SuggestedCorrections | Should -BeNullOrEmpty
}

Context "Test Invoke-ScriptAnalyzer with customized rules - Advanced test cases" -Skip:$testingLibraryUsage {
It "will show the custom rule in the results when given a rule folder path with trailing backslash" {
# needs fixing for Linux
Expand Down