diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a3244d..3c9ee5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,51 @@ release notes. ## [Unreleased] -Nothing yet. +### Fixed + +- **A user-context run started from PowerShell 7 loaded PowerShell 7's modules.** The 5.1 host + inherited the session's `PSModulePath` and took `Microsoft.PowerShell.Management`, `Utility` + and `Security` from PowerShell 7's folders: no `Cert:` drive, and `Get-AuthenticodeSignature` + and `ConvertTo-SecureString` failed to load, so a script that works under the agent failed in + the harness. The child now gets the session's path without the three folders PowerShell 7 + adds for itself. Runs started from Windows PowerShell, and the scheduled-task runs (SYSTEM, + `-Credential`), are unchanged. +- A backtick line continuation in `Get-IslSetting`, the one left in the module since 0.6.0 said + there were none. +- **`Repair-IntuneScript` hid the mistake it was run on.** `return 1; exit 1` became + `1; exit 0; exit 1`: the same behaviour, the exit the author wrote unreachable, and no finding + left. A script-scope `return` with an exit other than 0 after it in the same block now carries + no edit and stays reported; a `return` with nothing, or `exit 0`, after it is fixed as before. +- **`IslPowerShell7Syntax` gave the parse error's evidence for errors that are not parse errors.** + A cmdlet or parameter only PowerShell 7 has, and `ForEach-Object -Parallel`, parse under 5.1: + the call fails, the script carries on and a detection reaches its own exit 0, the opposite of + the exit 1 the evidence described. The messages say so and cite the new experiments + (REM-PS7-CMDLET, REM-PS7-PARAM, REM-PS7-PARALLEL); `#Requires -Version 7` cites its own + (REM-PS7-REQUIRES). +- **`Out-File -Encoding utf8NoBOM` was in the rule's table and never matched**, filtered out by + the code that read the table. It is a finding now, with what the agent did with it + (REM-PS7-ENCODING). An empty `Rename-Item` entry is gone. +- **`IslInteractiveCall` called `Get-Credential -Credential $credential` an error**, although a + credential that is already built is returned without a prompt (12 ms under the agent, + REM-CRED-BUILT). It stays an error where it is sure to prompt (bare, with `-Message` or + `-UserName`, or handed a literal name) and is a warning when handed anything else. + +### Changed + +- `IslContextIssue` reported every path from `D:\` to `Z:\` in a SYSTEM script as an unmapped + drive, a warning. A SYSTEM detection saw a local `D:` and not the `X:` the signed-in user had + mapped (REM-DRIVES-SYS), and the letter cannot say which of the two a script means, so the + finding is now Information and says which case fails. +- Validation round 10 (Findings, "PowerShell 7 at run time, a built credential, and the drives + SYSTEM sees"): seven remediations on the joined device, and `New-IslDriveFixture.ps1` in the kit + for the drive state one of them reports on. +- The README and `Get-IntuneAnalyzerRulePath`'s help say what `Invoke-ScriptAnalyzer -Severity` + does with the custom rules: PSScriptAnalyzer 1.25.0 filters on the rule's registered severity, + Warning for every custom rule, so `-Severity Error` returns none of the records and + `-Severity Warning` all of them. They also describe the cache as it works: nested script + blocks are skipped, and the cache serves the other rules at the root. +- The about topic named "a missing exit" among `Repair-IntuneScript`'s fixes; they are a + script-scope return, the encoding and a padded requirement value. ## [0.26.0] - 2026-09-28 diff --git a/Private/Get-IslDesktopModulePath.ps1 b/Private/Get-IslDesktopModulePath.ps1 new file mode 100644 index 0000000..6803936 --- /dev/null +++ b/Private/Get-IslDesktopModulePath.ps1 @@ -0,0 +1,48 @@ +function Get-IslDesktopModulePath { + <# + .SYNOPSIS + The PSModulePath a child process should get: this session's, without PowerShell 7's own folders. + + .DESCRIPTION + A process started from PowerShell 7 inherits its PSModulePath, which lists PowerShell 7's + module folders ahead of Windows PowerShell's. A powershell.exe child then loads PowerShell + 7's Microsoft.PowerShell.Management, Utility and Security in place of its own: no Cert: + drive, and Get-AuthenticodeSignature and ConvertTo-SecureString fail to load. PowerShell 7 + resets the path itself when it starts powershell.exe as a command, but not for a process + started through System.Diagnostics.Process, which is how the harness starts one. + + The three folders removed are the ones PowerShell 7 adds for itself: $PSHOME\Modules, + Program Files\PowerShell\Modules and Documents\PowerShell\Modules. Everything else stays in + its order, so a folder the session added still reaches the child. Under Windows PowerShell + the path is returned as it is, since $PSHOME\Modules there is the child's own. + + .PARAMETER ModulePath + The path to filter; the session's PSModulePath when omitted. + + .EXAMPLE + Get-IslDesktopModulePath + + This session's PSModulePath as a Windows PowerShell child should see it. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [AllowEmptyString()] + [string]$ModulePath = $env:PSModulePath + ) + + if ($PSVersionTable.PSEdition -ne 'Core') { return $ModulePath } + + $documents = [Environment]::GetFolderPath('MyDocuments') + $coreOnly = @( + Join-Path -Path $PSHOME -ChildPath 'Modules' + if ($env:ProgramFiles) { Join-Path -Path $env:ProgramFiles -ChildPath 'PowerShell\Modules' } + if ($documents) { Join-Path -Path $documents -ChildPath 'PowerShell\Modules' } + ) | ForEach-Object { $_.TrimEnd('\') } + + $separator = [System.IO.Path]::PathSeparator + $kept = foreach ($entry in ($ModulePath -split [regex]::Escape($separator))) { + if ($entry -and $entry.TrimEnd('\') -notin $coreOnly) { $entry } + } + $kept -join $separator +} diff --git a/Private/Get-IslSetting.ps1 b/Private/Get-IslSetting.ps1 index 8c6e67a..317f016 100644 --- a/Private/Get-IslSetting.ps1 +++ b/Private/Get-IslSetting.ps1 @@ -110,8 +110,8 @@ function Get-IslSetting { while ($probe) { $candidate = Join-Path -Path $probe -ChildPath 'IntuneScriptLab.settings.psd1' if (Test-Path -LiteralPath $candidate -PathType Leaf) { - $found = ConvertTo-SettingsObject -Table (Import-PowerShellDataFile -LiteralPath $candidate) ` - -Source $candidate + $candidateTable = Import-PowerShellDataFile -LiteralPath $candidate + $found = ConvertTo-SettingsObject -Table $candidateTable -Source $candidate break } $probe = Split-Path -Path $probe -Parent diff --git a/Private/Invoke-IslProcess.ps1 b/Private/Invoke-IslProcess.ps1 index 96c215b..03e2ba8 100644 --- a/Private/Invoke-IslProcess.ps1 +++ b/Private/Invoke-IslProcess.ps1 @@ -5,7 +5,9 @@ .DESCRIPTION User: a direct child process with stdin closed and both output streams read through - the OEM code page, as a console-less powershell.exe writes them. + the OEM code page, as a console-less powershell.exe writes them. Started from PowerShell 7, + the child gets the session's PSModulePath without PowerShell 7's own folders + (Get-IslDesktopModulePath), so a powershell.exe loads its own modules. System: a one-shot scheduled task registered for NT AUTHORITY\SYSTEM (session 0, the same place the Intune agent runs scripts) whose action is cmd.exe redirecting the command's @@ -76,6 +78,13 @@ $startInfo.RedirectStandardError = $true $startInfo.StandardOutputEncoding = $oem $startInfo.StandardErrorEncoding = $oem + # From PowerShell 7 the child would inherit PowerShell 7's module folders and a + # powershell.exe would load its Microsoft.PowerShell.* modules from them + if ($PSVersionTable.PSEdition -eq 'Core') { + $desktopModulePath = Get-IslDesktopModulePath + if ($desktopModulePath) { $startInfo.Environment['PSModulePath'] = $desktopModulePath } + else { $null = $startInfo.Environment.Remove('PSModulePath') } + } $process = [System.Diagnostics.Process]::new() $process.StartInfo = $startInfo diff --git a/Private/Rules/Find-IslContextIssue.ps1 b/Private/Rules/Find-IslContextIssue.ps1 index 9e8a992..000a1cf 100644 --- a/Private/Rules/Find-IslContextIssue.ps1 +++ b/Private/Rules/Find-IslContextIssue.ps1 @@ -35,6 +35,8 @@ function Find-IslContextIssue { $evidence = ('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)')) + $driveEvidence = ('A SYSTEM detection listed C:\ and D:\, both local volumes, and found no X:\ while ' + + 'the signed-in user had X: mapped to a share (REM-DRIVES-SYS)') $hkcuPattern = '(?i)^(HKCU:|Registry::HKEY_CURRENT_USER|HKEY_CURRENT_USER\\)' foreach ($literal in ($literals | Where-Object { $_.Value -match $hkcuPattern })) { @@ -96,16 +98,18 @@ function Find-IslContextIssue { } New-IslFinding @findingSplat } + # A drive letter says nothing about what is behind it: a local volume is there for SYSTEM, a + # drive the user mapped is not, and the script's text cannot tell the two apart foreach ($literal in ($literals | Where-Object { $_.Value -match '^[D-Zd-z]:\\' })) { $findingSplat = @{ RuleName = $rule - Severity = 'Warning' + Severity = 'Information' Context = $Context Extent = $literal.Extent - Message = ("Drive $($literal.Value.Substring(0, 2)) 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') - Evidence = $evidence + Message = ("Drive $($literal.Value.Substring(0, 2)) exists for SYSTEM only if it is a local " + + 'volume: a drive the user mapped belongs to the user''s session. For a mapped drive ' + + 'use the UNC path and make sure the computer account can reach it') + Evidence = $driveEvidence } New-IslFinding @findingSplat } diff --git a/Private/Rules/Find-IslExitCodeIssue.ps1 b/Private/Rules/Find-IslExitCodeIssue.ps1 index 3dfc259..92ecfa5 100644 --- a/Private/Rules/Find-IslExitCodeIssue.ps1 +++ b/Private/Rules/Find-IslExitCodeIssue.ps1 @@ -34,6 +34,20 @@ function Find-IslExitCodeIssue { param($Return) if ($Return.Pipeline) { "$($Return.Pipeline.Extent.Text); exit 0" } else { 'exit 0' } } + + # An exit other than 0 after the return in the same block is the exit the author meant. Writing + # 'exit 0' in front of it would leave it unreachable with no finding left to say so, so that + # return gets no edit and stays reported + function Test-ExitFollowsReturn { + param($Return) + $passed = $false + foreach ($statement in @($Return.Parent.Statements)) { + if ($statement -eq $Return) { $passed = $true; continue } + if (-not $passed -or $statement.GetType().Name -ne 'ExitStatementAst') { continue } + if ($statement.Pipeline -and $statement.Pipeline.Extent.Text.Trim() -ne '0') { return $true } + } + $false + } if ($type -notin 'Detection', 'Remediation', 'Win32Detection') { return } $ast = $Context.Ast @@ -71,7 +85,9 @@ function Find-IslExitCodeIssue { 'runs, so the remediation never triggers. Use exit 1 directly') Evidence = ('"return 1; exit 1" ran with exit code 0 and the remediation was skipped; ' + 'Microsoft''s sample detection scripts use this pattern (REM-RETURN-EXIT)') - Fix = @{ Replacement = Get-ReturnReplacement -Return $return } + } + if (-not (Test-ExitFollowsReturn -Return $return)) { + $findingSplat.Fix = @{ Replacement = Get-ReturnReplacement -Return $return } } New-IslFinding @findingSplat } @@ -145,7 +161,9 @@ function Find-IslExitCodeIssue { Message = ('return at script scope ends the remediation with exit 0 (success) regardless of ' + 'what was actually done') Evidence = 'Script-scope return produced exit code 0 (REM-RETURN-EXIT)' - Fix = @{ Replacement = Get-ReturnReplacement -Return $return } + } + if (-not (Test-ExitFollowsReturn -Return $return)) { + $findingSplat.Fix = @{ Replacement = Get-ReturnReplacement -Return $return } } New-IslFinding @findingSplat } diff --git a/Private/Rules/Find-IslInteractiveCall.ps1 b/Private/Rules/Find-IslInteractiveCall.ps1 index 26ed4eb..9c8f19c 100644 --- a/Private/Rules/Find-IslInteractiveCall.ps1 +++ b/Private/Rules/Find-IslInteractiveCall.ps1 @@ -29,13 +29,53 @@ function Find-IslInteractiveCall { $timeout = if ($Context.ScriptType -eq 'PlatformScript') { '30 minutes' } else { '60 minutes' } $evidence = ("Launched as powershell.exe -NoProfile -executionPolicy bypass -file, without -NonInteractive; " + "AgentExecutor timeout $timeout (PS-PROBE-SYS64, REM-PROBE-SYS64, Win32 log)") + $builtEvidence = ($evidence + '; handed a PSCredential object, Get-Credential -Credential returned it ' + + 'in 12 ms under the agent, without a prompt (REM-CRED-BUILT)') $ast = $Context.Ast + # What Get-Credential is handed as -Credential, by name or as the first positional argument. + # Nothing when it is called bare or with -Message, -UserName or -Title, which always prompt + function Get-CredentialArgument { + param($Command) + foreach ($prompting in 'Message', 'UserName', 'Title') { + if (Test-IslCommandParameter -Command $Command -ParameterName $prompting) { return } + } + $elements = @($Command.CommandElements | Select-Object -Skip 1) + for ($index = 0; $index -lt $elements.Count; $index++) { + $element = $elements[$index] + if ($element.GetType().Name -eq 'CommandParameterAst') { + if (-not 'Credential'.StartsWith($element.ParameterName, 'OrdinalIgnoreCase')) { continue } + if ($element.Argument) { return $element.Argument } + if ($index + 1 -lt $elements.Count) { return $elements[$index + 1] } + return + } + # A value right after another parameter belongs to that parameter + $previous = if ($index -gt 0) { $elements[$index - 1] } else { $null } + $taken = $previous -and $previous.GetType().Name -eq 'CommandParameterAst' -and -not $previous.Argument + if (-not $taken) { return $element } + } + } + $alwaysPrompt = 'Read-Host', 'Pause', 'Out-GridView', 'Show-Command', 'Get-Credential' foreach ($command in (Find-IslCommand -Ast $ast -Name $alwaysPrompt)) { $name = $command.GetCommandName() - if ($name -eq 'Get-Credential' -and $command.CommandElements.Count -gt 1) { - # Get-Credential with a name/message still prompts; only a fully built credential doesn't + # Get-Credential -Credential returns a credential that is already built and prompts for the + # password of a user name. A literal is a name; anything else cannot be told apart here + $handed = if ($name -eq 'Get-Credential') { Get-CredentialArgument -Command $command } + $literalTypes = 'StringConstantExpressionAst', 'ExpandableStringExpressionAst' + if ($handed -and $handed.GetType().Name -notin $literalTypes) { + $findingSplat = @{ + RuleName = $rule + Severity = 'Warning' + Context = $Context + Extent = $command.Extent + Message = ('Get-Credential -Credential returns a credential that is already built and ' + + "prompts for the password of a user name: if $($handed.Extent.Text) can ever be a " + + "name, the script hangs until the $timeout timeout") + Evidence = $builtEvidence + } + New-IslFinding @findingSplat + continue } $findingSplat = @{ RuleName = $rule diff --git a/Private/Rules/Find-IslPowerShell7Syntax.ps1 b/Private/Rules/Find-IslPowerShell7Syntax.ps1 index 2e54964..d0f668a 100644 --- a/Private/Rules/Find-IslPowerShell7Syntax.ps1 +++ b/Private/Rules/Find-IslPowerShell7Syntax.ps1 @@ -1,14 +1,17 @@ function Find-IslPowerShell7Syntax { <# .SYNOPSIS - Flags syntax, cmdlets and parameters that only exist in PowerShell 7. + Flags syntax, cmdlets, parameters and parameter values that only exist in PowerShell 7. .DESCRIPTION The Intune Management Extension runs every script with Windows PowerShell 5.1 (observed: PSVersion 5.1.26100, Desktop edition, for remediations, platform scripts and - Win32 detection). A PowerShell 7-only construct is a parse error there, which means the - script never runs: a detection script exits 1 and triggers the remediation, a Win32 - detection reports "not detected". + Win32 detection). PowerShell 7 syntax is a parse error there, which means the script never + runs: a detection script exits 1 and triggers the remediation, a Win32 detection reports + "not detected". A #Requires -Version 7 ends the same way, before the first line. A cmdlet, + a parameter or a parameter value only PowerShell 7 has does parse, so the script starts: + that one call fails with an error and the script carries on to its own exit, without the + result it was written to use. .PARAMETER Context The IntuneScriptLab.ScriptContext from Get-IslScriptContext: AST, tokens, bytes and the @@ -28,6 +31,14 @@ function Find-IslPowerShell7Syntax { $rule = 'IslPowerShell7Syntax' $evidence = ('Scripts run under Windows PowerShell 5.1; a parse error made the detection exit 1 and run the ' + 'remediation (REM-PS7-SYNTAX)') + $requiresEvidence = ('#Requires -Version 7.0 under the agent: the detection exited 1 without running ' + + '(ScriptRequiresUnmatchedPSVersion), the remediation ran and the status was Recurred (REM-PS7-REQUIRES)') + $runtimeEvidence = ('Test-Json, ConvertFrom-Json -AsHashtable and ForEach-Object -Parallel each wrote an ' + + 'error under the agent and the detection ran on to its exit 0: "without issues", the error text in ' + + 'the error field, no remediation (REM-PS7-CMDLET, REM-PS7-PARAM, REM-PS7-PARALLEL)') + $valueEvidence = ('Out-File -Encoding utf8NoBOM failed validation against the 5.1 set (unknown, string, ' + + 'unicode, bigendianunicode, utf8, utf7, utf32, ascii, default, oem) under the agent; no file was ' + + 'written and the detection ran on to its exit 0 (REM-PS7-ENCODING)') $ast = $Context.Ast foreach ($parseError in $Context.ParseErrors) { @@ -52,8 +63,8 @@ function Find-IslPowerShell7Syntax { Context = $Context Extent = $ast.Extent Message = ("'#Requires -Version $($requires.RequiredPSVersion)' cannot be satisfied: Intune runs " + - 'Windows PowerShell 5.1') - Evidence = $evidence + 'Windows PowerShell 5.1, so the script exits 1 before its first line') + Evidence = $requiresEvidence } New-IslFinding @findingSplat } @@ -108,6 +119,8 @@ function Find-IslPowerShell7Syntax { New-IslFinding @findingSplat } + # From here on the script parses, so it starts: the call fails where it stands and the script + # carries on to its own exit, which is the opposite of what a parse error does to a detection foreach ($command in (Find-IslCommand -Ast $ast -Name 'ForEach-Object', '%', 'foreach')) { if (Test-IslCommandParameter -Command $command -ParameterName 'Parallel') { $findingSplat = @{ @@ -115,14 +128,14 @@ function Find-IslPowerShell7Syntax { Severity = 'Error' Context = $Context Extent = $command.Extent - Message = 'ForEach-Object -Parallel is PowerShell 7 only' - Evidence = $evidence + Message = ('ForEach-Object -Parallel is PowerShell 7 only: under Windows PowerShell 5.1 the ' + + 'call fails with an error and the script carries on without its result') + Evidence = $runtimeEvidence } New-IslFinding @findingSplat } } - # Cmdlets and parameters that do not exist in 5.1: a runtime error, not a parse error $coreOnlyCommands = 'Get-Error', 'Join-String', 'Test-Json', 'ConvertFrom-Markdown', 'Get-Uptime', 'Remove-Alias', 'Get-ExperimentalFeature', 'Get-MarkdownOption', 'Show-Markdown', 'Switch-Process', 'ConvertTo-CliXml', 'ConvertFrom-CliXml' @@ -132,8 +145,9 @@ function Find-IslPowerShell7Syntax { Severity = 'Error' Context = $Context Extent = $command.Extent - Message = "$($command.GetCommandName()) does not exist in Windows PowerShell 5.1" - Evidence = $evidence + Message = ("$($command.GetCommandName()) does not exist in Windows PowerShell 5.1: the call " + + 'fails with an error and the script carries on without its result') + Evidence = $runtimeEvidence } New-IslFinding @findingSplat } @@ -152,26 +166,57 @@ function Find-IslPowerShell7Syntax { 'Get-Content' = 'AsByteStream' 'Set-Content' = 'AsByteStream' 'Add-Content' = 'AsByteStream' - 'Out-File' = 'Encoding utf8NoBOM' 'Start-Process' = 'Environment' 'Compress-Archive' = 'PassThru' 'Import-Module' = 'UseWindowsPowerShell', 'SkipEditionCheck' - - 'Rename-Item' = '' } foreach ($commandName in $coreOnlyParameters.Keys) { foreach ($command in (Find-IslCommand -Ast $ast -Name $commandName)) { - foreach ($parameter in ($coreOnlyParameters[$commandName] | - Where-Object { $_ -and $_ -notmatch ' ' })) { + foreach ($parameter in $coreOnlyParameters[$commandName]) { if (Test-IslCommandParameter -Command $command -ParameterName $parameter) { $findingSplat = @{ RuleName = $rule Severity = 'Error' Context = $Context Extent = $command.Extent - Message = "$commandName -$parameter does not exist in Windows PowerShell 5.1" - Evidence = $evidence + Message = ("$commandName -$parameter does not exist in Windows PowerShell 5.1: the " + + 'call fails with an error and the script carries on without its result') + Evidence = $runtimeEvidence + } + New-IslFinding @findingSplat + } + } + } + } + + # A parameter both hosts have, with a value only PowerShell 7 accepts + $coreOnlyValues = @{ + 'Out-File' = @{ Encoding = 'utf8NoBOM' } + } + foreach ($commandName in $coreOnlyValues.Keys) { + foreach ($command in (Find-IslCommand -Ast $ast -Name $commandName)) { + foreach ($parameter in $coreOnlyValues[$commandName].Keys) { + $elements = @($command.CommandElements) + $argument = $null + for ($index = 1; $index -lt $elements.Count -and -not $argument; $index++) { + $element = $elements[$index] + if ($element.GetType().Name -ne 'CommandParameterAst') { continue } + if (-not $parameter.StartsWith($element.ParameterName, 'OrdinalIgnoreCase')) { continue } + $argument = if ($element.Argument) { $element.Argument } + elseif ($index + 1 -lt $elements.Count) { $elements[$index + 1] } + } + $isLiteral = $argument -and $argument.GetType().Name -eq 'StringConstantExpressionAst' + if ($isLiteral -and $argument.Value -in $coreOnlyValues[$commandName][$parameter]) { + $findingSplat = @{ + RuleName = $rule + Severity = 'Error' + Context = $Context + Extent = $command.Extent + Message = ("$commandName -$parameter $($argument.Value) is a PowerShell 7 value: " + + 'under Windows PowerShell 5.1 the call fails with an error, writes nothing, and ' + + 'the script carries on') + Evidence = $valueEvidence } New-IslFinding @findingSplat } diff --git a/README.md b/README.md index 050cf0f..392b049 100644 --- a/README.md +++ b/README.md @@ -67,12 +67,12 @@ the unit tests fail when it is stale). `Get-Help about_IntuneScriptLab` is the c | Rule | What it catches | Observed behaviour behind it | |---|---|---| -| `IslPowerShell7Syntax` | Ternary, `&&`/`\|\|`, `??`, `?.`, `-Parallel`, `#Requires -Version 7`, 7-only cmdlets and parameters | Scripts run under Windows PowerShell 5.1. A parse error makes a detection exit 1 (remediation runs) and a Win32 detection "not detected" | +| `IslPowerShell7Syntax` | Ternary, `&&`/`\|\|`, `??`, `?.`, `-Parallel`, `#Requires -Version 7`, 7-only cmdlets, parameters and parameter values | Scripts run under Windows PowerShell 5.1. A parse error or an unmet `#Requires` makes a detection exit 1 (remediation runs) and a Win32 detection "not detected". A 7-only cmdlet, parameter or value parses: the call fails and the script carries on to its own exit | | `IslEncodingIssue` | Non-ASCII in a UTF-8 file without BOM; UTF-16; non-ASCII in reported output | Files arrive byte-for-byte; without a BOM, 5.1 decodes them as ANSI. Output goes through the OEM code page | -| `IslInteractiveCall` | `Read-Host`, `Pause`, `Get-Credential`, `Out-GridView`, console reads, confirming cmdlets without `-Force` | The agent never passes `-NonInteractive`; prompts hang until the 30/60-minute timeout | +| `IslInteractiveCall` | `Read-Host`, `Pause`, `Get-Credential`, `Out-GridView`, console reads, confirming cmdlets without `-Force` | The agent never passes `-NonInteractive`; prompts hang until the 30/60-minute timeout. `Get-Credential -Credential` handed a credential that is already built returns it, so a variable there is a warning, not an error | | `IslExitCodeIssue` | `return` before `exit 1`, exit codes other than 0/1, unhandled `throw`, missing `exit` | Any non-zero exit runs the remediation; `return` ends the script with exit 0 (Microsoft's own samples do this); `throw` exits 1 | | `IslOutputIssue` | A trailing `Write-Host`/`Warning`/`Verbose` displacing the summary; many `Write-Output` lines; Win32 detection: no stdout, `Write-Error`, unguarded cmdlets; Win32 requirement: a second output line (`Write-Host` included), whitespace in the value, no output, `Write-Error`, non-zero exit | Remediations report the last console line (last 2,048 chars), host streams included: a trailing `Write-Warning` is reported as `WARNING: ...`. Win32 detection: installed = exit 0 **and** stdout; any stderr = not detected; `Write-Host` counts as stdout. Win32 requirement: the whole console output minus its final line break is compared (case-insensitively for strings), so a second line or trailing spaces never match; exit 1 or stderr fails the rule | -| `IslContextIssue` | `HKCU:`, `$env:APPDATA`/`USERPROFILE`, per-user folders, mapped drives in SYSTEM scripts; HKLM writes and service control in user scripts; a note that user context needs an Entra-joined device | SYSTEM runs in session 0 with the `systemprofile` profile and `C:\WINDOWS\TEMP`; user context runs as the signed-in user, and only on Entra joined / hybrid-joined devices: on an Entra-registered device the agent downloads the policy and skips it ("not AADJ/HAADJ device") | +| `IslContextIssue` | `HKCU:`, `$env:APPDATA`/`USERPROFILE`, per-user folders in SYSTEM scripts, and a note on drive letters other than `C:`, which may be mapped drives; HKLM writes and service control in user scripts; a note that user context needs an Entra-joined device | SYSTEM runs in session 0 with the `systemprofile` profile and `C:\WINDOWS\TEMP`, sees local volumes and not the drives the signed-in user mapped; user context runs as the signed-in user, and only on Entra joined / hybrid-joined devices: on an Entra-registered device the agent downloads the policy and skips it ("not AADJ/HAADJ device") | | `IslArchitectureIssue` | `HKLM:\SOFTWARE`, `Program Files`, `System32` in 32-bit scripts; `Sysnative` in 64-bit | `runAs32Bit` launches `SysWOW64\...\powershell.exe`; the portal defaults scripts and remediations to 32-bit | | `IslArm64Assumption` | `'AMD64'` used to mean "64-bit", x64-only installers, `Program Files (x86)` checked without `Program Files (Arm)` | On Windows on ARM the native host reports `ARM64`; the x86 host is emulated and reports `ARCHITEW6432=ARM64`; there is no x64 PowerShell host (local survey of an ARM64 device) | | `IslRebootCommand` | `Restart-Computer`, `Stop-Computer`, `shutdown /r` | Unsupported in remediations; a reboot loses the run result | @@ -172,7 +172,10 @@ first (`return 'ok'` to `'ok'; exit 0`); a UTF-16 or BOM-less non-ASCII file is with a BOM; a padded requirement value (`' ok '`) is trimmed. `Repair-IntuneScript` applies them per script, reports what it changed and how many findings remain, and previews with `-WhatIf`. Whether that `exit 0` should have been an `exit 1` is still the author's call, which is why the -finding stays an error until the intent is made explicit. Findings carry the edit as `Fix`. +finding stays an error until the intent is made explicit. Where the script already says which exit +was meant, a `return` with an exit other than 0 after it in the same block (`return 1; exit 1`), +there is no edit: `1; exit 0; exit 1` would behave the same, hide the exit the author wrote and +leave no finding behind, so that one stays for a person. Findings carry the edit as `Fix`. ## Runtime harness (0.2) @@ -495,8 +498,14 @@ for `-CustomRulePath` or for a `PSScriptAnalyzerSettings.psd1`; `-IncludeRule` a `Measure-` names (a `-CustomRulePath` switches the built-in rules off unless `-IncludeDefaultRules` asks for them), and a `-ScriptDefinition` is analyzed too, with its type from a `# IntuneScriptLab: ScriptType=...` directive. Each record carries the observed Intune behaviour -after the message. The wrapper analyzes a file once at its root script block and answers from a -cache for the nested ones PSScriptAnalyzer also hands it. +after the message. PSScriptAnalyzer hands a rule every script block in a file; the wrapper answers +at the root one only, where the file is analyzed once and the result cached for the rules that +follow. + +`Invoke-ScriptAnalyzer -Severity` does not filter these records by their own severity. +PSScriptAnalyzer registers every custom rule as Warning and filters on that, so `-Severity Error` +returns none of them and `-Severity Warning` returns all of them, whatever each record says +(PSScriptAnalyzer 1.25.0). Filter the output instead: `... | Where-Object Severity -eq Error`. ### Pre-flight against the tenant (0.11) diff --git a/Tests/Integration/PSScriptAnalyzerRules.Tests.ps1 b/Tests/Integration/PSScriptAnalyzerRules.Tests.ps1 index 96a664a..2dec687 100644 --- a/Tests/Integration/PSScriptAnalyzerRules.Tests.ps1 +++ b/Tests/Integration/PSScriptAnalyzerRules.Tests.ps1 @@ -122,6 +122,23 @@ Describe 'PSScriptAnalyzer wrapper' -Tag 'Integration', 'Analyzer' -Skip:(-not $ $only.Count | Should-Be 1 } + It 'is filtered by -Severity as a Warning rule, whatever the record says (PSScriptAnalyzer 1.25.0)' { + # The README and the help say so; a PSScriptAnalyzer that filters on the record fails this + # and both need rewriting + $scriptAnalyzerSplat = @{ + Path = $script:Detect + CustomRulePath = $script:RulePath + IncludeDefaultRules = $false + } + @($script:Direct | Where-Object Severity -eq 'Error').Count | Should-BeGreaterThan 0 + @($script:Direct | Where-Object Severity -ne 'Warning').Count | Should-BeGreaterThan 1 + @(Invoke-ScriptAnalyzer @scriptAnalyzerSplat -Severity Error).Count | Should-Be 0 + @(Invoke-ScriptAnalyzer @scriptAnalyzerSplat -Severity Information).Count | Should-Be 0 + @(Invoke-ScriptAnalyzer @scriptAnalyzerSplat -Severity Warning).Count | Should-Be $script:Direct.Count + $filtered = @(Invoke-ScriptAnalyzer @scriptAnalyzerSplat | Where-Object Severity -eq 'Error') + $filtered.Count | Should-Be @($script:Direct | Where-Object Severity -eq 'Error').Count + } + 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 diff --git a/Tests/Integration/RuntimeHarness.Tests.ps1 b/Tests/Integration/RuntimeHarness.Tests.ps1 index 2b6b885..e02507b 100644 --- a/Tests/Integration/RuntimeHarness.Tests.ps1 +++ b/Tests/Integration/RuntimeHarness.Tests.ps1 @@ -39,6 +39,16 @@ Describe 'Runtime harness launch' -Tag 'Integration', 'Runtime' { $root | Should-NotBe $TestDrive } + It 'gives the script Windows PowerShell''s own modules whichever PowerShell starts the harness' { + # Started from PowerShell 7 with its module path inherited, the 5.1 host loaded PowerShell + # 7's Microsoft.PowerShell.* modules: version 7.0.0.0, no Cert: drive, no Security module + $path = New-TestScript 'modules.ps1' ('"$((Get-Command Get-Item).Module.Version.Major)|' + + '$(Test-Path Cert:\LocalMachine)|$([bool](Get-Command Get-AuthenticodeSignature).Module)"') -Bom + $result = Invoke-IntunePlatformScriptTest -Path $path -Architecture $script:Native + $result.StdOut.Trim() | Should-Be '3|True|True' + $result.StdErr | Should-BeFalsy + } + It 'switches between the 32-bit and native hosts' { $path = New-TestScript 'arch.ps1' '"$([Environment]::Is64BitProcess)|$env:PROCESSOR_ARCHITECTURE"' -Bom (Invoke-IntunePlatformScriptTest -Path $path -Architecture x86).StdOut.Trim() | diff --git a/Tests/Unit/Private/Get-IslDesktopModulePath.Tests.ps1 b/Tests/Unit/Private/Get-IslDesktopModulePath.Tests.ps1 new file mode 100644 index 0000000..dc2c1b6 --- /dev/null +++ b/Tests/Unit/Private/Get-IslDesktopModulePath.Tests.ps1 @@ -0,0 +1,73 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.2.0' } + +<# + The module path a child process is given. Under PowerShell 7 the session's path lists + PowerShell 7's own folders, which a powershell.exe child must not see; under Windows PowerShell + there is nothing to remove. +#> + +BeforeDiscovery { + $script:OnCore = $PSVersionTable.PSEdition -eq 'Core' +} + +BeforeAll { + $script:ModuleRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) + Import-Module (Join-Path $script:ModuleRoot 'IntuneScriptLab.psd1') -Force + + $script:CoreFolders = @( + Join-Path $PSHOME 'Modules' + Join-Path $env:ProgramFiles 'PowerShell\Modules' + Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'PowerShell\Modules' + ) + $script:DesktopFolders = @( + Join-Path $env:ProgramFiles 'WindowsPowerShell\Modules' + Join-Path $env:WINDIR 'system32\WindowsPowerShell\v1.0\Modules' + ) + + function Get-ModulePath { + param([string]$ModulePath) + InModuleScope IntuneScriptLab -Parameters @{ ModulePath = $ModulePath } { + Get-IslDesktopModulePath -ModulePath $ModulePath + } + } +} + +AfterAll { + Remove-Module IntuneScriptLab -Force -ErrorAction SilentlyContinue +} + +Describe 'Get-IslDesktopModulePath' -Tag 'Unit', 'Private' { + + Context 'Under PowerShell 7' -Skip:(-not $script:OnCore) { + It 'removes the three folders PowerShell 7 adds for itself and keeps the rest in order' { + $session = 'C:\Session\Modules' + $given = @($script:CoreFolders[2], $script:CoreFolders[1], $script:CoreFolders[0]) + + $script:DesktopFolders[0] + $session + $script:DesktopFolders[1] + Get-ModulePath ($given -join ';') | + Should-Be (@($script:DesktopFolders[0], $session, $script:DesktopFolders[1]) -join ';') + } + + It 'matches a folder whatever its case or trailing backslash' { + $given = "$($script:CoreFolders[0].ToLowerInvariant())\;$($script:DesktopFolders[1])" + Get-ModulePath $given | Should-Be $script:DesktopFolders[1] + } + + It 'returns an empty string when nothing is left' { + Get-ModulePath ($script:CoreFolders -join ';') | Should-Be '' + } + + It 'reads the session path when none is given' { + $result = InModuleScope IntuneScriptLab { Get-IslDesktopModulePath } + $entries = @($result -split ';') + @($entries | Where-Object { $_ -in $script:CoreFolders }).Count | Should-Be 0 + $entries.Count | Should-BeGreaterThan 0 + } + } + + Context 'Under Windows PowerShell' -Skip:$script:OnCore { + It 'returns the path as it is, $PSHOME\Modules included' { + $given = "$(Join-Path $PSHOME 'Modules');C:\Session\Modules" + Get-ModulePath $given | Should-Be $given + } + } +} diff --git a/Tests/Unit/Private/Invoke-IslProcess.Tests.ps1 b/Tests/Unit/Private/Invoke-IslProcess.Tests.ps1 index abed2a7..df74ac2 100644 --- a/Tests/Unit/Private/Invoke-IslProcess.Tests.ps1 +++ b/Tests/Unit/Private/Invoke-IslProcess.Tests.ps1 @@ -55,6 +55,26 @@ Describe 'Invoke-IslProcess' -Tag 'Unit', 'Private' { $result.TimedOut | Should-BeFalse } + It 'hands the child the session module path without the folders PowerShell 7 adds for itself' { + # Under Windows PowerShell there is nothing to remove and the path arrives whole + $added = Join-Path $TestDrive 'SessionModules' + $saved = $env:PSModulePath + $env:PSModulePath = "$saved;$added" + try { + $result = Invoke-Process @{ + FilePath = $script:Cmd; Arguments = '/C echo %PSModulePath%' + WorkingDirectory = $TestDrive; WorkFolder = $TestDrive + } + } + finally { $env:PSModulePath = $saved } + $entries = @($result.StdOut.Trim() -split ';') + @($entries | Where-Object { $_ -eq $added }).Count | Should-Be 1 + if ($PSVersionTable.PSEdition -eq 'Core') { + @($entries | Where-Object { $_ -eq (Join-Path $PSHOME 'Modules') }).Count | Should-Be 0 + } + else { $result.StdOut.Trim() | Should-Be "$saved;$added" } + } + It 'kills the process tree at the timeout and reports no exit code' { $result = Invoke-Process @{ FilePath = $script:Cmd; Arguments = '/C ping -n 30 127.0.0.1 > nul' diff --git a/Tests/Unit/Private/Rules/Find-IslContextIssue.Tests.ps1 b/Tests/Unit/Private/Rules/Find-IslContextIssue.Tests.ps1 index 984e2d9..03d804f 100644 --- a/Tests/Unit/Private/Rules/Find-IslContextIssue.Tests.ps1 +++ b/Tests/Unit/Private/Rules/Find-IslContextIssue.Tests.ps1 @@ -23,10 +23,22 @@ Describe 'Find-IslContextIssue' -Tag 'Unit', 'Private', 'Rule' { @($findings | Where-Object Severity -eq 'Error').Count | Should-Be 2 } - It 'warns on mapped drive letters in SYSTEM context' { + It 'notes a drive letter other than C: in SYSTEM context, since it may be a mapped drive' { + # A local D: is there for SYSTEM and a mapped X: is not (REM-DRIVES-SYS); the literal cannot + # say which it is, so this is a note, not a warning $path = New-TestScript 'Detect-D.ps1' "Copy-Item 'H:\file' 'C:\x'; exit 0" $findings = @(Get-RuleFinding $path IslContextIssue @{ Context = 'System' }) - @($findings | Where-Object Message -like '*not mapped*').Count | Should-Be 1 + $drive = @($findings | Where-Object Message -like 'Drive H:*') + $drive.Count | Should-Be 1 + $drive[0].Severity | Should-Be 'Information' + $drive[0].Message | Should-BeLikeString '*only if it is a local volume*mapped*user''s session*UNC*' + $drive[0].Evidence | Should-BeLikeString '*(REM-DRIVES-SYS)' + } + + It 'says nothing about drive letters in user context' { + $path = New-TestScript 'script.ps1' "Copy-Item 'H:\file' 'C:\Users\Public\x'" + $findings = @(Get-RuleFinding $path IslContextIssue @{ Context = 'User' }) + @($findings | Where-Object Message -like 'Drive *').Count | Should-Be 0 } It 'warns on HKLM writes and service control in user context' { diff --git a/Tests/Unit/Private/Rules/Find-IslExitCodeIssue.Tests.ps1 b/Tests/Unit/Private/Rules/Find-IslExitCodeIssue.Tests.ps1 index 7557440..6810598 100644 --- a/Tests/Unit/Private/Rules/Find-IslExitCodeIssue.Tests.ps1 +++ b/Tests/Unit/Private/Rules/Find-IslExitCodeIssue.Tests.ps1 @@ -25,6 +25,37 @@ Describe 'Find-IslExitCodeIssue' -Tag 'Unit', 'Private', 'Rule' { Should-Be 1 } + It 'carries the exit 0 edit when no exit follows the return in its block' { + $path = New-TestScript 'Detect-Fx.ps1' "if (Test-Path C:\x) { return 'ok' }`nexit 1" + $finding = @(Get-RuleFinding $path IslExitCodeIssue | Where-Object Message -like '*return*') + $finding.Count | Should-Be 1 + $finding[0].Fix.Replacement | Should-Be "'ok'; exit 0" + } + + It 'carries no edit when an exit other than 0 follows the return ' -ForEach @( + @{ Case = 'at the top level'; Body = "Write-Output 'found 1'`nreturn 1`nexit 1" } + @{ Case = 'inside an if'; Body = "if (`$broken) { return 'bad'; exit 2 }`nexit 0" } + @{ Case = 'with a computed value further down'; Body = "return`nWrite-Output 'never'`nexit `$code" } + ) { + # The author meant that exit; 'exit 0' written in front of it would hide the mistake + $path = New-TestScript 'Detect-Nf.ps1' $Body + $finding = @(Get-RuleFinding $path IslExitCodeIssue | Where-Object Text -like 'return*') + $finding.Count | Should-Be 1 + $finding[0].Severity | Should-Be 'Error' + $finding[0].Fix | Should-BeNull + } + + It 'still carries the edit when the exit that follows is exit 0, in a detection and a remediation' { + $detect = New-TestScript 'Detect-Z.ps1' "if (`$fine) { return 'ok'; exit 0 }`nexit 1" + (Get-RuleFinding $detect IslExitCodeIssue | Where-Object Message -like '*return*').Fix.Replacement | + Should-Be "'ok'; exit 0" + $remediate = New-TestScript 'Remediate-Z.ps1' "return`nexit 0" + (Get-RuleFinding $remediate IslExitCodeIssue | Where-Object Message -like '*return*').Fix.Replacement | + Should-Be 'exit 0' + $failing = New-TestScript 'Remediate-N.ps1' "return`nexit 1" + (Get-RuleFinding $failing IslExitCodeIssue | Where-Object Message -like '*return*').Fix | Should-BeNull + } + It 'warns on exit codes other than 0 and 1 in a detection' { $path = New-TestScript 'Detect-X.ps1' 'if (1) { exit 2 } else { exit -1 }' @(Get-RuleFinding $path IslExitCodeIssue | Where-Object Message -like '*non-zero*').Count | Should-Be 2 diff --git a/Tests/Unit/Private/Rules/Find-IslInteractiveCall.Tests.ps1 b/Tests/Unit/Private/Rules/Find-IslInteractiveCall.Tests.ps1 index 4718010..b180b8c 100644 --- a/Tests/Unit/Private/Rules/Find-IslInteractiveCall.Tests.ps1 +++ b/Tests/Unit/Private/Rules/Find-IslInteractiveCall.Tests.ps1 @@ -24,6 +24,38 @@ Describe 'Find-IslInteractiveCall' -Tag 'Unit', 'Private', 'Rule' { $findings.Severity | Should-All { $_ -eq 'Error' } } + It 'flags Get-Credential as an error when it is sure to prompt: ' -ForEach @( + @{ Call = 'Get-Credential' } + @{ Call = "Get-Credential -Message 'Sign in'" } + @{ Call = "Get-Credential -UserName admin -Message 'Sign in'" } + @{ Call = "Get-Credential -Credential 'CONTOSO\admin'" } + @{ Call = 'Get-Credential admin' } + @{ Call = 'Get-Credential "$env:USERDOMAIN\admin"' } + @{ Call = 'Get-Credential $name -Message hello' } + ) { + $path = New-TestScript 'script.ps1' "`$c = $Call" + $findings = @(Get-RuleFinding $path IslInteractiveCall) + $findings.Count | Should-Be 1 + $findings[0].Severity | Should-Be 'Error' + $findings[0].Message | Should-BeLikeString 'Get-Credential waits for input*' + } + + It 'warns when Get-Credential is handed something that may be a built credential: ' -ForEach @( + @{ Call = 'Get-Credential -Credential $built'; Handed = '$built' } + @{ Call = 'Get-Credential $built'; Handed = '$built' } + @{ Call = 'Get-Credential -Cred:$built'; Handed = '$built' } + @{ Call = 'Get-Credential -ErrorAction Stop -Credential $settings.Account'; Handed = '$settings.Account' } + @{ Call = 'Get-Credential (Import-Clixml C:\x.xml)'; Handed = '(Import-Clixml C:\x.xml)' } + ) { + # A PSCredential is returned as it is (REM-CRED-BUILT); a user name in the same place prompts + $path = New-TestScript 'script.ps1' "`$c = $Call" + $findings = @(Get-RuleFinding $path IslInteractiveCall) + $findings.Count | Should-Be 1 + $findings[0].Severity | Should-Be 'Warning' + $findings[0].Message | Should-BeLikeString "*already built*if $Handed can ever be a name*30 minutes*" + $findings[0].Evidence | Should-BeLikeString '*(REM-CRED-BUILT*' + } + 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" + diff --git a/Tests/Unit/Private/Rules/Find-IslPowerShell7Syntax.Tests.ps1 b/Tests/Unit/Private/Rules/Find-IslPowerShell7Syntax.Tests.ps1 index d288d0c..d8927df 100644 --- a/Tests/Unit/Private/Rules/Find-IslPowerShell7Syntax.Tests.ps1 +++ b/Tests/Unit/Private/Rules/Find-IslPowerShell7Syntax.Tests.ps1 @@ -39,6 +39,46 @@ Describe 'Find-IslPowerShell7Syntax' -Tag 'Unit', 'Private', 'Rule' { $messages | Should-BeLikeString '*-AsHashtable*' } + It 'cites the parse-error experiment for syntax only, and the run-time ones for what parses' { + # A parse error stops the script before its first line (REM-PS7-SYNTAX); a missing cmdlet + # or parameter fails where it stands and the script carries on (round 10) + $syntax = New-TestScript 'Detect-S.ps1' '$x = $true ? 1 : 2; exit 0' + @(Get-RuleFinding $syntax IslPowerShell7Syntax).Evidence | Should-All { $_ -like '*(REM-PS7-SYNTAX)' } + + $path = New-TestScript 'Detect-Rt.ps1' ("'{}' | Test-Json`n`$j = '{}' | ConvertFrom-Json -AsHashtable`n" + + "1..3 | ForEach-Object -Parallel { `$_ }`nexit 0") + $findings = @(Get-RuleFinding $path IslPowerShell7Syntax) + $findings.Count | Should-Be 3 + $findings.Evidence | Should-All { $_ -like '*(REM-PS7-CMDLET, REM-PS7-PARAM, REM-PS7-PARALLEL)' } + $findings.Evidence | Should-All { $_ -notlike '*parse error*' } + $findings.Message | Should-All { $_ -like '*the call fails with an error and the script carries on*' } + + $requires = New-TestScript 'Detect-Rq.ps1' "#Requires -Version 7.0`nexit 0" + @(Get-RuleFinding $requires IslPowerShell7Syntax).Evidence | Should-All { $_ -like '*(REM-PS7-REQUIRES)' } + } + + It 'flags Out-File -Encoding utf8NoBOM, a value Windows PowerShell 5.1 does not have: ' -ForEach @( + @{ Call = "'x' | Out-File -FilePath C:\Windows\Temp\a.txt -Encoding utf8NoBOM" } + @{ Call = "'x' | Out-File C:\Windows\Temp\a.txt -Enc:UTF8NOBOM" } + @{ Call = "Out-File -Encoding 'utf8NoBOM' -InputObject x -FilePath C:\Windows\Temp\a.txt" } + ) { + $path = New-TestScript 'Remediate-E.ps1' "$Call`nexit 0" + $findings = @(Get-RuleFinding $path IslPowerShell7Syntax) + $findings.Count | Should-Be 1 + $findings[0].Severity | Should-Be 'Error' + $findings[0].Message | + Should-BeLikeString 'Out-File -Encoding utf8NoBOM is a PowerShell 7 value*writes nothing*' + $findings[0].Evidence | Should-BeLikeString '*(REM-PS7-ENCODING)' + } + + It 'leaves the encodings both hosts have, a computed encoding and Rename-Item alone' { + $path = New-TestScript 'Remediate-K.ps1' ("'x' | Out-File C:\Windows\Temp\a.txt -Encoding utf8`n" + + "'x' | Out-File C:\Windows\Temp\b.txt -Encoding `$encoding`n" + + "'x' | Out-File C:\Windows\Temp\c.txt`n" + + "Rename-Item -Path C:\Windows\Temp\a.txt -NewName d.txt`nexit 0") + @(Get-RuleFinding $path IslPowerShell7Syntax).Count | Should-Be 0 + } + 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 diff --git a/Tests/Unit/Public/Repair-IntuneScript.Tests.ps1 b/Tests/Unit/Public/Repair-IntuneScript.Tests.ps1 index e22633b..a7cd066 100644 --- a/Tests/Unit/Public/Repair-IntuneScript.Tests.ps1 +++ b/Tests/Unit/Public/Repair-IntuneScript.Tests.ps1 @@ -44,6 +44,20 @@ Describe 'Repair-IntuneScript' -Tag 'Unit', 'Public' { [System.IO.File]::ReadAllText($path) | Should-Be "if (`$a) { exit 0 }`nif (`$b) { 'done'; exit 0 }`nexit 0" } + + It 'leaves a return alone when an exit other than 0 follows it, and the finding with it' { + # 'return 1; exit 1' made explicit would read '1; exit 0; exit 1': the same behaviour, the + # exit the author meant unreachable, and nothing left to report it + $body = "Write-Output 'found'`nreturn 1`nexit 1" + $path = New-TestScript 'Remediations\E\Detect.ps1' $body -Bom + $result = Repair-IntuneScript -Path $path + $result.Applied | Should-Be 0 + $result.Written | Should-BeFalse + [System.IO.File]::ReadAllText($path) | Should-Be $body + $left = @(Test-IntuneScript -Path $path | Where-Object RuleName -eq 'IslExitCodeIssue') + $left.Severity | Should-ContainCollection 'Error' + $result.Remaining | Should-Be @(Test-IntuneScript -Path $path).Count + } } Context 'Encoding' { @@ -116,7 +130,7 @@ Describe 'Repair-IntuneScript' -Tag 'Unit', 'Public' { It 'expands a folder and reports one object per script' { $folder = Join-Path $TestDrive 'Tree' - New-TestScript 'Tree\Remediations\One\Detect.ps1' "return 'a'`nexit 1" -Bom | Out-Null + New-TestScript 'Tree\Remediations\One\Detect.ps1' "if (`$a) { return 'a' }`nexit 1" -Bom | Out-Null New-TestScript 'Tree\Remediations\Two\Detect.ps1' "Write-Output 'b'`nexit 1" -Bom | Out-Null $results = @(Repair-IntuneScript -Path $folder) $results.Count | Should-Be 2 @@ -126,7 +140,7 @@ Describe 'Repair-IntuneScript' -Tag 'Unit', 'Public' { It 'lists what it would do for a folder under -WhatIf' { $folder = Join-Path $TestDrive 'WhatIfTree' - $body = "return 'a'`nexit 1" + $body = "if (`$a) { 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 diff --git a/Validation/Experiments.psd1 b/Validation/Experiments.psd1 index bbb720b..4830dbd 100644 --- a/Validation/Experiments.psd1 +++ b/Validation/Experiments.psd1 @@ -361,6 +361,111 @@ exit 0 Detection = 'Write-ProbeRecord REM-SIZE-250KB detection; Write-Output "big script ran"; exit 0' Remediation = 'Write-ProbeRecord REM-SIZE-250KB remediation; exit 0' } + # Round 10: what Windows PowerShell 5.1 does under the agent with the PowerShell 7 cmdlets, + # parameters and values that parse (REM-PS7-SYNTAX is the one that does not), whether + # Get-Credential prompts when it is handed a credential that is already built, and which + # drives a SYSTEM script sees while the signed-in user has one mapped. The remediation + # writes a probe record if it runs; the detection's last line carries the observation. + @{ + Name = 'REM-PS7-CMDLET' + Question = 'A cmdlet only PowerShell 7 has (Test-Json): does the detection stop or carry on' + RunAs32Bit = $false + RunAsAccount = 'system' + Detection = @' +Write-ProbeRecord REM-PS7-CMDLET detection +Write-Output 'before' +$valid = '{}' | Test-Json +Write-Output "after cmdlet valid=[$valid]" +exit 0 +'@ + Remediation = 'Write-ProbeRecord REM-PS7-CMDLET remediation; exit 0' + } + @{ + Name = 'REM-PS7-PARAM' + Question = 'A parameter only PowerShell 7 has (ConvertFrom-Json -AsHashtable): stop or carry on' + RunAs32Bit = $false + RunAsAccount = 'system' + Detection = @' +Write-ProbeRecord REM-PS7-PARAM detection +Write-Output 'before' +$table = '{"a":1}' | ConvertFrom-Json -AsHashtable +Write-Output "after parameter table=[$($table.a)]" +exit 0 +'@ + Remediation = 'Write-ProbeRecord REM-PS7-PARAM remediation; exit 0' + } + @{ + Name = 'REM-PS7-PARALLEL' + Question = 'ForEach-Object -Parallel under 5.1: stop or carry on' + RunAs32Bit = $false + RunAsAccount = 'system' + Detection = @' +Write-ProbeRecord REM-PS7-PARALLEL detection +Write-Output 'before' +$items = 1..2 | ForEach-Object -Parallel { $_ } +Write-Output "after parallel items=[$(@($items).Count)]" +exit 0 +'@ + Remediation = 'Write-ProbeRecord REM-PS7-PARALLEL remediation; exit 0' + } + @{ + Name = 'REM-PS7-ENCODING' + Question = 'A value only PowerShell 7 has (Out-File -Encoding utf8NoBOM): is the file written' + RunAs32Bit = $false + RunAsAccount = 'system' + Detection = @' +Write-ProbeRecord REM-PS7-ENCODING detection +$target = 'C:\ProgramData\IntuneScriptLab\REM-PS7-ENCODING.txt' +Remove-Item -Path $target -ErrorAction SilentlyContinue +'x' | Out-File -FilePath $target -Encoding utf8NoBOM +Write-Output "after encoding written=[$(Test-Path -Path $target)]" +exit 0 +'@ + Remediation = 'Write-ProbeRecord REM-PS7-ENCODING remediation; exit 0' + } + @{ + Name = 'REM-PS7-REQUIRES' + Question = 'What the agent reports for #Requires -Version 7.0' + RunAs32Bit = $false + RunAsAccount = 'system' + Detection = @' +#Requires -Version 7.0 +Write-ProbeRecord REM-PS7-REQUIRES detection +Write-Output "ran anyway" +exit 0 +'@ + Remediation = 'Write-ProbeRecord REM-PS7-REQUIRES remediation; exit 0' + } + @{ + Name = 'REM-CRED-BUILT' + Question = 'Get-Credential -Credential with a PSCredential object: does it prompt or return' + RunAs32Bit = $false + RunAsAccount = 'system' + Detection = @' +Write-ProbeRecord REM-CRED-BUILT detection +$secure = New-Object -TypeName System.Security.SecureString +foreach ($char in 'not-a-secret'.ToCharArray()) { $secure.AppendChar($char) } +$built = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList 'isl-built', $secure +$stopwatch = [Diagnostics.Stopwatch]::StartNew() +$returned = Get-Credential -Credential $built +Write-Output "returned=[$($returned.UserName)] ms=$($stopwatch.ElapsedMilliseconds)" +exit 0 +'@ + Remediation = 'Write-ProbeRecord REM-CRED-BUILT remediation; exit 0' + } + @{ + Name = 'REM-DRIVES-SYS' + Question = 'Which drives SYSTEM sees while the signed-in user has X: mapped to a share' + RunAs32Bit = $false + RunAsAccount = 'system' + Detection = @' +Write-ProbeRecord REM-DRIVES-SYS detection +$drives = [IO.DriveInfo]::GetDrives() | ForEach-Object { "$($_.Name)=$($_.DriveType)" } +Write-Output ("drives=" + ($drives -join ',') + " X=[" + (Test-Path -Path 'X:\') + "]") +exit 0 +'@ + Remediation = 'Write-ProbeRecord REM-DRIVES-SYS remediation; exit 0' + } ) diff --git a/Validation/Findings.md b/Validation/Findings.md index f3a5407..6da9c59 100644 --- a/Validation/Findings.md +++ b/Validation/Findings.md @@ -374,6 +374,15 @@ console session active): the stored-password task no longer comes back with `0x8 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. +2026-10-05, the same device: `query user`, which `Get-IslLogonSession` parses to find the +account's session, printed ` USERNAME SESSIONNAME ID STATE IDLE TIME LOGON TIME` +and ` isl-user console 1 Active none 10/4/2026 11:00 PM`: the +user name column is 22 characters wide. What it prints for a name that fills or exceeds the column +is **not measured**: a local account name stops at 20 characters, and no Entra account with a +longer one was signed in. Fed such a line by hand, the parser takes a 21-character name and the +session name as one field, so the account would not match and the launcher would fall back to the +stored-password task. + ### Reporting latency, re-measured (2026-09-29) | Kind | Device (log line, converted to UTC) | Graph | Lag | @@ -474,6 +483,39 @@ exclusions (`IslAssignmentIssue`), notes a run-once schedule whose time has pass Entra registered devices, round 1). An include and an exclude of the same group cannot be seen after the fact, so it is documented only. +## PowerShell 7 at run time, a built credential, and the drives SYSTEM sees + +Round 10, 2026-10-05, VM 125 (Entra joined, Windows PowerShell 5.1.26100.9444): seven SYSTEM +remediations, 64-bit, the agent restarted once. Deployed 05:59 UTC, fetched at the restart, queued +for 06:10; `REM-INSTALL-MODULE` (round 7) had the runner by 06:19 and held it for about an hour, its +60-minute timeout as before, so the seven detections ran at 07:19:05-07:20:31 UTC; Graph's run +states for all seven carry `lastStateUpdateDateTime` 07:22:40. Each row is the device's own result record +(`SideCarPolicies\Scripts\Reports\...\Result`) next to the probe file and Graph `deviceRunStates`. + +REM-PS7-SYNTAX (round 1) is the case that does not parse: the detection never starts and exits 1. +These are the cases that do parse. + +| Rule | Documented | Observed | | +|---|---|---|---| +| A cmdlet only PowerShell 7 has (`Test-Json`) | Not documented | **The script carries on.** The call writes `The term 'Test-Json' is not recognized` (`CommandNotFoundException`) and the next line runs: output `after cmdlet valid=[]`, `FirstDetectExitCode` 0, `RemediationStatus` 4 (without issues), the error text in `PreRemediationDetectScriptError`, no remediation run, Graph `detectionState success, remediationState skipped` (REM-PS7-CMDLET) | ⚠️ | +| A parameter only PowerShell 7 has (`ConvertFrom-Json -AsHashtable`) | Not documented | The same: `A parameter cannot be found that matches parameter name 'AsHashtable'` (`NamedParameterNotFound`), output `after parameter table=[]`, exit 0, without issues, no remediation (REM-PS7-PARAM) | ⚠️ | +| `ForEach-Object -Parallel` | Not documented | The same: `Parameter set cannot be resolved using the specified named parameters` (`AmbiguousParameterSet`), nothing came out of the pipeline (the reported `items=[1]` is `@($null).Count`), exit 0, without issues, no remediation (REM-PS7-PARALLEL) | ⚠️ | +| A value only PowerShell 7 has (`Out-File -Encoding utf8NoBOM`) | Not documented | The same, and nothing is written: `The argument "utf8NoBOM" does not belong to the set "unknown,string,unicode,bigendianunicode,utf8,utf7,utf32,ascii,default,oem"` (`ParameterArgumentValidationError`), the target file did not exist afterwards (`written=[False]`), exit 0, without issues, no remediation (REM-PS7-ENCODING) | ⚠️ | +| `#Requires -Version 7.0` | The script does not run (PS docs on #Requires) | As documented, and what Intune makes of it: the detection exits 1 without running, stderr `The script 'detect.ps1' cannot be run because it contained a "#requires" statement for Windows PowerShell 7.0` (`ScriptRequiresUnmatchedPSVersion`), no output; the remediation script **runs** (its probe record at 07:19:36), the post-detection fails the same way, `RemediationStatus` 2 (recurred), Graph `detectionState fail, remediationState remediationFailed`. The result record carries `RemediationExitCode` 1 although the remediation ends in `exit 0` and wrote its record; not followed up (REM-PS7-REQUIRES) | ✅ | +| `Get-Credential -Credential` handed a `PSCredential` | Not documented for the agent | **Returns it, no prompt:** `returned=[isl-built] ms=12`, exit 0, nothing on stderr, although the agent launches without `-NonInteractive` (REM-CRED-BUILT). A user name in the same place is the prompting case, which was not run: a prompt holds the runner for the 60-minute timeout (REM-INSTALL-MODULE) | ✅ | +| Drives a SYSTEM script sees | Not documented | **Local volumes, and not the drives the signed-in user mapped.** With a second local volume `D:` on the device and `X:` mapped to a share in the console user's session (`net use` there listed it before the run and again after it), the SYSTEM detection reported `drives=C:\=Fixed,D:\=Fixed X=[False]` (REM-DRIVES-SYS; the fixture is `New-IslDriveFixture.ps1`) | ⚠️ | + +**For the tool:** `IslPowerShell7Syntax` kept one evidence string, the parse error's, for all of +these; a parse error and a failed call end in opposite verdicts for a detection (exit 1 and a +remediation run, against the script's own exit 0 and "without issues"). The cmdlet, parameter and +`-Parallel` findings now say the call fails and the script carries on, and cite these experiments; +`#Requires` cites its own; `Out-File -Encoding utf8NoBOM`, listed in the rule but never matched, +is a finding. `IslInteractiveCall` keeps `Get-Credential` an error where it is sure to +prompt and makes `-Credential` with anything but a literal a warning, since a variable there may +hold a credential that is already built. `IslContextIssue` no longer calls every drive letter from +`D:` to `Z:` an unmapped drive: the letter cannot say whether it is a local volume, so the finding +is a note that says which case fails. + ## Win32 custom detection scripts Round 2: ten Win32 apps sharing one `.intunewin` package (an install script that only writes a probe diff --git a/Validation/New-IslDriveFixture.ps1 b/Validation/New-IslDriveFixture.ps1 new file mode 100644 index 0000000..61cbe33 --- /dev/null +++ b/Validation/New-IslDriveFixture.ps1 @@ -0,0 +1,129 @@ +#Requires -Version 5.1 + +<# + .SYNOPSIS + Gives a lab device a local D: volume and a drive X: mapped in the signed-in user's session. + + .DESCRIPTION + The fixture REM-DRIVES-SYS looks at (Findings, "PowerShell 7 at run time, a built + credential, and the drives SYSTEM sees"). Run on the device as SYSTEM through the guest + agent: + + .\Invoke-LabGuestScript.ps1 -VmId 125 -ScriptPath .\New-IslDriveFixture.ps1 + + D: is a 64 MB virtual disk under C:\ProgramData\IntuneScriptLab, attached and formatted + with diskpart; it is detached by a restart and attached again by another run. X: is a + share of a folder beside it, mapped with "net use" by a scheduled task that runs inside the + console user's own session, which is the only place a user's mapped drive exists. Every + step is skipped when it is already done, because a guest exec retried after a host-side + timeout runs the script twice. + + .PARAMETER UserName + The signed-in account whose session gets the mapping. Default: the owner of explorer.exe. + + .PARAMETER Remove + Undo the fixture: unmap X:, remove the share and the task, detach and delete the disk. + + .EXAMPLE + .\Invoke-LabGuestScript.ps1 -VmId 125 -ScriptPath .\New-IslDriveFixture.ps1 + + Creates the fixture on VM 125 and prints what the user's session and SYSTEM each see. + + .EXAMPLE + .\Invoke-LabGuestScript.ps1 -VmId 125 -ScriptPath .\New-IslDriveFixture.ps1 -ArgumentList '-Remove' + + Removes it again. + + .EXAMPLE + .\New-IslDriveFixture.ps1 -UserName isl-user + + On the device itself, elevated, naming the account instead of reading it from explorer.exe. + + .INPUTS + None. + + .OUTPUTS + System.String. What "net use" shows inside the user's session, then the drives SYSTEM sees. + + .NOTES + Author: Jeffrey Stuhr. Needs SYSTEM or an elevated session, and a signed-in console user. +#> +[CmdletBinding(SupportsShouldProcess)] +param( + [string]$UserName, + + [switch]$Remove +) + +$ErrorActionPreference = 'Stop' +$lab = 'C:\ProgramData\IntuneScriptLab' +$disk = Join-Path -Path $lab -ChildPath 'isl-d.vhdx' +$shareFolder = Join-Path -Path $lab -ChildPath 'Share' +$shareName = 'isl-share' +$taskName = 'ISL-MapDrive' +$report = Join-Path -Path $lab -ChildPath 'mapdrive.txt' + +function Invoke-DiskPart { + param([Parameter(Mandatory)][string[]]$Command) + $scriptFile = Join-Path -Path $lab -ChildPath 'diskpart.txt' + Set-Content -Path $scriptFile -Value $Command -Encoding ASCII + $output = & diskpart.exe /s $scriptFile 2>&1 + Remove-Item -Path $scriptFile -ErrorAction SilentlyContinue + $output | Where-Object { $_ -match 'success|error|fail' } +} + +function Invoke-InUserSession { + # A one-shot task with an Interactive principal runs inside the account's own logon session + param([Parameter(Mandatory)][string]$Account, [Parameter(Mandatory)][string]$CommandLine) + if (Test-Path -Path $report) { Clear-Content -Path $report } + $action = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument "/c ($CommandLine) > `"$report`" 2>&1" + $principal = New-ScheduledTaskPrincipal -UserId $Account -LogonType Interactive -RunLevel Limited + $null = Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Force + Start-ScheduledTask -TaskName $taskName + Start-Sleep -Seconds 6 + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false + Get-Content -Path $report -ErrorAction SilentlyContinue | Where-Object { "$_".Trim() } +} + +if (-not $UserName) { + $shell = Get-CimInstance -ClassName Win32_Process -Filter "Name='explorer.exe'" | Select-Object -First 1 + if ($shell) { $UserName = (Invoke-CimMethod -InputObject $shell -MethodName GetOwner).User } +} +if (-not $UserName) { throw 'No signed-in user found (no explorer.exe); pass -UserName.' } + +if ($Remove) { + if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Remove the D: and X: fixture')) { + Invoke-InUserSession -Account $UserName -CommandLine 'net use X: /delete /y & net use' + if (Get-SmbShare -Name $shareName -ErrorAction SilentlyContinue) { + Remove-SmbShare -Name $shareName -Force + } + if (Test-Path -Path $disk) { + Invoke-DiskPart -Command "select vdisk file=$disk", 'detach vdisk' + Remove-Item -Path $disk -Force + } + Remove-Item -Path $shareFolder -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $report -ErrorAction SilentlyContinue + } +} +elseif ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, "Create D: and map X: for $UserName")) { + $null = New-Item -ItemType Directory -Path $shareFolder -Force + if (-not (Test-Path -Path 'D:\')) { + if (Test-Path -Path $disk) { Invoke-DiskPart -Command "select vdisk file=$disk", 'attach vdisk' } + else { + $create = "create vdisk file=$disk maximum=64 type=expandable", 'attach vdisk', + 'create partition primary', 'format fs=ntfs quick label=ISLLOCAL', 'assign letter=D' + Invoke-DiskPart -Command $create + } + } + Set-Content -Path (Join-Path -Path $shareFolder -ChildPath 'hello.txt') -Value 'mapped' + if (-not (Get-SmbShare -Name $shareName -ErrorAction SilentlyContinue)) { + $null = New-SmbShare -Name $shareName -Path $shareFolder -ReadAccess 'Everyone' + } + "--- as $UserName ---" + $map = "if not exist X:\hello.txt net use X: \\localhost\$shareName /persistent:no" + Invoke-InUserSession -Account $UserName -CommandLine "$map & net use & whoami" +} + +"--- as $([Security.Principal.WindowsIdentity]::GetCurrent().Name) ---" +$drives = [IO.DriveInfo]::GetDrives() | ForEach-Object { "$($_.Name)=$($_.DriveType)" } +"drives=$($drives -join ',') X=[$(Test-Path -Path 'X:\')]" diff --git a/Validation/README.md b/Validation/README.md index 9a4d899..c9d8107 100644 --- a/Validation/README.md +++ b/Validation/README.md @@ -19,6 +19,7 @@ with the documented behaviour next to the observed one. | `New-IntuneLabVm.ps1` | Clones a Proxmox template into a test VM, fixes the OOBE account screens, snapshots it. | | `Invoke-LabGuestScript.ps1` | Delivers a local script to a lab VM through the QEMU guest agent in numbered base64 parts and runs it there as SYSTEM with the arguments given (`-ArgumentList '-AutoLogon'`). The way to run `New-IslHarnessUser.ps1` or any collection script on a VM without opening a console. | | `New-IslHarnessUser.ps1` | Run on a lab device (as SYSTEM through the guest agent, or elevated): creates the standard local account the harness's `-Credential` runs scripts as, stores its generated password as a DPAPI credential for the running identity (`ISL_TEST_CREDENTIAL` for the UserContext integration suite) and, with `-AutoLogon`, makes it the console user at the next boot through the LSA secret. The password is never printed. | +| `New-IslDriveFixture.ps1` | Run on a lab device through `Invoke-LabGuestScript.ps1`: attaches a small virtual disk as a local `D:` and maps `X:` to a share inside the signed-in user's own session, the state REM-DRIVES-SYS (round 10) reports on; `-Remove` undoes it. Safe to run twice. | | `Invoke-FilterProbe.ps1` | Sends a matrix of assignment filter rules through `assignmentFilters/validateFilter` and `evaluateAssignmentFilter` (the portal's Preview devices) and records what the service accepted, refused and matched, written against one enrolled device's own values. Creates nothing; needs a Graph session. Results in `Results\filter-probe.json`. | | `Invoke-AssignmentProbe.ps1` | Creates six remediations that differ only in their assignment (none, exclusion only, include plus exclusion of the same group, run-once in the past, a user group) so the agent logs show which are delivered. Round 9's evidence (Findings, "Assignment sanity"); the policies stay deployed like the other ISL-* objects. | | `Get-IslEspEvidence.ps1` | Run on a lab device as SYSTEM after an Enrollment Status Page run (`Invoke-LabGuestScript.ps1 -RemoteName Get-IslEspEvidence.ps1`): one JSON document with every probe record, the `Write-EspState` records, the FirstSync and `EnrollmentStatusTracking` registry bookkeeping, the Autopilot diagnostics and the page-relevant agent log lines. Round 8's evidence. | diff --git a/docs/IntuneScriptLab/Get-IntuneAnalyzerRulePath.md b/docs/IntuneScriptLab/Get-IntuneAnalyzerRulePath.md index 0aecd9e..b16352a 100644 --- a/docs/IntuneScriptLab/Get-IntuneAnalyzerRulePath.md +++ b/docs/IntuneScriptLab/Get-IntuneAnalyzerRulePath.md @@ -4,7 +4,7 @@ external help file: IntuneScriptLab-Help.xml HelpUri: https://github.com/fadwen/IntuneScriptLab/blob/main/docs/IntuneScriptLab/Get-IntuneAnalyzerRulePath.md Locale: en-US Module Name: IntuneScriptLab -ms.date: 09/28/2026 +ms.date: 10/05/2026 PlatyPS schema version: 2024-05-01 title: Get-IntuneAnalyzerRulePath --- @@ -91,7 +91,13 @@ The full path of PSScriptAnalyzer\IntuneScriptLab.Rules.psm1 in the installed mo Author: Jeffrey Stuhr. PSScriptAnalyzer invokes a custom rule once per script block in a -file; the wrapper analyzes the file once at the root and answers from a cache for the rest. +file; the wrapper answers at the root one only, where the file is analyzed once and the result +cached for the rules that follow. + +Invoke-ScriptAnalyzer -Severity does not filter these records by their own severity: +PSScriptAnalyzer registers every custom rule as Warning and filters on that, so -Severity Error +returns none of them and -Severity Warning returns all of them, whatever each record says +(PSScriptAnalyzer 1.25.0). Filter the output with Where-Object Severity -eq Error instead. ## RELATED LINKS diff --git a/docs/IntuneScriptLab/Repair-IntuneScript.md b/docs/IntuneScriptLab/Repair-IntuneScript.md index f455693..a2cecee 100644 --- a/docs/IntuneScriptLab/Repair-IntuneScript.md +++ b/docs/IntuneScriptLab/Repair-IntuneScript.md @@ -4,7 +4,7 @@ external help file: IntuneScriptLab-Help.xml HelpUri: https://github.com/fadwen/IntuneScriptLab/blob/main/docs/IntuneScriptLab/Repair-IntuneScript.md Locale: en-US Module Name: IntuneScriptLab -ms.date: 09/28/2026 +ms.date: 10/05/2026 PlatyPS schema version: 2024-05-01 title: Repair-IntuneScript --- @@ -34,7 +34,10 @@ behaviour-preserving edit: IslExitCodeIssue a script-scope 'return' becomes the exit it already implied: '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 + exit should have been 1 is still the author's call. A return with an + exit other than 0 after it in the same block ('return 1; exit 1') is + left alone: the author meant that exit, and the finding stays until + a person decides 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 diff --git a/docs/Rules.md b/docs/Rules.md index 30bd694..7702db5 100644 --- a/docs/Rules.md +++ b/docs/Rules.md @@ -45,12 +45,12 @@ As SYSTEM, HKCU: is the SYSTEM account's own hive, USERPROFILE is C:\WINDOWS\sys | 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) | +| Information | Drive exists for SYSTEM only if it is a local volume: a drive the user mapped belongs to the user's session. For a mapped drive use the UNC path and make sure the computer account can reach it | A SYSTEM detection listed C:\ and D:\, both local volumes, and found no X:\ while the signed-in user had X: mapped to a share (REM-DRIVES-SYS) | | Warning | to a machine-wide location runs as the signed-in user, who is usually not an administrator; it will fail with access denied | User context ran as AzureAD\ in the console session with that user's profile and rights (REM-PROBE-USER64, PS-PROBE-USER) | | Warning | needs administrator rights; in user context the script runs as the signed-in user | User context ran as AzureAD\ in the console session with that user's profile and rights (REM-PROBE-USER64, PS-PROBE-USER) | | Information | User context runs only on Entra joined or hybrid-joined devices: on an Entra-registered device the agent downloads the policy and skips it. Deploy as SYSTEM if registered devices must be covered | IntuneManagementExtension.log on a registered device: "This is not AADJ/HAADJ device, skip user context"; the same scripts ran as AzureAD\ on a joined device (join-type experiments, REM-PROBE-USER64, PS-PROBE-USER) | -Experiments: PS-PROBE-SYS64, PS-PROBE-USER, REM-PROBE-SYS64, REM-PROBE-USER64 +Experiments: PS-PROBE-SYS64, PS-PROBE-USER, REM-DRIVES-SYS, REM-PROBE-SYS64, REM-PROBE-USER64 ## IslEncodingIssue @@ -108,11 +108,12 @@ The agent launches powershell.exe with -NoProfile -ExecutionPolicy Bypass -File | Severity | Message | Evidence | |---|---|---| +| Warning | Get-Credential -Credential returns a credential that is already built and prompts for the password of a user name: if can ever be a name, 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); handed a PSCredential object, Get-Credential -Credential returned it in 12 ms under the agent, without a prompt (REM-CRED-BUILT) | | 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:$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 +Experiments: PS-PROBE-SYS64, REM-CRED-BUILT, REM-PROBE-SYS64 ## IslLongSleep @@ -166,22 +167,23 @@ Experiments: REM-EXIT-ERRNOEXIT, REM-OUT-HOSTLAST, REM-OUT-LONG, REM-OUT-STREAMS ## IslPowerShell7Syntax -Flags syntax, cmdlets and parameters that only exist in PowerShell 7. +Flags syntax, cmdlets, parameters and parameter values that only exist in PowerShell 7. -The Intune Management Extension runs every script with Windows PowerShell 5.1 (observed: PSVersion 5.1.26100, Desktop edition, for remediations, platform scripts and Win32 detection). A PowerShell 7-only construct is a parse error there, which means the script never runs: a detection script exits 1 and triggers the remediation, a Win32 detection reports "not detected". +The Intune Management Extension runs every script with Windows PowerShell 5.1 (observed: PSVersion 5.1.26100, Desktop edition, for remediations, platform scripts and Win32 detection). PowerShell 7 syntax is a parse error there, which means the script never runs: a detection script exits 1 and triggers the remediation, a Win32 detection reports "not detected". A #Requires -Version 7 ends the same way, before the first line. A cmdlet, a parameter or a parameter value only PowerShell 7 has does parse, so the script starts: that one call fails with an error and the script carries on to its own exit, without the result it was written to use. | Severity | Message | Evidence | |---|---|---| | Error | Parse error: | Scripts run under Windows PowerShell 5.1; a parse error made the detection exit 1 and run the remediation (REM-PS7-SYNTAX) | -| Error | '#Requires -Version ' cannot be satisfied: Intune runs Windows PowerShell 5.1 | Scripts run under Windows PowerShell 5.1; a parse error made the detection exit 1 and run the remediation (REM-PS7-SYNTAX) | +| Error | '#Requires -Version ' cannot be satisfied: Intune runs Windows PowerShell 5.1, so the script exits 1 before its first line | #Requires -Version 7.0 under the agent: the detection exited 1 without running (ScriptRequiresUnmatchedPSVersion), the remediation ran and the status was Recurred (REM-PS7-REQUIRES) | | Error | is PowerShell 7 only; Windows PowerShell 5.1 fails to parse the whole script | Scripts run under Windows PowerShell 5.1; a parse error made the detection exit 1 and run the remediation (REM-PS7-SYNTAX) | | Error | Null-coalescing operator (?? / ??=) is PowerShell 7 only; Windows PowerShell 5.1 fails to parse the whole script | Scripts run under Windows PowerShell 5.1; a parse error made the detection exit 1 and run the remediation (REM-PS7-SYNTAX) | | Error | Null-conditional member access (?. / ?[]) is PowerShell 7 only; Windows PowerShell 5.1 fails to parse the whole script | Scripts run under Windows PowerShell 5.1; a parse error made the detection exit 1 and run the remediation (REM-PS7-SYNTAX) | -| Error | ForEach-Object -Parallel is PowerShell 7 only | Scripts run under Windows PowerShell 5.1; a parse error made the detection exit 1 and run the remediation (REM-PS7-SYNTAX) | -| Error | does not exist in Windows PowerShell 5.1 | Scripts run under Windows PowerShell 5.1; a parse error made the detection exit 1 and run the remediation (REM-PS7-SYNTAX) | -| Error | - does not exist in Windows PowerShell 5.1 | Scripts run under Windows PowerShell 5.1; a parse error made the detection exit 1 and run the remediation (REM-PS7-SYNTAX) | +| Error | ForEach-Object -Parallel is PowerShell 7 only: under Windows PowerShell 5.1 the call fails with an error and the script carries on without its result | Test-Json, ConvertFrom-Json -AsHashtable and ForEach-Object -Parallel each wrote an error under the agent and the detection ran on to its exit 0: "without issues", the error text in the error field, no remediation (REM-PS7-CMDLET, REM-PS7-PARAM, REM-PS7-PARALLEL) | +| Error | does not exist in Windows PowerShell 5.1: the call fails with an error and the script carries on without its result | Test-Json, ConvertFrom-Json -AsHashtable and ForEach-Object -Parallel each wrote an error under the agent and the detection ran on to its exit 0: "without issues", the error text in the error field, no remediation (REM-PS7-CMDLET, REM-PS7-PARAM, REM-PS7-PARALLEL) | +| Error | - does not exist in Windows PowerShell 5.1: the call fails with an error and the script carries on without its result | Test-Json, ConvertFrom-Json -AsHashtable and ForEach-Object -Parallel each wrote an error under the agent and the detection ran on to its exit 0: "without issues", the error text in the error field, no remediation (REM-PS7-CMDLET, REM-PS7-PARAM, REM-PS7-PARALLEL) | +| Error | - is a PowerShell 7 value: under Windows PowerShell 5.1 the call fails with an error, writes nothing, and the script carries on | Out-File -Encoding utf8NoBOM failed validation against the 5.1 set (unknown, string, unicode, bigendianunicode, utf8, utf7, utf32, ascii, default, oem) under the agent; no file was written and the detection ran on to its exit 0 (REM-PS7-ENCODING) | -Experiments: REM-PS7-SYNTAX +Experiments: REM-PS7-CMDLET, REM-PS7-ENCODING, REM-PS7-PARALLEL, REM-PS7-PARAM, REM-PS7-REQUIRES, REM-PS7-SYNTAX ## IslRebootCommand diff --git a/en-US/IntuneScriptLab-Help.xml b/en-US/IntuneScriptLab-Help.xml index d648107..c3f90e4 100644 --- a/en-US/IntuneScriptLab-Help.xml +++ b/en-US/IntuneScriptLab-Help.xml @@ -2289,7 +2289,13 @@ built-in rules in the same pass, the same CI gate and the same editor squiggles. Author: Jeffrey Stuhr. PSScriptAnalyzer invokes a custom rule once per script block in a -file; the wrapper analyzes the file once at the root and answers from a cache for the rest. +file; the wrapper answers at the root one only, where the file is analyzed once and the result +cached for the rules that follow. + +Invoke-ScriptAnalyzer -Severity does not filter these records by their own severity: +PSScriptAnalyzer registers every custom rule as Warning and filters on that, so -Severity Error +returns none of them and -Severity Warning returns all of them, whatever each record says +(PSScriptAnalyzer 1.25.0). Filter the output with Where-Object Severity -eq Error instead. @@ -4155,7 +4161,10 @@ behaviour-preserving edit: IslExitCodeIssue a script-scope 'return' becomes the exit it already implied: '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 + exit should have been 1 is still the author's call. A return with an + exit other than 0 after it in the same block ('return 1; exit 1') is + left alone: the author meant that exit, and the finding stays until + a person decides 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 diff --git a/en-US/about_IntuneScriptLab.help.txt b/en-US/about_IntuneScriptLab.help.txt index ab0d97c..e5e7047 100644 --- a/en-US/about_IntuneScriptLab.help.txt +++ b/en-US/about_IntuneScriptLab.help.txt @@ -46,7 +46,8 @@ LONG DESCRIPTION bitness, encoding, interactive calls, sleeps, reboots, relative paths, execution policy calls, module dependencies, size and the signature check. Findings carry the evidence. - Repair-IntuneScript Applies the fixes that are mechanical (a missing exit, a BOM). + Repair-IntuneScript Applies the fixes that are mechanical (a script-scope return, + the encoding, a padded requirement value). Get-IntuneAnalyzerRulePath The same rules as PSScriptAnalyzer custom rules for -CustomRulePath. Export-IntuneFindingSarif Findings as SARIF for GitHub code scanning. Assert-PassIntuneAnalysis The Should-* assertions for a Pester suite (Should-PassIntuneAnalysis,