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

## [Unreleased]

Nothing yet.
### Added

- The filter parser warns about a `-contains` value that is only whitespace: the service accepts
`" "`, trims it to nothing, and every value contains that, so the clause matches every device
(`FLT-W27`, `FLT-Y03`). `Test-IntuneDeployedScript` reports it as `IslFilterIssue` Information.
- In exclude mode, `Test-IntuneAssignmentFilter` and `Test-IntuneDeployedScript` say what a
warning means for the assignment: a clause that never matches excludes nobody, so the assignment
reaches every device in the group; one that matches every device excludes everybody. Include
mode keeps the parser's text.

### Changed

- A double quote inside a filter value (`"say \"hi\""` or `"say ""hi"""`) is now refused with a
message saying that no escape exists and that `-contains` or `-startsWith` on the parts around
the quote is the way through (`FLT-V44`, `FLT-V45`), instead of "expected 'and', 'or' or ')'".
- The `Test-IntuneAssignmentFilter` help example that checks every filter in a tenant keeps to the
Windows platform first, since the parser's property set and value tables are the Windows ones.

## [0.30.0] - 2026-10-08

Expand Down
44 changes: 40 additions & 4 deletions Private/ConvertFrom-IslFilterRule.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ function ConvertFrom-IslFilterRule {
-ErrorVariable, and the public command wants to write exactly one. The result carries the
clauses in order, the tree the evaluator walks, and warnings for rules the service
accepts but that never match a Windows device: an enumerated value outside the documented
set (cpuArchitecture "x64", deviceTrustType "Microsoft Entra joined"), the deprecated
osVersion property and the undocumented isTpmAttested.
set (cpuArchitecture "x64", deviceTrustType "Microsoft Entra joined"), a -contains value
that is only whitespace (the evaluator trims it to nothing, and every name contains that),
the deprecated osVersion property and the undocumented isTpmAttested.

.PARAMETER Rule
The rule text as the portal's rule syntax editor or the Graph assignmentFilter.rule holds it.
Expand Down Expand Up @@ -93,6 +94,23 @@ function ConvertFrom-IslFilterRule {
})
}

