Skip to content

Commit 00a254a

Browse files
committed
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).
1 parent 9d71217 commit 00a254a

4 files changed

Lines changed: 207 additions & 33 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,20 @@ release notes.
1111

1212
## [Unreleased]
1313

14-
Nothing yet.
14+
### Fixed
15+
16+
- **`IslPowerShell7Syntax` listed three things that are not PowerShell 7-only.** `Switch-Process`
17+
exists on Linux and macOS only, so a script calling it fails on both Windows hosts;
18+
`Invoke-WebRequest -StatusCodeVariable` exists on neither host (only `Invoke-RestMethod` has
19+
it); `Get-ChildItem -FollowSymlink` has been in Windows PowerShell since 5.0. All three are
20+
gone from the rule.
21+
22+
### Changed
23+
24+
- The cmdlets, parameters and values `IslPowerShell7Syntax` reports are one table
25+
(`Get-IslCoreOnlyFeature`), and a unit test holds every entry against both hosts as child
26+
processes: absent from Windows PowerShell 5.1, present in PowerShell 7, a value refused by the
27+
5.1 `ValidateSet` and taken by 7. The three entries above are what it found on its first run.
1528

1629
## [0.27.0] - 2026-10-05
1730

‎Private/Get-IslCoreOnlyFeature.ps1‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
function Get-IslCoreOnlyFeature {
2+
<#
3+
.SYNOPSIS
4+
The cmdlets, parameters and parameter values that PowerShell 7 has and Windows PowerShell 5.1 lacks.
5+
6+
.DESCRIPTION
7+
The table IslPowerShell7Syntax reports from, kept in one place so a test can hold it against
8+
both hosts: every command listed must be absent from Windows PowerShell 5.1 and present in
9+
PowerShell 7, every parameter likewise on its command, and every value refused by the 5.1
10+
parameter's ValidateSet and taken by 7's. The 0.26.0 audit found two parameters here that
11+
exist on neither host, and an Out-File value that the rule never matched; the test is what
12+
keeps that from happening again.
13+
14+
A script that uses one of these parses under 5.1, so it starts: the call fails with an
15+
error and the script carries on to its own exit (REM-PS7-CMDLET, REM-PS7-PARAM,
16+
REM-PS7-ENCODING).
17+
18+
.EXAMPLE
19+
(Get-IslCoreOnlyFeature).Parameters['ConvertFrom-Json']
20+
21+
AsHashtable, Depth, NoEnumerate, DateKind.
22+
23+
.OUTPUTS
24+
IntuneScriptLab.CoreOnlyFeature: Commands (string[]), Parameters (hashtable of command to
25+
parameter names) and Values (hashtable of command to hashtable of parameter to values).
26+
#>
27+
[CmdletBinding()]
28+
[OutputType('IntuneScriptLab.CoreOnlyFeature')]
29+
param()
30+
31+
[pscustomobject]@{
32+
PSTypeName = 'IntuneScriptLab.CoreOnlyFeature'
33+
# Switch-Process is not here: PowerShell 7 has it on Linux and macOS only, so a script that
34+
# calls it fails on both Windows hosts. Get-ChildItem -FollowSymlink is not here either:
35+
# Windows PowerShell 5.0 already had it. Both were listed until the test below ran
36+
Commands = [string[]]@(
37+
'Get-Error', 'Join-String', 'Test-Json', 'ConvertFrom-Markdown', 'Get-Uptime', 'Remove-Alias',
38+
'Get-ExperimentalFeature', 'Get-MarkdownOption', 'Show-Markdown', 'ConvertTo-CliXml',
39+
'ConvertFrom-CliXml'
40+
)
41+
Parameters = @{
42+
'ConvertFrom-Json' = 'AsHashtable', 'Depth', 'NoEnumerate', 'DateKind'
43+
'ConvertTo-Json' = 'AsArray', 'EnumsAsStrings', 'EscapeHandling'
44+
'Split-Path' = 'LeafBase', 'Extension'
45+
'Invoke-WebRequest' = 'SkipCertificateCheck', 'SkipHttpErrorCheck', 'Form', 'Resume',
46+
'Authentication', 'Token', 'AllowInsecureRedirect', 'RetryIntervalSec'
47+
'Invoke-RestMethod' = 'SkipCertificateCheck', 'SkipHttpErrorCheck', 'Form', 'Resume',
48+
'StatusCodeVariable', 'Authentication', 'Token', 'AllowInsecureRedirect', 'ResponseHeadersVariable'
49+
'Select-String' = 'Raw', 'Culture', 'NoEmphasis'
50+
'Test-Connection' = 'TargetName', 'TcpPort', 'Ping', 'Traceroute', 'IPv4', 'IPv6', 'Repeat'
51+
'Get-Content' = 'AsByteStream'
52+
'Set-Content' = 'AsByteStream'
53+
'Add-Content' = 'AsByteStream'
54+
'Start-Process' = 'Environment'
55+
'Compress-Archive' = 'PassThru'
56+
'Import-Module' = 'UseWindowsPowerShell', 'SkipEditionCheck'
57+
}
58+
# A parameter both hosts have, with a value only PowerShell 7 accepts
59+
Values = @{
60+
'Out-File' = @{ Encoding = [string[]]@('utf8NoBOM') }
61+
}
62+
}
63+
}

