diff --git a/Build/Build-RuleReference.ps1 b/Build/Build-RuleReference.ps1 index 22257f2..1e37503 100644 --- a/Build/Build-RuleReference.ps1 +++ b/Build/Build-RuleReference.ps1 @@ -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) + '' + + $text.Substring($index + $nested.Extent.Text.Length) + } + } + return $text.Replace('$', [string][char]1) + } 'ParenExpressionAst' { $inner = $Ast.Pipeline.PipelineElements[0] if ($inner.PSObject.Properties['Expression']) { @@ -142,6 +154,7 @@ function ConvertTo-Placeholder { $text = [regex]::Replace($Text, '\$\((?:[^()]|\((?:[^()]|\([^()]*\))*\))*\)', '') $text = [regex]::Replace($text, '\$\{[^}]+\}', '') $text = [regex]::Replace($text, '\$[A-Za-z_][\w:]*', '') + $text = $text.Replace([string][char]1, '$') ($text -replace '\s+', ' ').Trim() } diff --git a/CHANGELOG.md b/CHANGELOG.md index ac94a26..0a3244d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 -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 ``. +- 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. @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index eb40200..bf62233 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/Examples/IntuneScriptLab.settings.psd1 b/Examples/IntuneScriptLab.settings.psd1 index dc2204d..b90fc03 100644 --- a/Examples/IntuneScriptLab.settings.psd1 +++ b/Examples/IntuneScriptLab.settings.psd1 @@ -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. diff --git a/Examples/intune-script-gate.yml b/Examples/intune-script-gate.yml index ee7a4aa..eedb1c5 100644 --- a/Examples/intune-script-gate.yml +++ b/Examples/intune-script-gate.yml @@ -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 @@ -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 diff --git a/IntuneScriptLab.Format.ps1xml b/IntuneScriptLab.Format.ps1xml index 19d0246..d6f6753 100644 --- a/IntuneScriptLab.Format.ps1xml +++ b/IntuneScriptLab.Format.ps1xml @@ -298,7 +298,10 @@ Name - $_.Duration.ToString('hh\:mm\:ss') + + if ($_.Duration.Days) { '{0}d {1}' -f $_.Duration.Days, $_.Duration.ToString('hh\:mm\:ss') } + else { $_.Duration.ToString('hh\:mm\:ss') } + Runs diff --git a/IntuneScriptLab.psd1 b/IntuneScriptLab.psd1 index 7a9bfd0..4502cef 100644 --- a/IntuneScriptLab.psd1 +++ b/IntuneScriptLab.psd1 @@ -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. @@ -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 diff --git a/Private/ConvertFrom-IslFilterRule.ps1 b/Private/ConvertFrom-IslFilterRule.ps1 index b317aea..4acfd36 100644 --- a/Private/ConvertFrom-IslFilterRule.ps1 +++ b/Private/ConvertFrom-IslFilterRule.ps1 @@ -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") } } } diff --git a/Private/Get-IslOemEncoding.ps1 b/Private/Get-IslOemEncoding.ps1 index c31608b..9703db7 100644 --- a/Private/Get-IslOemEncoding.ps1 +++ b/Private/Get-IslOemEncoding.ps1 @@ -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' } diff --git a/Private/Invoke-IslProcess.ps1 b/Private/Invoke-IslProcess.ps1 index aa9c891..96c215b 100644 --- a/Private/Invoke-IslProcess.ps1 +++ b/Private/Invoke-IslProcess.ps1 @@ -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. @@ -162,6 +162,7 @@ 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 @@ -169,17 +170,29 @@ function Invoke-IslProcess { # 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" diff --git a/Private/Invoke-IslScriptRun.ps1 b/Private/Invoke-IslScriptRun.ps1 index 9f03bcd..b6aaf77 100644 --- a/Private/Invoke-IslScriptRun.ps1 +++ b/Private/Invoke-IslScriptRun.ps1 @@ -52,7 +52,7 @@ function Invoke-IslScriptRun { ) $hostInfo = Get-IslHostPath -Architecture $Architecture - $source = (Resolve-Path -LiteralPath $Path).ProviderPath + $source = (Resolve-Path -LiteralPath $Path -ErrorAction Stop).ProviderPath # Like IMECache: the script runs from a copy, so $PSScriptRoot is not the source folder $runId = [guid]::NewGuid().ToString('N') diff --git a/Private/Rules/Find-IslContextIssue.ps1 b/Private/Rules/Find-IslContextIssue.ps1 index f0180ef..9e8a992 100644 --- a/Private/Rules/Find-IslContextIssue.ps1 +++ b/Private/Rules/Find-IslContextIssue.ps1 @@ -61,7 +61,7 @@ function Find-IslContextIssue { Context = $Context Extent = $variable.Extent Message = ("`$$name resolves to the SYSTEM profile (systemprofile), not the signed-in user. " + - "Look the user up (e.g. via explorer.exe''s owner or HKU) or run in user context") + "Look the user up (e.g. via explorer.exe's owner or HKU) or run in user context") Evidence = $evidence } New-IslFinding @findingSplat diff --git a/Private/Rules/Find-IslEncodingIssue.ps1 b/Private/Rules/Find-IslEncodingIssue.ps1 index c99f84a..ca40db9 100644 --- a/Private/Rules/Find-IslEncodingIssue.ps1 +++ b/Private/Rules/Find-IslEncodingIssue.ps1 @@ -51,16 +51,36 @@ } if ($hasNonAscii -and -not $hasUtf8Bom) { - $findingSplat = @{ - RuleName = $rule - Severity = 'Warning' - Context = $Context - Extent = $Context.Ast.Extent - Message = ('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') - Evidence = ('Same bytes uploaded with and without BOM: without, the literal "Grüße — ✓" ran as ' + - '"Grüße â€" âœ"" (REM-ENC-BOM vs REM-PROBE-SYS64)') - Fix = @{ Encoding = 'UTF8BOM' } + $isUtf8 = $true + try { $null = [System.Text.UTF8Encoding]::new($false, $true).GetString($bytes) } catch { $isUtf8 = $false } + if ($isUtf8) { + $findingSplat = @{ + RuleName = $rule + Severity = 'Warning' + Context = $Context + Extent = $Context.Ast.Extent + Message = ('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') + Evidence = ('Same bytes uploaded with and without BOM: without, the literal "Grüße — ✓" ran ' + + 'as "Grüße â€" âœ"" (REM-ENC-BOM vs REM-PROBE-SYS64)') + Fix = @{ Encoding = 'UTF8BOM' } + } + } + else { + # Not UTF-8 at all: an ANSI file. 5.1 reads it in the ANSI code page, so the characters + # survive on the device, but nothing else expects it. Repair decodes it as ANSI + $findingSplat = @{ + RuleName = $rule + Severity = 'Information' + Context = $Context + Extent = $Context.Ast.Extent + Message = ('Non-ASCII bytes that are not UTF-8 (an ANSI code page): Windows PowerShell 5.1 ' + + 'reads the file as ANSI, but Intune expects UTF-8 and other tooling reads it as such. ' + + 'Save as UTF-8 with BOM') + Evidence = ('Microsoft Learn: "Ensure the scripts are encoded in UTF-8"; Windows PowerShell ' + + 'reads a file without a BOM in the system ANSI code page (about_Character_Encoding)') + Fix = @{ Encoding = 'UTF8BOM' } + } } New-IslFinding @findingSplat } diff --git a/Private/Rules/Find-IslInteractiveCall.ps1 b/Private/Rules/Find-IslInteractiveCall.ps1 index 6141a86..26ed4eb 100644 --- a/Private/Rules/Find-IslInteractiveCall.ps1 +++ b/Private/Rules/Find-IslInteractiveCall.ps1 @@ -81,7 +81,7 @@ function Find-IslInteractiveCall { foreach ($command in (Find-IslCommand -Ast $ast -Name $commandName)) { $forced = $confirming[$commandName] -and (Test-IslCommandParameter -Command $command -ParameterName $confirming[$commandName]) - $silenced = $forced -or (Test-IslCommandParameter -Command $command -ParameterName 'Confirm') + $silenced = $forced -or ($command.Extent.Text -match '(?i)-Confirm:\s*\$false') if (-not $silenced) { $findingSplat = @{ RuleName = $rule diff --git a/Private/Rules/Find-IslOutputIssue.ps1 b/Private/Rules/Find-IslOutputIssue.ps1 index e48d49d..5be0a40 100644 --- a/Private/Rules/Find-IslOutputIssue.ps1 +++ b/Private/Rules/Find-IslOutputIssue.ps1 @@ -157,7 +157,7 @@ Where-Object { -not (Test-IslCommandParameter -Command $_ -ParameterName 'ErrorAction') }) $preferenceSet = Find-IslAstNode -Ast $ast -TypeName AssignmentStatementAst -Where { param($node) $node.Left.Extent.Text -match '(?i)^\$ErrorActionPreference$' -and - $node.Right.Extent.Text -match '(?i)SilentlyContinue|Ignore|Stop' + $node.Right.Extent.Text -match '(?i)SilentlyContinue|Ignore' } if ($unguarded.Count -gt 0 -and -not $preferenceSet) { $findingSplat = @{ @@ -271,7 +271,7 @@ Where-Object { -not (Test-IslCommandParameter -Command $_ -ParameterName 'ErrorAction') }) $preferenceSet = Find-IslAstNode -Ast $ast -TypeName AssignmentStatementAst -Where { param($node) $node.Left.Extent.Text -match '(?i)^\$ErrorActionPreference$' -and - $node.Right.Extent.Text -match '(?i)SilentlyContinue|Ignore|Stop' + $node.Right.Extent.Text -match '(?i)SilentlyContinue|Ignore' } if ($unguarded.Count -gt 0 -and -not $preferenceSet) { $findingSplat = @{ diff --git a/Private/Rules/Find-IslPowerShell7Syntax.ps1 b/Private/Rules/Find-IslPowerShell7Syntax.ps1 index 9e957f7..2e54964 100644 --- a/Private/Rules/Find-IslPowerShell7Syntax.ps1 +++ b/Private/Rules/Find-IslPowerShell7Syntax.ps1 @@ -31,6 +31,8 @@ function Find-IslPowerShell7Syntax { $ast = $Context.Ast foreach ($parseError in $Context.ParseErrors) { + # A module the parser could not find (using module) is a dependency, not PowerShell 7 syntax + if ("$($parseError.ErrorId)" -eq 'ModuleNotFoundDuringParse') { continue } $findingSplat = @{ RuleName = $rule Severity = 'Error' @@ -152,10 +154,10 @@ function Find-IslPowerShell7Syntax { 'Add-Content' = 'AsByteStream' 'Out-File' = 'Encoding utf8NoBOM' 'Start-Process' = 'Environment' - 'Get-Process' = 'CommandLine' + 'Compress-Archive' = 'PassThru' 'Import-Module' = 'UseWindowsPowerShell', 'SkipEditionCheck' - 'New-TemporaryFile' = 'Extension' + 'Rename-Item' = '' } foreach ($commandName in $coreOnlyParameters.Keys) { diff --git a/Private/Rules/Find-IslScriptSize.ps1 b/Private/Rules/Find-IslScriptSize.ps1 index 3fb70ce..9a95830 100644 --- a/Private/Rules/Find-IslScriptSize.ps1 +++ b/Private/Rules/Find-IslScriptSize.ps1 @@ -30,13 +30,24 @@ function Find-IslScriptSize { $rule = 'IslScriptSize' $bytes = $Context.Bytes.Length if ($bytes -le 200KB) { return } - $kind = if ($Context.ScriptType -eq 'PlatformScript') { 'platform script' } else { 'remediation' } + $kind = switch ($Context.ScriptType) { + 'PlatformScript' { 'platform script' } + 'Win32Detection' { 'Win32 detection script' } + 'Win32Requirement' { 'Win32 requirement script' } + default { 'remediation' } + } + # Only remediations and platform scripts were measured; Win32 scripts get the remediation limits + $measured = if ($kind -like 'Win32*') { 'remediation' } else { $kind } # The largest sizes the API accepted and the smallest it refused, per type (round 7) $accepted = if ($kind -eq 'platform script') { 660KB } else { 504KB } $refused = if ($kind -eq 'platform script') { 680KB } else { 512KB } + $win32Note = if ($kind -like 'Win32*') { + '; Win32 detection and requirement scripts were not measured, the remediation limits are assumed' + } + else { '' } $evidence = ('Microsoft Learn: "must be less than 200 KB"; the Graph API accepted a 504 KB remediation ' + 'and a 660 KB platform script and refused 512 KB and 680 KB; a 250 KB remediation and a 500 KB ' + - 'platform script ran on the device (REM-SIZE-250KB, PS-SIZE-500KB)') + 'platform script ran on the device (REM-SIZE-250KB, PS-SIZE-500KB)' + $win32Note) $sizeKB = [math]::Round($bytes / 1KB) if ($bytes -ge $refused) { @@ -45,7 +56,8 @@ function Find-IslScriptSize { Severity = 'Error' Context = $Context Extent = $Context.Ast.Extent - Message = ("The file is $sizeKB KB; the service refused a $kind of $([int]($refused / 1KB)) KB, " + + Message = ("The file is $sizeKB KB; the service refused a $measured of $([int]($refused / 1KB)) " + + 'KB, ' + 'so this one cannot be uploaded. Split it or move the bulk into content the script downloads') Evidence = $evidence } @@ -56,7 +68,7 @@ function Find-IslScriptSize { Severity = 'Warning' Context = $Context Extent = $Context.Ast.Extent - Message = ("The file is $sizeKB KB, over the documented 200 KB limit. The API took a $kind of " + + Message = ("The file is $sizeKB KB, over the documented 200 KB limit. The API took a $measured of " + "up to $([int]($accepted / 1KB)) KB and the device ran one this size, but the portal and " + 'other tooling may hold to 200 KB') Evidence = $evidence diff --git a/Public/Assert-IntuneResult.ps1 b/Public/Assert-IntuneResult.ps1 index b6f0e50..1c5d726 100644 --- a/Public/Assert-IntuneResult.ps1 +++ b/Public/Assert-IntuneResult.ps1 @@ -166,12 +166,15 @@ function Assert-PassIntuneAnalysis { ) Test-IslPesterAssertionSupport - # One script per assertion; a folder is a -ForEach in the test, which names each file + # A file, a folder, or a pipeline of either: every script found is analyzed and the failure + # names each file $assert = New-ShouldAssertion -Caller $PSCmdlet -Actual $Actual -Buffer $Input $Actual = $assert.Actual() - $path = if ($Actual -is [System.IO.FileSystemInfo]) { $Actual.FullName } else { "$Actual" } + $paths = @(foreach ($item in @($Actual)) { + if ($item -is [System.IO.FileSystemInfo]) { $item.FullName } else { "$item" } + }) - $findings = @(Test-IntuneScript -Path $path -ScriptType $ScriptType -MinimumSeverity $MinimumSeverity | + $findings = @(Test-IntuneScript -Path $paths -ScriptType $ScriptType -MinimumSeverity $MinimumSeverity | Where-Object { $_.RuleName -ne 'IslAssumedContext' }) if ($findings.Count -eq 0) { return } diff --git a/Public/Compare-IntuneDeployedScript.ps1 b/Public/Compare-IntuneDeployedScript.ps1 index 9e71ba3..d323f49 100644 --- a/Public/Compare-IntuneDeployedScript.ps1 +++ b/Public/Compare-IntuneDeployedScript.ps1 @@ -34,6 +34,9 @@ function Compare-IntuneDeployedScript { $settingsCache = @{} $mapped = @{} $sha = [System.Security.Cryptography.SHA256]::Create() + # Captured here: inside the nested functions $PSBoundParameters is their own, not this command's + $nameGiven = $PSBoundParameters.ContainsKey('Name') + $settingsGiven = $PSBoundParameters.ContainsKey('Settings') function Get-NameKey { param([string]$Value) @@ -43,7 +46,8 @@ function Compare-IntuneDeployedScript { function Test-Wanted { param($Policy) $displayName = "$($Policy.displayName)" - $byName = @($Name | Where-Object { $displayName -like $_ }).Count -gt 0 + # -Id alone selects by id: -Name's default of '*' only counts when -Name was given or -Id was not + $byName = ($nameGiven -or -not $Id) -and @($Name | Where-Object { $displayName -like $_ }).Count -gt 0 $byId = $Id -and "$($Policy.id)" -in $Id $byName -or $byId } @@ -150,22 +154,25 @@ function Compare-IntuneDeployedScript { $tenantLines = @($tenantText.Text -split "`r?`n") $localTrim = Get-TrimmedLine -Lines @($localLines | ForEach-Object { $_.TrimEnd() }) $tenantTrim = Get-TrimmedLine -Lines @($tenantLines | ForEach-Object { $_.TrimEnd() }) - if (($localTrim -join "`n") -ne ($tenantTrim -join "`n")) { + if (($localTrim -join "`n") -cne ($tenantTrim -join "`n")) { $differences.Add('Content') $line = 0 $limit = [Math]::Min($localTrim.Count, $tenantTrim.Count) - while ($line -lt $limit -and $localTrim[$line] -eq $tenantTrim[$line]) { $line++ } + while ($line -lt $limit -and $localTrim[$line] -ceq $tenantTrim[$line]) { $line++ } $localExcerpt = '' if ($line -lt $localTrim.Count) { $localExcerpt = Get-Excerpt -Line $localTrim[$line] } $tenantExcerpt = '' if ($line -lt $tenantTrim.Count) { $tenantExcerpt = Get-Excerpt -Line $tenantTrim[$line] } - $compared = @(Compare-Object -ReferenceObject @($localTrim) -DifferenceObject @($tenantTrim)) + $compareSplat = @{ + ReferenceObject = @($localTrim); DifferenceObject = @($tenantTrim); CaseSensitive = $true + } + $compared = @(Compare-Object @compareSplat) $onlyLocal = @($compared | Where-Object SideIndicator -eq '<=').Count $onlyTenant = @($compared | Where-Object SideIndicator -eq '=>').Count $details.Add(("content differs from line $($line + 1): local '$localExcerpt', tenant " + "'$tenantExcerpt' ($onlyLocal line(s) only local, $onlyTenant only in the tenant)")) } - elseif (($localLines -join "`n") -ne ($tenantLines -join "`n")) { + elseif (($localLines -join "`n") -cne ($tenantLines -join "`n")) { $differences.Add('Whitespace') $details.Add('only trailing whitespace or blank lines at the end differ') } @@ -189,7 +196,7 @@ function Compare-IntuneDeployedScript { } } $settingSplat = @{ Path = $File; Cache = $settingsCache } - if ($PSBoundParameters.ContainsKey('Settings')) { $settingSplat.Settings = $Settings } + if ($settingsGiven) { $settingSplat.Settings = $Settings } $fileSettings = Get-IslSetting @settingSplat $explicit = @{} foreach ($key in 'Context', 'Architecture', 'EnforceSignatureCheck') { @@ -335,7 +342,7 @@ function Compare-IntuneDeployedScript { $remediations = '/beta/deviceManagement/deviceHealthScripts' foreach ($summary in (Get-WantedPolicy -Uri "$remediations`?`$select=id,displayName")) { $policy = Invoke-IslGraphRequest -Uri "$remediations/$($summary.id)" - $settings = @{ + $policySettings = @{ RunAsAccount = $policy.runAsAccount; RunAs32Bit = $policy.runAs32Bit EnforceSignatureCheck = $policy.enforceSignatureCheck } @@ -344,13 +351,13 @@ function Compare-IntuneDeployedScript { Content = $policy.detectionScriptContent Candidates = Find-LocalFile -Policy $policy -Role 'detection' -Pattern '^(?i)detect' } - Compare-Role @detectSplat @settings + Compare-Role @detectSplat @policySettings $remediateSplat = @{ PolicyKind = 'Remediation'; Policy = $policy; Role = 'remediation' Content = $policy.remediationScriptContent Candidates = Find-LocalFile -Policy $policy -Role 'remediation' -Pattern '^(?i)remediat' } - Compare-Role @remediateSplat @settings + Compare-Role @remediateSplat @policySettings } } diff --git a/Public/Export-IntuneFindingSarif.ps1 b/Public/Export-IntuneFindingSarif.ps1 index e560aaa..8a3b3ba 100644 --- a/Public/Export-IntuneFindingSarif.ps1 +++ b/Public/Export-IntuneFindingSarif.ps1 @@ -48,14 +48,15 @@ function Export-IntuneFindingSarif { } end { - function Get-RelativeUri { + function Get-ArtifactLocation { param([string]$FilePath) - $relative = if ($FilePath.StartsWith($rootPath, [System.StringComparison]::OrdinalIgnoreCase)) { - $FilePath.Substring($rootPath.Length).TrimStart('\', '/') + if ($FilePath.StartsWith($rootPath, [System.StringComparison]::OrdinalIgnoreCase)) { + $relative = $FilePath.Substring($rootPath.Length).TrimStart('\', '/') + $segments = $relative -split '[\\/]' | ForEach-Object { [System.Uri]::EscapeDataString($_) } + return [ordered]@{ uri = ($segments -join '/'); uriBaseId = '%SRCROOT%' } } - else { $FilePath } - $segments = $relative -split '[\\/]' | ForEach-Object { [System.Uri]::EscapeDataString($_) } - $segments -join '/' + # Outside the root: an absolute file URI, with no base to resolve against + [ordered]@{ uri = ([System.Uri]::new($FilePath)).AbsoluteUri } } $ruleIndex = [ordered]@{} @@ -63,7 +64,14 @@ function Export-IntuneFindingSarif { foreach ($name in @($all | ForEach-Object { $_.RuleName } | Sort-Object -Unique)) { $summary = '' $description = '' - $level = 'warning' + # The level of the most severe finding the rule produced in this log; the fixed-table + # rules carry their own + $rank = @{ note = 0; warning = 1; error = 2 } + $level = 'note' + foreach ($item in ($all | Where-Object RuleName -eq $name)) { + $itemLevel = $levels["$($item.Severity)"] + if ($itemLevel -and $rank[$itemLevel] -gt $rank[$level]) { $level = $itemLevel } + } if ($fixedRules.ContainsKey($name)) { $summary = $fixedRules[$name].Summary $level = $fixedRules[$name].Level @@ -97,10 +105,7 @@ function Export-IntuneFindingSarif { locations = @( [ordered]@{ physicalLocation = [ordered]@{ - artifactLocation = [ordered]@{ - uri = Get-RelativeUri -FilePath "$($item.ScriptPath)" - uriBaseId = '%SRCROOT%' - } + artifactLocation = Get-ArtifactLocation -FilePath "$($item.ScriptPath)" region = $region } } @@ -135,11 +140,13 @@ function Export-IntuneFindingSarif { ) } - $folder = Split-Path -Path $Path -Parent + # Resolved against the PowerShell location: .NET's current directory is not $PWD + $outFile = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) + $folder = Split-Path -Path $outFile -Parent if ($folder) { $null = New-Item -ItemType Directory -Path $folder -Force } $json = $log | ConvertTo-Json -Depth 12 - [System.IO.File]::WriteAllText($Path, $json, [System.Text.UTF8Encoding]::new($false)) - Write-Verbose "Wrote $($all.Count) result(s) for $($rules.Count) rule(s) to $Path" - Get-Item -LiteralPath $Path + [System.IO.File]::WriteAllText($outFile, $json, [System.Text.UTF8Encoding]::new($false)) + Write-Verbose "Wrote $($all.Count) result(s) for $($rules.Count) rule(s) to $outFile" + Get-Item -LiteralPath $outFile } } diff --git a/Public/Get-IntuneAgentTimeline.ps1 b/Public/Get-IntuneAgentTimeline.ps1 index 92399e5..046a906 100644 --- a/Public/Get-IntuneAgentTimeline.ps1 +++ b/Public/Get-IntuneAgentTimeline.ps1 @@ -38,10 +38,14 @@ function Get-IntuneAgentTimeline { $launches = 'RemediationStart', 'ScriptPolicyStart', 'AppExecution' $results = 'RemediationReport', 'DetectionResult', 'ScriptPolicyResult', 'ScriptExit', 'AppReport', - 'AppInstallOutcome', 'AppDetection', 'AppApplicability', 'EspAppState' + 'AppInstallOutcome', 'AppRelationshipReport', 'AppDetection', 'AppApplicability', 'EspAppState' $kindByLog = @{ HealthScripts = 'Remediation'; AppWorkload = 'Win32App' } - $timelines = foreach ($group in ($entries | Where-Object { $_.Event -and $_.Id } | Group-Object Id)) { + $groups = @($entries | Where-Object { $_.Event -and $_.Id } | Group-Object Id) + # -Id keeps every line that mentions an id (a relationship line names two); the timeline is + # the one whose own id it is + if ($Id) { $groups = @($groups | Where-Object { "$($_.Name)" -in $Id }) } + $timelines = foreach ($group in $groups) { $steps = @($group.Group | Sort-Object Time, Log, Line | ForEach-Object { [pscustomobject]@{ PSTypeName = 'IntuneScriptLab.AgentTimelineStep' diff --git a/Public/Get-IntuneScriptHealth.ps1 b/Public/Get-IntuneScriptHealth.ps1 index e61ae8c..e7a66cc 100644 --- a/Public/Get-IntuneScriptHealth.ps1 +++ b/Public/Get-IntuneScriptHealth.ps1 @@ -30,11 +30,14 @@ function Get-IntuneScriptHealth { [string]$MarkdownPath ) Write-Verbose "Starting $($MyInvocation.MyCommand.Name) for $($Kind -join ', ')" + # Captured here: inside the nested functions $PSBoundParameters is their own, not this command's + $nameGiven = $PSBoundParameters.ContainsKey('Name') function Test-Wanted { param($Policy) $displayName = "$($Policy.displayName)" - $byName = @($Name | Where-Object { $displayName -like $_ }).Count -gt 0 + # -Id alone selects by id: -Name's default of '*' only counts when -Name was given or -Id was not + $byName = ($nameGiven -or -not $Id) -and @($Name | Where-Object { $displayName -like $_ }).Count -gt 0 $byId = $Id -and "$($Policy.id)" -in $Id $byName -or $byId } @@ -87,7 +90,9 @@ function Get-IntuneScriptHealth { } catch { Write-Warning ("The app install export could not be read ($($_.Exception.Message)); the apps' " + - 'device columns stay empty. DeviceManagementManagedDevices.Read.All allows it') + 'device columns stay empty. Creating an export job needs a ReadWrite scope: ' + + 'DeviceManagementApps.ReadWrite.All, DeviceManagementConfiguration.ReadWrite.All or ' + + 'DeviceManagementManagedDevices.ReadWrite.All') } } @@ -155,9 +160,8 @@ function Get-IntuneScriptHealth { $notes = [System.Collections.Generic.List[string]]::new() $health = 'Healthy' - $unassigned = @($findings | Where-Object { - $_.RuleName -eq 'IslAssignmentIssue' -and $_.Message -like '*never runs anywhere*' - }).Count -gt 0 + # From the assignments themselves, so -SkipAnalysis does not hide it + $unassigned = $assignment.Includes -eq 0 $allFailed = $HasRunState -and $Failed -gt 0 -and $Succeeded -eq 0 if ($errors) { $notes.Add("$errors error finding(s)") } if ($unassigned) { $notes.Add('assigned to nobody') } @@ -281,7 +285,9 @@ function Get-IntuneScriptHealth { } $lines.Add('') } - [System.IO.File]::WriteAllLines($MarkdownPath, $lines, [System.Text.UTF8Encoding]::new($false)) + # Resolved against the PowerShell location: .NET's current directory is not $PWD + $markdownFile = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($MarkdownPath) + [System.IO.File]::WriteAllLines($markdownFile, $lines, [System.Text.UTF8Encoding]::new($false)) Write-Verbose "Markdown report written to $MarkdownPath" } diff --git a/Public/Repair-IntuneScript.ps1 b/Public/Repair-IntuneScript.ps1 index 2eab902..c3a789a 100644 --- a/Public/Repair-IntuneScript.ps1 +++ b/Public/Repair-IntuneScript.ps1 @@ -27,7 +27,7 @@ function Repair-IntuneScript { foreach ($resolved in (Resolve-Path -Path $item -ErrorAction Stop)) { if (Test-Path -LiteralPath $resolved.ProviderPath -PathType Container) { Get-ChildItem -LiteralPath $resolved.ProviderPath -Recurse -Filter *.ps1 -File | - ForEach-Object FullName + ForEach-Object { $_.FullName } } else { $resolved.ProviderPath } } @@ -55,7 +55,16 @@ function Repair-IntuneScript { $bytes[2] -eq 0xBF) { [System.Text.UTF8Encoding]::new($true) } - else { [System.Text.UTF8Encoding]::new($false) } + else { + # No BOM: UTF-8 when the bytes decode as such, otherwise the ANSI code page + # Windows PowerShell 5.1 reads it in (a UTF-8 decode would turn every non-ASCII + # character into U+FFFD and lose it for good) + try { + $null = [System.Text.UTF8Encoding]::new($false, $true).GetString($bytes) + [System.Text.UTF8Encoding]::new($false) + } + catch { Get-IslOemEncoding -Kind ANSI } + } $text = $encoding.GetString($bytes).TrimStart([char]0xFEFF) $lineStarts = [System.Collections.Generic.List[int]]::new() $lineStarts.Add(0) diff --git a/Public/Test-IntuneDeployedScript.ps1 b/Public/Test-IntuneDeployedScript.ps1 index 03d14cf..8c64e73 100644 --- a/Public/Test-IntuneDeployedScript.ps1 +++ b/Public/Test-IntuneDeployedScript.ps1 @@ -29,6 +29,9 @@ function Test-IntuneDeployedScript { $Settings ) Write-Verbose "Starting $($MyInvocation.MyCommand.Name) for $($PSBoundParameters.Keys -join ', ')" + # Captured here: inside the nested functions $PSBoundParameters is their own, not this command's + $nameGiven = $PSBoundParameters.ContainsKey('Name') + $settingsGiven = $PSBoundParameters.ContainsKey('Settings') $severityRank = @{ Information = 0; Warning = 1; Error = 2 } $workName = "IntuneScriptLab\preflight-$([guid]::NewGuid().ToString('N'))" @@ -51,7 +54,8 @@ function Test-IntuneDeployedScript { function Test-Wanted { param($Policy) $displayName = "$($Policy.displayName)" - $byName = @($Name | Where-Object { $displayName -like $_ }).Count -gt 0 + # -Id alone selects by id: -Name's default of '*' only counts when -Name was given or -Id was not + $byName = ($nameGiven -or -not $Id) -and @($Name | Where-Object { $displayName -like $_ }).Count -gt 0 $byId = $Id -and "$($Policy.id)" -in $Id $byName -or $byId } @@ -110,7 +114,7 @@ function Test-IntuneDeployedScript { EnforceSignatureCheck = [bool]$EnforceSignatureCheck MinimumSeverity = $MinimumSeverity } - if ($PSBoundParameters.ContainsKey('Settings')) { $testSplat.Settings = $Settings } + if ($settingsGiven) { $testSplat.Settings = $Settings } if ($IncludeRule) { $testSplat.IncludeRule = $IncludeRule } if ($ExcludeRule) { $testSplat.ExcludeRule = $ExcludeRule } Write-Verbose "$PolicyKind '$($Policy.displayName)' $Role as $ScriptType, $context, $architecture" @@ -266,7 +270,7 @@ function Test-IntuneDeployedScript { $remediations = '/beta/deviceManagement/deviceHealthScripts' foreach ($summary in (Get-WantedPolicy -Uri "$remediations`?`$select=id,displayName")) { $policy = Invoke-IslGraphRequest -Uri "$remediations/$($summary.id)?`$expand=assignments" - $settings = @{ + $policySettings = @{ RunAsAccount = $policy.runAsAccount; RunAs32Bit = $policy.runAs32Bit EnforceSignatureCheck = $policy.enforceSignatureCheck } @@ -274,13 +278,13 @@ function Test-IntuneDeployedScript { PolicyKind = 'Remediation'; Policy = $policy; Role = 'detection'; ScriptType = 'Detection' Content = $policy.detectionScriptContent } - Test-PolicyScript @detectSplat @settings + Test-PolicyScript @detectSplat @policySettings if ($policy.remediationScriptContent) { $remediateSplat = @{ PolicyKind = 'Remediation'; Policy = $policy; Role = 'remediation' ScriptType = 'Remediation'; Content = $policy.remediationScriptContent } - Test-PolicyScript @remediateSplat @settings + Test-PolicyScript @remediateSplat @policySettings } else { $noteSplat = @{ @@ -362,14 +366,16 @@ function Test-IntuneDeployedScript { if ("$($app.installExperience.runAsAccount)" -eq 'user') { $deviceGroups = foreach ($assignment in @($app.assignments)) { $target = $assignment.target - if ("$($target.'@odata.type')" -ne '#microsoft.graph.groupAssignmentTarget') { continue } + $type = "$($target.'@odata.type')" + if ($type -eq '#microsoft.graph.allDevicesAssignmentTarget') { 'all devices'; continue } + if ($type -ne '#microsoft.graph.groupAssignmentTarget') { continue } if (Test-DeviceGroup -GroupId "$($target.groupId)") { "$($target.groupId)" } } if ($deviceGroups) { $assignmentSplat = @{ PolicyKind = 'Win32App'; Policy = $app; Rule = 'IslAssignmentIssue' Severity = 'Warning' - Message = 'Install behavior User, assigned to a group of devices ' + + Message = 'Install behavior User, assigned to devices ' + "($($deviceGroups -join ', ')): the app is never installed there. Assign " + 'it to users' Evidence = 'A user-context app assigned to a device group was never installed ' + diff --git a/Public/Test-IntuneScript.ps1 b/Public/Test-IntuneScript.ps1 index a4f893d..1f588f7 100644 --- a/Public/Test-IntuneScript.ps1 +++ b/Public/Test-IntuneScript.ps1 @@ -54,7 +54,7 @@ function Test-IntuneScript { foreach ($resolved in (Resolve-Path -Path $item -ErrorAction Stop)) { if (Test-Path -LiteralPath $resolved.ProviderPath -PathType Container) { Get-ChildItem -LiteralPath $resolved.ProviderPath -Recurse -Filter *.ps1 -File | - ForEach-Object FullName + ForEach-Object { $_.FullName } } else { $resolved.ProviderPath } } @@ -67,7 +67,10 @@ function Test-IntuneScript { $fileSettings = Get-IslSetting @settingsSplat $include = if ($PSBoundParameters.ContainsKey('IncludeRule')) { $IncludeRule } else { $fileSettings.IncludeRule } - $exclude = @($ExcludeRule) + @($fileSettings.ExcludeRule) | Where-Object { $_ } + # An explicit -IncludeRule names what to run, so the file's exclusions stand aside for it + $exclude = if ($PSBoundParameters.ContainsKey('IncludeRule')) { @($ExcludeRule) } + else { @($ExcludeRule) + @($fileSettings.ExcludeRule) } + $exclude = @($exclude | Where-Object { $_ }) $minimum = if ($PSBoundParameters.ContainsKey('MinimumSeverity')) { $MinimumSeverity } elseif ($fileSettings.MinimumSeverity) { $fileSettings.MinimumSeverity } else { $MinimumSeverity } @@ -83,7 +86,10 @@ function Test-IntuneScript { Architecture = $Architecture Settings = $fileSettings } - if ($EnforceSignatureCheck) { $scriptContextSplat.EnforceSignatureCheck = 'True' } + # Bound either way it is explicit: the pre-flight passes the policy's own false value + if ($PSBoundParameters.ContainsKey('EnforceSignatureCheck')) { + $scriptContextSplat.EnforceSignatureCheck = if ($EnforceSignatureCheck) { 'True' } else { 'False' } + } $scriptContext = Get-IslScriptContext @scriptContextSplat Write-Verbose ("$file : $($scriptContext.ScriptType) ($($scriptContext.TypeSource)), " + "$($scriptContext.Context), $($scriptContext.Architecture)") @@ -93,7 +99,7 @@ function Test-IntuneScript { }) # Say what was assumed: the wrong type silently skips whole rule sets. The note obeys # the rule filters, so -ExcludeRule IslAssumedContext silences it - $noteWanted = $scriptContext.TypeSource -notin 'parameter', 'settings' -and + $noteWanted = $scriptContext.TypeSource -notin 'parameter', 'settings', 'directive' -and (Test-RuleSelected -RuleName 'IslAssumedContext' @filters) if ($noteWanted) { $findingSplat = @{ @@ -102,7 +108,8 @@ function Test-IntuneScript { Context = $scriptContext Message = ("Analyzed as $($scriptContext.ScriptType) ($($scriptContext.TypeSource)), " + "$($scriptContext.Context) context, $($scriptContext.Architecture): the portal " + - 'defaults; a deployment through the Graph API or IaC gets 64-bit SYSTEM. Pass ' + + 'defaults where nothing said otherwise; a deployment through the Graph API or IaC ' + + 'gets 64-bit SYSTEM. Pass ' + "-ScriptType/-Context/-Architecture or add a '# IntuneScriptLab:' " + 'directive if that is wrong') Evidence = ('Portal defaults: platform scripts run as the user in 32-bit, remediations ' + diff --git a/README.md b/README.md index 8600068..050cf0f 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ scripts, platform scripts, and Win32 app detection and requirement scripts. test suites (Pester 6.2+). Every rule and every verdict is backed by what real devices did, not only by the docs (see -[Validation/Findings.md](Validation/Findings.md)). No Intune connection needed; runs on +[Validation/Findings.md](https://github.com/fadwen/IntuneScriptLab/blob/main/Validation/Findings.md)). No Intune connection needed; runs on Windows PowerShell 5.1 and PowerShell 7; x86, x64 and ARM64 hosts. ## Prerequisites @@ -94,12 +94,15 @@ decide: | Inferred from the path | ScriptType | Default context | Default architecture | |---|---|---|---| -| `*requirement*` | Win32Requirement | System | x64 | -| `*detect*` under a `Win32`, `Apps` or `Packages` folder (two levels up at most), or named after an app, package or installer (`Detect-Agent.ps1`, `Detect-App.ps1`) | Win32Detection | System | x64 | -| `*detect*` under a `Remediations` or `HealthScripts` folder, or any other `*detect*` | Detection | System | x86 | -| `*remediat*`, `*fix*` | Remediation | System | x86 | +| `requirement` | Win32Requirement | System | x64 | +| `detect` under a `Win32`, `Apps` or `Packages` folder (two levels up at most), or with `app`, `apps`, `win32`, `package`, `software`, `install`, `installed`, `msi` or `exe` in the name (`Detect-App.ps1`, `Detect-Package.ps1`) | Win32Detection | System | x64 | +| `detect` under a `Remediations` or `HealthScripts` folder, or any other `detect` | Detection | System | x86 | +| `remediat`, `fix` | Remediation | System | x86 | | anything else | PlatformScript | User | x86 | +Each word matches at the start of a word in the file name or a folder name: `Get-Requirement.ps1` +is a requirement script, `Get-AppRequirement.ps1` is not. + Defaults follow the **portal**. Scripts created through the Graph API get 64-bit SYSTEM instead, so the same script can behave differently depending on how it was deployed. Every inferred file gets an `IslAssumedContext` note saying what was assumed; `-ExcludeRule IslAssumedContext` (or @@ -143,9 +146,11 @@ applies to every script below it (the nearest one wins): } ``` -Explicit parameters win over the file, and a script's directive wins over its type, context, -architecture and signature entries. `-Settings` takes a path or a hashtable instead of the search -(`@{}` for none) on `Test-IntuneScript`, `Test-IntuneDeployedScript` and the CI gate; the +Explicit parameters win over the file (`-ExcludeRule` adds to the file's list, `-IncludeRule` sets it +aside), and a script's directive wins over its type, context, architecture and signature entries. +`-Settings` takes a path or a hashtable instead of the search (`@{}` for none) on `Test-IntuneScript`, +`Repair-IntuneScript`, `Test-IntuneDeployedScript`, `Compare-IntuneDeployedScript`, +`Get-IntuneScriptHealth` and the CI gate; the PSScriptAnalyzer rules and the gate pick the file up on their own. [Examples/IntuneScriptLab.settings.psd1](./Examples/IntuneScriptLab.settings.psd1) is a commented template. @@ -241,12 +246,14 @@ interactive and runs inside it, which is the agent's shape; when it does not, th with the password ("run whether user is logged on or not") and runs in session 0 with the account's profile loaded, and `RunAs` says `(Password)` so you know the session differs; that logon needs the "Log on as a batch job" right, which a standard user does not have by default, and the launcher -reports `0x80070569` with that hint when the scheduler refuses it. Needs an elevated session. On +reports the refusal with that hint within seconds, whether the scheduler answers `0x80070569` or +simply never starts the task (`0x00041303`, "has not run yet", which is what the lab device does +today). Needs an elevated session. On the lab device the interactive path reproduced the agent's launch point for point (console session, `UserInteractive` true, the account's profile paths, system32; `Validation/Findings.md`, "The harness as another account"). The script copy and its output live under `ProgramData\IntuneScriptLab\Runs` with the account granted Modify, since another account cannot reach your temp folder. -[Validation/New-IslHarnessUser.ps1](Validation/New-IslHarnessUser.ps1) creates a standard lab +[Validation/New-IslHarnessUser.ps1](https://github.com/fadwen/IntuneScriptLab/blob/main/Validation/New-IslHarnessUser.ps1) creates a standard lab account with a generated password stored as a DPAPI credential, and the `UserContext` integration suite runs against it when `ISL_TEST_CREDENTIAL` points at that file. @@ -477,7 +484,7 @@ Invoke-ScriptAnalyzer -Path .\Remediations -Recurse -CustomRulePath (Get-IntuneA RuleName Severity ScriptName Line Message -------- -------- ---------- ---- ------- Measure-IslExitCodeIssue Error Detect.ps1 12 'return' at script scope ends the script with exit 0 ... [Observed: ...] -Measure-IslLongSleep Warning Detect.ps1 15 Start-Sleep -Seconds 4000 ... [Observed: ...] +Measure-IslLongSleep Error Detect.ps1 15 Start-Sleep -Seconds 4000 exceeds the 3600 s timeout ... [Observed: ...] PSAvoidUsingWriteHost Warning Remediate.ps1 3 File 'Remediate.ps1' uses Write-Host ... ``` @@ -494,7 +501,8 @@ cache for the nested ones PSScriptAnalyzer also hands it. ### Pre-flight against the tenant (0.11) ```powershell -Connect-MgGraph -Scopes DeviceManagementConfiguration.Read.All, DeviceManagementApps.Read.All, GroupMember.Read.All +Connect-MgGraph -Scopes DeviceManagementScripts.Read.All, DeviceManagementConfiguration.Read.All, + DeviceManagementApps.Read.All, GroupMember.ReadBasic.All Test-IntuneDeployedScript -MinimumSeverity Warning Policy: Remediation 'Fix-Widget' (2b1c...) @@ -509,7 +517,7 @@ Warning IslEncodingIssue detection UTF-8 without a BOM: Windows Severity Rule Role Line Message -------- ---- ---- ---- ------- Error IslDetectionRuleIssue policy Detection rule 2 is a file rule with detectionType doesNotExist ... -Warning IslAssignmentIssue policy Install behavior User, assigned to a group of devices ... +Warning IslAssignmentIssue policy Install behavior User, assigned to devices ... ``` `Test-IntuneDeployedScript` reads what the tenant actually has (remediations, platform scripts, @@ -542,7 +550,7 @@ Clauses : device.deviceName -startsWith "LAB-" [not matched, actual: DESKTOP- device.operatingSystemVersion -ge 10.0.26100 [matched, actual: 10.0.26200.9457] Test-IntuneAssignmentFilter -Rule '(device.cpuArchitecture -eq "x64")' -SyntaxOnly -WARNING: 'x64' is not a value a Windows device reports for device.cpuArchitecture (amd64, x86, arm64, unknown); the clause at position 2 never matches +WARNING: 'x64' is not a value a Windows device reports for device.cpuArchitecture (amd64, x86, arm64, unknown); the clause at character 29 never matches ``` An assignment filter decides before anything runs, and until now the only way to try a rule was @@ -607,13 +615,14 @@ because the per-app status endpoints are gone from Graph. Health is Broken for a policy assigned to nobody or a policy every device failed; Attention for warnings, failures, a remediation whose issue stays detected, drift or an assigned policy nobody has reported on yet; Healthy otherwise, with the reasons in Notes. `-SkipAnalysis` and `-SkipRunState` leave parts out -(run states lag the device by up to an hour for remediations), `-MarkdownPath` writes the same report +(a remediation's run state reaches Graph with the agent's next hourly report, about an hour after +the run; a state that has not changed is not re-reported), `-MarkdownPath` writes the same report as a Markdown table per kind, Broken first. ### In a GitHub Actions workflow (0.12) ```yaml -# .github/workflows/intune-script-gate.yml, from Examples/intune-script-gate.yml +# The analyze step, in short; Examples/intune-script-gate.yml is the complete workflow - name: Analyze shell: pwsh run: | @@ -667,18 +676,20 @@ Invoke-Pester .\Tests -ExcludeTagFilter Elevated # skip the SYSTEM-context tests | Folder | What it holds | |---|---| | `Tests/Unit/Public` | One suite per exported function. The runtime commands are tested with the process launch mocked, so the status mapping runs in milliseconds. | -| `Tests/Unit/Private` | One suite per private helper and, under `Rules/`, one per rule, calling the rule directly through `InModuleScope`. | +| `Tests/Unit/Private` | A suite for most private helpers and, under `Rules/`, one per rule, calling the rule directly through `InModuleScope`. | | `Tests/Integration` | The suites that start real Windows PowerShell 5.1 processes: launch shape, output as Intune reports it, the remediation and Win32 workflows, SYSTEM context (skipped unless elevated), and the assertions on real results. About a minute. | | `Tests/TestHelpers` | Fixtures every suite dot-sources: `New-TestScript`, `Get-RuleFinding`, `New-Win32Fixture`, `Get-AssertionMessage`. | -Host coverage is split, not overlapping: the x64 runtime path is exercised on the x64 CI runner and -the arm64 path on the Windows on ARM runner (each refuses the other's 64-bit host), and the x86 -path on both. The Unit suites pass with PowerShell 7 and with Windows PowerShell 5.1 as the module -host; CI runs them on both, shuffled, with an 80% coverage gate. +Host coverage is split, not overlapping: the integration suites exercise the x64 runtime path on +the x64 CI runner and the arm64 path on an ARM64 development machine (each refuses the other's +64-bit host), and the x86 path on both. The Unit suites pass with PowerShell 7 and with Windows +PowerShell 5.1 as the module host: CI runs the whole suite on PowerShell 7, shuffled and with an +80% coverage gate, and the unit suites again on Windows PowerShell 5.1 and on the Windows on ARM +runner. ## Help -Command help is PlatyPS Markdown under [docs/IntuneScriptLab/](docs/IntuneScriptLab/) compiled +Command help is PlatyPS Markdown under [docs/IntuneScriptLab/](https://github.com/fadwen/IntuneScriptLab/tree/main/docs/IntuneScriptLab) compiled into `en-US/IntuneScriptLab-Help.xml`, which is what `Get-Help` reads. Edit the Markdown, not the functions' comment blocks (those carry only `.EXTERNALHELP` and a synopsis), then rebuild: @@ -692,11 +703,11 @@ Install-PSResource Microsoft.PowerShell.PlatyPS # 1.0.3 or later, once | Symptom | Cause and fix | |---|---| | `IslAssumedContext` finding on every script | The script type was inferred from the file name. Pass `-ScriptType`, or put `# IntuneScriptLab: ScriptType=Detection` at the top of the script, and the finding goes away. | -| A rule fires on something Intune tolerates | Every rule cites its `Evidence`, ending in experiment IDs such as `REM-EXIT-2`: those are the experiments in [Experiments.psd1](Validation/Experiments.psd1) whose results [Findings.md](Validation/Findings.md) summarises. Use `-ExcludeRule` for a deliberate exception, and open an issue with the script if the evidence is wrong. | +| A rule fires on something Intune tolerates | Every rule cites its `Evidence`, ending in experiment IDs such as `REM-EXIT-2`: those are the experiments in [Experiments.psd1](https://github.com/fadwen/IntuneScriptLab/blob/main/Validation/Experiments.psd1) whose results [Findings.md](https://github.com/fadwen/IntuneScriptLab/blob/main/Validation/Findings.md) summarises. Use `-ExcludeRule` for a deliberate exception, and open an issue with the script if the evidence is wrong. | | Import fails on Windows PowerShell 5.1 with odd characters in the error | A source file was saved as UTF-8 without a BOM. 5.1 reads that as the ANSI code page. Save with a BOM (this is also what rule `IslEncodingIssue` reports for your scripts). | | `-Context System` says "run elevated" | `Register-ScheduledTask` needs an administrator session. Restart PowerShell as administrator; the current-user runs need no elevation. | | `-Architecture x64` refused on an ARM64 PC | ARM64 Windows has no x64 `powershell.exe`; only x86 (emulated) and arm64 (native) exist there, which is what Intune's agent uses too. Test x64 on an x64 machine. | -| Runtime result differs from what Intune showed | Compare `Output` (the last console line, 2,048-character tail, OEM code page) and `ExitCode` on the result. Intune reports the same values; the portal just formats them. The `-Verbose` switch prints each step. | +| Runtime result differs from what Intune showed | Compare what the result carries as Intune reports it: `IntuneOutput` and `IntuneError` on a remediation result, `StdOut`, `StdErr` and `ExitCode` on a detection, platform script or requirement result (OEM code page; the 2,048-character tail where the portal caps it). The `-Verbose` switch prints each step. | | Pester says `Should-HaveIntuneStatus` is not recognised | The aliases come from this module, not Pester: `Import-Module IntuneScriptLab` in `BeforeAll`, and use Pester 6.2 or later (`New-ShouldAssertion`). | ## Roadmap diff --git a/Tests/Integration/PSScriptAnalyzerRules.Tests.ps1 b/Tests/Integration/PSScriptAnalyzerRules.Tests.ps1 index 84f9838..96a664a 100644 --- a/Tests/Integration/PSScriptAnalyzerRules.Tests.ps1 +++ b/Tests/Integration/PSScriptAnalyzerRules.Tests.ps1 @@ -42,8 +42,9 @@ BeforeAll { if ($analyzer) { Import-Module $analyzer -ErrorAction Stop } $script:RulePath = Get-IntuneAnalyzerRulePath $script:Detect = Join-Path $TestDrive 'Detect-Widget.ps1' + # No directive: the type comes from the file name, so the assumed-context note is among the + # findings and the wrapper has to carry it too $detectBody = @( - '# IntuneScriptLab: ScriptType=Detection' '$item = Get-Item C:\Windows\notepad.exe' '$siblings = gci C:\Windows -Filter *.exe' 'function Get-Helper { 1..3 | ForEach-Object { $_ } }' @@ -122,15 +123,18 @@ Describe 'PSScriptAnalyzer wrapper' -Tag 'Integration', 'Analyzer' -Skip:(-not $ } It 'analyzes a -ScriptDefinition the same way, reading the type from the directive' { + # A definition has no file name to infer from; the directive names the type, and a declared + # type earns no assumed-context note + $definition = "# IntuneScriptLab: ScriptType=Detection`r`n" + [System.IO.File]::ReadAllText($script:Detect) $records = @($scriptAnalyzerSplat = @{ - ScriptDefinition = ([System.IO.File]::ReadAllText($script:Detect)) + ScriptDefinition = $definition CustomRulePath = $script:RulePath IncludeDefaultRules = $false } Invoke-ScriptAnalyzer @scriptAnalyzerSplat) - $records.Count | Should-Be $script:Direct.Count - ($records | Where-Object RuleName -eq 'Measure-IslAssumedContext').Message | - Should-BeLikeString '*Detection (directive)*' + $records.Count | Should-Be ($script:Direct.Count - 1) + $records.RuleName | Should-NotContainCollection 'Measure-IslAssumedContext' + $records.RuleName | Should-ContainCollection 'Measure-IslExitCodeIssue' } It 'runs next to the built-in rules in one pass with -IncludeDefaultRules' { diff --git a/Tests/Unit/Private/ConvertFrom-IslFilterRule.Tests.ps1 b/Tests/Unit/Private/ConvertFrom-IslFilterRule.Tests.ps1 index ffdc194..a5add8d 100644 --- a/Tests/Unit/Private/ConvertFrom-IslFilterRule.Tests.ps1 +++ b/Tests/Unit/Private/ConvertFrom-IslFilterRule.Tests.ps1 @@ -242,18 +242,24 @@ Describe 'ConvertFrom-IslFilterRule' -Tag 'Unit', 'Private' { } Context 'Warnings' { - It 'warns that never matches a Windows device' -ForEach @( - @{ Rule = '(device.cpuArchitecture -eq "x64")'; Text = "*'x64'*device.cpuArchitecture*never matches*" } - @{ Rule = '(device.cpuArchitecture -in ["amd64", "x64"])'; Text = "*'x64'*never matches*" } - @{ Rule = '(device.deviceTrustType -eq "Microsoft Entra joined")' + It 'warns that uses a value no Windows device reports' -ForEach @( + @{ Rule = '(device.cpuArchitecture -eq "x64")'; Kind = 'NeverMatches' + Text = "*'x64'*device.cpuArchitecture*never matches*" } + @{ Rule = '(device.cpuArchitecture -in ["amd64", "x64"])'; Kind = 'NeverMatches' + Text = "*'x64'*never matches*" } + @{ Rule = '(device.deviceTrustType -eq "Microsoft Entra joined")'; Kind = 'NeverMatches' Text = "*'Microsoft Entra joined'*device.deviceTrustType*" } - @{ Rule = '(device.deviceTrustType -ne "AzureADJoined")'; Text = "*'AzureADJoined'*" } - @{ Rule = '(device.deviceOwnership -eq "company")'; Text = "*'company'*Personal, Corporate, Unknown*" } - @{ Rule = '(device.operatingSystemSKU -eq "Windows Enterprise")'; Text = "*'Windows Enterprise'*" } + # -ne with a value nobody reports is true for every device, the opposite trap + @{ Rule = '(device.deviceTrustType -ne "AzureADJoined")'; Kind = 'AlwaysMatches' + Text = "*'AzureADJoined'*matches every device*" } + @{ Rule = '(device.deviceOwnership -eq "company")'; Kind = 'NeverMatches' + Text = "*'company'*Personal, Corporate, Unknown*" } + @{ Rule = '(device.operatingSystemSKU -eq "Windows Enterprise")'; Kind = 'NeverMatches' + Text = "*'Windows Enterprise'*" } ) { $parsed = ConvertFrom-Rule -Rule $Rule @($parsed.Warnings).Count | Should-Be 1 - $parsed.Warnings[0].Kind | Should-Be 'NeverMatches' + $parsed.Warnings[0].Kind | Should-Be $Kind $parsed.Warnings[0].Message | Should-BeLikeString $Text } diff --git a/Tests/Unit/Private/Get-IslOemEncoding.Tests.ps1 b/Tests/Unit/Private/Get-IslOemEncoding.Tests.ps1 index 7b6c78c..075acd3 100644 --- a/Tests/Unit/Private/Get-IslOemEncoding.Tests.ps1 +++ b/Tests/Unit/Private/Get-IslOemEncoding.Tests.ps1 @@ -26,6 +26,12 @@ Describe 'Get-IslOemEncoding' -Tag 'Unit', 'Private' { $encoding.CodePage | Should-Be $expected } + It 'returns the ANSI code page from ACP with -Kind ANSI' { + $key = 'HKLM:\SYSTEM\CurrentControlSet\Control\Nls\CodePage' + $expected = [int](Get-ItemProperty -Path $key -Name ACP).ACP + (InModuleScope IntuneScriptLab { Get-IslOemEncoding -Kind ANSI }).CodePage | Should-Be $expected + } + It 'is a real code page, not code page 1' { (InModuleScope IntuneScriptLab { Get-IslOemEncoding }).CodePage | Should-BeGreaterThan 1 } diff --git a/Tests/Unit/Private/Invoke-IslProcess.Tests.ps1 b/Tests/Unit/Private/Invoke-IslProcess.Tests.ps1 index 3609965..abed2a7 100644 --- a/Tests/Unit/Private/Invoke-IslProcess.Tests.ps1 +++ b/Tests/Unit/Private/Invoke-IslProcess.Tests.ps1 @@ -231,6 +231,21 @@ Describe 'Invoke-IslProcess' -Tag 'Unit', 'Private' { Should-Invoke Unregister-ScheduledTask -ModuleName IntuneScriptLab -Exactly -Times 1 } + It 'reports a stored-password task the scheduler never launches, within seconds (VM 125, isl-user)' { + Mock Get-IslLogonSession -ModuleName IntuneScriptLab { @() } + Mock Start-ScheduledTask -ModuleName IntuneScriptLab { } + # The task sits Ready with "has not run yet" (267011) and no error anywhere + Mock Get-ScheduledTaskInfo -ModuleName IntuneScriptLab { + [pscustomobject]@{ LastTaskResult = 267011 } + } + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + $failure = { Invoke-Process $script:LaunchSplat } | Should-Throw + $stopwatch.Elapsed.TotalSeconds | Should-BeLessThan 15 + $failure.Exception.Message | + Should-BeLikeString '*LAB\isl-user did not start: 0x00041303*never launched*Log on as a batch job*' + Should-Invoke Unregister-ScheduledTask -ModuleName IntuneScriptLab -Exactly -Times 1 + } + It 'names the account in the elevation message when the registration is refused' { Mock Get-IslLogonSession -ModuleName IntuneScriptLab { @() } Mock Register-ScheduledTask -ModuleName IntuneScriptLab { throw 'Access is denied.' } diff --git a/Tests/Unit/Private/Invoke-IslScriptRun.Tests.ps1 b/Tests/Unit/Private/Invoke-IslScriptRun.Tests.ps1 index 971118b..fa86e71 100644 --- a/Tests/Unit/Private/Invoke-IslScriptRun.Tests.ps1 +++ b/Tests/Unit/Private/Invoke-IslScriptRun.Tests.ps1 @@ -58,6 +58,12 @@ Describe 'Invoke-IslScriptRun' -Tag 'Unit', 'Private' { $result.PSObject.TypeNames | Should-ContainCollection 'IntuneScriptLab.RunResult' } + It 'throws for a script that does not exist instead of running nothing' { + $missing = Join-Path $TestDrive 'nowhere.ps1' + { Invoke-ScriptRun @{ Path = $missing; Architecture = 'x86' } } | Should-Throw + Should-Invoke Invoke-IslProcess -ModuleName IntuneScriptLab -Times 0 -Exactly + } + It 'removes the cache copy after the run' { $result = Invoke-ScriptRun @{ Path = $script:Script; Architecture = 'x86' } $workFolder = ($result.StdOut -split '\|')[3] diff --git a/Tests/Unit/Private/Rules/Find-IslEncodingIssue.Tests.ps1 b/Tests/Unit/Private/Rules/Find-IslEncodingIssue.Tests.ps1 index 0ba7309..4aaa753 100644 --- a/Tests/Unit/Private/Rules/Find-IslEncodingIssue.Tests.ps1 +++ b/Tests/Unit/Private/Rules/Find-IslEncodingIssue.Tests.ps1 @@ -24,6 +24,18 @@ Describe 'Find-IslEncodingIssue' -Tag 'Unit', 'Private', 'Rule' { @($findings | Where-Object Severity -eq 'Warning').Count | Should-Be 1 } + It 'notes a BOM-less file that is not UTF-8 as ANSI, with the same fix' { + $path = Join-Path $TestDrive 'Detect-Ansi.ps1' + $ansi = [System.Text.Encoding]::GetEncoding(1252) + [System.IO.File]::WriteAllBytes($path, $ansi.GetBytes($script:NonAscii)) + $findings = @(Get-RuleFinding $path IslEncodingIssue) + $ansi = @($findings | Where-Object Message -like 'Non-ASCII bytes that are not UTF-8*') + $ansi.Count | Should-Be 1 + $ansi[0].Severity | Should-Be 'Information' + $ansi[0].Fix.Encoding | Should-Be 'UTF8BOM' + @($findings | Where-Object Severity -eq 'Warning').Count | Should-Be 0 + } + It 'does not warn when the BOM is present, but still notes output mangling' { $path = New-TestScript 'Detect-E2.ps1' $script:NonAscii -Bom $findings = @(Get-RuleFinding $path IslEncodingIssue) diff --git a/Tests/Unit/Private/Rules/Find-IslInteractiveCall.Tests.ps1 b/Tests/Unit/Private/Rules/Find-IslInteractiveCall.Tests.ps1 index 596bb71..4718010 100644 --- a/Tests/Unit/Private/Rules/Find-IslInteractiveCall.Tests.ps1 +++ b/Tests/Unit/Private/Rules/Find-IslInteractiveCall.Tests.ps1 @@ -24,10 +24,14 @@ Describe 'Find-IslInteractiveCall' -Tag 'Unit', 'Private', 'Rule' { $findings.Severity | Should-All { $_ -eq 'Error' } } - It 'warns on Set-ExecutionPolicy and Install-Module without -Force' { + It 'warns on Set-ExecutionPolicy and Install-Module without -Force or -Confirm:$false' { + # A bare -Confirm forces the prompt; only -Confirm:$false switches it off $path = New-TestScript 'script.ps1' ("Set-ExecutionPolicy RemoteSigned`nInstall-Module Foo -Force`n" + - 'Install-Module Bar') - @(Get-RuleFinding $path IslInteractiveCall).Count | Should-Be 2 + "Install-Module Bar`nInstall-Module Baz -Confirm`nInstall-Module Qux -Confirm:`$false") + $findings = @(Get-RuleFinding $path IslInteractiveCall) + $findings.Count | Should-Be 3 + @($findings.Text) | Should-ContainCollection 'Install-Module Baz -Confirm' + @($findings.Text) | Should-NotContainCollection 'Install-Module Qux -Confirm:$false' } It 'names the platform-script timeout' { diff --git a/Tests/Unit/Private/Rules/Find-IslOutputIssue.Tests.ps1 b/Tests/Unit/Private/Rules/Find-IslOutputIssue.Tests.ps1 index f8268af..7b47a22 100644 --- a/Tests/Unit/Private/Rules/Find-IslOutputIssue.Tests.ps1 +++ b/Tests/Unit/Private/Rules/Find-IslOutputIssue.Tests.ps1 @@ -101,6 +101,16 @@ Describe 'Find-IslOutputIssue' -Tag 'Unit', 'Private', 'Rule' { Should-Be 1 } + It 'takes SilentlyContinue as a guard for a probing cmdlet, but not Stop, which puts the miss on stderr' { + $probe = "Get-Item 'HKLM:\SOFTWARE\NoSuchVendor'`nWrite-Output 'found'`nexit 0" + $unguarded = 'Get-Item writes an error record*' + $stop = New-TestScript 'App-Stop\Detect.ps1' ("`$ErrorActionPreference = 'Stop'`n" + $probe) + @(Get-RuleFinding $stop IslOutputIssue | Where-Object Message -like $unguarded).Count | Should-Be 1 + $quietBody = "`$ErrorActionPreference = 'SilentlyContinue'`n" + $probe + $quiet = New-TestScript 'App-Quiet\Detect.ps1' $quietBody + @(Get-RuleFinding $quiet IslOutputIssue | Where-Object Message -like $unguarded).Count | Should-Be 0 + } + It 'notes a non-zero exit and warns on an unguarded probing cmdlet' { $path = New-TestScript 'App-Requirement.ps1' ("`$v = (Get-ItemProperty 'HKLM:\SOFTWARE\X').Version`n" + "if (`$v) { Write-Output `$v } else { exit 1 }") diff --git a/Tests/Unit/Private/Rules/Find-IslPowerShell7Syntax.Tests.ps1 b/Tests/Unit/Private/Rules/Find-IslPowerShell7Syntax.Tests.ps1 index a5fe336..d288d0c 100644 --- a/Tests/Unit/Private/Rules/Find-IslPowerShell7Syntax.Tests.ps1 +++ b/Tests/Unit/Private/Rules/Find-IslPowerShell7Syntax.Tests.ps1 @@ -39,6 +39,11 @@ Describe 'Find-IslPowerShell7Syntax' -Tag 'Unit', 'Private', 'Rule' { $messages | Should-BeLikeString '*-AsHashtable*' } + It 'leaves a module the parser cannot find to the dependency rule' { + $path = New-TestScript 'Detect-U.ps1' "using module NoSuchModuleForIsl`nexit 0" + @(Get-RuleFinding $path IslPowerShell7Syntax).Count | Should-Be 0 + } + It 'stays quiet on 5.1-compatible code' { $path = New-TestScript 'Detect-Ok.ps1' ("if (Test-Path 'C:\x') { Write-Output 'ok'; exit 0 } " + "else { Write-Output 'missing'; exit 1 }") diff --git a/Tests/Unit/Public/Assert-PassIntuneAnalysis.Tests.ps1 b/Tests/Unit/Public/Assert-PassIntuneAnalysis.Tests.ps1 index 1b930be..bfd67a0 100644 --- a/Tests/Unit/Public/Assert-PassIntuneAnalysis.Tests.ps1 +++ b/Tests/Unit/Public/Assert-PassIntuneAnalysis.Tests.ps1 @@ -31,6 +31,15 @@ Describe 'Assert-PassIntuneAnalysis' -Tag 'Unit', 'Public' { Get-Item $script:Clean | Should-PassIntuneAnalysis } + It 'accepts a pipeline of files, and names the one that fails' { + $second = New-TestScript 'Detect-Clean2.ps1' ("if (Test-Path 'C:\x') { Write-Output 'ok'; exit 0 } " + + "else { exit 1 }") + Get-Item $script:Clean, $second | Should-PassIntuneAnalysis + $message = Get-AssertionMessage { $script:Clean, $script:Dirty | Should-PassIntuneAnalysis } + $message | Should-BeLikeString '*Detect-Bad.ps1:*' + $message | Should-NotBeLikeString '*Detect-Clean.ps1:*' + } + It 'limits MinimumSeverity and ScriptType to the analyzer values' { $command = Get-Command Assert-PassIntuneAnalysis $command.Parameters['MinimumSeverity'].Attributes.ValidValues | diff --git a/Tests/Unit/Public/Compare-IntuneDeployedScript.Tests.ps1 b/Tests/Unit/Public/Compare-IntuneDeployedScript.Tests.ps1 index baa314c..9df5dd5 100644 --- a/Tests/Unit/Public/Compare-IntuneDeployedScript.Tests.ps1 +++ b/Tests/Unit/Public/Compare-IntuneDeployedScript.Tests.ps1 @@ -1,4 +1,4 @@ -#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.2.0' } +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.2.0' } <# Drift between a fake tenant (the Graph seam mocked) and a local folder laid out by the @@ -190,6 +190,19 @@ Describe 'Compare-IntuneDeployedScript' -Tag 'Unit', 'Public' { $orphan.LocalPath | Should-BeLikeString '*Report-Only\Remediate.ps1' } + It 'compares content case-sensitively' { + $root = Join-Path $TestDrive 'case' + $file = Join-Path $root 'Remediations\Fix-Widget\Detect.ps1' + $null = New-Item -ItemType Directory -Path (Split-Path $file -Parent) -Force + $upper = $script:Detect -replace 'exit 0', 'EXIT 0' + [System.IO.File]::WriteAllBytes($file, (Get-ByteArray -Text $upper -Bom)) + $results = @(Compare-IntuneDeployedScript -Path $root -Kind Remediation -Name 'Fix-Widget') + $detection = Get-Result -Results $results -Policy 'Fix-Widget' -Role 'detection' + $detection.State | Should-Be 'Drifted' + @($detection.Differences) | Should-BeCollection @('Content') + $detection.Detail | Should-BeLikeString 'content differs from line 2*' + } + It 'reports two local candidates as Ambiguous' { $dup = $script:Results | Where-Object PolicyName -eq 'Dup' $dup.State | Should-Be 'Ambiguous' @@ -221,6 +234,20 @@ Describe 'Compare-IntuneDeployedScript' -Tag 'Unit', 'Public' { $Uri -like '*mobileApps*' } -Times 0 -Exactly } + It 'selects by id alone when no name is given' { + $results = @(Compare-IntuneDeployedScript -Path $script:Root -Id 'rem-c') + $results.PolicyName | Should-All { $_ -eq 'Ctx-Check' } + $results.Count | Should-Be 1 + } + + It 'uses -Settings for the settings comparison instead of the nearest settings file' { + # Set-Wallpaper's local file says nothing, so the settings hashtable is what gets compared + $compareSplat = @{ Path = $script:Root; Name = 'Set-Wallpaper'; Settings = @{ Context = 'System' } } + $wallpaper = @(Compare-IntuneDeployedScript @compareSplat) + $wallpaper.Count | Should-Be 1 + @($wallpaper[0].Differences) | Should-ContainCollection 'Settings' + $wallpaper[0].Detail | Should-BeLikeString '*Context=System locally, the policy runs as User*' + } It 'takes local files from -Map by role, relative to -Path, and reports an entry with no policy' { $map = @{ diff --git a/Tests/Unit/Public/Export-IntuneFindingSarif.Tests.ps1 b/Tests/Unit/Public/Export-IntuneFindingSarif.Tests.ps1 index 66d41d1..4cb1875 100644 --- a/Tests/Unit/Public/Export-IntuneFindingSarif.Tests.ps1 +++ b/Tests/Unit/Public/Export-IntuneFindingSarif.Tests.ps1 @@ -49,7 +49,8 @@ Describe 'Export-IntuneFindingSarif' -Tag 'Unit', 'Public' { $exitRule = $run.tool.driver.rules | Where-Object id -eq 'IslExitCodeIssue' $exitRule.shortDescription.text | Should-BeLikeString '*exit*' $exitRule.fullDescription.text | Should-BeLikeString '*return*' - $exitRule.defaultConfiguration.level | Should-Be 'warning' + # The level of the most severe finding the rule produced in this log + $exitRule.defaultConfiguration.level | Should-Be 'error' } It 'writes one result per finding with the relative location, level, snippet and evidence' { @@ -101,7 +102,18 @@ Describe 'Export-IntuneFindingSarif' -Tag 'Unit', 'Public' { $note = $run.results | Where-Object ruleId -eq 'IslAssumedContext' $note.level | Should-Be 'note' $note.locations[0].physicalLocation.region.startLine | Should-Be 1 - $note.locations[0].physicalLocation.artifactLocation.uri | Should-BeLikeString '*Detect-Far.ps1' - $note.locations[0].physicalLocation.artifactLocation.uri | Should-NotBeLikeString 'Remediations*' + # An absolute file URI with no base, so nothing resolves it under the root + $location = $note.locations[0].physicalLocation.artifactLocation + $location.uri | Should-BeLikeString 'file:///*Detect-Far.ps1' + $location.PSObject.Properties['uriBaseId'] | Should-BeNull + } + + It 'resolves a relative -Path against the PowerShell location' { + $folder = Join-Path $TestDrive 'relative' + $null = New-Item -ItemType Directory -Path $folder -Force + Push-Location $folder + try { $null = Export-IntuneFindingSarif -Finding $script:Findings -Path '.\out\findings.sarif' } + finally { Pop-Location } + Test-Path -LiteralPath (Join-Path $folder 'out\findings.sarif') | Should-BeTrue } } diff --git a/Tests/Unit/Public/Get-IntuneAgentTimeline.Tests.ps1 b/Tests/Unit/Public/Get-IntuneAgentTimeline.Tests.ps1 index 2d7d655..460ba84 100644 --- a/Tests/Unit/Public/Get-IntuneAgentTimeline.Tests.ps1 +++ b/Tests/Unit/Public/Get-IntuneAgentTimeline.Tests.ps1 @@ -1,4 +1,4 @@ -#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.2.0' } +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.2.0' } <# Per-policy timelines over a fake log folder: a remediation's fetch, start, verdict and report, @@ -125,6 +125,30 @@ Describe 'Get-IntuneAgentTimeline' -Tag 'Unit', 'Public' { $health.Id | Should-BeCollection @($script:Policy) } + It 'keeps -Id to the timeline whose own id it is, and takes a relationship report as the outcome' { + $other = '1a2b3c4d-0000-4000-8000-000000000002' + $logs = Join-Path $TestDrive 'RelationLogs' + $aw = @{ Component = 'AppWorkload' } + Write-TestLog $logs 'AppWorkload.log' @( + New-CmTraceLine @aw -Time '09:00:00.0000000' -Message ('[Win32App][DetectionActionHandler] ' + + "Detection for policy with id: $($script:App) resulted in action status: Success and " + + 'detection state: NotDetected.') + New-CmTraceLine @aw -Time '09:00:05.0000000' -Message ('[Win32App][DetectionActionHandler] ' + + "Detection for policy with id: $other resulted in action status: Success and " + + 'detection state: Detected.') + # Names both apps: the report is about the first, the second is the impacting app + New-CmTraceLine @aw -Time '09:00:10.0000000' -Message ('[Win32App][ReportingManager] Sending ' + + 'status to company portal based on report: {"ApplicationId":"' + $script:App + + '","ResultantAppState":1,"ReportingImpact":{"DesiredState":1,"Classification":1,' + + '"ConflictReason":0,"ImpactingApps":[{"AppId":"' + $other + '"}]}}') + ) | Out-Null + $timelines = @(Get-IntuneAgentTimeline -Path $logs -Id $other) + @($timelines.Id) | Should-BeCollection @($other) + $first = @(Get-IntuneAgentTimeline -Path $logs -Id $script:App) + $first.Count | Should-Be 1 + $first[0].Outcome | Should-BeLikeString 'AppRelationshipReport*' + } + It 'cuts the steps by time' { $lateSplat = @{ Path = $script:Logs; Id = $script:Policy; After = [datetime]'2026-09-25 08:45:00' } $late = Get-IntuneAgentTimeline @lateSplat diff --git a/Tests/Unit/Public/Get-IntuneScriptHealth.Tests.ps1 b/Tests/Unit/Public/Get-IntuneScriptHealth.Tests.ps1 index 541a950..7847460 100644 --- a/Tests/Unit/Public/Get-IntuneScriptHealth.Tests.ps1 +++ b/Tests/Unit/Public/Get-IntuneScriptHealth.Tests.ps1 @@ -1,4 +1,4 @@ -#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.2.0' } +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.2.0' } <# The health report over a fake tenant: the Graph seam serves the policies with their assignments @@ -194,7 +194,11 @@ Describe 'Get-IntuneScriptHealth' -Tag 'Unit', 'Public' { It 'leaves the findings alone with -SkipAnalysis' { $report = @(Get-IntuneScriptHealth -SkipAnalysis -SkipRunState) - ($report | Where-Object PolicyId -eq 'rem-b').Health | Should-Be 'Healthy' + # Unassigned is read from the assignments, not from a finding, so it survives the switch + $reportOnly = $report | Where-Object PolicyId -eq 'rem-b' + $reportOnly.Health | Should-Be 'Broken' + $reportOnly.Notes | Should-BeLikeString '*assigned to nobody*' + $reportOnly.Errors | Should-Be 0 ($report | Where-Object PolicyId -eq 'app-b').Errors | Should-Be 0 Should-Invoke Test-IntuneDeployedScript -ModuleName IntuneScriptLab -Times 0 -Exactly } @@ -227,5 +231,18 @@ Describe 'Get-IntuneScriptHealth' -Tag 'Unit', 'Public' { $remediationRows[0] | Should-BeLikeString '| Broken | Report-Only | user | 0 include | 0 | 1 |*' $remediationRows[1] | Should-BeLikeString '| Attention | Fix-Widget | system |*' } + It 'selects by id alone when no name is given' { + $report = @(Get-IntuneScriptHealth -Id 'ps-b' -SkipAnalysis -SkipRunState) + $report.PolicyName | Should-BeCollection @('Set-Proxy') + } + + It 'resolves a relative -MarkdownPath against the PowerShell location' { + $folder = Join-Path $TestDrive 'relative' + $null = New-Item -ItemType Directory -Path $folder -Force + Push-Location $folder + try { $null = Get-IntuneScriptHealth -MarkdownPath '.\health.md' -SkipAnalysis -SkipRunState } + finally { Pop-Location } + Test-Path -LiteralPath (Join-Path $folder 'health.md') | Should-BeTrue + } } } diff --git a/Tests/Unit/Public/Repair-IntuneScript.Tests.ps1 b/Tests/Unit/Public/Repair-IntuneScript.Tests.ps1 index f236014..e22633b 100644 --- a/Tests/Unit/Public/Repair-IntuneScript.Tests.ps1 +++ b/Tests/Unit/Public/Repair-IntuneScript.Tests.ps1 @@ -123,5 +123,27 @@ Describe 'Repair-IntuneScript' -Tag 'Unit', 'Public' { ($results | Where-Object Path -like '*One*').Applied | Should-Be 1 ($results | Where-Object Path -like '*Two*').Applied | Should-Be 0 } + + It 'lists what it would do for a folder under -WhatIf' { + $folder = Join-Path $TestDrive 'WhatIfTree' + $body = "return 'a'`nexit 1" + $path = New-TestScript 'WhatIfTree\Remediations\One\Detect.ps1' $body -Bom + $results = @(Repair-IntuneScript -Path $folder -WhatIf) + $results.Count | Should-Be 1 + $results[0].Applied | Should-Be 1 + $results[0].Written | Should-BeFalse + [System.IO.File]::ReadAllText($path) | Should-Be $body + } + + It 'reads a BOM-less file that is not UTF-8 as ANSI and writes it back as UTF-8 with a BOM, intact' { + $path = Join-Path $TestDrive 'Detect-Ansi.ps1' + $text = 'Write-Output "Gr' + [char]0xFC + [char]0xDF + 'e"' + "`nexit 0" + [System.IO.File]::WriteAllBytes($path, [System.Text.Encoding]::GetEncoding(1252).GetBytes($text)) + $result = Repair-IntuneScript -Path $path -ScriptType PlatformScript + $result.Written | Should-BeTrue + $bytes = [System.IO.File]::ReadAllBytes($path) + @($bytes[0], $bytes[1], $bytes[2]) | Should-BeCollection @([byte]0xEF, [byte]0xBB, [byte]0xBF) + [System.Text.Encoding]::UTF8.GetString($bytes, 3, $bytes.Length - 3) | Should-Be $text + } } } diff --git a/Tests/Unit/Public/Test-IntuneDeployedScript.Tests.ps1 b/Tests/Unit/Public/Test-IntuneDeployedScript.Tests.ps1 index 1ba760c..a3fc4bf 100644 --- a/Tests/Unit/Public/Test-IntuneDeployedScript.Tests.ps1 +++ b/Tests/Unit/Public/Test-IntuneDeployedScript.Tests.ps1 @@ -1,4 +1,4 @@ -#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.2.0' } +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.2.0' } <# The Graph pre-flight against a fake tenant: the Graph seam (Invoke-IslGraphRequest) is mocked @@ -91,6 +91,8 @@ BeforeAll { @{ target = @{ '@odata.type' = '#microsoft.graph.allLicensedUsersAssignmentTarget' deviceAndAppManagementAssignmentFilterId = 'flt-x64' deviceAndAppManagementAssignmentFilterType = 'include' } } + # All devices is a device target too: a user-context app never installs through it + @{ target = @{ '@odata.type' = '#microsoft.graph.allDevicesAssignmentTarget' } } ) } @{ @@ -202,7 +204,8 @@ Describe 'Test-IntuneDeployedScript' -Tag 'Unit', 'Public' { $findings.Count | Should-Be 1 $findings[0].PolicyName | Should-Be 'Widget 2.0' $findings[0].Severity | Should-Be 'Warning' - $findings[0].Message | Should-BeLikeString '*grp-devices*' + $findings[0].Message | Should-BeLikeString '*assigned to devices (*grp-devices*' + $findings[0].Message | Should-BeLikeString '*all devices*' Should-Invoke Invoke-IslGraphRequest -ModuleName IntuneScriptLab -ParameterFilter { $Uri -like '/v1.0/groups/*' } -Times 1 -Exactly @@ -305,6 +308,19 @@ Describe 'Test-IntuneDeployedScript' -Tag 'Unit', 'Public' { $Uri -like '*/mobileApps/app-b*' } -Times 1 -Exactly } + It 'selects by id alone when no name is given' { + @(Test-IntuneDeployedScript -Id 'rem-b').PolicyName | Sort-Object -Unique | + Should-BeCollection @('Report-Only') + } + + It 'forwards -Settings to the script analysis' { + $withRule = @(Test-IntuneDeployedScript -Name 'Fix-Widget' -Kind Remediation) + $withRule.RuleName | Should-ContainCollection 'IslExitCodeIssue' + $settingsSplat = @{ + Name = 'Fix-Widget'; Kind = 'Remediation'; Settings = @{ ExcludeRule = 'IslExitCodeIssue' } + } + @(Test-IntuneDeployedScript @settingsSplat).RuleName | Should-NotContainCollection 'IslExitCodeIssue' + } It 'applies the rule and severity filters to script findings and policy checks alike' { $errors = @(Test-IntuneDeployedScript -MinimumSeverity Error) @@ -330,7 +346,10 @@ Describe 'Test-IntuneDeployedScript' -Tag 'Unit', 'Public' { } $warnings = @() $findings = @(Test-IntuneDeployedScript -Kind Win32App -WarningVariable warnings 3>$null) - $findings.RuleName | Should-NotContainCollection 'IslAssignmentIssue' + # The group could not be read, so only the All devices target is named + $assignment = @($findings | Where-Object RuleName -eq 'IslAssignmentIssue') + $assignment.Count | Should-Be 1 + $assignment[0].Message | Should-BeLikeString '*assigned to devices (all devices)*' @($warnings).Count | Should-Be 1 "$($warnings[0])" | Should-BeLikeString '*Group members could not be read*GroupMember.Read.All*' } diff --git a/Tests/Unit/Public/Test-IntuneScript.Tests.ps1 b/Tests/Unit/Public/Test-IntuneScript.Tests.ps1 index 622a41f..d2218c7 100644 --- a/Tests/Unit/Public/Test-IntuneScript.Tests.ps1 +++ b/Tests/Unit/Public/Test-IntuneScript.Tests.ps1 @@ -71,10 +71,12 @@ Describe 'Test-IntuneScript' -Tag 'Unit', 'Public' { Should-Be 1 } - It 'lets a directive comment override the file name' { + It 'lets a directive comment override the file name, and treats it as declared' { $path = New-TestScript 'script.ps1' "# IntuneScriptLab: ScriptType=Win32Detection`nexit 0" $findings = @(Test-IntuneScript -Path $path) $findings.ScriptType | Should-All { $_ -eq 'Win32Detection' } + # A type written in the script is not assumed, so the note stays away + @($findings | Where-Object RuleName -eq 'IslAssumedContext').Count | Should-Be 0 } It 'lets an explicit parameter override both, and drops the note' { @@ -130,7 +132,7 @@ Describe 'Test-IntuneScript' -Tag 'Unit', 'Public' { Context 'Suppressions' { It 'drops a finding a header directive suppresses, and shows it with -IncludeSuppressed' { $body = "# IntuneScriptLab: ScriptType=Detection Suppress=IslLongSleep`n" + - "Start-Sleep -Seconds 4000`nexit 1" + "Start-Sleep -Seconds 4000`nRead-Host 'Continue?'`nexit 1" $path = New-TestScript 'Detect-Quiet.ps1' $body @(Test-IntuneScript -Path $path).RuleName | Should-NotContainCollection 'IslLongSleep' $all = @(Test-IntuneScript -Path $path -IncludeSuppressed) @@ -193,6 +195,15 @@ Describe 'Test-IntuneScript' -Tag 'Unit', 'Public' { Should-NotContainCollection 'IslLongSleep' } + It 'lets an explicit -IncludeRule set the file''s exclusions aside, while -ExcludeRule adds to them' { + $settings = @{ ExcludeRule = 'IslLongSleep' } + $asked = @(Test-IntuneScript -Path $script:Slow -Settings $settings -IncludeRule IslLongSleep) + $asked.RuleName | Should-ContainCollection 'IslLongSleep' + $added = @(Test-IntuneScript -Path $script:Slow -Settings $settings -ExcludeRule IslAssumedContext) + $added.RuleName | Should-NotContainCollection 'IslLongSleep' + $added.RuleName | Should-NotContainCollection 'IslAssumedContext' + } + It 'takes -Settings as a path or a hashtable, and @{} as no settings' { $explicit = @(Test-IntuneScript -Path $script:Slow -Settings @{ ExcludeRule = 'IslLongSleep' }) $explicit.RuleName | Should-NotContainCollection 'IslLongSleep' diff --git a/Tests/Unit/RuleReference.Tests.ps1 b/Tests/Unit/RuleReference.Tests.ps1 index 5cd26e8..31a09c5 100644 --- a/Tests/Unit/RuleReference.Tests.ps1 +++ b/Tests/Unit/RuleReference.Tests.ps1 @@ -34,11 +34,11 @@ Describe 'Build-RuleReference' -Tag 'Unit' { foreach ($row in $rows) { $row.Groups[2].Value | Should-NotBeWhiteSpaceString $row.Groups[3].Value | Should-NotBeWhiteSpaceString - # An unresolved expression would leave a variable or subexpression behind; a literal - # "$" (the name of an environment variable in a message) is fine + # An unresolved expression would leave a subexpression behind; a literal $name in a + # message ($PSScriptRoot, $env:ProgramW6432) is text the rule prints and stays $row.Groups[1].Value | Should-NotBeLikeString '*<*' - $row.Groups[2].Value | Should-NotBeLikeString '*$[a-zA-Z_(]*' - $row.Groups[3].Value | Should-NotBeLikeString '*$[a-zA-Z_(]*' + $row.Groups[2].Value | Should-NotBeLikeString '*$(*' + $row.Groups[3].Value | Should-NotBeLikeString '*$(*' } } diff --git a/Validation/Findings.md b/Validation/Findings.md index 5981ea8..f3a5407 100644 --- a/Validation/Findings.md +++ b/Validation/Findings.md @@ -368,6 +368,27 @@ round 1 (REM-PROBE-USER64, `AzureAD\JeffStuhr`). someone else; the stored-password fallback needs the batch logon right, and the launcher now says so instead of timing out. +Re-checked 2026-09-29 on the same device (isl-user still without the batch logon right, its +console session active): the stored-password task no longer comes back with `0x80070569`. It sits +`Ready` with `LastTaskResult` `0x00041303` ("has not run yet"), `LastRunTime` unset, and no error +anywhere, and the launcher waited out its timeout. 0.26.0 treats five seconds of that after +`Start-ScheduledTask` as the refusal and reports it with the same hint. + +### Reporting latency, re-measured (2026-09-29) + +| Kind | Device (log line, converted to UTC) | Graph | Lag | +|---|---|---|---| +| Remediation, changed result (REM-FIX after its marker was removed, hourly) | post-detection passed 01:37:34 | `lastStateUpdateDateTime` 02:42:58, `detectionState` fail / `remediationState` success | 65 min: the result rode the agent's next hourly report batch (`data in request` at 02:43:02 device time, 20 policies in one request); Graph wrote the state within seconds of the upload | +| Remediation, unchanged result (three hourly policies over four cycles) | runs logged every hour | `lastStateUpdateDateTime` unchanged since the last change (days earlier), `lastSyncDateTime` current | never: the agent logs "app result is the same as cached one, no need to save" and reports nothing, so `lastStateUpdateDateTime` is the last change, not the last run | +| Platform script (two policies, Failed) | 16:56:38 and 16:56:44 | `lastStateUpdateDateTime` 16:56:46 | 2-8 s | +| Win32 app install state (`DeviceInstallStatusByApp` export, two apps) | report lines 00:25:53 and 00:26:02 | `LastModifiedDateTime` 00:26:32 for both | 30-39 s | + +The report line's `Result` code, matched against Graph on both lab devices: `3` when the detection +found no issue (`success` / `skipped`), `4` when the issue was found and the remediation ran, both +for a fix (`fail` / `success`) and for a recurrence (`fail` / `remediationFailed` or `scriptError`), +`5` when the detection script itself failed (`scriptError` / `skipped`). It is not the registry +`RemediationStatus` code set above. + ## The Enrollment Status Page Round 8, 2026-09-28, VM 126 (a fresh clone with SMBIOS serial `ISL-ESP-01`): hardware hash imported diff --git a/docs/IntuneScriptLab/Assert-HaveIntuneStatus.md b/docs/IntuneScriptLab/Assert-HaveIntuneStatus.md index 656e2dd..06b9436 100644 --- a/docs/IntuneScriptLab/Assert-HaveIntuneStatus.md +++ b/docs/IntuneScriptLab/Assert-HaveIntuneStatus.md @@ -28,9 +28,11 @@ Assert-HaveIntuneStatus [-Expected] [[-Actual] ] [-Because ] [-MinimumSeverity ] [-Scr ## DESCRIPTION -Runs Test-IntuneScript on the path and fails with the list of findings, each with its line, -severity, rule and message, so the test output is the fix list. +Runs Test-IntuneScript on the path, folder or pipeline of files and fails with the list of +findings, each with its file, line, severity, rule and message, so the test output is the fix +list. The IslAssumedContext note is never counted. ## EXAMPLES diff --git a/docs/IntuneScriptLab/Compare-IntuneDeployedScript.md b/docs/IntuneScriptLab/Compare-IntuneDeployedScript.md index 7effd5a..894cd8e 100644 --- a/docs/IntuneScriptLab/Compare-IntuneDeployedScript.md +++ b/docs/IntuneScriptLab/Compare-IntuneDeployedScript.md @@ -43,8 +43,9 @@ Local files are found by convention under -Path, searched recursively: Platform script 'Set-Proxy' Set-Proxy.ps1 anywhere, or a folder named Set-Proxy with one .ps1 Win32 app 'Widget 2.0' a folder named 'Widget 2.0' holding Detect*.ps1 and Requirement*.ps1 -Characters Windows does not allow in a name are matched as underscores, so the app -'Widget: 2.0' matches a folder named 'Widget_ 2.0'. +Names are matched without regard to case, with every character other than a letter, a digit, +'.', '_' or '-' taken as an underscore, so the app 'Widget: 2.0' matches a folder named +'Widget_ 2.0' or 'widget__2.0'. -Map names the files directly when the layout is different. A policy with no local file, a local match that is ambiguous and, with @@ -57,8 +58,10 @@ Inferred values are never compared. One result per script role. State is InSync, Drifted, Missing (no local file), Ambiguous -(more than one local candidate) or NotInTenant (a local file or -Map entry the tenant has no -script for); Differences lists what differs (Content, LineEndings, Whitespace, Bom, Settings) +(more than one local candidate) or NotInTenant (a local file for a role the policy does not +carry, or a -Map entry naming a policy the selection did not include; a local folder for a +policy the tenant does not have is not reported); Differences lists what differs (Content, +LineEndings, Whitespace, Bom, Settings) and Detail says where. Nothing in the tenant is changed. @@ -66,7 +69,7 @@ Nothing in the tenant is changed. ### EXAMPLE 1 -Connect-MgGraph -Scopes DeviceManagementConfiguration.Read.All, DeviceManagementApps.Read.All +Connect-MgGraph -Scopes DeviceManagementScripts.Read.All, DeviceManagementApps.Read.All Compare-IntuneDeployedScript -Path C:\Repos\intune-scripts | Where-Object State -ne InSync Every deployed script whose local copy differs, is missing or is ambiguous. diff --git a/docs/IntuneScriptLab/Export-IntuneAgentDiagnostic.md b/docs/IntuneScriptLab/Export-IntuneAgentDiagnostic.md index b1742d7..a4dad04 100644 --- a/docs/IntuneScriptLab/Export-IntuneAgentDiagnostic.md +++ b/docs/IntuneScriptLab/Export-IntuneAgentDiagnostic.md @@ -66,8 +66,9 @@ Logs, device facts and timelines only, written to diag.zip over an earlier one. Export-IntuneAgentDiagnostic -Path .\copied.zip -LogPath \\server\share\LAB-042\Logs -SkipRegistry -Timelines and logs from a log folder copied off another device; the registry and device -sections describe this machine, so they are left out. +Timelines and logs from a log folder copied off another device; the registry describes this +machine, so it is left out. The Device section still describes this machine, not the one the +logs came from. ## PARAMETERS @@ -202,8 +203,8 @@ those sections are present) and SizeBytes. ## NOTES Author: Jeffrey Stuhr. -Reading HKLM\SOFTWARE\Microsoft\IntuneManagementExtension needs no -elevation; dsregcmd /status runs when dsregcmd.exe is on the path. +The registry is read with the caller's rights; a key that cannot be read is left out of the +zip rather than failing the export. dsregcmd /status runs when dsregcmd.exe is on the path. ## RELATED LINKS diff --git a/docs/IntuneScriptLab/Export-IntuneAgentDiagnostic.ps1 b/docs/IntuneScriptLab/Export-IntuneAgentDiagnostic.ps1 deleted file mode 100644 index e69de29..0000000 diff --git a/docs/IntuneScriptLab/Export-IntuneFindingSarif.md b/docs/IntuneScriptLab/Export-IntuneFindingSarif.md index 3d73eb6..432d2da 100644 --- a/docs/IntuneScriptLab/Export-IntuneFindingSarif.md +++ b/docs/IntuneScriptLab/Export-IntuneFindingSarif.md @@ -29,8 +29,9 @@ Export-IntuneFindingSarif [-Finding] [-Path] [[-Root] ] Parses the agent's CMTrace logs (IntuneManagementExtension, AppWorkload, HealthScripts and AgentExecutor, rolled files included) into one object per entry, merges them in time order -and names the event each known line records: a script policy fetch and its download count, -a remediation's schedule inspection, start, detection result and report, a Win32 app's -policy fetch, applicability, detection, rule evaluation, install and report, AgentExecutor's -launch, exit code and script output, the Enrollment Status Page's phase, selected apps, +and names the event each known line records: a platform script's policy fetch and download +count, its start, launch, result and exit, a user-context script skipped on an Entra +registered device, the agent's start and its userless check-in, a remediation's schedule +inspection, queueing, start, detection result and report, a Win32 app's policy fetch, one +filtered out by an assignment filter or skipped for user context, its applicability, +requirement checks, detection, rule evaluation, install, exit code, outcome and report, +AgentExecutor's launch, exit code, script output and errors, the Enrollment Status Page's phase, selected apps, their registration and tracked install states, its completion and the check-in that follows it, and an app's relationships: the subgraph it is processed in, a skipped subgraph, the report that names the impacting app with its classification and conflict reason, the dependency @@ -72,10 +75,11 @@ Everything the agent logged about one policy in the last two hours, across all f ### EXAMPLE 3 -Get-IntuneAgentLog -Path .\Logs -Log AppWorkload -EventName AppDetection, AppInstallExit, AppReport | - Group-Object Id | ForEach-Object { $_.Group | Select-Object -Last 3 } +Get-IntuneAgentLog -Path .\Logs -Log AppWorkload -EventName AppDetection, AppReport | + Group-Object Id | ForEach-Object { $_.Group | Select-Object -Last 1 } -From a copied log folder: the last detection, install exit code and report per app. +From a copied log folder: the last detection or report per app. An install exit code line +carries no app id, so read those with -EventName AppInstallExit on their own. ## PARAMETERS @@ -313,7 +317,7 @@ This command does not accept pipeline input. Pass the paths and filters as param ### IntuneScriptLab.AgentLogEntry -One object per log entry: Time (the local time the agent wrote), Level (Information, Warning or Error), Component, Thread, Event (the name from the event table, or empty for a line the table does not know), Detail (what the event's pattern captured: an exit code, a detection state, a download count), Id (the first policy or app id in the message), Message, Log (the file's base name) and Line. With -ListEvent, one IntuneScriptLab.AgentLogEventDefinition per event with Event and Pattern. +One object per log entry: Time (the local time the agent wrote), Level (Information, Warning or Error), Component, Thread, Event (the name from the event table, or empty for a line the table does not know), Detail (what the event's pattern captured: an exit code, a detection state, a download count), Id (the first non-empty GUID in the message, or the empty GUID when that is all the line carries), Message, Log (the file's base name) and Line. With -ListEvent, one IntuneScriptLab.AgentLogEventDefinition per event with Event and Pattern. ## NOTES diff --git a/docs/IntuneScriptLab/Get-IntuneAgentTimeline.md b/docs/IntuneScriptLab/Get-IntuneAgentTimeline.md index 5af4fa0..831da40 100644 --- a/docs/IntuneScriptLab/Get-IntuneAgentTimeline.md +++ b/docs/IntuneScriptLab/Get-IntuneAgentTimeline.md @@ -30,14 +30,19 @@ Get-IntuneAgentTimeline [[-Path] ] [-Log ] [-Id ] Reads the agent's logs through Get-IntuneAgentLog, keeps the lines the event table names and groups them by the policy or app id they carry, so one object tells the story of one -remediation, platform script or Win32 app: when the agent fetched it, queued it, ran it, what -the detection said and what it reported, in order, with the time between the first and the -last step. +remediation, platform script or Win32 app: when the agent inspected its schedule or +applicability, launched it, what the detection said and what it reported, in order, with the +time between the first and the last step. A line that carries no id (the queue notice, an +install exit code, AgentExecutor's own lines) belongs to no timeline. Win32 apps are named from the policy list the agent logs; scripts show the id. -Outcome is the last result the agent logged for the id: a remediation's report result -(Result 4 fixed, 3 failed, 2 nothing to fix, per the validation rounds), a platform script's -policy result, an app's reported state or install outcome. +Outcome is the last result the agent logged for the id: a remediation's report result (the +Result code of its report line: 3, the detection found no issue; 4, the issue was found and the +remediation ran, whether or not the post-detection then passed, which Graph tells apart as +remediationState success or remediationFailed; 5, the detection script itself failed; matched +against Graph's run states on the lab devices, 2026-09-29), a platform script's +policy result, an app's reported state or relationship report, detection, applicability or +Enrollment Status Page state. Runs counts the launches (remediation starts, script policy starts, app executions). Steps holds every event with its @@ -65,7 +70,7 @@ Step by step for one policy. ### EXAMPLE 3 Get-IntuneAgentTimeline -Path .\Logs -After (Get-Date).AddHours(-1) | - Where-Object Outcome -like 'AppReport*' | Select-Object Name, Outcome, Summary + Where-Object Outcome -like 'App*Report*' | Select-Object Name, Outcome, Summary The apps a copied log folder shows in the last hour, by name, with their reported states. @@ -115,7 +120,7 @@ HelpMessage: '' ### -Id -Only the timelines of these policy or app ids. +Only the timelines whose own id is one of these. A line that merely mentions the id (a relationship report names two apps) does not add another timeline. ```yaml Type: System.String[] diff --git a/docs/IntuneScriptLab/Get-IntuneAgentTimeline.ps1 b/docs/IntuneScriptLab/Get-IntuneAgentTimeline.ps1 deleted file mode 100644 index e69de29..0000000 diff --git a/docs/IntuneScriptLab/Get-IntuneAnalyzerRulePath.md b/docs/IntuneScriptLab/Get-IntuneAnalyzerRulePath.md index 1915ed7..0aecd9e 100644 --- a/docs/IntuneScriptLab/Get-IntuneAnalyzerRulePath.md +++ b/docs/IntuneScriptLab/Get-IntuneAnalyzerRulePath.md @@ -60,10 +60,11 @@ Only the IntuneScriptLab rules, without the note about the assumed script type. ### EXAMPLE 3 -@{ CustomRulePath = Get-IntuneAnalyzerRulePath; IncludeDefaultRules = $true } | - ConvertTo-Json +$rules = Get-IntuneAnalyzerRulePath +"@{ CustomRulePath = '$rules'; IncludeDefaultRules = `$true }" | + Set-Content .\PSScriptAnalyzerSettings.psd1 -The values for a PSScriptAnalyzerSettings.psd1 that includes the rules in every run. +Writes a PSScriptAnalyzerSettings.psd1 that includes the rules in every run. ## PARAMETERS diff --git a/docs/IntuneScriptLab/Get-IntuneScriptHealth.md b/docs/IntuneScriptLab/Get-IntuneScriptHealth.md index 58f9ffc..bcec463 100644 --- a/docs/IntuneScriptLab/Get-IntuneScriptHealth.md +++ b/docs/IntuneScriptLab/Get-IntuneScriptHealth.md @@ -44,12 +44,17 @@ and Win32 app the tenant has, one object with: gone from Graph); - a Health verdict with its reasons: Broken when a finding is an Error, when the policy is assigned to nobody, or when every device that ran it failed; Attention when there are - Warnings, failures, a remediation whose issue stays detected, drift, or an assigned policy - that no device has reported on yet; Healthy otherwise. - -Intune's run states lag the device: a remediation's result reached Graph up to an hour after -the device ran it in the validation rounds, platform script and app states within minutes -(Validation\Findings.md). + Warnings, failures, a remediation whose issue stays detected, drift (Drifted, Missing or + Ambiguous), devices the policy was not applicable to, or an assigned policy that no device + has reported on yet; Healthy otherwise. + +Intune's run states lag the device. A remediation's result travels in the agent's hourly +report batch, not at the run: a fix at 01:37 UTC reached Graph at 02:42 UTC, when the next +cycle uploaded it, and Graph wrote the state within seconds of that upload. A platform script's +run state arrived 2-8 s after the device's log line, an app's install state 30-39 s after +(2026-09-29, Validation\Findings.md). A recurring remediation whose result has not changed is +not re-reported at all, so lastStateUpdateDateTime is the last change, not the last run, and a +count that is days old is not stale. A policy changed minutes ago is best read with -SkipRunState. -MarkdownPath writes the same report as a Markdown table per kind, for a wiki or a pull request. Nothing in the tenant is changed. @@ -58,8 +63,8 @@ Nothing in the tenant is changed. ### EXAMPLE 1 -Connect-MgGraph -Scopes DeviceManagementConfiguration.Read.All, DeviceManagementApps.Read.All, - DeviceManagementScripts.Read.All, DeviceManagementManagedDevices.Read.All +Connect-MgGraph -Scopes DeviceManagementConfiguration.Read.All, DeviceManagementApps.ReadWrite.All, + DeviceManagementScripts.Read.All, GroupMember.ReadBasic.All Get-IntuneScriptHealth | Where-Object Health -ne Healthy Every policy that needs a look, with the reasons in Notes. @@ -237,7 +242,7 @@ HelpMessage: '' ### -SkipAnalysis -Leave the script rules out: Errors and Warnings stay empty and do not weigh on Health. +Leave the script rules out: Errors and Warnings are 0 and do not weigh on Health; the assignment check still does. ```yaml Type: System.Management.Automation.SwitchParameter @@ -319,21 +324,25 @@ The command takes no pipeline input. One object per policy: Kind, PolicyName, PolicyId, Context (system, user, or the app's install context), Assigned (include targets, with a note for exclusions and filters), LastModified, -Errors, Warnings, Findings (the objects behind the counts), Drift and DriftDetail, Succeeded, +Errors, Warnings, Findings (the objects behind the counts), Drift (InSync, Drifted, Missing, Ambiguous, NotInTenant, NoScript for a policy that carries no script, or empty without -Path) and DriftDetail, Succeeded, Failed, Detected (remediations: issue detected or back, not fixed), Pending, LastRun, Health (Healthy, Attention, Broken) and Notes. ## NOTES Author: Jeffrey Stuhr. -Needs a Microsoft.Graph.Authentication session with -DeviceManagementConfiguration.Read.All, DeviceManagementScripts.Read.All, -DeviceManagementApps.Read.All and, for the app install export, -DeviceManagementManagedDevices.Read.All; GroupMember.Read.All for the assignment check. - -Remediation run states in Graph lagged the device by up to an hour in the validation rounds; -platform script and Win32 app states appeared within seconds to minutes (Validation\Findings.md, -"Remediation daily schedule and detect-only assignment", "Win32 custom detection scripts"). +Needs a Microsoft.Graph.Authentication session with DeviceManagementScripts.Read.All +(remediations, platform scripts and their run summaries), DeviceManagementConfiguration.Read.All +(assignment filters), DeviceManagementApps.Read.All (apps) and GroupMember.ReadBasic.All (the +assignment check reads only member ids and types). The app install export creates an export +job, which the Graph reference lists as a write: one of DeviceManagementApps.ReadWrite.All, +DeviceManagementConfiguration.ReadWrite.All or DeviceManagementManagedDevices.ReadWrite.All. +Without it the export is skipped with a warning and the apps' device columns stay empty. + +Remediation run states reach Graph with the agent's next hourly report batch (65 minutes after +the run in the 2026-09-29 measurement, seconds after the upload itself); platform script states +2-8 s and Win32 app install states 30-39 s after the device's log line (Validation\Findings.md, +"Reporting latency, re-measured"). ## RELATED LINKS diff --git a/docs/IntuneScriptLab/IntuneScriptLab.md b/docs/IntuneScriptLab/IntuneScriptLab.md index 2323268..5acf217 100644 --- a/docs/IntuneScriptLab/IntuneScriptLab.md +++ b/docs/IntuneScriptLab/IntuneScriptLab.md @@ -14,7 +14,7 @@ title: IntuneScriptLab Module ## Description -Test Intune scripts before Intune does: static analysis, a runtime harness, Pester assertions +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 ## IntuneScriptLab Cmdlets diff --git a/docs/IntuneScriptLab/Invoke-IntuneDetectionTest.md b/docs/IntuneScriptLab/Invoke-IntuneDetectionTest.md index a62686c..ad9ab2c 100644 --- a/docs/IntuneScriptLab/Invoke-IntuneDetectionTest.md +++ b/docs/IntuneScriptLab/Invoke-IntuneDetectionTest.md @@ -68,7 +68,8 @@ Inside a Pester test. ### -Architecture Host to run in: x64 (Intune's default for Win32 detection), x86 (the "run as 32-bit" -option), or arm64 on a Windows on ARM device. +option), or arm64 on a Windows on ARM device. A Windows on ARM device has no x64 host, so the +x64 default is refused there: pass arm64, the host the agent uses on ARM64. ```yaml Type: System.String @@ -114,7 +115,7 @@ HelpMessage: '' ### -Credential -Run as this account instead of the current user, with -Context User: a one-shot scheduled task registered for the account, interactive inside the account's own session when it holds one (the way the agent runs user-context scripts inside the signed-in user's session, REM-PROBE-USER64), otherwise a stored-password logon in session 0; the result's RunAs says which. Needs an elevated session, and is refused with -Context System. Validation\New-IslHarnessUser.ps1 creates a lab account with a stored credential to use here. +Run as this account instead of the current user, with -Context User: a one-shot scheduled task registered for the account, interactive inside the account's own session when it holds one (the way the agent runs user-context scripts inside the signed-in user's session, REM-PROBE-USER64), otherwise a stored-password logon in session 0, which needs the account to hold the "Log on as a batch job" right (a standard user does not; the scheduler then never starts the task, and the launcher reports that within seconds rather than at the timeout); the result's RunAs says which. Needs an elevated session, and is refused with -Context System. Validation\New-IslHarnessUser.ps1 creates a lab account with a stored credential to use here. ```yaml Type: System.Management.Automation.PSCredential @@ -216,7 +217,7 @@ This command does not accept pipeline input. Pass the script path with -Path. ### IntuneScriptLab.DetectionResult -Detected, Reason, ExitCode, StdOut, StdErr, TimedOut, Duration, SignatureStatus (with -EnforceSignatureCheck), Architecture, Context, Host and ScriptPath. Detected is true only for exit 0 with stdout and no stderr, which is the rule Intune applies. +Detected, Reason, ExitCode, StdOut, StdErr, TimedOut, Duration, SignatureStatus (filled with -EnforceSignatureCheck, empty otherwise), RunAs (the account and logon type the script ran as), Architecture, Context, Host and ScriptPath. Detected is true only for exit 0 with stdout and no stderr, which is the rule Intune applies. ## NOTES diff --git a/docs/IntuneScriptLab/Invoke-IntunePlatformScriptTest.md b/docs/IntuneScriptLab/Invoke-IntunePlatformScriptTest.md index 98b8175..882636e 100644 --- a/docs/IntuneScriptLab/Invoke-IntunePlatformScriptTest.md +++ b/docs/IntuneScriptLab/Invoke-IntunePlatformScriptTest.md @@ -112,7 +112,7 @@ HelpMessage: '' ### -Credential -Run as this account instead of the current user, with -Context User: a one-shot scheduled task registered for the account, interactive inside the account's own session when it holds one (the way the agent runs user-context scripts inside the signed-in user's session, REM-PROBE-USER64), otherwise a stored-password logon in session 0; the result's RunAs says which. Needs an elevated session, and is refused with -Context System. Validation\New-IslHarnessUser.ps1 creates a lab account with a stored credential to use here. +Run as this account instead of the current user, with -Context User: a one-shot scheduled task registered for the account, interactive inside the account's own session when it holds one (the way the agent runs user-context scripts inside the signed-in user's session, REM-PROBE-USER64), otherwise a stored-password logon in session 0, which needs the account to hold the "Log on as a batch job" right (a standard user does not; the scheduler then never starts the task, and the launcher reports that within seconds rather than at the timeout); the result's RunAs says which. Needs an elevated session, and is refused with -Context System. Validation\New-IslHarnessUser.ps1 creates a lab account with a stored credential to use here. ```yaml Type: System.Management.Automation.PSCredential @@ -191,7 +191,7 @@ This command does not accept pipeline input. Pass the script path with -Path. ### IntuneScriptLab.PlatformScriptResult -RunState (Success, Failed or TimedOut), ExitCode, ResultMessage (stdout and stderr as the portal shows them), StdOut, StdErr, TimedOut, Duration, Warnings (on a failure, what Intune does next: three runs in total at policy fetches, then Failed for good), Architecture, Context, Host and ScriptPath. +RunState (Success, Failed or TimedOut), ExitCode, ResultMessage (stdout and stderr as the portal shows them), StdOut, StdErr, TimedOut, Duration, Warnings (on a failure, what Intune does next: three runs in total at policy fetches, then Failed for good), RunAs (the account and logon type the script ran as), Architecture, Context, Host and ScriptPath. ## NOTES diff --git a/docs/IntuneScriptLab/Invoke-IntuneRemediationTest.md b/docs/IntuneScriptLab/Invoke-IntuneRemediationTest.md index f8e2afb..a1211d2 100644 --- a/docs/IntuneScriptLab/Invoke-IntuneRemediationTest.md +++ b/docs/IntuneScriptLab/Invoke-IntuneRemediationTest.md @@ -35,6 +35,9 @@ again. The status follows what the device recorded: Without issues detection exited 0, remediation skipped + Issue detected (no remediation script) + detection non-zero with no -RemediationPath, as a detect-only + remediation records it Fixed detection non-zero, remediation 0, post-detection 0 Recurred detection non-zero, remediation 0, post-detection still non-zero Failed remediation exited non-zero (post-detection skipped) @@ -126,7 +129,7 @@ HelpMessage: '' ### -Credential -Run as this account instead of the current user, with -Context User: a one-shot scheduled task registered for the account, interactive inside the account's own session when it holds one (the way the agent runs user-context scripts inside the signed-in user's session, REM-PROBE-USER64), otherwise a stored-password logon in session 0; the result's RunAs says which. Needs an elevated session, and is refused with -Context System. Validation\New-IslHarnessUser.ps1 creates a lab account with a stored credential to use here. +Run as this account instead of the current user, with -Context User: a one-shot scheduled task registered for the account, interactive inside the account's own session when it holds one (the way the agent runs user-context scripts inside the signed-in user's session, REM-PROBE-USER64), otherwise a stored-password logon in session 0, which needs the account to hold the "Log on as a batch job" right (a standard user does not; the scheduler then never starts the task, and the launcher reports that within seconds rather than at the timeout); the result's RunAs says which. Needs an elevated session, and is refused with -Context System. Validation\New-IslHarnessUser.ps1 creates a lab account with a stored credential to use here. ```yaml Type: System.Management.Automation.PSCredential @@ -227,7 +230,7 @@ This command does not accept pipeline input. Pass the scripts with -DetectionPat ### IntuneScriptLab.RemediationResult -Status (Without issues, Issue detected (no remediation script), Fixed, Recurred, Failed or TimedOut), IntuneOutput and IntuneError (the last line of each stream, 2,048-character tail, as the portal reports them), RemediationOutput, PostOutput, the PreDetection, Remediation and PostDetection runs, Warnings, Architecture, Context and Host. +Status (Without issues, Issue detected (no remediation script), Fixed, Recurred, Failed or TimedOut), IntuneOutput (the pre-detection's last stdout line) and IntuneError (its stderr text), each cut to its last 2,048 characters as the portal reports them, RemediationOutput, PostOutput, the PreDetection, Remediation and PostDetection runs, Warnings, RunAs (the account and logon type the scripts ran as), Architecture, Context and Host. ## NOTES diff --git a/docs/IntuneScriptLab/Invoke-IntuneRequirementTest.md b/docs/IntuneScriptLab/Invoke-IntuneRequirementTest.md index 788dc3a..27c0b20 100644 --- a/docs/IntuneScriptLab/Invoke-IntuneRequirementTest.md +++ b/docs/IntuneScriptLab/Invoke-IntuneRequirementTest.md @@ -34,7 +34,8 @@ requirement rule (Windows PowerShell 5.1 in the chosen host, -NoProfile -Executi Bypass -File, working directory C:\WINDOWS\system32, a copy of the script, OEM output) and evaluates the rule as the agent was observed to: only on exit 0 with nothing on stderr; the whole console output minus its final line break is the value (Write-Host counts; a second line -or trailing spaces never match); string comparison ignores case; Integer, +never matches, and for a String rule neither do trailing spaces; the typed rules trim +first); string comparison ignores case; Integer, Float, Version, Boolean and DateTime outputs are parsed as that type, and an output that does not parse fails the rule. @@ -69,7 +70,8 @@ As SYSTEM from an elevated session, inside a Pester test. ### -Architecture Host to run in: x64 (the default for requirement rules), x86 (the "run as 32-bit" -option), or arm64 on a Windows on ARM device. +option), or arm64 on a Windows on ARM device. A Windows on ARM device has no x64 host, so the +x64 default is refused there: pass arm64, the host the agent uses on ARM64. ```yaml Type: System.String @@ -115,7 +117,7 @@ HelpMessage: '' ### -Credential -Run as this account instead of the current user, with -Context User: a one-shot scheduled task registered for the account, interactive inside the account's own session when it holds one (the way the agent runs user-context scripts inside the signed-in user's session, REM-PROBE-USER64), otherwise a stored-password logon in session 0; the result's RunAs says which. Needs an elevated session, and is refused with -Context System. Validation\New-IslHarnessUser.ps1 creates a lab account with a stored credential to use here. +Run as this account instead of the current user, with -Context User: a one-shot scheduled task registered for the account, interactive inside the account's own session when it holds one (the way the agent runs user-context scripts inside the signed-in user's session, REM-PROBE-USER64), otherwise a stored-password logon in session 0, which needs the account to hold the "Log on as a batch job" right (a standard user does not; the scheduler then never starts the task, and the launcher reports that within seconds rather than at the timeout); the result's RunAs says which. Needs an elevated session, and is refused with -Context System. Validation\New-IslHarnessUser.ps1 creates a lab account with a stored credential to use here. ```yaml Type: System.Management.Automation.PSCredential @@ -259,7 +261,7 @@ This command does not accept pipeline input. Pass the script path with -Path. ### IntuneScriptLab.RequirementResult -Applicable, Reason, Output (the text the rule compared), ExitCode, StdOut, StdErr, TimedOut, Duration, OutputType, Operator, Value, Architecture, Context, Host and ScriptPath. +Applicable, Reason, Output (the text the rule compared), ExitCode, StdOut, StdErr, TimedOut, Duration, OutputType, Operator, Value, RunAs (the account and logon type the script ran as), Architecture, Context, Host and ScriptPath. ## NOTES diff --git a/docs/IntuneScriptLab/Invoke-IntuneWin32AppTest.md b/docs/IntuneScriptLab/Invoke-IntuneWin32AppTest.md index 4144b4b..346da2a 100644 --- a/docs/IntuneScriptLab/Invoke-IntuneWin32AppTest.md +++ b/docs/IntuneScriptLab/Invoke-IntuneWin32AppTest.md @@ -49,14 +49,15 @@ Statuses: Not detected after install install returned a success code but detection still fails, Intune's 0x87D1041C "app installed but not detected" - Install failed exit code not in the success/reboot/retry codes + Install failed (exit N) exit code not in the success/reboot/retry codes Retry exit code mapped to retry (Intune retries 3 times, 5 min apart) Not installed intent Uninstall and nothing was detected, so nothing ran Uninstalled the uninstall command succeeded and the post-detection no longer reports the app (W32-UNINSTALL: Intune shows "Not installed") Still detected after uninstall the uninstall command succeeded but the detection still says installed - Uninstall failed uninstall exit code not in the success/reboot/retry codes + Uninstall failed (exit N) + uninstall exit code not in the success/reboot/retry codes Not installed (dependency) a dependency was not detected and could not be installed (a detect dependency, a missing install command, or a failed install), so this @@ -142,7 +143,7 @@ package is uninstalled if it is present, then the app installs. ### -Architecture -Host for the detection script: x64 (Intune default), x86, or arm64. +Host for the detection script: x64 (Intune default), x86, or arm64. A Windows on ARM device has no x64 host, so a -DetectionPath with the x64 default is refused there: pass arm64. ```yaml Type: System.String @@ -209,7 +210,7 @@ HelpMessage: '' ### -Credential -Run every launch (detection and requirement scripts, dependencies, the install or uninstall command) as this account instead of the current user, with -Context User: a one-shot scheduled task registered for the account, interactive inside the account's own session when it holds one (the way the agent runs user-context scripts inside the signed-in user's session, REM-PROBE-USER64), otherwise a stored-password logon in session 0; the result's RunAs says which. Needs an elevated session, and is refused with -Context System. Validation\New-IslHarnessUser.ps1 creates a lab account with a stored credential to use here. +Run every launch (the detection script, the related apps' detections, the install or uninstall command) as this account instead of the current user, with -Context User: a one-shot scheduled task registered for the account, interactive inside the account's own session when it holds one (the way the agent runs user-context scripts inside the signed-in user's session, REM-PROBE-USER64), otherwise a stored-password logon in session 0, which needs the account to hold the "Log on as a batch job" right (a standard user does not; the scheduler then never starts the task, and the launcher reports that within seconds rather than at the timeout); the result's RunAs says which. Needs an elevated session, and is refused with -Context System. Validation\New-IslHarnessUser.ps1 creates a lab account with a stored credential to use here. ```yaml Type: System.Management.Automation.PSCredential @@ -522,7 +523,7 @@ This command does not accept pipeline input. Pass the paths, rules and commands ### IntuneScriptLab.Win32AppResult -Status (Installed, Installed after install, Not detected after install, Install failed (exit n), Retry, Not installed, Not installed (dependency), Uninstalled, Still detected after uninstall, Uninstall failed (exit n) or TimedOut), Intent, the PreDetection and PostDetection script results, PreRules and PostRules (one IntuneScriptLab.RuleResult per detection rule), Dependencies and Superseded (one IntuneScriptLab.Win32RelatedResult per related app, with Name, Relationship, Status and its own detections, rules and install or uninstall run), the Install or Uninstall run (command, exit code, output), Warnings, Architecture and Context. +Status (Installed, Installed after install, Not detected after install, Install failed (exit n), Retry, Not installed, Not installed (dependency), Uninstalled, Still detected after uninstall, Uninstall failed (exit n) or TimedOut), Intent, the PreDetection and PostDetection script results, PreRules and PostRules (one IntuneScriptLab.RuleResult per detection rule), Dependencies and Superseded (one IntuneScriptLab.Win32RelatedResult per related app, with Name, Relationship, Status and its own detections, rules and install or uninstall run), the Install or Uninstall run (command, exit code, output), Warnings, RunAs (the account and logon type the launches ran as), Architecture and Context. ## NOTES diff --git a/docs/IntuneScriptLab/Repair-IntuneScript.md b/docs/IntuneScriptLab/Repair-IntuneScript.md index 49d0f49..f455693 100644 --- a/docs/IntuneScriptLab/Repair-IntuneScript.md +++ b/docs/IntuneScriptLab/Repair-IntuneScript.md @@ -35,8 +35,9 @@ behaviour-preserving edit: 'return' turns into 'exit 0', 'return ' into '; exit 0'. The script does the same as before, and now says so; whether that exit should have been 1 is still the author's call - IslEncodingIssue a UTF-8 file without a BOM that holds non-ASCII text, or a UTF-16 - file, is rewritten as UTF-8 with a BOM, the encoding Intune expects + IslEncodingIssue a UTF-8 file without a BOM that holds non-ASCII text, a UTF-16 file + or an ANSI file (read in the system ANSI code page, so its characters + survive) is rewritten as UTF-8 with a BOM, the encoding Intune expects IslOutputIssue a requirement script's output literal with leading or trailing whitespace is trimmed, so it can match the portal value @@ -237,7 +238,7 @@ Paths, or objects with a FullName property such as the FileInfo objects Get-Chil ### IntuneScriptLab.Repair -One per script: Path, Applied (fixes made), Remaining (findings still reported afterwards), Written (whether the file changed; false under -WhatIf) and Fixes, one IntuneScriptLab.Fix per edit with RuleName, Line (0 for the encoding), Before and After. +One per script: Path, Applied (fixes made, or that would be made under -WhatIf), Remaining (findings still reported after the fixes; under -WhatIf, the findings minus the fixes), Written (whether the file changed; false under -WhatIf) and Fixes, one IntuneScriptLab.Fix per edit with RuleName, Line (0 for the encoding), Before and After. ## NOTES diff --git a/docs/IntuneScriptLab/Test-IntuneAssignmentFilter.md b/docs/IntuneScriptLab/Test-IntuneAssignmentFilter.md index 05c6024..8f4108f 100644 --- a/docs/IntuneScriptLab/Test-IntuneAssignmentFilter.md +++ b/docs/IntuneScriptLab/Test-IntuneAssignmentFilter.md @@ -73,6 +73,8 @@ Reason : Not applicable: the rule does not match this device; the portal sho are not met." (W32-FILTER-INCLUDE) Clauses : device.deviceName -startsWith "LAB-" [not matched, actual: DESKTOP-P96U0KB] device.cpuArchitecture -eq "arm64" [matched, actual: arm64] +Warnings : {} +Rule : (device.deviceName -startsWith "LAB-") and (device.cpuArchitecture -eq "arm64") The rule against this device; each clause reports what it saw. @@ -84,7 +86,11 @@ Test-IntuneAssignmentFilter -Rule $rule -Device $device -Mode Exclude Applicable : False Matched : True +Mode : Exclude Reason : Not applicable: the exclude rule matches this device (W32-FILTER-EXCLUDE) +Clauses : device.operatingSystemVersion -lt 10.0.26100.5000 [matched, actual: 10.0.26100.4652] +Warnings : {} +Rule : (device.operatingSystemVersion -lt 10.0.26100.5000) A described device against an exclude filter: the version compares numerically and the match excludes the device. diff --git a/docs/IntuneScriptLab/Test-IntuneDeployedScript.md b/docs/IntuneScriptLab/Test-IntuneDeployedScript.md index 4e8fa2d..6c05a18 100644 --- a/docs/IntuneScriptLab/Test-IntuneDeployedScript.md +++ b/docs/IntuneScriptLab/Test-IntuneDeployedScript.md @@ -44,19 +44,22 @@ justified (Validation\Findings.md): IslDetectionRuleIssue Error a file rule with detectionType doesNotExist: the agent does not evaluate it (missing file: not detected; present file: "Invalid detection rule", 0x87D30004) - IslAssignmentIssue Warning a Win32 app with install behavior User assigned to a group - whose members are devices: never installed ("userless + IslAssignmentIssue Warning a Win32 app with install behavior User assigned to All + devices or to a group whose sampled members (the first + twenty) are all devices: never installed ("userless check-in", Not applicable, code 1011) IslDetectOnly Info a remediation with no remediation script: the detection runs alone on its schedule (remediationState skipped) - IslFilterIssue Warning an assignment filter on the policy whose clause no Windows - device can match ("x64" for cpuArchitecture, "Microsoft - Entra joined" for deviceTrustType): the service accepts the - rule and the filter evaluator matches nothing, so an include - filter reaches nobody and an exclude filter excludes nobody - (FLT-V25, FLT-E07, FLT-V27, FLT-F01). The deprecated - osVersion, the undocumented isTpmAttested and a rule this - evaluator cannot read are Information. + IslFilterIssue Warning an assignment filter on the policy with an -eq or -in clause + whose value no Windows device reports ("x64" for + cpuArchitecture, "Microsoft Entra joined" for + deviceTrustType): the service accepts the rule and the + filter evaluator matches nothing on that clause, so a rule + made of it reaches nobody as an include and excludes nobody + as an exclude (FLT-V25, FLT-E07, FLT-V27, FLT-F01). The same + value under -ne or -notIn matches every device; that, the + deprecated osVersion, the undocumented isTpmAttested and a + rule this evaluator cannot read are Information. IslAssignmentIssue Warning a policy with no assignment, or only exclusions: no device resolves it, so it never runs (ASSIGN-NONE, ASSIGN-EXCLONLY) IslAssignmentIssue Info a user-context remediation or platform script assigned to @@ -67,10 +70,13 @@ justified (Validation\Findings.md): once at the fetch (ASSIGN-PAST2) Needs Microsoft.Graph.Authentication connected first (Connect-MgGraph) with -DeviceManagementConfiguration.Read.All (policies and assignment filters), -DeviceManagementApps.Read.All, DeviceManagementScripts.Read.All and, for the assignment check, -GroupMember.Read.All. -A group lookup or a filter read the session is not allowed to make is reported once and skipped. +DeviceManagementScripts.Read.All (remediations and platform scripts), +DeviceManagementConfiguration.Read.All (assignment filters), DeviceManagementApps.Read.All (Win32 +apps) and, for the assignment check, GroupMember.ReadBasic.All (only member ids and types are +read; GroupMember.Read.All also works). Each is the least privileged permission the Graph +reference lists for that read. +A group lookup or a filter read that fails (a session without the scope, most often) is reported +once, and the rest of the run does without them. Script content is written to a temporary folder for the analysis, byte for byte as the tenant stores it, and removed afterwards. @@ -80,7 +86,8 @@ Nothing in the tenant is changed. ### EXAMPLE 1 -Connect-MgGraph -Scopes DeviceManagementConfiguration.Read.All, DeviceManagementApps.Read.All +Connect-MgGraph -Scopes DeviceManagementScripts.Read.All, DeviceManagementConfiguration.Read.All, + DeviceManagementApps.Read.All, GroupMember.ReadBasic.All Test-IntuneDeployedScript -MinimumSeverity Warning Every deployed script with a warning or an error, as the agent will run it. @@ -97,7 +104,7 @@ The remediations named Fix-* only, detection and remediation scripts alike. Test-IntuneDeployedScript -Kind Win32App -SkipGroupLookup | Group-Object PolicyName | Sort-Object Count -Descending | Select-Object Count, Name -Which apps' detection and requirement scripts collect the most findings, without touching +Which apps collect the most findings, script and policy checks together, without touching groups. ## PARAMETERS diff --git a/docs/IntuneScriptLab/Test-IntuneScript.md b/docs/IntuneScriptLab/Test-IntuneScript.md index 79e40c5..140ce13 100644 --- a/docs/IntuneScriptLab/Test-IntuneScript.md +++ b/docs/IntuneScriptLab/Test-IntuneScript.md @@ -43,12 +43,14 @@ explicitly, put a directive in the script, or let them be inferred from the file # IntuneScriptLab: ScriptType=Detection Context=System Architecture=x64 -Inference from the file name (whole words): requirement → Win32Requirement; detect with -app/win32/package/software/install/msi/exe → Win32Detection; detect → Detection; +Inference from the file name and the two nearest folder names, each word matched at its +start: requirement → Win32Requirement; detect with app, apps, win32, package, software, +install, installed, msi or exe in the name or a folder → Win32Detection; detect → Detection; remediate/remediation/fix → Remediation; anything else → PlatformScript. An -IslAssumedContext note is always emitted when the type was not given explicitly, because -the wrong type silently skips whole rule sets. +IslAssumedContext note is emitted whenever the type was inferred rather than given by a +parameter, a directive or a settings file, because the wrong type silently skips whole rule +sets. Defaults follow the portal: platform scripts run as the user in 32-bit, remediations as SYSTEM in 32-bit, Win32 detection as SYSTEM in 64-bit. @@ -158,7 +160,7 @@ HelpMessage: '' Analyze Win32 detection and requirement scripts as if the rule's "Enforce script signature check" were on: an unsigned script gets an IslSignatureIssue error, because the agent will not run it. The directive comment "# IntuneScriptLab: EnforceSignatureCheck=true" does the same for -one script. +one script, and the settings key EnforceSignatureCheck = $true for a folder. ```yaml Type: System.Management.Automation.SwitchParameter @@ -315,8 +317,9 @@ HelpMessage: '' A settings file path or a hashtable in the file's shape, instead of the search for IntuneScriptLab.settings.psd1 above each script; @{} means no settings. Keys: ExcludeRule, IncludeRule, MinimumSeverity, Severity (a hashtable of rule name to severity), ScriptType, -Context, Architecture and EnforceSignatureCheck. Explicit parameters win over the file, and a -script's directive wins over its type, context, architecture and signature entries. +Context, Architecture and EnforceSignatureCheck. Explicit parameters win over the file +(-ExcludeRule adds to the file's ExcludeRule; -IncludeRule sets it aside), and a script's +directive wins over its type, context, architecture and signature entries. ```yaml Type: System.Object @@ -360,7 +363,7 @@ Several paths at once, bound to -Path. ### IntuneScriptLab.Finding -One object per finding: RuleName, Severity (Information, Warning or Error), Message, ScriptPath, Line, Column, ScriptType, Text (the offending code) and Evidence (the observed Intune behaviour the rule rests on). +One object per finding: RuleName, Severity (Information, Warning or Error), Message, ScriptPath, Line, Column, ScriptType, Text (the offending code), Evidence (the observed Intune behaviour the rule rests on), Suppressed (true for a finding a Suppress directive silenced, shown with -IncludeSuppressed) and Fix (the edit Repair-IntuneScript would make, for the rules that offer one). ## NOTES diff --git a/docs/IntuneScriptLab/Test-IntuneWin32Rule.md b/docs/IntuneScriptLab/Test-IntuneWin32Rule.md index d6bb56a..4850eea 100644 --- a/docs/IntuneScriptLab/Test-IntuneWin32Rule.md +++ b/docs/IntuneScriptLab/Test-IntuneWin32Rule.md @@ -406,7 +406,7 @@ This command does not accept pipeline input. Pass the rule as parameters or as a ### IntuneScriptLab.RuleResult -Met, Kind (File, Registry or ProductCode), RuleType (Detection or Requirement), Target (the path, key or product code), Operation, Operator, Value, Actual (what was read from the device), Check32BitOn64System and Reason, which names the experiment the behaviour comes from. +Met, Kind (File, Registry or ProductCode), RuleType (Detection or Requirement), Target (the path, key or product code), Operation, Operator, Value, Actual (what was read from the device), Check32BitOn64System and Reason, which says what was read and, where the agent behaves other than documented, names the experiment. ## NOTES diff --git a/docs/Rules.md b/docs/Rules.md index ad73c7c..30bd694 100644 --- a/docs/Rules.md +++ b/docs/Rules.md @@ -14,9 +14,9 @@ Remediations and platform scripts default to the 32-bit host in the portal (Win3 | Severity | Message | Evidence | |---|---|---| | Information or Warning | HKLM:\SOFTWARE is redirected to HKLM:\SOFTWARE\WOW6432Node in the 32-bit host. Run the script in 64-bit or open the key with RegistryView.Registry64 | runAs32Bit launched C:\Windows\SysWOW64\WindowsPowerShell\v1.0\powershell.exe with Is64BitProcess=False (PS-PROBE-SYS32, REM-PROBE-SYS32, W32-DET-32) | -| Information or Warning | 'Program Files' resolves to 'Program Files (x86)' via the 32-bit environment. Use or run in 64-bit | runAs32Bit launched C:\Windows\SysWOW64\WindowsPowerShell\v1.0\powershell.exe with Is64BitProcess=False (PS-PROBE-SYS32, REM-PROBE-SYS32, W32-DET-32) | +| Information or Warning | 'Program Files' resolves to 'Program Files (x86)' via the 32-bit environment. Use ${env:ProgramW6432} or run in 64-bit | runAs32Bit launched C:\Windows\SysWOW64\WindowsPowerShell\v1.0\powershell.exe with Is64BitProcess=False (PS-PROBE-SYS32, REM-PROBE-SYS32, W32-DET-32) | | Information or Warning | System32 is redirected to SysWOW64 in the 32-bit host. Use Sysnative or run in 64-bit | runAs32Bit launched C:\Windows\SysWOW64\WindowsPowerShell\v1.0\powershell.exe with Is64BitProcess=False (PS-PROBE-SYS32, REM-PROBE-SYS32, W32-DET-32) | -| Information or Warning | is 'Program Files (x86)' in the 32-bit host. Use for the 64-bit folder | runAs32Bit launched C:\Windows\SysWOW64\WindowsPowerShell\v1.0\powershell.exe with Is64BitProcess=False (PS-PROBE-SYS32, REM-PROBE-SYS32, W32-DET-32) | +| Information or Warning | $env:ProgramFiles is 'Program Files (x86)' in the 32-bit host. Use $env:ProgramW6432 for the 64-bit folder | runAs32Bit launched C:\Windows\SysWOW64\WindowsPowerShell\v1.0\powershell.exe with Is64BitProcess=False (PS-PROBE-SYS32, REM-PROBE-SYS32, W32-DET-32) | | Warning | Sysnative only exists for 32-bit processes; in the 64-bit host the path does not exist | 64-bit host observed: System32\WindowsPowerShell\v1.0\powershell.exe, Is64BitProcess=True (PS-PROBE-SYS64); on ARM64 the native host has no Sysnative either (local survey) | Experiments: PS-PROBE-SYS32, PS-PROBE-SYS64, REM-PROBE-SYS32, W32-DET-32 @@ -31,7 +31,7 @@ On an ARM64 device the native host reports PROCESSOR_ARCHITECTURE=ARM64 (and the |---|---|---| | Warning | 'AMD64' does not match on Windows on ARM (the value is ARM64), so a 64-bit check built on it takes the 32-bit branch. Test [Environment]::Is64BitProcess or match 'ARM64\|AMD64' | Windows 11 ARM64 host survey: System32 PowerShell is native ARM64 (PROCESSOR_ARCHITECTURE=ARM64, Is64BitProcess=True), SysWOW64 is x86 (PROCESSOR_ARCHITEW6432=ARM64), Program Files (Arm) exists, no x64 PowerShell host | | Information | x64-specific package: on an ARM64 device this installs the emulated x64 build, or fails if the installer checks the CPU. Prefer an ARM64 or architecture-neutral package when one exists | Windows 11 ARM64 host survey: System32 PowerShell is native ARM64 (PROCESSOR_ARCHITECTURE=ARM64, Is64BitProcess=True), SysWOW64 is x86 (PROCESSOR_ARCHITEW6432=ARM64), Program Files (Arm) exists, no x64 PowerShell host | -| Information | Program Files (x86) is checked but not 'Program Files (Arm)', where ARM64 devices can keep 32-bit ARM apps () | Windows 11 ARM64 host survey: System32 PowerShell is native ARM64 (PROCESSOR_ARCHITECTURE=ARM64, Is64BitProcess=True), SysWOW64 is x86 (PROCESSOR_ARCHITEW6432=ARM64), Program Files (Arm) exists, no x64 PowerShell host | +| Information | Program Files (x86) is checked but not 'Program Files (Arm)', where ARM64 devices can keep 32-bit ARM apps (${env:ProgramFiles(Arm)}) | Windows 11 ARM64 host survey: System32 PowerShell is native ARM64 (PROCESSOR_ARCHITECTURE=ARM64, Is64BitProcess=True), SysWOW64 is x86 (PROCESSOR_ARCHITEW6432=ARM64), Program Files (Arm) exists, no x64 PowerShell host | ## IslContextIssue @@ -42,7 +42,7 @@ As SYSTEM, HKCU: is the SYSTEM account's own hive, USERPROFILE is C:\WINDOWS\sys | Severity | Message | Evidence | |---|---|---| | Error | HKCU: under SYSTEM is the SYSTEM account's hive, not the signed-in user's. Load the user's hive via HKU\ or run the script in user context | SYSTEM context: User=NT AUTHORITY\SYSTEM, session 0, USERPROFILE=C:\WINDOWS\system32\config\systemprofile, APPDATA under it, TEMP=C:\WINDOWS\TEMP (REM-PROBE-SYS64, PS-PROBE-SYS64) | -| Error | $ resolves to the SYSTEM profile (systemprofile), not the signed-in user. Look the user up (e.g. via explorer.exe''s owner or HKU) or run in user context | SYSTEM context: User=NT AUTHORITY\SYSTEM, session 0, USERPROFILE=C:\WINDOWS\system32\config\systemprofile, APPDATA under it, TEMP=C:\WINDOWS\TEMP (REM-PROBE-SYS64, PS-PROBE-SYS64) | +| Error | $ resolves to the SYSTEM profile (systemprofile), not the signed-in user. Look the user up (e.g. via explorer.exe's owner or HKU) or run in user context | SYSTEM context: User=NT AUTHORITY\SYSTEM, session 0, USERPROFILE=C:\WINDOWS\system32\config\systemprofile, APPDATA under it, TEMP=C:\WINDOWS\TEMP (REM-PROBE-SYS64, PS-PROBE-SYS64) | | Information | $ is C:\WINDOWS\TEMP under SYSTEM, which is fine if that is what you expect | SYSTEM context: User=NT AUTHORITY\SYSTEM, session 0, USERPROFILE=C:\WINDOWS\system32\config\systemprofile, APPDATA under it, TEMP=C:\WINDOWS\TEMP (REM-PROBE-SYS64, PS-PROBE-SYS64) | | Error | GetFolderPath for a per-user folder returns the SYSTEM profile's folder under SYSTEM | SYSTEM context: User=NT AUTHORITY\SYSTEM, session 0, USERPROFILE=C:\WINDOWS\system32\config\systemprofile, APPDATA under it, TEMP=C:\WINDOWS\TEMP (REM-PROBE-SYS64, PS-PROBE-SYS64) | | Warning | Drive is not mapped for SYSTEM; mapped drives belong to the user session. Use a UNC path and make sure the computer account can reach it | SYSTEM context: User=NT AUTHORITY\SYSTEM, session 0, USERPROFILE=C:\WINDOWS\system32\config\systemprofile, APPDATA under it, TEMP=C:\WINDOWS\TEMP (REM-PROBE-SYS64, PS-PROBE-SYS64) | @@ -62,6 +62,7 @@ Intune delivers the script bytes unchanged. Without a UTF-8 BOM, Windows PowerSh |---|---|---| | Warning | File is UTF-16. Intune expects UTF-8; save as UTF-8 with BOM | Microsoft Learn: "Ensure the scripts are encoded in UTF-8"; files are delivered byte-for-byte | | Warning | 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 | Same bytes uploaded with and without BOM: without, the literal "Grüße — ✓" ran as "Grüße â€" âœ"" (REM-ENC-BOM vs REM-PROBE-SYS64) | +| Information | Non-ASCII bytes that are not UTF-8 (an ANSI code page): Windows PowerShell 5.1 reads the file as ANSI, but Intune expects UTF-8 and other tooling reads it as such. Save as UTF-8 with BOM | Microsoft Learn: "Ensure the scripts are encoded in UTF-8"; Windows PowerShell reads a file without a BOM in the system ANSI code page (about_Character_Encoding) | | Information | Non-ASCII text in output is mangled on the way to Intune (OEM code page); keep reported output ASCII | With a BOM the script ran correctly, but Intune reported "Grüße - √" for "Grüße — ✓" (REM-ENC-BOM) | Experiments: REM-ENC-BOM, REM-PROBE-SYS64 @@ -109,7 +110,7 @@ The agent launches powershell.exe with -NoProfile -ExecutionPolicy Bypass -File |---|---|---| | Error | waits for input that never comes; the script hangs until the timeout | Launched as powershell.exe -NoProfile -executionPolicy bypass -file, without -NonInteractive; AgentExecutor timeout (PS-PROBE-SYS64, REM-PROBE-SYS64, Win32 log) | | Error | .() waits for input; the script hangs until the timeout | Launched as powershell.exe -NoProfile -executionPolicy bypass -file, without -NonInteractive; AgentExecutor timeout (PS-PROBE-SYS64, REM-PROBE-SYS64, Win32 log) | -| Warning | can prompt for confirmation (or to trust a repository); add -Force / -Confirm: or it hangs until the timeout | Launched as powershell.exe -NoProfile -executionPolicy bypass -file, without -NonInteractive; AgentExecutor timeout (PS-PROBE-SYS64, REM-PROBE-SYS64, Win32 log) | +| Warning | can prompt for confirmation (or to trust a repository); add -Force / -Confirm:$false or it hangs until the timeout | Launched as powershell.exe -NoProfile -executionPolicy bypass -file, without -NonInteractive; AgentExecutor timeout (PS-PROBE-SYS64, REM-PROBE-SYS64, Win32 log) | Experiments: PS-PROBE-SYS64, REM-PROBE-SYS64 @@ -200,8 +201,8 @@ The working directory is not the script's folder. SYSTEM scripts start in C:\WIN | Severity | Message | Evidence | |---|---|---| -| Warning | Relative path '' resolves against an unpredictable working directory. Anchor it to or use a full path | cwd was C:\WINDOWS\system32 for SYSTEM scripts, the content folder for Win32 installs, and once an old IMECache folder for a user script (PS-PROBE-USER) | -| Information | is not the script folder under Intune; use for files shipped with the script | cwd was C:\WINDOWS\system32 for SYSTEM scripts, the content folder for Win32 installs, and once an old IMECache folder for a user script (PS-PROBE-USER) | +| Warning | Relative path '' resolves against an unpredictable working directory. Anchor it to $PSScriptRoot or use a full path | cwd was C:\WINDOWS\system32 for SYSTEM scripts, the content folder for Win32 installs, and once an old IMECache folder for a user script (PS-PROBE-USER) | +| Information | $PWD is not the script folder under Intune; use $PSScriptRoot for files shipped with the script | cwd was C:\WINDOWS\system32 for SYSTEM scripts, the content folder for Win32 installs, and once an old IMECache folder for a user script (PS-PROBE-USER) | Experiments: PS-PROBE-USER @@ -213,8 +214,8 @@ Microsoft Learn puts the limit for remediation and platform scripts at 200 KB. T | Severity | Message | Evidence | |---|---|---| -| Error | The file is KB; the service refused a of KB, so this one cannot be uploaded. Split it or move the bulk into content the script downloads | Microsoft Learn: "must be less than 200 KB"; the Graph API accepted a 504 KB remediation and a 660 KB platform script and refused 512 KB and 680 KB; a 250 KB remediation and a 500 KB platform script ran on the device (REM-SIZE-250KB, PS-SIZE-500KB) | -| Warning | The file is KB, over the documented 200 KB limit. The API took a of up to KB and the device ran one this size, but the portal and other tooling may hold to 200 KB | Microsoft Learn: "must be less than 200 KB"; the Graph API accepted a 504 KB remediation and a 660 KB platform script and refused 512 KB and 680 KB; a 250 KB remediation and a 500 KB platform script ran on the device (REM-SIZE-250KB, PS-SIZE-500KB) | +| Error | The file is KB; the service refused a of KB, so this one cannot be uploaded. Split it or move the bulk into content the script downloads | Microsoft Learn: "must be less than 200 KB"; the Graph API accepted a 504 KB remediation and a 660 KB platform script and refused 512 KB and 680 KB; a 250 KB remediation and a 500 KB platform script ran on the device (REM-SIZE-250KB, PS-SIZE-500KB); Win32 detection and requirement scripts were not measured, the remediation limits are assumed | +| Warning | The file is KB, over the documented 200 KB limit. The API took a of up to KB and the device ran one this size, but the portal and other tooling may hold to 200 KB | Microsoft Learn: "must be less than 200 KB"; the Graph API accepted a 504 KB remediation and a 660 KB platform script and refused 512 KB and 680 KB; a 250 KB remediation and a 500 KB platform script ran on the device (REM-SIZE-250KB, PS-SIZE-500KB); Win32 detection and requirement scripts were not measured, the remediation limits are assumed | Experiments: PS-SIZE-500KB, REM-SIZE-250KB diff --git a/en-US/IntuneScriptLab-Help.xml b/en-US/IntuneScriptLab-Help.xml index ce8cb3d..d648107 100644 --- a/en-US/IntuneScriptLab-Help.xml +++ b/en-US/IntuneScriptLab-Help.xml @@ -445,9 +445,11 @@ Validation\Findings.md (documented vs observed Intune behaviour). HaveIntuneStatus - For IntuneScriptLab.RemediationResult ('Without issues', 'Fixed', 'Recurred', 'Failed', -'TimedOut') and IntuneScriptLab.Win32AppResult ('Installed', 'Installed after install', -'Not detected after install', 'Install failed (exit N)', 'Retry', 'TimedOut'). + For IntuneScriptLab.RemediationResult ('Without issues', 'Issue detected (no remediation +script)', 'Fixed', 'Recurred', 'Failed', 'TimedOut') and IntuneScriptLab.Win32AppResult +('Installed', 'Installed after install', 'Not detected after install', 'Install failed (exit +N)', 'Retry', 'Not installed', 'Not installed (dependency)', 'Uninstalled', 'Still detected +after uninstall', 'Uninstall failed (exit N)', 'TimedOut'). On failure the message carries what Intune would have shown: the actual status, the reported output and error, and the harness warnings. @@ -895,8 +897,9 @@ Validation\Findings.md (documented vs observed Intune behaviour). PassIntuneAnalysis - Runs Test-IntuneScript on the path and fails with the list of findings, each with its line, -severity, rule and message, so the test output is the fix list. + Runs Test-IntuneScript on the path, folder or pipeline of files and fails with the list of +findings, each with its file, line, severity, rule and message, so the test output is the fix +list. The IslAssumedContext note is never counted. @@ -1091,8 +1094,9 @@ Local files are found by convention under -Path, searched recursively: Platform script 'Set-Proxy' Set-Proxy.ps1 anywhere, or a folder named Set-Proxy with one .ps1 Win32 app 'Widget 2.0' a folder named 'Widget 2.0' holding Detect*.ps1 and Requirement*.ps1 -Characters Windows does not allow in a name are matched as underscores, so the app -'Widget: 2.0' matches a folder named 'Widget_ 2.0'. +Names are matched without regard to case, with every character other than a letter, a digit, +'.', '_' or '-' taken as an underscore, so the app 'Widget: 2.0' matches a folder named +'Widget_ 2.0' or 'widget__2.0'. -Map names the files directly when the layout is different. A policy with no local file, a local match that is ambiguous and, with @@ -1105,8 +1109,10 @@ Inferred values are never compared. One result per script role. State is InSync, Drifted, Missing (no local file), Ambiguous -(more than one local candidate) or NotInTenant (a local file or -Map entry the tenant has no -script for); Differences lists what differs (Content, LineEndings, Whitespace, Bom, Settings) +(more than one local candidate) or NotInTenant (a local file for a role the policy does not +carry, or a -Map entry naming a policy the selection did not include; a local folder for a +policy the tenant does not have is not reported); Differences lists what differs (Content, +LineEndings, Whitespace, Bom, Settings) and Detail says where. Nothing in the tenant is changed. @@ -1278,7 +1284,7 @@ Windows PowerShell 5.1 reads UTF-8 without a BOM as ANSI (Validation\Findings.md --------- EXAMPLE 1 --------- - Connect-MgGraph -Scopes DeviceManagementConfiguration.Read.All, DeviceManagementApps.Read.All + Connect-MgGraph -Scopes DeviceManagementScripts.Read.All, DeviceManagementApps.Read.All Compare-IntuneDeployedScript -Path C:\Repos\intune-scripts | Where-Object State -ne InSync € Every deployed script whose local copy differs, is missing or is ambiguous. @@ -1489,8 +1495,8 @@ those sections are present) and SizeBytes. Author: Jeffrey Stuhr. -Reading HKLM\SOFTWARE\Microsoft\IntuneManagementExtension needs no -elevation; dsregcmd /status runs when dsregcmd.exe is on the path. +The registry is read with the caller's rights; a key that cannot be read is left out of the +zip rather than failing the export. dsregcmd /status runs when dsregcmd.exe is on the path. @@ -1519,8 +1525,9 @@ elevation; dsregcmd /status runs when dsregcmd.exe is on the path. Export-IntuneAgentDiagnostic -Path .\copied.zip -LogPath \\server\share\LAB-042\Logs -SkipRegistry € - Timelines and logs from a log folder copied off another device; the registry and device -sections describe this machine, so they are left out. + Timelines and logs from a log folder copied off another device; the registry describes this +machine, so it is left out. The Device section still describes this machine, not the one the +logs came from. @@ -1552,8 +1559,9 @@ sections describe this machine, so they are left out. Turns the findings Test-IntuneScript produces into one SARIF run: the IntuneScriptLab tool -with a rule entry per rule seen (its description from the rule's own help and its default -level), and a result per finding with the file, line, column and text of the offending +with a rule entry per rule seen (its description from the rule's own help and, as its level, +that of the most severe finding it produced in this log), and a result per finding with the +file, line, column and text of the offending code, the message, the observed Intune behaviour as a property and, for a finding a Suppress directive silenced, an in-source suppression. File paths are written relative to @@ -1621,7 +1629,7 @@ Its folder is created; an existing file is replaced. The folder file paths are made relative to. Default: GITHUB_WORKSPACE, or the current directory. -A finding outside it keeps its absolute path. +A finding outside it gets an absolute file URI with no base. System.String @@ -1726,10 +1734,13 @@ Export-IntuneFindingSarif -Finding $findings -Path $env:RUNNER_TEMP\isl.sarif -R Parses the agent's CMTrace logs (IntuneManagementExtension, AppWorkload, HealthScripts and AgentExecutor, rolled files included) into one object per entry, merges them in time order -and names the event each known line records: a script policy fetch and its download count, -a remediation's schedule inspection, start, detection result and report, a Win32 app's -policy fetch, applicability, detection, rule evaluation, install and report, AgentExecutor's -launch, exit code and script output, the Enrollment Status Page's phase, selected apps, +and names the event each known line records: a platform script's policy fetch and download +count, its start, launch, result and exit, a user-context script skipped on an Entra +registered device, the agent's start and its userless check-in, a remediation's schedule +inspection, queueing, start, detection result and report, a Win32 app's policy fetch, one +filtered out by an assignment filter or skipped for user context, its applicability, +requirement checks, detection, rule evaluation, install, exit code, outcome and report, +AgentExecutor's launch, exit code, script output and errors, the Enrollment Status Page's phase, selected apps, their registration and tracked install states, its completion and the check-in that follows it, and an app's relationships: the subgraph it is processed in, a skipped subgraph, the report that names the impacting app with its classification and conflict reason, the dependency @@ -1955,7 +1966,7 @@ Default: the agent's log folder, IntuneScriptLab.AgentLogEntry - One object per log entry: Time (the local time the agent wrote), Level (Information, Warning or Error), Component, Thread, Event (the name from the event table, or empty for a line the table does not know), Detail (what the event's pattern captured: an exit code, a detection state, a download count), Id (the first policy or app id in the message), Message, Log (the file's base name) and Line. With -ListEvent, one IntuneScriptLab.AgentLogEventDefinition per event with Event and Pattern. + One object per log entry: Time (the local time the agent wrote), Level (Information, Warning or Error), Component, Thread, Event (the name from the event table, or empty for a line the table does not know), Detail (what the event's pattern captured: an exit code, a detection state, a download count), Id (the first non-empty GUID in the message, or the empty GUID when that is all the line carries), Message, Log (the file's base name) and Line. With -ListEvent, one IntuneScriptLab.AgentLogEventDefinition per event with Event and Pattern. @@ -1992,10 +2003,11 @@ a line the agent writes that is not in the table has no event and passes through --------- EXAMPLE 3 --------- - Get-IntuneAgentLog -Path .\Logs -Log AppWorkload -EventName AppDetection, AppInstallExit, AppReport | - Group-Object Id | ForEach-Object { $_.Group | Select-Object -Last 3 } + Get-IntuneAgentLog -Path .\Logs -Log AppWorkload -EventName AppDetection, AppReport | + Group-Object Id | ForEach-Object { $_.Group | Select-Object -Last 1 } € - From a copied log folder: the last detection, install exit code and report per app. + From a copied log folder: the last detection or report per app. An install exit code line +carries no app id, so read those with -EventName AppInstallExit on their own. @@ -2028,14 +2040,19 @@ a line the agent writes that is not in the table has no event and passes through Reads the agent's logs through Get-IntuneAgentLog, keeps the lines the event table names and groups them by the policy or app id they carry, so one object tells the story of one -remediation, platform script or Win32 app: when the agent fetched it, queued it, ran it, what -the detection said and what it reported, in order, with the time between the first and the -last step. +remediation, platform script or Win32 app: when the agent inspected its schedule or +applicability, launched it, what the detection said and what it reported, in order, with the +time between the first and the last step. A line that carries no id (the queue notice, an +install exit code, AgentExecutor's own lines) belongs to no timeline. Win32 apps are named from the policy list the agent logs; scripts show the id. -Outcome is the last result the agent logged for the id: a remediation's report result -(Result 4 fixed, 3 failed, 2 nothing to fix, per the validation rounds), a platform script's -policy result, an app's reported state or install outcome. +Outcome is the last result the agent logged for the id: a remediation's report result (the +Result code of its report line: 3, the detection found no issue; 4, the issue was found and the +remediation ran, whether or not the post-detection then passed, which Graph tells apart as +remediationState success or remediationFailed; 5, the detection script itself failed; matched +against Graph's run states on the lab devices, 2026-09-29), a platform script's +policy result, an app's reported state or relationship report, detection, applicability or +Enrollment Status Page state. Runs counts the launches (remediation starts, script policy starts, app executions). Steps holds every event with its @@ -2114,7 +2131,7 @@ live agent is no obstacle; a copied log folder works with -Path. Id - Only the timelines of these policy or app ids. + Only the timelines whose own id is one of these. A line that merely mentions the id (a relationship report names two apps) does not add another timeline. System.String[] @@ -2198,7 +2215,7 @@ timeline; Get-IntuneAgentLog -Log AgentExecutor shows them by time. --------- EXAMPLE 3 --------- Get-IntuneAgentTimeline -Path .\Logs -After (Get-Date).AddHours(-1) | - Where-Object Outcome -like 'AppReport*' | Select-Object Name, Outcome, Summary + Where-Object Outcome -like 'App*Report*' | Select-Object Name, Outcome, Summary € The apps a copied log folder shows in the last hour, by name, with their reported states. @@ -2301,10 +2318,11 @@ file; the wrapper analyzes the file once at the root and answers from a cache fo --------- EXAMPLE 3 --------- - @{ CustomRulePath = Get-IntuneAnalyzerRulePath; IncludeDefaultRules = $true } | - ConvertTo-Json + $rules = Get-IntuneAnalyzerRulePath +"@{ CustomRulePath = '$rules'; IncludeDefaultRules = `$true }" | + Set-Content .\PSScriptAnalyzerSettings.psd1 € - The values for a PSScriptAnalyzerSettings.psd1 that includes the rules in every run. + Writes a PSScriptAnalyzerSettings.psd1 that includes the rules in every run. @@ -2346,12 +2364,17 @@ and Win32 app the tenant has, one object with: gone from Graph); - a Health verdict with its reasons: Broken when a finding is an Error, when the policy is assigned to nobody, or when every device that ran it failed; Attention when there are - Warnings, failures, a remediation whose issue stays detected, drift, or an assigned policy - that no device has reported on yet; Healthy otherwise. + Warnings, failures, a remediation whose issue stays detected, drift (Drifted, Missing or + Ambiguous), devices the policy was not applicable to, or an assigned policy that no device + has reported on yet; Healthy otherwise. -Intune's run states lag the device: a remediation's result reached Graph up to an hour after -the device ran it in the validation rounds, platform script and app states within minutes -(Validation\Findings.md). +Intune's run states lag the device. A remediation's result travels in the agent's hourly +report batch, not at the run: a fix at 01:37 UTC reached Graph at 02:42 UTC, when the next +cycle uploaded it, and Graph wrote the state within seconds of that upload. A platform script's +run state arrived 2-8 s after the device's log line, an app's install state 30-39 s after +(2026-09-29, Validation\Findings.md). A recurring remediation whose result has not changed is +not re-reported at all, so lastStateUpdateDateTime is the last change, not the last run, and a +count that is days old is not stale. A policy changed minutes ago is best read with -SkipRunState. -MarkdownPath writes the same report as a Markdown table per kind, for a wiki or a pull request. Nothing in the tenant is changed. @@ -2515,7 +2538,7 @@ Compare-IntuneDeployedScript (its folder convention, or -Map). SkipAnalysis - Leave the script rules out: Errors and Warnings stay empty and do not weigh on Health. + Leave the script rules out: Errors and Warnings are 0 and do not weigh on Health; the assignment check still does. System.Management.Automation.SwitchParameter @@ -2560,7 +2583,7 @@ Faster, and the right choice right after a change. One object per policy: Kind, PolicyName, PolicyId, Context (system, user, or the app's install context), Assigned (include targets, with a note for exclusions and filters), LastModified, -Errors, Warnings, Findings (the objects behind the counts), Drift and DriftDetail, Succeeded, +Errors, Warnings, Findings (the objects behind the counts), Drift (InSync, Drifted, Missing, Ambiguous, NotInTenant, NoScript for a policy that carries no script, or empty without -Path) and DriftDetail, Succeeded, Failed, Detected (remediations: issue detected or back, not fixed), Pending, LastRun, Health (Healthy, Attention, Broken) and Notes. @@ -2569,22 +2592,26 @@ Health (Healthy, Attention, Broken) and Notes. Author: Jeffrey Stuhr. -Needs a Microsoft.Graph.Authentication session with -DeviceManagementConfiguration.Read.All, DeviceManagementScripts.Read.All, -DeviceManagementApps.Read.All and, for the app install export, -DeviceManagementManagedDevices.Read.All; GroupMember.Read.All for the assignment check. +Needs a Microsoft.Graph.Authentication session with DeviceManagementScripts.Read.All +(remediations, platform scripts and their run summaries), DeviceManagementConfiguration.Read.All +(assignment filters), DeviceManagementApps.Read.All (apps) and GroupMember.ReadBasic.All (the +assignment check reads only member ids and types). The app install export creates an export +job, which the Graph reference lists as a write: one of DeviceManagementApps.ReadWrite.All, +DeviceManagementConfiguration.ReadWrite.All or DeviceManagementManagedDevices.ReadWrite.All. +Without it the export is skipped with a warning and the apps' device columns stay empty. -Remediation run states in Graph lagged the device by up to an hour in the validation rounds; -platform script and Win32 app states appeared within seconds to minutes (Validation\Findings.md, -"Remediation daily schedule and detect-only assignment", "Win32 custom detection scripts"). +Remediation run states reach Graph with the agent's next hourly report batch (65 minutes after +the run in the 2026-09-29 measurement, seconds after the upload itself); platform script states +2-8 s and Win32 app install states 30-39 s after the device's log line (Validation\Findings.md, +"Reporting latency, re-measured"). --------- EXAMPLE 1 --------- - Connect-MgGraph -Scopes DeviceManagementConfiguration.Read.All, DeviceManagementApps.Read.All, - DeviceManagementScripts.Read.All, DeviceManagementManagedDevices.Read.All + Connect-MgGraph -Scopes DeviceManagementConfiguration.Read.All, DeviceManagementApps.ReadWrite.All, + DeviceManagementScripts.Read.All, GroupMember.ReadBasic.All Get-IntuneScriptHealth | Where-Object Health -ne Healthy € Every policy that needs a look, with the reasons in Notes. @@ -2719,7 +2746,8 @@ Test-IntuneScript's IslContextIssue flags statically. Architecture Host to run in: x64 (Intune's default for Win32 detection), x86 (the "run as 32-bit" -option), or arm64 on a Windows on ARM device. +option), or arm64 on a Windows on ARM device. A Windows on ARM device has no x64 host, so the +x64 default is refused there: pass arm64, the host the agent uses on ARM64. System.String @@ -2743,7 +2771,7 @@ not that account's profile or rights. Credential - Run as this account instead of the current user, with -Context User: a one-shot scheduled task registered for the account, interactive inside the account's own session when it holds one (the way the agent runs user-context scripts inside the signed-in user's session, REM-PROBE-USER64), otherwise a stored-password logon in session 0; the result's RunAs says which. Needs an elevated session, and is refused with -Context System. Validation\New-IslHarnessUser.ps1 creates a lab account with a stored credential to use here. + Run as this account instead of the current user, with -Context User: a one-shot scheduled task registered for the account, interactive inside the account's own session when it holds one (the way the agent runs user-context scripts inside the signed-in user's session, REM-PROBE-USER64), otherwise a stored-password logon in session 0, which needs the account to hold the "Log on as a batch job" right (a standard user does not; the scheduler then never starts the task, and the launcher reports that within seconds rather than at the timeout); the result's RunAs says which. Needs an elevated session, and is refused with -Context System. Validation\New-IslHarnessUser.ps1 creates a lab account with a stored credential to use here. System.Management.Automation.PSCredential @@ -2799,7 +2827,7 @@ Intune allows 60 minutes; the default here is 5. IntuneScriptLab.DetectionResult - Detected, Reason, ExitCode, StdOut, StdErr, TimedOut, Duration, SignatureStatus (with -EnforceSignatureCheck), Architecture, Context, Host and ScriptPath. Detected is true only for exit 0 with stdout and no stderr, which is the rule Intune applies. + Detected, Reason, ExitCode, StdOut, StdErr, TimedOut, Duration, SignatureStatus (filled with -EnforceSignatureCheck, empty otherwise), RunAs (the account and logon type the script ran as), Architecture, Context, Host and ScriptPath. Detected is true only for exit 0 with stdout and no stderr, which is the rule Intune applies. @@ -2964,7 +2992,7 @@ hybrid-joined devices run them. Credential - Run as this account instead of the current user, with -Context User: a one-shot scheduled task registered for the account, interactive inside the account's own session when it holds one (the way the agent runs user-context scripts inside the signed-in user's session, REM-PROBE-USER64), otherwise a stored-password logon in session 0; the result's RunAs says which. Needs an elevated session, and is refused with -Context System. Validation\New-IslHarnessUser.ps1 creates a lab account with a stored credential to use here. + Run as this account instead of the current user, with -Context User: a one-shot scheduled task registered for the account, interactive inside the account's own session when it holds one (the way the agent runs user-context scripts inside the signed-in user's session, REM-PROBE-USER64), otherwise a stored-password logon in session 0, which needs the account to hold the "Log on as a batch job" right (a standard user does not; the scheduler then never starts the task, and the launcher reports that within seconds rather than at the timeout); the result's RunAs says which. Needs an elevated session, and is refused with -Context System. Validation\New-IslHarnessUser.ps1 creates a lab account with a stored credential to use here. System.Management.Automation.PSCredential @@ -3009,7 +3037,7 @@ Intune allows 30 minutes; the default here is 5. IntuneScriptLab.PlatformScriptResult - RunState (Success, Failed or TimedOut), ExitCode, ResultMessage (stdout and stderr as the portal shows them), StdOut, StdErr, TimedOut, Duration, Warnings (on a failure, what Intune does next: three runs in total at policy fetches, then Failed for good), Architecture, Context, Host and ScriptPath. + RunState (Success, Failed or TimedOut), ExitCode, ResultMessage (stdout and stderr as the portal shows them), StdOut, StdErr, TimedOut, Duration, Warnings (on a failure, what Intune does next: three runs in total at policy fetches, then Failed for good), RunAs (the account and logon type the script ran as), Architecture, Context, Host and ScriptPath. @@ -3098,6 +3126,9 @@ again. The status follows what the device recorded: Without issues detection exited 0, remediation skipped + Issue detected (no remediation script) + detection non-zero with no -RemediationPath, as a detect-only + remediation records it Fixed detection non-zero, remediation 0, post-detection 0 Recurred detection non-zero, remediation 0, post-detection still non-zero Failed remediation exited non-zero (post-detection skipped) @@ -3197,7 +3228,7 @@ user-context remediations on Entra-registered devices. Credential - Run as this account instead of the current user, with -Context User: a one-shot scheduled task registered for the account, interactive inside the account's own session when it holds one (the way the agent runs user-context scripts inside the signed-in user's session, REM-PROBE-USER64), otherwise a stored-password logon in session 0; the result's RunAs says which. Needs an elevated session, and is refused with -Context System. Validation\New-IslHarnessUser.ps1 creates a lab account with a stored credential to use here. + Run as this account instead of the current user, with -Context User: a one-shot scheduled task registered for the account, interactive inside the account's own session when it holds one (the way the agent runs user-context scripts inside the signed-in user's session, REM-PROBE-USER64), otherwise a stored-password logon in session 0, which needs the account to hold the "Log on as a batch job" right (a standard user does not; the scheduler then never starts the task, and the launcher reports that within seconds rather than at the timeout); the result's RunAs says which. Needs an elevated session, and is refused with -Context System. Validation\New-IslHarnessUser.ps1 creates a lab account with a stored credential to use here. System.Management.Automation.PSCredential @@ -3253,7 +3284,7 @@ Intune allows 60 minutes; the default here is 5. IntuneScriptLab.RemediationResult - Status (Without issues, Issue detected (no remediation script), Fixed, Recurred, Failed or TimedOut), IntuneOutput and IntuneError (the last line of each stream, 2,048-character tail, as the portal reports them), RemediationOutput, PostOutput, the PreDetection, Remediation and PostDetection runs, Warnings, Architecture, Context and Host. + Status (Without issues, Issue detected (no remediation script), Fixed, Recurred, Failed or TimedOut), IntuneOutput (the pre-detection's last stdout line) and IntuneError (its stderr text), each cut to its last 2,048 characters as the portal reports them, RemediationOutput, PostOutput, the PreDetection, Remediation and PostDetection runs, Warnings, RunAs (the account and logon type the scripts ran as), Architecture, Context and Host. @@ -3342,7 +3373,8 @@ requirement rule (Windows PowerShell 5.1 in the chosen host, -NoProfile -Executi Bypass -File, working directory C:\WINDOWS\system32, a copy of the script, OEM output) and evaluates the rule as the agent was observed to: only on exit 0 with nothing on stderr; the whole console output minus its final line break is the value (Write-Host counts; a second line -or trailing spaces never match); string comparison ignores case; Integer, +never matches, and for a String rule neither do trailing spaces; the typed rules trim +first); string comparison ignores case; Integer, Float, Version, Boolean and DateTime outputs are parsed as that type, and an output that does not parse fails the rule. @@ -3423,7 +3455,8 @@ Under Intune the requirement runs before the install and after the detection has Architecture Host to run in: x64 (the default for requirement rules), x86 (the "run as 32-bit" -option), or arm64 on a Windows on ARM device. +option), or arm64 on a Windows on ARM device. A Windows on ARM device has no x64 host, so the +x64 default is refused there: pass arm64, the host the agent uses on ARM64. System.String @@ -3447,7 +3480,7 @@ User runs it as the account running this command. Credential - Run as this account instead of the current user, with -Context User: a one-shot scheduled task registered for the account, interactive inside the account's own session when it holds one (the way the agent runs user-context scripts inside the signed-in user's session, REM-PROBE-USER64), otherwise a stored-password logon in session 0; the result's RunAs says which. Needs an elevated session, and is refused with -Context System. Validation\New-IslHarnessUser.ps1 creates a lab account with a stored credential to use here. + Run as this account instead of the current user, with -Context User: a one-shot scheduled task registered for the account, interactive inside the account's own session when it holds one (the way the agent runs user-context scripts inside the signed-in user's session, REM-PROBE-USER64), otherwise a stored-password logon in session 0, which needs the account to hold the "Log on as a batch job" right (a standard user does not; the scheduler then never starts the task, and the launcher reports that within seconds rather than at the timeout); the result's RunAs says which. Needs an elevated session, and is refused with -Context System. Validation\New-IslHarnessUser.ps1 creates a lab account with a stored credential to use here. System.Management.Automation.PSCredential @@ -3524,7 +3557,7 @@ Intune allows 60 minutes; the default here is 5. IntuneScriptLab.RequirementResult - Applicable, Reason, Output (the text the rule compared), ExitCode, StdOut, StdErr, TimedOut, Duration, OutputType, Operator, Value, Architecture, Context, Host and ScriptPath. + Applicable, Reason, Output (the text the rule compared), ExitCode, StdOut, StdErr, TimedOut, Duration, OutputType, Operator, Value, RunAs (the account and logon type the script ran as), Architecture, Context, Host and ScriptPath. @@ -3621,14 +3654,15 @@ Statuses: Not detected after install install returned a success code but detection still fails, Intune's 0x87D1041C "app installed but not detected" - Install failed exit code not in the success/reboot/retry codes + Install failed (exit N) exit code not in the success/reboot/retry codes Retry exit code mapped to retry (Intune retries 3 times, 5 min apart) Not installed intent Uninstall and nothing was detected, so nothing ran Uninstalled the uninstall command succeeded and the post-detection no longer reports the app (W32-UNINSTALL: Intune shows "Not installed") Still detected after uninstall the uninstall command succeeded but the detection still says installed - Uninstall failed uninstall exit code not in the success/reboot/retry codes + Uninstall failed (exit N) + uninstall exit code not in the success/reboot/retry codes Not installed (dependency) a dependency was not detected and could not be installed (a detect dependency, a missing install command, or a failed install), so this @@ -3783,7 +3817,7 @@ Soft/hard reboot codes count as success but are reported. Architecture - Host for the detection script: x64 (Intune default), x86, or arm64. + Host for the detection script: x64 (Intune default), x86, or arm64. A Windows on ARM device has no x64 host, so a -DetectionPath with the x64 default is refused there: pass arm64. System.String @@ -3817,7 +3851,7 @@ install in the user's context but cannot impersonate the signed-in user the agen Credential - Run every launch (detection and requirement scripts, dependencies, the install or uninstall command) as this account instead of the current user, with -Context User: a one-shot scheduled task registered for the account, interactive inside the account's own session when it holds one (the way the agent runs user-context scripts inside the signed-in user's session, REM-PROBE-USER64), otherwise a stored-password logon in session 0; the result's RunAs says which. Needs an elevated session, and is refused with -Context System. Validation\New-IslHarnessUser.ps1 creates a lab account with a stored credential to use here. + Run every launch (the detection script, the related apps' detections, the install or uninstall command) as this account instead of the current user, with -Context User: a one-shot scheduled task registered for the account, interactive inside the account's own session when it holds one (the way the agent runs user-context scripts inside the signed-in user's session, REM-PROBE-USER64), otherwise a stored-password logon in session 0, which needs the account to hold the "Log on as a batch job" right (a standard user does not; the scheduler then never starts the task, and the launcher reports that within seconds rather than at the timeout); the result's RunAs says which. Needs an elevated session, and is refused with -Context System. Validation\New-IslHarnessUser.ps1 creates a lab account with a stored credential to use here. System.Management.Automation.PSCredential @@ -3982,7 +4016,7 @@ Default 5 minutes (Intune: 60). IntuneScriptLab.Win32AppResult - Status (Installed, Installed after install, Not detected after install, Install failed (exit n), Retry, Not installed, Not installed (dependency), Uninstalled, Still detected after uninstall, Uninstall failed (exit n) or TimedOut), Intent, the PreDetection and PostDetection script results, PreRules and PostRules (one IntuneScriptLab.RuleResult per detection rule), Dependencies and Superseded (one IntuneScriptLab.Win32RelatedResult per related app, with Name, Relationship, Status and its own detections, rules and install or uninstall run), the Install or Uninstall run (command, exit code, output), Warnings, Architecture and Context. + Status (Installed, Installed after install, Not detected after install, Install failed (exit n), Retry, Not installed, Not installed (dependency), Uninstalled, Still detected after uninstall, Uninstall failed (exit n) or TimedOut), Intent, the PreDetection and PostDetection script results, PreRules and PostRules (one IntuneScriptLab.RuleResult per detection rule), Dependencies and Superseded (one IntuneScriptLab.Win32RelatedResult per related app, with Name, Relationship, Status and its own detections, rules and install or uninstall run), the Install or Uninstall run (command, exit code, output), Warnings, RunAs (the account and logon type the launches ran as), Architecture and Context. @@ -4122,8 +4156,9 @@ behaviour-preserving edit: 'return' turns into 'exit 0', 'return <value>' into '<value>; exit 0'. The script does the same as before, and now says so; whether that exit should have been 1 is still the author's call - IslEncodingIssue a UTF-8 file without a BOM that holds non-ASCII text, or a UTF-16 - file, is rewritten as UTF-8 with a BOM, the encoding Intune expects + IslEncodingIssue a UTF-8 file without a BOM that holds non-ASCII text, a UTF-16 file + or an ANSI file (read in the system ANSI code page, so its characters + survive) is rewritten as UTF-8 with a BOM, the encoding Intune expects IslOutputIssue a requirement script's output literal with leading or trailing whitespace is trimmed, so it can match the portal value @@ -4279,7 +4314,7 @@ The command supports IntuneScriptLab.Repair - One per script: Path, Applied (fixes made), Remaining (findings still reported afterwards), Written (whether the file changed; false under -WhatIf) and Fixes, one IntuneScriptLab.Fix per edit with RuleName, Line (0 for the encoding), Before and After. + One per script: Path, Applied (fixes made, or that would be made under -WhatIf), Remaining (findings still reported after the fixes; under -WhatIf, the findings minus the fixes), Written (whether the file changed; false under -WhatIf) and Fixes, one IntuneScriptLab.Fix per edit with RuleName, Line (0 for the encoding), Before and After. @@ -4518,7 +4553,9 @@ Mode : Include Reason : Not applicable: the rule does not match this device; the portal shows "Filters criteria are not met." (W32-FILTER-INCLUDE) Clauses : device.deviceName -startsWith "LAB-" [not matched, actual: DESKTOP-P96U0KB] - device.cpuArchitecture -eq "arm64" [matched, actual: arm64] + device.cpuArchitecture -eq "arm64" [matched, actual: arm64] +Warnings : {} +Rule : (device.deviceName -startsWith "LAB-") and (device.cpuArchitecture -eq "arm64") € The rule against this device; each clause reports what it saw. @@ -4534,7 +4571,11 @@ Test-IntuneAssignmentFilter -Rule $rule -Device $device -Mode Exclude€ Applicable : False Matched : True -Reason : Not applicable: the exclude rule matches this device (W32-FILTER-EXCLUDE) +Mode : Exclude +Reason : Not applicable: the exclude rule matches this device (W32-FILTER-EXCLUDE) +Clauses : device.operatingSystemVersion -lt 10.0.26100.5000 [matched, actual: 10.0.26100.4652] +Warnings : {} +Rule : (device.operatingSystemVersion -lt 10.0.26100.5000) € A described device against an exclude filter: the version compares numerically and the match excludes the device. @@ -4611,19 +4652,22 @@ justified (Validation\Findings.md): IslDetectionRuleIssue Error a file rule with detectionType doesNotExist: the agent does not evaluate it (missing file: not detected; present file: "Invalid detection rule", 0x87D30004) - IslAssignmentIssue Warning a Win32 app with install behavior User assigned to a group - whose members are devices: never installed ("userless + IslAssignmentIssue Warning a Win32 app with install behavior User assigned to All + devices or to a group whose sampled members (the first + twenty) are all devices: never installed ("userless check-in", Not applicable, code 1011) IslDetectOnly Info a remediation with no remediation script: the detection runs alone on its schedule (remediationState skipped) - IslFilterIssue Warning an assignment filter on the policy whose clause no Windows - device can match ("x64" for cpuArchitecture, "Microsoft - Entra joined" for deviceTrustType): the service accepts the - rule and the filter evaluator matches nothing, so an include - filter reaches nobody and an exclude filter excludes nobody - (FLT-V25, FLT-E07, FLT-V27, FLT-F01). The deprecated - osVersion, the undocumented isTpmAttested and a rule this - evaluator cannot read are Information. + IslFilterIssue Warning an assignment filter on the policy with an -eq or -in clause + whose value no Windows device reports ("x64" for + cpuArchitecture, "Microsoft Entra joined" for + deviceTrustType): the service accepts the rule and the + filter evaluator matches nothing on that clause, so a rule + made of it reaches nobody as an include and excludes nobody + as an exclude (FLT-V25, FLT-E07, FLT-V27, FLT-F01). The same + value under -ne or -notIn matches every device; that, the + deprecated osVersion, the undocumented isTpmAttested and a + rule this evaluator cannot read are Information. IslAssignmentIssue Warning a policy with no assignment, or only exclusions: no device resolves it, so it never runs (ASSIGN-NONE, ASSIGN-EXCLONLY) IslAssignmentIssue Info a user-context remediation or platform script assigned to @@ -4634,10 +4678,13 @@ justified (Validation\Findings.md): once at the fetch (ASSIGN-PAST2) Needs Microsoft.Graph.Authentication connected first (Connect-MgGraph) with -DeviceManagementConfiguration.Read.All (policies and assignment filters), -DeviceManagementApps.Read.All, DeviceManagementScripts.Read.All and, for the assignment check, -GroupMember.Read.All. -A group lookup or a filter read the session is not allowed to make is reported once and skipped. +DeviceManagementScripts.Read.All (remediations and platform scripts), +DeviceManagementConfiguration.Read.All (assignment filters), DeviceManagementApps.Read.All (Win32 +apps) and, for the assignment check, GroupMember.ReadBasic.All (only member ids and types are +read; GroupMember.Read.All also works). Each is the least privileged permission the Graph +reference lists for that read. +A group lookup or a filter read that fails (a session without the scope, most often) is reported +once, and the rest of the run does without them. Script content is written to a temporary folder for the analysis, byte for byte as the tenant stores it, and removed afterwards. @@ -4833,7 +4880,8 @@ modified. --------- EXAMPLE 1 --------- - Connect-MgGraph -Scopes DeviceManagementConfiguration.Read.All, DeviceManagementApps.Read.All + Connect-MgGraph -Scopes DeviceManagementScripts.Read.All, DeviceManagementConfiguration.Read.All, + DeviceManagementApps.Read.All, GroupMember.ReadBasic.All Test-IntuneDeployedScript -MinimumSeverity Warning € Every deployed script with a warning or an error, as the agent will run it. @@ -4858,7 +4906,7 @@ Test-IntuneDeployedScript -MinimumSeverity Warning Test-IntuneDeployedScript -Kind Win32App -SkipGroupLookup | Group-Object PolicyName | Sort-Object Count -Descending | Select-Object Count, Name € - Which apps' detection and requirement scripts collect the most findings, without touching + Which apps collect the most findings, script and policy checks together, without touching groups. @@ -4903,12 +4951,14 @@ explicitly, put a directive in the script, or let them be inferred from the file # IntuneScriptLab: ScriptType=Detection Context=System Architecture=x64 -Inference from the file name (whole words): requirement → Win32Requirement; detect with -app/win32/package/software/install/msi/exe → Win32Detection; detect → Detection; +Inference from the file name and the two nearest folder names, each word matched at its +start: requirement → Win32Requirement; detect with app, apps, win32, package, software, +install, installed, msi or exe in the name or a folder → Win32Detection; detect → Detection; remediate/remediation/fix → Remediation; anything else → PlatformScript. An -IslAssumedContext note is always emitted when the type was not given explicitly, because -the wrong type silently skips whole rule sets. +IslAssumedContext note is emitted whenever the type was inferred rather than given by a +parameter, a directive or a settings file, because the wrong type silently skips whole rule +sets. Defaults follow the portal: platform scripts run as the user in 32-bit, remediations as SYSTEM in 32-bit, Win32 detection as SYSTEM in 64-bit. @@ -5042,7 +5092,7 @@ Auto (default) uses the directive or the type's portal default. Analyze Win32 detection and requirement scripts as if the rule's "Enforce script signature check" were on: an unsigned script gets an IslSignatureIssue error, because the agent will not run it. The directive comment "# IntuneScriptLab: EnforceSignatureCheck=true" does the same for -one script. +one script, and the settings key EnforceSignatureCheck = $true for a folder. System.Management.Automation.SwitchParameter @@ -5118,8 +5168,9 @@ Auto (default) uses the directive or the file name. A settings file path or a hashtable in the file's shape, instead of the search for IntuneScriptLab.settings.psd1 above each script; @{} means no settings. Keys: ExcludeRule, IncludeRule, MinimumSeverity, Severity (a hashtable of rule name to severity), ScriptType, -Context, Architecture and EnforceSignatureCheck. Explicit parameters win over the file, and a -script's directive wins over its type, context, architecture and signature entries. +Context, Architecture and EnforceSignatureCheck. Explicit parameters win over the file +(-ExcludeRule adds to the file's ExcludeRule; -IncludeRule sets it aside), and a script's +directive wins over its type, context, architecture and signature entries. System.Object @@ -5159,7 +5210,7 @@ script's directive wins over its type, context, architecture and signature entri IntuneScriptLab.Finding - One object per finding: RuleName, Severity (Information, Warning or Error), Message, ScriptPath, Line, Column, ScriptType, Text (the offending code) and Evidence (the observed Intune behaviour the rule rests on). + One object per finding: RuleName, Severity (Information, Warning or Error), Message, ScriptPath, Line, Column, ScriptType, Text (the offending code), Evidence (the observed Intune behaviour the rule rests on), Suppressed (true for a finding a Suppress directive silenced, shown with -IncludeSuppressed) and Fix (the edit Repair-IntuneScript would make, for the rules that offer one). @@ -5835,7 +5886,7 @@ string, or the product version. IntuneScriptLab.RuleResult - Met, Kind (File, Registry or ProductCode), RuleType (Detection or Requirement), Target (the path, key or product code), Operation, Operator, Value, Actual (what was read from the device), Check32BitOn64System and Reason, which names the experiment the behaviour comes from. + Met, Kind (File, Registry or ProductCode), RuleType (Detection or Requirement), Target (the path, key or product code), Operation, Operator, Value, Actual (what was read from the device), Check32BitOn64System and Reason, which says what was read and, where the agent behaves other than documented, names the experiment. diff --git a/en-US/about_IntuneScriptLab.help.txt b/en-US/about_IntuneScriptLab.help.txt index 8582f5b..ab0d97c 100644 --- a/en-US/about_IntuneScriptLab.help.txt +++ b/en-US/about_IntuneScriptLab.help.txt @@ -17,7 +17,7 @@ LONG DESCRIPTION EVIDENCE Every rule, warning and verdict in the module is backed by an observation recorded in the - validation kit's Findings (Validation\Findings.md next to the module): a documented behaviour + validation kit's Findings (Validation\Findings.md in the repository): a documented behaviour checked against a real Entra joined or registered device, or an undocumented one measured there. Each finding carries an Evidence string ending in experiment ids such as REM-EXIT-2 or W32-DET-NOOUT; those are the names in Validation\Experiments.psd1, and the script body that ran @@ -62,8 +62,12 @@ LONG DESCRIPTION the last line, the detection state, the install outcome. -Context System runs as SYSTEM; -Credential runs as another account, in its session when it has one. - Test-IntuneWin32Rule A file, registry, MSI or script detection or requirement rule - Test-IntuneWin32Requirement against this device, with the agent's own semantics. + Test-IntuneWin32Rule A file, registry or MSI detection or requirement rule against + this device, with the agent's own semantics (a script rule is + Invoke-IntuneDetectionTest's job). + Test-IntuneWin32Requirement The base requirements (architecture, OS version, disk, memory, + processors) against this device, with the portal's texts and + applicability codes. Test-IntuneAssignmentFilter An assignment filter rule against this device or a described one, with the service's syntax and matching. @@ -107,7 +111,8 @@ LONG DESCRIPTION THE VALIDATION KIT - The Validation folder next to the module holds the experiments (Experiments.psd1), the driver + The Validation folder in the repository (not in the Gallery package) holds the experiments + (Experiments.psd1), the driver that deploys and collects them (Invoke-ValidationRound.ps1), the probes and helpers, and Findings.md, the documented-versus-observed record every rule cites. Run a round against your own tenant and test device to re-check the observations, or add an experiment to answer a new