From 775012f9d85ec126575ac5fb761468c8ee3807d0 Mon Sep 17 00:00:00 2001 From: fadwen <110697945+fadwen@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:40:47 -0700 Subject: [PATCH 1/2] test(rules): the in-box module table is held against the Windows client the tests run on Get-IslInboxModule is a hand-written list captured as SYSTEM on the lab device, and nothing checked it afterwards: a misspelt name, or a module Windows drops, would have made IslModuleDependency wrong without a test noticing, the way the PowerShell 7-only tables were before they got a test of their own. The new test asks the host's Windows PowerShell, as a child process, for the modules under the two system module paths a SYSTEM session reads (REM-PSMODULEPATH). Every listed module must be there, except the engine module, which has no folder, and the seven a Home edition does not ship; and nothing may be there that the table or the test does not account for. The test runs on a Windows client (ProductType 1) and is skipped on a server, whose set differs. Measured for this change: the table re-captured as SYSTEM on VM 125 on 2026-10-06 (Windows 11 Enterprise LTSC 24H2, build 26100) is the same 90 modules. A Windows 11 Home ARM64 24H2 host has 84 of them, lacking AppLocker, AppvClient, AssignedAccess, BranchCache, ConfigCI, iSCSI and UEV, and has HostNetworkingService besides. Import-Module of Microsoft.PowerShell.Core by name fails on both hosts although its commands are there; the help says so. The table is unchanged. --- CHANGELOG.md | 7 ++ Private/Get-IslInboxModule.ps1 | 15 ++- .../Unit/Private/Get-IslInboxModule.Tests.ps1 | 99 +++++++++++++++++++ Validation/Findings.md | 2 +- 4 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 Tests/Unit/Private/Get-IslInboxModule.Tests.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 110171a..f9ff68c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,13 @@ release notes. `'AMD64'` compared with `-eq`, `$PWD.Path`. The `#Requires` finding now sits on its line rather than on the whole script. +### Changed + +- The in-box module table `IslModuleDependency` reports from is held against the Windows client the tests run + on: every listed module must be under the host's system module paths, except the engine module and the seven + a Home edition lacks, and nothing may be there that the table or the test does not account for. The table + was re-captured as SYSTEM on the lab device on 2026-10-06 and is unchanged. + ### Fixed - **`Test-IntuneDeployedScript` decided whether a group holds devices from its first 20 members.** A mixed group diff --git a/Private/Get-IslInboxModule.ps1 b/Private/Get-IslInboxModule.ps1 index a681f10..26c4c9e 100644 --- a/Private/Get-IslInboxModule.ps1 +++ b/Private/Get-IslInboxModule.ps1 @@ -5,9 +5,18 @@ function Get-IslInboxModule { .DESCRIPTION Get-Module -ListAvailable as NT AUTHORITY\SYSTEM on an Entra-joined Windows 11 24H2 test - device with nothing installed beyond Windows itself (VM 125, 2026-09-28): the list a script - can import under the agent without installing anything first. Feature-on-demand modules - (RSAT, Hyper-V, containers) are absent on purpose, since a device may or may not have them. + device with nothing installed beyond Windows itself (VM 125, 2026-09-28, the same 90 again + on 2026-10-06 under Windows 11 Enterprise LTSC 24H2): the list a script can import under + the agent without installing anything first. Feature-on-demand modules (RSAT, Hyper-V, + containers) are absent on purpose, since a device may or may not have them. + + Microsoft.PowerShell.Core is listed although Get-Module -ListAvailable never shows it: the + engine's own module, whose commands every session has. Import-Module of it by name fails on + both hosts ("no valid module file was found", 2026-10-06), which is a script's mistake of + another kind than a missing module. A Home edition lacks seven of the others + (AppLocker, AppvClient, AssignedAccess, BranchCache, ConfigCI, iSCSI, UEV), and a Windows 11 + Home ARM64 host has HostNetworkingService in addition; the unit test holds the table against + the host it runs on with those allowances. .EXAMPLE 'ActiveDirectory' -in (Get-IslInboxModule) diff --git a/Tests/Unit/Private/Get-IslInboxModule.Tests.ps1 b/Tests/Unit/Private/Get-IslInboxModule.Tests.ps1 new file mode 100644 index 0000000..fbcc9c8 --- /dev/null +++ b/Tests/Unit/Private/Get-IslInboxModule.Tests.ps1 @@ -0,0 +1,99 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.2.0' } + +<# + The in-box module table IslModuleDependency reports from, held against a Windows client's own + Windows PowerShell: every listed module is there under the system module paths, except the + engine module and the ones a Home edition does not ship, and nothing is there that the table + does not name or this file does not account for. The host is asked as a child process, so the + test gives the same answer whichever host runs it; a server has a different set and is skipped. +#> + +BeforeDiscovery { + $script:ClientHost = $false + if (Get-Command powershell.exe -ErrorAction SilentlyContinue) { + # ProductType 1 is a workstation; the table describes a client, and a server's set differs + $os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction SilentlyContinue + $script:ClientHost = [int]$os.ProductType -eq 1 + } +} + +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-IslInboxModule }) + + # Listed, and rightly, but not a folder Get-Module -ListAvailable finds: the engine's own module + $script:EngineModule = 'Microsoft.PowerShell.Core' + # Pro and Enterprise features a Home edition does not ship (Windows 11 Home 24H2, ARM64) + $script:EditionOnly = 'AppLocker', 'AppvClient', 'AssignedAccess', 'BranchCache', 'ConfigCI', 'iSCSI', 'UEV' + # Present on a Windows 11 Home ARM64 24H2 host and absent from the Enterprise x64 device the + # table was captured on; neither is a plain device every script can count on + $script:SeenElsewhere = 'HostNetworkingService' + + # Discovery-time variables do not reach the run, so the lookup is repeated here + $script:ClientHost = $false + if (Get-Command powershell.exe -ErrorAction SilentlyContinue) { + $os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction SilentlyContinue + $script:ClientHost = [int]$os.ProductType -eq 1 + } + if ($script:ClientHost) { + # What the host's Windows PowerShell has under the two system module paths, the ones a + # SYSTEM session under the agent reads (REM-PSMODULEPATH), and whether the engine module loads + $probe = { + $system = @("$env:SystemRoot\System32\WindowsPowerShell\v1.0\Modules", + "$env:ProgramFiles\WindowsPowerShell\Modules") + $names = Get-Module -ListAvailable | Where-Object { + $module = $_ + $system | Where-Object { $module.ModuleBase.StartsWith($_, 'OrdinalIgnoreCase') } + } | Select-Object -ExpandProperty Name -Unique | Sort-Object -Unique + # The engine module has no folder and Import-Module of it fails; its commands are there + $engine = (Get-Command -Name Get-Command).ModuleName + @{ Modules = @($names); Engine = $engine } | ConvertTo-Json -Compress + } + $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($probe.ToString())) + $raw = & powershell.exe -NoProfile -NonInteractive -EncodedCommand $encoded 2>&1 + $answer = ($raw | Where-Object { "$_".StartsWith('{') } | Select-Object -Last 1) | ConvertFrom-Json + $script:HostModules = @($answer.Modules) + $script:HostEngineModule = "$($answer.Engine)" + } +} + +AfterAll { + Remove-Module IntuneScriptLab -Force -ErrorAction SilentlyContinue +} + +Describe 'Get-IslInboxModule' -Tag 'Unit', 'Private' { + + It 'names each module once' { + $script:Table.Count | Should-BeGreaterThan 80 + @($script:Table | Sort-Object -Unique).Count | Should-Be $script:Table.Count + $script:Table | Should-ContainCollection 'Microsoft.PowerShell.Utility' + $script:Table | Should-ContainCollection 'ScheduledTasks' + $script:Table | Should-NotContainCollection 'ActiveDirectory' + } + + Context 'Against this Windows client' -Skip:(-not $script:ClientHost) { + It 'has every listed module under the system module paths, or is a Home edition without the feature' { + $script:HostModules.Count | Should-BeGreaterThan 50 + $missing = @($script:Table | Where-Object { + $_ -ne $script:EngineModule -and $_ -notin $script:EditionOnly -and + $_ -notin $script:HostModules + }) + $missing | Should-BeCollection @() + } + + It 'has the engine module''s commands without a module folder for it' { + $script:HostModules | Should-NotContainCollection $script:EngineModule + $script:HostEngineModule | Should-Be $script:EngineModule + } + + It 'has nothing under the system module paths that the table or this file does not account for' { + # A module here that is in neither list is either new to Windows, in which case the + # table is behind, or a feature of this host, in which case it goes in SeenElsewhere + $unlisted = @($script:HostModules | Where-Object { + $_ -notin $script:Table -and $_ -notin $script:SeenElsewhere + }) + $unlisted | Should-BeCollection @() + } + } +} diff --git a/Validation/Findings.md b/Validation/Findings.md index e8b1573..32db546 100644 --- a/Validation/Findings.md +++ b/Validation/Findings.md @@ -286,7 +286,7 @@ device, the agent restarted once to fetch them. | Rule | Documented | Observed | | |---|---|---|---| | Execution policy | Scripts run regardless of the device's execution policy (PS) | AgentExecutor launches every script as `powershell.exe -NoProfile -executionPolicy bypass -file