I’m a huge fan of PSScriptAnalyzer and I wanted to integrate my module with it. There are many ci/cd or workflows that involve it, and would rather bundle it in rather than causing someone to learn the IntuneScriptLab commands.

When I announced IntuneScriptLab I described it as a module for validating and troubleshooting Intune detection, remediation and install scripts, “including a PSScriptAnalyzer wrapper.” That last part gets one line in the README, and it’s the piece most people will meet first. If your scripts repository already runs Invoke-ScriptAnalyzer, the wrapper is how the Intune rules show up in it without anyone learning a new command.

The linting post covers what the rules check and the evidence behind them. This one is about the plumbing that makes them PSScriptAnalyzer rules: how a finding becomes a diagnostic record, why each file is analyzed once even though PSScriptAnalyzer asks many times, what happens to a script with no file, and the behaviors I ran into when I pushed on it, one of which drops every Intune finding from a CI gate that filters the usual way.

One pass, two rule sets

The rules live in PSScriptAnalyzer\IntuneScriptLab.Rules.psm1 inside the installed module. Get-IntuneAnalyzerRulePath returns its full path, so you don’t need to know where the module landed:

1
Invoke-ScriptAnalyzer -Path .\Remediations -Recurse -CustomRulePath (Get-IntuneAnalyzerRulePath) -IncludeDefaultRules

-IncludeDefaultRules decides whether you get one rule set or two. As soon as you pass -CustomRulePath, PSScriptAnalyzer switches its built-in rules off, and you get only the custom ones. With the switch, both sets run in the same pass and come back in one list.

I wrote a remediation detection with a few problems in it to see what that list looks like:

1
2
3
4
5
6
7
$cache = gci "$env:ProgramData\Widget\cache" -ErrorAction SilentlyContinue
if ($cache) {
    Write-Host "cache present"
    return $cache.Count
}
Start-Sleep -Seconds 4000
exit 1

Run with IntuneScriptLab 0.32.0 and PSScriptAnalyzer 1.25.0 on PowerShell 7.6, sorted by line and with the evidence trimmed from the messages:

1
2
3
4
5
6
7
8
RuleName                     Severity Line Message
--------                     -------- ---- -------
PSAvoidUsingCmdletAliases     Warning    1 'gci' is an alias of 'Get-ChildItem'. Alias can introduce possible problems and make scripts hard to maintain. ...
Measure-IslAssumedContext Information    1 Analyzed as Detection (inferred from folder and file name), System context, x86: the portal defaults where nothing said ...
PSAvoidUsingWriteHost         Warning    3 File 'Detect.ps1' uses Write-Host. Avoid using Write-Host because it might not work in all hosts, ...
Measure-IslOutputIssue        Warning    3 Write-Host is the last thing written, so its text is what Intune reports instead of your summary. Write the summary line last, ...
Measure-IslExitCodeIssue        Error    4 return at script scope ends the script with exit 0; any exit 1 after it never runs, so the remediation never triggers. ...
Measure-IslLongSleep            Error    6 Sleeping 4000 s exceeds the 3600 s timeout; the script is killed before it finishes ...

Line 3 shows both rule sets side by side. PSScriptAnalyzer flags Write-Host on style grounds, and the Intune rule flags the same call because it’s the last thing written, so its text becomes the line the portal shows. Two different reasons to look at one line, from two rule sets that don’t know about each other.

Each Intune record’s message ends with the observed behavior behind it, in brackets:

