One of the rules in IntuneScriptLab exists because of Microsoft’s own sample scripts.

The remediation samples page on Learn has detection scripts for expired certificates. When they find a match, they do this:

1
2
3
Write-Host "Match"
Return $results.count
exit 1

A comment just above those lines says Intune only remediates on exit code 1, so the script makes sure to exit 1. It doesn’t. A return at script scope ends the script, the returned count becomes output, and the process exits 0. The exit 1 on the next line is unreachable, so the remediation doesn’t run, and the portal reports “Without issues” on the very devices the script was written for.

I didn’t take that on faith from reading PowerShell semantics. Experiment REM-RETURN-EXIT in the validation kit uploaded the pattern as a remediation:

1
2
3
4
Detection = @'
Write-ProbeRecord REM-RETURN-EXIT detection; Write-Output "found 1 issue"; return 1; exit 1
'@
Remediation = 'Write-ProbeRecord REM-RETURN-EXIT remediation; exit 0'

On both test devices the detection reported exit code 0 and the output 1, and the remediation’s probe record didn’t appear. The comment above the sample is wrong in a second way too: the remediation runs on any non-zero exit, and REM-EXIT-2 and REM-EXIT-NEG1 both triggered it with 2 and -1.

The third sample on that page has the same shape in its error path, return $errMsg followed by exit 1 inside the catch. A detection that hits an exception reports “Without issues” with the error message as its output.

This post covers Test-IntuneScript, the command that catches that pattern and the others like it without running anything. It also covers the settings that tell it what a script is, and Repair-IntuneScript for the findings that have a mechanical fix. The overview post covers the agent’s launch line and where the evidence comes from, so I won’t repeat that here.

Running it

Test-IntuneScript takes files, folders or pipeline input and returns finding objects:

1
2
3
Test-IntuneScript -Path .\Detect-LegacyTls.ps1 -ScriptType Detection
Test-IntuneScript -Path .\Detect-App.ps1 -ScriptType Win32Detection | Where-Object Severity -eq Error
Get-ChildItem .\Remediations -Recurse -Filter *.ps1 | Test-IntuneScript -MinimumSeverity Warning

You need nothing but PowerShell to run it. It runs on Windows PowerShell 5.1 or PowerShell 7, on any OS, with no Intune connection, because it never executes the script. It parses it.

Every finding you get back has the same shape, built by one private function so no rule can drift from it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
[pscustomobject]@{
    PSTypeName = 'IntuneScriptLab.Finding'
    RuleName   = $RuleName
    Severity   = $Severity
    Message    = $Message
    ScriptPath = $Context.Path
    Line       = if ($Extent) { $Extent.StartLineNumber } else { 0 }
    Column     = if ($Extent) { $Extent.StartColumnNumber } else { 0 }
    ScriptType = $Context.ScriptType
    Text       = if ($Extent) { $Extent.Text } else { '' }
    Evidence   = $Evidence
    Suppressed = $false
    Fix        = $Fix
}

Text is the offending code itself. Evidence is the observed behavior the rule rests on, ending in the experiment IDs from Validation/Experiments.psd1. Fix is filled in only for the findings Repair-IntuneScript can apply, which I’ll get to at the end.

How a rule sees a script

Before any rule runs, Get-IslScriptContext builds one object per file, and every rule receives it. It parses the file once with the PowerShell parser, keeps the AST, the tokens, the parse errors and the raw bytes, and works out the effective script type, context and architecture:

1
2
3
4
5
$resolved = (Resolve-Path -LiteralPath $Path).ProviderPath
$bytes = [System.IO.File]::ReadAllBytes($resolved)
$tokens = $null
$errors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile($resolved, [ref]$tokens, [ref]$errors)

The bytes are there for the encoding rule, because it has to see the file the way the agent delivers it. The tokens are there because comments aren’t in the AST, and directives and suppressions live in comments.

Each rule is a Find-Isl* function under Private\Rules, and the module runs them in a fixed order:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Rule registry: every Find-* function in Private\Rules is a rule. Test-IntuneScript runs them
# in this order so the output reads from "will it run at all" down to "will it report well".
$script:RuleOrder = @(
    'Find-IslPowerShell7Syntax'
    'Find-IslEncodingIssue'
    'Find-IslInteractiveCall'
    'Find-IslExitCodeIssue'
    'Find-IslOutputIssue'
    ...
)