# A double quote inside a value has no escape (FLT-V44, FLT-V45): "say \"hi\"" tokenizes as a
# string ending in a backslash with a word glued to it, "say ""hi""" as two strings glued
# together. Either shape where a connector was expected is that mistake, not a missing 'and'
function Test-EscapedQuote {
param($Previous, $Next)
if (-not $Previous -or -not $Next -or $Previous.Type -ne 'string') { return $false }
$glued = $Next.Position -eq $Previous.End + 1

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.

Glued means no whitespace between the two tokens: Position is 1-based and End is the 0-based exclusive end of the previous match, so End + 1 is the position right after it. The existing refusal (device.deviceName -eq "ab" "cd") has a space between the strings and keeps its old message; only the two shapes a would-be escape produces (\" leaves a backslash on the string, "" leaves two strings touching) are renamed.

[bool]($glued -and ($Next.Type -eq 'string' -or $Previous.Text.EndsWith('\')))
}

function Write-EscapeFailure {
param($Previous)
Write-Failure ("a double quote inside a value cannot be escaped: the service refuses both \`" and `"`" " +
"(the value at position $($Previous.Position)); match the parts around the quote with " +
'-contains or -startsWith instead')
}

# The value after an operator: a string, a list, $null or a bare version, checked against
# what the property and the operator accept
function Read-Value {
Expand Down Expand Up @@ -130,7 +148,11 @@ function ConvertFrom-IslFilterRule {
$next = Get-CurrentToken
if ($next -and $next.Type -eq 'comma') { $state.Pos++ }
elseif ($next -and $next.Type -ne 'rbracket') {
Write-Failure "expected ',' or ']' at position $($next.Position), found '$($next.Text)'"
if (Test-EscapedQuote -Previous $item -Next $next) { Write-EscapeFailure -Previous $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.

Inside a list the same shapes end up in the "expected ',' or ']'" branch rather than the connector branches below, so the check is repeated here. [""] stays accepted: an empty string token is a normal item (FLT-W06), and nothing is glued to it.

else {
Write-Failure ("expected ',' or ']' at position $($next.Position), " +
"found '$($next.Text)'")
}
return
}
}
Expand Down Expand Up @@ -190,6 +212,13 @@ function ConvertFrom-IslFilterRule {
}
}
}
if ($Operator -eq 'contains' -and $kind -eq 'String' -and "$value".Trim().Length -eq 0) {

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.

Only -contains gets the warning. -startsWith " " would match every device for the same reason (the evaluator trims the value and every string starts with the empty string), but the probe never sent it, so it stays silent until it is measured. -eq " " and the other operators compare a trimmed empty value against the device's and are not affected; the "does not warn" table covers -eq " ".

# The evaluator trims the value to nothing, and every name contains an empty string:
# -contains " " matched every device (FLT-W27, FLT-Y03)
Add-Warning -Kind 'AlwaysMatches' -Message ("'$value' is only whitespace, which the evaluator trims " +
"to nothing, and every value contains that; the clause at character $($token.Position) " +
'matches every device')
}
if ($isList -and $kind -eq 'String') { $value = @($value) }
$value
}
Expand Down Expand Up @@ -292,7 +321,12 @@ function ConvertFrom-IslFilterRule {
$close = Get-CurrentToken
if (-not $close) { Write-Failure "the '(' at position $($token.Position) is not closed"; return }
if ($close.Type -ne 'rparen') {
Write-Failure "expected 'and', 'or' or ')' at position $($close.Position), found '$($close.Text)'"
$previous = $tokens[$state.Pos - 1]
if (Test-EscapedQuote -Previous $previous -Next $close) { Write-EscapeFailure -Previous $previous }
else {
Write-Failure ("expected 'and', 'or' or ')' at position $($close.Position), " +
"found '$($close.Text)'")
}
return
}
$state.Pos++
Expand Down Expand Up @@ -354,7 +388,9 @@ function ConvertFrom-IslFilterRule {
$tree = Read-Expression
$rest = Get-CurrentToken
if (-not $state.Error -and $rest) {
$previous = $tokens[$state.Pos - 1]
if ($rest.Type -eq 'rparen') { Write-Failure "unexpected ')' at position $($rest.Position)" }
elseif (Test-EscapedQuote -Previous $previous -Next $rest) { Write-EscapeFailure -Previous $previous }
else { Write-Failure "expected 'and' or 'or' before '$($rest.Text)' at position $($rest.Position)" }
}
}
Expand Down
50 changes: 50 additions & 0 deletions Private/Get-IslFilterWarningMessage.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
function Get-IslFilterWarningMessage {
<#
.SYNOPSIS
The text of a filter parser warning, with what it means for the mode the filter is attached in.

.DESCRIPTION
ConvertFrom-IslFilterRule reports a clause as "never matches" or "matches every device"
without knowing whether the filter is attached to an assignment in include or exclude mode,
and the two modes turn the same clause into opposite outcomes: an include filter that never
matches reaches nobody (W32-FILTER-INCLUDE), while an exclude filter that never matches
excludes nobody, so the assignment reaches every device in the group (W32-FILTER-EXCLUDE).
Test-IntuneAssignmentFilter and Test-IntuneDeployedScript know the mode, and append the
exclude-mode consequence here so one message is read the same way in both places. Include
mode and the other warning kinds come back unchanged.

.PARAMETER Warning
One IntuneScriptLab.FilterWarning from ConvertFrom-IslFilterRule (Kind and Message).

.PARAMETER Mode
Include (the default) or Exclude: how the filter is attached to the assignment.

.EXAMPLE
Get-IslFilterWarningMessage -Warning $parsed.Warnings[0] -Mode Exclude

The "never matches" message followed by "; as an exclude filter it excludes nobody, so the
assignment reaches every device in the group".

.OUTPUTS
System.String
#>
[CmdletBinding()]
[OutputType([string])]
param(
[Parameter(Mandatory)]
$Warning,

[ValidateSet('Include', 'Exclude')]
[string]$Mode = 'Include'
)

$note = if ($Mode -ne 'Exclude') { '' }

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 separate helper rather than a -Mode parameter on the parser, because the parser is also what Test-IntuneDeployedScript calls once per filter while the mode belongs to each assignment: one filter can hang off an include assignment and an exclude one at the same time, and the note has to differ per assignment, not per parse.

elseif ($Warning.Kind -eq 'NeverMatches') {
'; as an exclude filter it excludes nobody, so the assignment reaches every device in the group'
}
elseif ($Warning.Kind -eq 'AlwaysMatches') {
'; as an exclude filter it excludes every device, so the assignment reaches nobody'
}
else { '' }
"$($Warning.Message)$note"
}
9 changes: 7 additions & 2 deletions Public/Test-IntuneAssignmentFilter.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,12 @@ function Test-IntuneAssignmentFilter {
Write-Error @errorSplat
return
}
foreach ($warning in $parsed.Warnings) { Write-Warning $warning.Message }
# The parser does not know the mode; in exclude mode a clause that never matches excludes nobody
$messages = [System.Collections.Generic.List[string]]::new()
foreach ($warning in $parsed.Warnings) {
$messages.Add((Get-IslFilterWarningMessage -Warning $warning -Mode $Mode))
}
foreach ($message in $messages) { Write-Warning $message }

$matched = $null
$applicable = $null
Expand Down Expand Up @@ -152,7 +157,7 @@ function Test-IntuneAssignmentFilter {
Applicable = $applicable
Reason = $reason
Clauses = $parsed.Clauses
Warnings = @($parsed.Warnings | ForEach-Object { $_.Message })
Warnings = $messages.ToArray()
Device = $deviceView
}
}
Expand Down
4 changes: 3 additions & 1 deletion Public/Test-IntuneDeployedScript.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -190,11 +190,13 @@ function Test-IntuneDeployedScript {
ConvertTo-PolicyFinding @unreadSplat
continue
}
$mode = if ($filterType -eq 'exclude') { 'Exclude' } else { 'Include' }

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.

Severity is left on the warning kind, not on the mode: an exclude filter whose clause never matches still does nothing, which is the Warning case. The message carries the consequence (reaches every device in the group) so the reader does not have to work it out from the filter type in the label.

foreach ($warning in $parsed.Warnings) {
$severity = if ($warning.Kind -eq 'NeverMatches') { 'Warning' } else { 'Information' }
$message = Get-IslFilterWarningMessage -Warning $warning -Mode $mode
$issueSplat = @{
PolicyKind = $PolicyKind; Policy = $Policy; Rule = 'IslFilterIssue'; Severity = $severity
Message = "${label}: $($warning.Message). Rule: $($filter.rule)"
Message = "${label}: $message. Rule: $($filter.rule)"
Evidence = $filterEvidence
}
ConvertTo-PolicyFinding @issueSplat
Expand Down
14 changes: 12 additions & 2 deletions Tests/Unit/Private/ConvertFrom-IslFilterRule.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,11 @@ Describe 'ConvertFrom-IslFilterRule' -Tag 'Unit', 'Private' {
@{ Rule = '(device.deviceName -eq "X") // comment'; Message = "*expected 'and' or 'or' before '//'*" }
@{ Rule = '(device.deviceName -eq "X") xor (device.model -eq "Y")'
Message = "*expected 'and' or 'or' before 'xor'*" }
@{ Rule = '(device.deviceName -eq "say \"hi\"")'
Message = "*expected 'and', 'or' or ')'*found 'hi\'*" }
# No escape exists (FLT-V44, FLT-V45): a backslash-quote or a doubled quote is named as such
@{ Rule = '(device.deviceName -eq "say \"hi\"")'; Message = '*cannot be escaped*position 24*' }
@{ Rule = '(device.deviceName -eq "say ""hi""")'; Message = '*cannot be escaped*position 24*' }
@{ Rule = 'device.deviceName -eq "say \"hi\""'; Message = '*cannot be escaped*position 23*' }
@{ Rule = '(device.deviceName -in ["say \"hi\""])'; Message = '*cannot be escaped*position 25*' }

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.

Positions in these four rows are the position of the string token that holds the first half of the broken value, which is where the reader has to look; the earlier found 'hi\' message pointed at the fragment after the quote instead.

@{ Rule = '(device.deviceName -eq "X") or'; Message = '*expected a clause or "(" at the end*' }
@{ Rule = '(device.deviceName -eq "X") and'; Message = '*expected a clause or "(" at the end*' }
@{ Rule = '(device.deviceName -eq "X") or ()'; Message = "*unexpected ')'*" }
Expand Down Expand Up @@ -256,6 +259,11 @@ Describe 'ConvertFrom-IslFilterRule' -Tag 'Unit', 'Private' {
Text = "*'company'*Personal, Corporate, Unknown*" }
@{ Rule = '(device.operatingSystemSKU -eq "Windows Enterprise")'; Kind = 'NeverMatches'
Text = "*'Windows Enterprise'*" }
# " " is accepted and trimmed to nothing, which every value contains (FLT-W27, FLT-Y03)
@{ Rule = '(device.deviceName -contains " ")'; Kind = 'AlwaysMatches'
Text = "*' ' is only whitespace*matches every device*" }
@{ Rule = '(device.model -contains " ")'; Kind = 'AlwaysMatches'
Text = "*only whitespace*character 25 matches every device*" }
) {
$parsed = ConvertFrom-Rule -Rule $Rule
@($parsed.Warnings).Count | Should-Be 1
Expand All @@ -269,6 +277,8 @@ Describe 'ConvertFrom-IslFilterRule' -Tag 'Unit', 'Private' {
@{ Rule = '(device.operatingSystemSKU -startsWith "Ent")' }
@{ Rule = '(device.operatingSystemSKU -eq "EnterpriseSEval")' }
@{ Rule = '(device.deviceTrustType -eq $null)' }
@{ Rule = '(device.deviceName -contains " x ")' }
@{ Rule = '(device.deviceName -eq " ")' }
) {
@((ConvertFrom-Rule -Rule $Rule).Warnings).Count | Should-Be 0
}
Expand Down
45 changes: 45 additions & 0 deletions Tests/Unit/Private/Get-IslFilterWarningMessage.Tests.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.2.0' }

<#
The exclude-mode note on a filter parser warning: a clause that never matches excludes nobody
and one that matches every device excludes everybody (W32-FILTER-INCLUDE, W32-FILTER-EXCLUDE);
include mode and the other warning kinds are passed through unchanged.
#>

BeforeAll {
$script:ModuleRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))
Import-Module (Join-Path $script:ModuleRoot 'IntuneScriptLab.psd1') -Force

function script:Get-Message {
param([string]$Kind, [string]$Mode)
$warning = [pscustomobject]@{ Kind = $Kind; Message = 'the clause at character 29 never matches' }
InModuleScope IntuneScriptLab -Parameters @{ Warning = $warning; Mode = $Mode } {
Get-IslFilterWarningMessage -Warning $Warning -Mode $Mode
}
}
}

AfterAll {
Remove-Module IntuneScriptLab -Force -ErrorAction SilentlyContinue
}

Describe 'Get-IslFilterWarningMessage' -Tag 'Unit', 'Private' {

It 'appends what a <Kind> clause does to an exclude filter' -ForEach @(
@{ Kind = 'NeverMatches'
Expected = '*never matches; as an exclude filter it excludes nobody, so the assignment reaches*' }
@{ Kind = 'AlwaysMatches'
Expected = '*as an exclude filter it excludes every device, so the assignment reaches nobody' }
) {
Get-Message -Kind $Kind -Mode Exclude | Should-BeLikeString $Expected
}

It 'passes a <Kind> warning through unchanged in <Mode> mode' -ForEach @(
@{ Kind = 'NeverMatches'; Mode = 'Include' }
@{ Kind = 'AlwaysMatches'; Mode = 'Include' }
@{ Kind = 'Deprecated'; Mode = 'Exclude' }
@{ Kind = 'Undocumented'; Mode = 'Exclude' }
) {
Get-Message -Kind $Kind -Mode $Mode | Should-Be 'the clause at character 29 never matches'
}
}
18 changes: 18 additions & 0 deletions Tests/Unit/Public/Test-IntuneAssignmentFilter.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,24 @@ Describe 'Test-IntuneAssignmentFilter' -Tag 'Unit', 'Public' {
$result.Matched | Should-BeFalse
}

It 'says what a clause that <Effect> means for an exclude filter' -ForEach @(
@{ Rule = '(device.cpuArchitecture -eq "x64")'; Effect = 'never matches'
Note = ('*never matches; as an exclude filter it excludes nobody, so the assignment reaches ' +
'every device in the group') }
@{ Rule = '(device.deviceTrustType -ne "Hybrid Entra joined")'; Effect = 'matches every device'
Note = ('*matches every device; as an exclude filter it excludes every device, so the ' +
'assignment reaches nobody') }
) {
$warnings = @()
$excludeSplat = @{ Rule = $Rule; Device = $script:Joined; Mode = 'Exclude' }
$result = Test-IntuneAssignmentFilter @excludeSplat -WarningVariable warnings 3>$null
"$($warnings[0])" | Should-BeLikeString $Note
$result.Warnings[0] | Should-BeLikeString $Note
# Include mode keeps the parser's text, which already says what happens to the assignment
$included = Test-IntuneAssignmentFilter -Rule $Rule -Device $script:Joined 3>$null
$included.Warnings[0] | Should-NotBeLikeString '*as an exclude filter*'
}

It 'reads the local device when none is given' {
Mock Get-IslFilterDeviceFact -ModuleName IntuneScriptLab {
@{ deviceName = 'LOCAL-1'; cpuArchitecture = 'arm64' }
Expand Down
21 changes: 21 additions & 0 deletions Tests/Unit/Public/Test-IntuneDeployedScript.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,27 @@ Describe 'Test-IntuneDeployedScript' -Tag 'Unit', 'Public' {
} -Times 1 -Exactly
}

It 'says that a never-matching clause on an exclude filter reaches every device in the group' {
# Widget 1.0 carries the lab-devices filter in exclude mode; point it at the x64 one
$target = $script:Tenant.apps[1].assignments[0].target
$target.deviceAndAppManagementAssignmentFilterId = 'flt-x64'

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 fixture's one exclude assignment points at the lab-devices filter, which produces no warning, so the test redirects it to the x64 filter and restores it in finally. The tenant hashtable is shared across the file, hence the restore rather than a second fixture.

try {
$findings = @(Test-IntuneDeployedScript -IncludeRule IslFilterIssue)
}
finally {
$target.deviceAndAppManagementAssignmentFilterId = 'flt-lab'
}
$excluded = @($findings | Where-Object PolicyName -eq 'Widget 1.0')
$excluded.Count | Should-Be 1
$excluded[0].Severity | Should-Be 'Warning'
$excluded[0].Message | Should-BeLikeString ("Filter 'x64 only' (exclude): 'x64'*never matches; " +
'as an exclude filter it excludes nobody, so the assignment reaches every device in the group. ' +
'Rule: *')
# The include assignments keep the parser's text
@($findings | Where-Object PolicyName -eq 'Fix-Widget')[0].Message |
Should-NotBeLikeString '*as an exclude filter*'
}

It 'skips the filter check after the tenant refuses to show a filter' {
$filterRoute = { $Uri -like '*/assignmentFilters/*' }
Mock Invoke-IslGraphRequest -ModuleName IntuneScriptLab -ParameterFilter $filterRoute {
Expand Down
16 changes: 12 additions & 4 deletions docs/IntuneScriptLab/Test-IntuneAssignmentFilter.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,16 @@ match excludes the device.

### EXAMPLE 3

Get-MgBetaDeviceManagementAssignmentFilter | Test-IntuneAssignmentFilter -SyntaxOnly |
Get-MgBetaDeviceManagementAssignmentFilter -All |
Where-Object { "$($_.Platform)" -eq 'windows10AndLater' } |
Test-IntuneAssignmentFilter -SyntaxOnly |
Where-Object Warnings | Select-Object Rule, Warnings

Every filter in the tenant whose rule can never match a Windows device (a "x64" architecture,
a "Microsoft Entra joined" trust type), without evaluating anything.
Every Windows filter in the tenant whose rule can never match a Windows device (a "x64"
architecture, a "Microsoft Entra joined" trust type), without evaluating anything. The platform

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 reason is spelled out because the obvious guess (non-Windows filters fail to parse) is mostly wrong: an iOS filter on osVersion or enrollmentProfileName parses fine. The hazard is a false "never matches" on a correct macOS value such as x64, which the Windows value table does not contain.

filter matters: the parser knows the Windows property set and value tables, so a macOS filter
with the correct "x64" would be reported as never matching, and an iOS-only property such as
isRooted is refused.

### EXAMPLE 4

Expand Down Expand Up @@ -144,7 +149,10 @@ HelpMessage: ''

Include (the default) or Exclude: how the filter is attached to the assignment.
The verdict
is Applicable, which is Matched for an include filter and not Matched for an exclude one.
is Applicable, which is Matched for an include filter and not Matched for an exclude one. In
Exclude mode a warning about a clause that never matches adds that the filter excludes nobody,
so the assignment reaches every device in the group; one that matches every device adds that
the assignment reaches nobody.

```yaml
Type: System.String
Expand Down
Loading
Loading