Class: Inspec::Resources::LinuxPorts

Inherits:
PortsInfo
  • Object
show all
Defined in:
lib/inspec/resources/port.rb

Overview

extract port information from netstat

Constant Summary collapse

ALLOWED_PROTOCOLS =
%w{tcp tcp6 udp udp6}.freeze

Instance Attribute Summary

Attributes inherited from PortsInfo

#inspec

Instance Method Summary collapse

Methods inherited from PortsInfo

#initialize

Constructor Details

This class inherits a constructor from Inspec::Resources::PortsInfo

Instance Method Details

#infoObject



391
392
393
# File 'lib/inspec/resources/port.rb', line 391

def info
  ports_via_ss || ports_via_netstat
end

#parse_net_address(net_addr, protocol) ⇒ Object



435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
# File 'lib/inspec/resources/port.rb', line 435

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

#parse_netstat_line(line) ⇒ Object



486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
# File 'lib/inspec/resources/port.rb', line 486

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 - User, 8 - Inode, 9 - PID/Program name
  # * UDP lines have an empty State column and the Busybox variant lacks
  # the User and Inode columns.
  reg =  /^(?<proto>\S+)\s+(\S+)\s+(\S+)\s+(?<local_addr>\S+)\s+(?<foreign_addr>\S+)\s+(\S+)?\s+((\S+)\s+(\S+)\s+)?(?<pid_prog>\S+)/
  parsed = reg.match(line)

  return {} if parsed.nil? || line.match(/^proto/i)

  # parse ip4 and ip6 addresses
  protocol = parsed[:proto].downcase

  # detect protocol if not provided
  protocol += "6" if parsed[:local_addr].count(":") > 1 && %w{tcp udp}.include?(protocol)

  # extract host and port information
  host, port = parse_net_address(parsed[:local_addr], protocol)
  return {} if host.nil?

  # extract PID
  process = parsed[:pid_prog].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

#parse_ss_line(line) ⇒ Object



547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
# File 'lib/inspec/resources/port.rb', line 547

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:")

  # To remove brackets that might surround the IPv6 address
  # example: [::] and [fe80::dc11:b9b6:514b:134]%eth0:123
  host = host.tr("[]", "")

  # 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

#ports_via_netstatObject



416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
# File 'lib/inspec/resources/port.rb', line 416

def ports_via_netstat
  return nil unless inspec.command("netstat").exist?

  cmd = inspec.command("netstat -tulpen")
  return nil unless cmd.exit_status.to_i == 0

  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

#ports_via_ssObject



395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
# File 'lib/inspec/resources/port.rb', line 395

def ports_via_ss
  return nil unless inspec.command("ss").exist?

  if @port.nil?
    cmd = inspec.command("ss -tulpen")
  else
    cmd = inspec.command("ss -tulpen '( dport = #{@port} or sport = #{@port} )'")
  end

  return nil unless cmd.exit_status.to_i == 0

  ports = []

  cmd.stdout.each_line do |line|
    parsed_line = parse_ss_line(line)
    ports << parsed_line unless parsed_line.nil?
  end

  ports
end

#tokenize_ss_line(line) ⇒ Object



521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
# File 'lib/inspec/resources/port.rb', line 521

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