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
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,19 @@ release notes.

## [Unreleased]

Nothing yet.
### Fixed

- **`Invoke-IntuneRemediationTest` reported Recurred for a remediation that writes to stderr and exits 0.** On
the device that run is a script error: `RemediationStatus` 3, Graph `remediationState` `scriptError`, the
error text attached, no post-detection. The harness now reports Failed, skips the post-detection and warns
that the exit code was 0. A detection that writes to stderr and exits 0 is still Without issues, as before.
Measured in user context on the lab device with five one-off remediations (round 11, `REM-STDERR-*`).

### Added

- `IslOutputIssue` warns about `Write-Error` and an unguarded cmdlet in a remediation script, the way it did
for Win32 detection scripts, since either makes the agent report a script error instead of running the
post-detection; the unguarded cmdlet carries `-ErrorAction Stop` as its fix.

## [0.28.0] - 2026-10-06

Expand Down
47 changes: 47 additions & 0 deletions Private/Rules/Find-IslOutputIssue.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,53 @@
}
}

if ($type -eq 'Remediation') {
# The remediation script of a pair. Anything on its stderr makes the agent report a script
# error and skip the post-detection, with exit 0 or not; the detection script's stderr
# changes nothing (REM-DETECT-STDERR-EXIT0)
$remediationEvidence = ('A remediation that wrote a cmdlet error to stderr and exited 0 was reported ' +
'as RemediationStatus 3, Graph remediationState scriptError, no post-detection run; the same ' +
'script with the error silenced ran the post-detection and was reported Recurred ' +
'(REM-STDERR-EXIT0, REM-STDERR-SILENT)')
foreach ($command in (Find-IslCommand -Ast $ast -Name 'Write-Error')) {
$findingSplat = @{
RuleName = $rule
Severity = 'Warning'
Context = $Context
Extent = $command.Extent
Message = ('Write-Error puts text on stderr: Intune reports the remediation as a script error ' +
'and skips the post-detection even when the script exits 0. Exit non-zero to fail on ' +
'purpose, or report the problem with Write-Output')
Evidence = $remediationEvidence
}
New-IslFinding @findingSplat
}
$probing = 'Get-Item', 'Get-ItemProperty', 'Get-ItemPropertyValue', 'Get-ChildItem', 'Get-Package',
'Get-Service', 'Get-Process', 'Get-WmiObject', 'Get-CimInstance', 'Get-AppxPackage',
'Set-ItemProperty', 'New-ItemProperty', 'Remove-Item', 'Remove-ItemProperty', 'Copy-Item', 'Move-Item'

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 Win32 list plus the cmdlets a remediation uses to change things, which are the ones that fail on a missing target: the post's own Set-ItemProperty was the case measured.

$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'

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 preference of Stop counts as a guard here, unlike in the Win32 block: a terminating error ends the remediation with exit 1, which is the honest Failed, not a success claim followed by a script error.

}
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, and the script carries on to exit 0: Intune then reports a script error, not ' +
'the Recurred the post-detection would have given. Use -ErrorAction Stop and let the ' +
'failure be one, or check the target first')
Evidence = $remediationEvidence
Fix = @{ Replacement = $unguarded[0].Extent.Text + ' -ErrorAction Stop' }

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.

Stop, where the Win32 rule offers SilentlyContinue: on a remediation the error is a script error whichever way, and Stop at least ends the script before it prints success and exits 0. SilentlyContinue would turn the run into a Recurred that hides the cause.

}
New-IslFinding @findingSplat
}
}

