Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
111 changes: 110 additions & 1 deletion Private/Rules/Find-IslInteractiveCall.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,122 @@ function Find-IslInteractiveCall {
}
}

$credentialType = '^(System\.Management\.Automation\.)?PSCredential$'

Copy link
Copy Markdown
Owner Author

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.


# 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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The 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 }

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The 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' }

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The 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):'

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The 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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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") |
Expand Down
51 changes: 50 additions & 1 deletion Tests/Unit/Private/Rules/Find-IslInteractiveCall.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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)' }

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The 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"
Expand All @@ -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" +
Expand Down
5 changes: 4 additions & 1 deletion Validation/Findings.md
Original file line number Diff line number Diff line change
Expand Up @@ -542,7 +542,10 @@ remediation run, against the script's own exit 0 and "without issues"). The cmdl
`#Requires` cites its own; `Out-File -Encoding utf8NoBOM`, listed in the rule but never matched,
is a finding. `IslInteractiveCall` keeps `Get-Credential` an error where it is sure to
prompt and makes `-Credential` with anything but a literal a warning, since a variable there may
hold a credential that is already built. `IslContextIssue` no longer calls every drive letter from
hold a credential that is already built, and a note when the script gives the argument no value
that is not one: built by `[pscredential]::new()`, `New-Object`, `Import-Clixml` (which hands back
what `Export-Clixml` wrote, a credential in this idiom), a cast or a typed parameter; the call cannot
prompt and can go. `IslContextIssue` no longer calls every drive letter from
`D:` to `Z:` an unmapped drive: the letter cannot say whether it is a local volume, so the finding
is a note that says which case fails.

Expand Down
1 change: 1 addition & 0 deletions docs/Rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ The agent launches powershell.exe with -NoProfile -ExecutionPolicy Bypass -File

| Severity | Message | Evidence |
|---|---|---|
| Information | Get-Credential -Credential returns <value> as it is: it comes from <value>, so it is a credential that is already built and nothing prompts; the call can go | Launched as powershell.exe -NoProfile -executionPolicy bypass -file, without -NonInteractive; AgentExecutor timeout <value> (PS-PROBE-SYS64, REM-PROBE-SYS64, Win32 log); handed a PSCredential object, Get-Credential -Credential returned it in 12 ms under the agent, without a prompt (REM-CRED-BUILT) |
| Warning | Get-Credential -Credential returns a credential that is already built and prompts for the password of a user name: if <value> can ever be a name, the script hangs until the <value> timeout | Launched as powershell.exe -NoProfile -executionPolicy bypass -file, without -NonInteractive; AgentExecutor timeout <value> (PS-PROBE-SYS64, REM-PROBE-SYS64, Win32 log); handed a PSCredential object, Get-Credential -Credential returned it in 12 ms under the agent, without a prompt (REM-CRED-BUILT) |
| Error | <value> waits for input that never comes; the script hangs until the <value> timeout | Launched as powershell.exe -NoProfile -executionPolicy bypass -file, without -NonInteractive; AgentExecutor timeout <value> (PS-PROBE-SYS64, REM-PROBE-SYS64, Win32 log) |
| Error | <value>.<value>() waits for input; the script hangs until the <value> timeout | Launched as powershell.exe -NoProfile -executionPolicy bypass -file, without -NonInteractive; AgentExecutor timeout <value> (PS-PROBE-SYS64, REM-PROBE-SYS64, Win32 log) |
Expand Down
Loading