Private/Rules/Find-IslOutputIssue.ps1
|
function Find-IslOutputIssue { <# .SYNOPSIS Flags output that Intune will drop, truncate or misread. .DESCRIPTION Remediations report only the *last* line of the console output, capped at its last 2,048 characters. That console output includes the host streams: a trailing Write-Host, Write-Warning ("WARNING: ...") or Write-Verbose -Verbose ("VERBOSE: ...") becomes the reported line and displaces the summary written before it. Win32 detection is stricter: the app counts as installed only when the script exits 0 *and* wrote something to stdout, and anything on stderr (Write-Error, a cmdlet's own error record, a throw) makes it "not detected" even then. Write-Host does count as stdout there; Write-Warning doesn't count as stderr. Platform scripts report everything, so no rule applies. .PARAMETER Context The IntuneScriptLab.ScriptContext from Get-IslScriptContext: AST, tokens, bytes and the effective ScriptType, Context and Architecture. .EXAMPLE Find-IslOutputIssue -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 = 'IslOutputIssue' $type = $Context.ScriptType $ast = $Context.Ast $stdoutCommands = 'Write-Output', 'echo', 'write', 'Write-Host' $notCaptured = 'Write-Host', 'Write-Warning', 'Write-Verbose', 'Write-Information', 'Write-Debug' if ($type -in 'Detection', 'Remediation') { $hostEvidence = ('A trailing Write-Host, Write-Warning or Write-Verbose -Verbose was reported verbatim: ' + ('"host-last", "WARNING: warn-last", "VERBOSE: verbose-last" (REM-OUT-HOSTLAST/WARNLAST/VERBLAST); ' + 'otherwise the last Write-Output line wins (REM-OUT-STREAMS)')) $hostCommands = @(Find-IslCommand -Ast $ast -Name $notCaptured) $outputs = @(Find-IslCommand -Ast $ast -Name 'Write-Output', 'echo', 'write') # Whatever writes last, in source order, is what Intune shows. Write-Verbose without # -Verbose prints nothing, so it can't displace anything. $writers = @($hostCommands | Where-Object { $_.GetCommandName() -ne 'Write-Verbose' -or (Test-IslCommandParameter -Command $_ -ParameterName 'Verbose') }) + $outputs $last = $writers | Sort-Object { $_.Extent.EndOffset } | Select-Object -Last 1 if ($last -and $last.GetCommandName() -in $notCaptured) { $prefix = switch ($last.GetCommandName()) { 'Write-Warning' { ' with a "WARNING: " prefix' } 'Write-Verbose' { ' with a "VERBOSE: " prefix' } default { '' } } $findingSplat = @{ RuleName = $rule Severity = 'Warning' Context = $Context Extent = $last.Extent Message = ("$($last.GetCommandName()) is the last thing written, so its text$prefix is what " + 'Intune reports instead of your summary. Write the summary line last, with Write-Output') Evidence = $hostEvidence } New-IslFinding @findingSplat } elseif ($hostCommands.Count -gt 0) { $names = ($hostCommands | ForEach-Object { $_.GetCommandName() } | Sort-Object -Unique) -join ', ' $findingSplat = @{ RuleName = $rule Severity = 'Information' Context = $Context Extent = $hostCommands[0].Extent Message = ("$names text lands in the same console output Intune reads; it is fine as logging " + 'as long as the summary Write-Output stays last') Evidence = $hostEvidence } New-IslFinding @findingSplat } if ($outputs.Count -gt 1) { $findingSplat = @{ RuleName = $rule Severity = 'Information' Context = $Context Extent = $outputs[0].Extent Message = ("$($outputs.Count) Write-Output calls: Intune reports only the last line " + '(and only its last 2,048 characters). Put the summary last') Evidence = ('Last line only (REM-OUT-STREAMS); a 6,000-character line was reported as its ' + 'final 2,048 characters (REM-OUT-LONG)') } New-IslFinding @findingSplat } } if ($type -eq 'Win32Detection') { $stderrCommands = @(Find-IslCommand -Ast $ast -Name 'Write-Error') foreach ($command in $stderrCommands) { $findingSplat = @{ RuleName = $rule Severity = 'Error' Context = $Context Extent = $command.Extent Message = ('Write-Error puts text on stderr: the app is reported as not detected even with ' + 'exit 0 and stdout') Evidence = ('exit 0 + "installed" + Write-Error: AgentExecutor reported exitCode -1, ' + 'applicationDetected False (W32-DET-STDERR)') } New-IslFinding @findingSplat } $stderrMembers = Find-IslAstNode -Ast $ast -TypeName InvokeMemberExpressionAst -Where { param($node) $node.Extent.Text -match '(?i)\[Console\]::Error\.|\.UI\.WriteErrorLine' } foreach ($node in $stderrMembers) { $findingSplat = @{ RuleName = $rule Severity = 'Error' Context = $Context Extent = $node.Extent Message = ('Writing to the error stream makes the app "not detected" regardless of exit code ' + 'and stdout') Evidence = 'Any stderr output → not detected (W32-DET-STDERR)' } New-IslFinding @findingSplat } $stdout = @(Find-IslCommand -Ast $ast -Name $stdoutCommands) # A bare expression statement at script scope also writes to stdout $bareOutput = @(Find-IslAstNode -Ast $ast -TypeName PipelineAst -Where { param($node) $node.Parent.GetType().Name -in 'NamedBlockAst', 'StatementBlockAst' -and $node.PipelineElements.Count -eq 1 -and $node.PipelineElements[0].GetType().Name -ne 'CommandAst' -and -not (Test-IslInsideFunction -Node $node) }) $exitZero = @(Find-IslAstNode -Ast $ast -TypeName ExitStatementAst -Where { param($node) -not $node.Pipeline -or $node.Pipeline.Extent.Text.Trim() -eq '0' }) if ($stdout.Count -eq 0 -and $bareOutput.Count -eq 0) { $extent = if ($exitZero) { $exitZero[0].Extent } else { $ast.Extent } $findingSplat = @{ RuleName = $rule Severity = 'Error' Context = $Context Extent = $extent Message = ('Nothing is ever written to stdout: exit 0 alone means "not detected". ' + 'Write-Output a line before exit 0 on the installed path') Evidence = 'exit 0 with no stdout → NotDetected → install ran → 0x87D1041C (W32-DET-NOOUT)' } New-IslFinding @findingSplat } # Cmdlet error records also go to stderr $probing = 'Get-Item', 'Get-ItemProperty', 'Get-ItemPropertyValue', 'Get-ChildItem', 'Get-Package', 'Get-Service', 'Get-Process', 'Get-WmiObject', 'Get-CimInstance', 'Get-AppxPackage' $unguarded = @(Find-IslCommand -Ast $ast -Name $probing | Where-Object { -not (Test-IslCommandParameter -Command $_ -ParameterName 'ErrorAction') }) $preferenceSet = Find-IslAstNode -Ast $ast -TypeName AssignmentStatementAst -Where { param($node) $node.Left.Extent.Text -match '(?i)^\$ErrorActionPreference$' -and $node.Right.Extent.Text -match '(?i)SilentlyContinue|Ignore|Stop' } if ($unguarded.Count -gt 0 -and -not $preferenceSet) { $findingSplat = @{ RuleName = $rule Severity = 'Warning' Context = $Context Extent = $unguarded[0].Extent Message = ("$($unguarded[0].GetCommandName()) writes an error record to stderr when its " + "target is missing, which makes the app 'not detected'. Use -ErrorAction " + 'SilentlyContinue (or Test-Path first) on the not-installed path') Evidence = ('A non-terminating cmdlet error appeared in the captured error output ' + '(REM-EXIT-ERRNOEXIT); any stderr fails Win32 detection (W32-DET-STDERR)') } New-IslFinding @findingSplat } } if ($type -eq 'Win32Requirement') { # The rule compares the whole stdout, minus its final line break, with the portal value: # one Write-Output of exactly that value, exit 0, nothing on stderr $wholeEvidence = ('"first" then "ok" and "ok" then "second" both failed string equal ok; "ok " ' + 'failed too (W32-REQ-LASTLINE, W32-REQ-FIRSTLINE, W32-REQ-TRAIL)') foreach ($command in (Find-IslCommand -Ast $ast -Name 'Write-Error')) { $findingSplat = @{ RuleName = $rule Severity = 'Error' Context = $Context Extent = $command.Extent Message = ('Write-Error puts text on stderr: the rule fails even with exit 0 and the right ' + 'output') Evidence = '"ok" plus Write-Error with exit 0: not applicable (W32-REQ-STDERR)' } New-IslFinding @findingSplat } # Write-Host text is part of the compared output (Write-Host "ok" met the rule on the device), # so a logging Write-Host next to the value is a second line, and a second line never matches $outputs = @(Find-IslCommand -Ast $ast -Name $stdoutCommands) $bareOutput = @(Find-IslAstNode -Ast $ast -TypeName PipelineAst -Where { param($node) $node.Parent.GetType().Name -in 'NamedBlockAst', 'StatementBlockAst' -and $node.PipelineElements.Count -eq 1 -and $node.PipelineElements[0].GetType().Name -ne 'CommandAst' -and -not (Test-IslInsideFunction -Node $node) }) $writers = @($outputs) + @($bareOutput) if ($writers.Count -eq 0) { $findingSplat = @{ RuleName = $rule Severity = 'Error' Context = $Context Extent = $ast.Extent Message = ('Nothing is written to stdout, so the rule has nothing to compare and fails. ' + 'Write-Output the value the rule expects') Evidence = 'No output against string equal ok: not applicable (W32-REQ-NOOUT)' } New-IslFinding @findingSplat } elseif ($writers.Count -gt 1) { $findingSplat = @{ RuleName = $rule Severity = 'Warning' Context = $Context Extent = $writers[1].Extent Message = ("$($writers.Count) statements write to the console (Write-Host included): the " + 'rule compares the whole output, so a second line means no match. Write exactly one value') Evidence = ($wholeEvidence + '; Write-Host "ok" alone met the rule (W32-REQ-HOST)') } New-IslFinding @findingSplat } foreach ($command in $outputs) { $literal = $command.CommandElements | Select-Object -Skip 1 | Where-Object { $_.GetType().Name -eq 'StringConstantExpressionAst' -and $_.Value -ne $_.Value.Trim() } | Select-Object -First 1 if ($literal) { $findingSplat = @{ RuleName = $rule Severity = 'Warning' Context = $Context Extent = $literal.Extent Message = ('The output has leading or trailing whitespace, which the rule does not trim, ' + 'so it will not match the portal value') Evidence = $wholeEvidence # The literal's own quotes around its trimmed source text, escapes untouched Fix = @{ Replacement = $literal.Extent.Text.Substring(0, 1) + $literal.Extent.Text.Substring(1, $literal.Extent.Text.Length - 2).Trim() + $literal.Extent.Text.Substring($literal.Extent.Text.Length - 1) } } New-IslFinding @findingSplat } } $exits = @(Find-IslAstNode -Ast $ast -TypeName ExitStatementAst -Where { param($node) $node.Pipeline -and $node.Pipeline.Extent.Text.Trim() -ne '0' }) foreach ($exit in $exits) { $findingSplat = @{ RuleName = $rule Severity = 'Information' Context = $Context Extent = $exit.Extent Message = ('A non-zero exit fails the rule without looking at the output; fine as a ' + 'deliberate "not applicable", surprising if the output was meant to decide') Evidence = '"ok" with exit 1 against string equal ok: not applicable (W32-REQ-EXIT1)' } New-IslFinding @findingSplat } $probing = 'Get-Item', 'Get-ItemProperty', 'Get-ItemPropertyValue', 'Get-ChildItem', 'Get-Package', 'Get-Service', 'Get-Process', 'Get-WmiObject', 'Get-CimInstance', 'Get-AppxPackage' $unguarded = @(Find-IslCommand -Ast $ast -Name $probing | Where-Object { -not (Test-IslCommandParameter -Command $_ -ParameterName 'ErrorAction') }) $preferenceSet = Find-IslAstNode -Ast $ast -TypeName AssignmentStatementAst -Where { param($node) $node.Left.Extent.Text -match '(?i)^\$ErrorActionPreference$' -and $node.Right.Extent.Text -match '(?i)SilentlyContinue|Ignore|Stop' } if ($unguarded.Count -gt 0 -and -not $preferenceSet) { $findingSplat = @{ RuleName = $rule Severity = 'Warning' Context = $Context Extent = $unguarded[0].Extent Message = ("$($unguarded[0].GetCommandName()) writes an error record to stderr when its " + 'target is missing, which fails the rule. Use -ErrorAction SilentlyContinue or ' + 'Test-Path first') Evidence = 'Any stderr fails the rule (W32-REQ-STDERR)' } New-IslFinding @findingSplat } } } |