Starting .ps1 Script from PowerShell with Parameters and Credentials and getting output using variable
Start-Process
would be my last resort choice for invoking PowerShell from PowerShell - especially because all I/O becomes strings and not (deserialized) objects.
Two alternatives:
1. If the user is a local admin and PSRemoting is configured
If a remote session against the local machine (unfortunately restricted to local admins) is a option, I'd definitely go with Invoke-Command
:
$strings = Invoke-Command -FilePath C:\...\script1.ps1 -ComputerName localhost -Credential $credential
$strings
will contain the results.
2. If the user is not an admin on the target system
You can write your own "local-only Invoke-Command
" by spinning up an out-of-process runspace by:
- Creating a
PowerShellProcessInstance
, under a different login - Creating a runspace in said process
- Execute your code in said out-of-process runspace
I put together such a function below, see inline comments for a walk-through:
function Invoke-RunAs
{
[CmdletBinding()]
param(
[Alias('PSPath')]
[ValidateScript({Test-Path $_ -PathType Leaf})]
[Parameter(Position = 0, Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
[string]
${FilePath},
[Parameter(Mandatory = $true)]
[pscredential]
[System.Management.Automation.CredentialAttribute()]
${Credential},
[Alias('Args')]
[Parameter(ValueFromRemainingArguments = $true)]
[System.Object[]]
${ArgumentList},
[Parameter(Position = 1)]
[System.Collections.IDictionary]
$NamedArguments
)
begin
{
# First we set up a separate managed powershell process
Write-Verbose "Creating PowerShellProcessInstance and runspace"
$ProcessInstance = [System.Management.Automation.Runspaces.PowerShellProcessInstance]::new($PSVersionTable.PSVersion, $Credential, $null, $false)
# And then we create a new runspace in said process
$Runspace = [runspacefactory]::CreateOutOfProcessRunspace($null, $ProcessInstance)
$Runspace.Open()
Write-Verbose "Runspace state is $($Runspace.RunspaceStateInfo)"
}
process
{
foreach($path in $FilePath){
Write-Verbose "In process block, Path:'$path'"
try{
# Add script file to the code we'll be running
$powershell = [powershell]::Create([initialsessionstate]::CreateDefault2()).AddCommand((Resolve-Path $path).ProviderPath, $true)
# Add named param args, if any
if($PSBoundParameters.ContainsKey('NamedArguments')){
Write-Verbose "Adding named arguments to script"
$powershell = $powershell.AddParameters($NamedArguments)
}
# Add argument list values if present
if($PSBoundParameters.ContainsKey('ArgumentList')){
Write-Verbose "Adding unnamed arguments to script"
foreach($arg in $ArgumentList){
$powershell = $powershell.AddArgument($arg)
}
}
# Attach to out-of-process runspace
$powershell.Runspace = $Runspace
# Invoke, let output bubble up to caller
$powershell.Invoke()
if($powershell.HadErrors){
foreach($e in $powershell.Streams.Error){
Write-Error $e
}
}
}
finally{
# clean up
if($powershell -is [IDisposable]){
$powershell.Dispose()
}
}
}
}
end
{
foreach($target in $ProcessInstance,$Runspace){
# clean up
if($target -is [IDisposable]){
$target.Dispose()
}
}
}
}
Then use like so:
$output = Invoke-RunAs -FilePath C:\path\to\script1.ps1 -Credential $targetUser -NamedArguments @{ClientDevice = "ClientName"}
Note:
The following solution works with any external program, and captures output invariably as text.
To invoke another PowerShell instance and capture its output as rich objects (with limitations), see the variant solution in the bottom section or consider Mathias R. Jessen's helpful answer, which uses the PowerShell SDK.
Here's a proof-of-concept based on direct use of the System.Diagnostics.Process
and System.Diagnostics.ProcessStartInfo
.NET types to capture process output in memory (as stated in your question, Start-Process
is not an option, because it only supports capturing output in files, as shown in this answer):
Note:
Due to running as a different user, this is supported on Windows only (as of .NET Core 3.1), but in both PowerShell editions there.
Due to needing to run as a different user and needing to capture output,
.WindowStyle
cannot be used to run the command hidden (because using.WindowStyle
requires.UseShellExecute
to be$true
, which is incompatible with these requirements); however, since all output is being captured, setting.CreateNoNewWindow
to$true
effectively results in hidden execution.
# Get the target user's name and password.
$cred = Get-Credential
# Create a ProcessStartInfo instance
# with the relevant properties.
$psi = [System.Diagnostics.ProcessStartInfo] @{
# For demo purposes, use a simple `cmd.exe` command that echoes the username.
# See the bottom section for a call to `powershell.exe`.
FileName = 'cmd.exe'
Arguments = '/c echo %USERNAME%'
# Set this to a directory that the target user
# is permitted to access.
WorkingDirectory = 'C:\' #'
# Ask that output be captured in the
# .StandardOutput / .StandardError properties of
# the Process object created later.
UseShellExecute = $false # must be $false
RedirectStandardOutput = $true
RedirectStandardError = $true
# Uncomment this line if you want the process to run effectively hidden.
# CreateNoNewWindow = $true
# Specify the user identity.
# Note: If you specify a UPN in .UserName
# ([email protected]), set .Domain to $null
Domain = $env:USERDOMAIN
UserName = $cred.UserName
Password = $cred.Password
}
# Create (launch) the process...
$ps = [System.Diagnostics.Process]::Start($psi)
# Read the captured standard output.
# By reading to the *end*, this implicitly waits for (near) termination
# of the process.
# Do NOT use $ps.WaitForExit() first, as that can result in a deadlock.
$stdout = $ps.StandardOutput.ReadToEnd()
# Uncomment the following lines to report the process' exit code.
# $ps.WaitForExit()
# "Process exit code: $($ps.ExitCode)"
"Running ``cmd /c echo %USERNAME%`` as user $($cred.UserName) yielded:"
$stdout
The above yields something like the following, showing that the process successfully ran with the given user identity:
Running `cmd /c echo %USERNAME%` as user jdoe yielded:
jdoe
Since you're calling another PowerShell instance, you may want to take advantage of the PowerShell CLI's ability to represent output in CLIXML format, which allows deserializing the output into rich objects, albeit with limited type fidelity, as explained in this related answer.
# Get the target user's name and password.
$cred = Get-Credential
# Create a ProcessStartInfo instance
# with the relevant properties.
$psi = [System.Diagnostics.ProcessStartInfo] @{
# Invoke the PowerShell CLI with a simple sample command
# that calls `Get-Date` to output the current date as a [datetime] instance.
FileName = 'powershell.exe'
# `-of xml` asks that the output be returned as CLIXML,
# a serialization format that allows deserialization into
# rich objects.
Arguments = '-of xml -noprofile -c Get-Date'
# Set this to a directory that the target user
# is permitted to access.
WorkingDirectory = 'C:\' #'
# Ask that output be captured in the
# .StandardOutput / .StandardError properties of
# the Process object created later.
UseShellExecute = $false # must be $false
RedirectStandardOutput = $true
RedirectStandardError = $true
# Uncomment this line if you want the process to run effectively hidden.
# CreateNoNewWindow = $true
# Specify the user identity.
# Note: If you specify a UPN in .UserName
# ([email protected]), set .Domain to $null
Domain = $env:USERDOMAIN
UserName = $cred.UserName
Password = $cred.Password
}
# Create (launch) the process...
$ps = [System.Diagnostics.Process]::Start($psi)
# Read the captured standard output, in CLIXML format,
# stripping the `#` comment line at the top (`#< CLIXML`)
# which the deserializer doesn't know how to handle.
$stdoutCliXml = $ps.StandardOutput.ReadToEnd() -replace '^#.*\r?\n'
# Uncomment the following lines to report the process' exit code.
# $ps.WaitForExit()
# "Process exit code: $($ps.ExitCode)"
# Use PowerShell's deserialization API to
# "rehydrate" the objects.
$stdoutObjects = [Management.Automation.PSSerializer]::Deserialize($stdoutCliXml)
"Running ``Get-Date`` as user $($cred.UserName) yielded:"
$stdoutObjects
"`nas data type:"
$stdoutObjects.GetType().FullName
The above outputs something like the following, showing that the [datetime]
instance (System.DateTime
) output by Get-Date
was deserialized as such:
Running `Get-Date` as user jdoe yielded:
Friday, March 27, 2020 6:26:49 PM
as data type:
System.DateTime