Skip to content

Commit 17f1a24

Browse files
committed
Making copilot suggested edits
1 parent 69b200f commit 17f1a24

File tree

3 files changed

+164
-50
lines changed

3 files changed

+164
-50
lines changed

Rules/Strings.resx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<?xml version="1.0" encoding="utf-8"?>
1+
<?xml version="1.0" encoding="utf-8"?>
22
<root>
33
<!--
44
Microsoft ResX Schema
@@ -1282,6 +1282,6 @@
12821282
<value>Module manifest field '{0}' uses wildcard ('*') which is not recommended for Constrained Language Mode. Explicitly list exported items instead.</value>
12831283
</data>
12841284
<data name="UseConstrainedLanguageModeScriptModuleError" xml:space="preserve">
1285-
<value>Module manifest field '{0}' contains script file '{1}' (.ps1). Use binary modules (.psm1 or .dll) instead for Constrained Language Mode compatibility.</value>
1285+
<value>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.</value>
12861286
</data>
12871287
</root>

Rules/UseConstrainedLanguageMode.cs

Lines changed: 73 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,20 @@ public class UseConstrainedLanguageMode : ConfigurableRule
6363
};
6464

6565
/// <summary>
66-
/// When true, ignores script signatures and runs all CLM checks regardless of signature status.
67-
/// When false (default), scripts with valid signatures are treated as having elevated permissions
68-
/// and only critical checks (dot-sourcing, parameter types, manifests) are performed.
69-
/// Set to true to enforce full CLM compliance even for signed scripts.
66+
/// Cache for typed variable assignments per scope to avoid O(N*M) performance issues.
67+
/// Key: Scope AST (FunctionDefinitionAst or ScriptBlockAst)
68+
/// Value: Dictionary mapping variable names to their type names
69+
/// </summary>
70+
private Dictionary<Ast, Dictionary<string, string>> _typedVariableCache;
71+
72+
/// <summary>
73+
/// When True, ignores the presence of script signature blocks and runs all CLM checks
74+
/// regardless of whether a script appears to be signed.
75+
/// When False (default), scripts that contain a PowerShell signature block (for example,
76+
/// one starting with '# SIG # Begin signature block') are treated as having elevated
77+
/// permissions for this rule and only critical checks (dot-sourcing, parameter types,
78+
/// manifests) are performed. No cryptographic validation or trust evaluation of the
79+
/// signature is performed.
7080
/// </summary>
7181
[ConfigurableRuleProperty(defaultValue: false)]
7282
public bool IgnoreSignatures { get; set; }
@@ -93,18 +103,15 @@ private bool IsTypeAllowed(string typeName)
93103
// Handle array types (e.g., string[], System.String[], int[][])
94104
// Strip array brackets and check the base type
95105
string baseTypeName = typeName;
96-
if (typeName.EndsWith("[]"))
97-
{
98-
// Remove all trailing [] pairs
99-
baseTypeName = typeName.TrimEnd('[', ']');
106+
100107

101-
// Handle multi-dimensional or jagged arrays by removing all brackets
102-
while (baseTypeName.EndsWith("[]"))
103-
{
104-
baseTypeName = baseTypeName.Substring(0, baseTypeName.Length - 2);
105-
}
108+
// Handle multi-dimensional or jagged arrays by removing all brackets
109+
while (baseTypeName.EndsWith("[]", StringComparison.Ordinal))
110+
{
111+
baseTypeName = baseTypeName.Substring(0, baseTypeName.Length - 2);
106112
}
107113

114+
108115
// Check exact match first
109116
if (AllowedTypes.Contains(baseTypeName))
110117
{
@@ -134,6 +141,9 @@ public override IEnumerable<DiagnosticRecord> AnalyzeScript(Ast ast, string file
134141
throw new ArgumentNullException(nameof(ast));
135142
}
136143

144+
// Initialize cache for this analysis to avoid O(N*M) performance issues
145+
_typedVariableCache = new Dictionary<Ast, Dictionary<string, string>>();
146+
137147
var diagnosticRecords = new List<DiagnosticRecord>();
138148

139149
// Check if the file is signed (via signature block detection)
@@ -675,7 +685,52 @@ private ParameterAst FindParameterForVariable(VariableExpressionAst varExpr)
675685
}
676686

677687
/// <summary>
678-
/// Looks for a typed assignment to a variable (e.g., [Type]$var = ...)
688+
/// Builds and caches typed variable assignments for a given scope.
689+
/// This is called once per scope to avoid O(N*M) performance issues.
690+
/// </summary>
691+
private Dictionary<string, string> GetOrBuildTypedVariableCache(Ast scope)
692+
{
693+
if (scope == null)
694+
{
695+
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
696+
}
697+
698+
// Check if we already have cached results for this scope
699+
if (_typedVariableCache.TryGetValue(scope, out var cachedResults))
700+
{
701+
return cachedResults;
702+
}
703+
704+
// Build the cache for this scope
705+
var typedVariables = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
706+
707+
// Find all assignment statements in this scope
708+
var assignments = scope.FindAll(testAst => testAst is AssignmentStatementAst, true);
709+
710+
foreach (AssignmentStatementAst assignment in assignments)
711+
{
712+
// Check if the left side is a convert expression with a variable
713+
if (assignment.Left is ConvertExpressionAst convertExpr &&
714+
convertExpr.Child is VariableExpressionAst assignedVar)
715+
{
716+
var varName = assignedVar.VariablePath.UserPath;
717+
var typeName = convertExpr.Type.TypeName.FullName;
718+
719+
// Store in cache (first assignment wins)
720+
if (!typedVariables.ContainsKey(varName))
721+
{
722+
typedVariables[varName] = typeName;
723+
}
724+
}
725+
}
726+
727+
// Cache the results
728+
_typedVariableCache[scope] = typedVariables;
729+
return typedVariables;
730+
}
731+
732+
/// <summary>
733+
/// Looks for a typed assignment to a variable using cached results.
679734
/// </summary>
680735
private string FindTypedAssignment(VariableExpressionAst varExpr)
681736
{
@@ -700,19 +755,12 @@ private string FindTypedAssignment(VariableExpressionAst varExpr)
700755
return null;
701756
}
702757

703-
// Find all assignment statements in this scope
704-
var assignments = searchScope.FindAll(testAst =>
705-
testAst is AssignmentStatementAst, true);
758+
// Use cached results instead of re-scanning the entire scope
759+
var typedVariables = GetOrBuildTypedVariableCache(searchScope);
706760

707-
foreach (AssignmentStatementAst assignment in assignments)
761+
if (typedVariables.TryGetValue(varName, out string typeName))
708762
{
709-
// Check if the left side is a convert expression with our variable
710-
if (assignment.Left is ConvertExpressionAst convertExpr &&
711-
convertExpr.Child is VariableExpressionAst assignedVar &&
712-
string.Equals(assignedVar.VariablePath.UserPath, varName, StringComparison.OrdinalIgnoreCase))
713-
{
714-
return convertExpr.Type.TypeName.FullName;
715-
}
763+
return typeName;
716764
}
717765

718766
return null;

Tests/Rules/UseConstrainedLanguageMode.tests.ps1

Lines changed: 89 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -339,8 +339,8 @@ enum MyEnum {
339339
}
340340
}
341341