‎Private/Rules/Find-IslPowerShell7Syntax.ps1‎

Lines changed: 7 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -136,10 +136,8 @@ function Find-IslPowerShell7Syntax {
136136
}
137137
}
138138

139-
$coreOnlyCommands = 'Get-Error', 'Join-String', 'Test-Json', 'ConvertFrom-Markdown', 'Get-Uptime',
140-
'Remove-Alias', 'Get-ExperimentalFeature', 'Get-MarkdownOption', 'Show-Markdown', 'Switch-Process',
141-
'ConvertTo-CliXml', 'ConvertFrom-CliXml'
142-
foreach ($command in (Find-IslCommand -Ast $ast -Name $coreOnlyCommands)) {
139+
$coreOnly = Get-IslCoreOnlyFeature
140+
foreach ($command in (Find-IslCommand -Ast $ast -Name $coreOnly.Commands)) {
143141
$findingSplat = @{
144142
RuleName = $rule
145143
Severity = 'Error'
@@ -152,28 +150,9 @@ function Find-IslPowerShell7Syntax {
152150
New-IslFinding @findingSplat
153151
}
154152

155-
$coreOnlyParameters = @{
156-
'ConvertFrom-Json' = 'AsHashtable', 'Depth', 'NoEnumerate', 'DateKind'
157-
'ConvertTo-Json' = 'AsArray', 'EnumsAsStrings', 'EscapeHandling'
158-
'Split-Path' = 'LeafBase', 'Extension'
159-
'Get-ChildItem' = 'FollowSymlink'
160-
'Invoke-WebRequest' = 'SkipCertificateCheck', 'SkipHttpErrorCheck', 'Form', 'Resume',
161-
'StatusCodeVariable', 'Authentication', 'Token', 'AllowInsecureRedirect', 'RetryIntervalSec'
162-
'Invoke-RestMethod' = 'SkipCertificateCheck', 'SkipHttpErrorCheck', 'Form', 'Resume',
163-
'StatusCodeVariable', 'Authentication', 'Token', 'AllowInsecureRedirect', 'ResponseHeadersVariable'
164-
'Select-String' = 'Raw', 'Culture', 'NoEmphasis'
165-
'Test-Connection' = 'TargetName', 'TcpPort', 'Ping', 'Traceroute', 'IPv4', 'IPv6', 'Repeat'
166-
'Get-Content' = 'AsByteStream'
167-
'Set-Content' = 'AsByteStream'
168-
'Add-Content' = 'AsByteStream'
169-
'Start-Process' = 'Environment'
170-
171-
'Compress-Archive' = 'PassThru'
172-
'Import-Module' = 'UseWindowsPowerShell', 'SkipEditionCheck'
173-
}
174-
foreach ($commandName in $coreOnlyParameters.Keys) {
153+
foreach ($commandName in $coreOnly.Parameters.Keys) {
175154
foreach ($command in (Find-IslCommand -Ast $ast -Name $commandName)) {
176-
foreach ($parameter in $coreOnlyParameters[$commandName]) {
155+
foreach ($parameter in $coreOnly.Parameters[$commandName]) {
177156
if (Test-IslCommandParameter -Command $command -ParameterName $parameter) {
178157
$findingSplat = @{
179158
RuleName = $rule
@@ -190,13 +169,9 @@ function Find-IslPowerShell7Syntax {
190169
}
191170
}
192171

193-
# A parameter both hosts have, with a value only PowerShell 7 accepts
194-
$coreOnlyValues = @{
195-
'Out-File' = @{ Encoding = 'utf8NoBOM' }
196-
}
197-
foreach ($commandName in $coreOnlyValues.Keys) {
172+
foreach ($commandName in $coreOnly.Values.Keys) {
198173
foreach ($command in (Find-IslCommand -Ast $ast -Name $commandName)) {
199-
foreach ($parameter in $coreOnlyValues[$commandName].Keys) {
174+
foreach ($parameter in $coreOnly.Values[$commandName].Keys) {
200175
$elements = @($command.CommandElements)
201176
$argument = $null
202177
for ($index = 1; $index -lt $elements.Count -and -not $argument; $index++) {
@@ -207,7 +182,7 @@ function Find-IslPowerShell7Syntax {
207182
elseif ($index + 1 -lt $elements.Count) { $elements[$index + 1] }
208183
}
209184
$isLiteral = $argument -and $argument.GetType().Name -eq 'StringConstantExpressionAst'
210-
if ($isLiteral -and $argument.Value -in $coreOnlyValues[$commandName][$parameter]) {
185+
if ($isLiteral -and $argument.Value -in $coreOnly.Values[$commandName][$parameter]) {
211186
$findingSplat = @{
212187
RuleName = $rule
213188
Severity = 'Error'
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.2.0' }
2+
3+
<#
4+
The table IslPowerShell7Syntax reports from, held against the two hosts themselves: each entry
5+
must be missing from Windows PowerShell 5.1 and present in PowerShell 7. Both hosts are asked
6+
as child processes, so the test gives the same answer whichever one runs it; it is skipped
7+
where either host is missing.
8+
#>
9+
10+
BeforeDiscovery {
11+
$script:BothHosts = [bool](Get-Command powershell.exe -ErrorAction SilentlyContinue) -and
12+
[bool](Get-Command pwsh.exe -ErrorAction SilentlyContinue)
13+
}
14+
15+
BeforeAll {
16+
$script:ModuleRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))
17+
Import-Module (Join-Path $script:ModuleRoot 'IntuneScriptLab.psd1') -Force
18+
$script:Table = InModuleScope IntuneScriptLab { Get-IslCoreOnlyFeature }
19+
20+
# What each host says about the table: for every command whether it exists, for every parameter
21+
# whether its command has it, and for every value whether the parameter's ValidateSet takes it
22+
# (no ValidateSet means the value is not refused there)
23+
$probe = {
24+
param($Json)
25+
$table = $Json | ConvertFrom-Json
26+
$result = @{ Commands = @{}; Parameters = @{}; Values = @{} }
27+
foreach ($name in $table.Commands) {
28+
$result.Commands[$name] = [bool](Get-Command -Name $name -ErrorAction SilentlyContinue)
29+
}
30+
foreach ($entry in $table.Parameters) {
31+
$command = Get-Command -Name $entry.Command -ErrorAction SilentlyContinue
32+
foreach ($parameter in $entry.Parameters) {
33+
$result.Parameters["$($entry.Command) -$parameter"] =
34+
[bool]($command -and $command.Parameters.ContainsKey($parameter))
35+
}
36+
}
37+
foreach ($entry in $table.Values) {
38+
$command = Get-Command -Name $entry.Command -ErrorAction SilentlyContinue
39+
$set = @()
40+
if ($command -and $command.Parameters.ContainsKey($entry.Parameter)) {
41+
$set = @($command.Parameters[$entry.Parameter].Attributes |
42+
Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] } |
43+
ForEach-Object { $_.ValidValues })
44+
}
45+
foreach ($value in $entry.Values) {
46+
$result.Values["$($entry.Command) -$($entry.Parameter) $value"] =
47+
if ($set.Count) { [bool]($set -contains $value) } else { [bool]$command }
48+
}
49+
}
50+
$result | ConvertTo-Json -Depth 4 -Compress
51+
}
52+
$flat = [pscustomobject]@{
53+
Commands = @($script:Table.Commands)
54+
Parameters = @(foreach ($command in $script:Table.Parameters.Keys) {
55+
@{ Command = $command; Parameters = @($script:Table.Parameters[$command]) }
56+
}) + @(@{ Command = 'ForEach-Object'; Parameters = @('Parallel') })
57+
Values = @(foreach ($command in $script:Table.Values.Keys) {
58+
foreach ($parameter in $script:Table.Values[$command].Keys) {
59+
$values = @($script:Table.Values[$command][$parameter])
60+
@{ Command = $command; Parameter = $parameter; Values = $values }
61+
}
62+
})
63+
}
64+
$json = $flat | ConvertTo-Json -Depth 5 -Compress
65+
$command = "& { $($probe.ToString()) } '$($json.Replace("'", "''"))'"
66+
$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command))
67+
function script:Get-HostAnswer {
68+
param([string]$Exe, [string]$Command)
69+
$raw = & $Exe -NoProfile -NonInteractive -EncodedCommand $Command 2>&1
70+
($raw | Where-Object { "$_".StartsWith('{') } | Select-Object -Last 1) | ConvertFrom-Json
71+
}
72+
# Discovery-time variables do not reach the run, so the lookup is repeated here
73+
$script:BothHosts = [bool](Get-Command powershell.exe -ErrorAction SilentlyContinue) -and
74+
[bool](Get-Command pwsh.exe -ErrorAction SilentlyContinue)
75+
if ($script:BothHosts) {
76+
$script:Desktop = Get-HostAnswer 'powershell.exe' $encoded
77+
$script:Core = Get-HostAnswer 'pwsh.exe' $encoded
78+
}
79+
}
80+
81+
AfterAll {
82+
Remove-Module IntuneScriptLab -Force -ErrorAction SilentlyContinue
83+
}
84+
85+
Describe 'Get-IslCoreOnlyFeature' -Tag 'Unit', 'Private' {
86+
87+
It 'returns the three tables' {
88+
$script:Table.PSObject.TypeNames | Should-ContainCollection 'IntuneScriptLab.CoreOnlyFeature'
89+
$script:Table.Commands.Count | Should-BeGreaterThan 5
90+
$script:Table.Parameters.Keys.Count | Should-BeGreaterThan 5
91+
$script:Table.Values['Out-File'].Encoding | Should-ContainCollection 'utf8NoBOM'
92+
}
93+
94+
Context 'Against both hosts' -Skip:(-not $script:BothHosts) {
95+
It 'every listed command is missing from Windows PowerShell 5.1 and present in PowerShell 7' {
96+
$wrong = @(foreach ($name in $script:Table.Commands) {
97+
if ($script:Desktop.Commands.$name) { "$name exists in 5.1" }
98+
if (-not $script:Core.Commands.$name) { "$name is missing from 7" }
99+
})
100+
$wrong | Should-BeCollection @()
101+
}
102+
103+
It 'every listed parameter is missing from the 5.1 command and present on the 7 one' {
104+
$keys = @($script:Core.Parameters.PSObject.Properties.Name)
105+
$keys.Count | Should-BeGreaterThan 30
106+
$wrong = @(foreach ($key in $keys) {
107+
if ($script:Desktop.Parameters.$key) { "$key exists in 5.1" }
108+
if (-not $script:Core.Parameters.$key) { "$key is missing from 7" }
109+
})
110+
$wrong | Should-BeCollection @()
111+
}
112+
113+
It 'every listed value is refused by the 5.1 parameter and taken by the 7 one' {
114+
$keys = @($script:Core.Values.PSObject.Properties.Name)
115+
$keys.Count | Should-BeGreaterThan 0
116+
$wrong = @(foreach ($key in $keys) {
117+
if ($script:Desktop.Values.$key) { "$key is accepted by 5.1" }
118+
if (-not $script:Core.Values.$key) { "$key is refused by 7" }
119+
})
120+
$wrong | Should-BeCollection @()
121+
}
122+
}
123+
}

0 commit comments

Comments
 (0)