@@ -137,49 +137,174 @@ function Protect-LogLine {
137137 return $protectedLine
138138}
139139
140+ # Native-process invocation with masked output. Uses System.Diagnostics.Process
141+ # with redirected stdout/stderr so the child's raw bytes bypass PowerShell's
142+ # ErrorRecord promotion (which would otherwise leak unmasked to Start-Transcript).
143+
144+ # Windows npm shims (e.g. npx.cmd) need cmd.exe; *nix shebang scripts launch directly.
145+ function Resolve-NativeExecutable {
146+ param ([string ]$Name )
147+
148+ $resolved = Get-Command - Name $Name - CommandType Application - ErrorAction Stop |
149+ Select-Object - First 1
150+
151+ $exePath = $resolved.Source
152+ $prefix = @ ()
153+
154+ if ($IsWindows -and ($exePath -like ' *.cmd' -or $exePath -like ' *.bat' )) {
155+ $prefix = @ (' /c' , $exePath )
156+ $exePath = $env: ComSpec
157+ }
158+
159+ return [pscustomobject ]@ { FilePath = $exePath ; Prefix = $prefix }
160+ }
161+
162+ # Stderr is always masked + streamed. Stdout is either captured (and returned)
163+ # or masked + streamed. Sets $global:LASTEXITCODE.
164+ function Invoke-MaskedProcess {
165+ [CmdletBinding ()]
166+ param (
167+ [Parameter (Mandatory )][string ]$FilePath ,
168+ [string []]$Arguments = @ (),
169+ [hashtable ]$Replacements ,
170+ [switch ]$CaptureStdout
171+ )
172+
173+ $exe = Resolve-NativeExecutable - Name $FilePath
174+ $finalArgs = @ () + $exe.Prefix + $Arguments
175+
176+ $psi = [System.Diagnostics.ProcessStartInfo ]::new()
177+ $psi.FileName = $exe.FilePath
178+ $psi.RedirectStandardOutput = $true
179+ $psi.RedirectStandardError = $true
180+ $psi.RedirectStandardInput = $false
181+ $psi.UseShellExecute = $false
182+ $psi.CreateNoWindow = $true
183+ # Default OEM encoding on Windows mangles `az --debug` output.
184+ $psi.StandardOutputEncoding = [System.Text.Encoding ]::UTF8
185+ $psi.StandardErrorEncoding = [System.Text.Encoding ]::UTF8
186+
187+ foreach ($a in $finalArgs ) {
188+ [void ]$psi.ArgumentList.Add ([string ]$a )
189+ }
190+
191+ $stdoutQueue = [System.Collections.Concurrent.ConcurrentQueue [string ]]::new()
192+ $stderrQueue = [System.Collections.Concurrent.ConcurrentQueue [string ]]::new()
193+
194+ $proc = [System.Diagnostics.Process ]::new()
195+ $proc.StartInfo = $psi
196+
197+ # Allocate explicitly — `$x = if (...) { [List[T]]::new() }` returns $null
198+ # because PowerShell enumerates the empty list.
199+ $stdoutBuffer = $null
200+ if ($CaptureStdout ) {
201+ $stdoutBuffer = [System.Collections.Generic.List [string ]]::new()
202+ }
203+
204+ # Use ThreadJob readers rather than Register-ObjectEvent: Action handlers
205+ # don't drain reliably while the main runspace is in Start-Sleep.
206+ $readerScript = {
207+ param ([System.IO.StreamReader ]$Reader ,
208+ [System.Collections.Concurrent.ConcurrentQueue [string ]]$Queue )
209+ try {
210+ while ($null -ne ($line = $Reader.ReadLine ())) {
211+ [void ]$Queue.Enqueue ($line )
212+ }
213+ } catch {
214+ # IO errors on process teardown are expected; main thread owns exit.
215+ }
216+ }
217+
218+ $outJob = $null
219+ $errJob = $null
220+
221+ try {
222+ [void ]$proc.Start ()
223+
224+ $outJob = Start-ThreadJob - ScriptBlock $readerScript - ArgumentList $proc.StandardOutput , $stdoutQueue
225+ $errJob = Start-ThreadJob - ScriptBlock $readerScript - ArgumentList $proc.StandardError , $stderrQueue
226+
227+ $line = $null
228+ while (-not $proc.HasExited -or
229+ $outJob.State -eq ' Running' -or
230+ $errJob.State -eq ' Running' ) {
231+ Start-Sleep - Milliseconds 100
232+ while ($stderrQueue.TryDequeue ([ref ]$line )) {
233+ Write-Host (Protect-LogLine - Line $line - Replacements $Replacements )
234+ }
235+ while ($stdoutQueue.TryDequeue ([ref ]$line )) {
236+ if ($CaptureStdout ) {
237+ [void ]$stdoutBuffer.Add ($line )
238+ } else {
239+ Write-Host (Protect-LogLine - Line $line - Replacements $Replacements )
240+ }
241+ }
242+ }
243+
244+ # Readers exit on EOF once the child closes its pipes.
245+ Wait-Job - Job $outJob , $errJob | Out-Null
246+
247+ while ($stderrQueue.TryDequeue ([ref ]$line )) {
248+ Write-Host (Protect-LogLine - Line $line - Replacements $Replacements )
249+ }
250+ while ($stdoutQueue.TryDequeue ([ref ]$line )) {
251+ if ($CaptureStdout ) {
252+ [void ]$stdoutBuffer.Add ($line )
253+ } else {
254+ Write-Host (Protect-LogLine - Line $line - Replacements $Replacements )
255+ }
256+ }
257+
258+ $global :LASTEXITCODE = $proc.ExitCode
259+ }
260+ finally {
261+ if ($outJob ) { Remove-Job - Job $outJob - Force - ErrorAction SilentlyContinue }
262+ if ($errJob ) { Remove-Job - Job $errJob - Force - ErrorAction SilentlyContinue }
263+ $proc.Dispose ()
264+ }
265+
266+ if ($CaptureStdout ) {
267+ return ($stdoutBuffer -join " `n " )
268+ }
269+ }
270+
271+ # Run `npx apiops <args>` masking ALL output streams. Returns the exit code.
140272function Invoke-MaskedApiopsCommand {
273+ [CmdletBinding ()]
141274 param (
142- [scriptblock ] $Command ,
275+ [Parameter ( Mandatory )][ string []] $Arguments ,
143276 [hashtable ]$Replacements
144277 )
145278
146- & $Command 2>&1 | ForEach-Object {
147- $message = $_.ToString ()
148- Write-Host (Protect-LogLine - Line $message - Replacements $Replacements )
149- }
279+ Invoke-MaskedProcess - FilePath ' npx' `
280+ - Arguments (@ (' apiops' ) + $Arguments ) `
281+ - Replacements $Replacements
150282
151283 return $LASTEXITCODE
152284}
153285
154- # Invoke a native command (typically `az`) capturing its stdout for return while
155- # streaming stderr through Protect-LogLine so verbose/debug output cannot leak
156- # secrets (subscription id, resource group name, etc.). Returns stdout as a
286+ # Run `az <args>` capturing stdout (typically JSON) for the caller while
287+ # masking and streaming stderr (verbose/debug output). Returns stdout as a
157288# single string. Sets/preserves $LASTEXITCODE for the caller.
158289function Invoke-MaskedAzCommand {
290+ [CmdletBinding ()]
159291 param (
160- [scriptblock ] $Command ,
292+ [Parameter ( Mandatory )][ string []] $Arguments ,
161293 [hashtable ]$Replacements
162294 )
163295
164- $stdoutLines = New-Object System.Collections.Generic.List[string ]
165-
166- & $Command 2>&1 | ForEach-Object {
167- if ($_ -is [System.Management.Automation.ErrorRecord ]) {
168- $message = $_.Exception.Message
169- if ([string ]::IsNullOrEmpty($message )) { $message = $_.ToString () }
170- Write-Host (Protect-LogLine - Line $message - Replacements $Replacements )
171- } else {
172- $stdoutLines.Add ([string ]$_ )
173- }
174- }
175-
176- return ($stdoutLines -join " `n " )
296+ return Invoke-MaskedProcess - FilePath ' az' `
297+ - Arguments $Arguments `
298+ - Replacements $Replacements `
299+ - CaptureStdout
177300}
178301
179302Export-ModuleMember - Function `
180303 Protect-Identifier , `
181304 Protect-SubscriptionId , `
182305 Protect-ResourceGroupName , `
183306 Protect-LogLine , `
307+ Resolve-NativeExecutable , `
308+ Invoke-MaskedProcess , `
184309 Invoke-MaskedApiopsCommand , `
185310 Invoke-MaskedAzCommand
0 commit comments