342-
Context "Informational severity" {
343-
It "Should have Information severity" {
342+
Context "Rule severity" {
343+
It "Should have Warning severity" {
344344
$def = 'Add-Type -AssemblyName System.Windows.Forms'
345345
$violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
346346
$matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
@@ -350,27 +350,27 @@ enum MyEnum {
350350

351351
Context "When type constraints are used" {
352352
It "Should flag disallowed type constraint on parameter" {
353-
$def = 'function Test { param([System.Net.WebClient]$Client) }'
353+
$def = 'function Test { param([System.IO.File]$FileHelper) }'
354354
$violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
355355
$matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
356356
$matchingViolations.Count | Should -BeGreaterThan 0
357-
$matchingViolations[0].Message | Should -BeLike "*System.Net.WebClient*not permitted*"
357+
$matchingViolations[0].Message | Should -BeLike "*System.IO.File*not permitted*"
358358
}
359359

360360
It "Should flag disallowed type constraint on variable declaration" {
361-
$def = '[System.Net.WebClient]$client = $null'
361+
$def = '[System.IO.File]$fileHelper = $null'
362362
$violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
363363
$matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
364364
$matchingViolations.Count | Should -BeGreaterThan 0
365-
$matchingViolations[0].Message | Should -BeLike "*System.Net.WebClient*not permitted*"
365+
$matchingViolations[0].Message | Should -BeLike "*System.IO.File*not permitted*"
366366
}
367367

368368
It "Should flag disallowed type cast on variable assignment" {
369-
$def = '$client = [System.Net.WebClient]$value'
369+
$def = '$fileHelper = [System.IO.File]$value'
370370
$violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
371371
$matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
372372
$matchingViolations.Count | Should -BeGreaterThan 0
373-
$matchingViolations[0].Message | Should -BeLike "*System.Net.WebClient*not permitted*"
373+
$matchingViolations[0].Message | Should -BeLike "*System.IO.File*not permitted*"
374374
}
375375

376376
It "Should NOT flag allowed type constraint" {
@@ -441,8 +441,8 @@ $result = $client.DownloadString("http://example.com")
441441
$matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
442442
# Should flag both the type constraint AND the member access
443443
$matchingViolations.Count | Should -BeGreaterThan 1
444-
# At least one violation should mention DownloadString
445-
($matchingViolations.Message | Where-Object { $_ -like "*DownloadString*" }).Count | Should -BeGreaterThan 0
444+
# At least one violation should mention ReadAllText
445+
($matchingViolations.Message | Where-Object { $_ -like "*ReadAllText*" }).Count | Should -BeGreaterThan 0
446446
}
447447

448448
It "Should NOT flag method invocation on allowed types" {
@@ -458,19 +458,19 @@ function Test {
458458
}
459459

460460
It "Should NOT flag static method calls on disallowed types (already caught by type expression check)" {
461-
$def = '[System.Net.WebClient]::New()'
461+
$def = '[System.IO.File]::Exists("test.txt")'
462462
$violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
463463
$matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
464464
# Should only flag once for the type expression, not for member access
465465
$matchingViolations.Count | Should -Be 1
466-
$matchingViolations[0].Message | Should -BeLike "*System.Net.WebClient*"
466+
$matchingViolations[0].Message | Should -BeLike "*System.IO.File*"
467467
}
468468

469469
It "Should flag chained method calls on disallowed types" {
470470
$def = @'
471471
function Test {
472-
param([System.Net.WebClient]$Client)
473-
$result = $Client.DownloadString("http://example.com").ToUpper()
472+
param([System.IO.FileInfo]$FileHelper)
473+
$result = $FileHelper.OpenText().ReadToEnd()
474474
}
475475
'@
476476
$violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
@@ -483,13 +483,13 @@ function Test {
483483
$def = @'
484484
function Complex-Test {
485485
param(
486-
[System.Net.WebClient]$WebClient,
487-
[System.Net.Sockets.TcpClient]$TcpClient,
486+
[System.IO.File]$FileHelper,
487+
[System.IO.Directory]$DirHelper,
488488
[string]$SafeString
489489
)
490490
491-
$data = $WebClient.DownloadData("http://test.com")
492-
$TcpClient.Connect("localhost", 8080)
491+
$data = $FileHelper.ReadAllBytes("C:\test.bin")
492+
$DirHelper.GetFiles("C:\temp")
493493
$upper = $SafeString.ToUpper()
494494
}
495495
'@
@@ -526,8 +526,8 @@ Add-Type -TypeDefinition "public class Test { }"
526526
It "Should NOT flag disallowed types in signed scripts" {
527527
$scriptPath = Join-Path $tempPath "SignedWithDisallowedType.ps1"
528528
$scriptContent = @'
529-
$client = New-Object System.Net.WebClient
530-
$data = $client.DownloadString("http://example.com")
529+
$fileHelper = New-Object System.IO.FileInfo("C:\test.txt")
530+
$data = $fileHelper.OpenText()
531531
532532
# SIG # Begin signature block
533533
# MIIFFAYJKoZIhvcNAQcCoIIFBTCCBQECAQExCzAJ...
@@ -536,7 +536,7 @@ $data = $client.DownloadString("http://example.com")
536536
Set-Content -Path $scriptPath -Value $scriptContent
537537
$violations = Invoke-ScriptAnalyzer -Path $scriptPath -Settings $settings
538538
$typeViolations = $violations | Where-Object {
539-
$_.RuleName -eq $violationName -and $_.Message -like "*WebClient*type*"
539+
$_.RuleName -eq $violationName -and $_.Message -like "*FileInfo*type*"
540540
}
541541
$typeViolations | Should -BeNullOrEmpty
542542
}
@@ -582,7 +582,7 @@ class MyClass {
582582
$scriptPath = Join-Path $tempPath "SignedWithBadParam.ps1"
583583
$scriptContent = @'
584584
function Test {
585-
param([System.Net.WebClient]$Client)
585+
param([System.IO.File]$FileHelper)
586586
Write-Output "Test"
587587
}
588588
@@ -593,7 +593,7 @@ function Test {
593593
Set-Content -Path $scriptPath -Value $scriptContent
594594
$violations = Invoke-ScriptAnalyzer -Path $scriptPath -Settings $settings
595595
$paramViolations = $violations | Where-Object {
596-
$_.RuleName -eq $violationName -and $_.Message -like "*WebClient*"
596+
$_.RuleName -eq $violationName -and $_.Message -like "*File*"
597597
}
598598
# Parameter type constraints should still be flagged
599599
$paramViolations.Count | Should -BeGreaterThan 0
@@ -643,5 +643,71 @@ function Test {
643643
$scriptModuleViolations.Count | Should -BeGreaterThan 0
644644
}
645645
}
646+
647+
Context "Performance with large scripts" {
648+
It "Should handle scripts with many typed variables and member invocations efficiently" {
649+
# This test verifies the O(N+M) cache optimization
650+
# Without caching, this would be O(N*M) and very slow
651+
652+
# Build a script with many typed variables and member invocations
653+
$scriptBuilder = [System.Text.StringBuilder]::new()
654+
[void]$scriptBuilder.AppendLine('function Test-Performance {')
655+
[void]$scriptBuilder.AppendLine(' param([string]$Path)')
656+
657+
# Add 30 typed variable assignments
658+
for ($i = 1; $i -le 30; $i++) {
659+
[void]$scriptBuilder.AppendLine(" [System.IO.File]`$file$i = `$null")
660+
}
661+
662+
# Add 50 member invocations (testing cache reuse)
663+
for ($i = 1; $i -le 50; $i++) {
664+
$varNum = ($i % 30) + 1
665+
[void]$scriptBuilder.AppendLine(" `$result$i = `$file$varNum.ReadAllText(`$Path)")
666+
}
667+
668+
[void]$scriptBuilder.AppendLine('}')
669+
$def = $scriptBuilder.ToString()
670+
671+
# Measure performance
672+
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
673+
$violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
674+
$stopwatch.Stop()
675+
676+
$matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
677+
678+
# Should detect violations (30 type constraints + 50 member accesses = 80+)
679+
$matchingViolations.Count | Should -BeGreaterThan 50
680+
681+
# Performance check: Should complete quickly (under 500ms)
682+
# With cache: O(N+M) = 30+50 = 80 operations
683+
# Without cache: O(N*M) = 50*30 = 1,500 operations (much slower)
684+
$stopwatch.ElapsedMilliseconds | Should -BeLessThan 500
685+
}
686+
687+
It "Should cache results per scope correctly" {
688+
# Test that cache is scoped properly and doesn't leak between functions
689+
$def = @'
690+
function Function1 {
691+
[System.IO.File]$file1 = $null
692+
$result1 = $file1.ReadAllText("C:\test1.txt")
646693
}
647694
695+
function Function2 {
696+
[System.IO.Directory]$file1 = $null
697+
$result2 = $file1.GetFiles("C:\temp")
698+
}
699+
'@
700+
$violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
701+
$matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
702+
703+
# Should detect violations in both functions
704+
# Each function has: 1 type constraint + 1 member access = 2 violations each
705+
$matchingViolations.Count | Should -BeGreaterOrEqual 4
706+
707+
# Verify both File and Directory are mentioned
708+
$messages = $matchingViolations.Message -join ' '
709+
$messages | Should -BeLike "*File*"
710+
$messages | Should -BeLike "*Directory*"
711+
}
712+
}
713+
}

0 commit comments

Comments
 (0)