if ($type -eq 'Win32Detection') {
$stderrCommands = @(Find-IslCommand -Ast $ast -Name 'Write-Error')
foreach ($command in $stderrCommands) {
Expand Down
9 changes: 9 additions & 0 deletions Public/Invoke-IntuneRemediationTest.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,17 @@ function Invoke-IntuneRemediationTest {
}
else {
$remediation = Invoke-IslScriptRun -Path $RemediationPath -Phase 'remediate' @scriptRunSplat
# Anything on the remediation's stderr is a script error to the agent, whatever the exit
# code: RemediationStatus 3, Graph scriptError, no post-detection (REM-STDERR-EXIT0)
$remediationStdErr = -not [string]::IsNullOrWhiteSpace($remediation.StdErr)

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.

Whitespace is not an error: a redirection can leave a stray line ending on stderr, and the agent's capture trims. Anything with text in it was a script error on the device.

if ($remediation.TimedOut) { $status = 'TimedOut' }
elseif ($remediation.ExitCode -ne 0) { $status = 'Failed' }
elseif ($remediationStdErr) {
$status = 'Failed'
$warnings.Add('Remediation exited 0 but wrote to stderr: Intune reports the run as a script ' +
'error (Failed, Graph scriptError) with the error text attached, and skips the ' +
'post-detection. Silence the error or exit non-zero on purpose')
}
else {
$post = Invoke-IslScriptRun -Path $DetectionPath -Phase 'detect' @scriptRunSplat
$status = if ($post.TimedOut) { 'TimedOut' }
Expand Down
32 changes: 32 additions & 0 deletions Tests/Unit/Private/Rules/Find-IslOutputIssue.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,38 @@ Describe 'Find-IslOutputIssue' -Tag 'Unit', 'Private', 'Rule' {
}
}

Context 'Remediation script' {
It 'warns on Write-Error, which makes the run a script error even with exit 0' {
$path = New-TestScript 'Remediate-E.ps1' "Write-Error 'could not'`nWrite-Output 'done'`nexit 0"
$findings = @(Get-RuleFinding $path IslOutputIssue | Where-Object Message -like '*Write-Error*')
$findings.Count | Should-Be 1
$findings[0].Severity | Should-Be 'Warning'
$findings[0].Message | Should-BeLikeString '*script error*skips the post-detection*'
$findings[0].Evidence | Should-BeLikeString '*REM-STDERR-EXIT0*'
}

It 'warns on an unguarded cmdlet and offers -ErrorAction Stop, as the error is a script error anyway' {
$body = "Set-ItemProperty -Path HKCU:\Software\X -Name V -Value 0`nWrite-Output 'done'`nexit 0"
$path = New-TestScript 'Remediate-U.ps1' $body
$findings = @(Get-RuleFinding $path IslOutputIssue | Where-Object Message -like '*Set-ItemProperty*')
$findings.Count | Should-Be 1
$findings[0].Severity | Should-Be 'Warning'
$findings[0].Fix.Replacement |
Should-Be 'Set-ItemProperty -Path HKCU:\Software\X -Name V -Value 0 -ErrorAction Stop'
}

It 'is silent for a guarded cmdlet, a Stop or SilentlyContinue preference, and in a detection' {
$guarded = New-TestScript 'Remediate-G.ps1' "Set-ItemProperty HKCU:\X V 0 -ErrorAction Stop`nexit 0"
@(Get-RuleFinding $guarded IslOutputIssue | Where-Object Severity -eq 'Warning').Count | Should-Be 0
$body = "`$ErrorActionPreference = 'Stop'`nSet-ItemProperty HKCU:\X V 0`nexit 0"
$preference = New-TestScript 'Remediate-P.ps1' $body
@(Get-RuleFinding $preference IslOutputIssue | Where-Object Severity -eq 'Warning').Count | Should-Be 0
# A detection's stderr changes nothing on the device (REM-DETECT-STDERR-EXIT0)
$detect = New-TestScript 'Detect-U.ps1' "Get-Item C:\x`nWrite-Output 'ok'`nexit 0"
@(Get-RuleFinding $detect IslOutputIssue | Where-Object Severity -eq 'Warning').Count | Should-Be 0
}
}

Context 'Win32 detection' {
It 'errors on exit 0 with no stdout' {
$path = New-TestScript 'app.ps1' ("# IntuneScriptLab: ScriptType=Win32Detection`n" +
Expand Down
25 changes: 25 additions & 0 deletions Tests/Unit/Public/Invoke-IntuneRemediationTest.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,31 @@ Describe 'Invoke-IntuneRemediationTest' -Tag 'Unit', 'Public' {
$result.PostDetection.ExitCode | Should-Be 1
}

It 'reports Failed, skips the post-detection and warns when the remediation exits 0 but wrote to stderr' {
# On the device a cmdlet error on the remediation's stderr is a script error whatever the
# exit code: RemediationStatus 3, Graph scriptError, no post-detection (REM-STDERR-EXIT0)
Mock Invoke-IslScriptRun -ModuleName IntuneScriptLab -ParameterFilter { $Phase -eq 'remediate' } {
$null = New-Item -ItemType File -Path (Join-Path (Split-Path $Path -Parent) 'fixed.marker') -Force
[pscustomobject]@{ ExitCode = 0; TimedOut = $false; StdOut = 'turned off'
StdErr = "Set-ItemProperty : Cannot find path 'HKCU:\x' because it does not exist." }
}
$result = Invoke-IntuneRemediationTest -DetectionPath $script:Detect -RemediationPath $script:Remediate
$result.Status | Should-Be 'Failed'
$result.PostDetection | Should-BeNull
$result.Remediation.ExitCode | Should-Be 0
$result.RemediationOutput | Should-Be 'turned off'
$result.Warnings -join ' ' | Should-BeLikeString '*exited 0 but wrote to stderr*script error*'
}

It 'does not take whitespace on the remediation stderr for an error' {
Mock Invoke-IslScriptRun -ModuleName IntuneScriptLab -ParameterFilter { $Phase -eq 'remediate' } {
[pscustomobject]@{ ExitCode = 0; TimedOut = $false; StdOut = 'did nothing'; StdErr = "`r`n" }
}
$result = Invoke-IntuneRemediationTest -DetectionPath $script:Detect -RemediationPath $script:Remediate
$result.Status | Should-Be 'Recurred'
$result.Warnings | Should-BeCollection @()
}

It 'reports Failed and skips the post-detection when the remediation exits non-zero' {
Mock Invoke-IslScriptRun -ModuleName IntuneScriptLab -ParameterFilter { $Phase -eq 'remediate' } {
[pscustomobject]@{ ExitCode = 1; TimedOut = $false; StdOut = ''; StdErr = 'nope' }
Expand Down
63 changes: 63 additions & 0 deletions Validation/Experiments.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,69 @@
# (hourly, the default when omitted).
@{
Remediations = @(
# --- Round 11: a remediation that writes to stderr, in user context on the ESP device
# (ISL-ESP-Devices, -GroupName; the signed-in user must hold an Intune licence, a local
# account or an unlicensed Entra user gets no user-context policy). Run once each, the
# date in the past so the agent runs them at its next policy fetch
@{
Name = 'REM-STDERR-EXIT0'
Question = 'Remediation writes a cmdlet error to stderr and exits 0: Recurred, or script error'
RunAs32Bit = $true
RunAsAccount = 'user'
Schedule = @{ Type = 'RunOnce'; DelayMinutes = -10080 }

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 week in the past on purpose: the agent runs a run-once schedule that has passed at its next policy fetch, whatever the device clock says (VM 125 sits hours behind until an Entra user signs in). A time ahead waited for a clock that never arrived.

Detection = @'
Write-ProbeRecord REM-STDERR-EXIT0 detection
$key = 'HKCU:\Software\Microsoft\Siuf\Rules'
$value = (Get-ItemProperty -Path $key -ErrorAction SilentlyContinue).NumberOfSIUFInPeriod
if ($value -eq 0) { Write-Output 'Feedback requests are off'; exit 0 }
Write-Output "Feedback requests are on (value: $value)"
exit 1
'@
Remediation = @'
Write-ProbeRecord REM-STDERR-EXIT0 remediation
Set-ItemProperty -Path 'HKCU:\Software\Microsoft\Siuf\Rules' -Name NumberOfSIUFInPeriod -Value 0
Write-Output 'Feedback requests turned off'
exit 0
'@
}
@{
Name = 'REM-STDERR-SILENT'
Question = 'The same remediation with the error silenced: exit 0, no stderr, key still missing'
RunAs32Bit = $true
RunAsAccount = 'user'
Schedule = @{ Type = 'RunOnce'; DelayMinutes = -10080 }
Detection = @'
Write-ProbeRecord REM-STDERR-SILENT detection
$key = 'HKCU:\Software\Microsoft\Siuf\Rules'
$value = (Get-ItemProperty -Path $key -ErrorAction SilentlyContinue).NumberOfSIUFInPeriod
if ($value -eq 0) { Write-Output 'Feedback requests are off'; exit 0 }
Write-Output "Feedback requests are on (value: $value)"
exit 1
'@
Remediation = @'
Write-ProbeRecord REM-STDERR-SILENT remediation
$key = 'HKCU:\Software\Microsoft\Siuf\Rules'
Set-ItemProperty -Path $key -Name NumberOfSIUFInPeriod -Value 0 -ErrorAction SilentlyContinue
Write-Output 'Feedback requests turned off'
exit 0
'@
}
@{
Name = 'REM-DETECT-STDERR-EXIT0'
Question = 'Detection writes a cmdlet error to stderr and exits 0: without issues, or detect error'
RunAs32Bit = $true
RunAsAccount = 'user'
Schedule = @{ Type = 'RunOnce'; DelayMinutes = -10080 }
Detection = @'
Write-ProbeRecord REM-DETECT-STDERR-EXIT0 detection
Get-Item -Path 'C:\does\not\exist'
Write-Output 'Feedback requests are off'
exit 0
'@
Remediation = @'
Write-ProbeRecord REM-DETECT-STDERR-EXIT0 remediation; Write-Output 'nothing to do'; exit 0
'@
}
# --- Round 8: the Enrollment Status Page. Deployed to ISL-ESP-Devices (ESP-DEV-*) and
# ISL-ESP-Users (ESP-USR-*) with -GroupName; every script records the ESP's state at run time
@{
Expand Down
37 changes: 35 additions & 2 deletions Validation/Findings.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,8 @@ IME log evidence (registered device): `IsDeviceWPJ()` throws from `NetGetAadJoin
| Script decoding | UTF-8; no BOM when signature check is on (REM) | With BOM: literal `Grüße — ✓` correct. **Without BOM: decoded as ANSI** → `Grüße â€" ✓` | ❌ trap |
| Non-ASCII in captured output | Not documented | Output goes through the OEM console code page (437): even with a BOM, `Grüße — ✓` is reported as `Grüße - √` (best-fit, lossy) | ⚠️ |
| Execution order | Not documented | Sequential, ~15 s per detection/remediation pair; 17 policies took ~6 minutes | ⚠️ |
| Status mapping (device registry `RemediationStatus`) | Portal: Without issues / Fixed / Recurred / Failed | `4` = without issues (detect exit 0) · `1` = fixed (remediate, then detect exit 0) · `2` = recurred (post-detect still non-zero, including when remediation exited 0) · `3` = remediation failed (remediation exit non-zero; post-detect skipped) | ⚠️ |
| Status mapping (Graph `deviceRunStates`) | `detectionState` / `remediationState` enums (GRAPH-HS) | detect exit 0 → `detectionState=success`, `remediationState=skipped` · fixed → `fail` / `success` · post-detect still failing (exit 2, -1, throw, parse error, or remediation exited 0 but didn't fix) → `fail` / **`remediationFailed`** · remediation script exit non-zero → `fail` / **`scriptError`**. So `remediationFailed` means "recurred", not "the remediation script failed" | ⚠️ |
| Status mapping (device registry `RemediationStatus`) | Portal: Without issues / Fixed / Recurred / Failed | `4` = without issues (detect exit 0) · `1` = fixed (remediate, then detect exit 0) · `2` = recurred (post-detect still non-zero, including when remediation exited 0) · `3` = remediation failed (remediation exit non-zero, **or exit 0 with anything on stderr**; post-detect skipped either way, round 11) | ⚠️ |
| Status mapping (Graph `deviceRunStates`) | `detectionState` / `remediationState` enums (GRAPH-HS) | detect exit 0 → `detectionState=success`, `remediationState=skipped` · fixed → `fail` / `success` · post-detect still failing (exit 2, -1, throw, parse error, or remediation exited 0 but didn't fix) → `fail` / **`remediationFailed`** · remediation script exit non-zero, or exit 0 with anything on stderr (round 11) → `fail` / **`scriptError`**. So `remediationFailed` means "recurred", not "the remediation script failed" | ⚠️ |
| Reporting latency to Graph | Recurring scripts report on change only (REM) | `lastStateUpdateDateTime` = 14:19:17, i.e. ~40 s after the last of the 17 policies finished (reported as one batch). The run states were still empty when queried at 14:23 and populated by 15:23 | ⚠️ |
| Result cache | Not documented | `HKLM\SOFTWARE\Microsoft\IntuneManagementExtension\SideCarPolicies\Scripts\Reports\<userId>\<policyId>_<version>\Result` (JSON with pre/post output, error and exit codes). Platform scripts: `...\IntuneManagementExtension\Policies\<userId>\<policyId>` (`Result`, `ErrorCode`, `DownloadCount`) | ⚠️ |
| First run after new assignment | Not documented; policy retrieval on IME start / sign-in / 8h (REM) | Policies received ~8 min after assignment (13:50). After each fetch the HS scheduler queues a run **5 minutes later** (`Job is queued and will be scheduled to run at ...`). Restarting IME before then discards the queued run | ⚠️ |
Expand Down Expand Up @@ -549,6 +549,39 @@ 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.

## A remediation that writes to stderr

Round 11, 2026-10-07, VM 126 (Windows 11 Enterprise LTSC 24H2, Entra joined through Autopilot,
agent 1.105.152.0), user context, the licensed ESP user signed in at the console. Five one-off
remediations for a per-user registry setting that is absent until written
(`HKCU:\Software\Microsoft\Siuf\Rules`, `NumberOfSIUFInPeriod`), deployed outside the kit with the
same scripts the `REM-STDERR-*` experiments carry; the device registry result, the agent logs and
the Graph run states were read for each.

| Remediation script | Exit codes (detect / remediate / post) | Device `RemediationStatus` | Graph `detectionState` / `remediationState` | Notes |
|---|---|---|---|---|
| `Set-ItemProperty` on the missing key, no `-ErrorAction`, `exit 0` | 1 / 0 / none | **3** | `fail` / **`scriptError`** | AgentExecutor logged `Powershell exit code is 0`; the cmdlet error is in `RemediationScriptErrorDetails`; `RemediationScriptOutputDetails` still says "turned off". No post-detection (REM-STDERR-EXIT0) |
| Same with `-ErrorAction Stop` (`exit 1`) | 1 / 1 / none | 3 | `fail` / `scriptError` | The same report, with exit 1 |
| Same with `-ErrorAction SilentlyContinue` (`exit 0`, nothing on stderr) | 1 / 0 / 1 | **2** | `fail` / `remediationFailed` | The post-detection ran and found the key still missing: Recurred (REM-STDERR-SILENT) |
| Detection writes `Get-Item` of a missing path to stderr, `exit 0` | 0 / none / none | 4 | `success` / `skipped` | Without issues; the error text in `PreRemediationDetectScriptError` (REM-DETECT-STDERR-EXIT0) |
| Key created first (`New-Item`), then set | 1 / 0 / 0 | 1 | `fail` / `success` | Fixed |

**For the tool:** `Invoke-IntuneRemediationTest` reported the first row as Recurred up to 0.28.0,
on the round-1 rule that a remediation exit of 0 runs the post-detection. It reports Failed, skips
the post-detection and warns. `IslOutputIssue` warns about `Write-Error` and an unguarded cmdlet
in a remediation script, as it did for Win32 detection, with `-ErrorAction Stop` as the fix.

Two things about getting user-context policies to run at all, learnt on the way:

- A local account at the console is not a user to the agent: with `isl-user` signed in on VM 125
the runner logged `needs user context, but no user logged on now, skip it`. With the Entra test
user signed in instead, the runner processed the session and got `0 script policies` for it; that
user has no Intune licence. Only the licensed ESP user on VM 126 received the policies.
- `Restart-Service IntuneManagementExtension` reports the service running and restarts nothing:
the stop fails, the process keeps its start time, and the next runner cycle is an hour away.
`sc stop`, `Stop-Process` on the service's process and `Start-Service` fetched the new
assignments within ten minutes.

## Win32 custom detection scripts

Round 2: ten Win32 apps sharing one `.intunewin` package (an install script that only writes a probe
Expand Down
Loading
Loading