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
17 changes: 15 additions & 2 deletions Build/Build-RuleReference.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,20 @@ function Resolve-Text {
param($Ast, [object[]]$Assignments)
if ($null -eq $Ast) { return '' }
switch ($Ast.GetType().Name) {
'StringConstantExpressionAst' { return $Ast.Value }
'ExpandableStringExpressionAst' { return $Ast.Value }
# A literal $ (single-quoted, or escaped in a double-quoted string) is text, not a value:
# marked so the placeholder pass leaves it alone
'StringConstantExpressionAst' { return $Ast.Value.Replace('$', [string][char]1) }
'ExpandableStringExpressionAst' {
$text = $Ast.Value
foreach ($nested in ($Ast.NestedExpressions | Sort-Object { $_.Extent.StartOffset } -Descending)) {
$index = $text.LastIndexOf($nested.Extent.Text)
if ($index -ge 0) {
$text = $text.Substring(0, $index) + '<value>' +
$text.Substring($index + $nested.Extent.Text.Length)
}
}
return $text.Replace('$', [string][char]1)
}
'ParenExpressionAst' {
$inner = $Ast.Pipeline.PipelineElements[0]
if ($inner.PSObject.Properties['Expression']) {
Expand Down Expand Up @@ -142,6 +154,7 @@ function ConvertTo-Placeholder {
$text = [regex]::Replace($Text, '\$\((?:[^()]|\((?:[^()]|\([^()]*\))*\))*\)', '<value>')
$text = [regex]::Replace($text, '\$\{[^}]+\}', '<value>')
$text = [regex]::Replace($text, '\$[A-Za-z_][\w:]*', '<value>')
$text = $text.Replace([string][char]1, '$')
($text -replace '\s+', ' ').Trim()
}

Expand Down
85 changes: 84 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,88 @@ release notes.

Nothing yet.

## [0.26.0] - 2026-09-28

Every claim the module makes, in its README, help, about topic, rule reference, examples and
changelog, was checked against the code and by running it. What follows is what did not hold.

### Fixed

- **`-Settings` never reached the analysis.** `Test-IntuneDeployedScript` and
`Compare-IntuneDeployedScript` overwrote their `Settings` parameter with a body-level
`$settings` hashtable (variable names are case-insensitive) and then tested
`$PSBoundParameters` inside a nested function, where it is that function's own. The
pre-flight ran its normal settings-file search instead, and the drift compare ignored the
hashtable. `Get-IntuneScriptHealth` forwarded the parameter to both, so it lost it too.
- **`-Id` on its own selected every policy** in the three Graph commands, because `-Name`
defaults to `*` and selection was name or id. `-Id` alone now selects by id.
- **`Repair-IntuneScript -Path <folder> -WhatIf` returned nothing.** The folder was enumerated
through `ForEach-Object -MemberName`, which honours `-WhatIf`. `Test-IntuneScript` had the
same construct and returned no findings for a folder while `$WhatIfPreference` was set.
- **The encoding fix corrupted ANSI files.** A BOM-less file that was not UTF-8 was decoded as
UTF-8 and written back with every non-ASCII character replaced by U+FFFD. Repair now reads
such a file in the system ANSI code page, and `IslEncodingIssue` reports it as ANSI
(Information) rather than as UTF-8 without a BOM.
- **A type declared in a directive earned the assumed-context note** and was called a portal
default. A directive is a declaration; the note is for inferred types only.
- **A settings file's `ExcludeRule` beat an explicit `-IncludeRule`.** An explicit include now
sets the file's exclusions aside; `-ExcludeRule` still adds to them.
- **`Should-PassIntuneAnalysis` failed on a pipeline of files**, joining their paths into one.
It now analyzes every file and names the ones that fail.
- **SARIF:** every rule was given the level `warning`; it now carries the level of the most
severe finding it produced in the log. A finding outside `-Root` was written as an escaped
relative URI under the root; it is now an absolute file URI. A relative `-Path` was resolved
against the process directory, as was `Get-IntuneScriptHealth -MarkdownPath`.
- **A missing script path** made the harness return a result with a made-up exit code instead
of an error.
- **`Get-IntuneAgentTimeline -Id`** returned every timeline whose lines mentioned the id (a
relationship report names two apps); it now returns the timeline whose own id it is. A
relationship report is an outcome.
- **`Compare-IntuneDeployedScript`** compared content without regard to case, so a change in
letter case only came back as "the bytes differ outside the UTF-8 text".
- **A user-context Win32 app assigned to All devices** was not flagged, only one assigned to a
device group.
- **`Get-IntuneScriptHealth -SkipAnalysis`** called an unassigned policy Healthy, because the
check read a finding. It now reads the assignments.
- **`IslFilterIssue`** called `-ne` and `-notIn` with a value no device reports "never matches";
such a clause matches every device, and is reported so, as Information.
- **`IslInteractiveCall`** took a bare `-Confirm`, which forces the prompt, as silencing it.
- **`IslOutputIssue`** took `$ErrorActionPreference = 'Stop'` as guarding a probing cmdlet;
Stop puts the miss on stderr, which is the failure the rule warns about.
- **`IslPowerShell7Syntax`** reported a `using module` the parser could not find as PowerShell 7
syntax, and listed `Get-Process -CommandLine` and `New-TemporaryFile -Extension`, which exist
on neither host.
- **`IslScriptSize`** called Win32 detection and requirement scripts remediations; it now names
them and says the remediation limits are assumed for them, since only remediations and
platform scripts were measured.
- **A stored-password task the scheduler never launches** made the harness wait out the whole
timeout. For an account without the "Log on as a batch job" right the lab device no longer
answers `0x80070569`; the task sits Ready with `0x00041303` ("has not run yet") and no error
anywhere. Five seconds of that is now reported as the refusal, with the same hint.
- **`Test-IntuneScript -EnforceSignatureCheck:$false`** was not explicit, so a tenant script's
own directive could turn the check on under the pre-flight.
- A typo in the `IslContextIssue` message; the AgentTimeline `Duration` column dropped days;
the workflow template's runtime job did not split a comma-separated `SCRIPT_PATHS`.

### Changed

- The reporting lag in `Get-IntuneScriptHealth`'s help is the measured one: a remediation's
changed result reaches Graph with the agent's next hourly report batch (65 minutes after the
run on 2026-09-29), a platform script's in 2-8 s, an app's in 30-39 s; an unchanged result is
not re-reported, so `lastStateUpdateDateTime` is the last change. `Get-IntuneAgentTimeline`'s
help gave the remediation report codes wrong: 3 is no issue, 4 is the remediation having run
(fixed or not), 5 is a detection script failure.
- The rule reference keeps literal names such as `$PSScriptRoot` in a message instead of
replacing them with `<value>`.
- The manifest description names the whole module; the README's links into the repository are
absolute, since the README ships in the package and `docs/` and `Validation/` do not.
- Help corrections throughout: what each result carries (`RunAs`, `IntuneError` as the stderr
tail, `SignatureStatus` always present), the complete status lists, the events
`Get-IntuneAgentLog` names, the Id rule, what `-SkipRegistry` leaves out, the ARM64 note on
the x64 default, the base requirements `Test-IntuneWin32Requirement` covers, how names are
matched and which local files count as `NotInTenant`, the Attention rules, and the
`Applied` count under `-WhatIf`.

## [0.25.0] - 2026-09-28

The first release from this repository, and the first published to the PowerShell Gallery.
Expand Down Expand Up @@ -280,5 +362,6 @@ Nothing any command does has changed.

- Static rules: `Test-IntuneScript`.

[Unreleased]: https://github.com/fadwen/IntuneScriptLab/compare/v0.25.0...HEAD
[Unreleased]: https://github.com/fadwen/IntuneScriptLab/compare/v0.26.0...HEAD
[0.26.0]: https://github.com/fadwen/IntuneScriptLab/compare/v0.25.0...v0.26.0
[0.25.0]: https://github.com/fadwen/IntuneScriptLab/releases/tag/v0.25.0
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@ The harness launches `powershell.exe` hosts by architecture, reads the registry
scheduled tasks, so the suites run on Windows only: the `desktop` job under 5.1 and the `arm64`
job on Windows on ARM. The `help` job proves the module imports on Linux; nothing else there runs.

### Variable names are case-insensitive, and nested functions have their own `$PSBoundParameters`

A body-level `$settings = @{ ... }` silently overwrote the `-Settings` parameter of two commands,
and `$PSBoundParameters.ContainsKey('Settings')` inside a nested helper was the helper's own,
always false. Name locals so they cannot collide with a parameter, and capture what a nested
function needs from `$PSBoundParameters` into a plain variable before defining it. 0.26.0 fixed
both, with tests.

### A nested function returning `@()` hands the caller `$null`

Several public commands use nested helper functions. PowerShell unrolls an empty array on the
Expand Down
5 changes: 3 additions & 2 deletions Examples/IntuneScriptLab.settings.psd1
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
@{
# IntuneScriptLab.settings.psd1: put it in the folder that holds the scripts, or any folder
# above them; the nearest one applies to every script below it. Every key is optional.
# Explicit parameters to Test-IntuneScript win over these; a '# IntuneScriptLab:' directive in
# a script wins over the type, context, architecture and signature entries.
# Explicit parameters to Test-IntuneScript win over these (-ExcludeRule adds to ExcludeRule,
# -IncludeRule sets it aside); a '# IntuneScriptLab:' directive in a script wins over the type,
# context, architecture and signature entries.

# Rules to skip everywhere (wildcards allowed). The context note is the usual one to drop once
# the folder layout or the entries below settle every script's type.
Expand Down
4 changes: 2 additions & 2 deletions Examples/intune-script-gate.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# IntuneScriptLab gate for a repository of Intune scripts. Copy to .github/workflows/ and set the
# paths. On every pull request that touches a script it runs the analysis the way Intune will run
# the scripts, annotates the changed files with the findings, writes a job summary and fails on
# the scripts, annotates the scripts under the paths you set with the findings, writes a job summary and fails on
# errors. The optional second job runs the runtime harness on a Windows runner.
#
# Where the module comes from: a copy checked into the repository when ISL_MODULE_PATH points at
Expand Down Expand Up @@ -97,7 +97,7 @@ jobs:
$env:PSModulePath = "$root$([IO.Path]::PathSeparator)$env:PSModulePath"
}
$config = New-PesterConfiguration
$config.Run.Path = $env:SCRIPT_PATHS
$config.Run.Path = @($env:SCRIPT_PATHS -split ',' | ForEach-Object { $_.Trim() })
$config.Run.Exit = $true
$config.Output.Verbosity = 'Detailed'
Invoke-Pester -Configuration $config
5 changes: 4 additions & 1 deletion IntuneScriptLab.Format.ps1xml
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,10 @@
<PropertyName>Name</PropertyName>
</TableColumnItem>
<TableColumnItem>
<ScriptBlock>$_.Duration.ToString('hh\:mm\:ss')</ScriptBlock>
<ScriptBlock>
if ($_.Duration.Days) { '{0}d {1}' -f $_.Duration.Days, $_.Duration.ToString('hh\:mm\:ss') }
else { $_.Duration.ToString('hh\:mm\:ss') }
</ScriptBlock>
</TableColumnItem>
<TableColumnItem>
<PropertyName>Runs</PropertyName>
Expand Down
21 changes: 18 additions & 3 deletions IntuneScriptLab.psd1
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
@{
# Module manifest for IntuneScriptLab
RootModule = 'IntuneScriptLab.psm1'
ModuleVersion = '0.25.0'
ModuleVersion = '0.26.0'
GUID = '3f6b2c9e-7d41-4a8f-9c2b-5e0d8a1f4b76'
Author = 'Jeffrey Stuhr'
CompanyName = ''
Copyright = '(c) 2026 Jeffrey Stuhr. All rights reserved.'
# One line, within the repository's 115-character limit; the README carries the long form
Description = 'Test Intune scripts before Intune does: static analysis, a runtime harness, Pester assertions'
# Two lines, within the repository's 115-character limit; the README carries the long form
Description = @'
Test Intune scripts before Intune does: static rules, a runtime harness, Pester assertions, a Graph
pre-flight over the tenant's deployed scripts and readers for the agent's logs
'@

# The analyzer itself runs anywhere. The rules describe Windows PowerShell 5.1 behaviour
# because that is what the Intune Management Extension runs scripts with.
Expand Down Expand Up @@ -61,6 +64,18 @@
LicenseUri = 'https://github.com/fadwen/IntuneScriptLab/blob/main/LICENSE'
ProjectUri = 'https://github.com/fadwen/IntuneScriptLab'
ReleaseNotes = @'
0.26.0 - Every claim in the README, help, about topic, rule reference and examples was checked
against the code and by running it, and what did not hold was fixed: -Settings never
reached the pre-flight or the drift compare; -Id alone selected every policy;
Repair-IntuneScript -WhatIf on a folder returned nothing; the encoding fix corrupted
ANSI files; a directive earned the assumed-context note; a settings file's ExcludeRule
beat an explicit -IncludeRule; Should-PassIntuneAnalysis failed on a pipeline of files;
SARIF rule levels, outside-root URIs and relative output paths; a missing script path
returned a result; timeline -Id and relationship reports; case-insensitive drift
compare; All devices for a user-context app; -SkipAnalysis hiding 'assigned to nobody';
-ne/-notIn filter values; a bare -Confirm; Stop as a guard; using module and two
parameters in the PowerShell 7 rule; Win32 scripts in the size rule. Help corrected
throughout. See CHANGELOG.md.
0.25.0 - The first release from the module's own repository, github.com/fadwen/IntuneScriptLab, and
the first published to the PowerShell Gallery. Nothing any command does has changed: the
build scripts moved under Build\, the manifest points at the new repository, and releases
Expand Down
12 changes: 8 additions & 4 deletions Private/ConvertFrom-IslFilterRule.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -177,12 +177,16 @@ function ConvertFrom-IslFilterRule {
Write-Failure "an empty string is refused $where; compare with `$null for a device without a value"
return
}
if ($Property.Values -and ($isList -or $Operator -in 'eq', 'ne')) {
if ($Property.Values -and $Operator -in 'eq', 'in', 'ne', 'notIn') {
# -eq and -in with such a value match nobody; -ne and -notIn match every device
$positive = $Operator -in 'eq', 'in'
foreach ($item in @($value)) {
if ($null -ne $item -and "$item".Trim() -notin $Property.Values) {
Add-Warning -Kind 'NeverMatches' -Message ("'$item' is not a value a Windows device reports " +
"for device.$($Property.Name) ($($Property.Values -join ', ')); the clause at position " +
"$($token.Position) never matches")
$warningKind = if ($positive) { 'NeverMatches' } else { 'AlwaysMatches' }
$effect = if ($positive) { 'never matches' } else { 'matches every device' }
Add-Warning -Kind $warningKind -Message ("'$item' is not a value a Windows device reports " +
"for device.$($Property.Name) ($($Property.Values -join ', ')); the clause at character " +
"$($token.Position) $effect")
}
}
}
Expand Down
16 changes: 12 additions & 4 deletions Private/Get-IslOemEncoding.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,26 @@
(437 on US systems), which is what turned "Grüße — ✓" into "Grüße - √" in Intune's
reports. Read it from the registry rather than CultureInfo, which lies under invariant
globalization, and register the legacy code pages when running on .NET Core.

.PARAMETER Kind
OEM (the default) is what a console-less powershell.exe writes its output in; ANSI is
what Windows PowerShell 5.1 reads a file without a BOM as.
#>
[CmdletBinding()]
[OutputType([System.Text.Encoding])]
param()
param(
[ValidateSet('OEM', 'ANSI')]
[string]$Kind = 'OEM'
)

$codePage = 437
$valueName = if ($Kind -eq 'ANSI') { 'ACP' } else { 'OEMCP' }
$codePage = if ($Kind -eq 'ANSI') { 1252 } else { 437 }
try {
$key = 'HKLM:\SYSTEM\CurrentControlSet\Control\Nls\CodePage'
$value = (Get-ItemProperty -Path $key -Name OEMCP -ErrorAction Stop).OEMCP
$value = (Get-ItemProperty -Path $key -Name $valueName -ErrorAction Stop).$valueName
if ($value -match '^\d+$') { $codePage = [int]$value }
}
catch { Write-Verbose "OEMCP not readable from the registry, assuming $codePage" }
catch { Write-Verbose "$valueName not readable from the registry, assuming $codePage" }

try {
if (-not ('System.Text.CodePagesEncodingProvider' -as [type])) { throw 'no provider type' }
Expand Down
25 changes: 19 additions & 6 deletions Private/Invoke-IslProcess.ps1
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
function Invoke-IslProcess {
function Invoke-IslProcess {
<#
.SYNOPSIS
Runs an executable as the current user, as SYSTEM or as another account, capturing its result.
Expand Down Expand Up @@ -162,24 +162,37 @@ function Invoke-IslProcess {
try {
Start-ScheduledTask -TaskName $taskName
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
$neverStartedAfter = (Get-Date).AddSeconds(5)
$launchFailure = $null
while (-not (Test-Path -LiteralPath $exitFile) -and (Get-Date) -lt $deadline) {
Start-Sleep -Milliseconds 250
# A task the scheduler refuses to start (a logon type the account is not granted)
# ends at once with a result code and never writes the exit file: read it rather
# than waiting for the timeout. 267009 is "running", 267011 "has not run yet"
$info = Get-ScheduledTaskInfo -TaskName $taskName -ErrorAction SilentlyContinue
if ($info -and $info.LastTaskResult -notin 0, 267009, 267011 -and
(Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State -ne 'Running') {
if (-not $info) { continue }
$state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State
if ($info.LastTaskResult -notin 0, 267009, 267011 -and $state -ne 'Running') {
$launchFailure = [uint32]$info.LastTaskResult
break
}
# The refusal does not always come with a code: on the lab device a stored-password
# task for an account without the batch logon right sits Ready, "has not run yet",
# with no error anywhere. Five seconds of that after Start-ScheduledTask is the same
# failure
$neverStarted = $info.LastTaskResult -eq 267011 -and $state -eq 'Ready'
if ($neverStarted -and (Get-Date) -gt $neverStartedAfter) {
$launchFailure = [uint32]267011
break
}
}
if ($launchFailure) {
$code = '0x{0:X8}' -f $launchFailure
$hint = if ($code -eq '0x80070569') {
' (the account is not granted the "Log on as a batch job" right a stored-password task ' +
'needs; grant it in the local security policy, or run while the account holds a session)'
$never = if ($code -eq '0x00041303') { 'the scheduler never launched it: ' } else { '' }
$hint = if ($code -eq '0x80070569' -or ($code -eq '0x00041303' -and $logon -eq 'Password')) {
" ($($never)the account is not granted the ""Log on as a batch job"" right a " +
'stored-password task needs; grant it in the local security policy, or run while the ' +
'account holds a session)'
}
else { '' }
throw "The scheduled task for $userName did not start: $code$hint"
Expand Down
Loading
Loading