I like that ordering more than I expected to. A parse error at the top of the list makes everything below it moot until you fix it, and reading top-down mirrors the order you’d work through them anyway.

The AST helpers compare node types by name, as strings. The comment at the top of the file says why: the ternary and pipeline chain node types don’t exist in Windows PowerShell 5.1, so referencing them as types would break the module’s import on the very host it’s describing.

Since 0.28.0 the tree is walked once per script. Before that, each rule walked it on its own, some once per command name they looked for, and that walking was most of an analysis. Get-IslAstIndex now visits every node once, groups the nodes by type name and the commands by name, and caches the result against the AST object itself. Every rule now reads from that index:

1
2
3
4
5
6
7
8
9
$index = Get-IslAstIndex -Ast $Ast
$nodes = foreach ($name in $TypeName) {
    if ($index.ByType.ContainsKey($name)) { $index.ByType[$name] }
}
# Each type's list is in document order; several types are merged back into it
if ($TypeName.Count -gt 1) { $nodes = $nodes | Sort-Object -Property { $_.Extent.StartOffset } }
foreach ($node in $nodes) {
    if (-not $Where -or [bool](& $Where $node)) { $node }
}

Keying the index by type name keeps the 5.1 story intact: on Windows PowerShell a ternary node type is a key that’s never there, where a type reference would fail to resolve. The changelog puts the gain at 91 ms a script against 233 ms for a 95-line detection, and 60 scripts in 5.4 seconds against 14.0, with the findings unchanged. The certificate sample from the top of this post averaged 18 ms on my machine.

The exit code rule

Find-IslExitCodeIssue is where the sample-script finding comes from. It collects three things from the AST: every exit, every return that isn’t inside a function or script block, and every throw that isn’t inside a function or a try with a catch.

1
2
3
4
5
6
7
8
$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)
    })

The script-scope filter keeps it accurate. A return inside a helper function is ordinary code; a return at the top level behaves like exit 0. If your script also has an exit somewhere, the rule reports the return as an Error, because you clearly meant one of those exits to run. With no exit at all it’s a Warning.

What the rule says depends on the script type, because the agent reads the same exit code differently for each:

Script typeFindingWhat the device did
Detectionreturn at script scopeExit 0, remediation skipped (REM-RETURN-EXIT)
Detectionexit with anything other than 0 or 1Any non-zero runs the remediation, then reports Recurred when the post-detection returns it again (REM-EXIT-2, REM-EXIT-NEG1)
DetectionUnhandled throwExit 1, remediation runs, post-detection throws again, Recurred (REM-EXIT-THROW)
DetectionNo exit at allExit 0, “without issues”, remediation never runs (REM-EXIT-NONE)
RemediationUnhandled throwFailed, and the post-detection is skipped (REM-REMFAIL)
Win32 detectionUnhandled throwExit 1 plus stderr, not detected (W32-DET-THROW)

An exit with a computed value, like exit $code, gets an Information note asking you to make sure it’s only ever 0 or 1. The rule can’t evaluate the variable, so it asks.

What Intune keeps from your output

Find-IslOutputIssue is the longest rule in the module, because output means something different for every script type.

For remediations, Intune reports the last line of the console output, and only its last 2,048 characters. The detail I didn’t expect, and got wrong in an early version of the findings table, is that the console output includes the host streams. A trailing Write-Host was reported as its text, a trailing Write-Warning as WARNING: warn-last, a Write-Verbose -Verbose as VERBOSE: verbose-last. If your detection writes its summary with Write-Output and then logs one more line with Write-Host, the portal shows you the log line.

So the rule finds whatever writes last in source order:

1
2
3
4
5
6
7
# 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

Source order is an approximation of run order, and the rule doesn’t pretend otherwise; branches mean the last line in the file may not be the last line printed. It’s still the right default, and when you need the real answer, the runtime harness in the next post runs the script.

Win32 detection scripts are stricter in a different direction. An app counts as installed only when the script exits 0 and wrote something to stdout, and anything on stderr makes it “not detected” even then. Write-Host counts as stdout there; Write-Warning doesn’t count as stderr. That makes a probing cmdlet a hazard. Get-ItemPropertyValue against a missing registry value writes an error record to stderr. On the not-installed path you won’t care; on any path that goes on to report “installed”, you just lost the detection. I ran a small detection script against the rule to see all three findings at once:

