From 67e239a0134a79a27a06a87a7da26f3d40850234 Mon Sep 17 00:00:00 2001 From: Josh Corrick Date: Sat, 7 Mar 2026 14:30:06 -0800 Subject: [PATCH 01/14] Add UseConstrainedLanguageMode rule and tests --- Rules/Strings.resx | 28 ++- Rules/UseConstrainedLanguageMode.cs | 212 ++++++++++++++++++ .../UseConstrainedLanguageMode.tests.ps1 | 96 ++++++++ 3 files changed, 334 insertions(+), 2 deletions(-) create mode 100644 Rules/UseConstrainedLanguageMode.cs create mode 100644 Tests/Rules/UseConstrainedLanguageMode.tests.ps1 diff --git a/Rules/Strings.resx b/Rules/Strings.resx index c7645c9cf..45031370a 100644 --- a/Rules/Strings.resx +++ b/Rules/Strings.resx @@ -1,4 +1,4 @@ - + @@ -1249,7 +1249,7 @@ Add-Type is not permitted in Constrained Language Mode. Consider alternative approaches if this script will run in a restricted environment. - New-Object with COM objects is not permitted in Constrained Language Mode. Consider alternative approaches if this script will run in a restricted environment. + New-Object with the COM object '{0}' is not permitted in Constrained Language Mode. Consider alternative approaches if this script will run in a restricted environment. XAML usage is not permitted in Constrained Language Mode. Consider alternative approaches if this script will run in a restricted environment. @@ -1260,4 +1260,10 @@ Invoke-Expression is restricted in Constrained Language Mode. Consider alternative approaches if this script will run in a restricted environment. - + + New-Object with type '{0}' is not permitted in Constrained Language Mode. Consider using an allowed type. + + + Type constraint [{0}] is not permitted in Constrained Language Mode. Consider using an allowed type. + + \ No newline at end of file diff --git a/Rules/UseConstrainedLanguageMode.cs b/Rules/UseConstrainedLanguageMode.cs index 8debe1cc0..aed3b60ed 100644 --- a/Rules/UseConstrainedLanguageMode.cs +++ b/Rules/UseConstrainedLanguageMode.cs @@ -6,6 +6,8 @@ using System.Linq; using System.Management.Automation.Language; using Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic; +using System.Management.Automation; + #if !CORECLR using System.ComponentModel.Composition; #endif @@ -21,6 +23,80 @@ namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules #endif public class UseConstrainedLanguageMode : ConfigurableRule { + // Allowed COM objects in Constrained Language Mode + private static readonly HashSet AllowedComObjects = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Scripting.Dictionary", + "Scripting.FileSystemObject", + "VBScript.RegExp" + }; + + // Allowed types in Constrained Language Mode (type accelerators and common types) + private static readonly HashSet AllowedTypes = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "adsi", "adsisearcher", "Alias", "AllowEmptyCollection", "AllowEmptyString", + "AllowNull", "ArgumentCompleter", "ArgumentCompletions", "array", "bigint", + "bool", "byte", "char", "cimclass", "cimconverter", "ciminstance", "CimSession", + "cimtype", "CmdletBinding", "cultureinfo", "datetime", "decimal", "double", + "DscLocalConfigurationManager", "DscProperty", "DscResource", "ExperimentAction", + "Experimental", "ExperimentalFeature", "float", "guid", "hashtable", "int", + "int16", "int32", "int64", "ipaddress", "IPEndpoint", "long", "mailaddress", + "Microsoft.PowerShell.Commands.ModuleSpecification", "NoRunspaceAffinity", + "NullString", "Object[]", "ObjectSecurity", "ordered", "OutputType", "Parameter", + "PhysicalAddress", "pscredential", "pscustomobject", "PSDefaultValue", + "pslistmodifier", "psobject", "psprimitivedictionary", "PSTypeNameAttribute", + "regex", "sbyte", "securestring", "semver", "short", "single", "string", + "SupportsWildcards", "switch", "timespan", "uint", "uint16", "uint32", "uint64", + "ulong", "uri", "ushort", "ValidateCount", "ValidateDrive", "ValidateLength", + "ValidateNotNull", "ValidateNotNullOrEmpty", "ValidateNotNullOrWhiteSpace", + "ValidatePattern", "ValidateRange", "ValidateScript", "ValidateSet", + "ValidateTrustedData", "ValidateUserDrive", "version", "void", "WildcardPattern", + "wmi", "wmiclass", "wmisearcher", "X500DistinguishedName", "X509Certificate", "xml", + // Full type names for common allowed types + "System.Object", "System.String", "System.Int32", "System.Boolean", "System.Byte", + "System.Collections.Hashtable", "System.DateTime", "System.Version", "System.Uri", + "System.Guid", "System.TimeSpan", "System.Management.Automation.PSCredential", + "System.Management.Automation.PSObject", "System.Security.SecureString", + "System.Text.RegularExpressions.Regex", "System.Xml.XmlDocument", + "System.Collections.ArrayList", "System.Collections.Generic.List", + "System.Net.IPAddress", "System.Net.Mail.MailAddress" + }; + + public UseConstrainedLanguageMode() + { + // This rule is disabled by default - users must explicitly enable it + Enable = false; + } + + /// + /// Checks if a type name is allowed in Constrained Language Mode + /// + private bool IsTypeAllowed(string typeName) + { + if (string.IsNullOrWhiteSpace(typeName)) + { + return true; // Can't determine, so don't flag + } + + // Check exact match first + if (AllowedTypes.Contains(typeName)) + { + return true; + } + + // Check simple name (last part after last dot) + if (typeName.Contains('.')) + { + var simpleTypeName = typeName.Substring(typeName.LastIndexOf('.') + 1); + if (AllowedTypes.Contains(simpleTypeName)) + { + return true; + } + } + + return false; + } + /// /// Analyzes the script to check for patterns that may require Constrained Language Mode. /// @@ -52,7 +128,7 @@ testAst is CommandAst cmdAst && )); } - // Check for New-Object with COM objects (not allowed in Constrained Language Mode) + // Check for New-Object with COM objects and TypeName (only specific ones are allowed in CLM) var newObjectCommands = ast.FindAll(testAst => testAst is CommandAst cmdAst && cmdAst.GetCommandName() != null && @@ -61,20 +137,66 @@ testAst is CommandAst cmdAst && foreach (CommandAst cmd in newObjectCommands) { - // Check if -ComObject parameter is used - var comObjectParam = cmd.CommandElements.OfType() - .FirstOrDefault(p => p.ParameterName.Equals("ComObject", StringComparison.OrdinalIgnoreCase)); - - if (comObjectParam != null) + // Use StaticParameterBinder to reliably get parameter values + var bindingResult = StaticParameterBinder.BindCommand(cmd, true); + + // Check for -ComObject parameter + if (bindingResult.BoundParameters.ContainsKey("ComObject")) { - diagnosticRecords.Add( - new DiagnosticRecord( - String.Format(CultureInfo.CurrentCulture, Strings.UseConstrainedLanguageModeComObjectError), - cmd.Extent, - GetName(), - GetDiagnosticSeverity(), - fileName - )); + string comObjectValue = null; + + // Try to get the value from the AST directly first + if (bindingResult.BoundParameters["ComObject"].Value is StringConstantExpressionAst strAst) + { + comObjectValue = strAst.Value; + } + else + { + // Fall back to ConstantValue + comObjectValue = bindingResult.BoundParameters["ComObject"].ConstantValue as string; + } + + // Only flag if COM object name was found AND it's not in the allowed list + if (!string.IsNullOrWhiteSpace(comObjectValue) && !AllowedComObjects.Contains(comObjectValue)) + { + diagnosticRecords.Add( + new DiagnosticRecord( + String.Format(CultureInfo.CurrentCulture, + Strings.UseConstrainedLanguageModeComObjectError, + comObjectValue), + cmd.Extent, + GetName(), + GetDiagnosticSeverity(), + fileName + )); + } + } + + // Check for -TypeName parameter + if (bindingResult.BoundParameters.ContainsKey("TypeName")) + { + var typeNameValue = bindingResult.BoundParameters["TypeName"].ConstantValue as string; + + // If ConstantValue is null, try to extract from the AST Value + if (typeNameValue == null && bindingResult.BoundParameters["TypeName"].Value is StringConstantExpressionAst typeStrAst) + { + typeNameValue = typeStrAst.Value; + } + + // Only flag if type name was found AND it's not in the allowed list + if (!string.IsNullOrWhiteSpace(typeNameValue) && !IsTypeAllowed(typeNameValue)) + { + diagnosticRecords.Add( + new DiagnosticRecord( + String.Format(CultureInfo.CurrentCulture, + Strings.UseConstrainedLanguageModeNewObjectError, + typeNameValue), + cmd.Extent, + GetName(), + GetDiagnosticSeverity(), + fileName + )); + } } } @@ -146,6 +268,26 @@ testAst is CommandAst cmdAst && )); } + // Check for disallowed type accelerators and type constraints + var typeConstraints = ast.FindAll(testAst => testAst is TypeConstraintAst, true); + foreach (TypeConstraintAst typeConstraint in typeConstraints) + { + var typeName = typeConstraint.TypeName.FullName; + if (!IsTypeAllowed(typeName)) + { + diagnosticRecords.Add( + new DiagnosticRecord( + String.Format(CultureInfo.CurrentCulture, + Strings.UseConstrainedLanguageModeConstrainedTypeError, + typeName), + typeConstraint.Extent, + GetName(), + GetDiagnosticSeverity(), + fileName + )); + } + } + return diagnosticRecords; } diff --git a/Tests/Rules/UseConstrainedLanguageMode.tests.ps1 b/Tests/Rules/UseConstrainedLanguageMode.tests.ps1 index 6c48f7b07..cba94eef8 100644 --- a/Tests/Rules/UseConstrainedLanguageMode.tests.ps1 +++ b/Tests/Rules/UseConstrainedLanguageMode.tests.ps1 @@ -5,7 +5,6 @@ BeforeAll { $testRootDirectory = Split-Path -Parent $PSScriptRoot Import-Module (Join-Path $testRootDirectory "PSScriptAnalyzerTestHelper.psm1") - $violationMessage = "is not permitted in Constrained Language Mode" $violationName = "PSUseConstrainedLanguageMode" $ruleName = $violationName @@ -33,7 +32,7 @@ Add-Type -TypeDefinition @" $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings $violations.Count | Should -Be 1 $violations[0].RuleName | Should -Be $violationName - $violations[0].Message | Should -BeLike "*Add-Type*$violationMessage*" + $violations[0].Message | Should -BeLike "*Add-Type*" } It "Should not flag other commands" { @@ -44,19 +43,45 @@ Add-Type -TypeDefinition @" } Context "When New-Object with COM is used" { - It "Should detect New-Object -ComObject usage" { - $def = 'New-Object -ComObject "Scripting.FileSystemObject"' + It "Should detect disallowed New-Object -ComObject usage" { + $def = 'New-Object -ComObject "Excel.Application"' $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } $matchingViolations.Count | Should -Be 1 - $matchingViolations[0].Message | Should -BeLike "*COM object*$violationMessage*" + $matchingViolations[0].Message | Should -BeLike "*COM object*" + } + + It "Should NOT flag allowed COM objects - Scripting.Dictionary" { + $def = 'New-Object -ComObject "Scripting.Dictionary"' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $violations | Where-Object { $_.RuleName -eq $violationName } | Should -BeNullOrEmpty + } + + It "Should NOT flag allowed COM objects - Scripting.FileSystemObject" { + $def = 'New-Object -ComObject "Scripting.FileSystemObject"' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $violations | Where-Object { $_.RuleName -eq $violationName } | Should -BeNullOrEmpty + } + + It "Should NOT flag allowed COM objects - VBScript.RegExp" { + $def = 'New-Object -ComObject "VBScript.RegExp"' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $violations | Where-Object { $_.RuleName -eq $violationName } | Should -BeNullOrEmpty } - It "Should not flag New-Object without -ComObject" { + It "Should NOT flag New-Object with allowed TypeName" { $def = 'New-Object -TypeName System.Collections.ArrayList' $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings $violations | Where-Object { $_.RuleName -eq $violationName } | Should -BeNullOrEmpty } + + It "Should flag New-Object with disallowed TypeName" { + $def = 'New-Object -TypeName System.Net.WebClient' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations.Count | Should -Be 1 + $matchingViolations[0].Message | Should -BeLike "*System.Net.WebClient*not permitted*" + } } Context "When XAML is used" { @@ -71,7 +96,7 @@ $xaml = @" $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } $matchingViolations.Count | Should -Be 1 - $matchingViolations[0].Message | Should -BeLike "*XAML*$violationMessage*" + $matchingViolations[0].Message | Should -BeLike "*XAML*" } } @@ -81,7 +106,7 @@ $xaml = @" $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } $matchingViolations.Count | Should -Be 1 - $matchingViolations[0].Message | Should -BeLike "*Invoke-Expression*$violationMessage*" + $matchingViolations[0].Message | Should -BeLike "*Invoke-Expression*" } } @@ -93,4 +118,20 @@ $xaml = @" $matchingViolations[0].Severity | Should -Be 'Information' } } + + Context "When type constraints are used" { + It "Should flag disallowed type constraint" { + $def = 'function Test { param([System.Net.WebClient]$Client) }' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations.Count | Should -BeGreaterThan 0 + $matchingViolations[0].Message | Should -BeLike "*System.Net.WebClient*not permitted*" + } + + It "Should NOT flag allowed type constraint" { + $def = 'function Test { param([string]$Name, [int]$Count, [hashtable]$Data) }' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $violations | Where-Object { $_.RuleName -eq $violationName } | Should -BeNullOrEmpty + } + } } From 790fe8c6cdb928bf1a87c9f69ae656178a43224e Mon Sep 17 00:00:00 2001 From: Josh Corrick Date: Sun, 8 Mar 2026 13:44:42 -0700 Subject: [PATCH 03/14] Enhance detection of disallowed types in CLM rule --- Rules/Strings.resx | 9 + Rules/UseConstrainedLanguageMode.cs | 249 +++++++++++++++++- .../UseConstrainedLanguageMode.tests.ps1 | 138 +++++++++- 3 files changed, 394 insertions(+), 2 deletions(-) diff --git a/Rules/Strings.resx b/Rules/Strings.resx index 33b3ca2ce..a55a629cf 100644 --- a/Rules/Strings.resx +++ b/Rules/Strings.resx @@ -1266,4 +1266,13 @@ Type constraint [{0}] is not permitted in Constrained Language Mode. Consider using an allowed type. + + Type expression [{0}] is not permitted in Constrained Language Mode. Consider using an allowed type. + + + Type cast [{0}] is not permitted in Constrained Language Mode. Consider using an allowed type. + + + Member '{1}' accessed on type [{0}] which is not permitted in Constrained Language Mode. Consider using an allowed type. + \ No newline at end of file diff --git a/Rules/UseConstrainedLanguageMode.cs b/Rules/UseConstrainedLanguageMode.cs index aed3b60ed..e317968c5 100644 --- a/Rules/UseConstrainedLanguageMode.cs +++ b/Rules/UseConstrainedLanguageMode.cs @@ -268,7 +268,7 @@ testAst is CommandAst cmdAst && )); } - // Check for disallowed type accelerators and type constraints + // Check for disallowed type constraints (e.g., [System.Net.WebClient]$client) var typeConstraints = ast.FindAll(testAst => testAst is TypeConstraintAst, true); foreach (TypeConstraintAst typeConstraint in typeConstraints) { @@ -288,9 +288,256 @@ testAst is CommandAst cmdAst && } } + // Check for disallowed type expressions and casts (e.g., [System.Net.WebClient]::new() or $x -as [Type]) + var typeExpressions = ast.FindAll(testAst => testAst is TypeExpressionAst, true); + foreach (TypeExpressionAst typeExpr in typeExpressions) + { + var typeName = typeExpr.TypeName.FullName; + if (!IsTypeAllowed(typeName)) + { + diagnosticRecords.Add( + new DiagnosticRecord( + String.Format(CultureInfo.CurrentCulture, + Strings.UseConstrainedLanguageModeTypeExpressionError, + typeName), + typeExpr.Extent, + GetName(), + GetDiagnosticSeverity(), + fileName + )); + } + } + + // Check for convert expressions (e.g., $x = [System.Net.WebClient]$value) + var convertExpressions = ast.FindAll(testAst => testAst is ConvertExpressionAst, true); + foreach (ConvertExpressionAst convertExpr in convertExpressions) + { + var typeName = convertExpr.Type.TypeName.FullName; + if (!IsTypeAllowed(typeName)) + { + diagnosticRecords.Add( + new DiagnosticRecord( + String.Format(CultureInfo.CurrentCulture, + Strings.UseConstrainedLanguageModeConvertExpressionError, + typeName), + convertExpr.Extent, + GetName(), + GetDiagnosticSeverity(), + fileName + )); + } + } + + // Check for member invocations on disallowed types + // This includes method calls and property access on variables with type constraints + var memberInvocations = ast.FindAll(testAst => + testAst is InvokeMemberExpressionAst || testAst is MemberExpressionAst, true); + + foreach (Ast memberAst in memberInvocations) + { + // Skip static member access - already handled by TypeExpressionAst check + if (memberAst is InvokeMemberExpressionAst invokeAst && invokeAst.Static) + { + continue; + } + + if (memberAst is MemberExpressionAst memAst && memAst.Static) + { + continue; + } + + // Get the expression being invoked on (e.g., the variable in $var.Method()) + ExpressionAst targetExpr = memberAst is InvokeMemberExpressionAst invExpr + ? invExpr.Expression + : ((MemberExpressionAst)memberAst).Expression; + + // Check if the target has a type constraint + string constrainedType = GetTypeConstraintFromExpression(targetExpr); + if (!string.IsNullOrWhiteSpace(constrainedType) && !IsTypeAllowed(constrainedType)) + { + string memberName = memberAst is InvokeMemberExpressionAst inv + ? (inv.Member as StringConstantExpressionAst)?.Value ?? "" + : ((memberAst as MemberExpressionAst).Member as StringConstantExpressionAst)?.Value ?? ""; + + diagnosticRecords.Add( + new DiagnosticRecord( + String.Format(CultureInfo.CurrentCulture, + Strings.UseConstrainedLanguageModeMemberAccessError, + constrainedType, + memberName), + memberAst.Extent, + GetName(), + GetDiagnosticSeverity(), + fileName + )); + } + } + return diagnosticRecords; } + /// + /// Attempts to determine if an expression has a type constraint. + /// Returns the type name if found, otherwise null. + /// + private string GetTypeConstraintFromExpression(ExpressionAst expr) + { + if (expr == null) + { + return null; + } + + // Check if this is a convert expression with a type (e.g., [Type]$var) + if (expr is ConvertExpressionAst convertExpr) + { + return convertExpr.Type.TypeName.FullName; + } + + // Check if this is a variable expression + if (expr is VariableExpressionAst varExpr) + { + // Walk up the AST to find if this variable has a type constraint in a parameter + var parameterAst = FindParameterForVariable(varExpr); + if (parameterAst != null) + { + // Get the first type constraint attribute + var typeConstraint = parameterAst.Attributes + .OfType() + .FirstOrDefault(); + + if (typeConstraint != null) + { + return typeConstraint.TypeName.FullName; + } + } + + // Check if the variable was declared with a type constraint elsewhere + // Look for assignment statements with type constraints + var assignmentWithType = FindTypedAssignment(varExpr); + if (assignmentWithType != null) + { + return assignmentWithType; + } + } + + // Check if this is a member expression that might have a known return type + // For now, we'll be conservative and only check direct type constraints + + return null; + } + + /// + /// Finds the parameter AST for a given variable expression, if it exists. + /// + private ParameterAst FindParameterForVariable(VariableExpressionAst varExpr) + { + if (varExpr == null) + { + return null; + } + + var varName = varExpr.VariablePath.UserPath; + + // Walk up to find the containing function or script block + Ast current = varExpr.Parent; + while (current != null) + { + if (current is FunctionDefinitionAst funcAst) + { + // Check parameters in the param block + var paramBlock = funcAst.Body?.ParamBlock; + if (paramBlock?.Parameters != null) + { + foreach (var param in paramBlock.Parameters) + { + if (string.Equals(param.Name.VariablePath.UserPath, varName, StringComparison.OrdinalIgnoreCase)) + { + return param; + } + } + } + + // Check function parameters (for functions with parameters outside param block) + if (funcAst.Parameters != null) + { + foreach (var param in funcAst.Parameters) + { + if (string.Equals(param.Name.VariablePath.UserPath, varName, StringComparison.OrdinalIgnoreCase)) + { + return param; + } + } + } + + break; // Don't check outer function scopes + } + + if (current is ScriptBlockAst scriptAst) + { + var paramBlock = scriptAst.ParamBlock; + if (paramBlock?.Parameters != null) + { + foreach (var param in paramBlock.Parameters) + { + if (string.Equals(param.Name.VariablePath.UserPath, varName, StringComparison.OrdinalIgnoreCase)) + { + return param; + } + } + } + break; // Don't check outer script block scopes + } + + current = current.Parent; + } + + return null; + } + + /// + /// Looks for a typed assignment to a variable (e.g., [Type]$var = ...) + /// + private string FindTypedAssignment(VariableExpressionAst varExpr) + { + if (varExpr == null) + { + return null; + } + + var varName = varExpr.VariablePath.UserPath; + + // Walk up to find the containing function or script block + Ast searchScope = varExpr.Parent; + while (searchScope != null && + !(searchScope is FunctionDefinitionAst) && + !(searchScope is ScriptBlockAst)) + { + searchScope = searchScope.Parent; + } + + if (searchScope == null) + { + return null; + } + + // Find all assignment statements in this scope + var assignments = searchScope.FindAll(testAst => + testAst is AssignmentStatementAst, true); + + foreach (AssignmentStatementAst assignment in assignments) + { + // Check if the left side is a convert expression with our variable + if (assignment.Left is ConvertExpressionAst convertExpr && + convertExpr.Child is VariableExpressionAst assignedVar && + string.Equals(assignedVar.VariablePath.UserPath, varName, StringComparison.OrdinalIgnoreCase)) + { + return convertExpr.Type.TypeName.FullName; + } + } + + return null; + } + /// /// Retrieves the common name of this rule. /// diff --git a/Tests/Rules/UseConstrainedLanguageMode.tests.ps1 b/Tests/Rules/UseConstrainedLanguageMode.tests.ps1 index cba94eef8..4a37863c6 100644 --- a/Tests/Rules/UseConstrainedLanguageMode.tests.ps1 +++ b/Tests/Rules/UseConstrainedLanguageMode.tests.ps1 @@ -120,7 +120,7 @@ $xaml = @" } Context "When type constraints are used" { - It "Should flag disallowed type constraint" { + It "Should flag disallowed type constraint on parameter" { $def = 'function Test { param([System.Net.WebClient]$Client) }' $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } @@ -128,10 +128,146 @@ $xaml = @" $matchingViolations[0].Message | Should -BeLike "*System.Net.WebClient*not permitted*" } + It "Should flag disallowed type constraint on variable declaration" { + $def = '[System.Net.WebClient]$client = $null' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations.Count | Should -BeGreaterThan 0 + $matchingViolations[0].Message | Should -BeLike "*System.Net.WebClient*not permitted*" + } + + It "Should flag disallowed type cast on variable assignment" { + $def = '$client = [System.Net.WebClient]$value' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations.Count | Should -BeGreaterThan 0 + $matchingViolations[0].Message | Should -BeLike "*System.Net.WebClient*not permitted*" + } + It "Should NOT flag allowed type constraint" { $def = 'function Test { param([string]$Name, [int]$Count, [hashtable]$Data) }' $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings $violations | Where-Object { $_.RuleName -eq $violationName } | Should -BeNullOrEmpty } + + It "Should NOT flag allowed type cast on variable" { + $def = '[string]$name = $null' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $violations | Where-Object { $_.RuleName -eq $violationName } | Should -BeNullOrEmpty + } + + It "Should flag multiple type issues in same script" { + $def = @' +function Test { + param([System.Net.WebClient]$Client) + [System.Net.Sockets.TcpClient]$tcp = $null + $web = [System.Net.WebClient]::new() +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + # Should flag: 1) param type constraint, 2) variable type constraint, 3) type expression + # Note: May also flag member access if methods/properties are called on typed variables + $matchingViolations.Count | Should -BeGreaterOrEqual 3 + } + } + + Context "When instance methods are invoked on disallowed types" { + It "Should flag method invocation on parameter with disallowed type constraint" { + $def = @' +function Download-File { + param([System.Net.WebClient]$Client, [string]$Url) + $Client.DownloadString($Url) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + # Should flag both the type constraint AND the member access + $matchingViolations.Count | Should -BeGreaterThan 1 + # At least one violation should mention DownloadString + ($matchingViolations.Message | Where-Object { $_ -like "*DownloadString*" }).Count | Should -BeGreaterThan 0 + } + + It "Should flag property access on variable with disallowed type constraint" { + $def = @' +function Test { + param([System.Net.WebClient]$Client) + $baseAddr = $Client.BaseAddress +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + # Should flag both the type constraint AND the member access + $matchingViolations.Count | Should -BeGreaterThan 1 + # At least one violation should mention BaseAddress + ($matchingViolations.Message | Where-Object { $_ -like "*BaseAddress*" }).Count | Should -BeGreaterThan 0 + } + + It "Should flag method invocation on typed variable assignment" { + $def = @' +[System.Net.WebClient]$client = $null +$result = $client.DownloadString("http://example.com") +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + # Should flag both the type constraint AND the member access + $matchingViolations.Count | Should -BeGreaterThan 1 + # At least one violation should mention DownloadString + ($matchingViolations.Message | Where-Object { $_ -like "*DownloadString*" }).Count | Should -BeGreaterThan 0 + } + + It "Should NOT flag method invocation on allowed types" { + $def = @' +function Test { + param([string]$Text) + $upper = $Text.ToUpper() + $length = $Text.Length +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $violations | Where-Object { $_.RuleName -eq $violationName } | Should -BeNullOrEmpty + } + + It "Should NOT flag static method calls on disallowed types (already caught by type expression check)" { + $def = '[System.Net.WebClient]::New()' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + # Should only flag once for the type expression, not for member access + $matchingViolations.Count | Should -Be 1 + $matchingViolations[0].Message | Should -BeLike "*System.Net.WebClient*" + } + + It "Should flag chained method calls on disallowed types" { + $def = @' +function Test { + param([System.Net.WebClient]$Client) + $result = $Client.DownloadString("http://example.com").ToUpper() +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + # Should flag the type constraint and at least the first member access + $matchingViolations.Count | Should -BeGreaterThan 1 + } + + It "Should handle complex scenarios with multiple typed variables" { + $def = @' +function Complex-Test { + param( + [System.Net.WebClient]$WebClient, + [System.Net.Sockets.TcpClient]$TcpClient, + [string]$SafeString + ) + + $data = $WebClient.DownloadData("http://test.com") + $TcpClient.Connect("localhost", 8080) + $upper = $SafeString.ToUpper() +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + # Should flag: 2 type constraints + 2 method invocations (not SafeString) + $matchingViolations.Count | Should -BeGreaterThan 2 + } } } From b4b566c9ca2b7eaf8c9e995b2dedc4b288ced784 Mon Sep 17 00:00:00 2001 From: Josh Corrick Date: Sat, 14 Mar 2026 22:06:29 -0700 Subject: [PATCH 04/14] Detect and flag class definitions in Constrained Language --- Rules/Strings.resx | 3 ++ Rules/UseConstrainedLanguageMode.cs | 19 +++++++ .../UseConstrainedLanguageMode.tests.ps1 | 50 +++++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/Rules/Strings.resx b/Rules/Strings.resx index a55a629cf..30f97e1d2 100644 --- a/Rules/Strings.resx +++ b/Rules/Strings.resx @@ -1275,4 +1275,7 @@ Member '{1}' accessed on type [{0}] which is not permitted in Constrained Language Mode. Consider using an allowed type. + + PowerShell class '{0}' is not permitted in Constrained Language Mode. Consider using alternative approaches such as hashtables or PSCustomObject. + \ No newline at end of file diff --git a/Rules/UseConstrainedLanguageMode.cs b/Rules/UseConstrainedLanguageMode.cs index e317968c5..3e880a7bc 100644 --- a/Rules/UseConstrainedLanguageMode.cs +++ b/Rules/UseConstrainedLanguageMode.cs @@ -268,6 +268,25 @@ testAst is CommandAst cmdAst && )); } + // Check for class definitions (not allowed in Constrained Language Mode) + var classDefinitions = ast.FindAll(testAst => + testAst is TypeDefinitionAst typeAst && typeAst.IsClass, + true); + + foreach (TypeDefinitionAst classDef in classDefinitions) + { + diagnosticRecords.Add( + new DiagnosticRecord( + String.Format(CultureInfo.CurrentCulture, + Strings.UseConstrainedLanguageModeClassError, + classDef.Name), + classDef.Extent, + GetName(), + GetDiagnosticSeverity(), + fileName + )); + } + // Check for disallowed type constraints (e.g., [System.Net.WebClient]$client) var typeConstraints = ast.FindAll(testAst => testAst is TypeConstraintAst, true); foreach (TypeConstraintAst typeConstraint in typeConstraints) diff --git a/Tests/Rules/UseConstrainedLanguageMode.tests.ps1 b/Tests/Rules/UseConstrainedLanguageMode.tests.ps1 index 4a37863c6..91e80a8b0 100644 --- a/Tests/Rules/UseConstrainedLanguageMode.tests.ps1 +++ b/Tests/Rules/UseConstrainedLanguageMode.tests.ps1 @@ -110,6 +110,56 @@ $xaml = @" } } + Context "When PowerShell classes are used" { + It "Should detect class definition" { + $def = @' +class MyClass { + [string]$Name + [int]$Value + + MyClass([string]$name) { + $this.Name = $name + } +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations.Count | Should -Be 1 + $matchingViolations[0].Message | Should -BeLike "*class*MyClass*" + } + + It "Should detect multiple class definitions" { + $def = @' +class FirstClass { + [string]$Name +} + +class SecondClass { + [int]$Value +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations.Count | Should -Be 2 + } + + It "Should not flag enum definitions" { + $def = @' +enum MyEnum { + Value1 + Value2 +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + # Enums are allowed, so no class-specific violations + # (though we may still flag other issues if present) + $classViolations = $violations | Where-Object { + $_.RuleName -eq $violationName -and $_.Message -like "*class*" + } + $classViolations | Should -BeNullOrEmpty + } + } + Context "Informational severity" { It "Should have Information severity" { $def = 'Add-Type -AssemblyName System.Windows.Forms' From f8ab608bfd6cd56f86afff6a59d762827e334272 Mon Sep 17 00:00:00 2001 From: Josh Corrick Date: Sun, 15 Mar 2026 13:42:03 -0700 Subject: [PATCH 05/14] Enhance CLM rule to check module manifests for wildcards/.ps1 --- Rules/Strings.resx | 6 + Rules/UseConstrainedLanguageMode.cs | 230 ++++++++++++++++++ .../UseConstrainedLanguageMode.tests.ps1 | 142 +++++++++++ 3 files changed, 378 insertions(+) diff --git a/Rules/Strings.resx b/Rules/Strings.resx index 30f97e1d2..7d081acc2 100644 --- a/Rules/Strings.resx +++ b/Rules/Strings.resx @@ -1278,4 +1278,10 @@ PowerShell class '{0}' is not permitted in Constrained Language Mode. Consider using alternative approaches such as hashtables or PSCustomObject. + + Module manifest field '{0}' uses wildcard ('*') which is not recommended for Constrained Language Mode. Explicitly list exported items instead. + + + Module manifest field '{0}' contains script file '{1}' (.ps1). Use binary modules (.psm1 or .dll) instead for Constrained Language Mode compatibility. + \ No newline at end of file diff --git a/Rules/UseConstrainedLanguageMode.cs b/Rules/UseConstrainedLanguageMode.cs index 3e880a7bc..47774eff4 100644 --- a/Rules/UseConstrainedLanguageMode.cs +++ b/Rules/UseConstrainedLanguageMode.cs @@ -109,6 +109,15 @@ public override IEnumerable AnalyzeScript(Ast ast, string file var diagnosticRecords = new List(); + // Check if this is a module manifest (.psd1 file) + bool isModuleManifest = fileName != null && fileName.EndsWith(".psd1", StringComparison.OrdinalIgnoreCase); + + if (isModuleManifest) + { + // Perform PSD1-specific checks + CheckModuleManifest(ast, fileName, diagnosticRecords); + } + // Check for Add-Type usage (not allowed in Constrained Language Mode) var addTypeCommands = ast.FindAll(testAst => testAst is CommandAst cmdAst && @@ -557,6 +566,227 @@ convertExpr.Child is VariableExpressionAst assignedVar && return null; } + /// + /// Checks module manifest (.psd1) files for CLM compatibility issues. + /// + private void CheckModuleManifest(Ast ast, string fileName, List diagnosticRecords) + { + // Find the hashtable in the manifest + var hashtableAst = ast.Find(x => x is HashtableAst, false) as HashtableAst; + + if (hashtableAst == null) + { + return; + } + + // Check for wildcard exports in FunctionsToExport, CmdletsToExport, AliasesToExport + CheckWildcardExports(hashtableAst, fileName, diagnosticRecords); + + // Check for .ps1 files in RootModule and NestedModules + CheckScriptModules(hashtableAst, fileName, diagnosticRecords); + } + + /// + /// Checks for wildcard ('*') in export fields which are not allowed in CLM. + /// + private void CheckWildcardExports(HashtableAst hashtableAst, string fileName, List diagnosticRecords) + { + string[] exportFields = { "FunctionsToExport", "CmdletsToExport", "AliasesToExport", "VariablesToExport" }; + + foreach (var kvp in hashtableAst.KeyValuePairs) + { + if (kvp.Item1 is StringConstantExpressionAst keyAst) + { + string keyName = keyAst.Value; + + if (exportFields.Contains(keyName, StringComparer.OrdinalIgnoreCase)) + { + // Check if the value contains a wildcard + bool hasWildcard = false; + IScriptExtent wildcardExtent = null; + + // The value in a hashtable is a StatementAst, need to extract the expression + var valueExpr = GetExpressionFromStatement(kvp.Item2); + + if (valueExpr is StringConstantExpressionAst stringValue) + { + if (stringValue.Value == "*") + { + hasWildcard = true; + wildcardExtent = stringValue.Extent; + } + } + else if (valueExpr is ArrayLiteralAst arrayValue) + { + foreach (var element in arrayValue.Elements) + { + if (element is StringConstantExpressionAst strElement && strElement.Value == "*") + { + hasWildcard = true; + wildcardExtent = strElement.Extent; + break; + } + } + } + else if (valueExpr is ArrayExpressionAst arrayExpr) + { + // Array expressions like @('a', 'b') have a SubExpression inside + if (arrayExpr.SubExpression?.Statements != null) + { + foreach (var stmt in arrayExpr.SubExpression.Statements) + { + var expr = GetExpressionFromStatement(stmt); + if (expr is ArrayLiteralAst arrayLiteral) + { + foreach (var element in arrayLiteral.Elements) + { + if (element is StringConstantExpressionAst strElement && strElement.Value == "*") + { + hasWildcard = true; + wildcardExtent = strElement.Extent; + break; + } + } + } + if (hasWildcard) break; + } + } + } + + if (hasWildcard && wildcardExtent != null) + { + diagnosticRecords.Add( + new DiagnosticRecord( + String.Format(CultureInfo.CurrentCulture, + Strings.UseConstrainedLanguageModeWildcardExportError, + keyName), + wildcardExtent, + GetName(), + GetDiagnosticSeverity(), + fileName + )); + } + } + } + } + } + + /// + /// Checks for .ps1 files in RootModule and NestedModules which are not recommended for CLM. + /// + private void CheckScriptModules(HashtableAst hashtableAst, string fileName, List diagnosticRecords) + { + string[] moduleFields = { "RootModule", "ModuleToProcess", "NestedModules" }; + + foreach (var kvp in hashtableAst.KeyValuePairs) + { + if (kvp.Item1 is StringConstantExpressionAst keyAst) + { + string keyName = keyAst.Value; + + if (moduleFields.Contains(keyName, StringComparer.OrdinalIgnoreCase)) + { + var valueExpr = GetExpressionFromStatement(kvp.Item2); + CheckForPs1Files(valueExpr, keyName, fileName, diagnosticRecords); + } + } + } + } + + /// + /// Extracts an ExpressionAst from a StatementAst (typically from hashtable values). + /// + private ExpressionAst GetExpressionFromStatement(StatementAst statement) + { + if (statement is PipelineAst pipeline && pipeline.PipelineElements.Count == 1) + { + if (pipeline.PipelineElements[0] is CommandExpressionAst commandExpr) + { + return commandExpr.Expression; + } + } + return null; + } + + /// + /// Helper method to check if an expression contains .ps1 file references. + /// + private void CheckForPs1Files(ExpressionAst valueAst, string fieldName, string fileName, List diagnosticRecords) + { + if (valueAst is StringConstantExpressionAst stringValue) + { + if (stringValue.Value != null && stringValue.Value.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase)) + { + diagnosticRecords.Add( + new DiagnosticRecord( + String.Format(CultureInfo.CurrentCulture, + Strings.UseConstrainedLanguageModeScriptModuleError, + fieldName, + stringValue.Value), + stringValue.Extent, + GetName(), + GetDiagnosticSeverity(), + fileName + )); + } + } + else if (valueAst is ArrayLiteralAst arrayValue) + { + foreach (var element in arrayValue.Elements) + { + if (element is StringConstantExpressionAst strElement && + strElement.Value != null && + strElement.Value.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase)) + { + diagnosticRecords.Add( + new DiagnosticRecord( + String.Format(CultureInfo.CurrentCulture, + Strings.UseConstrainedLanguageModeScriptModuleError, + fieldName, + strElement.Value), + strElement.Extent, + GetName(), + GetDiagnosticSeverity(), + fileName + )); + } + } + } + else if (valueAst is ArrayExpressionAst arrayExpr) + { + // Array expressions like @('a', 'b') have a SubExpression inside + if (arrayExpr.SubExpression?.Statements != null) + { + foreach (var stmt in arrayExpr.SubExpression.Statements) + { + var expr = GetExpressionFromStatement(stmt); + if (expr is ArrayLiteralAst arrayLiteral) + { + foreach (var element in arrayLiteral.Elements) + { + if (element is StringConstantExpressionAst strElement && + strElement.Value != null && + strElement.Value.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase)) + { + diagnosticRecords.Add( + new DiagnosticRecord( + String.Format(CultureInfo.CurrentCulture, + Strings.UseConstrainedLanguageModeScriptModuleError, + fieldName, + strElement.Value), + strElement.Extent, + GetName(), + GetDiagnosticSeverity(), + fileName + )); + } + } + } + } + } + } + } + /// /// Retrieves the common name of this rule. /// diff --git a/Tests/Rules/UseConstrainedLanguageMode.tests.ps1 b/Tests/Rules/UseConstrainedLanguageMode.tests.ps1 index 91e80a8b0..98a543463 100644 --- a/Tests/Rules/UseConstrainedLanguageMode.tests.ps1 +++ b/Tests/Rules/UseConstrainedLanguageMode.tests.ps1 @@ -160,6 +160,148 @@ enum MyEnum { } } + Context "When module manifests (.psd1) are analyzed" { + BeforeAll { + $tempPath = Join-Path $TestDrive "TestManifests" + New-Item -Path $tempPath -ItemType Directory -Force | Out-Null + } + + It "Should flag wildcard in FunctionsToExport" { + $manifestPath = Join-Path $tempPath "WildcardFunctions.psd1" + $manifestContent = @' +@{ + ModuleVersion = '1.0.0' + GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + FunctionsToExport = '*' +} +'@ + Set-Content -Path $manifestPath -Value $manifestContent + $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations.Count | Should -BeGreaterThan 0 + $matchingViolations[0].Message | Should -BeLike "*FunctionsToExport*wildcard*" + } + + It "Should flag wildcard in CmdletsToExport" { + $manifestPath = Join-Path $tempPath "WildcardCmdlets.psd1" + $manifestContent = @' +@{ + ModuleVersion = '1.0.0' + GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + CmdletsToExport = '*' +} +'@ + Set-Content -Path $manifestPath -Value $manifestContent + $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations.Count | Should -BeGreaterThan 0 + $matchingViolations[0].Message | Should -BeLike "*CmdletsToExport*wildcard*" + } + + It "Should flag wildcard in array of exports" { + $manifestPath = Join-Path $tempPath "WildcardArray.psd1" + $manifestContent = @' +@{ + ModuleVersion = '1.0.0' + GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + AliasesToExport = @('Get-Foo', '*', 'Set-Bar') +} +'@ + Set-Content -Path $manifestPath -Value $manifestContent + $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations.Count | Should -BeGreaterThan 0 + $matchingViolations[0].Message | Should -BeLike "*AliasesToExport*wildcard*" + } + + It "Should NOT flag explicit list of exports" { + $manifestPath = Join-Path $tempPath "ExplicitExports.psd1" + $manifestContent = @' +@{ + ModuleVersion = '1.0.0' + GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + FunctionsToExport = @('Get-MyFunction', 'Set-MyFunction') + CmdletsToExport = @('Get-MyCmdlet') + AliasesToExport = @() +} +'@ + Set-Content -Path $manifestPath -Value $manifestContent + $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings + $wildcardViolations = $violations | Where-Object { + $_.RuleName -eq $violationName -and $_.Message -like "*wildcard*" + } + $wildcardViolations | Should -BeNullOrEmpty + } + + It "Should flag .ps1 file in RootModule" { + $manifestPath = Join-Path $tempPath "ScriptRootModule.psd1" + $manifestContent = @' +@{ + ModuleVersion = '1.0.0' + GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + RootModule = 'MyModule.ps1' +} +'@ + Set-Content -Path $manifestPath -Value $manifestContent + $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations.Count | Should -BeGreaterThan 0 + $matchingViolations[0].Message | Should -BeLike "*RootModule*MyModule.ps1*" + } + + It "Should flag .ps1 file in NestedModules" { + $manifestPath = Join-Path $tempPath "ScriptNestedModule.psd1" + $manifestContent = @' +@{ + ModuleVersion = '1.0.0' + GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + NestedModules = @('Helper.ps1', 'Utility.psm1') +} +'@ + Set-Content -Path $manifestPath -Value $manifestContent + $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations.Count | Should -BeGreaterThan 0 + $matchingViolations[0].Message | Should -BeLike "*NestedModules*Helper.ps1*" + } + + It "Should NOT flag .psm1 or .dll modules" { + $manifestPath = Join-Path $tempPath "BinaryModules.psd1" + $manifestContent = @' +@{ + ModuleVersion = '1.0.0' + GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + RootModule = 'MyModule.psm1' + NestedModules = @('Helper.dll', 'Utility.psm1') +} +'@ + Set-Content -Path $manifestPath -Value $manifestContent + $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings + $scriptModuleViolations = $violations | Where-Object { + $_.RuleName -eq $violationName -and $_.Message -like "*.ps1*" + } + $scriptModuleViolations | Should -BeNullOrEmpty + } + + It "Should flag both wildcard and .ps1 issues in same manifest" { + $manifestPath = Join-Path $tempPath "MultipleIssues.psd1" + $manifestContent = @' +@{ + ModuleVersion = '1.0.0' + GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + RootModule = 'MyModule.ps1' + FunctionsToExport = '*' + CmdletsToExport = '*' +} +'@ + Set-Content -Path $manifestPath -Value $manifestContent + $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + # Should have at least 3 violations: RootModule .ps1, FunctionsToExport *, CmdletsToExport * + $matchingViolations.Count | Should -BeGreaterOrEqual 3 + } + } + Context "Informational severity" { It "Should have Information severity" { $def = 'Add-Type -AssemblyName System.Windows.Forms' From 1be14c8a92fc99a11a00c5c257e1a7544a23178d Mon Sep 17 00:00:00 2001 From: Josh Corrick Date: Sun, 15 Mar 2026 16:09:17 -0700 Subject: [PATCH 06/14] Differentiate CLM checks for signed vs unsigned scripts --- Rules/UseConstrainedLanguageMode.cs | 186 +++++++++++++++++++++++----- 1 file changed, 155 insertions(+), 31 deletions(-) diff --git a/Rules/UseConstrainedLanguageMode.cs b/Rules/UseConstrainedLanguageMode.cs index 47774eff4..8a1f2c298 100644 --- a/Rules/UseConstrainedLanguageMode.cs +++ b/Rules/UseConstrainedLanguageMode.cs @@ -109,16 +109,94 @@ public override IEnumerable AnalyzeScript(Ast ast, string file var diagnosticRecords = new List(); + // Basic check if the file is signed + bool isFileSigned = IsScriptSigned(fileName); + // Check if this is a module manifest (.psd1 file) bool isModuleManifest = fileName != null && fileName.EndsWith(".psd1", StringComparison.OrdinalIgnoreCase); if (isModuleManifest) { // Perform PSD1-specific checks + // These checks are ALWAYS enforced, even for signed scripts CheckModuleManifest(ast, fileName, diagnosticRecords); } - // Check for Add-Type usage (not allowed in Constrained Language Mode) + // For signed scripts, only check specific patterns that are still restricted + if (isFileSigned) + { + // Even signed scripts have these restrictions in CLM: + + // 1. Check for dot-sourcing (still restricted in CLM even for signed scripts) + CheckDotSourcing(ast, fileName, diagnosticRecords); + + // 2. Check for type constraints on parameters (still need to be validated) + CheckParameterTypeConstraints(ast, fileName, diagnosticRecords); + + return diagnosticRecords; + } + + // For unsigned scripts, perform all CLM checks + CheckAllClmRestrictions(ast, fileName, diagnosticRecords); + + return diagnosticRecords; + } + + /// + /// Checks if a PowerShell script file appears to be digitally signed. + /// Note: This performs a simple text check for the signature block marker. + /// It does NOT validate signature authenticity, certificate trust, or file integrity. + /// For production use, PowerShell's execution policy and Get-AuthenticodeSignature + /// should be used to properly validate signatures. + /// + private bool IsScriptSigned(string fileName) + { + if (string.IsNullOrEmpty(fileName) || !System.IO.File.Exists(fileName)) + { + return false; + } + + // Only check .ps1, .psm1, and .psd1 files + string extension = System.IO.Path.GetExtension(fileName); + if (!extension.Equals(".ps1", StringComparison.OrdinalIgnoreCase) && + !extension.Equals(".psm1", StringComparison.OrdinalIgnoreCase) && + !extension.Equals(".psd1", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + try + { + // Read the file content + string content = System.IO.File.ReadAllText(fileName); + + // Check for signature block marker + // A signed PowerShell script contains a signature block that starts with: + // # SIG # Begin signature block + // + // IMPORTANT: This is a simple text check only. It does NOT validate: + // - Signature authenticity + // - Certificate validity or trust + // - File integrity (hash matching) + // - Certificate expiration + // + // This check assumes that if a signature block is present, the script + // was intended to be signed. Actual signature validation is performed + // by PowerShell at execution time based on execution policy. + return content.IndexOf("# SIG # Begin signature block", StringComparison.OrdinalIgnoreCase) >= 0; + } + catch + { + // If we can't read the file, assume it's not signed + return false; + } + } + + /// + /// Performs all CLM restriction checks (for unsigned scripts). + /// + private void CheckAllClmRestrictions(Ast ast, string fileName, List diagnosticRecords) + { var addTypeCommands = ast.FindAll(testAst => testAst is CommandAst cmdAst && cmdAst.GetCommandName() != null && @@ -230,33 +308,8 @@ testAst is StringConstantExpressionAst strAst && } } - // Check for dot-sourcing - PowerShell doesn't have a specific DotSourceExpressionAst - // We look for patterns where a script block or file is dot-sourced - // This is best detected through token analysis, but for simplicity we'll check for common patterns - var scriptBlocks = ast.FindAll(testAst => testAst is ScriptBlockExpressionAst, true); - - foreach (ScriptBlockExpressionAst sbAst in scriptBlocks) - { - // Check if preceded by a dot token (basic heuristic for dot-sourcing) - // More sophisticated detection would require token analysis - var parent = sbAst.Parent; - if (parent is CommandAst cmdAst) - { - // Check if this looks like a dot-source pattern - var cmdName = cmdAst.GetCommandName(); - if (cmdName != null && cmdName.StartsWith(".")) - { - diagnosticRecords.Add( - new DiagnosticRecord( - String.Format(CultureInfo.CurrentCulture, Strings.UseConstrainedLanguageModeDotSourceError), - sbAst.Extent, - GetName(), - GetDiagnosticSeverity(), - fileName - )); - } - } - } + // Check for dot-sourcing (also called separately for signed scripts) + CheckDotSourcing(ast, fileName, diagnosticRecords); // Check for Invoke-Expression usage (restricted in Constrained Language Mode) var invokeExpressionCommands = ast.FindAll(testAst => @@ -296,8 +349,15 @@ testAst is CommandAst cmdAst && )); } - // Check for disallowed type constraints (e.g., [System.Net.WebClient]$client) - var typeConstraints = ast.FindAll(testAst => testAst is TypeConstraintAst, true); + // Check for parameter type constraints (also called separately for signed scripts) + CheckParameterTypeConstraints(ast, fileName, diagnosticRecords); + + // Check for disallowed type constraints on variables (e.g., [System.Net.WebClient]$client) + var typeConstraints = ast.FindAll(testAst => + testAst is TypeConstraintAst typeConstraint && + !(typeConstraint.Parent is ParameterAst), // Exclude parameters - handled above + true); + foreach (TypeConstraintAst typeConstraint in typeConstraints) { var typeName = typeConstraint.TypeName.FullName; @@ -400,8 +460,71 @@ testAst is CommandAst cmdAst && )); } } + } - return diagnosticRecords; + /// + /// Checks for dot-sourcing patterns which are restricted in CLM even for signed scripts. + /// + private void CheckDotSourcing(Ast ast, string fileName, List diagnosticRecords) + { + // Check for dot-sourcing - PowerShell doesn't have a specific DotSourceExpressionAst + // We look for patterns where a script block or file is dot-sourced + var scriptBlocks = ast.FindAll(testAst => testAst is ScriptBlockExpressionAst, true); + + foreach (ScriptBlockExpressionAst sbAst in scriptBlocks) + { + // Check if preceded by a dot token (basic heuristic for dot-sourcing) + var parent = sbAst.Parent; + if (parent is CommandAst cmdAst) + { + // Check if this looks like a dot-source pattern + var cmdName = cmdAst.GetCommandName(); + if (cmdName != null && cmdName.StartsWith(".")) + { + diagnosticRecords.Add( + new DiagnosticRecord( + String.Format(CultureInfo.CurrentCulture, Strings.UseConstrainedLanguageModeDotSourceError), + sbAst.Extent, + GetName(), + GetDiagnosticSeverity(), + fileName + )); + } + } + } + } + + /// + /// Checks parameter type constraints which need validation even for signed scripts. + /// + private void CheckParameterTypeConstraints(Ast ast, string fileName, List diagnosticRecords) + { + // Find all parameter definitions + var parameters = ast.FindAll(testAst => testAst is ParameterAst, true); + + foreach (ParameterAst param in parameters) + { + // Check for type constraints on parameters + var typeConstraints = param.Attributes.OfType(); + + foreach (var typeConstraint in typeConstraints) + { + var typeName = typeConstraint.TypeName.FullName; + if (!IsTypeAllowed(typeName)) + { + diagnosticRecords.Add( + new DiagnosticRecord( + String.Format(CultureInfo.CurrentCulture, + Strings.UseConstrainedLanguageModeConstrainedTypeError, + typeName), + typeConstraint.Extent, + GetName(), + GetDiagnosticSeverity(), + fileName + )); + } + } + } } /// @@ -591,6 +714,7 @@ private void CheckModuleManifest(Ast ast, string fileName, List private void CheckWildcardExports(HashtableAst hashtableAst, string fileName, List diagnosticRecords) { + //AliasesToExport and VariablesToExport can use wildcards in CLM, but it is not recommended for performance reasons. We will flag it as an informational message to encourage best practices. string[] exportFields = { "FunctionsToExport", "CmdletsToExport", "AliasesToExport", "VariablesToExport" }; foreach (var kvp in hashtableAst.KeyValuePairs) From a23e14f1181c9630fb345427305a0557e9a80548 Mon Sep 17 00:00:00 2001 From: Josh Corrick Date: Sun, 15 Mar 2026 16:52:18 -0700 Subject: [PATCH 07/14] Improve dot-sourcing detection and expand CLM rule tests --- Rules/UseConstrainedLanguageMode.cs | 38 ++-- .../UseConstrainedLanguageMode.tests.ps1 | 182 ++++++++++++++++++ 2 files changed, 200 insertions(+), 20 deletions(-) diff --git a/Rules/UseConstrainedLanguageMode.cs b/Rules/UseConstrainedLanguageMode.cs index 8a1f2c298..60e9986ce 100644 --- a/Rules/UseConstrainedLanguageMode.cs +++ b/Rules/UseConstrainedLanguageMode.cs @@ -467,29 +467,27 @@ testAst is TypeConstraintAst typeConstraint && /// private void CheckDotSourcing(Ast ast, string fileName, List diagnosticRecords) { - // Check for dot-sourcing - PowerShell doesn't have a specific DotSourceExpressionAst - // We look for patterns where a script block or file is dot-sourced - var scriptBlocks = ast.FindAll(testAst => testAst is ScriptBlockExpressionAst, true); + // Dot-sourcing is detected by looking for commands where the extent text starts with a dot + // Example: . $PSScriptRoot\Helper.ps1 + // Example: . .\script.ps1 + // PowerShell doesn't have a specific DotSourceExpressionAst, so we check the command extent + var commands = ast.FindAll(testAst => testAst is CommandAst, true); - foreach (ScriptBlockExpressionAst sbAst in scriptBlocks) + foreach (CommandAst cmdAst in commands) { - // Check if preceded by a dot token (basic heuristic for dot-sourcing) - var parent = sbAst.Parent; - if (parent is CommandAst cmdAst) + // Check if the command extent starts with a dot followed by whitespace + // This indicates dot-sourcing + string extentText = cmdAst.Extent.Text.TrimStart(); + if (extentText.StartsWith(". ") || extentText.StartsWith(".\t")) { - // Check if this looks like a dot-source pattern - var cmdName = cmdAst.GetCommandName(); - if (cmdName != null && cmdName.StartsWith(".")) - { - diagnosticRecords.Add( - new DiagnosticRecord( - String.Format(CultureInfo.CurrentCulture, Strings.UseConstrainedLanguageModeDotSourceError), - sbAst.Extent, - GetName(), - GetDiagnosticSeverity(), - fileName - )); - } + diagnosticRecords.Add( + new DiagnosticRecord( + String.Format(CultureInfo.CurrentCulture, Strings.UseConstrainedLanguageModeDotSourceError), + cmdAst.Extent, + GetName(), + GetDiagnosticSeverity(), + fileName + )); } } } diff --git a/Tests/Rules/UseConstrainedLanguageMode.tests.ps1 b/Tests/Rules/UseConstrainedLanguageMode.tests.ps1 index 98a543463..7d552a9db 100644 --- a/Tests/Rules/UseConstrainedLanguageMode.tests.ps1 +++ b/Tests/Rules/UseConstrainedLanguageMode.tests.ps1 @@ -110,6 +110,16 @@ $xaml = @" } } + Context "When dot-sourcing is used" { + It "Should detect dot-sourcing in unsigned scripts" { + $def = '. $PSScriptRoot\Helper.ps1' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations.Count | Should -BeGreaterThan 0 + $matchingViolations[0].Message | Should -BeLike "*dot*" + } + } + Context "When PowerShell classes are used" { It "Should detect class definition" { $def = @' @@ -160,6 +170,33 @@ enum MyEnum { } } + Context "When type expressions are used" { + It "Should flag static type reference with new()" { + $def = '$instance = [System.Net.WebClient]::new()' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations.Count | Should -BeGreaterThan 0 + $matchingViolations[0].Message | Should -BeLike "*System.Net.WebClient*" + } + + It "Should flag static method call on disallowed type" { + $def = '[System.IO.File]::ReadAllText("C:\test.txt")' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations.Count | Should -BeGreaterThan 0 + $matchingViolations[0].Message | Should -BeLike "*System.IO.File*" + } + + It "Should NOT flag static reference to allowed type" { + $def = '[string]::Empty' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $typeExprViolations = $violations | Where-Object { + $_.RuleName -eq $violationName -and $_.Message -like "*type expression*string*" + } + $typeExprViolations | Should -BeNullOrEmpty + } + } + Context "When module manifests (.psd1) are analyzed" { BeforeAll { $tempPath = Join-Path $TestDrive "TestManifests" @@ -462,4 +499,149 @@ function Complex-Test { $matchingViolations.Count | Should -BeGreaterThan 2 } } + + Context "When scripts are digitally signed" { + BeforeAll { + $tempPath = Join-Path $TestDrive "SignedScripts" + New-Item -Path $tempPath -ItemType Directory -Force | Out-Null + } + + It "Should NOT flag Add-Type in signed scripts" { + $scriptPath = Join-Path $tempPath "SignedWithAddType.ps1" + $scriptContent = @' +Add-Type -TypeDefinition "public class Test { }" + +# SIG # Begin signature block +# MIIFFAYJKoZIhvcNAQcCoIIFBTCCBQECAQExCzAJ... +# SIG # End signature block +'@ + Set-Content -Path $scriptPath -Value $scriptContent + $violations = Invoke-ScriptAnalyzer -Path $scriptPath -Settings $settings + $addTypeViolations = $violations | Where-Object { + $_.RuleName -eq $violationName -and $_.Message -like "*Add-Type*" + } + $addTypeViolations | Should -BeNullOrEmpty + } + + It "Should NOT flag disallowed types in signed scripts" { + $scriptPath = Join-Path $tempPath "SignedWithDisallowedType.ps1" + $scriptContent = @' +$client = New-Object System.Net.WebClient +$data = $client.DownloadString("http://example.com") + +# SIG # Begin signature block +# MIIFFAYJKoZIhvcNAQcCoIIFBTCCBQECAQExCzAJ... +# SIG # End signature block +'@ + Set-Content -Path $scriptPath -Value $scriptContent + $violations = Invoke-ScriptAnalyzer -Path $scriptPath -Settings $settings + $typeViolations = $violations | Where-Object { + $_.RuleName -eq $violationName -and $_.Message -like "*WebClient*type*" + } + $typeViolations | Should -BeNullOrEmpty + } + + It "Should NOT flag classes in signed scripts" { + $scriptPath = Join-Path $tempPath "SignedWithClass.ps1" + $scriptContent = @' +class MyClass { + [string]$Name } + +# SIG # Begin signature block +# MIIFFAYJKoZIhvcNAQcCoIIFBTCCBQECAQExCzAJ... +# SIG # End signature block +'@ + Set-Content -Path $scriptPath -Value $scriptContent + $violations = Invoke-ScriptAnalyzer -Path $scriptPath -Settings $settings + $classViolations = $violations | Where-Object { + $_.RuleName -eq $violationName -and $_.Message -like "*class*MyClass*" + } + $classViolations | Should -BeNullOrEmpty + } + + It "Should STILL flag dot-sourcing in signed scripts" { + $scriptPath = Join-Path $tempPath "SignedWithDotSource.ps1" + $scriptContent = @' +. .\Helper.ps1 + +# SIG # Begin signature block +# MIIFFAYJKoZIhvcNAQcCoIIFBTCCBQECAQExCzAJ... +# SIG # End signature block +'@ + Set-Content -Path $scriptPath -Value $scriptContent + $violations = Invoke-ScriptAnalyzer -Path $scriptPath -Settings $settings + $dotSourceViolations = $violations | Where-Object { + $_.RuleName -eq $violationName -and $_.Message -like "*dot*" + } + # Dot-sourcing should still be flagged even in signed scripts + $dotSourceViolations.Count | Should -BeGreaterThan 0 + } + + It "Should STILL flag disallowed parameter types in signed scripts" { + $scriptPath = Join-Path $tempPath "SignedWithBadParam.ps1" + $scriptContent = @' +function Test { + param([System.Net.WebClient]$Client) + Write-Output "Test" +} + +# SIG # Begin signature block +# MIIFFAYJKoZIhvcNAQcCoIIFBTCCBQECAQExCzAJ... +# SIG # End signature block +'@ + Set-Content -Path $scriptPath -Value $scriptContent + $violations = Invoke-ScriptAnalyzer -Path $scriptPath -Settings $settings + $paramViolations = $violations | Where-Object { + $_.RuleName -eq $violationName -and $_.Message -like "*WebClient*" + } + # Parameter type constraints should still be flagged + $paramViolations.Count | Should -BeGreaterThan 0 + } + + It "Should STILL flag wildcard exports in signed manifests" { + $manifestPath = Join-Path $tempPath "SignedManifest.psd1" + $manifestContent = @' +@{ + ModuleVersion = '1.0.0' + GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + FunctionsToExport = '*' +} + +# SIG # Begin signature block +# MIIFFAYJKoZIhvcNAQcCoIIFBTCCBQECAQExCzAJ... +# SIG # End signature block +'@ + Set-Content -Path $manifestPath -Value $manifestContent + $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings + $wildcardViolations = $violations | Where-Object { + $_.RuleName -eq $violationName -and $_.Message -like "*wildcard*" + } + # Wildcard exports should still be flagged + $wildcardViolations.Count | Should -BeGreaterThan 0 + } + + It "Should STILL flag .ps1 modules in signed manifests" { + $manifestPath = Join-Path $tempPath "SignedManifestWithScript.psd1" + $manifestContent = @' +@{ + ModuleVersion = '1.0.0' + GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + RootModule = 'MyModule.ps1' +} + +# SIG # Begin signature block +# MIIFFAYJKoZIhvcNAQcCoIIFBTCCBQECAQExCzAJ... +# SIG # End signature block +'@ + Set-Content -Path $manifestPath -Value $manifestContent + $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings + $scriptModuleViolations = $violations | Where-Object { + $_.RuleName -eq $violationName -and $_.Message -like "*.ps1*" + } + # Script modules should still be flagged + $scriptModuleViolations.Count | Should -BeGreaterThan 0 + } + } +} + From 1abc86a9104531b1daf653011edfd9659b09857d Mon Sep 17 00:00:00 2001 From: Josh Corrick Date: Mon, 16 Mar 2026 08:51:20 -0700 Subject: [PATCH 08/14] Add IgnoreSignatures option to CLM rule and improve type checks --- Rules/UseConstrainedLanguageMode.cs | 43 +++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/Rules/UseConstrainedLanguageMode.cs b/Rules/UseConstrainedLanguageMode.cs index 60e9986ce..58d0b1a8e 100644 --- a/Rules/UseConstrainedLanguageMode.cs +++ b/Rules/UseConstrainedLanguageMode.cs @@ -62,10 +62,22 @@ public class UseConstrainedLanguageMode : ConfigurableRule "System.Net.IPAddress", "System.Net.Mail.MailAddress" }; + /// + /// When true, ignores script signatures and runs all CLM checks regardless of signature status. + /// When false (default), scripts with valid signatures are treated as having elevated permissions + /// and only critical checks (dot-sourcing, parameter types, manifests) are performed. + /// Set to true to enforce full CLM compliance even for signed scripts. + /// + [ConfigurableRuleProperty(defaultValue: false)] + public bool IgnoreSignatures { get; set; } + public UseConstrainedLanguageMode() { // This rule is disabled by default - users must explicitly enable it Enable = false; + + // IgnoreSignatures defaults to false (respects signatures) + IgnoreSignatures = false; } /// @@ -78,16 +90,31 @@ private bool IsTypeAllowed(string typeName) return true; // Can't determine, so don't flag } + // Handle array types (e.g., string[], System.String[], int[][]) + // Strip array brackets and check the base type + string baseTypeName = typeName; + if (typeName.EndsWith("[]")) + { + // Remove all trailing [] pairs + baseTypeName = typeName.TrimEnd('[', ']'); + + // Handle multi-dimensional or jagged arrays by removing all brackets + while (baseTypeName.EndsWith("[]")) + { + baseTypeName = baseTypeName.Substring(0, baseTypeName.Length - 2); + } + } + // Check exact match first - if (AllowedTypes.Contains(typeName)) + if (AllowedTypes.Contains(baseTypeName)) { return true; } // Check simple name (last part after last dot) - if (typeName.Contains('.')) + if (baseTypeName.Contains('.')) { - var simpleTypeName = typeName.Substring(typeName.LastIndexOf('.') + 1); + var simpleTypeName = baseTypeName.Substring(baseTypeName.LastIndexOf('.') + 1); if (AllowedTypes.Contains(simpleTypeName)) { return true; @@ -109,8 +136,11 @@ public override IEnumerable AnalyzeScript(Ast ast, string file var diagnosticRecords = new List(); - // Basic check if the file is signed - bool isFileSigned = IsScriptSigned(fileName); + // Check if the file is signed (via signature block detection) + bool isFileSigned = IgnoreSignatures ? false : IsScriptSigned(fileName); + + // Note: If IgnoreSignatures is true, isFileSigned will always be false, + // causing all CLM checks to run regardless of actual signature status // Check if this is a module manifest (.psd1 file) bool isModuleManifest = fileName != null && fileName.EndsWith(".psd1", StringComparison.OrdinalIgnoreCase); @@ -123,6 +153,7 @@ public override IEnumerable AnalyzeScript(Ast ast, string file } // For signed scripts, only check specific patterns that are still restricted + // (unless IgnoreSignatures is true, then this block is skipped) if (isFileSigned) { // Even signed scripts have these restrictions in CLM: @@ -136,7 +167,7 @@ public override IEnumerable AnalyzeScript(Ast ast, string file return diagnosticRecords; } - // For unsigned scripts, perform all CLM checks + // For unsigned scripts (or when IgnoreSignatures is true), perform all CLM checks CheckAllClmRestrictions(ast, fileName, diagnosticRecords); return diagnosticRecords; From 3526267814a43dce93cf59be2ae411cc7c165787 Mon Sep 17 00:00:00 2001 From: Josh Corrick Date: Tue, 17 Mar 2026 08:21:05 -0700 Subject: [PATCH 09/14] Add documentation for PSUseConstrainedLanguageMode rule --- docs/Rules/UseConstrainedLanguageMode.md | 374 +++++++++++++++++++++++ 1 file changed, 374 insertions(+) create mode 100644 docs/Rules/UseConstrainedLanguageMode.md diff --git a/docs/Rules/UseConstrainedLanguageMode.md b/docs/Rules/UseConstrainedLanguageMode.md new file mode 100644 index 000000000..95b30b847 --- /dev/null +++ b/docs/Rules/UseConstrainedLanguageMode.md @@ -0,0 +1,374 @@ +--- +description: Use patterns compatible with Constrained Language Mode +ms.date: 03/17/2026 +ms.topic: reference +title: UseConstrainedLanguageMode +--- +# UseConstrainedLanguageMode + +**Severity Level: Information** + +## Description + +This rule identifies PowerShell patterns that are restricted or not permitted in Constrained Language Mode (CLM). + +Constrained Language Mode is a PowerShell security feature that restricts: +- .NET types that can be used +- COM objects that can be instantiated +- Commands that can be executed +- Language features that can be used + +CLM is commonly used in: +- Application Control environments (Application Control for Business, AppLocker) +- Just Enough Administration (JEA) endpoints +- Secure environments requiring additional PowerShell restrictions + +**Signed Script Behavior**: Digitally signed scripts from trusted publishers execute in Full Language Mode (FLM) even in CLM environments. The rule detects signature blocks (`# SIG # Begin signature block`) and adjusts checks accordingly - most restrictions don't apply to signed scripts, but certain checks (dot-sourcing, parameter types, manifest best practices) are always enforced. + +**Important**: The rule performs a simple text check for signature blocks and does NOT validate signature authenticity or certificate trust. Actual signature validation is performed by PowerShell at runtime. + +## Constrained Language Mode Restrictions + +### Unsigned Scripts (Full CLM Checking) + +The following are flagged for unsigned scripts: + +1. **Add-Type** - Code compilation not permitted +2. **Disallowed COM Objects** - Only Scripting.Dictionary, Scripting.FileSystemObject, VBScript.RegExp allowed +3. **Disallowed .NET Types** - Only ~70 allowed types (string, int, hashtable, pscredential, etc.) +4. **Type Constraints** - On parameters and variables +5. **Type Expressions** - Static type references like `[Type]::Method()` +6. **Type Casts** - Converting to disallowed types +7. **Member Invocations** - Methods/properties on disallowed types +8. **PowerShell Classes** - `class` keyword not permitted +9. **XAML/WPF** - Not permitted +10. **Invoke-Expression** - Restricted +11. **Dot-Sourcing** - May be restricted depending on the file being sourced +12. **Module Manifest Wildcards** - Wildcard exports not recommended +13. **Module Manifest .ps1 Files** - Script modules ending with .ps1 not allowed + +Always enforced, even for signed scripts + +### Signed Scripts (Selective Checking) + +For scripts with signature blocks, only these are checked: +- Dot-sourcing +- Parameter type constraints +- Module manifest wildcards (.psd1 files) +- Module manifest script modules (.psd1 files) + +## Configuration + +### Basic Configuration + +```powershell +@{ + Rules = @{ + PSUseConstrainedLanguageMode = @{ + Enable = $true + } + } +} +``` + +### Parameters + +#### Enable: bool (Default value is `$false`) + +Enable or disable the rule during ScriptAnalyzer invocation. This rule is disabled by default because not all scripts need CLM compatibility. + +#### IgnoreSignatures: bool (Default value is `$false`) + +Control signature detection behavior: + +- `$false` (default): Automatically detect signatures. Signed scripts get selective checking, unsigned get full checking. +- `$true`: Bypass signature detection. ALL scripts get full CLM checking regardless of signature status. + +```powershell +@{ + Rules = @{ + PSUseConstrainedLanguageMode = @{ + Enable = $true + IgnoreSignatures = $true # Enforce full CLM compliance for all scripts + } + } +} +``` + +**Use `IgnoreSignatures = $true` when:** +- Auditing signed scripts for complete CLM compatibility +- Preparing scripts for untrusted environments +- Enforcing strict CLM compliance organization-wide +- Development/testing to see all potential issues + +## How to Fix + +### Replace Add-Type + +Use allowed cmdlets or pre-compile assemblies. + +### Replace Disallowed COM Objects + +Use only allowed COM objects (Scripting.Dictionary, Scripting.FileSystemObject, VBScript.RegExp) or PowerShell cmdlets. + +### Replace Disallowed Types + +Use allowed type accelerators (`[string]`, `[int]`, `[hashtable]`, etc.) or allowed cmdlets instead of disallowed .NET types. + +### Replace PowerShell Classes + +Use `New-Object PSObject` with `Add-Member` or hashtables instead of classes. + +**Important**: `[PSCustomObject]@{}` syntax is NOT allowed in CLM because it uses type casting. + +### Avoid XAML + +Don't use WPF/XAML in CLM-compatible scripts. + +### Replace Invoke-Expression + +Use direct execution (`&`) or safer alternatives. + +### Replace Dot-Sourcing + +Use modules with Import-Module instead of dot-sourcing when possible. + +### Fix Module Manifests + +- Replace wildcard exports (`*`) with explicit lists +- Use `.psm1` or `.dll` instead of `.ps1` for RootModule/NestedModules + +## Examples + +### Example 1: Add-Type + +#### Wrong + +```powershell +Add-Type -TypeDefinition @" + public class Helper { + public static string DoWork() { return "Done"; } + } +"@ +``` + +#### Correct + +```powershell +# Code sign your module using Add-Type +# Use allowed cmdlets instead +# Or pre-compile and load the assembly +``` + +### Example 2: COM Objects + +#### Wrong + +```powershell +$excel = New-Object -ComObject Excel.Application +``` + +#### Correct + +```powershell +# Use allowed COM object +$dict = New-Object -ComObject Scripting.Dictionary + +# Or use PowerShell cmdlets +Import-Excel -Path $file # From ImportExcel module +``` + +### Example 3: Disallowed Types + +#### Wrong + +```powershell +# Type constraint and member invocation flagged +function Download-File { + param([System.Net.WebClient]$Client) + $Client.DownloadString($url) +} + +# Type cast and method call flagged +[System.Net.WebClient]$client = New-Object System.Net.WebClient +$data = $client.DownloadData($url) +``` + +#### Correct + +```powershell +# Use allowed cmdlets +function Download-File { + param([string]$Url) + Invoke-WebRequest -Uri $Url +} + +# Use allowed types +function Process-Text { + param([string]$Text) + $upper = $Text.ToUpper() # String methods are allowed +} +``` + +### Example 4: PowerShell Classes + +#### Wrong + +```powershell +class MyClass { + [string]$Name + + [string]GetInfo() { + return $this.Name + } +} + +# Also wrong - uses type cast +$obj = [PSCustomObject]@{ + Name = "Test" +} +``` + +#### Correct + +```powershell +# Option 1: New-Object PSObject with Add-Member +$obj = New-Object PSObject -Property @{ + Name = "Test" +} + +$obj | Add-Member -MemberType ScriptMethod -Name GetInfo -Value { + return $this.Name +} + +Add-Member -InputObject $obj -NotePropertyMembers @{"Number" = 42} + +# Option 2: Hashtable +$obj = @{ + Name = "Test" + Number = 42 +} +``` + +### Example 5: Module Manifests + +#### Wrong + +```powershell +@{ + ModuleVersion = '1.0.0' + RootModule = 'MyModule.ps1' # .ps1 not recommended + FunctionsToExport = '*' # Wildcard not recommended + CmdletsToExport = '*' +} +``` + +#### Correct + +```powershell +@{ + ModuleVersion = '1.0.0' + RootModule = 'MyModule.psm1' # Use .psm1 or .dll + FunctionsToExport = @( # Explicit list + 'Get-MyFunction' + 'Set-MyFunction' + ) + CmdletsToExport = @() +} +``` + +### Example 6: Array Types + +#### Wrong + +```powershell +# Disallowed type in array +param([System.Net.WebClient[]]$Clients) +``` + +#### Correct + +```powershell +# Allowed types in arrays are fine +param([string[]]$Names) +param([int[]]$Numbers) +param([hashtable[]]$Configuration) +``` + +## Detailed Restrictions + +### 1. Add-Type +`Add-Type` allows compiling arbitrary C# code and is not permitted in CLM. + +**Enforced For**: Unsigned scripts only + +### 2. COM Objects +Only three COM objects are allowed: +- `Scripting.Dictionary` +- `Scripting.FileSystemObject` +- `VBScript.RegExp` + +All others (Excel.Application, WScript.Shell, etc.) are flagged. + +**Enforced For**: Unsigned scripts only + +### 3. .NET Types +Only ~70 allowed types including: +- Primitives: `string`, `int`, `bool`, `byte`, `char`, `datetime`, `decimal`, `double`, etc. +- Collections: `hashtable`, `array`, `arraylist` +- PowerShell: `pscredential`, `psobject`, `securestring` +- Utilities: `regex`, `guid`, `version`, `uri`, `xml` +- Arrays: `string[]`, `int[][]`, etc. (array of any allowed type) + +The rule checks type usage in: +- Parameter type constraints (**always enforced, even for signed scripts**) +- Variable type constraints +- New-Object -TypeName +- Type expressions (`[Type]::Method()`) +- Type casts (`[Type]$variable`) +- Member invocations on typed variables + +**Enforced For**: Parameter constraints always; others unsigned only + +### 4. PowerShell Classes +The `class` keyword is not permitted. Use `New-Object PSObject` with `Add-Member` or hashtables. + +**Note**: `[PSCustomObject]@{}` is also not allowed because it uses type casting. + +**Enforced For**: Unsigned scripts only + +### 5. XAML/WPF +XAML and WPF are not permitted in CLM. + +**Enforced For**: Unsigned scripts only + +### 6. Invoke-Expression +`Invoke-Expression` is restricted in CLM. + +**Enforced For**: Unsigned scripts only + +### 7. Dot-Sourcing +Dot-sourcing (`. $PSScriptRoot\script.ps1`) may be restricted depending on source location. + +**Enforced For**: ALL scripts (unsigned and signed) + +### 8. Module Manifest Best Practices + +#### Wildcard Exports +Don't use `*` in: `FunctionsToExport`, `CmdletsToExport`, `AliasesToExport`, `VariablesToExport` + +Use explicit lists for security and clarity. + +**Enforced For**: ALL .psd1 files (unsigned and signed) + +#### Script Module Files +Don't use `.ps1` files in: `RootModule`, `ModuleToProcess`, `NestedModules` + +Use `.psm1` (script modules) or `.dll` (binary modules) for better performance and compatibility. + +**Enforced For**: ALL .psd1 files (unsigned and signed) + +## More Information + +- [About Language Modes](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_language_modes) +- [PowerShell Constrained Language Mode](https://devblogs.microsoft.com/powershell/powershell-constrained-language-mode/) From d87628db39d3f9b91fe5956ef4939b87f38d1260 Mon Sep 17 00:00:00 2001 From: Josh Corrick Date: Tue, 17 Mar 2026 08:34:47 -0700 Subject: [PATCH 10/14] Increase severity of UseConstrainedLanguageMode to Warning for optional rule --- Rules/UseConstrainedLanguageMode.cs | 4 ++-- docs/Rules/UseConstrainedLanguageMode.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Rules/UseConstrainedLanguageMode.cs b/Rules/UseConstrainedLanguageMode.cs index 58d0b1a8e..78517d964 100644 --- a/Rules/UseConstrainedLanguageMode.cs +++ b/Rules/UseConstrainedLanguageMode.cs @@ -973,7 +973,7 @@ public override string GetName() /// public override RuleSeverity GetSeverity() { - return RuleSeverity.Information; + return RuleSeverity.Warning; } /// @@ -981,7 +981,7 @@ public override RuleSeverity GetSeverity() /// public DiagnosticSeverity GetDiagnosticSeverity() { - return DiagnosticSeverity.Information; + return DiagnosticSeverity.Warning; } /// diff --git a/docs/Rules/UseConstrainedLanguageMode.md b/docs/Rules/UseConstrainedLanguageMode.md index 95b30b847..293d3c3be 100644 --- a/docs/Rules/UseConstrainedLanguageMode.md +++ b/docs/Rules/UseConstrainedLanguageMode.md @@ -6,7 +6,7 @@ title: UseConstrainedLanguageMode --- # UseConstrainedLanguageMode -**Severity Level: Information** +**Severity Level: Warning** ## Description From 69b200f4e9959ee45bd0692ef35129840049ddeb Mon Sep 17 00:00:00 2001 From: Josh Corrick Date: Tue, 17 Mar 2026 08:47:55 -0700 Subject: [PATCH 11/14] Update test to expect 'Warning' severity instead of 'Info' --- Tests/Rules/UseConstrainedLanguageMode.tests.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/Rules/UseConstrainedLanguageMode.tests.ps1 b/Tests/Rules/UseConstrainedLanguageMode.tests.ps1 index 7d552a9db..95bf38332 100644 --- a/Tests/Rules/UseConstrainedLanguageMode.tests.ps1 +++ b/Tests/Rules/UseConstrainedLanguageMode.tests.ps1 @@ -344,7 +344,7 @@ enum MyEnum { $def = 'Add-Type -AssemblyName System.Windows.Forms' $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } - $matchingViolations[0].Severity | Should -Be 'Information' + $matchingViolations[0].Severity | Should -Be 'Warning' } } From 17f1a241fd735791c7806fe663970a2a607dc791 Mon Sep 17 00:00:00 2001 From: Josh Corrick Date: Tue, 17 Mar 2026 10:35:06 -0700 Subject: [PATCH 12/14] Making copilot suggested edits --- Rules/Strings.resx | 4 +- Rules/UseConstrainedLanguageMode.cs | 98 +++++++++++---- .../UseConstrainedLanguageMode.tests.ps1 | 112 ++++++++++++++---- 3 files changed, 164 insertions(+), 50 deletions(-) diff --git a/Rules/Strings.resx b/Rules/Strings.resx index 7d081acc2..02e713378 100644 --- a/Rules/Strings.resx +++ b/Rules/Strings.resx @@ -1,4 +1,4 @@ - +