1
2
3
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 [Observed: "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)]

PSScriptAnalyzer’s DiagnosticRecord has no evidence field, so the evidence rides along in the message.

Rule functions written out by hand

PSScriptAnalyzer finds custom rules by parsing the module file and looking for functions that take an AST parameter. It doesn’t import the module and ask what it exports; it reads the source. A loop that generates Measure-* functions at import time would produce rules PSScriptAnalyzer can’t see, so every rule is a real function in the file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
function Measure-IslExitCodeIssue {
    [CmdletBinding()]
    [OutputType([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [System.Management.Automation.Language.ScriptBlockAst]$ScriptBlockAst
    )
    Get-IslRuleRecord -Rule 'IslExitCodeIssue' -ScriptBlockAst $ScriptBlockAst
}

There’s one of those per rule, fifteen, plus Measure-IslAssumedContext for the note about an inferred script type. They’re identical apart from the name. Repetition like that drifts the day someone adds a rule and forgets the wrapper. The integration suite guards against that by comparing what PSScriptAnalyzer discovers with the module’s own rule list:

1
2
3
4
5
It 'exposes one Measure- rule per IntuneScriptLab rule, plus the assumed-context note' {
    $rules = @(Get-ScriptAnalyzerRule -CustomRulePath $script:RulePath)
    $expected = @(InModuleScope IntuneScriptLab { $script:RuleOrder } |
        ForEach-Object { $_ -replace '^Find-', 'Measure-' }) + 'Measure-IslAssumedContext'
    @($rules.RuleName | Sort-Object) | Should-BeCollection @($expected | Sort-Object)

Add a Find-Isl* rule without its Measure-Isl* twin and that test fails.

One more discovery detail from the command’s help: PSScriptAnalyzer 1.25 didn’t find the rules when it was given the PSScriptAnalyzer folder, in any layout I tried. It wants the .psm1 file path, which is what Get-IntuneAnalyzerRulePath returns.

From finding to diagnostic record

Every Measure-* function hands off to Get-IslRuleRecord. It runs the analysis (once, more on that next), picks the findings for its rule and turns each into a DiagnosticRecord.

The interesting part is the extent. A Test-IntuneScript finding carries a line, a column and the offending text. PSScriptAnalyzer wants a ScriptExtent with a start and an end position, so the wrapper rebuilds one from those three fields:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
$extent = if ($finding.Line -gt 0) {
    $lines = @("$($finding.Text)" -split "`r?`n")
    $endLine = $finding.Line + $lines.Count - 1
    $endColumn = if ($lines.Count -eq 1) { $finding.Column + $lines[0].Length }
    else { $lines[-1].Length + 1 }
    $start = [System.Management.Automation.Language.ScriptPosition]::new($file, $finding.Line,
        $finding.Column, $lines[0])
    $end = [System.Management.Automation.Language.ScriptPosition]::new($file, $endLine, $endColumn,
        $lines[-1])
    [System.Management.Automation.Language.ScriptExtent]::new($start, $end)
}
else { $ScriptBlockAst.Extent }

A finding that spans lines, like a whole if block, gets an end position on its last line. A file-level finding with no position, like the encoding warning or the assumed-context note, gets the root script block’s extent, so it shows up on line 1. The record keeps the script path, and its Extent.Text is the code the rule pointed at; the tests check that with the Start-Sleep -Seconds 4000 line.

Then the record itself:

1
2
3
4
5
6
7
8
$message = if ($finding.Evidence) { "$($finding.Message) [Observed: $($finding.Evidence)]" }
else { $finding.Message }
[Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord]@{
    Message  = $message
    Extent   = $extent
    RuleName = "Measure-$Rule"
    Severity = $finding.Severity
}

The severity is the finding’s own, Error, Warning or Information, and that line comes back later in this post.

Analyzing each file once

PSScriptAnalyzer calls a custom rule once for every ScriptBlockAst in a file. That’s the root, plus every function body, every script block passed to ForEach-Object, every { } anywhere. With sixteen rules, a file with a hundred script blocks would mean sixteen hundred calls, and Test-IntuneScript analyzes the whole file each time it runs.

The wrapper handles that at two levels. Nested script blocks return immediately, because the root’s analysis already covers the whole file:

1
2
# Nested script blocks are part of the same file; the root carries the analysis
if ($null -ne $ScriptBlockAst.Parent) { return }

At the root, the sixteen rules share one analysis through a cache keyed on the file’s path, last write time and size:

1
2
3
4
5
6
$item = Get-Item -LiteralPath $file -ErrorAction Stop
$key = "$($item.FullName)|$($item.LastWriteTimeUtc.Ticks)|$($item.Length)"
if (-not $script:AnalysisCache.ContainsKey($key)) {
    $script:AnalysisCache[$key] = @(Test-IntuneScript -Path $item.FullName)
}
return $script:AnalysisCache[$key]

The first rule to reach the root pays for Test-IntuneScript, and the other fifteen read the result. Saving the file changes the timestamp and the size, so an edited file gets a fresh key.

I wanted numbers, so I generated a detection script with 60 small functions in it, 121 script blocks in all, and timed it on my desktop with IntuneScriptLab 0.32.0, PSScriptAnalyzer 1.25.0 and PowerShell 7.6.6, each run in a fresh session:

RunTime
Test-IntuneScript on the file, first call407 ms
Test-IntuneScript on the file, second call61 ms
Invoke-ScriptAnalyzer with the Intune rules1,677 ms
Invoke-ScriptAnalyzer with the Intune rules, on a one-line file1,160 ms
Invoke-ScriptAnalyzer with one built-in rule47 ms

The one-line file is the start-up cost: PSScriptAnalyzer loading the rule module, and the rule module importing IntuneScriptLab. That’s about 1.2 seconds a call before any analysis, and the rest of the 1,677 ms is one pass of Test-IntuneScript. A second session gave the same shape, 1,319 ms against 1,243 ms on the one-line file.

The analysis itself got faster in 0.28.0, about two and a half times, so the cache saves less than it used to. Sixteen rules each running a 61 ms analysis on the root would add about a second. The nested-block check is the one that would hurt without it: 121 script blocks times sixteen rules is 1,936 calls, and at 61 ms each that’s around two minutes.

The cache lives as long as the rule module does, and PSScriptAnalyzer loads it again for each Invoke-ScriptAnalyzer call, so it doesn’t carry from one call to the next. A second call on the same file paid the start-up and the analysis again. A file analyzed in two separate calls is analyzed twice.

Scripts without a file

Invoke-ScriptAnalyzer -ScriptDefinition analyzes a string, and a string has no path. That breaks two things the analysis depends on: Test-IntuneScript takes a file, and script type inference reads the file name.

The wrapper writes the text to a temporary .ps1, UTF-8 with a BOM, keyed in the cache by a SHA-256 of the text:

1
2
3
4
5
6
7
8
$temp = Join-Path -Path $folder -ChildPath "definition-$([guid]::NewGuid().ToString('N')).ps1"
try {
    [System.IO.File]::WriteAllText($temp, $text, [System.Text.UTF8Encoding]::new($true))
    $script:AnalysisCache[$key] = @(Test-IntuneScript -Path $temp)
}
finally {
    Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue
}

The temporary name has no useful words in it, so a definition can’t be typed by inference. A directive comment does it instead:

1
2
$definition = "# IntuneScriptLab: ScriptType=Win32Detection`r`nif (Test-Path 'C:\App\app.exe') { exit 0 }`r`nexit 1"
Invoke-ScriptAnalyzer -ScriptDefinition $definition -CustomRulePath (Get-IntuneAnalyzerRulePath)
1
2
3
RuleName               Severity Line Message
--------               -------- ---- -------
Measure-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

The BOM is for the encoding rule’s sake. Without one, a definition containing any non-ASCII character would earn an encoding warning about the temporary file, which is a problem the real script may not have.

Names, settings and suppressions

Inside PSScriptAnalyzer the rules go by their Measure- names, and every PSScriptAnalyzer filter takes those:

1
Invoke-ScriptAnalyzer -Path .\Detect-App.ps1 -CustomRulePath (Get-IntuneAnalyzerRulePath) -ExcludeRule Measure-IslAssumedContext

A PSScriptAnalyzerSettings.psd1 works too, with one spelling trap. The parameter and the settings key differ, and PSScriptAnalyzer refuses the file if you mix them up:

1
2
excluderule is not a valid key in the settings hashtable. Valid keys are CustomRulePath, ExcludeRules,
IncludeRules, IncludeDefaultRules, RecurseCustomRulePath, Rules and Severity.

The parameter is -ExcludeRule; the settings key is ExcludeRules. With that fixed:

1
2
3
4
5
@{
    CustomRulePath      = @('C:\Users\you\Documents\PowerShell\Modules\IntuneScriptLab\0.32.0\PSScriptAnalyzer\IntuneScriptLab.Rules.psm1')
    IncludeDefaultRules = $true
    ExcludeRules        = @('Measure-IslAssumedContext', 'PSAvoidUsingCmdletAliases')
}
1
2
3
4
5
6
RuleName                 Severity Line
--------                 -------- ----
PSAvoidUsingWriteHost     Warning    3
Measure-IslExitCodeIssue    Error    4
Measure-IslLongSleep        Error    6
Measure-IslOutputIssue    Warning    3

IntuneScriptLab’s own settings file still applies underneath. The wrapper calls Test-IntuneScript without -Settings, so the nearest IntuneScriptLab.settings.psd1 above the script takes effect. I dropped one with Severity = @{ IslLongSleep = 'Information' } above the test folder, and the 4,000-second sleep came back from Invoke-ScriptAnalyzer as Information. A team can keep its Intune-specific choices (default script type, context, architecture, severity overrides) in the file that travels with the scripts, and its PSScriptAnalyzer choices in the other.

Suppression works both ways. IntuneScriptLab’s directive comment, # IntuneScriptLab: Suppress=IslLongSleep at the end of the line, removes the finding before the wrapper sees it. PSScriptAnalyzer’s own attribute works with the Measure- name:

1
2
3
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('Measure-IslLongSleep', '')]
param()
Start-Sleep -Seconds 600

I checked it with a control file: the same script without the attribute produced the Measure-IslLongSleep warning on line 2, and with the attribute it produced nothing. Use whichever your repository already uses. The directive has one advantage: Test-IntuneScript -IncludeSuppressed can still list what it waved through.

The -Severity trap

This one showed up while I was testing the wrapper for this post. A lot of pipelines run PSScriptAnalyzer like this and fail the build on the result:

1
Invoke-ScriptAnalyzer -Path . -Recurse -Settings .\PSScriptAnalyzerSettings.psd1 -Severity Error

With the Intune rules loaded, that returns none of their records, Errors included. On the test file above, the return-before-exit and the 4,000-second sleep are both Error records, and -Severity Error dropped both.

The reason shows up in Get-ScriptAnalyzerRule:

1
2
3
4
5
6
RuleName                     Severity SourceName
--------                     -------- ----------
Measure-IslArchitectureIssue  Warning IntuneScriptLab.Rules
Measure-IslArm64Assumption    Warning IntuneScriptLab.Rules
Measure-IslAssumedContext     Warning IntuneScriptLab.Rules
Measure-IslContextIssue       Warning IntuneScriptLab.Rules

Get-ScriptAnalyzerRule lists all 16 Intune rules as Warning, whatever their records say. In my tests, -Severity filtered on that registered severity: -Severity Warning returned all four Intune records on the file, still marked Error and Information, and -Severity Error and -Severity Information returned none. Each record’s own Severity property is correct, so filtering after the fact works:

1
2
Invoke-ScriptAnalyzer -Path . -Recurse -CustomRulePath (Get-IntuneAnalyzerRulePath) |
    Where-Object Severity -eq Error
1
2
3
4
RuleName                 Severity Line
--------                 -------- ----
Measure-IslExitCodeIssue    Error    4
Measure-IslLongSleep        Error    6

If your CI gate filters with -Severity, move the filter to Where-Object. The README and the command’s help have said so since 0.27.0; before that, nothing mentioned it. The example gate script in the repository doesn’t have this problem, since it calls Test-IntuneScript directly.

Pointing an editor at it

The README says the rules show up as editor squiggles alongside the built-in ones. The way to get there in VS Code is a PSScriptAnalyzer settings file, named by the PowerShell extension’s powershell.scriptAnalysis.settingsPath setting, with the rule path in CustomRulePath.

A settings file is data, so it can’t call Get-IntuneAnalyzerRulePath; it needs the literal path. The command’s help has a one-liner that writes one:

1
2
3
$rules = Get-IntuneAnalyzerRulePath
"@{ CustomRulePath = '$rules'; IncludeDefaultRules = `$true }" |
    Set-Content .\PSScriptAnalyzerSettings.psd1

That path has the module version in it, ...\IntuneScriptLab\0.32.0\PSScriptAnalyzer\..., because that’s how Install-PSResource lays modules out. Update the module, remove the old version, and the settings file points at a folder that’s gone. The one-liner is cheap to rerun after an update, and a repository that checks the file in needs a path every build agent shares.

Next to the built-in compatibility rules

PSScriptAnalyzer has its own rules for code that won’t run on an older PowerShell: PSUseCompatibleSyntax and PSUseCompatibleCommands. They’re off by default and need a target, a version for the syntax rule and a platform profile for the commands rule. I turned both on for Windows PowerShell 5.1 and ran them next to the Intune rules on a two-line script with a ternary and ConvertFrom-Json -AsHashtable:

1
2
3
4
5
6
7
8
RuleName                     Severity Line Message
--------                     -------- ---- -------
PSUseCompatibleSyntax           Error    1 The ternary expression syntax '<test> ? <exp1> : <exp2>' is not available by default in PowerShell versions 3,4,5,6
Measure-IslPowerShell7Syntax    Error    1 Ternary operator (? :) is PowerShell 7 only; Windows PowerShell 5.1 fails to parse the whole script [Observed: ...]
PSUseCompatibleCommands       Warning    2 The parameter 'AsHashtable' is not available for command 'ConvertFrom-Json' by default in PowerShell version '5.1.17763.316'
                                           on platform 'Microsoft Windows Server 2019 Datacenter'
Measure-IslPowerShell7Syntax    Error    2 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 [Observed: ...]

They agree on both lines. If you already run the compatibility rules against 5.1, Measure-IslPowerShell7Syntax duplicates them, and excluding one or the other is reasonable. For line 1 the Intune rule adds the consequence: a parse error in a detection script exits 1 and runs the remediation, which REM-PS7-SYNTAX showed on a device. Line 2 behaves differently. Under the agent the -AsHashtable call writes an error and the script carries on to its own exit 0, so a detection reports “Without issues” with no remediation, and the finding’s evidence cites the round 10 experiment that showed it. The Intune rule also needs no configuration, where the commands rule needs a profile name, and the one I used describes Windows Server 2019 rather than a Windows 11 client.

The other Intune rules have no built-in counterpart. Nothing in PSScriptAnalyzer knows what a detection script’s exit code means, which output line the portal keeps, or what HKCU: is under SYSTEM.

Which one to call

Test-IntuneScript and the wrapper produce the same findings from the same code, so the choice comes down to where you want them.

The wrapper fits when something already runs PSScriptAnalyzer: a pipeline step, an editor, a pre-commit hook. The Intune rules join without a new command, and the output format is the one those tools already parse.

Test-IntuneScript gives you more of each finding. Its objects carry the Evidence as its own property, the offending Text, the Fix that Repair-IntuneScript can apply, and Suppressed when you ask for suppressed findings. Export-IntuneFindingSarif takes them for GitHub code scanning, and the example gate script builds its annotations from them. A DiagnosticRecord has room for a message, an extent, a rule name and a severity, and the wrapper fits the rest into the message.

I run Test-IntuneScript when I’m working on a script and want the whole story, and leave the wrapper to the places where PSScriptAnalyzer was already running.