From 54b42a17b071f611ddcad66507b205839956a5ea Mon Sep 17 00:00:00 2001 From: fadwen <110697945+fadwen@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:00:52 -0700 Subject: [PATCH] test(rules): the 7-only cmdlet, parameter and value tables are checked against both hosts The tables IslPowerShell7Syntax reports from were kept by hand inside the rule; the 0.26.0 audit found entries that exist on neither host. They live in Get-IslCoreOnlyFeature now, and a unit test sends them to powershell.exe and pwsh.exe as child processes: every entry must be absent from 5.1 and present in 7. On its first run it found three more wrong entries, removed here: Switch-Process (Linux and macOS only), Invoke-WebRequest -StatusCodeVariable (neither host) and Get-ChildItem -FollowSymlink (in 5.1 since 5.0). --- CHANGELOG.md | 11 ++ Private/Get-IslCoreOnlyFeature.ps1 | 63 +++++++++ Private/Rules/Find-IslPowerShell7Syntax.ps1 | 39 +----- .../Private/Get-IslCoreOnlyFeature.Tests.ps1 | 123 ++++++++++++++++++ 4 files changed, 204 insertions(+), 32 deletions(-) create mode 100644 Private/Get-IslCoreOnlyFeature.ps1 create mode 100644 Tests/Unit/Private/Get-IslCoreOnlyFeature.Tests.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 20b07e3..75b93a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,12 @@ release notes. a task then never starts for an Entra account, with or without the "Log on as a batch job" right, so the refusal names that instead of the right. +- **`IslPowerShell7Syntax` listed three things that are not PowerShell 7-only.** `Switch-Process` + exists on Linux and macOS only, so a script calling it fails on both Windows hosts; + `Invoke-WebRequest -StatusCodeVariable` exists on neither host (only `Invoke-RestMethod` has + it); `Get-ChildItem -FollowSymlink` has been in Windows PowerShell since 5.0. All three are + gone from the rule. + ### Changed - Validation kit: `Collect` moves its payload through the guest agent's file-read call in one @@ -36,6 +42,11 @@ release notes. `REM-INSTALL-MODULE`, which hangs for its 60-minute timeout every hour and holds the lab's remediation runner, is no longer assigned. +- The cmdlets, parameters and values `IslPowerShell7Syntax` reports are one table + (`Get-IslCoreOnlyFeature`), and a unit test holds every entry against both hosts as child + processes: absent from Windows PowerShell 5.1, present in PowerShell 7, a value refused by the + 5.1 `ValidateSet` and taken by 7. The three entries above are what it found on its first run. + ## [0.27.0] - 2026-10-05 Fixes to the runtime harness and to four rules, each rule change backed by a tenth validation diff --git a/Private/Get-IslCoreOnlyFeature.ps1 b/Private/Get-IslCoreOnlyFeature.ps1 new file mode 100644 index 0000000..01f5c2f --- /dev/null +++ b/Private/Get-IslCoreOnlyFeature.ps1 @@ -0,0 +1,63 @@ +function Get-IslCoreOnlyFeature { + <# + .SYNOPSIS + The cmdlets, parameters and parameter values that PowerShell 7 has and Windows PowerShell 5.1 lacks. + + .DESCRIPTION + The table IslPowerShell7Syntax reports from, kept in one place so a test can hold it against + both hosts: every command listed must be absent from Windows PowerShell 5.1 and present in + PowerShell 7, every parameter likewise on its command, and every value refused by the 5.1 + parameter's ValidateSet and taken by 7's. The 0.26.0 audit found two parameters here that + exist on neither host, and an Out-File value that the rule never matched; the test is what + keeps that from happening again. + + A script that uses one of these parses under 5.1, so it starts: the call fails with an + error and the script carries on to its own exit (REM-PS7-CMDLET, REM-PS7-PARAM, + REM-PS7-ENCODING). + + .EXAMPLE + (Get-IslCoreOnlyFeature).Parameters['ConvertFrom-Json'] + + AsHashtable, Depth, NoEnumerate, DateKind. + + .OUTPUTS + IntuneScriptLab.CoreOnlyFeature: Commands (string[]), Parameters (hashtable of command to + parameter names) and Values (hashtable of command to hashtable of parameter to values). + #> + [CmdletBinding()] + [OutputType('IntuneScriptLab.CoreOnlyFeature')] + param() + + [pscustomobject]@{ + PSTypeName = 'IntuneScriptLab.CoreOnlyFeature' + # Switch-Process is not here: PowerShell 7 has it on Linux and macOS only, so a script that + # calls it fails on both Windows hosts. Get-ChildItem -FollowSymlink is not here either: + # Windows PowerShell 5.0 already had it. Both were listed until the test below ran + Commands = [string[]]@( + 'Get-Error', 'Join-String', 'Test-Json', 'ConvertFrom-Markdown', 'Get-Uptime', 'Remove-Alias', + 'Get-ExperimentalFeature', 'Get-MarkdownOption', 'Show-Markdown', 'ConvertTo-CliXml', + 'ConvertFrom-CliXml' + ) + Parameters = @{ + 'ConvertFrom-Json' = 'AsHashtable', 'Depth', 'NoEnumerate', 'DateKind' + 'ConvertTo-Json' = 'AsArray', 'EnumsAsStrings', 'EscapeHandling' + 'Split-Path' = 'LeafBase', 'Extension' + 'Invoke-WebRequest' = 'SkipCertificateCheck', 'SkipHttpErrorCheck', 'Form', 'Resume', + 'Authentication', 'Token', 'AllowInsecureRedirect', 'RetryIntervalSec' + 'Invoke-RestMethod' = 'SkipCertificateCheck', 'SkipHttpErrorCheck', 'Form', 'Resume', + 'StatusCodeVariable', 'Authentication', 'Token', 'AllowInsecureRedirect', 'ResponseHeadersVariable' + 'Select-String' = 'Raw', 'Culture', 'NoEmphasis' + 'Test-Connection' = 'TargetName', 'TcpPort', 'Ping', 'Traceroute', 'IPv4', 'IPv6', 'Repeat' + 'Get-Content' = 'AsByteStream' + 'Set-Content' = 'AsByteStream' + 'Add-Content' = 'AsByteStream' + 'Start-Process' = 'Environment' + 'Compress-Archive' = 'PassThru' + 'Import-Module' = 'UseWindowsPowerShell', 'SkipEditionCheck' + } + # A parameter both hosts have, with a value only PowerShell 7 accepts + Values = @{ + 'Out-File' = @{ Encoding = [string[]]@('utf8NoBOM') } + } + } +} diff --git a/Private/Rules/Find-IslPowerShell7Syntax.ps1 b/Private/Rules/Find-IslPowerShell7Syntax.ps1 index d0f668a..9c593d6 100644 --- a/Private/Rules/Find-IslPowerShell7Syntax.ps1 +++ b/Private/Rules/Find-IslPowerShell7Syntax.ps1 @@ -136,10 +136,8 @@ function Find-IslPowerShell7Syntax { } } - $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' - foreach ($command in (Find-IslCommand -Ast $ast -Name $coreOnlyCommands)) { + $coreOnly = Get-IslCoreOnlyFeature + foreach ($command in (Find-IslCommand -Ast $ast -Name $coreOnly.Commands)) { $findingSplat = @{ RuleName = $rule Severity = 'Error' @@ -152,28 +150,9 @@ function Find-IslPowerShell7Syntax { New-IslFinding @findingSplat } - $coreOnlyParameters = @{ - 'ConvertFrom-Json' = 'AsHashtable', 'Depth', 'NoEnumerate', 'DateKind' - 'ConvertTo-Json' = 'AsArray', 'EnumsAsStrings', 'EscapeHandling' - 'Split-Path' = 'LeafBase', 'Extension' - 'Get-ChildItem' = 'FollowSymlink' - 'Invoke-WebRequest' = 'SkipCertificateCheck', 'SkipHttpErrorCheck', 'Form', 'Resume', - 'StatusCodeVariable', 'Authentication', 'Token', 'AllowInsecureRedirect', 'RetryIntervalSec' - 'Invoke-RestMethod' = 'SkipCertificateCheck', 'SkipHttpErrorCheck', 'Form', 'Resume', - 'StatusCodeVariable', 'Authentication', 'Token', 'AllowInsecureRedirect', 'ResponseHeadersVariable' - 'Select-String' = 'Raw', 'Culture', 'NoEmphasis' - 'Test-Connection' = 'TargetName', 'TcpPort', 'Ping', 'Traceroute', 'IPv4', 'IPv6', 'Repeat' - 'Get-Content' = 'AsByteStream' - 'Set-Content' = 'AsByteStream' - 'Add-Content' = 'AsByteStream' - 'Start-Process' = 'Environment' - - 'Compress-Archive' = 'PassThru' - 'Import-Module' = 'UseWindowsPowerShell', 'SkipEditionCheck' - } - foreach ($commandName in $coreOnlyParameters.Keys) { + foreach ($commandName in $coreOnly.Parameters.Keys) { foreach ($command in (Find-IslCommand -Ast $ast -Name $commandName)) { - foreach ($parameter in $coreOnlyParameters[$commandName]) { + foreach ($parameter in $coreOnly.Parameters[$commandName]) { if (Test-IslCommandParameter -Command $command -ParameterName $parameter) { $findingSplat = @{ RuleName = $rule @@ -190,13 +169,9 @@ function Find-IslPowerShell7Syntax { } } - # A parameter both hosts have, with a value only PowerShell 7 accepts - $coreOnlyValues = @{ - 'Out-File' = @{ Encoding = 'utf8NoBOM' } - } - foreach ($commandName in $coreOnlyValues.Keys) { + foreach ($commandName in $coreOnly.Values.Keys) { foreach ($command in (Find-IslCommand -Ast $ast -Name $commandName)) { - foreach ($parameter in $coreOnlyValues[$commandName].Keys) { + foreach ($parameter in $coreOnly.Values[$commandName].Keys) { $elements = @($command.CommandElements) $argument = $null for ($index = 1; $index -lt $elements.Count -and -not $argument; $index++) { @@ -207,7 +182,7 @@ function Find-IslPowerShell7Syntax { 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]) { + if ($isLiteral -and $argument.Value -in $coreOnly.Values[$commandName][$parameter]) { $findingSplat = @{ RuleName = $rule Severity = 'Error' diff --git a/Tests/Unit/Private/Get-IslCoreOnlyFeature.Tests.ps1 b/Tests/Unit/Private/Get-IslCoreOnlyFeature.Tests.ps1 new file mode 100644 index 0000000..619fb9d --- /dev/null +++ b/Tests/Unit/Private/Get-IslCoreOnlyFeature.Tests.ps1 @@ -0,0 +1,123 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.2.0' } + +<# + The table IslPowerShell7Syntax reports from, held against the two hosts themselves: each entry + must be missing from Windows PowerShell 5.1 and present in PowerShell 7. Both hosts are asked + as child processes, so the test gives the same answer whichever one runs it; it is skipped + where either host is missing. +#> + +BeforeDiscovery { + $script:BothHosts = [bool](Get-Command powershell.exe -ErrorAction SilentlyContinue) -and + [bool](Get-Command pwsh.exe -ErrorAction SilentlyContinue) +} + +BeforeAll { + $script:ModuleRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) + Import-Module (Join-Path $script:ModuleRoot 'IntuneScriptLab.psd1') -Force + $script:Table = InModuleScope IntuneScriptLab { Get-IslCoreOnlyFeature } + + # What each host says about the table: for every command whether it exists, for every parameter + # whether its command has it, and for every value whether the parameter's ValidateSet takes it + # (no ValidateSet means the value is not refused there) + $probe = { + param($Json) + $table = $Json | ConvertFrom-Json + $result = @{ Commands = @{}; Parameters = @{}; Values = @{} } + foreach ($name in $table.Commands) { + $result.Commands[$name] = [bool](Get-Command -Name $name -ErrorAction SilentlyContinue) + } + foreach ($entry in $table.Parameters) { + $command = Get-Command -Name $entry.Command -ErrorAction SilentlyContinue + foreach ($parameter in $entry.Parameters) { + $result.Parameters["$($entry.Command) -$parameter"] = + [bool]($command -and $command.Parameters.ContainsKey($parameter)) + } + } + foreach ($entry in $table.Values) { + $command = Get-Command -Name $entry.Command -ErrorAction SilentlyContinue + $set = @() + if ($command -and $command.Parameters.ContainsKey($entry.Parameter)) { + $set = @($command.Parameters[$entry.Parameter].Attributes | + Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] } | + ForEach-Object { $_.ValidValues }) + } + foreach ($value in $entry.Values) { + $result.Values["$($entry.Command) -$($entry.Parameter) $value"] = + if ($set.Count) { [bool]($set -contains $value) } else { [bool]$command } + } + } + $result | ConvertTo-Json -Depth 4 -Compress + } + $flat = [pscustomobject]@{ + Commands = @($script:Table.Commands) + Parameters = @(foreach ($command in $script:Table.Parameters.Keys) { + @{ Command = $command; Parameters = @($script:Table.Parameters[$command]) } + }) + @(@{ Command = 'ForEach-Object'; Parameters = @('Parallel') }) + Values = @(foreach ($command in $script:Table.Values.Keys) { + foreach ($parameter in $script:Table.Values[$command].Keys) { + $values = @($script:Table.Values[$command][$parameter]) + @{ Command = $command; Parameter = $parameter; Values = $values } + } + }) + } + $json = $flat | ConvertTo-Json -Depth 5 -Compress + $command = "& { $($probe.ToString()) } '$($json.Replace("'", "''"))'" + $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)) + function script:Get-HostAnswer { + param([string]$Exe, [string]$Command) + $raw = & $Exe -NoProfile -NonInteractive -EncodedCommand $Command 2>&1 + ($raw | Where-Object { "$_".StartsWith('{') } | Select-Object -Last 1) | ConvertFrom-Json + } + # Discovery-time variables do not reach the run, so the lookup is repeated here + $script:BothHosts = [bool](Get-Command powershell.exe -ErrorAction SilentlyContinue) -and + [bool](Get-Command pwsh.exe -ErrorAction SilentlyContinue) + if ($script:BothHosts) { + $script:Desktop = Get-HostAnswer 'powershell.exe' $encoded + $script:Core = Get-HostAnswer 'pwsh.exe' $encoded + } +} + +AfterAll { + Remove-Module IntuneScriptLab -Force -ErrorAction SilentlyContinue +} + +Describe 'Get-IslCoreOnlyFeature' -Tag 'Unit', 'Private' { + + It 'returns the three tables' { + $script:Table.PSObject.TypeNames | Should-ContainCollection 'IntuneScriptLab.CoreOnlyFeature' + $script:Table.Commands.Count | Should-BeGreaterThan 5 + $script:Table.Parameters.Keys.Count | Should-BeGreaterThan 5 + $script:Table.Values['Out-File'].Encoding | Should-ContainCollection 'utf8NoBOM' + } + + Context 'Against both hosts' -Skip:(-not $script:BothHosts) { + It 'every listed command is missing from Windows PowerShell 5.1 and present in PowerShell 7' { + $wrong = @(foreach ($name in $script:Table.Commands) { + if ($script:Desktop.Commands.$name) { "$name exists in 5.1" } + if (-not $script:Core.Commands.$name) { "$name is missing from 7" } + }) + $wrong | Should-BeCollection @() + } + + It 'every listed parameter is missing from the 5.1 command and present on the 7 one' { + $keys = @($script:Core.Parameters.PSObject.Properties.Name) + $keys.Count | Should-BeGreaterThan 30 + $wrong = @(foreach ($key in $keys) { + if ($script:Desktop.Parameters.$key) { "$key exists in 5.1" } + if (-not $script:Core.Parameters.$key) { "$key is missing from 7" } + }) + $wrong | Should-BeCollection @() + } + + It 'every listed value is refused by the 5.1 parameter and taken by the 7 one' { + $keys = @($script:Core.Values.PSObject.Properties.Name) + $keys.Count | Should-BeGreaterThan 0 + $wrong = @(foreach ($key in $keys) { + if ($script:Desktop.Values.$key) { "$key is accepted by 5.1" } + if (-not $script:Core.Values.$key) { "$key is refused by 7" } + }) + $wrong | Should-BeCollection @() + } + } +}