Repository navigation
fix(filters): name the escaped quote, the whitespace -contains and the exclude-mode effect #33
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
|
|
@@ -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 | ||
| [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 { | ||
|
|
@@ -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 } | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| else { | ||
| Write-Failure ("expected ',' or ']' at position $($next.Position), " + | ||
| "found '$($next.Text)'") | ||
| } | ||
| return | ||
| } | ||
| } | ||
|
|
@@ -190,6 +212,13 @@ function ConvertFrom-IslFilterRule { | |
| } | ||
| } | ||
| } | ||
| if ($Operator -eq 'contains' -and $kind -eq 'String' -and "$value".Trim().Length -eq 0) { | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Only |
||
| # 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 | ||
| } | ||
|
|
@@ -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++ | ||
|
|
@@ -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)" } | ||
| } | ||
| } | ||
|
|
||
| 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') { '' } | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A separate helper rather than a |
||
| 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" | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -190,11 +190,13 @@ function Test-IntuneDeployedScript { | |
| ConvertTo-PolicyFinding @unreadSplat | ||
| continue | ||
| } | ||
| $mode = if ($filterType -eq 'exclude') { 'Exclude' } else { 'Include' } | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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*' } | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| @{ 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 ')'*" } | ||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
| } | ||
|
|
||
| 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' | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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' | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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 { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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 | ||
|
|
||
|
|
@@ -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 | ||
|
|
||
There was a problem hiding this comment.
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.