From 9a2275bb8149ee2488008a20030eaa688d252a38 Mon Sep 17 00:00:00 2001 From: fadwen <110697945+fadwen@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:16:08 -0700 Subject: [PATCH] feat(rules): Get-Credential -Credential is a note when the argument can only be a credential IslInteractiveCall warned on every Get-Credential -Credential call handed anything but a literal, because a variable there may hold a user name (prompts) or a credential that is already built (returned as it is, REM-CRED-BUILT). The rule now follows the argument back through the script: when every value it can take is a PSCredential, the finding is an Information note that says the call can go. A value counts as a credential when it is built by [pscredential]::new() or New-Object with the PSCredential type, cast to the type, read by Import-Clixml (which hands back what Export-Clixml wrote, a credential in this idiom), or held by a variable or parameter typed [pscredential]. A variable qualifies when every assignment to it, and any parameter of that name, is one of those; a second assignment of another kind, an untyped parameter, a pipeline or a call keeps the warning, as does a member expression. The evidence is unchanged: the device measurement is the same, the refinement is static. --- CHANGELOG.md | 5 + Private/Rules/Find-IslInteractiveCall.ps1 | 111 +++++++++++++++++- README.md | 2 +- .../Rules/Find-IslInteractiveCall.Tests.ps1 | 51 +++++++- Validation/Findings.md | 5 +- docs/Rules.md | 1 + 6 files changed, 171 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75b93a3..a837347 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,11 @@ release notes. processes: absent from Windows PowerShell 5.1, present in PowerShell 7, a value refused by the 5.1 `ValidateSet` and taken by 7. The three entries above are what it found on its first run. +- `IslInteractiveCall` tells a `Get-Credential -Credential` call that cannot prompt from one that may: + when every value the argument can hold is a credential, built by `[pscredential]::new()`, `New-Object`, + `Import-Clixml`, a cast or a `[pscredential]` parameter, the finding is a note that says the call can go. + A variable with any other source, a member or a call stays a warning. + ## [0.27.0] - 2026-10-05 Fixes to the runtime harness and to four rules, each rule change backed by a tenth validation diff --git a/Private/Rules/Find-IslInteractiveCall.ps1 b/Private/Rules/Find-IslInteractiveCall.ps1 index 9c8f19c..6303036 100644 --- a/Private/Rules/Find-IslInteractiveCall.ps1 +++ b/Private/Rules/Find-IslInteractiveCall.ps1 @@ -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 + 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 } + 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' } + } + '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):' + $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 diff --git a/README.md b/README.md index 752c2d2..ebf792f 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ the unit tests fail when it is stale). `Get-Help about_IntuneScriptLab` is the c |---|---|---| | `IslPowerShell7Syntax` | Ternary, `&&`/`\|\|`, `??`, `?.`, `-Parallel`, `#Requires -Version 7`, 7-only cmdlets, parameters and parameter values | Scripts run under Windows PowerShell 5.1. A parse error or an unmet `#Requires` makes a detection exit 1 (remediation runs) and a Win32 detection "not detected". A 7-only cmdlet, parameter or value parses: the call fails and the script carries on to its own exit | | `IslEncodingIssue` | Non-ASCII in a UTF-8 file without BOM; UTF-16; non-ASCII in reported output | Files arrive byte-for-byte; without a BOM, 5.1 decodes them as ANSI. Output goes through the OEM code page | -| `IslInteractiveCall` | `Read-Host`, `Pause`, `Get-Credential`, `Out-GridView`, console reads, confirming cmdlets without `-Force` | The agent never passes `-NonInteractive`; prompts hang until the 30/60-minute timeout. `Get-Credential -Credential` handed a credential that is already built returns it, so a variable there is a warning, not an error | +| `IslInteractiveCall` | `Read-Host`, `Pause`, `Get-Credential`, `Out-GridView`, console reads, confirming cmdlets without `-Force` | The agent never passes `-NonInteractive`; prompts hang until the 30/60-minute timeout. `Get-Credential -Credential` handed a credential that is already built returns it, so a variable there is a warning, not an error, and a note when every value it can hold is a credential: built by `[pscredential]::new()`, `New-Object`, `Import-Clixml`, a cast or a typed parameter | | `IslExitCodeIssue` | `return` before `exit 1`, exit codes other than 0/1, unhandled `throw`, missing `exit` | Any non-zero exit runs the remediation; `return` ends the script with exit 0 (Microsoft's own samples do this); `throw` exits 1 | | `IslOutputIssue` | A trailing `Write-Host`/`Warning`/`Verbose` displacing the summary; many `Write-Output` lines; Win32 detection: no stdout, `Write-Error`, unguarded cmdlets; Win32 requirement: a second output line (`Write-Host` included), whitespace in the value, no output, `Write-Error`, non-zero exit | Remediations report the last console line (last 2,048 chars), host streams included: a trailing `Write-Warning` is reported as `WARNING: ...`. Win32 detection: installed = exit 0 **and** stdout; any stderr = not detected; `Write-Host` counts as stdout. Win32 requirement: the whole console output minus its final line break is compared (case-insensitively for strings), so a second line or trailing spaces never match; exit 1 or stderr fails the rule | | `IslContextIssue` | `HKCU:`, `$env:APPDATA`/`USERPROFILE`, per-user folders in SYSTEM scripts, and a note on drive letters other than `C:`, which may be mapped drives; HKLM writes and service control in user scripts; a note that user context needs an Entra-joined device | SYSTEM runs in session 0 with the `systemprofile` profile and `C:\WINDOWS\TEMP`, sees local volumes and not the drives the signed-in user mapped; user context runs as the signed-in user, and only on Entra joined / hybrid-joined devices: on an Entra-registered device the agent downloads the policy and skips it ("not AADJ/HAADJ device") | diff --git a/Tests/Unit/Private/Rules/Find-IslInteractiveCall.Tests.ps1 b/Tests/Unit/Private/Rules/Find-IslInteractiveCall.Tests.ps1 index b180b8c..ed08860 100644 --- a/Tests/Unit/Private/Rules/Find-IslInteractiveCall.Tests.ps1 +++ b/Tests/Unit/Private/Rules/Find-IslInteractiveCall.Tests.ps1 @@ -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)' } ) { # 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: