diff --git a/lib/train/extras/command_wrapper.rb b/lib/train/extras/command_wrapper.rb index 716d1796..0c8149be 100644 --- a/lib/train/extras/command_wrapper.rb +++ b/lib/train/extras/command_wrapper.rb @@ -121,43 +121,6 @@ def safe_string(str) end end - # this is required if you run locally on windows, - # winrm connections provide a PowerShell shell automatically - # TODO: only activate in local mode - class PowerShellCommand < CommandWrapperBase - Train::Options.attach(self) - - def initialize(backend, options) - @backend = backend - validate_options(options) - end - - def run(script) - # wrap the script to ensure we always run it via powershell - # especially in local mode, we cannot be sure that we get a Powershell - # we may just get a `cmd`. - # TODO: we may want to opt for powershell.exe -command instead of `encodeCommand` - "powershell -NoProfile -encodedCommand #{encoded(safe_script(script))}" - end - - # suppress the progress stream from leaking to stderr - def safe_script(script) - "$ProgressPreference='SilentlyContinue';" + script - end - - # Encodes the script so that it can be passed to the PowerShell - # --EncodedCommand argument. - # @return [String] The UTF-16LE base64 encoded script - def encoded(script) - encoded_script = safe_script(script).encode('UTF-16LE', 'UTF-8') - Base64.strict_encode64(encoded_script) - end - - def to_s - 'PowerShell CommandWrapper' - end - end - class CommandWrapper include_options LinuxCommand @@ -168,10 +131,6 @@ def self.load(transport, options) msg = res.verify fail Train::UserError, "Sudo failed: #{msg}" unless msg.nil? res - # only use powershell command for local transport. winrm transport - # uses powershell as default - elsif transport.os.windows? && transport.class == Train::Transports::Local::Connection - PowerShellCommand.new(transport, options) end end end diff --git a/lib/train/transports/local.rb b/lib/train/transports/local.rb index 70fd1be7..1b731710 100644 --- a/lib/train/transports/local.rb +++ b/lib/train/transports/local.rb @@ -16,28 +16,18 @@ def connection(_ = nil) @connection ||= Connection.new(@options) end - class Connection < BaseConnection # rubocop:disable Metrics/ClassLength - require 'json' - require 'base64' - require 'securerandom' - + class Connection < BaseConnection def initialize(options) super(options) @cmd_wrapper = nil @cmd_wrapper = CommandWrapper.load(self, options) - if @platform.windows? - begin - @pipe = acquire_named_pipe - rescue - @pipe = false - end - end + @platform = platform end def run_command(cmd) - if defined?(@pipe) && @pipe - res = run_powershell_using_named_pipe(cmd) - CommandResult.new(res['stdout'], res['stderr'], res['exitstatus']) + if defined?(@platform) && @platform.windows? + @windows_runner ||= WindowsRunner.new + @windows_runner.run_command(cmd) else cmd = @cmd_wrapper.run(cmd) unless @cmd_wrapper.nil? res = Mixlib::ShellOut.new(cmd) @@ -53,12 +43,11 @@ def local? end def file(path) - @files[path] ||= \ - if os.windows? - Train::File::Local::Windows.new(self, path) - else - Train::File::Local::Unix.new(self, path) - end + @files[path] ||= if os.windows? + Train::File::Local::Windows.new(self, path) + else + Train::File::Local::Unix.new(self, path) + end end def login_command @@ -69,81 +58,131 @@ def uri 'local://' end - private + class WindowsRunner + require 'json' + require 'base64' + require 'securerandom' + + def initialize + @pipe = acquire_pipe + end + + def run_command(cmd) + res = @pipe ? run_via_pipe(cmd) : run_via_shellout(cmd) + Local::CommandResult.new(res.stdout, res.stderr, res.exitstatus) + rescue Errno::ENOENT => _ + CommandResult.new('', '', 1) + end + + private - def acquire_named_pipe - pipe_name = "inspec_#{SecureRandom.hex}" - pipe_location = "//localhost/pipe/#{pipe_name}" - start_named_pipe_server(pipe_name) unless File.exist?(pipe_location) + def acquire_pipe + pipe_name = Dir.entries('//./pipe/').find { |f| f.match?(/inspec_/) } + + return create_pipe("inspec_#{SecureRandom.hex}") if pipe_name.nil? - # Try to acquire pipe for 10 seconds with 0.1 second intervals. - # This allows time for PowerShell to start the pipe - pipe = nil - 100.times do begin - pipe = open(pipe_location, 'r+') - break + pipe = open("//./pipe/#{pipe_name}", 'r+') rescue - sleep 0.1 + # Pipes are closed when a Train connection ends. When running + # multiple independent scans (e.g. Unit tests) the pipe will be + # unavailable because the previous process is closing it. + # This creates a new pipe in that case + pipe = create_pipe("inspec_#{SecureRandom.hex}") end + + return false if pipe.nil? + + pipe end - fail "Could not open named pipe #{pipe_location}" if pipe.nil? - pipe - end + def create_pipe(pipe_name) + start_pipe_server(pipe_name) - def run_powershell_using_named_pipe(script) - script = "$ProgressPreference='SilentlyContinue';" + script - encoded_script = Base64.strict_encode64(script) - @pipe.puts(encoded_script) - @pipe.flush - JSON.parse(Base64.decode64(@pipe.readline)) - end + pipe = nil - def start_named_pipe_server(pipe_name) - require 'win32/process' + # PowerShell needs time to create pipe. + 100.times do + begin + pipe = open("//./pipe/#{pipe_name}", 'r+') + break + rescue + sleep 0.1 + end + end - script = <<-EOF - $ErrorActionPreference = 'Stop' + return false if pipe.nil? - $pipeServer = New-Object System.IO.Pipes.NamedPipeServerStream('#{pipe_name}') - $pipeReader = New-Object System.IO.StreamReader($pipeServer) - $pipeWriter = New-Object System.IO.StreamWriter($pipeServer) + pipe + end - $pipeServer.WaitForConnection() + def run_via_shellout(script) + # Prevent progress stream from leaking into stderr + script = "$ProgressPreference='SilentlyContinue';" + script - # Create loop to receive and process user commands/scripts - $clientConnected = $true - while($clientConnected) { - $input = $pipeReader.ReadLine() - $command = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($input)) + # Encode script so PowerShell can use it + script = script.encode('UTF-16LE', 'UTF-8') + base64_script = Base64.strict_encode64(script) - # Execute user command/script and convert result to JSON - $scriptBlock = $ExecutionContext.InvokeCommand.NewScriptBlock($command) - try { - $stdout = & $scriptBlock | Out-String - $result = @{ 'stdout' = $stdout ; 'stderr' = ''; 'exitstatus' = 0 } - } catch { - $stderr = $_ | Out-String - $result = @{ 'stdout' = ''; 'stderr' = $_; 'exitstatus' = 1 } - } - $resultJSON = $result | ConvertTo-JSON + cmd = "powershell -NoProfile -EncodedCommand #{base64_script}" - # Encode JSON in Base64 and write to pipe - $encodedResult = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($resultJSON)) - $pipeWriter.WriteLine($encodedResult) - $pipeWriter.Flush() - } - EOF + res = Mixlib::ShellOut.new(cmd) + res.run_command + end + + def run_via_pipe(script) + script = "$ProgressPreference='SilentlyContinue';" + script + encoded_script = Base64.strict_encode64(script) + @pipe.puts(encoded_script) + @pipe.flush + OpenStruct.new(JSON.parse(Base64.decode64(@pipe.readline))) + end - utf8_script = script.encode('UTF-16LE', 'UTF-8') - base64_script = Base64.strict_encode64(utf8_script) - cmd = "powershell -NoProfile -ExecutionPolicy bypass -NonInteractive -EncodedCommand #{base64_script}" + def start_pipe_server(pipe_name) + require 'win32/process' + + script = <<-EOF + $ErrorActionPreference = 'Stop' + + $pipeServer = New-Object System.IO.Pipes.NamedPipeServerStream('#{pipe_name}') + $pipeReader = New-Object System.IO.StreamReader($pipeServer) + $pipeWriter = New-Object System.IO.StreamWriter($pipeServer) + + $pipeServer.WaitForConnection() + + # Create loop to receive and process user commands/scripts + $clientConnected = $true + while($clientConnected) { + $input = $pipeReader.ReadLine() + $command = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($input)) + + # Execute user command/script and convert result to JSON + $scriptBlock = $ExecutionContext.InvokeCommand.NewScriptBlock($command) + try { + $stdout = & $scriptBlock | Out-String + $result = @{ 'stdout' = $stdout ; 'stderr' = ''; 'exitstatus' = 0 } + } catch { + $stderr = $_ | Out-String + $result = @{ 'stdout' = ''; 'stderr' = $_; 'exitstatus' = 1 } + } + $resultJSON = $result | ConvertTo-JSON + + # Encode JSON in Base64 and write to pipe + $encodedResult = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($resultJSON)) + $pipeWriter.WriteLine($encodedResult) + $pipeWriter.Flush() + } + EOF + + utf8_script = script.encode('UTF-16LE', 'UTF-8') + base64_script = Base64.strict_encode64(utf8_script) + cmd = "powershell -NoProfile -ExecutionPolicy bypass -NonInteractive -EncodedCommand #{base64_script}" - server_pid = Process.create(command_line: cmd).process_id + server_pid = Process.create(command_line: cmd).process_id - # Ensure process is killed when the Train process exits - at_exit { Process.kill('KILL', server_pid) } + # Ensure process is killed when the Train process exits + at_exit { Process.kill('KILL', server_pid) } + end end end end diff --git a/test/windows/local_test.rb b/test/windows/local_test.rb index a6517749..fc2e7c74 100644 --- a/test/windows/local_test.rb +++ b/test/windows/local_test.rb @@ -40,6 +40,23 @@ cmd.stderr.must_equal '' end + it 'when named pipe is not available it runs `Mixlib::Shellout`' do + # Must call `:conn` early so we can stub `:acquire_pipe` + connection = conn + + # Prevent named pipe from being created + Train::Transports::Local::Connection::WindowsRunner + .any_instance + .stubs(:acquire_pipe) + .returns(false) + + # Verify pipe was not created + SecureRandom.stubs(:hex).returns('minitest') + cmd = connection.run_command('Get-ChildItem //./pipe/ | Where-Object { $_.Name -Match "inspec_minitest" }') + cmd.stdout.must_equal '' + cmd.stderr.must_equal '' + end + describe 'file' do before do @temp = Tempfile.new('foo')