Private/Find-DeprecationInAst.ps1
|
function Find-DeprecationInAst { <# .SYNOPSIS Core engine: walks a parsed script and returns a finding for every deprecated item. .DESCRIPTION Looks at four things in the syntax tree, matching the four rule kinds: * Command - every command invocation (Get-MsolUser, MSOnline\Get-MsolUser, ...) * Parameter - parameters on a command (Connect-ExchangeOnline -UseRPSSession) * Module - Import-Module / Install-Module / #Requires -Modules / using module * StringLiteral - string literals such as URLs ('https://graph.windows.net/...') Comments are never flagged. Suppression comments (m365ds-ignore...) are honoured when tokens are passed. #> [CmdletBinding()] [OutputType('M365DeprecationScanner.Finding')] param( [Parameter(Mandatory)] [System.Management.Automation.Language.Ast]$Ast, [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Rules, [AllowNull()] [System.Management.Automation.Language.Token[]]$Tokens, [AllowNull()] [string]$FilePath ) # Findings are collected first and returned sorted by position in the file. @(Find-DeprecationInAstUnsorted @PSBoundParameters) | Sort-Object -Property @{ Expression = { $_.Extent.StartOffset } }, RuleId } function Find-DeprecationInAstUnsorted { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'FilePath', Justification = 'Used inside the $emit script block.')] [CmdletBinding()] param( [Parameter(Mandatory)] [System.Management.Automation.Language.Ast]$Ast, [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Rules, [AllowNull()] [System.Management.Automation.Language.Token[]]$Tokens, [AllowNull()] [string]$FilePath ) $commandIndex = New-RuleIndex -Rules @($Rules | Where-Object { $_.Kind -eq 'Command' }) $moduleIndex = New-RuleIndex -Rules @($Rules | Where-Object { $_.Kind -eq 'Module' }) $parameterRules = @($Rules | Where-Object { $_.Kind -eq 'Parameter' }) $stringRules = @($Rules | Where-Object { $_.Kind -eq 'StringLiteral' }) $suppressions = Get-SuppressionMap -Tokens $Tokens $emitted = @{} # Small local helper so every finding goes through suppression + de-duplication. $emit = { param($Rule, $Extent, [string]$MatchText) $key = '{0}|{1}|{2}' -f $Rule.Id, $Extent.StartOffset, $MatchText if ($emitted.ContainsKey($key)) { return } $emitted[$key] = $true if (Test-Suppressed -SuppressionMap $suppressions -Line $Extent.StartLineNumber -RuleId $Rule.Id) { return } New-DeprecationFinding -Rule $Rule -Extent $Extent -MatchText $MatchText -FilePath $FilePath } $moduleCommands = @( 'Import-Module', 'Install-Module', 'Update-Module', 'Save-Module', 'Install-PSResource', 'Update-PSResource', 'Save-PSResource' ) # Walk the syntax tree once and sort the interesting nodes by type. The predicate runs for every # node, so a single pass is much faster than one FindAll per node type on large scripts. $commandAsts = New-Object System.Collections.Generic.List[object] $usingAsts = New-Object System.Collections.Generic.List[object] $stringAsts = New-Object System.Collections.Generic.List[object] $nodes = $Ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.CommandAst] -or $node -is [System.Management.Automation.Language.StringConstantExpressionAst] -or $node -is [System.Management.Automation.Language.ExpandableStringExpressionAst] -or $node -is [System.Management.Automation.Language.UsingStatementAst] }, $true) foreach ($node in $nodes) { if ($node -is [System.Management.Automation.Language.CommandAst]) { $commandAsts.Add($node) } elseif ($node -is [System.Management.Automation.Language.UsingStatementAst]) { $usingAsts.Add($node) } else { $stringAsts.Add($node) } } # --- Commands, parameters and module-loading commands ------------------------------- foreach ($commandAst in $commandAsts) { $commandName = $commandAst.GetCommandName() if ([string]::IsNullOrEmpty($commandName)) { continue } # Strip a module qualifier: MSOnline\Get-MsolUser -> Get-MsolUser $bareName = $commandName.Substring($commandName.LastIndexOf('\') + 1) $nameElement = $commandAst.CommandElements[0] # Fast path: most commands (Get-ChildItem, Where-Object, ...) match no rule at all. Checking that # inline avoids an advanced-function call per command, which dominates the run time. $possible = $commandIndex.Exact.ContainsKey($bareName) if (-not $possible) { foreach ($wildcardRule in $commandIndex.Wildcards) { if ($wildcardRule.Matcher.IsMatch($bareName)) { $possible = $true; break } } } if ($possible) { $rule = Select-MatchingRule -Index $commandIndex -Name $bareName if ($rule) { & $emit $rule $nameElement.Extent $commandName } } if ($parameterRules.Count -gt 0) { foreach ($parameterAst in $commandAst.CommandElements) { if ($parameterAst -isnot [System.Management.Automation.Language.CommandParameterAst]) { continue } # '-Switch:$false' explicitly turns the deprecated switch off - that is fine. $argument = $parameterAst.Argument if ($argument -is [System.Management.Automation.Language.VariableExpressionAst] -and $argument.VariablePath.UserPath -eq 'false') { continue } foreach ($rule in $parameterRules) { if ($rule.CommandMatcher.IsMatch($bareName) -and $rule.Matcher.IsMatch($parameterAst.ParameterName)) { & $emit $rule $parameterAst.Extent ('{0} -{1}' -f $bareName, $parameterAst.ParameterName) } } } } if ($moduleIndex.Count -gt 0 -and $moduleCommands -contains $bareName) { foreach ($moduleArgument in Get-ModuleNameArgument -CommandAst $commandAst) { $rule = Select-MatchingRule -Index $moduleIndex -Name $moduleArgument.Name if ($rule) { & $emit $rule $moduleArgument.Extent ('{0} {1}' -f $bareName, $moduleArgument.Name) } } } } if ($moduleIndex.Count -gt 0) { # --- using module X ---------------------------------------------------------------- foreach ($usingAst in $usingAsts) { if ($usingAst.UsingStatementKind -ne [System.Management.Automation.Language.UsingStatementKind]::Module -or $null -eq $usingAst.Name) { continue } $rule = Select-MatchingRule -Index $moduleIndex -Name $usingAst.Name.Value if ($rule) { & $emit $rule $usingAst.Extent ('using module {0}' -f $usingAst.Name.Value) } } # --- #Requires -Modules X ---------------------------------------------------------- $scriptBlockAst = $Ast -as [System.Management.Automation.Language.ScriptBlockAst] if ($scriptBlockAst -and $scriptBlockAst.ScriptRequirements) { foreach ($requiredModule in $scriptBlockAst.ScriptRequirements.RequiredModules) { $rule = Select-MatchingRule -Index $moduleIndex -Name $requiredModule.Name if ($rule) { $extent = Get-RequiresExtent -Tokens $Tokens -ModuleName $requiredModule.Name -Fallback $scriptBlockAst.Extent & $emit $rule $extent ('#Requires -Modules {0}' -f $requiredModule.Name) } } } } # --- String literals (endpoints, URLs) --------------------------------------------------- if ($stringRules.Count -gt 0) { foreach ($stringAst in $stringAsts) { $value = $stringAst.Value if ([string]::IsNullOrEmpty($value)) { continue } $skipChecked = $false foreach ($rule in $stringRules) { $match = $null try { $match = $rule.Regex.Match($value) } catch [System.Text.RegularExpressions.RegexMatchTimeoutException] { Write-Warning "Rule $($rule.Id): regular expression timed out on a string at line $($stringAst.Extent.StartLineNumber); skipped." } if (-not ($match -and $match.Success)) { continue } # Only strings that match pay for these checks (most strings match nothing). if (-not $skipChecked) { $skipChecked = $true # The command name itself (Get-Foo) is also a StringConstantExpressionAst - skip it. $parent = $stringAst.Parent $isCommandName = $parent -is [System.Management.Automation.Language.CommandAst] -and [object]::ReferenceEquals($parent.CommandElements[0], $stringAst) # Strings nested inside "...$('x')..." are already covered by the outer expandable string. if ($isCommandName -or (Test-InsideExpandableString -Ast $stringAst)) { break } } & $emit $rule $stringAst.Extent $match.Value } } } } function Get-ModuleNameArgument { <# .SYNOPSIS Extracts literal module names (with their extents) from Import-Module / Install-Module calls. .DESCRIPTION Handles '-Name X', '-Name:X', positional 'X' and lists 'X, Y'. Dynamic values such as variables cannot be resolved statically and are ignored. #> [CmdletBinding()] param( [Parameter(Mandatory)] [System.Management.Automation.Language.CommandAst]$CommandAst ) # Parameters of the module cmdlets that take a value; their argument is never the module name. $valueParameters = @( 'RequiredVersion', 'MinimumVersion', 'MaximumVersion', 'Version', 'Scope', 'Repository', 'Prefix', 'Credential', 'Proxy', 'ProxyCredential', 'ArgumentList', 'Function', 'Cmdlet', 'Alias', 'Variable', 'PSSession', 'CimSession', 'CimResourceUri', 'CimNamespace', 'Path', 'LiteralPath', 'DestinationPath', 'InputObject', 'FullyQualifiedName', 'Assembly', 'ModuleInfo', 'TemporaryPath', 'Args', 'ProgressAction', 'ErrorAction', 'WarningAction', 'ErrorVariable', 'WarningVariable', 'OutVariable', 'OutBuffer', 'PipelineVariable', 'InformationAction', 'InformationVariable', 'EA', 'WA', 'EV', 'WV', 'OV', 'OB', 'PV', 'IA', 'IV', 'Proga' ) $elements = $CommandAst.CommandElements $nameExpressions = New-Object System.Collections.Generic.List[object] $sawNameParameter = $false $positionalTaken = $false for ($i = 1; $i -lt $elements.Count; $i++) { $element = $elements[$i] if ($element -is [System.Management.Automation.Language.CommandParameterAst]) { if ($element.ParameterName -in 'Name', 'ModuleName') { $sawNameParameter = $true if ($element.Argument) { $nameExpressions.Add($element.Argument) } elseif ($i + 1 -lt $elements.Count) { $nameExpressions.Add($elements[$i + 1]) $i++ } } elseif (-not $element.Argument -and $valueParameters -contains $element.ParameterName) { $i++ # skip the parameter's value } continue } if (-not $positionalTaken -and -not $sawNameParameter) { $nameExpressions.Add($element) $positionalTaken = $true } } foreach ($expression in $nameExpressions) { foreach ($candidate in Get-LiteralElement -Ast $expression) { if ($candidate -is [System.Management.Automation.Language.StringConstantExpressionAst] -and $candidate.Value) { [pscustomobject]@{ Name = $candidate.Value; Extent = $candidate.Extent } } } } } function Get-RequiresExtent { <# .SYNOPSIS Finds the '#Requires' comment that mentions a module so the finding points at the right line. #> [CmdletBinding()] param( [AllowNull()] [System.Management.Automation.Language.Token[]]$Tokens, [Parameter(Mandatory)] [string]$ModuleName, [Parameter(Mandatory)] [System.Management.Automation.Language.IScriptExtent]$Fallback ) if ($Tokens) { $escapedName = [regex]::Escape($ModuleName) foreach ($token in $Tokens) { if ($token.Kind -eq [System.Management.Automation.Language.TokenKind]::Comment -and $token.Text -match '^#requires\b' -and $token.Text -match "(?<![\w.-])$escapedName(?![\w.-])") { return $token.Extent } } } return $Fallback } function Get-LiteralElement { <# .SYNOPSIS Unwraps 'X, Y', @('X'), ('X') and @('X', 'Y') to the individual element expressions. #> [CmdletBinding()] param( [Parameter(Mandatory)] [System.Management.Automation.Language.Ast]$Ast ) if ($Ast -is [System.Management.Automation.Language.ArrayLiteralAst]) { foreach ($element in $Ast.Elements) { Get-LiteralElement -Ast $element } } elseif ($Ast -is [System.Management.Automation.Language.ArrayExpressionAst]) { foreach ($statement in $Ast.SubExpression.Statements) { Get-LiteralElement -Ast $statement } } elseif ($Ast -is [System.Management.Automation.Language.ParenExpressionAst]) { Get-LiteralElement -Ast $Ast.Pipeline } elseif ($Ast -is [System.Management.Automation.Language.PipelineAst] -and $Ast.PipelineElements.Count -eq 1 -and $Ast.PipelineElements[0] -is [System.Management.Automation.Language.CommandExpressionAst]) { Get-LiteralElement -Ast $Ast.PipelineElements[0].Expression } else { $Ast } } function Test-InsideExpandableString { [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory)] [System.Management.Automation.Language.Ast]$Ast ) for ($node = $Ast.Parent; $null -ne $node; $node = $node.Parent) { if ($node -is [System.Management.Automation.Language.ExpandableStringExpressionAst]) { return $true } } return $false } |