Repository navigation
feat(rules): Get-Credential -Credential is a note when the argument can only be a credential #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -56,13 +56,122 @@ function Find-IslInteractiveCall { | |
| } | ||
| } | ||
|
|
||
| $credentialType = '^(System\.Management\.Automation\.)?PSCredential$' | ||
|
|
||
| # How an expression builds a credential, when it can only ever produce one: the constructor, | ||
| # New-Object with the type, a cast, or Import-Clixml, which hands back a credential that | ||
| # Export-Clixml wrote. Nothing for anything else, a member or a call included | ||
| function Get-CredentialSource { | ||
| param($Expression) | ||
| # Parentheses, a one-element pipeline and the expression statement around a value are wrappers | ||
| $unwrapped = $false | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A flag rather than a switch with continue: inside a switch, continue applies to the switch, not to the enclosing while, so the first draft unwrapped one layer only and a parenthesized Import-Clixml stayed a warning. |
||
| while ($Expression -and -not $unwrapped) { | ||
| $kind = $Expression.GetType().Name | ||
| if ($kind -eq 'ParenExpressionAst') { $Expression = $Expression.Pipeline } | ||
| elseif ($kind -eq 'PipelineAst' -and @($Expression.PipelineElements).Count -eq 1) { | ||
| $Expression = $Expression.PipelineElements[0] | ||
| } | ||
| elseif ($kind -eq 'CommandExpressionAst') { $Expression = $Expression.Expression } | ||
| else { $unwrapped = $true } | ||
| } | ||
| if (-not $Expression) { return } | ||
| switch ($Expression.GetType().Name) { | ||
| 'CommandAst' { | ||
| $commandName = $Expression.GetCommandName() | ||
| if ($commandName -eq 'Import-Clixml') { return 'Import-Clixml' } | ||
| if ($commandName -ne 'New-Object') { return } | ||
| $typeName = $null | ||
| $elements = @($Expression.CommandElements | Select-Object -Skip 1) | ||
| for ($index = 0; $index -lt $elements.Count; $index++) { | ||
| $element = $elements[$index] | ||
| if ($element.GetType().Name -eq 'CommandParameterAst') { | ||
| if (-not 'TypeName'.StartsWith($element.ParameterName, 'OrdinalIgnoreCase')) { continue } | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Prefix match as PowerShell binds parameters: -Type and -TypeN name the same parameter. -ComObject never matches, so New-Object -ComObject is not taken for a credential. |
||
| if ($element.Argument) { $typeName = $element.Argument.Extent.Text } | ||
| elseif ($index + 1 -lt $elements.Count) { $typeName = $elements[$index + 1].Extent.Text } | ||
| break | ||
| } | ||
| $previous = if ($index -gt 0) { $elements[$index - 1] } else { $null } | ||
| $taken = $previous -and $previous.GetType().Name -eq 'CommandParameterAst' -and | ||
| -not $previous.Argument | ||
| if (-not $taken) { $typeName = $element.Extent.Text; break } | ||
| } | ||
| if ("$typeName".Trim('''"') -match $credentialType) { return 'New-Object PSCredential' } | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The type can be written quoted, New-Object 'System.Management.Automation.PSCredential', and Extent.Text keeps the quotes. |
||
| } | ||
| 'InvokeMemberExpressionAst' { | ||
| $onType = $Expression.Expression.GetType().Name -eq 'TypeExpressionAst' -and | ||
| $Expression.Expression.TypeName.FullName -match $credentialType | ||
| if ($onType -and "$($Expression.Member.Value)" -eq 'new') { return '[pscredential]::new()' } | ||
| } | ||
| 'ConvertExpressionAst' { | ||
| if ($Expression.Type.TypeName.FullName -match $credentialType) { return 'a [pscredential] cast' } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| # How a variable comes to hold a credential, when every place the script gives it a value | ||
| # builds one: its assignments, and a parameter typed [pscredential]. Nothing when the script | ||
| # never gives it a value, or any one of them could be something else | ||
| function Get-VariableCredentialSource { | ||
| param($Variable) | ||
| $scopePrefix = '^(script|local|private|global):' | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. $script:c and $c name the same variable in a script body, so the prefix is dropped on both sides. Scopes of nested functions are not modelled: every assignment to the name anywhere in the script counts, which can only keep the warning, never add the note. |
||
| $variableName = $Variable.VariablePath.UserPath -replace $scopePrefix, '' | ||
| $sources = [System.Collections.Generic.List[string]]::new() | ||
| $assignments = Find-IslAstNode -Ast $ast -TypeName AssignmentStatementAst -Where { | ||
| param($node) | ||
| $target = $node.Left | ||
| $wrapped = $target.GetType().Name -in 'ConvertExpressionAst', 'AttributedExpressionAst' | ||
| if ($wrapped) { $target = $target.Child } | ||
| $target.GetType().Name -eq 'VariableExpressionAst' -and | ||
| ($target.VariablePath.UserPath -replace $scopePrefix, '') -eq $variableName | ||
| } | ||
| foreach ($assignment in $assignments) { | ||
| $typed = $assignment.Left.GetType().Name -eq 'ConvertExpressionAst' -and | ||
| $assignment.Left.Type.TypeName.FullName -match $credentialType | ||
| $source = if ($typed) { 'a [pscredential] variable' } | ||
| else { Get-CredentialSource -Expression $assignment.Right } | ||
| if (-not $source) { return } | ||
| $sources.Add($source) | ||
| } | ||
| $parameters = Find-IslAstNode -Ast $ast -TypeName ParameterAst -Where { | ||
| param($node) | ||
| $node.Name.VariablePath.UserPath -eq $variableName | ||
| } | ||
| foreach ($parameter in $parameters) { | ||
| $typed = @($parameter.Attributes | Where-Object { | ||
| $_.GetType().Name -eq 'TypeConstraintAst' -and $_.TypeName.FullName -match $credentialType | ||
| }).Count -gt 0 | ||
| if (-not $typed) { return } | ||
| $sources.Add('a [pscredential] parameter') | ||
| } | ||
| if ($sources.Count) { @($sources | Select-Object -Unique) -join ', ' } | ||
| } | ||
|
|
||
| $alwaysPrompt = 'Read-Host', 'Pause', 'Out-GridView', 'Show-Command', 'Get-Credential' | ||
| foreach ($command in (Find-IslCommand -Ast $ast -Name $alwaysPrompt)) { | ||
| $name = $command.GetCommandName() | ||
| # Get-Credential -Credential returns a credential that is already built and prompts for the | ||
| # password of a user name. A literal is a name; anything else cannot be told apart here | ||
| # password of a user name. A literal is a name; an expression or variable that can only | ||
| # hold a credential never prompts; anything else cannot be told apart here | ||
| $handed = if ($name -eq 'Get-Credential') { Get-CredentialArgument -Command $command } | ||
| $literalTypes = 'StringConstantExpressionAst', 'ExpandableStringExpressionAst' | ||
| $source = if ($handed -and $handed.GetType().Name -eq 'VariableExpressionAst') { | ||
| Get-VariableCredentialSource -Variable $handed | ||
| } | ||
| elseif ($handed) { Get-CredentialSource -Expression $handed } | ||
| if ($source) { | ||
| $findingSplat = @{ | ||
| RuleName = $rule | ||
| Severity = 'Information' | ||
| Context = $Context | ||
| Extent = $command.Extent | ||
| Message = ("Get-Credential -Credential returns $($handed.Extent.Text) as it is: it comes from " + | ||
| "$source, so it is a credential that is already built and nothing prompts; the call " + | ||
| 'can go') | ||
| Evidence = $builtEvidence | ||
| } | ||
| New-IslFinding @findingSplat | ||
| continue | ||
| } | ||
| if ($handed -and $handed.GetType().Name -notin $literalTypes) { | ||
| $findingSplat = @{ | ||
| RuleName = $rule | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -45,7 +45,7 @@ Describe 'Find-IslInteractiveCall' -Tag 'Unit', 'Private', 'Rule' { | |
| @{ Call = 'Get-Credential $built'; Handed = '$built' } | ||
| @{ Call = 'Get-Credential -Cred:$built'; Handed = '$built' } | ||
| @{ Call = 'Get-Credential -ErrorAction Stop -Credential $settings.Account'; Handed = '$settings.Account' } | ||
| @{ Call = 'Get-Credential (Import-Clixml C:\x.xml)'; Handed = '(Import-Clixml C:\x.xml)' } | ||
| @{ Call = 'Get-Credential (Get-Thing)'; Handed = '(Get-Thing)' } | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The parenthesized Import-Clixml case this replaces is now a note, and sits in the set below. A call the rule knows nothing about keeps this case a warning. |
||
| ) { | ||
| # A PSCredential is returned as it is (REM-CRED-BUILT); a user name in the same place prompts | ||
| $path = New-TestScript 'script.ps1' "`$c = $Call" | ||
|
|
@@ -56,6 +56,55 @@ Describe 'Find-IslInteractiveCall' -Tag 'Unit', 'Private', 'Rule' { | |
| $findings[0].Evidence | Should-BeLikeString '*(REM-CRED-BUILT*' | ||
| } | ||
|
|
||
| It 'notes Get-Credential -Credential when what it is handed can only be a credential: <Script>' -ForEach @( | ||
| @{ Script = "`$c = Import-Clixml C:\x.xml`nGet-Credential -Credential `$c"; Source = 'Import-Clixml' } | ||
| @{ Script = "`$c = [pscredential]::new('a', `$s)`nGet-Credential `$c"; Source = '[pscredential]::new()' } | ||
| @{ Script = "`$c = New-Object System.Management.Automation.PSCredential 'a', `$s`nGet-Credential `$c" | ||
| Source = 'New-Object PSCredential' } | ||
| @{ Script = "`$c = New-Object -TypeName PSCredential -ArgumentList 'a', `$s`nGet-Credential -Cred:`$c" | ||
| Source = 'New-Object PSCredential' } | ||
| @{ Script = "[pscredential]`$c = Get-Thing`nGet-Credential `$c"; Source = 'a [pscredential] variable' } | ||
| @{ Script = "param([pscredential]`$Credential)`nGet-Credential `$Credential" | ||
| Source = 'a [pscredential] parameter' } | ||
| @{ Script = "param([System.Management.Automation.PSCredential]`$Credential)`nGet-Credential `$Credential" | ||
| Source = 'a [pscredential] parameter' } | ||
| @{ Script = "`$script:c = (Import-Clixml x)`nGet-Credential `$c"; Source = 'Import-Clixml' } | ||
| @{ Script = "`$c = Import-Clixml x`n`$c = [pscredential]::new('a', `$s)`nGet-Credential `$c" | ||
| Source = 'Import-Clixml, [pscredential]::new()' } | ||
| @{ Script = 'Get-Credential (Import-Clixml C:\x.xml)'; Source = 'Import-Clixml' } | ||
| @{ Script = "Get-Credential ([pscredential]::new('a', `$s))"; Source = '[pscredential]::new()' } | ||
| @{ Script = 'Get-Credential ([pscredential]$x)'; Source = 'a [pscredential] cast' } | ||
| ) { | ||
| # Every value the argument can take is a PSCredential, which Get-Credential returns as it is | ||
| # (REM-CRED-BUILT): the call cannot prompt, so the finding is a note | ||
| $path = New-TestScript 'script.ps1' $Script | ||
| $findings = @(Get-RuleFinding $path IslInteractiveCall) | ||
| $findings.Count | Should-Be 1 | ||
| $findings[0].Severity | Should-Be 'Information' | ||
| # The brackets in a source are text, not a wildcard class | ||
| $expected = "*as it is: it comes from $([WildcardPattern]::Escape($Source)), so*" | ||
| $findings[0].Message | Should-BeLikeString $expected | ||
| $findings[0].Evidence | Should-BeLikeString '*(REM-CRED-BUILT*' | ||
| } | ||
|
|
||
| It 'keeps the warning when a value handed to Get-Credential could be something else: <Script>' -ForEach @( | ||
| @{ Script = "`$c = Import-Clixml x`n`$c = 'admin'`nGet-Credential `$c" } | ||
| @{ Script = "param([string]`$Credential)`nGet-Credential `$Credential" } | ||
| @{ Script = "param(`$Credential)`n`$Credential = Import-Clixml x`nGet-Credential `$Credential" } | ||
| @{ Script = "`$c = Import-Clixml x | Select-Object -First 1`nGet-Credential `$c" } | ||
| @{ Script = "`$c = Get-Thing`nGet-Credential `$c" } | ||
| @{ Script = "`$c = New-Object -ComObject Shell.Application`nGet-Credential `$c" } | ||
| @{ Script = "`$c = [string]`$x`nGet-Credential `$c" } | ||
| @{ Script = 'Get-Credential $never' } | ||
| ) { | ||
| # An untyped parameter, a second assignment, a pipeline or a call: one of its values may be a name | ||
| $path = New-TestScript 'script.ps1' $Script | ||
| $findings = @(Get-RuleFinding $path IslInteractiveCall) | ||
| $findings.Count | Should-Be 1 | ||
| $findings[0].Severity | Should-Be 'Warning' | ||
| $findings[0].Message | Should-BeLikeString '*can ever be a name*' | ||
| } | ||
|
|
||
| It 'warns on Set-ExecutionPolicy and Install-Module without -Force or -Confirm:$false' { | ||
| # A bare -Confirm forces the prompt; only -Confirm:$false switches it off | ||
| $path = New-TestScript 'script.ps1' ("Set-ExecutionPolicy RemoteSigned`nInstall-Module Foo -Force`n" + | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TypeName.FullName is the type as the script wrote it, pscredential, PSCredential or the namespaced name; -match is case-insensitive, so all three pass. The accelerator is not resolved, which keeps the rule off reflection and on the AST alone like the rest of the rules.