Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
63 changes: 63 additions & 0 deletions Private/Get-IslCoreOnlyFeature.ps1
Original file line number Diff line number Diff line change
@@ -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[]]@(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typed, here and for the Out-File value below, so a table cut down to one entry still serializes as an array when the test sends it to the hosts as JSON; a lone string would arrive as a scalar and the probe would iterate its characters.

'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') }
}
}
}
39 changes: 7 additions & 32 deletions Private/Rules/Find-IslPowerShell7Syntax.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand All @@ -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++) {
Expand All @@ -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'
Expand Down
123 changes: 123 additions & 0 deletions Tests/Unit/Private/Get-IslCoreOnlyFeature.Tests.ps1
Original file line number Diff line number Diff line change
@@ -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') })

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ForEach-Object -Parallel is not in the table because the rule handles it on its own path, with the REM-PS7-PARALLEL evidence. Added to the probe here so it is held against the hosts all the same.

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("'", "''"))'"

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The table travels as a single-quoted literal argument inside the encoded command, so no JSON quoting crosses the command line, and -EncodedCommand carries the same bytes to both hosts.

$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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Either host can print lines of its own before the answer: module import noise, or a stderr record merged in by 2>&1. The answer is the last line that starts with a brace.

}
# 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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A guard that the probe answered at all. Without it a probe that failed to read the table would return no keys, and the three assertions would pass on an empty set.

$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 @()
}
}
}
Loading