diff --git a/RuleDocumentation/README.md b/RuleDocumentation/README.md index 78a0ab2e4..2ed360e88 100644 --- a/RuleDocumentation/README.md +++ b/RuleDocumentation/README.md @@ -16,6 +16,7 @@ |[AvoidOverwritingBuiltInCmdlets](./AvoidOverwritingBuiltInCmdlets.md) | Warning | | |[AvoidNullOrEmptyHelpMessageAttribute](./AvoidNullOrEmptyHelpMessageAttribute.md) | Warning | | |[AvoidShouldContinueWithoutForce](./AvoidShouldContinueWithoutForce.md) | Warning | | +|[UseUsingScopeModifierInNewRunspaces](./UseUsingScopeModifierInNewRunspaces.md) | Warning | | |[AvoidUsingCmdletAliases](./AvoidUsingCmdletAliases.md) | Warning | Yes | |[AvoidUsingComputerNameHardcoded](./AvoidUsingComputerNameHardcoded.md) | Error | | |[AvoidUsingConvertToSecureStringWithPlainText](./AvoidUsingConvertToSecureStringWithPlainText.md) | Error | | diff --git a/RuleDocumentation/UseUsingScopeModifierInNewRunspaces.md b/RuleDocumentation/UseUsingScopeModifierInNewRunspaces.md new file mode 100644 index 000000000..d1ba06cf8 --- /dev/null +++ b/RuleDocumentation/UseUsingScopeModifierInNewRunspaces.md @@ -0,0 +1,65 @@ +# UseUsingScopeModifierInNewRunspaces + +**Severity Level: Warning** + +## Description + +If a ScriptBlock is intended to be run in a new RunSpace, variables inside it should use $using: scope modifier, or be initialized within the ScriptBlock. +This applies to: + +- Invoke-Command * +- Workflow { InlineScript {}} +- Foreach-Object ** +- Start-Job +- Start-ThreadJob +- The `Script` resource in DSC configurations, specifically for the `GetScript`, `TestScript` and `SetScript` properties + +\* Only with the -ComputerName or -Session parameter. +\*\* Only with the -Parallel parameter + +## How to Fix + +Within the ScriptBlock, instead of just using a variable from the parent scope, you have to add the `using:` scope modifier to it. + +## Example + +### Wrong + +```PowerShell +$var = "foo" +1..2 | ForEach-Object -Parallel { $var } +``` + +### Correct + +```PowerShell +$var = "foo" +1..2 | ForEach-Object -Parallel { $using:var } +``` + +## More correct examples + +```powershell +$bar = "bar" +Invoke-Command -ComputerName "foo" -ScriptBlock { $using:bar } +``` + +```powershell +$bar = "bar" +$s = New-PSSession -ComputerName "foo" +Invoke-Command -Session $s -ScriptBlock { $using:bar } +``` + +```powershell +# Remark: Workflow is supported on Windows PowerShell only +Workflow { + $foo = "foo" + InlineScript { $using:foo } +} +``` + +```powershell +$foo = "foo" +Start-ThreadJob -ScriptBlock { $using:foo } +Start-Job -ScriptBlock {$using:foo } +``` \ No newline at end of file diff --git a/Rules/Strings.Designer.cs b/Rules/Strings.Designer.cs index c0cb02ba6..4b253c71a 100644 --- a/Rules/Strings.Designer.cs +++ b/Rules/Strings.Designer.cs @@ -3003,6 +3003,51 @@ internal static string UseTypeAtVariableAssignmentName { } } + /// + /// Looks up a localized string similar to Use 'Using:' scope modifier in RunSpace ScriptBlocks. + /// + internal static string UseUsingScopeModifierInNewRunspacesCommonName { + get { + return ResourceManager.GetString("UseUsingScopeModifierInNewRunspacesCommonName", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Replace {0} with {1}. + /// + internal static string UseUsingScopeModifierInNewRunspacesCorrectionDescription { + get { + return ResourceManager.GetString("UseUsingScopeModifierInNewRunspacesCorrectionDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to If a ScriptBlock is intended to be run as a new RunSpace, variables inside it should use 'Using:' scope modifier, or be initialized within the ScriptBlock.. + /// + internal static string UseUsingScopeModifierInNewRunspacesDescription { + get { + return ResourceManager.GetString("UseUsingScopeModifierInNewRunspacesDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The variable '{0}' is not declared within this ScriptBlock, and is missing the 'Using:' scope modifier.. + /// + internal static string UseUsingScopeModifierInNewRunspacesError { + get { + return ResourceManager.GetString("UseUsingScopeModifierInNewRunspacesError", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to UseUsingScopeModifierInNewRunspaces. + /// + internal static string UseUsingScopeModifierInNewRunspacesName { + get { + return ResourceManager.GetString("UseUsingScopeModifierInNewRunspacesName", resourceCulture); + } + } + /// /// Looks up a localized string similar to Use UTF8 Encoding For Help File. /// diff --git a/Rules/Strings.resx b/Rules/Strings.resx index fc47fc6bd..e64cecd6b 100644 --- a/Rules/Strings.resx +++ b/Rules/Strings.resx @@ -1114,12 +1114,27 @@ ReviewUnusedParameter - Ensure all parameters are used within the same script, scriptblock, or function where they are declared. - - - The parameter '{0}' has been declared but not used. - - - ReviewUnusedParameter - - \ No newline at end of file + Ensure all parameters are used within the same script, scriptblock, or function where they are declared. + + + The parameter '{0}' has been declared but not used. + + + ReviewUnusedParameter + + + Use 'Using:' scope modifier in RunSpace ScriptBlocks + + + If a ScriptBlock is intended to be run as a new RunSpace, variables inside it should use 'Using:' scope modifier, or be initialized within the ScriptBlock. + + + UseUsingScopeModifierInNewRunspaces + + + The variable '{0}' is not declared within this ScriptBlock, and is missing the 'Using:' scope modifier. + + + Replace {0} with {1} + + diff --git a/Rules/UseUsingScopeModifierInNewRunspaces.cs b/Rules/UseUsingScopeModifierInNewRunspaces.cs new file mode 100644 index 000000000..1db914276 --- /dev/null +++ b/Rules/UseUsingScopeModifierInNewRunspaces.cs @@ -0,0 +1,518 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +#if !CORECLR +using System.ComponentModel.Composition; +#endif +using System.Globalization; +using System.Linq; +using System.Management.Automation.Language; +using Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic; + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules +{ + /// + /// UseUsingScopeModifierInNewRunspaces: Analyzes the ast to check that variables in script blocks that run in new run spaces are properly initialized or passed in with '$using:(varName)'. + /// +#if !CORECLR +[Export(typeof(IScriptRule))] +#endif + public class UseUsingScopeModifierInNewRunspaces : IScriptRule + { + /// + /// AnalyzeScript: Analyzes the ast to check variables in script blocks that will run in new runspaces are properly initialized or passed in with $using: + /// + /// The script's ast + /// The script's file name + /// A List of results from this rule + public IEnumerable AnalyzeScript(Ast ast, string fileName) + { + var visitor = new SyntaxCompatibilityVisitor(this, fileName); + ast.Visit(visitor); + return visitor.GetDiagnosticRecords(); + } + + /// + /// GetName: Retrieves the name of this rule. + /// + /// The name of this rule + public string GetName() + { + return string.Format( + CultureInfo.CurrentCulture, + Strings.NameSpaceFormat, + GetSourceName(), + Strings.UseUsingScopeModifierInNewRunspacesName); + } + + /// + /// GetCommonName: Retrieves the common name of this rule. + /// + /// The common name of this rule + public string GetCommonName() + { + return string.Format(CultureInfo.CurrentCulture, Strings.UseUsingScopeModifierInNewRunspacesCommonName); + } + + /// + /// GetDescription: Retrieves the description of this rule. + /// + /// The description of this rule + public string GetDescription() + { + return string.Format(CultureInfo.CurrentCulture, Strings.UseUsingScopeModifierInNewRunspacesDescription); + } + + /// + /// GetSourceType: Retrieves the type of the rule: builtin, managed or module. + /// + public SourceType GetSourceType() + { + return SourceType.Builtin; + } + + /// + /// GetSeverity: Retrieves the severity of the rule: error, warning of information. + /// + /// + public RuleSeverity GetSeverity() + { + return RuleSeverity.Warning; + } + + /// + /// GetSourceName: Retrieves the module/assembly name the rule is from. + /// + public string GetSourceName() + { + return string.Format(CultureInfo.CurrentCulture, Strings.SourceName); + } + +#if !(PSV3 || PSV4) + private class SyntaxCompatibilityVisitor : AstVisitor2 +#else + private class SyntaxCompatibilityVisitor : AstVisitor +#endif + { + private const DiagnosticSeverity Severity = DiagnosticSeverity.Warning; + + private static readonly string[] s_dscScriptResourceCommandNames = {"GetScript", "TestScript", "SetScript"}; + + private static readonly IEnumerable s_jobCmdletNamesAndAliases = + Helper.Instance.CmdletNameAndAliases("Start-Job"); + + private static readonly IEnumerable s_threadJobCmdletNamesAndAliases = + Helper.Instance.CmdletNameAndAliases("Start-ThreadJob"); + + private static readonly IEnumerable s_inlineScriptCmdletNamesAndAliases = + Helper.Instance.CmdletNameAndAliases("InlineScript"); + + private static readonly IEnumerable s_foreachObjectCmdletNamesAndAliases = + Helper.Instance.CmdletNameAndAliases("Foreach-Object"); + + private static readonly IEnumerable s_invokeCommandCmdletNamesAndAliases = + Helper.Instance.CmdletNameAndAliases("Invoke-Command"); + + private readonly Dictionary> _varsDeclaredPerSession; + + private readonly List _diagnosticAccumulator; + + private readonly UseUsingScopeModifierInNewRunspaces _rule; + + private readonly string _analyzedFilePath; + + public SyntaxCompatibilityVisitor(UseUsingScopeModifierInNewRunspaces rule, string analyzedScriptPath) + { + _diagnosticAccumulator = new List(); + _varsDeclaredPerSession = new Dictionary>(); + _rule = rule; + _analyzedFilePath = analyzedScriptPath; + } + + /// + /// GetDiagnosticRecords: Retrieves all Diagnostic Records that were generated during visiting + /// + public IEnumerable GetDiagnosticRecords() + { + return _diagnosticAccumulator; + } + + /// + /// VisitScriptBlockExpression: When a ScriptBlockExpression is visited, see if it belongs to a command that needs its variables + /// prefixed with the 'Using' scope modifier. If so, analyze the block and generate diagnostic records for variables where it is missing. + /// + /// + /// + /// AstVisitAction.Continue or AstVisitAction.SkipChildren, depending on what we found. Diagnostic records are saved in `_diagnosticAccumulator`. + /// + public override AstVisitAction VisitScriptBlockExpression(ScriptBlockExpressionAst scriptBlockExpressionAst) + { + if (!(scriptBlockExpressionAst.Parent is CommandAst commandAst)) + { + return AstVisitAction.Continue; + } + + string cmdName = commandAst.GetCommandName(); + if (cmdName == null) + { + // Skip for situations where command name cannot be resolved like `& $commandName -ComputerName -ScriptBlock { $foo }` + return AstVisitAction.SkipChildren; + } + + // We need this information, because some cmdlets can have more than one ScriptBlock parameter + var scriptBlockParameterAst = commandAst.CommandElements[ + commandAst.CommandElements.IndexOf(scriptBlockExpressionAst) - 1] as CommandParameterAst; + + if (IsInlineScriptBlock(cmdName) || + IsJobScriptBlock(cmdName, scriptBlockParameterAst) || + IsForeachScriptBlock(cmdName, scriptBlockParameterAst) || + IsInvokeCommandComputerScriptBlock(cmdName, commandAst) || + IsDSCScriptResource(cmdName, commandAst)) + { + AnalyzeScriptBlock(scriptBlockExpressionAst); + return AstVisitAction.SkipChildren; + } + + if (IsInvokeCommandSessionScriptBlock(cmdName, commandAst)) + { + if (!TryGetSessionNameFromInvokeCommand(commandAst, out var sessionName)) + { + return AstVisitAction.Continue; + } + + IReadOnlyDictionary varsInLocalAssignments = FindVarsInAssignmentAsts(scriptBlockExpressionAst); + if (varsInLocalAssignments != null) + { + AddAssignedVarsToSession(sessionName, varsInLocalAssignments); + } + + GenerateDiagnosticRecords( + FindNonAssignedNonUsingVarAsts( + scriptBlockExpressionAst, + GetAssignedVarsInSession(sessionName))); + + return AstVisitAction.SkipChildren; + } + + return AstVisitAction.Continue; + } + + /// + /// FindVarsInAssignmentAsts: Retrieves all assigned variables from an Ast: + /// Example: `$foo = "foo"` ==> the VariableExpressionAst for $foo is returned + /// + /// + private static IReadOnlyDictionary FindVarsInAssignmentAsts(Ast ast) + { + Dictionary variableDictionary = + new Dictionary(); + + // Find all variables that are assigned within this ast + foreach (AssignmentStatementAst statementAst in ast.FindAll(IsAssignmentStatementAst, true)) + { + if (TryGetVariableFromExpression(statementAst.Left, out VariableExpressionAst variable)) + { + string variableName = string.Format(variable.VariablePath.UserPath, + StringComparer.OrdinalIgnoreCase); + variableDictionary.Add(variableName, variable); + } + }; + + return new ReadOnlyDictionary(variableDictionary); + } + + /// + /// TryGetVariableFromExpression: extracts the variable from an expression like an assignment + /// + /// + /// + private static bool TryGetVariableFromExpression(ExpressionAst expression, out VariableExpressionAst variableExpressionAst) + { + switch (expression) + { + case VariableExpressionAst variable: + variableExpressionAst = variable; + return true; + + case AttributedExpressionAst attributedAst: + return TryGetVariableFromExpression(attributedAst.Child, out variableExpressionAst); + + default: + variableExpressionAst = null; + return false; + } + } + + /// + /// IsAssignmentStatementAst: helper function to prevent allocation of closures for FindAll predicate. + /// + /// + private static bool IsAssignmentStatementAst(Ast ast) + { + return ast is AssignmentStatementAst; + } + + /// + /// FindNonAssignedNonUsingVarAsts: Retrieve variables that are: + /// - not assigned before + /// - not prefixed with the 'Using' scope modifier + /// - not a PowerShell special variable + /// + /// + /// + private static IEnumerable FindNonAssignedNonUsingVarAsts( + Ast ast, IReadOnlyDictionary varsInAssignments) + { + // Find all variables that are not locally assigned, and don't have $using: scope modifier + foreach (VariableExpressionAst variable in ast.FindAll(IsNonUsingNonSpecialVariableExpressionAst, true)) + { + var varName = string.Format(variable.VariablePath.UserPath, StringComparer.OrdinalIgnoreCase); + + if (varsInAssignments.ContainsKey(varName)) + { + yield break; + } + + yield return variable; + } + } + + /// + /// IsNonUsingNonSpecialVariableExpressionAst: helper function to prevent allocation of closures for FindAll predicate. + /// + /// + private static bool IsNonUsingNonSpecialVariableExpressionAst(Ast ast) + { + return ast is VariableExpressionAst variable && + !(variable.Parent is UsingExpressionAst) && + !Helper.Instance.HasSpecialVars(variable.VariablePath.UserPath); + } + + /// + /// GetSuggestedCorrections: Retrieves a CorrectionExtent for a given variable + /// + /// + private static IEnumerable GetSuggestedCorrections(VariableExpressionAst ast) + { + string varWithUsing = $"$using:{ast.VariablePath.UserPath}"; + + var description = string.Format( + CultureInfo.CurrentCulture, + Strings.UseUsingScopeModifierInNewRunspacesCorrectionDescription, + ast.Extent.Text, + varWithUsing); + + return new[] + { + new CorrectionExtent( + startLineNumber: ast.Extent.StartLineNumber, + endLineNumber: ast.Extent.EndLineNumber, + startColumnNumber: ast.Extent.StartColumnNumber, + endColumnNumber: ast.Extent.EndColumnNumber, + text: varWithUsing, + file: ast.Extent.File, + description: description + ) + }; + } + + /// + /// TryGetSessionNameFromInvokeCommand: Retrieves the name of the session (that Invoke-Command is run with). + /// + /// + /// + /// + private static bool TryGetSessionNameFromInvokeCommand(CommandAst invokeCommandAst, out string sessionName) + { + // Sift through Invoke-Command parameters to find the value of the -Session parameter + // Start at 1 to skip the command name + for (int i = 1; i < invokeCommandAst.CommandElements.Count; i++) + { + // We need a parameter + if (!(invokeCommandAst.CommandElements[i] is CommandParameterAst parameterAst)) + { + continue; + } + + // The parameter must be called "Session" + if (!parameterAst.ParameterName.Equals("Session", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + // If we have a partial AST, ensure we don't crash + if (i + 1 >= invokeCommandAst.CommandElements.Count) + { + break; + } + + // The -Session parameter expects an argument of type [System.Management.Automation.Runspaces.PSSession]. + // It will typically be provided in the form of a variable. We do not support other scenarios at this time. + if (!(invokeCommandAst.CommandElements[i + 1] is VariableExpressionAst variableAst)) + { + break; + } + + sessionName = variableAst.VariablePath.UserPath; + return true; + } + + sessionName = null; + return false; + } + + /// + /// GetAssignedVarsInSession: Retrieves all previously declared vars for a given session (as in Invoke-Command -Session $session). + /// + /// + private IReadOnlyDictionary GetAssignedVarsInSession(string sessionName) + { + return _varsDeclaredPerSession[sessionName]; + } + + /// + /// AddAssignedVarsToSession: Adds variables to the list of assigned variables for a given Invoke-Command session. + /// + /// + /// + private void AddAssignedVarsToSession(string sessionName, IReadOnlyDictionary variablesToAdd) + { + if (!_varsDeclaredPerSession.ContainsKey(sessionName)) + { + _varsDeclaredPerSession.Add(sessionName, new Dictionary()); + } + + foreach (var item in variablesToAdd) + { + _varsDeclaredPerSession[sessionName].Add(item.Key, item.Value); + } + } + + /// + /// AnalyzeScriptBlock: Generate a Diagnostic Record for each incorrectly used variable inside a given ScriptBlock. + /// + /// + private void AnalyzeScriptBlock(ScriptBlockExpressionAst scriptBlockExpressionAst) + { + IEnumerable nonAssignedNonUsingVarAsts = FindNonAssignedNonUsingVarAsts( + scriptBlockExpressionAst, + FindVarsInAssignmentAsts(scriptBlockExpressionAst)); + + GenerateDiagnosticRecords(nonAssignedNonUsingVarAsts); + } + + /// + /// GenerateDiagnosticRecords: Add Diagnostic Records to the internal list for each given variable + /// + /// + private void GenerateDiagnosticRecords(IEnumerable nonAssignedNonUsingVarAsts) + { + foreach (VariableExpressionAst variableExpression in nonAssignedNonUsingVarAsts) + { + string diagnosticMessage = string.Format( + CultureInfo.CurrentCulture, + Strings.UseUsingScopeModifierInNewRunspacesError, + variableExpression.ToString()); + + _diagnosticAccumulator.Add( + new DiagnosticRecord( + diagnosticMessage, + variableExpression.Extent, + _rule.GetName(), + Severity, + _analyzedFilePath, + ruleId: _rule.GetName(), + GetSuggestedCorrections(ast: variableExpression))); + } + } + + /// + /// IsInvokeCommandSessionScriptBlock: Returns true if: + /// - command is 'Invoke-Command' (or alias) + /// - parameter '-Session' is present + /// + /// + /// + private bool IsInvokeCommandSessionScriptBlock(string cmdName, CommandAst commandAst) + { + return s_invokeCommandCmdletNamesAndAliases.Contains(cmdName, StringComparer.OrdinalIgnoreCase) && + commandAst.CommandElements.Any( + e => e is CommandParameterAst parameterAst && + parameterAst.ParameterName.Equals("session", StringComparison.OrdinalIgnoreCase)); + } + + /// + /// IsInvokeCommandComputerScriptBlock: Returns true if: + /// - command is 'Invoke-Command' (or alias) + /// - parameter '-Computer' is present + /// + /// + /// + private bool IsInvokeCommandComputerScriptBlock(string cmdName, CommandAst commandAst) + { + // 'com' is the shortest unambiguous form for the '-Computer' parameter + return s_invokeCommandCmdletNamesAndAliases.Contains(cmdName, StringComparer.OrdinalIgnoreCase) && + commandAst.CommandElements.Any( + e => e is CommandParameterAst parameterAst && + parameterAst.ParameterName.StartsWith("com", StringComparison.OrdinalIgnoreCase)); + } + + /// + /// IsForeachScriptBlock: Returns true if: + /// - command is 'Foreach-Object' (or alias) + /// - parameter '-Parallel' is present + /// + /// + /// + private bool IsForeachScriptBlock(string cmdName, CommandParameterAst scriptBlockParameterAst) + { + // 'pa' is the shortest unambiguous form for the '-Parallel' parameter + return s_foreachObjectCmdletNamesAndAliases.Contains(cmdName, StringComparer.OrdinalIgnoreCase) && + (scriptBlockParameterAst != null && + scriptBlockParameterAst.ParameterName.StartsWith("pa", StringComparison.OrdinalIgnoreCase)); + } + + /// + /// IsJobScriptBlock: Returns true if: + /// - command is 'Start-Job' or 'Start-ThreadJob' (or alias) + /// - parameter name for this ScriptBlock not '-InitializationScript' + /// + /// + /// + private bool IsJobScriptBlock(string cmdName, CommandParameterAst scriptBlockParameterAst) + { + // 'ini' is the shortest unambiguous form for the '-InitializationScript' parameter + return (s_jobCmdletNamesAndAliases.Contains(cmdName, StringComparer.OrdinalIgnoreCase) || + s_threadJobCmdletNamesAndAliases.Contains(cmdName, StringComparer.OrdinalIgnoreCase)) && + !(scriptBlockParameterAst != null && + scriptBlockParameterAst.ParameterName.StartsWith("ini", StringComparison.OrdinalIgnoreCase)); + } + + /// + /// IsInlineScriptBlock: Returns true if: + /// - command is 'InlineScript' (or alias) + /// + /// + private bool IsInlineScriptBlock(string cmdName) + { + return s_inlineScriptCmdletNamesAndAliases.Contains(cmdName, StringComparer.OrdinalIgnoreCase); + } + + /// + /// IsDSCScriptResource: Returns true if: + /// - command is 'GetScript', 'TestScript' or 'SetScript' + /// + /// + private bool IsDSCScriptResource(string cmdName, CommandAst commandAst) + { + // Inside DSC Script resource, GetScript is of the form 'Script foo { GetScript = {} }' + // If we reach this point in the code, we are sure there are at least two CommandElements, so the index of [1] will not fail. + return s_dscScriptResourceCommandNames.Contains(cmdName, StringComparer.OrdinalIgnoreCase) && + commandAst.CommandElements[1].ToString() == "="; + } + } + } +} diff --git a/Tests/Engine/GetScriptAnalyzerRule.tests.ps1 b/Tests/Engine/GetScriptAnalyzerRule.tests.ps1 index eac588eb4..d94d419c7 100644 --- a/Tests/Engine/GetScriptAnalyzerRule.tests.ps1 +++ b/Tests/Engine/GetScriptAnalyzerRule.tests.ps1 @@ -59,7 +59,7 @@ Describe "Test Name parameters" { It "get Rules with no parameters supplied" { $defaultRules = Get-ScriptAnalyzerRule - $expectedNumRules = 63 + $expectedNumRules = 64 if ((Test-PSEditionCoreClr) -or (Test-PSVersionV3) -or (Test-PSVersionV4)) { # for PSv3 PSAvoidGlobalAliases is not shipped because diff --git a/Tests/Rules/UseUsingScopeModifierInNewRunspaces.tests.ps1 b/Tests/Rules/UseUsingScopeModifierInNewRunspaces.tests.ps1 new file mode 100644 index 000000000..66452baf6 --- /dev/null +++ b/Tests/Rules/UseUsingScopeModifierInNewRunspaces.tests.ps1 @@ -0,0 +1,286 @@ +$settings = @{ + IncludeRules = "PSUseUsingScopeModifierInNewRunspaces" + Severity = "warning" # because we need to prevent ParseErrors from being reported, so 'workflow' keyword will not be flagged when running test on Pwsh. +} + +Describe "UseUsingScopeModifierInNewRunspaces" { + Context "Should detect something" { + BeforeAll { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments','')] + $testCases = @( + # Test: Foreach-Object -Parallel {} + @{ + Description = "Foreach-Object -Parallel with undeclared var" + ScriptBlock = '{ + 1..2 | ForEach-Object -Parallel { $var } + }' + } + @{ + Description = "foreach -parallel alias with undeclared var" + ScriptBlock = '{ + 1..2 | ForEach -Parallel { $var } + }' + } + @{ + Description = "% -parallel alias with undeclared var" + ScriptBlock = '{ + 1..2 | % -Parallel { $var } + }' + } + @{ + Description = "Foreach-Object -pa abbreviated param with undeclared var" + ScriptBlock = '{ + 1..2 | foreach-object -pa { $var } + }' + } + @{ + Description = "Foreach-Object -Parallel nested with undeclared var" + ScriptBlock = '{ + $myNestedScriptBlock = { + 1..2 | ForEach-Object -Parallel { $var } + } + }' + } + # Start-Job / Start-ThreadJob + @{ + Description = 'Start-Job without $using:' + ScriptBlock = '{ + $foo = "bar" + Start-Job {$foo} | Receive-Job -Wait -AutoRemoveJob + }' + } + @{ + Description = 'Start-ThreadJob without $using:' + ScriptBlock = '{ + $foo = "bar" + Start-ThreadJob -ScriptBlock {$foo} | Receive-Job -Wait -AutoRemoveJob + }' + } + @{ + Description = 'Start-Job with -InitializationScript with a variable' + ScriptBlock = '{ + $foo = "bar" + Start-Job -ScriptBlock {$foo} -InitializationScript {$foo} | Receive-Job -Wait -AutoRemoveJob + }' + } + @{ + Description = 'Start-ThreadJob with -InitializationScript with a variable' + ScriptBlock = '{ + $foo = "bar" + Start-ThreadJob -ScriptBlock {$foo} -InitializationScript {$foo} | Receive-Job -Wait -AutoRemoveJob + }' + } + # workflow/inlinescript + @{ + Description = "Workflow/InlineScript" + ScriptBlock = '{ + $foo = "bar" + workflow baz { InlineScript {$foo} } + }' + } + # Invoke-Command + @{ + Description = 'Invoke-Command with -ComputerName' + ScriptBlock = '{ + Invoke-Command -ScriptBlock {Write-Output $foo} -ComputerName "bar" + }' + } + @{ + Description = 'Invoke-Command with two different sessions, where var is declared in wrong session' + ScriptBlock = '{ + $session = new-PSSession -ComputerName "baz" + $otherSession = new-PSSession -ComputerName "bar" + Invoke-Command -session $session -ScriptBlock {[string]$foo = "foo" } + Invoke-Command -session $otherSession -ScriptBlock {Write-Output $foo} + }' + } + @{ + Description = 'Invoke-Command with session, where var is declared after use' + ScriptBlock = '{ + $session = new-PSSession -ComputerName "baz" + Invoke-Command -session $session -ScriptBlock {Write-Output $foo} + Invoke-Command -session $session -ScriptBlock {$foo = "foo" } + }' + } + # DSC Script resource + @{ + Description = 'DSC Script resource with GetScript {}' + ScriptBlock = 'Script ReturnFoo { + GetScript = { + return @{ "Result" = "$foo" } + } + }' + } + @{ + Description = 'DSC Script resource with TestScript {}' + ScriptBlock = 'Script TestFoo { + TestScript = { + return [bool]$foo + } + }' + } + @{ + Description = 'DSC Script resource with SetScript {}' + ScriptBlock = 'Script SetFoo { + SetScript = { + $foo | Set-Content -path "~\nonexistent\foo.txt" + } + }' + } + ) + } + + It "should emit for: " -TestCases $testCases { + param($Description, $ScriptBlock) + [System.Array] $warnings = Invoke-ScriptAnalyzer -ScriptDefinition $ScriptBlock -Settings $settings + $warnings.Count | Should -Be 1 + } + + It "should emit suggested correction" { + $ScriptBlock = '{ + 1..2 | ForEach-Object -Parallel { $var } + }' + $warnings = Invoke-ScriptAnalyzer -ScriptDefinition $ScriptBlock -Settings $settings + + $warnings[0].SuggestedCorrections[0].Text | Should -Be '$using:var' + } + } + + Context "Should not detect anything" { + BeforeAll { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments','')] + $testCases = @( + @{ + Description = "Foreach-Object with uninitialized var inside" + ScriptBlock = '{ + 1..2 | ForEach-Object { $var } + }' + } + @{ + Description = "Foreach-Object -Parallel with uninitialized `$using: var" + ScriptBlock = '{ + 1..2 | foreach-object -Parallel { $using:var } + }' + } + @{ + Description = "Foreach-Object -Parallel with var assigned locally" + ScriptBlock = '{ + 1..2 | ForEach-Object -Parallel { [string]$var="somevalue" } + }' + } + @{ + Description = "Foreach-Object -Parallel with built-in var '`$PSBoundParameters' inside" + ScriptBlock = '{ + 1..2 | ForEach-Object -Parallel{ $PSBoundParameters } + }' + } + @{ + Description = "Foreach-Object -Parallel with vars in other parameters" + ScriptBlock = '{ + $foo = "bar" + ForEach-Object -Parallel {$_} -InputObject $foo + }' + } + # Start-Job / Start-ThreadJob + @{ + Description = 'Start-Job with $using:' + ScriptBlock = '{ + $foo = "bar" + Start-Job -ScriptBlock {$using:foo} | Receive-Job -Wait -AutoRemoveJob + }' + } + @{ + Description = 'Start-ThreadJob with $using:' + ScriptBlock = '{ + $foo = "bar" + Start-ThreadJob -ScriptBlock {$using:foo} | Receive-Job -Wait -AutoRemoveJob + }' + } + @{ + Description = 'Start-Job with -InitializationScript with a variable' + ScriptBlock = '{ + $foo = "bar" + Start-Job -ScriptBlock {$using:foo} -InitializationScript {$foo} | Receive-Job -Wait -AutoRemoveJob + }' + } + @{ + Description = 'Start-ThreadJob with -InitializationScript with a variable' + ScriptBlock = '{ + $foo = "bar" + Start-ThreadJob -ScriptBlock {$using:foo} -InitializationScript {$foo} | Receive-Job -Wait -AutoRemoveJob + }' + } + # workflow/inlinescript + @{ + Description = "Workflow/InlineScript" + ScriptBlock = '{ + $foo = "bar" + workflow baz { InlineScript {$using:foo} } + }' + } + # Invoke-Command + @{ + Description = 'Invoke-Command -Session, var declared in same session, other scriptblock' + ScriptBlock = '{ + $session = new-PSSession -ComputerName "baz" + Invoke-Command -session $session -ScriptBlock {$foo = "foo" } + Invoke-Command -session $session -ScriptBlock {Write-Output $foo} + }' + } + @{ + Description = 'Invoke-Command without -ComputerName' + ScriptBlock = '{ + Invoke-Command -ScriptBlock {Write-Output $foo} + }' + } + # Unsupported scenarios + @{ + Description = 'Rule should skip analysis when Command Name cannot be resolved' + ScriptBlock = '{ + $commandName = "Invoke-Command" + & $commandName -ComputerName -ScriptBlock { $foo } + }' + } + # DSC Script resource + @{ + Description = 'DSC Script resource with GetScript {}' + ScriptBlock = 'Script ReturnFoo { + GetScript = { + return @{ "Result" = "$using:foo" } + } + }' + } + @{ + Description = 'DSC Script resource with TestScript {}' + ScriptBlock = 'Script TestFoo { + TestScript = { + return [bool]$using:foo + } + }' + } + @{ + Description = 'DSC Script resource with SetScript {}' + ScriptBlock = 'Script SetFoo { + SetScript = { + $using:foo | Set-Content -path "~\nonexistent\foo.txt" + } + }' + } + @{ + Description = 'Non-DSC function with the name SetScript {}' + ScriptBlock = '{ + SetScript -ScriptBlock { + $foo | Set-Content -path "~\nonexistent\foo.txt" + } + }' + } + ) + } + + It "should not emit anything for: " -TestCases $testCases { + param($Description, $ScriptBlock) + [System.Array] $warnings = Invoke-ScriptAnalyzer -ScriptDefinition $ScriptBlock -Settings $settings + $warnings.Count | Should -Be 0 + } + } +}