From 207e7429e448c8eb30543e152dbef6595dc73440 Mon Sep 17 00:00:00 2001 From: GraceSolutions Date: Sat, 6 Jun 2026 18:29:30 -0400 Subject: [PATCH] feat(process): add Start-InfisicalProcess with event-based capture and friendly TimeSpan logging - New cmdlet Start-InfisicalProcess: launches a child process with InfisicalSecret objects decrypted directly into ProcessStartInfo.Environment (optional -Prefix), additional -EnvironmentVariables, stdout/stderr capture, -AcceptableExitCodeList, -ParsingExpression regex parsing, -ExecutionTimeout / -ExecutionTimeoutInterval, -NoWait, -WindowStyle / -CreateNoWindow parameter sets, -Priority, -StandardInputObjectList, -SecureArgumentList, -LogOutput, -ContinueOnError, and ShouldProcess support. Secret plaintext is never written to user or machine scope. - Stream capture uses event-based OutputDataReceived/ErrorDataReceived with BeginOutputReadLine/BeginErrorReadLine (no Task / ReadToEndAsync / GetAwaiter().GetResult()) to avoid PowerShell SynchronizationContext deadlocks. - Restored the do { log; sleep } while (!HasExited) polling pattern using Thread.Sleep(pollInterval) so verbose "has been running for X" / "Checking again in Y" messages fire at the configured cadence even when no -ExecutionTimeout is supplied. - TimeSpan values in verbose logs and on the result now use a friendly format ("7 seconds, and 364 milliseconds", "1 minute, and 30 seconds", "N/A" when zero) matching the legacy Start-ProcessWithOutput GetTimeSpanMessage scriptblock. - Added DurationFriendly property to InfisicalProcessResult and a "The command execution took X" verbose line at completion. - build.ps1 CmdletsToExport and Test-ModuleImports expected list contain 42 cmdlets. - Added 9 xUnit tests covering FormatFriendly singular/plural, multi-unit joining, zero, sub-millisecond, and skip-zero-components behavior. --- CHANGELOG.md | 49 ++++- .../en-US/PSInfisicalAPI.dll-Help.xml | 47 +++++ README.md | 8 +- build.ps1 | 5 +- docs/DesignSpec.md | 42 ++++ .../InfisicalProcessRunnerHelpersTests.cs | 75 +++++++ .../Cmdlets/StartInfisicalProcessCmdlet.cs | 159 ++++++++++++++ .../Models/InfisicalProcessResult.cs | 26 +++ .../Process/InfisicalProcessOptions.cs | 30 +++ .../Process/InfisicalProcessRunner.cs | 153 ++++++++++++++ .../Process/InfisicalProcessRunnerHelpers.cs | 196 ++++++++++++++++++ 11 files changed, 780 insertions(+), 10 deletions(-) create mode 100644 src/PSInfisicalAPI.Tests/InfisicalProcessRunnerHelpersTests.cs create mode 100644 src/PSInfisicalAPI/Cmdlets/StartInfisicalProcessCmdlet.cs create mode 100644 src/PSInfisicalAPI/Models/InfisicalProcessResult.cs create mode 100644 src/PSInfisicalAPI/Process/InfisicalProcessOptions.cs create mode 100644 src/PSInfisicalAPI/Process/InfisicalProcessRunner.cs create mode 100644 src/PSInfisicalAPI/Process/InfisicalProcessRunnerHelpers.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index c2a21aa..6c0ac6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,47 +6,82 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loos ## Unreleased +- `Start-InfisicalProcess`: switched stdout/stderr capture to event-based `OutputDataReceived`/`ErrorDataReceived` with `BeginOutputReadLine`/`BeginErrorReadLine` (removed `Task`/`ReadToEndAsync`/`GetAwaiter().GetResult()` to eliminate PowerShell `SynchronizationContext` deadlock risk). Restored the original `do { log; sleep } while (!HasExited)` polling pattern using `Thread.Sleep(pollInterval)` so verbose "has been running for X" / "Checking again in Y" messages fire at the configured cadence even when no `-ExecutionTimeout` is supplied. +- `Start-InfisicalProcess`: TimeSpan values in verbose logs and on the result now use a friendly format ("`7 seconds, and 364 milliseconds`", "`1 minute, and 30 seconds`", "`N/A`" when zero) matching the legacy `Start-ProcessWithOutput` `GetTimeSpanMessage` scriptblock. Added `DurationFriendly` property to `InfisicalProcessResult` and a "The command execution took X" verbose line at completion. + +## 2026.06.06.2227 + +- Build produced from commit d3c7b83da717. + +## Unreleased (carried forward) + +## 2026.06.06.2221 + +- Build produced from commit d3c7b83da717. + +## Unreleased (carried forward) + +## 2026.06.06.2207 + +- Build produced from commit d3c7b83da717. + +## Unreleased (carried forward) + +## 2026.06.06.2206 + +- Build produced from commit d3c7b83da717. + +## Unreleased (carried forward) + +## 2026.06.06.2155 + +- Build produced from commit d3c7b83da717. + +## Unreleased (carried forward) + +- Added `Start-InfisicalProcess` cmdlet: launches a child process with `InfisicalSecret` objects (pipeline or `-Secret`) decrypted directly into `ProcessStartInfo.Environment`, with optional `-Prefix`, additional `-EnvironmentVariables`, stdout/stderr capture, `-AcceptableExitCodeList` validation, `-ParsingExpression` regex parsing, `-ExecutionTimeout`/`-ExecutionTimeoutInterval` polling, `-NoWait`, `-WindowStyle`/`-CreateNoWindow` parameter sets, `-Priority`, `-StandardInputObjectList`, `-SecureArgumentList`, `-LogOutput`, `-ContinueOnError`, and `ShouldProcess` support. Secret plaintext is never written to user or machine scope. `build.ps1` `CmdletsToExport` and `Test-ModuleImports` expected list now contain 42 cmdlets. + ## 2026.06.06.2138 - Build produced from commit 318db7048017. -## Unreleased (carried forward) +## Unreleased (carried forward) ## 2026.06.05.2040 - Build produced from commit 1270c9099cae. -## Unreleased (carried forward) +## Unreleased (carried forward) ## 2026.06.05.0240 - Build produced from commit b438abf18f18. -## Unreleased (carried forward) +## Unreleased (carried forward) ## 2026.06.05.0215 - Build produced from commit 82f99ea7d4a4. -## Unreleased (carried forward) +## Unreleased (carried forward) ## 2026.06.05.0205 - Build produced from commit 86968c18cb15. -## Unreleased (carried forward) +## Unreleased (carried forward) ## 2026.06.05.0117 - Build produced from commit cffda99591c9. -## Unreleased (carried forward) +## Unreleased (carried forward) ## 2026.06.05.0015 - Build produced from commit fb27ab8a8503. -## Unreleased (carried forward) +## Unreleased (carried forward) - Fixed `ParameterNameConflictsWithAlias` registration error on `Get-InfisicalCertificateApplication`, `Get-InfisicalCertificateApplicationEnrollment`, and `New-InfisicalScepDynamicChallenge`. The cmdlets each declared an `[Alias]` entry that matched the parameter's own name, which PowerShell rejects at bind time and made the cmdlets unusable. diff --git a/Module/PSInfisicalAPI/en-US/PSInfisicalAPI.dll-Help.xml b/Module/PSInfisicalAPI/en-US/PSInfisicalAPI.dll-Help.xml index bf7d41e..7b31ff4 100644 --- a/Module/PSInfisicalAPI/en-US/PSInfisicalAPI.dll-Help.xml +++ b/Module/PSInfisicalAPI/en-US/PSInfisicalAPI.dll-Help.xml @@ -1654,4 +1654,51 @@ $WriteInfisicalScepMdmProfileToWmiResult = Write-InfisicalScepMdmProfileToWmi @W + + + Start-InfisicalProcess + Starts a child process with Infisical secrets injected directly into its environment block. + Start + InfisicalProcess + + + Launches the executable specified by -FilePath, captures stdout/stderr, validates the exit code against -AcceptableExitCodeList, and optionally parses output with -ParsingExpression. InfisicalSecret objects supplied via -Secret (pipeline or by name) are decrypted into the ProcessStartInfo.Environment dictionary only, never written to the user or machine scope; -Prefix prepends a string to each injected variable name. -EnvironmentVariables adds additional non-secret values. -ExecutionTimeout, -NoWait, -CreateNoWindow, -WindowStyle, -Priority, -StandardInputObjectList, -SecureArgumentList, -LogOutput, and -ContinueOnError mirror the semantics of the upstream Start-ProcessWithOutput helper. Honors -WhatIf and -Confirm. + + + Notes + + Secret values exist as plain strings only within the child process environment block; they are never persisted to the calling shell, the user scope, or the machine scope. Use -SecureArgumentList to mask sensitive command-line arguments in verbose output. + + + + + EXAMPLE 1 + Get-InfisicalSecret -SecretPath '/build' | Start-InfisicalProcess -FilePath 'dotnet.exe' -ArgumentList @('publish','-c','Release') -AcceptableExitCodeList @('0') -CreateNoWindow + Decrypts every secret at /build, exposes each one as a process environment variable, and runs dotnet publish with no visible window. + + + EXAMPLE 2 + $Secrets = Get-InfisicalSecret -SecretPath '/runtime' +Start-InfisicalProcess -FilePath 'node.exe' -ArgumentList @('app.js') -Secret $Secrets -Prefix 'APP_' -ExecutionTimeout ([TimeSpan]::FromMinutes(5)) -LogOutput + Injects the /runtime secrets as APP_-prefixed environment variables, runs node app.js, and forcibly terminates the process after five minutes if it has not exited. + + + EXAMPLE 3 + $StartInfisicalProcessParameters = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase) +$StartInfisicalProcessParameters.FilePath = 'pwsh.exe' +$StartInfisicalProcessParameters.ArgumentList = @('-NoProfile','-Command','Write-Host $env:DEPLOY_TOKEN.Length') +$StartInfisicalProcessParameters.Secret = Get-InfisicalSecret -SecretPath '/deploy' +$StartInfisicalProcessParameters.Prefix = 'DEPLOY_' +$StartInfisicalProcessParameters.AcceptableExitCodeList = @('0') +$StartInfisicalProcessParameters.CreateNoWindow = $True +$StartInfisicalProcessParameters.SecureArgumentList = $True +$StartInfisicalProcessParameters.LogOutput = $True +$StartInfisicalProcessParameters.Verbose = $True + +$StartInfisicalProcessResult = Start-InfisicalProcess @StartInfisicalProcessParameters + Splatted invocation that runs pwsh with DEPLOY_-prefixed secrets in scope, masks the command line in verbose output, and echoes both stdout and stderr to the verbose stream after exit. + + + + diff --git a/README.md b/README.md index 605b084..8c85102 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Import-Module -Name .\Module\PSInfisicalAPI ## Cmdlets -The module exports 37 cmdlets. Discovery cmdlets (`Get-Infisical*`) use a `List` (default) / single-record parameter-set pair: invoking without the identity parameter returns the collection, supplying the identity parameter returns one record. +The module exports 42 cmdlets. Discovery cmdlets (`Get-Infisical*`) use a `List` (default) / single-record parameter-set pair: invoking without the identity parameter returns the collection, supplying the identity parameter returns one record. ### Session @@ -99,6 +99,12 @@ The module exports 37 cmdlets. Discovery cmdlets (`Get-Infisical*`) use a `List` | `Export-InfisicalScepMdmProfile` | Writes a SCEP MDM profile to disk as a SyncML payload suitable for MDM delivery. | | `Write-InfisicalScepMdmProfileToWmi`| Submits a SCEP MDM profile to the local MDM Bridge WMI provider to trigger enrollment. | +### Process + +| Cmdlet | Purpose | +| ------------------------ | -------------------------------------------------------------------------------------------------- | +| `Start-InfisicalProcess` | Launches a child process with Infisical secrets injected directly into its environment block, capturing stdout/stderr and validating the exit code. | + Use `Get-Help -Full` for parameter details and `Get-Help about_PSInfisicalAPI` for the module overview. ## Quick start diff --git a/build.ps1 b/build.ps1 index 8de86d7..26fdb71 100644 --- a/build.ps1 +++ b/build.ps1 @@ -144,7 +144,8 @@ function Write-Manifest { 'New-InfisicalScepDynamicChallenge', 'Get-InfisicalScepMdmProfile', 'Export-InfisicalScepMdmProfile', - 'Write-InfisicalScepMdmProfileToWmi' + 'Write-InfisicalScepMdmProfileToWmi', + 'Start-InfisicalProcess' ) AliasesToExport = @() VariablesToExport = @() @@ -209,7 +210,7 @@ if (`$cmds.Count -eq 0) { throw "No cmdlets were exported by the PSInfisicalAPI module." } -`$expectedCmds = @('Connect-Infisical','Disconnect-Infisical','Get-InfisicalSecret','New-InfisicalSecret','Update-InfisicalSecret','Remove-InfisicalSecret','Copy-InfisicalSecret','ConvertTo-InfisicalSecretDictionary','Export-InfisicalSecrets','Get-InfisicalProject','New-InfisicalProject','Update-InfisicalProject','Remove-InfisicalProject','Get-InfisicalEnvironment','New-InfisicalEnvironment','Update-InfisicalEnvironment','Remove-InfisicalEnvironment','Get-InfisicalFolder','New-InfisicalFolder','Update-InfisicalFolder','Remove-InfisicalFolder','Get-InfisicalTag','New-InfisicalTag','Update-InfisicalTag','Remove-InfisicalTag','Get-InfisicalCertificateAuthority','Get-InfisicalPkiSubscriber','Get-InfisicalCertificateProfile','Get-InfisicalCertificatePolicy','Get-InfisicalCertificate','Request-InfisicalCertificate','ConvertTo-InfisicalCertificate','Install-InfisicalCertificate','Uninstall-InfisicalCertificate','Export-InfisicalCertificate','Get-InfisicalCertificateApplication','Get-InfisicalCertificateApplicationEnrollment','New-InfisicalScepDynamicChallenge','Get-InfisicalScepMdmProfile','Export-InfisicalScepMdmProfile','Write-InfisicalScepMdmProfileToWmi') +`$expectedCmds = @('Connect-Infisical','Disconnect-Infisical','Get-InfisicalSecret','New-InfisicalSecret','Update-InfisicalSecret','Remove-InfisicalSecret','Copy-InfisicalSecret','ConvertTo-InfisicalSecretDictionary','Export-InfisicalSecrets','Get-InfisicalProject','New-InfisicalProject','Update-InfisicalProject','Remove-InfisicalProject','Get-InfisicalEnvironment','New-InfisicalEnvironment','Update-InfisicalEnvironment','Remove-InfisicalEnvironment','Get-InfisicalFolder','New-InfisicalFolder','Update-InfisicalFolder','Remove-InfisicalFolder','Get-InfisicalTag','New-InfisicalTag','Update-InfisicalTag','Remove-InfisicalTag','Get-InfisicalCertificateAuthority','Get-InfisicalPkiSubscriber','Get-InfisicalCertificateProfile','Get-InfisicalCertificatePolicy','Get-InfisicalCertificate','Request-InfisicalCertificate','ConvertTo-InfisicalCertificate','Install-InfisicalCertificate','Uninstall-InfisicalCertificate','Export-InfisicalCertificate','Get-InfisicalCertificateApplication','Get-InfisicalCertificateApplicationEnrollment','New-InfisicalScepDynamicChallenge','Get-InfisicalScepMdmProfile','Export-InfisicalScepMdmProfile','Write-InfisicalScepMdmProfileToWmi','Start-InfisicalProcess') foreach (`$expected in `$expectedCmds) { if (-not (Get-Command -Name `$expected -Module PSInfisicalAPI -ErrorAction SilentlyContinue)) { throw "Cmdlet not found: `$expected" diff --git a/docs/DesignSpec.md b/docs/DesignSpec.md index eeff9c9..b0000ad 100644 --- a/docs/DesignSpec.md +++ b/docs/DesignSpec.md @@ -1491,6 +1491,48 @@ No warnings should be emitted. --- +# 16.6 Start-InfisicalProcess + +Signature: + +```text +Start-InfisicalProcess + -FilePath + [-WorkingDirectory ] + [-ArgumentList ] + [-AcceptableExitCodeList ] + [-WindowStyle ] + [-CreateNoWindow] + [-NoWait] + [-Priority ] + [-ExecutionTimeout ] + [-ExecutionTimeoutInterval ] + [-StandardInputObjectList ] + [-EnvironmentVariables ] + [-ParsingExpression ] + [-SecureArgumentList] + [-LogOutput] + [-ContinueOnError] + [-Secret ] + [-Prefix ] +``` + +Behavior: + +```text +Buffer pipeline InfisicalSecret objects in ProcessRecord. +Decrypt secrets only into ProcessStartInfo.Environment. +Apply -Prefix to each secret name before injection. +Never write secret plaintext to user or machine environment scope. +Honor -WhatIf / -Confirm. +Default -AcceptableExitCodeList = @('0','3010'). +Throw a terminating error on unacceptable exit code unless -ContinueOnError is set. +``` + +Output: `InfisicalProcessResult` with `ExitCode`, `ExitCodeAsHex`, `ExitCodeAsInteger`, `ExitCodeAsDecimal`, `StandardOutput`, `StandardError`, `StandardOutputObject`, `StandardErrorObject`, `StartTime`, `ExitTime`, `Duration`, `DurationFriendly`, `ProcessId`, `TimedOut`, `Succeeded`, `SecretCount`. + +--- + # 17. SecureString Utility Required utility: diff --git a/src/PSInfisicalAPI.Tests/InfisicalProcessRunnerHelpersTests.cs b/src/PSInfisicalAPI.Tests/InfisicalProcessRunnerHelpersTests.cs new file mode 100644 index 0000000..5ebc344 --- /dev/null +++ b/src/PSInfisicalAPI.Tests/InfisicalProcessRunnerHelpersTests.cs @@ -0,0 +1,75 @@ +using System; +using PSInfisicalAPI.Process; +using Xunit; + +namespace PSInfisicalAPI.Tests +{ + public class InfisicalProcessRunnerHelpersTests + { + [Fact] + public void FormatFriendly_Zero_Returns_NotAvailable() + { + Assert.Equal("N/A", InfisicalProcessRunnerHelpers.FormatFriendly(TimeSpan.Zero)); + } + + [Fact] + public void FormatFriendly_Single_Unit_Plural() + { + Assert.Equal("30 seconds", InfisicalProcessRunnerHelpers.FormatFriendly(TimeSpan.FromSeconds(30))); + Assert.Equal("5 minutes", InfisicalProcessRunnerHelpers.FormatFriendly(TimeSpan.FromMinutes(5))); + Assert.Equal("250 milliseconds", InfisicalProcessRunnerHelpers.FormatFriendly(TimeSpan.FromMilliseconds(250))); + } + + [Fact] + public void FormatFriendly_Single_Unit_Singular() + { + Assert.Equal("1 second", InfisicalProcessRunnerHelpers.FormatFriendly(TimeSpan.FromSeconds(1))); + Assert.Equal("1 minute", InfisicalProcessRunnerHelpers.FormatFriendly(TimeSpan.FromMinutes(1))); + Assert.Equal("1 hour", InfisicalProcessRunnerHelpers.FormatFriendly(TimeSpan.FromHours(1))); + Assert.Equal("1 day", InfisicalProcessRunnerHelpers.FormatFriendly(TimeSpan.FromDays(1))); + Assert.Equal("1 millisecond", InfisicalProcessRunnerHelpers.FormatFriendly(TimeSpan.FromMilliseconds(1))); + } + + [Fact] + public void FormatFriendly_Two_Units_Uses_And_Join() + { + TimeSpan value = TimeSpan.FromSeconds(7) + TimeSpan.FromMilliseconds(364); + Assert.Equal("7 seconds, and 364 milliseconds", InfisicalProcessRunnerHelpers.FormatFriendly(value)); + } + + [Fact] + public void FormatFriendly_Multiple_Units_Uses_Comma_And_Trailing_And() + { + TimeSpan value = TimeSpan.FromHours(1) + TimeSpan.FromMinutes(2) + TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(45); + Assert.Equal("1 hour, 2 minutes, 3 seconds, and 45 milliseconds", InfisicalProcessRunnerHelpers.FormatFriendly(value)); + } + + [Fact] + public void FormatFriendly_Skips_Zero_Components() + { + TimeSpan value = TimeSpan.FromHours(2) + TimeSpan.FromMilliseconds(500); + Assert.Equal("2 hours, and 500 milliseconds", InfisicalProcessRunnerHelpers.FormatFriendly(value)); + } + + [Fact] + public void FormatFriendly_Mixed_Singular_And_Plural() + { + TimeSpan value = TimeSpan.FromMinutes(1) + TimeSpan.FromSeconds(30); + Assert.Equal("1 minute, and 30 seconds", InfisicalProcessRunnerHelpers.FormatFriendly(value)); + } + + [Fact] + public void FormatFriendly_Days_Component() + { + TimeSpan value = TimeSpan.FromDays(2) + TimeSpan.FromHours(3); + Assert.Equal("2 days, and 3 hours", InfisicalProcessRunnerHelpers.FormatFriendly(value)); + } + + [Fact] + public void FormatFriendly_SubMillisecond_Returns_NotAvailable() + { + TimeSpan value = TimeSpan.FromTicks(100); + Assert.Equal("N/A", InfisicalProcessRunnerHelpers.FormatFriendly(value)); + } + } +} diff --git a/src/PSInfisicalAPI/Cmdlets/StartInfisicalProcessCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/StartInfisicalProcessCmdlet.cs new file mode 100644 index 0000000..c129a22 --- /dev/null +++ b/src/PSInfisicalAPI/Cmdlets/StartInfisicalProcessCmdlet.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Management.Automation; +using System.Text.RegularExpressions; +using PSInfisicalAPI.Models; +using PSInfisicalAPI.Process; + +namespace PSInfisicalAPI.Cmdlets +{ + [Cmdlet(VerbsLifecycle.Start, "InfisicalProcess", DefaultParameterSetName = WindowStyleSet, SupportsShouldProcess = true)] + [OutputType(typeof(InfisicalProcessResult))] + public sealed class StartInfisicalProcessCmdlet : InfisicalCmdletBase + { + private const string Component = "StartInfisicalProcessCmdlet"; + private const string WindowStyleSet = "WindowStyle"; + private const string CreateNoWindowSet = "CreateNoWindow"; + + [Parameter(Mandatory = true, Position = 0)] + [ValidateNotNullOrEmpty] + [Alias("FP")] + public string FilePath { get; set; } + + [Parameter] + [Alias("WD")] + public DirectoryInfo WorkingDirectory { get; set; } + + [Parameter] + [AllowEmptyCollection] + [AllowNull] + [Alias("AL")] + public string[] ArgumentList { get; set; } + + [Parameter] + [AllowEmptyCollection] + [AllowNull] + [Alias("AECL")] + public string[] AcceptableExitCodeList { get; set; } + + [Parameter(ParameterSetName = WindowStyleSet)] + [ValidateSet("Normal", "Hidden", "Minimized", "Maximized")] + [Alias("WS")] + public string WindowStyle { get; set; } = "Hidden"; + + [Parameter(ParameterSetName = CreateNoWindowSet)] + [Alias("CNW")] + public SwitchParameter CreateNoWindow { get; set; } + + [Parameter] + [Alias("NW")] + public SwitchParameter NoWait { get; set; } + + [Parameter] + [ValidateSet("AboveNormal", "BelowNormal", "High", "Idle", "Normal", "RealTime")] + [Alias("P")] + public string Priority { get; set; } = "Normal"; + + [Parameter] + [Alias("ET")] + public TimeSpan ExecutionTimeout { get; set; } + + [Parameter] + [Alias("ETI")] + public TimeSpan ExecutionTimeoutInterval { get; set; } = TimeSpan.FromSeconds(15); + + [Parameter] + [Alias("SIO")] + public object[] StandardInputObjectList { get; set; } + + [Parameter] + [Alias("ENV")] + public IDictionary EnvironmentVariables { get; set; } + + [Parameter] + [Alias("StandardOutputParsingExpression", "SOPE", "PE")] + public Regex ParsingExpression { get; set; } + + [Parameter] + [Alias("SAL")] + public SwitchParameter SecureArgumentList { get; set; } + + [Parameter] + [Alias("LO")] + public SwitchParameter LogOutput { get; set; } + + [Parameter] + [Alias("COE")] + public SwitchParameter ContinueOnError { get; set; } + + [Parameter(ValueFromPipeline = true)] + [Alias("Secrets", "InputObject")] + public InfisicalSecret[] Secret { get; set; } + + [Parameter] + public string Prefix { get; set; } + + private readonly List _secretBuffer = new List(); + + protected override void ProcessRecord() + { + if (Secret == null) { return; } + foreach (InfisicalSecret secret in Secret) + { + if (secret != null) { _secretBuffer.Add(secret); } + } + } + + protected override void EndProcessing() + { + try + { + string target = string.IsNullOrEmpty(WorkingDirectory != null ? WorkingDirectory.FullName : null) + ? FilePath + : string.Concat(FilePath, " (in ", WorkingDirectory.FullName, ")"); + + if (!ShouldProcess(target, "Start process with Infisical secrets")) { return; } + + InfisicalProcessOptions options = new InfisicalProcessOptions + { + FilePath = FilePath, + WorkingDirectory = WorkingDirectory, + ArgumentList = ArgumentList, + AcceptableExitCodeList = AcceptableExitCodeList, + WindowStyle = WindowStyle, + CreateNoWindow = CreateNoWindow.IsPresent, + NoWait = NoWait.IsPresent, + Priority = Priority, + ExecutionTimeout = MyInvocation.BoundParameters.ContainsKey("ExecutionTimeout") ? (TimeSpan?)ExecutionTimeout : null, + ExecutionTimeoutInterval = ExecutionTimeoutInterval, + StandardInputObjectList = StandardInputObjectList, + EnvironmentVariables = EnvironmentVariables, + ParsingExpression = ParsingExpression, + SecureArgumentList = SecureArgumentList.IsPresent, + LogOutput = LogOutput.IsPresent, + ContinueOnError = ContinueOnError.IsPresent, + Secrets = _secretBuffer.ToArray(), + Prefix = Prefix + }; + + InfisicalProcessResult result = InfisicalProcessRunner.Run(options, Logger); + WriteObject(result); + + if (!result.Succeeded && !NoWait.IsPresent && !ContinueOnError.IsPresent) + { + string message = string.Concat("Process '", FilePath, "' exited with code ", result.ExitCode.HasValue ? result.ExitCode.Value.ToString() : "", " which is not in the acceptable exit code list."); + InvalidOperationException exception = new InvalidOperationException(message); + ErrorRecord error = new ErrorRecord(exception, "StartInfisicalProcess.UnacceptableExitCode", ErrorCategory.InvalidResult, result); + ThrowTerminatingError(error); + } + } + catch (PipelineStoppedException) { throw; } + catch (Exception exception) + { + ThrowTerminatingForException(Component, "StartProcess", exception); + } + } + } +} diff --git a/src/PSInfisicalAPI/Models/InfisicalProcessResult.cs b/src/PSInfisicalAPI/Models/InfisicalProcessResult.cs new file mode 100644 index 0000000..d2eb217 --- /dev/null +++ b/src/PSInfisicalAPI/Models/InfisicalProcessResult.cs @@ -0,0 +1,26 @@ +using System; +using System.Text.RegularExpressions; + +namespace PSInfisicalAPI.Models +{ + public sealed class InfisicalProcessResult + { + public string FilePath { get; set; } + public int? ProcessId { get; set; } + public int? ExitCode { get; set; } + public string ExitCodeAsHex { get; set; } + public int? ExitCodeAsInteger { get; set; } + public string ExitCodeAsDecimal { get; set; } + public DateTime? StartTime { get; set; } + public DateTime? ExitTime { get; set; } + public TimeSpan? Duration { get; set; } + public string DurationFriendly { get; set; } + public string StandardOutput { get; set; } + public string StandardError { get; set; } + public Match[] StandardOutputObject { get; set; } + public Match[] StandardErrorObject { get; set; } + public bool TimedOut { get; set; } + public bool Succeeded { get; set; } + public int SecretCount { get; set; } + } +} diff --git a/src/PSInfisicalAPI/Process/InfisicalProcessOptions.cs b/src/PSInfisicalAPI/Process/InfisicalProcessOptions.cs new file mode 100644 index 0000000..fc72ead --- /dev/null +++ b/src/PSInfisicalAPI/Process/InfisicalProcessOptions.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections; +using System.IO; +using System.Text.RegularExpressions; +using PSInfisicalAPI.Models; + +namespace PSInfisicalAPI.Process +{ + public sealed class InfisicalProcessOptions + { + public string FilePath { get; set; } + public DirectoryInfo WorkingDirectory { get; set; } + public string[] ArgumentList { get; set; } + public string[] AcceptableExitCodeList { get; set; } + public string WindowStyle { get; set; } + public bool CreateNoWindow { get; set; } + public bool NoWait { get; set; } + public string Priority { get; set; } + public TimeSpan? ExecutionTimeout { get; set; } + public TimeSpan ExecutionTimeoutInterval { get; set; } + public object[] StandardInputObjectList { get; set; } + public IDictionary EnvironmentVariables { get; set; } + public Regex ParsingExpression { get; set; } + public bool SecureArgumentList { get; set; } + public bool LogOutput { get; set; } + public bool ContinueOnError { get; set; } + public InfisicalSecret[] Secrets { get; set; } + public string Prefix { get; set; } + } +} diff --git a/src/PSInfisicalAPI/Process/InfisicalProcessRunner.cs b/src/PSInfisicalAPI/Process/InfisicalProcessRunner.cs new file mode 100644 index 0000000..4a098d8 --- /dev/null +++ b/src/PSInfisicalAPI/Process/InfisicalProcessRunner.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using PSInfisicalAPI.Logging; +using PSInfisicalAPI.Models; +using SystemProcess = System.Diagnostics.Process; +using static PSInfisicalAPI.Process.InfisicalProcessRunnerHelpers; + +namespace PSInfisicalAPI.Process +{ + public static class InfisicalProcessRunner + { + private const string Component = "InfisicalProcessRunner"; + + public static InfisicalProcessResult Run(InfisicalProcessOptions options, IInfisicalLogger logger) + { + if (options == null) { throw new ArgumentNullException(nameof(options)); } + if (string.IsNullOrWhiteSpace(options.FilePath)) { throw new ArgumentException("FilePath is required.", nameof(options)); } + + string[] acceptable = (options.AcceptableExitCodeList != null && options.AcceptableExitCodeList.Length > 0) + ? options.AcceptableExitCodeList + : new[] { "0", "3010" }; + + InfisicalProcessResult result = new InfisicalProcessResult + { + FilePath = options.FilePath, + ExitCode = -1, + SecretCount = options.Secrets != null ? options.Secrets.Length : 0 + }; + + SystemProcess process = new SystemProcess(); + process.StartInfo.FileName = options.FilePath; + process.StartInfo.UseShellExecute = false; + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.RedirectStandardError = true; + process.StartInfo.RedirectStandardInput = true; + + if (options.CreateNoWindow) { process.StartInfo.CreateNoWindow = true; } + else if (!string.IsNullOrWhiteSpace(options.WindowStyle)) + { + process.StartInfo.WindowStyle = (ProcessWindowStyle)Enum.Parse(typeof(ProcessWindowStyle), options.WindowStyle, true); + } + + if (options.WorkingDirectory != null && !string.IsNullOrWhiteSpace(options.WorkingDirectory.FullName)) + { + if (!Directory.Exists(options.WorkingDirectory.FullName)) + { + Directory.CreateDirectory(options.WorkingDirectory.FullName); + } + process.StartInfo.WorkingDirectory = options.WorkingDirectory.FullName; + } + + ApplyEnvironment(process.StartInfo.Environment, options, logger); + + if (options.ArgumentList != null && options.ArgumentList.Length > 0) + { + process.StartInfo.Arguments = string.Join(" ", options.ArgumentList); + } + + LogCommand(process.StartInfo, options, logger); + LogVerbose(logger, string.Concat("Acceptable exit codes: ", string.Join("; ", acceptable))); + + Stopwatch timer = Stopwatch.StartNew(); + process.Start(); + result.ProcessId = process.Id; + try { result.StartTime = process.StartTime; } catch { } + + ApplyPriority(process, options.Priority, logger); + + StringBuilder stdoutBuffer = new StringBuilder(); + StringBuilder stderrBuffer = new StringBuilder(); + object stdoutGate = new object(); + object stderrGate = new object(); + process.OutputDataReceived += (sender, args) => { if (args.Data != null) { lock (stdoutGate) { stdoutBuffer.AppendLine(args.Data); } } }; + process.ErrorDataReceived += (sender, args) => { if (args.Data != null) { lock (stderrGate) { stderrBuffer.AppendLine(args.Data); } } }; + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + WriteStandardInput(process, options.StandardInputObjectList, options.SecureArgumentList, logger); + + if (options.NoWait) + { + LogVerbose(logger, string.Concat("Skipping wait for process ID ", process.Id, ".")); + return result; + } + + WaitForExit(process, options.ExecutionTimeout, options.ExecutionTimeoutInterval, timer, result, logger); + + try { process.WaitForExit(); } catch { } + lock (stdoutGate) { result.StandardOutput = stdoutBuffer.Length > 0 ? stdoutBuffer.ToString() : null; } + lock (stderrGate) { result.StandardError = stderrBuffer.Length > 0 ? stderrBuffer.ToString() : null; } + + try { result.ExitCode = process.ExitCode; } catch { result.ExitCode = null; } + FormatExitCodes(result); + try { result.ExitTime = process.ExitTime; } catch { } + if (result.StartTime.HasValue && result.ExitTime.HasValue) { result.Duration = result.ExitTime.Value - result.StartTime.Value; } + if (result.Duration.HasValue) + { + result.DurationFriendly = FormatFriendly(result.Duration.Value); + LogVerbose(logger, string.Concat("The command execution took ", result.DurationFriendly, ".")); + } + + ApplyRegex(result, options.ParsingExpression); + result.Succeeded = IsAcceptable(result, acceptable); + + try { process.Dispose(); } catch { } + + if (options.LogOutput || !result.Succeeded) + { + LogVerbose(logger, string.Concat("StandardOutput: ", string.IsNullOrEmpty(result.StandardOutput) ? "N/A" : result.StandardOutput)); + LogVerbose(logger, string.Concat("StandardError: ", string.IsNullOrEmpty(result.StandardError) ? "N/A" : result.StandardError)); + } + + return result; + } + + private static bool IsAcceptable(InfisicalProcessResult result, string[] acceptable) + { + if (acceptable.Any(c => string.Equals(c, "*", StringComparison.Ordinal))) { return true; } + HashSet set = new HashSet(acceptable, StringComparer.OrdinalIgnoreCase); + if (result.ExitCode.HasValue && set.Contains(result.ExitCode.Value.ToString(CultureInfo.InvariantCulture))) { return true; } + if (!string.IsNullOrEmpty(result.ExitCodeAsHex) && set.Contains(result.ExitCodeAsHex)) { return true; } + if (result.ExitCodeAsInteger.HasValue && set.Contains(result.ExitCodeAsInteger.Value.ToString(CultureInfo.InvariantCulture))) { return true; } + if (!string.IsNullOrEmpty(result.ExitCodeAsDecimal) && set.Contains(result.ExitCodeAsDecimal)) { return true; } + return false; + } + + private static void FormatExitCodes(InfisicalProcessResult result) + { + if (!result.ExitCode.HasValue) { return; } + try + { + result.ExitCodeAsHex = "0x" + Convert.ToString(result.ExitCode.Value, 16).PadLeft(8, '0').ToUpperInvariant(); + int parsed; + if (int.TryParse(result.ExitCodeAsHex.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out parsed)) + { + result.ExitCodeAsInteger = parsed; + } + result.ExitCodeAsDecimal = result.ExitCode.Value.ToString(CultureInfo.InvariantCulture); + } + catch { } + } + + private static void LogVerbose(IInfisicalLogger logger, string message) + { + if (logger != null) { logger.Verbose(Component, message); } + } + } +} diff --git a/src/PSInfisicalAPI/Process/InfisicalProcessRunnerHelpers.cs b/src/PSInfisicalAPI/Process/InfisicalProcessRunnerHelpers.cs new file mode 100644 index 0000000..239f382 --- /dev/null +++ b/src/PSInfisicalAPI/Process/InfisicalProcessRunnerHelpers.cs @@ -0,0 +1,196 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using PSInfisicalAPI.Logging; +using PSInfisicalAPI.Models; +using PSInfisicalAPI.Security; +using SystemProcess = System.Diagnostics.Process; + +namespace PSInfisicalAPI.Process +{ + internal static class InfisicalProcessRunnerHelpers + { + private const string Component = "InfisicalProcessRunner"; + + internal static void ApplyEnvironment(IDictionary processEnv, InfisicalProcessOptions options, IInfisicalLogger logger) + { + if (processEnv == null) { return; } + + if (options.EnvironmentVariables != null && options.EnvironmentVariables.Count > 0) + { + Log(logger, string.Concat("Injecting ", options.EnvironmentVariables.Count, " explicit environment variable(s) into the process.")); + foreach (DictionaryEntry entry in options.EnvironmentVariables) + { + if (entry.Key == null) { continue; } + string key = entry.Key.ToString(); + string value = entry.Value != null ? entry.Value.ToString() : string.Empty; + processEnv[key] = value; + } + } + + if (options.Secrets == null || options.Secrets.Length == 0) { return; } + + Log(logger, string.Concat("Injecting ", options.Secrets.Length, " Infisical secret(s) into the process environment.")); + foreach (InfisicalSecret secret in options.Secrets) + { + if (secret == null || string.IsNullOrEmpty(secret.SecretName) || secret.SecretValue == null) { continue; } + string name = string.IsNullOrEmpty(options.Prefix) ? secret.SecretName : string.Concat(options.Prefix, secret.SecretName); + SecureStringUtility.UsePlainText(secret.SecretValue, plain => + { + processEnv[name] = plain; + return true; + }); + } + } + + internal static void LogCommand(ProcessStartInfo startInfo, InfisicalProcessOptions options, IInfisicalLogger logger) + { + StringBuilder builder = new StringBuilder(); + builder.Append("Attempting to execute: "); + builder.Append(startInfo.FileName); + + if (!string.IsNullOrEmpty(startInfo.Arguments)) + { + builder.Append(' '); + if (options.SecureArgumentList) + { + int len = Math.Min(20, Math.Max(5, startInfo.Arguments.Length)); + builder.Append(new string('*', len)); + } + else + { + builder.Append(startInfo.Arguments); + } + } + + Log(logger, builder.ToString()); + } + + internal static void ApplyPriority(SystemProcess process, string priority, IInfisicalLogger logger) + { + if (string.IsNullOrWhiteSpace(priority)) { return; } + ProcessPriorityClass desired; + if (!Enum.TryParse(priority, true, out desired)) { return; } + try + { + if (process.PriorityClass != desired) + { + process.PriorityClass = desired; + Log(logger, string.Concat("Set process priority class to '", desired, "' for process ID ", process.Id, ".")); + } + } + catch (Exception exception) + { + Log(logger, string.Concat("Unable to set process priority class to '", desired, "': ", exception.Message)); + } + } + + internal static void WriteStandardInput(SystemProcess process, object[] inputs, bool secureArguments, IInfisicalLogger logger) + { + if (inputs == null || inputs.Length == 0) { return; } + for (int i = 0; i < inputs.Length; i++) + { + try + { + object value = inputs[i]; + string preview = secureArguments ? new string('*', 8) : (value != null ? value.ToString() : string.Empty); + Log(logger, string.Concat("Writing standard input object ", i + 1, " of ", inputs.Length, " to process ID ", process.Id, ": ", preview)); + process.StandardInput.WriteLine(value); + } + catch (Exception exception) + { + Log(logger, string.Concat("Failed to write standard input object ", i + 1, ": ", exception.Message)); + } + } + } + + internal static void WaitForExit(SystemProcess process, TimeSpan? timeout, TimeSpan interval, Stopwatch timer, InfisicalProcessResult result, IInfisicalLogger logger) + { + TimeSpan pollInterval = interval.TotalMilliseconds > 0 ? interval : TimeSpan.FromSeconds(15); + int processId = process.Id; + + if (!timeout.HasValue) + { + Log(logger, string.Concat("A timeout was not specified for process ID ", processId, ".")); + Log(logger, string.Concat("The wait for process ID ", processId, " termination will be indefinite.")); + } + else + { + Log(logger, string.Concat("Process timeout duration: ", FormatFriendly(timeout.Value))); + } + + while (!GetProcessHasExited(processId)) + { + Log(logger, string.Concat("Process ID ", processId, " has been running for ", FormatFriendly(timer.Elapsed), ".")); + + if (timeout.HasValue && timer.Elapsed >= timeout.Value) + { + Log(logger, string.Concat("Process ID ", processId, " exceeded the maximum timeout duration of ", FormatFriendly(timeout.Value), "; terminating.")); + try { process.Kill(); result.TimedOut = true; } catch { } + break; + } + + Log(logger, string.Concat("Checking again in another ", FormatFriendly(pollInterval), ". Please wait...")); + System.Threading.Thread.Sleep(pollInterval); + } + } + + private static bool GetProcessHasExited(int processId) + { + try { return SystemProcess.GetProcessById(processId).HasExited; } + catch { return true; } + } + + internal static string FormatFriendly(TimeSpan value) + { + string[] names = new[] { "Days", "Hours", "Minutes", "Seconds", "Milliseconds" }; + int[] values = new[] { value.Days, value.Hours, value.Minutes, value.Seconds, value.Milliseconds }; + + List nonZeroIndices = new List(); + for (int i = 0; i < values.Length; i++) { if (values[i] > 0) { nonZeroIndices.Add(i); } } + + if (nonZeroIndices.Count == 0) { return "N/A"; } + + StringBuilder builder = new StringBuilder(); + int last = nonZeroIndices.Count - 1; + for (int i = 0; i < nonZeroIndices.Count; i++) + { + int index = nonZeroIndices[i]; + int amount = values[index]; + string name = names[index].ToLowerInvariant(); + if (amount == 1) { name = name.TrimEnd('s'); } + + if (nonZeroIndices.Count > 1 && i == last) { builder.Append("and "); } + builder.Append(amount); + builder.Append(' '); + builder.Append(name); + if (nonZeroIndices.Count > 1 && i != last) { builder.Append(", "); } + } + + return builder.ToString(); + } + + internal static void ApplyRegex(InfisicalProcessResult result, Regex expression) + { + if (expression == null) { return; } + if (!string.IsNullOrEmpty(result.StandardOutput)) + { + result.StandardOutputObject = expression.Matches(result.StandardOutput).Cast().ToArray(); + } + if (!string.IsNullOrEmpty(result.StandardError)) + { + result.StandardErrorObject = expression.Matches(result.StandardError).Cast().ToArray(); + } + } + + private static void Log(IInfisicalLogger logger, string message) + { + if (logger != null) { logger.Verbose(Component, message); } + } + } +}