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
+ }
+ }
+}