1
2
3
4
$version = Get-ItemPropertyValue -Path 'HKLM:\SOFTWARE\Contoso\Agent' -Name Version
if ($version -ge '2.0') { exit 0 }
Write-Error "Contoso agent missing"
exit 1
1
2
3
4
5
6
RuleName       Severity Line Message
--------       -------- ---- -------
IslOutputIssue Error       3 Write-Error puts text on stderr: the app is reported as not detected even with exit 0 and stdout
IslOutputIssue Error       2 Nothing is ever written to stdout: exit 0 alone means "not detected". Write-Output a line before exit 0 on the installed path
IslOutputIssue Warning     1 Get-ItemPropertyValue 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

That script would never detect the app, installed or not. With the app present, line 2 exits 0 with an empty stdout. Intune would run the install, detect again, still find nothing, and report 0x87D1041C, “not detected after installation complete”.

The probing check knows when you’ve covered it. Setting $ErrorActionPreference to SilentlyContinue or Ignore counts. Setting it to Stop doesn’t, and an earlier version of the rule got that wrong: Stop turns the miss into a terminating error, which still lands on stderr.

Win32 requirement scripts get a third set. The agent compares the whole stdout, minus its final line break, with the value in the portal, case-insensitively for strings. A second line fails the match, so do trailing spaces, and Write-Host counts as part of the output. A requirement script with a logging Write-Host next to its Write-Output fails every comparison. The rule reports it as two writers where one is allowed.

Platform scripts report all of their output, untruncated, so the output rule has nothing to say about them.

Encoding on the way in

The agent delivers your script byte for byte. It doesn’t add a BOM or strip one. Windows PowerShell 5.1 reads a file without a BOM in the system ANSI code page, so a UTF-8 file with any non-ASCII character in it is corrupted before the first line runs. The probe header’s test string, a German word, a long dash and a check mark, came back from the BOM-less upload as the familiar Grüße mojibake.

Find-IslEncodingIssue checks the bytes directly:

1
2
3
4
5
$hasUtf8Bom = $bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF
$isUtf16 = $bytes.Length -ge 2 -and
    (($bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) -or ($bytes[0] -eq 0xFE -and $bytes[1] -eq 0xFF))
$hasNonAscii = $false
foreach ($byte in $bytes) { if ($byte -gt 0x7F) { $hasNonAscii = $true; break } }

A file with non-ASCII bytes and no BOM is then decoded with a strict UTF-8 decoder. If that succeeds, it’s UTF-8 without a BOM, a Warning. If it throws, the file is in an ANSI code page. Windows PowerShell 5.1 reads that correctly and most of your other tools won’t expect it, so it’s Information. The repair depends on telling those apart, as you’ll see.

A BOM fixes the input side only. Output goes through the OEM console code page, 437 on a US system, so even with a perfect BOM the check mark came back from Intune as a square-root sign. The rule adds an Information note when a detection or remediation has a non-ASCII string literal, since that text is probably headed for the portal.

I was completely bemused by the square-root sign return. Won’t lie that I spent more time troubleshooting that then was necessary.

PowerShell 7 syntax

If your script uses a ternary, &&, ?? or ?., Windows PowerShell 5.1 can’t parse it. A parse error means the script never starts, the process exits 1, and for a detection script that means the remediation runs. Experiment REM-PS7-SYNTAX showed that for a ternary on both devices, with the parser’s message landing in the error field.

Find-IslPowerShell7Syntax catches it from both directions, depending on which host is running the analysis. Under PowerShell 7 the parser understands a ternary and produces a TernaryExpressionAst, so the rule looks for the node. Under Windows PowerShell 5.1 the same file fails to parse, so the rule reports the parse errors. I ran the same file through both hosts to check:

1
2
3
4
5
# PowerShell 7.6
IslPowerShell7Syntax Error 2 Ternary operator (? :) is PowerShell 7 only; Windows PowerShell 5.1 fails to parse the whole script

# Windows PowerShell 5.1
IslPowerShell7Syntax 2 Parse error: Unexpected token '?' in expression or statement.

