diff --git a/.gitignore b/.gitignore index 979c01916..7f786ed6d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ Gemfile.lock inspec.lock .kitchen +*.code-workspace *.plan *.tfstate* local diff --git a/Gemfile b/Gemfile index fee6f685c..bc943b782 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,6 @@ -source 'https://rubygems.org' +source "https://rubygems.org" -gem 'bundle' +gem "bundle" # Note that 'aws-sdk' pulls in a large number of libraries, choose explicitly those to include instead # gem 'aws-sdk', '~> 3' # @@ -11,9 +11,10 @@ gem 'bundle' # In the mean time the gem can be added here for local development # Use Latest Inspec -gem 'inspec-bin' +gem "inspec-bin" +gem "train-aws", git: 'https://github.com/mitre/train-aws.git', branch: 'al/dep-updates' -gem 'rubocop', '~> 1.25.1', require: false +gem "rubocop", "~> 1.25.1", require: false group :test do gem "chefstyle", "~> 2.2.2" @@ -22,7 +23,7 @@ group :test do end group :development do - gem 'rake' - gem 'minitest' - gem 'pry-byebug' + gem "rake" + gem "minitest" + gem "pry-byebug" end diff --git a/Makefile b/Makefile index c37e2f38d..a3a510e18 100644 --- a/Makefile +++ b/Makefile @@ -25,4 +25,5 @@ shell_tester: docker-compose run --rm --entrypoint bash tester logout: - docker-compose run --rm aws rm -rf /app/.aws \ No newline at end of file + docker-compose run --rm aws rm -rf /app/.aws + diff --git a/docs-chef-io/content/inspec/resources/aws_alternate_contact.md b/docs-chef-io/content/inspec/resources/aws_alternate_contact.md new file mode 100644 index 000000000..9493fb1c7 --- /dev/null +++ b/docs-chef-io/content/inspec/resources/aws_alternate_contact.md @@ -0,0 +1,113 @@ ++++ +title = "aws_alternate_contact Resource" +platform = "aws" +draft = false +gh_repo = "inspec-aws" + +[menu.inspec] +title = "aws_alternate_contact" +identifier = "inspec/resources/aws/aws_alternate_contact Resource" +parent = "inspec/resources/aws" ++++ + +Use the `aws_alternate_contact` InSpec audit resource to test properties of the alternate contact information associated with your account. + +For additional information, including details on parameters and properties, see the [AWS documentation on the alternate contact information associated with your account](https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-update-contact-alternate.html). Technical details on the data structure can be found for the [api documentation.](https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/Account/Client.html#get_contact_information-instance_method) + +## Installation + +{{% inspec_aws_install %}} + +## Syntax + +The `aws_alternate_contact` resource allows the testing of the alternate contact information associated with your account. + +```ruby +describe aws_alternate_contact do + it { should exist } +end +``` + +## Parameters + +`type` _(required)_ + +: This resource accepts a single parameter, the type of the alternate contact type. + This can be passed either as a string or as a `type: 'value'` key-value entry in a hash. Valid types are 'billing', 'operations' and 'security' + +## Properties + + +`api_response` (Struct) +: Returns the api response from our call to the aws api as a struct. + +`raw_data` (Hash) +: Returns a transformed Hash of Strings of the data associated with the alternate contact. + +`aws_account_id` (String) +: 12-digit account ID number of the Amazon Web Services account associated with the alternate contact. + +`name` (String) +: Specifies the full name of the alternate contact. + +`title` (String) +: Specifies the full name of the alternate contact. + +`email_address` (String) +: Specifies the full name of the alternate contact. + +`phone_number` (String) +: Specifies the phone number associated with the alternate contact. + +## Examples + +The following examples show how to use this InSpec audit resource. + +**Test that a alternate contact exists for the aws account.** + +```ruby +describe aws_alternate_contact do + it { should exist } +end +``` + +**Test that the alternate contact is set and the values for its full name and first address line are set as expected.** + +```ruby +describe aws_alternate_contact(type: 'billing') do + it { should be_configured } + its('name') { should cmp 'John Smith' } + its('title') { should cmp 'Money Guy' } +end +``` +```ruby +describe aws_alternate_contact('security') do + it { should exist } + its('name') { should cmp 'Jane Smith' } + its('title') { should cmp 'Security Gal' } +end +``` + +## Matchers + +{{% inspec_matchers_link %}} + +### exist (alias of configured) + +Use `should` to test if the aws account has a alternate contact configured. + +```ruby +it { should exist } +``` + +### configured + +The `configured` matcher tests if the described alternate contact is set and configured for the aws account by returning `true` if the api response is not null or data exists in the raw data. + +```ruby +it { should be_configured } +``` + +## AWS Permissions + +{{% aws_permissions_principal action="Aws::Account::Types::GetAlternateContactResponse" %}} diff --git a/docs-chef-io/content/inspec/resources/aws_billing_contact.md b/docs-chef-io/content/inspec/resources/aws_billing_contact.md new file mode 100644 index 000000000..36d3662e9 --- /dev/null +++ b/docs-chef-io/content/inspec/resources/aws_billing_contact.md @@ -0,0 +1,103 @@ ++++ +title = "aws_billing_contact Resource" +platform = "aws" +draft = false +gh_repo = "inspec-aws" + +[menu.inspec] +title = "aws_billing_contact" +identifier = "inspec/resources/aws/aws_billing_contact Resource" +parent = "inspec/resources/aws" ++++ + +Use the `aws_billing_contact` InSpec audit resource to test properties of the billing contact information associated with your account. + +For additional information, including details on parameters and properties, see the [AWS documentation on the billing contact information associated with your account](https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-update-contact-billing.html). Technical details on the data structure can be found for the [api documentation.](https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/Account/Client.html#get_contact_information-instance_method) + +## Installation + +{{% inspec_aws_install %}} + +## Syntax + +The `aws_billing_contact` resource allows the testing of the billing contact information associated with your account. + +```ruby +describe aws_billing_contact do + it { should exist } +end +``` + +## Parameters + +This resources does not take any parameters at this time. + +## Properties + + +`api_response` (Struct) +: Returns the api response from our call to the aws api as a struct. + +`raw_data` (Hash) +: Returns a transformed Hash of Strings of the data associated with the billing contact. + +`aws_account_id` (String) +: 12-digit account ID number of the Amazon Web Services account associated with the billing contact. + +`name` (String) +: Specifies the full name of the billing contact. + +`title` (String) +: Specifies the full name of the billing contact. + +`email_address` (String) +: Specifies the full name of the billing contact. + +`phone_number` (String) +: Specifies the phone number associated with the billing contact. + +## Examples + +The following examples show how to use this InSpec audit resource. + +**Test that a billing contact exists for the aws account.** + +```ruby +describe aws_billing_contact do + it { should exist } +end +``` + +**Test that the billing contact is set and the values for its full name and first address line are set as expected.** + +```ruby +describe aws_billing_contact do + it { should be_configured } + its('name') { should cmp 'John Smith' } + its('title') { should cmp 'Money Guy' } +end +``` + +## Matchers + +{{% inspec_matchers_link %}} + +### exist (alias of configured) + +Use `should` to test if the aws account has a billing contact configured. + +```ruby +it { should exist } +``` + +### configured + +The `configured` matcher tests if the described billing contact is set and configured for the aws account by returning `true` if the api response is not null or data exists in the raw data. + +```ruby +it { should be_configured } +``` + +## AWS Permissions + +{{% aws_permissions_principal action="Aws::Account::Types::GetAlternateContactResponse" %}} diff --git a/docs-chef-io/content/inspec/resources/aws_cloudtrail_trail.md b/docs-chef-io/content/inspec/resources/aws_cloudtrail_trail.md index 3a520aa4d..5c918f767 100644 --- a/docs-chef-io/content/inspec/resources/aws_cloudtrail_trail.md +++ b/docs-chef-io/content/inspec/resources/aws_cloudtrail_trail.md @@ -130,6 +130,15 @@ describe aws_cloudtrail_trail('TRAIL_NAME') do end ``` +**Test if a trail is monitoring an AWS object type:** + +```ruby +describe aws_cloudtrail_trail('TRAIL_NAME') do + it { should be_monitoring_read("AWS::S3::Object") } + it { should be_monitoring_write("AWS::S3::Object") } +end +``` + ## Matchers {{% inspec_matchers_link %}} @@ -192,6 +201,26 @@ describe aws_cloudtrail_trail('TRAIL_NAME') do end ``` +### be_monitoring_read + +The test will pass if the identified trail is monitoring read events on the given AWS object type (if the trail is only monitoring one ARN of that object type, the test will fail). + +```ruby +describe aws_cloudtrail_trail('TRAIL_NAME') do + it { should be_monitoring_read("AWS::S3::Object") } +end +``` + +### be_monitoring_write + +The test will pass if the identified trail is monitoring write events on the given AWS object type (if the trail is only monitoring one ARN of that object type, the test will fail). + +```ruby +describe aws_cloudtrail_trail('TRAIL_NAME') do + it { should be_monitoring_write("AWS::S3::Object") } +end +``` + ## AWS Permissions {{% aws_permissions_principal action="CloudTrail:Client:DescribeTrailsResponse" %}} diff --git a/docs-chef-io/content/inspec/resources/aws_ec2_instance.md b/docs-chef-io/content/inspec/resources/aws_ec2_instance.md index 5650f4a1b..d9b5b3f1b 100644 --- a/docs-chef-io/content/inspec/resources/aws_ec2_instance.md +++ b/docs-chef-io/content/inspec/resources/aws_ec2_instance.md @@ -80,6 +80,9 @@ One of either the EC2 instance's ID or name must be be provided. There are also additional properties available. For a comprehensive list, see [the API reference documentation](https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_Instance.html) +`instance` +: A hash containing all the data collected about the EC2. + ## Examples **Test that an EC2 instance is running.** diff --git a/docs-chef-io/content/inspec/resources/aws_iam_access_analyzers.md b/docs-chef-io/content/inspec/resources/aws_iam_access_analyzers.md new file mode 100644 index 000000000..d34b46927 --- /dev/null +++ b/docs-chef-io/content/inspec/resources/aws_iam_access_analyzers.md @@ -0,0 +1,106 @@ ++++ +title = "aws_iam_access_analyzers Resource" +platform = "aws" +draft = false +gh_repo = "inspec-aws" + +[menu.inspec] +title = "aws_iam_access_analyzers" +identifier = "inspec/resources/aws/aws_iam_access_analyzers Resource" +parent = "inspec/resources/aws" ++++ + +Use the `aws_iam_access_analyzers` InSpec audit resource to verify settings for multiple AWS IAM Access Analyzers. + +For additional information, including details on parameters and properties, see the [AWS documentation on Access Analyzers](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-accessanalyzer-analyzer.html). + +## Installation + +{{% inspec_aws_install %}} + +## Syntax + +Ensure that an access analyzer (of either `account` or `organization` type) exists. + +```ruby +describe aws_iam_access_analyzers do + it { should exist } +end +``` + +## Parameters + +`type` _(optional)_ + +: The type of access analyzers to be tested. Must be one of `account` or `organization`. If this parameter is not given, the resource will return data for both types. + +## Properties + +`analyzer_names` +: List of names of returned analyzers. + +`analyzer_types` +: List of types of returned analyzers (`account` or `organization`). + +`arns` +: List of ARNs of returned analyzers. + +`created_date` +: List of creation dates of returned analyzers. + +`last_resource_analyzed` +: List of resources that were most recently analyzed by the returned analyzers. + +`last_analyzed_date` +: List of timestamps representing the times at which the returned analyzers last analyzed a resource. + +`tags` +: List of hashes of tags for each returned analyzer. + +`status` +: List of statuses for the returned analyzers (`Active`, `Disabled`, `Creating`, or `Failed`). + +`status_reason` +: List of details about the current status for each returned analyzer. + +## Examples + +Determine if an access analyzer for the AWS account (as opposed to the entire organization) exists: + +```ruby +describe aws_iam_access_analyzers('account') do + it { should exist } +end + +describe aws_iam_access_analyzers(type: 'account') do + it { should exist } +end +``` + +## Matchers + +{{% inspec_matchers_link %}} + +### exist + +Use `should` to test that the entity exists. + +```ruby +describe aws_iam_access_analyzers + it { should exist } +end +``` + +Use `should_not` to test the entity does not exist. + +```ruby +describe aws_iam_access_analyzers + it { should_not exist } +end +``` + +## AWS Permissions + +{{% aws_permissions_principal action="access-analyzer:ListAnalyzers" %}} + +You can find detailed documentation on this action in the [AWS API documentation](https://docs.aws.amazon.com/access-analyzer/latest/APIReference/API_ListAnalyzers.html). diff --git a/docs-chef-io/content/inspec/resources/aws_iam_credential_report.md b/docs-chef-io/content/inspec/resources/aws_iam_credential_report.md new file mode 100644 index 000000000..94b4e75db --- /dev/null +++ b/docs-chef-io/content/inspec/resources/aws_iam_credential_report.md @@ -0,0 +1,145 @@ ++++ +title = "aws_iam_credential_report Resource" +platform = "aws" +draft = false +gh_repo = "inspec-aws" + +[menu.inspec] +title = "aws_iam_credential_report" +identifier = "inspec/resources/aws/aws_iam_credential_report Resource" +parent = "inspec/resources/aws" ++++ + +Use the `aws_iam_credential_report` InSpec audit resource to list all users in the AWS account and the status of their credentials. + +For additional information, including details on parameters and properties, see the [AWS documentation on Credential Reports](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_getting-report.html). + +## Installation + +{{% inspec_aws_install %}} + +## Syntax + +Use the AWS Credential Report to query data about users' access credential configurations and the timestamps at which credentials were last used. + +```ruby +describe aws_iam_credential_report.where(user: username) do + its('mfa_active') { should eq true } +end +``` + +## Parameters + +This resource does not require any parameters. + +## Properties + +`user` +: List of the usernames (not full ARNs) associated with the account. + +`arn` +: List of the full ARNs of users associated with the account. + +`user_creation_time` +: List of the timestamps when the user was created, in ISO 8601 date-time format. + +`password_enabled` +: List of booleans for whether each user has a password enabled for the AWS console. The value for the AWS account root user is always "not_supported". + +`password_last_used` +: List of the timestamps when the user last logged in using the password, in ISO 8601 date-time format (value will be 'N/A' for a user with no password or a user who has never logged in with their password). + +`password_last_changed` +: List of the timestamps when the user last changed their password, in ISO 8601 date-time format (value will be 'N/A' for a user with no password or a user who has never logged in with their password). The value for the AWS account root user is always "not_supported". + +`password_next_rotation` +: List of the dates and times (in ISO 8601 date-time format) at which each user will be forced to change the password, if the user is required to rotate passwords (value will be 'N/A' for a user with no password or a user who has never logged in with their password). The value for the AWS account root user is always "not_supported". + +`mfa_active` +: List of booleans for whether each user has a multi-factor authentication (MFA) device enabled. + +`access_key_1_active` +: List of booleans for whether each user has an active access key in their first key slot. + +`access_key_1_last_rotated` +: List of dates and times (in ISO 8601 date-time format) for when each user's first access key was last rotated (value will be 'N/A' for users without an access key in the first slot, if the key has never been used). + +`access_key_1_last_used_date` +: List of dates and times (in ISO 8601 date-time format) for when each user's first access key was last used to sign an AWS API request (value will be 'N/A' for users without an access key in the first slot, or if the key has never been used). + +`access_key_1_last_used_region` +: List of AWS regions in which each user's first access key was last used to sign an AWS API request (value will be 'N/A' for users without an access key in the first slot, if the key has never been used, or if the last service this key was used for is not region-specific). + +`access_key_1_last_used_service` +: List of AWS services for which each user's first access key was last used to sign an AWS API request (value will be 'N/A' for users without an access key in the first slot, or if the key has never been used). + +`access_key_2_active` +: List of booleans for whether each user has an active access key in their second key slot. + +`access_key_2_last_rotated` +: List of dates and times (in ISO 8601 date-time format) for when each user's second access key was last rotated (value will be 'N/A' for users without an access key in the second slot, if the key has never been used). + +`access_key_2_last_used_date` +: List of dates and times (in ISO 8601 date-time format) for when each user's second access key was last used to sign an AWS API request (value will be 'N/A' for users without an access key in the second slot, or if the key has never been used). + +`access_key_2_last_used_region` +: List of AWS regions in which each user's second access key was last used to sign an AWS API request (value will be 'N/A' for users without an access key in the second slot, if the key has never been used, or if the last service this key was used for is not region-specific). + +`access_key_2_last_used_service` +: List of AWS services for which each user's second access key was last used to sign an AWS API request (value will be 'N/A' for users without an access key in the second slot, or if the key has never been used). + +`cert_1_active` +: List of booleans for whether each user has an X.509 signing certificate and the certificate is active. + +`cert_1_last_rotated` +: List of dates and times (in ISO 8601 date-time format) for when each user's signing certificate was created or last changed (value will be 'N/A' for users with no active certificate). + +`cert_2_active` +: List of booleans for whether each user has a second X.509 signing certificate and the certificate is active. + +`cert_2_last_rotated` +: List of dates and times (in ISO 8601 date-time format) for when each user's second signing certificate was created or last changed (value will be 'N/A' for users with no active certificate or only one active certificate). + +## Examples + +Determine if the root user has MFA enabled: + +```ruby +describe aws_iam_credential_report.where(user: '').entries.first do + its('mfa_active') { should eq true } +end +``` + +Ensuring that all users with passwords have used them within the last month: +```ruby +aws_iam_credential_report.where(password_enabled: true).entries.each do |user| + describe "The user (#{user.user})" do + subject { ((Time.current - user.password_last_used) / (24 * 60 * 60)).to_i } + it 'must have used their password within the last 30 days.' do + expect(subject).to be < 30 + end + end +end +``` + +Check if access keys for all users have been rotated within the last month: +```ruby +aws_iam_credential_report.where(access_key_1_active: true).entries.each do |user| + describe "The user (#{user.user})" do + subject { ((Time.current - user.access_key_1_last_used_date) / (24 * 60 * 60)).to_i } + it 'must have used access key 1 within the last 90 days.' do + expect(subject).to be < 90 + end + end +end +``` +## Matchers + +{{% inspec_matchers_link %}} + +## AWS Permissions + +Your [Principal](https://docs.aws.amazon.com/IAM/latest/UserGuide/intro-structure.html#intro-structure-principal) will need the `{{ .Get "iam:GenerateCredentialReport" }}` action and the `{{ .Get "iam:GetCredentialReport" }}` with `Effect` set to `Allow`. + +You can find detailed documentation on these actions in the AWS API documentation: [GenerateCredentialReport](https://docs.aws.amazon.com/IAM/latest/APIReference/API_GenerateCredentialReport.html), [GetCredentialReport](https://docs.aws.amazon.com/IAM/latest/APIReference/API_GetCredentialReport.html). + diff --git a/docs-chef-io/content/inspec/resources/aws_iam_group.md b/docs-chef-io/content/inspec/resources/aws_iam_group.md index bb426d0d0..9e224e508 100644 --- a/docs-chef-io/content/inspec/resources/aws_iam_group.md +++ b/docs-chef-io/content/inspec/resources/aws_iam_group.md @@ -91,6 +91,6 @@ end ## AWS Permissions -{{% aws_permissions_principal action="IAM:Client:GetGroupResponse" %}} +{{% aws_permissions_principal action="iam:GetGroup" %}} -You can find detailed documentation at [Actions, Resources, and Condition Keys for Identity And Access Management](https://docs.aws.amazon.com/IAM/latest/UserGuide/list_identityandaccessmanagement.html). +You can find detailed documentation on this action in the [AWS API documentation](https://docs.aws.amazon.com/IAM/latest/APIReference/API_GetGroup.html). diff --git a/docs-chef-io/content/inspec/resources/aws_macie.md b/docs-chef-io/content/inspec/resources/aws_macie.md new file mode 100644 index 000000000..d3c6bfefd --- /dev/null +++ b/docs-chef-io/content/inspec/resources/aws_macie.md @@ -0,0 +1,125 @@ ++++ +title = "aws_macie Resource" +platform = "aws" +draft = false +gh_repo = "inspec-aws" + +[menu.inspec] +title = "aws_macie" +identifier = "inspec/resources/aws/aws_macie Resource" +parent = "inspec/resources/aws" ++++ + +Use the `aws_macie` InSpec audit resource to query the configuration and findings of Amazon Macie. See the [Amazon Macie API docs](https://docs.aws.amazon.com/macie/latest/APIReference/welcome.html) for details on information available from Macie. + +## Installation + +{{% inspec_aws_install %}} + +## Syntax + +An `aws_macie` resource declares the tests for the Amazon Macie instance active for the organization. + +```ruby +describe aws_macie do + it { should be_enabled } +end +``` + +The `aws_macie` resource has three properties which behave as [Filter Tables](https://github.com/inspec/inspec/blob/main/dev-docs/filtertable-usage.md): +- `jobs` +- `buckets` +- `findings` + +```ruby +describe aws_macie.jobs.where(name: "expected-job-name") do + its('job_status') { should_not cmp "CANCELLED" } +end +``` + +## Parameters + +This resource does not require any parameters. + +## Properties + +`session` +: Returns the status and configuration settings for an Amazon Macie account. + +`jobs` +: Returns a FilterTable of all jobs defined for Amazon Macie. See all columns inside the table in the [AWS docs](https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/Macie2/Client.html#list_classification_jobs-instance_method) + +`buckets` +: Returns a FilterTable contianing statistical data and other information on all buckets monitored by Amazon Macie. See all columns inside the table in the [AWS docs](https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/Macie2/Client.html#describe_buckets-instance_method) + +`findings` +: Returns a FilterTable of all findings discovered by Aamzon Macie. See all columns inside the table in the [AWS docs](https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/Macie2/Client.html#get_findings-instance_method). + +## Examples + +**Test if Macie is enabled.** + +```ruby +describe aws_macie do + it { should be_enabled } +end +``` + +**Test that a given job is active.** + +```ruby +describe aws_macie.jobs.where(name: "expected-job-name") do + its('job_status') { should_not cmp "CANCELLED" } +end +``` + +**Test that there are no active findings.** + +```ruby +describe aws_macie.findings do + its('count') { should eq 0 } +end +``` + +**Test that a given S3 bucket is being monitored by Macie.** + +```ruby +describe aws_macie do + it { should be_monitoring("my-sample-s3-name") } +end +``` + +## Matchers + +This InSpec audit resource has the following special matchers. For a full list of available matchers, please visit our [matchers page](https://www.inspec.io/docs/reference/matchers/). + + +### enabled? + +```ruby +describe aws_macie do + it { should be_enabled } +end +``` + +### monitored? + +Tests theat a particular S3 or list of S3 buckets is monitored by Macie's jobs. + +```ruby +describe aws_macie do + it { should be_monitoring(["my-sample-s3-name-1", "my-sample-s3-name-2"]) } +end +``` + +## AWS Permissions + +Your [Principal](https://docs.aws.amazon.com/IAM/latest/UserGuide/intro-structure.html#intro-structure-principal) will need several action permissions to use each feature of the Macie resource. Your role will need: + +- the `{{ .Get "macie2:GetFindings" }}` action +- the `{{ .Get "ListClassificationJobs" }}` action +- the `{{ .Get "macie2:DescribeBuckets" }}` action +- the `{{ .Get "macie2:ListFindings" }}` action +- the `{{ .Get "macie2:GetMacieSession" }}` action + +All with `Effect` set to `Allow`. \ No newline at end of file diff --git a/docs-chef-io/content/inspec/resources/aws_operations_contact.md b/docs-chef-io/content/inspec/resources/aws_operations_contact.md new file mode 100644 index 000000000..c36d70998 --- /dev/null +++ b/docs-chef-io/content/inspec/resources/aws_operations_contact.md @@ -0,0 +1,102 @@ ++++ +title = "aws_operations_contact Resource" +platform = "aws" +draft = false +gh_repo = "inspec-aws" + +[menu.inspec] +title = "aws_operations_contact" +identifier = "inspec/resources/aws/aws_operations_contact Resource" +parent = "inspec/resources/aws" ++++ + +Use the `aws_operations_contact` InSpec audit resource to test properties of the operations contact information associated with your account. + +For additional information, including details on parameters and properties, see the [AWS documentation on the operations contact information associated with your account](https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-update-contact-operations.html). Technical details on the data structure can be found for the [api documentation.](https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/Account/Client.html#get_contact_information-instance_method) + +## Installation + +{{% inspec_aws_install %}} + +## Syntax + +The `aws_operations_contact` resource allows the testing of the operations contact information associated with your account. + +```ruby +describe aws_operations_contact do + it { should exist } +end +``` + +## Parameters + +This resources does not take any parameters at this time. + +## Properties + +`api_response` (Struct) +: Returns the api response from our call to the aws api as a struct. + +`raw_data` (Hash) +: Returns a transformed Hash of Strings of the data associated with the operations contact. + +`aws_account_id` (String) +: 12-digit account ID number of the Amazon Web Services account associated with the operations contact. + +`name` (String) +: Specifies the full name of the operations contact. + +`title` (String) +: Specifies the full name of the operations contact. + +`email_address` (String) +: Specifies the full name of the operations contact. + +`phone_number` (String) +: Specifies the phone number associated with the operations contact. + +## Examples + +The following examples show how to use this InSpec audit resource. + +**Test that a operations contact exists for the aws account.** + +```ruby +describe aws_operations_contact do + it { should exist } +end +``` + +**Test that the operations contact is set and the values for its full name and first address line are set as expected.** + +```ruby +describe aws_operations_contact do + it { should be_configured } + its('name') { should cmp 'John Smith' } + its('title') { should cmp 'Ops Guy' } +end +``` + +## Matchers + +{{% inspec_matchers_link %}} + +### exist (alias of configured) + +Use `should` to test if the aws account has a operations contact configured. + +```ruby +it { should exist } +``` + +### configured + +The `configured` matcher tests if the described operations contact is set and configured for the aws account by returning `true` if the api response is not null or data exists in the raw data. + +```ruby +it { should be_configured } +``` + +## AWS Permissions + +{{% aws_permissions_principal action="Aws::Account::Types::GetAlternateContactResponse" %}} diff --git a/docs-chef-io/content/inspec/resources/aws_primary_contact.md b/docs-chef-io/content/inspec/resources/aws_primary_contact.md new file mode 100644 index 000000000..d1024869c --- /dev/null +++ b/docs-chef-io/content/inspec/resources/aws_primary_contact.md @@ -0,0 +1,126 @@ ++++ +title = "aws_primary_contact Resource" +platform = "aws" +draft = false +gh_repo = "inspec-aws" + +[menu.inspec] +title = "aws_primary_contact" +identifier = "inspec/resources/aws/aws_primary_contact Resource" +parent = "inspec/resources/aws" ++++ + +Use the `aws_primary_contact` InSpec audit resource to test properties of the primary contact information associated with your account. + +For additional information, including details on parameters and properties, see the [AWS documentation on primary contact information associated with your account](https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-update-contact-primary.html). Technical details on the data structure can be found for the [api documentation.](https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/Account/Client.html#get_contact_information-instance_method) + +## Installation + +{{% inspec_aws_install %}} + +## Syntax + +An `aws_primary_contact` resource allows the testing of the primary contact information associated with your account. + +```ruby +describe aws_primary_contact do + it { should exist } +end +``` + +## Parameters + +This resources does not take any parameters at this time. + +## Properties + +`aws_account_id` (String) +: 12-digit account ID number of the Amazon Web Services account. + +`address_line_1` (String) +: Specifies the first address line for the primary contact's address. + +`address_line_2` (String) +: Specifies the second address line for the primary contact's address. + +`address_line_3` (String) +: Specifies the third address line for the primary contact's address. + +`city` (String) +: Specifies the city for the primary contact's address. + +`state_or_region` (String) +: Specifies the state or region for the primary contact's address. + +`postal_code` (String) +: Specifies the postal code for the primary contact's address. + +`country_code` (String) +: Specifies the country code of the primary contact. + +`company_name` (String) +: Specifies the company name associated with the primary contact. + +`full_name` (String) +: Specifies the full name of the primary contact. + +`phone_number` (String) +: Specifies the phone number associated with the primary contact. + +`website_url` (String) +: Specifies the website url associated with the primary contact. + +`district_or_county` (String) +: Specifies the district or county associated with the primary contact. + +`api_response` (Struct) +: Returns the api response from our call to the aws api as a struct. + +`raw_data` (Hash) +: Returns a transformed Hash of Strings of the data associated with the primary contact. + +## Examples + +The following examples show how to use this InSpec audit resource. + +**Test that a primary contact exists for the aws account.** + +```ruby +describe aws_primary_contact do + it { should exist } +end +``` + +**Test that an the primary contact is set and the values for its full name and first address line are set as expected.** + +```ruby +describe aws_primary_contact do + it { should be_configured } + its('full_name') { should cmp 'John Smith' } + its('address_line_1') { should cmp '42 Wallaby Way' } +end +``` + +## Matchers + +{{% inspec_matchers_link %}} + +### exist (alias of configured) + +Use `should` to test the if the aws account has a primary contact configured. + +```ruby +it { should exist } +``` + +### configured + +The `configured` matcher tests if the primary contact is set and configured for the aws account by returning `true` if the api response is not null or data exists in the raw data. + +```ruby +it { should be_configured } +``` + +## AWS Permissions + +{{% aws_permissions_principal action="Aws::Account::Types::GetContactInformationResponse" %}} \ No newline at end of file diff --git a/docs-chef-io/content/inspec/resources/aws_region.md b/docs-chef-io/content/inspec/resources/aws_region.md index 0940e2b8f..b77aaaccf 100644 --- a/docs-chef-io/content/inspec/resources/aws_region.md +++ b/docs-chef-io/content/inspec/resources/aws_region.md @@ -31,6 +31,7 @@ end ```ruby describe aws_region(region_name: 'us-east-1') do it { should exist } + its('opt_in_status') { should cmp 'opt-in-not-required' } end ``` @@ -49,6 +50,9 @@ end `endpoint` : The resolved endpoint of the region. +`opt_in_status` +: The opt-in status of the Region (opt-in-not-required | opted-in | not-opted-in). + ## Examples **Test whether a region exists.** @@ -79,6 +83,14 @@ The control will pass if the describe returns at least one result. it { should exist } ``` +### ebs_encryption_enabled + +The control will pass if the region has EBS volume encryption enabled by default. + +```ruby +it { should have_ebs_encryption_enabled } +``` + ## AWS Permissions {{% aws_permissions_principal action="EC2:Client:DescribeRegionsResult" %}} diff --git a/docs-chef-io/content/inspec/resources/aws_regions.md b/docs-chef-io/content/inspec/resources/aws_regions.md index 52f2c24c2..5cb815faa 100644 --- a/docs-chef-io/content/inspec/resources/aws_regions.md +++ b/docs-chef-io/content/inspec/resources/aws_regions.md @@ -44,6 +44,12 @@ end `endpoints` : The resolved endpoints of the regions. +`opt_in_status` +: The opt-in status of the Region. Possible values are: `opt-in-not-required`, `opted-in` and `not-opted-in`. + +`region_opt_status` +: One of the potential statuses a Region can undergo. Possible values are: `Enabled`, `Enabling`, `Disabled`, `Disabling` and `Enabled_By_Default`. + ## Examples The following examples show how to use this InSpec audit resource. diff --git a/docs-chef-io/content/inspec/resources/aws_s3_bucket.md b/docs-chef-io/content/inspec/resources/aws_s3_bucket.md index d918ae16f..2f7c35793 100644 --- a/docs-chef-io/content/inspec/resources/aws_s3_bucket.md +++ b/docs-chef-io/content/inspec/resources/aws_s3_bucket.md @@ -192,6 +192,30 @@ The `have_secure_transport_enabled` matcher tests if a bucket policy that explic it { should have_secure_transport_enabled } +#### prevent_public_access + +The `prevent_public_access` matcher tests if the buckets public access is restricted via the bucket access block of the given bucket via the AWS S3 API. + + it { should be_prevent_public_access } + +#### preventing_public_access_via_bucket + +Alias of `prevent_public_access`. + + it { should be_preventing_public_public_access_via_bucket } + +#### prevent_public_access_by_account + +The `prevent_public_access_by_account` matcher tests if the buckets public access is restricted via current aws account via the AWS S3 Control API. + + it { should be_prevent_public_access_by_account } + +#### preventing_public_access_by_account + +Alias of `prevent_public_access_by_account`. + + it { should be_preventing_public_access_by_account } + ## AWS Permissions Your [Principal](https://docs.aws.amazon.com/IAM/latest/UserGuide/intro-structure.html#intro-structure-principal) will need the `S3:Client:GetBucketAclOutput`, `S3:Client:GetBucketLocationOutput`, `S3:Client:GetBucketLoggingOutput`, `S3:Client:GetBucketPolicyOutput`, and `S3:Client:GetBucketEncryptionOutput` actions set to allow. diff --git a/docs-chef-io/content/inspec/resources/aws_security_contact.md b/docs-chef-io/content/inspec/resources/aws_security_contact.md new file mode 100644 index 000000000..37f70d4dc --- /dev/null +++ b/docs-chef-io/content/inspec/resources/aws_security_contact.md @@ -0,0 +1,102 @@ ++++ +title = "aws_security_contact Resource" +platform = "aws" +draft = false +gh_repo = "inspec-aws" + +[menu.inspec] +title = "aws_security_contact" +identifier = "inspec/resources/aws/aws_security_contact Resource" +parent = "inspec/resources/aws" ++++ + +Use the `aws_security_contact` InSpec audit resource to test properties of the security contact information associated with your account. + +For additional information, including details on parameters and properties, see the [AWS documentation on the security contact information associated with your account](https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-update-contact-security.html). Technical details on the data structure can be found for the [api documentation.](https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/Account/Client.html#get_contact_information-instance_method) + +## Installation + +{{% inspec_aws_install %}} + +## Syntax + +The `aws_security_contact` resource allows the testing of the security contact information associated with your account. + +```ruby +describe aws_security_contact do + it { should exist } +end +``` + +## Parameters + +This resources does not take any parameters at this time. + +## Properties + +`api_response` (Struct) +: Returns the api response from our call to the aws api as a struct. + +`raw_data` (Hash) +: Returns a transformed Hash of Strings of the data associated with the security contact. + +`aws_account_id` (String) +: 12-digit account ID number of the Amazon Web Services account associated with the security contact. + +`name` (String) +: Specifies the full name of the security contact. + +`title` (String) +: Specifies the full name of the security contact. + +`email_address` (String) +: Specifies the full name of the security contact. + +`phone_number` (String) +: Specifies the phone number associated with the security contact. + +## Examples + +The following examples show how to use this InSpec audit resource. + +**Test that a security contact exists for the aws account.** + +```ruby +describe aws_security_contact do + it { should exist } +end +``` + +**Test that the security contact is set and the values for its full name and first address line are set as expected.** + +```ruby +describe aws_security_contact do + it { should be_configured } + its('name') { should cmp 'John Smith' } + its('title') { should cmp 'Ops Guy' } +end +``` + +## Matchers + +{{% inspec_matchers_link %}} + +### exist (alias of configured) + +Use `should` to test if the aws account has a security contact configured. + +```ruby +it { should exist } +``` + +### configured + +The `configured` matcher tests if the described security contact is set and configured for the aws account by returning `true` if the api response is not null or data exists in the raw data. + +```ruby +it { should be_configured } +``` + +## AWS Permissions + +{{% aws_permissions_principal action="Aws::Account::Types::GetAlternateContactResponse" %}} diff --git a/docs-chef-io/content/inspec/resources/aws_securityhub.md b/docs-chef-io/content/inspec/resources/aws_securityhub.md new file mode 100644 index 000000000..61741f682 --- /dev/null +++ b/docs-chef-io/content/inspec/resources/aws_securityhub.md @@ -0,0 +1,114 @@ ++++ +title = "aws_securityhub Resource" +platform = "aws" +draft = false +gh_repo = "inspec-aws" + +[menu.inspec] +title = "aws_securityhub" +identifier = "inspec/resources/aws/aws_securityhub Resource" +parent = "inspec/resources/aws" ++++ + +Use the `aws_securityhub` InSpec audit resource to test properties of a single AWS Security Hub. + +For additional information, including details on parameters and properties, see the [AWS documentation on AWS Security Hub](https://docs.aws.amazon.com/securityhub/1.0/APIReference/API_DescribeHub.html). + +## Installation + +{{% inspec_aws_install %}} + +## Syntax + +Ensure that the hub exists. + +```ruby +describe aws_securityhub_hub do + it { should be_subscribed } +end +``` + +## Parameters + +`aws_region` _(required)_ + +: The region of the Hub resource that was retrieved. + +## Properties + +`hub_arn` +: The ARN of the Hub resource that was retrieved. + +`subscribed_at` +: The date and time when Security Hub was enabled in the account. + +`auto_enable_controls` +: Whether to automatically enable new controls when they are added to standards that are enabled. + +## Examples + +**Ensure an auto enable controls is true.** + +```ruby +describe aws_securityhub do + it { should exist } + its('auto_enable_controls') { should eq true } +end +``` + +**Ensure a hub ARN is available.** + +```ruby +describe aws_securityhub_hub do + it { should be_subscribed } + its('hub_arn') { should eq 'HUB_ARN' } +end +``` + +## Matchers + +{{% inspec_matchers_link %}} + +The controls will pass if the `describe` method returns at least one result. + +### exist + +Use `should` to test that the entity exists. + +```ruby +describe aws_securityhub_hub(hub_arn: 'HUB_ARN') do + it { should exist } +end +``` + +Use `should_not` to test the entity does not exist. + +```ruby +describe aws_securityhub_hub(hub_arn: 'HUB_ARN') do + it { should_not exist } +end +``` + +### subscribed + +Use `should` to test that security hub is scribed in us-east-1. + +```ruby +describe aws_securityhub_hub(aws_region: 'us-east-1' do + it { should be_subscribed } +end +``` + +### be_available + +Use `should` to check if the entity is available. + +```ruby +describe aws_securityhub_hub(hub_arn: 'HUB_ARN') do + it { should be_available } +end +``` + +## AWS Permissions + +{{% aws_permissions_principal action="SecurityHub:Client:DescribeHubResponse" %}} diff --git a/gemspec-util.sh b/gemspec-util.sh new file mode 100755 index 000000000..14af8ed8c --- /dev/null +++ b/gemspec-util.sh @@ -0,0 +1,10 @@ +#!/bin/zsh + +echo -e 'deps and their versions in gemspec\n' +cat *.gemspec | ack -o '(?<=dependency).*(?<=[<>=]\s)(?>\d+\.?\d+)' | tr -d "',><=~" + +echo -e 'Deps and their version on RubyGems\n\n' +cat *.gemspec | ack -o '(?<=dependency).*(?<=[<>=]\s)(?>\d+\.?\d+)' | tr -d "',><=~" | awk '{ print $1 }' | xargs gem info -r | ack '\(\d+\.\d+\.\d+\)' + +echo -e 'gemspec and current remote versions side-by-side\n\n' +paste <(cat $(ls | ack gemspec) | ack -o '(?<=dependency).*(?<=[<>=]\s)(?>\d+\.?\d+)' | tr -d "',><=~") <(cat $(ls | ack gemspec) | ack -o '(?<=dependency).*(?<=[<>=]\s)(?>\d+\.?\d+)' | tr -d "',><=~" | awk '{ print $1 }' | xargs gem info -r | ack -o '(?<=\()(\d+\.\d+\.\d+)(?!>\))') diff --git a/libraries/aws_alternate_contact.rb b/libraries/aws_alternate_contact.rb new file mode 100644 index 000000000..b93437bbf --- /dev/null +++ b/libraries/aws_alternate_contact.rb @@ -0,0 +1,120 @@ +require "aws_backend" + +class AwsAlternateAccount < AwsResourceBase + name "aws_alternate_contact" + desc "Verifies the billing contact information for an AWS Account." + example <<~EXAMPLE1 + describe aws_alternate_account(type: 'billing') do + it { should be_configured } + its('name') { should cmp 'John Smith' } + its('email_address') { should cmp 'jsmith@acme.com' } + end + EXAMPLE1 + + example <<~EXAMPLE2 + describe aws_alternate_account('security') do + it { should be_configured } + its('name') { should cmp 'Jane Smith' } + its('email_address') { should cmp 'janesmith@acme.com' } + end + EXAMPLE2 + + attr_reader :raw_data, + :api, + :api_response, + :email_address, + :name, + :phone_number, + :title, + :aws_account_id + + def initialize(opts = {}) + @raw_data = {} + supported_opt_keys = %i(type) + supported_opts_values = %w{billing operations security} + opts = { type: opts } if opts.is_a?(String) + + unless opts.respond_to?(:keys) + raise ArgumentError, + "Invalid aws_alternate_contact param '#{opts}'. Please pass a hash with these supported key(s): #{supported_opt_keys}" + end + unless (opts.keys - supported_opt_keys).empty? + raise ArgumentError, + "Unsupported aws_alternate_contact options '#{opts.keys - supported_opt_keys}'. Supported key(s): #{supported_opt_keys}" + end + unless opts.keys && (opts.keys & supported_opt_keys).length == 1 + raise ArgumentError, + "Specifying more than one of :type for aws_alternate_account is not supported" + end + unless supported_opts_values.any? { |val| opts.values.include?(val) } + raise ArgumentError, + "You may only pass a value of type: #{supported_opts_values} as the ':type' for aws_alternate_account" + end + super(opts) + validate_parameters(required: [:type]) + catch_aws_errors do + begin + @aws_account_id = fetch_aws_account + @api_response = fetch_aws_alternate_contact(opts[:type]) + rescue Aws::Account::Errors::ResourceNotFoundException + skip_resource( + "The #{opts[:type].uppercase} contact has not been configured for this AWS Account.", + ) + return + rescue Aws::Errors::NoSuchEndpointError, Seahorse::Client::NetworkingError + skip_resource( + "The account contact endpoint is not available in this segment, please review this via the AWS Management Console.", + ) + end + return [] if !@api_response || @api_response.empty? + end + + unless @api_response.nil? + @api_response + .members + .map(&:to_s) + .each do |key| + instance_variable_set("@#{key}", @api_response.send(key)) + end + @raw_data = @api_response.to_h.transform_keys(&:to_s) + else + @name, @email_address, @phone_number, @title = "" + end + end + + def configured? + !@api_response.nil? || !@raw_data + end + + alias exist? configured? + + def resource_id + if @aws_account_id + "AWS #{opts[:type].capitalize} Contact for account: #{@aws_account_id}" + else + "AWS #{opts[:type].capitalize} Contact Information" + end + end + + def to_s + if @aws_account_id + "AWS #{opts[:type].capitalize} Contact for account: #{@aws_account_id}" + else + "AWS Account #{opts[:type].capitalize} Contact" + end + end + + private + + def fetch_aws_account + arn = @aws.sts_client.get_caller_identity({}).arn + arn.split(":")[4] + end + + def fetch_aws_alternate_contact(type) + @aws + .account_client + .get_alternate_contact({ alternate_contact_type: type.upcase.to_s }) + .alternate_contact + end +end diff --git a/libraries/aws_backend.rb b/libraries/aws_backend.rb index 217ba5a83..efc69ff44 100644 --- a/libraries/aws_backend.rb +++ b/libraries/aws_backend.rb @@ -28,6 +28,7 @@ require "aws-sdk-rds" require "aws-sdk-route53" require "aws-sdk-s3" +require "aws-sdk-s3control" require "aws-sdk-shield" require "aws-sdk-sns" require "aws-sdk-sqs" @@ -62,6 +63,10 @@ require "aws-sdk-waf" require "aws-sdk-synthetics" require "aws-sdk-apigatewayv2" +require "aws-sdk-account" +require "aws-sdk-accessanalyzer" +require "aws-sdk-macie2" +require "aws-sdk-wafv2" # AWS Inspec Backend Classes # @@ -74,9 +79,7 @@ def initialize(params) # This can be useful for e.g. # https://docs.aws.amazon.com/sdk-for-ruby/v3/developer-guide/stubbing.html # https://docs.aws.amazon.com/sdk-for-ruby/v3/developer-guide/setup-config.html#aws-ruby-sdk-setting-non-standard-endpoint - if params.is_a?(Hash) - @client_args = params.fetch(:client_args, nil) - end + @client_args = params.fetch(:client_args, nil) if params.is_a?(Hash) @cache = {} end @@ -215,6 +218,10 @@ def storage_client aws_client(Aws::S3::Client) end + def storage_control_client + aws_client(Aws::S3Control::Client) + end + def sts_client aws_client(Aws::STS::Client) end @@ -338,6 +345,22 @@ def synthetics_client def apigatewayv2_client aws_client(Aws::ApiGatewayV2::Client) end + + def account_client + aws_client(Aws::Account::Client) + end + + def access_analyzer_client + aws_client(Aws::AccessAnalyzer::Client) + end + + def partitions_region_client + aws_client(Aws::Partitions::Region::Client) + end + + def macie_client + aws_client(Aws::Macie2::Client) + end end # Base class for AWS resources @@ -353,15 +376,29 @@ def initialize(opts) client_args = { client_args: {} } if opts.is_a?(Hash) # below allows each resource to optionally and conveniently set a region - client_args[:client_args][:region] = opts[:aws_region] if opts[:aws_region] + client_args[:client_args][:region] = opts[:aws_region] if opts[ + :aws_region + ] # below allows each resource to optionally and conveniently set an endpoint - client_args[:client_args][:endpoint] = opts[:aws_endpoint] if opts[:aws_endpoint] + client_args[:client_args][:endpoint] = opts[:aws_endpoint] if opts[ + :aws_endpoint + ] # below allows each resource to optionally and conveniently set max_retries and retry_backoff env_hash = ENV.map { |k, v| [k.downcase, v] }.to_h - opts[:aws_retry_limit]= env_hash["aws_retry_limit"].to_i if !opts[:aws_retry_limit] && env_hash["aws_retry_limit"] - opts[:aws_retry_backoff]= env_hash["aws_retry_backoff"].to_i if !opts[:aws_retry_backoff] && env_hash["aws_retry_backoff"] - client_args[:client_args][:retry_limit] = opts[:aws_retry_limit] if opts[:aws_retry_limit] - client_args[:client_args][:retry_backoff] = "lambda { |c| sleep(#{opts[:aws_retry_backoff]}) }" if opts[:aws_retry_backoff] + opts[:aws_retry_limit] = env_hash["aws_retry_limit"].to_i if !opts[ + :aws_retry_limit + ] && env_hash["aws_retry_limit"] + opts[:aws_retry_backoff] = env_hash["aws_retry_backoff"].to_i if !opts[ + :aws_retry_backoff + ] && env_hash["aws_retry_backoff"] + client_args[:client_args][:retry_limit] = opts[:aws_retry_limit] if opts[ + :aws_retry_limit + ] + if opts[:aws_retry_backoff] + client_args[:client_args][ + :retry_backoff + ] = "lambda { |c| sleep(#{opts[:aws_retry_backoff]}) }" + end # this catches the stub_data true option for unit testing - and others that could be useful for consumers client_args[:client_args].update(opts[:client_args]) if opts[:client_args] @@ -375,9 +412,14 @@ def initialize(opts) # here we might want to inject stub data for testing, let's use an option for that return if !defined?(@opts.keys) || !@opts.include?(:stub_data) - raise ArgumentError, "Expected stub data to be an array" if !opts[:stub_data].is_a?(Array) + if !opts[:stub_data].is_a?(Array) + raise ArgumentError, "Expected stub data to be an array" + end opts[:stub_data].each do |stub| - raise ArgumentError, "Expect each stub_data hash to have :client, :method and :data keys" if !stub.keys.all? { |a| %i(method data client).include?(a) } + if !stub.keys.all? { |a| %i(method data client).include?(a) } + raise ArgumentError, + "Expect each stub_data hash to have :client, :method and :data keys" + end @aws.aws_client(stub[:client]).stub_responses(stub[:method], stub[:data]) end end @@ -389,24 +431,57 @@ def initialize(opts) # If a parameter is entirely optional, use `allow` def validate_parameters(allow: [], required: nil, require_any_of: nil) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity if required - raise ArgumentError, "Expected required parameters as Array of Symbols, got #{required}" unless required.is_a?(Array) && required.all? { |r| r.is_a?(Symbol) } - raise ArgumentError, "#{@__resource_name__}: `#{required}` must be provided" unless @opts.is_a?(Hash) && required.all? { |req| @opts.key?(req) && !@opts[req].nil? && @opts[req] != "" } + unless required.is_a?(Array) && required.all? { |r| r.is_a?(Symbol) } + raise ArgumentError, + "Expected required parameters as Array of Symbols, got #{required}" + end + unless @opts.is_a?(Hash) && + required.all? { |req| + @opts.key?(req) && !@opts[req].nil? && @opts[req] != "" + } + raise ArgumentError, + "#{@__resource_name__}: `#{required}` must be provided" + end allow += required end if require_any_of - raise ArgumentError, "Expected required parameters as Array of Symbols, got #{require_any_of}" unless require_any_of.is_a?(Array) && require_any_of.all? { |r| r.is_a?(Symbol) } - raise ArgumentError, "#{@__resource_name__}: One of `#{require_any_of}` must be provided." unless @opts.is_a?(Hash) && require_any_of.any? { |req| @opts.key?(req) && !@opts[req].nil? && @opts[req] != "" } + unless require_any_of.is_a?(Array) && + require_any_of.all? { |r| r.is_a?(Symbol) } + raise ArgumentError, + "Expected required parameters as Array of Symbols, got #{require_any_of}" + end + unless @opts.is_a?(Hash) && + require_any_of.any? { |req| + @opts.key?(req) && !@opts[req].nil? && @opts[req] != "" + } + raise ArgumentError, + "#{@__resource_name__}: One of `#{require_any_of}` must be provided." + end allow += require_any_of end - allow += %i(client_args stub_data aws_region aws_endpoint aws_retry_limit aws_retry_backoff resource_data) - raise ArgumentError, "Scalar arguments not supported" unless defined?(@opts.keys) - raise ArgumentError, "Unexpected arguments found" unless @opts.keys.all? { |a| allow.include?(a) } - raise ArgumentError, "Provided parameter should not be empty" unless @opts.values.all? do |a| + allow += %i( + client_args + stub_data + aws_region + aws_endpoint + aws_retry_limit + aws_retry_backoff + resource_data + ) + unless defined?(@opts.keys) + raise ArgumentError, "Scalar arguments not supported" + end + unless @opts.keys.all? { |a| allow.include?(a) } + raise ArgumentError, "Unexpected arguments found" + end + unless @opts.values.all? { |a| return true if a.instance_of?(Integer) return true if [TrueClass, FalseClass].include?(a.class) !a.empty? + } + raise ArgumentError, "Provided parameter should not be empty" end true end @@ -430,16 +505,47 @@ def name end # Intercept AWS exceptions - def catch_aws_errors + def catch_aws_errors # rubocop:disable Metrics/MethodLength yield # Catch and create custom messages as needed + rescue Aws::Account::Errors::ResourceNotFoundException => e + Inspec::Log.warn(e.message.to_s) + skip_resource(e.message.to_s) + nil + rescue Aws::IAM::Errors::NoSuchEntity => e + Inspec::Log.error("IAM Service Error: #{e.message}") + skip_resource("IAM Service Error: #{e.message}") + nil rescue Aws::Errors::MissingCredentialsError - Inspec::Log.error "It appears that you have not set your AWS credentials. See https://www.inspec.io/docs/reference/platforms for details." + Inspec::Log.error("It appears that you have not set your AWS credentials. See https://www.inspec.io/docs/reference/platforms for details.") fail_resource("No AWS credentials available") nil + rescue Aws::SecurityHub::Errors::InvalidAccessException => e + Inspec::Log.warn("#{e.message} in region: #{opts[:aws_region]}") + nil rescue Aws::Errors::NoSuchEndpointError - Inspec::Log.error "The endpoint that is trying to be accessed does not exist." + Inspec::Log.error("The endpoint that is trying to be accessed does not exist.") fail_resource("Invalid Endpoint error") nil + rescue Aws::AccessAnalyzer::Errors::ServiceError => e + Inspec::Log.warn(e.message) + skip_resource(e.message) + nil + rescue Aws::S3::Errors::NoSuchPublicAccessBlockConfiguration + Inspec::Log.error("No public access block configuration was found") + skip_resource("No public access block configuration was found") + nil + rescue Aws::Macie2::Errors::ServiceError => e + Inspec::Log.error("Macie Service Error: #{e.message}") + skip_resource("Macie Service Error: #{e.message}") + nil + rescue Aws::Macie2::Errors::ResourceNotFoundException => e + Inspec::Log.error("Macie Resource: #{e.message}") + skip_resource("Macie Resource Error: #{e.message}") + nil + rescue Seahorse::Client::NetworkingError => e + Inspec::Log.error("Seahorse Error: #{e.message}") + skip_resource("Seahorse Error: #{e.message}") + nil rescue Aws::Errors::ServiceError => e if is_permissions_error(e) advice = "" @@ -448,18 +554,20 @@ def catch_aws_errors when "InvalidAccessKeyId" advice = "Please ensure your AWS Access Key ID is set correctly." when "InvalidClientTokenId" - advice = "Please ensure that the aws access key, aws secret access key, and the aws session token are correct." + advice = + "Please ensure that the aws access key, aws secret access key, and the aws session token are correct." when "AccessDenied" - advice = "Please check the IAM permissions required for this Resource in the documentation, " \ - "and ensure your Service Principal has these permissions set." + advice = + "Please check the IAM permissions required for this Resource in the documentation, " \ + "and ensure your Service Principal has these permissions set." end error_message = "#{e.message}: #{advice}" raise Inspec::Exceptions::ResourceFailed, error_message else Inspec::Log.warn "AWS Service Error encountered running a control with Resource #{@__resource_name__}. " \ - "Error message: #{e.message}. You should address this error to ensure your controls are " \ - "behaving as expected." + "Error message: #{e.message} You should address this error to ensure your controls are " \ + "behaving as expected." @failed_resource = true end nil @@ -479,9 +587,7 @@ def is_permissions_error(error) def map_tags(tag_list) return {} if tag_list.nil? || tag_list.empty? tags = {} - tag_list.each do |tag| - tags[tag[:key]] = tag[:value] - end + tag_list.each { |tag| tags[tag[:key]] = tag[:value] } tags end @@ -509,9 +615,10 @@ def respond_to_missing?(*several_variants) # This method should be used when AWS API returns multiple resources for the provided criteria. def resource_fail(message = nil) - message ||= "#{@__resource_name__}: #{@display_name}. Multiple AWS resources were returned for the provided criteria. "\ - "If you wish to test multiple entities, please use the plural resource. "\ - "Otherwise, please provide more specific criteria to lookup the resource." + message ||= + "#{@__resource_name__}: #{@display_name}. Multiple AWS resources were returned for the provided criteria. " \ + "If you wish to test multiple entities, please use the plural resource. " \ + "Otherwise, please provide more specific criteria to lookup the resource." # Fail resource in resource pack. `exists?` method will return `false`. @failed_resource = true # Fail resource in InSpec core. Tests in InSpec profile will return the message. @@ -546,14 +653,16 @@ def self.populate_filter_table(raw_data, table_scheme) end def fetch(client:, operation:, kwargs: {}) - raise ArgumentError, "Valid Client not found!" unless @aws.respond_to?(client) + unless @aws.respond_to?(client) + raise ArgumentError, "Valid Client not found!" + end client_obj = @aws.send(client) - raise ArgumentError, "#{client} does not support #{operation}" unless client_obj.respond_to?(operation) - - catch_aws_errors do - client_obj.send(operation, **kwargs) + unless client_obj.respond_to?(operation) + raise ArgumentError, "#{client} does not support #{operation}" end + + catch_aws_errors { client_obj.send(operation, **kwargs) } end private @@ -561,7 +670,10 @@ def fetch(client:, operation:, kwargs: {}) def populate_filter_table_from_response return unless @table.present? - table_schema = @table.first.keys.map { |key| { column: key.to_s.pluralize.to_sym, field: key, style: :simple } } + table_schema = + @table.first.keys.map do |key| + { column: key.to_s.pluralize.to_sym, field: key, style: :simple } + end AwsCollectionResourceBase.populate_filter_table(:table, table_schema) end end @@ -583,14 +695,16 @@ def create_methods(object, data) when /Aws::.*/ # iterate around the instance variables data.instance_variables.each do |var| - create_method(object, var.to_s.delete("@"), data.instance_variable_get(var)) + create_method( + object, + var.to_s.delete("@"), + data.instance_variable_get(var), + ) end # When the data is a Hash object iterate around each of the key value pairs and # create a method for each one. when "Hash" - data.each do |key, value| - create_method(object, key, value) - end + data.each { |key, value| create_method(object, key, value) } end end @@ -610,7 +724,11 @@ def create_method(object, name, value) value end when "Hash" - value.count == 0 ? return_value = value : return_value = AwsResourceProbe.new(value) + if value.count == 0 + return_value = value + else + return_value = AwsResourceProbe.new(value) + end object.define_singleton_method name do return_value end @@ -633,9 +751,7 @@ def create_method(object, name, value) else if name.eql?(:tags) probes = {} - value.each do |tag| - probes[tag[:key]] = tag[:value] - end + value.each { |tag| probes[tag[:key]] = tag[:value] } else probes = [] value.each do |value_item| diff --git a/libraries/aws_billing_contact.rb b/libraries/aws_billing_contact.rb new file mode 100644 index 000000000..46384452d --- /dev/null +++ b/libraries/aws_billing_contact.rb @@ -0,0 +1,94 @@ +require "aws_backend" + +class AwsBillingAccount < AwsResourceBase + name "aws_billing_contact" + desc "Verifies the billing contact information for an AWS Account." + example <<~EXAMPLE + describe aws_billing_account do + it { should be_configured } + its('name') { should cmp 'John Smith' } + its('email_address') { should cmp 'jsmith@acme.com' } + end + EXAMPLE + + attr_reader :raw_data, + :api, + :api_response, + :email_address, + :name, + :phone_number, + :title, + :aws_account_id + + def initialize(opts = {}) + super(opts) + @raw_data = {} + @title, @name, @email_address, @phone_number = "" + validate_parameters + catch_aws_errors do + begin + catch_aws_errors do + @aws_account_id = fetch_aws_account + @api_response = fetch_aws_alternate_contact("billing") + rescue Aws::Account::Errors::ResourceNotFoundException + @api_response = nil + skip_resource( + "The Billing contact has not been configured for this AWS Account.", + ) + rescue Aws::Errors::NoSuchEndpointError + @api_response = nil + skip_resource( + "The account contact endpoint is not available in this segment, please review this via the AWS Management Console.", + ) + end + end + return [] if !@api_response || @api_response.empty? + end + + if @api_response + @api_response + .members + .map(&:to_s) + .each do |key| + instance_variable_set("@#{key}", @api_response.send(key)) + end + end + @raw_data = @api_response.to_h.transform_keys(&:to_s) + end + + def configured? + !@api_response.nil? || !@raw_data + end + + alias exist? configured? + + def resource_id + if @aws_account_id + "AWS Billing Contact for account: #{@aws_account_id}" + else + "AWS Billing Contact Information" + end + end + + def to_s + if @aws_account_id + "AWS Billing Contact for account: #{@aws_account_id}" + else + "AWS Account Primary Contact" + end + end + + private + + def fetch_aws_account + arn = @aws.sts_client.get_caller_identity({}).arn + arn.split(":")[4] + end + + def fetch_aws_alternate_contact(type) + @aws + .account_client + .get_alternate_contact({ alternate_contact_type: type.upcase.to_s }) + .alternate_contact + end +end diff --git a/libraries/aws_cloudtrail_trail.rb b/libraries/aws_cloudtrail_trail.rb index bb55764c8..f9e15ec52 100644 --- a/libraries/aws_cloudtrail_trail.rb +++ b/libraries/aws_cloudtrail_trail.rb @@ -4,14 +4,26 @@ class AwsCloudTrailTrail < AwsResourceBase name "aws_cloudtrail_trail" desc "Verifies settings for an individual AWS CloudTrail Trail." example <<-EXAMPLE - describe aws_cloudtrail_trail('TRIAL_NAME') do + describe aws_cloudtrail_trail('TRAIL_NAME') do it { should exist } + it { should be_monitoring_read("AWS::S3::Object") } + it { should be_monitoring_write("AWS::S3::Object") } + it { should be_multi_region_trail } end EXAMPLE - attr_reader :cloud_watch_logs_log_group_arn, :cloud_watch_logs_role_arn, :home_region, :trail_name, - :kms_key_id, :s3_bucket_name, :s3_key_prefix, :trail_arn, :is_multi_region_trail, - :log_file_validation_enabled, :is_organization_trail + attr_reader :cloud_watch_logs_log_group_arn, + :cloud_watch_logs_role_arn, + :home_region, + :trail_name, + :kms_key_id, + :s3_bucket_name, + :s3_key_prefix, + :trail_arn, + :is_multi_region_trail, + :log_file_validation_enabled, + :is_organization_trail, + :event_selectors alias multi_region_trail? is_multi_region_trail alias log_file_validation_enabled? log_file_validation_enabled @@ -24,8 +36,13 @@ def initialize(opts = {}) validate_parameters(required: [:trail_name]) @trail_name = opts[:trail_name] + @event_selectors = [] catch_aws_errors do - resp = @aws.cloudtrail_client.describe_trails({ trail_name_list: [@trail_name] }) + resp = + @aws.cloudtrail_client.describe_trails( + { trail_name_list: [@trail_name] }, + ) + @event_selectors = @aws.cloudtrail_client.get_event_selectors({ trail_name: @trail_name }) @trail = resp.trail_list[0].to_h @trail_arn = @trail[:trail_arn] @kms_key_id = @trail[:kms_key_id] @@ -48,8 +65,14 @@ def delivered_logs_days_ago return nil unless exists? catch_aws_errors do begin - trail_status = @aws.cloudtrail_client.get_trail_status({ name: @trail_name }).to_h - ((Time.now - trail_status[:latest_cloud_watch_logs_delivery_time]) / (24 * 60 * 60)).to_i unless trail_status[:latest_cloud_watch_logs_delivery_time].nil? + trail_status = + @aws.cloudtrail_client.get_trail_status({ name: @trail_name }).to_h + unless trail_status[:latest_cloud_watch_logs_delivery_time].nil? + ( + (Time.now - trail_status[:latest_cloud_watch_logs_delivery_time]) / + (24 * 60 * 60) + ).to_i + end rescue Aws::CloudTrail::Errors::TrailNotFoundException nil end @@ -59,7 +82,9 @@ def delivered_logs_days_ago def logging? catch_aws_errors do begin - @aws.cloudtrail_client.get_trail_status({ name: @trail_name }).to_h[:is_logging] + @aws.cloudtrail_client.get_trail_status({ name: @trail_name }).to_h[ + :is_logging + ] rescue Aws::CloudTrail::Errors::TrailNotFoundException nil end @@ -74,23 +99,90 @@ def get_log_group_for_multi_region_active_mgmt_rw_all return nil unless exists? return nil unless @cloud_watch_logs_log_group_arn return nil if @cloud_watch_logs_log_group_arn.split(":").count < 6 - return @cloud_watch_logs_log_group_arn.split(":")[6] if has_event_selector_mgmt_events_rw_type_all? && logging? + if has_event_selector_mgmt_events_rw_type_all? && logging? + @cloud_watch_logs_log_group_arn.split(":")[6] + end end + # TODO: see what happens when running against nil event selectors def has_event_selector_mgmt_events_rw_type_all? return nil unless exists? event_selector_found = false - begin - event_selectors = @aws.cloudtrail_client.get_event_selectors(trail_name: @trail_name) - event_selectors.event_selectors.each do |es| - event_selector_found = true if es.read_write_type == "All" && es.include_management_events == true + if using_basic_event_selectors? + begin + @event_selectors.event_selectors.each do |es| + event_selector_found = true if es.read_write_type == "All" && + es.include_management_events == true + end + rescue Aws::CloudTrail::Errors::TrailNotFoundException + event_selector_found end - rescue Aws::CloudTrail::Errors::TrailNotFoundException - event_selector_found + else + event_selector_found = @event_selectors.advanced_event_selectors.any? { |es| + # check if readOnly is unset entirely (means both read and write are logged) + es.field_selectors.none? { |fs| fs.field == "readOnly" } && \ + # check if a field selector is set to track management events + es.field_selectors.any? { |fs| fs.field == "eventCategory" && fs.equals == ["Management"] } + } end event_selector_found end + def monitoring?(aws_resource_type, mode) + # basic event selectors have a simpler structure than the advanced ones - check basic first + if using_basic_event_selectors? + basic_mode = mode == "r" ? "ReadOnly" : "WriteOnly" + @event_selectors.event_selectors.any? do |es| + es.read_write_type.match?(/All|#{basic_mode}/) && + es.data_resources.any? do |dr| + dr.type.include?(aws_resource_type) && + # make sure the values do not indicate individual resources + dr.values.all? do |val| + # val can be of the form 'arn:aws:s3' but not 'arn:aws:s3:::/' + val.split(%r{[:/]}).count <= 3 + end + end + end + else + read_only = mode == "r" + @event_selectors.advanced_event_selectors.any? do |es| + ( + es.field_selectors.any? do |fs| # check if readOnly is explicitly set to true + fs.field == "readOnly" && fs.equals == [read_only.to_s] # NOTE: that AdvancedFieldSelector has a field named "equals" + # also note that designating an AFS as writeOnly means setting + # the readOnly field to 'false' + end || + es.field_selectors.none? do |fs| # or check if readOnly is unset entirely (means both read and write are logged) + fs.field == "readOnly" + end + ) && + es.field_selectors.any? do |fs| # check if some other field selector is set to the right resource type + fs.field == "resources.type" && fs.equals == [aws_resource_type] + end && + es.field_selectors.none? do |fs| # check that no other event selector is tracking an individual arn + # if no arn field is set, cloudtrail is tracking the whole type + fs.field.downcase == "resources.arn" + end + end + end + end + + def monitoring_read?(aws_resource_type) + monitoring?(aws_resource_type, "r") + end + + def monitoring_write?(aws_resource_type) + monitoring?(aws_resource_type, "w") + end + + def using_advanced_event_selectors? + @event_selectors.advanced_event_selectors.present? + end + + def using_basic_event_selectors? + @event_selectors.event_selectors.present? + end + def exists? !@trail.nil? && !@trail.empty? end diff --git a/libraries/aws_ec2_instance.rb b/libraries/aws_ec2_instance.rb index 4a511ed4b..feaa7f616 100644 --- a/libraries/aws_ec2_instance.rb +++ b/libraries/aws_ec2_instance.rb @@ -16,31 +16,40 @@ class AwsEc2Instance < AwsResourceBase end " + attr_reader :instance + def initialize(opts = {}) opts = { instance_id: opts } if opts.is_a?(String) super(opts) validate_parameters(require_any_of: %i(instance_id name)) if opts[:instance_id] && !opts[:instance_id].empty? # Use instance_id, if provided - if !opts[:instance_id].is_a?(String) || opts[:instance_id] !~ /(^i-[0-9a-f]{8})|(^i-[0-9a-f]{17})$/ - raise ArgumentError, "#{@__resource_name__}: `instance_id` must be a string in the format of 'i-' followed by 8 or 17 hexadecimal characters." + if !opts[:instance_id].is_a?(String) || + opts[:instance_id] !~ /(^i-[0-9a-f]{8})|(^i-[0-9a-f]{17})$/ + raise ArgumentError, + "#{@__resource_name__}: `instance_id` must be a string in the format of 'i-' followed by 8 or 17 hexadecimal characters." end @display_name = opts[:instance_id] instance_arguments = { instance_ids: [opts[:instance_id]] } elsif opts[:name] && !opts[:name].empty? # Otherwise use name, if provided @display_name = opts[:name] - instance_arguments = { filters: [{ name: "tag:Name", values: [opts[:name]] }] } + instance_arguments = { + filters: [{ name: "tag:Name", values: [opts[:name]] }], + } else - raise ArgumentError, "#{@__resource_name__}: either instance_id or name must be provided." + raise ArgumentError, + "#{@__resource_name__}: either instance_id or name must be provided." end catch_aws_errors do resp = @aws.compute_client.describe_instances(instance_arguments) - if resp.reservations.first.nil? || resp.reservations.first.instances.first.nil? + if resp.reservations.first.nil? || + resp.reservations.first.instances.first.nil? empty_response_warn return end - if resp.reservations.count > 1 || resp.reservations.first.instances.count > 1 + if resp.reservations.count > 1 || + resp.reservations.first.instances.count > 1 resource_fail return else @@ -57,7 +66,9 @@ def state end def security_groups - @instance[:security_groups].map { |sg| { id: sg[:group_id], name: sg[:group_name] } } + @instance[:security_groups].map do |sg| + { id: sg[:group_id], name: sg[:group_name] } + end end def tags @@ -86,7 +97,9 @@ def availability_zone def ebs_volumes return nil unless @instance[:block_device_mappings] return nil if @instance[:block_device_mappings].count == 0 - @instance[:block_device_mappings].map { |vol| { id: vol[:ebs][:volume_id], name: vol[:device_name] } } + @instance[:block_device_mappings].map do |vol| + { id: vol[:ebs][:volume_id], name: vol[:device_name] } + end end def network_interface_ids @@ -96,12 +109,18 @@ def network_interface_ids end def has_roles? - return false unless @instance[:iam_instance_profile] && @instance[:iam_instance_profile][:arn] + unless @instance[:iam_instance_profile] && + @instance[:iam_instance_profile][:arn] + return false + end instance_profile = @instance[:iam_instance_profile][:arn].split("/").last @returned_roles = nil # Check if there is a role created at the attached profile catch_aws_errors do - resp = @aws.iam_client.get_instance_profile({ instance_profile_name: instance_profile }) + resp = + @aws.iam_client.get_instance_profile( + { instance_profile_name: instance_profile }, + ) @returned_roles = resp.instance_profile.roles end @returned_roles && !@returned_roles.empty? @@ -117,7 +136,15 @@ def role end # Generate a matcher for each state - %w{pending running shutting-down terminated stopping stopped unknown}.each do |state_name| + %w{ + pending + running + shutting-down + terminated + stopping + stopped + unknown + }.each do |state_name| define_method "#{state_name.tr("-", "_")}?" do state == state_name end diff --git a/libraries/aws_iam_access_analyzers.rb b/libraries/aws_iam_access_analyzers.rb new file mode 100644 index 000000000..bfcdec0b3 --- /dev/null +++ b/libraries/aws_iam_access_analyzers.rb @@ -0,0 +1,125 @@ +require "aws_backend" +require "pry" + +class AwsIamAccessAnalyzer < AwsResourceBase + name "aws_iam_access_analyzers" + desc "Verifies settings for a collection AWS IAM Access Analyzers." + example <<~EXAMPLE1 + # retrieve both 'account' and 'organization' analyzers + describe aws_iam_access_analyzers do + it { should exist } + end + EXAMPLE1 + + example <<~EXAMPLE2 + # retrieve only 'account' analyzers + describe aws_iam_access_analyzers('account') do + it { should exist } + end + EXAMPLE2 + + example <<~EXAMPLE3 + # retrieve only 'account' analyzers + describe aws_iam_access_analyzers(type: 'account') do + it { should exist } + end + EXAMPLE3 + + attr_reader :table, :raw_data, :api_response, :aws_account_id, :parameters + + FilterTable + .create + .register_column(:analyzer_names, field: :name) + .register_column(:analyzer_type, field: :type) + .register_column(:arns, field: :arn) + .register_column(:created_date, field: :created_at) + .register_column(:last_resource_analyzed, field: :last_resource_analyzed) + .register_column(:last_analyzed_date, field: :last_resource_analyzed_at) + .register_column(:tags, field: :tags) + .register_column(:status, field: :status) + .register_column(:status_reason, filed: :status_reason) + .install_filter_methods_on_resource(self, :table) + + def initialize(opts = {}) + @raw_data = [] + parameters = {} + @api_response = nil + @supported_opts_values = %w{account organization all} + opts = { type: opts } if opts.is_a?(String) + super(opts) + validate_parameters(allow: %i(type aws_region)) + parameters[:type] = opts[:type].upcase if opts[:type] + unless @supported_opts_values.map(&:upcase).include?(parameters[:type]) || parameters[:type].nil? + raise ArgumentError, "Unsupported Account Type: '#{parameters[:type].downcase}'. Supported account types: #{@supported_opts_values}" + end + @table = fetch_data(parameters) + end + + def fetch_data(parameters) + analyzer_rows = [] + + catch_aws_errors do + catch_aws_errors { @aws_account_id = fetch_aws_account } + if parameters.empty? || parameters[:type] == "ALL" + @api_response = @aws.access_analyzer_client.list_analyzers + elsif parameters[:type] == "ACCOUNT" || + parameters[:type] == "ORGANIZATION" + @api_response = @aws.access_analyzer_client.list_analyzers(parameters) + end + + @api_response.analyzers.each do |aa| + analyzer_rows += [ + { + arn: aa.arn, + name: aa.name, + type: aa.type, + created_at: aa.created_at, + last_resource_analyzed: aa.last_resource_analyzed, + last_resource_analyzed_at: aa.last_resource_analyzed_at, + tags: aa.tags, + status: aa.status, + status_reason: aa.status_reason, + }, + ] + end + @raw_data = + @api_response[:analyzers].empty? ? [] : @api_response.to_h[:analyzers] + end + @table = analyzer_rows + end + + def resource_id + response = "AWS IAM " + opts[:type] ? response += "#{opts[:type].capitalize} " : "" + if @aws_account_id + response += + "Account Analyzer for #{@aws_account_id} in #{get_current_region}" + else + response += "Account Analyzer Information in #{get_current_region}" + end + response + end + + def to_s + response = "AWS IAM " + opts[:type] ? response += "#{opts[:type].capitalize} " : "" + if @aws_account_id + response += + "Account Analyzer for #{@aws_account_id} in #{get_current_region}" + else + response += "Account Analyzer Information in #{get_current_region}" + end + response + end + + private + + def fetch_aws_account + arn = @aws.sts_client.get_caller_identity({}).arn + arn.split(":")[4] + end + + def get_current_region + @aws.access_analyzer_client.config.region + end +end diff --git a/libraries/aws_iam_access_keys.rb b/libraries/aws_iam_access_keys.rb index ba4c4b441..4571a6b21 100644 --- a/libraries/aws_iam_access_keys.rb +++ b/libraries/aws_iam_access_keys.rb @@ -11,21 +11,42 @@ class AwsIamAccessKeys < AwsCollectionResourceBase attr_reader :table - FilterTable.create - .register_column(:usernames, field: :username) - .register_column(:access_key_ids, field: :access_key_id) - .register_column(:created_date, field: :create_date) - .register_column(:created_days_ago, field: :created_days_ago) - .register_column(:created_with_user, field: :created_with_user) - .register_column(:created_hours_ago, field: :created_hours_ago) - .register_column(:active, field: :active) - .register_column(:inactive, field: :inactive) - .register_column(:last_used_date, field: :last_used_date, lazy_instance: :lazy_load_last_used_date) - .register_column(:last_used_hours_ago, field: :last_used_hours_ago, lazy_instance: :lazy_load_last_used_hours_ago) - .register_column(:last_used_days_ago, field: :last_used_days_ago, lazy_instance: :lazy_load_last_used_days_ago) - .register_column(:ever_used, field: :ever_used, lazy_instance: :lazy_load_ever_used) - .register_column(:never_used, field: :never_used, lazy_instance: :lazy_load_never_used_time) - .register_column(:user_created_date, field: :user_created_date) + FilterTable + .create + .register_column(:usernames, field: :username) + .register_column(:access_key_ids, field: :access_key_id) + .register_column(:created_date, field: :create_date) + .register_column(:created_days_ago, field: :created_days_ago) + .register_column(:created_with_user, field: :created_with_user) + .register_column(:created_hours_ago, field: :created_hours_ago) + .register_column(:active, field: :active) + .register_column(:inactive, field: :inactive) + .register_column( + :last_used_date, + field: :last_used_date, + lazy_instance: :lazy_load_last_used_date, + ) + .register_column( + :last_used_hours_ago, + field: :last_used_hours_ago, + lazy_instance: :lazy_load_last_used_hours_ago, + ) + .register_column( + :last_used_days_ago, + field: :last_used_days_ago, + lazy_instance: :lazy_load_last_used_days_ago, + ) + .register_column( + :ever_used, + field: :ever_used, + lazy_instance: :lazy_load_ever_used, + ) + .register_column( + :never_used, + field: :never_used, + lazy_instance: :lazy_load_never_used_time, + ) + .register_column(:user_created_date, field: :user_created_date) .register_custom_matcher(:exists?) { |x| !x.entries.empty? } .install_filter_methods_on_resource(self, :table) @@ -51,13 +72,7 @@ def fetch_data(username) # Otherwise, get details of all users. # Returns a map (K,V) of (username: user_details) def get_users(username = nil) - catch_aws_errors do - if username - [fetch_user(username)] - else - collect_all_users - end - end + catch_aws_errors { username ? [fetch_user(username)] : collect_all_users } end def fetch_user(username) @@ -69,24 +84,21 @@ def fetch_user(username) end def collect_all_users - catch_aws_errors do - iam_client.list_users.flat_map(&:users) - end + catch_aws_errors { iam_client.list_users.flat_map(&:users) } end # Given a Hash of Users, build Access Key details for each. def get_keys - @_users.flat_map do |user| - fetch_keys(user.user_name) - end + @_users.flat_map { |user| fetch_keys(user.user_name) } end def fetch_keys(username) - access_keys = catch_aws_errors do - iam_client.list_access_keys(user_name: username) - rescue Aws::IAM::Errors::NoSuchEntity - # Swallow - a miss on search results should return an empty table - end + access_keys = + catch_aws_errors do + iam_client.list_access_keys({ user_name: username }) + rescue Aws::IAM::Errors::NoSuchEntity + # Swallow - a miss on search results should return an empty table + end access_keys&.flat_map do |response| response.access_key_metadata.flat_map do |access_key| access_key_hash = access_key.to_h @@ -94,19 +106,28 @@ def fetch_keys(username) access_key_hash[:id] = access_key_hash[:access_key_id] access_key_hash[:active] = access_key_hash[:status] == "Active" access_key_hash[:inactive] = access_key_hash[:status] != "Active" - access_key_hash[:created_hours_ago] = ((Time.now - access_key_hash[:create_date]) / (60*60)).to_i - access_key_hash[:created_days_ago] = (access_key_hash[:created_hours_ago] / 24).to_i - access_key_hash[:user_created_date] = access_key_hash[:create_date] - access_key_hash[:created_with_user] = (access_key_hash[:create_date] - access_key_hash[:user_created_date]).abs < 1.0/24.0 + access_key_hash[:created_hours_ago] = ( + (Time.now - access_key_hash[:create_date]) / (60 * 60) + ).to_i + access_key_hash[:created_days_ago] = ( + access_key_hash[:created_hours_ago] / 24 + ).to_i + access_key_hash[:user_created_date] = @_users + .find { |user| user.user_name == access_key_hash[:username] } + .create_date + access_key_hash[:created_with_user] = ( + access_key_hash[:create_date] - access_key_hash[:user_created_date] + ).abs < 1.0 / 24.0 access_key_hash end end end def last_used(row, _condition, _table) - @last_used ||= catch_aws_errors do - iam_client.get_access_key_last_used(access_key_id: row[:access_key_id]) - .access_key_last_used + catch_aws_errors do + iam_client.get_access_key_last_used( + { access_key_id: row[:access_key_id] }, + ).access_key_last_used end end @@ -114,26 +135,35 @@ def lazy_load_last_used_date(row, condition, table) row[:last_used_date] ||= last_used(row, condition, table).last_used_date end - def lazy_load_ever_used(row, condition, table) - row[:ever_used] = !lazy_load_never_used_time(row, condition, table) + def lazy_load_never_used_time(row, condition, table) + row[:never_used] ||= lazy_load_last_used_date(row, condition, table).nil? end - def lazy_load_never_used_time(row, condition, table) - row[:never_used] = lazy_load_last_used_date(row, condition, table).nil? + def lazy_load_ever_used(row, condition, table) + row[:ever_used] ||= !lazy_load_never_used_time(row, condition, table) end def lazy_load_last_used_hours_ago(row, condition, table) - return if lazy_load_never_used_time(row, condition, table) - - row[:last_used_hours_ago] = ((Time.now - row[:last_used_date]) / (60*60)).to_i + return row[:last_used_hours_ago] = nil if lazy_load_never_used_time( + row, + condition, + table, + ) + row[:last_used_hours_ago] = ( + (Time.now - row[:last_used_date]) / (60 * 60) + ).to_i end def lazy_load_last_used_days_ago(row, condition, table) - return if lazy_load_never_used_time(row, condition, table) + return row[:last_used_days_ago] = nil if lazy_load_never_used_time( + row, + condition, + table, + ) if row[:last_used_hours_ago].nil? lazy_load_last_used_hours_ago(row, condition, table) end - row[:last_used_days_ago] = (row[:last_used_hours_ago]/24).to_i + row[:last_used_days_ago] = (row[:last_used_hours_ago] / 24).to_i end def iam_client diff --git a/libraries/aws_iam_credential_report.rb b/libraries/aws_iam_credential_report.rb new file mode 100644 index 000000000..05a0fa3dd --- /dev/null +++ b/libraries/aws_iam_credential_report.rb @@ -0,0 +1,86 @@ +require "aws_backend" +require "csv" + +class AwsIamCredentialReport < AwsCollectionResourceBase + name "aws_iam_credential_report" + desc "Lists all users in the AWS account and the status of their credentials." + + example " + describe aws_iam_credential_report.where(mfa_active: false) do + it { should_not exist } + end + " + + attr_reader :table, :response, :test + + FilterTable.create + .register_column(:user, field: :user) + .register_column(:arn, field: :arn) + .register_column(:user_creation_time, field: :user_creation_time) + .register_column(:password_enabled, field: :password_enabled) + .register_column(:password_last_used, field: :password_last_used) + .register_column(:password_last_changed, field: :password_last_changed) + .register_column(:password_next_rotation, field: :password_next_rotation) + .register_column(:mfa_active, field: :mfa_active) + .register_column(:access_key_1_active, field: :access_key_1_active) + .register_column(:access_key_1_last_rotated, field: :access_key_1_last_rotated) + .register_column(:access_key_1_last_used_date, field: :access_key_1_last_used_date) + .register_column(:access_key_1_last_used_region, field: :access_key_1_last_used_region) + .register_column(:access_key_1_last_used_service, field: :access_key_1_last_used_service) + .register_column(:access_key_2_active, field: :access_key_2_active) + .register_column(:access_key_2_last_rotated, field: :access_key_2_last_rotated) + .register_column(:access_key_2_last_used_date, field: :access_key_2_last_used_date) + .register_column(:access_key_2_last_used_region, field: :access_key_2_last_used_region) + .register_column(:access_key_2_last_used_service, field: :access_key_2_last_used_service) + .register_column(:cert_1_active, field: :cert_1_active) + .register_column(:cert_1_last_rotated, field: :cert_1_last_rotated) + .register_column(:cert_2_active, field: :cert_2_active) + .register_column(:cert_2_last_rotated, field: :cert_2_last_rotated) + .install_filter_methods_on_resource(self, :table) + + def initialize(opts = {}) + super(opts) + validate_parameters + @table = fetch_data + end + + def to_s + "IAM Credential Report" + end + + private + + def fetch_data + catch_aws_errors do + @aws.iam_client.generate_credential_report + begin + attempts ||= 0 + @response = @aws.iam_client.get_credential_report + rescue Aws::IAM::Errors::ReportInProgress => e + if (attempts += 1) <= 5 + Inspec::Log.warn "AWS IAM Credential Report still being generated - attempt #{attempts}/5." + sleep 5 + retry + else + Inspec::Log.warn "AWS IAM Credential Report was not generated quickly enough." + raise e + end + end + + bool_converter = proc do |field| + case field.downcase + when "true" + true + when "false" + false + else + field + end + end + + no_info = proc { |field| field == "no_information" ? "N/A" : field } + report = CSV.parse(response.content, headers: true, header_converters: :symbol, converters: [:date_time, bool_converter, no_info]) + report.map(&:to_h) + end + end +end diff --git a/libraries/aws_iam_password_policy.rb b/libraries/aws_iam_password_policy.rb index 1ede00f6c..e36bb5a78 100644 --- a/libraries/aws_iam_password_policy.rb +++ b/libraries/aws_iam_password_policy.rb @@ -17,9 +17,14 @@ class AwsIamPasswordPolicy < AwsResourceBase def initialize(opts = {}) super(opts) validate_parameters - + @policy = nil catch_aws_errors do @policy = @aws.iam_client.get_account_password_policy.password_policy + @aws_account_id = fetch_aws_account + rescue Aws::IAM::Errors::NoSuchEntity + skip_resource( + "The account password policy either does not exist or is set to default settings; please review via the AWS Management Console.", + ) end end @@ -95,10 +100,19 @@ def exists? end def resource_id - @policy + if @aws_account_id + "AWS Password Policy for account: #{@aws_account_id}" + else + "AWS Account Password Policy" + end end - def to_s - "AWS IAM Password Policy" + alias to_s resource_id + + private + + def fetch_aws_account + arn = @aws.sts_client.get_caller_identity({}).arn + arn.split(":")[4] end end diff --git a/libraries/aws_iam_users.rb b/libraries/aws_iam_users.rb index 10f87d931..d6830c4be 100644 --- a/libraries/aws_iam_users.rb +++ b/libraries/aws_iam_users.rb @@ -21,7 +21,7 @@ class AwsIamUsers < AwsCollectionResourceBase .register_column(:attached_policy_names, field: :attached_policy_names, lazy_instance: :lazy_load_attached_policy_names) .register_column(:attached_policy_arns, field: :attached_policy_arns, lazy_instance: :lazy_load_attached_policy_arns) .register_column(:has_console_password, field: :has_console_password, lazy_instance: :lazy_load_has_console_password) - .register_column(:has_inline_policies, field: :has_inline_policies, lazy_instance: :lazy_load_has_inline_policies) + .register_column(:has_inline_policies, field: :has_inline_policies, lazy_instance: :lazy_load_has_inline_policies) .register_column(:inline_policy_names, field: :inline_policy_names, lazy_instance: :lazy_load_inline_policies) .register_column(:has_mfa_enabled, field: :has_mfa_enabled, lazy_instance: :lazy_load_has_mfa_enabled) .register_column(:password_ever_used?, field: :password_ever_used?) diff --git a/libraries/aws_kms_key.rb b/libraries/aws_kms_key.rb index bf4f03eb2..6ebc71225 100644 --- a/libraries/aws_kms_key.rb +++ b/libraries/aws_kms_key.rb @@ -9,6 +9,8 @@ class AwsKmsKey < AwsResourceBase end " + attr_reader :display_name, :arn, :alias + def initialize(opts = {}) # SDK permits key_id to hold either an ID or an ARN opts = { key_id: opts } if opts.is_a?(String) @@ -18,7 +20,7 @@ def initialize(opts = {}) @alias = opts[:alias] opts[:key_id] = fetch_key_id end - @display_name = opts[:key_id] + @display_name = key_metadata[:key_id] @arn = key_metadata[:arn] create_resource_methods(key_metadata) diff --git a/libraries/aws_macie.rb b/libraries/aws_macie.rb new file mode 100644 index 000000000..8bfbf742b --- /dev/null +++ b/libraries/aws_macie.rb @@ -0,0 +1,199 @@ +require "aws_backend" + +class AwsMacieJobTable + + FilterTable.create + .register_column(:bucket_criteria, field: :bucket_criteria) + .register_column(:bucket_definitions, field: :bucket_definitions) + .register_column(:created_at, field: :created_at) + .register_column(:job_id, field: :job_id) + .register_column(:job_status, field: :job_status) + .register_column(:job_type, field: :job_type) + .register_column(:last_run_error_status, field: :last_run_error_status) + .register_column(:name, field: :name) + .register_column(:user_paused_details, field: :user_paused_details) + .install_filter_methods_on_resource(self, :job_table) + + attr_reader :job_table, :job_name + + def monitoring?(buckets) + entries.each do |job| + job[:bucket_definitions].each do |bd| + buckets -= bd[:buckets] + end + end + buckets.empty? + end + + def initialize(job_table, job_name = nil) + @job_table = job_table + @job_name = job_name + end + + def to_s + @job_name.present? ? @job_name : "AWS Macie Jobs" + end +end + +class AwsMacieBucketTable + + FilterTable.create + .register_column(:account_id, field: :account_id) + .register_column(:allows_unencrypted_object_uploads, field: :allows_unencrypted_object_uploads) + .register_column(:bucket_arn, field: :bucket_arn) + .register_column(:bucket_created_at, field: :bucket_created_at) + .register_column(:bucket_name, field: :bucket_name) + .register_column(:classifiable_object_count, field: :classifiable_object_count) + .register_column(:classifiable_size_in_bytes, field: :classifiable_size_in_bytes) + .register_column(:error_code, field: :error_code) + .register_column(:error_message, field: :error_message) + .register_column(:job_details, field: :job_details) + .register_column(:last_automated_discovery_time, field: :last_automated_discovery_time) + .register_column(:last_updated, field: :last_updated) + .register_column(:object_count, field: :object_count) + .register_column(:object_count_by_encryption_type, field: :object_count_by_encryption_type) + .register_column(:public_access, field: :public_access) + .register_column(:region, field: :region) + .register_column(:replication_details, field: :replication_details) + .register_column(:sensitivity_score, field: :sensitivity_score) + .register_column(:server_side_encryption, field: :server_side_encryption) + .register_column(:shared_access, field: :shared_access) + .register_column(:size_in_bytes, field: :size_in_bytes) + .register_column(:size_in_bytes_compressed, field: :size_in_bytes_compressed) + .register_column(:tags, field: :tags) + .register_column(:unclassifiable_object_count, field: :unclassifiable_object_count) + .register_column(:unclassifiable_object_size_in_bytes, field: :unclassifiable_object_size_in_bytes) + .register_column(:versioning, field: :versioning) + .install_filter_methods_on_resource(self, :buckets_table) + + attr_reader :buckets_table, :buckets_name + + def initialize(buckets_table, buckets_name = nil) + @buckets_table = buckets_table + @buckets_name = buckets_name + end + + def to_s + @buckets_name.present? ? @buckets_name : "AWS Macie Buckets" + end +end + +class AwsMacieFindingTable + + FilterTable.create + .register_column(:account_id, field: :account_id) + .register_column(:archived, field: :archived) + .register_column(:category, field: :category) + .register_column(:classification_details, field: :classification_details) + .register_column(:count, field: :count) + .register_column(:created_at, field: :created_at) + .register_column(:description, field: :description) + .register_column(:id, field: :id) + .register_column(:partition, field: :partition) + .register_column(:policy_details, field: :policy_details) + .register_column(:region, field: :region) + .register_column(:resources_affected, field: :resources_affected) + .register_column(:sample, field: :sample) + .register_column(:schema_version, field: :schema_version) + .register_column(:severity, field: :severity) + .register_column(:title, field: :title) + .register_column(:type, field: :type) + .register_column(:updated_at, field: :updated_at) + .install_filter_methods_on_resource(self, :findings_table) + + attr_reader :findings_table, :findings_name + + def initialize(findings_table, findings_name = nil) + @findings_table = findings_table + @findings_name = findings_name + end + + def to_s + @findings_name.present? ? @findings_name : "AWS Macie Findings" + end +end + +class AWSMacie < AwsResourceBase + name "aws_macie" + desc "Gets information about Macie status and configuration." + + example " + describe aws_macie do + it { should be_enabled } + it { should be_monitoring_buckets(['arn1', 'arn2', 'arn3']) } + end + + describe aws_macie.findings do + its('count') { should eq 0 } + end + " + + def initialize(opts = {}) + @raw_data = {} + @res = {} + @describe_hub = [] + super(opts) + validate_parameters + fetch_data + end + + def session + return [] unless @session.present? + @session + end + + def jobs + @jobs_table + end + + def buckets + @buckets_table + end + + def findings + @findings_table + end + + def monitoring_buckets?(buckets) + return false unless @jobs + b = [buckets] unless buckets.is_a?(Array) + jobs.monitoring?(b) + end + + alias monitoring_bucket? monitoring_buckets? + + def enabled? + @session.status == "ENABLED" + end + + def to_s + "AWS Macie" + end + + private + + def fetch_data + catch_aws_errors do + begin + @session = @aws.macie_client.get_macie_session + @jobs = @aws.macie_client.list_classification_jobs + @jobs.present? ? @jobs_table = AwsMacieJobTable.new(@jobs.items.map(&:to_h)) : @jobs_table = [] + @buckets = @aws.macie_client.describe_buckets + @buckets.present? ? @buckets_table = AwsMacieBucketTable.new(@buckets.buckets.map(&:to_h)) : @buckets_table = [] + @findings = @aws.macie_client.list_findings.finding_ids + if @findings.present? + @findings_table = AwsMacieFindingTable.new( + @aws.macie_client.get_findings(finding_ids: @findings).findings.map(&:to_h), + ) + else + @findings_table = [] + end + rescue Aws::Errors::NoSuchEndpointError + skip_resource( + "The account contact endpoint is not available in this segment, please review this via the AWS Management Console.", + ) + end + return [] if (!@session or @session.empty?) || (!@jobs or @jobs.empty?) || (!@bucket or @bucket.empty?) + end + end +end diff --git a/libraries/aws_network_acl.rb b/libraries/aws_network_acl.rb index 1ed0e6155..6f09c15c5 100644 --- a/libraries/aws_network_acl.rb +++ b/libraries/aws_network_acl.rb @@ -1,11 +1,36 @@ require "aws_backend" +class AwsNetworkACLTable + + FilterTable.create + .register_column(:cidr_block, field: :cidr_block) + .register_column(:egress, field: :egress) + .register_column(:icmp_type_code, field: :icmp_type_code) + .register_column(:ipv_6_cidr_block, field: :ipv_6_cidr_block) + .register_column(:port_range, field: :port_range) + .register_column(:protocol, field: :protocol) + .register_column(:rule_action, field: :rule_action) + .register_column(:rule_number, field: :rule_number) + .install_filter_methods_on_resource(self, :acl_table) + + attr_reader :acl_table, :acl_name + + def initialize(acl_table, acl_name = nil) + @acl_table = acl_table + @acl_name = acl_name + end + + def to_s + @acl_name.present? ? "ACL #{acl_name}" : "ACL: " + end +end + class AwsNetworkACL < AwsResourceBase EGRESS = "egress".freeze INGRESS = "ingress".freeze name "aws_network_acl" desc "Verifies settings for a single AWS Network ACL" - example " + example <<~EXAMPLE1 describe aws_network_acl(network_acl_id: '014aef8a0689b8f43') do it { should exist } end @@ -13,7 +38,12 @@ class AwsNetworkACL < AwsResourceBase describe aws_network_acl('014aef8a0689b8f43') do it { should exist } end - " + EXAMPLE1 + example <<~EXAMPLE2 + describe aws_network_acl('014aef8a0689b8f43').acls.where(cidr_block: '0.0.0.0/0', rule_action: 'allow', protocol: '-1') do + it { should_not exist } + end + EXAMPLE2 def initialize(opts = {}) opts = { network_acl_id: opts } if opts.is_a?(String) @@ -90,6 +120,11 @@ def to_s "Network ACL ID: #{@opts[:network_acl_id]}" end + def acls + return [] unless network_acl + AwsNetworkACLTable.new(network_acl.entries.map(&:to_h), @opts[:network_acl_id]) + end + private def fetch diff --git a/libraries/aws_operations_contact.rb b/libraries/aws_operations_contact.rb new file mode 100644 index 000000000..9371abb45 --- /dev/null +++ b/libraries/aws_operations_contact.rb @@ -0,0 +1,90 @@ +require "aws_backend" + +class AwsOperationsAccount < AwsResourceBase + name "aws_operations_contact" + desc "Verifies the operations contact information for an AWS Account." + example <<~EXAMPLE + describe aws_operations_account do + it { should be_configured } + its('name') { should cmp 'John Smith' } + its('email_address') { should cmp 'jsmith@acme.com' } + end + EXAMPLE + + attr_reader :raw_data, + :api, + :api_response, + :email_address, + :name, + :phone_number, + :title, + :aws_account_id + + def initialize(opts = {}) + super(opts) + @raw_data = {} + @title, @name, @email_address, @phone_number = "" + validate_parameters + begin + catch_aws_errors do + @aws_account_id = fetch_aws_account + @api_response = fetch_aws_alternate_contact("operations") + rescue Aws::Account::Errors::ResourceNotFoundException + skip_resource( + "The Operations contact has not been configured for this AWS Account.", + ) + rescue Aws::Errors::NoSuchEndpointError, Seahorse::Client::NetworkingError + skip_resource( + "The account contact endpoint is not available in this segment, please review this via the AWS Management Console.", + ) + end + return [] if !@api_response || @api_response.empty? + end + + if @api_response + @api_response + .members + .map(&:to_s) + .each do |key| + instance_variable_set("@#{key}", @api_response.send(key)) + end + end + @raw_data = @api_response.to_h.transform_keys(&:to_s) + end + + def configured? + !@api_response.nil? || !@raw_data + end + + alias exist? configured? + + def resource_id + if @aws_account_id + "AWS Operations Contact for account: #{@aws_account_id}" + else + "AWS Operations Contact Information" + end + end + + def to_s + if @aws_account_id + "AWS Operations Contact for account: #{@aws_account_id}" + else + "AWS Account Primary Contact" + end + end + + private + + def fetch_aws_account + arn = @aws.sts_client.get_caller_identity({}).arn + arn.split(":")[4] + end + + def fetch_aws_alternate_contact(type) + @aws + .account_client + .get_alternate_contact({ alternate_contact_type: type.upcase.to_s }) + .alternate_contact + end +end diff --git a/libraries/aws_primary_contact.rb b/libraries/aws_primary_contact.rb new file mode 100644 index 000000000..66c8b45b2 --- /dev/null +++ b/libraries/aws_primary_contact.rb @@ -0,0 +1,97 @@ +require "aws_backend" + +class AwsPrimaryAccount < AwsResourceBase + name "aws_primary_contact" + desc "Verifies the primary contact information for an AWS Account." + example <<~EXAMPLE + describe aws_primary_contact do + it { should be_configured } + its('full_name') { should cmp 'John Smith' } + its('address_line_1') { should cmp '42 Wallaby Way' } + end + EXAMPLE + + attr_reader :raw_data, + :api_response, + :address_line_1, + :address_line_2, + :address_line_3, + :city, + :country_code, + :company_name, + :district_or_county, + :full_name, + :phone_number, + :postal_code, + :state_or_region, + :website_url, + :aws_account_id + + def initialize(opts = {}) + @raw_data = {} + @address_line_1, + @address_line_2, + @address_line_3, + @city, + @country_code, + @company_name, + @district_or_county, + @full_name, + @phone_number, + @postal_code, + @state_or_region, + @website_url = + "" + super(opts) + validate_parameters + begin + catch_aws_errors do + @aws_account_id = fetch_aws_account + @api_response = + @aws.account_client.get_contact_information.contact_information + rescue Aws::Account::Errors::ResourceNotFoundException + skip_resource( + "The Primary contact has not been configured for this AWS Account.", + ) + rescue Aws::Errors::NoSuchEndpointError, Seahorse::Client::NetworkingError + skip_resource( + "The account contact endpoint is not available in this segment, please review this via the AWS Management Console.", + ) + end + return [] if !@api_response || @api_response.empty? + end + + if @api_response + @api_response + .members + .map(&:to_s) + .each do |key| + instance_variable_set("@#{key}", @api_response.send(key)) + end + end + @raw_data = @api_response.to_h.transform_keys(&:to_s) + end + + def configured? + !@api_response.nil? || !@raw_data + end + + alias exist? configured? + + def resource_id + if @aws_account_id + "AWS Primary Contact for account: #{@aws_account_id}" + else + "AWS Account Primary Contact Information" + end + end + + alias to_s resource_id + + private + + def fetch_aws_account + arn = @aws.sts_client.get_caller_identity({}).arn + arn.split(":")[4] + end +end diff --git a/libraries/aws_rds_instance.rb b/libraries/aws_rds_instance.rb index b233dec82..30c8b8871 100644 --- a/libraries/aws_rds_instance.rb +++ b/libraries/aws_rds_instance.rb @@ -26,6 +26,10 @@ def resource_id "#{@rds_instance? @rds_instance[:db_instance_identifier]: ""}_#{@rds_instance? @rds_instance[:db_name]: ""}_#{@rds_instance? @rds_instance[:master_username]: ""}" end + def public? + @rds_instance[:publicly_accessible] + end + def has_encrypted_storage? @rds_instance[:storage_encrypted] end diff --git a/libraries/aws_rds_instances.rb b/libraries/aws_rds_instances.rb index 0b8fa576a..bcf77567d 100644 --- a/libraries/aws_rds_instances.rb +++ b/libraries/aws_rds_instances.rb @@ -21,6 +21,8 @@ class AwsRdsInstances < AwsCollectionResourceBase end " + attr_reader :table + def initialize(opts = {}) super(opts) validate_parameters @@ -28,4 +30,8 @@ def initialize(opts = {}) populate_filter_table_from_response end + + def exist? + !@table.empty? + end end diff --git a/libraries/aws_region.rb b/libraries/aws_region.rb index e3091a727..6a3f760f0 100644 --- a/libraries/aws_region.rb +++ b/libraries/aws_region.rb @@ -7,9 +7,10 @@ class AwsRegion < AwsResourceBase example " describe aws_region('eu-west-2') do it { should exist } + it { should have_ebs_encryption_enabled } end " - attr_reader :region_name, :endpoint + attr_reader :region_name, :endpoint, :resp, :opt_in_status, :ebs_encryption_enabled def initialize(opts = {}) opts = { region_name: opts } if opts.is_a?(String) @@ -19,9 +20,11 @@ def initialize(opts = {}) @region_name = opts[:region_name] catch_aws_errors do - resp = @aws.compute_client.describe_regions(region_names: [@region_name]) - return if resp.regions.empty? - @endpoint = resp.regions[0].endpoint + @resp = @aws.compute_client.describe_regions({ region_names: [@region_name] }) + return if @resp.regions.empty? + @ebs_encryption_enabled = fetch_ebs_status_by_region(@region_name) + @opt_in_status = @resp.regions.first.opt_in_status + @endpoint = @resp.regions.first.endpoint end end @@ -33,7 +36,22 @@ def exists? !@endpoint.nil? end + def has_ebs_encryption_enabled? + @ebs_encryption_enabled + end + def to_s "Region #{@region_name}" end + + private + + def fetch_ebs_status_by_region(region) + catch_aws_errors do + new_client = @aws.compute_client + new_client.config.region = region + new_client.get_ebs_encryption_by_default[:ebs_encryption_by_default] + end + end + end diff --git a/libraries/aws_regions.rb b/libraries/aws_regions.rb index 4f527177c..00413e899 100644 --- a/libraries/aws_regions.rb +++ b/libraries/aws_regions.rb @@ -10,11 +10,13 @@ class AwsRegions < AwsResourceBase end " - attr_reader :table + attr_reader :table, :regions - FilterTable.create + FilterTable + .create .register_column(:region_names, field: :region_name) - .register_column(:endpoints, field: :endpoint) + .register_column(:endpoints, field: :endpoint) + .register_column(:opt_in_status, field: :opt_in_status) .install_filter_methods_on_resource(self, :table) def initialize(opts = {}) @@ -30,8 +32,13 @@ def fetch_data end return [] if !@regions || @regions.empty? @regions.each do |region| - region_rows += [{ region_name: region[:region_name], - endpoint: region[:endpoint] }] + region_rows += [ + { + region_name: region[:region_name], + endpoint: region[:endpoint], + opt_in_status: region[:opt_in_status], + }, + ] end @table = region_rows end diff --git a/libraries/aws_s3_bucket.rb b/libraries/aws_s3_bucket.rb index 53b0ae6a0..07e98513f 100644 --- a/libraries/aws_s3_bucket.rb +++ b/libraries/aws_s3_bucket.rb @@ -1,4 +1,5 @@ require "aws_backend" +require "hashie/mash" class AwsS3Bucket < AwsResourceBase name "aws_s3_bucket" @@ -58,7 +59,7 @@ def public? begin @bucket_policy_status_public = @aws.storage_client.get_bucket_policy_status(bucket: @bucket_name).policy_status.is_public rescue Aws::S3::Errors::NoSuchBucketPolicy - @bucket_policy_status_public = false # preserves the original behaviour + @bucket_policy_status_public = false # preserves the original behavior end @bucket_policy_status_public || \ bucket_acl.any? { |g| g.grantee.type == "Group" && g.grantee.uri =~ /AllUsers/ } || \ @@ -75,12 +76,33 @@ def has_access_logging_enabled? def prevent_public_access? return false unless exists? - @prevent_public_access ||= catch_aws_errors do - public_access_config = @aws.storage_client.get_public_access_block(bucket: @bucket_name).public_access_block_configuration - public_access_config.block_public_acls == true && public_access_config.ignore_public_acls == true && public_access_config.block_public_policy == true && public_access_config.restrict_public_buckets == true - end + @prevent_public_access = + begin + public_access_config = @aws.storage_client.get_public_access_block(bucket: @bucket_name).public_access_block_configuration + rescue Aws::S3::Errors::NoSuchPublicAccessBlockConfiguration + @prevent_public_access = false + end + return false unless @prevent_public_access + public_access_config.block_public_acls == true && public_access_config.ignore_public_acls == true && public_access_config.block_public_policy == true && public_access_config.restrict_public_buckets == true + end + + alias preventing_public_access_via_bucket? prevent_public_access? + + def prevent_public_access_by_account? + return false unless exists? + @account_id = fetch_aws_account + @prevent_public_access_by_account = + begin + public_access_account_config = @aws.storage_control_client.get_public_access_block(account_id: @account_id).public_access_block_configuration + rescue Aws::S3::Errors::NoSuchPublicAccessBlockConfiguration + @prevent_public_access_by_account = false + end + return false unless @prevent_public_access_by_account + public_access_account_config.block_public_acls == true && public_access_account_config.ignore_public_acls == true && public_access_account_config.block_public_policy == true && public_access_account_config.restrict_public_buckets == true end + alias preventing_public_access_via_account? prevent_public_access_by_account? + def has_default_encryption_enabled? return false unless exists? @has_default_encryption_enabled ||= catch_aws_errors do @@ -101,6 +123,13 @@ def has_versioning_enabled? end end + def versioning + return [] unless exists? # exists? would throw the same NoSuchBucket error if the bucket name was not valid + catch_aws_errors do + @versioning ||= Hashie::Mash.new(@aws.storage_client.get_bucket_versioning(bucket: @bucket_name)) + end + end + def has_secure_transport_enabled? bucket_policy.any? { |s| s.effect == "Deny" && s.condition && s.condition["Bool"] && s.condition["Bool"]["aws:SecureTransport"] && s.condition["Bool"]["aws:SecureTransport"] == "false" } end @@ -157,4 +186,11 @@ def resource_id def to_s "S3 Bucket #{@bucket_name}" end + + private + + def fetch_aws_account + arn = @aws.sts_client.get_caller_identity({}).arn + arn.split(":")[4] + end end diff --git a/libraries/aws_s3_buckets.rb b/libraries/aws_s3_buckets.rb index fcd1e8f3f..371810fa7 100644 --- a/libraries/aws_s3_buckets.rb +++ b/libraries/aws_s3_buckets.rb @@ -4,8 +4,8 @@ class AwsS3Buckets < AwsResourceBase name "aws_s3_buckets" desc "Verifies settings for AWS S3 Buckets in bulk." example " - describe aws_s3_bucket do - its('bucket_names') { should eq ['my_bucket'] } + describe aws_s3_buckets do + its('bucket_names') { should include 'my_bucket' } end " diff --git a/libraries/aws_security_contact.rb b/libraries/aws_security_contact.rb new file mode 100644 index 000000000..414eefb8f --- /dev/null +++ b/libraries/aws_security_contact.rb @@ -0,0 +1,90 @@ +require "aws_backend" + +class AwsSecurityAccount < AwsResourceBase + name "aws_security_contact" + desc "Verifies the security contact information for an AWS Account." + example <<~EXAMPLE + describe aws_security_account do + it { should be_configured } + its('name') { should cmp 'John Smith' } + its('email_address') { should cmp 'jsmith@acme.com' } + end + EXAMPLE + + attr_reader :raw_data, + :api, + :api_response, + :email_address, + :name, + :phone_number, + :title, + :aws_account_id + + def initialize(opts = {}) + super(opts) + @raw_data = {} + @title, @name, @email_address, @phone_number = "" + validate_parameters + begin + catch_aws_errors do + @aws_account_id = fetch_aws_account + @api_response = fetch_aws_alternate_contact("security") + rescue Aws::Account::Errors::ResourceNotFoundException + skip_resource( + "The Security contact has not been configured for this AWS Account.", + ) + rescue Aws::Errors::NoSuchEndpointError, Seahorse::Client::NetworkingError + skip_resource( + "The account contact endpoint is not available in this segment, please review this via the AWS Management Console.", + ) + end + return [] if !@api_response || @api_response.empty? + end + + if @api_response + @api_response + .members + .map(&:to_s) + .each do |key| + instance_variable_set("@#{key}", @api_response.send(key)) + end + end + @raw_data = @api_response.to_h.transform_keys(&:to_s) + end + + def configured? + !@api_response.nil? || !@raw_data + end + + alias exist? configured? + + def resource_id + if @aws_account_id + "AWS Security Contact for account: #{@aws_account_id}" + else + "AWS Security Contact Information" + end + end + + def to_s + if @aws_account_id + "AWS Security Contact for account: #{@aws_account_id}" + else + "AWS Account Primary Contact" + end + end + + private + + def fetch_aws_account + arn = @aws.sts_client.get_caller_identity({}).arn + arn.split(":")[4] + end + + def fetch_aws_alternate_contact(type) + @aws + .account_client + .get_alternate_contact({ alternate_contact_type: type.upcase.to_s }) + .alternate_contact + end +end diff --git a/libraries/aws_securityhub.rb b/libraries/aws_securityhub.rb new file mode 100644 index 000000000..1c17c06b0 --- /dev/null +++ b/libraries/aws_securityhub.rb @@ -0,0 +1,42 @@ +require "aws_backend" + +class AWSSecurityHub < AwsResourceBase + name "aws_securityhub" + desc "Gets information about the Security Hub." + + example " + describe aws_securityhub do + it { should be_subscribed } + end + " + + attr_reader :describe_hub, :res, :hub_arn, :subscribed_at, :auto_enable_controls, :control_finding_generator + + def initialize(opts = {}) + @raw_data = {} + @res = {} + @describe_hub = [] + super(opts) + validate_parameters + catch_aws_errors do + @describe_hub = @aws.securityhub_client.describe_hub + @res = @describe_hub.to_h.presence || {} + create_resource_methods(@res) unless @res.nil? + end + end + + def subscribed? + @res[:subscribed_at].present? || !@res.empty? + end + + alias exists? subscribed? + alias exist? subscribed? + + def resource_id + @res[:hub_arn].presence || "" + end + + def to_s + "Security Hub: #{resource_id}" + end +end diff --git a/notes b/notes new file mode 100644 index 000000000..15123a59d --- /dev/null +++ b/notes @@ -0,0 +1 @@ +https://aws.amazon.com/compliance/fips/#FIPS_Endpoints_by_Service