diff --git a/Engine/Commands/InvokeScriptAnalyzerCommand.cs b/Engine/Commands/InvokeScriptAnalyzerCommand.cs index a444327e0..edcc79cba 100644 --- a/Engine/Commands/InvokeScriptAnalyzerCommand.cs +++ b/Engine/Commands/InvokeScriptAnalyzerCommand.cs @@ -340,11 +340,11 @@ protected override void BeginProcessing() settingsObj?.CustomRulePath?.ToArray(), this.SessionState, combRecurseCustomRulePath); - combRulePaths = rulePaths == null - ? settingsCustomRulePath - : settingsCustomRulePath == null - ? rulePaths - : rulePaths.Concat(settingsCustomRulePath).ToArray(); + combRulePaths = rulePaths == null + ? settingsCustomRulePath + : settingsCustomRulePath == null + ? rulePaths + : rulePaths.Concat(settingsCustomRulePath).ToArray(); } catch (Exception exception) { diff --git a/Engine/Settings/PSGallery.psd1 b/Engine/Settings/PSGallery.psd1 index 38fd93a4f..f900b2c30 100644 --- a/Engine/Settings/PSGallery.psd1 +++ b/Engine/Settings/PSGallery.psd1 @@ -16,6 +16,7 @@ 'PSAvoidGlobalVars', 'PSUseDeclaredVarsMoreThanAssignments', 'PSAvoidUsingInvokeExpression', + 'PSAvoidUsingNewObject', 'PSAvoidUsingPlainTextForPassword', 'PSAvoidUsingComputerNameHardcoded', 'PSUsePSCredentialType', diff --git a/Engine/Settings/ScriptFunctions.psd1 b/Engine/Settings/ScriptFunctions.psd1 index b394abddf..79c39a9d5 100644 --- a/Engine/Settings/ScriptFunctions.psd1 +++ b/Engine/Settings/ScriptFunctions.psd1 @@ -7,5 +7,6 @@ 'PSAvoidUsingPositionalParameters', 'PSAvoidGlobalVars', 'PSUseDeclaredVarsMoreThanAssignments', - 'PSAvoidUsingInvokeExpression') + 'PSAvoidUsingInvokeExpression', + 'PSAvoidUsingNewObject') } \ No newline at end of file diff --git a/Rules/AvoidUsingNewObject.cs b/Rules/AvoidUsingNewObject.cs new file mode 100644 index 000000000..5cc35c34f --- /dev/null +++ b/Rules/AvoidUsingNewObject.cs @@ -0,0 +1,644 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Management.Automation.Language; +using Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic; + +#if !CORECLR +using System.ComponentModel.Composition; +#endif + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules +{ + /// + /// Flags New-Object usage except when creating COM objects via -ComObject parameter. + /// Supports parameter abbreviation, splatting, variable resolution, and expandable strings. + /// +#if !CORECLR + [Export(typeof(IScriptRule))] +#endif + public class AvoidUsingNewObject : IScriptRule + { + #region Constants + + private const string CmdletName = "New-Object"; + private const string ComObjectParameterName = "ComObject"; + private const string TypeNameParameterName = "TypeName"; + + #endregion + + #region Fields + + /// + /// Root AST for variable assignment tracking. + /// + private Ast _rootAst; + + /// + /// Lazy-loaded cache mapping variable names to their assignment statements. + /// Only initialized when splatted variables are encountered. + /// + private Lazy>> _assignmentCache; + + #endregion + + #region Public Methods + + /// + /// Analyzes PowerShell AST for New-Object usage that should be replaced with type literals. + /// + /// The root AST to analyze + /// Source file name for diagnostic reporting + /// Enumerable of diagnostic records for non-COM New-Object usage + /// Thrown when ast parameter is null + public IEnumerable AnalyzeScript(Ast ast, string fileName) + { + if (ast == null) throw new ArgumentNullException(Strings.NullAstErrorMessage); + + _rootAst = ast; + + // Direct enumeration without intermediate collection + foreach (var node in ast.FindAll(testAst => testAst is CommandAst cmdAst && + string.Equals(cmdAst.GetCommandName(), CmdletName, StringComparison.OrdinalIgnoreCase), true)) + { + var cmdAst = (CommandAst)node; + + if (!IsComObject(cmdAst)) + { + yield return new DiagnosticRecord( + string.Format(CultureInfo.CurrentCulture, Strings.AvoidUsingNewObjectError), + cmdAst.Extent, + GetName(), + DiagnosticSeverity.Warning, + fileName, + CmdletName); + } + } + } + + #endregion + + #region Private Instance Methods - Core Logic + + /// + /// Determines if a New-Object command creates a COM object. + /// + /// The New-Object command AST to analyze + /// True if command creates COM object via -ComObject parameter; false otherwise + private bool IsComObject(CommandAst commandAst) + { + // Quick check for positional TypeName (non-splat expressions after New-Object) + if (commandAst.CommandElements.Count >= 2) + { + var firstArg = commandAst.CommandElements[1]; + + // Non-splat variable provided for first positional parameter. + if (firstArg is VariableExpressionAst varAst && !varAst.Splatted) + { + return false; + } + + // Non-splat using expression provided for first positional parameter (e.g., $using:var) + if (firstArg is UsingExpressionAst usingAst && !(usingAst.SubExpression is VariableExpressionAst usingVar && usingVar.Splatted)) + { + return false; + } + } + + // Parse named parameters with early TypeName exit + return HasComObjectParameter(commandAst); + } + + /// + /// Parses command parameters to detect COM object usage with minimal allocations. + /// Implements early exit optimization for TypeName parameter detection. + /// + /// The command AST to analyze for parameters + /// True if ComObject parameter is found; false if TypeName parameter is found or neither + private bool HasComObjectParameter(CommandAst commandAst) + { + var waitingForParamValue = false; + var elements = commandAst.CommandElements; + var elementCount = elements.Count; + + // Fast path: insufficient elements + if (elementCount <= 1) return false; + + for (int i = 1; i < elementCount; i++) + { + var element = elements[i]; + + if (waitingForParamValue) + { + waitingForParamValue = false; + continue; + } + + switch (element) + { + case CommandParameterAst paramAst: + var paramName = paramAst.ParameterName; + + // Early exit: TypeName parameter means not COM + if (TypeNameParameterName.StartsWith(paramName, StringComparison.OrdinalIgnoreCase)) + return false; + + // Found ComObject parameter + if (ComObjectParameterName.StartsWith(paramName, StringComparison.OrdinalIgnoreCase)) + return true; + + waitingForParamValue = true; + break; + + case ExpandableStringExpressionAst expandableAst: + if (ProcessExpandableString(expandableAst, ref waitingForParamValue, out bool result)) + return result; + break; + + case VariableExpressionAst varAst when varAst.Splatted: + if (IsSplattedVariableComObject(varAst)) + return true; + break; + + case UsingExpressionAst usingAst when usingAst.SubExpression is VariableExpressionAst usingVar && usingVar.Splatted: + if (IsSplattedVariableComObject((VariableExpressionAst)usingAst.SubExpression)) + return true; + break; + } + } + + return false; + } + + /// + /// Processes expandable strings with minimal allocations. + /// + private bool ProcessExpandableString( + ExpandableStringExpressionAst expandableAst, + ref bool waitingForParamValue, + out bool foundResult) + { + foundResult = false; + var expandedValues = Helper.Instance.GetStringsFromExpressionAst(expandableAst); + + if (expandedValues is IList list && list.Count == 0 && expandableAst.NestedExpressions != null) + { + // Defer cache initialization until actually needed + var resolvedText = TryResolveExpandableString(expandableAst); + if (resolvedText != null) + { + expandedValues = new[] { resolvedText }; + } + } + + foreach (var expandedValue in expandedValues) + { + if (expandedValue.Length > 1 && expandedValue[0] == '-') + { + // Avoid substring allocation by using span slicing + var paramName = GetParameterNameFromExpandedValue(expandedValue); + + if (TypeNameParameterName.StartsWith(paramName, StringComparison.OrdinalIgnoreCase)) + { + foundResult = true; + return true; // TypeName found, not COM + } + + if (ComObjectParameterName.StartsWith(paramName, StringComparison.OrdinalIgnoreCase)) + { + foundResult = true; + return true; // ComObject found + } + + waitingForParamValue = true; + } + } + + return false; + } + + #endregion + + #region Private Instance Methods - Variable Resolution + + /// + /// Lazy resolution of expandable strings - only initialize cache when needed. + /// + private string TryResolveExpandableString(ExpandableStringExpressionAst expandableAst) + { + if (expandableAst.NestedExpressions?.Count != 1) + return null; + + var nestedExpr = expandableAst.NestedExpressions[0]; + + if (!(nestedExpr is VariableExpressionAst varAst)) + return null; + + // Now we actually need the cache + EnsureAssignmentCacheInitialized(); + + var varName = GetVariableNameWithoutScope(varAst); + var varValues = ResolveVariableValues(varName); + + if (varValues is IList list && list.Count > 0) + { + var resolvedText = expandableAst.Extent.Text; + var userPath = varAst.VariablePath.UserPath; + + // Optimize string replacement + var varPattern = string.Concat("${", userPath, "}"); + var index = resolvedText.IndexOf(varPattern, StringComparison.Ordinal); + + if (index >= 0) + { + return string.Concat( + resolvedText.Substring(0, index), + list[0], + resolvedText.Substring(index + varPattern.Length) + ); + } + + // Try without braces + varPattern = "$" + userPath; + index = resolvedText.IndexOf(varPattern, StringComparison.Ordinal); + + if (index >= 0) + { + return string.Concat( + resolvedText.Substring(0, index), + list[0], + resolvedText.Substring(index + varPattern.Length) + ); + } + } + + return null; + } + + /// + /// Analyzes splatted variable for COM object parameters by examining hashtable assignments. + /// Supports string literals, expandable strings, and variable key resolution. + /// + /// Splatted variable expression to analyze + /// True if hashtable contains ComObject key or abbreviation + private bool IsSplattedVariableComObject(VariableExpressionAst splattedVar) + { + EnsureAssignmentCacheInitialized(); + + var variableName = GetVariableNameWithoutScope(splattedVar); + + return IsSplattedVariableComObjectRecursive(variableName, new HashSet(StringComparer.OrdinalIgnoreCase)); + } + + private bool IsSplattedVariableComObjectRecursive(string variableName, HashSet visited) + { + if (visited.Contains(variableName)) + { + return false; + } + + visited.Add(variableName); + + var assignments = FindHashtableAssignments(variableName); + + foreach (var assignment in assignments) + { + if (assignment.Right is CommandExpressionAst cmdExpr) + { + if (cmdExpr.Expression is HashtableAst hashtableAst) + { + var hasComKey = HasComObjectKey(hashtableAst); + + if (hasComKey) + { + return true; + } + } + else if (cmdExpr.Expression is VariableExpressionAst referencedVar) + { + // Follow variable chain: $params2 = $script:params + var referencedName = GetVariableNameWithoutScope(referencedVar); + + if (IsSplattedVariableComObjectRecursive(referencedName, visited)) + { + return true; + } + } + } + } + + return false; + } + + #endregion + + #region Private Instance Methods - Cache Management + + /// + /// Ensures the assignment cache is initialized before use. + /// + private void EnsureAssignmentCacheInitialized() + { + if (_assignmentCache == null) + { + _assignmentCache = new Lazy>>(BuildAssignmentCache); + } + } + + /// + /// Builds case-insensitive cache mapping variable names to their assignment statements. + /// Single AST traversal shared across all variable lookups. + /// + /// Dictionary of variable assignments indexed by variable name + private Dictionary> BuildAssignmentCache() + { + var cache = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var ast in _rootAst.FindAll(ast => ast is AssignmentStatementAst, true)) + { + var assignment = (AssignmentStatementAst)ast; + VariableExpressionAst leftVar = null; + + switch (assignment.Left) + { + case VariableExpressionAst varAst: + leftVar = varAst; + break; + case ConvertExpressionAst convertExpr when convertExpr.Child is VariableExpressionAst convertedVar: + leftVar = convertedVar; + break; + } + + if (leftVar != null) + { + var variableName = GetVariableNameWithoutScope(leftVar); + + if (!cache.TryGetValue(variableName, out var list)) + { + list = new List(4); + cache[variableName] = list; + } + + list.Add(assignment); + } + } + + return cache; + } + + /// + /// Retrieves cached assignment statements for the specified variable. + /// + /// Variable name to look up + /// List of assignment statements or empty list if none found + private List FindHashtableAssignments(string variableName) + { + var cache = _assignmentCache.Value; + return cache.TryGetValue(variableName, out var assignments) + ? assignments + : new List(); + } + + #endregion + + #region Private Instance Methods - Hashtable Analysis + + /// + /// Checks hashtable for ComObject keys, supporting parameter abbreviation. + /// Handles string literals, expandable strings, and variable keys. + /// + /// Hashtable AST to examine + /// True if any key matches ComObject parameter name or abbreviation + private bool HasComObjectKey(HashtableAst hashtableAst) + { + foreach (var keyValuePair in hashtableAst.KeyValuePairs) + { + var keyStrings = GetKeyStrings(keyValuePair.Item1); + + foreach (var keyString in keyStrings) + { + // Require minimum 3 characters for COM parameter abbreviation to avoid false positives + if (keyString.Length >= 3) + { + if (ComObjectParameterName.StartsWith(keyString, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + } + } + + return false; + } + + /// + /// Extracts string values from hashtable key expressions. + /// Supports literals, expandable strings, and variable resolution. + /// + /// Key expression from hashtable + /// List of resolved string values for the key + private List GetKeyStrings(ExpressionAst keyExpression) + { + switch (keyExpression) + { + case StringConstantExpressionAst stringAst: + return new List(1) { stringAst.Value }; + + case ExpandableStringExpressionAst expandableAst: + var expandedStrings = Helper.Instance.GetStringsFromExpressionAst(expandableAst); + + if (expandedStrings is IList list && list.Count > 0) + return new List(list); + + // Fallback for single variable case + if (expandableAst.NestedExpressions?.Count == 1 && + expandableAst.NestedExpressions[0] is VariableExpressionAst varAst) + { + return ResolveVariableValues(varAst.VariablePath.UserPath); + } + break; + + case VariableExpressionAst variableAst: + return ResolveVariableValues(variableAst.VariablePath.UserPath); + } + + return new List(0); + } + + /// + /// Resolves variable values by analyzing string assignments using cached lookups. + /// + /// Variable name to resolve + /// List of possible string values assigned to the variable + private List ResolveVariableValues(string variableName) + { + var values = new List(); + + // Optimize scope removal + var colonIndex = variableName.IndexOf(':'); + var normalizedName = colonIndex >= 0 + ? TrimQuotes(variableName.Substring(colonIndex + 1)) + : TrimQuotes(variableName); + + if (_assignmentCache.Value.TryGetValue(normalizedName, out var variableAssignments)) + { + foreach (var assignment in variableAssignments) + { + if (assignment.Right is CommandExpressionAst cmdExpr) + { + var extractedValues = Helper.Instance.GetStringsFromExpressionAst(cmdExpr.Expression); + + // Avoid AddRange if possible + if (extractedValues is IList list) + { + for (int i = 0; i < list.Count; i++) + values.Add(list[i]); + } + else + { + values.AddRange(extractedValues); + } + } + } + } + + return values; + } + + #endregion + + #region Private Static Methods + + /// + /// Extracts parameter name without allocating substrings. + /// + private static string GetParameterNameFromExpandedValue(string expandedValue) + { + // Skip the '-' prefix + var startIndex = 1; + var length = expandedValue.Length - 1; + + // Check for quotes and adjust + if (length >= 2) + { + var firstChar = expandedValue[startIndex]; + var lastChar = expandedValue[expandedValue.Length - 1]; + + if ((firstChar == '"' || firstChar == '\'') && firstChar == lastChar) + { + startIndex++; + length -= 2; + } + } + + // Only allocate if we need to trim + return startIndex == 1 && length == expandedValue.Length - 1 + ? expandedValue.Substring(1) + : expandedValue.Substring(startIndex, length); + } + + /// + /// Extracts the variable name without scope from a VariableExpressionAst. + /// Additionally, trims quotes from the variable name (required for expandable strings). + /// + /// The VariableExpressionAst to extract the variable name from + /// The variable name without scope + private static string GetVariableNameWithoutScope(VariableExpressionAst variableAst) + { + var variableName = Helper.Instance.VariableNameWithoutScope(variableAst.VariablePath); + return TrimQuotes(variableName); + } + + /// + /// ExpandableStringExpressionAst's with quotes will not resolve to a non-quoted string. + /// It's necessary to trim the quotes from the input string in order to successfully lookup + /// the variable value. + /// + /// The input string to trim + /// The trimmed string, or the original string if it doesn't contain quotes + private static string TrimQuotes(string input) + { + if (input.Length < 2) + return input; + + char first = input[0]; + char last = input[input.Length - 1]; + + if (first != last) + return input; + + if (first == '"' || first == '\'') + return input.Substring(1, input.Length - 2); + + return input; + } + + #endregion + + #region IScriptRule Implementation + + /// + /// Gets the fully qualified name of this rule. + /// + /// Rule name in format "SourceName\RuleName" + public string GetName() + { + return string.Format( + CultureInfo.CurrentCulture, + Strings.NameSpaceFormat, + GetSourceName(), + Strings.AvoidUsingNewObjectName + ); + } + + /// + /// Gets the user-friendly common name of this rule. + /// + /// Localized common name + public string GetCommonName() + { + return string.Format(CultureInfo.CurrentCulture, Strings.AvoidUsingNewObjectCommonName); + } + + /// + /// Gets the detailed description of what this rule checks. + /// + /// Localized rule description + public string GetDescription() + { + return string.Format(CultureInfo.CurrentCulture, Strings.AvoidUsingNewObjectDescription); + } + + /// + /// Gets the severity level of violations detected by this rule. + /// + /// Warning severity level + public RuleSeverity GetSeverity() + { + return RuleSeverity.Warning; + } + + /// + /// Gets the source name for this rule. + /// + /// PSScriptAnalyzer source name + public string GetSourceName() + { + return string.Format(CultureInfo.CurrentCulture, Strings.SourceName); + } + + /// + /// Gets the source type indicating this is a built-in rule. + /// + /// Builtin source type + public SourceType GetSourceType() + { + return SourceType.Builtin; + } + + #endregion + } +} \ No newline at end of file diff --git a/Rules/Strings.resx b/Rules/Strings.resx index 260214967..e53caff6c 100644 --- a/Rules/Strings.resx +++ b/Rules/Strings.resx @@ -135,6 +135,12 @@ Avoid Using Invoke-Expression + + For creating .NET objects, prefer using type literals (e.g., [Some.Type]::new()) over the New-Object cmdlet. Type literals offer better performance, compile-time validation, and clearer intent, especially for well-known types. + + + Avoid Using New-Object + Readability and clarity should be the goal of any script we expect to maintain over time. When calling a command that takes parameters, where possible consider using name parameters as opposed to positional parameters. To fix a violation of this rule, please use named parameters instead of positional parameters when calling a command. @@ -381,6 +387,9 @@ AvoidUsingInvokeExpression + + AvoidUsingNewObject + AvoidUsingPlainTextForPassword @@ -519,6 +528,9 @@ Invoke-Expression is used. Please remove Invoke-Expression from script and find other options instead. + + Consider replacing the 'New-Object' cmdlet with type literals (e.g., '[System.Object]::new()') for creating .NET objects. This approach is generally more performant and idiomatic. + Cmdlet '{0}' has positional parameter. Please use named parameters instead of positional parameters when calling a command. diff --git a/Tests/Rules/AvoidUsingNewObject.ps1 b/Tests/Rules/AvoidUsingNewObject.ps1 new file mode 100644 index 000000000..0ee714183 --- /dev/null +++ b/Tests/Rules/AvoidUsingNewObject.ps1 @@ -0,0 +1,107 @@ +$hashset1 = New-Object System.Collections.Generic.HashSet[String] # Issue #1 + +[Hashtable] $param = @{ + TypeName = 'System.Collections.Generic.HashSet[String]' +} + +New-Object @param # Issue #2 + +function Test +{ + $hashset2 = New-Object -TypeName System.Collections.Generic.HashSet[String] # Issue #3 + New-Object -TypeName System.Collections.Generic.HashSet[String] # Issue #4 + + [Hashtable] $param = @{ + TypeName = 'System.Collections.Generic.HashSet[String]' + } + + New-Object @param # Issue #5 + + Invoke-Command -ComputerName "localhost" -ScriptBlock { + New-Object -TypeName System.Collections.Generic.HashSet[String] # Issue #6 + + [Hashtable] $param = @{ + TypeName = 'System.Collections.Generic.HashSet[String]' + } + + New-Object @param # Issue #7 + } + + function Test2 + { + [Cmdletbinding()] + Param () + + New-Object -TypeName System.Collections.Generic.HashSet[String] # Issue #8 + + [Hashtable] $param = @{ + TypeName = 'System.Collections.Generic.HashSet[String]' + } + + New-Object @param # Issue #9 + + Invoke-Command -ComputerName "localhost" -ScriptBlock { + New-Object -TypeName System.Collections.Generic.HashSet[String] # Issue #10 + } + } +} + +class TestClass +{ + [System.Collections.Generic.HashSet[String]] $Set + [System.Collections.Generic.HashSet[String]] $Set2 = (New-Object -TypeName System.Collections.Generic.HashSet[String]) # Issue #11 + + TestClass () + { + $this.Set = (New-Object -TypeName System.Collections.Generic.HashSet[String]) # Issue #12 + + Invoke-Command -ComputerName "localhost" -ScriptBlock { + New-Object -TypeName System.Collections.Generic.HashSet[String] # Issue #13 + + } + + [Hashtable] $param = @{ + TypeName = 'System.Collections.Generic.HashSet[String]' + } + + New-Object @param # Issue #14 + } +} + +[Hashtable] $script:params = @{ + TypeName = 'System.Collections.Generic.HashSet[String]' +} + +function Test-Scope +{ + New-Object @script:params # Issue #15 + + $params2 = $script:params + New-Object @params2 # Issue #16 + + [Hashtable] $params3 = @{ + TypeName = 'System.Collections.Generic.HashSet[String]' + } + + Invoke-Command -ComputerName "localhost" -ScriptBlock { + New-Object @using:params3 # Issue #17 + } +} + +$scripbBlockTest = { + New-Object -TypeName System.Collections.Generic.HashSet[String] # Issue #18 + + [Hashtable] $params4 = @{ + TypeName = 'System.Collections.Generic.HashSet[String]' + } + + New-Object @params4 # Issue #19 +} + +$test = "co" +$value = "WScript.Shell" +[hashtable] $test1 = @{ + "$test" = $value +} + +New-Object @test1 # Issue #20 \ No newline at end of file diff --git a/Tests/Rules/AvoidUsingNewObject.tests.ps1 b/Tests/Rules/AvoidUsingNewObject.tests.ps1 new file mode 100644 index 000000000..5c2f27c62 --- /dev/null +++ b/Tests/Rules/AvoidUsingNewObject.tests.ps1 @@ -0,0 +1,27 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +BeforeAll { + $NewObjectMessage = "Consider replacing the 'New-Object' cmdlet with type literals" + $NewObjectName = "PSAvoidUsingNewObject" + $violations = Invoke-ScriptAnalyzer "$PSScriptRoot\AvoidUsingNewObject.ps1" | Where-Object { $_.RuleName -eq $NewObjectName } + $noViolations = Invoke-ScriptAnalyzer "$PSScriptRoot\AvoidUsingNewObjectNoViolations.ps1" | Where-Object { $_.RuleName -eq $NewObjectName } +} + +Describe "AvoidUsingNewObject" { + Context "When there are violations" { + It "has 20 New-Object violations" { + $violations.Count | Should -Be 20 + } + + It "has the correct description message for New-Object" { + $violations[0].Message | Should -Match $NewObjectMessage + } + } + + Context "When there are no violations" { + It "has 0 violations" { + $noViolations.Count | Should -Be 0 + } + } +} diff --git a/Tests/Rules/AvoidUsingNewObjectNoViolations.ps1 b/Tests/Rules/AvoidUsingNewObjectNoViolations.ps1 new file mode 100644 index 000000000..ca0c86aa5 --- /dev/null +++ b/Tests/Rules/AvoidUsingNewObjectNoViolations.ps1 @@ -0,0 +1,104 @@ +New-Object -ComObject "WScript.Shell" + +[Hashtable] $param = @{ + ComObject = "WScript.Shell" +} + +New-Object @param + +function Test +{ + $partialParameter = "CO" + New-Object -"$partialParameter" "WScript.Shell" + + [Hashtable] $param = @{ + ComO = "WScript.Shell" + } + + New-Object @param + + Invoke-Command -ComputerName "localhost" -ScriptBlock { + New-Object -ComObject "WScript.Shell" + + [Hashtable] $param = @{ + CO = "WScript.Shell" + } + + New-Object @param + } + + function Test2 + { + [Cmdletbinding()] + Param () + + New-Object -ComObject "WScript.Shell" + + [Hashtable] $param = @{ + C = "WScript.Shell" + } + + New-Object @param + + Invoke-Command -ComputerName "localhost" -ScriptBlock { + New-Object -ComObject "WScript.Shell" + } + } +} + +class TestClass +{ + TestClass () + { + New-Object -ComObject "WScript.Shell" + + Invoke-Command -ComputerName "localhost" -ScriptBlock { + New-Object -ComObject "WScript.Shell" + + } + + [Hashtable] $param = @{ + ComObject = "WScript.Shell" + } + + New-Object @param + } +} + +[Hashtable] $script:params = @{ + ComObject = "WScript.Shell" +} + +function Test-Scope +{ + New-Object @script:params + + $params2 = $script:params + New-Object @params2 + + [Hashtable] $params3 = @{ + ComObject = "WScript.Shell" + } + + Invoke-Command -ComputerName "localhost" -ScriptBlock { + New-Object @using:params3 + } +} + +$scripbBlockTest = { + New-Object -ComObject "WScript.Shell" + + [Hashtable] $params4 = @{ + ComObject = 'WScript.Shell' + } + + New-Object @params4 +} + +$partialKey = "COMO" +$value = "WScript.Shell" +[hashtable] $partialKeyParams = @{ + "$partialKey" = $value +} + +New-Object @partialKeyParams \ No newline at end of file diff --git a/docs/Rules/AvoidUsingNewObject.md b/docs/Rules/AvoidUsingNewObject.md new file mode 100644 index 000000000..b3603ae1e --- /dev/null +++ b/docs/Rules/AvoidUsingNewObject.md @@ -0,0 +1,256 @@ +--- +description: Avoid Using New-Object +ms.date: 06/14/2025 +ms.topic: reference +title: AvoidUsingNewObject +--- +# AvoidUsingNewObject + +**Severity Level: Warning** + +## Description + +The `New-Object` cmdlet should be avoided in modern PowerShell code except when creating COM objects. PowerShell provides more efficient, readable, and idiomatic alternatives for object creation that offer better performance and cleaner syntax. + +This rule flags all uses of `New-Object` ***except*** when used with the `-ComObject` parameter, as COM object creation is one of the few legitimate remaining use cases for this cmdlet. + +## Why Avoid New-Object? + +### Performance Issues +`New-Object` uses reflection internally, which is significantly slower than direct type instantiation or accelerated type syntax. The performance difference becomes more pronounced in loops or frequently executed code. + +### Readability and Maintainability +Modern PowerShell syntax is more concise and easier to read than `New-Object` constructions, especially for common .NET types. + +### PowerShell Best Practices +The PowerShell community and Microsoft recommend using native PowerShell syntax over legacy cmdlets when better alternatives exist. + +## Examples + +### Wrong + +```powershell +# Creating .NET objects +$list = New-Object System.Collections.Generic.List[string] +$hashtable = New-Object System.Collections.Hashtable +$stringBuilder = New-Object System.Text.StringBuilder +$datetime = New-Object System.DateTime(2023, 12, 25) + +# Creating custom objects +$obj = New-Object PSObject -Property @{ + Name = "John" + Age = 30 +} +``` + +### Correct + +```powershell +# Use accelerated type syntax for .NET objects +$list = [System.Collections.Generic.List[string]]::new() +$hashtable = @{} # or [hashtable]@{} +$stringBuilder = [System.Text.StringBuilder]::new() +$datetime = [DateTime]::new(2023, 12, 25) + +# Use PSCustomObject for custom objects +$obj = [PSCustomObject]@{ + Name = "John" + Age = 30 +} + +# COM objects are still acceptable with New-Object +$excel = New-Object -ComObject Excel.Application +$word = New-Object -ComObject Word.Application +``` + +## Alternative Approaches + +### For .NET Types +- **Static `new()` method**: `[TypeName]::new(parameters)` +- **Type accelerators**: Use built-in shortcuts like `@{}` for hashtables +- **Cast operators**: `[TypeName]$value` for type conversion + +### For Custom Objects +- **PSCustomObject**: `[PSCustomObject]@{ Property = Value }` +- **Ordered dictionaries**: `[ordered]@{ Property = Value }` + +### For Collections +- **Array subexpression**: `@(items)` +- **Hashtable literal**: `@{ Key = Value }` +- **Generic collections**: `[System.Collections.Generic.List[Type]]::new()` + +## Performance Comparison + +```powershell +# Slow - uses reflection +Measure-Command { 1..1000 | ForEach-Object { New-Object System.Text.StringBuilder } } + +# Fast - direct instantiation +Measure-Command { 1..1000 | ForEach-Object { [System.Text.StringBuilder]::new() } } +``` + +The modern syntax provides a performance improvement over `New-Object` for most common scenarios. + +## Exceptions + +The rule allows `New-Object` when used with the `-ComObject` parameter because: +- COM object creation requires the `New-Object` cmdlet. +- No direct PowerShell alternative exists for COM instantiation. +- COM objects are external to the .NET type system. + +```powershell +# This is acceptable +$shell = New-Object -ComObject WScript.Shell +$ie = New-Object -ComObject InternetExplorer.Application +``` + +--- + +## Migration Guide + +| Old Syntax | New Syntax | +|------------|------------| +| **Creating a custom object**:
`New-Object PSObject -Property @{ Name = 'John'; Age = 30 }` | **Use PSCustomObject**:
`[PSCustomObject]@{ Name = 'John'; Age = 30 }` | +| **Creating a hashtable**:
`New-Object System.Collections.Hashtable` | **Use hashtable literal**:
`@{}`
**Or explicitly cast**:
`[hashtable]@{}` | +| **Creating a generic list**:
`New-Object 'System.Collections.Generic.List[string]'` | **Use static `new()` method**:
`[System.Collections.Generic.List[string]]::new()` | +| **Creating a DateTime object**:
`New-Object DateTime -ArgumentList 2023, 12, 25` | **Use static `new()` method**:
`[DateTime]::new(2023, 12, 25)` | +| **Creating a StringBuilder**:
`New-Object System.Text.StringBuilder` | **Use static `new()` method**:
`[System.Text.StringBuilder]::new()` | +| **Creating a process object**:
`New-Object System.Diagnostics.Process` | **Use static `new()` method**:
`[System.Diagnostics.Process]::new()` | +| **Creating a custom .NET object**:
`New-Object -TypeName 'Namespace.TypeName' -ArgumentList $args` | **Use static `new()` method**:
`[Namespace.TypeName]::new($args)` | + +--- + +### Detailed Examples + +#### Custom Object Creation + +**Old Syntax:** + +```powershell +$obj = New-Object PSObject -Property @{ + Name = 'John' + Age = 30 +} +``` + +**New Syntax:** + +```powershell +$obj = [PSCustomObject]@{ + Name = 'John' + Age = 30 +} +``` + +#### Hashtable Creation + +**Old Syntax:** + +```powershell +$hashtable = New-Object System.Collections.Hashtable +$hashtable.Add('Key', 'Value') +``` + +**New Syntax:** + +```powershell +$hashtable = @{ + Key = 'Value' +} +``` + +Or explicitly cast: + +```powershell +$hashtable = [hashtable]@{ + Key = 'Value' +} +``` + +#### Generic List Creation + +**Old Syntax:** + +```powershell +$list = New-Object 'System.Collections.Generic.List[string]' +$list.Add('Item1') +$list.Add('Item2') +``` + +**New Syntax:** + +```powershell +$list = [System.Collections.Generic.List[string]]::new() +$list.Add('Item1') +$list.Add('Item2') +``` + +#### DateTime Object Creation + +**Old Syntax:** + +```powershell +$date = New-Object DateTime -ArgumentList 2023, 12, 25 +``` + +**New Syntax:** + +```powershell +$date = [DateTime]::new(2023, 12, 25) +``` + +#### StringBuilder Creation + +**Old Syntax:** + +```powershell +$stringBuilder = New-Object System.Text.StringBuilder +$stringBuilder.Append('Hello') +``` + +**New Syntax:** + +```powershell +$stringBuilder = [System.Text.StringBuilder]::new() +$stringBuilder.Append('Hello') +``` + +#### Custom .NET Object Creation + +**Old Syntax:** + +```powershell +$customObject = New-Object -TypeName 'Namespace.TypeName' -ArgumentList $arg1, $arg2 +``` + +**New Syntax:** + +```powershell +$customObject = [Namespace.TypeName]::new($arg1, $arg2) +``` + +#### Process Object Creation + +**Old Syntax:** + +```powershell +$process = New-Object System.Diagnostics.Process +``` + +**New Syntax:** + +```powershell +$process = [System.Diagnostics.Process]::new() +``` + +--- +## Related Links + +- [New-Object][01] +- [PowerShell scripting performance considerations][02] +- [Creating .NET and COM objects][03] + + +[01]: https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.utility/new-object +[02]: https://learn.microsoft.com/en-us/powershell/scripting/dev-cross-plat/performance/script-authoring-considerations +[03]: https://learn.microsoft.com/en-us/powershell/scripting/samples/creating-.net-and-com-objects--new-object- \ No newline at end of file