inspec/lib/resources/aws/aws_security_group.rb

250 lines
7.8 KiB
Ruby
Raw Normal View History

require 'set'
require 'ipaddr'
class AwsSecurityGroup < Inspec.resource(1)
name 'aws_security_group'
desc 'Verifies settings for an individual AWS Security Group.'
example "
describe aws_security_group('sg-12345678') do
it { should exist }
end
"
supports platform: 'aws'
include AwsSingularResourceMixin
attr_reader :description, :group_id, :group_name, :vpc_id, :inbound_rules, :outbound_rules
def to_s
"EC2 Security Group #{@group_id}"
end
def allow_in?(criteria = {})
allow(inbound_rules, criteria)
end
RSpec::Matchers.alias_matcher :allow_in, :be_allow_in
def allow_out?(criteria = {})
allow(outbound_rules, criteria)
end
RSpec::Matchers.alias_matcher :allow_out, :be_allow_out
def allow_in_only?(criteria = {})
allow_only(inbound_rules, criteria)
end
RSpec::Matchers.alias_matcher :allow_in_only, :be_allow_in_only
def allow_out_only?(criteria = {})
allow_only(outbound_rules, criteria)
end
RSpec::Matchers.alias_matcher :allow_out_only, :be_allow_out_only
private
def allow_only(rules, criteria)
# allow_{in_out}_only require either a single-rule group, or you
# to select a rule using position.
return false unless rules.count == 1 || criteria.key?(:position)
criteria[:exact] = true
allow(rules, criteria)
end
def allow(rules, criteria)
criteria = allow__check_criteria(criteria)
rules = allow__focus_on_position(rules, criteria)
rules.any? do |rule|
matched = true
matched &&= allow__match_port(rule, criteria)
matched &&= allow__match_protocol(rule, criteria)
matched &&= allow__match_ipv4_range(rule, criteria)
matched
end
end
def allow__check_criteria(raw_criteria)
allowed_criteria = [
:from_port,
:ipv4_range,
:port,
:position,
:protocol,
:to_port,
:exact, # Internal
]
recognized_criteria = {}
allowed_criteria.each do |expected_criterion|
if raw_criteria.key?(expected_criterion)
recognized_criteria[expected_criterion] = raw_criteria.delete(expected_criterion)
end
end
# Any leftovers are unwelcome
unless raw_criteria.empty?
raise ArgumentError, "Unrecognized security group rule 'allow' criteria '#{raw_criteria.keys.join(',')}'. Expected criteria: #{allowed_criteria.join(', ')}"
end
recognized_criteria
end
def allow__focus_on_position(rules, criteria)
return rules unless criteria.key?(:position)
idx = criteria.delete(:position)
# Normalize to a zero-based numeric index
case # rubocop: disable Style/EmptyCaseCondition
when idx.is_a?(Symbol) && idx == :first
idx = 0
when idx.is_a?(Symbol) && idx == :last
idx = rules.count - 1
when idx.is_a?(String)
idx = idx.to_i - 1 # We document this as 1-based, so adjust to be zero-based.
when idx.is_a?(Numeric)
idx -= 1 # We document this as 1-based, so adjust to be zero-based.
else
raise ArgumentError, "aws_security_group 'allow' 'position' criteria must be an integer or the symbols :first or :last"
end
unless idx < rules.count
raise ArgumentError, "aws_security_group 'allow' 'position' criteria #{idx+1} is out of range - there are only #{rules.count} rules for security group #{group_id}."
end
[rules[idx]]
end
def allow__match_port(rule, criteria) # rubocop: disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
if criteria[:exact] || criteria[:from_port] || criteria[:to_port]
# Exact match mode
# :port is shorthand for a single-valued port range.
criteria[:to_port] = criteria[:from_port] = criteria[:port] if criteria[:port]
to = criteria[:to_port]
from = criteria[:from_port]
# It's a match if neither criteria was specified
return true if to.nil? && from.nil?
# Normalize to integers
to = to.to_i unless to.nil?
from = from.to_i unless from.nil?
# It's a match if either was specified and the other was not
return true if rule[:to_port] == to && from.nil?
return true if rule[:from_port] == from && to.nil?
# Finally, both must match.
rule[:to_port] == to && rule[:from_port] == from
elsif !criteria[:port]
# port not specified, match anything
true
else
# Range membership mode
rule_from = rule[:from_port] || 0
rule_to = rule[:to_port] || 65535
(rule_from..rule_to).cover?(criteria[:port].to_i)
end
end
def allow__match_protocol(rule, criteria)
return true unless criteria.key?(:protocol)
prot = criteria[:protocol]
# We provide a "fluency alias" for -1 (any).
prot = '-1' if prot == 'any'
rule[:ip_protocol] == prot
end
def allow__match_ipv4_range(rule, criteria)
return true unless criteria.key?(:ipv4_range)
query = criteria[:ipv4_range]
query = [query] unless query.is_a?(Array)
ranges = rule[:ip_ranges].map { |rng| rng[:cidr_ip] }
if criteria[:exact]
Set.new(query) == Set.new(ranges)
else
# CIDR subset mode
# "Each of the provided IP ranges must be a member of one of the rule's listed IP ranges"
query.all? do |candidate|
candidate = IPAddr.new(candidate)
ranges.any? do |range|
range = IPAddr.new(range)
range.include?(candidate)
end
end
end
end
def validate_params(raw_params)
recognized_params = check_resource_param_names(
raw_params: raw_params,
allowed_params: [:id, :group_id, :group_name, :vpc_id],
allowed_scalar_name: :group_id,
allowed_scalar_type: String,
)
# id is an alias for group_id
recognized_params[:group_id] = recognized_params.delete(:id) if recognized_params.key?(:id)
if recognized_params.key?(:group_id) && recognized_params[:group_id] !~ /^sg\-[0-9a-f]{8}/
raise ArgumentError, 'aws_security_group security group ID must be in the format "sg-" followed by 8 hexadecimal characters.'
end
if recognized_params.key?(:vpc_id) && recognized_params[:vpc_id] !~ /^vpc\-[0-9a-f]{8}/
raise ArgumentError, 'aws_security_group VPC ID must be in the format "vpc-" followed by 8 hexadecimal characters.'
end
validated_params = recognized_params
if validated_params.empty?
raise ArgumentError, 'You must provide parameters to aws_security_group, such as group_name, group_id, or vpc_id.g_group.'
end
validated_params
end
def fetch_from_api # rubocop: disable Metrics/AbcSize
backend = BackendFactory.create(inspec_runner)
# Transform into filter format expected by AWS
filters = []
[
:description,
:group_id,
:group_name,
:vpc_id,
].each do |criterion_name|
instance_var = "@#{criterion_name}".to_sym
next unless instance_variable_defined?(instance_var)
val = instance_variable_get(instance_var)
next if val.nil?
filters.push(
{
name: criterion_name.to_s.tr('_', '-'),
values: [val],
},
)
end
dsg_response = backend.describe_security_groups(filters: filters)
if dsg_response.security_groups.empty?
@exists = false
@inbound_rules = []
@outbound_rules = []
return
end
@exists = true
@description = dsg_response.security_groups[0].description
@group_id = dsg_response.security_groups[0].group_id
@group_name = dsg_response.security_groups[0].group_name
@vpc_id = dsg_response.security_groups[0].vpc_id
@inbound_rules = dsg_response.security_groups[0].ip_permissions.map(&:to_h)
@outbound_rules = dsg_response.security_groups[0].ip_permissions_egress.map(&:to_h)
end
class Backend
class AwsClientApi < AwsBackendBase
BackendFactory.set_default_backend self
self.aws_client_class = Aws::EC2::Client
def describe_security_groups(query)
aws_service_client.describe_security_groups(query)
end
end
end
end