From 3e2450e7033727d77a19a74758e8d39c572f014d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Vale?= Date: Wed, 7 Mar 2018 13:39:27 +0000 Subject: [PATCH] Host resource: use bash over netcat in Linux (#2607) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add support to use bash in host resource Netcat's presence is widely regarded as a security issue, and thus not always available. This solution first tries to use bash builtins and timeout (from coreutils), so is less likely to require installing additional packages. * Darwin UDP support in host resource * Host: use netcat first if available Signed-off-by: João Vale --- lib/resources/host.rb | 130 ++++++++++--------- test/helper.rb | 21 +++- test/unit/mock/cmd/nc-example-com | 0 test/unit/mock/cmd/type-nc | 0 test/unit/resources/host_test.rb | 201 ++++++++++++++++++++++-------- 5 files changed, 231 insertions(+), 121 deletions(-) delete mode 100644 test/unit/mock/cmd/nc-example-com delete mode 100644 test/unit/mock/cmd/type-nc diff --git a/lib/resources/host.rb b/lib/resources/host.rb index f173645c6..a27c07ad3 100644 --- a/lib/resources/host.rb +++ b/lib/resources/host.rb @@ -55,13 +55,13 @@ module Inspec::Resources @protocol = params.fetch(:protocol, 'icmp') end - return skip_resource 'Invalid protocol: only `tcp` and `icmp` protocols are support for the `host` resource.' unless - %w{icmp tcp}.include?(@protocol) - @host_provider = nil if inspec.os.linux? @host_provider = LinuxHostProvider.new(inspec) elsif inspec.os.windows? + return skip_resource 'Invalid protocol: only `tcp` and `icmp` protocols are support for the `host` resource on your OS.' unless + %w{icmp tcp}.include?(@protocol) + @host_provider = WindowsHostProvider.new(inspec) elsif inspec.os.darwin? @host_provider = DarwinHostProvider.new(inspec) @@ -147,6 +147,69 @@ module Inspec::Resources end class UnixHostProvider < HostProvider + def initialize(inspec) + super + + @has_nc = inspec.command('nc').exist? + @has_ncat = inspec.command('ncat').exist? + @has_net_redirections = inspec.command("strings `which bash` | grep -qE '/dev/(tcp|udp)/'").exit_status == 0 + end + + def missing_requirements(protocol) + missing = [] + + if %w{tcp udp}.include?(protocol) && !@has_nc && !@has_ncat + if @has_net_redirections + missing << "#{timeout} (part of coreutils) or netcat must be installed" unless inspec.command(timeout).exist? + else + missing << 'netcat must be installed' + end + end + + missing + end + + def ping(hostname, port, protocol) + if %w{tcp udp}.include?(protocol) + if @has_nc || @has_ncat + resp = inspec.command(netcat_check_command(hostname, port, protocol)) + else + resp = inspec.command("#{timeout} 1 bash -c \"< /dev/#{protocol}/#{hostname}/#{port}\"") + end + else + # fall back to ping, but we can only test ICMP packages with ping + resp = inspec.command("ping -w 1 -c 1 #{hostname}") + end + + { + success: resp.exit_status.to_i.zero?, + connection: resp.stderr, + socket: resp.stdout, + } + end + + def netcat_check_command(hostname, port, protocol) + if @has_nc + base_cmd = 'nc' + elsif @has_ncat + base_cmd = 'ncat' + else + return + end + + if protocol == 'udp' + extra_flags = '-u' + else + extra_flags = '' + end + + "echo | #{base_cmd} -v -w 1 #{extra_flags} #{hostname} #{port}" + end + + def timeout + 'timeout' + end + def resolve_with_dig(hostname) addresses = [] @@ -191,28 +254,8 @@ module Inspec::Resources end class DarwinHostProvider < UnixHostProvider - def missing_requirements(protocol) - missing = [] - - if protocol == 'tcp' - missing << 'netcat must be installed' unless inspec.command('nc').exist? - end - - missing - end - - def ping(hostname, port, protocol) - if protocol == 'tcp' - resp = inspec.command("nc -vz -G 1 #{hostname} #{port}") - else - resp = inspec.command("ping -W 1 -c 1 #{hostname}") - end - - { - success: resp.exit_status.to_i.zero?, - connection: resp.stderr, - socket: resp.stdout, - } + def timeout + 'gtimeout' end def resolve(hostname) @@ -221,43 +264,6 @@ module Inspec::Resources end class LinuxHostProvider < UnixHostProvider - def missing_requirements(protocol) - missing = [] - - if protocol == 'tcp' && (!inspec.command('nc').exist? && !inspec.command('ncat').exist?) - missing << 'netcat must be installed' - end - - missing - end - - def ping(hostname, port, protocol) - if protocol == 'tcp' - resp = inspec.command(tcp_check_command(hostname, port)) - else - # fall back to ping, but we can only test ICMP packages with ping - resp = inspec.command("ping -w 1 -c 1 #{hostname}") - end - - { - success: resp.exit_status.to_i.zero?, - connection: resp.stderr, - socket: resp.stdout, - } - end - - def tcp_check_command(hostname, port) - if inspec.command('nc').exist? - base_cmd = 'nc' - elsif inspec.command('ncat').exist? - base_cmd = 'ncat' - else - return - end - - "echo | #{base_cmd} -v -w 1 #{hostname} #{port}" - end - def resolve(hostname) resolve_with_getent(hostname) end diff --git a/test/helper.rb b/test/helper.rb index fb28e3897..1994361b0 100644 --- a/test/helper.rb +++ b/test/helper.rb @@ -415,13 +415,22 @@ class MockLoader "modprobe --showconfig" => cmd.call('modprobe-config'), # get-process cmdlet for processes resource '$Proc = Get-Process -IncludeUserName | Where-Object {$_.Path -ne $null } | Select-Object PriorityClass,Id,CPU,PM,VirtualMemorySize,NPM,SessionId,Responding,StartTime,TotalProcessorTime,UserName,Path | ConvertTo-Csv -NoTypeInformation;$Proc.Replace("""","").Replace("`r`n","`n")' => cmd.call('get-process_processes'), - # host resource: check to see if netcat is installed - %{bash -c 'type "nc"'} => cmd.call('type-nc'), - 'type "nc"' => cmd.call('type-nc'), - # host resource: netcat for TCP reachability check on linux - 'echo | nc -v -w 1 example.com 1234' => cmd.call('nc-example-com'), + # host resource: TCP/UDP reachability check on linux + %{bash -c 'type "nc"'} => empty.call, + %{bash -c 'type "ncat"'} => empty.call, + %{bash -c 'type "timeout"'} => empty.call, + %{strings `which bash` | grep -qE '/dev/(tcp|udp)/'} => empty.call, + %{echo | nc -v -w 1 -u example.com 1234} => empty.call, + %{echo | nc -v -w 1 example.com 1234} => empty.call, + 'timeout 1 bash -c "< /dev/tcp/example.com/1234"' => empty.call, + 'timeout 1 bash -c "< /dev/udp/example.com/1234"' => empty.call, # host resource: netcat for TCP reachability check on darwin - 'nc -vz -G 1 example.com 1234' => cmd.call('nc-example-com'), + 'type "nc"' => empty.call, + 'type "ncat"' => empty.call, + 'type "gtimeout"' => empty.call, + 'nc -vz -G 1 example.com 1234' => empty.call, + 'gtimeout 1 bash -c "< /dev/tcp/example.com/1234"' => empty.call, + 'gtimeout 1 bash -c "< /dev/udp/example.com/1234"' => empty.call, # host resource: test-netconnection for reachability check on windows 'Test-NetConnection -ComputerName microsoft.com -WarningAction SilentlyContinue -RemotePort 1234| Select-Object -Property ComputerName, TcpTestSucceeded, PingSucceeded | ConvertTo-Json' => cmd.call('Test-NetConnection'), # postgres tests diff --git a/test/unit/mock/cmd/nc-example-com b/test/unit/mock/cmd/nc-example-com deleted file mode 100644 index e69de29bb..000000000 diff --git a/test/unit/mock/cmd/type-nc b/test/unit/mock/cmd/type-nc deleted file mode 100644 index e69de29bb..000000000 diff --git a/test/unit/resources/host_test.rb b/test/unit/resources/host_test.rb index edef604fb..3d0978fdf 100644 --- a/test/unit/resources/host_test.rb +++ b/test/unit/resources/host_test.rb @@ -55,6 +55,14 @@ describe 'Inspec::Resources::Host' do _(resource.to_s).must_equal 'Host example.com port 1234 proto tcp' end + it 'check host udp on ubuntu' do + resource = MockLoader.new(:ubuntu1404).load_resource('host', 'example.com', port: 1234, protocol: 'udp') + _(resource.resolvable?).must_equal true + _(resource.reachable?).must_equal true + _(resource.ipaddress).must_equal ["12.34.56.78", "2606:2800:220:1:248:1893:25c8:1946"] + _(resource.to_s).must_equal 'Host example.com port 1234 proto udp' + end + it 'check host tcp on centos 7' do resource = MockLoader.new(:centos7).load_resource('host', 'example.com', port: 1234, protocol: 'tcp') _(resource.resolvable?).must_equal true @@ -63,6 +71,14 @@ describe 'Inspec::Resources::Host' do _(resource.to_s).must_equal 'Host example.com port 1234 proto tcp' end + it 'check host udp on centos 7' do + resource = MockLoader.new(:centos7).load_resource('host', 'example.com', port: 1234, protocol: 'udp') + _(resource.resolvable?).must_equal true + _(resource.reachable?).must_equal true + _(resource.ipaddress).must_equal ["12.34.56.78", "2606:2800:220:1:248:1893:25c8:1946"] + _(resource.to_s).must_equal 'Host example.com port 1234 proto udp' + end + it 'check host tcp on darwin' do resource = MockLoader.new(:osx104).load_resource('host', 'example.com', port: 1234, protocol: 'tcp') _(resource.resolvable?).must_equal true @@ -71,6 +87,14 @@ describe 'Inspec::Resources::Host' do _(resource.to_s).must_equal 'Host example.com port 1234 proto tcp' end + it 'check host udp on darwin' do + resource = MockLoader.new(:osx104).load_resource('host', 'example.com', port: 1234, protocol: 'udp') + _(resource.resolvable?).must_equal true + _(resource.reachable?).must_equal true + _(resource.ipaddress).must_equal ["12.34.56.78", "2606:2800:220:1:248:1893:25c8:1946"] + _(resource.to_s).must_equal 'Host example.com port 1234 proto udp' + end + it 'check host tcp on windows' do resource = MockLoader.new(:windows).load_resource('host', 'microsoft.com', port: 1234, protocol: 'tcp') _(resource.resolvable?).must_equal true @@ -89,12 +113,31 @@ describe 'Inspec::Resources::Host' do end describe Inspec::Resources::UnixHostProvider do + let(:provider) { Inspec::Resources::UnixHostProvider.new(inspec) } + let(:inspec) { mock('inspec-backend') } + let(:nc_command) { mock('nc-command') } + let(:ncat_command) { mock('ncat-command') } + let(:timeout_command) { mock("timeout-command") } + let(:strings_command) { mock("strings-command") } + + before do + inspec.stubs(:command).with('nc').returns(nc_command) + inspec.stubs(:command).with('ncat').returns(ncat_command) + inspec.stubs(:command).with('timeout').returns(timeout_command) + inspec.stubs(:command).with('gtimeout').returns(timeout_command) + inspec.stubs(:command).with("strings `which bash` | grep -qE '/dev/(tcp|udp)/'").returns(strings_command) + end + describe '#resolve_with_dig' do - let(:provider) { Inspec::Resources::UnixHostProvider.new(inspec) } - let(:inspec) { mock('inspec-backend') } let(:v4_command) { mock('v4_command') } let(:v6_command) { mock('v6_command') } + before do + strings_command.stubs(:exit_status).returns(0) + nc_command.stubs(:exist?).returns(false) + ncat_command.stubs(:exist?).returns(false) + end + it 'returns an array of IP addresses' do ipv4_command_output = <<-EOL a.cname.goes.here @@ -169,16 +212,20 @@ EOL end describe '#resolve_with_getent' do + before do + strings_command.stubs(:exit_status).returns(0) + nc_command.stubs(:exist?).returns(false) + ncat_command.stubs(:exist?).returns(false) + end + it 'returns an array of IP addresses when successful' do command_output = "123.123.123.123 STREAM testdomain.com\n2607:f8b0:4004:805::200e STREAM\n" command = mock('getent_command') command.stubs(:stdout).returns(command_output) command.stubs(:exit_status).returns(0) - inspec = mock('inspec') inspec.stubs(:command).with('getent ahosts testdomain.com').returns(command) - provider = Inspec::Resources::UnixHostProvider.new(inspec) provider.resolve_with_getent('testdomain.com').must_equal(['123.123.123.123', '2607:f8b0:4004:805::200e']) end @@ -186,84 +233,132 @@ EOL command = mock('getent_command') command.stubs(:exit_status).returns(1) - inspec = mock('inspec') inspec.stubs(:command).with('getent ahosts testdomain.com').returns(command) - provider = Inspec::Resources::UnixHostProvider.new(inspec) provider.resolve_with_getent('testdomain.com').must_be_nil end end -end -describe Inspec::Resources::LinuxHostProvider do - let(:provider) { Inspec::Resources::LinuxHostProvider.new(inspec) } - let(:inspec) { mock('inspec-backend') } - let(:nc_command) { mock('nc-command') } - let(:ncat_command) { mock('ncat-command') } - before do - provider.stubs(:inspec).returns(inspec) + describe "#ping" do + let(:command_response) { mock('response') } + + before do + strings_command.stubs(:exit_status).returns(0) + ncat_command.stubs(:exist?).returns(false) + + command_response.stubs(:exit_status).returns('0') + command_response.stubs(:stdout).returns('foo') + command_response.stubs(:stderr).returns('bar') + end + + it "calls netcat if available" do + nc_command.stubs(:exist?).returns(true) + inspec.expects(:command).with('echo | nc -v -w 1 example.com 1234').returns(command_response) + + provider.ping('example.com', '1234', 'tcp') + end + + it "uses bash if netcat not available" do + nc_command.stubs(:exist?).returns(false) + inspec.expects(:command).with('timeout 1 bash -c "< /dev/tcp/example.com/1234"').returns(command_response) + + provider.ping('example.com', '1234', 'tcp') + end + + it "uses bash if netcat not available on Darwin" do + nc_command.stubs(:exist?).returns(false) + inspec.expects(:command).with('gtimeout 1 bash -c "< /dev/tcp/example.com/1234"').returns(command_response) + + darwin_provider = Inspec::Resources::DarwinHostProvider.new(inspec) + darwin_provider.ping('example.com', '1234', 'tcp') + end end + describe '#missing_requirements' do - it "returns an empty array if nc is installed but ncat is not installed" do - inspec.stubs(:command).with('nc').returns(nc_command) - nc_command.stubs(:exist?).returns(true) - inspec.stubs(:command).with('ncat').returns(ncat_command) - ncat_command.stubs(:exist?).returns(false) + describe 'bash with net redirects and no netcat' do + before do + strings_command.stubs(:exit_status).returns(0) + nc_command.stubs(:exist?).returns(false) + ncat_command.stubs(:exist?).returns(false) + end - provider.missing_requirements('tcp').must_equal([]) + it "returns an empty array if timeout is available" do + timeout_command.stubs(:exist?).returns(true) + provider.missing_requirements('tcp').must_equal([]) + end + + it "returns a missing requirement when timeout is missing" do + timeout_command.stubs(:exist?).returns(false) + provider.missing_requirements('tcp').must_equal(['timeout (part of coreutils) or netcat must be installed']) + end end - it "returns an empty array if nc is not installed but ncat is installed" do - inspec.stubs(:command).with('nc').returns(nc_command) - nc_command.stubs(:exist?).returns(false) - inspec.stubs(:command).with('ncat').returns(ncat_command) - ncat_command.stubs(:exist?).returns(true) + describe 'bash without net redirects' do + before do + strings_command.stubs(:exit_status).returns(1) + end - provider.missing_requirements('tcp').must_equal([]) - end + it "returns an empty array if nc is installed but ncat is not installed" do + nc_command.stubs(:exist?).returns(true) + ncat_command.stubs(:exist?).returns(false) + provider.missing_requirements('tcp').must_equal([]) + end - it "returns an empty array if both nc and ncat are installed" do - inspec.stubs(:command).with('nc').returns(nc_command) - nc_command.stubs(:exist?).returns(true) - inspec.stubs(:command).with('ncat').returns(ncat_command) - ncat_command.stubs(:exist?).returns(true) + it "returns an empty array if nc is not installed but ncat is installed" do + nc_command.stubs(:exist?).returns(false) + ncat_command.stubs(:exist?).returns(true) + provider.missing_requirements('tcp').must_equal([]) + end - provider.missing_requirements('tcp').must_equal([]) - end + it "returns an empty array if both nc and ncat are installed" do + nc_command.stubs(:exist?).returns(true) + ncat_command.stubs(:exist?).returns(true) + provider.missing_requirements('tcp').must_equal([]) + end - it "returns a missing requirement when neither nc nor ncat are installed" do - inspec.stubs(:command).with('nc').returns(nc_command) - nc_command.stubs(:exist?).returns(false) - inspec.stubs(:command).with('ncat').returns(ncat_command) - ncat_command.stubs(:exist?).returns(false) - - provider.missing_requirements('tcp').must_equal(['netcat must be installed']) + it "returns a missing requirement when neither nc nor ncat are installed" do + nc_command.stubs(:exist?).returns(false) + ncat_command.stubs(:exist?).returns(false) + provider.missing_requirements('tcp').must_equal(['netcat must be installed']) + end end end - describe '#tcp_check_command' do - it 'returns an nc command when nc exists' do - inspec.expects(:command).with('nc').returns(nc_command) - nc_command.expects(:exist?).returns(true) - provider.tcp_check_command('foo', 1234).must_equal 'echo | nc -v -w 1 foo 1234' + describe '#netcat_check_command' do + before do + strings_command.stubs(:exit_status).returns(1) end - it 'returns an ncat command when nc does not exist but ncat exists' do - inspec.expects(:command).with('nc').returns(nc_command) - inspec.expects(:command).with('ncat').returns(ncat_command) + it 'returns an nc command when nc exists tcp' do + nc_command.expects(:exist?).returns(true) + ncat_command.expects(:exist?).returns(false) + provider.netcat_check_command('foo', 1234, 'tcp').must_equal 'echo | nc -v -w 1 foo 1234' + end + + it 'returns an nc command when nc exists udp' do + nc_command.expects(:exist?).returns(true) + ncat_command.expects(:exist?).returns(false) + provider.netcat_check_command('foo', 1234, 'udp').must_equal 'echo | nc -v -w 1 -u foo 1234' + end + + it 'returns an ncat command when nc does not exist but ncat exists tcp' do nc_command.expects(:exist?).returns(false) ncat_command.expects(:exist?).returns(true) - provider.tcp_check_command('foo', 1234).must_equal 'echo | ncat -v -w 1 foo 1234' + provider.netcat_check_command('foo', 1234, 'tcp').must_equal 'echo | ncat -v -w 1 foo 1234' + end + + it 'returns an ncat command when nc does not exist but ncat exists udp' do + nc_command.expects(:exist?).returns(false) + ncat_command.expects(:exist?).returns(true) + provider.netcat_check_command('foo', 1234, 'udp').must_equal 'echo | ncat -v -w 1 -u foo 1234' end it 'returns nil if neither nc or ncat exist' do - inspec.expects(:command).with('nc').returns(nc_command) - inspec.expects(:command).with('ncat').returns(ncat_command) nc_command.expects(:exist?).returns(false) ncat_command.expects(:exist?).returns(false) - provider.tcp_check_command('foo', 1234).must_be_nil + provider.netcat_check_command('foo', 1234, 'tcp').must_be_nil end end end