Private/Rules/Find-IslInteractiveCall.ps1

function Find-IslInteractiveCall {
    <#
    .SYNOPSIS
        Flags anything that waits for a person.
 
    .DESCRIPTION
        The agent launches powershell.exe with -NoProfile -ExecutionPolicy Bypass -File and
        nothing else: no -NonInteractive. A prompt therefore doesn't fail, it waits, until the
        agent kills the script at its timeout (30 minutes for platform scripts, 60 for
        remediations and Win32 detection). Remediations run one at a time, so one hung script
        also delays every other remediation on the device.
 
    .PARAMETER Context
        The IntuneScriptLab.ScriptContext from Get-IslScriptContext: AST, tokens, bytes and the
        effective ScriptType, Context and Architecture.
 
    .EXAMPLE
        Find-IslInteractiveCall -Context (Get-IslScriptContext -Path .\Detect.ps1)
 
        The findings this rule produces for one script, as IntuneScriptLab.Finding objects.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [pscustomobject]$Context
    )

    $rule = 'IslInteractiveCall'
    $timeout = if ($Context.ScriptType -eq 'PlatformScript') { '30 minutes' } else { '60 minutes' }
    $evidence = ("Launched as powershell.exe -NoProfile -executionPolicy bypass -file, without -NonInteractive; " +
        "AgentExecutor timeout $timeout (PS-PROBE-SYS64, REM-PROBE-SYS64, Win32 log)")
    $builtEvidence = ($evidence + '; handed a PSCredential object, Get-Credential -Credential returned it ' +
        'in 12 ms under the agent, without a prompt (REM-CRED-BUILT)')
    $ast = $Context.Ast

    # What Get-Credential is handed as -Credential, by name or as the first positional argument.
    # Nothing when it is called bare or with -Message, -UserName or -Title, which always prompt
    function Get-CredentialArgument {
        param($Command)
        foreach ($prompting in 'Message', 'UserName', 'Title') {
            if (Test-IslCommandParameter -Command $Command -ParameterName $prompting) { return }
        }
        $elements = @($Command.CommandElements | Select-Object -Skip 1)
        for ($index = 0; $index -lt $elements.Count; $index++) {
            $element = $elements[$index]
            if ($element.GetType().Name -eq 'CommandParameterAst') {
                if (-not 'Credential'.StartsWith($element.ParameterName, 'OrdinalIgnoreCase')) { continue }
                if ($element.Argument) { return $element.Argument }
                if ($index + 1 -lt $elements.Count) { return $elements[$index + 1] }
                return
            }
            # A value right after another parameter belongs to that parameter
            $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) { return $element }
        }
    }

    $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; 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
                # The call returns what it was handed, so the argument stands in for it
                Fix      = @{ Replacement = $handed.Extent.Text }
            }
            New-IslFinding @findingSplat
            continue
        }
        if ($handed -and $handed.GetType().Name -notin $literalTypes) {
            $findingSplat = @{
                RuleName = $rule
                Severity = 'Warning'
                Context  = $Context
                Extent   = $command.Extent
                Message  = ('Get-Credential -Credential returns a credential that is already built and ' +
                    "prompts for the password of a user name: if $($handed.Extent.Text) can ever be a " +
                    "name, the script hangs until the $timeout timeout")
                Evidence = $builtEvidence
            }
            New-IslFinding @findingSplat
            continue
        }
        $findingSplat = @{
            RuleName = $rule
            Severity = 'Error'
            Context  = $Context
            Extent   = $command.Extent
            Message  = "$name waits for input that never comes; the script hangs until the $timeout timeout"
            Evidence = $evidence
        }
        New-IslFinding @findingSplat
    }

    $hostPrompts = Find-IslAstNode -Ast $ast -TypeName InvokeMemberExpressionAst -Where {
        param($node)
        $member = "$($node.Member.Value)"
        ($member -in 'ReadLine', 'ReadKey', 'Prompt', 'PromptForChoice', 'PromptForCredential') -and
        ($node.Expression.Extent.Text -match '(?i)console|host|RawUI|\bUI\b')
    }
    foreach ($node in $hostPrompts) {
        $findingSplat = @{
            RuleName = $rule
            Severity = 'Error'
            Context  = $Context
            Extent   = $node.Extent
            Message  = ("$($node.Expression.Extent.Text).$($node.Member.Value)() waits for input; the script " +
                "hangs until the $timeout timeout")
            Evidence = $evidence
        }
        New-IslFinding @findingSplat
    }

    # Cmdlets that confirm unless told not to
    $confirming = @{
        'Set-ExecutionPolicy'     = 'Force'
        'Install-Module'          = 'Force'
        'Install-PackageProvider' = 'Force'
        'Install-Package'         = 'Force'
        'Update-Module'           = 'Force'
        'Uninstall-Module'        = 'Force'
        'Register-PSRepository'   = ''
    }
    foreach ($commandName in $confirming.Keys) {
        foreach ($command in (Find-IslCommand -Ast $ast -Name $commandName)) {
            $forced = $confirming[$commandName] -and
                (Test-IslCommandParameter -Command $command -ParameterName $confirming[$commandName])
            $silenced = $forced -or ($command.Extent.Text -match '(?i)-Confirm:\s*\$false')
            if (-not $silenced) {
                $findingSplat = @{
                    RuleName = $rule
                    Severity = 'Warning'
                    Context  = $Context
                    Extent   = $command.Extent
                    Message  = ("$commandName can prompt for confirmation (or to trust a repository); add " +
                        "-Force / -Confirm:`$false or it hangs until the $timeout timeout")
                    Evidence = $evidence
                }
                # The switch the message asks for, added to the call. Set-ExecutionPolicy gets none: the
                # agent launches with -ExecutionPolicy Bypass, and IslExecutionPolicyCall removes the call
                if ($commandName -ne 'Set-ExecutionPolicy') {
                    $switch = if ($confirming[$commandName]) { " -$($confirming[$commandName])" }
                    else { ' -Confirm:$false' }
                    $findingSplat.Fix = @{ Replacement = $command.Extent.Text + $switch }
                }
                New-IslFinding @findingSplat
            }
        }
    }
}