diff --git a/Engine/Commands/GetScriptAnalyzerRuleCommand.cs b/Engine/Commands/GetScriptAnalyzerRuleCommand.cs index ed4799c40..a914e1f59 100644 --- a/Engine/Commands/GetScriptAnalyzerRuleCommand.cs +++ b/Engine/Commands/GetScriptAnalyzerRuleCommand.cs @@ -24,7 +24,7 @@ namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.Commands /// GetScriptAnalyzerRuleCommand: Cmdlet to list all the analyzer rule names and descriptions. /// [Cmdlet(VerbsCommon.Get, "ScriptAnalyzerRule", HelpUri = "http://go.microsoft.com/fwlink/?LinkId=525913")] - public class GetScriptAnalyzerRuleCommand : PSCmdlet + public class GetScriptAnalyzerRuleCommand : PSCmdlet, IOutputWriter { #region Parameters /// @@ -69,12 +69,6 @@ public string[] Severity #endregion Parameters - #region Private Members - - Dictionary> validationResults = new Dictionary>(); - - #endregion - #region Overrides /// @@ -82,43 +76,7 @@ public string[] Severity /// protected override void BeginProcessing() { - #region Set PSCmdlet property of Helper - - Helper.Instance.MyCmdlet = this; - - #endregion - // Verifies rule extensions - if (customizedRulePath != null) - { - validationResults = ScriptAnalyzer.Instance.CheckRuleExtension(customizedRulePath, this); - foreach (string extension in validationResults["InvalidPaths"]) - { - WriteWarning(string.Format(CultureInfo.CurrentCulture,Strings.MissingRuleExtension, extension)); - } - } - else - { - validationResults.Add("InvalidPaths", new List()); - validationResults.Add("ValidModPaths", new List()); - validationResults.Add("ValidDllPaths", new List()); - } - - try - { - if (validationResults["ValidDllPaths"].Count == 0) - { - ScriptAnalyzer.Instance.Initialize(); - } - else - { - ScriptAnalyzer.Instance.Initilaize(validationResults); - } - } - catch (Exception ex) - { - ThrowTerminatingError(new ErrorRecord(ex, ex.HResult.ToString("X", CultureInfo.CurrentCulture), - ErrorCategory.NotSpecified, this)); - } + ScriptAnalyzer.Instance.Initialize(this, customizedRulePath); } /// @@ -126,11 +84,7 @@ protected override void BeginProcessing() /// protected override void ProcessRecord() { - string[] modNames = null; - if (validationResults["ValidModPaths"].Count > 0) - { - modNames = validationResults["ValidModPaths"].ToArray(); - } + string[] modNames = ScriptAnalyzer.Instance.GetValidModulePaths(); IEnumerable rules = ScriptAnalyzer.Instance.GetRule(modNames, name); if (rules == null) diff --git a/Engine/Commands/InvokeScriptAnalyzerCommand.cs b/Engine/Commands/InvokeScriptAnalyzerCommand.cs index 2fa611404..d50b40795 100644 --- a/Engine/Commands/InvokeScriptAnalyzerCommand.cs +++ b/Engine/Commands/InvokeScriptAnalyzerCommand.cs @@ -31,7 +31,7 @@ namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.Commands /// InvokeScriptAnalyzerCommand: Cmdlet to statically check PowerShell scripts. /// [Cmdlet(VerbsLifecycle.Invoke, "ScriptAnalyzer", HelpUri = "http://go.microsoft.com/fwlink/?LinkId=525914")] - public class InvokeScriptAnalyzerCommand : PSCmdlet + public class InvokeScriptAnalyzerCommand : PSCmdlet, IOutputWriter { #region Parameters /// @@ -128,81 +128,23 @@ public SwitchParameter SuppressedOnly #region Private Members Dictionary> validationResults = new Dictionary>(); - private ScriptBlockAst ast = null; - private IEnumerable rules = null; #endregion - #region Ovserrides + #region Overrides /// /// Imports all known rules and loggers. /// protected override void BeginProcessing() { - #region Set PSCmdlet property of Helper - - Helper.Instance.MyCmdlet = this; - - #endregion - - #region Verifies rule extensions and loggers path - - List paths = new List(); - - if (customizedRulePath != null) paths.AddRange(customizedRulePath.ToList()); - - if (paths.Count > 0) - { - validationResults = ScriptAnalyzer.Instance.CheckRuleExtension(paths.ToArray(), this); - foreach (string extension in validationResults["InvalidPaths"]) - { - WriteWarning(string.Format(CultureInfo.CurrentCulture, Strings.MissingRuleExtension, extension)); - } - } - else - { - validationResults.Add("InvalidPaths", new List()); - validationResults.Add("ValidModPaths", new List()); - validationResults.Add("ValidDllPaths", new List()); - } - - #endregion - - #region Initializes Rules - - try - { - if (validationResults["ValidDllPaths"].Count == 0 && - validationResults["ValidModPaths"].Count == 0) - { - ScriptAnalyzer.Instance.Initialize(); - } - else - { - ScriptAnalyzer.Instance.Initilaize(validationResults); - } - } - catch (Exception ex) - { - ThrowTerminatingError(new ErrorRecord(ex, ex.HResult.ToString("X", CultureInfo.CurrentCulture), - ErrorCategory.NotSpecified, this)); - } - - #endregion - - #region Verify rules - - rules = ScriptAnalyzer.Instance.ScriptRules.Union( - ScriptAnalyzer.Instance.TokenRules).Union( - ScriptAnalyzer.Instance.ExternalRules ?? Enumerable.Empty()); - - if (rules == null || rules.Count() == 0) - { - ThrowTerminatingError(new ErrorRecord(new Exception(), string.Format(CultureInfo.CurrentCulture, Strings.RulesNotFound), ErrorCategory.ResourceExists, this)); - } - - #endregion + ScriptAnalyzer.Instance.Initialize( + this, + customizedRulePath, + this.includeRule, + this.excludeRule, + this.severity, + this.suppressedOnly); } /// @@ -221,452 +163,15 @@ protected override void ProcessRecord() private void ProcessPath(string path) { - const string ps1Suffix = "ps1"; - const string psm1Suffix = "psm1"; - const string psd1Suffix = "psd1"; - - if (path == null) - { - ThrowTerminatingError(new ErrorRecord(new FileNotFoundException(), - string.Format(CultureInfo.CurrentCulture, Strings.FileNotFound, path), - ErrorCategory.InvalidArgument, this)); - } - - if (Directory.Exists(path)) - { - if (recurse) - { - - foreach (string filePath in Directory.GetFiles(path)) - { - ProcessPath(filePath); - } - foreach (string filePath in Directory.GetDirectories(path)) - { - ProcessPath(filePath); - } - } - else - { - foreach (string filePath in Directory.GetFiles(path)) - { - ProcessPath(filePath); - } - } - } - else if (File.Exists(path)) - { - if ((path.Length > ps1Suffix.Length && path.Substring(path.Length - ps1Suffix.Length).Equals(ps1Suffix, StringComparison.OrdinalIgnoreCase)) || - (path.Length > psm1Suffix.Length && path.Substring(path.Length - psm1Suffix.Length).Equals(psm1Suffix, StringComparison.OrdinalIgnoreCase)) || - (path.Length > psd1Suffix.Length && path.Substring(path.Length - psd1Suffix.Length).Equals(psd1Suffix, StringComparison.OrdinalIgnoreCase))) - { - WriteVerbose(string.Format(CultureInfo.CurrentCulture, Strings.VerboseFileMessage, path)); - AnalyzeFile(path); - } - } - else - { - WriteError(new ErrorRecord(new FileNotFoundException(), string.Format(CultureInfo.CurrentCulture, Strings.FileNotFound, path), ErrorCategory.InvalidArgument, this)); - } - - } - - ConcurrentBag diagnostics; - ConcurrentBag suppressed; - Dictionary> ruleSuppressions; - List includeRegexList; - List excludeRegexList; - ConcurrentDictionary> ruleDictionary; - - /// - /// Analyzes a single script file. - /// - /// The path to the file ot analyze - private void AnalyzeFile(string filePath) - { - Token[] tokens = null; - ParseError[] errors = null; - ConcurrentBag diagnostics = new ConcurrentBag(); - ConcurrentBag suppressed = new ConcurrentBag(); - BlockingCollection> verboseOrErrors = new BlockingCollection>(); - - // Use a List of KVP rather than dictionary, since for a script containing inline functions with same signature, keys clash - List> cmdInfoTable = new List>(); - - //Check wild card input for the Include/ExcludeRules and create regex match patterns - includeRegexList = new List(); - excludeRegexList = new List(); - if (includeRule != null) - { - foreach (string rule in includeRule) - { - Regex includeRegex = new Regex(String.Format("^{0}$", Regex.Escape(rule).Replace(@"\*", ".*")), RegexOptions.IgnoreCase); - includeRegexList.Add(includeRegex); - } - } - if (excludeRule != null) - { - foreach (string rule in excludeRule) - { - Regex excludeRegex = new Regex(String.Format("^{0}$", Regex.Escape(rule).Replace(@"\*", ".*")), RegexOptions.IgnoreCase); - excludeRegexList.Add(excludeRegex); - } - } - - - //Parse the file - if (File.Exists(filePath)) - { - ast = Parser.ParseFile(filePath, out tokens, out errors); - } - else - { - ThrowTerminatingError(new ErrorRecord(new FileNotFoundException(), - string.Format(CultureInfo.CurrentCulture, Strings.InvalidPath, filePath), - ErrorCategory.InvalidArgument, filePath)); - } - - if (errors != null && errors.Length > 0) - { - foreach (ParseError error in errors) - { - string parseErrorMessage = String.Format(CultureInfo.CurrentCulture, Strings.ParserErrorFormat, error.Extent.File, error.Message.TrimEnd('.'), error.Extent.StartLineNumber, error.Extent.StartColumnNumber); - WriteError(new ErrorRecord(new ParseException(parseErrorMessage), parseErrorMessage, ErrorCategory.ParserError, error.ErrorId)); - } - } - - if (errors.Length > 10) - { - string manyParseErrorMessage = String.Format(CultureInfo.CurrentCulture, Strings.ParserErrorMessage, System.IO.Path.GetFileName(filePath)); - WriteError(new ErrorRecord(new ParseException(manyParseErrorMessage), manyParseErrorMessage, ErrorCategory.ParserError, filePath)); - - return; - } - - ruleSuppressions = Helper.Instance.GetRuleSuppression(ast); - - foreach (List ruleSuppressionsList in ruleSuppressions.Values) - { - foreach (RuleSuppression ruleSuppression in ruleSuppressionsList) - { - if (!String.IsNullOrWhiteSpace(ruleSuppression.Error)) - { - WriteError(new ErrorRecord(new ArgumentException(ruleSuppression.Error), ruleSuppression.Error, ErrorCategory.InvalidArgument, ruleSuppression)); - } - } - } - - #region Run VariableAnalysis - try - { - Helper.Instance.InitializeVariableAnalysis(ast); - } - catch { } - #endregion - - Helper.Instance.Tokens = tokens; - - #region Run ScriptRules - //Trim down to the leaf element of the filePath and pass it to Diagnostic Record - string fileName = System.IO.Path.GetFileName(filePath); - - if (ScriptAnalyzer.Instance.ScriptRules != null) - { - var tasks = ScriptAnalyzer.Instance.ScriptRules.Select(scriptRule => Task.Factory.StartNew(() => - { - bool includeRegexMatch = false; - bool excludeRegexMatch = false; - - foreach (Regex include in includeRegexList) - { - if (include.IsMatch(scriptRule.GetName())) - { - includeRegexMatch = true; - break; - } - } - - foreach (Regex exclude in excludeRegexList) - { - if (exclude.IsMatch(scriptRule.GetName())) - { - excludeRegexMatch = true; - break; - } - } - - if ((includeRule == null || includeRegexMatch) && (excludeRule == null || !excludeRegexMatch)) - { - List result = new List(); - - result.Add(string.Format(CultureInfo.CurrentCulture, Strings.VerboseRunningMessage, scriptRule.GetName())); - - // Ensure that any unhandled errors from Rules are converted to non-terminating errors - // We want the Engine to continue functioning even if one or more Rules throws an exception - try - { - var records = Helper.Instance.SuppressRule(scriptRule.GetName(), ruleSuppressions, scriptRule.AnalyzeScript(ast, ast.Extent.File).ToList()); - foreach (var record in records.Item2) - { - diagnostics.Add(record); - } - foreach (var suppressedRec in records.Item1) - { - suppressed.Add(suppressedRec); - } - } - catch (Exception scriptRuleException) - { - result.Add(new ErrorRecord(scriptRuleException, Strings.RuleErrorMessage, ErrorCategory.InvalidOperation, ast.Extent.File)); - } - - verboseOrErrors.Add(result); - } - })); - - Task.Factory.ContinueWhenAll(tasks.ToArray(), t => verboseOrErrors.CompleteAdding()); - - while (!verboseOrErrors.IsCompleted) - { - List data = null; - try - { - data = verboseOrErrors.Take(); - } - catch (InvalidOperationException) { } - - if (data != null) - { - WriteVerbose(data[0] as string); - if (data.Count == 2) - { - WriteError(data[1] as ErrorRecord); - } - } - } - } - - #endregion - - #region Run Token Rules - - if (ScriptAnalyzer.Instance.TokenRules != null) - { - foreach (ITokenRule tokenRule in ScriptAnalyzer.Instance.TokenRules) - { - bool includeRegexMatch = false; - bool excludeRegexMatch = false; - foreach (Regex include in includeRegexList) - { - if (include.IsMatch(tokenRule.GetName())) - { - includeRegexMatch = true; - break; - } - } - foreach (Regex exclude in excludeRegexList) - { - if (exclude.IsMatch(tokenRule.GetName())) - { - excludeRegexMatch = true; - break; - } - } - if ((includeRule == null || includeRegexMatch) && (excludeRule == null || !excludeRegexMatch)) - { - WriteVerbose(string.Format(CultureInfo.CurrentCulture, Strings.VerboseRunningMessage, tokenRule.GetName())); - - // Ensure that any unhandled errors from Rules are converted to non-terminating errors - // We want the Engine to continue functioning even if one or more Rules throws an exception - try - { - var records = Helper.Instance.SuppressRule(tokenRule.GetName(), ruleSuppressions, tokenRule.AnalyzeTokens(tokens, filePath).ToList()); - foreach (var record in records.Item2) - { - diagnostics.Add(record); - } - foreach (var suppressedRec in records.Item1) - { - suppressed.Add(suppressedRec); - } - } - catch (Exception tokenRuleException) - { - WriteError(new ErrorRecord(tokenRuleException, Strings.RuleErrorMessage, ErrorCategory.InvalidOperation, fileName)); - } - } - } - } - - #endregion - - #region DSC Resource Rules - if (ScriptAnalyzer.Instance.DSCResourceRules != null) - { - // Invoke AnalyzeDSCClass only if the ast is a class based resource - if (Helper.Instance.IsDscResourceClassBased(ast)) - { - // Run DSC Class rule - foreach (IDSCResourceRule dscResourceRule in ScriptAnalyzer.Instance.DSCResourceRules) - { - bool includeRegexMatch = false; - bool excludeRegexMatch = false; - - foreach (Regex include in includeRegexList) - { - if (include.IsMatch(dscResourceRule.GetName())) - { - includeRegexMatch = true; - break; - } - } - - foreach (Regex exclude in excludeRegexList) - { - if (exclude.IsMatch(dscResourceRule.GetName())) - { - excludeRegexMatch = true; - break; - } - } - - if ((includeRule == null || includeRegexMatch) && (excludeRule == null || excludeRegexMatch)) - { - WriteVerbose(string.Format(CultureInfo.CurrentCulture, Strings.VerboseRunningMessage, dscResourceRule.GetName())); - - // Ensure that any unhandled errors from Rules are converted to non-terminating errors - // We want the Engine to continue functioning even if one or more Rules throws an exception - try - { - var records = Helper.Instance.SuppressRule(dscResourceRule.GetName(), ruleSuppressions, dscResourceRule.AnalyzeDSCClass(ast, filePath).ToList()); - foreach (var record in records.Item2) - { - diagnostics.Add(record); - } - foreach (var suppressedRec in records.Item1) - { - suppressed.Add(suppressedRec); - } - } - catch (Exception dscResourceRuleException) - { - WriteError(new ErrorRecord(dscResourceRuleException, Strings.RuleErrorMessage, ErrorCategory.InvalidOperation, filePath)); - } - } - } - } - - // Check if the supplied artifact is indeed part of the DSC resource - if (Helper.Instance.IsDscResourceModule(filePath)) - { - // Run all DSC Rules - foreach (IDSCResourceRule dscResourceRule in ScriptAnalyzer.Instance.DSCResourceRules) - { - bool includeRegexMatch = false; - bool excludeRegexMatch = false; - foreach (Regex include in includeRegexList) - { - if (include.IsMatch(dscResourceRule.GetName())) - { - includeRegexMatch = true; - break; - } - } - foreach (Regex exclude in excludeRegexList) - { - if (exclude.IsMatch(dscResourceRule.GetName())) - { - excludeRegexMatch = true; - } - } - if ((includeRule == null || includeRegexMatch) && (excludeRule == null || !excludeRegexMatch)) - { - WriteVerbose(string.Format(CultureInfo.CurrentCulture, Strings.VerboseRunningMessage, dscResourceRule.GetName())); - - // Ensure that any unhandled errors from Rules are converted to non-terminating errors - // We want the Engine to continue functioning even if one or more Rules throws an exception - try - { - var records = Helper.Instance.SuppressRule(dscResourceRule.GetName(), ruleSuppressions, dscResourceRule.AnalyzeDSCResource(ast, filePath).ToList()); - foreach (var record in records.Item2) - { - diagnostics.Add(record); - } - foreach (var suppressedRec in records.Item1) - { - suppressed.Add(suppressedRec); - } - } - catch (Exception dscResourceRuleException) - { - WriteError(new ErrorRecord(dscResourceRuleException, Strings.RuleErrorMessage, ErrorCategory.InvalidOperation, filePath)); - } - } - } - - } - } - #endregion - - #region Run External Rules - - if (ScriptAnalyzer.Instance.ExternalRules != null) - { - List exRules = new List(); - - foreach (ExternalRule exRule in ScriptAnalyzer.Instance.ExternalRules) - { - if ((includeRule == null || includeRule.Contains(exRule.GetName(), StringComparer.OrdinalIgnoreCase)) && - (excludeRule == null || !excludeRule.Contains(exRule.GetName(), StringComparer.OrdinalIgnoreCase))) - { - string ruleName = string.Format(CultureInfo.CurrentCulture, "{0}\\{1}", exRule.GetSourceName(), exRule.GetName()); - WriteVerbose(string.Format(CultureInfo.CurrentCulture, Strings.VerboseRunningMessage, ruleName)); - - // Ensure that any unhandled errors from Rules are converted to non-terminating errors - // We want the Engine to continue functioning even if one or more Rules throws an exception - try - { - exRules.Add(exRule); - } - catch (Exception externalRuleException) - { - WriteError(new ErrorRecord(externalRuleException, Strings.RuleErrorMessage, ErrorCategory.InvalidOperation, fileName)); - } - } - } - - foreach (var record in ScriptAnalyzer.Instance.GetExternalRecord(ast, tokens, exRules.ToArray(), this, fileName)) - { - diagnostics.Add(record); - } - } - - #endregion - - IEnumerable diagnosticsList = diagnostics; - - if (severity != null) - { - var diagSeverity = severity.Select(item => Enum.Parse(typeof(DiagnosticSeverity), item, true)); - diagnosticsList = diagnostics.Where(item => diagSeverity.Contains(item.Severity)); - } + IEnumerable diagnosticsList = + ScriptAnalyzer.Instance.AnalyzePath(path, this.recurse); //Output through loggers foreach (ILogger logger in ScriptAnalyzer.Instance.Loggers) { - if (SuppressedOnly) - { - foreach (DiagnosticRecord suppressRecord in suppressed) - { - logger.LogObject(suppressRecord, this); - } - } - else + foreach (DiagnosticRecord diagnostic in diagnosticsList) { - foreach (DiagnosticRecord diagnostic in diagnosticsList) - { - logger.LogObject(diagnostic, this); - } + logger.LogObject(diagnostic, this); } } } diff --git a/Engine/Helper.cs b/Engine/Helper.cs index 66105b1b3..347ebc521 100644 --- a/Engine/Helper.cs +++ b/Engine/Helper.cs @@ -27,6 +27,13 @@ namespace Microsoft.Windows.PowerShell.ScriptAnalyzer /// public class Helper { + #region Private members + + private CommandInvocationIntrinsics invokeCommand; + private IOutputWriter outputWriter; + + #endregion + #region Singleton private static object syncRoot = new Object(); @@ -41,15 +48,21 @@ public static Helper Instance { if (instance == null) { - lock (syncRoot) - { - if (instance == null) - instance = new Helper(); - } + Instance = new Helper(); } return instance; } + internal set + { + lock (syncRoot) + { + if (instance == null) + { + instance = value; + } + } + } } #endregion @@ -66,11 +79,6 @@ public static Helper Instance /// private Dictionary AliasToCmdletDictionary; - /// - /// ScriptAnalyzer Cmdlet, used for getting commandinfos of other commands. - /// - public PSCmdlet MyCmdlet { get; set; } - internal TupleComparer tupleComparer = new TupleComparer(); /// @@ -93,6 +101,32 @@ public static Helper Instance #endregion + /// + /// Initializes the Helper class. + /// + private Helper() + { + } + + /// + /// Initializes the Helper class. + /// + /// + /// A CommandInvocationIntrinsics instance for use in gathering + /// information about available commands and aliases. + /// + /// + /// An IOutputWriter instance for use in writing output + /// to the PowerShell environment. + /// + public Helper( + CommandInvocationIntrinsics invokeCommand, + IOutputWriter outputWriter) + { + this.invokeCommand = invokeCommand; + this.outputWriter = outputWriter; + } + #region Methods /// /// Initialize : Initializes dictionary of alias. @@ -104,7 +138,7 @@ public void Initialize() KeywordBlockDictionary = new Dictionary>>(StringComparer.OrdinalIgnoreCase); VariableAnalysisDictionary = new Dictionary(); - IEnumerable aliases = MyCmdlet.InvokeCommand.GetCommands("*", CommandTypes.Alias, true); + IEnumerable aliases = this.invokeCommand.GetCommands("*", CommandTypes.Alias, true); foreach (AliasInfo aliasInfo in aliases) { @@ -317,7 +351,7 @@ public bool PositionalParameterUsed(CommandAst cmdAst) /// public CommandInfo GetCommandInfo(string name, CommandTypes commandType = CommandTypes.All) { - return Helper.Instance.MyCmdlet.InvokeCommand.GetCommand(name, commandType); + return this.invokeCommand.GetCommand(name, commandType); } /// @@ -914,7 +948,7 @@ public Tuple, List> SuppressRule(string { ruleSuppression.Error = String.Format(CultureInfo.CurrentCulture, Strings.RuleSuppressionErrorFormat, ruleSuppression.StartAttributeLine, System.IO.Path.GetFileName(diagnostics.First().Extent.File), String.Format(Strings.RuleSuppressionIDError, ruleSuppression.RuleSuppressionID)); - Helper.Instance.MyCmdlet.WriteError(new ErrorRecord(new ArgumentException(ruleSuppression.Error), ruleSuppression.Error, ErrorCategory.InvalidArgument, ruleSuppression)); + this.outputWriter.WriteError(new ErrorRecord(new ArgumentException(ruleSuppression.Error), ruleSuppression.Error, ErrorCategory.InvalidArgument, ruleSuppression)); } } diff --git a/Engine/IOutputWriter.cs b/Engine/IOutputWriter.cs new file mode 100644 index 000000000..8e3ee3a3b --- /dev/null +++ b/Engine/IOutputWriter.cs @@ -0,0 +1,52 @@ +// +// Copyright (c) Microsoft Corporation. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +using System.Management.Automation; + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer +{ + /// + /// Provides an interface for writing output to a PowerShell session. + /// + public interface IOutputWriter + { + /// + /// Writes an error to the session. + /// + /// The ErrorRecord to write. + void WriteError(ErrorRecord error); + + /// + /// Writes a warning to the session. + /// + /// The warning string to write. + void WriteWarning(string message); + + /// + /// Writes a verbose message to the session. + /// + /// The verbose message to write. + void WriteVerbose(string message); + + /// + /// Writes a debug message to the session. + /// + /// The debug message to write. + void WriteDebug(string message); + + /// + /// Throws a terminating error in the session. + /// + /// The ErrorRecord which describes the failure. + void ThrowTerminatingError(ErrorRecord record); + } +} diff --git a/Engine/ScriptAnalyzer.cs b/Engine/ScriptAnalyzer.cs index 24c2d0152..82e17f39e 100644 --- a/Engine/ScriptAnalyzer.cs +++ b/Engine/ScriptAnalyzer.cs @@ -24,14 +24,24 @@ using System.Management.Automation.Runspaces; using System.Reflection; using System.Globalization; +using System.Collections.Concurrent; +using System.Threading.Tasks; namespace Microsoft.Windows.PowerShell.ScriptAnalyzer { - internal class ScriptAnalyzer + public sealed class ScriptAnalyzer { - #region Private memebers + #region Private members + private IOutputWriter outputWriter; private CompositionContainer container; + Dictionary> validationResults = new Dictionary>(); + string[] includeRule; + string[] excludeRule; + string[] severity; + List includeRegexList; + List excludeRegexList; + bool suppressedOnly; #endregion @@ -74,7 +84,7 @@ public static ScriptAnalyzer Instance [ImportMany] public IEnumerable DSCResourceRules { get; private set; } - public List ExternalRules { get; private set; } + internal List ExternalRules { get; set; } #endregion @@ -83,43 +93,177 @@ public static ScriptAnalyzer Instance /// /// Initialize : Initializes default rules, loggers and helper. /// - public void Initialize() + internal void Initialize( + TCmdlet cmdlet, + string[] customizedRulePath = null, + string[] includeRuleNames = null, + string[] excludeRuleNames = null, + string[] severity = null, + bool suppressedOnly = false) + where TCmdlet : PSCmdlet, IOutputWriter { - // Clear external rules for each invoke. - ExternalRules = new List(); + if (cmdlet == null) + { + throw new ArgumentNullException("cmdlet"); + } - // Initialize helper - Helper.Instance.Initialize(); + this.Initialize( + cmdlet, + cmdlet.SessionState.Path, + cmdlet.SessionState.InvokeCommand, + customizedRulePath, + includeRuleNames, + excludeRuleNames, + severity, + suppressedOnly); + } - // An aggregate catalog that combines multiple catalogs. - using (AggregateCatalog catalog = new AggregateCatalog()) + /// + /// Initialize : Initializes default rules, loggers and helper. + /// + public void Initialize( + Runspace runspace, + IOutputWriter outputWriter, + string[] customizedRulePath = null, + string[] includeRuleNames = null, + string[] excludeRuleNames = null, + string[] severity = null, + bool suppressedOnly = false) + { + if (runspace == null) { - // Adds all the parts found in the same directory. - string dirName = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + throw new ArgumentNullException("runspace"); + } - // Assembly.GetExecutingAssembly().Location - catalog.Catalogs.Add(new DirectoryCatalog(dirName)); + this.Initialize( + outputWriter, + runspace.SessionStateProxy.Path, + runspace.SessionStateProxy.InvokeCommand, + customizedRulePath, + includeRuleNames, + excludeRuleNames, + severity, + suppressedOnly); + } - // Create the CompositionContainer with the parts in the catalog. - container = new CompositionContainer(catalog); + private void Initialize( + IOutputWriter outputWriter, + PathIntrinsics path, + CommandInvocationIntrinsics invokeCommand, + string[] customizedRulePath, + string[] includeRuleNames, + string[] excludeRuleNames, + string[] severity, + bool suppressedOnly = false) + { + if (outputWriter == null) + { + throw new ArgumentNullException("outputWriter"); + } - // Fill the imports of this object. - try + this.outputWriter = outputWriter; + + #region Verifies rule extensions and loggers path + + List paths = this.GetValidCustomRulePaths(customizedRulePath, path); + + #endregion + + #region Initializes Rules + + this.severity = severity; + this.suppressedOnly = suppressedOnly; + this.includeRule = includeRuleNames; + this.excludeRule = excludeRuleNames; + this.includeRegexList = new List(); + this.excludeRegexList = new List(); + + //Check wild card input for the Include/ExcludeRules and create regex match patterns + if (this.includeRule != null) + { + foreach (string rule in includeRule) { - container.ComposeParts(this); + Regex includeRegex = new Regex(String.Format("^{0}$", Regex.Escape(rule).Replace(@"\*", ".*")), RegexOptions.IgnoreCase); + this.includeRegexList.Add(includeRegex); } - catch (CompositionException compositionException) + } + if (this.excludeRule != null) + { + foreach (string rule in excludeRule) { - Console.WriteLine(compositionException.ToString()); + Regex excludeRegex = new Regex(String.Format("^{0}$", Regex.Escape(rule).Replace(@"\*", ".*")), RegexOptions.IgnoreCase); + this.excludeRegexList.Add(excludeRegex); } } + + try + { + this.LoadRules(this.validationResults, invokeCommand); + } + catch (Exception ex) + { + this.outputWriter.ThrowTerminatingError( + new ErrorRecord( + ex, + ex.HResult.ToString("X", CultureInfo.CurrentCulture), + ErrorCategory.NotSpecified, this)); + } + + #endregion + + #region Verify rules + + IEnumerable rules = + this.ScriptRules.Union( + this.TokenRules).Union( + this.ExternalRules ?? Enumerable.Empty()); + + // Ensure that rules were actually loaded + if (rules == null || rules.Count() == 0) + { + this.outputWriter.ThrowTerminatingError( + new ErrorRecord( + new Exception(), + string.Format( + CultureInfo.CurrentCulture, + Strings.RulesNotFound), + ErrorCategory.ResourceExists, + this)); + } + + #endregion } - /// - /// Initilaize : Initializes default rules, external rules and loggers. - /// - /// Path validation result. - public void Initilaize(Dictionary> result) + private List GetValidCustomRulePaths(string[] customizedRulePath, PathIntrinsics path) + { + List paths = new List(); + + if (customizedRulePath != null) + { + paths.AddRange( + customizedRulePath.ToList()); + } + + if (paths.Count > 0) + { + this.validationResults = this.CheckRuleExtension(paths.ToArray(), path); + foreach (string extension in this.validationResults["InvalidPaths"]) + { + this.outputWriter.WriteWarning(string.Format(CultureInfo.CurrentCulture, Strings.MissingRuleExtension, extension)); + } + } + else + { + this.validationResults = new Dictionary>(); + this.validationResults.Add("InvalidPaths", new List()); + this.validationResults.Add("ValidModPaths", new List()); + this.validationResults.Add("ValidDllPaths", new List()); + } + + return paths; + } + + private void LoadRules(Dictionary> result, CommandInvocationIntrinsics invokeCommand) { List paths = new List(); @@ -127,8 +271,12 @@ public void Initilaize(Dictionary> result) ExternalRules = new List(); // Initialize helper + Helper.Instance = new Helper(invokeCommand, this.outputWriter); Helper.Instance.Initialize(); + // Clear external rules for each invoke. + ExternalRules = new List(); + // An aggregate catalog that combines multiple catalogs. using (AggregateCatalog catalog = new AggregateCatalog()) { @@ -140,7 +288,7 @@ public void Initilaize(Dictionary> result) paths = result.ContainsKey("ValidDllPaths") ? result["ValidDllPaths"] : result["ValidPaths"]; foreach (string path in paths) { - if (String.Equals(Path.GetExtension(path),".dll",StringComparison.OrdinalIgnoreCase)) + if (String.Equals(Path.GetExtension(path), ".dll", StringComparison.OrdinalIgnoreCase)) { catalog.Catalogs.Add(new AssemblyCatalog(path)); } @@ -160,7 +308,7 @@ public void Initilaize(Dictionary> result) } catch (CompositionException compositionException) { - Console.WriteLine(compositionException.ToString()); + this.outputWriter.WriteWarning(compositionException.ToString()); } } @@ -169,6 +317,18 @@ public void Initilaize(Dictionary> result) ExternalRules = GetExternalRule(result["ValidModPaths"].ToArray()); } + internal string[] GetValidModulePaths() + { + List validModulePaths = null; + + if (!this.validationResults.TryGetValue("ValidModPaths", out validModulePaths)) + { + validModulePaths = new List(); + } + + return validModulePaths.ToArray(); + } + public IEnumerable GetRule(string[] moduleNames, string[] ruleNames) { IEnumerable results = null; @@ -196,9 +356,9 @@ public IEnumerable GetRule(string[] moduleNames, string[] ruleNames) } results = from rule in rules - from regex in regexList - where regex.IsMatch(rule.GetName()) - select rule; + from regex in regexList + where regex.IsMatch(rule.GetName()) + select rule; } else { @@ -208,7 +368,7 @@ where regex.IsMatch(rule.GetName()) return results; } - public List GetExternalRule(string[] moduleNames) + private List GetExternalRule(string[] moduleNames) { List rules = new List(); @@ -247,15 +407,15 @@ public List GetExternalRule(string[] moduleNames) ParameterMetadata param = funcInfo.Parameters.Values .First(item => item.Name.EndsWith("ast", StringComparison.OrdinalIgnoreCase) || item.Name.EndsWith("token", StringComparison.OrdinalIgnoreCase)); - + //Only add functions that are defined as rules. if (param != null) { script = string.Format(CultureInfo.CurrentCulture, "(Get-Help -Name {0}).Description | Out-String", funcInfo.Name); - string desc =posh.AddScript(script).Invoke()[0].ImmediateBaseObject.ToString() + string desc = posh.AddScript(script).Invoke()[0].ImmediateBaseObject.ToString() .Replace("\r\n", " ").Trim(); - rules.Add(new ExternalRule(funcInfo.Name, funcInfo.Name, desc, param.Name,param.ParameterType.FullName, + rules.Add(new ExternalRule(funcInfo.Name, funcInfo.Name, desc, param.Name, param.ParameterType.FullName, funcInfo.ModuleName, funcInfo.Module.Path)); } } @@ -274,7 +434,7 @@ public List GetExternalRule(string[] moduleNames) /// /// /// - public IEnumerable GetExternalRecord(Ast ast, Token[] token, ExternalRule[] rules, InvokeScriptAnalyzerCommand command, string filePath) + internal IEnumerable GetExternalRecord(Ast ast, Token[] token, ExternalRule[] rules, string filePath) { // Defines InitialSessionState. InitialSessionState state = InitialSessionState.CreateDefault2(); @@ -341,7 +501,7 @@ public IEnumerable GetExternalRecord(Ast ast, Token[] token, E { // Find all AstTypes that appeared in rule groups. IEnumerable childAsts = ast.FindAll(new Func((testAst) => - (astRuleGroup.Key.IndexOf(testAst.GetType().FullName,StringComparison.OrdinalIgnoreCase) != -1)), false); + (astRuleGroup.Key.IndexOf(testAst.GetType().FullName, StringComparison.OrdinalIgnoreCase) != -1)), false); foreach (Ast childAst in childAsts) { @@ -393,7 +553,7 @@ public IEnumerable GetExternalRecord(Ast ast, Token[] token, E if (psobject.ImmediateBaseObject is ErrorRecord) { ErrorRecord record = (ErrorRecord)psobject.ImmediateBaseObject; - command.WriteError(record); + this.outputWriter.WriteError(record); continue; } @@ -407,7 +567,7 @@ public IEnumerable GetExternalRecord(Ast ast, Token[] token, E } catch (Exception ex) { - command.WriteError(new ErrorRecord(ex, ex.HResult.ToString("X"), ErrorCategory.NotSpecified, this)); + this.outputWriter.WriteError(new ErrorRecord(ex, ex.HResult.ToString("X"), ErrorCategory.NotSpecified, this)); continue; } @@ -420,9 +580,9 @@ public IEnumerable GetExternalRecord(Ast ast, Token[] token, E } } //Catch exception where customized defined rules have exceptins when doing invoke - catch(Exception ex) + catch (Exception ex) { - command.WriteError(new ErrorRecord(ex, ex.HResult.ToString("X"), ErrorCategory.NotSpecified, this)); + this.outputWriter.WriteError(new ErrorRecord(ex, ex.HResult.ToString("X"), ErrorCategory.NotSpecified, this)); } return diagnostics; @@ -430,7 +590,7 @@ public IEnumerable GetExternalRecord(Ast ast, Token[] token, E } } - public Dictionary> CheckRuleExtension(string[] path, PSCmdlet cmdlet) + public Dictionary> CheckRuleExtension(string[] path, PathIntrinsics basePath) { Dictionary> results = new Dictionary>(); @@ -443,7 +603,7 @@ public Dictionary> CheckRuleExtension(string[] path, PSCmdl { try { - cmdlet.WriteVerbose(string.Format(CultureInfo.CurrentCulture, Strings.CheckModuleName, childPath)); + this.outputWriter.WriteVerbose(string.Format(CultureInfo.CurrentCulture, Strings.CheckModuleName, childPath)); string resolvedPath = string.Empty; @@ -458,7 +618,7 @@ public Dictionary> CheckRuleExtension(string[] path, PSCmdl } else { - resolvedPath = cmdlet.SessionState.Path + resolvedPath = basePath .GetResolvedPSPathFromPSPath(childPath).First().ToString(); } @@ -491,12 +651,12 @@ public Dictionary> CheckRuleExtension(string[] path, PSCmdl { try { - string resolvedPath = cmdlet.SessionState.Path + string resolvedPath = basePath .GetResolvedPSPathFromPSPath(childPath).First().ToString(); - cmdlet.WriteDebug(string.Format(CultureInfo.CurrentCulture, Strings.CheckAssemblyFile, resolvedPath)); + this.outputWriter.WriteDebug(string.Format(CultureInfo.CurrentCulture, Strings.CheckAssemblyFile, resolvedPath)); - if (String.Equals(Path.GetExtension(resolvedPath),".dll", StringComparison.OrdinalIgnoreCase)) + if (String.Equals(Path.GetExtension(resolvedPath), ".dll", StringComparison.OrdinalIgnoreCase)) { if (!File.Exists(resolvedPath)) { @@ -526,12 +686,12 @@ public Dictionary> CheckRuleExtension(string[] path, PSCmdl { for (int i = 0; i < validModPaths.Count; i++) { - validModPaths[i] = cmdlet.SessionState.Path + validModPaths[i] = basePath .GetResolvedPSPathFromPSPath(validModPaths[i]).First().ToString(); } for (int i = 0; i < validDllPaths.Count; i++) { - validDllPaths[i] = cmdlet.SessionState.Path + validDllPaths[i] = basePath .GetResolvedPSPathFromPSPath(validDllPaths[i]).First().ToString(); } } @@ -550,5 +710,470 @@ public Dictionary> CheckRuleExtension(string[] path, PSCmdl } #endregion + + + /// + /// Analyzes a script file or a directory containing script files. + /// + /// The path of the file or directory to analyze. + /// + /// If true, recursively searches the given file path and analyzes any + /// script files that are found. + /// + /// An enumeration of DiagnosticRecords that were found by rules. + public IEnumerable AnalyzePath(string path, bool searchRecursively = false) + { + List scriptFilePaths = new List(); + + if (path == null) + { + this.outputWriter.ThrowTerminatingError( + new ErrorRecord( + new FileNotFoundException(), + string.Format(CultureInfo.CurrentCulture, Strings.FileNotFound, path), + ErrorCategory.InvalidArgument, + this)); + } + + // Precreate the list of script file paths to analyze. This + // is an optimization over doing the whole operation at once + // and calling .Concat on IEnumerables to join results. + this.BuildScriptPathList(path, searchRecursively, scriptFilePaths); + + foreach (string scriptFilePath in scriptFilePaths) + { + // Yield each record in the result so that the + // caller can pull them one at a time + foreach (var diagnosticRecord in this.AnalyzeFile(scriptFilePath)) + { + yield return diagnosticRecord; + } + } + } + + private void BuildScriptPathList( + string path, + bool searchRecursively, + IList scriptFilePaths) + { + const string ps1Suffix = "ps1"; + const string psm1Suffix = "psm1"; + const string psd1Suffix = "psd1"; + + if (Directory.Exists(path)) + { + if (searchRecursively) + { + foreach (string filePath in Directory.GetFiles(path)) + { + this.BuildScriptPathList(filePath, searchRecursively, scriptFilePaths); + } + foreach (string filePath in Directory.GetDirectories(path)) + { + this.BuildScriptPathList(filePath, searchRecursively, scriptFilePaths); + } + } + else + { + foreach (string filePath in Directory.GetFiles(path)) + { + this.BuildScriptPathList(filePath, searchRecursively, scriptFilePaths); + } + } + } + else if (File.Exists(path)) + { + if ((path.Length > ps1Suffix.Length && path.Substring(path.Length - ps1Suffix.Length).Equals(ps1Suffix, StringComparison.OrdinalIgnoreCase)) || + (path.Length > psm1Suffix.Length && path.Substring(path.Length - psm1Suffix.Length).Equals(psm1Suffix, StringComparison.OrdinalIgnoreCase)) || + (path.Length > psd1Suffix.Length && path.Substring(path.Length - psd1Suffix.Length).Equals(psd1Suffix, StringComparison.OrdinalIgnoreCase))) + { + scriptFilePaths.Add(path); + } + } + else + { + this.outputWriter.WriteError( + new ErrorRecord( + new FileNotFoundException(), + string.Format(CultureInfo.CurrentCulture, Strings.FileNotFound, path), + ErrorCategory.InvalidArgument, + this)); + } + } + + private IEnumerable AnalyzeFile(string filePath) + { + ScriptBlockAst scriptAst = null; + Token[] scriptTokens = null; + ParseError[] errors; + + this.outputWriter.WriteVerbose(string.Format(CultureInfo.CurrentCulture, Strings.VerboseFileMessage, filePath)); + + //Parse the file + if (File.Exists(filePath)) + { + scriptAst = Parser.ParseFile(filePath, out scriptTokens, out errors); + } + else + { + this.outputWriter.ThrowTerminatingError(new ErrorRecord(new FileNotFoundException(), + string.Format(CultureInfo.CurrentCulture, Strings.InvalidPath, filePath), + ErrorCategory.InvalidArgument, filePath)); + + return null; + } + + if (errors != null && errors.Length > 0) + { + foreach (ParseError error in errors) + { + string parseErrorMessage = String.Format(CultureInfo.CurrentCulture, Strings.ParserErrorFormat, error.Extent.File, error.Message.TrimEnd('.'), error.Extent.StartLineNumber, error.Extent.StartColumnNumber); + this.outputWriter.WriteError(new ErrorRecord(new ParseException(parseErrorMessage), parseErrorMessage, ErrorCategory.ParserError, error.ErrorId)); + } + } + + if (errors.Length > 10) + { + string manyParseErrorMessage = String.Format(CultureInfo.CurrentCulture, Strings.ParserErrorMessage, System.IO.Path.GetFileName(filePath)); + this.outputWriter.WriteError(new ErrorRecord(new ParseException(manyParseErrorMessage), manyParseErrorMessage, ErrorCategory.ParserError, filePath)); + + return new List(); + } + + return this.AnalyzeSyntaxTree(scriptAst, scriptTokens, filePath); + } + + /// + /// Analyzes the syntax tree of a script file that has already been parsed. + /// + /// The ScriptBlockAst from the parsed script. + /// The tokens found in the script. + /// The path to the file that was parsed. + /// An enumeration of DiagnosticRecords that were found by rules. + public IEnumerable AnalyzeSyntaxTree( + ScriptBlockAst scriptAst, + Token[] scriptTokens, + string filePath) + { + Dictionary> ruleSuppressions; + ConcurrentBag diagnostics = new ConcurrentBag(); + ConcurrentBag suppressed = new ConcurrentBag(); + BlockingCollection> verboseOrErrors = new BlockingCollection>(); + + // Use a List of KVP rather than dictionary, since for a script containing inline functions with same signature, keys clash + List> cmdInfoTable = new List>(); + + ruleSuppressions = Helper.Instance.GetRuleSuppression(scriptAst); + + foreach (List ruleSuppressionsList in ruleSuppressions.Values) + { + foreach (RuleSuppression ruleSuppression in ruleSuppressionsList) + { + if (!String.IsNullOrWhiteSpace(ruleSuppression.Error)) + { + this.outputWriter.WriteError(new ErrorRecord(new ArgumentException(ruleSuppression.Error), ruleSuppression.Error, ErrorCategory.InvalidArgument, ruleSuppression)); + } + } + } + + #region Run VariableAnalysis + try + { + Helper.Instance.InitializeVariableAnalysis(scriptAst); + } + catch { } + #endregion + + Helper.Instance.Tokens = scriptTokens; + + #region Run ScriptRules + //Trim down to the leaf element of the filePath and pass it to Diagnostic Record + string fileName = System.IO.Path.GetFileName(filePath); + + if (this.ScriptRules != null) + { + var tasks = this.ScriptRules.Select(scriptRule => Task.Factory.StartNew(() => + { + bool includeRegexMatch = false; + bool excludeRegexMatch = false; + + foreach (Regex include in includeRegexList) + { + if (include.IsMatch(scriptRule.GetName())) + { + includeRegexMatch = true; + break; + } + } + + foreach (Regex exclude in excludeRegexList) + { + if (exclude.IsMatch(scriptRule.GetName())) + { + excludeRegexMatch = true; + break; + } + } + + if ((includeRule == null || includeRegexMatch) && (excludeRule == null || !excludeRegexMatch)) + { + List result = new List(); + + result.Add(string.Format(CultureInfo.CurrentCulture, Strings.VerboseRunningMessage, scriptRule.GetName())); + + // Ensure that any unhandled errors from Rules are converted to non-terminating errors + // We want the Engine to continue functioning even if one or more Rules throws an exception + try + { + var records = Helper.Instance.SuppressRule(scriptRule.GetName(), ruleSuppressions, scriptRule.AnalyzeScript(scriptAst, scriptAst.Extent.File).ToList()); + foreach (var record in records.Item2) + { + diagnostics.Add(record); + } + foreach (var suppressedRec in records.Item1) + { + suppressed.Add(suppressedRec); + } + } + catch (Exception scriptRuleException) + { + result.Add(new ErrorRecord(scriptRuleException, Strings.RuleErrorMessage, ErrorCategory.InvalidOperation, scriptAst.Extent.File)); + } + + verboseOrErrors.Add(result); + } + })); + + Task.Factory.ContinueWhenAll(tasks.ToArray(), t => verboseOrErrors.CompleteAdding()); + + while (!verboseOrErrors.IsCompleted) + { + List data = null; + try + { + data = verboseOrErrors.Take(); + } + catch (InvalidOperationException) { } + + if (data != null) + { + this.outputWriter.WriteVerbose(data[0] as string); + if (data.Count == 2) + { + this.outputWriter.WriteError(data[1] as ErrorRecord); + } + } + } + } + + #endregion + + #region Run Token Rules + + if (this.TokenRules != null) + { + foreach (ITokenRule tokenRule in this.TokenRules) + { + bool includeRegexMatch = false; + bool excludeRegexMatch = false; + foreach (Regex include in includeRegexList) + { + if (include.IsMatch(tokenRule.GetName())) + { + includeRegexMatch = true; + break; + } + } + foreach (Regex exclude in excludeRegexList) + { + if (exclude.IsMatch(tokenRule.GetName())) + { + excludeRegexMatch = true; + break; + } + } + if ((includeRule == null || includeRegexMatch) && (excludeRule == null || !excludeRegexMatch)) + { + this.outputWriter.WriteVerbose(string.Format(CultureInfo.CurrentCulture, Strings.VerboseRunningMessage, tokenRule.GetName())); + + // Ensure that any unhandled errors from Rules are converted to non-terminating errors + // We want the Engine to continue functioning even if one or more Rules throws an exception + try + { + var records = Helper.Instance.SuppressRule(tokenRule.GetName(), ruleSuppressions, tokenRule.AnalyzeTokens(scriptTokens, filePath).ToList()); + foreach (var record in records.Item2) + { + diagnostics.Add(record); + } + foreach (var suppressedRec in records.Item1) + { + suppressed.Add(suppressedRec); + } + } + catch (Exception tokenRuleException) + { + this.outputWriter.WriteError(new ErrorRecord(tokenRuleException, Strings.RuleErrorMessage, ErrorCategory.InvalidOperation, fileName)); + } + } + } + } + + #endregion + + #region DSC Resource Rules + if (this.DSCResourceRules != null) + { + // Invoke AnalyzeDSCClass only if the ast is a class based resource + if (Helper.Instance.IsDscResourceClassBased(scriptAst)) + { + // Run DSC Class rule + foreach (IDSCResourceRule dscResourceRule in this.DSCResourceRules) + { + bool includeRegexMatch = false; + bool excludeRegexMatch = false; + + foreach (Regex include in includeRegexList) + { + if (include.IsMatch(dscResourceRule.GetName())) + { + includeRegexMatch = true; + break; + } + } + + foreach (Regex exclude in excludeRegexList) + { + if (exclude.IsMatch(dscResourceRule.GetName())) + { + excludeRegexMatch = true; + break; + } + } + + if ((includeRule == null || includeRegexMatch) && (excludeRule == null || excludeRegexMatch)) + { + this.outputWriter.WriteVerbose(string.Format(CultureInfo.CurrentCulture, Strings.VerboseRunningMessage, dscResourceRule.GetName())); + + // Ensure that any unhandled errors from Rules are converted to non-terminating errors + // We want the Engine to continue functioning even if one or more Rules throws an exception + try + { + var records = Helper.Instance.SuppressRule(dscResourceRule.GetName(), ruleSuppressions, dscResourceRule.AnalyzeDSCClass(scriptAst, filePath).ToList()); + foreach (var record in records.Item2) + { + diagnostics.Add(record); + } + foreach (var suppressedRec in records.Item1) + { + suppressed.Add(suppressedRec); + } + } + catch (Exception dscResourceRuleException) + { + this.outputWriter.WriteError(new ErrorRecord(dscResourceRuleException, Strings.RuleErrorMessage, ErrorCategory.InvalidOperation, filePath)); + } + } + } + } + + // Check if the supplied artifact is indeed part of the DSC resource + if (Helper.Instance.IsDscResourceModule(filePath)) + { + // Run all DSC Rules + foreach (IDSCResourceRule dscResourceRule in this.DSCResourceRules) + { + bool includeRegexMatch = false; + bool excludeRegexMatch = false; + foreach (Regex include in includeRegexList) + { + if (include.IsMatch(dscResourceRule.GetName())) + { + includeRegexMatch = true; + break; + } + } + foreach (Regex exclude in excludeRegexList) + { + if (exclude.IsMatch(dscResourceRule.GetName())) + { + excludeRegexMatch = true; + } + } + if ((includeRule == null || includeRegexMatch) && (excludeRule == null || !excludeRegexMatch)) + { + this.outputWriter.WriteVerbose(string.Format(CultureInfo.CurrentCulture, Strings.VerboseRunningMessage, dscResourceRule.GetName())); + + // Ensure that any unhandled errors from Rules are converted to non-terminating errors + // We want the Engine to continue functioning even if one or more Rules throws an exception + try + { + var records = Helper.Instance.SuppressRule(dscResourceRule.GetName(), ruleSuppressions, dscResourceRule.AnalyzeDSCResource(scriptAst, filePath).ToList()); + foreach (var record in records.Item2) + { + diagnostics.Add(record); + } + foreach (var suppressedRec in records.Item1) + { + suppressed.Add(suppressedRec); + } + } + catch (Exception dscResourceRuleException) + { + this.outputWriter.WriteError(new ErrorRecord(dscResourceRuleException, Strings.RuleErrorMessage, ErrorCategory.InvalidOperation, filePath)); + } + } + } + + } + } + #endregion + + #region Run External Rules + + if (this.ExternalRules != null) + { + List exRules = new List(); + + foreach (ExternalRule exRule in this.ExternalRules) + { + if ((includeRule == null || includeRule.Contains(exRule.GetName(), StringComparer.OrdinalIgnoreCase)) && + (excludeRule == null || !excludeRule.Contains(exRule.GetName(), StringComparer.OrdinalIgnoreCase))) + { + string ruleName = string.Format(CultureInfo.CurrentCulture, "{0}\\{1}", exRule.GetSourceName(), exRule.GetName()); + this.outputWriter.WriteVerbose(string.Format(CultureInfo.CurrentCulture, Strings.VerboseRunningMessage, ruleName)); + + // Ensure that any unhandled errors from Rules are converted to non-terminating errors + // We want the Engine to continue functioning even if one or more Rules throws an exception + try + { + exRules.Add(exRule); + } + catch (Exception externalRuleException) + { + this.outputWriter.WriteError(new ErrorRecord(externalRuleException, Strings.RuleErrorMessage, ErrorCategory.InvalidOperation, fileName)); + } + } + } + + foreach (var record in this.GetExternalRecord(scriptAst, scriptTokens, exRules.ToArray(), fileName)) + { + diagnostics.Add(record); + } + } + + #endregion + + IEnumerable diagnosticsList = diagnostics; + + if (severity != null) + { + var diagSeverity = severity.Select(item => Enum.Parse(typeof(DiagnosticSeverity), item, true)); + diagnosticsList = diagnostics.Where(item => diagSeverity.Contains(item.Severity)); + } + + return this.suppressedOnly ? + suppressed.OfType() : + diagnosticsList; + } } } diff --git a/Engine/ScriptAnalyzerEngine.csproj b/Engine/ScriptAnalyzerEngine.csproj index 06f02c73e..912072190 100644 --- a/Engine/ScriptAnalyzerEngine.csproj +++ b/Engine/ScriptAnalyzerEngine.csproj @@ -69,6 +69,7 @@ + diff --git a/Tests/Engine/CustomizedRule.tests.ps1 b/Tests/Engine/CustomizedRule.tests.ps1 index f7fac7213..cf87ed818 100644 --- a/Tests/Engine/CustomizedRule.tests.ps1 +++ b/Tests/Engine/CustomizedRule.tests.ps1 @@ -1,4 +1,11 @@ -Import-Module PSScriptAnalyzer +# Check if PSScriptAnalyzer is already loaded so we don't +# overwrite a test version of Invoke-ScriptAnalyzer by +# accident +if (!(Get-Module PSScriptAnalyzer) -and !$testingLibraryUsage) +{ + Import-Module PSScriptAnalyzer +} + $directory = Split-Path -Parent $MyInvocation.MyCommand.Path $message = "this is help" $measure = "Measure-RequiresRunAsAdministrator" diff --git a/Tests/Engine/InvokeScriptAnalyzer.tests.ps1 b/Tests/Engine/InvokeScriptAnalyzer.tests.ps1 index ad4c411b2..05a008c48 100644 --- a/Tests/Engine/InvokeScriptAnalyzer.tests.ps1 +++ b/Tests/Engine/InvokeScriptAnalyzer.tests.ps1 @@ -1,4 +1,11 @@ -Import-Module PSScriptAnalyzer +# Check if PSScriptAnalyzer is already loaded so we don't +# overwrite a test version of Invoke-ScriptAnalyzer by +# accident +if (!(Get-Module PSScriptAnalyzer) -and !$testingLibraryUsage) +{ + Import-Module PSScriptAnalyzer +} + $sa = Get-Command Invoke-ScriptAnalyzer $directory = Split-Path -Parent $MyInvocation.MyCommand.Path $singularNouns = "PSUseSingularNouns" @@ -216,7 +223,17 @@ Describe "Test CustomizedRulePath" { Context "When used incorrectly" { It "file cannot be found" { $wrongRule = Invoke-ScriptAnalyzer $directory\TestScript.ps1 -CustomizedRulePath "This is a wrong rule" 3>&1 | Select-Object -First 1 - $wrongRule | Should Match "Cannot find rule extension 'This is a wrong rule'." + + if ($testingLibraryUsage) + { + # Special case for library usage testing: warning output written + # with PSHost.UI.WriteWarningLine does not get redirected correctly + # so we can't use this approach for checking the warning message. + # Instead, reach into the test IOutputWriter implementation to find it. + $wrongRule = $testOutputWriter.MostRecentWarningMessage + } + + $wrongRule | Should Match "Cannot find rule extension 'This is a wrong rule'." } } } \ No newline at end of file diff --git a/Tests/Engine/LibraryUsage.tests.ps1 b/Tests/Engine/LibraryUsage.tests.ps1 new file mode 100644 index 000000000..37137c832 --- /dev/null +++ b/Tests/Engine/LibraryUsage.tests.ps1 @@ -0,0 +1,125 @@ +Import-Module PSScriptAnalyzer + +$directory = Split-Path -Parent $MyInvocation.MyCommand.Path + +# Overwrite Invoke-ScriptAnalyzer with a version that +# wraps the usage of ScriptAnalyzer as a .NET library +function Invoke-ScriptAnalyzer { + param ( + [parameter(Mandatory = $true, Position = 0)] + [Alias("PSPath")] + [string] $Path, + + [Parameter(Mandatory = $false)] + [string[]] $CustomizedRulePath = $null, + + [Parameter(Mandatory=$false)] + [string[]] $ExcludeRule = $null, + + [Parameter(Mandatory = $false)] + [string[]] $IncludeRule = $null, + + [ValidateSet("Warning", "Error", "Information", IgnoreCase = $true)] + [Parameter(Mandatory = $false)] + [string[]] $Severity = $null, + + [Parameter(Mandatory = $false)] + [switch] $Recurse, + + [Parameter(Mandatory = $false)] + [switch] $SuppressedOnly + ) + + $scriptAnalyzer = New-Object "Microsoft.Windows.PowerShell.ScriptAnalyzer.ScriptAnalyzer" + $scriptAnalyzer.Initialize( + $runspace, + $testOutputWriter, + $CustomizedRulePath, + $IncludeRule, + $ExcludeRule, + $Severity, + $SuppressedOnly.IsPresent + ); + + return $scriptAnalyzer.AnalyzePath($Path, $Recurse.IsPresent); +} + +# Define an implementation of the IOutputWriter interface +Add-Type -Language CSharp @" +using System.Management.Automation; +using System.Management.Automation.Host; +using Microsoft.Windows.PowerShell.ScriptAnalyzer; + +public class PesterTestOutputWriter : IOutputWriter +{ + private PSHost psHost; + + public string MostRecentWarningMessage { get; private set; } + + public static PesterTestOutputWriter Create(PSHost psHost) + { + PesterTestOutputWriter testOutputWriter = new PesterTestOutputWriter(); + testOutputWriter.psHost = psHost; + return testOutputWriter; + } + + public void WriteError(ErrorRecord error) + { + // We don't write errors to avoid misleading + // error messages in test output + } + + public void WriteWarning(string message) + { + psHost.UI.WriteWarningLine(message); + + this.MostRecentWarningMessage = message; + } + + public void WriteVerbose(string message) + { + // We don't write verbose output to avoid lots + // of unnecessary messages in test output + } + + public void WriteDebug(string message) + { + psHost.UI.WriteDebugLine(message); + } + + public void ThrowTerminatingError(ErrorRecord record) + { + throw new RuntimeException( + "Test failed due to terminating error: \r\n" + record.ToString(), + null, + record); + } +} +"@ -ReferencedAssemblies "Microsoft.Windows.PowerShell.ScriptAnalyzer" -ErrorAction SilentlyContinue + +if ($testOutputWriter -eq $null) +{ + $testOutputWriter = [PesterTestOutputWriter]::Create($Host); +} + +# Create a fresh runspace to pass into the ScriptAnalyzer class +$initialSessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault2(); +$runspace = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace([System.Management.Automation.Host.PSHost]$Host, [System.Management.Automation.Runspaces.InitialSessionState]$initialSessionState); +$runspace.Open(); + +# Let other test scripts know we are testing library usage now +$testingLibraryUsage = $true + +# Invoke existing test files that use Invoke-ScriptAnalyzer +. $directory\InvokeScriptAnalyzer.tests.ps1 +. $directory\RuleSuppression.tests.ps1 +. $directory\CustomizedRule.tests.ps1 + +# We're done testing library usage +$testingLibraryUsage = $false + +# Clean up the test runspace +$runspace.Dispose(); + +# Re-import the PSScriptAnalyzer module to overwrite the library test cmdlet +Import-Module PSScriptAnalyzer diff --git a/Tests/Engine/RuleSuppression.tests.ps1 b/Tests/Engine/RuleSuppression.tests.ps1 index 17d8a719d..de32e5a94 100644 --- a/Tests/Engine/RuleSuppression.tests.ps1 +++ b/Tests/Engine/RuleSuppression.tests.ps1 @@ -1,4 +1,11 @@ -Import-Module -Verbose PSScriptAnalyzer +# Check if PSScriptAnalyzer is already loaded so we don't +# overwrite a test version of Invoke-ScriptAnalyzer by +# accident +if (!(Get-Module PSScriptAnalyzer) -and !$testingLibraryUsage) +{ + Import-Module -Verbose PSScriptAnalyzer +} + $directory = Split-Path -Parent $MyInvocation.MyCommand.Path $violations = Invoke-ScriptAnalyzer $directory\RuleSuppression.ps1