Private/Rules/Find-IslExitCodeIssue.ps1

function Find-IslExitCodeIssue {
    <#
    .SYNOPSIS
        Flags exit-code mistakes in detection and remediation scripts.
 
    .DESCRIPTION
        Observed rules for remediations: the remediation script runs on *any* non-zero detection
        exit code (2 and -1 both triggered it), not only on 1 as documented. A `return` at script
        scope ends the script with exit 0, so `return $x; exit 1`, the pattern in Microsoft's own
        sample scripts, never triggers a remediation. An unhandled `throw` exits 1, which does
        trigger it, and then the post-detection throws again and the status becomes Recurred.
        Falling off the end without `exit` is exit 0: "without issues".
 
    .PARAMETER Context
        The IntuneScriptLab.ScriptContext from Get-IslScriptContext: AST, tokens, bytes and the
        effective ScriptType, Context and Architecture.
 
    .EXAMPLE
        Find-IslExitCodeIssue -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 = 'IslExitCodeIssue'
    $type = $Context.ScriptType

    # The edit that says what a script-scope return already does: exit 0, after any value it returned
    function Get-ReturnReplacement {
        param($Return)
        if ($Return.Pipeline) { "$($Return.Pipeline.Extent.Text); exit 0" } else { 'exit 0' }
    }
    if ($type -notin 'Detection', 'Remediation', 'Win32Detection') { return }
    $ast = $Context.Ast

    $exits = @(Find-IslAstNode -Ast $ast -TypeName ExitStatementAst)
    $topLevelReturns = @(Find-IslAstNode -Ast $ast -TypeName ReturnStatementAst -Where {
            param($node) -not (Test-IslInsideFunction -Node $node)
        })
    $unhandledThrows = @(Find-IslAstNode -Ast $ast -TypeName ThrowStatementAst -Where {
            param($node)
            -not (Test-IslInsideFunction -Node $node) -and -not (Test-IslInsideTryWithCatch -Node $node)
        })

    if ($type -eq 'Detection') {
        if ($exits.Count -eq 0) {
            $findingSplat = @{
                RuleName = $rule
                Severity = 'Warning'
                Context  = $Context
                Extent   = $ast.Extent
                Message  = ('No exit statement: the script always ends with exit 0 ("without issues") and the ' +
                    'remediation never runs')
                Evidence = ('A detection with no exit reported FirstDetectExitCode=0, status "without issues" ' +
                    '(REM-EXIT-NONE)')
            }
            New-IslFinding @findingSplat
        }
        foreach ($return in $topLevelReturns) {
            $severity = if ($exits.Count -gt 0) { 'Error' } else { 'Warning' }
            $findingSplat = @{
                RuleName = $rule
                Severity = $severity
                Context  = $Context
                Extent   = $return.Extent
                Message  = ('return at script scope ends the script with exit 0; any exit 1 after it never ' +
                    'runs, so the remediation never triggers. Use exit 1 directly')
                Evidence = ('"return 1; exit 1" ran with exit code 0 and the remediation was skipped; ' +
                    'Microsoft''s sample detection scripts use this pattern (REM-RETURN-EXIT)')
                Fix      = @{ Replacement = Get-ReturnReplacement -Return $return }
            }
            New-IslFinding @findingSplat
        }
        foreach ($exit in $exits) {
            $value = $exit.Pipeline
            if (-not $value) { continue }
            $constant = $value.Extent.Text.Trim()
            if ($constant -match '^-?\d+$') {
                if ([int]$constant -notin 0, 1) {
                    $findingSplat = @{
                        RuleName = $rule
                        Severity = 'Warning'
                        Context  = $Context
                        Extent   = $exit.Extent
                        Message  = ("exit ${constant}: Intune treats every non-zero exit as 'issue found' and " +
                            'runs the remediation, then reports Recurred when the post-detection returns it ' +
                            "again. Use exit 1 for 'issue found'")
                        Evidence = ('exit 2 and exit -1 both triggered the remediation and ended as Recurred ' +
                            '(REM-EXIT-2, REM-EXIT-NEG1); docs say only exit 1 does')
                    }
                    New-IslFinding @findingSplat
                }
            }
            else {
                $findingSplat = @{
                    RuleName = $rule
                    Severity = 'Information'
                    Context  = $Context
                    Extent   = $exit.Extent
                    Message  = "exit with a computed value ($constant): make sure it is only ever 0 or 1"
                    Evidence = 'Any non-zero exit runs the remediation (REM-EXIT-2, REM-EXIT-NEG1)'
                }
                New-IslFinding @findingSplat
            }
        }
        foreach ($throw in $unhandledThrows) {
            $findingSplat = @{
                RuleName = $rule
                Severity = 'Warning'
                Context  = $Context
                Extent   = $throw.Extent
                Message  = ('Unhandled throw exits 1, so the remediation runs; the post-detection then throws ' +
                    'again and the status becomes Recurred. Catch it and decide on exit 0 or 1 explicitly')
                Evidence = ('A detection that threw exited 1, ran the remediation and reported Recurred ' +
                    '(REM-EXIT-THROW)')
            }
            New-IslFinding @findingSplat
        }
    }

    if ($type -eq 'Remediation') {
        foreach ($throw in $unhandledThrows) {
            $findingSplat = @{
                RuleName = $rule
                Severity = 'Warning'
                Context  = $Context
                Extent   = $throw.Extent
                Message  = ('Unhandled throw makes the remediation exit 1: Intune reports it as failed and ' +
                    'skips the post-detection')
                Evidence = ('A remediation exiting non-zero reported RemediationStatus=3 (failed) with no ' +
                    'post-detection run (REM-REMFAIL)')
            }
            New-IslFinding @findingSplat
        }
        foreach ($return in $topLevelReturns) {
            $findingSplat = @{
                RuleName = $rule
                Severity = 'Information'
                Context  = $Context
                Extent   = $return.Extent
                Message  = ('return at script scope ends the remediation with exit 0 (success) regardless of ' +
                    'what was actually done')
                Evidence = 'Script-scope return produced exit code 0 (REM-RETURN-EXIT)'
                Fix      = @{ Replacement = Get-ReturnReplacement -Return $return }
            }
            New-IslFinding @findingSplat
        }
    }

    if ($type -eq 'Win32Detection') {
        foreach ($throw in $unhandledThrows) {
            $findingSplat = @{
                RuleName = $rule
                Severity = 'Warning'
                Context  = $Context
                Extent   = $throw.Extent
                Message  = 'Unhandled throw exits 1 and writes to stderr: the app is reported as not detected'
                Evidence = 'Detection script that threw: exit 1, applicationDetected: False (W32-DET-THROW)'
            }
            New-IslFinding @findingSplat
        }
        if ($exits.Count -eq 0) {
            $findingSplat = @{
                RuleName = $rule
                Severity = 'Information'
                Context  = $Context
                Extent   = $ast.Extent
                Message  = ('No exit statement: the script ends with exit 0, so detection depends entirely on ' +
                    'whether anything was written to stdout')
                Evidence = 'Exit 0 without stdout is "not detected" (W32-DET-NOOUT)'
            }
            New-IslFinding @findingSplat
        }
    }
}