diff --git a/docs/resources/aws_iam_access_keys.md b/docs/resources/aws_iam_access_keys.md new file mode 100644 index 000000000..90ca9f8dc --- /dev/null +++ b/docs/resources/aws_iam_access_keys.md @@ -0,0 +1,165 @@ +--- +title: About the aws_iam_access_keys Resource +--- + +# aws_iam_access_keys + +Use the `aws_iam_access_keys` InSpec audit resource to test properties of some or all IAM Access Keys. + +To test properties of a single Access Key, use the `aws_iam_access_key` resource instead. +To test properties of an individual user's access keys, use the `aws_iam_user` resource. + +Access Keys are closely related to AWS User resources. Use this resource to perform audits of all keys or of keys specified by criteria unrelated to any particular user. + +
+ +## Syntax + +An `aws_iam_access_keys` resource block uses an optional filter to select a group of access keys and then tests that group. + + # Do not allow any access keys + describe aws_iam_access_keys do + it { should_not exist } + end + + # Don't let fred have access keys, using filter argument syntax + describe aws_iam_access_keys.where(username: 'fred') do + it { should_not exist } + end + + # Don't let fred have access keys, using filter block syntax (most flexible) + describe aws_iam_access_keys.where { username == 'fred' } do + it { should_not exist } + end + +
+ +## Examples + +The following examples show how to use this InSpec audit resource. + +### Disallow access keys created more than 90 days ago + + describe aws_iam_access_keys.where { created_age > 90 } do + it { should_not exist } + end + +
+ +## Matchers + +### exists + +The control will pass if the filter returns at least one result. Use should_not if you expect zero matches. + + # Sally should have at least one access key + describe aws_iam_access_keys.where(username: 'sally') do + it { should exist } + end + + # Don't let fred have access keys + describe aws_iam_access_keys.where(username: 'fred') do + it { should_not exist } + end + +## Filter Criteria + +### active + +A true / false value indicating if an Access Key is currently "Active" (the normal state) in the AWS console. See also: `inactive`. + + # Check whether a particular key is enabled + describe aws_iam_access_keys.where { active } do + its('access_key_ids') { should include('AKIA1234567890ABCDEF')} + end + +### created_date + +A DateTime identifying when the Access Key was created. See also `created_days_ago` and `created_hours_ago`. + + # Detect keys older than 2017 + describe aws_iam_access_keys.where { created_date < DateTime.parse('2017-01-01') } do + it { should_not exist } + end + +### created_days_ago, created_hours_ago + +An integer, representing how old the access key is. + + # Don't allow keys that are older than 90 days + describe aws_iam_access_keys.where { created_days_ago > 90 } do + it { should_not exist } + end + +### ever_used + +A true / false value indicating if the Access Key has ever been used, based on the last_used_date. See also: `never_used`. + + # Check to see if a particular key has ever been used + describe aws_iam_access_keys.where { ever_used } do + its('access_key_ids') { should include('AKIA1234567890ABCDEF')} + end + + +### inactive + +A true / false value indicating if the Access Key has been marked Inactive in the AWS console. See also: `active`. + + # Don't leave inactive keys laying around + describe aws_iam_access_keys.where { inactive } do + it { should_not exist } + end + +### last_used_date + +A DateTime identifying when the Access Key was last used. Returns nil if the key has never been used. See also: `ever_used`, `last_used_days_ago`, `last_used_hours_ago`, and `never_used`. + + # No one should do anything on Mondays + describe aws_iam_access_keys.where { ever_used and last_used_date.monday? } do + it { should_not exist } + end + +### last_used_days_ago, last_used_hours_ago + +An integer representing when the key was last used. See also: `ever_used`, `last_used_date`, and `never_used`. + + # Don't allow keys that sit unused for more than 90 days + describe aws_iam_access_keys.where { last_used_days_ago > 90 } do + it { should_not exist } + end + +### never_used + +A true / false value indicating if the Access Key has never been used, based on the last_used_date. See also: `ever_used`. + + # Don't allow unused keys to lay around + describe aws_iam_access_keys.where { never_used } do + it { should_not exist } + end + +### username + +Searches for access keys owned by the named user. Each user may have zero, one, or two access keys. + + describe aws_iam_access_keys(username: 'bob') do + it { should exist } + end + +## Properties + +### access_key_ids + +Provides a list of all access key IDs matched. + + describe aws_iam_access_keys do + its('access_key_ids') { should include('AKIA1234567890ABCDEF') } + end + +### entries + +Provides access to the raw results of the query. This can be useful for checking counts and other advanced operations. + + # Allow at most 100 access keys on the account + describe aws_iam_access_keys do + its('entries.count') { should be <= 100} + end diff --git a/libraries/aws_iam_access_keys.rb b/libraries/aws_iam_access_keys.rb new file mode 100644 index 000000000..241876a78 --- /dev/null +++ b/libraries/aws_iam_access_keys.rb @@ -0,0 +1,164 @@ +class AwsIamAccessKeys < Inspec.resource(1) + name 'aws_iam_access_keys' + desc 'Verifies settings for AWS IAM Access Keys in bulk' + example ' + describe aws_iam_access_keys do + it { should_not exist } + end + ' + + VALUED_CRITERIA = [ + :username, + :id, + :access_key_id, + :created_date, + ].freeze + + # Constructor. Args are reserved for row fetch filtering. + def initialize(filter_criteria = {}) + filter_criteria = validate_filter_criteria(filter_criteria) + @table = AccessKeyProvider.create.fetch(filter_criteria) + end + + def validate_filter_criteria(criteria) + # Allow passing a scalar string, the Access Key ID. + criteria = { access_key_id: criteria } if criteria.is_a? String + unless criteria.is_a? Hash + raise 'Unrecognized criteria for fetching Access Keys. ' \ + "Use 'criteria: value' format." + end + + # id and access_key_id are aliases; standardize on access_key_id + criteria[:access_key_id] = criteria.delete(:id) if criteria.key?(:id) + if criteria[:access_key_id] and + criteria[:access_key_id] !~ /^AKIA[0-9A-Z]{16}$/ + raise 'Incorrect format for Access Key ID - expected AKIA followed ' \ + 'by 16 letters or numbers' + end + + criteria.keys.each do |criterion| + unless VALUED_CRITERIA.include?(criterion) # rubocop:disable Style/Next + raise 'Unrecognized filter criterion for aws_iam_access_keys, ' \ + "'#{criterion}'. Valid choices are " \ + "#{VALUED_CRITERIA.join(', ')}." + end + end + + criteria + end + + # Underlying FilterTable implementation. + filter = FilterTable.create + filter.add_accessor(:where) + .add_accessor(:entries) + .add(:exists?) { |x| !x.entries.empty? } + .add(:access_key_ids, field: :access_key_id) + .add(:created_date, field: :created_date) + .add(:created_days_ago, field: :created_days_ago) + .add(:created_hours_ago, field: :created_hours_ago) + .add(:usernames, field: :username) + .add(:active, field: :active) + .add(:inactive, field: :inactive) + .add(:last_used_date, field: :last_used_date) + .add(:last_used_hours_ago, field: :last_used_hours_ago) + .add(:last_used_days_ago, field: :last_used_days_ago) + .add(:ever_used, field: :ever_used) + .add(:never_used, field: :never_used) + filter.connect(self, :access_key_data) + + def access_key_data + @table + end + + def to_s + 'IAM Access Keys' + end + + # Internal support class. This is used to fetch + # the users and access keys. We have an abstract + # class with a concrete AWS implementation provided here; + # a few mock implementations are also provided in the unit tests. + class AccessKeyProvider + # Implementation of AccessKeyProvider which operates by looping over + # all users, then fetching their access keys. + # TODO: An alternate, more scalable implementation could be made + # using the Credential Report. + class AwsUserIterator < AccessKeyProvider + def fetch(criteria) + iam_client = AWSConnection.new.iam_client + usernames = [] + if criteria.key?(:username) + usernames.push criteria[:username] + else + # TODO: pagination check and resume + usernames = iam_client.list_users.users.map(&:user_name) + end + + access_key_data = [] + usernames.each do |username| + begin + user_keys = iam_client.list_access_keys(user_name: username) + .access_key_metadata + user_keys = user_keys.map do |metadata| + { + access_key_id: metadata.access_key_id, + username: username, + status: metadata.status, + create_date: metadata.create_date, # DateTime.parse(metadata.create_date), + } + end + + # Synthetics + user_keys.each do |key_info| + add_synthetic_fields(key_info) + end + access_key_data.concat(user_keys) + rescue Aws::IAM::Errors::NoSuchEntity # rubocop:disable Lint/HandleExceptions + # Swallow - a miss on search results should return an empty table + end + end + access_key_data + end + + def add_synthetic_fields(key_info) # rubocop:disable Metrics/AbcSize + key_info[:id] = key_info[:access_key_id] + key_info[:active] = key_info[:status] == 'Active' + key_info[:inactive] = key_info[:status] != 'Active' + key_info[:created_hours_ago] = ((Time.now - key_info[:create_date]) / (60*60)).to_i + key_info[:created_days_ago] = (key_info[:created_hours_ago] / 24).to_i + + # Last used is a separate API call + iam_client = AWSConnection.new.iam_client + last_used = + iam_client.get_access_key_last_used(access_key_id: key_info[:access_key_id]) + .access_key_last_used.last_used_date + key_info[:ever_used] = !last_used.nil? + key_info[:never_used] = last_used.nil? + key_info[:last_used_time] = last_used + return unless last_used + key_info[:last_used_hours_ago] = ((Time.now - last_used) / (60*60)).to_i + key_info[:last_used_days_ago] = (key_info[:last_used_hours_ago]/24).to_i + end + end + + DEFAULT_PROVIDER = AwsIamAccessKeys::AccessKeyProvider::AwsUserIterator + @selected_implementation = DEFAULT_PROVIDER + + # Use this to change what class is created by create(). + def self.select(klass) + @selected_implementation = klass + end + + def self.reset + @selected_implementation = DEFAULT_PROVIDER + end + + def self.create + @selected_implementation.new + end + + def fetch(_filter_criteria) + raise 'Unimplemented abstract method - internal error.' + end + end +end diff --git a/test/integration/build/aws.tf b/test/integration/build/aws.tf index b3d58a330..f923d3490 100644 --- a/test/integration/build/aws.tf +++ b/test/integration/build/aws.tf @@ -159,6 +159,10 @@ output "access_key_user" { value = "${aws_iam_user.access_key_user.name}" } +output "access_key_id" { + value = "${aws_iam_access_key.access_key.id}" +} + output "example_ec2_name" { value = "${aws_instance.example.tags.Name}" } diff --git a/test/integration/verify/controls/aws_iam_access_key.rb b/test/integration/verify/controls/aws_iam_access_key.rb index 99536ce19..6cec7002b 100644 --- a/test/integration/verify/controls/aws_iam_access_key.rb +++ b/test/integration/verify/controls/aws_iam_access_key.rb @@ -1,3 +1,57 @@ -describe aws_iam_access_key(username: 'not-a-user', 'id': 'not-an-id') do - it { should_not exist } -end +access_key_user = attribute( + 'access_key_user', + default: 'default.access_key_user', + description: 'Name of IAM user access_key_user') + +access_key_id = attribute( + 'access_key_id', + default: 'AKIA1234567890AZFAKE', + description: 'Access Key ID of access key of IAM user access_key_user') + +describe aws_iam_access_key(username: 'not-a-user', 'id': 'not-an-id') do + it { should_not exist } +end + +describe aws_iam_access_key(username: access_key_user, 'id': access_key_id) do + it { should exist } + # TODO - check last used, created, other key metadata +end + +control 'IAM Access Keys' do + title 'Fetch all' + describe aws_iam_access_keys do + it { should exist } + end +end + + +control 'IAM Access Keys' do + title 'Client-side filtering' + all_keys = aws_iam_access_keys + describe all_keys.where(username: access_key_user) do + its('entries.length') { should be 1 } + its('access_key_ids.first') { should eq access_key_id } + end + describe all_keys.where(created_days_ago: 0) do + it { should exist } + end + describe all_keys.where { active } do + it { should exist } + end + describe all_keys.where { ever_used } + .where { last_used_days_ago > 0 } do + it { should exist } + end +end + +control 'AKS3' do + title 'Fetch-time filtering' + describe aws_iam_access_keys(username: access_key_user) do + its('entries.length') { should be 1 } + its('access_key_ids.first') { should eq access_key_id } + end + + describe aws_iam_access_keys(username: 'i-dont-exist-presumably') do + it { should_not exist } + end +end \ No newline at end of file diff --git a/test/unit/resources/aws_iam_access_keys_test.rb b/test/unit/resources/aws_iam_access_keys_test.rb new file mode 100644 index 000000000..1d1d01fb8 --- /dev/null +++ b/test/unit/resources/aws_iam_access_keys_test.rb @@ -0,0 +1,332 @@ + +require 'aws-sdk' +require 'helper' +require 'aws_iam_access_keys' + +#==========================================================# +# Constructor Tests # +#==========================================================# + +class AwsIamAccessKeysConstructorTest < Minitest::Test + # Reset provider back to the implementation default prior + # to each test. Tests must explicitly select an alternate. + def setup + AwsIamAccessKeys::AccessKeyProvider.reset + end + + def test_bare_constructor_does_not_explode + AwsIamAccessKeys::AccessKeyProvider.select(AlwaysEmptyMAKP) + AwsIamAccessKeys.new + end +end + +#==========================================================# +# Filtering Tests # +#==========================================================# + +class AwsIamAccessKeysFilterTest < Minitest::Test + # Reset provider back to the implementation default prior + # to each test. Tests must explicitly select an alternate. + def setup + AwsIamAccessKeys::AccessKeyProvider.reset + end + + def test_filter_methods_should_exist + AwsIamAccessKeys::AccessKeyProvider.select(AlwaysEmptyMAKP) + resource = AwsIamAccessKeys.new + [:where, :'exists?'].each do |meth| + assert_respond_to(resource, meth) + end + end + + def test_filter_method_where_should_be_chainable + AwsIamAccessKeys::AccessKeyProvider.select(AlwaysEmptyMAKP) + resource = AwsIamAccessKeys.new + assert_respond_to(resource.where, :where) + end + + def test_filter_method_exists_should_probe_empty_when_empty + AwsIamAccessKeys::AccessKeyProvider.select(AlwaysEmptyMAKP) + resource = AwsIamAccessKeys.new + refute(resource.exists?) + end + + def test_filter_method_exists_should_probe_present_when_present + AwsIamAccessKeys::AccessKeyProvider.select(BasicMAKP) + resource = AwsIamAccessKeys.new + assert(resource.exists?) + end +end + +#==========================================================# +# Filter Criteria Tests # +#==========================================================# + +class AwsIamAccessKeysFilterCriteriaTest < Minitest::Test + def setup + # Here we always want no rseults. + AwsIamAccessKeys::AccessKeyProvider.select(AlwaysEmptyMAKP) + @valued_criteria = { + username: 'bob', + id: 'AKIA1234567890ABCDEF', + access_key_id: 'AKIA1234567890ABCDEF', + } + end + + def test_criteria_when_used_in_constructor_with_value + @valued_criteria.each do |criterion, value| + AwsIamAccessKeys.new(criterion => value) + end + end + + def test_criteria_when_used_in_where_with_value + @valued_criteria.each do |criterion, value| + AwsIamAccessKeys.new.where(criterion => value) + end + end + + # Negative cases + def test_criteria_when_used_in_constructor_with_bad_criterion + assert_raises(RuntimeError) do + AwsIamAccessKeys.new(nope: 'some_val') + end + end + + def test_criteria_when_used_in_where_with_bad_criterion + assert_raises(RuntimeError) do + AwsIamAccessKeys.new(nope: 'some_val') + end + end + + # Identity criterion is allowed based on regex + def test_identity_criterion_when_used_in_constructor_positive + AwsIamAccessKeys.new('AKIA1234567890ABCDEF') + end + + # Permitted by FilterTable? + def test_identity_criterion_when_used_in_where_positive + AwsIamAccessKeys.new.where('AKIA1234567890ABCDEF') + end + + def test_identity_criterion_when_used_in_constructor_negative + assert_raises(RuntimeError) do + AwsIamAccessKeys.new('NopeAKIA1234567890ABCDEF') + end + end + + # Permitted by FilterTable? + # def test_identity_criterion_when_used_in_where_negative + # assert_raises(RuntimeError) do + # AwsIamAccessKeys.new.where('NopeAKIA1234567890ABCDEF') + # end + # end +end + +#==========================================================# +# Property Tests # +#==========================================================# +class AwsIamAccessKeysPropertiesTest < Minitest::Test + def setup + # Reset back to the basic kit each time. + AwsIamAccessKeys::AccessKeyProvider.select(BasicMAKP) + @all_basic = AwsIamAccessKeys.new + end + + #----------------------------------------------------------# + # created_date / created_days_ago / created_hours_ago # + #----------------------------------------------------------# + def test_property_created_date + assert_kind_of(DateTime, @all_basic.entries.first.created_date) + + arg_filtered = @all_basic.where(created_date: DateTime.parse('2017-10-27T17:58:00Z')) + assert_equal(1, arg_filtered.entries.count) + assert arg_filtered.access_key_ids.first.end_with?('BOB') + + block_filtered = @all_basic.where { created_date.friday? } + assert_equal(1, block_filtered.entries.count) + assert block_filtered.access_key_ids.first.end_with?('BOB') + end + + def test_property_created_days_ago + assert_kind_of(Integer, @all_basic.entries.first.created_days_ago) + + arg_filtered = @all_basic.where(created_days_ago: 9) + assert_equal(1, arg_filtered.entries.count) + assert arg_filtered.access_key_ids.first.end_with?('SALLY') + + block_filtered = @all_basic.where { created_days_ago > 2 } + assert_equal(2, block_filtered.entries.count) + end + + def test_property_created_hours_ago + assert_kind_of(Integer, @all_basic.entries.first.created_hours_ago) + + arg_filtered = @all_basic.where(created_hours_ago: 222) + assert_equal(1, arg_filtered.entries.count) + assert arg_filtered.access_key_ids.first.end_with?('SALLY') + + block_filtered = @all_basic.where { created_hours_ago > 100 } + assert_equal(2, block_filtered.entries.count) + end + + #----------------------------------------------------------# + # active / inactive # + #----------------------------------------------------------# + def test_property_active + assert_kind_of(TrueClass, @all_basic.entries.first.active) + + arg_filtered = @all_basic.where(active: true) + assert_equal(2, arg_filtered.entries.count) + + block_filtered = @all_basic.where { active } + assert_equal(2, block_filtered.entries.count) + assert block_filtered.access_key_ids.first.end_with?('BOB') + end + + def test_property_inactive + assert_kind_of(FalseClass, @all_basic.entries.first.inactive) + + arg_filtered = @all_basic.where(inactive: true) + assert_equal(1, arg_filtered.entries.count) + + block_filtered = @all_basic.where { inactive } + assert_equal(1, block_filtered.entries.count) + assert block_filtered.access_key_ids.first.end_with?('ROBIN') + end + + #-----------------------------------------------------------# + # last_used_date / last_used_days_ago / last_used_hours_ago # + #-----------------------------------------------------------# + def test_property_last_used_date + assert_kind_of(NilClass, @all_basic.entries[0].last_used_date) + assert_kind_of(DateTime, @all_basic.entries[1].last_used_date) + + arg_filtered = @all_basic.where(last_used_date: DateTime.parse('2017-10-27T17:58:00Z')) + assert_equal(1, arg_filtered.entries.count) + assert arg_filtered.access_key_ids.first.end_with?('SALLY') + + block_filtered = @all_basic.where { last_used_date and last_used_date.friday? } + assert_equal(1, block_filtered.entries.count) + assert block_filtered.access_key_ids.first.end_with?('SALLY') + end + + def test_property_last_used_days_ago + assert_kind_of(NilClass, @all_basic.entries[0].last_used_days_ago) + assert_kind_of(Integer, @all_basic.entries[1].last_used_days_ago) + + arg_filtered = @all_basic.where(last_used_days_ago: 4) + assert_equal(1, arg_filtered.entries.count) + assert arg_filtered.access_key_ids.first.end_with?('SALLY') + + block_filtered = @all_basic.where { last_used_days_ago and last_used_days_ago < 2 } + assert_equal(1, block_filtered.entries.count) + assert block_filtered.access_key_ids.first.end_with?('ROBIN') + end + + def test_property_last_used_hours_ago + assert_kind_of(NilClass, @all_basic.entries[0].last_used_hours_ago) + assert_kind_of(Integer, @all_basic.entries[1].last_used_hours_ago) + + arg_filtered = @all_basic.where(last_used_hours_ago: 102) + assert_equal(1, arg_filtered.entries.count) + assert arg_filtered.access_key_ids.first.end_with?('SALLY') + + block_filtered = @all_basic.where { last_used_hours_ago and last_used_hours_ago < 10 } + assert_equal(1, block_filtered.entries.count) + assert block_filtered.access_key_ids.first.end_with?('ROBIN') + end + + #-----------------------------------------------------------# + # ever_used / never_used # + #-----------------------------------------------------------# + def test_property_ever_used + assert_kind_of(FalseClass, @all_basic.entries[0].ever_used) + assert_kind_of(TrueClass, @all_basic.entries[1].ever_used) + + arg_filtered = @all_basic.where(ever_used: true) + assert_equal(2, arg_filtered.entries.count) + + block_filtered = @all_basic.where { ever_used } + assert_equal(2, block_filtered.entries.count) + assert block_filtered.access_key_ids.first.end_with?('SALLY') + end + + def test_property_never_used + assert_kind_of(TrueClass, @all_basic.entries[0].never_used) + assert_kind_of(FalseClass, @all_basic.entries[1].never_used) + + arg_filtered = @all_basic.where(never_used: true) + assert_equal(1, arg_filtered.entries.count) + + block_filtered = @all_basic.where { never_used } + assert_equal(1, block_filtered.entries.count) + assert block_filtered.access_key_ids.first.end_with?('BOB') + end +end +#==========================================================# +# Mock Support Classes # +#==========================================================# + +# MAKP = MockAccessKeyProvider. Abbreviation not used +# outside this file. + +class AlwaysEmptyMAKP < AwsIamAccessKeys::AccessKeyProvider + def fetch(_filter_criteria) + [] + end +end + +class BasicMAKP < AwsIamAccessKeys::AccessKeyProvider + def fetch(_filter_criteria) # rubocop:disable Metrics/MethodLength + [ + { + username: 'bob', + access_key_id: 'AKIA1234567890123BOB', + id: 'AKIA1234567890123BOB', + created_date: DateTime.parse('2017-10-27T17:58:00Z'), + created_days_ago: 4, + created_hours_ago: 102, + status: 'Active', + active: true, + inactive: false, + last_used_date: nil, + last_used_days_ago: nil, + last_used_hours_ago: nil, + ever_used: false, + never_used: true, + }, + { + username: 'sally', + access_key_id: 'AKIA12345678901SALLY', + id: 'AKIA12345678901SALLY', + created_date: DateTime.parse('2017-10-22T17:58:00Z'), + created_days_ago: 9, + created_hours_ago: 222, + status: 'Active', + active: true, + inactive: false, + last_used_date: DateTime.parse('2017-10-27T17:58:00Z'), + last_used_days_ago: 4, + last_used_hours_ago: 102, + ever_used: true, + never_used: false, + }, + { + username: 'robin', + access_key_id: 'AKIA12345678901ROBIN', + id: 'AKIA12345678901ROBIN', + created_date: DateTime.parse('2017-10-31T17:58:00Z'), + created_days_ago: 1, + created_hours_ago: 12, + status: 'Inactive', + active: false, + inactive: true, + last_used_date: DateTime.parse('2017-10-31T20:58:00Z'), + last_used_days_ago: 0, + last_used_hours_ago: 5, + ever_used: true, + never_used: false, + }, + ] + end +end