The rule also carries lists of cmdlets and parameters that exist only in PowerShell 7: Get-Error, Join-String, Test-Json, ConvertFrom-Json -AsHashtable, Split-Path -LeafBase, Invoke-RestMethod -SkipCertificateCheck and more. ForEach-Object -Parallel belongs with these, since 5.1 parses the call and only fails when the parameter binds.

In 0.28.0 the cmdlets, parameters and values sit in one table, Get-IslCoreOnlyFeature, and a unit test runs every entry against both hosts as child processes: each command has to be missing from 5.1 and present in 7, each parameter likewise, and each value refused by 5.1’s ValidateSet and taken by 7’s. Its first run removed three entries. Switch-Process exists only on Linux and macOS, so it fails on both Windows hosts. Invoke-WebRequest -StatusCodeVariable exists on neither (only Invoke-RestMethod has it). Get-ChildItem -FollowSymlink has been in Windows PowerShell since 5.0.

These parse, so the script starts, and the outcome flips. Round 10 of the validation kit put Test-Json, ConvertFrom-Json -AsHashtable, ForEach-Object -Parallel and Out-File -Encoding utf8NoBOM into SYSTEM detections on the joined device. Each call wrote its error, the next line ran, and the detection reached its own exit 0: “without issues”, the error text in the error field, no remediation. The -Parallel call produced nothing from its pipeline, and the Out-File call wrote no file at all. For a detection that’s the opposite of the parse-error verdict, with a half-run script on top.

0.26.0 had this wrong in two places. Every IslPowerShell7Syntax finding carried the parse error’s evidence, including the runtime ones, and Out-File -Encoding utf8NoBOM sat in the rule’s table without ever matching, because the code that read the table skipped any entry with a space in it. Both turned up while I was writing this series, and 0.27.0 describes each case the way round 10 measured it:

1
2
3
4
5
6
RuleName             Severity Line Message
--------             -------- ---- -------
IslPowerShell7Syntax Error       5 Out-File -Encoding utf8NoBOM is a PowerShell 7 value: under Windows PowerShell 5.1 the call fails with an error, writes nothing,
                                   and the script carries on
IslPowerShell7Syntax Error       6 ConvertFrom-Json -AsHashtable does not exist in Windows PowerShell 5.1: the call fails with an error and the script carries on
                                   without its result

The second finding’s evidence now cites the experiments that showed it:

1
2
Test-Json, ConvertFrom-Json -AsHashtable and ForEach-Object -Parallel each wrote an error under the agent and the detection ran on to its exit 0:
"without issues", the error text in the error field, no remediation (REM-PS7-CMDLET, REM-PS7-PARAM, REM-PS7-PARALLEL)

#Requires -Version 7 lands on the parse-error side, and round 10 measured that too (REM-PS7-REQUIRES). The detection exited 1 before its first line with ScriptRequiresUnmatchedPSVersion on stderr, the remediation ran, the post-detection failed the same way, and the policy reported Recurred. A using module the parser can’t find is skipped here and handed to the module dependency rule, since a missing module isn’t a syntax problem.

Context and bitness

Find-IslContextIssue covers the gap between your session and the agent’s. As SYSTEM, the HKCU: your script reads is the SYSTEM account’s own hive, $env:APPDATA and $env:USERPROFILE point into C:\WINDOWS\system32\config\systemprofile, there’s no console session, and the drives it sees are the device’s local volumes. The rule reports each of those in a SYSTEM script, with the probe record behind it:

