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 3c2ce5c2c..a5e26405d 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 /// @@ -123,86 +123,35 @@ public SwitchParameter SuppressedOnly } private bool suppressedOnly; - #endregion Parameters - - #region Private Members - - Dictionary> validationResults = new Dictionary>(); - private ScriptBlockAst ast = null; - private IEnumerable rules = null; + /// + /// Returns path to the file that contains user profile for ScriptAnalyzer + /// + [Parameter(Mandatory = false)] + [ValidateNotNull] + public string Profile + { + get { return profile; } + set { profile = value; } + } + private string profile; - #endregion + #endregion Parameters - #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, + this.profile); } /// @@ -221,452 +170,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 966876051..89a33b354 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.Alias | CommandTypes.Cmdlet | CommandTypes.Configuration | CommandTypes.ExternalScript | CommandTypes.Filter | CommandTypes.Function | CommandTypes.Script | CommandTypes.Workflow) { - 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/SafeDirectoryCatalog.cs b/Engine/SafeDirectoryCatalog.cs new file mode 100644 index 000000000..26879824d --- /dev/null +++ b/Engine/SafeDirectoryCatalog.cs @@ -0,0 +1,74 @@ +// +// 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; +using System.ComponentModel.Composition; +using System.ComponentModel.Composition.Hosting; +using System.IO; +using System.Linq; +using System.Reflection; + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer +{ + /// + /// Provides a simple DirectoryCatalog implementation that doesn't + /// fail assembly loading when it encounters a ReflectionTypeLoadException. + /// The default DirectoryCatalog implementation stops evaluating any + /// remaining assemblies in the directory if one of these exceptions + /// are encountered. + /// + internal class SafeDirectoryCatalog : AggregateCatalog + { + public SafeDirectoryCatalog(string folderLocation, IOutputWriter outputWriter) + { + if (outputWriter == null) + { + throw new ArgumentNullException("outputWriter"); + } + + // Make sure the directory actually exists + var directoryInfo = new DirectoryInfo(folderLocation); + if (directoryInfo.Exists == false) + { + throw new CompositionException( + "The specified folder does not exist: " + directoryInfo.FullName); + } + + // Load each DLL found in the directory + foreach (var dllFile in directoryInfo.GetFileSystemInfos("*.dll")) + { + try + { + // Attempt to create an AssemblyCatalog for this DLL + var assemblyCatalog = + new AssemblyCatalog( + Assembly.LoadFile( + dllFile.FullName)); + + // We must call ToArray here to pre-initialize the Parts + // IEnumerable and cause it to be stored. The result is + // not used here because it will be accessed later once + // the composition container starts assembling parts. + assemblyCatalog.Parts.ToArray(); + + this.Catalogs.Add(assemblyCatalog); + } + catch (ReflectionTypeLoadException e) + { + // Write out the exception details and allow the + // loading process to continue + outputWriter.WriteWarning(e.ToString()); + } + } + } + } +} diff --git a/Engine/ScriptAnalyzer.cs b/Engine/ScriptAnalyzer.cs index 24c2d0152..55b11ebb1 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,70 +93,360 @@ 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, + string profile = null) + 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, + profile); + } - // 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, + string profile = null) + { + if (runspace == null) { - // Adds all the parts found in the same directory. - string dirName = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + throw new ArgumentNullException("runspace"); + } + + this.Initialize( + outputWriter, + runspace.SessionStateProxy.Path, + runspace.SessionStateProxy.InvokeCommand, + customizedRulePath, + includeRuleNames, + excludeRuleNames, + severity, + suppressedOnly, + profile); + } - // Assembly.GetExecutingAssembly().Location - catalog.Catalogs.Add(new DirectoryCatalog(dirName)); + private void Initialize( + IOutputWriter outputWriter, + PathIntrinsics path, + CommandInvocationIntrinsics invokeCommand, + string[] customizedRulePath, + string[] includeRuleNames, + string[] excludeRuleNames, + string[] severity, + bool suppressedOnly = false, + string profile = null) + { + if (outputWriter == null) + { + throw new ArgumentNullException("outputWriter"); + } - // Create the CompositionContainer with the parts in the catalog. - container = new CompositionContainer(catalog); + this.outputWriter = outputWriter; - // Fill the imports of this object. + #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(); + + if (!String.IsNullOrWhiteSpace(profile)) + { try { - container.ComposeParts(this); + profile = path.GetResolvedPSPathFromPSPath(profile).First().Path; } - catch (CompositionException compositionException) + catch { - Console.WriteLine(compositionException.ToString()); + this.outputWriter.WriteError(new ErrorRecord(new FileNotFoundException(), + string.Format(CultureInfo.CurrentCulture, Strings.FileNotFound, profile), + ErrorCategory.InvalidArgument, this)); + } + + if (File.Exists(profile)) + { + Token[] parserTokens = null; + ParseError[] parserErrors = null; + Ast profileAst = Parser.ParseFile(profile, out parserTokens, out parserErrors); + IEnumerable hashTableAsts = profileAst.FindAll(item => item is HashtableAst, false); + foreach (HashtableAst hashTableAst in hashTableAsts) + { + foreach (var kvp in hashTableAst.KeyValuePairs) + { + if (!(kvp.Item1 is StringConstantExpressionAst)) + { + this.outputWriter.WriteError(new ErrorRecord(new ArgumentException(), + string.Format(CultureInfo.CurrentCulture, Strings.WrongKeyFormat, kvp.Item1.Extent.StartLineNumber, kvp.Item1.Extent.StartColumnNumber, profile), + ErrorCategory.InvalidArgument, this)); + continue; + } + + // parse the item2 as array + PipelineAst pipeAst = kvp.Item2 as PipelineAst; + List rhsList = new List(); + if (pipeAst != null) + { + ExpressionAst pureExp = pipeAst.GetPureExpression(); + if (pureExp is StringConstantExpressionAst) + { + rhsList.Add((pureExp as StringConstantExpressionAst).Value); + } + else + { + ArrayLiteralAst arrayLitAst = pureExp as ArrayLiteralAst; + if (arrayLitAst == null && pureExp is ArrayExpressionAst) + { + ArrayExpressionAst arrayExp = pureExp as ArrayExpressionAst; + // Statements property is never null + if (arrayExp.SubExpression != null) + { + StatementAst stateAst = arrayExp.SubExpression.Statements.First(); + if (stateAst != null && stateAst is PipelineAst) + { + CommandBaseAst cmdBaseAst = (stateAst as PipelineAst).PipelineElements.First(); + if (cmdBaseAst != null && cmdBaseAst is CommandExpressionAst) + { + CommandExpressionAst cmdExpAst = cmdBaseAst as CommandExpressionAst; + if (cmdExpAst.Expression is StringConstantExpressionAst) + { + rhsList.Add((cmdExpAst.Expression as StringConstantExpressionAst).Value); + } + else + { + arrayLitAst = cmdExpAst.Expression as ArrayLiteralAst; + } + } + } + } + } + + if (arrayLitAst != null) + { + foreach (var element in arrayLitAst.Elements) + { + if (!(element is StringConstantExpressionAst)) + { + this.outputWriter.WriteError(new ErrorRecord(new ArgumentException(), + string.Format(CultureInfo.CurrentCulture, Strings.WrongValueFormat, element.Extent.StartLineNumber, element.Extent.StartColumnNumber, profile), + ErrorCategory.InvalidArgument, this)); + continue; + } + + rhsList.Add((element as StringConstantExpressionAst).Value); + } + } + } + } + + if (rhsList.Count == 0) + { + this.outputWriter.WriteError(new ErrorRecord(new ArgumentException(), + string.Format(CultureInfo.CurrentCulture, Strings.WrongValueFormat, kvp.Item2.Extent.StartLineNumber, kvp.Item2.Extent.StartColumnNumber, profile), + ErrorCategory.InvalidArgument, this)); + break; + } + + switch ((kvp.Item1 as StringConstantExpressionAst).Value.ToLower()) + { + case "severity": + if (this.severity == null) + { + this.severity = rhsList.ToArray(); + } + else + { + this.severity = this.severity.Union(rhsList).ToArray(); + } + break; + case "includerules": + if (this.includeRule == null) + { + this.includeRule = rhsList.ToArray(); + } + else + { + this.includeRule = this.includeRule.Union(rhsList).ToArray(); + } + break; + case "excluderules": + if (this.excludeRule == null) + { + this.excludeRule = rhsList.ToArray(); + } + else + { + this.excludeRule = this.excludeRule.Union(rhsList).ToArray(); + } + break; + default: + this.outputWriter.WriteError(new ErrorRecord(new ArgumentException(), + string.Format(CultureInfo.CurrentCulture, Strings.WrongKey, kvp.Item1.Extent.StartLineNumber, kvp.Item1.Extent.StartColumnNumber, profile), + ErrorCategory.InvalidArgument, this)); + break; + } + } + } + } + } + + //Check wild card input for the Include/ExcludeRules and create regex match patterns + if (this.includeRule != null) + { + foreach (string rule in includeRule) + { + Regex includeRegex = new Regex(String.Format("^{0}$", Regex.Escape(rule).Replace(@"\*", ".*")), RegexOptions.IgnoreCase); + this.includeRegexList.Add(includeRegex); + } + } + if (this.excludeRule != null) + { + foreach (string rule in excludeRule) + { + 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 + + // Safely get one non-duplicated list of rules + IEnumerable rules = + Enumerable.Union( + Enumerable.Union( + this.ScriptRules ?? Enumerable.Empty(), + this.TokenRules ?? Enumerable.Empty()), + this.ExternalRules ?? Enumerable.Empty()); + + // Ensure that rules were actually loaded + if (rules == null || rules.Any() == false) + { + 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(); - // Clear external rules for each invoke. - ExternalRules = 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(); // Initialize helper + Helper.Instance = new Helper(invokeCommand, this.outputWriter); Helper.Instance.Initialize(); + // Clear external rules for each invoke. + this.ScriptRules = null; + this.TokenRules = null; + this.ExternalRules = null; + // An aggregate catalog that combines multiple catalogs. using (AggregateCatalog catalog = new AggregateCatalog()) { // Adds all the parts found in the same directory. string dirName = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); - catalog.Catalogs.Add(new DirectoryCatalog(dirName)); + catalog.Catalogs.Add( + new SafeDirectoryCatalog( + dirName, + this.outputWriter)); // Adds user specified directory 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)); } else { - catalog.Catalogs.Add(new DirectoryCatalog(path)); + catalog.Catalogs.Add( + new SafeDirectoryCatalog( + path, + this.outputWriter)); } } @@ -160,7 +460,7 @@ public void Initilaize(Dictionary> result) } catch (CompositionException compositionException) { - Console.WriteLine(compositionException.ToString()); + this.outputWriter.WriteWarning(compositionException.ToString()); } } @@ -169,6 +469,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 +508,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 +520,7 @@ where regex.IsMatch(rule.GetName()) return results; } - public List GetExternalRule(string[] moduleNames) + private List GetExternalRule(string[] moduleNames) { List rules = new List(); @@ -247,15 +559,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 +586,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 +653,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 +705,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 +719,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 +732,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 +742,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 +755,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 +770,7 @@ public Dictionary> CheckRuleExtension(string[] path, PSCmdl } else { - resolvedPath = cmdlet.SessionState.Path + resolvedPath = basePath .GetResolvedPSPathFromPSPath(childPath).First().ToString(); } @@ -491,12 +803,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 +838,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 +862,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..80e91a809 100644 --- a/Engine/ScriptAnalyzerEngine.csproj +++ b/Engine/ScriptAnalyzerEngine.csproj @@ -69,7 +69,9 @@ + + @@ -91,6 +93,9 @@ Strings.Designer.cs + + + @@ -99,6 +104,8 @@ mkdir "$(SolutionDir)$(SolutionName)" copy /y "$(ProjectDir)\*.ps1xml" "$(SolutionDir)$(SolutionName)" copy /y "$(ProjectDir)\*.psd1" "$(SolutionDir)$(SolutionName)" -copy /y "$(TargetPath)" "$(SolutionDir)$(SolutionName)" +copy /y "$(TargetPath)" "$(SolutionDir)$(SolutionName)" +mkdir "$(SolutionDir)$(SolutionName)\en-US" +copy /y "$(ProjectDir)\about_*.help.txt" "$(SolutionDir)$(SolutionName)\en-US" \ No newline at end of file diff --git a/Engine/Strings.Designer.cs b/Engine/Strings.Designer.cs index 74cebd11d..d9cb7c363 100644 --- a/Engine/Strings.Designer.cs +++ b/Engine/Strings.Designer.cs @@ -1,7 +1,7 @@ //------------------------------------------------------------------------------ // // This code was generated by a tool. -// Runtime Version:4.0.30319.35317 +// Runtime Version:4.0.30319.34014 // // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. @@ -276,6 +276,24 @@ internal static string VerboseRunningMessage { } } + /// + /// Looks up a localized string similar to {0} is not a valid key in the profile hashtable: line {0} column {1} in file {2}. + /// + internal static string WrongKey { + get { + return ResourceManager.GetString("WrongKey", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Key in the profile hashtable should be a string: line {0} column {1} in file {2}. + /// + internal static string WrongKeyFormat { + get { + return ResourceManager.GetString("WrongKeyFormat", resourceCulture); + } + } + /// /// Looks up a localized string similar to Scope can only be either function or class.. /// @@ -284,5 +302,14 @@ internal static string WrongScopeArgumentSuppressionAttributeError { return ResourceManager.GetString("WrongScopeArgumentSuppressionAttributeError", resourceCulture); } } + + /// + /// Looks up a localized string similar to Value in the profile hashtable should be a string or an array of strings: line {0} column {1} in file {2}. + /// + internal static string WrongValueFormat { + get { + return ResourceManager.GetString("WrongValueFormat", resourceCulture); + } + } } } diff --git a/Engine/Strings.resx b/Engine/Strings.resx index 369025dca..218013122 100644 --- a/Engine/Strings.resx +++ b/Engine/Strings.resx @@ -192,4 +192,13 @@ Cannot find any DiagnosticRecord with the Rule Suppression ID {0}. + + {0} is not a valid key in the profile hashtable: line {0} column {1} in file {2} + + + Key in the profile hashtable should be a string: line {0} column {1} in file {2} + + + Value in the profile hashtable should be a string or an array of strings: line {0} column {1} in file {2} + \ No newline at end of file diff --git a/Engine/about_PSScriptAnalyzer.help.txt b/Engine/about_PSScriptAnalyzer.help.txt new file mode 100644 index 000000000..a20df5e5d --- /dev/null +++ b/Engine/about_PSScriptAnalyzer.help.txt @@ -0,0 +1,210 @@ +TOPIC + about_PSScriptAnalyzer + +SHORT DESCRIPTION + PSScriptAnalyzer is a static code checker for PowerShell script. + +LONG DESCRIPTION + PSScriptAnalyzer checks the quality of Windows PowerShell script by evaluating + that script against a set of rules. The script can be in the form of a + stand-alone script (.ps1 files), a module (.psm1, .psd1 and .ps1 files) or + a DSC Resource (.psm1, .psd1 and .ps1 files). + + The rules are based on PowerShell best practices identified by the + PowerShell Team and the community. These rules can help you create more + readable, maintainable and reliable scripts. PSScriptAnalyzer generates + DiagnosticResults (errors and warnings) to inform you about potential script + issues, including the reason why there might be an issue, and provide you + with guidance on how to fix the issue. + + PSScriptAnalyzer is shipped with a collection of built-in rules that check + various aspects of PowerShell code such as presence of uninitialized + variables, usage of the PSCredential Type, usage of Invoke-Expression, etc. + + The following additional functionality is also supported: + + * Including and/or excluding specific rules globally + * Suppression of rules within script + * Creation of custom rules + * Creation of loggers + +RUNNING SCRIPT ANALYZER + + There are two commands provided by the PSScriptAnalyzer module, those are: + + Get-ScriptAnalyzerRule [-CustomizedRulePath ] [-Name ] + [-Severity ] + [] + + Invoke-ScriptAnalyzer [-Path] [-CustomizedRulePath ] + [-ExcludeRule ] [-IncludeRule] + [-Severity ] [-Recurse] [-SuppressedOnly] + [] + + To run the script analyzer against a single script file execute: + + PS C:\> Invoke-ScriptAnalyzer -Path myscript.ps1 + + This will analyze your script against every built-in rule. As you may find + if your script is sufficiently large, that could result in a lot of warnings + and/or errors. See the next section on recommendations for running against + an existing script, module or DSC resource. + + To run the script analyzer against a whole directory, specify the folder + containing the script, module and DSC files you want analyzed. Specify + the Recurse parameter if you also want sub-directories searched for files + to analyze. + + PS C:\> Invoke-ScriptAnalyzer -Path . -Recurse + + To see all the built-in rules execute: + + PS C:\> Get-ScriptAnalyzerRule + +RUNNING SCRIPT ANALYZER ON A NEW SCRIPT, MODULE OR DSC RESOURCE + + If you have the luxury of starting a new script, module or DSC resource, it + is in your best interest to run the script analyzer with all the rules + enabled. Be sure to evaluate your script often to address rule violations as + soon as they occur. + + Over time, you may find rules that you don't find value in or have a need to + explicitly violate. Suppress those rules as necessary but try to avoid + "knee jerk" suppression of rules. Analyze the diagnostic output and the part + of your script that violates the rule to be sure you understand the reason for + the warning and that it is indeed OK to suppress the rule. For information on + how to suppress rules see the RULE SUPPRESSION section below. + +RUNNING SCRIPT ANALYZER ON AN EXISTING SCRIPT, MODULE OR DSC RESOURCE + + If you have existing scripts, they are not likely following all of these best + practices, practices that have just found their way into books, web sites, + blog posts and now the PSScriptAnalyer in the past few years. + + For these existing scripts, if you just run the script analyzer without + limiting the set of rules executed, you may get deluged with diagnostics + output in the form of information, warning and error messages. You should + try running the script analyzer with all the rules enabled (the default) and + see if the output is "manageable". If it isn't, then you will want to "ease + into" things by starting with the most serious violations first - errors. + + You may be temtped to use the Invoke-ScriptAnalyzer command's Severity + parameter with the argument Error to do this - don't. This will run every + built-in rule and then filter the results during output. The more rules the + script analyzer runs, the longer it will take to analyze a file. You can + easily get Invoke-ScriptAnalyzer to run just the rules that are of severity + Error like so: + + PS C:\> $errorRules = Get-ScriptAnalyzer -Severity Error + PS C:\> Invoke-ScriptAnalyzer -Path . -IncludeRule $errorRules + + The output should be much shorter (hopefully) and more importantly, these rules + typically indicate serious issues in your script that should be addressed. + + Once you have addressed the errors in the script, you are ready to tackle + warnings. This is likely what generated the most output when you ran the + first time with all the rules enabled. Now not all of the warnings generated + by the script analyzer are of equal importance. For the existing script + scenario, try running error and warning rules included but with a few rules + "excluded": + + PS C:\> $rules = Get-ScriptAnalyzerRule -Severity Error,Warning + PS C:\> Invoke-ScriptAnalyzer -Path . -IncludeRule $rules -ExcludeRule ` + PSAvoidUsingCmdletAliases, PSAvoidUsingPositionalParameters + + The PSAvoidUsingCmdletAliases and PSAvoidUsingPositionalParameters warnings + are likely to generate prodigious amounts of output. While these rules have + their reason for being many existing scripts violate these rules over and + over again. It would be a shame if you let a flood of warnings from these two + rules, keep you from addressing more potentially serious warnings. + + There may be other rules that generate a lot of output that you don't care + about - at least not yet. As you examine the remaining diagnostics output, + it is often helpful to group output by rule. You may decide that the one or + two rules generating 80% of the output are rules you don't care about. You + can get this view of your output easily: + + PS C:\> $rules = Get-ScriptAnalyzerRule -Severity Error,Warning + PS C:\> $res = Invoke-ScriptAnalyzer -Path . -IncludeRule $rules -ExcludeRule ` + PSAvoidUsingPositionalParameters, PSAvoidUsingCmdletAliases + PS C:\> $res | Group RuleName | Sort Count -Desc | Format-Table Count, Name + + This renders output like the following: + + Count Name + ----- ---- + 23 PSAvoidUsingInvokeExpression + 8 PSUseDeclaredVarsMoreThanAssigments + 8 PSProvideDefaultParameterValue + 6 PSAvoidUninitializedVariable + 3 PSPossibleIncorrectComparisonWithNull + 1 PSAvoidUsingComputerNameHardcoded + + You may decide to exclude the PSAvoidUsingInvokeExpression rule for the moment + and focus on the rest, especially the PSUseDeclaredVarsMoreThanAssigments, + PSAvoidUninitializedVariable and PSPossibleIncorrectComparisonWithNull rules. + + As you fix rules, go back and enable more rules as you have time to address + the associated issues. In some cases, you may want to suppress a rule at + the function, script or class scope instead of globally excluding the rule. + See the RULE SUPPRESSION section below. + + While getting a completely clean run through every rule is a noble goal, it + may not always be feasible. You have to weigh the gain of passing the rule + and eliminating a "potential" issue with changing script and possibly + introducing a new problem. In the end, for existing scripts, it is usually + best to have evaluated the rule violations that you deem the most valuable to + address. + +RULE SUPPRESSSION + + Rule suppression allows you to turn off rule verification on a function, + scripts or class definition. This allows you to exclude only specified + scripts or functions from verification of a rule instead of globally + excluding the rule. + + There are several ways to suppress rules. You can suppress a rule globally + by using the ExcludeRule parameter when invoking the script analyzer e.g.: + + PS C:\> Invoke-ScriptAnalyzer -Path . -ExcludeRule ` + PSProvideDefaultParameterValue, PSAvoidUsingWMICmdlet + + Note that the ExcludeRule parameter takes an array of strings i.e. rule names. + + Sometimes you will want to suppress a rule for part of your script but not for + the entire script. PSScriptAnalyzer allows you to suppress rules at the + script, function and class scope. You can use the .NET Framework + System.Diagnoctics.CodeAnalysis.SuppressMesssageAttribute in your script + like so: + + function Commit-Change() { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseApprovedVerbs", + "", Scope="Function", + Target="*")] + param() + } + +EXTENSIBILITY + + PSScriptAnalyzer has been designed to allow you to create your own rules via + a custom .NET assembly or PowerShell module. PSScriptAnalyzer also allows + you to plug in a custom logger (implemented as a .NET assembly). + +CONTRIBUTE + + PSScriptAnalyzer is open source on GitHub: + + https://github.com/PowerShell/PSScriptAnalyzer + + As you run the script analyzer and find what you believe to be are bugs, + please submit them to: + + https://github.com/PowerShell/PSScriptAnalyzer/issues + + Better yet, fix the bug and submit a pull request. + +SEE ALSO + Get-ScriptAnalyzerRule + Invoke-ScriptAnalyzer + Set-StrictMode + about_Pester diff --git a/Rules/Strings.Designer.cs b/Rules/Strings.Designer.cs index 8c76e720b..40e597258 100644 --- a/Rules/Strings.Designer.cs +++ b/Rules/Strings.Designer.cs @@ -754,7 +754,7 @@ internal static string AvoidUsingWMICmdletCommonName { } /// - /// Looks up a localized string similar to Depricated. Starting in Windows PowerShell 3.0, these cmdlets have been superseded by CIM cmdlets.. + /// Looks up a localized string similar to Deprecated. Starting in Windows PowerShell 3.0, these cmdlets have been superseded by CIM cmdlets.. /// internal static string AvoidUsingWMICmdletDescription { get { diff --git a/Rules/Strings.resx b/Rules/Strings.resx index dd02abedf..fe00a5328 100644 --- a/Rules/Strings.resx +++ b/Rules/Strings.resx @@ -667,7 +667,7 @@ Avoid Using Get-WMIObject, Remove-WMIObject, Invoke-WmiMethod, Register-WmiEvent, Set-WmiInstance - Depricated. Starting in Windows PowerShell 3.0, these cmdlets have been superseded by CIM cmdlets. + Deprecated. Starting in Windows PowerShell 3.0, these cmdlets have been superseded by CIM cmdlets. File '{0}' uses WMI cmdlet. For PowerShell 3.0 and above, use CIM cmdlet which perform the same tasks as the WMI cmdlets. The CIM cmdlets comply with WS-Management (WSMan) standards and with the Common Information Model (CIM) standard, which enables the cmdlets to use the same techniques to manage Windows computers and those running other operating systems. 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/GlobalSuppression.ps1 b/Tests/Engine/GlobalSuppression.ps1 new file mode 100644 index 000000000..a777debc5 --- /dev/null +++ b/Tests/Engine/GlobalSuppression.ps1 @@ -0,0 +1,7 @@ +$a + +gcm "blah" + +$a = "//internal" + +Get-Alias -ComputerName dfd \ No newline at end of file diff --git a/Tests/Engine/GlobalSuppression.test.ps1 b/Tests/Engine/GlobalSuppression.test.ps1 new file mode 100644 index 000000000..11b030756 --- /dev/null +++ b/Tests/Engine/GlobalSuppression.test.ps1 @@ -0,0 +1,49 @@ +# 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\GlobalSuppression.ps1 +$suppression = Invoke-ScriptAnalyzer $directory\GlobalSuppression.ps1 -Profile $directory\Profile.ps1 + +Describe "GlobalSuppression" { + Context "Exclude Rule" { + It "Raises 1 violation for uninitialized variable and 1 for cmdlet alias" { + $withoutProfile = $violations | Where-Object { $_.RuleName -eq "PSAvoidUsingCmdletAliases" -or $_.RuleName -eq "PSAvoidUninitializedVariable" } + $withoutProfile.Count | Should Be 2 + } + + It "Does not raise any violations for uninitialized variable and cmdlet alias with profile" { + $withProfile = $suppression | Where-Object { $_.RuleName -eq "PSAvoidUsingCmdletAliases" -or $_.RuleName -eq "PSAvoidUninitializedVariable" } + $withProfile.Count | Should be 0 + } + } + + Context "Include Rule" { + It "Raises 1 violation for computername hard-coded" { + $withoutProfile = $violations | Where-Object { $_.RuleName -eq "PSAvoidUsingComputerNameHardcoded" } + $withoutProfile.Count | Should Be 1 + } + + It "Does not raise any violations for computername hard-coded" { + $withProfile = $suppression | Where-Object { $_.RuleName -eq "PSAvoidUsingComputerNameHardcoded" } + $withProfile.Count | Should be 0 + } + } + + Context "Severity" { + It "Raises 1 violation for internal url without profile" { + $withoutProfile = $violations | Where-Object { $_.RuleName -eq "PSAvoidUsingInternalURLs" } + $withoutProfile.Count | Should Be 1 + } + + It "Does not raise any violations for internal urls with profile" { + $withProfile = $suppression | Where-Object { $_.RuleName -eq "PSAvoidUsingInternalURLs" } + $withProfile.Count | Should be 0 + } + } +} \ No newline at end of file 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/Profile.ps1 b/Tests/Engine/Profile.ps1 new file mode 100644 index 000000000..8bc4bc7e2 --- /dev/null +++ b/Tests/Engine/Profile.ps1 @@ -0,0 +1,9 @@ +@{ + Severity='Warning' + IncludeRules=@('PSAvoidUsingCmdletAliases', + 'PSAvoidUsingPositionalParameters', + 'PSAvoidUsingInternalURLs' + 'PSAvoidUninitializedVariable') + ExcludeRules=@('PSAvoidUsingCmdletAliases' + 'PSAvoidUninitializedVariable') +} \ No newline at end of file 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