inspec/lib/resources/port.rb
Adam Leff 8e693a4ad9 Fix port resource for invalid IP address in netstat output
Netstat will sometimes output an IPv6 address that is not
formatted correctly; the address is either truncated or uses
or implies the `::` shorthand notation twice. This yields an
invalid IPv6 address and causes IPAddr.new to choke.

This change guards against invalid IP addresses and ensures they
do not end up in the port resource's entries list.

Signed-off-by: Adam Leff <adam@leff.co>
2017-03-29 08:33:50 +02:00

523 lines
16 KiB
Ruby

# encoding: utf-8
# author: Christoph Hartmann
# author: Dominik Richter
require 'utils/parser'
require 'utils/filter'
require 'ipaddr'
# TODO: currently we return local ip only
# TODO: improve handling of same port on multiple interfaces
module Inspec::Resources
class Port < Inspec.resource(1)
name 'port'
desc "Use the port InSpec audit resource to test basic port properties, such as port, process, if it's listening."
example "
describe port(80) do
it { should be_listening }
its('protocols') {should eq ['tcp']}
its('addresses') {should eq ['127.0.0.1']}
end
describe port.where { protocol =~ /tcp/ && port > 80 } do
it { should_not be_listening }
end
"
def initialize(*args)
args.unshift(nil) if args.length <= 1 # add the ip address to the front
@ip = args[0]
@port = if args[1].nil?
nil
else
args[1].to_i
end
@cache = nil
@port_manager = port_manager_for_os
return skip_resource 'The `port` resource is not supported on your OS yet.' if @port_manager.nil?
end
filter = FilterTable.create
filter.add_accessor(:where)
.add_accessor(:entries)
.add(:ports, field: 'port', style: :simple)
.add(:addresses, field: 'address', style: :simple)
.add(:protocols, field: 'protocol', style: :simple)
.add(:processes, field: 'process', style: :simple)
.add(:pids, field: 'pid', style: :simple)
.add(:listening?) { |x| !x.entries.empty? }
filter.connect(self, :info)
def to_s
"Port #{@port}"
end
private
def port_manager_for_os
os = inspec.os
if os.linux?
LinuxPorts.new(inspec)
elsif %w{darwin aix}.include?(os[:family])
# AIX: see http://www.ibm.com/developerworks/aix/library/au-lsof.html#resources
# and https://www-01.ibm.com/marketing/iwm/iwm/web/reg/pick.do?source=aixbp
# Darwin: https://developer.apple.com/library/mac/documentation/Darwin/Reference/ManPages/man8/lsof.8.html
LsofPorts.new(inspec)
elsif os.windows?
WindowsPorts.new(inspec)
elsif ['freebsd'].include?(os[:family])
FreeBsdPorts.new(inspec)
elsif os.solaris?
SolarisPorts.new(inspec)
elsif os.hpux?
HpuxPorts.new(inspec)
end
end
def info
return @cache if !@cache.nil?
# abort if os detection has not worked
return @cache = [] if @port_manager.nil?
# query ports
cache = @port_manager.info || []
cache.select! { |x| x['port'] == @port } unless @port.nil?
cache.select! { |x| x['address'] == @ip } unless @ip.nil?
@cache = cache
end
end
# implements an info method and returns all ip adresses and protocols for
# each port
# [{
# 'port' => 22,
# 'address' => '0.0.0.0'
# 'protocol' => 'tcp'
# },
# {
# 'port' => 22,
# 'address' => '::'
# 'protocol' => 'tcp6'
# }]
class PortsInfo
attr_reader :inspec
def initialize(inspec)
@inspec = inspec
end
end
# TODO: Add UDP infromation Get-NetUDPEndpoint
# TODO: currently Windows only supports tcp ports
# TODO: Get-NetTCPConnection does not return PIDs
# TODO: double-check output with 'netstat -ano'
# @see https://connect.microsoft.com/PowerShell/feedback/details/1349420/get-nettcpconnection-does-not-show-processid
class WindowsPorts < PortsInfo
def info
netstat_info || powershell_info
end
private
def powershell_info
cmd = inspec.command('Get-NetTCPConnection -state Listen | Select-Object -Property State, Caption, Description, LocalAddress, LocalPort, RemoteAddress, RemotePort, DisplayName, Status | ConvertTo-Json')
return nil if cmd.exit_status != 0
entries = JSON.parse(cmd.stdout)
return nil if entries.nil?
entries.map { |x|
{
'port' => x['LocalPort'],
'address' => x['LocalAddress'],
'protocol' => 'tcp',
}
}
rescue JSON::ParserError => _e
return nil
end
def netstat_info
# retrieve processes grepping by LISTENING state with 0 lines before and 1 after to catch the process name
# also UDP ports have nothing in the State column
cmd = inspec.command('netstat -anbo | Select-String -CaseSensitive -pattern "^\s+UDP|\s+LISTENING\s+\d+$" -context 0,1')
return nil if cmd.exit_status != 0
lines = cmd.stdout.scan(/^>\s*(tcp\S*|udp\S*)\s+(\S+):(\d+)\s+(\S+)\s+(\S*)\s+(\d+)\s+(.+)/i)
lines.map do |line|
pid = line[5].to_i
process = line[6].delete('[').delete(']').strip
process = 'System' if process == 'Can not obtain ownership information' && pid == 4
{
'port' => line[2].to_i,
'address' => line[1].delete('[').delete(']'),
'protocol' => line[0].downcase,
'pid' => pid,
'process' => process,
}
end
end
end
# extracts udp and tcp ports from the lsof command
class LsofPorts < PortsInfo
attr_reader :lsof
def initialize(inspec, lsofpath = nil)
@lsof = lsofpath || 'lsof'
super(inspec)
end
def info
ports = []
# check that lsof is available, otherwise fail
raise 'Please ensure `lsof` is available on the machine.' if !inspec.command(@lsof.to_s).exist?
# -F p=pid, c=command, P=protocol name, t=type, n=internet addresses
# see 'OUTPUT FOR OTHER PROGRAMS' in LSOF(8)
lsof_cmd = inspec.command("#{@lsof} -nP -i -FpctPn")
return nil if lsof_cmd.exit_status.to_i != 0
# map to desired return struct
lsof_parser(lsof_cmd).each do |process, port_ids|
pid, cmd = process.split(':')
port_ids.each do |port_str|
# should not break on ipv6 addresses
ipv, proto, port, host = port_str.split(':', 4)
ports.push({ 'port' => port.to_i,
'address' => host,
'protocol' => ipv == 'ipv6' ? proto + '6' : proto,
'process' => cmd,
'pid' => pid.to_i })
end
end
ports
end
# rubocop:disable Metrics/CyclomaticComplexity
# rubocop:disable Metrics/AbcSize
def lsof_parser(lsof_cmd)
procs = {}
# build this with formatted output (-F) from lsof
# procs = {
# '123:sshd' => [
# 'ipv4:tcp:22:127.0.0.1',
# 'ipv6:tcp:22:::1',
# 'ipv4:tcp:*',
# 'ipv6:tcp:*',
# ],
# '456:ntpd' => [
# 'ipv4:udp:123:*',
# 'ipv6:udp:123:*',
# ]
# }
proc_id = port_id = nil
lsof_cmd.stdout.each_line do |line|
line.chomp!
key = line.slice!(0)
case key
when 'p'
proc_id = line
port_id = nil
when 'c'
proc_id += ':' + line
when 't'
port_id = line.downcase
when 'P'
port_id += ':' + line.downcase
when 'n'
src, dst = line.split('->')
# skip active comm streams
next if dst
host, port = /^(\S+):(\d+|\*)$/.match(src)[1, 2]
# skip channels from port 0 - what does this mean?
next if port == '*'
# create new array stub if !exist?
procs[proc_id] = [] unless procs.key?(proc_id)
# change address '*' to zero
host = (port_id =~ /^ipv6:/) ? '[::]' : '0.0.0.0' if host == '*'
# entrust URI to scrub the host and port
begin
uri = URI("addr://#{host}:#{port}")
uri.host && uri.port
rescue => e
warn "could not parse URI 'addr://#{host}:#{port}' - #{e}"
next
end
# e.g. 'ipv4:tcp:22:127.0.0.1'
# strip ipv6 squares for inspec
port_id += ':' + port + ':' + host.gsub(/^\[|\]$/, '')
# lsof will give us another port unless it's done
procs[proc_id] << port_id
end
end
procs
end
end
# extract port information from netstat
class LinuxPorts < PortsInfo
def info
cmd = inspec.command('netstat -tulpen')
return nil if 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
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)
# 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
end
# extracts information from sockstat
class FreeBsdPorts < PortsInfo
def info
cmd = inspec.command('sockstat -46l')
return nil if cmd.exit_status.to_i != 0
ports = []
# split on each newline
cmd.stdout.each_line do |line|
port_info = parse_sockstat_line(line)
# push data, if not headerfile
next unless %w{tcp tcp6 udp udp6}.include?(port_info['protocol'])
ports.push(port_info)
end
ports
end
def parse_net_address(net_addr, protocol)
case protocol
when 'tcp4', 'udp4', 'tcp', 'udp'
# replace * with 0.0.0.0
net_addr = net_addr.gsub(/^\*:/, '0.0.0.0:') if net_addr =~ /^*:(\d+)$/
ip_addr = URI('addr://'+net_addr)
host = ip_addr.host
port = ip_addr.port
when 'tcp6', 'udp6'
return [] if net_addr == '*:*' # abort for now
# replace * with 0:0:0:0:0:0:0:0
net_addr = net_addr.gsub(/^\*:/, '0:0:0:0:0:0:0:0:') if net_addr =~ /^*:(\d+)$/
# extract port
ip6 = /^(\S+):(\d+)$/.match(net_addr)
ip6addr = ip6[1]
ip_addr = URI("addr://[#{ip6addr}]:#{ip6[2]}")
# replace []
host = ip_addr.host[1..ip_addr.host.size-2]
port = ip_addr.port
end
[host, port]
rescue URI::InvalidURIError => e
warn "Could not parse #{net_addr}, #{e}"
nil
end
def parse_sockstat_line(line)
# 1 - USER, 2 - COMMAND, 3 - PID, 4 - FD 5 - PROTO, 6 - LOCAL ADDRESS, 7 - FOREIGN ADDRESS
parsed = /^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)$/.match(line)
return {} if parsed.nil?
# extract ip information
protocol = parsed[5].downcase
host, port = parse_net_address(parsed[6], protocol)
return {} if host.nil? or port.nil?
# extract process
process = parsed[2]
# extract PID
pid = parsed[3]
pid = pid.to_i if pid =~ /^\d+$/
# map tcp4 and udp4
protocol = 'tcp' if protocol.eql?('tcp4')
protocol = 'udp' if protocol.eql?('udp4')
{
'port' => port,
'address' => host,
'protocol' => protocol,
'process' => process,
'pid' => pid,
}
end
end
class SolarisPorts < FreeBsdPorts
include SolarisNetstatParser
def info
# extract all port info
cmd = inspec.command('netstat -an -f inet -f inet6')
return nil if cmd.exit_status.to_i != 0
# parse the content
netstat_ports = parse_netstat(cmd.stdout)
# filter all ports, where we `listen`
listen = netstat_ports.select { |val|
!val['state'].nil? && 'listen'.casecmp(val['state']) == 0
}
# map the data
ports = listen.map { |val|
protocol = val['protocol']
local_addr = val['local-address']
# solaris uses 127.0.0.1.57455 instead 127.0.0.1:57455, lets convert the
# the last . to :
local_addr[local_addr.rindex('.')] = ':'
host, port = parse_net_address(local_addr, protocol)
if host.nil?
nil
else
{
'port' => port,
'address' => host,
'protocol' => protocol,
}
end
}
ports.compact
end
end
# extracts information from netstat for hpux
class HpuxPorts < FreeBsdPorts
def info
## Can't use 'netstat -an -f inet -f inet6' as the latter -f option overrides the former one and return only inet ports
cmd1 = inspec.command('netstat -an -f inet')
return nil if cmd1.exit_status.to_i != 0
cmd2 = inspec.command('netstat -an -f inet6')
return nil if cmd2.exit_status.to_i != 0
cmd = cmd1.stdout + cmd2.stdout
ports = []
# parse all lines
cmd.each_line do |line|
port_info = parse_netstat_line(line)
next unless %w{tcp tcp6 udp udp6}.include?(port_info['protocol'])
ports.push(port_info)
end
# select all ports, where we `listen`
ports.select { |val| val if 'listen'.casecmp(val['state']) == 0 }
end
def parse_netstat_line(line)
# parse each line
# 1 - Proto, 2 - Recv-Q, 3 - Send-Q, 4 - Local Address, 5 - Foreign Address, 6 - (state)
parsed = /^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)?/.match(line)
return {} if parsed.nil? || line.match(/^proto/i) || line.match(/^active/i)
protocol = parsed[1].downcase
state = parsed[6].nil?? ' ' : parsed[6].downcase
local_addr = parsed[4]
local_addr[local_addr.rindex('.')] = ':'
# extract host and port information
host, port = parse_net_address(local_addr, protocol)
return {} if host.nil?
# map data
{
'port' => port,
'address' => host,
'protocol' => protocol,
'state' => state,
}
end
end
end