'PowerShell, auto load functions from internet on demand

It was pointed out to me (in PowerShell, replicate bash parallel ping) that I can load a function from the internet as follows:

iex (irm https://raw.githubusercontent.com/proxb/AsyncFunctions/master/Test-ConnectionAsync.ps1)

The url referenced Test-ConnectionAsync.ps1 contains two functions: Ping-Subnet and Test-ConnectionAsync

This made me wonder if I could then define bypass functions in my personal module that are dummy functions that will be permanently overridden as soon as they are invoked. e.g.

function Ping-Subnet <mimic the switches of the function to be loaded> {
    if <function is not already loaded from internet> {
        iex (irm https://raw.githubusercontent.com/proxb/AsyncFunctions/master/Test-ConnectionAsync.ps1)
    }
    # Now, somehow, permanently overwrite Ping-Subnet to be the function that loaded from the URL
    Ping-Subnet <pass the switches that we mimicked to the required function that we have just loaded>
}

This would very simply allow me to reference a number of useful scripts directly from my module but without having to load them all from the internet upon loading the Module (i.e. the functions are only loaded on demand, when I invoke them, and I will often never invoke the functions unless I need them).



Solution 1:[1]

function Ping-Subnet{
    $toImport = (IRM "https://raw.githubusercontent.com/proxb/AsyncFunctions/master/Test-ConnectionAsync.ps1").
                Replace([Text.Encoding]::UTF8.GetString((239,187,191)),"")
    NMO([ScriptBlock]::Create($toImport))|Out-Null
    $MyInvocation.Line|IEX
}
function Test-ConnectionAsync{
    $toImport = (IRM "https://raw.githubusercontent.com/proxb/AsyncFunctions/master/Test-ConnectionAsync.ps1").
                Replace([Text.Encoding]::UTF8.GetString((239,187,191)),"")
    NMO([ScriptBlock]::Create($toImport))|Out-Null
    $MyInvocation.Line|IEX
}

Ping-Subnet -Result Success

Test-ConnectionAsync -Computername $env:COMPUTERNAME

Result:

Computername   Result
------------   ------
192.168.1.1   Success
192.168.1.2   Success
192.168.1.146 Success

Computername IPAddress                  Result
------------ ---------                  ------
HOME-PC      fe80::123:1234:ABCD:EF12  Success

Solution 2:[2]

You could use the Parser to find the functions in the remote script and load them into your scope. This will not be a self-updating function, but should be safer than what you're trying to accomplish.

using namespace System.Management.Automation.Language

function Load-Function {
    [cmdletbinding()]
    param(
        [parameter(Mandatory, ValueFromPipeline)]
        [uri] $URI
    )

    process {
        try {
            $funcs = Invoke-RestMethod $URI
            $ast = [Parser]::ParseInput($funcs, [ref] $null, [ref] $null)
            foreach($func in $ast.FindAll({ $args[0] -is [FunctionDefinitionAst] }, $true)) {
                if($func.Name -in (Get-Command -CommandType Function).Name) {
                    Write-Warning "$($func.Name) is already loaded! Skipping"
                    continue
                }
                New-Item -Name "script:$($func.Name)" -Path function: -Value $func.Body.GetScriptBlock()
            }
        }
        catch {
            Write-Warning $_.Exception.Message
        }
    }
}

Load-Function https://raw.githubusercontent.com/proxb/AsyncFunctions/master/Test-ConnectionAsync.ps1
Ping-Subnet # => now is available in your current session.

Solution 3:[3]

Yes, it should work. Calling Test-ConnectionAsync.ps1 from with-in a function will create the functions defined with-in, in the wrapping function's scope. You will be able to call any wrapped functions until the function's scope ends.

enter image description here

If you name the wrapper and wrapped functions differently, you can check whether the function has been declared with something like...

enter image description here

Otherwise, you need to get more creative.

This said, PROCEED WITH CAUTION. Remote code execution, like this, is fraught with security issues, especially in the way we're talking about it i.e., no validation of Test-ConnectionAsync.ps1.

Solution 4:[4]

Fors1k's answer deserves the credit for coming up with the clever fundamentals of the approach:

  • Download and execute the remote script's content in a dynamic module created with
    New-Module (whose built-in alias is nmo), which causes the script's functions to be auto-exported and to become available session-globally[1]

    • Note that dynamic modules aren't easy to discover, because they're not shown in
      Get-Module's output; however, you can discover them indirectly, via the .Source property of the command-info objects output by Get-Command:

      Get-Command | Where Source -like __DynamicModule_*
      
    • That the downloaded functions become available session-globally may be undesired if you're trying to use the technique inside a script that shouldn't affect the session's global state - see the bottom section for a solution.

  • Then re-invoke the function, under the assumption that the original stub function has been replaced with the downloaded version of the same name, passing the received arguments through.

While Fors1k's solution will typically work, here is a streamlined, robust alternative that prevents potential, inadvertent re-execution of code:

function Ping-Subnet{
  $uri = 'https://raw.githubusercontent.com/proxb/AsyncFunctions/master/Test-ConnectionAsync.ps1'
  # Define and session-globally import a dynamic module based on the remote
  # script's content.
  # Any functions defined in the script would automatically be exported.
  # However, unlike with persisted modules, *aliases* are *not* exported by 
  # default, which the appended Export-ModuleMember call below compensates for.
  # If desired, also add -Variable * in order to export variables too.
  # Conversely, if you only care about functions, remove the Export-ModuleMember call.
  $dynMod = New-Module ([scriptblock]::Create(
    ((Invoke-RestMethod $uri)) + "`nExport-ModuleMember -Function * -Alias *")
  )
  # If this stub function shadows the newly defined function in the dynamic
  # module, remove it first, so that re-invocation by name uses the new function.
  # Note: This happens if this stub function is run in a child scope, such as
  #       in a (non-dot-sourced) script rather than in the global scope.
  #       If run in the global scope, curiously, the stub function seemingly
  #       disappears from view right away - not even Get-Command -All shows it later.
  $myName = $MyInvocation.MyCommand.Name
  if ((Get-Command -Type Function $myName).ModuleName -ne $dynMod.Name) {
    Remove-Item -LiteralPath "function:$myName"
  }
  # Now invoke the newly defined function of the same name, passing the arguments
  # through.
  & $myName @args
}

Specifically, this implementation ensures:

  • That aliases defined in the remote script are exported as well (just remove + "`nExport-ModuleMember -Function * -Alias *" from the code above if that is undesired.

  • That the re-invocation robustly targets the new, module-defined implementation of the function - even if the stub function runs in a child scope, such as in a (non-dot-sourced) script.

    • When run in a child scope, $MyInvocation.Line|IEX (iex is a built-in alias of the Invoke-Expression cmdlet) would result in an infinite loop, because the stub function itself is still in effect at that time.
  • That all received arguments are passed through on re-invocation without re-evaluation.

    • Using the built-in magic of splatting the automatic $args variable (@args) passes only the received, already expanded arguments through, supporting both named and positional arguments.[2]

    • $MyInvocation.Line|IEX has two potential problems:

      • If the invoking command line contained multiple commands, they are all repeated.

        • You can solve this particular problem by substituting (Get-PSCallStack)[1].Position.Text for $MyInvocation.Line, but that still wouldn't address the next problem.
      • Both $MyInvocation.Line and (Get-PSCallStack)[1].Position.Text contain the arguments that were passed in unexpanded (unevaluated) form, which causes their re-evaluation by Invoke-Expression, and the perils of that are that, at least hypothetically, this re-evaluation could involve lengthy commands whose output served as arguments or, worse, commands that had side effects that cannot or should not be repeated.


Scoping the technique to a given local script:

That the downloaded functions become available session-globally may be undesired if you're trying to use the technique inside a script that shouldn't affect the session's global state; that is, you may want the functions exported via the dynamic module to disappear when the script exits.

This requires two extra steps:

  • Piping the dynamic module to Import-Module, which is the prerequisite for being able to unload it before exiting with Remove-Module

  • Calling Remove-Module with the dynamic module before exiting in order to unload it.

function Ping-Subnet{
  $uri = 'https://raw.githubusercontent.com/proxb/AsyncFunctions/master/Test-ConnectionAsync.ps1'
  # Save the module in a script-level variable, and pipe it to Import-Module
  # so that it can be removed before the script exits.
  $script:dynMod = New-Module ([scriptblock]::Create(
    ((Invoke-RestMethod $uri)) + "`nExport-ModuleMember -Function * -Alias *")
  ) | Import-Module -PassThru
  # If this stub function shadows the newly defined function in the dynamic
  # module, remove it first, so that re-invocation by name use the new function.
  # Note: This happens if this stub function is run in a child scope, such as
  #       in a (non-dot-sourced) script rather than in the global scope.
  #       If run in the global scope, curiously, the stub function seemingly
  #       disappears from view right away - not even Get-Command -All shows it later.
  $myName = $MyInvocation.MyCommand.Name
  if ((Get-Command -Type Function $myName).ModuleName -ne $dynMod.Name) {
    Remove-Item -LiteralPath "function:$myName"
  }
  # Now invoke the newly defined function of the same name, passing the arguments
  # through.
  & $myName @args
}

# Sample commands to perform in the script.
Ping-Subnet -?
Get-Command Ping-Subnet, Test-ConnectionAsync | Format-Table

# Before exiting, remove (unload) the dynamic module.
$dynMod | Remove-Module

[1] This assumes that the New-Module call itself is made outside of a module; if it is made inside a module, at least that module's commands see the auto-exported functions; if that module uses implicit exporting behavior (which is rare and not advisable), the auto-exported functions from the dynamic module would be included in that module's exports and therefore again become available session-globally.

[2] This magic has one limitation, which, however, will only rarely surface: [switch] parameters with a directly attached Boolean argument aren't supported (e.g., -CaseSensitive:$true) - see this answer.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 mklement0
Solution 2
Solution 3 Adam
Solution 4