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
46 changes: 45 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
48 changes: 48 additions & 0 deletions Private/Get-IslDesktopModulePath.ps1
Original file line number Diff line number Diff line change
@@ -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
}
4 changes: 2 additions & 2 deletions Private/Get-IslSetting.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion Private/Invoke-IslProcess.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 9 additions & 5 deletions Private/Rules/Find-IslContextIssue.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 })) {
Expand Down Expand Up @@ -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
}
Expand Down
22 changes: 20 additions & 2 deletions Private/Rules/Find-IslExitCodeIssue.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
44 changes: 42 additions & 2 deletions Private/Rules/Find-IslInteractiveCall.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading