Skip to content

Instantly share code, notes, and snippets.

@rjmholt
Last active September 27, 2021 17:07
Show Gist options
  • Save rjmholt/8cf7d4a3b1a4018d39acad10b287cf8e to your computer and use it in GitHub Desktop.
Save rjmholt/8cf7d4a3b1a4018d39acad10b287cf8e to your computer and use it in GitHub Desktop.
PSSA Custom Rules

Custom rule DLLs in PSScriptAnalyzer

This gist shows an example of how to create custom rules with DLLs in PSScriptAnalyzer.

Requirements

There are a few obstacles to surmount before you can make custom rule DLLs work in PSScriptAnalyzer:

  • The latest PSScriptAnalyzer release doesn't support custom rule DLLs. Instead you need the changes in PSScriptAnalyzer #1718.
  • PSScriptAnalyzer has no NuGet package, so to build against it you must clone the repo and use a project reference to the source.
  • Your custom rule project will need to target either netcoreapp3.1 or net452 to build against PSScriptAnalyzer for now. Ideally in future we will be able to support netstandard2.0 to enable this scenario.

Getting a simple rule invocation

  1. Build the PSScriptAnalyzer project with the required changes with ./build.ps1 -All, it will be in ./out/PSScriptAnalyzer
  2. Also build the custom rule project with the usual dotnet publish
  3. Load PSScriptAnalyzer with Import-Module ./out/PSScriptAnalyzer
  4. Invoke PSScriptAnalyzer with the custom rules with:
    Invoke-ScriptAnalyzer -ScriptDefinition 'gci; Get-AzVm' -CustomRulePath -CustomRulePath .\PssaCustomRules\bin\Debug\netcoreapp3.1\publish\PssaCustomRules.dll                                     

Integrating with VSCode

  1. Build PSScriptAnalyzer as before
  2. Move it to ~/.vscode-insiders/extensions/ms-vscode.powershell-preview-2021.9.1/modules (back up the original in there first)
  3. Create a configuration file for PSScriptAnalyzer (by default the extension will pick up PSScriptAnalyzerSettings.psd1 in the project root):
    @{
        CustomRulePath = "C:\path\to\CustomRules\bin\Debug\netcoreapp3.1\publish\PssaCustomRules.dll"
    }
  4. Make sure there are no other versions of PSScriptAnalyzer on the module path (currently the newest is picked up, but this will soon change to only load the bundled module)
  5. See VSCode load and run the rule and show the result in the Problems pane, along with the suggested code change with the lightbulb: image
namespace PssaCustomRules
{
/// <summary>
/// An abstract base class for rules.
/// This isn't required, but simplifies rule creation,
/// since it takes the rule interface hooks and implements them
/// according to an attribute,
/// so your final rule implementation is much easier to read
/// and repeats less code.
/// </summary>
public abstract class CustomRule : IScriptRule
{
private const string SourceName = nameof(PssaCustomRules);
private static readonly SourceType s_sourceType = SourceType.Managed;
private static readonly ConcurrentDictionary<Type, CustomRuleAttribute> s_attributeTable = new ConcurrentDictionary<Type, CustomRuleAttribute>();
private readonly Lazy<CustomRuleAttribute> _attributeLazy;
protected CustomRule()
{
_attributeLazy = new Lazy<CustomRuleAttribute>(GetCustomRuleAttribute);
}
internal CustomRuleAttribute RuleAttribute => _attributeLazy.Value;
public abstract IEnumerable<DiagnosticRecord> AnalyzeScript(Ast ast, string fileName);
protected DiagnosticRecord GetDiagnostic(
IScriptExtent extent,
string message,
string suggestedCorrection = null,
string correctionDescription = null)
{
// Some assumptions baked in here that you probably want to change/customize:
// - Every diagnostic the rule emits has the same severity (the rule severity)
// - The file of the script is the file of the extent - this is probably true, but needs checking
// - No suppression ID is offered (could easily be offered as another parameter on the method)
CorrectionExtent[] corrections = null;
if (!(suggestedCorrection is null))
{
corrections = new[]
{
new CorrectionExtent(extent, suggestedCorrection, extent.File, correctionDescription)
};
}
return new DiagnosticRecord(
message,
extent,
RuleAttribute.RuleName,
(DiagnosticSeverity)RuleAttribute.Severity,
extent.File,
suggestedCorrections: corrections);
}
public string GetCommonName() => RuleAttribute.RuleName;
public string GetDescription() => RuleAttribute.Description;
public string GetName() => RuleAttribute.RuleName;
public RuleSeverity GetSeverity() => RuleAttribute.Severity;
public string GetSourceName() => SourceName;
public SourceType GetSourceType() => s_sourceType;
private CustomRuleAttribute GetCustomRuleAttribute()
{
// This is just a way to amortize the cost of reflection on the custom attribute over each type,
// rather than each object - we cache the attribute we found for each class
return s_attributeTable.GetOrAdd(this.GetType(), type => type.GetCustomAttribute<CustomRuleAttribute>());
}
}
}
namespace PssaCustomRules
{
/// <summary>
/// An attribute definition to be used with the CustomRule abstract base class
/// to allow a rule to define its metadata in an attribute rather than with interface implementation.
/// </summary>
public class CustomRuleAttribute : Attribute
{
public CustomRuleAttribute(string ruleName, string description)
{
RuleName = ruleName;
Description = description;
}
public string RuleName { get; }
public string Description { get; }
// Rules default to having the "Warning" severity
public RuleSeverity Severity { get; set; } = RuleSeverity.Warning;
}
}
namespace PssaCustomRules
{
/// <summary>
/// Actual PSScriptAnalyzer example rule.
/// This can be loaded into PSScriptAnalyzer and run alongside builtin rules.
/// </summary>
[CustomRule("ExampleRule", "An example rule")]
public class ExampleRule : CustomRule
{
public override IEnumerable<DiagnosticRecord> AnalyzeScript(Ast ast, string fileName)
{
// Go through, find all instances of Get-AzureRmVm, suggest changing it to Get-AzVm
foreach (CommandAst command in ast.FindAll(IsAzureRmCommandAst, searchNestedScriptBlocks: true))
{
yield return GetDiagnostic(
command.Extent,
"AzureRM is deprecated",
suggestedCorrection: "Get-AzVm",
correctionDescription: "Use the Az module instead"); }
}
// Static method here just provides a more readable way to implement the delegate for Ast.FindAll()
private static bool IsAzureRmCommandAst(Ast ast)
{
return ast is CommandAst commandAst
&& string.Equals(commandAst.GetCommandName(), "Get-AzureRmVm");
}
}
}
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Engine\Engine.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
</PropertyGroup>
</Project>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment