From e2fa0b5e7386998fed3e62455f1b43e8b29983dc Mon Sep 17 00:00:00 2001 From: Adam Leff Date: Thu, 31 Aug 2017 03:53:08 -0400 Subject: [PATCH] port resource: support ss instead of netstat (#2110) * port resource: support ss instead of netstat `netstat` is officially deprecated and is replaced with `ss`. This PR changes the port resource to use `ss` if it's available on the target system. Signed-off-by: Adam Leff * Disable Metrics/ClassLength cop on the LinuxPorts class Signed-off-by: Adam Leff --- lib/resources/port.rb | 88 +++++++++++++++++++++++++++++++- test/helper.rb | 2 + test/unit/mock/cmd/ss-tulpen | 8 +++ test/unit/resources/port_test.rb | 55 +++++++++----------- 4 files changed, 121 insertions(+), 32 deletions(-) create mode 100644 test/unit/mock/cmd/ss-tulpen diff --git a/lib/resources/port.rb b/lib/resources/port.rb index c46d26936..3eca62a37 100644 --- a/lib/resources/port.rb +++ b/lib/resources/port.rb @@ -264,10 +264,34 @@ module Inspec::Resources end # extract port information from netstat - class LinuxPorts < PortsInfo + class LinuxPorts < PortsInfo # rubocop:disable Metrics/ClassLength + ALLOWED_PROTOCOLS = %w{tcp tcp6 udp udp6}.freeze + def info + ports_via_ss || ports_via_netstat + end + + def ports_via_ss + return nil unless inspec.command('ss').exist? + + cmd = inspec.command('ss -tulpen') + return nil unless cmd.exit_status.to_i.zero? + + ports = [] + + cmd.stdout.each_line do |line| + parsed_line = parse_ss_line(line) + ports << parsed_line unless parsed_line.nil? + end + + ports + end + + def ports_via_netstat + return nil unless inspec.command('netstat').exist? + cmd = inspec.command('netstat -tulpen') - return nil if cmd.exit_status.to_i != 0 + return nil unless cmd.exit_status.to_i.zero? ports = [] # parse all lines @@ -362,6 +386,66 @@ module Inspec::Resources 'pid' => pid, } end + + def parse_ss_line(line) + parsed = line.split(/\s+/, 7) + + # ss only returns "tcp" and "udp" as the protocol. However, netstat would return + # "tcp6" and "udp6" as necessary. In order to maintain backward compatibility, we + # will manually modify the protocol value if the line we're parsing is an IPv6 + # entry. + process_info = parsed[6] + protocol = parsed[0] + protocol += '6' if process_info.include?('v6only:1') + return nil unless ALLOWED_PROTOCOLS.include?(protocol) + + # parse the Local Address:Port + # examples: + # *:22 + # :::22 + # 10.0.2.15:1234 + # ::ffff:10.0.2.15:9300 + # fe80::a00:27ff:fe32:ed09%enp0s3:9200 + parsed_net_address = parsed[4].match(/(\S+):(\*|\d+)$/) + return nil if parsed_net_address.nil? + host = parsed_net_address[1] + port = parsed_net_address[2] + return nil if host.nil? && port.nil? + + # For backward compatibility with the netstat output, ensure the + # port is stored as an integer + port = port.to_i + + # for those "v4-but-listed-in-v6" entries, strip off the + # leading IPv6 value at the beginning + # example: ::ffff:10.0.2.15:9200 + host.delete!('::ffff:') if host.start_with?('::ffff:') + + # if there's an interface name in the local address, which is common for + # IPv6 listeners, strip that out too. + # example: fe80::a00:27ff:fe32:ed09%enp0s3 + host = host.split('%').first + + # if host is "*", replace with "0.0.0.0" to maintain backward compatibility with + # the netstat-provided data + host = '0.0.0.0' if host == '*' + + # parse the process name from the processes information + process_match = parsed[6].match(/users:\(\(\"(\S+)\"/) + process = process_match.nil? ? nil : process_match[1] + + # parse the PID from the processes information + pid_match = parsed[6].match(/pid=(\d+)/) + pid = pid_match.nil? ? nil : pid_match[1].to_i + + { + 'port' => port, + 'address' => host, + 'protocol' => protocol, + 'process' => process, + 'pid' => pid, + } + end end # extracts information from sockstat diff --git a/test/helper.rb b/test/helper.rb index 09780175b..cf79c265b 100644 --- a/test/helper.rb +++ b/test/helper.rb @@ -218,7 +218,9 @@ class MockLoader # lsof formatted list of ports (should be quite cross platform) 'lsof -nP -i -FpctPn' => cmd.call('lsof-nP-i-FpctPn'), # ports on linux + %{bash -c 'type "ss"'} => empty.call(), # allow the ss command to exist so the later mock is called 'netstat -tulpen' => cmd.call('netstat-tulpen'), + 'ss -tulpen' => cmd.call('ss-tulpen'), # ports on freebsd 'sockstat -46l' => cmd.call('sockstat'), # packages on windows diff --git a/test/unit/mock/cmd/ss-tulpen b/test/unit/mock/cmd/ss-tulpen new file mode 100644 index 000000000..58dd60315 --- /dev/null +++ b/test/unit/mock/cmd/ss-tulpen @@ -0,0 +1,8 @@ +Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port +udp UNCONN 0 0 *:68 *:* users:(("dhclient",pid=1146,fd=6)) ino:15168 sk:1 <-> +tcp LISTEN 0 128 *:22 *:* users:(("sshd",pid=1222,fd=3)) ino:15973 sk:2 <-> +tcp LISTEN 0 128 ::ffff:10.0.2.15:9200 :::* users:(("java",pid=1722,fd=125)) uid:112 ino:19543 sk:8 v6only:0 <-> +tcp LISTEN 0 128 fe80::a00:27ff:fe32:ed09%enp0s3:9200 :::* users:(("java",pid=1722,fd=124)) uid:112 ino:19542 sk:9 v6only:1 <-> +tcp LISTEN 0 128 ::ffff:10.0.2.15:9300 :::* users:(("java",pid=1722,fd=117)) uid:112 ino:19502 sk:a v6only:0 <-> +tcp LISTEN 0 128 fe80::a00:27ff:fe32:ed09%enp0s3:9300 :::* users:(("java",pid=1722,fd=115)) uid:112 ino:19494 sk:b v6only:1 <-> +tcp LISTEN 0 128 :::22 :::* users:(("sshd",pid=1222,fd=4)) ino:15982 sk:3 v6only:1 <-> \ No newline at end of file diff --git a/test/unit/resources/port_test.rb b/test/unit/resources/port_test.rb index 5a0e76b14..bc86111c2 100644 --- a/test/unit/resources/port_test.rb +++ b/test/unit/resources/port_test.rb @@ -10,19 +10,19 @@ describe 'Inspec::Resources::Port' do resource = MockLoader.new(:ubuntu1404).load_resource('port', 22) _(resource.listening?).must_equal true _(resource.protocols).must_equal %w{ tcp tcp6 } - _(resource.pids).must_equal [1] + _(resource.pids).must_equal [1222] _(resource.processes).must_equal ['sshd'] _(resource.addresses).must_equal ["0.0.0.0", "::"] end it 'lists all ports' do resource = MockLoader.new(:ubuntu1404).load_resource('port') - _(resource.entries.length).must_equal 5 + _(resource.entries.length).must_equal 7 _(resource.listening?).must_equal true - _(resource.protocols).must_equal %w{ tcp tcp6 udp } - _(resource.pids).must_equal [1, 2043, 545, 1234] - _(resource.processes).must_equal ['sshd', 'pidgin', 'rpcbind', 'java'] - _(resource.addresses).must_equal ['0.0.0.0', '2601:1:ad80:1445::', '::', '192.168.1.123'] + _(resource.protocols).must_equal %w{ udp tcp tcp6 } + _(resource.pids).must_equal [1146, 1222, 1722] + _(resource.processes).must_equal ['dhclient', 'sshd', 'java'] + _(resource.addresses).must_equal ['0.0.0.0', '10.0.2.15', 'fe80::a00:27ff:fe32:ed09', '::'] end it 'filter ports by conditions' do @@ -30,40 +30,35 @@ describe 'Inspec::Resources::Port' do _(resource.entries.length).must_equal 1 _(resource.listening?).must_equal true _(resource.protocols).must_equal ['udp'] - _(resource.pids).must_equal [545] - _(resource.processes).must_equal ['rpcbind'] + _(resource.pids).must_equal [1146] + _(resource.processes).must_equal ['dhclient'] _(resource.addresses).must_equal ['0.0.0.0'] end - it 'does not include an entry for a malformed IP address' do - # udp6 0 0 fe80::42:acff:fe11::123 :::* 0 54550 3335/ntpd - # the link-local IP is truncated and therefore invalid - resource = MockLoader.new(:ubuntu1404).load_resource('port', 123) - _(resource.entries.length).must_equal 0 - end - it 'verify UDP port on Ubuntu 14.04' do - resource = MockLoader.new(:ubuntu1404).load_resource('port', 111) + resource = MockLoader.new(:ubuntu1404).load_resource('port', 68) + _(resource.entries.length).must_equal 1 _(resource.listening?).must_equal true - _(resource.protocols).must_equal %w{ udp } - _(resource.pids).must_equal [545] - _(resource.processes).must_equal ['rpcbind'] - _(resource.addresses).must_equal ["0.0.0.0"] + _(resource.protocols).must_equal ['udp'] + _(resource.pids).must_equal [1146] + _(resource.processes).must_equal ['dhclient'] + _(resource.addresses).must_equal ['0.0.0.0'] end it 'accepts the port as a string' do - resource = MockLoader.new(:ubuntu1404).load_resource('port', '111') + resource = MockLoader.new(:ubuntu1404).load_resource('port', '68') + _(resource.entries.length).must_equal 1 _(resource.listening?).must_equal true - _(resource.protocols).must_equal %w{ udp } - _(resource.pids).must_equal [545] - _(resource.processes).must_equal ['rpcbind'] - _(resource.addresses).must_equal ["0.0.0.0"] + _(resource.protocols).must_equal ['udp'] + _(resource.pids).must_equal [1146] + _(resource.processes).must_equal ['dhclient'] + _(resource.addresses).must_equal ['0.0.0.0'] end it 'properly handles a IPv4 address in a v6 listing' do - resource = MockLoader.new(:ubuntu1404).load_resource('port', 8005) - _(resource.protocols).must_equal %w{ tcp6 } - _(resource.addresses).must_equal ['192.168.1.123'] + resource = MockLoader.new(:ubuntu1404).load_resource('port', 9200) + _(resource.protocols).must_equal %w{ tcp tcp6 } + _(resource.addresses).must_equal ['10.0.2.15', 'fe80::a00:27ff:fe32:ed09'] end it 'verify port on MacOs x' do @@ -158,7 +153,7 @@ describe 'Inspec::Resources::Port' do it 'verify port on wrlinux' do resource = MockLoader.new(:wrlinux).load_resource('port', 22) _(resource.listening?).must_equal true - _(resource.pids).must_equal [1] + _(resource.pids).must_equal [1222] _(resource.protocols).must_equal %w{ tcp tcp6 } _(resource.processes).must_equal ['sshd'] _(resource.addresses).must_equal ["0.0.0.0", "::"] @@ -177,7 +172,7 @@ describe 'Inspec::Resources::Port' do resource = MockLoader.new(:ubuntu1404).load_resource('port', '0.0.0.0', 22) _(resource.listening?).must_equal true _(resource.protocols).must_equal %w{ tcp } - _(resource.pids).must_equal [1] + _(resource.pids).must_equal [1222] _(resource.processes).must_equal ['sshd'] _(resource.addresses).must_equal ["0.0.0.0"] end