class Inspec::Resources::LinuxPorts

extract port information from netstat

def info

def info
  ports_via_ss || ports_via_netstat
end

def parse_net_address(net_addr, protocol)

def parse_net_address(net_addr, protocol)
  if protocol.eql?('tcp6') || protocol.eql?('udp6')
    # prep for URI parsing, parse ip6 port
    ip6 = /^(\S+):(\d+)$/.match(net_addr)
    ip6addr = ip6[1]
    ip6addr = '::' if ip6addr =~ /^:::$/
    # v6 addresses need to end in a double-colon when using
    # shorthand notation. netstat ends with a single colon.
    # IPAddr will fail to properly parse an address unless it
    # uses a double-colon for short-hand notation.
    ip6addr += ':' if ip6addr =~ /\w:$/
    begin
      ip_parser = IPAddr.new(ip6addr)
    rescue IPAddr::InvalidAddressError
      # This IP is not parsable. There appears to be a bug in netstat
      # output that truncates link-local IP addresses:
      # example: udp6 0 0 fe80::42:acff:fe11::123 :::* 0 54550 3335/ntpd
      # actual link address: inet6 fe80::42:acff:fe11:5/64 scope link
      #
      # in this example, the "5" is truncated making the netstat output
      # an invalid IP address.
      return [nil, nil]
    end
    # Check to see if this is a IPv4 address in a tcp6/udp6 line.
    # If so, don't put brackets around the IP or URI won't know how
    # to properly handle it.
    # example: tcp6       0      0 127.0.0.1:8005          :::*                    LISTEN
    if ip_parser.ipv4?
      ip_addr = URI("addr://#{ip6addr}:#{ip6[2]}")
      host = ip_addr.host
    else
      ip_addr = URI("addr://[#{ip6addr}]:#{ip6[2]}")
      # strip []
      host = ip_addr.host[1..ip_addr.host.size-2]
    end
  else
    ip_addr = URI('addr://'+net_addr)
    host = ip_addr.host
  end
  port = ip_addr.port
  [host, port]
rescue URI::InvalidURIError => e
  warn "Could not parse #{net_addr}, #{e}"
  nil
end

def parse_netstat_line(line)

def parse_netstat_line(line)
  # parse each line
  # 1 - Proto, 2 - Recv-Q, 3 - Send-Q, 4 - Local Address, 5 - Foreign Address, 6 - State, 7 - Inode, 8 - PID/Program name
  parsed = /^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)?\s+(\S+)\s+(\S+)\s+(\S+)/.match(line)
  return {} if parsed.nil? || line.match(/^proto/i)
  # parse ip4 and ip6 addresses
  protocol = parsed[1].downcase
  # detect protocol if not provided
  protocol += '6' if parsed[4].count(':') > 1 && %w{tcp udp}.include?(protocol)
  # extract host and port information
  host, port = parse_net_address(parsed[4], protocol)
  return {} if host.nil?
  # extract PID
  process = parsed[9].split('/')
  pid = process[0]
  pid = pid.to_i if pid =~ /^\d+$/
  process = process[1]
  {
    'port'     => port,
    'address'  => host,
    'protocol' => protocol,
    'process'  => process,
    'pid'      => pid,
  }
end

def parse_ss_line(line)

def parse_ss_line(line)
  # parsed = line.split(/\s+/, 7)
  parsed = tokenize_ss_line(line)
  # 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[:process_info]
  protocol = parsed[:netid]
  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[:local_addr].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 == '*'
  # in case process list parsing is not successfull
  process = nil
  pid = nil
  # parse process and pid from the process list
  #
  # remove the "users:((" and  "))" parts
  # input: users:((\"nginx\",pid=583,fd=8),(\"nginx\",pid=582,fd=8),(\"nginx\",pid=580,fd=8),(\"nginx\",pid=579,fd=8))
  # res: \"nginx\",pid=583,fd=8),(\"nginx\",pid=582,fd=8),(\"nginx\",pid=580,fd=8),(\"nginx\",pid=579,fd=8
  process_list_match = parsed[:process_info].match(/users:\(\((.+)\)\)/)
  if process_list_match
    # list entires are seperated by "," the braces can also be removed
    # input: \"nginx\",pid=583,fd=8),(\"nginx\",pid=582,fd=8),(\"nginx\",pid=580,fd=8),(\"nginx\",pid=579,fd=8
    # res: ["\"nginx\",pid=583,fd=8", "\"nginx\",pid=582,fd=8", "\"nginx\",pid=580,fd=8", "\"nginx\",pid=579,fd=8"]
    process_list = process_list_match[1].split('),(')
    # To stay backwards compatible with netstat we need to select
    # the last element in the resulting array.
    # res: "\"nginx\",pid=579,fd=8"
    # parse the process name from the process list
    process_match = process_list.last.match(/^\"(\S+)\"/)
    process = process_match.nil? ? nil : process_match[1]
    # parse the PID from the process list
    pid_match = process_list.last.match(/pid=(\d+)/)
    pid = pid_match.nil? ? nil : pid_match[1].to_i
  end
  {
    'port'     => port,
    'address'  => host,
    'protocol' => protocol,
    'process'  => process,
    'pid'      => pid,
  }
end

def ports_via_netstat

def ports_via_netstat
  return nil unless inspec.command('netstat').exist?
  cmd = inspec.command('netstat -tulpen')
  return nil unless cmd.exit_status.to_i.zero?
  ports = []
  # parse all lines
  cmd.stdout.each_line do |line|
    port_info = parse_netstat_line(line)
    # only push protocols we are interested in
    next unless %w{tcp tcp6 udp udp6}.include?(port_info['protocol'])
    ports.push(port_info)
  end
  ports
end

def ports_via_ss

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 tokenize_ss_line(line)

def tokenize_ss_line(line)
  # iproute-2.6.32-54.el6 output:
  # Netid State      Recv-Q Send-Q  Local Address:Port Peer Address:Port
  # udp   UNCONN     0      0       *:111              *:*                 users:(("rpcbind",1123,6)) ino=8680 sk=ffff8801390cf7c0
  # tcp   LISTEN     0      128     *:22               *:*                 users:(("sshd",3965,3)) ino:11604 sk:ffff88013a3b5800
  #
  # iproute-2.6.32-20.el6 output:
  # Netid            Recv-Q Send-Q  Local Address:Port Peer Address:Port
  # udp              0      0       *:111              *:*                 users:(("rpcbind",1123,6)) ino=8680 sk=ffff8801390cf7c0
  # tcp              0      128     *:22               *:*                 users:(("sshd",3965,3)) ino:11604 sk:ffff88013a3b5800
  tokens = line.split(/\s+/, 7)
  if tokens[1] =~ /^\d+$/ # iproute-2.6.32-20
    {
      netid: tokens[0],
      local_addr: tokens[3],
      process_info: tokens[5],
    }
  else # iproute-2.6.32-54
    {
      netid: tokens[0],
      local_addr: tokens[4],
      process_info: tokens[6],
    }
  end
end