diff --git a/Rules/Strings.resx b/Rules/Strings.resx index 67f925ede..d6362fd48 100644 --- a/Rules/Strings.resx +++ b/Rules/Strings.resx @@ -1236,6 +1236,57 @@ The reserved word '{0}' was used as a function name. This should be avoided. + + UseConstrainedLanguageMode + + + Consider Constrained Language Mode Restrictions + + + Identifies script patterns that are restricted in Constrained Language Mode. Constrained Language Mode limits the types, cmdlets, and .NET methods that can be used to help secure PowerShell in environments requiring additional restrictions. + + + Add-Type 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. + + + Dot-sourcing may be restricted in Constrained Language Mode depending on the source location. Ensure scripts are from trusted locations if running in a restricted environment. + + + 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. + + + 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. + + + 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 a module file (.psm1) or a binary module (.dll) instead for Constrained Language Mode compatibility. + + + [PSCustomObject]@{{}} syntax is not permitted in Constrained Language Mode. Use New-Object PSObject -Property @{{}} or plain hashtables instead. + Use correct function parameters definition kind. diff --git a/Rules/UseConstrainedLanguageMode.cs b/Rules/UseConstrainedLanguageMode.cs new file mode 100644 index 000000000..6046588a8 --- /dev/null +++ b/Rules/UseConstrainedLanguageMode.cs @@ -0,0 +1,1070 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +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 +using System.Globalization; + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules +{ + /// + /// UseConstrainedLanguageMode: Checks for patterns that indicate Constrained Language Mode should be considered. + /// +#if !CORECLR + [Export(typeof(IScriptRule))] +#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" + }; + + /// + /// Cache for typed variable assignments per scope to avoid O(N*M) performance issues. + /// Key: Scope AST (FunctionDefinitionAst or ScriptBlockAst) + /// Value: Dictionary mapping variable names to their type names + /// + private Dictionary> _typedVariableCache; + + /// + /// When True, ignores the presence of script signature blocks and runs all CLM checks + /// regardless of whether a script appears to be signed. + /// When False (default), scripts that contain a PowerShell signature block (for example, + /// one starting with '# SIG # Begin signature block') are treated as having elevated + /// permissions for this rule and only critical checks (dot-sourcing, parameter types, + /// manifests) are performed. No cryptographic validation or trust evaluation of the + /// signature is performed. + /// + [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; + } + + /// + /// 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 + } + + // Handle array types (e.g., string[], System.String[], int[][]) + // Strip array brackets and check the base type + string baseTypeName = typeName; + + + // Handle multi-dimensional or jagged arrays by removing all brackets + while (baseTypeName.EndsWith("[]", StringComparison.Ordinal)) + { + baseTypeName = baseTypeName.Substring(0, baseTypeName.Length - 2); + } + + + // Check exact match first + if (AllowedTypes.Contains(baseTypeName)) + { + return true; + } + + // Check simple name (last part after last dot) + if (baseTypeName.Contains('.')) + { + var simpleTypeName = baseTypeName.Substring(baseTypeName.LastIndexOf('.') + 1); + if (AllowedTypes.Contains(simpleTypeName)) + { + return true; + } + } + + return false; + } + + /// + /// Analyzes the script to check for patterns that may require Constrained Language Mode. + /// + public override IEnumerable AnalyzeScript(Ast ast, string fileName) + { + if (ast == null) + { + throw new ArgumentNullException(nameof(ast)); + } + + // Initialize cache for this analysis to avoid O(N*M) performance issues + _typedVariableCache = new Dictionary>(); + + var diagnosticRecords = new List(); + + // 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); + + if (isModuleManifest) + { + // Perform PSD1-specific checks + // These checks are ALWAYS enforced, even for signed scripts + CheckModuleManifest(ast, fileName, diagnosticRecords); + } + + // 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: + + // 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 (or when IgnoreSignatures is true), 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 && + cmdAst.GetCommandName().Equals("Add-Type", StringComparison.OrdinalIgnoreCase), + true); + + foreach (CommandAst cmd in addTypeCommands) + { + diagnosticRecords.Add( + new DiagnosticRecord( + String.Format(CultureInfo.CurrentCulture, Strings.UseConstrainedLanguageModeAddTypeError), + cmd.Extent, + GetName(), + GetDiagnosticSeverity(), + fileName + )); + } + + // 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 && + cmdAst.GetCommandName().Equals("New-Object", StringComparison.OrdinalIgnoreCase), + true); + + foreach (CommandAst cmd in newObjectCommands) + { + // Use StaticParameterBinder to reliably get parameter values + var bindingResult = StaticParameterBinder.BindCommand(cmd, true); + + // Check for -ComObject parameter + if (bindingResult.BoundParameters.ContainsKey("ComObject")) + { + 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 + )); + } + } + } + + // Check for XAML usage (not allowed in Constrained Language Mode) + var xamlPatterns = ast.FindAll(testAst => + testAst is StringConstantExpressionAst strAst && + strAst.Value.Contains("<") && strAst.Value.Contains("xmlns"), + true); + + foreach (StringConstantExpressionAst xamlAst in xamlPatterns) + { + if (xamlAst.Value.Contains("http://schemas.microsoft.com/winfx")) + { + diagnosticRecords.Add( + new DiagnosticRecord( + String.Format(CultureInfo.CurrentCulture, Strings.UseConstrainedLanguageModeXamlError), + xamlAst.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 => + testAst is CommandAst cmdAst && + cmdAst.GetCommandName() != null && + cmdAst.GetCommandName().Equals("Invoke-Expression", StringComparison.OrdinalIgnoreCase), + true); + + foreach (CommandAst cmd in invokeExpressionCommands) + { + diagnosticRecords.Add( + new DiagnosticRecord( + String.Format(CultureInfo.CurrentCulture, Strings.UseConstrainedLanguageModeInvokeExpressionError), + cmd.Extent, + GetName(), + GetDiagnosticSeverity(), + fileName + )); + } + + // 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 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; + if (!IsTypeAllowed(typeName)) + { + diagnosticRecords.Add( + new DiagnosticRecord( + String.Format(CultureInfo.CurrentCulture, + Strings.UseConstrainedLanguageModeConstrainedTypeError, + typeName), + typeConstraint.Extent, + GetName(), + GetDiagnosticSeverity(), + fileName + )); + } + } + + // 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; + + // Special case: [PSCustomObject]@{} is not allowed in CLM + // Even though PSCustomObject is an allowed type for parameters, + // the type cast syntax with hashtable literal is blocked in CLM + if (typeName.Equals("PSCustomObject", StringComparison.OrdinalIgnoreCase) && + convertExpr.Child is HashtableAst) + { + diagnosticRecords.Add( + new DiagnosticRecord( + String.Format(CultureInfo.CurrentCulture, + Strings.UseConstrainedLanguageModePSCustomObjectError), + convertExpr.Extent, + GetName(), + GetDiagnosticSeverity(), + fileName + )); + continue; // Already flagged, skip general type check + } + + 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 + )); + } + } + } + + /// + /// Checks for dot-sourcing patterns which are restricted in CLM even for signed scripts. + /// + private void CheckDotSourcing(Ast ast, string fileName, List diagnosticRecords) + { + // 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 (CommandAst cmdAst in commands) + { + // 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")) + { + diagnosticRecords.Add( + new DiagnosticRecord( + String.Format(CultureInfo.CurrentCulture, Strings.UseConstrainedLanguageModeDotSourceError), + cmdAst.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 + )); + } + } + } + } + + /// + /// 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; + } + + /// + /// Builds and caches typed variable assignments for a given scope. + /// This is called once per scope to avoid O(N*M) performance issues. + /// + private Dictionary GetOrBuildTypedVariableCache(Ast scope) + { + if (scope == null) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + // Check if we already have cached results for this scope + if (_typedVariableCache.TryGetValue(scope, out var cachedResults)) + { + return cachedResults; + } + + // Build the cache for this scope + var typedVariables = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // Find all assignment statements in this scope + var assignments = scope.FindAll(testAst => testAst is AssignmentStatementAst, true); + + foreach (AssignmentStatementAst assignment in assignments) + { + // Check if the left side is a convert expression with a variable + if (assignment.Left is ConvertExpressionAst convertExpr && + convertExpr.Child is VariableExpressionAst assignedVar) + { + var varName = assignedVar.VariablePath.UserPath; + var typeName = convertExpr.Type.TypeName.FullName; + + // Store in cache (first assignment wins) + if (!typedVariables.ContainsKey(varName)) + { + typedVariables[varName] = typeName; + } + } + } + + // Cache the results + _typedVariableCache[scope] = typedVariables; + return typedVariables; + } + + /// + /// Looks for a typed assignment to a variable using cached results. + /// + 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; + } + + // Use cached results instead of re-scanning the entire scope + var typedVariables = GetOrBuildTypedVariableCache(searchScope); + + if (typedVariables.TryGetValue(varName, out string typeName)) + { + return typeName; + } + + 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) + { + //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) + { + 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. + /// + public override string GetCommonName() + { + return string.Format(CultureInfo.CurrentCulture, Strings.UseConstrainedLanguageModeCommonName); + } + + /// + /// Retrieves the description of this rule. + /// + public override string GetDescription() + { + return string.Format(CultureInfo.CurrentCulture, Strings.UseConstrainedLanguageModeDescription); + } + + /// + /// Retrieves the name of this rule. + /// + public override string GetName() + { + return string.Format( + CultureInfo.CurrentCulture, + Strings.NameSpaceFormat, + GetSourceName(), + Strings.UseConstrainedLanguageModeName); + } + + /// + /// Retrieves the severity of the rule: error, warning or information. + /// + public override RuleSeverity GetSeverity() + { + return RuleSeverity.Warning; + } + + /// + /// Gets the severity of the returned diagnostic record: error, warning, or information. + /// + public DiagnosticSeverity GetDiagnosticSeverity() + { + return DiagnosticSeverity.Warning; + } + + /// + /// Retrieves the name of the module/assembly the rule is from. + /// + public override string GetSourceName() + { + return string.Format(CultureInfo.CurrentCulture, Strings.SourceName); + } + + /// + /// Retrieves the type of the rule, Builtin, Managed or Module. + /// + public override SourceType GetSourceType() + { + return SourceType.Builtin; + } + } +} diff --git a/Tests/Rules/UseConstrainedLanguageMode.tests.ps1 b/Tests/Rules/UseConstrainedLanguageMode.tests.ps1 new file mode 100644 index 000000000..ffd5c1583 --- /dev/null +++ b/Tests/Rules/UseConstrainedLanguageMode.tests.ps1 @@ -0,0 +1,753 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +BeforeAll { + $testRootDirectory = Split-Path -Parent $PSScriptRoot + Import-Module (Join-Path $testRootDirectory "PSScriptAnalyzerTestHelper.psm1") + + $violationName = "PSUseConstrainedLanguageMode" + $ruleName = $violationName + + # The rule is disabled by default, so we need to enable it + $settings = @{ + IncludeRules = @($ruleName) + Rules = @{ + $ruleName = @{ + Enable = $true + } + } + } +} + +Describe "UseConstrainedLanguageMode" { + Context "When Add-Type is used" { + It "Should detect Add-Type usage" { + $def = @' +Add-Type -TypeDefinition @" + public class TestType { + public static string Test() { return "test"; } + } +"@ +'@ + $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*" + } + + It "Should not flag other commands" { + $def = 'Get-Process | Where-Object { $_.Name -eq "powershell" }' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $violations | Where-Object { $_.RuleName -eq $violationName } | Should -BeNullOrEmpty + } + } + + Context "When New-Object with COM is used" { + 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*" + } + + 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 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.IO.File' + $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.IO.File*not permitted*" + } + } + + Context "When XAML is used" { + It "Should detect XAML usage" { + $def = @' +$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*" + } + } + + Context "When Invoke-Expression is used" { + It "Should detect Invoke-Expression usage" { + $def = 'Invoke-Expression "Get-Process"' + $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*" + } + } + + 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 = @' +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 "When type expressions are used" { + It "Should flag static type reference with new()" { + $def = '$instance = [System.IO.Directory]::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.IO.Directory*" + } + + 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" + 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 "Rule severity" { + It "Should have Warning severity" { + $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 'Warning' + } + } + + Context "When type constraints are used" { + It "Should flag disallowed type constraint on parameter" { + $def = 'function Test { param([System.IO.File]$FileHelper) }' + $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*not permitted*" + } + + It "Should flag disallowed type constraint on variable declaration" { + $def = '[System.IO.File]$fileHelper = $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.IO.File*not permitted*" + } + + It "Should flag disallowed type cast on variable assignment" { + $def = '$fileHelper = [System.IO.File]$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.IO.File*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.IO.File]$FileHelper) + [System.IO.Directory]$dirHelper = $null + $pathHelper = [System.IO.Path]::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 PSCustomObject type cast is used" { + It "Should flag [PSCustomObject]@{} syntax" { + $def = '$obj = [PSCustomObject]@{ Name = "Test"; Value = 42 }' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations.Count | Should -BeGreaterThan 0 + $matchingViolations[0].Message | Should -BeLike "*PSCustomObject*" + } + + It "Should flag multiple [PSCustomObject]@{} instances" { + $def = @' +$obj1 = [PSCustomObject]@{ Name = "Test1" } +$obj2 = [PSCustomObject]@{ Name = "Test2" } +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations.Count | Should -Be 2 + } + + It "Should NOT flag PSCustomObject as parameter type" { + $def = 'function Test { param([PSCustomObject]$InputObject) }' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $violations | Where-Object { $_.RuleName -eq $violationName } | Should -BeNullOrEmpty + } + + It "Should NOT flag New-Object PSObject" { + $def = '$obj = New-Object PSObject -Property @{ Name = "Test" }' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $violations | Where-Object { $_.RuleName -eq $violationName } | Should -BeNullOrEmpty + } + + It "Should NOT flag plain hashtables" { + $def = '$obj = @{ Name = "Test"; Value = 42 }' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $violations | Where-Object { $_.RuleName -eq $violationName } | Should -BeNullOrEmpty + } + + It "Should NOT flag [PSCustomObject] with variable (not hashtable literal)" { + $def = '$hash = @{}; $obj = [PSCustomObject]$hash' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + # This is a type cast but not the @{} literal pattern + # Since PSCustomObject is in allowed list, this won't be flagged + $matchingViolations | Should -BeNullOrEmpty + } + + } + + Context "When instance methods are invoked on disallowed types" { + It "Should flag method invocation on parameter with disallowed type constraint" { + $def = @' +function Read-File { + param([System.IO.File]$FileHelper, [string]$Path) + $FileHelper.ReadAllText($Path) +} +'@ + $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 ReadAllText + ($matchingViolations.Message | Where-Object { $_ -like "*ReadAllText*" }).Count | Should -BeGreaterThan 0 + } + + It "Should flag property access on variable with disallowed type constraint" { + $def = @' +function Test { + param([System.IO.FileInfo]$FileHelper) + $fullPath = $FileHelper.FullName +} +'@ + $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 FullName + ($matchingViolations.Message | Where-Object { $_ -like "*FullName*" }).Count | Should -BeGreaterThan 0 + } + + It "Should flag method invocation on typed variable assignment" { + $def = @' +[System.IO.File]$fileHelper = $null +$result = $fileHelper.ReadAllText("C:\test.txt") +'@ + $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 ReadAllText + ($matchingViolations.Message | Where-Object { $_ -like "*ReadAllText*" }).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.IO.File]::Exists("test.txt")' + $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.IO.File*" + } + + It "Should flag chained method calls on disallowed types" { + $def = @' +function Test { + param([System.IO.FileInfo]$FileHelper) + $result = $FileHelper.OpenText().ReadToEnd() +} +'@ + $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.IO.File]$FileHelper, + [System.IO.Directory]$DirHelper, + [string]$SafeString + ) + + $data = $FileHelper.ReadAllBytes("C:\test.bin") + $DirHelper.GetFiles("C:\temp") + $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 + } + } + + 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 = @' +$fileHelper = New-Object System.IO.FileInfo("C:\test.txt") +$data = $fileHelper.OpenText() + +# 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 "*FileInfo*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.IO.File]$FileHelper) + 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 "*File*" + } + # 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 + } + } + + Context "Performance with large scripts" { + It "Should handle scripts with many typed variables and member invocations efficiently" { + # This test verifies the O(N+M) cache optimization + # Without caching, this would be O(N*M) and very slow + + # Build a script with many typed variables and member invocations + $scriptBuilder = [System.Text.StringBuilder]::new() + [void]$scriptBuilder.AppendLine('function Test-Performance {') + [void]$scriptBuilder.AppendLine(' param([string]$Path)') + + # Add 30 typed variable assignments + for ($i = 1; $i -le 30; $i++) { + [void]$scriptBuilder.AppendLine(" [System.IO.File]`$file$i = `$null") + } + + # Add 50 member invocations (testing cache reuse) + for ($i = 1; $i -le 50; $i++) { + $varNum = ($i % 30) + 1 + [void]$scriptBuilder.AppendLine(" `$result$i = `$file$varNum.ReadAllText(`$Path)") + } + + [void]$scriptBuilder.AppendLine('}') + $def = $scriptBuilder.ToString() + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + + # Should detect violations (30 type constraints + 50 member accesses = 80+) + $matchingViolations.Count | Should -BeGreaterThan 50 + } + + It "Should cache results per scope correctly" { + # Test that cache is scoped properly and doesn't leak between functions + $def = @' +function Function1 { + [System.IO.File]$file1 = $null + $result1 = $file1.ReadAllText("C:\test1.txt") +} + +function Function2 { + [System.IO.Directory]$file1 = $null + $result2 = $file1.GetFiles("C:\temp") +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + + # Should detect violations in both functions + # Each function has: 1 type constraint + 1 member access = 2 violations each + $matchingViolations.Count | Should -BeGreaterOrEqual 4 + + # Verify both File and Directory are mentioned + $messages = $matchingViolations.Message -join ' ' + $messages | Should -BeLike "*File*" + $messages | Should -BeLike "*Directory*" + } + } +} diff --git a/docs/Rules/README.md b/docs/Rules/README.md index 51858d0de..82e06952e 100644 --- a/docs/Rules/README.md +++ b/docs/Rules/README.md @@ -70,6 +70,7 @@ The PSScriptAnalyzer contains the following rule definitions. | [UseConsistentIndentation](./UseConsistentIndentation.md) | Warning | No | Yes | | [UseConsistentParametersKind](./UseConsistentParametersKind.md) | Warning | No | Yes | | [UseConsistentWhitespace](./UseConsistentWhitespace.md) | Warning | No | Yes | +| [UseConstrainedLanguageMode](./UseConstrainedLanguageMode.md) | Warning | No | Yes | | [UseCorrectCasing](./UseCorrectCasing.md) | Information | No | Yes | | [UseDeclaredVarsMoreThanAssignments](./UseDeclaredVarsMoreThanAssignments.md) | Warning | Yes | | | [UseLiteralInitializerForHashtable](./UseLiteralInitializerForHashtable.md) | Warning | Yes | | diff --git a/docs/Rules/UseConstrainedLanguageMode.md b/docs/Rules/UseConstrainedLanguageMode.md new file mode 100644 index 000000000..61574dada --- /dev/null +++ b/docs/Rules/UseConstrainedLanguageMode.md @@ -0,0 +1,377 @@ +--- +description: Use patterns compatible with Constrained Language Mode +ms.date: 03/17/2026 +ms.topic: reference +title: UseConstrainedLanguageMode +--- +# UseConstrainedLanguageMode + +**Severity Level: Warning** + +## 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 scripts/modules using proper signing tools + # (for example, Set-AuthenticodeSignature or external signing processes) + # Use allowed cmdlets instead of Add-Type-defined types where possible + # Or pre-compile, sign, and load the assembly (for example, via Add-Type -Path) +``` + +### 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/powershell/module/microsoft.powershell.core/about/about_language_modes) +- [PowerShell Constrained Language Mode](https://devblogs.microsoft.com/powershell/powershell-constrained-language-mode/) +- [PowerShell Module Function Export in Constrained Language](https://devblogs.microsoft.com/powershell/powershell-module-function-export-in-constrained-language/) +- [PowerShell Constrained Language Mode and the Dot-Source Operator](https://devblogs.microsoft.com/powershell/powershell-constrained-language-mode-and-the-dot-source-operator/)