Private/Rules/Find-IslModuleDependency.ps1

function Find-IslModuleDependency {
    <#
    .SYNOPSIS
        Flags modules a script needs that the agent's SYSTEM session will not have.
 
    .DESCRIPTION
        Under the agent a script runs as SYSTEM in Windows PowerShell 5.1 with a module path of the
        systemprofile's Documents folder, Program Files\WindowsPowerShell\Modules and the System32
        modules (REM-PSMODULEPATH). A module installed for a user, or one that comes with RSAT, Azure
        or the Graph SDK, is not there unless it was installed machine-wide. Import-Module,
        #Requires -Modules and using module that name a module outside the in-box list
        (Get-IslInboxModule) are reported, as is installing one from the gallery inside the script,
        which depends on the NuGet provider and gallery reach in that session (REM-INSTALL-MODULE).
 
    .PARAMETER Context
        The IntuneScriptLab.ScriptContext from Get-IslScriptContext: AST, tokens, bytes and the
        effective ScriptType, Context and Architecture.
 
    .EXAMPLE
        Find-IslModuleDependency -Context (Get-IslScriptContext -Path .\Detect.ps1)
 
        The findings this rule produces for one script, as IntuneScriptLab.Finding objects.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [pscustomobject]$Context
    )

    $rule = 'IslModuleDependency'
    $inbox = Get-IslInboxModule
    $pathEvidence = ('SYSTEM''s PSModulePath under the agent is the systemprofile Documents folder, Program ' +
        'Files\WindowsPowerShell\Modules and System32\WindowsPowerShell\v1.0\Modules; 90 modules were available ' +
        'on a plain Windows 11 device, none of them user-installed (REM-PSMODULEPATH)')
    $installEvidence = ('Find-Module and Install-Module in a SYSTEM detection script never returned: no NuGet ' +
        'provider on the device, the process stuck on the provider prompt with 12 s of CPU in 20 minutes, ' +
        'killed at the 60-minute timeout (detectionState scriptError) while every remediation queued behind ' +
        'it waited (REM-INSTALL-MODULE)')

    function Test-InBox {
        param([string]$Name)
        # A path is a file the script ships or references, not a module by name; leave it to the
        # relative-path rule
        if ($Name -match '[\\/]' -or $Name -match '\.psm?1$|\.psd1$') { return $true }
        if ($Name -match '[\*\?\$]') { return $true }
        $Name -in $inbox
    }

    function Write-Missing {
        param([string]$Name, $Extent, [string]$How)
        $findingSplat = @{
            RuleName = $rule
            Severity = 'Warning'
            Context  = $Context
            Extent   = $Extent
            Message  = ("$How names $Name, which is not in-box under the agent's SYSTEM session. It must be " +
                'installed machine-wide (Program Files\WindowsPowerShell\Modules) before the script runs; a ' +
                '#Requires for a missing module stops the script before it starts (exit 1, ' +
                'ScriptRequiresMissingModules) and a detection failing that way still runs the remediation')
            Evidence = $pathEvidence + '; a #Requires -Modules for a module the device lacks exited 1 without ' +
                'running, the remediation ran anyway, and Graph reported detectionState fail, remediationState ' +
                'remediationFailed (REM-REQUIRES-MODULE)'
        }
        New-IslFinding @findingSplat
    }

    foreach ($required in @($Context.Ast.ScriptRequirements.RequiredModules | Where-Object { $_ })) {
        if (-not (Test-InBox -Name "$($required.Name)")) {
            Write-Missing -Name $required.Name -Extent $Context.Ast.Extent -How '#Requires -Modules'
        }
    }
    foreach ($using in (Find-IslAstNode -Ast $Context.Ast -TypeName UsingStatementAst -Where {
                param($node) "$($node.UsingStatementKind)" -eq 'Module'
            })) {
        $name = if ($using.Name) { $using.Name.Value } else { "$($using.ModuleSpecification.Extent.Text)" }
        if (-not (Test-InBox -Name $name)) { Write-Missing -Name $name -Extent $using.Extent -How 'using module' }
    }
    foreach ($command in (Find-IslCommand -Ast $Context.Ast -Name 'Import-Module', 'ipmo')) {
        $elements = $command.CommandElements
        $names = for ($i = 1; $i -lt $elements.Count; $i++) {
            $element = $elements[$i]
            if ($element.GetType().Name -eq 'CommandParameterAst') {
                $isName = 'Name'.StartsWith($element.ParameterName, 'OrdinalIgnoreCase')
                if ($isName -and $i + 1 -lt $elements.Count) {
                    $i++
                    $elements[$i]
                }
                continue
            }
            if ($i -eq 1) { $element }
        }
        foreach ($nameElement in @($names)) {
            $literals = if ($nameElement.GetType().Name -eq 'ArrayLiteralAst') { $nameElement.Elements }
            else { @($nameElement) }
            foreach ($literal in $literals) {
                if ($literal.GetType().Name -ne 'StringConstantExpressionAst') { continue }
                if (-not (Test-InBox -Name $literal.Value)) {
                    Write-Missing -Name $literal.Value -Extent $command.Extent -How 'Import-Module'
                }
            }
        }
    }

    $installers = 'Install-Module', 'Install-PSResource', 'Install-Package', 'Save-Module', 'Update-Module'
    foreach ($command in (Find-IslCommand -Ast $Context.Ast -Name $installers)) {
        $findingSplat = @{
            RuleName = $rule
            Severity = 'Warning'
            Context  = $Context
            Extent   = $command.Extent
            Message  = ("$($command.GetCommandName()) inside the script installs from the gallery as SYSTEM on " +
                'every device that runs it. Without the NuGet provider it waits on a prompt nobody can answer ' +
                'until the 60-minute timeout, and every other remediation on the device waits behind it. Ship ' +
                'the module with the content or install it once with a separate policy')
            Evidence = $installEvidence
        }
        New-IslFinding @findingSplat
    }
}