1
2
$hkcuPattern = '(?i)^(HKCU:|Registry::HKEY_CURRENT_USER|HKEY_CURRENT_USER\\)'
foreach ($literal in ($literals | Where-Object { $_.Value -match $hkcuPattern })) {

User context gets the opposite checks: writes to HKLM:, Program Files or the Windows folder, and service control. Whoever’s signed in is usually a standard user, and your script runs with their rights. There’s also an Information note on every user-context remediation and platform script, because of the registered-device behavior from the overview. The script can’t fix that one, but it’s the reason a user-context policy reaches only part of a fleet.

Find-IslArchitectureIssue handles WOW64 redirection. The portal defaults remediations and platform scripts to the 32-bit host, where HKLM:\SOFTWARE becomes WOW6432Node, Program Files becomes Program Files (x86) and System32 becomes SysWOW64. The classic result is a remediation that “fixes” a value in WOW6432Node and a 64-bit detection that can’t see the fix. Find-IslArm64Assumption adds the Windows on ARM version of the same mistake: a check for 'AMD64' to mean 64-bit takes the 32-bit branch on an ARM64 device, where the native host reports ARM64.

Shorter rules

The remaining rules are shorter, and most of them come from one experiment each.

RuleWhat it catchesBehind it
IslInteractiveCallRead-Host, Pause, Get-Credential, Out-GridView, console reads, confirming cmdlets without -ForceNo -NonInteractive, so prompts wait for the 30 or 60 minute timeout
IslRebootCommandRestart-Computer, Stop-Computer, shutdown /rA reboot loses the run’s result
IslRelativePath.\path, $PWDThe working directory is system32, the Win32 content folder, or whatever the agent last used
IslLongSleepStart-Sleep near or past the timeoutRemediations run one at a time, about 15 seconds each in round 1
IslSignatureIssueAn unsigned Win32 script under an enforced signature checkThe agent doesn’t run it at all and reports not detected
IslExecutionPolicyCallSet-ExecutionPolicyThe process scope is already Bypass; any other scope changes the device as SYSTEM
IslModuleDependencyModules outside the in-box list, Install-Module inside a script90 modules on a plain Windows 11 device under SYSTEM, and a relative first entry in the module path
IslScriptSizeFiles over 200 KBGraph took a 504 KB remediation and a 660 KB platform script and refused 512 KB and 680 KB

IslInteractiveCall treats Get-Credential with more care than the other prompts. Handed a credential that’s already built, Get-Credential -Credential $built returns it without prompting: 12 ms under the agent in REM-CRED-BUILT. Handed a user name, it prompts for the password and holds the runner until the timeout. 0.26.0 called every form an Error.

As of 0.28.0 the rule follows the argument back to where it came from. Every assignment to the variable and any parameter it’s bound to has to produce a credential: [pscredential]::new(), New-Object with the type, a [pscredential] cast or parameter, or Import-Clixml. When they all do, the call can’t prompt, and the finding is an Information note saying it can go. A variable with any other source stays a Warning, and a prompt that’s certain (bare, with -Message or -UserName, or handed a literal name) stays an Error. I ran all three past it, with $who filled from a made-up Get-StoredLogin:

1
2
3
4
5
6
7
8
RuleName           Severity    Line Message
--------           --------    ---- -------
IslInteractiveCall Information    2 Get-Credential -Credential returns $built as it is: it comes from [pscredential]::new(), so it is a credential that is
                                    already built and nothing prompts; the call can go
IslInteractiveCall Error          3 Get-Credential waits for input that never comes; the script hangs until the 60 minutes timeout
IslInteractiveCall Error          4 Get-Credential waits for input that never comes; the script hangs until the 60 minutes timeout
IslInteractiveCall Warning        9 Get-Credential -Credential returns a credential that is already built and prompts for the password of a user name: if $who
                                    can ever be a name, the script hangs until the 60 minutes timeout

Drive letters went the same direction. 0.26.0 warned that any D:\ through Z:\ path in a SYSTEM script was an unmapped drive. Round 10 gave a SYSTEM detection a second local volume, D:, and a share mapped to X: in the signed-in user’s session (New-IslDriveFixture.ps1 in the kit sets that up). The detection saw C: and D: and no X:. A letter in source can’t say which kind of drive it is, so the finding is now Information and says which case fails: Drive X: exists for SYSTEM only if it is a local volume.

The module dependency rule has my favorite story behind it. In round 7 a SYSTEM detection script ran Find-Module and Install-Module -Force. The device had no NuGet provider, so the process sat on the provider bootstrap prompt that nobody could answer: 12 seconds of CPU in 20 minutes. The agent killed it at exactly 60 minutes. Because remediations run one at a time, the two queued behind it ran an hour late.

The size rule is the one where the docs are most conservative. Over 200 KB is a Warning, since the portal and other tooling may hold to the documented number; over the size Graph refused is an Error, because the upload will fail. Only remediations and platform scripts were measured, and the rule says so when it applies the remediation limits to a Win32 script.

Telling it what a script is

Every rule above branches on script type, context or architecture, so whether you get those right decides which rules run against your script. Get-IslScriptContext resolves them in a fixed order: parameters, then a directive in the script, then the settings file, then inference from the file and folder names, then the portal’s defaults.

Inference matches words at a boundary, so prefix-check.ps1 isn’t a fix and undetectable.ps1 isn’t a detection. The two nearest folder names settle the ambiguous case, a Detect script that could be either kind:

1
2
3
4
5
6
if ($leaf -match '(?i)(^|[^a-z])(win32|apps?|packages?|installers?|intunewin)([^a-z]|$)') {
    $folderHint = 'Win32'
}
elseif ($leaf -match '(?i)(^|[^a-z])(remediations?|healthscripts?|proactive)([^a-z]|$)') {
    $folderHint = 'Remediation'
}

The folder layout in my demos follows from that: Remediations\Proxy\Detect.ps1 is a remediation detection, and Win32\Contoso\Detect-App.ps1 is a Win32 detection. Without a folder hint, a detection named after an app, package or installer is taken as Win32.

When the module had to guess, it tells you so with an IslAssumedContext note. Once you’ve declared the type with a parameter, a directive or the settings file, the note disappears. A directive is one comment line anywhere in the file:

1
# IntuneScriptLab: ScriptType=Win32Detection Context=System Architecture=x64 EnforceSignatureCheck=true

Suppressions and a settings file

Some findings describe something you meant to do. A remediation that waits for a service to settle might sleep for ten minutes. The directive comment takes a Suppress= entry, and where the comment sits decides its reach: in the header before the first statement it covers the file, on its own line the next line of code, at the end of a line that line.

I put a trailing suppression on a sleep in a test script and ran it with -IncludeSuppressed, which shows what was waved through. Trimmed to four of its six rows:

1
2
3
4
5
6
7
8
RuleName             Severity    Line Suppressed Message
--------             --------    ---- ---------- -------
IslPowerShell7Syntax Error          2      False Ternary operator (? :) is PowerShell 7 only; Windows PowerShell 5.1 fails to parse the whole script
IslEncodingIssue     Warning        1      False Non-ASCII characters in a UTF-8 file without a BOM: Windows PowerShell 5.1 decodes it as ANSI and corrupts them. Save as
                                                 UTF-8 with BOM
IslContextIssue      Error          1      False HKCU: under SYSTEM is the SYSTEM account's hive, not the signed-in user's. Load the user's hive via HKU\<SID> or run the
                                                 script in user context
IslLongSleep         Warning        3       True Sleeping 600 s holds the agent: remediations run one at a time and the whole script must finish within 3600 s

Without the switch, suppressed findings are dropped. With it, a code review can still see them.

If you keep a whole repository of scripts, an IntuneScriptLab.settings.psd1 in the scripts’ folder or any folder above it applies to everything below, and the nearest one wins:

1
2
3
4
5
6
7
8
@{
    ExcludeRule     = @('IslAssumedContext')
    MinimumSeverity = 'Information'
    Severity        = @{ IslLongSleep = 'Information' }   # keep the rule, lower the stakes
    ScriptType      = 'Remediation'                        # when a script's directive says nothing
    Context         = 'System'
    Architecture    = 'x64'
}

Your explicit parameters win over the file, and a script’s directive wins over the file’s type, context, architecture and signature entries. One precedence detail took a bug fix to get right: an explicit -IncludeRule now sets aside the file’s ExcludeRule list, since naming the rules you want to run is a stronger statement than a folder-wide exclusion. -ExcludeRule still adds to the file’s list.

Repair-IntuneScript

A finding with a Fix attached is one Repair-IntuneScript can apply. Three of them preserve what the script does:

  • A script-scope return becomes the exit 0 it already produced, with any returned value written first, so return 'ok' becomes 'ok'; exit 0.
  • A UTF-16 file, or a BOM-less file with non-ASCII characters, is rewritten as UTF-8 with a BOM.
  • A requirement script’s padded output literal is trimmed, so ' ok ' becomes 'ok'.

0.28.0 added eight more, each the edit the finding’s message asks for. These change what the script does, so they’re the ones to read in the -WhatIf output first:

FindingEdit
Install and repository cmdlets that confirm-Force on Install-Module, Install-PackageProvider, Install-Package, Update-Module and Uninstall-Module; -Confirm:$false on Register-PSRepository
A probing cmdlet in a Win32 detection or requirement script-ErrorAction SilentlyContinue added to the call
An exit code other than 0 or 1 in a detectionexit 1, which is how Intune reads it anyway
$env:ProgramFiles in a 32-bit host$env:ProgramW6432
'AMD64' as a -match pattern'ARM64|AMD64'
$PWD$PSScriptRoot
Get-Credential -> on a value that can only be a credentialThe call replaced by the value
Set-ExecutionPolicy, #Requires -Version 7The statement or the line removed

When an edit would leave a broken statement, it’s withheld and the finding stays: Set-ExecutionPolicy inside a pipeline, 'AMD64' compared with -eq, $PWD.Path. The #Requires finding also moved onto its own line in 0.28.0, so the edit has something to remove.

Edits are applied from the end of the file backward, so earlier line and column offsets stay valid, and each one is checked against the text the analysis saw before it’s made. -WhatIf previews the whole folder:

1
2
3
4
5
6
7
What if: Performing the operation "Apply 1 fix(es): IslEncodingIssue" on target "...\Remediations\Proxy\Detect.ps1".
What if: Performing the operation "Apply 1 fix(es): IslOutputIssue" on target "...\Win32\Contoso\Detect-App.ps1".

Applied Remaining Written Fixes              Path
------- --------- ------- -----              ----
      1         4   False IslEncodingIssue@0 ...\Remediations\Proxy\Detect.ps1
      1         3   False IslOutputIssue@1   ...\Win32\Contoso\Detect-App.ps1

The Win32 fix is the probing finding from the output section, and on its own it gets you part of the way:

1
2
3
4
$version = Get-ItemPropertyValue -Path 'HKLM:\SOFTWARE\Contoso\Agent' -Name Version -ErrorAction SilentlyContinue
if ($version -ge '2.0') { exit 0 }
Write-Error "Contoso agent missing"
exit 1

The other two findings on that script stay. The missing Write-Output on the installed path and the Write-Error on the other one are decisions about what the script should report, and the repair leaves those to you.

Repair takes the same -ScriptType, -Context, -Architecture and -EnforceSignatureCheck as Test-IntuneScript. Before 0.28.0 it had only the first, so a script deployed as 64-bit or in user context was repaired against the inferred defaults, and its Remaining count could disagree with what Test-IntuneScript reported for the same options.

The encoding fix is where the strict UTF-8 check from earlier pays off. A BOM-less UTF-8 file is decoded as UTF-8 and rewritten with a BOM. A BOM-less ANSI file is decoded in the system ANSI code page first. Before 0.26.0 the repair decoded both as UTF-8, and every non-ASCII character in an ANSI file came out as U+FFFD, gone for good. The changelog has that one under Fixed.

The return fix preserves behavior, and the certificate sample from the top of this post shows where that goes wrong. In 0.26.0 the repair rewrote it like this:

1
2
3
4
5
if ($results) {
    Write-Host "Match"
    $results.count; exit 0
    exit 1
}

Same behavior, “Without issues” as before, and the finding gone, with the exit 1 the author wrote now unreachable and nothing left to say so. The repair had hidden the mistake it was run on.

The fix came out of writing this post and shipped in 0.27.0. A script-scope return with an exit other than 0 after it in the same block now gets no edit:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# An exit other than 0 after the return in the same block is the exit the author meant. Writing
# 'exit 0' in front of it would leave it unreachable with no finding left to say so, so that
# return gets no edit and stays reported
function Test-ExitFollowsReturn {
    param($Return)
    $passed = $false
    foreach ($statement in @($Return.Parent.Statements)) {
        if ($statement -eq $Return) { $passed = $true; continue }
        if (-not $passed -or $statement.GetType().Name -ne 'ExitStatementAst') { continue }
        if ($statement.Pipeline -and $statement.Pipeline.Extent.Text.Trim() -ne '0') { return $true }
    }
    $false
}

On the certificate sample, -WhatIf now reports Applied 0, Remaining 3, and the IslExitCodeIssue finding stays an Error with no Fix attached. The script already says which exit was meant, and deleting the return is a one-line change you can make with the finding in front of you. A return with nothing after it, or with exit 0 after it, is still repaired as before.

The full message list for every rule, with the experiment IDs behind each message, is the generated rule reference. A unit test fails when it falls out of date with the rule files.