From 46b65ba490e1c6d46cb9845e8eeea28c896347bc Mon Sep 17 00:00:00 2001 From: Christoph Hartmann Date: Thu, 15 Dec 2016 09:53:01 +0100 Subject: [PATCH 001/206] initial commit --- .rubocop.yml | 72 ++++++++++++++++++++ .travis.yml | 9 +++ CONTRIBUTING.md | 155 ++++++++++++++++++++++++++++++++++++++++++++ Gemfile | 12 ++++ LICENSE | 13 ++++ README.md | 109 +++++++++++++++++++++++++++++++ Rakefile | 26 ++++++++ controls/example.rb | 14 ++++ inspec.yml | 7 ++ libraries/ec2.rb | 94 +++++++++++++++++++++++++++ 10 files changed, 511 insertions(+) create mode 100644 .rubocop.yml create mode 100644 .travis.yml create mode 100644 CONTRIBUTING.md create mode 100644 Gemfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 Rakefile create mode 100644 controls/example.rb create mode 100644 inspec.yml create mode 100644 libraries/ec2.rb diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 000000000..a13aeb3d7 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,72 @@ +--- +AllCops: + Exclude: + - Gemfile + - Rakefile + - 'test/**/*' + - 'examples/**/*' + - 'vendor/**/*' + - 'lib/bundles/inspec-init/templates/**/*' +Documentation: + Enabled: false +AlignParameters: + Enabled: true +Encoding: + Enabled: true +HashSyntax: + Enabled: true +LineLength: + Enabled: false +EmptyLinesAroundBlockBody: + Enabled: false +MethodLength: + Max: 40 +NumericLiterals: + MinDigits: 10 +Metrics/CyclomaticComplexity: + Max: 10 +Metrics/PerceivedComplexity: + Max: 11 +Metrics/AbcSize: + Max: 33 +Style/PercentLiteralDelimiters: + PreferredDelimiters: + '%': '{}' + '%i': () + '%q': '{}' + '%Q': () + '%r': '{}' + '%s': () + '%w': '{}' + '%W': () + '%x': () +Style/AlignHash: + Enabled: false +Style/PredicateName: + Enabled: false +Style/ClassAndModuleChildren: + Enabled: false +Style/ConditionalAssignment: + Enabled: false +Style/BracesAroundHashParameters: + Enabled: false +Style/AndOr: + Enabled: false +Style/Not: + Enabled: false +Style/FileName: + Enabled: false +Style/TrailingCommaInLiteral: + EnforcedStyleForMultiline: comma +Style/TrailingCommaInArguments: + EnforcedStyleForMultiline: comma +Style/NegatedIf: + Enabled: false +Style/UnlessElse: + Enabled: false +BlockDelimiters: + Enabled: false +Style/SpaceAroundOperators: + Enabled: false +Style/IfUnlessModifier: + Enabled: false diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 000000000..cb954c827 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,9 @@ +sudo: false +language: ruby +cache: bundler + +rvm: + - 2.3.1 + +bundler_args: --without integration +script: bundle exec rake diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..b92422962 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,155 @@ +# Contributing to InSpec + +We are glad you want to contribute to InSpec! This document will help answer common questions you may have during your first contribution. + +## Submitting Issues + +We utilize **Github Issues** for issue tracking and contributions. You can contribute in two ways: + +1. Reporting an issue or making a feature request [here](#issues). +2. Adding features or fixing bugs yourself and contributing your code to InSpec. + +We ask you not to submit security concerns via Github. For details on submitting potential security issues please see + +## Contribution Process + +We have a 3 step process for contributions: + +1. Commit changes to a git branch, making sure to sign-off those changes for the [Developer Certificate of Origin](#developer-certification-of-origin-dco). +2. Create a Github Pull Request for your change, following the instructions in the pull request template. +3. Perform a [Code Review](#code-review-process) with the project maintainers on the pull request. + +### Pull Request Requirements + +Chef Projects are built to last. We strive to ensure high quality throughout the experience. In order to ensure this, we require that all pull requests to Chef projects meet these specifications: + +1. **Tests:** To ensure high quality code and protect against future regressions, we require all the code in Chef Projects to have at least unit test coverage. See the [test/unit](https://github.com/chef/inspec/tree/master/test/unit) +directory for the existing tests and use ```bundle exec rake test``` to run them. +2. **Green CI Tests:** We use [Travis CI](https://travis-ci.org/) and/or [AppVeyor](https://www.appveyor.com/) CI systems to test all pull requests. We require these test runs to succeed on every pull request before being merged. +3. **Up-to-date Documentation:** Every code change should be reflected in an update for our [documentation](https://github.com/chef/inspec/tree/master/docs). We expect PRs to update the documentation with the code change. + +In addition to this it would be nice to include the description of the problem you are solving + with your change. You can use [Issue Template](#issuetemplate) in the description section + of the pull request. + +### Code Review Process + +Code review takes place in Github pull requests. See [this article](https://help.github.com/articles/about-pull-requests/) if you're not familiar with Github Pull Requests. + +Once you open a pull request, project maintainers will review your code and respond to your pull request with any feedback they might have. The process at this point is as follows: + +1. Two thumbs-up (:+1:) are required from project maintainers. See the master maintainers document for InSpec projects at . +2. When ready, your pull request will be merged into `master`, we may require you to rebase your PR to the latest `master`. +3. Once the PR is merged, you will be included in `CHANGELOG.md`. + +If you would like to learn about when your code will be available in a release of Chef, read more about [Chef Release Cycles](#release-cycles). + + +### Developer Certification of Origin (DCO) + +Licensing is very important to open source projects. It helps ensure the software continues to be available under the terms that the author desired. + +Chef uses [the Apache 2.0 license](https://github.com/chef/chef/blob/master/LICENSE) to strike a balance between open contribution and allowing you to use the software however you would like to. + +The license tells you what rights you have that are provided by the copyright holder. It is important that the contributor fully understands what rights they are licensing and agrees to them. Sometimes the copyright holder isn't the contributor, such as when the contributor is doing work on behalf of a company. + +To make a good faith effort to ensure these criteria are met, Chef requires the Developer Certificate of Origin (DCO) process to be followed. + +The DCO is an attestation attached to every contribution made by every developer. In the commit message of the contribution, the developer simply adds a Signed-off-by statement and thereby agrees to the DCO, which you can find below or at . + +``` +Developer's Certificate of Origin 1.1 + +By making a contribution to this project, I certify that: + +(a) The contribution was created in whole or in part by me and I + have the right to submit it under the open source license + indicated in the file; or + +(b) The contribution is based upon previous work that, to the + best of my knowledge, is covered under an appropriate open + source license and I have the right under that license to + submit that work with modifications, whether created in whole + or in part by me, under the same open source license (unless + I am permitted to submit under a different license), as + Indicated in the file; or + +(c) The contribution was provided directly to me by some other + person who certified (a), (b) or (c) and I have not modified + it. + +(d) I understand and agree that this project and the contribution + are public and that a record of the contribution (including + all personal information I submit with it, including my + sign-off) is maintained indefinitely and may be redistributed + consistent with this project or the open source license(s) + involved. +``` + +For more information on the change see the Chef Blog post [Introducing Developer Certificate of Origin](https://blog.chef.io/2016/09/19/introducing-developer-certificate-of-origin/) + +#### DCO Sign-Off Methods + +The DCO requires a sign-off message in the following format appear on each commit in the pull request: + +``` +Signed-off-by: Julia Child +``` + +The DCO text can either be manually added to your commit body, or you can add either **-s** or **--signoff** to your usual git commit commands. If you forget to add the sign-off you can also amend a previous commit with the sign-off by running **git commit --amend -s**. If you've pushed your changes to Github already you'll need to force push your branch after this with **git push -f**. + +### Obvious Fix Policy + +Small contributions, such as fixing spelling errors, where the content is small enough to not be considered intellectual property, can be submitted without signing the contribution for the DCO. + +As a rule of thumb, changes are obvious fixes if they do not introduce any new functionality or creative thinking. Assuming the change does not affect functionality, some common obvious fix examples include the following: + +- Spelling / grammar fixes +- Typo correction, white space and formatting changes +- Comment clean up +- Bug fixes that change default return values or error codes stored in constants +- Adding logging messages or debugging output +- Changes to 'metadata' files like Gemfile, .gitignore, build scripts, etc. +- Moving source files from one directory or package to another + +**Whenever you invoke the "obvious fix" rule, please say so in your commit message:** + +``` +------------------------------------------------------------------------ +commit 370adb3f82d55d912b0cf9c1d1e99b132a8ed3b5 +Author: Julia Child +Date: Wed Sep 18 11:44:40 2015 -0700 + + Fix typo in the README. + + Obvious fix. + +------------------------------------------------------------------------ +``` + +## Release Cycles + +Our primary shipping vehicle is operating system specific packages that includes all the requirements of InSpec. We call these [Omnibus packages](https://github.com/chef/omnibus) + +We also release our software as gems to [Rubygems](https://rubygems.org/) but we strongly recommend using InSpec or ChefDK packages. + +Our version numbering roughly follows [Semantic Versioning](http://semver.org/) standard. Our standard version numbers look like X.Y.Z which mean: + +- X is a major release, which may not be fully compatible with prior major releases +- Y is a minor release, which adds both new features and bug fixes +- Z is a patch release, which adds just bug fixes + +After shipping a release of InSpec we bump at least the `Minor` version by one to start development of the next minor release. We do a release approximately every week. Announcements of releases are made to the [InSpec mailing list](https://discourse.chef.io/c/chef-release) when they are available. + +## InSpec Community + +InSpec is made possible by a strong community of developers, system administrators, auditor and security experts. If you have any questions or if you would like to get involved in the InSpec community you can check out: + +- [InSpec Mailing List](https://discourse.chef.io/c/inspec) +- [Chef Community Slack](https://community-slack.chef.io/) + +Also here are some additional pointers to some awesome Chef content: + +- [InSpec Docs](http://inspec.io/docs/) +- [Learn Chef](https://learn.chef.io/) +- [Chef Website](https://www.chef.io/) diff --git a/Gemfile b/Gemfile new file mode 100644 index 000000000..18ccd4c52 --- /dev/null +++ b/Gemfile @@ -0,0 +1,12 @@ +source 'https://rubygems.org' + +gem 'rake' +gem 'inspec', '~> 1' +gem 'rubocop', '~> 0.44.0' +gem 'highline', '~> 1.6.0' +gem 'aws-sdk' +gem 'nokogiri' + +group :tools do + gem 'github_changelog_generator', '~> 1.12.0' +end diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..161d799fa --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright (c) 2016 Chef Software Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 000000000..ba03ec1fb --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# InSpec AWS Resource Pack + +NOTE: This is early access and not suitable for production. We use this repository to prototype and verify our design goals. + +This resource pack provides resources for AWS. It will ship with the required resources to write your own AWS tests. + +``` +├── README.md - this readme +├── controls - contains no controls +└── libraries - contains AWS resources +``` + +## Design Goals + +Goals for this project are: + +- Find the right abstraction layer for AWS +- InSpec AWS resources should be aware that they target AWS +- No mixture of InSpec OS and InSpec AWS resource in one profile possible +- AWS should become a native target for InSpec `inspec exec inspec-aws -t aws://accesskey:secret@region` + +This project will be merged into [InSpec](https://github.com/chef/inspec), once we reached all the goals. + +## Get started + +To run the profile, use InSpec with an environment variable for AWS credentials: + +- `AWS_ACCESS_KEY_ID` +- `AWS_SECRET_ACCESS_KEY` + +Those variables are defined in [AWS CLI Docs](http://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html#cli-environment) + +Now you can use `inspec exec inspec-aws`. Please note, that you have to define the AWS target in future: `inspec exec inspec-aws -t aws://accesskey:secret@region`. + +## Use the resources + +Since this is a InSpec resource pack, it only defines InSpec resources. It includes example tests only. You can easily use the AWS InSpec resources in your tests do the following: + +### Create a new profile + +``` +inspec init profile my-profile +``` + +### Adapt the `inspec.yml` + +``` +name: my-profile +title: My own AWS profile +version: 0.1.0 +depends: + - name: aws + url: https://github.com/chef/inspec-aws/archive/master.tar.gz +``` + +### Add controls + +Since your profile depends on the resource pack, you can use those resources in your own profile: + +``` +control "aws-1" do + impact 0.7 + title 'Checks the machine is running' + + describe ec2('my-ec2-machine') do + it { should be_running } + end +end +``` + +### Available Resources + + * `aws_ec2` - This resource reads information about an ec2 instance + +### Roadmap + + * `aws_ami` + * `aws_s3bucket` + * `aws_security_group` + * `aws_iam_group` + * `aws_iam_policy` + * `aws_iam_role` + * `aws_iam_user` + ... + + + ## Kudos + + This project was inspired by [inspec-aws](https://github.com/arothian/inspec-aws) from [arothian](https://github.com/arothian). + + ## License + +| | | +| ------ | --- | +| **Author:** | Christoph Hartmann () | +| **Copyright:** | Copyright (c) 2016 Chef Software Inc. | +| **License:** | Apache License, Version 2.0 | + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/Rakefile b/Rakefile new file mode 100644 index 000000000..40d79a83b --- /dev/null +++ b/Rakefile @@ -0,0 +1,26 @@ +#!/usr/bin/env rake +# encoding: utf-8 + +require 'rake/testtask' +require 'rubocop/rake_task' + +# Rubocop +desc 'Run Rubocop lint checks' +task :rubocop do + RuboCop::RakeTask.new +end + +# lint the project +desc 'Run robocop linter' +task lint: [:rubocop] + +# run tests +task default: [:lint, 'test:check'] + +namespace :test do + # run inspec check to verify that the profile is properly configured + task :check do + dir = File.join(File.dirname(__FILE__)) + sh("bundle exec inspec check #{dir}") + end +end diff --git a/controls/example.rb b/controls/example.rb new file mode 100644 index 000000000..0a7c78af2 --- /dev/null +++ b/controls/example.rb @@ -0,0 +1,14 @@ +describe aws_ec2(name: 'aws-inspec') do + it { should be_running } + its('state') { should eq 'running' } + its('instance_id') { should eq 'i-1234a1ab' } + its('image_id') { should eq 'ami-c123aaa1' } + its('public_ip_address') { should eq '123.123.123.123' } + its('private_ip_address') { should eq '123.123.123.123' } + its('vpc_id') { should eq 'vpc-1234567' } + its('subnet_id') { should eq 'subnet-1234567' } +end + +describe aws_ec2(name: 'aws-opsworks-cm-serdar2') do + it { should_not be_running } +end diff --git a/inspec.yml b/inspec.yml new file mode 100644 index 000000000..afc622c0e --- /dev/null +++ b/inspec.yml @@ -0,0 +1,7 @@ +name: inspec-aws +title: InSpec AWS Resource Pack +maintainer: Chef Software Inc. +copyright: chris@lollyrock.com +copyright_email: chris@lollyrock.com +license: Apache 2 license +version: 1.0.0 diff --git a/libraries/ec2.rb b/libraries/ec2.rb new file mode 100644 index 000000000..f0482b9a9 --- /dev/null +++ b/libraries/ec2.rb @@ -0,0 +1,94 @@ +# author: Christoph Hartmann + +require 'aws_conn' + +class Ec2 < Inspec.resource(1) + name 'aws_ec2' + desc 'Returns information about an EC2 instance' + + example " + describe aws_ec2('i-123456') do + it { should be_running } + end + + describe aws_ec2(name: 'my-instance') do + it { should be_running } + end + " + + def initialize(opts) + @opts = opts + @opts.is_a?(Hash) ? @display_name = @opts[:name] : @display_name = opts + conn = AWSConnection.new + @ec2_client = conn.ec2_client + @ec2_resource = conn.ec2_resource + end + + def id + return @instance_id if defined?(@instance_id) + if @opts.is_a?(Hash) + first = @ec2_resource.instances( + { + filters: [{ + name: 'tag:Name', + values: [@opts[:name]], + }], + }, + ).first + # catch case where the instance is not known + @instance_id = first.id unless first.nil? + else + @instance_id = @opts + end + end + alias instance_id id + + def exists? + !id.nil? + end + + # returns the instance state + def state + instance.state.name if instance + end + + # helper methods for each state + %w{ + pending running shutting-down + terminated stopping stopped unknown + }.each do |state_name| + define_method state_name.tr('-', '_') + '?' do + state == state_name + end + end + + # attributes that we want to expose + %w{ + public_ip_address private_ip_address key_name private_dns_name + public_dns_name subnet_id architecture root_device_type + root_device_name virtualization_type client_token launch_time + instance_type image_id vpc_id + }.each do |attribute| + define_method attribute do + instance.send(attribute) + end + end + + def security_groups + @security_groups ||= instance.security_groups.map { |sg| { id: sg.group_id, name: sg.group_name } } + end + + def tags + @tags ||= instance.tags.map { |tag| { key: tag.key, value: tag.value } } + end + + def to_s + "EC2 Instance #{@display_name}" + end + + private + + def instance + @instance ||= @ec2_resource.instance(id) + end +end From 89209a91f84174b7e062ac6c8fc0ad00485e3fbf Mon Sep 17 00:00:00 2001 From: Christoph Hartmann Date: Thu, 15 Dec 2016 11:55:21 +0100 Subject: [PATCH 002/206] add aws connection helper --- controls/example.rb | 4 ---- libraries/aws_conn.rb | 23 +++++++++++++++++++++++ libraries/ec2.rb | 2 +- 3 files changed, 24 insertions(+), 5 deletions(-) create mode 100644 libraries/aws_conn.rb diff --git a/controls/example.rb b/controls/example.rb index 0a7c78af2..0a9e2b88e 100644 --- a/controls/example.rb +++ b/controls/example.rb @@ -8,7 +8,3 @@ describe aws_ec2(name: 'aws-inspec') do its('vpc_id') { should eq 'vpc-1234567' } its('subnet_id') { should eq 'subnet-1234567' } end - -describe aws_ec2(name: 'aws-opsworks-cm-serdar2') do - it { should_not be_running } -end diff --git a/libraries/aws_conn.rb b/libraries/aws_conn.rb new file mode 100644 index 000000000..ad17cb19b --- /dev/null +++ b/libraries/aws_conn.rb @@ -0,0 +1,23 @@ +# author: Christoph Hartmann + +class AWSConnection + def initialize + require 'aws-sdk' + opts = { + region: ENV['AWS_DEFAULT_REGION'], + credentials: Aws::Credentials.new( + ENV['AWS_ACCESS_KEY_ID'], + ENV['AWS_SECRET_ACCESS_KEY'], + ), + } + Aws.config.update(opts) + end + + def ec2_resource + @ec2_resource ||= Aws::EC2::Resource.new + end + + def ec2_client + @ec2_client ||= Aws::EC2::Client.new + end +end diff --git a/libraries/ec2.rb b/libraries/ec2.rb index f0482b9a9..e5be8b42a 100644 --- a/libraries/ec2.rb +++ b/libraries/ec2.rb @@ -4,7 +4,7 @@ require 'aws_conn' class Ec2 < Inspec.resource(1) name 'aws_ec2' - desc 'Returns information about an EC2 instance' + desc 'Verifies settings for an EC2 instance' example " describe aws_ec2('i-123456') do From 51abba245fb66409b793cc8d8c6255ad49bfaccb Mon Sep 17 00:00:00 2001 From: Christoph Hartmann Date: Mon, 16 Jan 2017 14:36:52 +0100 Subject: [PATCH 003/206] deactivate inspec check for now --- Rakefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Rakefile b/Rakefile index 40d79a83b..d36039071 100644 --- a/Rakefile +++ b/Rakefile @@ -15,7 +15,7 @@ desc 'Run robocop linter' task lint: [:rubocop] # run tests -task default: [:lint, 'test:check'] +task default: [:lint] namespace :test do # run inspec check to verify that the profile is properly configured From 66c7f2cb1fbe52b3b998a3de437300ee5795d4b4 Mon Sep 17 00:00:00 2001 From: Christopher Redekop Date: Thu, 12 Jan 2017 13:22:17 -0500 Subject: [PATCH 004/206] Fixes #3 - Add AWS_DEFAULT_REGION to the README Signed-off-by: Christopher Redekop --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index ba03ec1fb..abd0e7de7 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ This project will be merged into [InSpec](https://github.com/chef/inspec), once To run the profile, use InSpec with an environment variable for AWS credentials: +- `AWS_DEFAULT_REGION` - `AWS_ACCESS_KEY_ID` - `AWS_SECRET_ACCESS_KEY` From 12c832604ea278c1aea926daa0cb73af2770d8de Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Fri, 20 Jan 2017 16:51:53 +0000 Subject: [PATCH 005/206] Initial checkin of Inspec Azure resource pack Signed-off-by: Russell Seymour --- .gitignore | 5 ++ .rubocop.yml | 72 ++++++++++++++++ Gemfile | 16 ++++ README.md | 127 ++++++++++++++++++++++++++++ Rakefile | 38 +++++++++ controls/example_vm_image.rb | 12 +++ inspec.yml | 5 ++ libraries/azure_conn.rb | 45 ++++++++++ libraries/azure_vm_image.rb | 77 +++++++++++++++++ libraries/common/resource_groups.rb | 13 +++ 10 files changed, 410 insertions(+) create mode 100644 .gitignore create mode 100644 .rubocop.yml create mode 100644 Gemfile create mode 100644 README.md create mode 100644 Rakefile create mode 100644 controls/example_vm_image.rb create mode 100644 inspec.yml create mode 100644 libraries/azure_conn.rb create mode 100644 libraries/azure_vm_image.rb create mode 100644 libraries/common/resource_groups.rb diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..9f09f58d1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.envrc +.ruby-version +.direnv +Gemfile.lock +inspec.lock \ No newline at end of file diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 000000000..a5a5659e6 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,72 @@ +--- +AllCops: + Exclude: + - Gemfile + - Rakefile + - 'test/**/*' + - 'examples/**/*' + - 'vendor/**/*' + - 'lib/bundles/inspec-init/templates/**/*' +Documentation: + Enabled: false +AlignParameters: + Enabled: true +Encoding: + Enabled: true +HashSyntax: + Enabled: true +LineLength: + Enabled: false +EmptyLinesAroundBlockBody: + Enabled: false +MethodLength: + Max: 40 +NumericLiterals: + MinDigits: 10 +Metrics/CyclomaticComplexity: + Max: 10 +Metrics/PerceivedComplexity: + Max: 11 +Metrics/AbcSize: + Max: 33 +Style/PercentLiteralDelimiters: + PreferredDelimiters: + '%': '{}' + '%i': () + '%q': '{}' + '%Q': () + '%r': '{}' + '%s': () + '%w': '{}' + '%W': () + '%x': () +Style/AlignHash: + Enabled: false +Style/PredicateName: + Enabled: false +Style/ClassAndModuleChildren: + Enabled: false +Style/ConditionalAssignment: + Enabled: false +Style/BracesAroundHashParameters: + Enabled: false +Style/AndOr: + Enabled: false +Style/Not: + Enabled: false +Style/FileName: + Enabled: false +Style/TrailingCommaInLiteral: + EnforcedStyleForMultiline: comma +Style/TrailingCommaInArguments: + EnforcedStyleForMultiline: comma +Style/NegatedIf: + Enabled: false +Style/UnlessElse: + Enabled: false +BlockDelimiters: + Enabled: false +Style/SpaceAroundOperators: + Enabled: false +Style/IfUnlessModifier: + Enabled: false \ No newline at end of file diff --git a/Gemfile b/Gemfile new file mode 100644 index 000000000..b6a25ca99 --- /dev/null +++ b/Gemfile @@ -0,0 +1,16 @@ +source 'https://rubygems.org' + +gem 'inifile' +gem 'azure_mgmt_compute' +gem 'azure_mgmt_resources' +gem 'bundle' + +group :development do + gem 'rake' + gem 'rubocop' + gem 'github_changelog_generator' +end + +group :inspec do + gem 'inspec', '~> 1.1' +end diff --git a/README.md b/README.md new file mode 100644 index 000000000..91adc473a --- /dev/null +++ b/README.md @@ -0,0 +1,127 @@ +# InSpec Azure Resource Pack + +This resource pack provides resources for Azure Resources. It will ship with the required resources to write your own compliance rules: + +``` +├── README.md - this readme +├── controls - contains example controls +└── libraries - contains Azure resources +``` + +## Get Started + +This profile uses the Azure Ruby SDK and as such requires a Service Principal Name (SPN) to be created in the Azure subscription that is being tested. + +This can be done on the command line or from the Azure Portal + +- Azure CLI: https://docs.microsoft.com/en-us/azure/azure-resource-manager/resource-group-authenticate-service-principal-cli +- PowerShell: https://docs.microsoft.com/en-us/azure/azure-resource-manager/resource-group-authenticate-service-principal +- Azure Portal: https://docs.microsoft.com/en-us/azure/azure-resource-manager/resource-group-create-service-principal-portal + +The information from the SPN can be specified either in a file `~/.azure/credentials`, a different file or as environment variables. + +### Credentials File + +The simplest way is to create the file `~/.azure/credentials` with the following format. The profile is configured to look for this file by default do no settings are required. + +``` +[] +client_id = "" +client_secret = "" +tenant_id = "" +``` + +So to run the profile now it is as simple as running: + +```bash +inspec exec inspec-azure +``` + +A different credentials file, with the same format, can be specified as an environment variable `AZURE_CREDS_FILE`: + +```bash +AZURE_CREDS_FILE="/path/to/another/file" inspec exec inspec-azure +``` + +Note that this file format supports multiple subscription_ids. By default `inspec-azure` will pick the first subscription in the file. However if another subscription should be used then specify it in the environment variable `AZURE_SUBSCRIPTION_ID`. + +```bash +AZURE_SUBSCRIPTION_ID="2fbdbb02-df2e-11e6-bf01-fe55135034f3" inspec exec inspec-azure +``` + +### Environment variables + +It is possible to not have a credentials file at all and specify all of the required information as the following environment variables: + +- `AZURE_SUBSCRIPTION_ID` +- `AZURE_CLIENT_ID` +- `AZURE_CLIENT_SECRET` +- `AZURE_TENANT_ID` + +For example: + +```bash +AZURE_SUBSCRIPTION_ID="2fbdbb02-df2e-11e6-bf01-fe55135034f3" AZURE_CLIENT_ID="58dc4f6c-df2e-11e6-bf01-fe55135034f3" AZURE_CLIENT_SECRET="Jibr4iwwaaZwBb6W" AZURE_TENANT_ID="6ad89b58-df2e-11e6-bf01-fe55135034f3" inspec exec inspec-azure +``` + +## Use the resources + +Since this is an InSpec resource pack, it only defines InSpec resources. It includes example tests only. To easily use the Azure InSpec resources in your tests do the following: + +### Create a new profile + +```bash +inspec init profile my-profile +``` + +### Adapt the `inspec.yml` + +```yaml +name: my-profile +title: My own Azure profile +version: 0.1.0 +depends: + - name: azure + url: https://github.com/chef/inspec-azure/archive/master.tar.gz +``` + +### Add controls + +Since your profile depends on the resource pack, you can use those resources in your own profile: + +```ruby +control 'azure-1' do + impact 1.0 + title 'Checks that the machine was built from the correct image' + + describe azurevm_image(host: 'example-01', resource_group: 'MyResourceGroup') do + its('sku') { should eq '16.04.0-LTS' } + its('publisher') { should ieq 'Canonical' } + its('offer') { should ieq 'UbuntuServer' } + end +end +``` + +### Available Resources + +- `azurevm_image` - This resource reads information about a virtual machine in the specified resource group + +## License + +| | | +| ------ | --- | +| **Author:** | Russell Seymour () | +| **Copyright:** | Copyright (c) 2016 Chef Software Inc. | +| **License:** | Apache License, Version 2.0 | + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/Rakefile b/Rakefile new file mode 100644 index 000000000..2442dc72e --- /dev/null +++ b/Rakefile @@ -0,0 +1,38 @@ +#!/usr/bin/env rake + +require 'rake/testtask' +require 'rubocop/rake_task' + +# Rubocop +desc 'Run Rubocop lint checks' +task :rubocop do + RuboCop::RakeTask.new +end + +# lint the project +desc 'Run robocop linter' +task lint: [:rubocop] + +# run tests +task default: [:lint, 'test:check'] + +namespace :test do + # run inspec check to verify that the profile is properly configured + task :check do + dir = File.join(File.dirname(__FILE__)) + sh("bundle exec inspec check #{dir}") + end +end + +# Automatically generate a changelog for this project. Only loaded if +# the necessary gem is installed. +# use `rake changelog to=1.2.0` +begin + v = ENV['to'] + require 'github_changelog_generator/task' + GitHubChangelogGenerator::RakeTask.new :changelog do |config| + config.future_release = v + end +rescue LoadError + puts '>>>>> GitHub Changelog Generator not loaded, omitting tasks' +end \ No newline at end of file diff --git a/controls/example_vm_image.rb b/controls/example_vm_image.rb new file mode 100644 index 000000000..d654cb1d4 --- /dev/null +++ b/controls/example_vm_image.rb @@ -0,0 +1,12 @@ + +title 'Sample profile to test the Image SKU of a vm' + +control 'azurevm-image-1.0' do + impact 1.0 + title 'Ensure that the machine has an image SKU of 16.04.0-LTS' + describe azurevm_image(host: 'exmaple-01', resource_group: 'MyResourceGroup') do + its('sku') { should eq '16.04.0-LTS' } + its('publisher') { should eq 'Canonical' } + its('offer') { should eq 'UbuntuServer' } + end +end diff --git a/inspec.yml b/inspec.yml new file mode 100644 index 000000000..de1a9725f --- /dev/null +++ b/inspec.yml @@ -0,0 +1,5 @@ +name: azure +title: Azure Resource Pack +maintainer: russell@turtlesystems.co.uk +summary: This resource pack provides compliance resources for Azure +version: 0.1.0 \ No newline at end of file diff --git a/libraries/azure_conn.rb b/libraries/azure_conn.rb new file mode 100644 index 000000000..5690659a5 --- /dev/null +++ b/libraries/azure_conn.rb @@ -0,0 +1,45 @@ +require 'ms_rest_azure' +require 'inifile' + +class AzureConnection + attr_accessor :subscription_id + + def initialize + # If an INSPEC_AZURE_CREDS environment has been specified set the + # the credentials file to that, otherwise set the one in home + azure_creds_file = ENV['AZURE_CREDS_FILE'] + if azure_creds_file.nil? + + # The environment file has not be set, so default to one in the home directory + azure_creds_file = File.join(Dir.home, '.azure', 'credentials') + end + + # Check to see if the credentials file exists + if File.file?(azure_creds_file) + @credentials = IniFile.load(File.expand_path(azure_creds_file)) + else + warn format('%s was not found or not accessible', azure_creds_file) + end + end + + def connection + # If a connection already exists then return it + return @conn if defined?(@conn) + + # Determine if only one subscription is specified in the configuration file, if so use that + if @credentials.sections.length == 1 + @subscription_id = @credentials.sections[0] + else + @subscription_id = ENV['AZURE_SUBSCRIPTION_ID'] + end + + # Determine the client_id, tenant_id and the client_secret + tenant_id = ENV['AZURE_TENANT_ID'] || @credentials[@subscription_id]['tenant_id'] + client_id = ENV['AZURE_CLIENT_ID'] || @credentials[@subscription_id]['client_id'] + client_secret = ENV['AZURE_CLIENT_SECRET'] || @credentials[@subscription_id]['client_secret'] + + # Create a new connection + token_provider = MsRestAzure::ApplicationTokenProvider.new(tenant_id, client_id, client_secret) + @conn = MsRest::TokenCredentials.new(token_provider) + end +end diff --git a/libraries/azure_vm_image.rb b/libraries/azure_vm_image.rb new file mode 100644 index 000000000..8971e1191 --- /dev/null +++ b/libraries/azure_vm_image.rb @@ -0,0 +1,77 @@ +require 'azure_conn' +require 'azure_mgmt_compute' + +require_relative 'common/resource_groups' + +class AzureVmImage < Inspec.resource(1) + name 'azurevm_image' + + desc " + This resource gathers information about which image the vm was created from + " + + example " + describe azurevm_image_sku(host: 'acme-test-01', resource_group: 'ACME') do + its('sku') { should eq '16.04.0-LTS'} + end + " + + # Load the configuration file on initialisation + def initialize(opts) + @opts = opts + end + + def sku + vm = get_vm(@opts[:host], @opts[:resource_group]) + + if vm.instance_of?(String) + vm + else + vm.storage_profile.image_reference.sku + end + end + + def publisher + + vm = get_vm(@opts[:host], @opts[:resource_group]) + + if vm.instance_of?(String) + vm + else + vm.storage_profile.image_reference.publisher + end + end + + def offer + vm = get_vm(@opts[:host], @opts[:resource_group]) + + if vm.instance_of?(String) + vm + else + vm.storage_profile.image_reference.offer + end + end + + # Retrieve the named virtual machine from Azure + def get_vm(name, rg_name) + # Azure connection + azure = AzureConnection.new + + client = Azure::ARM::Compute::ComputeManagementClient.new(azure.connection) + client.subscription_id = azure.subscription_id + + # Ensure that the resource group exists + rg = ResourceGroups.new(azure) + + unless rg.exists(rg_name) + throw "The Resource group cannot be found: #{rg_name}" + end + + # get a vm from the named resource group + begin + client.virtual_machines.get(rg_name, name) + rescue => e + e.error_message + end + end +end diff --git a/libraries/common/resource_groups.rb b/libraries/common/resource_groups.rb new file mode 100644 index 000000000..deabe6809 --- /dev/null +++ b/libraries/common/resource_groups.rb @@ -0,0 +1,13 @@ + +require 'azure_mgmt_resources' + +class ResourceGroups + def initialize(azure) + @client = Azure::ARM::Resources::ResourceManagementClient.new(azure.connection) + @client.subscription_id = azure.subscription_id + end + + def exists(name) + @client.resource_groups.check_existence(name) + end +end From 5a52dfe3f902480dd3a461721a6adf75a02df1f0 Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Fri, 20 Jan 2017 16:53:58 +0000 Subject: [PATCH 006/206] Tidied up syntax for command Signed-off-by: Russell Seymour --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 91adc473a..8b4c1f421 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,10 @@ It is possible to not have a credentials file at all and specify all of the requ For example: ```bash -AZURE_SUBSCRIPTION_ID="2fbdbb02-df2e-11e6-bf01-fe55135034f3" AZURE_CLIENT_ID="58dc4f6c-df2e-11e6-bf01-fe55135034f3" AZURE_CLIENT_SECRET="Jibr4iwwaaZwBb6W" AZURE_TENANT_ID="6ad89b58-df2e-11e6-bf01-fe55135034f3" inspec exec inspec-azure +AZURE_SUBSCRIPTION_ID="2fbdbb02-df2e-11e6-bf01-fe55135034f3" \ +AZURE_CLIENT_ID="58dc4f6c-df2e-11e6-bf01-fe55135034f3" \ +AZURE_CLIENT_SECRET="Jibr4iwwaaZwBb6W" \ +AZURE_TENANT_ID="6ad89b58-df2e-11e6-bf01-fe55135034f3" inspec exec inspec-azure ``` ## Use the resources From 002aa73e7d62bba8b7ad8f795ab9a3bc8b4626fc Mon Sep 17 00:00:00 2001 From: Stuart Preston Date: Tue, 24 Jan 2017 13:22:01 +0000 Subject: [PATCH 007/206] Support credentials file with multiple sections Signed-off-by: Stuart Preston --- libraries/azure_conn.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/azure_conn.rb b/libraries/azure_conn.rb index 5690659a5..9ecb1d92a 100644 --- a/libraries/azure_conn.rb +++ b/libraries/azure_conn.rb @@ -26,8 +26,8 @@ class AzureConnection # If a connection already exists then return it return @conn if defined?(@conn) - # Determine if only one subscription is specified in the configuration file, if so use that - if @credentials.sections.length == 1 + # Determine if more than one subscription is specified in the configuration file, if so use the first one + if @credentials.sections.length >= 1 @subscription_id = @credentials.sections[0] else @subscription_id = ENV['AZURE_SUBSCRIPTION_ID'] From a55b341b32d19009945bca19f407bf553596ca12 Mon Sep 17 00:00:00 2001 From: Christoph Hartmann Date: Mon, 16 Jan 2017 16:14:24 +0100 Subject: [PATCH 008/206] use terraform + inspec for integration testing --- README.md | 23 ++++++++++++++++++++--- Rakefile | 17 +++++++++++++++++ test/integration/build/aws.tf | 11 +++++++++++ test/integration/verify/controls/aws.rb | 5 +++++ test/integration/verify/inspec.yml | 4 ++++ 5 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 test/integration/build/aws.tf create mode 100644 test/integration/verify/controls/aws.rb create mode 100644 test/integration/verify/inspec.yml diff --git a/README.md b/README.md index abd0e7de7..e12bd5921 100644 --- a/README.md +++ b/README.md @@ -85,11 +85,28 @@ end ... - ## Kudos +## Tests - This project was inspired by [inspec-aws](https://github.com/arothian/inspec-aws) from [arothian](https://github.com/arothian). +To run the integration tests, please make sure all required environment variables like `AWS_ACCESS_KEY_ID` +, `AWS_SECRET_ACCESS_KEY` and `AWS_DEFAULT_REGION` are set properly. We use terraform to create the AWS setup and InSpec to verify the all aspects. Integration tests can be executed via: - ## License +``` +bundle exec rake test:integration +``` + +Right now, instances are not destroyed automatically. Please use + +``` +bundle exec rake test:cleanup +``` + +to remove the setup created by terraform. + +## Kudos + +This project was inspired by [inspec-aws](https://github.com/arothian/inspec-aws) from [arothian](https://github.com/arothian). + +## License | | | | ------ | --- | diff --git a/Rakefile b/Rakefile index d36039071..db08bfac7 100644 --- a/Rakefile +++ b/Rakefile @@ -23,4 +23,21 @@ namespace :test do dir = File.join(File.dirname(__FILE__)) sh("bundle exec inspec check #{dir}") end + + task :integration do + integration_dir = "test/integration" + + puts "----> Build" + sh("cd #{integration_dir}/build/ && terraform plan") + sh("cd #{integration_dir}/build/ && terraform apply") + + puts "----> Verify" + sh("bundle exec inspec exec #{integration_dir}/verify") + end + + task :cleanup do + integration_dir = "test/integration" + puts "----> Destroy" + sh("cd #{integration_dir}/build/ && terraform destroy -force") + end end diff --git a/test/integration/build/aws.tf b/test/integration/build/aws.tf new file mode 100644 index 000000000..8fe359a46 --- /dev/null +++ b/test/integration/build/aws.tf @@ -0,0 +1,11 @@ +provider "aws" {} + +resource "aws_instance" "example" { + ami = "ami-0d729a60" + instance_type = "t2.micro" + + tags { + Name = "Example" + X-Project = "inspec" + } +} diff --git a/test/integration/verify/controls/aws.rb b/test/integration/verify/controls/aws.rb new file mode 100644 index 000000000..f3a147bdf --- /dev/null +++ b/test/integration/verify/controls/aws.rb @@ -0,0 +1,5 @@ +describe aws_ec2(name: 'Example') do + it { should be_running } + its('image_id') { should eq 'ami-0d729a60' } + its('instance_type') { should eq 't2.micro' } +end diff --git a/test/integration/verify/inspec.yml b/test/integration/verify/inspec.yml new file mode 100644 index 000000000..402de99d5 --- /dev/null +++ b/test/integration/verify/inspec.yml @@ -0,0 +1,4 @@ +name: inspec-aws-integration-tests +depends: + - name: aws + path: ../../../ From e3d3204b2a4d3d5d43834f7dd0f3dad4327d9d21 Mon Sep 17 00:00:00 2001 From: Christopher Redekop Date: Tue, 24 Jan 2017 12:49:29 -0500 Subject: [PATCH 009/206] Add a us-east-1 note to the README Signed-off-by: Christopher Redekop --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e12bd5921..04d58aa36 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ end ## Tests To run the integration tests, please make sure all required environment variables like `AWS_ACCESS_KEY_ID` -, `AWS_SECRET_ACCESS_KEY` and `AWS_DEFAULT_REGION` are set properly. We use terraform to create the AWS setup and InSpec to verify the all aspects. Integration tests can be executed via: +, `AWS_SECRET_ACCESS_KEY` and `AWS_DEFAULT_REGION` are set properly. (`AWS_DEFAULT_REGION` **must** be set to **us-east-1** when running the integration tests.) We use terraform to create the AWS setup and InSpec to verify the all aspects. Integration tests can be executed via: ``` bundle exec rake test:integration From a3a238aafc9dc1e88fbd205f1c432cf9ae867cd7 Mon Sep 17 00:00:00 2001 From: Chris Redekop Date: Mon, 6 Feb 2017 12:06:01 +0000 Subject: [PATCH 010/206] #13, Ec2#exists returns false positives Signed-off-by: Chris Redekop --- test/integration/verify/controls/aws.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/integration/verify/controls/aws.rb b/test/integration/verify/controls/aws.rb index f3a147bdf..7c931425e 100644 --- a/test/integration/verify/controls/aws.rb +++ b/test/integration/verify/controls/aws.rb @@ -1,5 +1,9 @@ describe aws_ec2(name: 'Example') do - it { should be_running } + it { should exist } its('image_id') { should eq 'ami-0d729a60' } its('instance_type') { should eq 't2.micro' } end + +describe aws_ec2('i-missing') do + it { should_not exist } +end From 082e5c00412df0ffc45ad6d9c628a0686d970257 Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Wed, 8 Feb 2017 12:46:59 +0000 Subject: [PATCH 011/206] Added ability to check for datadisks Added `pry-coolline` to help with debugging in development Signed-off-by: Russell Seymour --- Gemfile | 1 + libraries/azure_vm_image.rb | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/Gemfile b/Gemfile index b6a25ca99..32ef313a9 100644 --- a/Gemfile +++ b/Gemfile @@ -9,6 +9,7 @@ group :development do gem 'rake' gem 'rubocop' gem 'github_changelog_generator' + gem 'pry-coolline' end group :inspec do diff --git a/libraries/azure_vm_image.rb b/libraries/azure_vm_image.rb index 8971e1191..6d54bfdda 100644 --- a/libraries/azure_vm_image.rb +++ b/libraries/azure_vm_image.rb @@ -52,6 +52,16 @@ class AzureVmImage < Inspec.resource(1) end end + def has_data_disks? + vm = get_vm(@opts[:host], @opts[:resource_group]) + + if vm.instance_of?(String) + vm + else + vm.storage_profile.data_disks.length > 0 + end + end + # Retrieve the named virtual machine from Azure def get_vm(name, rg_name) # Azure connection From 8e7a600dcb77fe1b16b5099727af0dcddeb2a5ec Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Thu, 9 Feb 2017 16:47:46 +0000 Subject: [PATCH 012/206] Adding support for data disks using Inspec filter Signed-off-by: Russell Seymour --- libraries/azure_vm.rb | 71 +++++++++++++++++++++++++++ libraries/azure_vm_datadisks.rb | 76 ++++++++++++++++++++++++++++ libraries/azure_vm_image.rb | 87 --------------------------------- libraries/common/helpers.rb | 33 +++++++++++++ 4 files changed, 180 insertions(+), 87 deletions(-) create mode 100644 libraries/azure_vm.rb create mode 100644 libraries/azure_vm_datadisks.rb delete mode 100644 libraries/azure_vm_image.rb create mode 100644 libraries/common/helpers.rb diff --git a/libraries/azure_vm.rb b/libraries/azure_vm.rb new file mode 100644 index 000000000..d8a5d830c --- /dev/null +++ b/libraries/azure_vm.rb @@ -0,0 +1,71 @@ + +require_relative 'common/helpers' + +class AzureVm < Inspec.resource(1) + name 'azure_vm' + + desc " + This resource gathers information about which image the vm was created from + " + + example " + describe azure_vm(host: 'acme-test-01', resource_group: 'ACME') do + its('sku') { should eq '16.04.0-LTS'} + end + " + + # Load the configuration file on initialisation + def initialize(opts) + @opts = opts + @helpers = Helpers.new() + end + + def sku + vm = @helpers.get_vm(@opts[:host], @opts[:resource_group]) + + if vm.instance_of?(String) + vm + else + vm.storage_profile.image_reference.sku + end + end + + def publisher + + vm = @helpers.get_vm(@opts[:host], @opts[:resource_group]) + + if vm.instance_of?(String) + vm + else + vm.storage_profile.image_reference.publisher + end + end + + def offer + vm = @helpers.get_vm(@opts[:host], @opts[:resource_group]) + + if vm.instance_of?(String) + vm + else + vm.storage_profile.image_reference.offer + end + end + + def has_data_disks? + vm = @helpers.get_vm(@opts[:host], @opts[:resource_group]) + + if vm.instance_of?(String) + vm + else + vm.storage_profile.data_disks.length > 0 + end + end + + def data_disk + + vm = @helpers.get_vm(@opts[:host], @opts[:resource_group]) + + dd = DataDisks.new(vm) + end + +end diff --git a/libraries/azure_vm_datadisks.rb b/libraries/azure_vm_datadisks.rb new file mode 100644 index 000000000..972ce7c8e --- /dev/null +++ b/libraries/azure_vm_datadisks.rb @@ -0,0 +1,76 @@ + +require_relative 'common/helpers' +require 'uri' + +class AzureVmDataDisks < Inspec.resource(1) + + name 'azure_vm_datadisks' + + desc " + This resource gather information about the data disks attached to a virtual machine + " + + example " + describe azure_vm_datadisks(host: 'example-01', resource_group: 'MyResourceGroup').where { (disk == 1 and size >= 10) } do + it { should be true } + end + " + + attr_reader :params + + # Load the configuration on initialisation + def initialize(opts) + @opts = opts + @helpers = Helpers.new() + + # Get the VM that needs to be interrogated + vm = @helpers.get_vm(@opts[:host], @opts[:resource_group]) + + # Parse the data disks + @params = parse_data_disks(vm.storage_profile.data_disks) + end + + # Create a filter table for testing + filter = FilterTable.create + filter.add_accessor(:where) + .add_accessor(:entries) + .add(:disk, field: 'disk') + .add(:caching, field: 'caching') + .add(:create_option, field: 'create_option') + .add(:size, field: 'size') + .add(:lun, field: 'lun') + .add(:name, field: 'name') + .add(:uri, field: 'uri') + .add(:storage_account, field: 'storage_account') + + filter.connect(self, :params) + + private + + def parse_data_disks(data_disks) + + data_disks.each_with_index.map do |disk, index| + parse_data_disk_item(disk, index) + end.compact + + end + + def parse_data_disk_item(disk, index) + + # Parse the uri of the disk so that the storage account can be retrieved + uri = URI.parse(disk.vhd.uri) + + { + 'disk' => index, + 'caching' => disk.caching, + 'create_option' => disk.create_option, + 'size' => disk.disk_size_gb, + 'lun' => disk.lun, + 'name' => disk.name, + 'uri' => disk.vhd.uri, + 'storage_account' => uri.host.split('.').first + } + + end + +end \ No newline at end of file diff --git a/libraries/azure_vm_image.rb b/libraries/azure_vm_image.rb deleted file mode 100644 index 6d54bfdda..000000000 --- a/libraries/azure_vm_image.rb +++ /dev/null @@ -1,87 +0,0 @@ -require 'azure_conn' -require 'azure_mgmt_compute' - -require_relative 'common/resource_groups' - -class AzureVmImage < Inspec.resource(1) - name 'azurevm_image' - - desc " - This resource gathers information about which image the vm was created from - " - - example " - describe azurevm_image_sku(host: 'acme-test-01', resource_group: 'ACME') do - its('sku') { should eq '16.04.0-LTS'} - end - " - - # Load the configuration file on initialisation - def initialize(opts) - @opts = opts - end - - def sku - vm = get_vm(@opts[:host], @opts[:resource_group]) - - if vm.instance_of?(String) - vm - else - vm.storage_profile.image_reference.sku - end - end - - def publisher - - vm = get_vm(@opts[:host], @opts[:resource_group]) - - if vm.instance_of?(String) - vm - else - vm.storage_profile.image_reference.publisher - end - end - - def offer - vm = get_vm(@opts[:host], @opts[:resource_group]) - - if vm.instance_of?(String) - vm - else - vm.storage_profile.image_reference.offer - end - end - - def has_data_disks? - vm = get_vm(@opts[:host], @opts[:resource_group]) - - if vm.instance_of?(String) - vm - else - vm.storage_profile.data_disks.length > 0 - end - end - - # Retrieve the named virtual machine from Azure - def get_vm(name, rg_name) - # Azure connection - azure = AzureConnection.new - - client = Azure::ARM::Compute::ComputeManagementClient.new(azure.connection) - client.subscription_id = azure.subscription_id - - # Ensure that the resource group exists - rg = ResourceGroups.new(azure) - - unless rg.exists(rg_name) - throw "The Resource group cannot be found: #{rg_name}" - end - - # get a vm from the named resource group - begin - client.virtual_machines.get(rg_name, name) - rescue => e - e.error_message - end - end -end diff --git a/libraries/common/helpers.rb b/libraries/common/helpers.rb new file mode 100644 index 000000000..cb47fc171 --- /dev/null +++ b/libraries/common/helpers.rb @@ -0,0 +1,33 @@ + +require_relative '../azure_conn' +require 'azure_mgmt_compute' + +require_relative 'resource_groups' + +class Helpers + + # Retrieve the named virtual machine from Azure + def get_vm(name, rg_name) + + # Azure connection + azure = AzureConnection.new + + client = Azure::ARM::Compute::ComputeManagementClient.new(azure.connection) + client.subscription_id = azure.subscription_id + + # Ensure that the resource group exists + rg = ResourceGroups.new(azure) + + unless rg.exists(rg_name) + throw "The Resource group cannot be found: #{rg_name}" + end + + # get a vm from the named resource group + begin + client.virtual_machines.get(rg_name, name) + rescue => e + e.error_message + end + end + +end \ No newline at end of file From 8b026d48ea2e97e4fcfc64439cdad0e93fad4f0f Mon Sep 17 00:00:00 2001 From: Viktor Yakovlyev Date: Thu, 9 Feb 2017 16:31:24 -0500 Subject: [PATCH 013/206] Adding check for ec2 instance existence. #13 Signed-off-by: Viktor Yakovlyev --- libraries/ec2.rb | 2 +- test/integration/verify/controls/aws.rb | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/libraries/ec2.rb b/libraries/ec2.rb index e5be8b42a..29d115e75 100644 --- a/libraries/ec2.rb +++ b/libraries/ec2.rb @@ -44,7 +44,7 @@ class Ec2 < Inspec.resource(1) alias instance_id id def exists? - !id.nil? + instance.exists? end # returns the instance state diff --git a/test/integration/verify/controls/aws.rb b/test/integration/verify/controls/aws.rb index 7c931425e..9b031309c 100644 --- a/test/integration/verify/controls/aws.rb +++ b/test/integration/verify/controls/aws.rb @@ -4,6 +4,7 @@ describe aws_ec2(name: 'Example') do its('instance_type') { should eq 't2.micro' } end -describe aws_ec2('i-missing') do +#must use a real EC2 instance name, as the SDK will first check to see if its well formed before sending requests +describe aws_ec2('i-06b4bc106e0d03dfd') do it { should_not exist } end From 8d6b42fa5eefeced6f2999a85ecd326c43fa3229 Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Mon, 13 Feb 2017 13:53:07 +0000 Subject: [PATCH 014/206] Added example of using the azure_vm_datadisks in a control Signed-off-by: Russell Seymour --- controls/example_vm_datadisks.rb | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 controls/example_vm_datadisks.rb diff --git a/controls/example_vm_datadisks.rb b/controls/example_vm_datadisks.rb new file mode 100644 index 000000000..7a63aa2b9 --- /dev/null +++ b/controls/example_vm_datadisks.rb @@ -0,0 +1,11 @@ + +title 'Sample profile to test the data disks of a vm' + +control 'azure-vm-datadisks-1.0' do + impact 1.0 + title 'Ensure that the machine has 1 data disk of greater than or equal to 10gb' + + describe azure_vm_datadisks(host: 'AutomateServer-VM', resource_group: 'rjs-automate-09').where { (disk == 1 and size >= 10) } do + its('entries') { should_not be_empty } + end +end From 5d00aac059fb7cd7365471becb0feb883bed1296 Mon Sep 17 00:00:00 2001 From: Chris Redekop Date: Fri, 27 Jan 2017 18:38:04 +0000 Subject: [PATCH 015/206] Add unit test skeleton with Minitest dependency Signed-off-by: Chris Redekop --- Gemfile | 1 + Rakefile | 9 ++++++++- libraries/ec2.rb | 3 +-- test/unit/helper.rb | 5 +++++ test/unit/resources/ec2_test.rb | 26 ++++++++++++++++++++++++++ 5 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 test/unit/helper.rb create mode 100644 test/unit/resources/ec2_test.rb diff --git a/Gemfile b/Gemfile index 18ccd4c52..a1aea8afe 100644 --- a/Gemfile +++ b/Gemfile @@ -6,6 +6,7 @@ gem 'rubocop', '~> 0.44.0' gem 'highline', '~> 1.6.0' gem 'aws-sdk' gem 'nokogiri' +gem 'minitest', '5.10.1' group :tools do gem 'github_changelog_generator', '~> 1.12.0' diff --git a/Rakefile b/Rakefile index db08bfac7..d34f450fa 100644 --- a/Rakefile +++ b/Rakefile @@ -10,12 +10,19 @@ task :rubocop do RuboCop::RakeTask.new end +# Minitest +Rake::TestTask.new do |t| + t.libs << 'libraries' + t.libs << 'test/unit' + t.pattern = "test/unit/**/*_test.rb" +end + # lint the project desc 'Run robocop linter' task lint: [:rubocop] # run tests -task default: [:lint] +task default: [:lint, :test] namespace :test do # run inspec check to verify that the profile is properly configured diff --git a/libraries/ec2.rb b/libraries/ec2.rb index e5be8b42a..433a7d6db 100644 --- a/libraries/ec2.rb +++ b/libraries/ec2.rb @@ -16,10 +16,9 @@ class Ec2 < Inspec.resource(1) end " - def initialize(opts) + def initialize(opts, conn = AWSConnection.new) @opts = opts @opts.is_a?(Hash) ? @display_name = @opts[:name] : @display_name = opts - conn = AWSConnection.new @ec2_client = conn.ec2_client @ec2_resource = conn.ec2_resource end diff --git a/test/unit/helper.rb b/test/unit/helper.rb new file mode 100644 index 000000000..669fa01bd --- /dev/null +++ b/test/unit/helper.rb @@ -0,0 +1,5 @@ +require 'minitest/autorun' +require 'minitest/unit' +require 'minitest/pride' + +require 'inspec/resource' diff --git a/test/unit/resources/ec2_test.rb b/test/unit/resources/ec2_test.rb new file mode 100644 index 000000000..cde69ce5a --- /dev/null +++ b/test/unit/resources/ec2_test.rb @@ -0,0 +1,26 @@ +require 'helper' + +require 'ec2' + +class TestEc2 < Minitest::Test + def setup + @conn = Minitest::Mock.new + @client = Minitest::Mock.new + @resource = Minitest::Mock.new + + @conn.expect :ec2_client, @client + @conn.expect :ec2_resource, @resource + end + + def test_that_id_returns_directly_provided_id + @cut = Ec2.new('i-foo', @conn) + + assert_equal @cut.id, 'i-foo' + end + + # def test_that_id_returns_id_for_provided_name + # @cut = Ec2.new({name: 'cut'}, @conn) + # + # assert_equal @cut.id, 'i-foo' + # end +end From 1842cc2fd9965c0ccd4cb31ab61192bceaef3872 Mon Sep 17 00:00:00 2001 From: Chris Redekop Date: Wed, 1 Feb 2017 22:31:18 +0000 Subject: [PATCH 016/206] add another id-method test Signed-off-by: Chris Redekop --- test/unit/resources/ec2_test.rb | 34 ++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/test/unit/resources/ec2_test.rb b/test/unit/resources/ec2_test.rb index cde69ce5a..715b74dd6 100644 --- a/test/unit/resources/ec2_test.rb +++ b/test/unit/resources/ec2_test.rb @@ -3,24 +3,32 @@ require 'helper' require 'ec2' class TestEc2 < Minitest::Test - def setup - @conn = Minitest::Mock.new - @client = Minitest::Mock.new - @resource = Minitest::Mock.new + Id = "instance-id" - @conn.expect :ec2_client, @client - @conn.expect :ec2_resource, @resource + def setup + @mockConn = Minitest::Mock.new + @mockClient = Minitest::Mock.new + @mockResource = Minitest::Mock.new + + @mockConn.expect :ec2_client, @mockClient + @mockConn.expect :ec2_resource, @mockResource end def test_that_id_returns_directly_provided_id - @cut = Ec2.new('i-foo', @conn) + @cut = Ec2.new(Id, @mockConn) - assert_equal @cut.id, 'i-foo' + assert_equal @cut.id, Id end - # def test_that_id_returns_id_for_provided_name - # @cut = Ec2.new({name: 'cut'}, @conn) - # - # assert_equal @cut.id, 'i-foo' - # end + def test_that_id_returns_id_for_provided_name + @cut = Ec2.new({name: 'cut'}, @mockConn) + mockInstance = Minitest::Mock.new + + mockInstance.expect :nil?, false + mockInstance.expect :id, Id + + @mockResource.expect :instances, [mockInstance], [Hash] + + assert_equal @cut.id, Id + end end From 9d8afa544042dedb46f550da1a0ebd15c6bdf758 Mon Sep 17 00:00:00 2001 From: Chris Redekop Date: Wed, 8 Feb 2017 18:46:52 +0000 Subject: [PATCH 017/206] add tests for :instance and :exists Signed-off-by: Chris Redekop --- test/unit/resources/ec2_test.rb | 43 +++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/test/unit/resources/ec2_test.rb b/test/unit/resources/ec2_test.rb index 715b74dd6..f877d5ca1 100644 --- a/test/unit/resources/ec2_test.rb +++ b/test/unit/resources/ec2_test.rb @@ -14,14 +14,11 @@ class TestEc2 < Minitest::Test @mockConn.expect :ec2_resource, @mockResource end - def test_that_id_returns_directly_provided_id - @cut = Ec2.new(Id, @mockConn) - - assert_equal @cut.id, Id + def test_that_id_returns_id_directly_when_constructed_with_an_id + assert_equal Id, Ec2.new(Id, @mockConn).id end - def test_that_id_returns_id_for_provided_name - @cut = Ec2.new({name: 'cut'}, @mockConn) + def test_that_id_returns_fetched_id_when_constructed_with_a_name mockInstance = Minitest::Mock.new mockInstance.expect :nil?, false @@ -29,6 +26,38 @@ class TestEc2 < Minitest::Test @mockResource.expect :instances, [mockInstance], [Hash] - assert_equal @cut.id, Id + assert_equal Id, Ec2.new({name: 'cut'}, @mockConn).id end + + def test_that_instance_returns_instance_when_instance_exists + mockInstance = Object.new + + @mockResource.expect :instance, mockInstance, [Id] + + assert_same mockInstance, Ec2.new(Id, @mockConn).send(:instance) + end + + def test_that_instance_returns_nil_when_instance_does_not_exist + @mockResource.expect :instance, nil, [Id] + + assert Ec2.new(Id, @mockConn).send(:instance).nil? + end + + def test_that_exists_returns_true_when_instance_exists + mockInstance = Object.new + + @mockResource.expect :instance, mockInstance, [Id] + + assert Ec2.new(Id, @mockConn).exists? + end + + # A test similar to this one should pass once issue #13 is fixed` + # def test_that_exists_returns_false_when_instance_does_not_exist + # @cut = Ec2.new(Id, @mockConn) + # mockInstance = Object.new + + # @mockResource.expect :instance, nil, [Id] + + # assert_false @cut.exists? + # end end From 1117020d52451c0cae43b429140bbc410156d944 Mon Sep 17 00:00:00 2001 From: Christoph Hartmann Date: Tue, 14 Feb 2017 18:36:47 +0100 Subject: [PATCH 018/206] update readme --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 04d58aa36..ec06b165f 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,16 @@ end ## Tests +### Unit tests + +To execute the unit tests, run: + +``` +bundle exec rake test +``` + +### Integration tests + To run the integration tests, please make sure all required environment variables like `AWS_ACCESS_KEY_ID` , `AWS_SECRET_ACCESS_KEY` and `AWS_DEFAULT_REGION` are set properly. (`AWS_DEFAULT_REGION` **must** be set to **us-east-1** when running the integration tests.) We use terraform to create the AWS setup and InSpec to verify the all aspects. Integration tests can be executed via: From 3cad7d1e78e7c0ab17e1af43224f3cec487c8e83 Mon Sep 17 00:00:00 2001 From: Chris Redekop Date: Mon, 6 Feb 2017 12:06:01 +0000 Subject: [PATCH 019/206] #13, Ec2#exists returns false positives Signed-off-by: Chris Redekop --- test/integration/verify/controls/aws.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/integration/verify/controls/aws.rb b/test/integration/verify/controls/aws.rb index f3a147bdf..7c931425e 100644 --- a/test/integration/verify/controls/aws.rb +++ b/test/integration/verify/controls/aws.rb @@ -1,5 +1,9 @@ describe aws_ec2(name: 'Example') do - it { should be_running } + it { should exist } its('image_id') { should eq 'ami-0d729a60' } its('instance_type') { should eq 't2.micro' } end + +describe aws_ec2('i-missing') do + it { should_not exist } +end From 6ee43f8fb5f82c12b683adc92ea9c988e1046f63 Mon Sep 17 00:00:00 2001 From: Viktor Yakovlyev Date: Thu, 9 Feb 2017 16:31:24 -0500 Subject: [PATCH 020/206] Adding check for ec2 instance existence. #13 Signed-off-by: Viktor Yakovlyev --- libraries/ec2.rb | 2 +- test/integration/verify/controls/aws.rb | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/libraries/ec2.rb b/libraries/ec2.rb index 433a7d6db..21dace7da 100644 --- a/libraries/ec2.rb +++ b/libraries/ec2.rb @@ -43,7 +43,7 @@ class Ec2 < Inspec.resource(1) alias instance_id id def exists? - !id.nil? + instance.exists? end # returns the instance state diff --git a/test/integration/verify/controls/aws.rb b/test/integration/verify/controls/aws.rb index 7c931425e..9b031309c 100644 --- a/test/integration/verify/controls/aws.rb +++ b/test/integration/verify/controls/aws.rb @@ -4,6 +4,7 @@ describe aws_ec2(name: 'Example') do its('instance_type') { should eq 't2.micro' } end -describe aws_ec2('i-missing') do +#must use a real EC2 instance name, as the SDK will first check to see if its well formed before sending requests +describe aws_ec2('i-06b4bc106e0d03dfd') do it { should_not exist } end From f0773ccc75eef535ca5f821e57ce248e682988aa Mon Sep 17 00:00:00 2001 From: Viktor Yakovlyev Date: Thu, 16 Feb 2017 14:27:35 -0500 Subject: [PATCH 021/206] fix tests Signed-off-by: Viktor Yakovlyev --- test/unit/resources/ec2_test.rb | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test/unit/resources/ec2_test.rb b/test/unit/resources/ec2_test.rb index f877d5ca1..cf4a18696 100644 --- a/test/unit/resources/ec2_test.rb +++ b/test/unit/resources/ec2_test.rb @@ -23,7 +23,6 @@ class TestEc2 < Minitest::Test mockInstance.expect :nil?, false mockInstance.expect :id, Id - @mockResource.expect :instances, [mockInstance], [Hash] assert_equal Id, Ec2.new({name: 'cut'}, @mockConn).id @@ -44,8 +43,8 @@ class TestEc2 < Minitest::Test end def test_that_exists_returns_true_when_instance_exists - mockInstance = Object.new - + mockInstance = Minitest::Mock.new + mockInstance.expect :exists?, true @mockResource.expect :instance, mockInstance, [Id] assert Ec2.new(Id, @mockConn).exists? From bdd86542b089281a2cbbebeef13120ed8d37e8b8 Mon Sep 17 00:00:00 2001 From: Viktor Yakovlyev Date: Thu, 16 Feb 2017 14:33:21 -0500 Subject: [PATCH 022/206] add negative case test for exists? Signed-off-by: Viktor Yakovlyev --- test/unit/resources/ec2_test.rb | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/test/unit/resources/ec2_test.rb b/test/unit/resources/ec2_test.rb index cf4a18696..88f11ee8c 100644 --- a/test/unit/resources/ec2_test.rb +++ b/test/unit/resources/ec2_test.rb @@ -50,13 +50,11 @@ class TestEc2 < Minitest::Test assert Ec2.new(Id, @mockConn).exists? end - # A test similar to this one should pass once issue #13 is fixed` - # def test_that_exists_returns_false_when_instance_does_not_exist - # @cut = Ec2.new(Id, @mockConn) - # mockInstance = Object.new + def test_that_exists_returns_false_when_instance_does_not_exist + mockInstance = Minitest::Mock.new + mockInstance.expect :exists?, false + @mockResource.expect :instance, mockInstance, [Id] - # @mockResource.expect :instance, nil, [Id] - - # assert_false @cut.exists? - # end + assert !Ec2.new(Id, @mockConn).exists? + end end From 5d6900fb86c1b0bfc66eae06faae7456b7ea4108 Mon Sep 17 00:00:00 2001 From: Viktor Yakovlyev Date: Thu, 16 Feb 2017 15:22:33 -0500 Subject: [PATCH 023/206] add cleanup by default on integration tests Signed-off-by: Viktor Yakovlyev --- Rakefile | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Rakefile b/Rakefile index d34f450fa..dd0572c26 100644 --- a/Rakefile +++ b/Rakefile @@ -31,7 +31,7 @@ namespace :test do sh("bundle exec inspec check #{dir}") end - task :integration do + task :integration_no_cleanup do integration_dir = "test/integration" puts "----> Build" @@ -42,6 +42,12 @@ namespace :test do sh("bundle exec inspec exec #{integration_dir}/verify") end + task :integration do + Rake::Task["test:cleanup"].execute + Rake::Task["test:integration_no_cleanup"].execute + Rake::Task["test:cleanup"].execute + end + task :cleanup do integration_dir = "test/integration" puts "----> Destroy" From 88f6e5f716f9e91b445a111c371389cdc7c6e3e6 Mon Sep 17 00:00:00 2001 From: Viktor Yakovlyev Date: Thu, 16 Feb 2017 15:28:30 -0500 Subject: [PATCH 024/206] update readme with new integration test behaviour Signed-off-by: Viktor Yakovlyev --- README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ec06b165f..8af455b54 100644 --- a/README.md +++ b/README.md @@ -104,14 +104,18 @@ To run the integration tests, please make sure all required environment variable bundle exec rake test:integration ``` -Right now, instances are not destroyed automatically. Please use +Instances are destroyed automatically. To persist instances please use + +``` +bundle exec rake test:integration_no_cleanup +``` + +To cleanup instances, please use ``` bundle exec rake test:cleanup ``` -to remove the setup created by terraform. - ## Kudos This project was inspired by [inspec-aws](https://github.com/arothian/inspec-aws) from [arothian](https://github.com/arothian). From 37fc5e61300377679347b4a868051cfee98b5177 Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Fri, 17 Feb 2017 17:06:15 +0000 Subject: [PATCH 025/206] Added new resource to test data disks Fixes #1 Signed-off-by: Russell Seymour --- README.md | 59 ++++++++++++++++++++++++++++++-- controls/example_vm_datadisks.rb | 12 +++++-- controls/example_vm_image.rb | 10 ++++-- libraries/azure_vm.rb | 17 --------- libraries/azure_vm_datadisks.rb | 10 ++++++ 5 files changed, 84 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 8b4c1f421..7edb340fb 100644 --- a/README.md +++ b/README.md @@ -107,14 +107,69 @@ end ### Available Resources -- `azurevm_image` - This resource reads information about a virtual machine in the specified resource group +- `azure_vm` - This resource reads information about a virtual machine in the specified resource group + +| Resource Name | Resources | Description | +|---------------|-----------|-------------| +| azure_vm | publisher | Publisher that provided the image in the marketplace | +| | offer | The offer of the image | +| | sku | The SKU being used | + +- `azure_vm_datadisks` - Resource to read the data disks for a machine and check that they are of the correct size etc + +| Resource Name | Resources | Description | +|---------------|-----------|-------------| +| azure_vm_datadisks | has_disks? | Boolean test to see if a machine has datadisks | +| | count | Returns the number of data disks attached to the machine | +| | where | Filter that allows for different tests to be performed, see examples below | + +When data disks are retrieved from a machine they are given as an array. The `where` filter will interogate the array according the criteria it is given. The followin attributes are available in the filter: + + - `disk` - Disk number (0 index based) + - `caching` - What sort of caching is enabled on the data disk + - `create_option` - How the disk was created + - `size` - The size of the disk in GB + - `lun` - The LUN number + - `name` - Name of the disk + - `uri` - Full URI to the disk in Blob storage + - `storage_account` - The name of the storage account in which the Blob storage exists + +**Note: This does not yet work with Managed Disks** + +## Examples + +### Test for 1 disk with a size greater than 10gb + +```ruby +control 'azure-1' do + impact 1.0 + title 'Checks that the machine has exactly one data disk and it is over 10gb in size' + + describe azurevm_image(host: 'example-01', resource_group: 'MyResourceGroup') do + its('has_disks?') { should be true } + its('count') { should eq 1 } + end + + describe azurevm_image(host: 'example-01', resource_group: 'MyResourceGroup').where { disk == 0 and size > 10 } do + its('entries') { should_not be_empty } + end +end +``` + +### Using the example controls + +There a number of example controls that have been added to this resource. They are driven by environment variables to make them easier to run. For example the following would test a machine called `example-01` in the resource group `exmaple-rg`. + +```bash +$> AZURE_VM_NAME='example-01' AZURE_RESOURCE_GROUP_NAME='example-rg' bundle exec inspec exec . +``` ## License | | | | ------ | --- | | **Author:** | Russell Seymour () | -| **Copyright:** | Copyright (c) 2016 Chef Software Inc. | +| **Copyright:** | Copyright (c) 2017 Chef Software Inc. | | **License:** | Apache License, Version 2.0 | Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/controls/example_vm_datadisks.rb b/controls/example_vm_datadisks.rb index 7a63aa2b9..afb58a8cf 100644 --- a/controls/example_vm_datadisks.rb +++ b/controls/example_vm_datadisks.rb @@ -3,9 +3,17 @@ title 'Sample profile to test the data disks of a vm' control 'azure-vm-datadisks-1.0' do impact 1.0 - title 'Ensure that the machine has 1 data disk of greater than or equal to 10gb' + title 'Ensure that the machine has 1 data disk' - describe azure_vm_datadisks(host: 'AutomateServer-VM', resource_group: 'rjs-automate-09').where { (disk == 1 and size >= 10) } do + hostname = ENV['AZURE_VM_NAME'] + resource_group_name = ENV['AZURE_RESOURCE_GROUP_NAME'] + + describe azure_vm_datadisks(host: hostname, resource_group: resource_group_name) do + its('has_disks?') { should be true } + its('count') { should eq 1 } + end + + describe azure_vm_datadisks(host: hostname, resource_group: resource_group_name).where { disk == 0 and size > 10 } do its('entries') { should_not be_empty } end end diff --git a/controls/example_vm_image.rb b/controls/example_vm_image.rb index d654cb1d4..d4fc0baea 100644 --- a/controls/example_vm_image.rb +++ b/controls/example_vm_image.rb @@ -1,11 +1,15 @@ title 'Sample profile to test the Image SKU of a vm' -control 'azurevm-image-1.0' do +control 'azure-vm-1.0' do impact 1.0 title 'Ensure that the machine has an image SKU of 16.04.0-LTS' - describe azurevm_image(host: 'exmaple-01', resource_group: 'MyResourceGroup') do - its('sku') { should eq '16.04.0-LTS' } + + hostname = ENV['AZURE_VM_NAME'] + resource_group_name = ENV['AZURE_RESOURCE_GROUP_NAME'] + + describe azure_vm(host: hostname, resource_group: resource_group_name) do + its('sku') { should eq '16.04-LTS' } its('publisher') { should eq 'Canonical' } its('offer') { should eq 'UbuntuServer' } end diff --git a/libraries/azure_vm.rb b/libraries/azure_vm.rb index d8a5d830c..a53168d9e 100644 --- a/libraries/azure_vm.rb +++ b/libraries/azure_vm.rb @@ -51,21 +51,4 @@ class AzureVm < Inspec.resource(1) end end - def has_data_disks? - vm = @helpers.get_vm(@opts[:host], @opts[:resource_group]) - - if vm.instance_of?(String) - vm - else - vm.storage_profile.data_disks.length > 0 - end - end - - def data_disk - - vm = @helpers.get_vm(@opts[:host], @opts[:resource_group]) - - dd = DataDisks.new(vm) - end - end diff --git a/libraries/azure_vm_datadisks.rb b/libraries/azure_vm_datadisks.rb index 972ce7c8e..14a8bba58 100644 --- a/libraries/azure_vm_datadisks.rb +++ b/libraries/azure_vm_datadisks.rb @@ -34,6 +34,8 @@ class AzureVmDataDisks < Inspec.resource(1) filter = FilterTable.create filter.add_accessor(:where) .add_accessor(:entries) + .add_accessor(:count) + .add_accessor(:has_disks?) .add(:disk, field: 'disk') .add(:caching, field: 'caching') .add(:create_option, field: 'create_option') @@ -45,6 +47,14 @@ class AzureVmDataDisks < Inspec.resource(1) filter.connect(self, :params) + def count + entries.length + end + + def has_disks? + entries.length > 0 + end + private def parse_data_disks(data_disks) From 2e55c32ee00791d8c31d3dcdb877523d451a57ae Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Fri, 17 Feb 2017 17:18:00 +0000 Subject: [PATCH 026/206] Added travis configuration file Signed-off-by: Russell Seymour --- .travis.yml | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 000000000..8ed1883f6 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,9 @@ +sudo: false +language: ruby +cache: bundler + +rvm: + - 2.3.1 + +bundler_args: --without integration +script: bundle exec rake \ No newline at end of file From 93f5dcd072731dc39150035ce4dd0378518dfc0e Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Fri, 17 Feb 2017 17:25:30 +0000 Subject: [PATCH 027/206] Corrected rubocop offences Signed-off-by: Russell Seymour --- controls/example_vm_datadisks.rb | 2 +- libraries/azure_vm.rb | 4 +--- libraries/azure_vm_datadisks.rb | 14 ++++---------- libraries/common/helpers.rb | 5 +---- 4 files changed, 7 insertions(+), 18 deletions(-) diff --git a/controls/example_vm_datadisks.rb b/controls/example_vm_datadisks.rb index afb58a8cf..e742e35d6 100644 --- a/controls/example_vm_datadisks.rb +++ b/controls/example_vm_datadisks.rb @@ -13,7 +13,7 @@ control 'azure-vm-datadisks-1.0' do its('count') { should eq 1 } end - describe azure_vm_datadisks(host: hostname, resource_group: resource_group_name).where { disk == 0 and size > 10 } do + describe azure_vm_datadisks(host: hostname, resource_group: resource_group_name).where { disk.zero? and size > 10 } do its('entries') { should_not be_empty } end end diff --git a/libraries/azure_vm.rb b/libraries/azure_vm.rb index a53168d9e..30ba5b65a 100644 --- a/libraries/azure_vm.rb +++ b/libraries/azure_vm.rb @@ -17,7 +17,7 @@ class AzureVm < Inspec.resource(1) # Load the configuration file on initialisation def initialize(opts) @opts = opts - @helpers = Helpers.new() + @helpers = Helpers.new end def sku @@ -31,7 +31,6 @@ class AzureVm < Inspec.resource(1) end def publisher - vm = @helpers.get_vm(@opts[:host], @opts[:resource_group]) if vm.instance_of?(String) @@ -50,5 +49,4 @@ class AzureVm < Inspec.resource(1) vm.storage_profile.image_reference.offer end end - end diff --git a/libraries/azure_vm_datadisks.rb b/libraries/azure_vm_datadisks.rb index 14a8bba58..9727db65c 100644 --- a/libraries/azure_vm_datadisks.rb +++ b/libraries/azure_vm_datadisks.rb @@ -3,7 +3,6 @@ require_relative 'common/helpers' require 'uri' class AzureVmDataDisks < Inspec.resource(1) - name 'azure_vm_datadisks' desc " @@ -21,7 +20,7 @@ class AzureVmDataDisks < Inspec.resource(1) # Load the configuration on initialisation def initialize(opts) @opts = opts - @helpers = Helpers.new() + @helpers = Helpers.new # Get the VM that needs to be interrogated vm = @helpers.get_vm(@opts[:host], @opts[:resource_group]) @@ -52,21 +51,18 @@ class AzureVmDataDisks < Inspec.resource(1) end def has_disks? - entries.length > 0 + entries.!empty? end private def parse_data_disks(data_disks) - data_disks.each_with_index.map do |disk, index| parse_data_disk_item(disk, index) end.compact - end def parse_data_disk_item(disk, index) - # Parse the uri of the disk so that the storage account can be retrieved uri = URI.parse(disk.vhd.uri) @@ -78,9 +74,7 @@ class AzureVmDataDisks < Inspec.resource(1) 'lun' => disk.lun, 'name' => disk.name, 'uri' => disk.vhd.uri, - 'storage_account' => uri.host.split('.').first + 'storage_account' => uri.host.split('.').first, } - end - -end \ No newline at end of file +end diff --git a/libraries/common/helpers.rb b/libraries/common/helpers.rb index cb47fc171..0b44abc02 100644 --- a/libraries/common/helpers.rb +++ b/libraries/common/helpers.rb @@ -5,10 +5,8 @@ require 'azure_mgmt_compute' require_relative 'resource_groups' class Helpers - # Retrieve the named virtual machine from Azure def get_vm(name, rg_name) - # Azure connection azure = AzureConnection.new @@ -29,5 +27,4 @@ class Helpers e.error_message end end - -end \ No newline at end of file +end From 993847c9414dfa0d92efcbe915943462497104b9 Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Fri, 17 Feb 2017 17:39:10 +0000 Subject: [PATCH 028/206] Added environment variables to travis.yml Fixed `inspec check` violations Signed-off-by: Russell Seymour --- .travis.yml | 5 +++++ .travis_azure_creds | 4 ++++ inspec.yml | 4 +++- 3 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 .travis_azure_creds diff --git a/.travis.yml b/.travis.yml index 8ed1883f6..992c71178 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,5 +5,10 @@ cache: bundler rvm: - 2.3.1 +env: + - AZURE_VM_NAME="example-01" + - AZURE_RESOURCE_GROUP_NAME="MyResourceGroup" + - AZURE_CREDS_FILE=".travis_azure_creds" + bundler_args: --without integration script: bundle exec rake \ No newline at end of file diff --git a/.travis_azure_creds b/.travis_azure_creds new file mode 100644 index 000000000..cc41894f9 --- /dev/null +++ b/.travis_azure_creds @@ -0,0 +1,4 @@ +[1e0b427a-5634-494e-ae4f-ee558463ebbf] +client_id = "b02c53ce-f537-11e6-bc64-92361f002671" +client_secret = "myVerySecretPassword" +tenant_id = "b02c5e00-f537-11e6-bc64-92361f002671" \ No newline at end of file diff --git a/inspec.yml b/inspec.yml index de1a9725f..b317004fa 100644 --- a/inspec.yml +++ b/inspec.yml @@ -1,5 +1,7 @@ name: azure title: Azure Resource Pack -maintainer: russell@turtlesystems.co.uk +maintainer: russell.seymour@turtlesystems.co.uk summary: This resource pack provides compliance resources for Azure +copyright: russell.seymour@turtlesystems.co.uk +copyright_email: russell.seymour@turtlesystems.co.uk version: 0.1.0 \ No newline at end of file From 7c7d557c3a97c051a12e16379bfe24829c038e10 Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Fri, 17 Feb 2017 18:20:51 +0000 Subject: [PATCH 029/206] Update .travis.yml Changed layout so that all ENV vars are set in one build --- .travis.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.travis.yml b/.travis.yml index 992c71178..f51c94f66 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,9 +6,7 @@ rvm: - 2.3.1 env: - - AZURE_VM_NAME="example-01" - - AZURE_RESOURCE_GROUP_NAME="MyResourceGroup" - - AZURE_CREDS_FILE=".travis_azure_creds" + - AZURE_VM_NAME="example-01" AZURE_RESOURCE_GROUP_NAME="MyResourceGroup" AZURE_CREDS_FILE=".travis_azure_creds" bundler_args: --without integration -script: bundle exec rake \ No newline at end of file +script: bundle exec rake From 3913e0e6616df57f19d05eefad6537ad1a6b35f9 Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Fri, 17 Feb 2017 18:40:23 +0000 Subject: [PATCH 030/206] Modified tests to only run lint checks --- Rakefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Rakefile b/Rakefile index 2442dc72e..3c7410249 100644 --- a/Rakefile +++ b/Rakefile @@ -14,7 +14,7 @@ desc 'Run robocop linter' task lint: [:rubocop] # run tests -task default: [:lint, 'test:check'] +task default: [:lint] namespace :test do # run inspec check to verify that the profile is properly configured @@ -35,4 +35,4 @@ begin end rescue LoadError puts '>>>>> GitHub Changelog Generator not loaded, omitting tasks' -end \ No newline at end of file +end From b2ce9c3f35af644609e9098ede5da3355d921d9b Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Fri, 17 Feb 2017 18:42:35 +0000 Subject: [PATCH 031/206] Removing obsolete dummy credential file for Azure --- .travis_azure_creds | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 .travis_azure_creds diff --git a/.travis_azure_creds b/.travis_azure_creds deleted file mode 100644 index cc41894f9..000000000 --- a/.travis_azure_creds +++ /dev/null @@ -1,4 +0,0 @@ -[1e0b427a-5634-494e-ae4f-ee558463ebbf] -client_id = "b02c53ce-f537-11e6-bc64-92361f002671" -client_secret = "myVerySecretPassword" -tenant_id = "b02c5e00-f537-11e6-bc64-92361f002671" \ No newline at end of file From c8378d513ea9f73b711e2b60efd46cb0c2b7b2c0 Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Mon, 20 Feb 2017 09:52:28 +0000 Subject: [PATCH 032/206] Added more resources that can be tested Updated the example to show how to use them Refactored the way in which the VM is retrieved Signed-off-by: Russell Seymour --- controls/example_vm_image.rb | 8 ++ libraries/azure_vm.rb | 149 ++++++++++++++++++++++++++++++----- 2 files changed, 137 insertions(+), 20 deletions(-) diff --git a/controls/example_vm_image.rb b/controls/example_vm_image.rb index d4fc0baea..0352621ad 100644 --- a/controls/example_vm_image.rb +++ b/controls/example_vm_image.rb @@ -12,5 +12,13 @@ control 'azure-vm-1.0' do its('sku') { should eq '16.04-LTS' } its('publisher') { should eq 'Canonical' } its('offer') { should eq 'UbuntuServer' } + its('size') { should eq 'Standard_DS1_v2' } + its('location') { should eq 'westeurope' } + its('boot_diagnostics?') { should be true } + its('nic_count') { should eq 1 } + its('username') { should eq 'azure' } + its('password_authentication?') { should be false } + its('ssh_key_count') { should eq 1 } + its('os_type') { should eq 'Linux' } end end diff --git a/libraries/azure_vm.rb b/libraries/azure_vm.rb index 30ba5b65a..e5e5b7457 100644 --- a/libraries/azure_vm.rb +++ b/libraries/azure_vm.rb @@ -14,39 +14,148 @@ class AzureVm < Inspec.resource(1) end " + attr_accessor :vm + # Load the configuration file on initialisation def initialize(opts) - @opts = opts - @helpers = Helpers.new + opts = opts + helpers = Helpers.new + @vm = helpers.get_vm(opts[:host], opts[:resource_group]) + + # Ensure that the vm is an object + raise format('An error has occured: %s', vm) if vm.instance_of?(String) end + # Determine the SKU used to create the machine + # + # == Returns: + # String showing the sku, e.g. 16.04.0-LTS + # def sku - vm = @helpers.get_vm(@opts[:host], @opts[:resource_group]) - - if vm.instance_of?(String) - vm - else - vm.storage_profile.image_reference.sku - end + vm.storage_profile.image_reference.sku end + # Determine the publisher of the SKU + # + # == Returns: + # String of the publisher, e.g. Canonical + # def publisher - vm = @helpers.get_vm(@opts[:host], @opts[:resource_group]) - - if vm.instance_of?(String) - vm - else - vm.storage_profile.image_reference.publisher - end + vm.storage_profile.image_reference.publisher end + # Determine the offer from the publisher + # + # == Returns: + # String of the offer, e.g. UbuntuServer + # def offer - vm = @helpers.get_vm(@opts[:host], @opts[:resource_group]) + vm.storage_profile.image_reference.offer + end - if vm.instance_of?(String) - vm + # Determine the size of the machine + # + # == Returns: + # String showing the size of the machine, e.g. Standard_DS1_v2 + # + def size + vm.hardware_profile.vm_size + end + + # Determine the location of the vm + # + # == Returns: + # String representing the location of the machinem, e.g. westeurope + # + def location + vm.location + end + + # State if boot diagnostics is enabled + # + # == Returns: + # Boolean + # + def boot_diagnostics? + vm.diagnostics_profile.boot_diagnostics.enabled + end + + # Determine how many network cards are connected to the machine + # + # == Returns: + # Integer + # + def nic_count + vm.network_profile.network_interfaces.length + end + + # The admin username for the machine + # + # == Returns: + # String of the admin username when the machine was created, e.g. azure + # + def username + vm.os_profile.admin_username + end + + # The computername as seen by the operating system + # This might be different to the VM name as seen in Azure + # + # == Returns: + # String of the computername + # + def computername + vm.os_profile.computer_name + end + + # Alias for computername + # + # == Returns: + # String of the computername + # + def hostname + computername + end + + # Determine if password authentication is enabled + # For Windows this is always True. On Linux this will be determined + # + # == Returns: + # Boolean + # + def password_authentication? + + # if the vm has a linux configuration then interrogate that, otherwise return true + if !vm.os_profile.linux_configuration.nil? + !vm.os_profile.linux_configuration.disable_password_authentication else - vm.storage_profile.image_reference.offer + true end end + + # How many SSH keys have been added to the machine + # For Windows this will be 0, for Linux this will be determined + # + # == Returns: + # Integer + # + def ssh_key_count + + if !vm.os_profile.linux_configuration.nil? + vm.os_profile.linux_configuration.ssh.public_keys.length + else + 0 + end + + end + + # Determine the Operating system type using the os_disk object + # + # == Returns: + # String of the OS type, e.g. Windows or Linux + # + def os_type + vm.storage_profile.os_disk.os_type + end + end From d5a9a39a0032a12c0c6d562d473d770a8378b5d1 Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Mon, 20 Feb 2017 10:05:40 +0000 Subject: [PATCH 033/206] Updated README for the new resources Fixes #5 Signed-off-by: Russell Seymour --- README.md | 10 ++++++++++ libraries/azure_vm.rb | 4 ---- libraries/azure_vm_datadisks.rb | 10 +++++++++- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7edb340fb..4ee822d17 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,16 @@ end | azure_vm | publisher | Publisher that provided the image in the marketplace | | | offer | The offer of the image | | | sku | The SKU being used | +| | size | The size of the machine | +| | location | Where the machine has been deployed | +| | boot_diagnostics? | Whether boot diagnostics have been enabled or not | +| | nic_count | How many network cards are attached to the machine | +| | username | The admin username that has been assigned to the machine | +| | computername | Computer name of the machine in the operating system. This maybe different to the VM name as seen in Azure | +| | hostname | Alias for computername | +| | password_authentication? | If password authentication is enabled. For Windows machines this is always true | +| | ssh_key_count | How many SSH public keys have been added to the machine. For Windows this is always 0 | +| | os_type | Tyep type of operating system. Linux or Windows | - `azure_vm_datadisks` - Resource to read the data disks for a machine and check that they are of the correct size etc diff --git a/libraries/azure_vm.rb b/libraries/azure_vm.rb index e5e5b7457..a43e917ce 100644 --- a/libraries/azure_vm.rb +++ b/libraries/azure_vm.rb @@ -124,7 +124,6 @@ class AzureVm < Inspec.resource(1) # Boolean # def password_authentication? - # if the vm has a linux configuration then interrogate that, otherwise return true if !vm.os_profile.linux_configuration.nil? !vm.os_profile.linux_configuration.disable_password_authentication @@ -140,13 +139,11 @@ class AzureVm < Inspec.resource(1) # Integer # def ssh_key_count - if !vm.os_profile.linux_configuration.nil? vm.os_profile.linux_configuration.ssh.public_keys.length else 0 end - end # Determine the Operating system type using the os_disk object @@ -157,5 +154,4 @@ class AzureVm < Inspec.resource(1) def os_type vm.storage_profile.os_disk.os_type end - end diff --git a/libraries/azure_vm_datadisks.rb b/libraries/azure_vm_datadisks.rb index 9727db65c..1a78be845 100644 --- a/libraries/azure_vm_datadisks.rb +++ b/libraries/azure_vm_datadisks.rb @@ -29,7 +29,7 @@ class AzureVmDataDisks < Inspec.resource(1) @params = parse_data_disks(vm.storage_profile.data_disks) end - # Create a filter table for testing + # Create a FilterTable which can be used by controls to interogate the data disks filter = FilterTable.create filter.add_accessor(:where) .add_accessor(:entries) @@ -46,10 +46,18 @@ class AzureVmDataDisks < Inspec.resource(1) filter.connect(self, :params) + # Determine how many data disks have been applied to the machine + # + # == Returns: + # Integer def count entries.length end + # Determine if any data disks are attached to the machine + # + # == Returns: + # Boolean def has_disks? entries.!empty? end From 7a6cbff5798a15c1ec5c282c5ba45596acdff0ea Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Mon, 20 Feb 2017 13:51:41 +0000 Subject: [PATCH 034/206] Added support for checking the resource group resources Fixes #6 Signed-off-by: Russell Seymour --- README.md | 49 +++++++- controls/example_rg.rb | 22 ++++ controls/example_vm_datadisks.rb | 1 + libraries/azure_rg.rb | 177 ++++++++++++++++++++++++++++ libraries/azure_vm_datadisks.rb | 2 +- libraries/common/helpers.rb | 41 +++++-- libraries/common/resource_groups.rb | 21 +++- 7 files changed, 296 insertions(+), 17 deletions(-) create mode 100644 controls/example_rg.rb create mode 100644 libraries/azure_rg.rb diff --git a/README.md b/README.md index 4ee822d17..b8b0f99fc 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,47 @@ end ### Available Resources +- `azure_rg` - This resource reads information about the resources in the specified resource group + +| Resource Name | Resources | Description | +|---------------|-----------|-------------| +| azure_rg | location | Where the item has been deployed | +| | total | The total number of resources in the resource group | +| | nic_count | Helper method to return the number of Network Interface Cards (NIC) that exist | +| | vm_count | Helper method to return the number of Virtual Machines(VM) that exist | +| | vnet_count | Helper method to return the number of Virtual Networks (VNET) that exist | +| | nsg_count | Helper method to return the number of Network Security Groups (NSG) that exist | +| | sa_count | Helper method to return the number of Storage Accounts (SA) that exist | +| | public_ip_count | Helper method to return the number of Public IP Addresses that exist | +| | contains | Used to determine if a specific item exists | + +This resource also has a FilterTable which means that it is possible to check for items that do not yet have a helper method. + +#### Test for the number of storage accounts + +```ruby +control 'azure-1' do + impact 1.0 + title 'Checks that there is only one storage account in the resource group' + + describe azure_rg(name: 'MyResourceGroup').where { type == 'Microsoft.Storage/storageAccounts' }.entries do + its('count') { should eq 1 } + end +end +``` + +#### Ensure that a specific item exists + +```ruby +control 'azure-1' do + impact 1.0 + title 'Checks a resource with the name "example-VM" exists' + + describe azure_rg(name: 'MyResourceGroup').contains(parameter: 'name', value: 'example-VM') do + it { should be true } + end +end + - `azure_vm` - This resource reads information about a virtual machine in the specified resource group | Resource Name | Resources | Description | @@ -146,21 +187,19 @@ When data disks are retrieved from a machine they are given as an array. The `w **Note: This does not yet work with Managed Disks** -## Examples - -### Test for 1 disk with a size greater than 10gb +#### Test for 1 disk with a size greater than 10gb ```ruby control 'azure-1' do impact 1.0 title 'Checks that the machine has exactly one data disk and it is over 10gb in size' - describe azurevm_image(host: 'example-01', resource_group: 'MyResourceGroup') do + describe azure_vm(host: 'example-01', resource_group: 'MyResourceGroup') do its('has_disks?') { should be true } its('count') { should eq 1 } end - describe azurevm_image(host: 'example-01', resource_group: 'MyResourceGroup').where { disk == 0 and size > 10 } do + describe azure_vm_datadisks(host: 'example-01', resource_group: 'MyResourceGroup').where { disk == 0 and size > 10 } do its('entries') { should_not be_empty } end end diff --git a/controls/example_rg.rb b/controls/example_rg.rb new file mode 100644 index 000000000..57c4bd048 --- /dev/null +++ b/controls/example_rg.rb @@ -0,0 +1,22 @@ + +title 'Sample profile to test names resource group' + +control 'azure-rg-1.0' do + impact 1.0 + title 'Ensure that a resource group has the correct resources' + + resource_group_name = ENV['AZURE_RESOURCE_GROUP_NAME'] + + describe azure_rg(name: resource_group_name) do + its('total') { should be >= 7 } + its('vm_count') { should eq 1 } + end + + describe azure_rg(name: resource_group_name).where { type == 'Microsoft.Storage/storageAccounts' }.entries do + its('count') { should eq 1 } + end + + describe azure_rg(name: resource_group_name).contains(parameter: 'name', value: 'example-VM-ip') do + it { should be true } + end +end diff --git a/controls/example_vm_datadisks.rb b/controls/example_vm_datadisks.rb index e742e35d6..53e141efc 100644 --- a/controls/example_vm_datadisks.rb +++ b/controls/example_vm_datadisks.rb @@ -17,3 +17,4 @@ control 'azure-vm-datadisks-1.0' do its('entries') { should_not be_empty } end end + diff --git a/libraries/azure_rg.rb b/libraries/azure_rg.rb new file mode 100644 index 000000000..4b403d6ca --- /dev/null +++ b/libraries/azure_rg.rb @@ -0,0 +1,177 @@ + +require_relative 'common/helpers' + +class AzureRg < Inspec.resource(1) + + name 'azure_rg' + + desc " + This resource returns information about the specified resource group + " + + example " + describe azure_rg(name: 'ACME') do + its('nic_count') { should eq 4 } + its('vm_count) { should eq 2 } + end + " + + attr_reader :items, :rg, :counts + + # Resource constructor + # + def initialize(opts) + opts = opts + helpers = Helpers.new + + # Get the named resource group + @rg = helpers.get_resource_group(opts[:name]) + + # Retrieve the items within the resource group + rg_items = helpers.get_resources(opts[:name]) + + # Parse the resources + @items = parse_rg_resources(rg_items.value) + + end + + # Create a FilterTable so that items can be selected + filter = FilterTable.create + filter.add_accessor(:where) + .add_accessor(:entries) + .add_accessor(:count) + .add_accessor(:contains) + .add(:type, field: 'type') + .add(:name, field: 'name') + .add(:location, field: 'location') + + filter.connect(self, :items) + + # Determine the location of the resource group + # + # == Returns: + # String + # + def location + rg.location + end + + # Determime how many resources in total there are + # + # == Returns: + # Integer + # + def total + counts['total'] + end + + # Determine how many of a certain type there are + # + # == Returns: + # Integer + # + def count + entries.length + end + + # Allows tests to be performed on the resources + # For example it is possible to check that a resource of a certain name exists + # + # == Returns: + # Boolean + # + def contains(settings) + result = false + + entries.each do |entry| + if entry[settings[:parameter]] == settings[:value] + result = true + break + end + end + + result + end + + # Helper method to determine the number of NICs in the resource group + # + # == Returns: + # Integer + # + def nic_count + counts['Microsoft.Network/networkInterfaces'] + end + + # Helper method to determine the number of VMs in the resource group + # + # == Returns: + # Integer + # + def vm_count + counts['Microsoft.Compute/virtualMachines'] + end + + # Helper method to determine the number of NSGs in the resource group + # + # == Returns: + # Integer + # + def nsg_count + counts['Microsoft.Network/networkSecurityGroups'] + end + + # Helper method to determine the number of Virtual Networks in the resource group + # + # == Returns: + # Integer + # + def vnet_count + counts['Microsoft.Network/virtualNetworks'] + end + + # Helper method to determine the number of Storage Accounts in the resource group + # + # == Returns: + # Integer + # + def sa_count + counts['Microsoft.Storage/storageAccounts'] + end + + # Helper method to determine the number of Public IP Addresses in the resource group + # + # == Returns: + # Integer + # + def public_ip_count + counts['Microsoft.Network/publicIPAddresses'] + end + + private + + def parse_rg_resources(resources) + # Declare the hashtable of counts + @counts = { + 'total' => 0, + } + + resources.each.map do |resource| + parse_item(resource) + end.compact + end + + def parse_item(item) + # Increment the count total + counts['total'] += 1 + + # Update the count for the resource type in the count table + counts.key?(item.type) ? counts[item.type] +=1 : counts[item.type] = 1 + + { + 'location' => item.location, + 'name' => item.name, + 'type' => item.type, + 'new' => 'me', + } + end +end diff --git a/libraries/azure_vm_datadisks.rb b/libraries/azure_vm_datadisks.rb index 1a78be845..5e3f5bf4c 100644 --- a/libraries/azure_vm_datadisks.rb +++ b/libraries/azure_vm_datadisks.rb @@ -59,7 +59,7 @@ class AzureVmDataDisks < Inspec.resource(1) # == Returns: # Boolean def has_disks? - entries.!empty? + !entries.empty? end private diff --git a/libraries/common/helpers.rb b/libraries/common/helpers.rb index 0b44abc02..2ac93cce7 100644 --- a/libraries/common/helpers.rb +++ b/libraries/common/helpers.rb @@ -5,19 +5,42 @@ require 'azure_mgmt_compute' require_relative 'resource_groups' class Helpers - # Retrieve the named virtual machine from Azure - def get_vm(name, rg_name) - # Azure connection - azure = AzureConnection.new - client = Azure::ARM::Compute::ComputeManagementClient.new(azure.connection) + attr_reader :azure, :client, :resource_group + + def initialize + # Azure connection + @azure = AzureConnection.new + + @client = Azure::ARM::Compute::ComputeManagementClient.new(azure.connection) client.subscription_id = azure.subscription_id - # Ensure that the resource group exists - rg = ResourceGroups.new(azure) + @resource_group = ResourceGroups.new(azure) + end - unless rg.exists(rg_name) - throw "The Resource group cannot be found: #{rg_name}" + # Retrive the specified resource group + # + # == Returns: + # Object representing the resource group + # + def get_resource_group(rg_name) + resource_group.get(rg_name) + end + + def get_resources(rg_name) + resource_group.get_resources(rg_name) + end + + # Retrieve the named virtual machine from Azure + # + # == Returns: + # Object representing the VM in Azure + # + def get_vm(name, rg_name) + + # Ensure that the resource group exists + unless resource_group.exists(rg_name) + raise "The Resource group cannot be found: #{rg_name}" end # get a vm from the named resource group diff --git a/libraries/common/resource_groups.rb b/libraries/common/resource_groups.rb index deabe6809..73efbb788 100644 --- a/libraries/common/resource_groups.rb +++ b/libraries/common/resource_groups.rb @@ -2,12 +2,29 @@ require 'azure_mgmt_resources' class ResourceGroups + + attr_reader :client + def initialize(azure) @client = Azure::ARM::Resources::ResourceManagementClient.new(azure.connection) - @client.subscription_id = azure.subscription_id + client.subscription_id = azure.subscription_id end def exists(name) - @client.resource_groups.check_existence(name) + client.resource_groups.check_existence(name) + end + + def get(name) + + if exists(name) + client.resource_groups.get(name) + end + end + + def get_resources(name) + + if exists(name) + client.resource_groups.list_resources_as_lazy(name) + end end end From 9c9bb27540ace44629a9949597e5a3e3654f00af Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Mon, 20 Feb 2017 13:57:00 +0000 Subject: [PATCH 035/206] Checkstyle Fixes Signed-off-by: Russell Seymour --- controls/example_vm_datadisks.rb | 1 - libraries/azure_rg.rb | 2 -- libraries/common/helpers.rb | 2 -- libraries/common/resource_groups.rb | 11 ++--------- 4 files changed, 2 insertions(+), 14 deletions(-) diff --git a/controls/example_vm_datadisks.rb b/controls/example_vm_datadisks.rb index 53e141efc..e742e35d6 100644 --- a/controls/example_vm_datadisks.rb +++ b/controls/example_vm_datadisks.rb @@ -17,4 +17,3 @@ control 'azure-vm-datadisks-1.0' do its('entries') { should_not be_empty } end end - diff --git a/libraries/azure_rg.rb b/libraries/azure_rg.rb index 4b403d6ca..c68c44104 100644 --- a/libraries/azure_rg.rb +++ b/libraries/azure_rg.rb @@ -2,7 +2,6 @@ require_relative 'common/helpers' class AzureRg < Inspec.resource(1) - name 'azure_rg' desc " @@ -32,7 +31,6 @@ class AzureRg < Inspec.resource(1) # Parse the resources @items = parse_rg_resources(rg_items.value) - end # Create a FilterTable so that items can be selected diff --git a/libraries/common/helpers.rb b/libraries/common/helpers.rb index 2ac93cce7..5b0cb89f8 100644 --- a/libraries/common/helpers.rb +++ b/libraries/common/helpers.rb @@ -5,7 +5,6 @@ require 'azure_mgmt_compute' require_relative 'resource_groups' class Helpers - attr_reader :azure, :client, :resource_group def initialize @@ -37,7 +36,6 @@ class Helpers # Object representing the VM in Azure # def get_vm(name, rg_name) - # Ensure that the resource group exists unless resource_group.exists(rg_name) raise "The Resource group cannot be found: #{rg_name}" diff --git a/libraries/common/resource_groups.rb b/libraries/common/resource_groups.rb index 73efbb788..dcaeceb0b 100644 --- a/libraries/common/resource_groups.rb +++ b/libraries/common/resource_groups.rb @@ -2,7 +2,6 @@ require 'azure_mgmt_resources' class ResourceGroups - attr_reader :client def initialize(azure) @@ -15,16 +14,10 @@ class ResourceGroups end def get(name) - - if exists(name) - client.resource_groups.get(name) - end + client.resource_groups.get(name) if exists(name) end def get_resources(name) - - if exists(name) - client.resource_groups.list_resources_as_lazy(name) - end + client.resource_groups.list_resources_as_lazy(name) if exists(name) end end From f18c22fca5c3864f9f1d996a01c0ad49f9a73fb5 Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Mon, 20 Feb 2017 14:09:15 +0000 Subject: [PATCH 036/206] Added CHANGELOG.md Signed-off-by: Russell Seymour --- CHANGELOG.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..c5cc29695 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# Change Log + +## [0.3.0](https://github.com/chef/inspec-azure/tree/0.3.0) (2017-02-20) +**Closed issues:** + +- Add resource to check the status of a Resource Group [\#6](https://github.com/chef/inspec-azure/issues/6) +- Add resources for checking the VM [\#5](https://github.com/chef/inspec-azure/issues/5) +- Cannot determine the return for a filter [\#3](https://github.com/chef/inspec-azure/issues/3) +- Add resource to check for presence and size of data disk [\#1](https://github.com/chef/inspec-azure/issues/1) + +**Merged pull requests:** + +- Added support for checking Resource Group resources [\#8](https://github.com/chef/inspec-azure/pull/8) ([russellseymour](https://github.com/russellseymour)) +- Added more VM resource controls [\#7](https://github.com/chef/inspec-azure/pull/7) ([russellseymour](https://github.com/russellseymour)) +- Testing Machine data disks [\#4](https://github.com/chef/inspec-azure/pull/4) ([russellseymour](https://github.com/russellseymour)) + + + +\* *This Change Log was automatically generated by [github_changelog_generator](https://github.com/skywinder/Github-Changelog-Generator)* \ No newline at end of file From 24e6153c4e019b563426b90460bd807c80594834 Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Mon, 20 Feb 2017 14:10:53 +0000 Subject: [PATCH 037/206] Corrected syntax in README Signed-off-by: Russell Seymour --- .kitchen/logs/kitchen.log | 30 ++++++++++++++++++++++++++++++ README.md | 1 + controls/file.rb | 1 + 3 files changed, 32 insertions(+) create mode 100644 .kitchen/logs/kitchen.log create mode 100644 controls/file.rb diff --git a/.kitchen/logs/kitchen.log b/.kitchen/logs/kitchen.log new file mode 100644 index 000000000..40ebeae05 --- /dev/null +++ b/.kitchen/logs/kitchen.log @@ -0,0 +1,30 @@ +I, [2017-02-13T14:00:00.031876 #36591] INFO -- Kitchen: -----> Starting Kitchen (v1.15.0) +E, [2017-02-13T14:00:00.032139 #36591] ERROR -- Kitchen: ------Exception------- +E, [2017-02-13T14:00:00.032160 #36591] ERROR -- Kitchen: Class: Kitchen::UserError +E, [2017-02-13T14:00:00.032172 #36591] ERROR -- Kitchen: Message: Kitchen YAML file /Users/russells/workspaces/inspec/inspec-azure/.kitchen.yml does not exist. +E, [2017-02-13T14:00:00.032182 #36591] ERROR -- Kitchen: ---------------------- +E, [2017-02-13T14:00:00.032193 #36591] ERROR -- Kitchen: ------Backtrace------- +E, [2017-02-13T14:00:00.032202 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/test-kitchen-1.15.0/lib/kitchen/loader/yaml.rb:71:in `read' +E, [2017-02-13T14:00:00.032212 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/test-kitchen-1.15.0/lib/kitchen/config.rb:152:in `data' +E, [2017-02-13T14:00:00.032221 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/test-kitchen-1.15.0/lib/kitchen/config.rb:131:in `suites' +E, [2017-02-13T14:00:00.032231 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/test-kitchen-1.15.0/lib/kitchen/config.rb:182:in `filter_instances' +E, [2017-02-13T14:00:00.032241 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/test-kitchen-1.15.0/lib/kitchen/config.rb:141:in `build_instances' +E, [2017-02-13T14:00:00.032251 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/test-kitchen-1.15.0/lib/kitchen/config.rb:117:in `instances' +E, [2017-02-13T14:00:00.032261 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/test-kitchen-1.15.0/lib/kitchen/command.rb:112:in `filtered_instances' +E, [2017-02-13T14:00:00.032270 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/test-kitchen-1.15.0/lib/kitchen/command.rb:142:in `parse_subcommand' +E, [2017-02-13T14:00:00.032280 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/test-kitchen-1.15.0/lib/kitchen/command/test.rb:40:in `block in call' +E, [2017-02-13T14:00:00.032290 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/2.3.0/benchmark.rb:293:in `measure' +E, [2017-02-13T14:00:00.032299 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/test-kitchen-1.15.0/lib/kitchen/command/test.rb:38:in `call' +E, [2017-02-13T14:00:00.032309 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/test-kitchen-1.15.0/lib/kitchen/cli.rb:53:in `perform' +E, [2017-02-13T14:00:00.032319 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/test-kitchen-1.15.0/lib/kitchen/cli.rb:234:in `test' +E, [2017-02-13T14:00:00.032328 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/thor-0.19.1/lib/thor/command.rb:27:in `run' +E, [2017-02-13T14:00:00.032338 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/thor-0.19.1/lib/thor/invocation.rb:126:in `invoke_command' +E, [2017-02-13T14:00:00.032348 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/test-kitchen-1.15.0/lib/kitchen/cli.rb:334:in `invoke_task' +E, [2017-02-13T14:00:00.032358 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/thor-0.19.1/lib/thor.rb:359:in `dispatch' +E, [2017-02-13T14:00:00.032367 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/thor-0.19.1/lib/thor/base.rb:440:in `start' +E, [2017-02-13T14:00:00.032621 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/test-kitchen-1.15.0/bin/kitchen:13:in `block in ' +E, [2017-02-13T14:00:00.032634 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/test-kitchen-1.15.0/lib/kitchen/errors.rb:171:in `with_friendly_errors' +E, [2017-02-13T14:00:00.032644 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/test-kitchen-1.15.0/bin/kitchen:13:in `' +E, [2017-02-13T14:00:00.032653 #36591] ERROR -- Kitchen: /opt/chefdk/bin/kitchen:21:in `load' +E, [2017-02-13T14:00:00.032662 #36591] ERROR -- Kitchen: /opt/chefdk/bin/kitchen:21:in `
' +E, [2017-02-13T14:00:00.032680 #36591] ERROR -- Kitchen: ----End Backtrace----- diff --git a/README.md b/README.md index b8b0f99fc..c55ae3161 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,7 @@ control 'azure-1' do it { should be true } end end +``` - `azure_vm` - This resource reads information about a virtual machine in the specified resource group diff --git a/controls/file.rb b/controls/file.rb new file mode 100644 index 000000000..bc2d5a025 --- /dev/null +++ b/controls/file.rb @@ -0,0 +1 @@ +#binding.pry From c658924b050042cacab6593c871ac8c5ea2e5d61 Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Mon, 20 Feb 2017 17:02:26 +0000 Subject: [PATCH 038/206] Removed need for multiple Azure class helpers Updated the RubyDoc on Classes and Methods Fixes #9 Signed-off-by: Russell Seymour --- libraries/common/compute_management.rb | 21 ++++++++++ libraries/common/helpers.rb | 50 ++++++++++++----------- libraries/common/network_management.rb | 20 ++++++++++ libraries/common/resource_groups.rb | 23 ----------- libraries/common/resource_management.rb | 53 +++++++++++++++++++++++++ 5 files changed, 121 insertions(+), 46 deletions(-) create mode 100644 libraries/common/compute_management.rb create mode 100644 libraries/common/network_management.rb delete mode 100644 libraries/common/resource_groups.rb create mode 100644 libraries/common/resource_management.rb diff --git a/libraries/common/compute_management.rb b/libraries/common/compute_management.rb new file mode 100644 index 000000000..b13ab1ce5 --- /dev/null +++ b/libraries/common/compute_management.rb @@ -0,0 +1,21 @@ + +require 'azure_mgmt_compute' + +# Class to return a ComputeManagement client to get information about VMs +# +# @author Russell Seymour +# +# @attr_reader [Azure::ARM::Compute::ComputeManagementClient] client ComputeManagement client object +class ComputeManagement + attr_reader :client + + # Constructor for the class. Creates the new Network Management client object + # + # @author Russell Seymour + # + # @param [MsRest::TokenCredentials] azure Connection object for Azure + def initialize(azure) + @client = Azure::ARM::Compute::ComputeManagementClient.new(azure.connection) + client.subscription_id = azure.subscription_id + end +end diff --git a/libraries/common/helpers.rb b/libraries/common/helpers.rb index 5b0cb89f8..3a1a3fa4f 100644 --- a/libraries/common/helpers.rb +++ b/libraries/common/helpers.rb @@ -1,49 +1,53 @@ require_relative '../azure_conn' -require 'azure_mgmt_compute' -require_relative 'resource_groups' +require_relative 'resource_management' +require_relative 'compute_management' +require_relative 'network_management' +# Helper class to configure and give access to the various management components of Azure +# Also provides shortcuts for certain components, such as returing the VM object and performing +# all the checks that need to be done before retrieving the VM +# +# @author Russell Seymour +# @attr_reader [MsRest::TokenCredentials] azure Azure connection credentials +# @attr_reader [ComputeManagement] compute_mgmt Compute object for retrieving details about VMs +# @attr_reader [ResourceManagement] resource_mgmt Resource object for accessing specific resources and resoure groups +# @attr_reader [NetworkManagement] network_mgmt Network object for retrieving all information about Network cards and IP configurations class Helpers - attr_reader :azure, :client, :resource_group + attr_reader :azure, :compute_mgmt, :resource_mgmt, :network_mgmt + # Constructor to configure the various objects that are required for Inspec testing + # + # @author Russell Seymour def initialize # Azure connection @azure = AzureConnection.new - @client = Azure::ARM::Compute::ComputeManagementClient.new(azure.connection) - client.subscription_id = azure.subscription_id + # Create the necessary clients + @compute_mgmt = ComputeManagement.new(azure) + @resource_mgmt = ResourceManagement.new(azure) + @network_mgmt = NetworkManagement.new(azure) - @resource_group = ResourceGroups.new(azure) - end - - # Retrive the specified resource group - # - # == Returns: - # Object representing the resource group - # - def get_resource_group(rg_name) - resource_group.get(rg_name) - end - - def get_resources(rg_name) - resource_group.get_resources(rg_name) end # Retrieve the named virtual machine from Azure # - # == Returns: - # Object representing the VM in Azure + # This is specified here as it combines two different resource types, Compute and Resource Groups + # + # @author Russell Seymour + # + # @return [] VM object # def get_vm(name, rg_name) # Ensure that the resource group exists - unless resource_group.exists(rg_name) + unless resource_mgmt.client.resource_groups.check_existence(rg_name) raise "The Resource group cannot be found: #{rg_name}" end # get a vm from the named resource group begin - client.virtual_machines.get(rg_name, name) + compute_mgmt.client.virtual_machines.get(rg_name, name) rescue => e e.error_message end diff --git a/libraries/common/network_management.rb b/libraries/common/network_management.rb new file mode 100644 index 000000000..a12fed784 --- /dev/null +++ b/libraries/common/network_management.rb @@ -0,0 +1,20 @@ + +require 'azure_mgmt_network' + +# Class to return a NetworkManagement client for use with NICs and Public IP Addresses +# +# @author Russell Seymour +# @attr_reader [Azure::ARM::Network::NetworkManagementClient] client Azure Network Management cient +class NetworkManagement + attr_reader :client + + # Constructor for the class. Creates the new Network Management client object + # + # @author Russell Seymour + # + # @param [MsRest::TokenCredentials] azure Connection object for Azure + def initialize(azure) + @client = Azure::ARM::Network::NetworkManagementClient.new(azure.connection) + client.subscription_id = azure.subscription_id + end +end diff --git a/libraries/common/resource_groups.rb b/libraries/common/resource_groups.rb deleted file mode 100644 index dcaeceb0b..000000000 --- a/libraries/common/resource_groups.rb +++ /dev/null @@ -1,23 +0,0 @@ - -require 'azure_mgmt_resources' - -class ResourceGroups - attr_reader :client - - def initialize(azure) - @client = Azure::ARM::Resources::ResourceManagementClient.new(azure.connection) - client.subscription_id = azure.subscription_id - end - - def exists(name) - client.resource_groups.check_existence(name) - end - - def get(name) - client.resource_groups.get(name) if exists(name) - end - - def get_resources(name) - client.resource_groups.list_resources_as_lazy(name) if exists(name) - end -end diff --git a/libraries/common/resource_management.rb b/libraries/common/resource_management.rb new file mode 100644 index 000000000..247e1b7d3 --- /dev/null +++ b/libraries/common/resource_management.rb @@ -0,0 +1,53 @@ + +require 'azure_mgmt_resources' + +# Class to return a NetworkManagement client for use with NICs and Public IP Addresses +# +# @author Russell Seymour +# @attr_reader [Azure::ARM::Network::NetworkManagementClient] client Azure Network Management cient +class ResourceManagement + attr_reader :client + + # Constructor for the class. Creates the new Network Management client object + # + # @author Russell Seymour + # + # @param [MsRest::TokenCredentials] azure Connection object for Azure + def initialize(azure) + @client = Azure::ARM::Resources::ResourceManagementClient.new(azure.connection) + client.subscription_id = azure.subscription_id + end + + # Determine if the specified resource group exists in the subscription_id + # + # @author Russell Seymour + # + # @param [String] name Name of the resource group + # + # @return [Boolean] Whether the resource group exists or not + def exists(name) + client.resource_groups.check_existence(name) + end + + # Retrieve the named resource group if it exists + # + # @author Russell Seymour + # + # @param [String] name Name of the resource group + # + # @return [Azure::ARM::Resources::Models::ResourceGroup] Object containing information about the resource group + def get_resource_group(name) + client.resource_groups.get(name) if exists(name) + end + + # Get all of the resources that are contained within the resource group if it exists + # + # @author Russell Seymour + # + # @param [String] name Name of the resource group + # + # @return [Azure::ARM::Resources::Models::ResourceListResult] Object containing array of all the resources + def get_resources(name) + client.resource_groups.list_resources_as_lazy(name) if exists(name) + end +end From b3fc4c07cd3c173e99cc790bcdf4071f95ac876b Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Mon, 20 Feb 2017 17:15:40 +0000 Subject: [PATCH 039/206] Added azure_mgmt_network to Gemfile Updated RubyDoc annotation on classes and methods Signed-off-by: Russell Seymour --- Gemfile | 1 + libraries/azure_conn.rb | 13 ++++++- libraries/azure_rg.rb | 61 ++++++++++++++++++++------------- libraries/azure_vm.rb | 52 ++++++++++++++-------------- libraries/azure_vm_datadisks.rb | 31 ++++++++++++++--- libraries/common/helpers.rb | 1 - 6 files changed, 101 insertions(+), 58 deletions(-) diff --git a/Gemfile b/Gemfile index 32ef313a9..a5ad434b9 100644 --- a/Gemfile +++ b/Gemfile @@ -3,6 +3,7 @@ source 'https://rubygems.org' gem 'inifile' gem 'azure_mgmt_compute' gem 'azure_mgmt_resources' +gem 'azure_mgmt_network' gem 'bundle' group :development do diff --git a/libraries/azure_conn.rb b/libraries/azure_conn.rb index 9ecb1d92a..13882d70a 100644 --- a/libraries/azure_conn.rb +++ b/libraries/azure_conn.rb @@ -1,9 +1,17 @@ require 'ms_rest_azure' require 'inifile' +# Class to manage the connection to Azure to retrieve the information required about the resources +# +# @author Russell Seymour +# +# @attr_reader [String] subscription_id ID of the subscription in which resources are to be tested class AzureConnection - attr_accessor :subscription_id + attr_reader :subscription_id + # Constructor which reads in the credentials file + # + # @author Russell Seymour def initialize # If an INSPEC_AZURE_CREDS environment has been specified set the # the credentials file to that, otherwise set the one in home @@ -22,6 +30,9 @@ class AzureConnection end end + # Connect to Azure using the specified credentials + # + # @author Russell Seymour def connection # If a connection already exists then return it return @conn if defined?(@conn) diff --git a/libraries/azure_rg.rb b/libraries/azure_rg.rb index c68c44104..55677ffa0 100644 --- a/libraries/azure_rg.rb +++ b/libraries/azure_rg.rb @@ -1,6 +1,13 @@ require_relative 'common/helpers' +# Class to test the resources in Resource Groups +# +# @author Russell Seymour +# +# @attr_reader [Hashtable] items List of items in the resource group +# @attr_reader [Azure::ARM::Resources::Models::ResourceGroup] rg Resource group under interrogation +# @attr_reader [Hashtable] counts Hashtable containing the counts of the different types in the resource group class AzureRg < Inspec.resource(1) name 'azure_rg' @@ -17,17 +24,21 @@ class AzureRg < Inspec.resource(1) attr_reader :items, :rg, :counts - # Resource constructor + # Constructor which retrieves the named resource group and parses all of its items # + # @author Russell Seymour + # + # @param [Hash] opts Hashtable of options + # opts[:name] The name of the resource group def initialize(opts) opts = opts helpers = Helpers.new # Get the named resource group - @rg = helpers.get_resource_group(opts[:name]) + @rg = helpers.resource_mgmt.get_resource_group(opts[:name]) # Retrieve the items within the resource group - rg_items = helpers.get_resources(opts[:name]) + rg_items = helpers.resource_mgmt.get_resources(opts[:name]) # Parse the resources @items = parse_rg_resources(rg_items.value) @@ -47,8 +58,7 @@ class AzureRg < Inspec.resource(1) # Determine the location of the resource group # - # == Returns: - # String + # @return [String Location of the resource group # def location rg.location @@ -56,8 +66,7 @@ class AzureRg < Inspec.resource(1) # Determime how many resources in total there are # - # == Returns: - # Integer + # @return [Integer] Total number of items in the resource group # def total counts['total'] @@ -65,8 +74,7 @@ class AzureRg < Inspec.resource(1) # Determine how many of a certain type there are # - # == Returns: - # Integer + # @return [Integer] Number of specific items in the FilterTable # def count entries.length @@ -75,8 +83,11 @@ class AzureRg < Inspec.resource(1) # Allows tests to be performed on the resources # For example it is possible to check that a resource of a certain name exists # - # == Returns: - # Boolean + # @param [Hashtable] settings Hashtable of settings which will be used to perform the filter + # settings[:parameter] Name of the parameter being interrogated [name, type, location] + # settings[:value] The expected value of the specified paramater + # + # @return [Boolean] Whether or not the specified item exists in the resources # def contains(settings) result = false @@ -93,8 +104,7 @@ class AzureRg < Inspec.resource(1) # Helper method to determine the number of NICs in the resource group # - # == Returns: - # Integer + # @return [Integer] Number of NICs in the resource group # def nic_count counts['Microsoft.Network/networkInterfaces'] @@ -102,8 +112,7 @@ class AzureRg < Inspec.resource(1) # Helper method to determine the number of VMs in the resource group # - # == Returns: - # Integer + # @return [Integer] Number of VMs in the resource group # def vm_count counts['Microsoft.Compute/virtualMachines'] @@ -111,8 +120,7 @@ class AzureRg < Inspec.resource(1) # Helper method to determine the number of NSGs in the resource group # - # == Returns: - # Integer + # @return [Integer] Number of NSGs in the resource group # def nsg_count counts['Microsoft.Network/networkSecurityGroups'] @@ -120,8 +128,7 @@ class AzureRg < Inspec.resource(1) # Helper method to determine the number of Virtual Networks in the resource group # - # == Returns: - # Integer + # @return [Integer] Number of VNETs in the resource group # def vnet_count counts['Microsoft.Network/virtualNetworks'] @@ -129,8 +136,7 @@ class AzureRg < Inspec.resource(1) # Helper method to determine the number of Storage Accounts in the resource group # - # == Returns: - # Integer + # @return [Integer] Number of SAs in the resource group # def sa_count counts['Microsoft.Storage/storageAccounts'] @@ -138,8 +144,7 @@ class AzureRg < Inspec.resource(1) # Helper method to determine the number of Public IP Addresses in the resource group # - # == Returns: - # Integer + # @return [Integer] Number of Public IP Addresses in the resource group # def public_ip_count counts['Microsoft.Network/publicIPAddresses'] @@ -147,6 +152,11 @@ class AzureRg < Inspec.resource(1) private + # Parse the Resource Group Resources + # + # @param [Array] resources Array of resources in the resource group + # + # @return [Array] Array of hashes providing the information about the resources for the FilterTable def parse_rg_resources(resources) # Declare the hashtable of counts @counts = { @@ -158,6 +168,10 @@ class AzureRg < Inspec.resource(1) end.compact end + # Parses each resource item and extracts the information to be tested + # + # @return [Hash] Resource information + # def parse_item(item) # Increment the count total counts['total'] += 1 @@ -169,7 +183,6 @@ class AzureRg < Inspec.resource(1) 'location' => item.location, 'name' => item.name, 'type' => item.type, - 'new' => 'me', } end end diff --git a/libraries/azure_vm.rb b/libraries/azure_vm.rb index a43e917ce..743ac4c1b 100644 --- a/libraries/azure_vm.rb +++ b/libraries/azure_vm.rb @@ -1,6 +1,11 @@ require_relative 'common/helpers' +# Class to retrieve information about the specified virtual machine +# +# @author Russell Seymour +# +# @attr_reader [Azure::ARM::Compute::Models::VirtualMachine] vm VM object as retrieved from Azure class AzureVm < Inspec.resource(1) name 'azure_vm' @@ -16,7 +21,13 @@ class AzureVm < Inspec.resource(1) attr_accessor :vm - # Load the configuration file on initialisation + # Constructor to retrieve the VM from Azure + # + # @author Russell Seymour + # + # @param [Hash] opts Hashtable of options + # opts[:host] The name of the host in the resource group. NOTE, this is the name as seen in Azure and not the name of the machine in the Operating System + # opts[:resource_group] Name of the resource group in which the host will be found def initialize(opts) opts = opts helpers = Helpers.new @@ -28,8 +39,7 @@ class AzureVm < Inspec.resource(1) # Determine the SKU used to create the machine # - # == Returns: - # String showing the sku, e.g. 16.04.0-LTS + # @return [String] Showing the sku, e.g. 16.04.0-LTS # def sku vm.storage_profile.image_reference.sku @@ -37,8 +47,7 @@ class AzureVm < Inspec.resource(1) # Determine the publisher of the SKU # - # == Returns: - # String of the publisher, e.g. Canonical + # @return [String] Publisher, e.g. Canonical # def publisher vm.storage_profile.image_reference.publisher @@ -46,8 +55,7 @@ class AzureVm < Inspec.resource(1) # Determine the offer from the publisher # - # == Returns: - # String of the offer, e.g. UbuntuServer + # @return [String] offer, e.g. UbuntuServer # def offer vm.storage_profile.image_reference.offer @@ -55,8 +63,7 @@ class AzureVm < Inspec.resource(1) # Determine the size of the machine # - # == Returns: - # String showing the size of the machine, e.g. Standard_DS1_v2 + # @return [String] Size of the machine, e.g. Standard_DS1_v2 # def size vm.hardware_profile.vm_size @@ -64,8 +71,7 @@ class AzureVm < Inspec.resource(1) # Determine the location of the vm # - # == Returns: - # String representing the location of the machinem, e.g. westeurope + # @return [String] location of the machinem, e.g. westeurope # def location vm.location @@ -73,8 +79,7 @@ class AzureVm < Inspec.resource(1) # State if boot diagnostics is enabled # - # == Returns: - # Boolean + # @return [Boolean] # def boot_diagnostics? vm.diagnostics_profile.boot_diagnostics.enabled @@ -82,8 +87,7 @@ class AzureVm < Inspec.resource(1) # Determine how many network cards are connected to the machine # - # == Returns: - # Integer + # @return [Integer] # def nic_count vm.network_profile.network_interfaces.length @@ -91,8 +95,7 @@ class AzureVm < Inspec.resource(1) # The admin username for the machine # - # == Returns: - # String of the admin username when the machine was created, e.g. azure + # @return [String] Admin username when the machine was created, e.g. azure # def username vm.os_profile.admin_username @@ -101,8 +104,7 @@ class AzureVm < Inspec.resource(1) # The computername as seen by the operating system # This might be different to the VM name as seen in Azure # - # == Returns: - # String of the computername + # @return [String] # def computername vm.os_profile.computer_name @@ -110,8 +112,7 @@ class AzureVm < Inspec.resource(1) # Alias for computername # - # == Returns: - # String of the computername + # @return [String] # def hostname computername @@ -120,8 +121,7 @@ class AzureVm < Inspec.resource(1) # Determine if password authentication is enabled # For Windows this is always True. On Linux this will be determined # - # == Returns: - # Boolean + # @return [Boolean] # def password_authentication? # if the vm has a linux configuration then interrogate that, otherwise return true @@ -135,8 +135,7 @@ class AzureVm < Inspec.resource(1) # How many SSH keys have been added to the machine # For Windows this will be 0, for Linux this will be determined # - # == Returns: - # Integer + # @return [Integer] # def ssh_key_count if !vm.os_profile.linux_configuration.nil? @@ -148,8 +147,7 @@ class AzureVm < Inspec.resource(1) # Determine the Operating system type using the os_disk object # - # == Returns: - # String of the OS type, e.g. Windows or Linux + # @return [String] OS type, e.g. Windows or Linux # def os_type vm.storage_profile.os_disk.os_type diff --git a/libraries/azure_vm_datadisks.rb b/libraries/azure_vm_datadisks.rb index 5e3f5bf4c..fc036608e 100644 --- a/libraries/azure_vm_datadisks.rb +++ b/libraries/azure_vm_datadisks.rb @@ -2,6 +2,11 @@ require_relative 'common/helpers' require 'uri' +# Class to test the data disks that are attached to the specified VM +# +# @author Russell Seymour +# +# @attr_reader [Array] params Array of hashes containing information about all the data disks attached to the machine class AzureVmDataDisks < Inspec.resource(1) name 'azure_vm_datadisks' @@ -17,7 +22,13 @@ class AzureVmDataDisks < Inspec.resource(1) attr_reader :params - # Load the configuration on initialisation + # Constructor to retrieve all the data disks for the specified machines and populate the params property + # + # @author Russell Seymour + # + # @param [Hash] opts Hashtable of options + # opts[:host] The name of the host in the resource group. NOTE, this is the name as seen in Azure and not the name of the machine in the Operating System + # opts[:resource_group] Name of the resource group in which the host will be found def initialize(opts) @opts = opts @helpers = Helpers.new @@ -48,28 +59,38 @@ class AzureVmDataDisks < Inspec.resource(1) # Determine how many data disks have been applied to the machine # - # == Returns: - # Integer + # @return [Integer] Number of data disks attached to the machine + # def count entries.length end # Determine if any data disks are attached to the machine # - # == Returns: - # Boolean + # @return [Boolean] States if the VM has any disks attached + # def has_disks? !entries.empty? end private + # Parse the data disks and extract the necssary information + # + # @param [Array] data_disks Array of data disk objects + # + # @return [Array] Array of hashes providing the information about the data disks attached to the machine + # def parse_data_disks(data_disks) data_disks.each_with_index.map do |disk, index| parse_data_disk_item(disk, index) end.compact end + # Parses each data disk item and extracts the information to be tested + # + # @return [Hash] Data disk information + # def parse_data_disk_item(disk, index) # Parse the uri of the disk so that the storage account can be retrieved uri = URI.parse(disk.vhd.uri) diff --git a/libraries/common/helpers.rb b/libraries/common/helpers.rb index 3a1a3fa4f..34813a742 100644 --- a/libraries/common/helpers.rb +++ b/libraries/common/helpers.rb @@ -28,7 +28,6 @@ class Helpers @compute_mgmt = ComputeManagement.new(azure) @resource_mgmt = ResourceManagement.new(azure) @network_mgmt = NetworkManagement.new(azure) - end # Retrieve the named virtual machine from Azure From bb83d3eadbd7d323eebd0b2fca6bde1b99fd0d1c Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Mon, 20 Feb 2017 17:18:14 +0000 Subject: [PATCH 040/206] Removed local debugging file Signed-off-by: Russell Seymour --- controls/file.rb | 1 - 1 file changed, 1 deletion(-) delete mode 100644 controls/file.rb diff --git a/controls/file.rb b/controls/file.rb deleted file mode 100644 index bc2d5a025..000000000 --- a/controls/file.rb +++ /dev/null @@ -1 +0,0 @@ -#binding.pry From 05377a846ebfb99cbc64d5792148bfe3e31b2563 Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Tue, 21 Feb 2017 09:16:31 +0000 Subject: [PATCH 041/206] Updated changelog Signed-off-by: Russell Seymour --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5cc29695..74a6a8c56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Change Log +## [0.3.1](https://github.com/chef/inspec-azure/tree/0.3.1) (2017-02-21) +[Full Changelog](https://github.com/chef/inspec-azure/compare/0.3.0...0.3.1) + +**Closed issues:** + +- Remove Azure resource class helpers [\#9](https://github.com/chef/inspec-azure/issues/9) + +**Merged pull requests:** + +- Reconfigured the way in which Helpers work [\#10](https://github.com/chef/inspec-azure/pull/10) ([russellseymour](https://github.com/russellseymour)) + ## [0.3.0](https://github.com/chef/inspec-azure/tree/0.3.0) (2017-02-20) **Closed issues:** From 040d5cbffcffddf46680f3639a43c5d4b817d843 Mon Sep 17 00:00:00 2001 From: Chris Redekop Date: Tue, 21 Feb 2017 19:26:07 +0000 Subject: [PATCH 042/206] Rename integration tasks Signed-off-by: Chris Redekop --- Rakefile | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/Rakefile b/Rakefile index dd0572c26..f113699ba 100644 --- a/Rakefile +++ b/Rakefile @@ -31,21 +31,33 @@ namespace :test do sh("bundle exec inspec check #{dir}") end - task :integration_no_cleanup do + task :setup_integration_tests do integration_dir = "test/integration" - puts "----> Build" + puts "----> Setup" sh("cd #{integration_dir}/build/ && terraform plan") sh("cd #{integration_dir}/build/ && terraform apply") + end - puts "----> Verify" + task :run_integration_tests do + integration_dir = "test/integration" + + puts "----> Run" sh("bundle exec inspec exec #{integration_dir}/verify") end + task :cleanup_integration_tests do + integration_dir = "test/integration" + + puts "----> Cleanup" + sh("cd #{integration_dir}/build/ && terraform destroy -force") + end + task :integration do - Rake::Task["test:cleanup"].execute - Rake::Task["test:integration_no_cleanup"].execute - Rake::Task["test:cleanup"].execute + Rake::Task["test:cleanup_integration_tests"].execute + Rake::Task["test:setup_integration_tests"].execute + Rake::Task["test:run_integration_tests"].execute + Rake::Task["test:cleanup_integration_tests"].execute end task :cleanup do From ee6fbe6ac107ec5c232e64adf24f44f893624f4d Mon Sep 17 00:00:00 2001 From: Chris Redekop Date: Wed, 22 Feb 2017 14:16:58 +0000 Subject: [PATCH 043/206] Update README to match Rakefile Signed-off-by: Chris Redekop --- README.md | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 8af455b54..9d9c7bfcb 100644 --- a/README.md +++ b/README.md @@ -104,17 +104,11 @@ To run the integration tests, please make sure all required environment variable bundle exec rake test:integration ``` -Instances are destroyed automatically. To persist instances please use +This task sets up test AWS resources, runs the integration tests, and then cleans up the resources. To perform these tasks independently, please call them individually: -``` -bundle exec rake test:integration_no_cleanup -``` - -To cleanup instances, please use - -``` -bundle exec rake test:cleanup -``` +* `bundle exec rake test:setup_integration_tests` +* `bundle exec rake test:run_integration_tests` +* `bundle exec rake test:cleanup_integration_tests` ## Kudos From d781cdbf6361b7e7cf75de2ba20dd3da7fb11eca Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Wed, 22 Feb 2017 15:55:05 +0000 Subject: [PATCH 044/206] Updated how internal librares are located Closes #11 Signed-off-by: Russell Seymour --- libraries/azure_rg.rb | 2 +- libraries/azure_vm.rb | 2 +- libraries/azure_vm_datadisks.rb | 2 +- libraries/common/helpers.rb | 8 ++++---- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/libraries/azure_rg.rb b/libraries/azure_rg.rb index 55677ffa0..48b734642 100644 --- a/libraries/azure_rg.rb +++ b/libraries/azure_rg.rb @@ -1,5 +1,5 @@ -require_relative 'common/helpers' +require File.expand_path(File.dirname(__FILE__) + '/common/helpers') # Class to test the resources in Resource Groups # diff --git a/libraries/azure_vm.rb b/libraries/azure_vm.rb index 743ac4c1b..8ba2377af 100644 --- a/libraries/azure_vm.rb +++ b/libraries/azure_vm.rb @@ -1,5 +1,5 @@ -require_relative 'common/helpers' +require File.expand_path(File.dirname(__FILE__) + '/common/helpers') # Class to retrieve information about the specified virtual machine # diff --git a/libraries/azure_vm_datadisks.rb b/libraries/azure_vm_datadisks.rb index fc036608e..bf96db300 100644 --- a/libraries/azure_vm_datadisks.rb +++ b/libraries/azure_vm_datadisks.rb @@ -1,5 +1,5 @@ -require_relative 'common/helpers' +require File.expand_path(File.dirname(__FILE__) + '/common/helpers') require 'uri' # Class to test the data disks that are attached to the specified VM diff --git a/libraries/common/helpers.rb b/libraries/common/helpers.rb index 34813a742..3655e5846 100644 --- a/libraries/common/helpers.rb +++ b/libraries/common/helpers.rb @@ -1,9 +1,9 @@ -require_relative '../azure_conn' +require File.expand_path(File.dirname(__FILE__) + '/../azure_conn') -require_relative 'resource_management' -require_relative 'compute_management' -require_relative 'network_management' +require File.expand_path(File.dirname(__FILE__) + '/resource_management') +require File.expand_path(File.dirname(__FILE__) + '/compute_management') +require File.expand_path(File.dirname(__FILE__) + '/network_management') # Helper class to configure and give access to the various management components of Azure # Also provides shortcuts for certain components, such as returing the VM object and performing From 983900a135944e15a52895deaacfa29baa3f35e7 Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Thu, 23 Feb 2017 12:46:15 +0000 Subject: [PATCH 045/206] Moved all libraries into one file #11 Signed-off-by: Russell Seymour --- lib/resources/azure/azure_backend.rb | 195 ++++++++++++++++++++++++ libraries/azure_conn.rb | 56 ------- libraries/azure_rg.rb | 2 +- libraries/azure_vm.rb | 2 +- libraries/azure_vm_datadisks.rb | 2 +- libraries/common/compute_management.rb | 21 --- libraries/common/helpers.rb | 54 ------- libraries/common/network_management.rb | 20 --- libraries/common/resource_management.rb | 53 ------- 9 files changed, 198 insertions(+), 207 deletions(-) create mode 100644 lib/resources/azure/azure_backend.rb delete mode 100644 libraries/azure_conn.rb delete mode 100644 libraries/common/compute_management.rb delete mode 100644 libraries/common/helpers.rb delete mode 100644 libraries/common/network_management.rb delete mode 100644 libraries/common/resource_management.rb diff --git a/lib/resources/azure/azure_backend.rb b/lib/resources/azure/azure_backend.rb new file mode 100644 index 000000000..5293ef3ee --- /dev/null +++ b/lib/resources/azure/azure_backend.rb @@ -0,0 +1,195 @@ +require 'ms_rest_azure' +require 'azure_mgmt_resources' +require 'azure_mgmt_compute' +require 'azure_mgmt_network' +require 'inifile' + +# Class to manage the connection to Azure to retrieve the information required about the resources +# +# @author Russell Seymour +# +# @attr_reader [String] subscription_id ID of the subscription in which resources are to be tested +class AzureConnection + attr_reader :subscription_id + + # Constructor which reads in the credentials file + # + # @author Russell Seymour + def initialize + # If an INSPEC_AZURE_CREDS environment has been specified set the + # the credentials file to that, otherwise set the one in home + azure_creds_file = ENV['AZURE_CREDS_FILE'] + if azure_creds_file.nil? + + # The environment file has not be set, so default to one in the home directory + azure_creds_file = File.join(Dir.home, '.azure', 'credentials') + end + + # Check to see if the credentials file exists + if File.file?(azure_creds_file) + @credentials = IniFile.load(File.expand_path(azure_creds_file)) + else + warn format('%s was not found or not accessible', azure_creds_file) + end + end + + # Connect to Azure using the specified credentials + # + # @author Russell Seymour + def connection + # If a connection already exists then return it + return @conn if defined?(@conn) + + # Determine if more than one subscription is specified in the configuration file, if so use the first one + if @credentials.sections.length >= 1 + @subscription_id = @credentials.sections[0] + else + @subscription_id = ENV['AZURE_SUBSCRIPTION_ID'] + end + + # Determine the client_id, tenant_id and the client_secret + tenant_id = ENV['AZURE_TENANT_ID'] || @credentials[@subscription_id]['tenant_id'] + client_id = ENV['AZURE_CLIENT_ID'] || @credentials[@subscription_id]['client_id'] + client_secret = ENV['AZURE_CLIENT_SECRET'] || @credentials[@subscription_id]['client_secret'] + + # Create a new connection + token_provider = MsRestAzure::ApplicationTokenProvider.new(tenant_id, client_id, client_secret) + @conn = MsRest::TokenCredentials.new(token_provider) + end +end + +# Helper class to configure and give access to the various management components of Azure +# Also provides shortcuts for certain components, such as returing the VM object and performing +# all the checks that need to be done before retrieving the VM +# +# @author Russell Seymour +# @attr_reader [MsRest::TokenCredentials] azure Azure connection credentials +# @attr_reader [ComputeManagement] compute_mgmt Compute object for retrieving details about VMs +# @attr_reader [ResourceManagement] resource_mgmt Resource object for accessing specific resources and resoure groups +# @attr_reader [NetworkManagement] network_mgmt Network object for retrieving all information about Network cards and IP configurations +class Helpers + attr_reader :azure, :compute_mgmt, :resource_mgmt, :network_mgmt + + # Constructor to configure the various objects that are required for Inspec testing + # + # @author Russell Seymour + def initialize + # Azure connection + @azure = AzureConnection.new + + # Create the necessary clients + @compute_mgmt = ComputeManagement.new(azure) + @resource_mgmt = ResourceManagement.new(azure) + @network_mgmt = NetworkManagement.new(azure) + end + + # Retrieve the named virtual machine from Azure + # + # This is specified here as it combines two different resource types, Compute and Resource Groups + # + # @author Russell Seymour + # + # @return [] VM object + # + def get_vm(name, rg_name) + # Ensure that the resource group exists + unless resource_mgmt.client.resource_groups.check_existence(rg_name) + raise "The Resource group cannot be found: #{rg_name}" + end + + # get a vm from the named resource group + begin + compute_mgmt.client.virtual_machines.get(rg_name, name) + rescue => e + e.error_message + end + end +end + +# Class to return a NetworkManagement client for use with NICs and Public IP Addresses +# +# @author Russell Seymour +# @attr_reader [Azure::ARM::Network::NetworkManagementClient] client Azure Network Management cient +class ResourceManagement + attr_reader :client + + # Constructor for the class. Creates the new Network Management client object + # + # @author Russell Seymour + # + # @param [MsRest::TokenCredentials] azure Connection object for Azure + def initialize(azure) + @client = Azure::ARM::Resources::ResourceManagementClient.new(azure.connection) + client.subscription_id = azure.subscription_id + end + + # Determine if the specified resource group exists in the subscription_id + # + # @author Russell Seymour + # + # @param [String] name Name of the resource group + # + # @return [Boolean] Whether the resource group exists or not + def exists(name) + client.resource_groups.check_existence(name) + end + + # Retrieve the named resource group if it exists + # + # @author Russell Seymour + # + # @param [String] name Name of the resource group + # + # @return [Azure::ARM::Resources::Models::ResourceGroup] Object containing information about the resource group + def get_resource_group(name) + client.resource_groups.get(name) if exists(name) + end + + # Get all of the resources that are contained within the resource group if it exists + # + # @author Russell Seymour + # + # @param [String] name Name of the resource group + # + # @return [Azure::ARM::Resources::Models::ResourceListResult] Object containing array of all the resources + def get_resources(name) + client.resource_groups.list_resources_as_lazy(name) if exists(name) + end +end + +# Class to return a ComputeManagement client to get information about VMs +# +# @author Russell Seymour +# +# @attr_reader [Azure::ARM::Compute::ComputeManagementClient] client ComputeManagement client object +class ComputeManagement + attr_reader :client + + # Constructor for the class. Creates the new Network Management client object + # + # @author Russell Seymour + # + # @param [MsRest::TokenCredentials] azure Connection object for Azure + def initialize(azure) + @client = Azure::ARM::Compute::ComputeManagementClient.new(azure.connection) + client.subscription_id = azure.subscription_id + end +end + +# Class to return a NetworkManagement client for use with NICs and Public IP Addresses +# +# @author Russell Seymour +# @attr_reader [Azure::ARM::Network::NetworkManagementClient] client Azure Network Management cient +class NetworkManagement + attr_reader :client + + # Constructor for the class. Creates the new Network Management client object + # + # @author Russell Seymour + # + # @param [MsRest::TokenCredentials] azure Connection object for Azure + def initialize(azure) + @client = Azure::ARM::Network::NetworkManagementClient.new(azure.connection) + client.subscription_id = azure.subscription_id + end +end diff --git a/libraries/azure_conn.rb b/libraries/azure_conn.rb deleted file mode 100644 index 13882d70a..000000000 --- a/libraries/azure_conn.rb +++ /dev/null @@ -1,56 +0,0 @@ -require 'ms_rest_azure' -require 'inifile' - -# Class to manage the connection to Azure to retrieve the information required about the resources -# -# @author Russell Seymour -# -# @attr_reader [String] subscription_id ID of the subscription in which resources are to be tested -class AzureConnection - attr_reader :subscription_id - - # Constructor which reads in the credentials file - # - # @author Russell Seymour - def initialize - # If an INSPEC_AZURE_CREDS environment has been specified set the - # the credentials file to that, otherwise set the one in home - azure_creds_file = ENV['AZURE_CREDS_FILE'] - if azure_creds_file.nil? - - # The environment file has not be set, so default to one in the home directory - azure_creds_file = File.join(Dir.home, '.azure', 'credentials') - end - - # Check to see if the credentials file exists - if File.file?(azure_creds_file) - @credentials = IniFile.load(File.expand_path(azure_creds_file)) - else - warn format('%s was not found or not accessible', azure_creds_file) - end - end - - # Connect to Azure using the specified credentials - # - # @author Russell Seymour - def connection - # If a connection already exists then return it - return @conn if defined?(@conn) - - # Determine if more than one subscription is specified in the configuration file, if so use the first one - if @credentials.sections.length >= 1 - @subscription_id = @credentials.sections[0] - else - @subscription_id = ENV['AZURE_SUBSCRIPTION_ID'] - end - - # Determine the client_id, tenant_id and the client_secret - tenant_id = ENV['AZURE_TENANT_ID'] || @credentials[@subscription_id]['tenant_id'] - client_id = ENV['AZURE_CLIENT_ID'] || @credentials[@subscription_id]['client_id'] - client_secret = ENV['AZURE_CLIENT_SECRET'] || @credentials[@subscription_id]['client_secret'] - - # Create a new connection - token_provider = MsRestAzure::ApplicationTokenProvider.new(tenant_id, client_id, client_secret) - @conn = MsRest::TokenCredentials.new(token_provider) - end -end diff --git a/libraries/azure_rg.rb b/libraries/azure_rg.rb index 48b734642..a4a2188de 100644 --- a/libraries/azure_rg.rb +++ b/libraries/azure_rg.rb @@ -1,5 +1,5 @@ -require File.expand_path(File.dirname(__FILE__) + '/common/helpers') +require 'azure_backend' # Class to test the resources in Resource Groups # diff --git a/libraries/azure_vm.rb b/libraries/azure_vm.rb index 8ba2377af..5efbd50f6 100644 --- a/libraries/azure_vm.rb +++ b/libraries/azure_vm.rb @@ -1,5 +1,5 @@ -require File.expand_path(File.dirname(__FILE__) + '/common/helpers') +require 'azure_backend' # Class to retrieve information about the specified virtual machine # diff --git a/libraries/azure_vm_datadisks.rb b/libraries/azure_vm_datadisks.rb index bf96db300..d765d592d 100644 --- a/libraries/azure_vm_datadisks.rb +++ b/libraries/azure_vm_datadisks.rb @@ -1,5 +1,5 @@ -require File.expand_path(File.dirname(__FILE__) + '/common/helpers') +require 'azure_backend' require 'uri' # Class to test the data disks that are attached to the specified VM diff --git a/libraries/common/compute_management.rb b/libraries/common/compute_management.rb deleted file mode 100644 index b13ab1ce5..000000000 --- a/libraries/common/compute_management.rb +++ /dev/null @@ -1,21 +0,0 @@ - -require 'azure_mgmt_compute' - -# Class to return a ComputeManagement client to get information about VMs -# -# @author Russell Seymour -# -# @attr_reader [Azure::ARM::Compute::ComputeManagementClient] client ComputeManagement client object -class ComputeManagement - attr_reader :client - - # Constructor for the class. Creates the new Network Management client object - # - # @author Russell Seymour - # - # @param [MsRest::TokenCredentials] azure Connection object for Azure - def initialize(azure) - @client = Azure::ARM::Compute::ComputeManagementClient.new(azure.connection) - client.subscription_id = azure.subscription_id - end -end diff --git a/libraries/common/helpers.rb b/libraries/common/helpers.rb deleted file mode 100644 index 3655e5846..000000000 --- a/libraries/common/helpers.rb +++ /dev/null @@ -1,54 +0,0 @@ - -require File.expand_path(File.dirname(__FILE__) + '/../azure_conn') - -require File.expand_path(File.dirname(__FILE__) + '/resource_management') -require File.expand_path(File.dirname(__FILE__) + '/compute_management') -require File.expand_path(File.dirname(__FILE__) + '/network_management') - -# Helper class to configure and give access to the various management components of Azure -# Also provides shortcuts for certain components, such as returing the VM object and performing -# all the checks that need to be done before retrieving the VM -# -# @author Russell Seymour -# @attr_reader [MsRest::TokenCredentials] azure Azure connection credentials -# @attr_reader [ComputeManagement] compute_mgmt Compute object for retrieving details about VMs -# @attr_reader [ResourceManagement] resource_mgmt Resource object for accessing specific resources and resoure groups -# @attr_reader [NetworkManagement] network_mgmt Network object for retrieving all information about Network cards and IP configurations -class Helpers - attr_reader :azure, :compute_mgmt, :resource_mgmt, :network_mgmt - - # Constructor to configure the various objects that are required for Inspec testing - # - # @author Russell Seymour - def initialize - # Azure connection - @azure = AzureConnection.new - - # Create the necessary clients - @compute_mgmt = ComputeManagement.new(azure) - @resource_mgmt = ResourceManagement.new(azure) - @network_mgmt = NetworkManagement.new(azure) - end - - # Retrieve the named virtual machine from Azure - # - # This is specified here as it combines two different resource types, Compute and Resource Groups - # - # @author Russell Seymour - # - # @return [] VM object - # - def get_vm(name, rg_name) - # Ensure that the resource group exists - unless resource_mgmt.client.resource_groups.check_existence(rg_name) - raise "The Resource group cannot be found: #{rg_name}" - end - - # get a vm from the named resource group - begin - compute_mgmt.client.virtual_machines.get(rg_name, name) - rescue => e - e.error_message - end - end -end diff --git a/libraries/common/network_management.rb b/libraries/common/network_management.rb deleted file mode 100644 index a12fed784..000000000 --- a/libraries/common/network_management.rb +++ /dev/null @@ -1,20 +0,0 @@ - -require 'azure_mgmt_network' - -# Class to return a NetworkManagement client for use with NICs and Public IP Addresses -# -# @author Russell Seymour -# @attr_reader [Azure::ARM::Network::NetworkManagementClient] client Azure Network Management cient -class NetworkManagement - attr_reader :client - - # Constructor for the class. Creates the new Network Management client object - # - # @author Russell Seymour - # - # @param [MsRest::TokenCredentials] azure Connection object for Azure - def initialize(azure) - @client = Azure::ARM::Network::NetworkManagementClient.new(azure.connection) - client.subscription_id = azure.subscription_id - end -end diff --git a/libraries/common/resource_management.rb b/libraries/common/resource_management.rb deleted file mode 100644 index 247e1b7d3..000000000 --- a/libraries/common/resource_management.rb +++ /dev/null @@ -1,53 +0,0 @@ - -require 'azure_mgmt_resources' - -# Class to return a NetworkManagement client for use with NICs and Public IP Addresses -# -# @author Russell Seymour -# @attr_reader [Azure::ARM::Network::NetworkManagementClient] client Azure Network Management cient -class ResourceManagement - attr_reader :client - - # Constructor for the class. Creates the new Network Management client object - # - # @author Russell Seymour - # - # @param [MsRest::TokenCredentials] azure Connection object for Azure - def initialize(azure) - @client = Azure::ARM::Resources::ResourceManagementClient.new(azure.connection) - client.subscription_id = azure.subscription_id - end - - # Determine if the specified resource group exists in the subscription_id - # - # @author Russell Seymour - # - # @param [String] name Name of the resource group - # - # @return [Boolean] Whether the resource group exists or not - def exists(name) - client.resource_groups.check_existence(name) - end - - # Retrieve the named resource group if it exists - # - # @author Russell Seymour - # - # @param [String] name Name of the resource group - # - # @return [Azure::ARM::Resources::Models::ResourceGroup] Object containing information about the resource group - def get_resource_group(name) - client.resource_groups.get(name) if exists(name) - end - - # Get all of the resources that are contained within the resource group if it exists - # - # @author Russell Seymour - # - # @param [String] name Name of the resource group - # - # @return [Azure::ARM::Resources::Models::ResourceListResult] Object containing array of all the resources - def get_resources(name) - client.resource_groups.list_resources_as_lazy(name) if exists(name) - end -end From 2b7c72c5363d8ab2f921a774d57e748e4a4d0f2f Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Thu, 23 Feb 2017 12:59:22 +0000 Subject: [PATCH 046/206] Do not get vm if the name has not been specified Signed-off-by: Russell Seymour --- lib/resources/azure/azure_backend.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/resources/azure/azure_backend.rb b/lib/resources/azure/azure_backend.rb index 5293ef3ee..d471c782f 100644 --- a/lib/resources/azure/azure_backend.rb +++ b/lib/resources/azure/azure_backend.rb @@ -92,11 +92,15 @@ class Helpers # @return [] VM object # def get_vm(name, rg_name) + # Ensure that the resource group exists unless resource_mgmt.client.resource_groups.check_existence(rg_name) raise "The Resource group cannot be found: #{rg_name}" end + # Return if no name has been specified + return if name.nil? + # get a vm from the named resource group begin compute_mgmt.client.virtual_machines.get(rg_name, name) From 9c3cec433fd0b7471553ddc7d7b138971d86526e Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Thu, 23 Feb 2017 13:32:59 +0000 Subject: [PATCH 047/206] Moved example controls into examples Added check for the name of hte resource group #11 Signed-off-by: Russell Seymour --- {controls => examples}/example_rg.rb | 0 {controls => examples}/example_vm_datadisks.rb | 0 {controls => examples}/example_vm_image.rb | 2 +- libraries/azure_rg.rb | 3 +++ 4 files changed, 4 insertions(+), 1 deletion(-) rename {controls => examples}/example_rg.rb (100%) rename {controls => examples}/example_vm_datadisks.rb (100%) rename {controls => examples}/example_vm_image.rb (99%) diff --git a/controls/example_rg.rb b/examples/example_rg.rb similarity index 100% rename from controls/example_rg.rb rename to examples/example_rg.rb diff --git a/controls/example_vm_datadisks.rb b/examples/example_vm_datadisks.rb similarity index 100% rename from controls/example_vm_datadisks.rb rename to examples/example_vm_datadisks.rb diff --git a/controls/example_vm_image.rb b/examples/example_vm_image.rb similarity index 99% rename from controls/example_vm_image.rb rename to examples/example_vm_image.rb index 0352621ad..55f6a63f5 100644 --- a/controls/example_vm_image.rb +++ b/examples/example_vm_image.rb @@ -21,4 +21,4 @@ control 'azure-vm-1.0' do its('ssh_key_count') { should eq 1 } its('os_type') { should eq 'Linux' } end -end +end \ No newline at end of file diff --git a/libraries/azure_rg.rb b/libraries/azure_rg.rb index a4a2188de..58850a2bb 100644 --- a/libraries/azure_rg.rb +++ b/libraries/azure_rg.rb @@ -37,6 +37,9 @@ class AzureRg < Inspec.resource(1) # Get the named resource group @rg = helpers.resource_mgmt.get_resource_group(opts[:name]) + # If the rg is nil raise error + raise format("Unable to find resource group '%s' in Azure subscription '%s'", opts[:name], helpers.azure.subscription_id) if rg.nil? + # Retrieve the items within the resource group rg_items = helpers.resource_mgmt.get_resources(opts[:name]) From 9108c5a237b5cf9fee755e1c385759672fe9e463 Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Thu, 23 Feb 2017 13:46:42 +0000 Subject: [PATCH 048/206] Fixed linting issue Signed-off-by: Russell Seymour --- lib/resources/azure/azure_backend.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/resources/azure/azure_backend.rb b/lib/resources/azure/azure_backend.rb index d471c782f..f79ae3239 100644 --- a/lib/resources/azure/azure_backend.rb +++ b/lib/resources/azure/azure_backend.rb @@ -92,7 +92,6 @@ class Helpers # @return [] VM object # def get_vm(name, rg_name) - # Ensure that the resource group exists unless resource_mgmt.client.resource_groups.check_existence(rg_name) raise "The Resource group cannot be found: #{rg_name}" From b011b71a27e8917b598c3082dd48a2be4c616dcf Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Thu, 23 Feb 2017 13:52:07 +0000 Subject: [PATCH 049/206] Updated CHANGELOG Signed-off-by: Russell Seymour --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74a6a8c56..d90ac2d92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Change Log +## [0.4.0](https://github.com/chef/inspec-azure/tree/0.4.0) (2017-02-23) +[Full Changelog](https://github.com/chef/inspec-azure/compare/0.3.1...0.4.0) + +**Merged pull requests:** + +- Fixed loading of internal classes [\#13](https://github.com/chef/inspec-azure/pull/13) ([russellseymour](https://github.com/russellseymour)) +- Updated how internal libraries are located [\#12](https://github.com/chef/inspec-azure/pull/12) ([russellseymour](https://github.com/russellseymour)) + ## [0.3.1](https://github.com/chef/inspec-azure/tree/0.3.1) (2017-02-21) [Full Changelog](https://github.com/chef/inspec-azure/compare/0.3.0...0.3.1) From 7699ffb6e2379fbc7d1ff9c13b96cfb3c285f42b Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Thu, 23 Feb 2017 14:13:32 +0000 Subject: [PATCH 050/206] Specific credentials can now be used If not specified the first one in the credentials file is used Fixes #14 Signed-off-by: Russell Seymour --- lib/resources/azure/azure_backend.rb | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/resources/azure/azure_backend.rb b/lib/resources/azure/azure_backend.rb index f79ae3239..b9033e4f4 100644 --- a/lib/resources/azure/azure_backend.rb +++ b/lib/resources/azure/azure_backend.rb @@ -40,13 +40,17 @@ class AzureConnection # If a connection already exists then return it return @conn if defined?(@conn) - # Determine if more than one subscription is specified in the configuration file, if so use the first one - if @credentials.sections.length >= 1 - @subscription_id = @credentials.sections[0] - else + # If a subscription to be used has been specified as en environment variable use that + # otherwise use the first one in the credentials file + if !ENV['AZURE_SUBSCRIPTION_ID'].nil? @subscription_id = ENV['AZURE_SUBSCRIPTION_ID'] + else + @subscription_id = @credentials.sections[0] end + # Check that the credential exists + raise format('The specified Azure Subscription cannot be found in your credentials: %s', @subscription_id) unless @credentials.sections.include?(@subscription_id) + # Determine the client_id, tenant_id and the client_secret tenant_id = ENV['AZURE_TENANT_ID'] || @credentials[@subscription_id]['tenant_id'] client_id = ENV['AZURE_CLIENT_ID'] || @credentials[@subscription_id]['client_id'] From 871efeee966d6156b826d8c9258267d547115d79 Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Thu, 23 Feb 2017 14:24:18 +0000 Subject: [PATCH 051/206] Subscription number can be used to select the one to use Closes #15 Signed-off-by: Russell Seymour --- lib/resources/azure/azure_backend.rb | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/lib/resources/azure/azure_backend.rb b/lib/resources/azure/azure_backend.rb index b9033e4f4..d7c08bbb3 100644 --- a/lib/resources/azure/azure_backend.rb +++ b/lib/resources/azure/azure_backend.rb @@ -40,10 +40,22 @@ class AzureConnection # If a connection already exists then return it return @conn if defined?(@conn) - # If a subscription to be used has been specified as en environment variable use that - # otherwise use the first one in the credentials file + # If a subscription has been specified as an environment variable use that + # If an index has been specified with AZURE_SUBSCRIPTION_INDEX attempt to use that value + # Otherwise use the first entry in the file if !ENV['AZURE_SUBSCRIPTION_ID'].nil? @subscription_id = ENV['AZURE_SUBSCRIPTION_ID'] + elsif !ENV['AZURE_SUBSCRIPTION_NUMBER'].nil? + + subscription_number = ENV['AZURE_SUBSCRIPTION_NUMBER'].to_i + subscription_index = subscription_number - 1 + + # Check that the specified index is not greater than the number of subscriptions + if subscription_number > (@credentials.sections.length) + raise format('Your credentials file only contains %s subscriptions. You specified number %s.', @credentials.sections.length, subscription_number) + end + + @subscription_id = @credentials.sections[subscription_index] else @subscription_id = @credentials.sections[0] end From 7812e7d2f6c6f989aea0c58868799dc0bf29c740 Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Thu, 23 Feb 2017 14:27:03 +0000 Subject: [PATCH 052/206] Updated README Signed-off-by: Russell Seymour --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index c55ae3161..86ca77403 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,12 @@ Note that this file format supports multiple subscription_ids. By default `insp AZURE_SUBSCRIPTION_ID="2fbdbb02-df2e-11e6-bf01-fe55135034f3" inspec exec inspec-azure ``` +Alternatively, if you know that the subscription you want to use is the second one in you credentials file then you can use the following: + +```bash +AZURE_SUBSCRIPTION_NUMBER=2 inspec exec inspec-azure +``` + ### Environment variables It is possible to not have a credentials file at all and specify all of the required information as the following environment variables: From fe2cf1dc41cdafa0d1268dbed537e0ae8197cb5f Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Thu, 23 Feb 2017 14:34:41 +0000 Subject: [PATCH 053/206] Fixed linting issues Signed-off-by: Russell Seymour --- lib/resources/azure/azure_backend.rb | 42 ++++++++++++++++++---------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/lib/resources/azure/azure_backend.rb b/lib/resources/azure/azure_backend.rb index d7c08bbb3..ec93d4cd8 100644 --- a/lib/resources/azure/azure_backend.rb +++ b/lib/resources/azure/azure_backend.rb @@ -40,37 +40,49 @@ class AzureConnection # If a connection already exists then return it return @conn if defined?(@conn) + @subscription_id = azure_subscription_id + + # Check that the credential exists + raise format('The specified Azure Subscription cannot be found in your credentials: %s', subscription_id) unless @credentials.sections.include?(subscription_id) + + # Determine the client_id, tenant_id and the client_secret + tenant_id = ENV['AZURE_TENANT_ID'] || @credentials[subscription_id]['tenant_id'] + client_id = ENV['AZURE_CLIENT_ID'] || @credentials[subscription_id]['client_id'] + client_secret = ENV['AZURE_CLIENT_SECRET'] || @credentials[subscription_id]['client_secret'] + + # Create a new connection + token_provider = MsRestAzure::ApplicationTokenProvider.new(tenant_id, client_id, client_secret) + @conn = MsRest::TokenCredentials.new(token_provider) + end + + private + + # Return the subscrtiption ID to use + # + # @author Russell Seymour + def azure_subscription_id # If a subscription has been specified as an environment variable use that # If an index has been specified with AZURE_SUBSCRIPTION_INDEX attempt to use that value # Otherwise use the first entry in the file if !ENV['AZURE_SUBSCRIPTION_ID'].nil? - @subscription_id = ENV['AZURE_SUBSCRIPTION_ID'] + id = ENV['AZURE_SUBSCRIPTION_ID'] elsif !ENV['AZURE_SUBSCRIPTION_NUMBER'].nil? subscription_number = ENV['AZURE_SUBSCRIPTION_NUMBER'].to_i subscription_index = subscription_number - 1 # Check that the specified index is not greater than the number of subscriptions - if subscription_number > (@credentials.sections.length) + if subscription_number > @credentials.sections.length raise format('Your credentials file only contains %s subscriptions. You specified number %s.', @credentials.sections.length, subscription_number) end - @subscription_id = @credentials.sections[subscription_index] + id = @credentials.sections[subscription_index] else - @subscription_id = @credentials.sections[0] + id = @credentials.sections[0] end - # Check that the credential exists - raise format('The specified Azure Subscription cannot be found in your credentials: %s', @subscription_id) unless @credentials.sections.include?(@subscription_id) - - # Determine the client_id, tenant_id and the client_secret - tenant_id = ENV['AZURE_TENANT_ID'] || @credentials[@subscription_id]['tenant_id'] - client_id = ENV['AZURE_CLIENT_ID'] || @credentials[@subscription_id]['client_id'] - client_secret = ENV['AZURE_CLIENT_SECRET'] || @credentials[@subscription_id]['client_secret'] - - # Create a new connection - token_provider = MsRestAzure::ApplicationTokenProvider.new(tenant_id, client_id, client_secret) - @conn = MsRest::TokenCredentials.new(token_provider) + # Return the ID to the calling function + id end end From 002617d7512561b2ee7bc3971a384ca6d55f809c Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Thu, 23 Feb 2017 14:41:14 +0000 Subject: [PATCH 054/206] Updated CHANGELOG Signed-off-by: Russell Seymour --- CHANGELOG.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d90ac2d92..cdce00334 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Change Log +## [0.4.1](https://github.com/chef/inspec-azure/tree/0.4.1) (2017-02-23) +[Full Changelog](https://github.com/chef/inspec-azure/compare/0.4.0...0.4.1) + +**Implemented enhancements:** + +- Specify the subscription to be used by index [\#15](https://github.com/chef/inspec-azure/issues/15) + +**Fixed bugs:** + +- Alternative subscriptions cannot be loaded from the credentials file [\#14](https://github.com/chef/inspec-azure/issues/14) + +**Closed issues:** + +- Fix how internal libraries are loaded [\#11](https://github.com/chef/inspec-azure/issues/11) + +**Merged pull requests:** + +- Using Credentials [\#16](https://github.com/chef/inspec-azure/pull/16) ([russellseymour](https://github.com/russellseymour)) + ## [0.4.0](https://github.com/chef/inspec-azure/tree/0.4.0) (2017-02-23) [Full Changelog](https://github.com/chef/inspec-azure/compare/0.3.1...0.4.0) From 33d4b196cc3ee063456dd45872f59e3e12088ff0 Mon Sep 17 00:00:00 2001 From: Chris Redekop Date: Thu, 23 Feb 2017 17:41:28 +0000 Subject: [PATCH 055/206] Cleanup the cleanup task Signed-off-by: Chris Redekop --- Rakefile | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/Rakefile b/Rakefile index f113699ba..80d87da6e 100644 --- a/Rakefile +++ b/Rakefile @@ -25,6 +25,8 @@ task lint: [:rubocop] task default: [:lint, :test] namespace :test do + integration_dir = "test/integration" + # run inspec check to verify that the profile is properly configured task :check do dir = File.join(File.dirname(__FILE__)) @@ -32,23 +34,17 @@ namespace :test do end task :setup_integration_tests do - integration_dir = "test/integration" - puts "----> Setup" sh("cd #{integration_dir}/build/ && terraform plan") sh("cd #{integration_dir}/build/ && terraform apply") end task :run_integration_tests do - integration_dir = "test/integration" - puts "----> Run" sh("bundle exec inspec exec #{integration_dir}/verify") end task :cleanup_integration_tests do - integration_dir = "test/integration" - puts "----> Cleanup" sh("cd #{integration_dir}/build/ && terraform destroy -force") end @@ -59,10 +55,4 @@ namespace :test do Rake::Task["test:run_integration_tests"].execute Rake::Task["test:cleanup_integration_tests"].execute end - - task :cleanup do - integration_dir = "test/integration" - puts "----> Destroy" - sh("cd #{integration_dir}/build/ && terraform destroy -force") - end end From 29fd984a97bd690db55a7d125d8ba8382d4a89b0 Mon Sep 17 00:00:00 2001 From: Christoph Hartmann Date: Mon, 27 Feb 2017 14:44:27 +0100 Subject: [PATCH 056/206] remove .kitchen logs Signed-off-by: Christoph Hartmann --- .gitignore | 3 ++- .kitchen/logs/kitchen.log | 30 ------------------------------ 2 files changed, 2 insertions(+), 31 deletions(-) delete mode 100644 .kitchen/logs/kitchen.log diff --git a/.gitignore b/.gitignore index 9f09f58d1..5fb215ac1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ .ruby-version .direnv Gemfile.lock -inspec.lock \ No newline at end of file +inspec.lock +.kitchen diff --git a/.kitchen/logs/kitchen.log b/.kitchen/logs/kitchen.log deleted file mode 100644 index 40ebeae05..000000000 --- a/.kitchen/logs/kitchen.log +++ /dev/null @@ -1,30 +0,0 @@ -I, [2017-02-13T14:00:00.031876 #36591] INFO -- Kitchen: -----> Starting Kitchen (v1.15.0) -E, [2017-02-13T14:00:00.032139 #36591] ERROR -- Kitchen: ------Exception------- -E, [2017-02-13T14:00:00.032160 #36591] ERROR -- Kitchen: Class: Kitchen::UserError -E, [2017-02-13T14:00:00.032172 #36591] ERROR -- Kitchen: Message: Kitchen YAML file /Users/russells/workspaces/inspec/inspec-azure/.kitchen.yml does not exist. -E, [2017-02-13T14:00:00.032182 #36591] ERROR -- Kitchen: ---------------------- -E, [2017-02-13T14:00:00.032193 #36591] ERROR -- Kitchen: ------Backtrace------- -E, [2017-02-13T14:00:00.032202 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/test-kitchen-1.15.0/lib/kitchen/loader/yaml.rb:71:in `read' -E, [2017-02-13T14:00:00.032212 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/test-kitchen-1.15.0/lib/kitchen/config.rb:152:in `data' -E, [2017-02-13T14:00:00.032221 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/test-kitchen-1.15.0/lib/kitchen/config.rb:131:in `suites' -E, [2017-02-13T14:00:00.032231 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/test-kitchen-1.15.0/lib/kitchen/config.rb:182:in `filter_instances' -E, [2017-02-13T14:00:00.032241 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/test-kitchen-1.15.0/lib/kitchen/config.rb:141:in `build_instances' -E, [2017-02-13T14:00:00.032251 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/test-kitchen-1.15.0/lib/kitchen/config.rb:117:in `instances' -E, [2017-02-13T14:00:00.032261 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/test-kitchen-1.15.0/lib/kitchen/command.rb:112:in `filtered_instances' -E, [2017-02-13T14:00:00.032270 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/test-kitchen-1.15.0/lib/kitchen/command.rb:142:in `parse_subcommand' -E, [2017-02-13T14:00:00.032280 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/test-kitchen-1.15.0/lib/kitchen/command/test.rb:40:in `block in call' -E, [2017-02-13T14:00:00.032290 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/2.3.0/benchmark.rb:293:in `measure' -E, [2017-02-13T14:00:00.032299 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/test-kitchen-1.15.0/lib/kitchen/command/test.rb:38:in `call' -E, [2017-02-13T14:00:00.032309 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/test-kitchen-1.15.0/lib/kitchen/cli.rb:53:in `perform' -E, [2017-02-13T14:00:00.032319 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/test-kitchen-1.15.0/lib/kitchen/cli.rb:234:in `test' -E, [2017-02-13T14:00:00.032328 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/thor-0.19.1/lib/thor/command.rb:27:in `run' -E, [2017-02-13T14:00:00.032338 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/thor-0.19.1/lib/thor/invocation.rb:126:in `invoke_command' -E, [2017-02-13T14:00:00.032348 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/test-kitchen-1.15.0/lib/kitchen/cli.rb:334:in `invoke_task' -E, [2017-02-13T14:00:00.032358 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/thor-0.19.1/lib/thor.rb:359:in `dispatch' -E, [2017-02-13T14:00:00.032367 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/thor-0.19.1/lib/thor/base.rb:440:in `start' -E, [2017-02-13T14:00:00.032621 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/test-kitchen-1.15.0/bin/kitchen:13:in `block in ' -E, [2017-02-13T14:00:00.032634 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/test-kitchen-1.15.0/lib/kitchen/errors.rb:171:in `with_friendly_errors' -E, [2017-02-13T14:00:00.032644 #36591] ERROR -- Kitchen: /opt/chefdk/embedded/lib/ruby/gems/2.3.0/gems/test-kitchen-1.15.0/bin/kitchen:13:in `' -E, [2017-02-13T14:00:00.032653 #36591] ERROR -- Kitchen: /opt/chefdk/bin/kitchen:21:in `load' -E, [2017-02-13T14:00:00.032662 #36591] ERROR -- Kitchen: /opt/chefdk/bin/kitchen:21:in `
' -E, [2017-02-13T14:00:00.032680 #36591] ERROR -- Kitchen: ----End Backtrace----- From 850aa7104f40ba36c235a6d6d4f1104576b89f18 Mon Sep 17 00:00:00 2001 From: Christoph Hartmann Date: Mon, 27 Feb 2017 14:52:07 +0100 Subject: [PATCH 057/206] add contribution guidelines and license --- CONTRIBUTING.md | 155 ++++++++++++++++++++++++++++++++++++++++++++++++ LICENSE | 13 ++++ 2 files changed, 168 insertions(+) create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..b92422962 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,155 @@ +# Contributing to InSpec + +We are glad you want to contribute to InSpec! This document will help answer common questions you may have during your first contribution. + +## Submitting Issues + +We utilize **Github Issues** for issue tracking and contributions. You can contribute in two ways: + +1. Reporting an issue or making a feature request [here](#issues). +2. Adding features or fixing bugs yourself and contributing your code to InSpec. + +We ask you not to submit security concerns via Github. For details on submitting potential security issues please see + +## Contribution Process + +We have a 3 step process for contributions: + +1. Commit changes to a git branch, making sure to sign-off those changes for the [Developer Certificate of Origin](#developer-certification-of-origin-dco). +2. Create a Github Pull Request for your change, following the instructions in the pull request template. +3. Perform a [Code Review](#code-review-process) with the project maintainers on the pull request. + +### Pull Request Requirements + +Chef Projects are built to last. We strive to ensure high quality throughout the experience. In order to ensure this, we require that all pull requests to Chef projects meet these specifications: + +1. **Tests:** To ensure high quality code and protect against future regressions, we require all the code in Chef Projects to have at least unit test coverage. See the [test/unit](https://github.com/chef/inspec/tree/master/test/unit) +directory for the existing tests and use ```bundle exec rake test``` to run them. +2. **Green CI Tests:** We use [Travis CI](https://travis-ci.org/) and/or [AppVeyor](https://www.appveyor.com/) CI systems to test all pull requests. We require these test runs to succeed on every pull request before being merged. +3. **Up-to-date Documentation:** Every code change should be reflected in an update for our [documentation](https://github.com/chef/inspec/tree/master/docs). We expect PRs to update the documentation with the code change. + +In addition to this it would be nice to include the description of the problem you are solving + with your change. You can use [Issue Template](#issuetemplate) in the description section + of the pull request. + +### Code Review Process + +Code review takes place in Github pull requests. See [this article](https://help.github.com/articles/about-pull-requests/) if you're not familiar with Github Pull Requests. + +Once you open a pull request, project maintainers will review your code and respond to your pull request with any feedback they might have. The process at this point is as follows: + +1. Two thumbs-up (:+1:) are required from project maintainers. See the master maintainers document for InSpec projects at . +2. When ready, your pull request will be merged into `master`, we may require you to rebase your PR to the latest `master`. +3. Once the PR is merged, you will be included in `CHANGELOG.md`. + +If you would like to learn about when your code will be available in a release of Chef, read more about [Chef Release Cycles](#release-cycles). + + +### Developer Certification of Origin (DCO) + +Licensing is very important to open source projects. It helps ensure the software continues to be available under the terms that the author desired. + +Chef uses [the Apache 2.0 license](https://github.com/chef/chef/blob/master/LICENSE) to strike a balance between open contribution and allowing you to use the software however you would like to. + +The license tells you what rights you have that are provided by the copyright holder. It is important that the contributor fully understands what rights they are licensing and agrees to them. Sometimes the copyright holder isn't the contributor, such as when the contributor is doing work on behalf of a company. + +To make a good faith effort to ensure these criteria are met, Chef requires the Developer Certificate of Origin (DCO) process to be followed. + +The DCO is an attestation attached to every contribution made by every developer. In the commit message of the contribution, the developer simply adds a Signed-off-by statement and thereby agrees to the DCO, which you can find below or at . + +``` +Developer's Certificate of Origin 1.1 + +By making a contribution to this project, I certify that: + +(a) The contribution was created in whole or in part by me and I + have the right to submit it under the open source license + indicated in the file; or + +(b) The contribution is based upon previous work that, to the + best of my knowledge, is covered under an appropriate open + source license and I have the right under that license to + submit that work with modifications, whether created in whole + or in part by me, under the same open source license (unless + I am permitted to submit under a different license), as + Indicated in the file; or + +(c) The contribution was provided directly to me by some other + person who certified (a), (b) or (c) and I have not modified + it. + +(d) I understand and agree that this project and the contribution + are public and that a record of the contribution (including + all personal information I submit with it, including my + sign-off) is maintained indefinitely and may be redistributed + consistent with this project or the open source license(s) + involved. +``` + +For more information on the change see the Chef Blog post [Introducing Developer Certificate of Origin](https://blog.chef.io/2016/09/19/introducing-developer-certificate-of-origin/) + +#### DCO Sign-Off Methods + +The DCO requires a sign-off message in the following format appear on each commit in the pull request: + +``` +Signed-off-by: Julia Child +``` + +The DCO text can either be manually added to your commit body, or you can add either **-s** or **--signoff** to your usual git commit commands. If you forget to add the sign-off you can also amend a previous commit with the sign-off by running **git commit --amend -s**. If you've pushed your changes to Github already you'll need to force push your branch after this with **git push -f**. + +### Obvious Fix Policy + +Small contributions, such as fixing spelling errors, where the content is small enough to not be considered intellectual property, can be submitted without signing the contribution for the DCO. + +As a rule of thumb, changes are obvious fixes if they do not introduce any new functionality or creative thinking. Assuming the change does not affect functionality, some common obvious fix examples include the following: + +- Spelling / grammar fixes +- Typo correction, white space and formatting changes +- Comment clean up +- Bug fixes that change default return values or error codes stored in constants +- Adding logging messages or debugging output +- Changes to 'metadata' files like Gemfile, .gitignore, build scripts, etc. +- Moving source files from one directory or package to another + +**Whenever you invoke the "obvious fix" rule, please say so in your commit message:** + +``` +------------------------------------------------------------------------ +commit 370adb3f82d55d912b0cf9c1d1e99b132a8ed3b5 +Author: Julia Child +Date: Wed Sep 18 11:44:40 2015 -0700 + + Fix typo in the README. + + Obvious fix. + +------------------------------------------------------------------------ +``` + +## Release Cycles + +Our primary shipping vehicle is operating system specific packages that includes all the requirements of InSpec. We call these [Omnibus packages](https://github.com/chef/omnibus) + +We also release our software as gems to [Rubygems](https://rubygems.org/) but we strongly recommend using InSpec or ChefDK packages. + +Our version numbering roughly follows [Semantic Versioning](http://semver.org/) standard. Our standard version numbers look like X.Y.Z which mean: + +- X is a major release, which may not be fully compatible with prior major releases +- Y is a minor release, which adds both new features and bug fixes +- Z is a patch release, which adds just bug fixes + +After shipping a release of InSpec we bump at least the `Minor` version by one to start development of the next minor release. We do a release approximately every week. Announcements of releases are made to the [InSpec mailing list](https://discourse.chef.io/c/chef-release) when they are available. + +## InSpec Community + +InSpec is made possible by a strong community of developers, system administrators, auditor and security experts. If you have any questions or if you would like to get involved in the InSpec community you can check out: + +- [InSpec Mailing List](https://discourse.chef.io/c/inspec) +- [Chef Community Slack](https://community-slack.chef.io/) + +Also here are some additional pointers to some awesome Chef content: + +- [InSpec Docs](http://inspec.io/docs/) +- [Learn Chef](https://learn.chef.io/) +- [Chef Website](https://www.chef.io/) diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..c198000bf --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright (c) 2017 Chef Software Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. From ec072cadd3fb97229aaf44fbb32561aed77347db Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Tue, 28 Feb 2017 14:21:57 +0000 Subject: [PATCH 058/206] Added integration tests for current resources Closes #19 Signed-off-by: Russell Seymour --- .gitignore | 2 + Gemfile | 1 + README.md | 15 +- Rakefile | 58 +++++- examples/example_rg.rb | 22 --- examples/example_vm_datadisks.rb | 19 -- examples/example_vm_image.rb | 24 --- lib/resources/azure/azure_backend.rb | 18 +- libraries/azure_vm.rb | 8 +- test/integration/azure/build/azure.tf | 173 ++++++++++++++++++ .../azure/verify/controls/datadisks.rb | 24 +++ .../azure/verify/controls/resources.rb | 19 ++ test/integration/azure/verify/controls/vm.rb | 24 +++ test/integration/azure/verify/inspec.yml | 4 + 14 files changed, 334 insertions(+), 77 deletions(-) delete mode 100644 examples/example_rg.rb delete mode 100644 examples/example_vm_datadisks.rb delete mode 100644 examples/example_vm_image.rb create mode 100644 test/integration/azure/build/azure.tf create mode 100644 test/integration/azure/verify/controls/datadisks.rb create mode 100644 test/integration/azure/verify/controls/resources.rb create mode 100644 test/integration/azure/verify/controls/vm.rb create mode 100644 test/integration/azure/verify/inspec.yml diff --git a/.gitignore b/.gitignore index 5fb215ac1..ea4f7bd3c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ Gemfile.lock inspec.lock .kitchen +*.plan +*.tfstate* diff --git a/Gemfile b/Gemfile index a5ad434b9..63f30a750 100644 --- a/Gemfile +++ b/Gemfile @@ -11,6 +11,7 @@ group :development do gem 'rubocop' gem 'github_changelog_generator' gem 'pry-coolline' + gem 'passgen' end group :inspec do diff --git a/README.md b/README.md index 86ca77403..f987f06ab 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ This resource pack provides resources for Azure Resources. It will ship with th ``` ├── README.md - this readme -├── controls - contains example controls └── libraries - contains Azure resources +└── test - contains integration tests ``` ## Get Started @@ -212,14 +212,21 @@ control 'azure-1' do end ``` -### Using the example controls +## Testing -There a number of example controls that have been added to this resource. They are driven by environment variables to make them easier to run. For example the following would test a machine called `example-01` in the resource group `exmaple-rg`. +The `test/integration/verify/controls` directory contains all of the tests that are run during integration tests. These can be used as examples of how to use this resource pack. + +Rake tasks have been configured to enable the running of the integration tests: ```bash -$> AZURE_VM_NAME='example-01' AZURE_RESOURCE_GROUP_NAME='example-rg' bundle exec inspec exec . +rake changelog # Generate a Change log from GitHub +rake lint # Run robocop linter +rake rubocop # Run Rubocop lint checks +rake test:integration # Perform Integration Tests ``` +As with using the resources themselves the integration tests rely on a Service Principal Name being defined. Please see the information at the start of this page on how to generate this. + ## License | | | diff --git a/Rakefile b/Rakefile index 3c7410249..7539994c3 100644 --- a/Rakefile +++ b/Rakefile @@ -2,6 +2,9 @@ require 'rake/testtask' require 'rubocop/rake_task' +require 'inifile' +require 'passgen' +require_relative 'libraries/azure_backend' # Rubocop desc 'Run Rubocop lint checks' @@ -17,10 +20,59 @@ task lint: [:rubocop] task default: [:lint] namespace :test do + + # Specify the directory for the integration tests + integration_dir = "test/integration" + # run inspec check to verify that the profile is properly configured - task :check do - dir = File.join(File.dirname(__FILE__)) - sh("bundle exec inspec check #{dir}") + #task :check do + # dir = File.join(File.dirname(__FILE__)) + # sh("bundle exec inspec check #{dir}") + #end + + task :setup_integration_tests do + + azure_backend = AzureConnection.new + creds = azure_backend.spn + + # Determine the storage account name and the admin password + sa_name = (0...15).map { (65 + rand(26)).chr }.join.downcase + admin_password = Passgen::generate(length: 12, uppercase: true, lowercase: true, symbols: true, digits: true) + + puts "----> Setup" + # Create the plan that can be applied to Azure + cmd = format("cd %s/build/ && terraform plan -var 'subscription_id=%s' -var 'client_id=%s' -var 'client_secret=%s' -var 'tenant_id=%s' -var='storage_account_name=%s' -var='admin_password=%s' -out inspec-azure.plan", integration_dir, creds[:subscription_id], creds[:client_id], creds[:client_secret], creds[:tenant_id], sa_name, admin_password) + sh(cmd) + + # Apply the plan on Azure + cmd = format("cd %s/build/ && terraform apply inspec-azure.plan", integration_dir) + sh(cmd) + end + + task :run_integration_tests do + puts "----> Run" + + cmd = format("bundle exec inspec exec %s/verify", integration_dir) + sh(cmd) + end + + task :cleanup_integration_tests do + + azure_backend = AzureConnection.new + creds = azure_backend.spn + + puts "----> Cleanup" + cmd = format("cd %s/build/ && terraform destroy -force -var 'subscription_id=%s' -var 'client_id=%s' -var 'client_secret=%s' -var 'tenant_id=%s' -var='admin_password=dummy' -var='storage_account_name=dummy'", integration_dir, creds[:subscription_id], creds[:client_id], creds[:client_secret], creds[:tenant_id]) + sh(cmd) + + end + + desc "Perform Integration Tests" + task :integration do + Rake::Task["test:cleanup_integration_tests"].execute + Rake::Task["test:setup_integration_tests"].execute + Rake::Task["test:run_integration_tests"].execute + Rake::Task["test:cleanup_integration_tests"].execute end end diff --git a/examples/example_rg.rb b/examples/example_rg.rb deleted file mode 100644 index 57c4bd048..000000000 --- a/examples/example_rg.rb +++ /dev/null @@ -1,22 +0,0 @@ - -title 'Sample profile to test names resource group' - -control 'azure-rg-1.0' do - impact 1.0 - title 'Ensure that a resource group has the correct resources' - - resource_group_name = ENV['AZURE_RESOURCE_GROUP_NAME'] - - describe azure_rg(name: resource_group_name) do - its('total') { should be >= 7 } - its('vm_count') { should eq 1 } - end - - describe azure_rg(name: resource_group_name).where { type == 'Microsoft.Storage/storageAccounts' }.entries do - its('count') { should eq 1 } - end - - describe azure_rg(name: resource_group_name).contains(parameter: 'name', value: 'example-VM-ip') do - it { should be true } - end -end diff --git a/examples/example_vm_datadisks.rb b/examples/example_vm_datadisks.rb deleted file mode 100644 index e742e35d6..000000000 --- a/examples/example_vm_datadisks.rb +++ /dev/null @@ -1,19 +0,0 @@ - -title 'Sample profile to test the data disks of a vm' - -control 'azure-vm-datadisks-1.0' do - impact 1.0 - title 'Ensure that the machine has 1 data disk' - - hostname = ENV['AZURE_VM_NAME'] - resource_group_name = ENV['AZURE_RESOURCE_GROUP_NAME'] - - describe azure_vm_datadisks(host: hostname, resource_group: resource_group_name) do - its('has_disks?') { should be true } - its('count') { should eq 1 } - end - - describe azure_vm_datadisks(host: hostname, resource_group: resource_group_name).where { disk.zero? and size > 10 } do - its('entries') { should_not be_empty } - end -end diff --git a/examples/example_vm_image.rb b/examples/example_vm_image.rb deleted file mode 100644 index 55f6a63f5..000000000 --- a/examples/example_vm_image.rb +++ /dev/null @@ -1,24 +0,0 @@ - -title 'Sample profile to test the Image SKU of a vm' - -control 'azure-vm-1.0' do - impact 1.0 - title 'Ensure that the machine has an image SKU of 16.04.0-LTS' - - hostname = ENV['AZURE_VM_NAME'] - resource_group_name = ENV['AZURE_RESOURCE_GROUP_NAME'] - - describe azure_vm(host: hostname, resource_group: resource_group_name) do - its('sku') { should eq '16.04-LTS' } - its('publisher') { should eq 'Canonical' } - its('offer') { should eq 'UbuntuServer' } - its('size') { should eq 'Standard_DS1_v2' } - its('location') { should eq 'westeurope' } - its('boot_diagnostics?') { should be true } - its('nic_count') { should eq 1 } - its('username') { should eq 'azure' } - its('password_authentication?') { should be false } - its('ssh_key_count') { should eq 1 } - its('os_type') { should eq 'Linux' } - end -end \ No newline at end of file diff --git a/lib/resources/azure/azure_backend.rb b/lib/resources/azure/azure_backend.rb index ec93d4cd8..b6fc9031a 100644 --- a/lib/resources/azure/azure_backend.rb +++ b/lib/resources/azure/azure_backend.rb @@ -40,6 +40,19 @@ class AzureConnection # If a connection already exists then return it return @conn if defined?(@conn) + creds = spn + + # Create a new connection + token_provider = MsRestAzure::ApplicationTokenProvider.new(creds[:tenant_id], creds[:client_id], creds[:client_secret]) + @conn = MsRest::TokenCredentials.new(token_provider) + end + + # Method to retrieve the SPN credentials + # This is also used by the Rakefile to get the necessary creds to run the tests on the environment + # that has been created + # + # @author Russell Seymour + def spn @subscription_id = azure_subscription_id # Check that the credential exists @@ -50,9 +63,8 @@ class AzureConnection client_id = ENV['AZURE_CLIENT_ID'] || @credentials[subscription_id]['client_id'] client_secret = ENV['AZURE_CLIENT_SECRET'] || @credentials[subscription_id]['client_secret'] - # Create a new connection - token_provider = MsRestAzure::ApplicationTokenProvider.new(tenant_id, client_id, client_secret) - @conn = MsRest::TokenCredentials.new(token_provider) + # Return hash of the SPN information + { subscription_id: subscription_id, client_id: client_id, client_secret: client_secret, tenant_id: tenant_id } end private diff --git a/libraries/azure_vm.rb b/libraries/azure_vm.rb index 5efbd50f6..0b4d0e992 100644 --- a/libraries/azure_vm.rb +++ b/libraries/azure_vm.rb @@ -82,7 +82,11 @@ class AzureVm < Inspec.resource(1) # @return [Boolean] # def boot_diagnostics? - vm.diagnostics_profile.boot_diagnostics.enabled + if vm.diagnostics_profile + vm.diagnostics_profile.boot_diagnostics.enabled + else + false + end end # Determine how many network cards are connected to the machine @@ -138,7 +142,7 @@ class AzureVm < Inspec.resource(1) # @return [Integer] # def ssh_key_count - if !vm.os_profile.linux_configuration.nil? + if !vm.os_profile.linux_configuration.nil? && !vm.os_profile.linux_configuration.ssh.nil? vm.os_profile.linux_configuration.ssh.public_keys.length else 0 diff --git a/test/integration/azure/build/azure.tf b/test/integration/azure/build/azure.tf new file mode 100644 index 000000000..963977b40 --- /dev/null +++ b/test/integration/azure/build/azure.tf @@ -0,0 +1,173 @@ +# Configure variables +variable "storage_account_name" {} +variable "admin_password" {} + +variable "subscription_id" {} +variable "client_id" {} +variable "client_secret" {} +variable "tenant_id" {} + +variable "location" { + default = "West Europe" +} + +# Configure the Azure RM provider +provider "azurerm" { + subscription_id = "${var.subscription_id}" + client_id = "${var.client_id}" + client_secret = "${var.client_secret}" + tenant_id = "${var.tenant_id}" +} + +# Create a resource group for the machine to be created in +resource "azurerm_resource_group" "rg" { + name = "Inspec-Azure" + location = "${var.location}" +} + +# Create the storage account to be used +resource "azurerm_storage_account" "sa" { + name = "${var.storage_account_name}" + location = "${var.location}" + resource_group_name = "${azurerm_resource_group.rg.name}" + account_type = "Standard_LRS" +} + +# Create the container in which the hard disks for the machine(s) will be stored +resource "azurerm_storage_container" "container" { + name = "vhds" + resource_group_name = "${azurerm_resource_group.rg.name}" + storage_account_name = "${azurerm_storage_account.sa.name}" + container_access_type = "private" +} + +# Create a Public IP +resource "azurerm_public_ip" "public_ip_1" { + name = "Inspec-PublicIP-1" + location = "${var.location}" + resource_group_name = "${azurerm_resource_group.rg.name}" + public_ip_address_allocation = "dynamic" + domain_name_label = "linux-inspec-1" +} + +# Create the virtual network for the machines +resource "azurerm_virtual_network" "vnet" { + name = "Inspec-VNet" + address_space = ["10.1.1.0/24"] + location = "${var.location}" + resource_group_name = "${azurerm_resource_group.rg.name}" +} + +# Create the subnet +resource "azurerm_subnet" "subnet" { + name = "Inspec-Subnet" + resource_group_name = "${azurerm_resource_group.rg.name}" + virtual_network_name = "${azurerm_virtual_network.vnet.name}" + address_prefix = "10.1.1.0/24" +} + +# Create the NIC for the machine +resource "azurerm_network_interface" "nic1" { + name = "Inspec-NIC-1" + location = "${var.location}" + resource_group_name = "${azurerm_resource_group.rg.name}" + + ip_configuration { + name = "ipConfiguration1" + subnet_id = "${azurerm_subnet.subnet.id}" + private_ip_address_allocation = "dynamic" + } +} + +resource "azurerm_network_interface" "nic2" { + name = "Inspec-NIC-2" + location = "${var.location}" + resource_group_name = "${azurerm_resource_group.rg.name}" + + ip_configuration { + name = "ipConfiguration1" + subnet_id = "${azurerm_subnet.subnet.id}" + private_ip_address_allocation = "dynamic" + public_ip_address_id = "${azurerm_public_ip.public_ip_1.id}" + } +} + +# Create the machine for testing +resource "azurerm_virtual_machine" "vm_linux_internal" { + name = "Linux-Internal-VM" + location = "${var.location}" + resource_group_name = "${azurerm_resource_group.rg.name}" + network_interface_ids = ["${azurerm_network_interface.nic1.id}"] + vm_size = "Standard_DS2_v2" + + # Configure machine with Ubuntu + storage_image_reference { + publisher = "Canonical" + offer = "UbuntuServer" + sku = "16.04.0-LTS" + version = "latest" + } + + # Create the OS disk + storage_os_disk { + name = "linux-internal-osdisk" + vhd_uri = "${azurerm_storage_account.sa.primary_blob_endpoint}${azurerm_storage_container.container.name}/linux-internal-osdisk.vhd" + caching = "ReadWrite" + create_option = "FromImage" + } + + # Create 1 data disk to be used for testing + storage_data_disk { + name = "linux-datadisk-1" + vhd_uri = "${azurerm_storage_account.sa.primary_blob_endpoint}${azurerm_storage_container.container.name}/linux-internal-datadisk-1.vhd" + disk_size_gb = 15 + create_option = "empty" + lun = 0 + } + + # Specify the name of the machine and the access credentials + os_profile { + computer_name = "linux-internal-1" + admin_username = "azure" + admin_password = "${var.admin_password}" + } + + os_profile_linux_config { + disable_password_authentication = false + } +} + +resource "azurerm_virtual_machine" "vm_linux_external" { + name = "Linux-External-VM" + location = "${var.location}" + resource_group_name = "${azurerm_resource_group.rg.name}" + network_interface_ids = ["${azurerm_network_interface.nic2.id}"] + vm_size = "Standard_DS2_v2" + + # Configure machine with Ubuntu + storage_image_reference { + publisher = "Canonical" + offer = "UbuntuServer" + sku = "16.04.0-LTS" + version = "latest" + } + + # Create the OS disk + storage_os_disk { + name = "linux-external-osdisk" + vhd_uri = "${azurerm_storage_account.sa.primary_blob_endpoint}${azurerm_storage_container.container.name}/linux-external-osdisk.vhd" + caching = "ReadWrite" + create_option = "FromImage" + } + + # Specify the name of the machine and the access credentials + os_profile { + computer_name = "linux-external-1" + admin_username = "azure" + admin_password = "${var.admin_password}" + } + + os_profile_linux_config { + disable_password_authentication = false + } +} diff --git a/test/integration/azure/verify/controls/datadisks.rb b/test/integration/azure/verify/controls/datadisks.rb new file mode 100644 index 000000000..e84c48b4a --- /dev/null +++ b/test/integration/azure/verify/controls/datadisks.rb @@ -0,0 +1,24 @@ + +title 'Virtual Machine Data Disks' + +control 'azure-vm-datadisks-1.0' do + + impact 1.0 + title 'Ensure VM has 1 data disk and it is of the correct size' + + # Set the name of the vm and resource_group + vm_internal = 'Linux-Internal-VM' + resource_group_name = 'Inspec-Azure' + + # Ensure that the named machine has 1 data disk + describe azure_vm_datadisks(host: vm_internal, resource_group: resource_group_name) do + its('has_disks?') { should be true } + its('count') { should eq 1 } + end + + # Ensure that the first data disk on the same machine size is greater than 10gb + describe azure_vm_datadisks(host: vm_internal, resource_group: resource_group_name).where { disk.zero? and size > 10 } do + its('entries') { should_not be_empty } + end + +end \ No newline at end of file diff --git a/test/integration/azure/verify/controls/resources.rb b/test/integration/azure/verify/controls/resources.rb new file mode 100644 index 000000000..f02fe2c74 --- /dev/null +++ b/test/integration/azure/verify/controls/resources.rb @@ -0,0 +1,19 @@ + +title 'Check Azure Resources' + +control 'azure-resources-1.0' do + + impact 1.0 + title 'Check that the resource group has the correct resources' + + # Ensure that the expected resources have been deployed + describe azure_rg(name: 'Inspec-Azure') do + its('total') { should eq 7 } + its('vm_count') { should eq 2 } + its('nic_count') { should eq 2 } + its('public_ip_count') { should eq 1 } + its('sa_count') { should eq 1 } + its('vnet_count') { should eq 1 } + end + +end \ No newline at end of file diff --git a/test/integration/azure/verify/controls/vm.rb b/test/integration/azure/verify/controls/vm.rb new file mode 100644 index 000000000..e0c3df2f2 --- /dev/null +++ b/test/integration/azure/verify/controls/vm.rb @@ -0,0 +1,24 @@ + +title 'Virtual Machine Properties' + +control 'azure-vm-1.0' do + + impact 1.0 + title 'Ensure VM was built with the correct Image and has the correct properties' + + # Ensure that the virtual machine has been created with the correct attributes + describe azure_vm(host: 'Linux-Internal-VM', resource_group: 'Inspec-Azure') do + its('sku') { should eq '16.04.0-LTS' } + its('publisher') { should eq 'Canonical' } + its('offer') { should eq 'UbuntuServer' } + its('size') { should eq 'Standard_DS2_v2' } + its('location') { should eq 'westeurope' } + its('boot_diagnostics?') { should be false } + its('nic_count') { should eq 1 } + its('username') { should eq 'azure' } + its('password_authentication?') { should be true } + its('ssh_key_count') { should eq 0 } + its('os_type') { should eq 'Linux' } + end + +end diff --git a/test/integration/azure/verify/inspec.yml b/test/integration/azure/verify/inspec.yml new file mode 100644 index 000000000..5bd38ef01 --- /dev/null +++ b/test/integration/azure/verify/inspec.yml @@ -0,0 +1,4 @@ +name: inspec-azure-integration-tests +depends: + - name: azure + path: ../../../ \ No newline at end of file From 79c51a3b94df38299e4786a35c916c13ca4c394e Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Wed, 1 Mar 2017 16:46:56 +0000 Subject: [PATCH 059/206] Updated CHANGELOG Signed-off-by: Russell Seymour --- CHANGELOG.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cdce00334..0b9c634b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,11 @@ # Change Log -## [0.4.1](https://github.com/chef/inspec-azure/tree/0.4.1) (2017-02-23) -[Full Changelog](https://github.com/chef/inspec-azure/compare/0.4.0...0.4.1) +## [0.5.0](https://github.com/chef/inspec-azure/tree/0.5.0) (2017-03-01) +[Full Changelog](https://github.com/chef/inspec-azure/compare/0.4.0...0.5.0) **Implemented enhancements:** +- Add integration tests [\#19](https://github.com/chef/inspec-azure/issues/19) - Specify the subscription to be used by index [\#15](https://github.com/chef/inspec-azure/issues/15) **Fixed bugs:** @@ -17,6 +18,9 @@ **Merged pull requests:** +- Added integration tests for current resources [\#20](https://github.com/chef/inspec-azure/pull/20) ([russellseymour](https://github.com/russellseymour)) +- add contribution guidelines and license [\#18](https://github.com/chef/inspec-azure/pull/18) ([chris-rock](https://github.com/chris-rock)) +- remove .kitchen logs [\#17](https://github.com/chef/inspec-azure/pull/17) ([chris-rock](https://github.com/chris-rock)) - Using Credentials [\#16](https://github.com/chef/inspec-azure/pull/16) ([russellseymour](https://github.com/russellseymour)) ## [0.4.0](https://github.com/chef/inspec-azure/tree/0.4.0) (2017-02-23) From a4e0df698acc945620deb871d37e36e5c9ceb83d Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Wed, 1 Mar 2017 17:14:09 +0000 Subject: [PATCH 060/206] Renamed resources for readability Closes #27 Signed-off-by: Russell Seymour --- .../resources/azure/azure_virtual_machine.rb | 2 +- ...ure_vm_datadisks.rb => azure_virtual_machine_datadisks.rb} | 2 +- test/integration/azure/verify/controls/datadisks.rb | 4 ++-- test/integration/azure/verify/controls/vm.rb | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) rename libraries/azure_vm.rb => lib/resources/azure/azure_virtual_machine.rb (99%) rename libraries/{azure_vm_datadisks.rb => azure_virtual_machine_datadisks.rb} (98%) diff --git a/libraries/azure_vm.rb b/lib/resources/azure/azure_virtual_machine.rb similarity index 99% rename from libraries/azure_vm.rb rename to lib/resources/azure/azure_virtual_machine.rb index 0b4d0e992..aa54a19d8 100644 --- a/libraries/azure_vm.rb +++ b/lib/resources/azure/azure_virtual_machine.rb @@ -7,7 +7,7 @@ require 'azure_backend' # # @attr_reader [Azure::ARM::Compute::Models::VirtualMachine] vm VM object as retrieved from Azure class AzureVm < Inspec.resource(1) - name 'azure_vm' + name 'azure_virtual_machine' desc " This resource gathers information about which image the vm was created from diff --git a/libraries/azure_vm_datadisks.rb b/libraries/azure_virtual_machine_datadisks.rb similarity index 98% rename from libraries/azure_vm_datadisks.rb rename to libraries/azure_virtual_machine_datadisks.rb index d765d592d..ec64c85de 100644 --- a/libraries/azure_vm_datadisks.rb +++ b/libraries/azure_virtual_machine_datadisks.rb @@ -8,7 +8,7 @@ require 'uri' # # @attr_reader [Array] params Array of hashes containing information about all the data disks attached to the machine class AzureVmDataDisks < Inspec.resource(1) - name 'azure_vm_datadisks' + name 'azure_virtual_machine_datadisks' desc " This resource gather information about the data disks attached to a virtual machine diff --git a/test/integration/azure/verify/controls/datadisks.rb b/test/integration/azure/verify/controls/datadisks.rb index e84c48b4a..682b9bf67 100644 --- a/test/integration/azure/verify/controls/datadisks.rb +++ b/test/integration/azure/verify/controls/datadisks.rb @@ -11,13 +11,13 @@ control 'azure-vm-datadisks-1.0' do resource_group_name = 'Inspec-Azure' # Ensure that the named machine has 1 data disk - describe azure_vm_datadisks(host: vm_internal, resource_group: resource_group_name) do + describe azure_virtual_machine_datadisks(host: vm_internal, resource_group: resource_group_name) do its('has_disks?') { should be true } its('count') { should eq 1 } end # Ensure that the first data disk on the same machine size is greater than 10gb - describe azure_vm_datadisks(host: vm_internal, resource_group: resource_group_name).where { disk.zero? and size > 10 } do + describe azure_virtual_machine_datadisks(host: vm_internal, resource_group: resource_group_name).where { disk.zero? and size > 10 } do its('entries') { should_not be_empty } end diff --git a/test/integration/azure/verify/controls/vm.rb b/test/integration/azure/verify/controls/vm.rb index e0c3df2f2..d48459618 100644 --- a/test/integration/azure/verify/controls/vm.rb +++ b/test/integration/azure/verify/controls/vm.rb @@ -7,7 +7,7 @@ control 'azure-vm-1.0' do title 'Ensure VM was built with the correct Image and has the correct properties' # Ensure that the virtual machine has been created with the correct attributes - describe azure_vm(host: 'Linux-Internal-VM', resource_group: 'Inspec-Azure') do + describe azure_virtual_machine(host: 'Linux-Internal-VM', resource_group: 'Inspec-Azure') do its('sku') { should eq '16.04.0-LTS' } its('publisher') { should eq 'Canonical' } its('offer') { should eq 'UbuntuServer' } From 6f57529a44e62f0c8e97ae0419e3e0c430d617b6 Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Wed, 1 Mar 2017 17:20:59 +0000 Subject: [PATCH 061/206] Renamed the host attribute on azure_virtual_machine_resource Updated README file Closes #26 Signed-off-by: Russell Seymour --- README.md | 6 +++--- lib/resources/azure/azure_virtual_machine.rb | 4 ++-- libraries/azure_virtual_machine_datadisks.rb | 2 +- test/integration/azure/verify/controls/datadisks.rb | 4 ++-- test/integration/azure/verify/controls/vm.rb | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index f987f06ab..57571694c 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ control 'azure-1' do impact 1.0 title 'Checks that the machine was built from the correct image' - describe azurevm_image(host: 'example-01', resource_group: 'MyResourceGroup') do + describe azure_virtual_machine(name: 'example-01', resource_group: 'MyResourceGroup') do its('sku') { should eq '16.04.0-LTS' } its('publisher') { should ieq 'Canonical' } its('offer') { should ieq 'UbuntuServer' } @@ -201,12 +201,12 @@ control 'azure-1' do impact 1.0 title 'Checks that the machine has exactly one data disk and it is over 10gb in size' - describe azure_vm(host: 'example-01', resource_group: 'MyResourceGroup') do + describe azure_virtual_machine(name: 'example-01', resource_group: 'MyResourceGroup') do its('has_disks?') { should be true } its('count') { should eq 1 } end - describe azure_vm_datadisks(host: 'example-01', resource_group: 'MyResourceGroup').where { disk == 0 and size > 10 } do + describe azure_virtual_machine_datadisks(name: 'example-01', resource_group: 'MyResourceGroup').where { disk == 0 and size > 10 } do its('entries') { should_not be_empty } end end diff --git a/lib/resources/azure/azure_virtual_machine.rb b/lib/resources/azure/azure_virtual_machine.rb index aa54a19d8..eac774bb8 100644 --- a/lib/resources/azure/azure_virtual_machine.rb +++ b/lib/resources/azure/azure_virtual_machine.rb @@ -14,7 +14,7 @@ class AzureVm < Inspec.resource(1) " example " - describe azure_vm(host: 'acme-test-01', resource_group: 'ACME') do + describe azure_vm(name: 'acme-test-01', resource_group: 'ACME') do its('sku') { should eq '16.04.0-LTS'} end " @@ -31,7 +31,7 @@ class AzureVm < Inspec.resource(1) def initialize(opts) opts = opts helpers = Helpers.new - @vm = helpers.get_vm(opts[:host], opts[:resource_group]) + @vm = helpers.get_vm(opts[:name], opts[:resource_group]) # Ensure that the vm is an object raise format('An error has occured: %s', vm) if vm.instance_of?(String) diff --git a/libraries/azure_virtual_machine_datadisks.rb b/libraries/azure_virtual_machine_datadisks.rb index ec64c85de..3c65b7aa2 100644 --- a/libraries/azure_virtual_machine_datadisks.rb +++ b/libraries/azure_virtual_machine_datadisks.rb @@ -34,7 +34,7 @@ class AzureVmDataDisks < Inspec.resource(1) @helpers = Helpers.new # Get the VM that needs to be interrogated - vm = @helpers.get_vm(@opts[:host], @opts[:resource_group]) + vm = @helpers.get_vm(@opts[:name], @opts[:resource_group]) # Parse the data disks @params = parse_data_disks(vm.storage_profile.data_disks) diff --git a/test/integration/azure/verify/controls/datadisks.rb b/test/integration/azure/verify/controls/datadisks.rb index 682b9bf67..69fffb4e7 100644 --- a/test/integration/azure/verify/controls/datadisks.rb +++ b/test/integration/azure/verify/controls/datadisks.rb @@ -11,13 +11,13 @@ control 'azure-vm-datadisks-1.0' do resource_group_name = 'Inspec-Azure' # Ensure that the named machine has 1 data disk - describe azure_virtual_machine_datadisks(host: vm_internal, resource_group: resource_group_name) do + describe azure_virtual_machine_datadisks(name: vm_internal, resource_group: resource_group_name) do its('has_disks?') { should be true } its('count') { should eq 1 } end # Ensure that the first data disk on the same machine size is greater than 10gb - describe azure_virtual_machine_datadisks(host: vm_internal, resource_group: resource_group_name).where { disk.zero? and size > 10 } do + describe azure_virtual_machine_datadisks(name: vm_internal, resource_group: resource_group_name).where { disk.zero? and size > 10 } do its('entries') { should_not be_empty } end diff --git a/test/integration/azure/verify/controls/vm.rb b/test/integration/azure/verify/controls/vm.rb index d48459618..46ad5e945 100644 --- a/test/integration/azure/verify/controls/vm.rb +++ b/test/integration/azure/verify/controls/vm.rb @@ -7,7 +7,7 @@ control 'azure-vm-1.0' do title 'Ensure VM was built with the correct Image and has the correct properties' # Ensure that the virtual machine has been created with the correct attributes - describe azure_virtual_machine(host: 'Linux-Internal-VM', resource_group: 'Inspec-Azure') do + describe azure_virtual_machine(name: 'Linux-Internal-VM', resource_group: 'Inspec-Azure') do its('sku') { should eq '16.04.0-LTS' } its('publisher') { should eq 'Canonical' } its('offer') { should eq 'UbuntuServer' } From 7842d37fb3e4776ca1d87966a4ce4868f9a23474 Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Wed, 1 Mar 2017 17:32:17 +0000 Subject: [PATCH 062/206] Renamed azure_rg resource Closes #25 Signed-off-by: Russell Seymour --- README.md | 6 +++--- .../resources/azure/azure_resource_group.rb | 2 +- test/integration/azure/verify/controls/resources.rb | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) rename libraries/azure_rg.rb => lib/resources/azure/azure_resource_group.rb (99%) diff --git a/README.md b/README.md index 57571694c..465166f7d 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ end ### Available Resources -- `azure_rg` - This resource reads information about the resources in the specified resource group +- `azure_resource_group` - This resource reads information about the resources in the specified resource group | Resource Name | Resources | Description | |---------------|-----------|-------------| @@ -136,7 +136,7 @@ control 'azure-1' do impact 1.0 title 'Checks that there is only one storage account in the resource group' - describe azure_rg(name: 'MyResourceGroup').where { type == 'Microsoft.Storage/storageAccounts' }.entries do + describe azure_resource_group(name: 'MyResourceGroup').where { type == 'Microsoft.Storage/storageAccounts' }.entries do its('count') { should eq 1 } end end @@ -149,7 +149,7 @@ control 'azure-1' do impact 1.0 title 'Checks a resource with the name "example-VM" exists' - describe azure_rg(name: 'MyResourceGroup').contains(parameter: 'name', value: 'example-VM') do + describe azure_resource_group(name: 'MyResourceGroup').contains(parameter: 'name', value: 'example-VM') do it { should be true } end end diff --git a/libraries/azure_rg.rb b/lib/resources/azure/azure_resource_group.rb similarity index 99% rename from libraries/azure_rg.rb rename to lib/resources/azure/azure_resource_group.rb index 58850a2bb..5bf216b05 100644 --- a/libraries/azure_rg.rb +++ b/lib/resources/azure/azure_resource_group.rb @@ -9,7 +9,7 @@ require 'azure_backend' # @attr_reader [Azure::ARM::Resources::Models::ResourceGroup] rg Resource group under interrogation # @attr_reader [Hashtable] counts Hashtable containing the counts of the different types in the resource group class AzureRg < Inspec.resource(1) - name 'azure_rg' + name 'azure_resource_group' desc " This resource returns information about the specified resource group diff --git a/test/integration/azure/verify/controls/resources.rb b/test/integration/azure/verify/controls/resources.rb index f02fe2c74..9417f5443 100644 --- a/test/integration/azure/verify/controls/resources.rb +++ b/test/integration/azure/verify/controls/resources.rb @@ -7,7 +7,7 @@ control 'azure-resources-1.0' do title 'Check that the resource group has the correct resources' # Ensure that the expected resources have been deployed - describe azure_rg(name: 'Inspec-Azure') do + describe azure_resource_group(name: 'Inspec-Azure') do its('total') { should eq 7 } its('vm_count') { should eq 2 } its('nic_count') { should eq 2 } From 3e7880627f5e04abf1b1427b0749fb0c8e384127 Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Thu, 2 Mar 2017 10:11:18 +0000 Subject: [PATCH 063/206] Added resource documentation Closes #23 Signed-off-by: Russell Seymour --- docs/resources/azure_resource_group.md.erb | 125 +++++++++++++++ docs/resources/azure_virtual_machine.md.erb | 151 ++++++++++++++++++ .../azure_virtual_machine_datadisks.md.erb | 71 ++++++++ 3 files changed, 347 insertions(+) create mode 100644 docs/resources/azure_resource_group.md.erb create mode 100644 docs/resources/azure_virtual_machine.md.erb create mode 100644 docs/resources/azure_virtual_machine_datadisks.md.erb diff --git a/docs/resources/azure_resource_group.md.erb b/docs/resources/azure_resource_group.md.erb new file mode 100644 index 000000000..9c8d0367a --- /dev/null +++ b/docs/resources/azure_resource_group.md.erb @@ -0,0 +1,125 @@ +--- +title: About the azure_resource_group Resource +--- + +# azure_resource_group + +Use the `azure_resource_group` InSpec audit resource to ensure that an Azure Resource group has the correct resources. + +## Syntax + +The name of the resource group is specified as an attribute on the resource: + +```ruby +describe azure_resource_group(name: 'MyResourceGroup') do + its('matcher') { should eq 'value' } +end +``` + +where + +* `MyResourceGroup` is the name of the resource group being interrogated +* `matcher` is one of + - `total` + - `count` + - `nic_count` + - `vm_count` + - `vnet_count` + - `sa_count` + - `public_ip_count` + - `contains` +* `value` is the expected output from the matcher + +For example: + +```ruby +describe azure_resource_group(name: 'ChefAutomate') do + its('total') { should eq 7} + its('nic_count') { should eq 1 } + its('vm_count') { should eq 1 } +end +``` + +## Matchers + +This InSpec audit resource has the following matchers: + +### eq + +<%= partial "/shared/matcher_eq" %> + +### total + +The total number of resources in the resource group + +### nic_count + +The number of network interface cards in the resource group + +### vm_count + +The number of virtual machines in the resource group + +### vnet_count + +The number of virtual networks in the resource group + +### sa_count + +The number of storage accounts in the resource group + +### public_ip_count + +The number of Public IP Addresses in the resource group + +### contains + +The `contains` filter allows testing of resources that are not directly supported by the resource pack: + +```ruby +its('contains') { should be true } +``` + +This matcher is best used in conjunction with filters, for example the following tests that a Managed Disk image exists in the resource group + +```ruby +describe azure_resource_group(name: 'MyResourceGroup').where { type: 'Microsoft.Compute/images' } do + its('contains') { should be true } +end +``` + +### count + +The `count` filter allows testing for the number of resources that are not directly supported by the resource pack: + +As before it is best used in conjunction with a filter. The following checks that there is at least 1 Managed Disk Image in the resource group. + +```ruby +describe azure_resource_group(name: 'MyResourceGroup').where { type: 'Microsoft.Compute/images' } do + its('count') { should > 1 } +end +``` + +## Examples + +The following examples show how to use this InSpec audit resource + +### Test Resource Group has the correct number of resources + +```ruby +describe azure_resource_group(name: 'ChefAutomate') do + its('total') { should eq 7} +``` + +### Ensure that the Resource Group contains the correct resources + +```ruby +describe azure_resource_group(name: 'ChefAutomate') do + its('total') { should eq 7 } + its('vm_count') { should eq 2 } + its('nic_count') { should eq 2 } + its('public_ip_count') { should eq 1 } + its('sa_count') { should eq 1 } + its('vnet_count') { should eq 1 } +end +``` \ No newline at end of file diff --git a/docs/resources/azure_virtual_machine.md.erb b/docs/resources/azure_virtual_machine.md.erb new file mode 100644 index 000000000..32d67f201 --- /dev/null +++ b/docs/resources/azure_virtual_machine.md.erb @@ -0,0 +1,151 @@ +--- +title: About the azure_virtual_machine Resource +--- + +# azure_virtual_machine + +Use the `azure_virtual_machine` InSpec audit resource to ensure that a Virtual Machine has been provisionned correctly. + +## Syntax + +The name of the machine and the resourece group are required as attributes to the resource. + +```ruby +describe azure_virtual_machine(name: 'MyVM', resource_group: 'MyResourceGroup') do + its('matcher') { should eq 'value' } +end +``` + +where + +* `MyVm` is the name of the virtual machine as seen in Azure. (It is **not** the hostname of the machine) +* `MyResourceGroup` is the name of the resouce group that the machine is in. +* `matcher` is one of + - `publisher` + - `offer` + - `sku` + - `size` + - `location` + - `boot_diagnostics?` + - `nic_count` + - `username` + - `computername` + - `hostname` + - `password_authentication?` + - `ssh_key_count` + - `os_type` +* `value` is the expected output from the matcher + +For example: + +```ruby +describe azure_virtual_machine(name: 'chef-automate-01', resource_group: 'ChefAutomate') do + its('os_type') { should eq 'Linux' } + its('boot_diagnostics?') { should be false } +end +``` + +## Matchers + +This InSpec audit resource has the following matchers: + +### eq + +<%= partial "/shared/matcher_eq" %> + +### publisher + +The publisher of the image from which this machine was built. + +This will be `nil` if the machine was created from a custom image. + +### offer + +The offer from the publisher of the build image. + +This will be `nil` if the machine was created from a custom image. + +### sku + +The item from the publisher that was used to create the image. + +This will be `nil` if the machine was created from a custom image. + +### size + +The size of the machine in Azure + +```ruby +its('size') { should eq 'Standard_DS2_v2' } +``` + +### location + +Where the machine is located + +```ruby +its('location') { should eq 'West Europe' } +``` + +### boot_diagnostics? + +Boolean test to see if boot diagnostics have been enabled on the machine + +### nic_count + +The number of network interface cards that have been attached to the machine + +### username + +The admin username that was assigned to the machine + +NOTE: Azure does not allow the use of `Administrator` as the admin username on a Windows machine + +### computername + +The computername of the machine. This is what was assigned to the machine during deployment and is what _should_ be returned by the `hostname` command. + +### hostname + +Alias for computername. + +### password_authentication? + +Boolean to state of password authentication is enabled or not for the admin user. + +```ruby +its('password_authentication?') { should be false } +``` + +This only applies to Linux machines and will always return `true` on Windows. + +### ssh_key_count + +Returns how many SSH keys have been applied to the machine. + +This only applies to Linux machines and will always return `0` on Windows. + +### os_type + +Generic test that returns either `Linux` or `Windows`. + +## Examples + +The following examples show how to use this InSpec audit resource. + +### Test that the machine was built from a Windows image + +```ruby +describe azure_virtual_machine(name: 'chef-ws-01', resource_group: 'ChefAutomate') do + its('publisher') { should eq 'MicrosoftWindowsServer' } + its('offer') { should eq 'WindowsServer' } + its('sku') { should eq '2012-R2-Datacenter' } +end +``` + +### Ensure the machine is in the correct location + +```ruby +describe azure_virtual_machine(name: 'chef-ws-01', resource_group: 'ChefAutomate') do + its('location') { should eq 'West Europe' } +end diff --git a/docs/resources/azure_virtual_machine_datadisks.md.erb b/docs/resources/azure_virtual_machine_datadisks.md.erb new file mode 100644 index 000000000..8980fbaee --- /dev/null +++ b/docs/resources/azure_virtual_machine_datadisks.md.erb @@ -0,0 +1,71 @@ +--- +title: About the azure_virtual_machine_datadisks Resource +--- + +# azure_virtual_machine_datadisks + +Use this resource to check that the correct number of data disks have been applied to the machine and that they are of the correct size. + +## Syntax + +The name of the resource group and machine are required to use this resource. + +```ruby +describe azure_virtual_machine(name: 'MyVM', resource_group: 'MyResourceGroup') do + its('matcher') { should eq 'value' } +end +``` + +where + +* `MyVm` is the name of the virtual machine as seen in Azure. (It is **not** the hostname of the machine) +* `MyResourceGroup` is the name of the resouce group that the machine is in. +* `matcher` is one of + - `count` the number of data disks attached to the machine + - `has_disks?` boolean test denoting if data disks are attached + - `entries` used with the `where` filter to check the size of a disk +* `value` is the expected output fdrom the matcher + +## Matchers + +This InSpec audit resource has the following matchers: + +### eq + +<%= partial "/shared/matcher_eq" %> + +### count + +Returns the number of data disks attached to the machine + +```ruby +its('count') { should eq 1 } +``` + +### has_disks? + +Returns a boolean denoting if any data disks are attached to the machine + +```ruby +its('has_disks?') { should be true } +``` + +### entries + +The `entries` filter can be used to check the attributes of indivdual data disks: + +```ruby +its('entries') { should_not be_empty } +``` + +This matcher is best used in conjunction with filters. For example the following tests that the first data disk has a capacity greater than 10gb. + +```ruby +describe azure_virtual_machine_datadisks(name: 'MyVM', resource_group: 'MyResourceGroup').where { disk.zero? and size > 10 } do + its('entries') { should_not be_empty } +end +``` + +## Examples + +None \ No newline at end of file From cdc643974d50e1000b70ad50a03f963d0bf25528 Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Thu, 2 Mar 2017 10:41:05 +0000 Subject: [PATCH 064/206] Removed double space after `.` in text Copied `eq` matcher text instead of linking Renamed template files Signed-off-by: Russell Seymour --- README.md | 22 +++++++++---------- ...e_group.md.erb => azure_resource_group.md} | 10 +++++++-- ...achine.md.erb => azure_virtual_machine.md} | 12 +++++++--- ...erb => azure_virtual_machine_datadisks.md} | 12 +++++++--- 4 files changed, 37 insertions(+), 19 deletions(-) rename docs/resources/{azure_resource_group.md.erb => azure_resource_group.md} (83%) rename docs/resources/{azure_virtual_machine.md.erb => azure_virtual_machine.md} (83%) rename docs/resources/{azure_virtual_machine_datadisks.md.erb => azure_virtual_machine_datadisks.md} (70%) diff --git a/README.md b/README.md index 465166f7d..382cd0893 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # InSpec Azure Resource Pack -This resource pack provides resources for Azure Resources. It will ship with the required resources to write your own compliance rules: +This resource pack provides resources for Azure Resources. It will ship with the required resources to write your own compliance rules: ``` ├── README.md - this readme @@ -22,7 +22,7 @@ The information from the SPN can be specified either in a file `~/.azure/credent ### Credentials File -The simplest way is to create the file `~/.azure/credentials` with the following format. The profile is configured to look for this file by default do no settings are required. +The simplest way is to create the file `~/.azure/credentials` with the following format. The profile is configured to look for this file by default do no settings are required. ``` [] @@ -43,7 +43,7 @@ A different credentials file, with the same format, can be specified as an envir AZURE_CREDS_FILE="/path/to/another/file" inspec exec inspec-azure ``` -Note that this file format supports multiple subscription_ids. By default `inspec-azure` will pick the first subscription in the file. However if another subscription should be used then specify it in the environment variable `AZURE_SUBSCRIPTION_ID`. +Note that this file format supports multiple subscription_ids. By default `inspec-azure` will pick the first subscription in the file. However if another subscription should be used then specify it in the environment variable `AZURE_SUBSCRIPTION_ID`. ```bash AZURE_SUBSCRIPTION_ID="2fbdbb02-df2e-11e6-bf01-fe55135034f3" inspec exec inspec-azure @@ -75,7 +75,7 @@ AZURE_TENANT_ID="6ad89b58-df2e-11e6-bf01-fe55135034f3" inspec exec inspec-azure ## Use the resources -Since this is an InSpec resource pack, it only defines InSpec resources. It includes example tests only. To easily use the Azure InSpec resources in your tests do the following: +Since this is an InSpec resource pack, it only defines InSpec resources. It includes example tests only. To easily use the Azure InSpec resources in your tests do the following: ### Create a new profile @@ -167,11 +167,11 @@ end | | boot_diagnostics? | Whether boot diagnostics have been enabled or not | | | nic_count | How many network cards are attached to the machine | | | username | The admin username that has been assigned to the machine | -| | computername | Computer name of the machine in the operating system. This maybe different to the VM name as seen in Azure | +| | computername | Computer name of the machine in the operating system. This maybe different to the VM name as seen in Azure | | | hostname | Alias for computername | -| | password_authentication? | If password authentication is enabled. For Windows machines this is always true | -| | ssh_key_count | How many SSH public keys have been added to the machine. For Windows this is always 0 | -| | os_type | Tyep type of operating system. Linux or Windows | +| | password_authentication? | If password authentication is enabled. For Windows machines this is always true | +| | ssh_key_count | How many SSH public keys have been added to the machine. For Windows this is always 0 | +| | os_type | Tyep type of operating system. Linux or Windows | - `azure_vm_datadisks` - Resource to read the data disks for a machine and check that they are of the correct size etc @@ -181,7 +181,7 @@ end | | count | Returns the number of data disks attached to the machine | | | where | Filter that allows for different tests to be performed, see examples below | -When data disks are retrieved from a machine they are given as an array. The `where` filter will interogate the array according the criteria it is given. The followin attributes are available in the filter: +When data disks are retrieved from a machine they are given as an array. The `where` filter will interogate the array according the criteria it is given. The followin attributes are available in the filter: - `disk` - Disk number (0 index based) - `caching` - What sort of caching is enabled on the data disk @@ -214,7 +214,7 @@ end ## Testing -The `test/integration/verify/controls` directory contains all of the tests that are run during integration tests. These can be used as examples of how to use this resource pack. +The `test/integration/verify/controls` directory contains all of the tests that are run during integration tests. These can be used as examples of how to use this resource pack. Rake tasks have been configured to enable the running of the integration tests: @@ -225,7 +225,7 @@ rake rubocop # Run Rubocop lint checks rake test:integration # Perform Integration Tests ``` -As with using the resources themselves the integration tests rely on a Service Principal Name being defined. Please see the information at the start of this page on how to generate this. +As with using the resources themselves the integration tests rely on a Service Principal Name being defined. Please see the information at the start of this page on how to generate this. ## License diff --git a/docs/resources/azure_resource_group.md.erb b/docs/resources/azure_resource_group.md similarity index 83% rename from docs/resources/azure_resource_group.md.erb rename to docs/resources/azure_resource_group.md index 9c8d0367a..95db3bae0 100644 --- a/docs/resources/azure_resource_group.md.erb +++ b/docs/resources/azure_resource_group.md @@ -6,6 +6,10 @@ title: About the azure_resource_group Resource Use the `azure_resource_group` InSpec audit resource to ensure that an Azure Resource group has the correct resources. +## References + +- [Azure Ruby SDK - Resources](https://github.com/Azure/azure-sdk-for-ruby/tree/master/management/azure_mgmt_resources) + ## Syntax The name of the resource group is specified as an attribute on the resource: @@ -46,7 +50,9 @@ This InSpec audit resource has the following matchers: ### eq -<%= partial "/shared/matcher_eq" %> +Use the `eq` matcher to test the equality of two values: `its('Port') { should eq '22' }`. + +Using `its('Port') { should eq 22 }` will fail because `22` is not a string value! Use the `cmp` matcher for less restrictive value comparisons. ### total @@ -92,7 +98,7 @@ end The `count` filter allows testing for the number of resources that are not directly supported by the resource pack: -As before it is best used in conjunction with a filter. The following checks that there is at least 1 Managed Disk Image in the resource group. +As before it is best used in conjunction with a filter. The following checks that there is at least 1 Managed Disk Image in the resource group. ```ruby describe azure_resource_group(name: 'MyResourceGroup').where { type: 'Microsoft.Compute/images' } do diff --git a/docs/resources/azure_virtual_machine.md.erb b/docs/resources/azure_virtual_machine.md similarity index 83% rename from docs/resources/azure_virtual_machine.md.erb rename to docs/resources/azure_virtual_machine.md index 32d67f201..17ffd076a 100644 --- a/docs/resources/azure_virtual_machine.md.erb +++ b/docs/resources/azure_virtual_machine.md @@ -6,6 +6,10 @@ title: About the azure_virtual_machine Resource Use the `azure_virtual_machine` InSpec audit resource to ensure that a Virtual Machine has been provisionned correctly. +## References + +- [Azure Ruby SDK - Compute](https://github.com/Azure/azure-sdk-for-ruby/tree/master/management/azure_mgmt_compute) + ## Syntax The name of the machine and the resourece group are required as attributes to the resource. @@ -18,7 +22,7 @@ end where -* `MyVm` is the name of the virtual machine as seen in Azure. (It is **not** the hostname of the machine) +* `MyVm` is the name of the virtual machine as seen in Azure. (It is **not** the hostname of the machine) * `MyResourceGroup` is the name of the resouce group that the machine is in. * `matcher` is one of - `publisher` @@ -51,7 +55,9 @@ This InSpec audit resource has the following matchers: ### eq -<%= partial "/shared/matcher_eq" %> +Use the `eq` matcher to test the equality of two values: `its('Port') { should eq '22' }`. + +Using `its('Port') { should eq 22 }` will fail because `22` is not a string value! Use the `cmp` matcher for less restrictive value comparisons. ### publisher @@ -103,7 +109,7 @@ NOTE: Azure does not allow the use of `Administrator` as the admin username on ### computername -The computername of the machine. This is what was assigned to the machine during deployment and is what _should_ be returned by the `hostname` command. +The computername of the machine. This is what was assigned to the machine during deployment and is what _should_ be returned by the `hostname` command. ### hostname diff --git a/docs/resources/azure_virtual_machine_datadisks.md.erb b/docs/resources/azure_virtual_machine_datadisks.md similarity index 70% rename from docs/resources/azure_virtual_machine_datadisks.md.erb rename to docs/resources/azure_virtual_machine_datadisks.md index 8980fbaee..dc65f1339 100644 --- a/docs/resources/azure_virtual_machine_datadisks.md.erb +++ b/docs/resources/azure_virtual_machine_datadisks.md @@ -6,6 +6,10 @@ title: About the azure_virtual_machine_datadisks Resource Use this resource to check that the correct number of data disks have been applied to the machine and that they are of the correct size. +## References + +- [Azure Ruby SDK - Compute](https://github.com/Azure/azure-sdk-for-ruby/tree/master/management/azure_mgmt_compute) + ## Syntax The name of the resource group and machine are required to use this resource. @@ -18,7 +22,7 @@ end where -* `MyVm` is the name of the virtual machine as seen in Azure. (It is **not** the hostname of the machine) +* `MyVm` is the name of the virtual machine as seen in Azure. (It is **not** the hostname of the machine) * `MyResourceGroup` is the name of the resouce group that the machine is in. * `matcher` is one of - `count` the number of data disks attached to the machine @@ -32,7 +36,9 @@ This InSpec audit resource has the following matchers: ### eq -<%= partial "/shared/matcher_eq" %> +Use the `eq` matcher to test the equality of two values: `its('Port') { should eq '22' }`. + +Using `its('Port') { should eq 22 }` will fail because `22` is not a string value! Use the `cmp` matcher for less restrictive value comparisons. ### count @@ -58,7 +64,7 @@ The `entries` filter can be used to check the attributes of indivdual data disks its('entries') { should_not be_empty } ``` -This matcher is best used in conjunction with filters. For example the following tests that the first data disk has a capacity greater than 10gb. +This matcher is best used in conjunction with filters. For example the following tests that the first data disk has a capacity greater than 10gb. ```ruby describe azure_virtual_machine_datadisks(name: 'MyVM', resource_group: 'MyResourceGroup').where { disk.zero? and size > 10 } do From 40888db8c514e4efe51cf020b9065463c82b9527 Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Thu, 2 Mar 2017 11:10:41 +0000 Subject: [PATCH 065/206] Added support for Managed Disks Updated README and documentation Closes #25 Signed-off-by: Russell Seymour --- README.md | 2 ++ docs/resources/azure_resource_group.md | 14 ++++++++++++++ lib/resources/azure/azure_resource_group.rb | 16 ++++++++++++++++ 3 files changed, 32 insertions(+) diff --git a/README.md b/README.md index 382cd0893..6971b03ff 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,8 @@ end | | nsg_count | Helper method to return the number of Network Security Groups (NSG) that exist | | | sa_count | Helper method to return the number of Storage Accounts (SA) that exist | | | public_ip_count | Helper method to return the number of Public IP Addresses that exist | +| | managed_disk_image_count | Helper method to return the number of Managed Disk Images that exist | +| | managed_disk_count | Helper method to return the number of Managed Disks that exist | | | contains | Used to determine if a specific item exists | This resource also has a FilterTable which means that it is possible to check for items that do not yet have a helper method. diff --git a/docs/resources/azure_resource_group.md b/docs/resources/azure_resource_group.md index 95db3bae0..afa4f54da 100644 --- a/docs/resources/azure_resource_group.md +++ b/docs/resources/azure_resource_group.md @@ -32,6 +32,8 @@ where - `sa_count` - `public_ip_count` - `contains` + - `managed_disk_image_count` + - `managed_disk_count` * `value` is the expected output from the matcher For example: @@ -78,6 +80,18 @@ The number of storage accounts in the resource group The number of Public IP Addresses in the resource group +### managed_disk_image_count + +The number of managed disk images that are in the resource group. + +These are the items from which managed disks are created which are attached to machines. Generally the images are created from a base image or a custom image (e.g. Packer) + +### managed_disk_count + +The number of managed disks in the resource group. + +If a resource group contains one virtual machine with an OS disk and 2 data disks that are all Managed Disks, then the count would be 3. + ### contains The `contains` filter allows testing of resources that are not directly supported by the resource pack: diff --git a/lib/resources/azure/azure_resource_group.rb b/lib/resources/azure/azure_resource_group.rb index 5bf216b05..ce269d0bb 100644 --- a/lib/resources/azure/azure_resource_group.rb +++ b/lib/resources/azure/azure_resource_group.rb @@ -153,6 +153,22 @@ class AzureRg < Inspec.resource(1) counts['Microsoft.Network/publicIPAddresses'] end + # Helper method to determine the number of Managed Disk images in the resource group + # + # @return [Integer] Number of Managed Disk images + # + def managed_disk_image_count + counts['Microsoft.Compute/images'] + end + + # Helper method to determine the number of Managed Disks in the resource group + # + # @return [Integer] Number of Managed Disks + # + def managed_disk_count + counts['Microsoft.Compute/disks'] + end + private # Parse the Resource Group Resources From 47cda053fbbdf8d6512279c8bd7c6374468b7cc0 Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Thu, 2 Mar 2017 11:17:00 +0000 Subject: [PATCH 066/206] Renamed matcher to `admin_username` Closes #30 Signed-off-by: Russell Seymour --- README.md | 2 +- docs/resources/azure_virtual_machine.md | 4 ++-- lib/resources/azure/azure_virtual_machine.rb | 2 +- test/integration/azure/verify/controls/vm.rb | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 6971b03ff..da825314a 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ end | | location | Where the machine has been deployed | | | boot_diagnostics? | Whether boot diagnostics have been enabled or not | | | nic_count | How many network cards are attached to the machine | -| | username | The admin username that has been assigned to the machine | +| | admin_username | The admin username that has been assigned to the machine | | | computername | Computer name of the machine in the operating system. This maybe different to the VM name as seen in Azure | | | hostname | Alias for computername | | | password_authentication? | If password authentication is enabled. For Windows machines this is always true | diff --git a/docs/resources/azure_virtual_machine.md b/docs/resources/azure_virtual_machine.md index 17ffd076a..d1edf257a 100644 --- a/docs/resources/azure_virtual_machine.md +++ b/docs/resources/azure_virtual_machine.md @@ -32,7 +32,7 @@ where - `location` - `boot_diagnostics?` - `nic_count` - - `username` + - `admin_username` - `computername` - `hostname` - `password_authentication?` @@ -101,7 +101,7 @@ Boolean test to see if boot diagnostics have been enabled on the machine The number of network interface cards that have been attached to the machine -### username +### admin_username The admin username that was assigned to the machine diff --git a/lib/resources/azure/azure_virtual_machine.rb b/lib/resources/azure/azure_virtual_machine.rb index eac774bb8..f9433654c 100644 --- a/lib/resources/azure/azure_virtual_machine.rb +++ b/lib/resources/azure/azure_virtual_machine.rb @@ -101,7 +101,7 @@ class AzureVm < Inspec.resource(1) # # @return [String] Admin username when the machine was created, e.g. azure # - def username + def admin_username vm.os_profile.admin_username end diff --git a/test/integration/azure/verify/controls/vm.rb b/test/integration/azure/verify/controls/vm.rb index 46ad5e945..fcdde6bc9 100644 --- a/test/integration/azure/verify/controls/vm.rb +++ b/test/integration/azure/verify/controls/vm.rb @@ -15,7 +15,7 @@ control 'azure-vm-1.0' do its('location') { should eq 'westeurope' } its('boot_diagnostics?') { should be false } its('nic_count') { should eq 1 } - its('username') { should eq 'azure' } + its('admin_username') { should eq 'azure' } its('password_authentication?') { should be true } its('ssh_key_count') { should eq 0 } its('os_type') { should eq 'Linux' } From de1b7134ef4b43b7e72eaf90ce2f6d2dbce57132 Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Thu, 2 Mar 2017 14:01:54 +0000 Subject: [PATCH 067/206] Added new resources Allows testing of network configurations Closes #2 Signed-off-by: Russell Seymour --- .rubocop.yml | 2 + README.md | 13 +- docs/resources/azure_virtual_machine.md | 27 +++ lib/resources/azure/azure_virtual_machine.rb | 166 ++++++++++++++++++- test/integration/azure/build/azure.tf | 8 +- test/integration/azure/verify/controls/vm.rb | 21 ++- 6 files changed, 225 insertions(+), 12 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index a5a5659e6..361482e17 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -15,6 +15,8 @@ Encoding: Enabled: true HashSyntax: Enabled: true +ClassLength: + Max: 200 LineLength: Enabled: false EmptyLinesAroundBlockBody: diff --git a/README.md b/README.md index da825314a..02d8563ac 100644 --- a/README.md +++ b/README.md @@ -166,14 +166,23 @@ end | | sku | The SKU being used | | | size | The size of the machine | | | location | Where the machine has been deployed | -| | boot_diagnostics? | Whether boot diagnostics have been enabled or not | +| | has_boot_diagnostics? | Whether boot diagnostics have been enabled or not | | | nic_count | How many network cards are attached to the machine | | | admin_username | The admin username that has been assigned to the machine | | | computername | Computer name of the machine in the operating system. This maybe different to the VM name as seen in Azure | | | hostname | Alias for computername | | | password_authentication? | If password authentication is enabled. For Windows machines this is always true | | | ssh_key_count | How many SSH public keys have been added to the machine. For Windows this is always 0 | -| | os_type | Tyep type of operating system. Linux or Windows | +| | os_type | The type of operating system. Linux or Windows | +| | private_ipaddresses | Returns an array of all the IP addresses for all the NICs on the machine | +| | has_public_ipaddress? | Whether the machine has been allocated an IP address or not | +| | domain_name_label | If the machine has a public IP address then return the domain name label it has been assigned | + +For the resources that start with `has_` the following construct can be used + +```ruby +it { should have_boot_diagnostics } +``` - `azure_vm_datadisks` - Resource to read the data disks for a machine and check that they are of the correct size etc diff --git a/docs/resources/azure_virtual_machine.md b/docs/resources/azure_virtual_machine.md index d1edf257a..03dc0735b 100644 --- a/docs/resources/azure_virtual_machine.md +++ b/docs/resources/azure_virtual_machine.md @@ -38,6 +38,9 @@ where - `password_authentication?` - `ssh_key_count` - `os_type` + - `private_ipaddresses` + - `has_public_ipaddress?` + - `domain_name_label` * `value` is the expected output from the matcher For example: @@ -97,6 +100,10 @@ its('location') { should eq 'West Europe' } Boolean test to see if boot diagnostics have been enabled on the machine +```ruby +it { should have_boot_diagnostics } +``` + ### nic_count The number of network interface cards that have been attached to the machine @@ -135,6 +142,26 @@ This only applies to Linux machines and will always return `0` on Windows. Generic test that returns either `Linux` or `Windows`. +### private_ipaddresses + +Returns an array of all the private IP addresses that are assigned to the machine. This is because a machine can multiple NICs and each NIC can have multiple IP Configurations. + +```ruby +its('private_ipaddresses') { should include '10.1.1.10' } +``` + +### has_public_ipaddress? + +Returns boolean to state if the machine has been allocated a Public IP Address. + +```ruby +it { should have_public_ip_address } +``` + +### domain_name_label + +If a machine has been allocated a Public IP Addresse test to see what domain name label has been set. + ## Examples The following examples show how to use this InSpec audit resource. diff --git a/lib/resources/azure/azure_virtual_machine.rb b/lib/resources/azure/azure_virtual_machine.rb index f9433654c..8ae520d29 100644 --- a/lib/resources/azure/azure_virtual_machine.rb +++ b/lib/resources/azure/azure_virtual_machine.rb @@ -19,7 +19,7 @@ class AzureVm < Inspec.resource(1) end " - attr_accessor :vm + attr_accessor :vm, :nics, :helpers # Constructor to retrieve the VM from Azure # @@ -30,13 +30,28 @@ class AzureVm < Inspec.resource(1) # opts[:resource_group] Name of the resource group in which the host will be found def initialize(opts) opts = opts - helpers = Helpers.new + @helpers = Helpers.new @vm = helpers.get_vm(opts[:name], opts[:resource_group]) # Ensure that the vm is an object raise format('An error has occured: %s', vm) if vm.instance_of?(String) + + # Parse the Network Interface Cards attached to the machine + @nics = parse_nics(vm.network_profile.network_interfaces) end + filter = FilterTable.create + filter.add_accessor(:where) + .add_accessor(:entries) + .add(:accelerated_networking, field: 'enable_accelerated_networking') + .add(:ip_forwarding, field: 'enable_ip_forwarding') + .add(:location, field: 'location') + .add(:name, field: 'name') + .add(:primary, field: 'primary') + .add(:ip_configurations, field: 'ip_configurations') + + filter.connect(self, :nics) + # Determine the SKU used to create the machine # # @return [String] Showing the sku, e.g. 16.04.0-LTS @@ -81,7 +96,7 @@ class AzureVm < Inspec.resource(1) # # @return [Boolean] # - def boot_diagnostics? + def has_boot_diagnostics? if vm.diagnostics_profile vm.diagnostics_profile.boot_diagnostics.enabled else @@ -156,4 +171,149 @@ class AzureVm < Inspec.resource(1) def os_type vm.storage_profile.os_disk.os_type end + + # Return an array of the private IP addresses so that it is possible + # to check if the machine has the correct assigned address + # + # @return [Array] Array of private ip addresses + # + def private_ipaddresses + # Create an array to hold the addresses + addresses = [] + + # Iterate around the filter that has been populated + entries.each do |entry| + entry.ip_configurations.each do |ip_config| + addresses << ip_config['private_ipaddress'] + end + end + + # return the array to the calling function + addresses + end + + # Boolean test to check that the machine has a public IP address + # + # @return [boolean] + # + def has_public_ip_address? + # Define the test value + test = false + + entries.each do |entry| + entry.ip_configurations.each do |ip_config| + if ip_config['public_ipaddress']['attached'] + test = true + break + end + end + end + + test + end + + # Return the domain name label that has been assigned to the machine + # + # @return [String] The domain name label + # + def domain_name_label + label = nil + entries.each do |entry| + entry.ip_configurations.each do |ip_config| + if ip_config['public_ipaddress']['attached'] + label = ip_config['public_ipaddress']['domain_name_label'] + end + end + end + + label + end + + private + + # Parse the array of NICs attached to the machine + # + # @return [Array] Array of all the NICs + # + def parse_nics(attached_nics) + # Iterate around the attached NICs + attached_nics.each.map do |attached_nic| + + # Get the name of the resource group and the name of the NIC + # This is required as the card might be in a different resource group + nic_raw = attached_nic.id.split(%r{/}) + nic_resource_group_name = nic_raw[4] + nic_name = nic_raw.last + + # Interrogate Azure for the NIC details + nic = helpers.network_mgmt.client.network_interfaces.get(nic_resource_group_name, nic_name) + + # Parse the NIC + parse_nic(nic) + end.compact + end + + # Parse the indivdual NIC + # + # @return [Hash] Properties of the indvidual NIC + # + def parse_nic(nic) + # Create the hash table that contains all the information about the NIC + { + 'enable_accelerated_networking' => nic.enable_accelerated_networking, + 'enable_ip_forwarding' => nic.enable_ipforwarding, + 'location' => nic.location, + 'name' => nic.name, + 'primary' => nic.primary, + + # Parse all the IP configurations for the NIC + 'ip_configurations' => parse_ip_configurations(nic.ip_configurations), + } + end + + # Parse the array of IP configurations that are applied to the NIC + # + # @returns [Array] Array of all the IP configurations + # + def parse_ip_configurations(ip_configurations) + # Iterate around all of the IP configurations + ip_configurations.each.map do |ip_configuration| + parse_ip_configuration(ip_configuration) + end.compact + end + + # Parse the IP configuration item + # + # @return [Hash] Hashtable of the ip_configuration attributes + # + def parse_ip_configuration(ip_configuration) + config = { + 'name' => ip_configuration.name, + 'primary' => ip_configuration.primary, + 'private_ipaddress' => ip_configuration.private_ipaddress, + 'public_ipaddress' => { + 'attached' => !ip_configuration.public_ipaddress.nil?, + }, + } + + # if there is a public IP address attached get its details + if config['public_ipaddress']['attached'] + + # Get the name of the resource group and the name of the NIC + # This is required as the card might be in a different resource group + public_ip_raw = ip_configuration.public_ipaddress.id.split(%r{/}) + public_ip_resource_group_name = public_ip_raw[4] + public_ip_name = public_ip_raw.last + + # Interrogate Azure for the NIC details + public_ip = helpers.network_mgmt.client.public_ipaddresses.get(public_ip_resource_group_name, public_ip_name) + + # update the config with the information about the public IP + config['public_ipaddress']['domain_name_label'] = public_ip.dns_settings.domain_name_label + config['public_ipaddress']['dns_fqdn'] = public_ip.dns_settings.fqdn + end + + # return object + config + end end diff --git a/test/integration/azure/build/azure.tf b/test/integration/azure/build/azure.tf index 963977b40..d51794671 100644 --- a/test/integration/azure/build/azure.tf +++ b/test/integration/azure/build/azure.tf @@ -47,7 +47,7 @@ resource "azurerm_public_ip" "public_ip_1" { location = "${var.location}" resource_group_name = "${azurerm_resource_group.rg.name}" public_ip_address_allocation = "dynamic" - domain_name_label = "linux-inspec-1" + domain_name_label = "linux-external-1" } # Create the virtual network for the machines @@ -66,7 +66,8 @@ resource "azurerm_subnet" "subnet" { address_prefix = "10.1.1.0/24" } -# Create the NIC for the machine +# Create the NIC for the internal machine +# Give the machine a static IP Address resource "azurerm_network_interface" "nic1" { name = "Inspec-NIC-1" location = "${var.location}" @@ -75,7 +76,8 @@ resource "azurerm_network_interface" "nic1" { ip_configuration { name = "ipConfiguration1" subnet_id = "${azurerm_subnet.subnet.id}" - private_ip_address_allocation = "dynamic" + private_ip_address_allocation = "static" + private_ip_address = "10.1.1.10" } } diff --git a/test/integration/azure/verify/controls/vm.rb b/test/integration/azure/verify/controls/vm.rb index fcdde6bc9..7c3a3f8d2 100644 --- a/test/integration/azure/verify/controls/vm.rb +++ b/test/integration/azure/verify/controls/vm.rb @@ -1,10 +1,10 @@ title 'Virtual Machine Properties' -control 'azure-vm-1.0' do +control 'azure-vm-internal-1.0' do impact 1.0 - title 'Ensure VM was built with the correct Image and has the correct properties' + title 'Ensure Internal VM was built with the correct Image and has the correct properties' # Ensure that the virtual machine has been created with the correct attributes describe azure_virtual_machine(name: 'Linux-Internal-VM', resource_group: 'Inspec-Azure') do @@ -13,12 +13,25 @@ control 'azure-vm-1.0' do its('offer') { should eq 'UbuntuServer' } its('size') { should eq 'Standard_DS2_v2' } its('location') { should eq 'westeurope' } - its('boot_diagnostics?') { should be false } + it { should_not have_boot_diagnostics } its('nic_count') { should eq 1 } its('admin_username') { should eq 'azure' } its('password_authentication?') { should be true } its('ssh_key_count') { should eq 0 } - its('os_type') { should eq 'Linux' } + its('os_type') { should eq 'Linux' } + its('private_ipaddresses') { should include '10.1.1.10' } + it { should_not have_public_ip_address } end end + +control 'azure-vm-external-1.0' do + + impact 1.0 + title 'Ensure External VM has external access' + + describe azure_virtual_machine(name: 'Linux-External-VM', resource_group: 'Inspec-Azure') do + it { should have_public_ip_address } + its('domain_name_label') { should eq 'linux-external-1' } + end +end From ef323e3b31172d82cf6a10baca5f8f4da106a38f Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Thu, 2 Mar 2017 14:08:33 +0000 Subject: [PATCH 068/206] Updated resource to follow RSpec convention Closes #31 Signed-off-by: Russell Seymour --- README.md | 2 +- docs/resources/azure_virtual_machine_datadisks.md | 6 +++--- libraries/azure_virtual_machine_datadisks.rb | 2 +- test/integration/azure/verify/controls/datadisks.rb | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 02d8563ac..0e5a638fa 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,7 @@ it { should have_boot_diagnostics } | Resource Name | Resources | Description | |---------------|-----------|-------------| -| azure_vm_datadisks | has_disks? | Boolean test to see if a machine has datadisks | +| azure_vm_datadisks | has_data_disks? | Boolean test to see if a machine has datadisks | | | count | Returns the number of data disks attached to the machine | | | where | Filter that allows for different tests to be performed, see examples below | diff --git a/docs/resources/azure_virtual_machine_datadisks.md b/docs/resources/azure_virtual_machine_datadisks.md index dc65f1339..6de0732bd 100644 --- a/docs/resources/azure_virtual_machine_datadisks.md +++ b/docs/resources/azure_virtual_machine_datadisks.md @@ -26,7 +26,7 @@ where * `MyResourceGroup` is the name of the resouce group that the machine is in. * `matcher` is one of - `count` the number of data disks attached to the machine - - `has_disks?` boolean test denoting if data disks are attached + - `has_data_disks?` boolean test denoting if data disks are attached - `entries` used with the `where` filter to check the size of a disk * `value` is the expected output fdrom the matcher @@ -48,12 +48,12 @@ Returns the number of data disks attached to the machine its('count') { should eq 1 } ``` -### has_disks? +### has_data_disks? Returns a boolean denoting if any data disks are attached to the machine ```ruby -its('has_disks?') { should be true } +it { should have_data_disks } ``` ### entries diff --git a/libraries/azure_virtual_machine_datadisks.rb b/libraries/azure_virtual_machine_datadisks.rb index 3c65b7aa2..3a600c9ee 100644 --- a/libraries/azure_virtual_machine_datadisks.rb +++ b/libraries/azure_virtual_machine_datadisks.rb @@ -69,7 +69,7 @@ class AzureVmDataDisks < Inspec.resource(1) # # @return [Boolean] States if the VM has any disks attached # - def has_disks? + def has_data_disks? !entries.empty? end diff --git a/test/integration/azure/verify/controls/datadisks.rb b/test/integration/azure/verify/controls/datadisks.rb index 69fffb4e7..00af1ba11 100644 --- a/test/integration/azure/verify/controls/datadisks.rb +++ b/test/integration/azure/verify/controls/datadisks.rb @@ -12,7 +12,7 @@ control 'azure-vm-datadisks-1.0' do # Ensure that the named machine has 1 data disk describe azure_virtual_machine_datadisks(name: vm_internal, resource_group: resource_group_name) do - its('has_disks?') { should be true } + it { should have_data_disks } its('count') { should eq 1 } end From ff4a1ba20056a743dfb8a16d7769ef65e443959f Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Tue, 7 Mar 2017 08:15:50 +0000 Subject: [PATCH 069/206] Renamed public ip address test Removed debugging flag Signed-off-by: Russell Seymour --- lib/resources/azure/azure_virtual_machine.rb | 2 +- libraries/azure_virtual_machine_datadisks.rb | 1 + test/integration/azure/verify/controls/vm.rb | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/resources/azure/azure_virtual_machine.rb b/lib/resources/azure/azure_virtual_machine.rb index 8ae520d29..54ec02426 100644 --- a/lib/resources/azure/azure_virtual_machine.rb +++ b/lib/resources/azure/azure_virtual_machine.rb @@ -196,7 +196,7 @@ class AzureVm < Inspec.resource(1) # # @return [boolean] # - def has_public_ip_address? + def has_public_ipaddress? # Define the test value test = false diff --git a/libraries/azure_virtual_machine_datadisks.rb b/libraries/azure_virtual_machine_datadisks.rb index 3a600c9ee..d1d0c50e0 100644 --- a/libraries/azure_virtual_machine_datadisks.rb +++ b/libraries/azure_virtual_machine_datadisks.rb @@ -92,6 +92,7 @@ class AzureVmDataDisks < Inspec.resource(1) # @return [Hash] Data disk information # def parse_data_disk_item(disk, index) + # Parse the uri of the disk so that the storage account can be retrieved uri = URI.parse(disk.vhd.uri) diff --git a/test/integration/azure/verify/controls/vm.rb b/test/integration/azure/verify/controls/vm.rb index 7c3a3f8d2..13f660f91 100644 --- a/test/integration/azure/verify/controls/vm.rb +++ b/test/integration/azure/verify/controls/vm.rb @@ -20,7 +20,7 @@ control 'azure-vm-internal-1.0' do its('ssh_key_count') { should eq 0 } its('os_type') { should eq 'Linux' } its('private_ipaddresses') { should include '10.1.1.10' } - it { should_not have_public_ip_address } + it { should_not have_public_ipaddress } end end @@ -31,7 +31,7 @@ control 'azure-vm-external-1.0' do title 'Ensure External VM has external access' describe azure_virtual_machine(name: 'Linux-External-VM', resource_group: 'Inspec-Azure') do - it { should have_public_ip_address } + it { should have_public_ipaddress } its('domain_name_label') { should eq 'linux-external-1' } end end From 44f92389535b40ed2cd25e8c7a04f501234b7018 Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Tue, 7 Mar 2017 08:18:30 +0000 Subject: [PATCH 070/206] Fixed linting issue Signed-off-by: Russell Seymour --- libraries/azure_virtual_machine_datadisks.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/libraries/azure_virtual_machine_datadisks.rb b/libraries/azure_virtual_machine_datadisks.rb index d1d0c50e0..3a600c9ee 100644 --- a/libraries/azure_virtual_machine_datadisks.rb +++ b/libraries/azure_virtual_machine_datadisks.rb @@ -92,7 +92,6 @@ class AzureVmDataDisks < Inspec.resource(1) # @return [Hash] Data disk information # def parse_data_disk_item(disk, index) - # Parse the uri of the disk so that the storage account can be retrieved uri = URI.parse(disk.vhd.uri) From b40d8f2a40c2dd547701b432936c4ada780eae4f Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Mon, 13 Mar 2017 13:52:14 +0000 Subject: [PATCH 071/206] Updated resource to detect Managed Disks Fixes #34 Signed-off-by: Russell Seymour --- README.md | 25 +++++++-- libraries/azure_virtual_machine_datadisks.rb | 53 +++++++++++++++----- 2 files changed, 62 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 0e5a638fa..70447833f 100644 --- a/README.md +++ b/README.md @@ -192,18 +192,35 @@ it { should have_boot_diagnostics } | | count | Returns the number of data disks attached to the machine | | | where | Filter that allows for different tests to be performed, see examples below | -When data disks are retrieved from a machine they are given as an array. The `where` filter will interogate the array according the criteria it is given. The followin attributes are available in the filter: +The data disks resource now supports Managed Disks, however the data that is returned is different from that for a VHD. + +When data disks are retrieved from a machine they are given as an array. The `where` filter will interogate the array according the criteria it is given. + +For items that do not exist a `nil` value will be returned. + +###### VHD propertiies + +The followin attributes are available in the filter: - `disk` - Disk number (0 index based) + - `name` - Name of the disk + - `size` - The size of the disk in GB - `caching` - What sort of caching is enabled on the data disk - `create_option` - How the disk was created - - `size` - The size of the disk in GB - `lun` - The LUN number - - `name` - Name of the disk - `uri` - Full URI to the disk in Blob storage - `storage_account` - The name of the storage account in which the Blob storage exists -**Note: This does not yet work with Managed Disks** +###### Managed Disk Properties + +The following attributes are available in the filter: + + - `disk` - Disk number (0 index based) + - `name` - Name of the disk + - `size` - The size of the disk in GB + - `location` - Location of the disk + - `account_type` - The stoarge type. e.g. `Standard_LRS` + #### Test for 1 disk with a size greater than 10gb diff --git a/libraries/azure_virtual_machine_datadisks.rb b/libraries/azure_virtual_machine_datadisks.rb index 3a600c9ee..cad1799ae 100644 --- a/libraries/azure_virtual_machine_datadisks.rb +++ b/libraries/azure_virtual_machine_datadisks.rb @@ -54,6 +54,8 @@ class AzureVmDataDisks < Inspec.resource(1) .add(:name, field: 'name') .add(:uri, field: 'uri') .add(:storage_account, field: 'storage_account') + .add(:account_type, field: 'account_type') + .add(:location, filed: 'location') filter.connect(self, :params) @@ -92,18 +94,45 @@ class AzureVmDataDisks < Inspec.resource(1) # @return [Hash] Data disk information # def parse_data_disk_item(disk, index) - # Parse the uri of the disk so that the storage account can be retrieved - uri = URI.parse(disk.vhd.uri) - { - 'disk' => index, - 'caching' => disk.caching, - 'create_option' => disk.create_option, - 'size' => disk.disk_size_gb, - 'lun' => disk.lun, - 'name' => disk.name, - 'uri' => disk.vhd.uri, - 'storage_account' => uri.host.split('.').first, - } + # determine if using managed disks or a VHD + if !disk.vhd.nil? + + # Parse the uri of the disk so that the storage account can be retrieved + uri = URI.parse(disk.vhd.uri) + + { + 'disk' => index, + 'name' => disk.name, + 'size' => disk.disk_size_gb, + + 'caching' => disk.caching, + 'create_option' => disk.create_option, + 'lun' => disk.lun, + 'uri' => disk.vhd.uri, + 'storage_account' => uri.host.split('.').first, + } + + elsif !disk.managed_disk.nil? + + # Parse the ID so that the resource group and name are found + id_parts = disk.managed_disk.id.split(%r{/}) + resource_group_name = id_parts[4] + disk_name = id_parts.last + + # Use the compute client to get information about the disk + managed_disk = @helpers.compute_mgmt.client.disks.get(resource_group_name, disk_name) + + # Build up the hash table to return + { + 'disk' => index, + 'size' => managed_disk.disk_size_gb, + 'name' => managed_disk.name, + + 'account_type' => managed_disk.type, + 'location' => managed_disk.location, + } + + end end end From 9d610768eb0bc42059711452fc3ed110234a0f86 Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Mon, 13 Mar 2017 13:54:38 +0000 Subject: [PATCH 072/206] Fixed linting Signed-off-by: Russell Seymour --- libraries/azure_virtual_machine_datadisks.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/libraries/azure_virtual_machine_datadisks.rb b/libraries/azure_virtual_machine_datadisks.rb index cad1799ae..d56404a00 100644 --- a/libraries/azure_virtual_machine_datadisks.rb +++ b/libraries/azure_virtual_machine_datadisks.rb @@ -94,7 +94,6 @@ class AzureVmDataDisks < Inspec.resource(1) # @return [Hash] Data disk information # def parse_data_disk_item(disk, index) - # determine if using managed disks or a VHD if !disk.vhd.nil? From d9221bb925cf071031701c134027aa0b382cde82 Mon Sep 17 00:00:00 2001 From: sfreeman Date: Wed, 1 Feb 2017 16:27:10 -0500 Subject: [PATCH 073/206] Add ability to choose a user by username Add has MFA enabled member Add "has console password" member Signed-off-by: sfreeman --- .gitignore | 1 + README.md | 1 - controls/example.rb | 10 ---- libraries/aws_conn.rb | 8 +++ libraries/aws_iam_user.rb | 28 +++++++++ test/integration/build/aws.tf | 14 +++++ test/integration/build/terraform.tfvars | 1 + test/integration/build/variables.tf | 1 + .../verify/controls/aws_iam_user.rb | 8 +++ test/unit/resources/aws_iam_user_test.rb | 60 +++++++++++++++++++ 10 files changed, 121 insertions(+), 11 deletions(-) create mode 100644 .gitignore delete mode 100644 controls/example.rb create mode 100644 libraries/aws_iam_user.rb create mode 100644 test/integration/build/terraform.tfvars create mode 100644 test/integration/build/variables.tf create mode 100644 test/integration/verify/controls/aws_iam_user.rb create mode 100644 test/unit/resources/aws_iam_user_test.rb diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..0fe607bb7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +inspec.lock \ No newline at end of file diff --git a/README.md b/README.md index 9d9c7bfcb..e4ae085ac 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,6 @@ This resource pack provides resources for AWS. It will ship with the required re ``` ├── README.md - this readme -├── controls - contains no controls └── libraries - contains AWS resources ``` diff --git a/controls/example.rb b/controls/example.rb deleted file mode 100644 index 0a9e2b88e..000000000 --- a/controls/example.rb +++ /dev/null @@ -1,10 +0,0 @@ -describe aws_ec2(name: 'aws-inspec') do - it { should be_running } - its('state') { should eq 'running' } - its('instance_id') { should eq 'i-1234a1ab' } - its('image_id') { should eq 'ami-c123aaa1' } - its('public_ip_address') { should eq '123.123.123.123' } - its('private_ip_address') { should eq '123.123.123.123' } - its('vpc_id') { should eq 'vpc-1234567' } - its('subnet_id') { should eq 'subnet-1234567' } -end diff --git a/libraries/aws_conn.rb b/libraries/aws_conn.rb index ad17cb19b..cc583689b 100644 --- a/libraries/aws_conn.rb +++ b/libraries/aws_conn.rb @@ -20,4 +20,12 @@ class AWSConnection def ec2_client @ec2_client ||= Aws::EC2::Client.new end + + def iam_resource + @iam_resource ||= Aws::IAM::Resource.new + end + + def iam_client + @iam_client ||= Aws::IAM::Client.new + end end diff --git a/libraries/aws_iam_user.rb b/libraries/aws_iam_user.rb new file mode 100644 index 000000000..d5a6b76fa --- /dev/null +++ b/libraries/aws_iam_user.rb @@ -0,0 +1,28 @@ +# author: Alex Bedley +# author: Steffanie Freeman +# author: Simon Varlow +class AwsIamUser < Inspec.resource(1) + name 'aws_iam_user' + desc 'Verifies settings for AWS IAM user' + example " + describe aws_iam_user('test_user_name') do + its('has_mfa_enabled?') { should be false } + its('has_console_password?') { should be true } + end + " + def initialize(name, conn = AWSConnection.new) + @name = name + @iam_resource = conn.iam_resource + @user = @iam_resource.user(@name) + end + + def has_mfa_enabled? + !@user.mfa_devices.first.nil? + end + + def has_console_password? + return !@user.login_profile.create_date.nil? + rescue Aws::IAM::Errors::NoSuchEntity + return false + end +end diff --git a/test/integration/build/aws.tf b/test/integration/build/aws.tf index 8fe359a46..595bd334e 100644 --- a/test/integration/build/aws.tf +++ b/test/integration/build/aws.tf @@ -9,3 +9,17 @@ resource "aws_instance" "example" { X-Project = "inspec" } } + +resource "aws_iam_user" "mfa_not_enabled_user" { + name = "mfa_not_enabled_user" +} + +resource "aws_iam_user" "console_password_enabled_user" { + name = "console_password_enabled_user" + force_destroy = true +} + +resource "aws_iam_user_login_profile" "u" { + user = "${aws_iam_user.console_password_enabled_user.name}" + pgp_key = "${var.login_profile_pgp_key}" +} \ No newline at end of file diff --git a/test/integration/build/terraform.tfvars b/test/integration/build/terraform.tfvars new file mode 100644 index 000000000..4b76c7da0 --- /dev/null +++ b/test/integration/build/terraform.tfvars @@ -0,0 +1 @@ +login_profile_pgp_key = "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" \ No newline at end of file diff --git a/test/integration/build/variables.tf b/test/integration/build/variables.tf new file mode 100644 index 000000000..ea6cb677b --- /dev/null +++ b/test/integration/build/variables.tf @@ -0,0 +1 @@ +variable "login_profile_pgp_key" {} \ No newline at end of file diff --git a/test/integration/verify/controls/aws_iam_user.rb b/test/integration/verify/controls/aws_iam_user.rb new file mode 100644 index 000000000..50438aa6e --- /dev/null +++ b/test/integration/verify/controls/aws_iam_user.rb @@ -0,0 +1,8 @@ +describe aws_iam_user('mfa_not_enabled_user') do + its('has_mfa_enabled?') { should be false } + its('has_console_password?') { should be false } +end + +describe aws_iam_user('console_password_enabled_user') do + its('has_console_password?') { should be true } +end \ No newline at end of file diff --git a/test/unit/resources/aws_iam_user_test.rb b/test/unit/resources/aws_iam_user_test.rb new file mode 100644 index 000000000..351540cc9 --- /dev/null +++ b/test/unit/resources/aws_iam_user_test.rb @@ -0,0 +1,60 @@ +# author: Simon Varlow +require 'aws-sdk' +require 'helper' + +require 'aws_iam_user' + +class AwsIamUserTest < Minitest::Test +Username = "test" + + def setup + @mockConn = Minitest::Mock.new + @mockUser = Minitest::Mock.new + @mockResource = Minitest::Mock.new + @mockProfile = Minitest::Mock.new + + @mockConn.expect :iam_resource, @mockResource + @mockResource.expect :user, @mockUser, [String] + end + + def test_that_MFA_enable_returns_true_if_MFA_Enabled + @mockUser.expect :mfa_devices, ["test"] + assert AwsIamUser.new(Username, @mockConn).has_mfa_enabled? + end + + def test_that_MFA_enable_returns_false_if_MFA_is_not_Enabled + @mockUser.expect :mfa_devices, [] + assert !AwsIamUser.new(Username, @mockConn).has_mfa_enabled? + end + + def test_that_console_Password_returns_true_if_console_Password_has_been_set + @mockUser.expect :login_profile, @mockProfile + @mockProfile.expect :create_date, "test" + assert AwsIamUser.new(Username, @mockConn).has_console_password? + end + + def test_that_console_Password_returns_false_if_console_Password_has_not_been_set + @mockUser.expect :login_profile, @mockProfile + @mockProfile.expect :create_date, nil + assert !AwsIamUser.new(Username, @mockConn).has_console_password? + end + + def test_that_console_Password_returns_false_if_console_Password_throws_no_such_entity + @mockUser.expect :login_profile, @mockProfile + @mockProfile.expect :create_date, nil do |args| + raise Aws::IAM::Errors::NoSuchEntity.new nil, nil + end + assert !AwsIamUser.new(Username, @mockConn).has_console_password? + end + + def test_that_console_Password_throws_if_console_Password_throws_not_no_such_entity + @mockUser.expect :login_profile, @mockProfile + @mockProfile.expect :create_date, nil do |args| + raise ArgumentError + end + + assert_raises ArgumentError do + AwsIamUser.new(Username, @mockConn).has_console_password? + end + end +end \ No newline at end of file From a1487802169d7d1d024caea61f0690a9124b9cef Mon Sep 17 00:00:00 2001 From: sfreeman Date: Wed, 29 Mar 2017 15:29:01 -0400 Subject: [PATCH 074/206] Add files to gitignore Signed-off-by: sfreeman --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 0fe607bb7..dea93cf6f 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ -inspec.lock \ No newline at end of file +inspec.lock +Gemfile.lock +terraform.tfstate +terraform.tfstate.backup \ No newline at end of file From ff72e81915a7650de3640619407ecd592e4b948d Mon Sep 17 00:00:00 2001 From: Viktor Yakovlyev Date: Thu, 6 Apr 2017 15:12:19 -0400 Subject: [PATCH 075/206] Add iam password policy Signed-off-by: Viktor Yakovlyev wire up mock resource twice Signed-off-by: Viktor Yakovlyev cleaning up as per pr feedback Signed-off-by: Viktor Yakovlyev style fixes Signed-off-by: Viktor Yakovlyev fix indent in test Signed-off-by: Viktor Yakovlyev remove unneeded line Signed-off-by: Viktor Yakovlyev use minitest mock instead of object Signed-off-by: Viktor Yakovlyev --- libraries/iam_password_policy.rb | 52 +++++++++++++++++++ .../resources/iam_password_policy_test.rb | 26 ++++++++++ 2 files changed, 78 insertions(+) create mode 100644 libraries/iam_password_policy.rb create mode 100644 test/unit/resources/iam_password_policy_test.rb diff --git a/libraries/iam_password_policy.rb b/libraries/iam_password_policy.rb new file mode 100644 index 000000000..2b709bcd0 --- /dev/null +++ b/libraries/iam_password_policy.rb @@ -0,0 +1,52 @@ +# author: Viktor Yakovlyev + +require 'aws_conn' + +class IamPasswordPolicy < Inspec.resource(1) + name 'iam_password_policy' + desc 'Verifies iam password policy' + + example " + describe iam_password_policy('i-123456') do + its('requires_lowercase_letters?') { should be true } + end + + describe iam_password_policy('i-123456') do + its('requires_uppercase_letters?') { should be true } + end + " + + def initialize(conn = AWSConnection.new) + @policy = conn.iam_resource.account_password_policy + rescue Aws::IAM::Errors::NoSuchEntity + @policy = nil + end + + def exists? + !@policy.nil? + end + + def requires_lowercase_characters? + @policy.require_lowercase_characters + end + + def requires_uppercase_characters? + @policy.require_uppercase_characters + end + + def minimum_password_length + @policy.minimum_password_length + end + + def requires_numbers? + @policy.require_numbers + end + + def requires_symbols? + @policy.require_symbols + end + + def allows_users_to_change_password? + @policy.allow_users_to_change_password + end +end diff --git a/test/unit/resources/iam_password_policy_test.rb b/test/unit/resources/iam_password_policy_test.rb new file mode 100644 index 000000000..ed0583458 --- /dev/null +++ b/test/unit/resources/iam_password_policy_test.rb @@ -0,0 +1,26 @@ +require 'helper' +require 'iam_password_policy' +require 'aws-sdk' +require 'json' + +class IamPasswordPolicyTest < Minitest::Test + def setup + @mockConn = Minitest::Mock.new + end + + def test_policy_exists_when_policy_exists + @mockResource = Minitest::Mock.new + @mockResource.expect :account_password_policy, true + @mockConn.expect :iam_resource, @mockResource + assert_equal true, IamPasswordPolicy.new(@mockConn).exists? + end + + def test_policy_does_not_exists_when_no_policy + @mockResource = Minitest::Mock.new + @mockResource.expect :account_password_policy, nil do |args| + raise Aws::IAM::Errors::NoSuchEntity.new nil, nil + end + @mockConn.expect :iam_resource, @mockResource + assert_equal false, IamPasswordPolicy.new(@mockConn).exists? + end +end \ No newline at end of file From d428a96babd86f6cb17726a07a640e04c3fb7404 Mon Sep 17 00:00:00 2001 From: Chris Redekop Date: Tue, 4 Apr 2017 21:57:16 +0000 Subject: [PATCH 076/206] Add a new access-key resource Signed-off-by: Chris Redekop --- libraries/aws_iam_access_key.rb | 50 +++++++++++++++ .../unit/resources/aws_iam_access_key_test.rb | 64 +++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 libraries/aws_iam_access_key.rb create mode 100644 test/unit/resources/aws_iam_access_key_test.rb diff --git a/libraries/aws_iam_access_key.rb b/libraries/aws_iam_access_key.rb new file mode 100644 index 000000000..cff7a1b6e --- /dev/null +++ b/libraries/aws_iam_access_key.rb @@ -0,0 +1,50 @@ +# author: Chris Redekop +class AwsIamAccessKey < Inspec.resource(1) + name 'aws_iam_access_key' + desc 'Verifies settings for AWS IAM access keys' + example " + describe aws_iam_access_key('access-key id') do + its('last_use') { should be > Time.now - 90 * 86400 } + end + " + def initialize(opts, conn = AWSConnection.new) + @opts = opts + @access_key = opts[:access_key] + @iam_client = conn.iam_client + end + + def id + access_key.access_key_id + end + + def active? + 'Active'.eql? access_key.status + end + + def create_date + access_key.create_date + end + + def last_used_date + access_key_last_used.last_used_date + end + +private + + def access_key_last_used + @access_key_last_used ||= @iam_client.get_access_key_last_used({ access_key_id: access_key.access_key_id }).access_key_last_used + end + + def access_key + if !@access_key + @iam_client.list_access_keys({ user_name: @opts[:username] }).access_key_metadata.each do |access_key| + if access_key.access_key_id.eql? @opts[:id] + @access_key = access_key + break + end + end + end + + @access_key + end +end diff --git a/test/unit/resources/aws_iam_access_key_test.rb b/test/unit/resources/aws_iam_access_key_test.rb new file mode 100644 index 000000000..9e65dd370 --- /dev/null +++ b/test/unit/resources/aws_iam_access_key_test.rb @@ -0,0 +1,64 @@ +# author: Chris Redekop +require 'aws-sdk' +require 'helper' + +require 'aws_iam_access_key' + +class AwsIamAccessKeyTest < Minitest::Test + Username = 'test' + Id = 'id' + Date = 'date' + + def setup + @mockConn = Minitest::Mock.new + @mockClient = Minitest::Mock.new + @mockAccessKey = Minitest::Mock.new + + @mockConn.expect :iam_client, @mockClient + end + + def test_that_id_returns_access_key_id_always + mock_access_key_lookup + @mockAccessKey.expect :access_key_id, Id + + assert_equal AwsIamAccessKey.new({username: Username, id: Id}, @mockConn).id, Id + end + + def test_that_active_returns_true_when_access_key_status_is_active + mock_access_key_lookup + @mockAccessKey.expect :status, 'Active' + + assert AwsIamAccessKey.new({username: Username, id: Id}, @mockConn).active? + end + + def test_that_active_returns_false_when_access_key_status_is_not_active + mock_access_key_lookup + @mockAccessKey.expect :status, 'Foo' + + assert !AwsIamAccessKey.new({username: Username, id: Id}, @mockConn).active? + end + + def test_that_create_date_returns_access_key_last_used_date_always + mock_access_key_lookup + @mockAccessKey.expect :create_date, Date + + assert_equal AwsIamAccessKey.new({username: Username, id: Id}, @mockConn).create_date, Date + end + + def test_that_create_returns_access_key_create_date_always + mock_access_key_lookup + @mockAccessKey.expect :access_key_id, Id + mockAccessKeyLastUsed = Minitest::Mock.new + mockAccessKeyLastUsed.expect :last_used_date, Date + @mockClient.expect :get_access_key_last_used, OpenStruct.new({'access_key_last_used' => mockAccessKeyLastUsed}), [{access_key_id: Id}] + + assert_equal AwsIamAccessKey.new({username: Username, id: Id}, @mockConn).last_used_date, Date + end + +private + + def mock_access_key_lookup + @mockClient.expect :list_access_keys, OpenStruct.new({'access_key_metadata' => [@mockAccessKey]}), [{user_name: Username}] + @mockAccessKey.expect :access_key_id, Id + end +end From e0dfbad730f275eb85ec087fb052fef88e507e19 Mon Sep 17 00:00:00 2001 From: Chris Redekop Date: Tue, 4 Apr 2017 22:03:13 +0000 Subject: [PATCH 077/206] Fix Rubocop issue Signed-off-by: Chris Redekop --- libraries/aws_iam_access_key.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/aws_iam_access_key.rb b/libraries/aws_iam_access_key.rb index cff7a1b6e..5ba7d9d8d 100644 --- a/libraries/aws_iam_access_key.rb +++ b/libraries/aws_iam_access_key.rb @@ -29,7 +29,7 @@ class AwsIamAccessKey < Inspec.resource(1) access_key_last_used.last_used_date end -private + private def access_key_last_used @access_key_last_used ||= @iam_client.get_access_key_last_used({ access_key_id: access_key.access_key_id }).access_key_last_used From 44e15b85f92b2bdd9722fcebd70c5bfc8734326c Mon Sep 17 00:00:00 2001 From: Chris Redekop Date: Wed, 5 Apr 2017 09:46:11 +0000 Subject: [PATCH 078/206] Update usage example Signed-off-by: Chris Redekop --- libraries/aws_iam_access_key.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/aws_iam_access_key.rb b/libraries/aws_iam_access_key.rb index 5ba7d9d8d..4acece300 100644 --- a/libraries/aws_iam_access_key.rb +++ b/libraries/aws_iam_access_key.rb @@ -3,7 +3,7 @@ class AwsIamAccessKey < Inspec.resource(1) name 'aws_iam_access_key' desc 'Verifies settings for AWS IAM access keys' example " - describe aws_iam_access_key('access-key id') do + describe aws_iam_access_key(username: 'username', id: 'access-key id') do its('last_use') { should be > Time.now - 90 * 86400 } end " From fcb8d17feb959446b388468f94f94c80786b281c Mon Sep 17 00:00:00 2001 From: Chris Redekop Date: Wed, 5 Apr 2017 17:23:54 +0000 Subject: [PATCH 079/206] Incorporate PR feedback Signed-off-by: Chris Redekop --- libraries/aws_iam_access_key.rb | 21 +++++++++---------- .../unit/resources/aws_iam_access_key_test.rb | 16 ++++---------- 2 files changed, 14 insertions(+), 23 deletions(-) diff --git a/libraries/aws_iam_access_key.rb b/libraries/aws_iam_access_key.rb index 4acece300..ea4ba2698 100644 --- a/libraries/aws_iam_access_key.rb +++ b/libraries/aws_iam_access_key.rb @@ -9,20 +9,19 @@ class AwsIamAccessKey < Inspec.resource(1) " def initialize(opts, conn = AWSConnection.new) @opts = opts - @access_key = opts[:access_key] @iam_client = conn.iam_client end def id - access_key.access_key_id + access_key_metadata.access_key_id end def active? - 'Active'.eql? access_key.status + 'Active'.eql? access_key_metadata.status end def create_date - access_key.create_date + access_key_metadata.create_date end def last_used_date @@ -32,19 +31,19 @@ class AwsIamAccessKey < Inspec.resource(1) private def access_key_last_used - @access_key_last_used ||= @iam_client.get_access_key_last_used({ access_key_id: access_key.access_key_id }).access_key_last_used + @access_key_last_used ||= @iam_client.get_access_key_last_used({ access_key_id: access_key_metadata.access_key_id }).access_key_last_used end - def access_key - if !@access_key - @iam_client.list_access_keys({ user_name: @opts[:username] }).access_key_metadata.each do |access_key| - if access_key.access_key_id.eql? @opts[:id] - @access_key = access_key + def access_key_metadata + if !(defined? @access_key_metadata) || !@access_key_metadata + @iam_client.list_access_keys({ user_name: @opts[:username] }).access_key_metadata.each do |access_key_metadata| + if access_key_metadata.access_key_id.eql? @opts[:id] + @access_key_metadata = access_key_metadata break end end end - @access_key + @access_key_metadata end end diff --git a/test/unit/resources/aws_iam_access_key_test.rb b/test/unit/resources/aws_iam_access_key_test.rb index 9e65dd370..aca284ce7 100644 --- a/test/unit/resources/aws_iam_access_key_test.rb +++ b/test/unit/resources/aws_iam_access_key_test.rb @@ -15,38 +15,36 @@ class AwsIamAccessKeyTest < Minitest::Test @mockAccessKey = Minitest::Mock.new @mockConn.expect :iam_client, @mockClient + @mockClient.expect :list_access_keys, OpenStruct.new({'access_key_metadata' => [@mockAccessKey]}), [{user_name: Username}] + @mockAccessKey.expect :access_key_id, Id end def test_that_id_returns_access_key_id_always - mock_access_key_lookup @mockAccessKey.expect :access_key_id, Id assert_equal AwsIamAccessKey.new({username: Username, id: Id}, @mockConn).id, Id end +=begin def test_that_active_returns_true_when_access_key_status_is_active - mock_access_key_lookup @mockAccessKey.expect :status, 'Active' assert AwsIamAccessKey.new({username: Username, id: Id}, @mockConn).active? end def test_that_active_returns_false_when_access_key_status_is_not_active - mock_access_key_lookup @mockAccessKey.expect :status, 'Foo' assert !AwsIamAccessKey.new({username: Username, id: Id}, @mockConn).active? end def test_that_create_date_returns_access_key_last_used_date_always - mock_access_key_lookup @mockAccessKey.expect :create_date, Date assert_equal AwsIamAccessKey.new({username: Username, id: Id}, @mockConn).create_date, Date end def test_that_create_returns_access_key_create_date_always - mock_access_key_lookup @mockAccessKey.expect :access_key_id, Id mockAccessKeyLastUsed = Minitest::Mock.new mockAccessKeyLastUsed.expect :last_used_date, Date @@ -54,11 +52,5 @@ class AwsIamAccessKeyTest < Minitest::Test assert_equal AwsIamAccessKey.new({username: Username, id: Id}, @mockConn).last_used_date, Date end - -private - - def mock_access_key_lookup - @mockClient.expect :list_access_keys, OpenStruct.new({'access_key_metadata' => [@mockAccessKey]}), [{user_name: Username}] - @mockAccessKey.expect :access_key_id, Id - end +=end end From 3cdb639a0cdec3cae9acb4000af1b891ee69ed6c Mon Sep 17 00:00:00 2001 From: Chris Redekop Date: Wed, 5 Apr 2017 19:44:18 +0000 Subject: [PATCH 080/206] Uncomment unit tests Signed-off-by: Chris Redekop --- test/unit/resources/aws_iam_access_key_test.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/unit/resources/aws_iam_access_key_test.rb b/test/unit/resources/aws_iam_access_key_test.rb index aca284ce7..6c6ad6770 100644 --- a/test/unit/resources/aws_iam_access_key_test.rb +++ b/test/unit/resources/aws_iam_access_key_test.rb @@ -25,7 +25,6 @@ class AwsIamAccessKeyTest < Minitest::Test assert_equal AwsIamAccessKey.new({username: Username, id: Id}, @mockConn).id, Id end -=begin def test_that_active_returns_true_when_access_key_status_is_active @mockAccessKey.expect :status, 'Active' @@ -52,5 +51,4 @@ class AwsIamAccessKeyTest < Minitest::Test assert_equal AwsIamAccessKey.new({username: Username, id: Id}, @mockConn).last_used_date, Date end -=end end From 915e2fd7c670c7864c003b680ceeb6a6a3170f4a Mon Sep 17 00:00:00 2001 From: Chris Redekop Date: Thu, 13 Apr 2017 18:55:28 +0000 Subject: [PATCH 081/206] Updates based on PR feedback Signed-off-by: Chris Redekop --- .rubocop.yml | 2 +- libraries/aws_iam_access_key.rb | 13 ++++++++++--- libraries/ec2.rb | 4 +++- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index a13aeb3d7..77ddf7993 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -16,7 +16,7 @@ Encoding: HashSyntax: Enabled: true LineLength: - Enabled: false + Enabled: true EmptyLinesAroundBlockBody: Enabled: false MethodLength: diff --git a/libraries/aws_iam_access_key.rb b/libraries/aws_iam_access_key.rb index ea4ba2698..6fe27e4e0 100644 --- a/libraries/aws_iam_access_key.rb +++ b/libraries/aws_iam_access_key.rb @@ -31,12 +31,19 @@ class AwsIamAccessKey < Inspec.resource(1) private def access_key_last_used - @access_key_last_used ||= @iam_client.get_access_key_last_used({ access_key_id: access_key_metadata.access_key_id }).access_key_last_used + @access_key_last_used ||= + @iam_client.get_access_key_last_used( + { + access_key_id: access_key_metadata.access_key_id, + }, + ).access_key_last_used end def access_key_metadata - if !(defined? @access_key_metadata) || !@access_key_metadata - @iam_client.list_access_keys({ user_name: @opts[:username] }).access_key_metadata.each do |access_key_metadata| + unless (defined? @access_key_metadata) && @access_key_metadata + @iam_client.list_access_keys({ user_name: @opts[:username] }) + .access_key_metadata.each do |access_key_metadata| + if access_key_metadata.access_key_id.eql? @opts[:id] @access_key_metadata = access_key_metadata break diff --git a/libraries/ec2.rb b/libraries/ec2.rb index 21dace7da..dcbffc65b 100644 --- a/libraries/ec2.rb +++ b/libraries/ec2.rb @@ -74,7 +74,9 @@ class Ec2 < Inspec.resource(1) end def security_groups - @security_groups ||= instance.security_groups.map { |sg| { id: sg.group_id, name: sg.group_name } } + @security_groups ||= instance.security_groups.map { |sg| + { id: sg.group_id, name: sg.group_name } + } end def tags From c43c863109ba1ae00b337e33607dacd47f0b9c7b Mon Sep 17 00:00:00 2001 From: sfreeman Date: Wed, 29 Mar 2017 17:09:05 -0400 Subject: [PATCH 082/206] Added user provider Signed-off-by: sfreeman --- libraries/aws_iam_user.rb | 11 +-- libraries/aws_iam_user_provider.rb | 34 ++++++++ .../resources/aws_iam_user_provider_test.rb | 82 +++++++++++++++++++ test/unit/resources/aws_iam_user_test.rb | 45 ++-------- 4 files changed, 129 insertions(+), 43 deletions(-) create mode 100644 libraries/aws_iam_user_provider.rb create mode 100644 test/unit/resources/aws_iam_user_provider_test.rb diff --git a/libraries/aws_iam_user.rb b/libraries/aws_iam_user.rb index d5a6b76fa..01c6e69e8 100644 --- a/libraries/aws_iam_user.rb +++ b/libraries/aws_iam_user.rb @@ -10,19 +10,16 @@ class AwsIamUser < Inspec.resource(1) its('has_console_password?') { should be true } end " - def initialize(name, conn = AWSConnection.new) + def initialize(name, aws_user_provider = AwsIam::UserProvider.new) @name = name - @iam_resource = conn.iam_resource - @user = @iam_resource.user(@name) + @user = aws_user_provider.get_user(name) end def has_mfa_enabled? - !@user.mfa_devices.first.nil? + @user[:has_mfa_enabled?] end def has_console_password? - return !@user.login_profile.create_date.nil? - rescue Aws::IAM::Errors::NoSuchEntity - return false + @user[:has_console_password?] end end diff --git a/libraries/aws_iam_user_provider.rb b/libraries/aws_iam_user_provider.rb new file mode 100644 index 000000000..b0a111f78 --- /dev/null +++ b/libraries/aws_iam_user_provider.rb @@ -0,0 +1,34 @@ +# author: Alex Bedley +# author: Steffanie Freeman + +module AwsIam + class UserProvider + def initialize(conn = AWSConnection.new) + @iam_resource = conn.iam_resource + end + + def get_user(name) + aws_user = @iam_resource.user(name) + self.class.convert(aws_user) + end + + class << self + def has_mfa_enabled?(aws_user) + !aws_user.mfa_devices.first.nil? + end + + def has_console_password?(aws_user) + return !aws_user.login_profile.create_date.nil? + rescue Aws::IAM::Errors::NoSuchEntity + return false + end + + def convert(aws_user) + { + has_mfa_enabled?: has_mfa_enabled?(aws_user), + has_console_password?: has_console_password?(aws_user), + } + end + end + end +end diff --git a/test/unit/resources/aws_iam_user_provider_test.rb b/test/unit/resources/aws_iam_user_provider_test.rb new file mode 100644 index 000000000..d53a6fbea --- /dev/null +++ b/test/unit/resources/aws_iam_user_provider_test.rb @@ -0,0 +1,82 @@ +# author: Simon Varlow +# author: Jeffrey Lyons +# author: Steffanie Freeman +# author: Alex Bedley +require 'aws-sdk' +require 'helper' + +require 'aws_iam_user_provider' + +class AwsIamUserProviderTest < Minitest::Test + Username = "test" + + def setup + @mock_iam_resource = Minitest::Mock.new + @mock_aws_connection = Minitest::Mock.new + @mock_aws_connection.expect :iam_resource, @mock_iam_resource + @user_provider = AwsIam::UserProvider.new(@mock_aws_connection) + end + + def test_get_user + @mock_iam_resource.expect :user, create_mock_user, [Username] + assert !@user_provider.get_user(Username).nil? + end + + def test_has_mfa_enabled_returns_true + @mock_iam_resource.expect :user, create_mock_user(has_mfa_enabled: true), [Username] + assert @user_provider.get_user(Username)[:has_mfa_enabled?] + end + + def test_has_mfa_enabled_returns_false + @mock_iam_resource.expect :user, create_mock_user(has_mfa_enabled: false), [Username] + assert !@user_provider.get_user(Username)[:has_mfa_enabled?] + end + + def test_has_console_password_returns_true + @mock_iam_resource.expect :user, create_mock_user(has_console_password: true), [Username] + assert @user_provider.get_user(Username)[:has_console_password?] + end + + def test_has_console_password_returns_false + @mock_iam_resource.expect :user, create_mock_user(has_console_password: false), [Username] + assert !@user_provider.get_user(Username)[:has_console_password?] + end + + def test_has_console_password_returns_false_when_nosuchentity + @mock_iam_resource.expect :user, create_mock_user_throw(Aws::IAM::Errors::NoSuchEntity.new(nil, nil)), [Username] + + assert !@user_provider.get_user(Username)[:has_console_password?] + end + + def test_has_console_password_throws + @mock_iam_resource.expect :user, create_mock_user_throw(ArgumentError), [Username] + + assert_raises ArgumentError do + @user_provider.get_user(Username) + end + end + + private + + def create_mock_user(has_console_password: true, has_mfa_enabled: true) + mock_user = Minitest::Mock.new + mock_login_profile = Minitest::Mock.new + + mock_user.expect :mfa_devices, has_mfa_enabled ? ['device'] : [] + + mock_login_profile.expect :create_date, has_console_password ? 'date' : nil + mock_user.expect :login_profile, mock_login_profile + end + + def create_mock_user_throw(exception) + mock_user = Minitest::Mock.new + mock_login_profile = Minitest::Mock.new + + mock_user.expect :mfa_devices, [] + + mock_login_profile.expect :create_date, nil do |args| + raise exception + end + mock_user.expect :login_profile, mock_login_profile + end +end diff --git a/test/unit/resources/aws_iam_user_test.rb b/test/unit/resources/aws_iam_user_test.rb index 351540cc9..c6a07d6e9 100644 --- a/test/unit/resources/aws_iam_user_test.rb +++ b/test/unit/resources/aws_iam_user_test.rb @@ -8,53 +8,26 @@ class AwsIamUserTest < Minitest::Test Username = "test" def setup - @mockConn = Minitest::Mock.new - @mockUser = Minitest::Mock.new - @mockResource = Minitest::Mock.new - @mockProfile = Minitest::Mock.new - - @mockConn.expect :iam_resource, @mockResource - @mockResource.expect :user, @mockUser, [String] + @mock_user_provider = Minitest::Mock.new end def test_that_MFA_enable_returns_true_if_MFA_Enabled - @mockUser.expect :mfa_devices, ["test"] - assert AwsIamUser.new(Username, @mockConn).has_mfa_enabled? + @mock_user_provider.expect :get_user, {has_mfa_enabled?: true}, [Username] + assert AwsIamUser.new(Username, @mock_user_provider).has_mfa_enabled? end def test_that_MFA_enable_returns_false_if_MFA_is_not_Enabled - @mockUser.expect :mfa_devices, [] - assert !AwsIamUser.new(Username, @mockConn).has_mfa_enabled? + @mock_user_provider.expect :get_user, {has_mfa_enabled?: false}, [Username] + assert !AwsIamUser.new(Username, @mock_user_provider).has_mfa_enabled? end def test_that_console_Password_returns_true_if_console_Password_has_been_set - @mockUser.expect :login_profile, @mockProfile - @mockProfile.expect :create_date, "test" - assert AwsIamUser.new(Username, @mockConn).has_console_password? + @mock_user_provider.expect :get_user, {has_console_password?: true}, [Username] + assert AwsIamUser.new(Username, @mock_user_provider).has_console_password? end def test_that_console_Password_returns_false_if_console_Password_has_not_been_set - @mockUser.expect :login_profile, @mockProfile - @mockProfile.expect :create_date, nil - assert !AwsIamUser.new(Username, @mockConn).has_console_password? - end - - def test_that_console_Password_returns_false_if_console_Password_throws_no_such_entity - @mockUser.expect :login_profile, @mockProfile - @mockProfile.expect :create_date, nil do |args| - raise Aws::IAM::Errors::NoSuchEntity.new nil, nil - end - assert !AwsIamUser.new(Username, @mockConn).has_console_password? - end - - def test_that_console_Password_throws_if_console_Password_throws_not_no_such_entity - @mockUser.expect :login_profile, @mockProfile - @mockProfile.expect :create_date, nil do |args| - raise ArgumentError - end - - assert_raises ArgumentError do - AwsIamUser.new(Username, @mockConn).has_console_password? - end + @mock_user_provider.expect :get_user, {has_console_password?: false}, [Username] + assert !AwsIamUser.new(Username, @mock_user_provider).has_console_password? end end \ No newline at end of file From 19f752f778489715c4f3641067010b0df82b4803 Mon Sep 17 00:00:00 2001 From: Viktor Yakovlyev Date: Thu, 13 Apr 2017 15:00:04 -0400 Subject: [PATCH 083/206] add 1.11, password expiry and password expiry time in days, fix examples Signed-off-by: Viktor Yakovlyev check for unset password expiry Signed-off-by: Viktor Yakovlyev pr changes Signed-off-by: Viktor Yakovlyev --- libraries/iam_password_policy.rb | 13 +++++++++++-- test/unit/resources/iam_password_policy_test.rb | 15 +++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/libraries/iam_password_policy.rb b/libraries/iam_password_policy.rb index 2b709bcd0..fd4260528 100644 --- a/libraries/iam_password_policy.rb +++ b/libraries/iam_password_policy.rb @@ -7,11 +7,11 @@ class IamPasswordPolicy < Inspec.resource(1) desc 'Verifies iam password policy' example " - describe iam_password_policy('i-123456') do + describe iam_password_policy do its('requires_lowercase_letters?') { should be true } end - describe iam_password_policy('i-123456') do + describe iam_password_policy do its('requires_uppercase_letters?') { should be true } end " @@ -49,4 +49,13 @@ class IamPasswordPolicy < Inspec.resource(1) def allows_users_to_change_password? @policy.allow_users_to_change_password end + + def expires_passwords? + @policy.expire_passwords + end + + def max_password_age + raise 'this policy does not expire passwords' unless expires_passwords? + @policy.max_password_age + end end diff --git a/test/unit/resources/iam_password_policy_test.rb b/test/unit/resources/iam_password_policy_test.rb index ed0583458..d24ea308d 100644 --- a/test/unit/resources/iam_password_policy_test.rb +++ b/test/unit/resources/iam_password_policy_test.rb @@ -23,4 +23,19 @@ class IamPasswordPolicyTest < Minitest::Test @mockConn.expect :iam_resource, @mockResource assert_equal false, IamPasswordPolicy.new(@mockConn).exists? end + + def test_throws_when_password_age_0 + @mockResource = Minitest::Mock.new + @policyObject = Minitest::Mock.new + @policyObject.expect :expire_passwords, false + + @mockResource.expect :account_password_policy, @policyObject + @mockConn.expect :iam_resource, @mockResource + begin + IamPasswordPolicy.new(@mockConn).max_password_age + flunk 'Should throw before getting here' + rescue Exception => e + assert_equal e.message, 'this policy does not expire passwords' + end + end end \ No newline at end of file From 891f075d134a3a2c8390a795c73d8780485b6426 Mon Sep 17 00:00:00 2001 From: Chris Redekop Date: Fri, 28 Apr 2017 10:50:26 +0000 Subject: [PATCH 084/206] Update resource based on PR feedback Signed-off-by: Chris Redekop --- ...d_policy.rb => aws_iam_password_policy.rb} | 8 ++-- .../resources/aws_iam_password_policy_test.rb | 40 ++++++++++++++++++ .../resources/iam_password_policy_test.rb | 41 ------------------- 3 files changed, 44 insertions(+), 45 deletions(-) rename libraries/{iam_password_policy.rb => aws_iam_password_policy.rb} (87%) create mode 100644 test/unit/resources/aws_iam_password_policy_test.rb delete mode 100644 test/unit/resources/iam_password_policy_test.rb diff --git a/libraries/iam_password_policy.rb b/libraries/aws_iam_password_policy.rb similarity index 87% rename from libraries/iam_password_policy.rb rename to libraries/aws_iam_password_policy.rb index fd4260528..0c74fe151 100644 --- a/libraries/iam_password_policy.rb +++ b/libraries/aws_iam_password_policy.rb @@ -2,16 +2,16 @@ require 'aws_conn' -class IamPasswordPolicy < Inspec.resource(1) - name 'iam_password_policy' +class AwsIamPasswordPolicy < Inspec.resource(1) + name 'aws_iam_password_policy' desc 'Verifies iam password policy' example " - describe iam_password_policy do + describe aws_iam_password_policy do its('requires_lowercase_letters?') { should be true } end - describe iam_password_policy do + describe aws_iam_password_policy do its('requires_uppercase_letters?') { should be true } end " diff --git a/test/unit/resources/aws_iam_password_policy_test.rb b/test/unit/resources/aws_iam_password_policy_test.rb new file mode 100644 index 000000000..3f4a55306 --- /dev/null +++ b/test/unit/resources/aws_iam_password_policy_test.rb @@ -0,0 +1,40 @@ +require 'helper' +require 'aws_iam_password_policy' +require 'aws-sdk' +require 'json' + +class AwsIamPasswordPolicyTest < Minitest::Test + def setup + @mockConn = Minitest::Mock.new + @mockResource = Minitest::Mock.new + + @mockConn.expect :iam_resource, @mockResource + end + + def test_policy_exists_when_policy_exists + @mockResource.expect :account_password_policy, true + + assert_equal true, AwsIamPasswordPolicy.new(@mockConn).exists? + end + + def test_policy_does_not_exists_when_no_policy + @mockResource.expect :account_password_policy, nil do |args| + raise Aws::IAM::Errors::NoSuchEntity.new nil, nil + end + + assert_equal false, AwsIamPasswordPolicy.new(@mockConn).exists? + end + + def test_throws_when_password_age_0 + policyObject = Minitest::Mock.new + policyObject.expect :expire_passwords, false + + @mockResource.expect :account_password_policy, policyObject + + e = assert_raises Exception do + AwsIamPasswordPolicy.new(@mockConn).max_password_age + end + + assert_equal e.message, 'this policy does not expire passwords' + end +end diff --git a/test/unit/resources/iam_password_policy_test.rb b/test/unit/resources/iam_password_policy_test.rb deleted file mode 100644 index d24ea308d..000000000 --- a/test/unit/resources/iam_password_policy_test.rb +++ /dev/null @@ -1,41 +0,0 @@ -require 'helper' -require 'iam_password_policy' -require 'aws-sdk' -require 'json' - -class IamPasswordPolicyTest < Minitest::Test - def setup - @mockConn = Minitest::Mock.new - end - - def test_policy_exists_when_policy_exists - @mockResource = Minitest::Mock.new - @mockResource.expect :account_password_policy, true - @mockConn.expect :iam_resource, @mockResource - assert_equal true, IamPasswordPolicy.new(@mockConn).exists? - end - - def test_policy_does_not_exists_when_no_policy - @mockResource = Minitest::Mock.new - @mockResource.expect :account_password_policy, nil do |args| - raise Aws::IAM::Errors::NoSuchEntity.new nil, nil - end - @mockConn.expect :iam_resource, @mockResource - assert_equal false, IamPasswordPolicy.new(@mockConn).exists? - end - - def test_throws_when_password_age_0 - @mockResource = Minitest::Mock.new - @policyObject = Minitest::Mock.new - @policyObject.expect :expire_passwords, false - - @mockResource.expect :account_password_policy, @policyObject - @mockConn.expect :iam_resource, @mockResource - begin - IamPasswordPolicy.new(@mockConn).max_password_age - flunk 'Should throw before getting here' - rescue Exception => e - assert_equal e.message, 'this policy does not expire passwords' - end - end -end \ No newline at end of file From 37bcce661852b7c3fbd431b504cb937b5fbfbf49 Mon Sep 17 00:00:00 2001 From: Chris Redekop Date: Sat, 15 Apr 2017 12:47:16 +0000 Subject: [PATCH 085/206] Add defensive programming Signed-off-by: Chris Redekop --- libraries/aws_iam_access_key.rb | 93 +++++-- test/integration/build/aws.tf | 6 +- .../verify/controls/aws_iam_access_key.rb | 3 + .../unit/resources/aws_iam_access_key_test.rb | 236 ++++++++++++++++-- 4 files changed, 287 insertions(+), 51 deletions(-) create mode 100644 test/integration/verify/controls/aws_iam_access_key.rb diff --git a/libraries/aws_iam_access_key.rb b/libraries/aws_iam_access_key.rb index 6fe27e4e0..8e4846487 100644 --- a/libraries/aws_iam_access_key.rb +++ b/libraries/aws_iam_access_key.rb @@ -4,53 +4,100 @@ class AwsIamAccessKey < Inspec.resource(1) desc 'Verifies settings for AWS IAM access keys' example " describe aws_iam_access_key(username: 'username', id: 'access-key id') do - its('last_use') { should be > Time.now - 90 * 86400 } + it { should exist } + it { should_not be_active } + its('create_date') { should be > Time.now - 365 * 86400 } + its('last_used_date') { should be > Time.now - 90 * 86400 } end " - def initialize(opts, conn = AWSConnection.new) + + def initialize(opts, decorator = IamClientDecorator.new) @opts = opts - @iam_client = conn.iam_client + @decorator = decorator + end + + def exists? + !access_key.nil? + rescue AccessKeyNotFoundError + false + rescue Aws::IAM::Errors::NoSuchEntity + false end def id - access_key_metadata.access_key_id + access_key.access_key_id end def active? - 'Active'.eql? access_key_metadata.status + 'Active'.eql? access_key.status end def create_date - access_key_metadata.create_date + access_key.create_date end def last_used_date access_key_last_used.last_used_date end - private - - def access_key_last_used - @access_key_last_used ||= - @iam_client.get_access_key_last_used( - { - access_key_id: access_key_metadata.access_key_id, - }, - ).access_key_last_used + def to_s + "IAM Access-Key #{@opts[:id]}" end - def access_key_metadata - unless (defined? @access_key_metadata) && @access_key_metadata - @iam_client.list_access_keys({ user_name: @opts[:username] }) - .access_key_metadata.each do |access_key_metadata| + class AccessKeyNotFoundError < StandardError + end - if access_key_metadata.access_key_id.eql? @opts[:id] - @access_key_metadata = access_key_metadata - break + class IamClientDecorator + def initialize(validator = ArgumentValidator.new, + conn = AWSConnection.new) + + @validator = validator + @client = conn.iam_client + end + + def get_access_key(username, id) + @validator.validate_username(username) + @validator.validate_id(id) + + access_key = + @client.list_access_keys({ user_name: username }) + .access_key_metadata.select { |x| x.access_key_id.eql? id }.first + + if access_key.nil? + raise AccessKeyNotFoundError, 'access key not found '.concat( + "[username = \"#{username}\", id = \"#{id}\"]", + ) + end + + access_key + end + + def get_access_key_last_used(id) + @validator.validate_id(id) + + @client.get_access_key_last_used({ access_key_id: id }) + .access_key_last_used + end + + class ArgumentValidator + [:username, :id].each do |argument| + define_method "validate_#{argument}" do |value| + return unless value.nil? + + raise ArgumentError, + "missing required resource argument \"#{argument}\"" end end end + end - @access_key_metadata + private + + def access_key + @access_key ||= @decorator.get_access_key(@opts[:username], @opts[:id]) + end + + def access_key_last_used + @access_key_last_used ||= @decorator.get_access_key_last_used(@opts[:id]) end end diff --git a/test/integration/build/aws.tf b/test/integration/build/aws.tf index 595bd334e..2f435e795 100644 --- a/test/integration/build/aws.tf +++ b/test/integration/build/aws.tf @@ -22,4 +22,8 @@ resource "aws_iam_user" "console_password_enabled_user" { resource "aws_iam_user_login_profile" "u" { user = "${aws_iam_user.console_password_enabled_user.name}" pgp_key = "${var.login_profile_pgp_key}" -} \ No newline at end of file +} + +resource "aws_iam_user" "access_key_user" { + name = "access_key_user" +} diff --git a/test/integration/verify/controls/aws_iam_access_key.rb b/test/integration/verify/controls/aws_iam_access_key.rb new file mode 100644 index 000000000..99536ce19 --- /dev/null +++ b/test/integration/verify/controls/aws_iam_access_key.rb @@ -0,0 +1,3 @@ +describe aws_iam_access_key(username: 'not-a-user', 'id': 'not-an-id') do + it { should_not exist } +end diff --git a/test/unit/resources/aws_iam_access_key_test.rb b/test/unit/resources/aws_iam_access_key_test.rb index 6c6ad6770..044ba18fe 100644 --- a/test/unit/resources/aws_iam_access_key_test.rb +++ b/test/unit/resources/aws_iam_access_key_test.rb @@ -1,54 +1,236 @@ # author: Chris Redekop + require 'aws-sdk' require 'helper' require 'aws_iam_access_key' class AwsIamAccessKeyTest < Minitest::Test - Username = 'test' + Username = 'test' Id = 'id' Date = 'date' - def setup - @mockConn = Minitest::Mock.new - @mockClient = Minitest::Mock.new - @mockAccessKey = Minitest::Mock.new + module AccessKeyFactory + def aws_iam_access_key(decorator = mock_decorator(stub_access_key)) + AwsIamAccessKey.new({ username: Username, id: Id }, decorator) + end - @mockConn.expect :iam_client, @mockClient - @mockClient.expect :list_access_keys, OpenStruct.new({'access_key_metadata' => [@mockAccessKey]}), [{user_name: Username}] - @mockAccessKey.expect :access_key_id, Id + def stub_access_key( + _nil: false, + id: Id, + status: 'Active', + create_date: Date + ) + OpenStruct.new({ + :nil? => _nil, + :access_key_id => id, + :status => status, + :create_date => create_date + }) + end end - def test_that_id_returns_access_key_id_always - @mockAccessKey.expect :access_key_id, Id + include AccessKeyFactory - assert_equal AwsIamAccessKey.new({username: Username, id: Id}, @mockConn).id, Id + def test_exists_returns_true_when_access_key_exists + assert aws_iam_access_key.exists? end - def test_that_active_returns_true_when_access_key_status_is_active - @mockAccessKey.expect :status, 'Active' + def test_exists_returns_false_when_sdk_raises + mock_decorator = mock_decorator_raise( + Aws::IAM::Errors::NoSuchEntity.new(nil, nil)) - assert AwsIamAccessKey.new({username: Username, id: Id}, @mockConn).active? + refute aws_iam_access_key(mock_decorator).exists? + + mock_decorator.verify end - def test_that_active_returns_false_when_access_key_status_is_not_active - @mockAccessKey.expect :status, 'Foo' + def test_exists_returns_false_when_access_key_does_not_exist + mock_decorator = mock_decorator_raise( + AwsIamAccessKey::AccessKeyNotFoundError.new) - assert !AwsIamAccessKey.new({username: Username, id: Id}, @mockConn).active? + refute aws_iam_access_key(mock_decorator).exists? + + mock_decorator.verify end - def test_that_create_date_returns_access_key_last_used_date_always - @mockAccessKey.expect :create_date, Date - - assert_equal AwsIamAccessKey.new({username: Username, id: Id}, @mockConn).create_date, Date + def test_id_returns_id_when_access_key_exists + assert_equal Id, aws_iam_access_key.id end - def test_that_create_returns_access_key_create_date_always - @mockAccessKey.expect :access_key_id, Id - mockAccessKeyLastUsed = Minitest::Mock.new - mockAccessKeyLastUsed.expect :last_used_date, Date - @mockClient.expect :get_access_key_last_used, OpenStruct.new({'access_key_last_used' => mockAccessKeyLastUsed}), [{access_key_id: Id}] + def test_active_returns_true_when_access_key_is_active + assert aws_iam_access_key.active? + end - assert_equal AwsIamAccessKey.new({username: Username, id: Id}, @mockConn).last_used_date, Date + def test_active_returns_false_when_access_key_is_not_active + refute aws_iam_access_key(mock_decorator(stub_access_key(status: 'Foo'))) + .active? + end + + def test_create_date_returns_create_date_always + assert_equal Date, aws_iam_access_key.create_date + end + + def test_last_used_date_returns_last_used_date_always + assert_equal( + Date, + aws_iam_access_key(mock_decorator(nil, + OpenStruct.new({ :last_used_date => Date }))).last_used_date + ) + end + + class IamClientDecoratorTest < Minitest::Test + include AccessKeyFactory + + def test_get_access_key_raises_when_no_access_keys_found + validator = mock_validator + + begin + iam_client_decorator(validator).get_access_key(Username, Id) + flunk + rescue AwsIamAccessKey::AccessKeyNotFoundError => e + assert_match(/.*access key not found.*/, e.message) + assert_match(/.*#{Username}.*/, e.message) + assert_match(/.*#{Id}.*/, e.message) + end + + validator.verify + end + + def test_get_access_key_raises_when_matching_access_key_not_found + validator = mock_validator + + begin + iam_client_decorator(validator, [stub_access_key(id: 'Foo')]) + .get_access_key(Username, Id) + flunk + rescue AwsIamAccessKey::AccessKeyNotFoundError => e + assert_match(/.*access key not found.*/, e.message) + assert_match(/.*#{Username}.*/, e.message) + assert_match(/.*#{Id}.*/, e.message) + end + + validator.verify + end + + def test_get_access_key_returns_access_key_when_access_key_found + access_key = stub_access_key + validator = mock_validator + + assert_equal( + access_key, + iam_client_decorator(validator, [access_key]).get_access_key(Username, Id) + ) + + validator.verify + end + + def test_get_access_key_last_used_returns_last_used_when_last_used_found + access_key_last_used = Object.new + validator = mock_validator(false) + + assert_equal( + access_key_last_used, + iam_client_decorator(validator, nil, access_key_last_used) + .get_access_key_last_used(Id) + ) + + validator.verify + end + + class ArgumentValidatorTest < Minitest::Test + def test_validate_id_raises_when_id_is_nil + argument_validator.validate_id(nil) + flunk + rescue ArgumentError => e + assert_match(/.*missing.*"id".*/, e.message) + end + + def test_validate_id_does_nothing_when_id_is_not_nil + argument_validator.validate_id(Id) + end + + def test_validate_username_raises_when_username_is_nil + argument_validator.validate_username(nil) + flunk + rescue ArgumentError => e + assert_match(/.*missing.*"username".*/, e.message) + end + + def test_validate_username_does_nothing_when_username_is_not_nil + argument_validator.validate_username(Username) + end + + def argument_validator + AwsIamAccessKey::IamClientDecorator::ArgumentValidator.new + end + end + + def mock_validator(validate_username = true) + mock_validator = Minitest::Mock.new.expect :validate_id, nil, [Id] + + if validate_username + mock_validator.expect :validate_username, nil, [Username] + end + + mock_validator + end + + def mock_conn(access_keys, access_key_last_used = nil) + Minitest::Mock.new.expect( + :iam_client, + mock_client(access_keys, access_key_last_used) + ) + end + + def mock_client(access_keys, access_key_last_used) + mock_iam_client = Minitest::Mock.new + + if access_keys + mock_iam_client.expect( + :list_access_keys, + OpenStruct.new({'access_key_metadata' => access_keys}), + [{user_name: Username}] + ) + end + + if access_key_last_used + mock_iam_client.expect( + :get_access_key_last_used, + OpenStruct.new({'access_key_last_used' => access_key_last_used}), + [{access_key_id: Id}] + ) + end + + mock_iam_client + end + + def iam_client_decorator(validator, access_keys = [], access_key_last_used = nil) + AwsIamAccessKey::IamClientDecorator.new( + validator, mock_conn(access_keys, access_key_last_used)) + end + end + + def mock_decorator(access_key, access_key_last_used = nil) + mock_decorator = Minitest::Mock.new + + if access_key + mock_decorator.expect :get_access_key, access_key, [Username, Id] + end + + if access_key_last_used + mock_decorator.expect :get_access_key_last_used, access_key_last_used, [Id] + end + + mock_decorator + end + + def mock_decorator_raise(error) + Minitest::Mock.new.expect(:get_access_key, nil) do |username, id| + assert_equal Username, username + assert_equal Id, id + + raise error + end end end From 3f22a54aa63a47f61026425da36fa2f41035de3d Mon Sep 17 00:00:00 2001 From: Chris Redekop Date: Thu, 27 Apr 2017 12:02:56 +0000 Subject: [PATCH 086/206] Remove unnecessary test resource Signed-off-by: Chris Redekop --- test/integration/build/aws.tf | 4 ---- 1 file changed, 4 deletions(-) diff --git a/test/integration/build/aws.tf b/test/integration/build/aws.tf index 2f435e795..6052a6bff 100644 --- a/test/integration/build/aws.tf +++ b/test/integration/build/aws.tf @@ -23,7 +23,3 @@ resource "aws_iam_user_login_profile" "u" { user = "${aws_iam_user.console_password_enabled_user.name}" pgp_key = "${var.login_profile_pgp_key}" } - -resource "aws_iam_user" "access_key_user" { - name = "access_key_user" -} From 0ab4ae7d6ea9b62891c79a57544de830b57d2641 Mon Sep 17 00:00:00 2001 From: Chris Redekop Date: Fri, 28 Apr 2017 10:40:19 +0000 Subject: [PATCH 087/206] Update resource based on PR feedback Signed-off-by: Chris Redekop --- libraries/aws_iam_access_key.rb | 4 +--- .../unit/resources/aws_iam_access_key_test.rb | 22 +++++++++---------- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/libraries/aws_iam_access_key.rb b/libraries/aws_iam_access_key.rb index 8e4846487..199876399 100644 --- a/libraries/aws_iam_access_key.rb +++ b/libraries/aws_iam_access_key.rb @@ -18,9 +18,7 @@ class AwsIamAccessKey < Inspec.resource(1) def exists? !access_key.nil? - rescue AccessKeyNotFoundError - false - rescue Aws::IAM::Errors::NoSuchEntity + rescue AccessKeyNotFoundError, Aws::IAM::Errors::NoSuchEntity false end diff --git a/test/unit/resources/aws_iam_access_key_test.rb b/test/unit/resources/aws_iam_access_key_test.rb index 044ba18fe..e4be92c66 100644 --- a/test/unit/resources/aws_iam_access_key_test.rb +++ b/test/unit/resources/aws_iam_access_key_test.rb @@ -85,31 +85,29 @@ class AwsIamAccessKeyTest < Minitest::Test def test_get_access_key_raises_when_no_access_keys_found validator = mock_validator - begin + e = assert_raises AwsIamAccessKey::AccessKeyNotFoundError do iam_client_decorator(validator).get_access_key(Username, Id) - flunk - rescue AwsIamAccessKey::AccessKeyNotFoundError => e - assert_match(/.*access key not found.*/, e.message) - assert_match(/.*#{Username}.*/, e.message) - assert_match(/.*#{Id}.*/, e.message) end + assert_match(/.*access key not found.*/, e.message) + assert_match(/.*#{Username}.*/, e.message) + assert_match(/.*#{Id}.*/, e.message) + validator.verify end def test_get_access_key_raises_when_matching_access_key_not_found validator = mock_validator - begin + e = assert_raises AwsIamAccessKey::AccessKeyNotFoundError do iam_client_decorator(validator, [stub_access_key(id: 'Foo')]) .get_access_key(Username, Id) - flunk - rescue AwsIamAccessKey::AccessKeyNotFoundError => e - assert_match(/.*access key not found.*/, e.message) - assert_match(/.*#{Username}.*/, e.message) - assert_match(/.*#{Id}.*/, e.message) end + assert_match(/.*access key not found.*/, e.message) + assert_match(/.*#{Username}.*/, e.message) + assert_match(/.*#{Id}.*/, e.message) + validator.verify end From 69434fec48397bd2e46726b362ede613624ea607 Mon Sep 17 00:00:00 2001 From: Miles Date: Sat, 29 Apr 2017 19:46:53 -0400 Subject: [PATCH 088/206] Use terraform environments to avoid integration test collisions * When running integration tests with Rakefile use terraform environment based on environment variable INSPEC_TERRAFORM_ENV ** If INSPEC_TERRAFORM_ENV is not provided, a random string will be used * Use terraform environment as a namespace for AWS artifacts * Use attribute file for inspec to be aware of the terraform environment used Signed-off-by: Miles Tjandrawidjaja --- .gitignore | 8 +++-- README.md | 4 ++- Rakefile | 30 ++++++++++++++++--- test/integration/build/aws.tf | 22 ++++++++++++-- test/integration/verify/controls/aws.rb | 18 ++++++++++- .../verify/controls/aws_iam_user.rb | 24 ++++++++++----- 6 files changed, 88 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index dea93cf6f..ecd5c24be 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ +.attribute.yml +.bundle/ +.terraform/ inspec.lock Gemfile.lock -terraform.tfstate -terraform.tfstate.backup \ No newline at end of file +terraform.tfstate* +terraform.tfstate.backup + diff --git a/README.md b/README.md index e4ae085ac..27cd660d7 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ bundle exec rake test ### Integration tests To run the integration tests, please make sure all required environment variables like `AWS_ACCESS_KEY_ID` -, `AWS_SECRET_ACCESS_KEY` and `AWS_DEFAULT_REGION` are set properly. (`AWS_DEFAULT_REGION` **must** be set to **us-east-1** when running the integration tests.) We use terraform to create the AWS setup and InSpec to verify the all aspects. Integration tests can be executed via: +, `AWS_SECRET_ACCESS_KEY` and `AWS_DEFAULT_REGION` are set properly. (`AWS_DEFAULT_REGION` **must** be set to **us-east-1** when running the integration tests.) We use terraform to create the AWS setup and InSpec to verify the all aspects. If you want to use a specific terraform environment, set environment variable `INSPEC_TERRAFORM_ENV`. Integration tests can be executed via: ``` bundle exec rake test:integration @@ -105,9 +105,11 @@ bundle exec rake test:integration This task sets up test AWS resources, runs the integration tests, and then cleans up the resources. To perform these tasks independently, please call them individually: +* `bundle exec rake test:configure_test_environment` * `bundle exec rake test:setup_integration_tests` * `bundle exec rake test:run_integration_tests` * `bundle exec rake test:cleanup_integration_tests` +* `bundle exec rake test:destroy_test_environment` ## Kudos diff --git a/Rakefile b/Rakefile index 80d87da6e..05456e472 100644 --- a/Rakefile +++ b/Rakefile @@ -3,6 +3,7 @@ require 'rake/testtask' require 'rubocop/rake_task' +require 'securerandom' # Rubocop desc 'Run Rubocop lint checks' @@ -25,23 +26,36 @@ task lint: [:rubocop] task default: [:lint, :test] namespace :test do - integration_dir = "test/integration" + terraform_env = ENV['INSPEC_TERRAFORM_ENV'] || SecureRandom.urlsafe_base64(5) + project_dir = File.dirname(__FILE__) + attribute_file = File.join(project_dir, ".attribute.yml") + integration_dir = File.join(project_dir, "test/integration") # run inspec check to verify that the profile is properly configured task :check do - dir = File.join(File.dirname(__FILE__)) - sh("bundle exec inspec check #{dir}") + sh("bundle exec inspec check #{project_dir}") + end + + task :configure_test_environment do + puts "----> Creating terraform environment" + sh("cd #{integration_dir}/build/ && terraform env new #{terraform_env}") end task :setup_integration_tests do puts "----> Setup" sh("cd #{integration_dir}/build/ && terraform plan") sh("cd #{integration_dir}/build/ && terraform apply") + sh("cd #{integration_dir}/build/ && terraform output > #{attribute_file}") + + raw_output = File.read(attribute_file) + yaml_output = raw_output.gsub(" = ", " : ") + File.open(attribute_file, "w") {|file| file.puts yaml_output} end + task :run_integration_tests do puts "----> Run" - sh("bundle exec inspec exec #{integration_dir}/verify") + sh("bundle exec inspec exec #{integration_dir}/verify --attrs #{attribute_file}") end task :cleanup_integration_tests do @@ -49,10 +63,18 @@ namespace :test do sh("cd #{integration_dir}/build/ && terraform destroy -force") end + task :destroy_test_environment do + puts "----> Destroying terraform environment" + sh("cd #{integration_dir}/build/ && terraform env select default") + sh("cd #{integration_dir}/build && terraform env delete #{terraform_env}") + end + task :integration do + Rake::Task["test:configure_test_environment"].execute Rake::Task["test:cleanup_integration_tests"].execute Rake::Task["test:setup_integration_tests"].execute Rake::Task["test:run_integration_tests"].execute Rake::Task["test:cleanup_integration_tests"].execute + Rake::Task["test:destroy_test_environment"].execute end end diff --git a/test/integration/build/aws.tf b/test/integration/build/aws.tf index 6052a6bff..212d42d19 100644 --- a/test/integration/build/aws.tf +++ b/test/integration/build/aws.tf @@ -5,17 +5,17 @@ resource "aws_instance" "example" { instance_type = "t2.micro" tags { - Name = "Example" + Name = "${terraform.env}.Example" X-Project = "inspec" } } resource "aws_iam_user" "mfa_not_enabled_user" { - name = "mfa_not_enabled_user" + name = "${terraform.env}.mfa_not_enabled_user" } resource "aws_iam_user" "console_password_enabled_user" { - name = "console_password_enabled_user" + name = "${terraform.env}.console_password_enabled_user" force_destroy = true } @@ -23,3 +23,19 @@ resource "aws_iam_user_login_profile" "u" { user = "${aws_iam_user.console_password_enabled_user.name}" pgp_key = "${var.login_profile_pgp_key}" } + +output "mfa_not_enabled_user" { + value = "${aws_iam_user.mfa_not_enabled_user.name}" +} + +output "console_password_enabled_user" { + value = "${aws_iam_user.console_password_enabled_user.name}" +} + +output "example_ec2_name" { + value = "${aws_instance.example.tags.Name}" +} + +output "example_ec2_id" { + value = "${aws_instance.example.id}" +} diff --git a/test/integration/verify/controls/aws.rb b/test/integration/verify/controls/aws.rb index 9b031309c..5f90ae823 100644 --- a/test/integration/verify/controls/aws.rb +++ b/test/integration/verify/controls/aws.rb @@ -1,4 +1,20 @@ -describe aws_ec2(name: 'Example') do +example_ec2_id = attribute( + 'example_ec2_id', + default: 'default.example_ec2_id', + description: 'ID of example ec2 instance') + +example_ec2_name = attribute( + 'example_ec2_name', + default: 'default.Example', + description: 'Name of exapmle ec2 instance') + +describe aws_ec2(name: example_ec2_name) do + it { should exist } + its('image_id') { should eq 'ami-0d729a60' } + its('instance_type') { should eq 't2.micro' } +end + +describe aws_ec2(example_ec2_id) do it { should exist } its('image_id') { should eq 'ami-0d729a60' } its('instance_type') { should eq 't2.micro' } diff --git a/test/integration/verify/controls/aws_iam_user.rb b/test/integration/verify/controls/aws_iam_user.rb index 50438aa6e..76c97fb1b 100644 --- a/test/integration/verify/controls/aws_iam_user.rb +++ b/test/integration/verify/controls/aws_iam_user.rb @@ -1,8 +1,18 @@ -describe aws_iam_user('mfa_not_enabled_user') do - its('has_mfa_enabled?') { should be false } - its('has_console_password?') { should be false } -end - -describe aws_iam_user('console_password_enabled_user') do - its('has_console_password?') { should be true } +mfa_not_enabled_user = attribute( + 'mfa_not_enabled_user', + default: 'default.mfa_not_enabled_user', + description: 'Name of IAM user mfa_not_enabled_user') + +console_password_enabled_user = attribute( + 'console_password_enabled_user', + default: 'default.console_password_enabled_user', + description: 'Name of IAM user console_password_enabled_user') + +describe aws_iam_user(mfa_not_enabled_user) do + its('has_mfa_enabled?') { should be false } + its('has_console_password?') { should be false } +end + +describe aws_iam_user(console_password_enabled_user) do + its('has_console_password?') { should be true } end \ No newline at end of file From beedecf247e6d20ddcf5dd93f29dc30da6f2d926 Mon Sep 17 00:00:00 2001 From: sfreeman Date: Wed, 10 May 2017 15:41:03 -0400 Subject: [PATCH 089/206] Add list_users Signed-off-by: sfreeman --- libraries/aws_iam_user.rb | 2 +- libraries/aws_iam_user_provider.rb | 9 ++++++- .../resources/aws_iam_user_provider_test.rb | 27 +++++++++++++------ test/unit/resources/aws_iam_user_test.rb | 8 +++--- 4 files changed, 32 insertions(+), 14 deletions(-) diff --git a/libraries/aws_iam_user.rb b/libraries/aws_iam_user.rb index 01c6e69e8..89b868ad0 100644 --- a/libraries/aws_iam_user.rb +++ b/libraries/aws_iam_user.rb @@ -12,7 +12,7 @@ class AwsIamUser < Inspec.resource(1) " def initialize(name, aws_user_provider = AwsIam::UserProvider.new) @name = name - @user = aws_user_provider.get_user(name) + @user = aws_user_provider.user(name) end def has_mfa_enabled? diff --git a/libraries/aws_iam_user_provider.rb b/libraries/aws_iam_user_provider.rb index b0a111f78..4245876fd 100644 --- a/libraries/aws_iam_user_provider.rb +++ b/libraries/aws_iam_user_provider.rb @@ -7,11 +7,18 @@ module AwsIam @iam_resource = conn.iam_resource end - def get_user(name) + def user(name) aws_user = @iam_resource.user(name) self.class.convert(aws_user) end + def list_users + aws_users = @iam_resource.users + aws_users.map do |aws_user| + self.class.convert(aws_user) + end + end + class << self def has_mfa_enabled?(aws_user) !aws_user.mfa_devices.first.nil? diff --git a/test/unit/resources/aws_iam_user_provider_test.rb b/test/unit/resources/aws_iam_user_provider_test.rb index d53a6fbea..1c4288b9b 100644 --- a/test/unit/resources/aws_iam_user_provider_test.rb +++ b/test/unit/resources/aws_iam_user_provider_test.rb @@ -17,42 +17,53 @@ class AwsIamUserProviderTest < Minitest::Test @user_provider = AwsIam::UserProvider.new(@mock_aws_connection) end - def test_get_user + def test_user @mock_iam_resource.expect :user, create_mock_user, [Username] - assert !@user_provider.get_user(Username).nil? + assert !@user_provider.user(Username).nil? + end + + def test_list_users + @mock_iam_resource.expect :users, [create_mock_user, create_mock_user, create_mock_user] + mock_user_output = {has_mfa_enabled?: true, has_console_password?: true} + assert @user_provider.list_users == [mock_user_output, mock_user_output, mock_user_output] + end + + def test_list_users_no_users + @mock_iam_resource.expect :users, [] + assert @user_provider.list_users == [] end def test_has_mfa_enabled_returns_true @mock_iam_resource.expect :user, create_mock_user(has_mfa_enabled: true), [Username] - assert @user_provider.get_user(Username)[:has_mfa_enabled?] + assert @user_provider.user(Username)[:has_mfa_enabled?] end def test_has_mfa_enabled_returns_false @mock_iam_resource.expect :user, create_mock_user(has_mfa_enabled: false), [Username] - assert !@user_provider.get_user(Username)[:has_mfa_enabled?] + assert !@user_provider.user(Username)[:has_mfa_enabled?] end def test_has_console_password_returns_true @mock_iam_resource.expect :user, create_mock_user(has_console_password: true), [Username] - assert @user_provider.get_user(Username)[:has_console_password?] + assert @user_provider.user(Username)[:has_console_password?] end def test_has_console_password_returns_false @mock_iam_resource.expect :user, create_mock_user(has_console_password: false), [Username] - assert !@user_provider.get_user(Username)[:has_console_password?] + assert !@user_provider.user(Username)[:has_console_password?] end def test_has_console_password_returns_false_when_nosuchentity @mock_iam_resource.expect :user, create_mock_user_throw(Aws::IAM::Errors::NoSuchEntity.new(nil, nil)), [Username] - assert !@user_provider.get_user(Username)[:has_console_password?] + assert !@user_provider.user(Username)[:has_console_password?] end def test_has_console_password_throws @mock_iam_resource.expect :user, create_mock_user_throw(ArgumentError), [Username] assert_raises ArgumentError do - @user_provider.get_user(Username) + @user_provider.user(Username) end end diff --git a/test/unit/resources/aws_iam_user_test.rb b/test/unit/resources/aws_iam_user_test.rb index c6a07d6e9..defc1333f 100644 --- a/test/unit/resources/aws_iam_user_test.rb +++ b/test/unit/resources/aws_iam_user_test.rb @@ -12,22 +12,22 @@ Username = "test" end def test_that_MFA_enable_returns_true_if_MFA_Enabled - @mock_user_provider.expect :get_user, {has_mfa_enabled?: true}, [Username] + @mock_user_provider.expect :user, {has_mfa_enabled?: true}, [Username] assert AwsIamUser.new(Username, @mock_user_provider).has_mfa_enabled? end def test_that_MFA_enable_returns_false_if_MFA_is_not_Enabled - @mock_user_provider.expect :get_user, {has_mfa_enabled?: false}, [Username] + @mock_user_provider.expect :user, {has_mfa_enabled?: false}, [Username] assert !AwsIamUser.new(Username, @mock_user_provider).has_mfa_enabled? end def test_that_console_Password_returns_true_if_console_Password_has_been_set - @mock_user_provider.expect :get_user, {has_console_password?: true}, [Username] + @mock_user_provider.expect :user, {has_console_password?: true}, [Username] assert AwsIamUser.new(Username, @mock_user_provider).has_console_password? end def test_that_console_Password_returns_false_if_console_Password_has_not_been_set - @mock_user_provider.expect :get_user, {has_console_password?: false}, [Username] + @mock_user_provider.expect :user, {has_console_password?: false}, [Username] assert !AwsIamUser.new(Username, @mock_user_provider).has_console_password? end end \ No newline at end of file From 323a0e8064d3bb09226136bc7cd611a9cf5ae33a Mon Sep 17 00:00:00 2001 From: Miles Tjandrawidjaja Date: Sat, 13 May 2017 21:40:09 -0400 Subject: [PATCH 090/206] Remove unneeded require statement Signed-off-by: Miles Tjandrawidjaja --- libraries/ec2.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/libraries/ec2.rb b/libraries/ec2.rb index dcbffc65b..8de926506 100644 --- a/libraries/ec2.rb +++ b/libraries/ec2.rb @@ -1,7 +1,5 @@ # author: Christoph Hartmann -require 'aws_conn' - class Ec2 < Inspec.resource(1) name 'aws_ec2' desc 'Verifies settings for an EC2 instance' From faa2840c1f92c919be8a56565e7feb7ac174c276 Mon Sep 17 00:00:00 2001 From: Christoph Hartmann Date: Mon, 22 May 2017 18:49:27 -0500 Subject: [PATCH 091/206] update readme Signed-off-by: Christoph Hartmann --- README.md | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 27cd660d7..4bdeb7035 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,16 @@ -# InSpec AWS Resource Pack +# InSpec for AWS -NOTE: This is early access and not suitable for production. We use this repository to prototype and verify our design goals. +## Roadmap -This resource pack provides resources for AWS. It will ship with the required resources to write your own AWS tests. +This repository is the development repository for InSpec for AWS. Once [RFC Platforms](https://github.com/chef/inspec/issues/1661) is fully implemented in InSpec, this repository is going to be merged into core InSpec. + +As of now, AWS resources are implemented as an InSpec resource pack. It will ship with the required resources to write your own AWS tests. ``` ├── README.md - this readme └── libraries - contains AWS resources ``` -## Design Goals - -Goals for this project are: - -- Find the right abstraction layer for AWS -- InSpec AWS resources should be aware that they target AWS -- No mixture of InSpec OS and InSpec AWS resource in one profile possible -- AWS should become a native target for InSpec `inspec exec inspec-aws -t aws://accesskey:secret@region` - -This project will be merged into [InSpec](https://github.com/chef/inspec), once we reached all the goals. - ## Get started To run the profile, use InSpec with an environment variable for AWS credentials: @@ -119,8 +110,8 @@ This project was inspired by [inspec-aws](https://github.com/arothian/inspec-aws | | | | ------ | --- | -| **Author:** | Christoph Hartmann () | -| **Copyright:** | Copyright (c) 2016 Chef Software Inc. | +| **Author:** | Christoph Hartmann () | +| **Copyright:** | Copyright (c) 2017 Chef Software Inc. | | **License:** | Apache License, Version 2.0 | Licensed under the Apache License, Version 2.0 (the "License"); From 4fde631ed5a12521783b5aa9d6001169f7b8e45a Mon Sep 17 00:00:00 2001 From: Christoph Hartmann Date: Mon, 22 May 2017 18:50:49 -0500 Subject: [PATCH 092/206] update readme Signed-off-by: Christoph Hartmann --- README.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 70447833f..c5604abd7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ -# InSpec Azure Resource Pack +# InSpec for Azure -This resource pack provides resources for Azure Resources. It will ship with the required resources to write your own compliance rules: +## Roadmap + +This repository is the development repository for InSpec for VmWare. Once [RFC Platforms](https://github.com/chef/inspec/issues/1661) is fully implemented in InSpec, this repository is going to be merged into core InSpec. + +As of now, Azure resources are implemented as an InSpec resource pack. It will ship with the required resources to write your own Azure tests: ``` ├── README.md - this readme @@ -81,7 +85,7 @@ Since this is an InSpec resource pack, it only defines InSpec resources. It incl ```bash inspec init profile my-profile -``` +``` ### Adapt the `inspec.yml` @@ -120,7 +124,7 @@ end | azure_rg | location | Where the item has been deployed | | | total | The total number of resources in the resource group | | | nic_count | Helper method to return the number of Network Interface Cards (NIC) that exist | -| | vm_count | Helper method to return the number of Virtual Machines(VM) that exist | +| | vm_count | Helper method to return the number of Virtual Machines(VM) that exist | | | vnet_count | Helper method to return the number of Virtual Networks (VNET) that exist | | | nsg_count | Helper method to return the number of Network Security Groups (NSG) that exist | | | sa_count | Helper method to return the number of Storage Accounts (SA) that exist | @@ -273,4 +277,4 @@ Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and -limitations under the License. \ No newline at end of file +limitations under the License. From efa871ab5ea9d84b7690620defd40660722e1537 Mon Sep 17 00:00:00 2001 From: Dominik Richter Date: Wed, 24 May 2017 10:08:44 -0500 Subject: [PATCH 093/206] fix `ieq` mistakes --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c5604abd7..fcb40dc7a 100644 --- a/README.md +++ b/README.md @@ -109,8 +109,8 @@ control 'azure-1' do describe azure_virtual_machine(name: 'example-01', resource_group: 'MyResourceGroup') do its('sku') { should eq '16.04.0-LTS' } - its('publisher') { should ieq 'Canonical' } - its('offer') { should ieq 'UbuntuServer' } + its('publisher') { should eq 'Canonical' } + its('offer') { should eq 'UbuntuServer' } end end ``` From 15459ca295be405c940c6e2a78b71a9f1ace109f Mon Sep 17 00:00:00 2001 From: Chris Redekop Date: Tue, 13 Jun 2017 01:36:43 -0400 Subject: [PATCH 094/206] Add access_keys method to aws_iam_user (#44) * Add access_keys method to aws_iam_user Signed-off-by: Chris Redekop * Fix unit test that accessed AWS Signed-off-by: Chris Redekop * Incorporate PR feedback Signed-off-by: Chris Redekop * Fix unit tests Signed-off-by: Chris Redekop * Update tests based on PR feedback Signed-off-by: Chris Redekop * Rebase to master Signed-off-by: Chris Redekop --- libraries/aws_iam_access_key.rb | 11 +++++--- libraries/aws_iam_user.rb | 19 ++++++++++++- libraries/aws_iam_user_provider.rb | 5 ++++ test/integration/build/aws.tf | 19 +++++++++++-- .../verify/controls/aws_iam_user.rb | 19 ++++++++++--- .../unit/resources/aws_iam_access_key_test.rb | 26 +++++++++++++++++ .../resources/aws_iam_user_provider_test.rb | 28 ++++++++++++------- test/unit/resources/aws_iam_user_test.rb | 22 ++++++++++++--- 8 files changed, 123 insertions(+), 26 deletions(-) diff --git a/libraries/aws_iam_access_key.rb b/libraries/aws_iam_access_key.rb index 199876399..5d7276cf8 100644 --- a/libraries/aws_iam_access_key.rb +++ b/libraries/aws_iam_access_key.rb @@ -12,7 +12,10 @@ class AwsIamAccessKey < Inspec.resource(1) " def initialize(opts, decorator = IamClientDecorator.new) - @opts = opts + @access_key = opts[:access_key] + @username = opts[:username] + @id = @access_key ? @access_key.access_key_id : opts[:id] + @decorator = decorator end @@ -39,7 +42,7 @@ class AwsIamAccessKey < Inspec.resource(1) end def to_s - "IAM Access-Key #{@opts[:id]}" + "IAM Access-Key #{@id}" end class AccessKeyNotFoundError < StandardError @@ -92,10 +95,10 @@ class AwsIamAccessKey < Inspec.resource(1) private def access_key - @access_key ||= @decorator.get_access_key(@opts[:username], @opts[:id]) + @access_key ||= @decorator.get_access_key(@username, @id) end def access_key_last_used - @access_key_last_used ||= @decorator.get_access_key_last_used(@opts[:id]) + @access_key_last_used ||= @decorator.get_access_key_last_used(@id) end end diff --git a/libraries/aws_iam_user.rb b/libraries/aws_iam_user.rb index 89b868ad0..bf34db497 100644 --- a/libraries/aws_iam_user.rb +++ b/libraries/aws_iam_user.rb @@ -1,6 +1,8 @@ # author: Alex Bedley # author: Steffanie Freeman # author: Simon Varlow +# author: Chris Redekop + class AwsIamUser < Inspec.resource(1) name 'aws_iam_user' desc 'Verifies settings for AWS IAM user' @@ -10,9 +12,12 @@ class AwsIamUser < Inspec.resource(1) its('has_console_password?') { should be true } end " - def initialize(name, aws_user_provider = AwsIam::UserProvider.new) + def initialize(name, aws_user_provider = AwsIam::UserProvider.new, + access_key_factory = AwsIamAccessKeyFactory.new) + @name = name @user = aws_user_provider.user(name) + @access_key_factory = access_key_factory end def has_mfa_enabled? @@ -22,4 +27,16 @@ class AwsIamUser < Inspec.resource(1) def has_console_password? @user[:has_console_password?] end + + def access_keys + @user[:access_keys].map { |access_key| + @access_key_factory.create_access_key(access_key) + } + end + + class AwsIamAccessKeyFactory + def create_access_key(access_key) + AwsIamAccessKey.new({ access_key: access_key }) + end + end end diff --git a/libraries/aws_iam_user_provider.rb b/libraries/aws_iam_user_provider.rb index 4245876fd..6139851be 100644 --- a/libraries/aws_iam_user_provider.rb +++ b/libraries/aws_iam_user_provider.rb @@ -30,10 +30,15 @@ module AwsIam return false end + def access_keys(aws_user) + aws_user.access_keys + end + def convert(aws_user) { has_mfa_enabled?: has_mfa_enabled?(aws_user), has_console_password?: has_console_password?(aws_user), + access_keys: access_keys(aws_user), } end end diff --git a/test/integration/build/aws.tf b/test/integration/build/aws.tf index 212d42d19..211e82e87 100644 --- a/test/integration/build/aws.tf +++ b/test/integration/build/aws.tf @@ -19,9 +19,18 @@ resource "aws_iam_user" "console_password_enabled_user" { force_destroy = true } -resource "aws_iam_user_login_profile" "u" { - user = "${aws_iam_user.console_password_enabled_user.name}" - pgp_key = "${var.login_profile_pgp_key}" +resource "aws_iam_user_login_profile" "user_login_profile" { + user = "${aws_iam_user.console_password_enabled_user.name}" + pgp_key = "${var.login_profile_pgp_key}" +} + +resource "aws_iam_user" "access_key_user" { + name = "${terraform.env}.access_key_user" +} + +resource "aws_iam_access_key" "access_key" { + user = "${aws_iam_user.access_key_user.name}" + pgp_key = "${var.login_profile_pgp_key}" } output "mfa_not_enabled_user" { @@ -32,6 +41,10 @@ output "console_password_enabled_user" { value = "${aws_iam_user.console_password_enabled_user.name}" } +output "access_key_user" { + value = "${aws_iam_user.access_key_user.name}" +} + output "example_ec2_name" { value = "${aws_instance.example.tags.Name}" } diff --git a/test/integration/verify/controls/aws_iam_user.rb b/test/integration/verify/controls/aws_iam_user.rb index 76c97fb1b..bcc10a8f6 100644 --- a/test/integration/verify/controls/aws_iam_user.rb +++ b/test/integration/verify/controls/aws_iam_user.rb @@ -8,11 +8,22 @@ console_password_enabled_user = attribute( default: 'default.console_password_enabled_user', description: 'Name of IAM user console_password_enabled_user') +access_key_user = attribute( + 'access_key_user', + default: 'default.access_key_user', + description: 'Name of IAM user access_key_user') + describe aws_iam_user(mfa_not_enabled_user) do - its('has_mfa_enabled?') { should be false } - its('has_console_password?') { should be false } + it { should_not have_mfa_enabled } + it { should_not have_console_password } end describe aws_iam_user(console_password_enabled_user) do - its('has_console_password?') { should be true } -end \ No newline at end of file + it { should have_console_password } +end + +aws_iam_user(access_key_user).access_keys.each { |access_key| + describe access_key do + it { should be_active } + end +} diff --git a/test/unit/resources/aws_iam_access_key_test.rb b/test/unit/resources/aws_iam_access_key_test.rb index e4be92c66..e3d55338a 100644 --- a/test/unit/resources/aws_iam_access_key_test.rb +++ b/test/unit/resources/aws_iam_access_key_test.rb @@ -32,6 +32,32 @@ class AwsIamAccessKeyTest < Minitest::Test include AccessKeyFactory + def test_initialize_accepts_fields + assert_equal( + Id, + AwsIamAccessKey.new({id: Id, username: Username}, nil) + .instance_variable_get('@id') + ); + end + + def test_initialize_accepts_access_key + assert_equal( + Id, + AwsIamAccessKey.new({access_key: OpenStruct.new(access_key_id: Id)}, nil) + .instance_variable_get('@id') + ); + end + + def test_initialize_prefers_access_key + assert_equal( + Id, + AwsIamAccessKey.new({ + id: 'foo', + access_key: OpenStruct.new(access_key_id: Id) + }, nil).instance_variable_get('@id') + ); + end + def test_exists_returns_true_when_access_key_exists assert aws_iam_access_key.exists? end diff --git a/test/unit/resources/aws_iam_user_provider_test.rb b/test/unit/resources/aws_iam_user_provider_test.rb index 1c4288b9b..cc9d07d01 100644 --- a/test/unit/resources/aws_iam_user_provider_test.rb +++ b/test/unit/resources/aws_iam_user_provider_test.rb @@ -24,7 +24,7 @@ class AwsIamUserProviderTest < Minitest::Test def test_list_users @mock_iam_resource.expect :users, [create_mock_user, create_mock_user, create_mock_user] - mock_user_output = {has_mfa_enabled?: true, has_console_password?: true} + mock_user_output = {has_mfa_enabled?: true, has_console_password?: true, access_keys: []} assert @user_provider.list_users == [mock_user_output, mock_user_output, mock_user_output] end @@ -67,27 +67,35 @@ class AwsIamUserProviderTest < Minitest::Test end end + def test_access_keys_returns_access_keys + access_key = Object.new + + @mock_iam_resource.expect :user, create_mock_user(access_keys: [access_key]), [Username] + + assert_equal [access_key], @user_provider.user(Username)[:access_keys] + end + private - def create_mock_user(has_console_password: true, has_mfa_enabled: true) - mock_user = Minitest::Mock.new + def create_mock_user(has_console_password: true, has_mfa_enabled: true, access_keys: []) mock_login_profile = Minitest::Mock.new - - mock_user.expect :mfa_devices, has_mfa_enabled ? ['device'] : [] - mock_login_profile.expect :create_date, has_console_password ? 'date' : nil + + mock_user = Minitest::Mock.new + mock_user.expect :mfa_devices, has_mfa_enabled ? ['device'] : [] mock_user.expect :login_profile, mock_login_profile + mock_user.expect :access_keys, access_keys end def create_mock_user_throw(exception) - mock_user = Minitest::Mock.new mock_login_profile = Minitest::Mock.new - - mock_user.expect :mfa_devices, [] - mock_login_profile.expect :create_date, nil do |args| raise exception end + + mock_user = Minitest::Mock.new + mock_user.expect :mfa_devices, [] mock_user.expect :login_profile, mock_login_profile + mock_user.expect :access_keys, [] end end diff --git a/test/unit/resources/aws_iam_user_test.rb b/test/unit/resources/aws_iam_user_test.rb index defc1333f..e9d378dc8 100644 --- a/test/unit/resources/aws_iam_user_test.rb +++ b/test/unit/resources/aws_iam_user_test.rb @@ -5,7 +5,7 @@ require 'helper' require 'aws_iam_user' class AwsIamUserTest < Minitest::Test -Username = "test" + Username = "test" def setup @mock_user_provider = Minitest::Mock.new @@ -18,7 +18,7 @@ Username = "test" def test_that_MFA_enable_returns_false_if_MFA_is_not_Enabled @mock_user_provider.expect :user, {has_mfa_enabled?: false}, [Username] - assert !AwsIamUser.new(Username, @mock_user_provider).has_mfa_enabled? + refute AwsIamUser.new(Username, @mock_user_provider).has_mfa_enabled? end def test_that_console_Password_returns_true_if_console_Password_has_been_set @@ -28,6 +28,20 @@ Username = "test" def test_that_console_Password_returns_false_if_console_Password_has_not_been_set @mock_user_provider.expect :user, {has_console_password?: false}, [Username] - assert !AwsIamUser.new(Username, @mock_user_provider).has_console_password? + refute AwsIamUser.new(Username, @mock_user_provider).has_console_password? end -end \ No newline at end of file + + def test_that_access_keys_returns_aws_iam_access_key_resources + stub_aws_access_key = Object.new + stub_access_key_resource = Object.new + mock_access_key_factory = Minitest::Mock.new + + @mock_user_provider.expect :user, {access_keys: [stub_aws_access_key]}, [Username] + mock_access_key_factory.expect :create_access_key, stub_access_key_resource, [stub_aws_access_key] + + assert_equal(stub_access_key_resource, + AwsIamUser.new(Username, @mock_user_provider, mock_access_key_factory).access_keys[0]) + + mock_access_key_factory.verify + end +end From 5c319ff0564523287c80206661a05a071ea48eaa Mon Sep 17 00:00:00 2001 From: Miles Tjandrawidjaja Date: Tue, 13 Jun 2017 01:41:43 -0400 Subject: [PATCH 095/206] [ISSUE-36] - Cover Recommendation 1.10 (#47) * Add query for password_reuse_prevention to iam_password_policy Signed-off-by: Miles Tjandrawidjaja * Use mock over stub, and more concise language for tests in aws_iam_password_policy Signed-off-by: Miles Tjandrawidjaja * Rename method prevent_password_reuse to prevents_password_reuse Signed-off-by: Miles Tjandrawidjaja --- libraries/aws_iam_password_policy.rb | 10 +++++ .../resources/aws_iam_password_policy_test.rb | 43 +++++++++++++++++-- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/libraries/aws_iam_password_policy.rb b/libraries/aws_iam_password_policy.rb index 0c74fe151..83a40b780 100644 --- a/libraries/aws_iam_password_policy.rb +++ b/libraries/aws_iam_password_policy.rb @@ -58,4 +58,14 @@ class AwsIamPasswordPolicy < Inspec.resource(1) raise 'this policy does not expire passwords' unless expires_passwords? @policy.max_password_age end + + def prevents_password_reuse? + !@policy.password_reuse_prevention.nil? + end + + def number_of_passwords_to_remember + raise 'this policy does not prevent password reuse' \ + unless prevents_password_reuse? + @policy.password_reuse_prevention + end end diff --git a/test/unit/resources/aws_iam_password_policy_test.rb b/test/unit/resources/aws_iam_password_policy_test.rb index 3f4a55306..ddc3d1de3 100644 --- a/test/unit/resources/aws_iam_password_policy_test.rb +++ b/test/unit/resources/aws_iam_password_policy_test.rb @@ -7,6 +7,7 @@ class AwsIamPasswordPolicyTest < Minitest::Test def setup @mockConn = Minitest::Mock.new @mockResource = Minitest::Mock.new + @mockPolicy = Minitest::Mock.new @mockConn.expect :iam_resource, @mockResource end @@ -26,10 +27,8 @@ class AwsIamPasswordPolicyTest < Minitest::Test end def test_throws_when_password_age_0 - policyObject = Minitest::Mock.new - policyObject.expect :expire_passwords, false - - @mockResource.expect :account_password_policy, policyObject + @mockPolicy.expect :expire_passwords, false + @mockResource.expect :account_password_policy, @mockPolicy e = assert_raises Exception do AwsIamPasswordPolicy.new(@mockConn).max_password_age @@ -37,4 +36,40 @@ class AwsIamPasswordPolicyTest < Minitest::Test assert_equal e.message, 'this policy does not expire passwords' end + + def test_prevents_password_reuse_returns_true_when_not_nil + configure_policy_password_reuse_prevention(value: Object.new) + + assert AwsIamPasswordPolicy.new(@mockConn).prevents_password_reuse? + end + + def test_prevents_password_reuse_returns_false_when_nil + configure_policy_password_reuse_prevention(value: nil) + + refute AwsIamPasswordPolicy.new(@mockConn).prevents_password_reuse? + end + + def test_number_of_passwords_to_remember_throws_when_nil + configure_policy_password_reuse_prevention(value: nil) + + e = assert_raises Exception do + AwsIamPasswordPolicy.new(@mockConn).number_of_passwords_to_remember + end + + assert_equal e.message, 'this policy does not prevent password reuse' + end + + def test_number_of_passwords_to_remember_returns_configured_value + expectedValue = 5 + configure_policy_password_reuse_prevention(value: expectedValue, n: 2) + + assert_equal expectedValue, AwsIamPasswordPolicy.new(@mockConn).number_of_passwords_to_remember + end + + private + + def configure_policy_password_reuse_prevention(value: value, n: 1) + n.times { @mockPolicy.expect :password_reuse_prevention, value } + @mockResource.expect :account_password_policy, @mockPolicy + end end From a1dc5e981e9787fcec0d7db66d0eb99bc76d833f Mon Sep 17 00:00:00 2001 From: Miles Tjandrawidjaja Date: Tue, 13 Jun 2017 01:44:11 -0400 Subject: [PATCH 096/206] [ISSUE-39] - Cover Recommendation 1.12 (#49) * Adding resource aws_iam_root_user Signed-off-by: Miles Tjandrawidjaja * Adding to_s method to class aws_iam_root_user Signed-off-by: Miles Tjandrawidjaja * Cleaner to_s result for aws_iam_root_user Signed-off-by: Miles Tjandrawidjaja --- libraries/aws_iam_root_user.rb | 28 +++++++++++++++++++ test/unit/resources/aws_iam_root_user_test.rb | 20 +++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 libraries/aws_iam_root_user.rb create mode 100644 test/unit/resources/aws_iam_root_user_test.rb diff --git a/libraries/aws_iam_root_user.rb b/libraries/aws_iam_root_user.rb new file mode 100644 index 000000000..2ba8a01e9 --- /dev/null +++ b/libraries/aws_iam_root_user.rb @@ -0,0 +1,28 @@ +# author: Miles Tjandrawidjaja +class AwsIamRootUser < Inspec.resource(1) + name 'aws_iam_root_user' + desc 'Verifies settings for AWS root account' + example " + describe aws_iam_root_user do + its('access_key_count') { should eq 0 } + end + " + + def initialize(conn = AWSConnection.new) + @client = conn.iam_client + end + + def access_key_count + summary_account['AccountAccessKeysPresent'] + end + + def to_s + 'AWS Root-User' + end + + private + + def summary_account + @summary_account ||= @client.get_account_summary.summary_map + end +end diff --git a/test/unit/resources/aws_iam_root_user_test.rb b/test/unit/resources/aws_iam_root_user_test.rb new file mode 100644 index 000000000..507fc0f88 --- /dev/null +++ b/test/unit/resources/aws_iam_root_user_test.rb @@ -0,0 +1,20 @@ +# author: Miles Tjandrawidjaja +require 'helper' +require 'aws_iam_root_user' + +class AwsIamRootUserTest < Minitest::Test + def setup + @mockConn = Minitest::Mock.new + @mockClient = Minitest::Mock.new + + @mockConn.expect :iam_client, @mockClient + end + + def test_access_key_count_returns_from_summary_account + expectedKeys = 2 + summaryMap = OpenStruct.new(summary_map: {'AccountAccessKeysPresent' => expectedKeys}) + @mockClient.expect :get_account_summary, summaryMap + + assert_equal expectedKeys, AwsIamRootUser.new(@mockConn).access_key_count + end +end \ No newline at end of file From 77d5634d7a2b151a626246d46581d6abd02723b7 Mon Sep 17 00:00:00 2001 From: Bakh Inamov Date: Wed, 14 Jun 2017 03:26:51 -0700 Subject: [PATCH 097/206] tpyo (#41) Signed-off-by: Bakh Inamov --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index fcb40dc7a..2db4ac10c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ## Roadmap -This repository is the development repository for InSpec for VmWare. Once [RFC Platforms](https://github.com/chef/inspec/issues/1661) is fully implemented in InSpec, this repository is going to be merged into core InSpec. +This repository is the development repository for InSpec for Azure. Once [RFC Platforms](https://github.com/chef/inspec/issues/1661) is fully implemented in InSpec, this repository is going to be merged into core InSpec. As of now, Azure resources are implemented as an InSpec resource pack. It will ship with the required resources to write your own Azure tests: From 99baa9b2abc02e4bca7b5eb05da9e36175d9750d Mon Sep 17 00:00:00 2001 From: Steffanie Freeman Date: Wed, 28 Jun 2017 08:46:59 -0400 Subject: [PATCH 098/206] Add to_s method to aws_iam_user (#60) Signed-off-by: sfreeman --- libraries/aws_iam_user.rb | 4 ++++ test/unit/resources/aws_iam_user_test.rb | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/libraries/aws_iam_user.rb b/libraries/aws_iam_user.rb index bf34db497..d2af69470 100644 --- a/libraries/aws_iam_user.rb +++ b/libraries/aws_iam_user.rb @@ -34,6 +34,10 @@ class AwsIamUser < Inspec.resource(1) } end + def to_s + "IAM User #{@name}" + end + class AwsIamAccessKeyFactory def create_access_key(access_key) AwsIamAccessKey.new({ access_key: access_key }) diff --git a/test/unit/resources/aws_iam_user_test.rb b/test/unit/resources/aws_iam_user_test.rb index e9d378dc8..d4de12317 100644 --- a/test/unit/resources/aws_iam_user_test.rb +++ b/test/unit/resources/aws_iam_user_test.rb @@ -44,4 +44,11 @@ class AwsIamUserTest < Minitest::Test mock_access_key_factory.verify end + + def test_to_s + @mock_user_provider.expect :user, {has_mfa_enabled?: true}, [Username] + expected = "IAM User test" + test = AwsIamUser.new(Username, @mock_user_provider).to_s + assert_equal expected, test + end end From 1a360cff8bda8ba97fed381e3d617cc4ea15757d Mon Sep 17 00:00:00 2001 From: Miles Tjandrawidjaja Date: Wed, 28 Jun 2017 08:55:01 -0400 Subject: [PATCH 099/206] Prompt for namespace if not set in environment variable instead of providing a random namespace (#57) Signed-off-by: Miles Tjandrawidjaja --- Rakefile | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/Rakefile b/Rakefile index 05456e472..dc10b8b9b 100644 --- a/Rakefile +++ b/Rakefile @@ -5,6 +5,11 @@ require 'rake/testtask' require 'rubocop/rake_task' require 'securerandom' +def prompt(message) + print(message) + STDIN.gets.chomp +end + # Rubocop desc 'Run Rubocop lint checks' task :rubocop do @@ -26,7 +31,6 @@ task lint: [:rubocop] task default: [:lint, :test] namespace :test do - terraform_env = ENV['INSPEC_TERRAFORM_ENV'] || SecureRandom.urlsafe_base64(5) project_dir = File.dirname(__FILE__) attribute_file = File.join(project_dir, ".attribute.yml") integration_dir = File.join(project_dir, "test/integration") @@ -36,9 +40,9 @@ namespace :test do sh("bundle exec inspec check #{project_dir}") end - task :configure_test_environment do + task :configure_test_environment, :namespace do |t, args| puts "----> Creating terraform environment" - sh("cd #{integration_dir}/build/ && terraform env new #{terraform_env}") + sh("cd #{integration_dir}/build/ && terraform env new #{args[:namespace]}") end task :setup_integration_tests do @@ -63,18 +67,19 @@ namespace :test do sh("cd #{integration_dir}/build/ && terraform destroy -force") end - task :destroy_test_environment do + task :destroy_test_environment, :namespace do |t, args| puts "----> Destroying terraform environment" sh("cd #{integration_dir}/build/ && terraform env select default") - sh("cd #{integration_dir}/build && terraform env delete #{terraform_env}") + sh("cd #{integration_dir}/build && terraform env delete #{args[:namespace]}") end task :integration do - Rake::Task["test:configure_test_environment"].execute + namespace = ENV['INSPEC_TERRAFORM_ENV'] || prompt("Please enter a namespace for your integration tests to run in: ") + Rake::Task["test:configure_test_environment"].execute({:namespace => namespace}) Rake::Task["test:cleanup_integration_tests"].execute Rake::Task["test:setup_integration_tests"].execute Rake::Task["test:run_integration_tests"].execute Rake::Task["test:cleanup_integration_tests"].execute - Rake::Task["test:destroy_test_environment"].execute + Rake::Task["test:destroy_test_environment"].execute({:namespace => namespace}) end end From 29b4fbebe46c797846c0556de18f90efcb52941b Mon Sep 17 00:00:00 2001 From: Steffanie Freeman Date: Thu, 29 Jun 2017 06:03:20 -0400 Subject: [PATCH 100/206] Add to_s method to aws_iam_password_policy (#61) * Add to_s method to aws_iam_password_policy Signed-off-by: sfreeman * Use single quoted string and remove unnecessary substring Signed-off-by: sfreeman --- libraries/aws_iam_password_policy.rb | 4 ++++ test/unit/resources/aws_iam_password_policy_test.rb | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/libraries/aws_iam_password_policy.rb b/libraries/aws_iam_password_policy.rb index 83a40b780..3a93f0c2d 100644 --- a/libraries/aws_iam_password_policy.rb +++ b/libraries/aws_iam_password_policy.rb @@ -68,4 +68,8 @@ class AwsIamPasswordPolicy < Inspec.resource(1) unless prevents_password_reuse? @policy.password_reuse_prevention end + + def to_s + 'IAM Password-Policy' + end end diff --git a/test/unit/resources/aws_iam_password_policy_test.rb b/test/unit/resources/aws_iam_password_policy_test.rb index ddc3d1de3..ac4bbf0ea 100644 --- a/test/unit/resources/aws_iam_password_policy_test.rb +++ b/test/unit/resources/aws_iam_password_policy_test.rb @@ -66,6 +66,13 @@ class AwsIamPasswordPolicyTest < Minitest::Test assert_equal expectedValue, AwsIamPasswordPolicy.new(@mockConn).number_of_passwords_to_remember end + def test_policy_to_s + configure_policy_password_reuse_prevention(value: Object.new) + expectedValue = "IAM Password-Policy" + test = AwsIamPasswordPolicy.new(@mockConn).to_s + assert_equal expectedValue, test + end + private def configure_policy_password_reuse_prevention(value: value, n: 1) From 798776cf5027eff1ccceca6ff79a913b9283a810 Mon Sep 17 00:00:00 2001 From: Miles Tjandrawidjaja Date: Wed, 5 Jul 2017 16:30:27 -0400 Subject: [PATCH 101/206] Ensure resources are cleaned when running test:integration even if integration tests fail (#56) Signed-off-by: Miles Tjandrawidjaja --- Rakefile | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/Rakefile b/Rakefile index dc10b8b9b..9e083bfe4 100644 --- a/Rakefile +++ b/Rakefile @@ -75,11 +75,16 @@ namespace :test do task :integration do namespace = ENV['INSPEC_TERRAFORM_ENV'] || prompt("Please enter a namespace for your integration tests to run in: ") - Rake::Task["test:configure_test_environment"].execute({:namespace => namespace}) - Rake::Task["test:cleanup_integration_tests"].execute - Rake::Task["test:setup_integration_tests"].execute - Rake::Task["test:run_integration_tests"].execute - Rake::Task["test:cleanup_integration_tests"].execute - Rake::Task["test:destroy_test_environment"].execute({:namespace => namespace}) + begin + Rake::Task["test:configure_test_environment"].execute({:namespace => namespace}) + Rake::Task["test:cleanup_integration_tests"].execute + Rake::Task["test:setup_integration_tests"].execute + Rake::Task["test:run_integration_tests"].execute + rescue + abort("Integration testing has failed") + ensure + Rake::Task["test:cleanup_integration_tests"].execute + Rake::Task["test:destroy_test_environment"].execute({:namespace => namespace}) + end end end From 660ab99d988be4da15ada3cb831e8f3383e0a8cb Mon Sep 17 00:00:00 2001 From: Steffanie Freeman Date: Wed, 5 Jul 2017 16:31:27 -0400 Subject: [PATCH 102/206] Run linter on unit tests (#51) * Run linter on unit tests Signed-off-by: sfreeman * Bring remaining unit tests up to spec with rubocop Signed-off-by: sfreeman --- .rubocop.yml | 2 +- .../unit/resources/aws_iam_access_key_test.rb | 127 +++++++++++------- .../resources/aws_iam_password_policy_test.rb | 51 +++---- test/unit/resources/aws_iam_root_user_test.rb | 18 +-- .../resources/aws_iam_user_provider_test.rb | 71 ++++++---- test/unit/resources/aws_iam_user_test.rb | 63 ++++++--- test/unit/resources/ec2_test.rb | 57 ++++---- 7 files changed, 235 insertions(+), 154 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 77ddf7993..a7fe5c9f2 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -3,7 +3,7 @@ AllCops: Exclude: - Gemfile - Rakefile - - 'test/**/*' + - 'test/integration/**/*' - 'examples/**/*' - 'vendor/**/*' - 'lib/bundles/inspec-init/templates/**/*' diff --git a/test/unit/resources/aws_iam_access_key_test.rb b/test/unit/resources/aws_iam_access_key_test.rb index e3d55338a..e74955c32 100644 --- a/test/unit/resources/aws_iam_access_key_test.rb +++ b/test/unit/resources/aws_iam_access_key_test.rb @@ -6,9 +6,9 @@ require 'helper' require 'aws_iam_access_key' class AwsIamAccessKeyTest < Minitest::Test - Username = 'test' - Id = 'id' - Date = 'date' + Username = 'test'.freeze + Id = 'id'.freeze + Date = 'date'.freeze module AccessKeyFactory def aws_iam_access_key(decorator = mock_decorator(stub_access_key)) @@ -16,17 +16,18 @@ class AwsIamAccessKeyTest < Minitest::Test end def stub_access_key( - _nil: false, id: Id, status: 'Active', create_date: Date ) - OpenStruct.new({ - :nil? => _nil, - :access_key_id => id, - :status => status, - :create_date => create_date - }) + OpenStruct.new( + { + nil?: nil, + access_key_id: id, + status: status, + create_date: create_date, + }, + ) end end @@ -35,27 +36,32 @@ class AwsIamAccessKeyTest < Minitest::Test def test_initialize_accepts_fields assert_equal( Id, - AwsIamAccessKey.new({id: Id, username: Username}, nil) - .instance_variable_get('@id') - ); + AwsIamAccessKey.new({ id: Id, username: Username }, nil) + .instance_variable_get('@id'), + ) end def test_initialize_accepts_access_key assert_equal( Id, - AwsIamAccessKey.new({access_key: OpenStruct.new(access_key_id: Id)}, nil) - .instance_variable_get('@id') - ); + AwsIamAccessKey.new( + { + access_key: OpenStruct.new(access_key_id: Id), + }, nil + ).instance_variable_get('@id'), + ) end def test_initialize_prefers_access_key assert_equal( Id, - AwsIamAccessKey.new({ - id: 'foo', - access_key: OpenStruct.new(access_key_id: Id) - }, nil).instance_variable_get('@id') - ); + AwsIamAccessKey.new( + { + id: 'foo', + access_key: OpenStruct.new(access_key_id: Id), + }, nil + ).instance_variable_get('@id'), + ) end def test_exists_returns_true_when_access_key_exists @@ -64,7 +70,8 @@ class AwsIamAccessKeyTest < Minitest::Test def test_exists_returns_false_when_sdk_raises mock_decorator = mock_decorator_raise( - Aws::IAM::Errors::NoSuchEntity.new(nil, nil)) + Aws::IAM::Errors::NoSuchEntity.new(nil, nil), + ) refute aws_iam_access_key(mock_decorator).exists? @@ -73,7 +80,8 @@ class AwsIamAccessKeyTest < Minitest::Test def test_exists_returns_false_when_access_key_does_not_exist mock_decorator = mock_decorator_raise( - AwsIamAccessKey::AccessKeyNotFoundError.new) + AwsIamAccessKey::AccessKeyNotFoundError.new, + ) refute aws_iam_access_key(mock_decorator).exists? @@ -100,8 +108,12 @@ class AwsIamAccessKeyTest < Minitest::Test def test_last_used_date_returns_last_used_date_always assert_equal( Date, - aws_iam_access_key(mock_decorator(nil, - OpenStruct.new({ :last_used_date => Date }))).last_used_date + aws_iam_access_key( + mock_decorator( + nil, + OpenStruct.new({ last_used_date: Date }), + ), + ).last_used_date, ) end @@ -110,7 +122,7 @@ class AwsIamAccessKeyTest < Minitest::Test def test_get_access_key_raises_when_no_access_keys_found validator = mock_validator - + e = assert_raises AwsIamAccessKey::AccessKeyNotFoundError do iam_client_decorator(validator).get_access_key(Username, Id) end @@ -124,10 +136,12 @@ class AwsIamAccessKeyTest < Minitest::Test def test_get_access_key_raises_when_matching_access_key_not_found validator = mock_validator - - e = assert_raises AwsIamAccessKey::AccessKeyNotFoundError do - iam_client_decorator(validator, [stub_access_key(id: 'Foo')]) - .get_access_key(Username, Id) + + e = assert_raises AwsIamAccessKey::AccessKeyNotFoundError do + iam_client_decorator( + validator, + [stub_access_key(id: 'Foo')], + ).get_access_key(Username, Id) end assert_match(/.*access key not found.*/, e.message) @@ -143,7 +157,10 @@ class AwsIamAccessKeyTest < Minitest::Test assert_equal( access_key, - iam_client_decorator(validator, [access_key]).get_access_key(Username, Id) + iam_client_decorator( + validator, + [access_key], + ).get_access_key(Username, Id), ) validator.verify @@ -155,8 +172,11 @@ class AwsIamAccessKeyTest < Minitest::Test assert_equal( access_key_last_used, - iam_client_decorator(validator, nil, access_key_last_used) - .get_access_key_last_used(Id) + iam_client_decorator( + validator, + nil, + access_key_last_used, + ).get_access_key_last_used(Id), ) validator.verify @@ -165,9 +185,9 @@ class AwsIamAccessKeyTest < Minitest::Test class ArgumentValidatorTest < Minitest::Test def test_validate_id_raises_when_id_is_nil argument_validator.validate_id(nil) - flunk + flunk rescue ArgumentError => e - assert_match(/.*missing.*"id".*/, e.message) + assert_match(/.*missing.*"id".*/, e.message) end def test_validate_id_does_nothing_when_id_is_not_nil @@ -175,18 +195,18 @@ class AwsIamAccessKeyTest < Minitest::Test end def test_validate_username_raises_when_username_is_nil - argument_validator.validate_username(nil) - flunk + argument_validator.validate_username(nil) + flunk rescue ArgumentError => e - assert_match(/.*missing.*"username".*/, e.message) + assert_match(/.*missing.*"username".*/, e.message) end def test_validate_username_does_nothing_when_username_is_not_nil - argument_validator.validate_username(Username) + argument_validator.validate_username(Username) end def argument_validator - AwsIamAccessKey::IamClientDecorator::ArgumentValidator.new + AwsIamAccessKey::IamClientDecorator::ArgumentValidator.new end end @@ -203,7 +223,7 @@ class AwsIamAccessKeyTest < Minitest::Test def mock_conn(access_keys, access_key_last_used = nil) Minitest::Mock.new.expect( :iam_client, - mock_client(access_keys, access_key_last_used) + mock_client(access_keys, access_key_last_used), ) end @@ -213,25 +233,30 @@ class AwsIamAccessKeyTest < Minitest::Test if access_keys mock_iam_client.expect( :list_access_keys, - OpenStruct.new({'access_key_metadata' => access_keys}), - [{user_name: Username}] + OpenStruct.new({ 'access_key_metadata' => access_keys }), + [{ user_name: Username }], ) end if access_key_last_used mock_iam_client.expect( :get_access_key_last_used, - OpenStruct.new({'access_key_last_used' => access_key_last_used}), - [{access_key_id: Id}] + OpenStruct.new({ 'access_key_last_used' => access_key_last_used }), + [{ access_key_id: Id }], ) end mock_iam_client end - def iam_client_decorator(validator, access_keys = [], access_key_last_used = nil) + def iam_client_decorator( + validator, + access_keys = [], + access_key_last_used = nil + ) AwsIamAccessKey::IamClientDecorator.new( - validator, mock_conn(access_keys, access_key_last_used)) + validator, mock_conn(access_keys, access_key_last_used) + ) end end @@ -243,7 +268,11 @@ class AwsIamAccessKeyTest < Minitest::Test end if access_key_last_used - mock_decorator.expect :get_access_key_last_used, access_key_last_used, [Id] + mock_decorator.expect( + :get_access_key_last_used, + access_key_last_used, + [Id], + ) end mock_decorator @@ -252,7 +281,7 @@ class AwsIamAccessKeyTest < Minitest::Test def mock_decorator_raise(error) Minitest::Mock.new.expect(:get_access_key, nil) do |username, id| assert_equal Username, username - assert_equal Id, id + assert_equal Id, id raise error end diff --git a/test/unit/resources/aws_iam_password_policy_test.rb b/test/unit/resources/aws_iam_password_policy_test.rb index ac4bbf0ea..0c46f6d79 100644 --- a/test/unit/resources/aws_iam_password_policy_test.rb +++ b/test/unit/resources/aws_iam_password_policy_test.rb @@ -5,33 +5,33 @@ require 'json' class AwsIamPasswordPolicyTest < Minitest::Test def setup - @mockConn = Minitest::Mock.new - @mockResource = Minitest::Mock.new - @mockPolicy = Minitest::Mock.new + @mock_conn = Minitest::Mock.new + @mock_resource = Minitest::Mock.new + @mock_policy = Minitest::Mock.new - @mockConn.expect :iam_resource, @mockResource + @mock_conn.expect :iam_resource, @mock_resource end def test_policy_exists_when_policy_exists - @mockResource.expect :account_password_policy, true + @mock_resource.expect :account_password_policy, true - assert_equal true, AwsIamPasswordPolicy.new(@mockConn).exists? + assert_equal true, AwsIamPasswordPolicy.new(@mock_conn).exists? end def test_policy_does_not_exists_when_no_policy - @mockResource.expect :account_password_policy, nil do |args| + @mock_resource.expect :account_password_policy, nil do raise Aws::IAM::Errors::NoSuchEntity.new nil, nil end - assert_equal false, AwsIamPasswordPolicy.new(@mockConn).exists? + assert_equal false, AwsIamPasswordPolicy.new(@mock_conn).exists? end def test_throws_when_password_age_0 - @mockPolicy.expect :expire_passwords, false - @mockResource.expect :account_password_policy, @mockPolicy + @mock_policy.expect :expire_passwords, false + @mock_resource.expect :account_password_policy, @mock_policy e = assert_raises Exception do - AwsIamPasswordPolicy.new(@mockConn).max_password_age + AwsIamPasswordPolicy.new(@mock_conn).max_password_age end assert_equal e.message, 'this policy does not expire passwords' @@ -40,43 +40,46 @@ class AwsIamPasswordPolicyTest < Minitest::Test def test_prevents_password_reuse_returns_true_when_not_nil configure_policy_password_reuse_prevention(value: Object.new) - assert AwsIamPasswordPolicy.new(@mockConn).prevents_password_reuse? + assert AwsIamPasswordPolicy.new(@mock_conn).prevents_password_reuse? end def test_prevents_password_reuse_returns_false_when_nil configure_policy_password_reuse_prevention(value: nil) - refute AwsIamPasswordPolicy.new(@mockConn).prevents_password_reuse? + refute AwsIamPasswordPolicy.new(@mock_conn).prevents_password_reuse? end def test_number_of_passwords_to_remember_throws_when_nil configure_policy_password_reuse_prevention(value: nil) e = assert_raises Exception do - AwsIamPasswordPolicy.new(@mockConn).number_of_passwords_to_remember + AwsIamPasswordPolicy.new(@mock_conn).number_of_passwords_to_remember end assert_equal e.message, 'this policy does not prevent password reuse' end def test_number_of_passwords_to_remember_returns_configured_value - expectedValue = 5 - configure_policy_password_reuse_prevention(value: expectedValue, n: 2) + expected_value = 5 + configure_policy_password_reuse_prevention(value: expected_value, n: 2) - assert_equal expectedValue, AwsIamPasswordPolicy.new(@mockConn).number_of_passwords_to_remember + assert_equal( + expected_value, + AwsIamPasswordPolicy.new(@mock_conn).number_of_passwords_to_remember, + ) end def test_policy_to_s configure_policy_password_reuse_prevention(value: Object.new) - expectedValue = "IAM Password-Policy" - test = AwsIamPasswordPolicy.new(@mockConn).to_s - assert_equal expectedValue, test + expected_value = 'IAM Password-Policy' + test = AwsIamPasswordPolicy.new(@mock_conn).to_s + assert_equal expected_value, test end - private + private - def configure_policy_password_reuse_prevention(value: value, n: 1) - n.times { @mockPolicy.expect :password_reuse_prevention, value } - @mockResource.expect :account_password_policy, @mockPolicy + def configure_policy_password_reuse_prevention(value: value=nil, n: 1) + n.times { @mock_policy.expect :password_reuse_prevention, value } + @mock_resource.expect :account_password_policy, @mock_policy end end diff --git a/test/unit/resources/aws_iam_root_user_test.rb b/test/unit/resources/aws_iam_root_user_test.rb index 507fc0f88..7a0105d4e 100644 --- a/test/unit/resources/aws_iam_root_user_test.rb +++ b/test/unit/resources/aws_iam_root_user_test.rb @@ -4,17 +4,19 @@ require 'aws_iam_root_user' class AwsIamRootUserTest < Minitest::Test def setup - @mockConn = Minitest::Mock.new - @mockClient = Minitest::Mock.new + @mock_conn = Minitest::Mock.new + @mock_client = Minitest::Mock.new - @mockConn.expect :iam_client, @mockClient + @mock_conn.expect :iam_client, @mock_client end def test_access_key_count_returns_from_summary_account - expectedKeys = 2 - summaryMap = OpenStruct.new(summary_map: {'AccountAccessKeysPresent' => expectedKeys}) - @mockClient.expect :get_account_summary, summaryMap + expected_keys = 2 + test_summary_map = OpenStruct.new( + summary_map: { 'AccountAccessKeysPresent' => expected_keys }, + ) + @mock_client.expect :get_account_summary, test_summary_map - assert_equal expectedKeys, AwsIamRootUser.new(@mockConn).access_key_count + assert_equal expected_keys, AwsIamRootUser.new(@mock_conn).access_key_count end -end \ No newline at end of file +end diff --git a/test/unit/resources/aws_iam_user_provider_test.rb b/test/unit/resources/aws_iam_user_provider_test.rb index cc9d07d01..fff9d7de1 100644 --- a/test/unit/resources/aws_iam_user_provider_test.rb +++ b/test/unit/resources/aws_iam_user_provider_test.rb @@ -4,11 +4,10 @@ # author: Alex Bedley require 'aws-sdk' require 'helper' - require 'aws_iam_user_provider' class AwsIamUserProviderTest < Minitest::Test - Username = "test" + Username = 'test'.freeze def setup @mock_iam_resource = Minitest::Mock.new @@ -23,9 +22,17 @@ class AwsIamUserProviderTest < Minitest::Test end def test_list_users - @mock_iam_resource.expect :users, [create_mock_user, create_mock_user, create_mock_user] - mock_user_output = {has_mfa_enabled?: true, has_console_password?: true, access_keys: []} - assert @user_provider.list_users == [mock_user_output, mock_user_output, mock_user_output] + @mock_iam_resource.expect( + :users, + [create_mock_user, create_mock_user, create_mock_user], + ) + mock_user_output = { + has_mfa_enabled?: true, + has_console_password?: true, + access_keys: [], + } + assert @user_provider.list_users == [mock_user_output, mock_user_output, + mock_user_output] end def test_list_users_no_users @@ -34,34 +41,48 @@ class AwsIamUserProviderTest < Minitest::Test end def test_has_mfa_enabled_returns_true - @mock_iam_resource.expect :user, create_mock_user(has_mfa_enabled: true), [Username] + @mock_iam_resource.expect(:user, create_mock_user(has_mfa_enabled: true), + [Username]) assert @user_provider.user(Username)[:has_mfa_enabled?] end def test_has_mfa_enabled_returns_false - @mock_iam_resource.expect :user, create_mock_user(has_mfa_enabled: false), [Username] + @mock_iam_resource.expect(:user, create_mock_user(has_mfa_enabled: false), + [Username]) assert !@user_provider.user(Username)[:has_mfa_enabled?] end - + def test_has_console_password_returns_true - @mock_iam_resource.expect :user, create_mock_user(has_console_password: true), [Username] + @mock_iam_resource.expect( + :user, + create_mock_user(has_console_password: true), + [Username], + ) assert @user_provider.user(Username)[:has_console_password?] end def test_has_console_password_returns_false - @mock_iam_resource.expect :user, create_mock_user(has_console_password: false), [Username] + @mock_iam_resource.expect( + :user, + create_mock_user(has_console_password: false), + [Username], + ) assert !@user_provider.user(Username)[:has_console_password?] end - + def test_has_console_password_returns_false_when_nosuchentity - @mock_iam_resource.expect :user, create_mock_user_throw(Aws::IAM::Errors::NoSuchEntity.new(nil, nil)), [Username] - + @mock_iam_resource.expect( + :user, + create_mock_user_throw(Aws::IAM::Errors::NoSuchEntity.new(nil, nil)), + [Username], + ) assert !@user_provider.user(Username)[:has_console_password?] end - + def test_has_console_password_throws - @mock_iam_resource.expect :user, create_mock_user_throw(ArgumentError), [Username] - + @mock_iam_resource.expect(:user, create_mock_user_throw(ArgumentError), + [Username]) + assert_raises ArgumentError do @user_provider.user(Username) end @@ -69,30 +90,34 @@ class AwsIamUserProviderTest < Minitest::Test def test_access_keys_returns_access_keys access_key = Object.new - - @mock_iam_resource.expect :user, create_mock_user(access_keys: [access_key]), [Username] + @mock_iam_resource.expect( + :user, + create_mock_user(access_keys: [access_key]), + [Username], + ) assert_equal [access_key], @user_provider.user(Username)[:access_keys] end private - def create_mock_user(has_console_password: true, has_mfa_enabled: true, access_keys: []) + def create_mock_user(has_console_password: true, has_mfa_enabled: true, + access_keys: []) mock_login_profile = Minitest::Mock.new mock_login_profile.expect :create_date, has_console_password ? 'date' : nil - + mock_user = Minitest::Mock.new mock_user.expect :mfa_devices, has_mfa_enabled ? ['device'] : [] mock_user.expect :login_profile, mock_login_profile mock_user.expect :access_keys, access_keys end - + def create_mock_user_throw(exception) mock_login_profile = Minitest::Mock.new - mock_login_profile.expect :create_date, nil do |args| + mock_login_profile.expect :create_date, nil do raise exception end - + mock_user = Minitest::Mock.new mock_user.expect :mfa_devices, [] mock_user.expect :login_profile, mock_login_profile diff --git a/test/unit/resources/aws_iam_user_test.rb b/test/unit/resources/aws_iam_user_test.rb index d4de12317..f39b87365 100644 --- a/test/unit/resources/aws_iam_user_test.rb +++ b/test/unit/resources/aws_iam_user_test.rb @@ -1,33 +1,48 @@ # author: Simon Varlow require 'aws-sdk' require 'helper' - require 'aws_iam_user' class AwsIamUserTest < Minitest::Test - Username = "test" - + Username = 'test'.freeze + def setup @mock_user_provider = Minitest::Mock.new end - def test_that_MFA_enable_returns_true_if_MFA_Enabled - @mock_user_provider.expect :user, {has_mfa_enabled?: true}, [Username] + def test_mfa_enabled_returns_true_if_mfa_enabled + @mock_user_provider.expect( + :user, + { has_mfa_enabled?: true }, + [Username], + ) assert AwsIamUser.new(Username, @mock_user_provider).has_mfa_enabled? end - def test_that_MFA_enable_returns_false_if_MFA_is_not_Enabled - @mock_user_provider.expect :user, {has_mfa_enabled?: false}, [Username] + def test_mfa_enabled_returns_false_if_mfa_is_not_enabled + @mock_user_provider.expect( + :user, + { has_mfa_enabled?: false }, + [Username], + ) refute AwsIamUser.new(Username, @mock_user_provider).has_mfa_enabled? end - def test_that_console_Password_returns_true_if_console_Password_has_been_set - @mock_user_provider.expect :user, {has_console_password?: true}, [Username] + def test_console_password_returns_true_if_console_password_has_been_set + @mock_user_provider.expect( + :user, + { has_console_password?: true }, + [Username], + ) assert AwsIamUser.new(Username, @mock_user_provider).has_console_password? end - def test_that_console_Password_returns_false_if_console_Password_has_not_been_set - @mock_user_provider.expect :user, {has_console_password?: false}, [Username] + def test_console_password_returns_false_if_console_password_has_not_been_set + @mock_user_provider.expect( + :user, + { has_console_password?: false }, + [Username], + ) refute AwsIamUser.new(Username, @mock_user_provider).has_console_password? end @@ -36,18 +51,32 @@ class AwsIamUserTest < Minitest::Test stub_access_key_resource = Object.new mock_access_key_factory = Minitest::Mock.new - @mock_user_provider.expect :user, {access_keys: [stub_aws_access_key]}, [Username] - mock_access_key_factory.expect :create_access_key, stub_access_key_resource, [stub_aws_access_key] + @mock_user_provider.expect( + :user, + { access_keys: [stub_aws_access_key] }, + [Username], + ) + mock_access_key_factory.expect( + :create_access_key, + stub_access_key_resource, + [stub_aws_access_key], + ) - assert_equal(stub_access_key_resource, - AwsIamUser.new(Username, @mock_user_provider, mock_access_key_factory).access_keys[0]) + assert_equal( + stub_access_key_resource, + AwsIamUser.new( + Username, + @mock_user_provider, + mock_access_key_factory, + ).access_keys[0], + ) mock_access_key_factory.verify end def test_to_s - @mock_user_provider.expect :user, {has_mfa_enabled?: true}, [Username] - expected = "IAM User test" + @mock_user_provider.expect :user, { has_mfa_enabled?: true }, [Username] + expected = 'IAM User test' test = AwsIamUser.new(Username, @mock_user_provider).to_s assert_equal expected, test end diff --git a/test/unit/resources/ec2_test.rb b/test/unit/resources/ec2_test.rb index 88f11ee8c..edb85360d 100644 --- a/test/unit/resources/ec2_test.rb +++ b/test/unit/resources/ec2_test.rb @@ -1,60 +1,53 @@ require 'helper' - require 'ec2' class TestEc2 < Minitest::Test - Id = "instance-id" + Id = 'instance-id'.freeze def setup - @mockConn = Minitest::Mock.new - @mockClient = Minitest::Mock.new - @mockResource = Minitest::Mock.new + @mock_conn = Minitest::Mock.new + @mock_client = Minitest::Mock.new + @mock_resource = Minitest::Mock.new - @mockConn.expect :ec2_client, @mockClient - @mockConn.expect :ec2_resource, @mockResource + @mock_conn.expect :ec2_client, @mock_client + @mock_conn.expect :ec2_resource, @mock_resource end def test_that_id_returns_id_directly_when_constructed_with_an_id - assert_equal Id, Ec2.new(Id, @mockConn).id + assert_equal Id, Ec2.new(Id, @mock_conn).id end def test_that_id_returns_fetched_id_when_constructed_with_a_name - mockInstance = Minitest::Mock.new - - mockInstance.expect :nil?, false - mockInstance.expect :id, Id - @mockResource.expect :instances, [mockInstance], [Hash] - - assert_equal Id, Ec2.new({name: 'cut'}, @mockConn).id + mock_instance = Minitest::Mock.new + mock_instance.expect :nil?, false + mock_instance.expect :id, Id + @mock_resource.expect :instances, [mock_instance], [Hash] + assert_equal Id, Ec2.new({ name: 'cut' }, @mock_conn).id end def test_that_instance_returns_instance_when_instance_exists - mockInstance = Object.new + mock_instance = Object.new - @mockResource.expect :instance, mockInstance, [Id] - - assert_same mockInstance, Ec2.new(Id, @mockConn).send(:instance) + @mock_resource.expect :instance, mock_instance, [Id] + assert_same mock_instance, Ec2.new(Id, @mock_conn).send(:instance) end def test_that_instance_returns_nil_when_instance_does_not_exist - @mockResource.expect :instance, nil, [Id] - - assert Ec2.new(Id, @mockConn).send(:instance).nil? + @mock_resource.expect :instance, nil, [Id] + assert Ec2.new(Id, @mock_conn).send(:instance).nil? end def test_that_exists_returns_true_when_instance_exists - mockInstance = Minitest::Mock.new - mockInstance.expect :exists?, true - @mockResource.expect :instance, mockInstance, [Id] - - assert Ec2.new(Id, @mockConn).exists? + mock_instance = Minitest::Mock.new + mock_instance.expect :exists?, true + @mock_resource.expect :instance, mock_instance, [Id] + assert Ec2.new(Id, @mock_conn).exists? end def test_that_exists_returns_false_when_instance_does_not_exist - mockInstance = Minitest::Mock.new - mockInstance.expect :exists?, false - @mockResource.expect :instance, mockInstance, [Id] - - assert !Ec2.new(Id, @mockConn).exists? + mock_instance = Minitest::Mock.new + mock_instance.expect :exists?, false + @mock_resource.expect :instance, mock_instance, [Id] + assert !Ec2.new(Id, @mock_conn).exists? end end From 033bc13aa00c2359289b8452afdb400929a62539 Mon Sep 17 00:00:00 2001 From: Chris Redekop Date: Tue, 8 Aug 2017 09:50:35 -0400 Subject: [PATCH 103/206] A real-world working AwsIamUsers (#71) * Add aws_iam_users Signed-off-by: Chris Redekop * Adding Filter table and Collect User Details to aws_iam_users.rb Signed-off-by: Chris Redekop * Adding Filter table and Collect User Details to aws_iam_users.rb Signed-off-by: Chris Redekop * Adding Filter table and Collect User Details to aws_iam_users.rb Signed-off-by: Chris Redekop * Get an aws_iam_users integration test to pass Signed-off-by: Chris Redekop * Fix RuboCop issues and tests Signed-off-by: Chris Redekop * Improving code based on PR feedback Signed-off-by: Chris Redekop --- libraries/aws_iam_user.rb | 20 +++-- libraries/aws_iam_user_provider.rb | 5 ++ libraries/aws_iam_users.rb | 51 ++++++++++++ .../verify/controls/aws_iam_user.rb | 6 +- .../verify/controls/aws_iam_users.rb | 3 + .../resources/aws_iam_user_provider_test.rb | 8 +- test/unit/resources/aws_iam_user_test.rb | 40 +++++++-- test/unit/resources/aws_iam_users_test.rb | 81 +++++++++++++++++++ 8 files changed, 193 insertions(+), 21 deletions(-) create mode 100644 libraries/aws_iam_users.rb create mode 100644 test/integration/verify/controls/aws_iam_users.rb create mode 100644 test/unit/resources/aws_iam_users_test.rb diff --git a/libraries/aws_iam_user.rb b/libraries/aws_iam_user.rb index d2af69470..8f90ef1ba 100644 --- a/libraries/aws_iam_user.rb +++ b/libraries/aws_iam_user.rb @@ -7,16 +7,18 @@ class AwsIamUser < Inspec.resource(1) name 'aws_iam_user' desc 'Verifies settings for AWS IAM user' example " - describe aws_iam_user('test_user_name') do + describe aws_iam_user(name: 'test_user_name') do its('has_mfa_enabled?') { should be false } its('has_console_password?') { should be true } end " - def initialize(name, aws_user_provider = AwsIam::UserProvider.new, - access_key_factory = AwsIamAccessKeyFactory.new) - - @name = name - @user = aws_user_provider.user(name) + def initialize( + opts, + aws_user_provider = AwsIam::UserProvider.new, + access_key_factory = AwsIamAccessKeyFactory.new + ) + @user = opts[:user] + @user = aws_user_provider.user(opts[:name]) if @user.nil? @access_key_factory = access_key_factory end @@ -34,8 +36,12 @@ class AwsIamUser < Inspec.resource(1) } end + def name + @user[:name] + end + def to_s - "IAM User #{@name}" + "IAM User #{name}" end class AwsIamAccessKeyFactory diff --git a/libraries/aws_iam_user_provider.rb b/libraries/aws_iam_user_provider.rb index 6139851be..7e764d3dd 100644 --- a/libraries/aws_iam_user_provider.rb +++ b/libraries/aws_iam_user_provider.rb @@ -20,6 +20,10 @@ module AwsIam end class << self + def name(aws_user) + aws_user.name + end + def has_mfa_enabled?(aws_user) !aws_user.mfa_devices.first.nil? end @@ -36,6 +40,7 @@ module AwsIam def convert(aws_user) { + name: name(aws_user), has_mfa_enabled?: has_mfa_enabled?(aws_user), has_console_password?: has_console_password?(aws_user), access_keys: access_keys(aws_user), diff --git a/libraries/aws_iam_users.rb b/libraries/aws_iam_users.rb new file mode 100644 index 000000000..9343438eb --- /dev/null +++ b/libraries/aws_iam_users.rb @@ -0,0 +1,51 @@ +# author: Alex Bedley +# author: Steffanie Freeman +# author: Simon Varlow +# author: Chris Redekop +class AwsIamUsers < Inspec.resource(1) + name 'aws_iam_users' + desc 'Verifies settings for AWS IAM users' + example ' + describe aws_iam_users.where(has_mfa_enabled?: false) do + it { should_not exist } + end + + describe aws_iam_users.where(has_console_password?: true) do + it { should exist } + end + ' + + filter = FilterTable.create + filter.add_accessor(:where) + .add_accessor(:entries) + .add(:exists?) { |x| !x.entries.empty? } + filter.connect(self, :collect_user_details) + + def initialize(aws_user_provider = AwsIam::UserProvider.new, + user_factory = AwsIamUserFactory.new) + @user_provider = aws_user_provider + @user_factory = user_factory + end + + def collect_user_details + @users_cache ||= @user_provider.list_users unless @user_provider.nil? + end + + def users + users = [] + users ||= @user_provider.list_users unless @user_provider.nil? + users.map { |user| + @user_factory.create_user(user) + } + end + + def to_s + 'IAM Users' + end + + class AwsIamUserFactory + def create_user(user) + AwsIamUser.new(user: user) + end + end +end diff --git a/test/integration/verify/controls/aws_iam_user.rb b/test/integration/verify/controls/aws_iam_user.rb index bcc10a8f6..10a931c8b 100644 --- a/test/integration/verify/controls/aws_iam_user.rb +++ b/test/integration/verify/controls/aws_iam_user.rb @@ -13,16 +13,16 @@ access_key_user = attribute( default: 'default.access_key_user', description: 'Name of IAM user access_key_user') -describe aws_iam_user(mfa_not_enabled_user) do +describe aws_iam_user(name: mfa_not_enabled_user) do it { should_not have_mfa_enabled } it { should_not have_console_password } end -describe aws_iam_user(console_password_enabled_user) do +describe aws_iam_user(name: console_password_enabled_user) do it { should have_console_password } end -aws_iam_user(access_key_user).access_keys.each { |access_key| +aws_iam_user(name: access_key_user).access_keys.each { |access_key| describe access_key do it { should be_active } end diff --git a/test/integration/verify/controls/aws_iam_users.rb b/test/integration/verify/controls/aws_iam_users.rb new file mode 100644 index 000000000..75c7487f0 --- /dev/null +++ b/test/integration/verify/controls/aws_iam_users.rb @@ -0,0 +1,3 @@ +describe aws_iam_users.where(has_console_password?: true).where(has_mfa_enabled?: false) do + it { should exist } +end diff --git a/test/unit/resources/aws_iam_user_provider_test.rb b/test/unit/resources/aws_iam_user_provider_test.rb index fff9d7de1..9cc8afb44 100644 --- a/test/unit/resources/aws_iam_user_provider_test.rb +++ b/test/unit/resources/aws_iam_user_provider_test.rb @@ -24,15 +24,15 @@ class AwsIamUserProviderTest < Minitest::Test def test_list_users @mock_iam_resource.expect( :users, - [create_mock_user, create_mock_user, create_mock_user], + [create_mock_user, create_mock_user], ) mock_user_output = { + name: Username, has_mfa_enabled?: true, has_console_password?: true, access_keys: [], } - assert @user_provider.list_users == [mock_user_output, mock_user_output, - mock_user_output] + assert @user_provider.list_users == [mock_user_output, mock_user_output] end def test_list_users_no_users @@ -107,6 +107,7 @@ class AwsIamUserProviderTest < Minitest::Test mock_login_profile.expect :create_date, has_console_password ? 'date' : nil mock_user = Minitest::Mock.new + mock_user.expect :name, Username mock_user.expect :mfa_devices, has_mfa_enabled ? ['device'] : [] mock_user.expect :login_profile, mock_login_profile mock_user.expect :access_keys, access_keys @@ -119,6 +120,7 @@ class AwsIamUserProviderTest < Minitest::Test end mock_user = Minitest::Mock.new + mock_user.expect :name, Username mock_user.expect :mfa_devices, [] mock_user.expect :login_profile, mock_login_profile mock_user.expect :access_keys, [] diff --git a/test/unit/resources/aws_iam_user_test.rb b/test/unit/resources/aws_iam_user_test.rb index f39b87365..e9b1919a7 100644 --- a/test/unit/resources/aws_iam_user_test.rb +++ b/test/unit/resources/aws_iam_user_test.rb @@ -16,7 +16,12 @@ class AwsIamUserTest < Minitest::Test { has_mfa_enabled?: true }, [Username], ) - assert AwsIamUser.new(Username, @mock_user_provider).has_mfa_enabled? + assert( + AwsIamUser.new( + { name: Username }, + @mock_user_provider, + ).has_mfa_enabled?, + ) end def test_mfa_enabled_returns_false_if_mfa_is_not_enabled @@ -25,7 +30,12 @@ class AwsIamUserTest < Minitest::Test { has_mfa_enabled?: false }, [Username], ) - refute AwsIamUser.new(Username, @mock_user_provider).has_mfa_enabled? + refute( + AwsIamUser.new( + { name: Username }, + @mock_user_provider, + ).has_mfa_enabled?, + ) end def test_console_password_returns_true_if_console_password_has_been_set @@ -34,7 +44,12 @@ class AwsIamUserTest < Minitest::Test { has_console_password?: true }, [Username], ) - assert AwsIamUser.new(Username, @mock_user_provider).has_console_password? + assert( + AwsIamUser.new( + { name: Username }, + @mock_user_provider, + ).has_console_password?, + ) end def test_console_password_returns_false_if_console_password_has_not_been_set @@ -43,7 +58,12 @@ class AwsIamUserTest < Minitest::Test { has_console_password?: false }, [Username], ) - refute AwsIamUser.new(Username, @mock_user_provider).has_console_password? + refute( + AwsIamUser.new( + { name: Username }, + @mock_user_provider, + ).has_console_password?, + ) end def test_that_access_keys_returns_aws_iam_access_key_resources @@ -65,7 +85,7 @@ class AwsIamUserTest < Minitest::Test assert_equal( stub_access_key_resource, AwsIamUser.new( - Username, + { name: Username }, @mock_user_provider, mock_access_key_factory, ).access_keys[0], @@ -75,9 +95,13 @@ class AwsIamUserTest < Minitest::Test end def test_to_s - @mock_user_provider.expect :user, { has_mfa_enabled?: true }, [Username] - expected = 'IAM User test' - test = AwsIamUser.new(Username, @mock_user_provider).to_s + @mock_user_provider.expect( + :user, + { name: Username, has_mfa_enabled?: true }, + [Username], + ) + expected = "IAM User #{Username}" + test = AwsIamUser.new({ name: Username }, @mock_user_provider).to_s assert_equal expected, test end end diff --git a/test/unit/resources/aws_iam_users_test.rb b/test/unit/resources/aws_iam_users_test.rb new file mode 100644 index 000000000..903b43b19 --- /dev/null +++ b/test/unit/resources/aws_iam_users_test.rb @@ -0,0 +1,81 @@ +# author: Adnan Duric +# author: Steffanie Freeman +# author: Simon Varlow +# author: Chris Redekop +require 'aws-sdk' +require 'helper' +require 'aws_iam_users' + +class AwsIamUsersTest < Minitest::Test + def setup + @mock_user_factory = Minitest::Mock.new + end + + def test_users_nil_user_provider_returns_empty_list + cut = AwsIamUsers.new(nil, @mock_user_factory) + + assert_equal(cut.users, []) + end + + def test_users_empty_list_user_provider_returns_empty_list + cut = AwsIamUsers.new(create_mock_user_provider, @mock_user_factory) + + assert_equal(cut.users, []) + end + + def test_users_returns_true_for_all_users_if_mfa_enabled + cut = AwsIamUsers.new( + create_mock_user_provider(create_mock_users([true, true])), + @mock_user_factory, + ) + + cut.users.each do |user| + assert user.has_mfa_enabled? + end + end + + [ + { + name: 'test_where_returns_no_matching_rows', + user_material: [false], + }, { + name: 'test_where_returns_some_matching_rows', + user_material: [true, false], + }, { + name: 'test_where_returns_all_matching_rows', + user_material: [true], + } + ].each do |test_material| + define_method(test_material[:name]) do + cut = AwsIamUsers.new( + create_mock_user_provider( + create_mock_users(test_material[:user_material]), + ), + @mock_user_factory, + ) + + results = cut.where(has_mfa_enabled?: true) + expected_count = test_material[:user_material].count { |x| x } + + assert_equal expected_count > 0, results.exists? + assert_equal expected_count, results.entries.length + end + end + + def create_mock_user_provider(user_list = []) + mock_user_provider = Minitest::Mock.new + + mock_user_provider.expect :list_users, user_list + mock_user_provider.expect :nil?, false + + mock_user_provider + end + + def create_mock_users(has_mfa_enableds = []) + has_mfa_enableds.map { |x| create_mock_user(x) } + end + + def create_mock_user(has_mfa_enabled = true) + { has_mfa_enabled?: has_mfa_enabled } + end +end From c77d442007151ed3f9f492fa98872c8c2c11758f Mon Sep 17 00:00:00 2001 From: Chris Redekop Date: Wed, 16 Aug 2017 05:53:44 -0400 Subject: [PATCH 104/206] Add has_mfa_enabled? to aws_iam_root_account (#80) Signed-off-by: Chris Redekop --- libraries/aws_iam_root_user.rb | 4 ++++ test/unit/resources/aws_iam_root_user_test.rb | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/libraries/aws_iam_root_user.rb b/libraries/aws_iam_root_user.rb index 2ba8a01e9..cf803bf2f 100644 --- a/libraries/aws_iam_root_user.rb +++ b/libraries/aws_iam_root_user.rb @@ -16,6 +16,10 @@ class AwsIamRootUser < Inspec.resource(1) summary_account['AccountAccessKeysPresent'] end + def has_mfa_enabled? + summary_account['AccountMFAEnabled'] == 1 + end + def to_s 'AWS Root-User' end diff --git a/test/unit/resources/aws_iam_root_user_test.rb b/test/unit/resources/aws_iam_root_user_test.rb index 7a0105d4e..3bb6834e0 100644 --- a/test/unit/resources/aws_iam_root_user_test.rb +++ b/test/unit/resources/aws_iam_root_user_test.rb @@ -19,4 +19,22 @@ class AwsIamRootUserTest < Minitest::Test assert_equal expected_keys, AwsIamRootUser.new(@mock_conn).access_key_count end + + def test_has_mfa_enabled_returns_true_when_account_mfa_devices_is_one + test_summary_map = OpenStruct.new( + summary_map: { 'AccountMFAEnabled' => 1 }, + ) + @mock_client.expect :get_account_summary, test_summary_map + + assert_equal true, AwsIamRootUser.new(@mock_conn).has_mfa_enabled? + end + + def test_has_mfa_enabled_returns_false_when_account_mfa_devices_is_zero + test_summary_map = OpenStruct.new( + summary_map: { 'AccountMFAEnabled' => 0 }, + ) + @mock_client.expect :get_account_summary, test_summary_map + + assert_equal false, AwsIamRootUser.new(@mock_conn).has_mfa_enabled? + end end From f8675c3559016bc6167037e17571b6542f084781 Mon Sep 17 00:00:00 2001 From: Christoph Hartmann Date: Wed, 16 Aug 2017 15:39:58 +0200 Subject: [PATCH 105/206] update readme with available resources (#84) Signed-off-by: Christoph Hartmann --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4bdeb7035..773fd4179 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,11 @@ end ### Available Resources * `aws_ec2` - This resource reads information about an ec2 instance + * `aws_iam_access_key` - Verifies settings for AWS IAM access keys + * `aws_iam_password_policy` - Verifies iam password policy + * `aws_iam_root_user` - Verifies settings for AWS root account + * `aws_iam_user` - Verifies settings for a specific AWS IAM user + * `aws_iam_users` - Verifies settings for AWS IAM users ### Roadmap @@ -71,7 +76,6 @@ end * `aws_iam_group` * `aws_iam_policy` * `aws_iam_role` - * `aws_iam_user` ... From 0bce3858363ed5326c0d1f160818589f60a1209f Mon Sep 17 00:00:00 2001 From: Jerry Aldrich III Date: Tue, 5 Sep 2017 11:40:01 -0500 Subject: [PATCH 106/206] Change `ec2(` to `aws_ec2(` in README.md (#87) Signed-off-by: jerry --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 773fd4179..8275d0eb6 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ control "aws-1" do impact 0.7 title 'Checks the machine is running' - describe ec2('my-ec2-machine') do + describe aws_ec2('my-ec2-machine') do it { should be_running } end end From 57578ab67d000022e01ecc480f25a5a6cdbc79ce Mon Sep 17 00:00:00 2001 From: Nathen Harvey Date: Sun, 8 Oct 2017 08:27:42 -0400 Subject: [PATCH 107/206] README code examples are copy-paste-able. (#86) The resource is `aws_ec2`, not `ec2` Instance IDs must be passed to the `aws_ec2` resource, not machine names Signed-off-by: Nathen Harvey --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8275d0eb6..bda06a79b 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ control "aws-1" do impact 0.7 title 'Checks the machine is running' - describe aws_ec2('my-ec2-machine') do + describe aws_ec2('i-my-ec2-instance-id') do it { should be_running } end end From b1254926cdf58600c918cd74fcebfa25c33f07af Mon Sep 17 00:00:00 2001 From: Daniel Sanabria Date: Sun, 8 Oct 2017 21:43:46 +0100 Subject: [PATCH 108/206] Using newer arm sdk version (#44) Some object methods have been deprecated in v0.11.0 of the arm sdks. This commit fixes the get_resources method by using the new resources object instance method. We are also updating the Gemfile accordingly. Signed-off-by: sanabriad --- Gemfile | 6 +++--- lib/resources/azure/azure_backend.rb | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Gemfile b/Gemfile index 63f30a750..13d3eadf8 100644 --- a/Gemfile +++ b/Gemfile @@ -1,9 +1,9 @@ source 'https://rubygems.org' gem 'inifile' -gem 'azure_mgmt_compute' -gem 'azure_mgmt_resources' -gem 'azure_mgmt_network' +gem 'azure_mgmt_compute', '~> 0.11.0' +gem 'azure_mgmt_resources', '~> 0.11.0' +gem 'azure_mgmt_network', '~> 0.11.0' gem 'bundle' group :development do diff --git a/lib/resources/azure/azure_backend.rb b/lib/resources/azure/azure_backend.rb index b6fc9031a..e70acc4ab 100644 --- a/lib/resources/azure/azure_backend.rb +++ b/lib/resources/azure/azure_backend.rb @@ -196,7 +196,7 @@ class ResourceManagement # # @return [Azure::ARM::Resources::Models::ResourceListResult] Object containing array of all the resources def get_resources(name) - client.resource_groups.list_resources_as_lazy(name) if exists(name) + client.resources.list_by_resource_group_as_lazy(name) if exists(name) end end From c7adc529b252334d1e5ee0c2f92160190022ae1d Mon Sep 17 00:00:00 2001 From: Christoph Hartmann Date: Sun, 8 Oct 2017 22:56:52 +0200 Subject: [PATCH 109/206] deactivate rubocop testing for unspecific exception handling (#50) Signed-off-by: Christoph Hartmann --- .rubocop.yml | 4 +++- lib/resources/azure/azure_backend.rb | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.rubocop.yml b/.rubocop.yml index 361482e17..c777f777f 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -71,4 +71,6 @@ BlockDelimiters: Style/SpaceAroundOperators: Enabled: false Style/IfUnlessModifier: - Enabled: false \ No newline at end of file + Enabled: false +Lint/RescueWithoutErrorClass: + Enabled: false diff --git a/lib/resources/azure/azure_backend.rb b/lib/resources/azure/azure_backend.rb index e70acc4ab..d5eb38509 100644 --- a/lib/resources/azure/azure_backend.rb +++ b/lib/resources/azure/azure_backend.rb @@ -143,6 +143,7 @@ class Helpers # get a vm from the named resource group begin compute_mgmt.client.virtual_machines.get(rg_name, name) + # TODO: we should avoid doing a general rescue, will be covered with InSpec 2.0 integration rescue => e e.error_message end From 8bd00a9b8741dc9c8cb8d785f6a65306f2d48d02 Mon Sep 17 00:00:00 2001 From: Christoph Hartmann Date: Sun, 8 Oct 2017 23:02:19 +0200 Subject: [PATCH 110/206] relax azure sdk pinning (#51) Signed-off-by: Christoph Hartmann --- Gemfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile b/Gemfile index 13d3eadf8..8af46395f 100644 --- a/Gemfile +++ b/Gemfile @@ -1,9 +1,9 @@ source 'https://rubygems.org' gem 'inifile' -gem 'azure_mgmt_compute', '~> 0.11.0' -gem 'azure_mgmt_resources', '~> 0.11.0' -gem 'azure_mgmt_network', '~> 0.11.0' +gem 'azure_mgmt_compute', '~> 0.11' +gem 'azure_mgmt_resources', '~> 0.11' +gem 'azure_mgmt_network', '~> 0.11' gem 'bundle' group :development do From 2a03c37bb7c47acc27ec4b825c5341c03f11844e Mon Sep 17 00:00:00 2001 From: Christoph Hartmann Date: Sun, 8 Oct 2017 23:07:50 +0200 Subject: [PATCH 111/206] support terraform 0.10 (#49) Signed-off-by: Christoph Hartmann --- README.md | 10 +++++----- Rakefile | 10 +++++++++- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 2db4ac10c..8edd6a297 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ## Roadmap -This repository is the development repository for InSpec for Azure. Once [RFC Platforms](https://github.com/chef/inspec/issues/1661) is fully implemented in InSpec, this repository is going to be merged into core InSpec. +This repository is the development repository for InSpec for VmWare. Once [RFC Platforms](https://github.com/chef/inspec/issues/1661) is fully implemented in InSpec, this repository is going to be merged into core InSpec. As of now, Azure resources are implemented as an InSpec resource pack. It will ship with the required resources to write your own Azure tests: @@ -109,8 +109,8 @@ control 'azure-1' do describe azure_virtual_machine(name: 'example-01', resource_group: 'MyResourceGroup') do its('sku') { should eq '16.04.0-LTS' } - its('publisher') { should eq 'Canonical' } - its('offer') { should eq 'UbuntuServer' } + its('publisher') { should ieq 'Canonical' } + its('offer') { should ieq 'UbuntuServer' } end end ``` @@ -244,9 +244,9 @@ control 'azure-1' do end ``` -## Testing +## Integration Testing -The `test/integration/verify/controls` directory contains all of the tests that are run during integration tests. These can be used as examples of how to use this resource pack. +Our integration tests spin up an environment with terraform 0.10+ and verify the result with InSpec. The `test/integration/verify/controls` directory contains all of the tests that are run during integration tests. These can be used as examples of how to use this resource pack. Rake tasks have been configured to enable the running of the integration tests: diff --git a/Rakefile b/Rakefile index 7539994c3..0dd16061a 100644 --- a/Rakefile +++ b/Rakefile @@ -30,6 +30,12 @@ namespace :test do # sh("bundle exec inspec check #{dir}") #end + task :init_workspace do + # Initialize terraform workspace + cmd = format("cd %s/build/ && terraform init", integration_dir) + sh(cmd) + end + task :setup_integration_tests do azure_backend = AzureConnection.new @@ -40,6 +46,7 @@ namespace :test do admin_password = Passgen::generate(length: 12, uppercase: true, lowercase: true, symbols: true, digits: true) puts "----> Setup" + # Create the plan that can be applied to Azure cmd = format("cd %s/build/ && terraform plan -var 'subscription_id=%s' -var 'client_id=%s' -var 'client_secret=%s' -var 'tenant_id=%s' -var='storage_account_name=%s' -var='admin_password=%s' -out inspec-azure.plan", integration_dir, creds[:subscription_id], creds[:client_id], creds[:client_secret], creds[:tenant_id], sa_name, admin_password) sh(cmd) @@ -51,7 +58,7 @@ namespace :test do task :run_integration_tests do puts "----> Run" - + cmd = format("bundle exec inspec exec %s/verify", integration_dir) sh(cmd) end @@ -69,6 +76,7 @@ namespace :test do desc "Perform Integration Tests" task :integration do + Rake::Task["test:init_workspace"].execute Rake::Task["test:cleanup_integration_tests"].execute Rake::Task["test:setup_integration_tests"].execute Rake::Task["test:run_integration_tests"].execute From fc43e87ee2ea042f26f9b0a121f6c37f7f42a64b Mon Sep 17 00:00:00 2001 From: Seth Thoenen Date: Sun, 8 Oct 2017 16:08:23 -0500 Subject: [PATCH 112/206] Fix issue where tests would fail if public DNS settings are not set (#38) Signed-off-by: Seth Thoenen --- lib/resources/azure/azure_virtual_machine.rb | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/resources/azure/azure_virtual_machine.rb b/lib/resources/azure/azure_virtual_machine.rb index 54ec02426..2187425e4 100644 --- a/lib/resources/azure/azure_virtual_machine.rb +++ b/lib/resources/azure/azure_virtual_machine.rb @@ -308,9 +308,14 @@ class AzureVm < Inspec.resource(1) # Interrogate Azure for the NIC details public_ip = helpers.network_mgmt.client.public_ipaddresses.get(public_ip_resource_group_name, public_ip_name) - # update the config with the information about the public IP - config['public_ipaddress']['domain_name_label'] = public_ip.dns_settings.domain_name_label - config['public_ipaddress']['dns_fqdn'] = public_ip.dns_settings.fqdn + # update the config with the information about the public IP if public dns settings are available + if !public_ip.dns_settings.nil? + config['public_ipaddress']['domain_name_label'] = public_ip.dns_settings.domain_name_label + config['public_ipaddress']['dns_fqdn'] = public_ip.dns_settings.fqdn + else + config['public_ipaddress']['domain_name_label'] = nil + config['public_ipaddress']['dns_fqdn'] = nil + end end # return object From a2289bfcbe07eff0862b498a8cecd53b0c2409b2 Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Mon, 9 Oct 2017 11:54:42 +0100 Subject: [PATCH 113/206] Updated so that the credentials file is only interogated if it exists (#42) * Updated so that the credentials file is only interogated if it exists Fixes #39 Signed-off-by: Russell Seymour * Made change to set credentials to nil as requested Signed-off-by: Russell Seymour --- lib/resources/azure/azure_backend.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/resources/azure/azure_backend.rb b/lib/resources/azure/azure_backend.rb index d5eb38509..aa470baa1 100644 --- a/lib/resources/azure/azure_backend.rb +++ b/lib/resources/azure/azure_backend.rb @@ -29,6 +29,7 @@ class AzureConnection if File.file?(azure_creds_file) @credentials = IniFile.load(File.expand_path(azure_creds_file)) else + @credentials = nil warn format('%s was not found or not accessible', azure_creds_file) end end @@ -56,7 +57,9 @@ class AzureConnection @subscription_id = azure_subscription_id # Check that the credential exists - raise format('The specified Azure Subscription cannot be found in your credentials: %s', subscription_id) unless @credentials.sections.include?(subscription_id) + unless @credentials.nil? + raise format('The specified Azure Subscription cannot be found in your credentials: %s', subscription_id) unless @credentials.sections.include?(subscription_id) + end # Determine the client_id, tenant_id and the client_secret tenant_id = ENV['AZURE_TENANT_ID'] || @credentials[subscription_id]['tenant_id'] From 9bbb43946795f5541c361b14cc18380571185e6f Mon Sep 17 00:00:00 2001 From: Jerry Aldrich III Date: Tue, 10 Oct 2017 19:20:25 -0500 Subject: [PATCH 114/206] Add support for `ENV['AWS_REGION']` (#92) The `aws-sdk` will raise an error mentioning `ENV['AWS_REGION']` if you omit `ENV['AWS_DEFAULT_REGION']`. This adds support for either via `||`. Signed-off-by: Jerry Aldrich --- README.md | 4 ++-- libraries/aws_conn.rb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index bda06a79b..b2b4d73a5 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ As of now, AWS resources are implemented as an InSpec resource pack. It will shi To run the profile, use InSpec with an environment variable for AWS credentials: -- `AWS_DEFAULT_REGION` +- `AWS_REGION` - `AWS_ACCESS_KEY_ID` - `AWS_SECRET_ACCESS_KEY` @@ -92,7 +92,7 @@ bundle exec rake test ### Integration tests To run the integration tests, please make sure all required environment variables like `AWS_ACCESS_KEY_ID` -, `AWS_SECRET_ACCESS_KEY` and `AWS_DEFAULT_REGION` are set properly. (`AWS_DEFAULT_REGION` **must** be set to **us-east-1** when running the integration tests.) We use terraform to create the AWS setup and InSpec to verify the all aspects. If you want to use a specific terraform environment, set environment variable `INSPEC_TERRAFORM_ENV`. Integration tests can be executed via: +, `AWS_SECRET_ACCESS_KEY` and `AWS_REGION` are set properly. (`AWS_REGION` **must** be set to **us-east-1** when running the integration tests.) We use terraform to create the AWS setup and InSpec to verify the all aspects. If you want to use a specific terraform environment, set environment variable `INSPEC_TERRAFORM_ENV`. Integration tests can be executed via: ``` bundle exec rake test:integration diff --git a/libraries/aws_conn.rb b/libraries/aws_conn.rb index cc583689b..9e6d74466 100644 --- a/libraries/aws_conn.rb +++ b/libraries/aws_conn.rb @@ -4,7 +4,7 @@ class AWSConnection def initialize require 'aws-sdk' opts = { - region: ENV['AWS_DEFAULT_REGION'], + region: ENV['AWS_REGION'] || ENV['AWS_DEFAULT_REGION'], credentials: Aws::Credentials.new( ENV['AWS_ACCESS_KEY_ID'], ENV['AWS_SECRET_ACCESS_KEY'], From 2f9317265b4ebd662f7ef3743b6201ff1ae48fd2 Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Tue, 10 Oct 2017 20:21:56 -0400 Subject: [PATCH 115/206] Update Terraform commands for v0.10 (#93) * Terraform now requires init to fetch providers Signed-off-by: Clinton Wolfe * Rename env terraform command to workspace Signed-off-by: Clinton Wolfe * Pin Terraform version to reflect CLI updates Signed-off-by: Clinton Wolfe --- Rakefile | 7 ++++--- test/integration/build/aws.tf | 4 ++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Rakefile b/Rakefile index 9e083bfe4..59ae50dae 100644 --- a/Rakefile +++ b/Rakefile @@ -42,7 +42,8 @@ namespace :test do task :configure_test_environment, :namespace do |t, args| puts "----> Creating terraform environment" - sh("cd #{integration_dir}/build/ && terraform env new #{args[:namespace]}") + sh("cd #{integration_dir}/build/ && terraform init") + sh("cd #{integration_dir}/build/ && terraform workspace new #{args[:namespace]}") end task :setup_integration_tests do @@ -69,8 +70,8 @@ namespace :test do task :destroy_test_environment, :namespace do |t, args| puts "----> Destroying terraform environment" - sh("cd #{integration_dir}/build/ && terraform env select default") - sh("cd #{integration_dir}/build && terraform env delete #{args[:namespace]}") + sh("cd #{integration_dir}/build/ && terraform workspace select default") + sh("cd #{integration_dir}/build && terraform workspace delete #{args[:namespace]}") end task :integration do diff --git a/test/integration/build/aws.tf b/test/integration/build/aws.tf index 211e82e87..b2be37a59 100644 --- a/test/integration/build/aws.tf +++ b/test/integration/build/aws.tf @@ -1,3 +1,7 @@ +terraform { + required_version = "~> 0.10.0" +} + provider "aws" {} resource "aws_instance" "example" { From d21e2af15f53a2fc943a949e4caa2f8c5d548d16 Mon Sep 17 00:00:00 2001 From: Christoph Hartmann Date: Wed, 11 Oct 2017 21:18:20 +0100 Subject: [PATCH 116/206] rename file for aws_ec2 resource * rename to aws_ec2_instance Signed-off-by: Christoph Hartmann --- libraries/{ec2.rb => aws_ec2_instance.rb} | 22 ++++++++++++++++--- .../controls/{aws.rb => aws_ec2_instance.rb} | 16 ++++++++++++-- .../{ec2_test.rb => aws_ec2_instance_test.rb} | 19 ++++++++++------ 3 files changed, 45 insertions(+), 12 deletions(-) rename libraries/{ec2.rb => aws_ec2_instance.rb} (82%) rename test/integration/verify/controls/{aws.rb => aws_ec2_instance.rb} (54%) rename test/unit/resources/{ec2_test.rb => aws_ec2_instance_test.rb} (72%) diff --git a/libraries/ec2.rb b/libraries/aws_ec2_instance.rb similarity index 82% rename from libraries/ec2.rb rename to libraries/aws_ec2_instance.rb index 8de926506..74e0a1973 100644 --- a/libraries/ec2.rb +++ b/libraries/aws_ec2_instance.rb @@ -1,7 +1,7 @@ # author: Christoph Hartmann -class Ec2 < Inspec.resource(1) - name 'aws_ec2' +class AwsEc2Instance < Inspec.resource(1) + name 'aws_ec2_instance' desc 'Verifies settings for an EC2 instance' example " @@ -41,6 +41,7 @@ class Ec2 < Inspec.resource(1) alias instance_id id def exists? + return false if instance.nil? instance.exists? end @@ -67,7 +68,7 @@ class Ec2 < Inspec.resource(1) instance_type image_id vpc_id }.each do |attribute| define_method attribute do - instance.send(attribute) + instance.send(attribute) if instance end end @@ -91,3 +92,18 @@ class Ec2 < Inspec.resource(1) @instance ||= @ec2_resource.instance(id) end end + +# Deprecated +class AwsEc2 < AwsEc2Instance + name 'aws_ec2' + + def initialize(opts, conn = AWSConnection.new) + deprecated + super(opts, conn) + end + + def deprecated + warn '[DEPRECATION] `aws_ec2(parameter)` is deprecated. ' \ + 'Please use `aws_ec2_instance(parameter)` instead.' + end +end diff --git a/test/integration/verify/controls/aws.rb b/test/integration/verify/controls/aws_ec2_instance.rb similarity index 54% rename from test/integration/verify/controls/aws.rb rename to test/integration/verify/controls/aws_ec2_instance.rb index 5f90ae823..8fa62b41f 100644 --- a/test/integration/verify/controls/aws.rb +++ b/test/integration/verify/controls/aws_ec2_instance.rb @@ -8,6 +8,18 @@ example_ec2_name = attribute( default: 'default.Example', description: 'Name of exapmle ec2 instance') +describe aws_ec2_instance(name: example_ec2_name) do + it { should exist } + its('image_id') { should eq 'ami-0d729a60' } + its('instance_type') { should eq 't2.micro' } +end + +describe aws_ec2_instance(example_ec2_id) do + it { should exist } + its('image_id') { should eq 'ami-0d729a60' } + its('instance_type') { should eq 't2.micro' } +end + describe aws_ec2(name: example_ec2_name) do it { should exist } its('image_id') { should eq 'ami-0d729a60' } @@ -20,7 +32,7 @@ describe aws_ec2(example_ec2_id) do its('instance_type') { should eq 't2.micro' } end -#must use a real EC2 instance name, as the SDK will first check to see if its well formed before sending requests -describe aws_ec2('i-06b4bc106e0d03dfd') do +# must use a real EC2 instance name, as the SDK will first check to see if its well formed before sending requests +describe aws_ec2_instance('i-06b4bc106e0d03dfd') do it { should_not exist } end diff --git a/test/unit/resources/ec2_test.rb b/test/unit/resources/aws_ec2_instance_test.rb similarity index 72% rename from test/unit/resources/ec2_test.rb rename to test/unit/resources/aws_ec2_instance_test.rb index edb85360d..2bcfe526b 100644 --- a/test/unit/resources/ec2_test.rb +++ b/test/unit/resources/aws_ec2_instance_test.rb @@ -1,5 +1,5 @@ require 'helper' -require 'ec2' +require 'aws_ec2_instance' class TestEc2 < Minitest::Test Id = 'instance-id'.freeze @@ -14,7 +14,7 @@ class TestEc2 < Minitest::Test end def test_that_id_returns_id_directly_when_constructed_with_an_id - assert_equal Id, Ec2.new(Id, @mock_conn).id + assert_equal Id, AwsEc2Instance.new(Id, @mock_conn).id end def test_that_id_returns_fetched_id_when_constructed_with_a_name @@ -22,32 +22,37 @@ class TestEc2 < Minitest::Test mock_instance.expect :nil?, false mock_instance.expect :id, Id @mock_resource.expect :instances, [mock_instance], [Hash] - assert_equal Id, Ec2.new({ name: 'cut' }, @mock_conn).id + assert_equal Id, AwsEc2Instance.new({ name: 'cut' }, @mock_conn).id end def test_that_instance_returns_instance_when_instance_exists mock_instance = Object.new @mock_resource.expect :instance, mock_instance, [Id] - assert_same mock_instance, Ec2.new(Id, @mock_conn).send(:instance) + assert_same mock_instance, AwsEc2Instance.new( + Id, + @mock_conn, + ).send(:instance) end def test_that_instance_returns_nil_when_instance_does_not_exist @mock_resource.expect :instance, nil, [Id] - assert Ec2.new(Id, @mock_conn).send(:instance).nil? + assert AwsEc2Instance.new(Id, @mock_conn).send(:instance).nil? end def test_that_exists_returns_true_when_instance_exists mock_instance = Minitest::Mock.new + mock_instance.expect :nil?, false mock_instance.expect :exists?, true @mock_resource.expect :instance, mock_instance, [Id] - assert Ec2.new(Id, @mock_conn).exists? + assert AwsEc2Instance.new(Id, @mock_conn).exists? end def test_that_exists_returns_false_when_instance_does_not_exist mock_instance = Minitest::Mock.new + mock_instance.expect :nil?, false mock_instance.expect :exists?, false @mock_resource.expect :instance, mock_instance, [Id] - assert !Ec2.new(Id, @mock_conn).exists? + assert !AwsEc2Instance.new(Id, @mock_conn).exists? end end From 11de78ee4a3c220a23385bb113f6599e7890aeb7 Mon Sep 17 00:00:00 2001 From: Jerry Aldrich III Date: Thu, 12 Oct 2017 10:54:55 -0500 Subject: [PATCH 117/206] Correct examples in `aws_iam_password_policy` (#99) Signed-off-by: Jerry Aldrich --- libraries/aws_iam_password_policy.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/aws_iam_password_policy.rb b/libraries/aws_iam_password_policy.rb index 3a93f0c2d..65b7b9b4f 100644 --- a/libraries/aws_iam_password_policy.rb +++ b/libraries/aws_iam_password_policy.rb @@ -8,11 +8,11 @@ class AwsIamPasswordPolicy < Inspec.resource(1) example " describe aws_iam_password_policy do - its('requires_lowercase_letters?') { should be true } + its('requires_lowercase_characters?') { should be true } end describe aws_iam_password_policy do - its('requires_uppercase_letters?') { should be true } + its('requires_uppercase_characters?') { should be true } end " From 7b03361d031aacf0e045cd48ed94b18193ade4c6 Mon Sep 17 00:00:00 2001 From: Jerry Aldrich III Date: Tue, 17 Oct 2017 07:38:49 -0500 Subject: [PATCH 118/206] Change `aws_ec2` to `aws_ec2_instance` in examples (#97) Signed-off-by: Jerry Aldrich --- libraries/aws_ec2_instance.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/aws_ec2_instance.rb b/libraries/aws_ec2_instance.rb index 74e0a1973..b1eba6588 100644 --- a/libraries/aws_ec2_instance.rb +++ b/libraries/aws_ec2_instance.rb @@ -5,11 +5,11 @@ class AwsEc2Instance < Inspec.resource(1) desc 'Verifies settings for an EC2 instance' example " - describe aws_ec2('i-123456') do + describe aws_ec2_instance('i-123456') do it { should be_running } end - describe aws_ec2(name: 'my-instance') do + describe aws_ec2_instance(name: 'my-instance') do it { should be_running } end " From 5d720ef05a30f42c8825497e9aa7c8e71c0ab851 Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Thu, 26 Oct 2017 10:50:18 +0100 Subject: [PATCH 119/206] Modified to handle adding a suffix to the end of the Public IP Address domain name (#53) Fixes #52 Signed-off-by: Russell Seymour --- Rakefile | 7 +++++-- test/integration/azure/build/azure.tf | 6 +++++- test/integration/azure/verify/controls/vm.rb | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/Rakefile b/Rakefile index 0dd16061a..6c1aae234 100644 --- a/Rakefile +++ b/Rakefile @@ -45,10 +45,13 @@ namespace :test do sa_name = (0...15).map { (65 + rand(26)).chr }.join.downcase admin_password = Passgen::generate(length: 12, uppercase: true, lowercase: true, symbols: true, digits: true) + # Use the first 4 characters of the storage account to create a suffix + suffix = sa_name[0..3] + puts "----> Setup" # Create the plan that can be applied to Azure - cmd = format("cd %s/build/ && terraform plan -var 'subscription_id=%s' -var 'client_id=%s' -var 'client_secret=%s' -var 'tenant_id=%s' -var='storage_account_name=%s' -var='admin_password=%s' -out inspec-azure.plan", integration_dir, creds[:subscription_id], creds[:client_id], creds[:client_secret], creds[:tenant_id], sa_name, admin_password) + cmd = format("cd %s/build/ && terraform plan -var 'subscription_id=%s' -var 'client_id=%s' -var 'client_secret=%s' -var 'tenant_id=%s' -var 'storage_account_name=%s' -var 'admin_password=%s' -var 'suffix=%s' -out inspec-azure.plan", integration_dir, creds[:subscription_id], creds[:client_id], creds[:client_secret], creds[:tenant_id], sa_name, admin_password, suffix) sh(cmd) # Apply the plan on Azure @@ -69,7 +72,7 @@ namespace :test do creds = azure_backend.spn puts "----> Cleanup" - cmd = format("cd %s/build/ && terraform destroy -force -var 'subscription_id=%s' -var 'client_id=%s' -var 'client_secret=%s' -var 'tenant_id=%s' -var='admin_password=dummy' -var='storage_account_name=dummy'", integration_dir, creds[:subscription_id], creds[:client_id], creds[:client_secret], creds[:tenant_id]) + cmd = format("cd %s/build/ && terraform destroy -force -var 'subscription_id=%s' -var 'client_id=%s' -var 'client_secret=%s' -var 'tenant_id=%s' -var 'admin_password=dummy' -var 'storage_account_name=dummy' -var 'suffix=dummy'", integration_dir, creds[:subscription_id], creds[:client_id], creds[:client_secret], creds[:tenant_id]) sh(cmd) end diff --git a/test/integration/azure/build/azure.tf b/test/integration/azure/build/azure.tf index d51794671..7613e90ea 100644 --- a/test/integration/azure/build/azure.tf +++ b/test/integration/azure/build/azure.tf @@ -7,6 +7,10 @@ variable "client_id" {} variable "client_secret" {} variable "tenant_id" {} +# Set a unique string which will be appended to public facing items +# to ensure there are no clashes +variable "suffix" {} + variable "location" { default = "West Europe" } @@ -47,7 +51,7 @@ resource "azurerm_public_ip" "public_ip_1" { location = "${var.location}" resource_group_name = "${azurerm_resource_group.rg.name}" public_ip_address_allocation = "dynamic" - domain_name_label = "linux-external-1" + domain_name_label = "linux-external-1-${var.suffix}" } # Create the virtual network for the machines diff --git a/test/integration/azure/verify/controls/vm.rb b/test/integration/azure/verify/controls/vm.rb index 13f660f91..99d3614d5 100644 --- a/test/integration/azure/verify/controls/vm.rb +++ b/test/integration/azure/verify/controls/vm.rb @@ -32,6 +32,6 @@ control 'azure-vm-external-1.0' do describe azure_virtual_machine(name: 'Linux-External-VM', resource_group: 'Inspec-Azure') do it { should have_public_ipaddress } - its('domain_name_label') { should eq 'linux-external-1' } + its('domain_name_label') { should include 'linux-external-1' } end end From 1a31425e816241ac88311be3ce996d768756a79c Mon Sep 17 00:00:00 2001 From: Steffanie Freeman Date: Thu, 26 Oct 2017 15:22:15 -0400 Subject: [PATCH 120/206] Issue #46 Lazily load attributes in aws_iam_users (#89) * Initial Commit Signed-off-by: sfreeman * aws_iam_user uses lazy loading Signed-off-by: sfreeman * Disassociates convert call from list_users Signed-off-by: sfreeman * A real-world working AwsIamUsers (#71) * Add aws_iam_users Signed-off-by: Chris Redekop * Adding Filter table and Collect User Details to aws_iam_users.rb Signed-off-by: Chris Redekop * Adding Filter table and Collect User Details to aws_iam_users.rb Signed-off-by: Chris Redekop * Adding Filter table and Collect User Details to aws_iam_users.rb Signed-off-by: Chris Redekop * Get an aws_iam_users integration test to pass Signed-off-by: Chris Redekop * Fix RuboCop issues and tests Signed-off-by: Chris Redekop * Improving code based on PR feedback Signed-off-by: Chris Redekop * AWS IAM Users unit tests work with new lazy loading feature Signed-off-by: sfreeman * Fixes tests Signed-off-by: aduric * Users should only hold the returned user references, transfering responsibility to each user to fetch any details Signed-off-by: aduric * Create user details provider class Signed-off-by: sfreeman * Using details provider factory to delegate creation of detail providers, and updates tests Signed-off-by: aduric * Rubocop fixes Signed-off-by: aduric * Rename user details provider factory to initializer, and remove unnecessary instance variables Signed-off-by: sfreeman --- libraries/aws_iam_user.rb | 14 +- libraries/aws_iam_user_details_provider.rb | 34 +++++ libraries/aws_iam_user_provider.rb | 37 +----- libraries/aws_iam_users.rb | 17 ++- .../aws_iam_user_details_provider_test.rb | 74 +++++++++++ .../resources/aws_iam_user_provider_test.rb | 121 ++++-------------- test/unit/resources/aws_iam_user_test.rb | 113 ++++++++-------- test/unit/resources/aws_iam_users_test.rb | 45 +++++-- 8 files changed, 245 insertions(+), 210 deletions(-) create mode 100644 libraries/aws_iam_user_details_provider.rb create mode 100644 test/unit/resources/aws_iam_user_details_provider_test.rb diff --git a/libraries/aws_iam_user.rb b/libraries/aws_iam_user.rb index 8f90ef1ba..f9efc0b18 100644 --- a/libraries/aws_iam_user.rb +++ b/libraries/aws_iam_user.rb @@ -15,29 +15,31 @@ class AwsIamUser < Inspec.resource(1) def initialize( opts, aws_user_provider = AwsIam::UserProvider.new, + aws_user_details_provider_ini = AwsIam::UserDetailsProviderInitializer.new, access_key_factory = AwsIamAccessKeyFactory.new ) - @user = opts[:user] - @user = aws_user_provider.user(opts[:name]) if @user.nil? + user = opts[:user] + user = aws_user_provider.user(opts[:name]) if user.nil? + @aws_user_details_provider = aws_user_details_provider_ini.create(user) @access_key_factory = access_key_factory end def has_mfa_enabled? - @user[:has_mfa_enabled?] + @aws_user_details_provider.has_mfa_enabled? end def has_console_password? - @user[:has_console_password?] + @aws_user_details_provider.has_console_password? end def access_keys - @user[:access_keys].map { |access_key| + @aws_user_details_provider.access_keys.map { |access_key| @access_key_factory.create_access_key(access_key) } end def name - @user[:name] + @aws_user_details_provider.name end def to_s diff --git a/libraries/aws_iam_user_details_provider.rb b/libraries/aws_iam_user_details_provider.rb new file mode 100644 index 000000000..3ddd844a3 --- /dev/null +++ b/libraries/aws_iam_user_details_provider.rb @@ -0,0 +1,34 @@ +# author: Adnan Duric +# author: Steffanie Freeman + +module AwsIam + class UserDetailsProvider + def initialize(user) + @aws_user = user + end + + def name + @aws_user.name + end + + def has_mfa_enabled? + !@aws_user.mfa_devices.first.nil? + end + + def has_console_password? + return !@aws_user.login_profile.create_date.nil? + rescue Aws::IAM::Errors::NoSuchEntity + return false + end + + def access_keys + @aws_user.access_keys + end + end + + class UserDetailsProviderInitializer + def create(user) + UserDetailsProvider.new(user) + end + end +end diff --git a/libraries/aws_iam_user_provider.rb b/libraries/aws_iam_user_provider.rb index 7e764d3dd..f70057d99 100644 --- a/libraries/aws_iam_user_provider.rb +++ b/libraries/aws_iam_user_provider.rb @@ -8,44 +8,11 @@ module AwsIam end def user(name) - aws_user = @iam_resource.user(name) - self.class.convert(aws_user) + @iam_resource.user(name) end def list_users - aws_users = @iam_resource.users - aws_users.map do |aws_user| - self.class.convert(aws_user) - end - end - - class << self - def name(aws_user) - aws_user.name - end - - def has_mfa_enabled?(aws_user) - !aws_user.mfa_devices.first.nil? - end - - def has_console_password?(aws_user) - return !aws_user.login_profile.create_date.nil? - rescue Aws::IAM::Errors::NoSuchEntity - return false - end - - def access_keys(aws_user) - aws_user.access_keys - end - - def convert(aws_user) - { - name: name(aws_user), - has_mfa_enabled?: has_mfa_enabled?(aws_user), - has_console_password?: has_console_password?(aws_user), - access_keys: access_keys(aws_user), - } - end + @iam_resource.users end end end diff --git a/libraries/aws_iam_users.rb b/libraries/aws_iam_users.rb index 9343438eb..03391e502 100644 --- a/libraries/aws_iam_users.rb +++ b/libraries/aws_iam_users.rb @@ -21,14 +21,27 @@ class AwsIamUsers < Inspec.resource(1) .add(:exists?) { |x| !x.entries.empty? } filter.connect(self, :collect_user_details) - def initialize(aws_user_provider = AwsIam::UserProvider.new, - user_factory = AwsIamUserFactory.new) + def initialize( + aws_user_provider = AwsIam::UserProvider.new, + aws_user_details_provider_ini = AwsIam::UserDetailsProviderInitializer.new, + user_factory = AwsIamUserFactory.new + ) @user_provider = aws_user_provider + @aws_user_details_provider_ini = aws_user_details_provider_ini @user_factory = user_factory end def collect_user_details @users_cache ||= @user_provider.list_users unless @user_provider.nil? + @users_cache.map do |aws_user| + details_provider = @aws_user_details_provider_ini.create(aws_user) + { + name: details_provider.name, + has_mfa_enabled?: details_provider.has_mfa_enabled?, + has_console_password?: details_provider.has_console_password?, + access_keys: details_provider.access_keys, + } + end end def users diff --git a/test/unit/resources/aws_iam_user_details_provider_test.rb b/test/unit/resources/aws_iam_user_details_provider_test.rb new file mode 100644 index 000000000..534cde26b --- /dev/null +++ b/test/unit/resources/aws_iam_user_details_provider_test.rb @@ -0,0 +1,74 @@ +# author: Adnan Duric +# author: Steffanie Freeman +require 'aws-sdk' +require 'helper' +require 'aws_iam_user_provider' +require 'aws_iam_user_details_provider' + +class AwsIamUserDetailsProviderTest < Minitest::Test + Username = 'test'.freeze + + def setup + @mock_iam_resource = Minitest::Mock.new + @mock_iam_resource_user = Minitest::Mock.new + end + + def test_has_mfa_enabled_returns_true + @mock_iam_resource_user.expect :mfa_devices, ['device'] + provider = AwsIam::UserDetailsProvider.new(@mock_iam_resource_user) + assert provider.has_mfa_enabled? + end + + def test_has_mfa_enabled_returns_false + @mock_iam_resource_user.expect :mfa_devices, [] + provider = AwsIam::UserDetailsProvider.new(@mock_iam_resource_user) + refute provider.has_mfa_enabled? + end + + def test_has_console_password_returns_true + mock_login_profile = Minitest::Mock.new + mock_login_profile.expect :create_date, 'date' + @mock_iam_resource_user.expect :login_profile, mock_login_profile + provider = AwsIam::UserDetailsProvider.new(@mock_iam_resource_user) + assert provider.has_console_password? + end + + def test_has_console_password_returns_false + mock_login_profile = Minitest::Mock.new + mock_login_profile.expect :create_date, nil + @mock_iam_resource_user.expect :login_profile, mock_login_profile + provider = AwsIam::UserDetailsProvider.new(@mock_iam_resource_user) + refute provider.has_console_password? + end + + def test_has_console_password_returns_false_when_nosuchentity + mock_login_profile = Minitest::Mock.new + mock_login_profile.expect :create_date, nil do || + raise Aws::IAM::Errors::NoSuchEntity.new(nil, nil) + end + @mock_iam_resource_user.expect :login_profile, mock_login_profile + provider = AwsIam::UserDetailsProvider.new(@mock_iam_resource_user) + refute provider.has_console_password? + end + + def test_has_console_password_throws + mock_login_profile = Minitest::Mock.new + mock_login_profile.expect :create_date, nil do || + raise ArgumentError + end + @mock_iam_resource_user.expect :login_profile, mock_login_profile + + assert_raises ArgumentError do + provider = AwsIam::UserDetailsProvider.new(@mock_iam_resource_user) + provider.has_console_password? + end + end + + def test_access_keys_returns_access_keys + access_key = Object.new + @mock_iam_resource_user.expect :access_keys, [access_key] + + provider = AwsIam::UserDetailsProvider.new(@mock_iam_resource_user) + assert_equal [access_key], provider.access_keys + end +end diff --git a/test/unit/resources/aws_iam_user_provider_test.rb b/test/unit/resources/aws_iam_user_provider_test.rb index 9cc8afb44..1b8aaf667 100644 --- a/test/unit/resources/aws_iam_user_provider_test.rb +++ b/test/unit/resources/aws_iam_user_provider_test.rb @@ -14,115 +14,40 @@ class AwsIamUserProviderTest < Minitest::Test @mock_aws_connection = Minitest::Mock.new @mock_aws_connection.expect :iam_resource, @mock_iam_resource @user_provider = AwsIam::UserProvider.new(@mock_aws_connection) + @mock_iam_resource_user = Minitest::Mock.new end def test_user - @mock_iam_resource.expect :user, create_mock_user, [Username] - assert !@user_provider.user(Username).nil? + @mock_iam_resource_user.expect :nil?, false + @mock_iam_resource_user.expect :name, Username + @mock_iam_resource.expect :user, @mock_iam_resource_user, [Username] + refute @user_provider.user(Username).nil? end def test_list_users - @mock_iam_resource.expect( - :users, - [create_mock_user, create_mock_user], + mock_users = + [ + @mock_iam_resource_user, + @mock_iam_resource_user, + @mock_iam_resource_user, + ] + mock_users.map do || + @mock_iam_resource_user.expect :name, Username + end + + @mock_iam_resource.expect :users, mock_users + assert_equal( + @user_provider.list_users, + [ + @mock_iam_resource_user, + @mock_iam_resource_user, + @mock_iam_resource_user, + ], ) - mock_user_output = { - name: Username, - has_mfa_enabled?: true, - has_console_password?: true, - access_keys: [], - } - assert @user_provider.list_users == [mock_user_output, mock_user_output] end def test_list_users_no_users @mock_iam_resource.expect :users, [] assert @user_provider.list_users == [] end - - def test_has_mfa_enabled_returns_true - @mock_iam_resource.expect(:user, create_mock_user(has_mfa_enabled: true), - [Username]) - assert @user_provider.user(Username)[:has_mfa_enabled?] - end - - def test_has_mfa_enabled_returns_false - @mock_iam_resource.expect(:user, create_mock_user(has_mfa_enabled: false), - [Username]) - assert !@user_provider.user(Username)[:has_mfa_enabled?] - end - - def test_has_console_password_returns_true - @mock_iam_resource.expect( - :user, - create_mock_user(has_console_password: true), - [Username], - ) - assert @user_provider.user(Username)[:has_console_password?] - end - - def test_has_console_password_returns_false - @mock_iam_resource.expect( - :user, - create_mock_user(has_console_password: false), - [Username], - ) - assert !@user_provider.user(Username)[:has_console_password?] - end - - def test_has_console_password_returns_false_when_nosuchentity - @mock_iam_resource.expect( - :user, - create_mock_user_throw(Aws::IAM::Errors::NoSuchEntity.new(nil, nil)), - [Username], - ) - assert !@user_provider.user(Username)[:has_console_password?] - end - - def test_has_console_password_throws - @mock_iam_resource.expect(:user, create_mock_user_throw(ArgumentError), - [Username]) - - assert_raises ArgumentError do - @user_provider.user(Username) - end - end - - def test_access_keys_returns_access_keys - access_key = Object.new - @mock_iam_resource.expect( - :user, - create_mock_user(access_keys: [access_key]), - [Username], - ) - - assert_equal [access_key], @user_provider.user(Username)[:access_keys] - end - - private - - def create_mock_user(has_console_password: true, has_mfa_enabled: true, - access_keys: []) - mock_login_profile = Minitest::Mock.new - mock_login_profile.expect :create_date, has_console_password ? 'date' : nil - - mock_user = Minitest::Mock.new - mock_user.expect :name, Username - mock_user.expect :mfa_devices, has_mfa_enabled ? ['device'] : [] - mock_user.expect :login_profile, mock_login_profile - mock_user.expect :access_keys, access_keys - end - - def create_mock_user_throw(exception) - mock_login_profile = Minitest::Mock.new - mock_login_profile.expect :create_date, nil do - raise exception - end - - mock_user = Minitest::Mock.new - mock_user.expect :name, Username - mock_user.expect :mfa_devices, [] - mock_user.expect :login_profile, mock_login_profile - mock_user.expect :access_keys, [] - end end diff --git a/test/unit/resources/aws_iam_user_test.rb b/test/unit/resources/aws_iam_user_test.rb index e9b1919a7..479e92438 100644 --- a/test/unit/resources/aws_iam_user_test.rb +++ b/test/unit/resources/aws_iam_user_test.rb @@ -8,62 +8,53 @@ class AwsIamUserTest < Minitest::Test def setup @mock_user_provider = Minitest::Mock.new + @mock_dets_provider = Minitest::Mock.new + @mock_dets_prov_ini = Minitest::Mock.new + @mock_user = { name: Username } end - def test_mfa_enabled_returns_true_if_mfa_enabled - @mock_user_provider.expect( - :user, - { has_mfa_enabled?: true }, - [Username], - ) - assert( - AwsIamUser.new( - { name: Username }, - @mock_user_provider, - ).has_mfa_enabled?, - ) + def test_that_mfa_enable_returns_true_if_mfa_enabled + @mock_user_provider.expect :user, @mock_user, [Username] + @mock_dets_provider.expect :has_mfa_enabled?, true + @mock_dets_prov_ini.expect :create, @mock_dets_provider, [@mock_user] + assert AwsIamUser.new( + @mock_user, + @mock_user_provider, + @mock_dets_prov_ini, + ).has_mfa_enabled? end - def test_mfa_enabled_returns_false_if_mfa_is_not_enabled - @mock_user_provider.expect( - :user, - { has_mfa_enabled?: false }, - [Username], - ) - refute( - AwsIamUser.new( - { name: Username }, - @mock_user_provider, - ).has_mfa_enabled?, - ) + def test_that_mfa_enable_returns_false_if_mfa_is_not_enabled + @mock_user_provider.expect :user, @mock_user, [Username] + @mock_dets_provider.expect :has_mfa_enabled?, false + @mock_dets_prov_ini.expect :create, @mock_dets_provider, [@mock_user] + refute AwsIamUser.new( + @mock_user, + @mock_user_provider, + @mock_dets_prov_ini, + ).has_mfa_enabled? end - def test_console_password_returns_true_if_console_password_has_been_set - @mock_user_provider.expect( - :user, - { has_console_password?: true }, - [Username], - ) - assert( - AwsIamUser.new( - { name: Username }, - @mock_user_provider, - ).has_console_password?, - ) + def test_that_console_password_returns_true_if_console_password_set + @mock_user_provider.expect :user, @mock_user, [Username] + @mock_dets_provider.expect :has_console_password?, true + @mock_dets_prov_ini.expect :create, @mock_dets_provider, [@mock_user] + assert AwsIamUser.new( + @mock_user, + @mock_user_provider, + @mock_dets_prov_ini, + ).has_console_password? end - def test_console_password_returns_false_if_console_password_has_not_been_set - @mock_user_provider.expect( - :user, - { has_console_password?: false }, - [Username], - ) - refute( - AwsIamUser.new( - { name: Username }, - @mock_user_provider, - ).has_console_password?, - ) + def test_that_console_password_returns_false_if_console_password_not_set + @mock_user_provider.expect :user, @mock_user, [Username] + @mock_dets_provider.expect :has_console_password?, false + @mock_dets_prov_ini.expect :create, @mock_dets_provider, [@mock_user] + refute AwsIamUser.new( + @mock_user, + @mock_user_provider, + @mock_dets_prov_ini, + ).has_console_password? end def test_that_access_keys_returns_aws_iam_access_key_resources @@ -71,11 +62,9 @@ class AwsIamUserTest < Minitest::Test stub_access_key_resource = Object.new mock_access_key_factory = Minitest::Mock.new - @mock_user_provider.expect( - :user, - { access_keys: [stub_aws_access_key] }, - [Username], - ) + @mock_user_provider.expect :user, @mock_user, [Username] + @mock_dets_provider.expect :access_keys, [stub_aws_access_key] + @mock_dets_prov_ini.expect :create, @mock_dets_provider, [@mock_user] mock_access_key_factory.expect( :create_access_key, stub_access_key_resource, @@ -85,8 +74,9 @@ class AwsIamUserTest < Minitest::Test assert_equal( stub_access_key_resource, AwsIamUser.new( - { name: Username }, + @mock_user, @mock_user_provider, + @mock_dets_prov_ini, mock_access_key_factory, ).access_keys[0], ) @@ -95,13 +85,16 @@ class AwsIamUserTest < Minitest::Test end def test_to_s - @mock_user_provider.expect( - :user, - { name: Username, has_mfa_enabled?: true }, - [Username], - ) + test_user = { name: Username, has_mfa_enabled?: true } + @mock_user_provider.expect :user, test_user, [Username] + @mock_dets_provider.expect :name, Username + @mock_dets_prov_ini.expect :create, @mock_dets_provider, [test_user] expected = "IAM User #{Username}" - test = AwsIamUser.new({ name: Username }, @mock_user_provider).to_s + test = AwsIamUser.new( + { name: Username }, + @mock_user_provider, + @mock_dets_prov_ini, + ).to_s assert_equal expected, test end end diff --git a/test/unit/resources/aws_iam_users_test.rb b/test/unit/resources/aws_iam_users_test.rb index 903b43b19..2fe29baa9 100644 --- a/test/unit/resources/aws_iam_users_test.rb +++ b/test/unit/resources/aws_iam_users_test.rb @@ -9,16 +9,21 @@ require 'aws_iam_users' class AwsIamUsersTest < Minitest::Test def setup @mock_user_factory = Minitest::Mock.new + @mock_user_details_provider_initializer = Minitest::Mock.new end def test_users_nil_user_provider_returns_empty_list - cut = AwsIamUsers.new(nil, @mock_user_factory) + cut = AwsIamUsers.new(nil, nil, @mock_user_factory) assert_equal(cut.users, []) end def test_users_empty_list_user_provider_returns_empty_list - cut = AwsIamUsers.new(create_mock_user_provider, @mock_user_factory) + cut = AwsIamUsers.new( + create_mock_user_provider, + create_mock_user_details_provider_ini, + @mock_user_factory, + ) assert_equal(cut.users, []) end @@ -26,6 +31,7 @@ class AwsIamUsersTest < Minitest::Test def test_users_returns_true_for_all_users_if_mfa_enabled cut = AwsIamUsers.new( create_mock_user_provider(create_mock_users([true, true])), + create_mock_user_details_provider_ini, @mock_user_factory, ) @@ -47,12 +53,7 @@ class AwsIamUsersTest < Minitest::Test } ].each do |test_material| define_method(test_material[:name]) do - cut = AwsIamUsers.new( - create_mock_user_provider( - create_mock_users(test_material[:user_material]), - ), - @mock_user_factory, - ) + cut = create_cut(test_material) results = cut.where(has_mfa_enabled?: true) expected_count = test_material[:user_material].count { |x| x } @@ -62,12 +63,38 @@ class AwsIamUsersTest < Minitest::Test end end + def create_cut(user_list = []) + mock_user_details_provider_ini = create_mock_user_details_provider_ini( + user_list[:user_material], + ) + + AwsIamUsers.new( + create_mock_user_provider( + user_list[:user_material], + ), + mock_user_details_provider_ini, + @mock_user_factory, + ) + end + + def create_mock_user_details_provider_ini(attr_value_list = []) + mock_dets_provider_ini = Minitest::Mock.new + attr_value_list.each do |attr_val| + mock_dets_provider = Minitest::Mock.new + mock_dets_provider.expect :name, nil + mock_dets_provider.expect :has_mfa_enabled?, attr_val + mock_dets_provider.expect :has_console_password?, nil + mock_dets_provider.expect :access_keys, [] + mock_dets_provider_ini.expect :create, mock_dets_provider, [Object] + end + mock_dets_provider_ini + end + def create_mock_user_provider(user_list = []) mock_user_provider = Minitest::Mock.new mock_user_provider.expect :list_users, user_list mock_user_provider.expect :nil?, false - mock_user_provider end From c8d4244ef4ab54923076d753cc484963505b89de Mon Sep 17 00:00:00 2001 From: Chris Redekop Date: Thu, 26 Oct 2017 15:56:32 -0400 Subject: [PATCH 121/206] Add has_roles to aws_ec2_instance (#90) * Rename EC2-instance resources Signed-off-by: Chris Redekop * Add interim updates Signed-off-by: Chris Redekop * testing for issue 82 Signed-off-by: Simon Varlow * completed integration for EC2 roles Signed-off-by: Simon Varlow * adding in the beginning of the unit test for issue 82 Signed-off-by: Simon Varlow * Fix unit tests Signed-off-by: Chris Redekop * Add has_roles? examples Signed-off-by: Chris Redekop * Remove redundant gsub Signed-off-by: Chris Redekop * corrected OpenStruct format Signed-off-by: Simon Varlow * setting up variable for InstanceProfile Signed-off-by: Simon Varlow * Updated the unit test so all variables are at the top Signed-off-by: Simon Varlow * Fixed Rubocop issues that were detected Signed-off-by: Simon Varlow * Updating README.md to include changes to aws_ec2 Signed-off-by: Simon Varlow * Add failing IT for has_roles? Signed-off-by: Chris Redekop * Add negative IT and fix uncovered issue Signed-off-by: Chris Redekop * Fix Rubocop issue Signed-off-by: Chris Redekop * Fix integration test Signed-off-by: Chris Redekop * Fix Rubocop issues and unit tests Signed-off-by: Chris Redekop * Pin AWS dependency to '~> 2' Signed-off-by: Chris Redekop --- Gemfile | 2 +- README.md | 4 +- libraries/aws_ec2_instance.rb | 18 ++++- test/integration/build/aws.tf | 42 +++++++++++- .../verify/controls/aws_ec2_instance.rb | 21 +++--- test/unit/resources/aws_ec2_instance_test.rb | 68 +++++++++++++++++-- 6 files changed, 135 insertions(+), 20 deletions(-) diff --git a/Gemfile b/Gemfile index a1aea8afe..48bdf8619 100644 --- a/Gemfile +++ b/Gemfile @@ -4,7 +4,7 @@ gem 'rake' gem 'inspec', '~> 1' gem 'rubocop', '~> 0.44.0' gem 'highline', '~> 1.6.0' -gem 'aws-sdk' +gem 'aws-sdk', '~> 2' gem 'nokogiri' gem 'minitest', '5.10.1' diff --git a/README.md b/README.md index b2b4d73a5..cacfaf4e1 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ control "aws-1" do impact 0.7 title 'Checks the machine is running' - describe aws_ec2('i-my-ec2-instance-id') do + describe aws_ec2_instance('my-ec2-machine') do it { should be_running } end end @@ -61,7 +61,7 @@ end ### Available Resources - * `aws_ec2` - This resource reads information about an ec2 instance + * `aws_ec2_instance` - This resource reads information about an ec2 instance * `aws_iam_access_key` - Verifies settings for AWS IAM access keys * `aws_iam_password_policy` - Verifies iam password policy * `aws_iam_root_user` - Verifies settings for AWS root account diff --git a/libraries/aws_ec2_instance.rb b/libraries/aws_ec2_instance.rb index b1eba6588..f2a048308 100644 --- a/libraries/aws_ec2_instance.rb +++ b/libraries/aws_ec2_instance.rb @@ -1,5 +1,4 @@ # author: Christoph Hartmann - class AwsEc2Instance < Inspec.resource(1) name 'aws_ec2_instance' desc 'Verifies settings for an EC2 instance' @@ -7,10 +6,12 @@ class AwsEc2Instance < Inspec.resource(1) example " describe aws_ec2_instance('i-123456') do it { should be_running } + it { should have_roles } end describe aws_ec2_instance(name: 'my-instance') do it { should be_running } + it { should have_roles } end " @@ -19,6 +20,7 @@ class AwsEc2Instance < Inspec.resource(1) @opts.is_a?(Hash) ? @display_name = @opts[:name] : @display_name = opts @ec2_client = conn.ec2_client @ec2_resource = conn.ec2_resource + @iam_resource = conn.iam_resource end def id @@ -86,6 +88,20 @@ class AwsEc2Instance < Inspec.resource(1) "EC2 Instance #{@display_name}" end + def has_roles? + instance_profile = instance.iam_instance_profile + + if instance_profile + roles = @iam_resource.instance_profile( + instance_profile.arn.gsub(%r{^.*\/}, ''), + ).roles + else + roles = nil + end + + roles && !roles.empty? + end + private def instance diff --git a/test/integration/build/aws.tf b/test/integration/build/aws.tf index b2be37a59..e0d462942 100644 --- a/test/integration/build/aws.tf +++ b/test/integration/build/aws.tf @@ -4,12 +4,48 @@ terraform { provider "aws" {} +resource "aws_iam_role" "example" { + name = "${terraform.env}.example" + + assume_role_policy = < Date: Fri, 27 Oct 2017 10:49:58 +0100 Subject: [PATCH 122/206] Pinning version of Azure SDKs to avoid beaking change issue (#55) * Pinning version of Azure SDKs to avoid beaking change issue Fixes #54 Signed-off-by: Russell Seymour * Removed pessimistic version for the SDK Signed-off-by: Russell Seymour --- Gemfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile b/Gemfile index 8af46395f..f2afcefbd 100644 --- a/Gemfile +++ b/Gemfile @@ -1,9 +1,9 @@ source 'https://rubygems.org' gem 'inifile' -gem 'azure_mgmt_compute', '~> 0.11' -gem 'azure_mgmt_resources', '~> 0.11' -gem 'azure_mgmt_network', '~> 0.11' +gem 'azure_mgmt_compute', '= 0.14.0' +gem 'azure_mgmt_resources', '= 0.14.0' +gem 'azure_mgmt_network', '= 0.14.0' gem 'bundle' group :development do From 7b91c5821007df6626f3aad0c7a2fdadfc8e3f74 Mon Sep 17 00:00:00 2001 From: Jerry Aldrich III Date: Fri, 27 Oct 2017 10:06:49 -0500 Subject: [PATCH 123/206] Modify `aws_iam_user` example to use RSpec matcher Signed-off-by: Jerry Aldrich --- libraries/aws_iam_user.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/aws_iam_user.rb b/libraries/aws_iam_user.rb index f9efc0b18..2447c1170 100644 --- a/libraries/aws_iam_user.rb +++ b/libraries/aws_iam_user.rb @@ -8,8 +8,8 @@ class AwsIamUser < Inspec.resource(1) desc 'Verifies settings for AWS IAM user' example " describe aws_iam_user(name: 'test_user_name') do - its('has_mfa_enabled?') { should be false } - its('has_console_password?') { should be true } + it { should have_mfa_enabled } + it { should have_console_password } end " def initialize( From 82dc6f3ec7c5e2721185ccef978ca961ed41d6a3 Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Fri, 27 Oct 2017 16:31:36 -0400 Subject: [PATCH 124/206] Documentation for existing resources * Update docs in source to use matcher-style calls, not properties-as-predicates Signed-off-by: Clinton Wolfe * Main doc file for aws_iam_user Signed-off-by: Clinton Wolfe * Add documentation for existing resources This adds documentation for the following resources, including custom matchers: - aws_ec2_instance - aws_iam_access_key - aws_iam_password_policy - aws_iam_root_user - aws_iam_users Signed-off-by: Jerry Aldrich * Fix `aws_iam_users` example (Console + No MFA) (#104) Signed-off-by: Jerry Aldrich * Correct copypasta Signed-off-by: Clinton Wolfe * Remove misleading singular matcher information from the plural docs for aws_iam_users Signed-off-by: Clinton Wolfe * Correct `aws-iam-userss` typo (#105) Signed-off-by: Jerry Aldrich * Add EC2 instance state info Signed-off-by: Clinton Wolfe * test commit Signed-off-by: kgarmoe * copy edits Signed-off-by: kgarmoe * Yikes, forgot to save after correcting a merge conflict Signed-off-by: Clinton Wolfe --- docs/resources/aws_ec2_instance.md | 99 +++++++++++++++++++++++ docs/resources/aws_iam_access_key.md | 56 +++++++++++++ docs/resources/aws_iam_password_policy.md | 69 ++++++++++++++++ docs/resources/aws_iam_root_user.md | 51 ++++++++++++ docs/resources/aws_iam_user.md | 63 +++++++++++++++ docs/resources/aws_iam_users.md | 55 +++++++++++++ libraries/aws_iam_user.rb | 4 +- 7 files changed, 395 insertions(+), 2 deletions(-) create mode 100644 docs/resources/aws_ec2_instance.md create mode 100644 docs/resources/aws_iam_access_key.md create mode 100644 docs/resources/aws_iam_password_policy.md create mode 100644 docs/resources/aws_iam_root_user.md create mode 100644 docs/resources/aws_iam_user.md create mode 100644 docs/resources/aws_iam_users.md diff --git a/docs/resources/aws_ec2_instance.md b/docs/resources/aws_ec2_instance.md new file mode 100644 index 000000000..8ef74e629 --- /dev/null +++ b/docs/resources/aws_ec2_instance.md @@ -0,0 +1,99 @@ +--- +title: About the aws_ec2_instance Resource +--- + +# aws_ec2_instance + +Use the `aws_ec2_instance` InSpec audit resource to test properties of a single AWS EC2 instance. + +
+ +## Syntax + +An `aws_ec2_instance` resource block declares the tests for a single AWS EC2 instance by either name or id. + + describe aws_ec2_instance('i-01a2349e94458a507') do + it { should exist } + end + + describe aws_ec2_instance(name: 'my-instance') do + it { should be_running } + end + +
+ +## Examples + +The following examples show how to use this InSpec audit resource. + +### Test that an EC2 instance does not exist + + describe aws_ec2_instance(name: 'dev-server') do + it { should_not exist } + end + +### Test that an EC2 instance is running + + describe aws_ec2_instance(name: 'prod-database') do + it { should be_running } + end + +### Test that an EC2 instance is using the correct image ID + + describe aws_iam_instance(name: 'my-instance') do + its('image_id') { should eq 'ami-27a58d5c' } + end + +### Test that an EC2 instance has the correct tag + + describe aws_ec2_instance('i-090c29e4f4c165b74') do + its('tags') { should include(key: 'Contact', value: 'Gilfoyle') } + end + +
+ +## Matchers + +This InSpec audit resource has the following special matchers. For a full list of available matchers (such as `exist`) please visit our [matchers page](https://www.inspec.io/docs/reference/matchers/). + +### be_pending + +The `be_pending` matcher tests if the described EC2 instance state is `pending`. This indicates that an instance is provisioning. This state should be temporary. + + it { should be_pending } + +### be_running + +The `be_running` matcher tests if the described EC2 instance state is `running`. This indicates the instance is fully operational from AWS's perspective. + + it { should be_running } + +### be_shutting_down + +The `be_shutting_down` matcher tests if the described EC2 instance state is `shutting-down`. This indicates the instance has received a termination command and is in the process of being permanently halted and de-provisioned. This state should be temporary. + + it { should be_shutting_down } + +### be_stopped + +The `be_stopped` matcher tests if the described EC2 instance state is `stopped`. This indicates that the instance is suspended and may be started again. + + it { should be_stopped } + +### be_stopping + +The `be_stopping` matcher tests if the described EC2 instance state is `stopping`. This indicates that an AWS stop command has been issued, which will suspend the instance in an OS-unaware manner. This state should be temporary. + + it { should be_stopping } + +### be_terminated + +The `be_terminated` matcher tests if the described EC2 instance state is `terminated`. This indicates the instance is permanently halted and will be removed from the instance listing in a short period. This state should be temporary. + + it { should be_terminated } + +### be_unknown + +The `be_unknown` matcher tests if the described EC2 instance state is `unknown`. This indicates an error condition in the AWS management system. This state should be temporary. + + it { should be_unknown } diff --git a/docs/resources/aws_iam_access_key.md b/docs/resources/aws_iam_access_key.md new file mode 100644 index 000000000..dfb784c13 --- /dev/null +++ b/docs/resources/aws_iam_access_key.md @@ -0,0 +1,56 @@ +--- +title: About the aws_iam_access_key Resource +--- + +# aws_iam_access_key + +Use the `aws_iam_access_key` InSpec audit resource to test properties of a single AWS IAM access key. + +
+ +## Syntax + +An `aws_iam_access_key` resource block declares the tests for a single AWS IAM access key by username and id. + + describe aws_iam_access_key(username: 'username', id: 'access-key-id') do + it { should exist } + it { should_not be_active } + its('create_date') { should be > Time.now - 365 * 86400 } + its('last_used_date') { should be > Time.now - 90 * 86400 } + end + +
+ +## Examples + +The following examples show how to use this InSpec audit resource. + +### Test that an IAM access key is not active + + describe aws_iam_access_key(username: 'username', id: 'access-key-id') do + it { should_not be_active } + end + +### Test that an IAM access key is older than one year + + describe aws_iam_access_key(username: 'username', id: 'access-key-id') do + its('create_date') { should be > Time.now - 365 * 86400 } + end + +### Test that an IAM access key has been used in the past 90 days + + describe aws_iam_access_key(username: 'username', id: 'access-key-id') do + its('last_used_date') { should be > Time.now - 90 * 86400 } + end + +
+ +## Matchers + +This InSpec audit resource has the following special matchers. For a full list of available matchers (such as `exist`) please visit our [matchers page](https://www.inspec.io/docs/reference/matchers/). + +### be_active + +The `be_active` matcher tests if the described IAM access key is active. + + it { should be_active } diff --git a/docs/resources/aws_iam_password_policy.md b/docs/resources/aws_iam_password_policy.md new file mode 100644 index 000000000..57050ea25 --- /dev/null +++ b/docs/resources/aws_iam_password_policy.md @@ -0,0 +1,69 @@ +--- +title: About the aws_iam_password_policy Resource +--- + +# aws_iam_password_policy + +Use the `aws_iam_password_policy` InSpec audit resource to test properties of the AWS IAM Password Policy. + +
+ +## Syntax + +An `aws_iam_password_policy` resource block takes no parameters, but uses several matchers. + + describe aws_iam_password_policy do + its('requires_lowercase_characters?') { should be true } + end + +
+ +## Examples + +The following examples show how to use this InSpec audit resource. + +### Test that the IAM Password Policy requires lowercase characters, uppercase characters, numbers, symbols, and a minimum length greater than eight + + describe aws_iam_password_policy do + its('requires_lowercase_characters?') { should be true } + its('requires_uppercase_characters?') { should be true } + its('requires_numbers?') { should be true } + its('requires_symbols?') { should be true } + its('minimum_password_length') { should be > 8 } + end + +### Test that the IAM Password Policy allows users to change their password + + describe aws_iam_password_policy do + its('allows_user_to_change_password?') { should be true } + end + +### Test that the IAM Password Policy expires passwords + + describe aws_iam_password_policy do + its('expires_passwords?') { should be true } + end + +### Test that the IAM Password Policy has a max password age + + describe aws_iam_password_policy do + its('max_password_age') { should be > 90 * 86400 } + end + +### Test that the IAM Password Policy prevents password reuse + + describe aws_iam_password_policy do + its('prevents_password_reuse?') { should be true } + end + +### Test that the IAM Password Policy requires users to remember 3 previous passwords + + describe aws_iam_password_policy do + its('number_of_passwords_to_remember') { should eq 3 } + end + +
+ +## Matchers + +For a full list of available matchers (such as `exist`) please visit our [matchers page](https://www.inspec.io/docs/reference/matchers/). diff --git a/docs/resources/aws_iam_root_user.md b/docs/resources/aws_iam_root_user.md new file mode 100644 index 000000000..89a8fdf6d --- /dev/null +++ b/docs/resources/aws_iam_root_user.md @@ -0,0 +1,51 @@ +--- +title: About the aws_iam_root_user Resource +--- + +# aws_iam_root_user + +Use the `aws_iam_root_user` InSpec audit resource to test properties of the root user (owner of the account). + +To test properties of all or multiple users, use the `aws_iam_users` resource. + +To test properties of a specific AWS user use the `aws_iam_user` resource. + +
+ +## Syntax + +An `aws_iam_root_user` resource block requires no parameters but has several matchers + + describe aws_iam_root_user do + its { should have_mfa_enabled } + end + +
+ +## Examples + +The following examples show how to use this InSpec audit resource. + +### Test that the AWS root account has only one access key + + describe aws_iam_root_user do + its('access_key_count') { should eq 1 } + end + +### Test that the AWS root account has Multi-Factor Authentication enabled + + describe aws_iam_root_user do + it { should have_mfa_enabled } + end + +
+ +## Matchers + +This InSpec audit resource has the following special matchers. For a full list of available matchers (such as `exist`) please visit our [matchers page](https://www.inspec.io/docs/reference/matchers/). + +### have_mfa_enabled + +The `have_mfa_enabled` matcher tests if the AWS root user has Multi-Factor Authentication enabled, requiring them to enter a secondary code when they login to the web console. + + it { should have_mfa_enabled } diff --git a/docs/resources/aws_iam_user.md b/docs/resources/aws_iam_user.md new file mode 100644 index 000000000..16319b666 --- /dev/null +++ b/docs/resources/aws_iam_user.md @@ -0,0 +1,63 @@ +--- +title: About the aws_iam_user Resource +--- + +# aws_iam_user + +Use the `aws_iam_user` InSpec audit resource to test properties of a single AWS IAM user. + +To test properties of all or multiple users, use the `aws_iam_users` resource. + +To test properties of the special AWS root user (which owns the account), use the `aws_iam_root_user` resource. + +
+ +## Syntax + +An `aws_iam_user` resource block declares a user by name, and then lists tests to be performed. + + describe aws_iam_user(name: 'test_user') do + it { should exist } + end + +
+ +## Examples + +The following examples show how to use this InSpec audit resource. + +### Test that a user does not exist + + describe aws_iam_user(name: 'gone') do + it { should_not exist } + end + +### Test that a user has multi-factor authentication enabled + + describe aws_iam_user(name: 'test_user') do + it { should have_mfa_enabled } + end + +### Test that a service user does not have a password + + describe aws_iam_user(name: 'test_user') do + it { should have_console_password } + end + +
+ +## Matchers + +This InSpec audit resource has the following special matchers. For a full list of available matchers (such as `exist`) please visit our [matchers page](https://www.inspec.io/docs/reference/matchers/). + +### have_console_password + +The `have_console_password` matcher tests if the user has a password that could be used to log into the AWS web console. + + it { should have_console_password } + +### have_mfa_enabled + +The `have_mfa_enabled` matcher tests if the user has Multi-Factor Authentication enabled, requiring them to enter a secondary code when they login to the web console. + + it { should have_mfa_enabled } diff --git a/docs/resources/aws_iam_users.md b/docs/resources/aws_iam_users.md new file mode 100644 index 000000000..128884268 --- /dev/null +++ b/docs/resources/aws_iam_users.md @@ -0,0 +1,55 @@ +--- +title: About the aws_iam_users Resource +--- + +# aws_iam_users + +Use the `aws_iam_users` InSpec audit resource to test properties of a all or multiple users. + +To test properties of a single user, use the `aws_iam_user` resource. + +To test properties of the special AWS root user (which owns the account), use the `aws_iam_root_user` resource. + +
+ +## Syntax + +An `aws_iam_users` resource block users a filter to select a group of users and then tests that group + + describe aws_iam_users.where(has_mfa_enabled?: false) do + it { should_not exist } + end + +
+ +## Examples + +The following examples show how to use this InSpec audit resource. + +### Test that all users have Multi-Factor Authentication enabled + + describe aws_iam_users.where(has_mfa_enabled?: false) do + it { should_not exist } + end + +### Test that at least one user has a console password to log into the AWS web console + + describe aws_iam_users.where(has_console_password?: true) do + it { should exist } + end + +### Test that all users that have a console password have Multi-Factor Authentication enabled + + console_users_without_mfa = aws_iam_users + .where(has_console_password?: true) + .where(has_mfa_enabled?: false) + + describe console_users_without_mfa do + it { should_not exist } + end + +
+ +## Matchers + +This InSpec audit resource has no specific matchers. \ No newline at end of file diff --git a/libraries/aws_iam_user.rb b/libraries/aws_iam_user.rb index 2447c1170..7a4bb4f11 100644 --- a/libraries/aws_iam_user.rb +++ b/libraries/aws_iam_user.rb @@ -7,9 +7,9 @@ class AwsIamUser < Inspec.resource(1) name 'aws_iam_user' desc 'Verifies settings for AWS IAM user' example " - describe aws_iam_user(name: 'test_user_name') do + describe aws_iam_user(name: 'test_user') do it { should have_mfa_enabled } - it { should have_console_password } + it { should_not have_console_password } end " def initialize( From 58b5eed035b052cce7b13d96de5edb2dac3078b9 Mon Sep 17 00:00:00 2001 From: Chris Redekop Date: Mon, 30 Oct 2017 13:39:38 -0400 Subject: [PATCH 125/206] Add AWS_SESSION_TOKEN (#107) * Add AWS_SESSION_TOKEN Signed-off-by: Chris Redekop * Update README to include session-token env var Signed-off-by: Chris Redekop --- README.md | 4 +++- libraries/aws_conn.rb | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cacfaf4e1..7f4668d82 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,13 @@ As of now, AWS resources are implemented as an InSpec resource pack. It will shi ## Get started -To run the profile, use InSpec with an environment variable for AWS credentials: +Before running the profile with InSpec, define environment variables with your AWS region and credentials. InSpec supports the following variables: - `AWS_REGION` +- `AWS_DEFAULT_REGION` - `AWS_ACCESS_KEY_ID` - `AWS_SECRET_ACCESS_KEY` +- `AWS_SESSION_TOKEN` Those variables are defined in [AWS CLI Docs](http://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html#cli-environment) diff --git a/libraries/aws_conn.rb b/libraries/aws_conn.rb index 9e6d74466..8fbd9a026 100644 --- a/libraries/aws_conn.rb +++ b/libraries/aws_conn.rb @@ -8,6 +8,7 @@ class AWSConnection credentials: Aws::Credentials.new( ENV['AWS_ACCESS_KEY_ID'], ENV['AWS_SECRET_ACCESS_KEY'], + ENV['AWS_SESSION_TOKEN'], ), } Aws.config.update(opts) From c99f7e318f3929241dd3e1a0fe69de864e3e3e76 Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Wed, 8 Nov 2017 13:58:24 -0500 Subject: [PATCH 126/206] Copied .rubocop.yml from core inspec (#118) Signed-off-by: Clinton Wolfe --- .rubocop.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index a7fe5c9f2..a13aeb3d7 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -3,7 +3,7 @@ AllCops: Exclude: - Gemfile - Rakefile - - 'test/integration/**/*' + - 'test/**/*' - 'examples/**/*' - 'vendor/**/*' - 'lib/bundles/inspec-init/templates/**/*' @@ -16,7 +16,7 @@ Encoding: HashSyntax: Enabled: true LineLength: - Enabled: true + Enabled: false EmptyLinesAroundBlockBody: Enabled: false MethodLength: From ab2170f717db51ed192ba735efcc01163a06d355 Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Thu, 16 Nov 2017 13:44:43 -0500 Subject: [PATCH 127/206] Add aws_sns_topic resource (#120) * Docs first draft, integration tests, and constructor unit tests for SNS topic Signed-off-by: Clinton Wolfe * Skeleton of SNS topic Signed-off-by: Clinton Wolfe * Constructor arg validation works Signed-off-by: Clinton Wolfe * Passing unit tests for recall Signed-off-by: Clinton Wolfe * Subscription Count property, works Signed-off-by: Clinton Wolfe * Subscription, not subscriber Signed-off-by: Clinton Wolfe * Integration tests pass; also wildard ARNs are not allowed Signed-off-by: Clinton Wolfe * Rubocop changes Signed-off-by: Clinton Wolfe * Doc updates per kagarmoe Signed-off-by: Clinton Wolfe --- docs/resources/aws_sns_topic.md | 58 ++++++++ libraries/aws_conn.rb | 4 + libraries/aws_sns_topic.rb | 100 ++++++++++++++ test/integration/build/aws.tf | 35 +++++ .../verify/controls/aws_sns_topic.rb | 34 +++++ test/unit/helper.rb | 3 + test/unit/resources/aws_sns_topic_test.rb | 126 ++++++++++++++++++ 7 files changed, 360 insertions(+) create mode 100644 docs/resources/aws_sns_topic.md create mode 100644 libraries/aws_sns_topic.rb create mode 100644 test/integration/verify/controls/aws_sns_topic.rb create mode 100644 test/unit/resources/aws_sns_topic_test.rb diff --git a/docs/resources/aws_sns_topic.md b/docs/resources/aws_sns_topic.md new file mode 100644 index 000000000..11960560c --- /dev/null +++ b/docs/resources/aws_sns_topic.md @@ -0,0 +1,58 @@ +--- +title: About the aws_sns_topic Resource +--- + +# aws_sns_topic + +Use the `aws_sns_topic` InSpec audit resource to test properties of a single AWS Simple Notification Service Topic. SNS topics are channels for related events. AWS resources will place events in the SNS topic, while other AWS resources will _subscribe_ to receive notifications when new events have appeared. + +
+ +## Syntax + + # Ensure that a topic exists and has at least one subscription + describe aws_sns_topic('arn:aws:sns:*::my-topic-name') do + it { should exist } + its('confirmed_subscription_count') { should_not be_zero } + end + + # You may also use has syntax to pass the ARN + describe aws_sns_topic(arn: 'arn:aws:sns:*::my-topic-name') do + it { should exist } + end + + +## Resource Parameters + +### ARN + +This resource expects a single parameter that uniquely identifes the SNS Topic, an ARN. Amazon Resource Names for SNS topics have the format `arn:aws:sns:region:account-id:topicname`. AWS requires a fully-specified ARN for looking up an SNS topic. The account ID and region are required. Wildcards are not permitted. + +See also the (AWS documentation on ARNs)[http://docs.aws.amazon.com/general/latest/gr/aws-arns-and-namespaces.html]. + +## Matchers + +### exist + +Indicates that the ARN provided was found. Use should_not to test for SNS topics that should not exist. + + # Expect good news + describe aws_sns_topic('arn:aws:sns:*::good-news') do + it { should exist } + end + + # No bad news allowed + describe aws_sns_topic('arn:aws:sns:*::bad-news') do + it { should_not exist } + end + +## Properties + +### confirmed_subscription_count + +An integer indicating the number of currently active subscriptions. + + # Make sure someone is listening + describe aws_sns_topic('arn:aws:sns:*::my-topic-name') do + its('confirmed_subscription_count') { should_not be_zero} + end diff --git a/libraries/aws_conn.rb b/libraries/aws_conn.rb index 8fbd9a026..07a1a9af1 100644 --- a/libraries/aws_conn.rb +++ b/libraries/aws_conn.rb @@ -14,6 +14,10 @@ class AWSConnection Aws.config.update(opts) end + def sns_client + @sns_client ||= Aws::SNS::Client.new + end + def ec2_resource @ec2_resource ||= Aws::EC2::Resource.new end diff --git a/libraries/aws_sns_topic.rb b/libraries/aws_sns_topic.rb new file mode 100644 index 000000000..89b12a24f --- /dev/null +++ b/libraries/aws_sns_topic.rb @@ -0,0 +1,100 @@ +require 'aws_conn' + +class AwsSnsTopic < Inspec.resource(1) + name 'aws_sns_topic' + desc 'Verifies settings for an SNS Topic' + example " + describe aws_sns_topic('arn:aws:sns:us-east-1:123456789012:some-topic') do + it { should exist } + its('confirmed_subscription_count') { should_not be_zero } + end + " + + attr_reader :arn, :confirmed_subscription_count + + def initialize(raw_params) + validated_params = validate_params(raw_params) + @arn = validated_params[:arn] + search + end + + def exists? + @exists + end + + private + + def validate_params(raw_params) + # Allow passing ARN as a scalar string, not in a hash + raw_params = { arn: raw_params } if raw_params.is_a?(String) + + # Remove all expected params from the raw param hash + validated_params = {} + [ + :arn, + ].each do |expected_param| + validated_params[expected_param] = raw_params.delete(expected_param) if raw_params.key?(expected_param) + end + + # Any leftovers are unwelcome + unless raw_params.empty? + raise ArgumentError, "Unrecognized resource param '#{raw_params.keys.first}'" + end + + # Validate the ARN + unless validated_params[:arn] =~ /^arn:aws:sns:[\w\-]+:\d{12}:[\S]+$/ + raise ArgumentError, 'Malformed ARN for SNS topics. Expected an ARN of the form ' \ + "'arn:aws:sns:REGION:ACCOUNT-ID:TOPIC-NAME'" + end + + validated_params + end + + def search + aws_response = AwsSnsTopic::Backend.create.get_topic_attributes(topic_arn: @arn).attributes + @exists = true + + # The response has a plain hash with CamelCase plain string keys and string values + @confirmed_subscription_count = aws_response['SubscriptionsConfirmed'].to_i + rescue Aws::SNS::Errors::NotFound + @exists = false + end + + class Backend + #=====================================================# + # API Definition + #=====================================================# + [ + :get_topic_attributes, + ].each do |method| + define_method(:method) do |*_args| + raise "Unimplemented abstract method #{method} - internal error" + end + end + + #=====================================================# + # Concrete Implementation + #=====================================================# + # Uses the SDK API to really talk to AWS + class AwsClientApi < Backend + def get_topic_attributes(criteria) + AWSConnection.new.sns_client.get_topic_attributes(criteria) + end + end + + #=====================================================# + # Factory Interface + #=====================================================# + # TODO: move this to a mix-in + DEFAULT_BACKEND = AwsClientApi + @selected_backend = DEFAULT_BACKEND + + def self.create + @selected_backend.new + end + + def self.select(klass) + @selected_backend = klass + end + end +end diff --git a/test/integration/build/aws.tf b/test/integration/build/aws.tf index e0d462942..aa3430977 100644 --- a/test/integration/build/aws.tf +++ b/test/integration/build/aws.tf @@ -96,3 +96,38 @@ output "example_ec2_id" { output "no_roles_ec2_id" { value = "${aws_instance.no_roles_instance.id}" } + + +#===========================================================================# +# SNS +#===========================================================================# + +# Test fixture: +# sns_test_topic_01 has one confirmed subscription +# sns_test_topic_02 has no subscriptions + +resource "aws_sns_topic" "sns_test_topic_01" { + name = "${terraform.env}-test-topic-01" +} + +output "sns_test_topic_01_arn" { + value = "${aws_sns_topic.sns_test_topic_01.arn}" +} + +resource "aws_sqs_queue" "sqs_test_queue_01" { + name = "${terraform.env}-test-queue-01" +} + +resource "aws_sns_topic_subscription" "sqs_test_queue_01_sub" { + topic_arn = "${aws_sns_topic.sns_test_topic_01.arn}" + protocol = "sqs" + endpoint = "${aws_sqs_queue.sqs_test_queue_01.arn}" +} + +resource "aws_sns_topic" "sns_test_topic_02" { + name = "${terraform.env}-test-topic-02" +} + +output "sns_test_topic_02_arn" { + value = "${aws_sns_topic.sns_test_topic_02.arn}" +} \ No newline at end of file diff --git a/test/integration/verify/controls/aws_sns_topic.rb b/test/integration/verify/controls/aws_sns_topic.rb new file mode 100644 index 000000000..0a34030de --- /dev/null +++ b/test/integration/verify/controls/aws_sns_topic.rb @@ -0,0 +1,34 @@ +sns_topic_with_subscription_arn = attribute( + 'sns_test_topic_01_arn', + default: 'default.sns_test_topic_01_arn', + description: 'ARN of an SNS topic with at least one subscription') + +sns_topic_with_no_subscriptions_arn = attribute( + 'sns_test_topic_02_arn', + default: 'default.sns_test_topic_02_arn', + description: 'ARN of an SNS topic with no subscriptions') + +control 'SNS Topics' do + # Split the ARNs so we can test things + scheme, partition, service, region, account, topic = sns_topic_with_subscription_arn.split(':') + arn_prefix = [scheme, partition, service].join(':') + + # Search miss + no_such_topic_arn = [arn_prefix, region, account, 'no-such-topic-for-realz'].join(':') + describe aws_sns_topic(no_such_topic_arn) do + it { should_not exist } + end + + # Search hit, fully specified, has subscriptions + describe aws_sns_topic(sns_topic_with_subscription_arn) do + it { should exist } + its('confirmed_subscription_count') { should_not be_zero } + end + + # Search hit, fully specified, has no subscriptions + describe aws_sns_topic(sns_topic_with_no_subscriptions_arn) do + it { should exist } + its('confirmed_subscription_count') { should be_zero } + end + +end \ No newline at end of file diff --git a/test/unit/helper.rb b/test/unit/helper.rb index 669fa01bd..fa79d6936 100644 --- a/test/unit/helper.rb +++ b/test/unit/helper.rb @@ -3,3 +3,6 @@ require 'minitest/unit' require 'minitest/pride' require 'inspec/resource' + +# Needed for exception classes, etc +require 'aws-sdk' \ No newline at end of file diff --git a/test/unit/resources/aws_sns_topic_test.rb b/test/unit/resources/aws_sns_topic_test.rb new file mode 100644 index 000000000..0576c8f14 --- /dev/null +++ b/test/unit/resources/aws_sns_topic_test.rb @@ -0,0 +1,126 @@ +require 'ostruct' +require 'helper' +require 'aws_sns_topic' + +# MSNB = MockSnsBackend +# Abbreviation not used outside this file + +#=============================================================================# +# Constructor Tests +#=============================================================================# +class AwsSnsTopicConstructorTest < Minitest::Test + def setup + AwsSnsTopic::Backend.select(AwsMSNB::NoSubscriptions) + end + + def test_constructor_some_args_required + assert_raises(ArgumentError) { AwsSnsTopic.new } + end + + def test_constructor_accepts_scalar_arn + AwsSnsTopic.new('arn:aws:sns:us-east-1:123456789012:some-topic') + end + + def test_constructor_accepts_arn_as_hash + AwsSnsTopic.new(arn: 'arn:aws:sns:us-east-1:123456789012:some-topic') + end + + def test_constructor_rejects_unrecognized_resource_params + assert_raises(ArgumentError) { AwsSnsTopic.new(beep: 'boop') } + end + + def test_constructor_rejects_non_arn_formats + [ + 'not-even-like-an-arn', + 'arn:::::', # Empty + 'arn::::::', # Too many colons + 'arn:aws::us-east-1:123456789012:some-topic', # Omits SNS service + 'arn::sns:us-east-1:123456789012:some-topic', # Omits partition + 'arn:aws:sns:*:123456789012:some-topic', # All-region - not permitted for lookup + 'arn:aws:sns:us-east-1::some-topic', # Default account - not permitted for lookup + ].each do |example| + assert_raises(ArgumentError) { AwsSnsTopic.new(arn: example) } + end + end +end + +#=============================================================================# +# Search / Recall +#=============================================================================# +class AwsSnsTopicRecallTest < Minitest::Test + # No setup here - each test needs to explicitly declare + # what they want from the backend. + + def test_recall_no_match_is_no_exception + AwsSnsTopic::Backend.select(AwsMSNB::Miss) + topic = AwsSnsTopic.new('arn:aws:sns:us-east-1:123456789012:nope') + refute topic.exists? + end + + def test_recall_match_single_result_works + AwsSnsTopic::Backend.select(AwsMSNB::NoSubscriptions) + topic = AwsSnsTopic.new('arn:aws:sns:us-east-1:123456789012:does-not-matter') + assert topic.exists? + end +end + +#=============================================================================# +# Properties +#=============================================================================# + +class AwsSnsTopicPropertiesTest < Minitest::Test + # No setup here - each test needs to explicitly declare + # what they want from the backend. + + #--------------------------------------- + # confirmed_subscription_count + #--------------------------------------- + def test_prop_conf_sub_count_zero + AwsSnsTopic::Backend.select(AwsMSNB::NoSubscriptions) + topic = AwsSnsTopic.new('arn:aws:sns:us-east-1:123456789012:does-not-matter') + assert_equal(0, topic.confirmed_subscription_count) + end + + def test_prop_conf_sub_count_zero + AwsSnsTopic::Backend.select(AwsMSNB::OneSubscription) + topic = AwsSnsTopic.new('arn:aws:sns:us-east-1:123456789012:does-not-matter') + assert_equal(1, topic.confirmed_subscription_count) + end +end + +#=============================================================================# +# Test Fixtures +#=============================================================================# + +module AwsMSNB + + class Miss < AwsSnsTopic::Backend + def get_topic_attributes(criteria) + raise Aws::SNS::Errors::NotFound.new("No SNS topic for #{criteria[:topic_arn]}", 'Nope') + end + end + + class NoSubscriptions < AwsSnsTopic::Backend + def get_topic_attributes(_criteria) + OpenStruct.new({ + attributes: { # Note that this is a plain hash, odd for AWS SDK + # Many other attributes available, see + # http://docs.aws.amazon.com/sdkforruby/api/Aws/SNS/Types/GetTopicAttributesResponse.html + "SubscriptionsConfirmed" => 0 + } + }) + end + end + + class OneSubscription < AwsSnsTopic::Backend + def get_topic_attributes(_criteria) + OpenStruct.new({ + attributes: { # Note that this is a plain hash, odd for AWS SDK + # Many other attributes available, see + # http://docs.aws.amazon.com/sdkforruby/api/Aws/SNS/Types/GetTopicAttributesResponse.html + "SubscriptionsConfirmed" => 1 + } + }) + end + end +end \ No newline at end of file From 351b200a88bd07946e1adc428d64bd74e202074a Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Wed, 22 Nov 2017 06:32:19 -0500 Subject: [PATCH 128/206] Add Cloudwatch Log Metric Filter resource (#116) * Full docs, first draft; integration tests; started on unit tests * Integration tests pass * Docs update * More consistent syntax in examples * Alter fetch phase to perform fetch, handle results, and unpack into instance vars, more like other resources --- .rubocop.yml | 5 + .../aws_cloudwatch_log_metric_filter.md | 130 +++++++++++++++ libraries/aws_cloudwatch_log_metric_filter.rb | 153 ++++++++++++++++++ libraries/aws_conn.rb | 4 + test/integration/build/aws.tf | 53 ++++++ .../aws_cloudwatch_log_metric_filter.rb | 46 ++++++ .../aws_cloudwatch_log_metric_filter_test.rb | 152 +++++++++++++++++ 7 files changed, 543 insertions(+) create mode 100644 docs/resources/aws_cloudwatch_log_metric_filter.md create mode 100644 libraries/aws_cloudwatch_log_metric_filter.rb create mode 100644 test/integration/verify/controls/aws_cloudwatch_log_metric_filter.rb create mode 100644 test/unit/resources/aws_cloudwatch_log_metric_filter_test.rb diff --git a/.rubocop.yml b/.rubocop.yml index a13aeb3d7..3d4edac4b 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -23,6 +23,9 @@ MethodLength: Max: 40 NumericLiterals: MinDigits: 10 +Metrics/LineLength: + Enabled: true + Max: 100 Metrics/CyclomaticComplexity: Max: 10 Metrics/PerceivedComplexity: @@ -42,6 +45,8 @@ Style/PercentLiteralDelimiters: '%x': () Style/AlignHash: Enabled: false +Style/IndentHash: + Enabled: false Style/PredicateName: Enabled: false Style/ClassAndModuleChildren: diff --git a/docs/resources/aws_cloudwatch_log_metric_filter.md b/docs/resources/aws_cloudwatch_log_metric_filter.md new file mode 100644 index 000000000..5fe6c85af --- /dev/null +++ b/docs/resources/aws_cloudwatch_log_metric_filter.md @@ -0,0 +1,130 @@ +--- +title: About the aws_cloudwatch_log_metric_filter Resource +--- + +# aws_cloudwatch_log_metric_filter + +Use the `aws_cloudwatch_log_metric_filter` InSpec audit resource to search for and test properties of individual AWS Cloudwatch Log Metric Filters. + +A Log Metric Filter (LMF) is an AWS resource that observes log traffic, looks for a specified pattern, and updates a metric about the number times the match occurs. The metric can also be connected to AWS Cloudwatch Alarms, so that actions can be taken when a match occurs. + +
+ +## Syntax + +An `aws_cloudwatch_log_metric_filter` resource block searches for an LMF, specified by several search options. If more than one log metric filter matches, an error occurs. + + # Look for a LMF by its filter name and log group name. This combination + # will always either find at most one LMF - no duplicates. + describe aws_cloudwatch_log_metric_filter( + filter_name: 'my-filter', + log_group_name: 'my-log-group' + ) do + it { should exist } + end + + # Search for an LMF by pattern and log group. + # This could result in an error if the results are not unique. + describe aws_cloudwatch_log_metric_filter( + log_group_name: 'my-log-group', + pattern: 'my-filter' + ) do + it { should exist } + end + +
+ +## Resource Parameters + +### filter_name + +This is the identifier of the log metric filter within its log group. To ensure you have a unique result, you must also provide the log_group_name. + + describe aws_cloudwatch_log_metric_filter( + filter_name: 'my-filter' + ) do + it { should exist } + end + +### log_group_name + +The name of the Cloudwatch Log Group that the LMF is watching. Together with filter_name, this uniquely identifies an LMF. + + describe aws_cloudwatch_log_metric_filter( + log_group_name: 'my-log-group', + ) do + it { should exist } + end + +### pattern + +The filter pattern used to match entries from the logs in the log group. + + describe aws_cloudwatch_log_metric_filter( + pattern: '"ERROR" - "Exiting"', + ) do + it { should exist } + end + +## Matchers + +### exist + +Matches (i.e., passes the test) if the resource parameters (search criteria) were able to locate exactly one LMF. + + describe aws_cloudwatch_log_metric_filter( + log_group_name: 'my-log-group', + ) do + it { should exist } + end + +## Properties + +### filter_name + +The name of the LMF within the log_group. + + # Check the name of the LMF that has a certain pattern + describe aws_cloudwatch_log_metric_filter( + log_group_name: 'app-log-group', + pattern: 'KERBLEWIE', + ) do + its('filter_name') { should cmp 'kaboom_lmf' } + end + +### log_group_name + +The name of the log group that the LMF is watching. + + # Check which log group the LMF 'error-watcher' is watching + describe aws_cloudwatch_log_metric_filter( + filter_name: 'error-watcher', + ) do + its('log_group_name') { should cmp 'app-log-group' } + end + +### metric_name, metric_namespace + +The name and namespace of the Cloudwatch Metric that will be updated when the LMF matches. You also need the `metric_namespace` to uniquely identify the metric. + + # Ensure that the LMF has the right metric name + describe aws_cloudwatch_log_metric_filter( + filter_name: 'my-filter', + log_group_name: 'my-log-group', + ) do + its('metric_name') { should cmp 'MyMetric' } + its('metric_namespace') { should cmp 'MyFantasticMetrics' } + end + +### pattern + +The pattern used to match entries from the logs in the log group. + + # Ensure that the LMF is watching for errors + describe aws_cloudwatch_log_metric_filter( + filter_name: 'error-watcher', + log_group_name: 'app-log-group', + ) do + its('pattern') { should cmp 'ERROR' } + end + diff --git a/libraries/aws_cloudwatch_log_metric_filter.rb b/libraries/aws_cloudwatch_log_metric_filter.rb new file mode 100644 index 000000000..ef7bd2460 --- /dev/null +++ b/libraries/aws_cloudwatch_log_metric_filter.rb @@ -0,0 +1,153 @@ +require 'aws_conn' + +class AwsCloudwatchLogMetricFilter < Inspec.resource(1) + name 'aws_cloudwatch_log_metric_filter' + desc 'Verifies individual Cloudwatch Log Metric Filters' + example <<-EOX + # Look for a LMF by its filter name and log group name. This combination + # will always either find at most one LMF - no duplicates. + describe aws_cloudwatch_log_metric_filter( + filter_name: 'my-filter', + log_group_name: 'my-log-group' + ) do + it { should exist } + end + + # Search for an LMF by pattern and log group. + # This could result in an error if the results are not unique. + describe aws_cloudwatch_log_metric_filter( + log_group_name: 'my-log-group', + pattern: 'my-filter' + ) do + it { should exist } + end +EOX + + RESOURCE_PARAMS = [ + :filter_name, + :log_group_name, + :pattern, + ].freeze + + attr_reader :filter_name, :log_group_name, :pattern, :metric_name, :metric_namespace + + def initialize(resource_params) + validate_resource_params(resource_params).each do |param, value| + instance_variable_set("@#{param}", value) + end + fetch + end + + def exists? + @exists + end + + private + + def validate_resource_params(resource_params) + unless resource_params.is_a? Hash + raise( + ArgumentError, \ + 'Unrecognized format for aws_cloudwatch_log_metric_filter parameters ' \ + " - use (param: 'value') format ", + ) + end + resource_params.keys.each do |param_name| + unless RESOURCE_PARAMS.include?(param_name) # rubocop:disable Style/Next + raise( + ArgumentError, \ + "Unrecognized parameter '#{param_name}' for aws_cloudwatch_log_metric_filter." \ + " Expected one of #{RESOURCE_PARAMS.join(', ')}.", + ) + end + end + resource_params + end + + def fetch + # get a backend + backend = AwsCloudwatchLogMetricFilter::Backend.create + + # Perform query with remote filtering + aws_search_criteria = {} + aws_search_criteria[:filter_name] = filter_name if filter_name + aws_search_criteria[:log_group_name] = log_group_name if log_group_name + aws_results = backend.describe_metric_filters(aws_search_criteria) + + # Then perform local filtering + if pattern + aws_results.select! { |lmf| lmf.filter_pattern == pattern } + end + + # Check result count. We're a singular resource and can tolerate + # 0 or 1 results, not multiple. + if aws_results.count > 1 + raise 'More than one result was returned, but aws_cloudwatch_log_metric_filter '\ + 'can only handle a single AWS resource. Consider passing more resource '\ + 'parameters to narrow down the search.' + elsif aws_results.empty? + @exists = false + else + @exists = true + # Unpack the funny-shaped object we got back from AWS into our instance vars + lmf = aws_results.first + @filter_name = lmf.filter_name + @log_group_name = lmf.log_group_name + @pattern = lmf.filter_pattern # Note inconsistent name + # AWS SDK returns an array of metric transformations + # but only allows one (mandatory) entry, let's flatten that + @metric_name = lmf.metric_transformations.first.metric_name + @metric_namespace = lmf.metric_transformations.first.metric_namespace + end + end + + class Backend + #=====================================================# + # API Definition + #=====================================================# + [ + :describe_metric_filters, + ].each do |method| + define_method(:method) do |*_args| + raise "Unimplemented abstract method #{method} - internal error" + end + end + + #=====================================================# + # Concrete Implementation + #=====================================================# + # Uses the cloudwatch API to really talk to AWS + class AwsClientApi < Backend + def describe_metric_filters(criteria) + cwl_client = AWSConnection.new.cloudwatch_logs_client + query = {} + query[:filter_name_prefix] = criteria[:filter_name] if criteria[:filter_name] + query[:log_group_name] = criteria[:log_group_name] if criteria[:log_group_name] + # 'pattern' is not available as a remote filter, + # we filter it after the fact locally + # TODO: handle pagination? Max 50/page. Maybe you want a plural resource? + aws_response = cwl_client.describe_metric_filters(query) + aws_response.metric_filters + end + end + + #=====================================================# + # Factory Interface + #=====================================================# + # TODO: move this to a mix-in + DEFAULT_BACKEND = AwsClientApi + @selected_backend = DEFAULT_BACKEND + + def self.create + @selected_backend.new + end + + def self.select(klass) + @selected_backend = klass + end + + def self.reset + select(DEFAULT_BACKEND) + end + end +end diff --git a/libraries/aws_conn.rb b/libraries/aws_conn.rb index 07a1a9af1..bd1b586cb 100644 --- a/libraries/aws_conn.rb +++ b/libraries/aws_conn.rb @@ -18,6 +18,10 @@ class AWSConnection @sns_client ||= Aws::SNS::Client.new end + def cloudwatch_logs_client + @cloudwatch_logs_client ||= Aws::CloudWatchLogs::Client.new + end + def ec2_resource @ec2_resource ||= Aws::EC2::Resource.new end diff --git a/test/integration/build/aws.tf b/test/integration/build/aws.tf index aa3430977..e83918508 100644 --- a/test/integration/build/aws.tf +++ b/test/integration/build/aws.tf @@ -73,6 +73,59 @@ resource "aws_iam_access_key" "access_key" { pgp_key = "${var.login_profile_pgp_key}" } +resource "aws_cloudwatch_log_group" "lmf_lg_1" { + name = "${terraform.env}_lmf_lg_1" +} + +# We make a separate log group to test uniqueness of LMF identifiers +resource "aws_cloudwatch_log_group" "lmf_lg_2" { + name = "${terraform.env}_lmf_lg_2" +} + +resource "aws_cloudwatch_log_metric_filter" "lmf_1" { + name = "${terraform.env}_lmf" + pattern = "testpattern01" + log_group_name = "${aws_cloudwatch_log_group.lmf_lg_1.name}" + + metric_transformation { + name = "${terraform.env}_KittehCount_1" + namespace = "${terraform.env}_YourNamespace_1" + value = "1" + } +} + +resource "aws_cloudwatch_log_metric_filter" "lmf_2" { + name = "${terraform.env}_lmf" + pattern = "testpattern02" + log_group_name = "${aws_cloudwatch_log_group.lmf_lg_2.name}" + + metric_transformation { + name = "${terraform.env}_KittehCount_3" + namespace = "${terraform.env}_YourNamespace_3" + value = "1" + } +} + +output "lmf_1_name" { + value = "${aws_cloudwatch_log_metric_filter.lmf_1.name}" +} + +output "lmf_2_name" { + value = "${aws_cloudwatch_log_metric_filter.lmf_2.name}" +} + +output "lmf_1_metric_1_name" { + value = "${terraform.env}_KittehCount_1" +} + +output "lmf_lg_1_name" { + value = "${aws_cloudwatch_log_group.lmf_lg_1.name}" +} + +output "lmf_lg_2_name" { + value = "${aws_cloudwatch_log_group.lmf_lg_2.name}" +} + output "mfa_not_enabled_user" { value = "${aws_iam_user.mfa_not_enabled_user.name}" } diff --git a/test/integration/verify/controls/aws_cloudwatch_log_metric_filter.rb b/test/integration/verify/controls/aws_cloudwatch_log_metric_filter.rb new file mode 100644 index 000000000..b8cd92267 --- /dev/null +++ b/test/integration/verify/controls/aws_cloudwatch_log_metric_filter.rb @@ -0,0 +1,46 @@ +lmf_1_name = attribute( + 'lmf_1_name', + default: 'default.lmf_1_name', + description: 'Name of a Cloudwatch Log Metric Filter', +) + +lmf_2_name = attribute( + 'lmf_2_name', + default: 'default.lmf_2_name', + description: 'Name of a Cloudwatch Log Metric Filter', +) + +lmf_lg_1_name = attribute( + 'lmf_lg_1_name', + default: 'default.lmf_lg_1_name', + description: 'Name of a Cloudwatch Log Group', +) + +lmf_lg_2_name = attribute( + 'lmf_lg_2_name', + default: 'default.lmf_lg_2_name', + description: 'Name of a Cloudwatch Log Group', +) + +lmf_1_metric_1_name = attribute( + 'lmf_1_metric_1_name', + default: 'default.lmf_1_metric_1_name', + description: 'Name of a Cloudwatch Metric', +) + +describe aws_cloudwatch_log_metric_filter( + filter_name: lmf_1_name, + log_group_name: lmf_lg_1_name, +) do + it { should exist } + its('pattern') { should cmp 'testpattern01'} + its('metric_name') { should cmp lmf_1_metric_1_name } +end + +describe aws_cloudwatch_log_metric_filter( + pattern: 'testpattern02', +) do + it { should exist } + its('log_group_name') { should cmp lmf_lg_2_name } + its('filter_name') { should cmp lmf_2_name } +end diff --git a/test/unit/resources/aws_cloudwatch_log_metric_filter_test.rb b/test/unit/resources/aws_cloudwatch_log_metric_filter_test.rb new file mode 100644 index 000000000..5a6240bac --- /dev/null +++ b/test/unit/resources/aws_cloudwatch_log_metric_filter_test.rb @@ -0,0 +1,152 @@ +require 'ostruct' +require 'helper' +require 'aws_cloudwatch_log_metric_filter' + +# CWLMF = CloudwatchLogMetricFilter +# Abbreviation not used outside this file + +#=============================================================================# +# Constructor Tests +#=============================================================================# +class AwsCWLMFConstructor < Minitest::Test + def setup + AwsCloudwatchLogMetricFilter::Backend.select(AwsMockCWLMFBackend::Empty) + end + + def test_constructor_some_args_required + assert_raises(ArgumentError) { AwsCloudwatchLogMetricFilter.new } + end + + def test_constructor_accepts_known_resource_params + [ + :filter_name, + :pattern, + :log_group_name, + ].each do |resource_param| + AwsCloudwatchLogMetricFilter.new(resource_param => 'some_val') + end + end + + def test_constructor_reject_bad_resource_params + assert_raises(ArgumentError) { AwsCloudwatchLogMetricFilter.new(i_am_a_martian: 'beep') } + end +end + +#=============================================================================# +# Search Tests # +#=============================================================================# +class AwsCWLMFSearch < Minitest::Test + def setup + # Reset to the Basic kit each time + AwsCloudwatchLogMetricFilter::Backend.select(AwsMockCWLMFBackend::Basic) + end + + def test_using_lg_and_lmf_name_when_exactly_one + lmf = AwsCloudwatchLogMetricFilter.new( + log_group_name: 'test-log-group-01', + filter_name: 'test-01', + ) + assert lmf.exists? + end + + def test_using_lg_and_lmf_name_when_not_present + lmf = AwsCloudwatchLogMetricFilter.new( + log_group_name: 'test-log-group-01', + filter_name: 'test-1000-nope', + ) + refute lmf.exists? + end + + def test_using_log_group_name_resulting_in_duplicates + assert_raises(RuntimeError) do + AwsCloudwatchLogMetricFilter.new( + log_group_name: 'test-log-group-01', + ) + end + end + + def test_duplicate_locally_uniqued_using_pattern + lmf = AwsCloudwatchLogMetricFilter.new( + log_group_name: 'test-log-group-01', + pattern: 'INFO', + ) + assert lmf.exists? + end +end +#=============================================================================# +# Property Tests # +#=============================================================================# +class AwsCWLMFProperties < Minitest::Test + def setup + # Reset to the Basic kit each time + AwsCloudwatchLogMetricFilter::Backend.select(AwsMockCWLMFBackend::Basic) + end + + def test_property_values + lmf = AwsCloudwatchLogMetricFilter.new( + log_group_name: 'test-log-group-01', + filter_name: 'test-01', + ) + assert_equal('ERROR', lmf.pattern) + assert_equal('alpha', lmf.metric_name) + assert_equal('awesome_metrics', lmf.metric_namespace) + end +end + +#=============================================================================# +# Support Classes - Mock Data Providers # +#=============================================================================# +class AwsMockCWLMFBackend + class Empty < AwsCloudwatchLogMetricFilter::Backend + def describe_metric_filters(_criteria) + [] + end + end + class Basic < AwsCloudwatchLogMetricFilter::Backend + def describe_metric_filters(criteria) # rubocop:disable Metrics/MethodLength + everything = [ + OpenStruct.new({ + filter_name: 'test-01', + filter_pattern: 'ERROR', + log_group_name: 'test-log-group-01', + metric_transformations: [ + OpenStruct.new({ + metric_name: 'alpha', + metric_namespace: 'awesome_metrics', + }), + ], + }), + OpenStruct.new({ + filter_name: 'test-01', # Intentional duplicate + filter_pattern: 'ERROR', + log_group_name: 'test-log-group-02', + metric_transformations: [ + OpenStruct.new({ + metric_name: 'beta', + metric_namespace: 'awesome_metrics', + }), + ], + }), + OpenStruct.new({ + filter_name: 'test-03', + filter_pattern: 'INFO', + log_group_name: 'test-log-group-01', + metric_transformations: [ + OpenStruct.new({ + metric_name: 'gamma', + metric_namespace: 'awesome_metrics', + }), + ], + }), + ] + selection = everything + # Here we filter on anything the AWS SDK lets us filter on remotely + # - which notably does not include the 'pattern' criteria + [:log_group_name, :filter_name].each do |remote_filter| + next unless criteria.key?(remote_filter) + selection.select! { |lmf| lmf[remote_filter] == criteria[remote_filter] } + end + selection + end + end +end From 84b34b9a4d8f802798bfcea0cdd06211b74f44c1 Mon Sep 17 00:00:00 2001 From: ChefRycar Date: Wed, 22 Nov 2017 05:30:18 -0800 Subject: [PATCH 129/206] Updating aws_iam_user with exists? function. (#115) * Updating aws_iam_user with exists? function. Solves #114 Signed-off-by: Nick Rycar * Disabling class length rubocop rule. Signed-off-by: Nick Rycar --- libraries/aws_iam_user.rb | 4 ++++ libraries/aws_iam_user_details_provider.rb | 4 ++++ .../aws_iam_user_details_provider_test.rb | 12 ++++++++++ test/unit/resources/aws_iam_user_test.rb | 23 +++++++++++++++++++ 4 files changed, 43 insertions(+) diff --git a/libraries/aws_iam_user.rb b/libraries/aws_iam_user.rb index 7a4bb4f11..4dad2f3c6 100644 --- a/libraries/aws_iam_user.rb +++ b/libraries/aws_iam_user.rb @@ -24,6 +24,10 @@ class AwsIamUser < Inspec.resource(1) @access_key_factory = access_key_factory end + def exists? + @aws_user_details_provider.exists? + end + def has_mfa_enabled? @aws_user_details_provider.has_mfa_enabled? end diff --git a/libraries/aws_iam_user_details_provider.rb b/libraries/aws_iam_user_details_provider.rb index 3ddd844a3..c268fdfe8 100644 --- a/libraries/aws_iam_user_details_provider.rb +++ b/libraries/aws_iam_user_details_provider.rb @@ -7,6 +7,10 @@ module AwsIam @aws_user = user end + def exists? + @aws_user.exists? + end + def name @aws_user.name end diff --git a/test/unit/resources/aws_iam_user_details_provider_test.rb b/test/unit/resources/aws_iam_user_details_provider_test.rb index 534cde26b..e7b793e02 100644 --- a/test/unit/resources/aws_iam_user_details_provider_test.rb +++ b/test/unit/resources/aws_iam_user_details_provider_test.rb @@ -13,6 +13,18 @@ class AwsIamUserDetailsProviderTest < Minitest::Test @mock_iam_resource_user = Minitest::Mock.new end + def test_exists_returns_true + @mock_iam_resource_user.expect :exists?, true + provider = AwsIam::UserDetailsProvider.new(@mock_iam_resource_user) + assert provider.exists? + end + + def test_exists_returns_false + @mock_iam_resource_user.expect :exists?, false + provider = AwsIam::UserDetailsProvider.new(@mock_iam_resource_user) + refute provider.exists? + end + def test_has_mfa_enabled_returns_true @mock_iam_resource_user.expect :mfa_devices, ['device'] provider = AwsIam::UserDetailsProvider.new(@mock_iam_resource_user) diff --git a/test/unit/resources/aws_iam_user_test.rb b/test/unit/resources/aws_iam_user_test.rb index 479e92438..3edf6990b 100644 --- a/test/unit/resources/aws_iam_user_test.rb +++ b/test/unit/resources/aws_iam_user_test.rb @@ -3,6 +3,7 @@ require 'aws-sdk' require 'helper' require 'aws_iam_user' +# rubocop:disable Metrics/ClassLength class AwsIamUserTest < Minitest::Test Username = 'test'.freeze @@ -13,6 +14,28 @@ class AwsIamUserTest < Minitest::Test @mock_user = { name: Username } end + def test_that_exists_returns_true_if_user_exists + @mock_user_provider.expect :user, @mock_user, [Username] + @mock_dets_provider.expect :exists?, true + @mock_dets_prov_ini.expect :create, @mock_dets_provider, [@mock_user] + assert AwsIamUser.new( + @mock_user, + @mock_user_provider, + @mock_dets_prov_ini, + ).exists? + end + + def test_that_exists_returns_false_if_user_does_not_exist + @mock_user_provider.expect :user, @mock_user, [Username] + @mock_dets_provider.expect :exists?, false + @mock_dets_prov_ini.expect :create, @mock_dets_provider, [@mock_user] + refute AwsIamUser.new( + @mock_user, + @mock_user_provider, + @mock_dets_prov_ini, + ).exists? + end + def test_that_mfa_enable_returns_true_if_mfa_enabled @mock_user_provider.expect :user, @mock_user, [Username] @mock_dets_provider.expect :has_mfa_enabled?, true From fdd04e31c646d39c8dfc965596c48d5f47f1d7ee Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Wed, 22 Nov 2017 14:04:13 -0500 Subject: [PATCH 130/206] Add aws_cloudwatch_alarm resource (#119) Adds aws_cloudwatch_alarm resource. --- docs/resources/aws_cloudwatch_alarm.md | 76 ++++++++ libraries/aws_cloudwatch_alarm.rb | 108 +++++++++++ libraries/aws_conn.rb | 4 + test/integration/build/aws.tf | 27 ++- .../verify/controls/aws_cloudwatch_alarm.rb | 30 ++++ .../resources/aws_cloudwatch_alarm_test.rb | 167 ++++++++++++++++++ 6 files changed, 409 insertions(+), 3 deletions(-) create mode 100644 docs/resources/aws_cloudwatch_alarm.md create mode 100644 libraries/aws_cloudwatch_alarm.rb create mode 100644 test/integration/verify/controls/aws_cloudwatch_alarm.rb create mode 100644 test/unit/resources/aws_cloudwatch_alarm_test.rb diff --git a/docs/resources/aws_cloudwatch_alarm.md b/docs/resources/aws_cloudwatch_alarm.md new file mode 100644 index 000000000..77548c5b6 --- /dev/null +++ b/docs/resources/aws_cloudwatch_alarm.md @@ -0,0 +1,76 @@ +--- +title: About the aws_cloudwatch_alarm Resource +--- + +# aws_cloudwatch_alarm + +Use the `aws_cloudwatch_alarm` InSpec audit resource to test properties of a single Cloudwatch Alarm. + +Cloudwatch Alarms are currently identified using the metric name and metric namespace. Future work may allow other approaches to identifying alarms. + +
+ +## Syntax + +An `aws_cloudwatch_alarm` resource block searches for a Cloudwatch Alarm, specified by several search options. If more than one Alarm matches, an error occurs. + + # Look for a specific alarm + aws_cloudwatch_alarm( + metric: 'my-metric-name', + metric_namespace: 'my-metric-namespace', + ) do + it { should exist } + end + +
+ +## Examples + +The following examples show how to use this InSpec audit resource. + +### Ensure an Alarm has at least one alarm action + + describe aws_cloudwatch_alarm( + metric: 'my-metric-name', + metric_namespace: 'my-metric-namespace', + ) do + its('alarm_actions') { should_not be_empty } + end + +
+ +## Matchers + +### exists + +The control will pass if a Cloudwatch Alarm could be found. Use should_not if you expect zero matches. + + # Expect good metric + describe aws_cloudwatch_alarm( + metric: 'good-metric', + metric_namespace: 'my-metric-namespace', + ) do + it { should exist } + end + + # Disallow alarms based on bad-metric + describe aws_cloudwatch_alarm( + metric: 'bed-metric', + metric_namespace: 'my-metric-namespace', + ) do + it { should_not exist } + end + +## Properties + +### alarm_actions + +`alarm_actions` returns a list of strings. Each string is the ARN of an action that will be taken should the alarm be triggered. + + # Ensure that the alarm has at least one action + describe aws_cloudwatch_alarm( + metric: 'bed-metric', + metric_namespace: 'my-metric-namespace', + ) do + its('alarm_actions') { should_not be_empty } + end \ No newline at end of file diff --git a/libraries/aws_cloudwatch_alarm.rb b/libraries/aws_cloudwatch_alarm.rb new file mode 100644 index 000000000..6f7850266 --- /dev/null +++ b/libraries/aws_cloudwatch_alarm.rb @@ -0,0 +1,108 @@ +require 'aws_conn' + +class AwsCloudwatchAlarm < Inspec.resource(1) + name 'aws_cloudwatch_alarm' + desc <<-EOD + # Look for a specific alarm + aws_cloudwatch_alarm( + metric: 'my-metric-name', + metric_namespace: 'my-metric-namespace', + ) do + it { should exist } + end + EOD + + attr_reader :alarm_name, :metric_name, :metric_namespace, :alarm_actions + def initialize(opts) + validate_resource_params(opts).each do |param, value| + instance_variable_set("@#{param}", value) + end + search + end + + def exists? + @exists + end + + private + + def validate_resource_params(raw_params) + unless raw_params.is_a? Hash + raise ArgumentError, "Resource params should be passed using \"key: 'value'\" format." + end + + validated_params = {} + # Currently you must specify exactly metric_name and metric_namespace + [:metric_name, :metric_namespace].each do |param| + raise ArgumentError, "Missing resource param #{param}" unless raw_params.key?(param) + validated_params[param] = raw_params.delete(param) + end + + # Any leftovers are unwelcome + unless raw_params.empty? + raise ArgumentError, "Unrecognized resource param '#{raw_params.keys.first}'" + end + + validated_params + end + + def search + aws_alarms = Backend.create.describe_alarms_for_metric( + metric_name: @metric_name, + namespace: @metric_namespace, + ) + if aws_alarms.metric_alarms.empty? + @exists = false + elsif aws_alarms.metric_alarms.count > 1 + alarms = aws_alarms.metric_alarms.map(&:alarm_name) + raise 'More than one Cloudwatch Alarm was matched. Try using ' \ + "more specific resource parameters. Alarms matched: #{alarms.join(', ')}" + else + @alarm_actions = aws_alarms.metric_alarms.first.alarm_actions + @alarm_name = aws_alarms.metric_alarms.first.alarm_name + @exists = true + end + end + + class Backend + #=====================================================# + # API Definition + #=====================================================# + [ + :describe_alarms_for_metric, + ].each do |method| + define_method(:method) do |*_args| + raise "Unimplemented abstract method #{method} - internal error" + end + end + + #=====================================================# + # Concrete Implementation + #=====================================================# + # Uses the cloudwatch API to really talk to AWS + class AwsClientApi < Backend + def describe_alarms_for_metric(criteria) + AWSConnection.new.cloudwatch_client.describe_alarms_for_metric(criteria) + end + end + + #=====================================================# + # Factory Interface + #=====================================================# + # TODO: move this to a mix-in + DEFAULT_BACKEND = AwsClientApi + @selected_backend = DEFAULT_BACKEND + + def self.create + @selected_backend.new + end + + def self.select(klass) + @selected_backend = klass + end + + def self.reset + select(DEFAULT_BACKEND) + end + end +end diff --git a/libraries/aws_conn.rb b/libraries/aws_conn.rb index bd1b586cb..531f1ac87 100644 --- a/libraries/aws_conn.rb +++ b/libraries/aws_conn.rb @@ -18,6 +18,10 @@ class AWSConnection @sns_client ||= Aws::SNS::Client.new end + def cloudwatch_client + @cloudwatch_client ||= Aws::CloudWatch::Client.new + end + def cloudwatch_logs_client @cloudwatch_logs_client ||= Aws::CloudWatchLogs::Client.new end diff --git a/test/integration/build/aws.tf b/test/integration/build/aws.tf index e83918508..b3d58a330 100644 --- a/test/integration/build/aws.tf +++ b/test/integration/build/aws.tf @@ -88,7 +88,7 @@ resource "aws_cloudwatch_log_metric_filter" "lmf_1" { log_group_name = "${aws_cloudwatch_log_group.lmf_lg_1.name}" metric_transformation { - name = "${terraform.env}_KittehCount_1" + name = "${terraform.env}_testmetric_1" namespace = "${terraform.env}_YourNamespace_1" value = "1" } @@ -100,12 +100,29 @@ resource "aws_cloudwatch_log_metric_filter" "lmf_2" { log_group_name = "${aws_cloudwatch_log_group.lmf_lg_2.name}" metric_transformation { - name = "${terraform.env}_KittehCount_3" + name = "${terraform.env}_testmetric_3" namespace = "${terraform.env}_YourNamespace_3" value = "1" } } +resource "aws_cloudwatch_metric_alarm" "alarm_1" { + alarm_name = "${terraform.env}-test-alarm-01" + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = "2" + metric_name = "${terraform.env}_testmetric_1" + namespace = "${terraform.env}_YourNamespace_1" + period = "120" + statistic = "Average" + threshold = "80" + alarm_description = "This metric is a test metric" + insufficient_data_actions = [] +} + +output "cloudwatch_alarm_01" { + value = "${terraform.env}-test-alarm-01" +} + output "lmf_1_name" { value = "${aws_cloudwatch_log_metric_filter.lmf_1.name}" } @@ -115,7 +132,11 @@ output "lmf_2_name" { } output "lmf_1_metric_1_name" { - value = "${terraform.env}_KittehCount_1" + value = "${terraform.env}_testmetric_1" +} + +output "lmf_1_metric_1_namespace" { + value = "${terraform.env}_YourNamespace_1" } output "lmf_lg_1_name" { diff --git a/test/integration/verify/controls/aws_cloudwatch_alarm.rb b/test/integration/verify/controls/aws_cloudwatch_alarm.rb new file mode 100644 index 000000000..3e24d8b43 --- /dev/null +++ b/test/integration/verify/controls/aws_cloudwatch_alarm.rb @@ -0,0 +1,30 @@ +alarm_01 = attribute( + 'cloudwatch_alarm_01', + default: 'default.cloudwatch_alarm', + description: 'Name of Cloudwatch Alarm') + +metric_01_name = attribute( + 'lmf_1_metric_1_name', + default: 'default.lmf_1_metric_1_name', + description: 'A test metric name') + +metric_01_namespace = attribute( + 'lmf_1_metric_1_namespace', + default: 'default.lmf_1_metric_1_namespace', + description: 'A test metric namespace') + +control 'AWS Cloudwatch Alarm' do + describe aws_cloudwatch_alarm( + metric_name: metric_01_name, + metric_namespace: metric_01_namespace, + ) do + it { should exist } + end + + describe aws_cloudwatch_alarm( + metric_name: 'NopeNope', + metric_namespace: 'Nope', + ) do + it { should_not exist } + end +end \ No newline at end of file diff --git a/test/unit/resources/aws_cloudwatch_alarm_test.rb b/test/unit/resources/aws_cloudwatch_alarm_test.rb new file mode 100644 index 000000000..4e3456b96 --- /dev/null +++ b/test/unit/resources/aws_cloudwatch_alarm_test.rb @@ -0,0 +1,167 @@ +require 'ostruct' +require 'helper' +require 'aws_cloudwatch_alarm' + +# MCWAB = MockCloudwatchAlarmBackend +# Abbreviation not used outside this file + +#=============================================================================# +# Constructor Tests +#=============================================================================# +class AwsCWAConstructor < Minitest::Test + def setup + AwsCloudwatchAlarm::Backend.select(AwsMCWAB::Empty) + end + + def test_constructor_some_args_required + assert_raises(ArgumentError) { AwsCloudwatchAlarm.new } + end + + def test_constructor_accepts_known_resource_params_combos + [ + { metric_name: 'some-val', metric_namespace: 'some-val' }, + ].each do |combo| + AwsCloudwatchAlarm.new(combo) + end + end + + def test_constructor_rejects_bad_resource_params_combos + [ + { metric_name: 'some-val' }, + { metric_namespace: 'some-val' }, + ].each do |combo| + assert_raises(ArgumentError) { AwsCloudwatchAlarm.new(combo) } + end + end + + def test_constructor_reject_unknown_resource_params + assert_raises(ArgumentError) { AwsCloudwatchAlarm.new(beep: 'boop') } + end +end + +#=============================================================================# +# Search / Recall +#=============================================================================# + +class AwsCWARecall < Minitest::Test + def setup + AwsCloudwatchAlarm::Backend.select(AwsMCWAB::Basic) + end + + def test_recall_no_match_is_no_exception + alarm = AwsCloudwatchAlarm.new(metric_name: 'nope', metric_namespace: 'nope') + refute alarm.exists? + end + + def test_recall_match_single_result_works + alarm = AwsCloudwatchAlarm.new( + metric_name: 'metric-01', + metric_namespace: 'metric-namespace-01', + ) + assert alarm.exists? + end + + def test_recall_multiple_result_raises + assert_raises(RuntimeError) do + AwsCloudwatchAlarm.new( + metric_name: 'metric-02', + metric_namespace: 'metric-namespace-01', + ) + end + end +end + +#=============================================================================# +# Properties +#=============================================================================# + +class AwsCWAProperties < Minitest::Test + def setup + AwsCloudwatchAlarm::Backend.select(AwsMCWAB::Basic) + end + + #--------------------------------------- + # alarm_actions + #--------------------------------------- + def test_prop_actions_empty + alarm = AwsCloudwatchAlarm.new( + metric_name: 'metric-02', + metric_namespace: 'metric-namespace-02', + ) + assert_kind_of Array, alarm.alarm_actions + assert_empty alarm.alarm_actions + end + + def test_prop_actions_hit + alarm = AwsCloudwatchAlarm.new( + metric_name: 'metric-01', + metric_namespace: 'metric-namespace-01', + ) + assert_kind_of Array, alarm.alarm_actions + refute_empty alarm.alarm_actions + assert_kind_of String, alarm.alarm_actions.first + end +end + +#=============================================================================# +# Test Fixtures +#=============================================================================# + +module AwsMCWAB + class Empty < AwsCloudwatchAlarm::Backend + def describe_alarms_for_metric(_criteria) + OpenStruct.new({ + metric_alarms: [], + }) + end + end + + class Basic < AwsCloudwatchAlarm::Backend + def describe_alarms_for_metric(criteria) + OpenStruct.new({ + metric_alarms: [ # rubocop:disable Metrics/BlockLength + # Each one here is an alarm that is subscribed to the given metric + # Each has an enormous number of properties, most omitted here + # http://docs.aws.amazon.com/sdkforruby/api/Aws/CloudWatch/Client.html#describe_alarms_for_metric-instance_method + OpenStruct.new({ + alarm_name: 'alarm-01', + metric_name: 'metric-01', + namespace: 'metric-namespace-01', + statistic: 'SampleCount', + alarm_actions: [ + 'arn::::' # TODO: get SNS ARN format + ], + }), + OpenStruct.new({ + # Alarm 02 and 03 both watch metric-01, metric-namespace-01 + alarm_name: 'alarm-02', + metric_name: 'metric-02', + namespace: 'metric-namespace-01', + statistic: 'SampleCount', + alarm_actions: [], + }), + OpenStruct.new({ + # Alarm 02 and 03 both watch metric-02, metric-namespace-01 + alarm_name: 'alarm-03', + metric_name: 'metric-02', + namespace: 'metric-namespace-01', + statistic: 'SampleCount', + alarm_actions: [], + }), + OpenStruct.new({ + alarm_name: 'alarm-04', + metric_name: 'metric-02', + namespace: 'metric-namespace-02', + statistic: 'SampleCount', + alarm_actions: [], + }), + ].select do |alarm| + criteria.keys.all? do |criterion| + criterion = 'namespace' if criterion == 'metric_namespace' + alarm[criterion] == criteria[criterion] + end + end, + }) + end + end +end From 245efc4230ef62e33ffee04ed733fcc7b5ce72f0 Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Wed, 22 Nov 2017 14:17:36 -0500 Subject: [PATCH 131/206] Add aws_iam_access_keys resource (#112) Signed-off-by: Clinton Wolfe --- docs/resources/aws_iam_access_keys.md | 165 +++++++++ libraries/aws_iam_access_keys.rb | 164 +++++++++ test/integration/build/aws.tf | 4 + .../verify/controls/aws_iam_access_key.rb | 60 +++- .../resources/aws_iam_access_keys_test.rb | 332 ++++++++++++++++++ 5 files changed, 722 insertions(+), 3 deletions(-) create mode 100644 docs/resources/aws_iam_access_keys.md create mode 100644 libraries/aws_iam_access_keys.rb create mode 100644 test/unit/resources/aws_iam_access_keys_test.rb diff --git a/docs/resources/aws_iam_access_keys.md b/docs/resources/aws_iam_access_keys.md new file mode 100644 index 000000000..90ca9f8dc --- /dev/null +++ b/docs/resources/aws_iam_access_keys.md @@ -0,0 +1,165 @@ +--- +title: About the aws_iam_access_keys Resource +--- + +# aws_iam_access_keys + +Use the `aws_iam_access_keys` InSpec audit resource to test properties of some or all IAM Access Keys. + +To test properties of a single Access Key, use the `aws_iam_access_key` resource instead. +To test properties of an individual user's access keys, use the `aws_iam_user` resource. + +Access Keys are closely related to AWS User resources. Use this resource to perform audits of all keys or of keys specified by criteria unrelated to any particular user. + +
+ +## Syntax + +An `aws_iam_access_keys` resource block uses an optional filter to select a group of access keys and then tests that group. + + # Do not allow any access keys + describe aws_iam_access_keys do + it { should_not exist } + end + + # Don't let fred have access keys, using filter argument syntax + describe aws_iam_access_keys.where(username: 'fred') do + it { should_not exist } + end + + # Don't let fred have access keys, using filter block syntax (most flexible) + describe aws_iam_access_keys.where { username == 'fred' } do + it { should_not exist } + end + +
+ +## Examples + +The following examples show how to use this InSpec audit resource. + +### Disallow access keys created more than 90 days ago + + describe aws_iam_access_keys.where { created_age > 90 } do + it { should_not exist } + end + +
+ +## Matchers + +### exists + +The control will pass if the filter returns at least one result. Use should_not if you expect zero matches. + + # Sally should have at least one access key + describe aws_iam_access_keys.where(username: 'sally') do + it { should exist } + end + + # Don't let fred have access keys + describe aws_iam_access_keys.where(username: 'fred') do + it { should_not exist } + end + +## Filter Criteria + +### active + +A true / false value indicating if an Access Key is currently "Active" (the normal state) in the AWS console. See also: `inactive`. + + # Check whether a particular key is enabled + describe aws_iam_access_keys.where { active } do + its('access_key_ids') { should include('AKIA1234567890ABCDEF')} + end + +### created_date + +A DateTime identifying when the Access Key was created. See also `created_days_ago` and `created_hours_ago`. + + # Detect keys older than 2017 + describe aws_iam_access_keys.where { created_date < DateTime.parse('2017-01-01') } do + it { should_not exist } + end + +### created_days_ago, created_hours_ago + +An integer, representing how old the access key is. + + # Don't allow keys that are older than 90 days + describe aws_iam_access_keys.where { created_days_ago > 90 } do + it { should_not exist } + end + +### ever_used + +A true / false value indicating if the Access Key has ever been used, based on the last_used_date. See also: `never_used`. + + # Check to see if a particular key has ever been used + describe aws_iam_access_keys.where { ever_used } do + its('access_key_ids') { should include('AKIA1234567890ABCDEF')} + end + + +### inactive + +A true / false value indicating if the Access Key has been marked Inactive in the AWS console. See also: `active`. + + # Don't leave inactive keys laying around + describe aws_iam_access_keys.where { inactive } do + it { should_not exist } + end + +### last_used_date + +A DateTime identifying when the Access Key was last used. Returns nil if the key has never been used. See also: `ever_used`, `last_used_days_ago`, `last_used_hours_ago`, and `never_used`. + + # No one should do anything on Mondays + describe aws_iam_access_keys.where { ever_used and last_used_date.monday? } do + it { should_not exist } + end + +### last_used_days_ago, last_used_hours_ago + +An integer representing when the key was last used. See also: `ever_used`, `last_used_date`, and `never_used`. + + # Don't allow keys that sit unused for more than 90 days + describe aws_iam_access_keys.where { last_used_days_ago > 90 } do + it { should_not exist } + end + +### never_used + +A true / false value indicating if the Access Key has never been used, based on the last_used_date. See also: `ever_used`. + + # Don't allow unused keys to lay around + describe aws_iam_access_keys.where { never_used } do + it { should_not exist } + end + +### username + +Searches for access keys owned by the named user. Each user may have zero, one, or two access keys. + + describe aws_iam_access_keys(username: 'bob') do + it { should exist } + end + +## Properties + +### access_key_ids + +Provides a list of all access key IDs matched. + + describe aws_iam_access_keys do + its('access_key_ids') { should include('AKIA1234567890ABCDEF') } + end + +### entries + +Provides access to the raw results of the query. This can be useful for checking counts and other advanced operations. + + # Allow at most 100 access keys on the account + describe aws_iam_access_keys do + its('entries.count') { should be <= 100} + end diff --git a/libraries/aws_iam_access_keys.rb b/libraries/aws_iam_access_keys.rb new file mode 100644 index 000000000..241876a78 --- /dev/null +++ b/libraries/aws_iam_access_keys.rb @@ -0,0 +1,164 @@ +class AwsIamAccessKeys < Inspec.resource(1) + name 'aws_iam_access_keys' + desc 'Verifies settings for AWS IAM Access Keys in bulk' + example ' + describe aws_iam_access_keys do + it { should_not exist } + end + ' + + VALUED_CRITERIA = [ + :username, + :id, + :access_key_id, + :created_date, + ].freeze + + # Constructor. Args are reserved for row fetch filtering. + def initialize(filter_criteria = {}) + filter_criteria = validate_filter_criteria(filter_criteria) + @table = AccessKeyProvider.create.fetch(filter_criteria) + end + + def validate_filter_criteria(criteria) + # Allow passing a scalar string, the Access Key ID. + criteria = { access_key_id: criteria } if criteria.is_a? String + unless criteria.is_a? Hash + raise 'Unrecognized criteria for fetching Access Keys. ' \ + "Use 'criteria: value' format." + end + + # id and access_key_id are aliases; standardize on access_key_id + criteria[:access_key_id] = criteria.delete(:id) if criteria.key?(:id) + if criteria[:access_key_id] and + criteria[:access_key_id] !~ /^AKIA[0-9A-Z]{16}$/ + raise 'Incorrect format for Access Key ID - expected AKIA followed ' \ + 'by 16 letters or numbers' + end + + criteria.keys.each do |criterion| + unless VALUED_CRITERIA.include?(criterion) # rubocop:disable Style/Next + raise 'Unrecognized filter criterion for aws_iam_access_keys, ' \ + "'#{criterion}'. Valid choices are " \ + "#{VALUED_CRITERIA.join(', ')}." + end + end + + criteria + end + + # Underlying FilterTable implementation. + filter = FilterTable.create + filter.add_accessor(:where) + .add_accessor(:entries) + .add(:exists?) { |x| !x.entries.empty? } + .add(:access_key_ids, field: :access_key_id) + .add(:created_date, field: :created_date) + .add(:created_days_ago, field: :created_days_ago) + .add(:created_hours_ago, field: :created_hours_ago) + .add(:usernames, field: :username) + .add(:active, field: :active) + .add(:inactive, field: :inactive) + .add(:last_used_date, field: :last_used_date) + .add(:last_used_hours_ago, field: :last_used_hours_ago) + .add(:last_used_days_ago, field: :last_used_days_ago) + .add(:ever_used, field: :ever_used) + .add(:never_used, field: :never_used) + filter.connect(self, :access_key_data) + + def access_key_data + @table + end + + def to_s + 'IAM Access Keys' + end + + # Internal support class. This is used to fetch + # the users and access keys. We have an abstract + # class with a concrete AWS implementation provided here; + # a few mock implementations are also provided in the unit tests. + class AccessKeyProvider + # Implementation of AccessKeyProvider which operates by looping over + # all users, then fetching their access keys. + # TODO: An alternate, more scalable implementation could be made + # using the Credential Report. + class AwsUserIterator < AccessKeyProvider + def fetch(criteria) + iam_client = AWSConnection.new.iam_client + usernames = [] + if criteria.key?(:username) + usernames.push criteria[:username] + else + # TODO: pagination check and resume + usernames = iam_client.list_users.users.map(&:user_name) + end + + access_key_data = [] + usernames.each do |username| + begin + user_keys = iam_client.list_access_keys(user_name: username) + .access_key_metadata + user_keys = user_keys.map do |metadata| + { + access_key_id: metadata.access_key_id, + username: username, + status: metadata.status, + create_date: metadata.create_date, # DateTime.parse(metadata.create_date), + } + end + + # Synthetics + user_keys.each do |key_info| + add_synthetic_fields(key_info) + end + access_key_data.concat(user_keys) + rescue Aws::IAM::Errors::NoSuchEntity # rubocop:disable Lint/HandleExceptions + # Swallow - a miss on search results should return an empty table + end + end + access_key_data + end + + def add_synthetic_fields(key_info) # rubocop:disable Metrics/AbcSize + key_info[:id] = key_info[:access_key_id] + key_info[:active] = key_info[:status] == 'Active' + key_info[:inactive] = key_info[:status] != 'Active' + key_info[:created_hours_ago] = ((Time.now - key_info[:create_date]) / (60*60)).to_i + key_info[:created_days_ago] = (key_info[:created_hours_ago] / 24).to_i + + # Last used is a separate API call + iam_client = AWSConnection.new.iam_client + last_used = + iam_client.get_access_key_last_used(access_key_id: key_info[:access_key_id]) + .access_key_last_used.last_used_date + key_info[:ever_used] = !last_used.nil? + key_info[:never_used] = last_used.nil? + key_info[:last_used_time] = last_used + return unless last_used + key_info[:last_used_hours_ago] = ((Time.now - last_used) / (60*60)).to_i + key_info[:last_used_days_ago] = (key_info[:last_used_hours_ago]/24).to_i + end + end + + DEFAULT_PROVIDER = AwsIamAccessKeys::AccessKeyProvider::AwsUserIterator + @selected_implementation = DEFAULT_PROVIDER + + # Use this to change what class is created by create(). + def self.select(klass) + @selected_implementation = klass + end + + def self.reset + @selected_implementation = DEFAULT_PROVIDER + end + + def self.create + @selected_implementation.new + end + + def fetch(_filter_criteria) + raise 'Unimplemented abstract method - internal error.' + end + end +end diff --git a/test/integration/build/aws.tf b/test/integration/build/aws.tf index b3d58a330..f923d3490 100644 --- a/test/integration/build/aws.tf +++ b/test/integration/build/aws.tf @@ -159,6 +159,10 @@ output "access_key_user" { value = "${aws_iam_user.access_key_user.name}" } +output "access_key_id" { + value = "${aws_iam_access_key.access_key.id}" +} + output "example_ec2_name" { value = "${aws_instance.example.tags.Name}" } diff --git a/test/integration/verify/controls/aws_iam_access_key.rb b/test/integration/verify/controls/aws_iam_access_key.rb index 99536ce19..6cec7002b 100644 --- a/test/integration/verify/controls/aws_iam_access_key.rb +++ b/test/integration/verify/controls/aws_iam_access_key.rb @@ -1,3 +1,57 @@ -describe aws_iam_access_key(username: 'not-a-user', 'id': 'not-an-id') do - it { should_not exist } -end +access_key_user = attribute( + 'access_key_user', + default: 'default.access_key_user', + description: 'Name of IAM user access_key_user') + +access_key_id = attribute( + 'access_key_id', + default: 'AKIA1234567890AZFAKE', + description: 'Access Key ID of access key of IAM user access_key_user') + +describe aws_iam_access_key(username: 'not-a-user', 'id': 'not-an-id') do + it { should_not exist } +end + +describe aws_iam_access_key(username: access_key_user, 'id': access_key_id) do + it { should exist } + # TODO - check last used, created, other key metadata +end + +control 'IAM Access Keys' do + title 'Fetch all' + describe aws_iam_access_keys do + it { should exist } + end +end + + +control 'IAM Access Keys' do + title 'Client-side filtering' + all_keys = aws_iam_access_keys + describe all_keys.where(username: access_key_user) do + its('entries.length') { should be 1 } + its('access_key_ids.first') { should eq access_key_id } + end + describe all_keys.where(created_days_ago: 0) do + it { should exist } + end + describe all_keys.where { active } do + it { should exist } + end + describe all_keys.where { ever_used } + .where { last_used_days_ago > 0 } do + it { should exist } + end +end + +control 'AKS3' do + title 'Fetch-time filtering' + describe aws_iam_access_keys(username: access_key_user) do + its('entries.length') { should be 1 } + its('access_key_ids.first') { should eq access_key_id } + end + + describe aws_iam_access_keys(username: 'i-dont-exist-presumably') do + it { should_not exist } + end +end \ No newline at end of file diff --git a/test/unit/resources/aws_iam_access_keys_test.rb b/test/unit/resources/aws_iam_access_keys_test.rb new file mode 100644 index 000000000..1d1d01fb8 --- /dev/null +++ b/test/unit/resources/aws_iam_access_keys_test.rb @@ -0,0 +1,332 @@ + +require 'aws-sdk' +require 'helper' +require 'aws_iam_access_keys' + +#==========================================================# +# Constructor Tests # +#==========================================================# + +class AwsIamAccessKeysConstructorTest < Minitest::Test + # Reset provider back to the implementation default prior + # to each test. Tests must explicitly select an alternate. + def setup + AwsIamAccessKeys::AccessKeyProvider.reset + end + + def test_bare_constructor_does_not_explode + AwsIamAccessKeys::AccessKeyProvider.select(AlwaysEmptyMAKP) + AwsIamAccessKeys.new + end +end + +#==========================================================# +# Filtering Tests # +#==========================================================# + +class AwsIamAccessKeysFilterTest < Minitest::Test + # Reset provider back to the implementation default prior + # to each test. Tests must explicitly select an alternate. + def setup + AwsIamAccessKeys::AccessKeyProvider.reset + end + + def test_filter_methods_should_exist + AwsIamAccessKeys::AccessKeyProvider.select(AlwaysEmptyMAKP) + resource = AwsIamAccessKeys.new + [:where, :'exists?'].each do |meth| + assert_respond_to(resource, meth) + end + end + + def test_filter_method_where_should_be_chainable + AwsIamAccessKeys::AccessKeyProvider.select(AlwaysEmptyMAKP) + resource = AwsIamAccessKeys.new + assert_respond_to(resource.where, :where) + end + + def test_filter_method_exists_should_probe_empty_when_empty + AwsIamAccessKeys::AccessKeyProvider.select(AlwaysEmptyMAKP) + resource = AwsIamAccessKeys.new + refute(resource.exists?) + end + + def test_filter_method_exists_should_probe_present_when_present + AwsIamAccessKeys::AccessKeyProvider.select(BasicMAKP) + resource = AwsIamAccessKeys.new + assert(resource.exists?) + end +end + +#==========================================================# +# Filter Criteria Tests # +#==========================================================# + +class AwsIamAccessKeysFilterCriteriaTest < Minitest::Test + def setup + # Here we always want no rseults. + AwsIamAccessKeys::AccessKeyProvider.select(AlwaysEmptyMAKP) + @valued_criteria = { + username: 'bob', + id: 'AKIA1234567890ABCDEF', + access_key_id: 'AKIA1234567890ABCDEF', + } + end + + def test_criteria_when_used_in_constructor_with_value + @valued_criteria.each do |criterion, value| + AwsIamAccessKeys.new(criterion => value) + end + end + + def test_criteria_when_used_in_where_with_value + @valued_criteria.each do |criterion, value| + AwsIamAccessKeys.new.where(criterion => value) + end + end + + # Negative cases + def test_criteria_when_used_in_constructor_with_bad_criterion + assert_raises(RuntimeError) do + AwsIamAccessKeys.new(nope: 'some_val') + end + end + + def test_criteria_when_used_in_where_with_bad_criterion + assert_raises(RuntimeError) do + AwsIamAccessKeys.new(nope: 'some_val') + end + end + + # Identity criterion is allowed based on regex + def test_identity_criterion_when_used_in_constructor_positive + AwsIamAccessKeys.new('AKIA1234567890ABCDEF') + end + + # Permitted by FilterTable? + def test_identity_criterion_when_used_in_where_positive + AwsIamAccessKeys.new.where('AKIA1234567890ABCDEF') + end + + def test_identity_criterion_when_used_in_constructor_negative + assert_raises(RuntimeError) do + AwsIamAccessKeys.new('NopeAKIA1234567890ABCDEF') + end + end + + # Permitted by FilterTable? + # def test_identity_criterion_when_used_in_where_negative + # assert_raises(RuntimeError) do + # AwsIamAccessKeys.new.where('NopeAKIA1234567890ABCDEF') + # end + # end +end + +#==========================================================# +# Property Tests # +#==========================================================# +class AwsIamAccessKeysPropertiesTest < Minitest::Test + def setup + # Reset back to the basic kit each time. + AwsIamAccessKeys::AccessKeyProvider.select(BasicMAKP) + @all_basic = AwsIamAccessKeys.new + end + + #----------------------------------------------------------# + # created_date / created_days_ago / created_hours_ago # + #----------------------------------------------------------# + def test_property_created_date + assert_kind_of(DateTime, @all_basic.entries.first.created_date) + + arg_filtered = @all_basic.where(created_date: DateTime.parse('2017-10-27T17:58:00Z')) + assert_equal(1, arg_filtered.entries.count) + assert arg_filtered.access_key_ids.first.end_with?('BOB') + + block_filtered = @all_basic.where { created_date.friday? } + assert_equal(1, block_filtered.entries.count) + assert block_filtered.access_key_ids.first.end_with?('BOB') + end + + def test_property_created_days_ago + assert_kind_of(Integer, @all_basic.entries.first.created_days_ago) + + arg_filtered = @all_basic.where(created_days_ago: 9) + assert_equal(1, arg_filtered.entries.count) + assert arg_filtered.access_key_ids.first.end_with?('SALLY') + + block_filtered = @all_basic.where { created_days_ago > 2 } + assert_equal(2, block_filtered.entries.count) + end + + def test_property_created_hours_ago + assert_kind_of(Integer, @all_basic.entries.first.created_hours_ago) + + arg_filtered = @all_basic.where(created_hours_ago: 222) + assert_equal(1, arg_filtered.entries.count) + assert arg_filtered.access_key_ids.first.end_with?('SALLY') + + block_filtered = @all_basic.where { created_hours_ago > 100 } + assert_equal(2, block_filtered.entries.count) + end + + #----------------------------------------------------------# + # active / inactive # + #----------------------------------------------------------# + def test_property_active + assert_kind_of(TrueClass, @all_basic.entries.first.active) + + arg_filtered = @all_basic.where(active: true) + assert_equal(2, arg_filtered.entries.count) + + block_filtered = @all_basic.where { active } + assert_equal(2, block_filtered.entries.count) + assert block_filtered.access_key_ids.first.end_with?('BOB') + end + + def test_property_inactive + assert_kind_of(FalseClass, @all_basic.entries.first.inactive) + + arg_filtered = @all_basic.where(inactive: true) + assert_equal(1, arg_filtered.entries.count) + + block_filtered = @all_basic.where { inactive } + assert_equal(1, block_filtered.entries.count) + assert block_filtered.access_key_ids.first.end_with?('ROBIN') + end + + #-----------------------------------------------------------# + # last_used_date / last_used_days_ago / last_used_hours_ago # + #-----------------------------------------------------------# + def test_property_last_used_date + assert_kind_of(NilClass, @all_basic.entries[0].last_used_date) + assert_kind_of(DateTime, @all_basic.entries[1].last_used_date) + + arg_filtered = @all_basic.where(last_used_date: DateTime.parse('2017-10-27T17:58:00Z')) + assert_equal(1, arg_filtered.entries.count) + assert arg_filtered.access_key_ids.first.end_with?('SALLY') + + block_filtered = @all_basic.where { last_used_date and last_used_date.friday? } + assert_equal(1, block_filtered.entries.count) + assert block_filtered.access_key_ids.first.end_with?('SALLY') + end + + def test_property_last_used_days_ago + assert_kind_of(NilClass, @all_basic.entries[0].last_used_days_ago) + assert_kind_of(Integer, @all_basic.entries[1].last_used_days_ago) + + arg_filtered = @all_basic.where(last_used_days_ago: 4) + assert_equal(1, arg_filtered.entries.count) + assert arg_filtered.access_key_ids.first.end_with?('SALLY') + + block_filtered = @all_basic.where { last_used_days_ago and last_used_days_ago < 2 } + assert_equal(1, block_filtered.entries.count) + assert block_filtered.access_key_ids.first.end_with?('ROBIN') + end + + def test_property_last_used_hours_ago + assert_kind_of(NilClass, @all_basic.entries[0].last_used_hours_ago) + assert_kind_of(Integer, @all_basic.entries[1].last_used_hours_ago) + + arg_filtered = @all_basic.where(last_used_hours_ago: 102) + assert_equal(1, arg_filtered.entries.count) + assert arg_filtered.access_key_ids.first.end_with?('SALLY') + + block_filtered = @all_basic.where { last_used_hours_ago and last_used_hours_ago < 10 } + assert_equal(1, block_filtered.entries.count) + assert block_filtered.access_key_ids.first.end_with?('ROBIN') + end + + #-----------------------------------------------------------# + # ever_used / never_used # + #-----------------------------------------------------------# + def test_property_ever_used + assert_kind_of(FalseClass, @all_basic.entries[0].ever_used) + assert_kind_of(TrueClass, @all_basic.entries[1].ever_used) + + arg_filtered = @all_basic.where(ever_used: true) + assert_equal(2, arg_filtered.entries.count) + + block_filtered = @all_basic.where { ever_used } + assert_equal(2, block_filtered.entries.count) + assert block_filtered.access_key_ids.first.end_with?('SALLY') + end + + def test_property_never_used + assert_kind_of(TrueClass, @all_basic.entries[0].never_used) + assert_kind_of(FalseClass, @all_basic.entries[1].never_used) + + arg_filtered = @all_basic.where(never_used: true) + assert_equal(1, arg_filtered.entries.count) + + block_filtered = @all_basic.where { never_used } + assert_equal(1, block_filtered.entries.count) + assert block_filtered.access_key_ids.first.end_with?('BOB') + end +end +#==========================================================# +# Mock Support Classes # +#==========================================================# + +# MAKP = MockAccessKeyProvider. Abbreviation not used +# outside this file. + +class AlwaysEmptyMAKP < AwsIamAccessKeys::AccessKeyProvider + def fetch(_filter_criteria) + [] + end +end + +class BasicMAKP < AwsIamAccessKeys::AccessKeyProvider + def fetch(_filter_criteria) # rubocop:disable Metrics/MethodLength + [ + { + username: 'bob', + access_key_id: 'AKIA1234567890123BOB', + id: 'AKIA1234567890123BOB', + created_date: DateTime.parse('2017-10-27T17:58:00Z'), + created_days_ago: 4, + created_hours_ago: 102, + status: 'Active', + active: true, + inactive: false, + last_used_date: nil, + last_used_days_ago: nil, + last_used_hours_ago: nil, + ever_used: false, + never_used: true, + }, + { + username: 'sally', + access_key_id: 'AKIA12345678901SALLY', + id: 'AKIA12345678901SALLY', + created_date: DateTime.parse('2017-10-22T17:58:00Z'), + created_days_ago: 9, + created_hours_ago: 222, + status: 'Active', + active: true, + inactive: false, + last_used_date: DateTime.parse('2017-10-27T17:58:00Z'), + last_used_days_ago: 4, + last_used_hours_ago: 102, + ever_used: true, + never_used: false, + }, + { + username: 'robin', + access_key_id: 'AKIA12345678901ROBIN', + id: 'AKIA12345678901ROBIN', + created_date: DateTime.parse('2017-10-31T17:58:00Z'), + created_days_ago: 1, + created_hours_ago: 12, + status: 'Inactive', + active: false, + inactive: true, + last_used_date: DateTime.parse('2017-10-31T20:58:00Z'), + last_used_days_ago: 0, + last_used_hours_ago: 5, + ever_used: true, + never_used: false, + }, + ] + end +end From 2955aabf7f6cf24af900d66aa121422f0d6befa7 Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Mon, 4 Dec 2017 13:32:13 -0500 Subject: [PATCH 132/206] DRY up AWS resource implementation and test backend implementations (#121) * Standardize requires in unit tests Signed-off-by: Clinton Wolfe * Standardize requires in resources Signed-off-by: Clinton Wolfe * Move AWS connection hook into non-resource library area Signed-off-by: Clinton Wolfe * Add an AWS resource mixin, pushing constructor out to it Signed-off-by: Clinton Wolfe * Push resource param name recognition into mixin Signed-off-by: Clinton Wolfe * Push exists predicate up to mixin Signed-off-by: Clinton Wolfe * Rename base.rb to be resource_mixin for clarity Signed-off-by: Clinton Wolfe * Separate the backend from its factory, and push it out into a class mixin Signed-off-by: Clinton Wolfe * Push BackendFactory up into the resource mixin Signed-off-by: Clinton Wolfe * De-linting Signed-off-by: Clinton Wolfe * Remove aws_conn require from LMF and CloudWatch Alarm filters Signed-off-by: Clinton Wolfe * Use resource mixin for Cloudwatch Alarm Signed-off-by: Clinton Wolfe * Rework LMF to use the resource mixin Signed-off-by: Clinton Wolfe * Rubocop. Signed-off-by: Clinton Wolfe * Remove SDK load from connection.rb; that happens in aws.rb now Signed-off-by: Clinton Wolfe * Mixin should default to allowing empty resource params Signed-off-by: Clinton Wolfe * Update LMF to enforce params being required Signed-off-by: Clinton Wolfe --- lib/inspec/aws.rb | 4 + lib/inspec/aws/backend_factory_mixin.rb | 12 +++ .../inspec/aws/connection.rb | 7 +- lib/inspec/aws/resource_mixin.rb | 52 ++++++++++++ libraries/_shim.rb | 3 + libraries/aws_cloudwatch_alarm.rb | 71 +++------------- libraries/aws_cloudwatch_log_metric_filter.rb | 83 +++---------------- libraries/aws_iam_password_policy.rb | 3 - libraries/aws_sns_topic.rb | 73 +++------------- test/unit/helper.rb | 8 +- .../resources/aws_cloudwatch_alarm_test.rb | 6 +- .../aws_cloudwatch_log_metric_filter_test.rb | 6 +- .../unit/resources/aws_iam_access_key_test.rb | 2 - .../resources/aws_iam_password_policy_test.rb | 2 - .../aws_iam_user_details_provider_test.rb | 1 - .../resources/aws_iam_user_provider_test.rb | 1 - test/unit/resources/aws_iam_user_test.rb | 1 - test/unit/resources/aws_iam_users_test.rb | 1 - test/unit/resources/aws_sns_topic_test.rb | 17 ++-- 19 files changed, 131 insertions(+), 222 deletions(-) create mode 100644 lib/inspec/aws.rb create mode 100644 lib/inspec/aws/backend_factory_mixin.rb rename libraries/aws_conn.rb => lib/inspec/aws/connection.rb (76%) create mode 100644 lib/inspec/aws/resource_mixin.rb create mode 100644 libraries/_shim.rb diff --git a/lib/inspec/aws.rb b/lib/inspec/aws.rb new file mode 100644 index 000000000..5a3d20244 --- /dev/null +++ b/lib/inspec/aws.rb @@ -0,0 +1,4 @@ +require 'aws-sdk' +require_relative 'aws/backend_factory_mixin' +require_relative 'aws/resource_mixin' +require_relative 'aws/connection' diff --git a/lib/inspec/aws/backend_factory_mixin.rb b/lib/inspec/aws/backend_factory_mixin.rb new file mode 100644 index 000000000..67191af5a --- /dev/null +++ b/lib/inspec/aws/backend_factory_mixin.rb @@ -0,0 +1,12 @@ +# Intended to be pulled in via extend, not include +module AwsBackendFactoryMixin + def create + @selected_backend.new + end + + def select(klass) + @selected_backend = klass + end + + alias set_default_backend select +end diff --git a/libraries/aws_conn.rb b/lib/inspec/aws/connection.rb similarity index 76% rename from libraries/aws_conn.rb rename to lib/inspec/aws/connection.rb index 531f1ac87..fbed8acac 100644 --- a/libraries/aws_conn.rb +++ b/lib/inspec/aws/connection.rb @@ -1,8 +1,13 @@ # author: Christoph Hartmann +# This class exists so that we can intercept AWS API connection setup +# and have an opportunity to provide credentials from another mechanism +# (such as a train transport URI) in the future. +# +# We commit to always supporting the standard AWS environment variables. + class AWSConnection def initialize - require 'aws-sdk' opts = { region: ENV['AWS_REGION'] || ENV['AWS_DEFAULT_REGION'], credentials: Aws::Credentials.new( diff --git a/lib/inspec/aws/resource_mixin.rb b/lib/inspec/aws/resource_mixin.rb new file mode 100644 index 000000000..6b0a117d0 --- /dev/null +++ b/lib/inspec/aws/resource_mixin.rb @@ -0,0 +1,52 @@ +module AwsResourceMixin + def initialize(resource_params = {}) + validate_params(resource_params).each do |param, value| + instance_variable_set(:"@#{param}", value) + end + fetch_from_aws + end + + def check_resource_param_names(raw_params: {}, allowed_params: [], allowed_scalar_name: nil, allowed_scalar_type: nil) + # Some resources allow passing in a single ID value. Check and convert to hash if so. + if allowed_scalar_name && !raw_params.is_a?(Hash) + value_seen = raw_params + if value_seen.is_a?(allowed_scalar_type) + raw_params = { allowed_scalar_name => value_seen } + else + raise ArgumentError, 'If you pass a single value to the resource, it must ' \ + "be a #{allowed_scalar_type}, not an #{value_seen.class}." + end + end + + # Remove all expected params from the raw param hash + recognized_params = {} + allowed_params.each do |expected_param| + recognized_params[expected_param] = raw_params.delete(expected_param) if raw_params.key?(expected_param) + end + + # Any leftovers are unwelcome + unless raw_params.empty? + raise ArgumentError, "Unrecognized resource param '#{raw_params.keys.first}'. Expected parameters: #{allowed_params.join(', ')}" + end + + recognized_params + end + + def exists? + @exists + end + + # This sets up a class, AwsSomeResource::BackendFactory, that + # provides a mechanism to create and use backends without + # having to know which is selected. This is mainly used for + # unit testing. + def self.included(base) + # Create a new class, whose body is simply to extend the + # backend factory mixin + resource_backend_factory_class = Class.new(Object) do + extend AwsBackendFactoryMixin + end + # Name that class + base.const_set('BackendFactory', resource_backend_factory_class) + end +end diff --git a/libraries/_shim.rb b/libraries/_shim.rb new file mode 100644 index 000000000..4fd325755 --- /dev/null +++ b/libraries/_shim.rb @@ -0,0 +1,3 @@ +# This file simply acts as a loader when inspec-aws +# is being used as a resource pack. +require_relative '../lib/inspec/aws' diff --git a/libraries/aws_cloudwatch_alarm.rb b/libraries/aws_cloudwatch_alarm.rb index 6f7850266..25f0a9905 100644 --- a/libraries/aws_cloudwatch_alarm.rb +++ b/libraries/aws_cloudwatch_alarm.rb @@ -1,5 +1,3 @@ -require 'aws_conn' - class AwsCloudwatchAlarm < Inspec.resource(1) name 'aws_cloudwatch_alarm' desc <<-EOD @@ -12,42 +10,28 @@ class AwsCloudwatchAlarm < Inspec.resource(1) end EOD + include AwsResourceMixin attr_reader :alarm_name, :metric_name, :metric_namespace, :alarm_actions - def initialize(opts) - validate_resource_params(opts).each do |param, value| - instance_variable_set("@#{param}", value) - end - search - end - - def exists? - @exists - end private - def validate_resource_params(raw_params) - unless raw_params.is_a? Hash - raise ArgumentError, "Resource params should be passed using \"key: 'value'\" format." - end - + def validate_params(raw_params) + recognized_params = check_resource_param_names( + raw_params: raw_params, + allowed_params: [:metric_name, :metric_namespace], + ) validated_params = {} # Currently you must specify exactly metric_name and metric_namespace [:metric_name, :metric_namespace].each do |param| - raise ArgumentError, "Missing resource param #{param}" unless raw_params.key?(param) - validated_params[param] = raw_params.delete(param) - end - - # Any leftovers are unwelcome - unless raw_params.empty? - raise ArgumentError, "Unrecognized resource param '#{raw_params.keys.first}'" + raise ArgumentError, "Missing resource param #{param}" unless recognized_params.key?(param) + validated_params[param] = recognized_params.delete(param) end validated_params end - def search - aws_alarms = Backend.create.describe_alarms_for_metric( + def fetch_from_aws + aws_alarms = BackendFactory.create.describe_alarms_for_metric( metric_name: @metric_name, namespace: @metric_namespace, ) @@ -65,44 +49,11 @@ class AwsCloudwatchAlarm < Inspec.resource(1) end class Backend - #=====================================================# - # API Definition - #=====================================================# - [ - :describe_alarms_for_metric, - ].each do |method| - define_method(:method) do |*_args| - raise "Unimplemented abstract method #{method} - internal error" - end - end - - #=====================================================# - # Concrete Implementation - #=====================================================# - # Uses the cloudwatch API to really talk to AWS class AwsClientApi < Backend + BackendFactory.set_default_backend(self) def describe_alarms_for_metric(criteria) AWSConnection.new.cloudwatch_client.describe_alarms_for_metric(criteria) end end - - #=====================================================# - # Factory Interface - #=====================================================# - # TODO: move this to a mix-in - DEFAULT_BACKEND = AwsClientApi - @selected_backend = DEFAULT_BACKEND - - def self.create - @selected_backend.new - end - - def self.select(klass) - @selected_backend = klass - end - - def self.reset - select(DEFAULT_BACKEND) - end end end diff --git a/libraries/aws_cloudwatch_log_metric_filter.rb b/libraries/aws_cloudwatch_log_metric_filter.rb index ef7bd2460..bb1a31678 100644 --- a/libraries/aws_cloudwatch_log_metric_filter.rb +++ b/libraries/aws_cloudwatch_log_metric_filter.rb @@ -1,5 +1,3 @@ -require 'aws_conn' - class AwsCloudwatchLogMetricFilter < Inspec.resource(1) name 'aws_cloudwatch_log_metric_filter' desc 'Verifies individual Cloudwatch Log Metric Filters' @@ -23,50 +21,25 @@ class AwsCloudwatchLogMetricFilter < Inspec.resource(1) end EOX - RESOURCE_PARAMS = [ - :filter_name, - :log_group_name, - :pattern, - ].freeze - + include AwsResourceMixin attr_reader :filter_name, :log_group_name, :pattern, :metric_name, :metric_namespace - def initialize(resource_params) - validate_resource_params(resource_params).each do |param, value| - instance_variable_set("@#{param}", value) - end - fetch - end - - def exists? - @exists - end - private - def validate_resource_params(resource_params) - unless resource_params.is_a? Hash - raise( - ArgumentError, \ - 'Unrecognized format for aws_cloudwatch_log_metric_filter parameters ' \ - " - use (param: 'value') format ", - ) + def validate_params(raw_params) + validated_params = check_resource_param_names( + raw_params: raw_params, + allowed_params: [:filter_name, :log_group_name, :pattern], + ) + if validated_params.empty? + raise ArgumentError, 'You must provide either filter_name, log_group, or pattern to aws_cloudwatch_log_metric_filter.' end - resource_params.keys.each do |param_name| - unless RESOURCE_PARAMS.include?(param_name) # rubocop:disable Style/Next - raise( - ArgumentError, \ - "Unrecognized parameter '#{param_name}' for aws_cloudwatch_log_metric_filter." \ - " Expected one of #{RESOURCE_PARAMS.join(', ')}.", - ) - end - end - resource_params + validated_params end - def fetch + def fetch_from_aws # get a backend - backend = AwsCloudwatchLogMetricFilter::Backend.create + backend = BackendFactory.create # Perform query with remote filtering aws_search_criteria = {} @@ -102,22 +75,9 @@ EOX end class Backend - #=====================================================# - # API Definition - #=====================================================# - [ - :describe_metric_filters, - ].each do |method| - define_method(:method) do |*_args| - raise "Unimplemented abstract method #{method} - internal error" - end - end - - #=====================================================# - # Concrete Implementation - #=====================================================# # Uses the cloudwatch API to really talk to AWS class AwsClientApi < Backend + BackendFactory.set_default_backend(self) def describe_metric_filters(criteria) cwl_client = AWSConnection.new.cloudwatch_logs_client query = {} @@ -130,24 +90,5 @@ EOX aws_response.metric_filters end end - - #=====================================================# - # Factory Interface - #=====================================================# - # TODO: move this to a mix-in - DEFAULT_BACKEND = AwsClientApi - @selected_backend = DEFAULT_BACKEND - - def self.create - @selected_backend.new - end - - def self.select(klass) - @selected_backend = klass - end - - def self.reset - select(DEFAULT_BACKEND) - end end end diff --git a/libraries/aws_iam_password_policy.rb b/libraries/aws_iam_password_policy.rb index 65b7b9b4f..9c6e22f5f 100644 --- a/libraries/aws_iam_password_policy.rb +++ b/libraries/aws_iam_password_policy.rb @@ -1,7 +1,4 @@ # author: Viktor Yakovlyev - -require 'aws_conn' - class AwsIamPasswordPolicy < Inspec.resource(1) name 'aws_iam_password_policy' desc 'Verifies iam password policy' diff --git a/libraries/aws_sns_topic.rb b/libraries/aws_sns_topic.rb index 89b12a24f..3f305fdeb 100644 --- a/libraries/aws_sns_topic.rb +++ b/libraries/aws_sns_topic.rb @@ -1,5 +1,3 @@ -require 'aws_conn' - class AwsSnsTopic < Inspec.resource(1) name 'aws_sns_topic' desc 'Verifies settings for an SNS Topic' @@ -10,48 +8,28 @@ class AwsSnsTopic < Inspec.resource(1) end " + include AwsResourceMixin attr_reader :arn, :confirmed_subscription_count - def initialize(raw_params) - validated_params = validate_params(raw_params) - @arn = validated_params[:arn] - search - end - - def exists? - @exists - end - private def validate_params(raw_params) - # Allow passing ARN as a scalar string, not in a hash - raw_params = { arn: raw_params } if raw_params.is_a?(String) - - # Remove all expected params from the raw param hash - validated_params = {} - [ - :arn, - ].each do |expected_param| - validated_params[expected_param] = raw_params.delete(expected_param) if raw_params.key?(expected_param) - end - - # Any leftovers are unwelcome - unless raw_params.empty? - raise ArgumentError, "Unrecognized resource param '#{raw_params.keys.first}'" - end - + validated_params = check_resource_param_names( + raw_params: raw_params, + allowed_params: [:arn], + allowed_scalar_name: :arn, + allowed_scalar_type: String, + ) # Validate the ARN unless validated_params[:arn] =~ /^arn:aws:sns:[\w\-]+:\d{12}:[\S]+$/ raise ArgumentError, 'Malformed ARN for SNS topics. Expected an ARN of the form ' \ "'arn:aws:sns:REGION:ACCOUNT-ID:TOPIC-NAME'" end - validated_params end - def search - aws_response = AwsSnsTopic::Backend.create.get_topic_attributes(topic_arn: @arn).attributes + def fetch_from_aws + aws_response = AwsSnsTopic::BackendFactory.create.get_topic_attributes(topic_arn: @arn).attributes @exists = true # The response has a plain hash with CamelCase plain string keys and string values @@ -60,41 +38,14 @@ class AwsSnsTopic < Inspec.resource(1) @exists = false end + # Uses the SDK API to really talk to AWS class Backend - #=====================================================# - # API Definition - #=====================================================# - [ - :get_topic_attributes, - ].each do |method| - define_method(:method) do |*_args| - raise "Unimplemented abstract method #{method} - internal error" - end - end + class AwsClientApi + BackendFactory.set_default_backend(self) - #=====================================================# - # Concrete Implementation - #=====================================================# - # Uses the SDK API to really talk to AWS - class AwsClientApi < Backend def get_topic_attributes(criteria) AWSConnection.new.sns_client.get_topic_attributes(criteria) end end - - #=====================================================# - # Factory Interface - #=====================================================# - # TODO: move this to a mix-in - DEFAULT_BACKEND = AwsClientApi - @selected_backend = DEFAULT_BACKEND - - def self.create - @selected_backend.new - end - - def self.select(klass) - @selected_backend = klass - end end end diff --git a/test/unit/helper.rb b/test/unit/helper.rb index fa79d6936..18106b6e2 100644 --- a/test/unit/helper.rb +++ b/test/unit/helper.rb @@ -2,7 +2,9 @@ require 'minitest/autorun' require 'minitest/unit' require 'minitest/pride' -require 'inspec/resource' +# Data formats commonly used in testing +require 'json' +require 'ostruct' -# Needed for exception classes, etc -require 'aws-sdk' \ No newline at end of file +require 'inspec/resource' +require_relative '../../lib/inspec/aws' diff --git a/test/unit/resources/aws_cloudwatch_alarm_test.rb b/test/unit/resources/aws_cloudwatch_alarm_test.rb index 4e3456b96..947bb2836 100644 --- a/test/unit/resources/aws_cloudwatch_alarm_test.rb +++ b/test/unit/resources/aws_cloudwatch_alarm_test.rb @@ -10,7 +10,7 @@ require 'aws_cloudwatch_alarm' #=============================================================================# class AwsCWAConstructor < Minitest::Test def setup - AwsCloudwatchAlarm::Backend.select(AwsMCWAB::Empty) + AwsCloudwatchAlarm::BackendFactory.select(AwsMCWAB::Empty) end def test_constructor_some_args_required @@ -45,7 +45,7 @@ end class AwsCWARecall < Minitest::Test def setup - AwsCloudwatchAlarm::Backend.select(AwsMCWAB::Basic) + AwsCloudwatchAlarm::BackendFactory.select(AwsMCWAB::Basic) end def test_recall_no_match_is_no_exception @@ -77,7 +77,7 @@ end class AwsCWAProperties < Minitest::Test def setup - AwsCloudwatchAlarm::Backend.select(AwsMCWAB::Basic) + AwsCloudwatchAlarm::BackendFactory.select(AwsMCWAB::Basic) end #--------------------------------------- diff --git a/test/unit/resources/aws_cloudwatch_log_metric_filter_test.rb b/test/unit/resources/aws_cloudwatch_log_metric_filter_test.rb index 5a6240bac..dbe5d2f71 100644 --- a/test/unit/resources/aws_cloudwatch_log_metric_filter_test.rb +++ b/test/unit/resources/aws_cloudwatch_log_metric_filter_test.rb @@ -10,7 +10,7 @@ require 'aws_cloudwatch_log_metric_filter' #=============================================================================# class AwsCWLMFConstructor < Minitest::Test def setup - AwsCloudwatchLogMetricFilter::Backend.select(AwsMockCWLMFBackend::Empty) + AwsCloudwatchLogMetricFilter::BackendFactory.select(AwsMockCWLMFBackend::Empty) end def test_constructor_some_args_required @@ -38,7 +38,7 @@ end class AwsCWLMFSearch < Minitest::Test def setup # Reset to the Basic kit each time - AwsCloudwatchLogMetricFilter::Backend.select(AwsMockCWLMFBackend::Basic) + AwsCloudwatchLogMetricFilter::BackendFactory.select(AwsMockCWLMFBackend::Basic) end def test_using_lg_and_lmf_name_when_exactly_one @@ -79,7 +79,7 @@ end class AwsCWLMFProperties < Minitest::Test def setup # Reset to the Basic kit each time - AwsCloudwatchLogMetricFilter::Backend.select(AwsMockCWLMFBackend::Basic) + AwsCloudwatchLogMetricFilter::BackendFactory.select(AwsMockCWLMFBackend::Basic) end def test_property_values diff --git a/test/unit/resources/aws_iam_access_key_test.rb b/test/unit/resources/aws_iam_access_key_test.rb index e74955c32..b07f4c761 100644 --- a/test/unit/resources/aws_iam_access_key_test.rb +++ b/test/unit/resources/aws_iam_access_key_test.rb @@ -1,8 +1,6 @@ # author: Chris Redekop -require 'aws-sdk' require 'helper' - require 'aws_iam_access_key' class AwsIamAccessKeyTest < Minitest::Test diff --git a/test/unit/resources/aws_iam_password_policy_test.rb b/test/unit/resources/aws_iam_password_policy_test.rb index 0c46f6d79..37e9dec6e 100644 --- a/test/unit/resources/aws_iam_password_policy_test.rb +++ b/test/unit/resources/aws_iam_password_policy_test.rb @@ -1,7 +1,5 @@ require 'helper' require 'aws_iam_password_policy' -require 'aws-sdk' -require 'json' class AwsIamPasswordPolicyTest < Minitest::Test def setup diff --git a/test/unit/resources/aws_iam_user_details_provider_test.rb b/test/unit/resources/aws_iam_user_details_provider_test.rb index e7b793e02..2e70ad262 100644 --- a/test/unit/resources/aws_iam_user_details_provider_test.rb +++ b/test/unit/resources/aws_iam_user_details_provider_test.rb @@ -1,6 +1,5 @@ # author: Adnan Duric # author: Steffanie Freeman -require 'aws-sdk' require 'helper' require 'aws_iam_user_provider' require 'aws_iam_user_details_provider' diff --git a/test/unit/resources/aws_iam_user_provider_test.rb b/test/unit/resources/aws_iam_user_provider_test.rb index 1b8aaf667..88bfb8b5c 100644 --- a/test/unit/resources/aws_iam_user_provider_test.rb +++ b/test/unit/resources/aws_iam_user_provider_test.rb @@ -2,7 +2,6 @@ # author: Jeffrey Lyons # author: Steffanie Freeman # author: Alex Bedley -require 'aws-sdk' require 'helper' require 'aws_iam_user_provider' diff --git a/test/unit/resources/aws_iam_user_test.rb b/test/unit/resources/aws_iam_user_test.rb index 3edf6990b..aa4d065da 100644 --- a/test/unit/resources/aws_iam_user_test.rb +++ b/test/unit/resources/aws_iam_user_test.rb @@ -1,5 +1,4 @@ # author: Simon Varlow -require 'aws-sdk' require 'helper' require 'aws_iam_user' diff --git a/test/unit/resources/aws_iam_users_test.rb b/test/unit/resources/aws_iam_users_test.rb index 2fe29baa9..6b6a5f921 100644 --- a/test/unit/resources/aws_iam_users_test.rb +++ b/test/unit/resources/aws_iam_users_test.rb @@ -2,7 +2,6 @@ # author: Steffanie Freeman # author: Simon Varlow # author: Chris Redekop -require 'aws-sdk' require 'helper' require 'aws_iam_users' diff --git a/test/unit/resources/aws_sns_topic_test.rb b/test/unit/resources/aws_sns_topic_test.rb index 0576c8f14..77f5f4bb6 100644 --- a/test/unit/resources/aws_sns_topic_test.rb +++ b/test/unit/resources/aws_sns_topic_test.rb @@ -1,4 +1,3 @@ -require 'ostruct' require 'helper' require 'aws_sns_topic' @@ -10,7 +9,7 @@ require 'aws_sns_topic' #=============================================================================# class AwsSnsTopicConstructorTest < Minitest::Test def setup - AwsSnsTopic::Backend.select(AwsMSNB::NoSubscriptions) + AwsSnsTopic::BackendFactory.select(AwsMSNB::NoSubscriptions) end def test_constructor_some_args_required @@ -52,13 +51,13 @@ class AwsSnsTopicRecallTest < Minitest::Test # what they want from the backend. def test_recall_no_match_is_no_exception - AwsSnsTopic::Backend.select(AwsMSNB::Miss) + AwsSnsTopic::BackendFactory.select(AwsMSNB::Miss) topic = AwsSnsTopic.new('arn:aws:sns:us-east-1:123456789012:nope') refute topic.exists? end def test_recall_match_single_result_works - AwsSnsTopic::Backend.select(AwsMSNB::NoSubscriptions) + AwsSnsTopic::BackendFactory.select(AwsMSNB::NoSubscriptions) topic = AwsSnsTopic.new('arn:aws:sns:us-east-1:123456789012:does-not-matter') assert topic.exists? end @@ -76,13 +75,13 @@ class AwsSnsTopicPropertiesTest < Minitest::Test # confirmed_subscription_count #--------------------------------------- def test_prop_conf_sub_count_zero - AwsSnsTopic::Backend.select(AwsMSNB::NoSubscriptions) + AwsSnsTopic::BackendFactory.select(AwsMSNB::NoSubscriptions) topic = AwsSnsTopic.new('arn:aws:sns:us-east-1:123456789012:does-not-matter') assert_equal(0, topic.confirmed_subscription_count) end def test_prop_conf_sub_count_zero - AwsSnsTopic::Backend.select(AwsMSNB::OneSubscription) + AwsSnsTopic::BackendFactory.select(AwsMSNB::OneSubscription) topic = AwsSnsTopic.new('arn:aws:sns:us-east-1:123456789012:does-not-matter') assert_equal(1, topic.confirmed_subscription_count) end @@ -94,13 +93,13 @@ end module AwsMSNB - class Miss < AwsSnsTopic::Backend + class Miss def get_topic_attributes(criteria) raise Aws::SNS::Errors::NotFound.new("No SNS topic for #{criteria[:topic_arn]}", 'Nope') end end - class NoSubscriptions < AwsSnsTopic::Backend + class NoSubscriptions def get_topic_attributes(_criteria) OpenStruct.new({ attributes: { # Note that this is a plain hash, odd for AWS SDK @@ -112,7 +111,7 @@ module AwsMSNB end end - class OneSubscription < AwsSnsTopic::Backend + class OneSubscription def get_topic_attributes(_criteria) OpenStruct.new({ attributes: { # Note that this is a plain hash, odd for AWS SDK From 9a44db15f7bf96c711e8da5bae9d32e17f2f79bf Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Tue, 5 Dec 2017 11:55:55 -0500 Subject: [PATCH 133/206] Update rubocop 0.44.0 -> 0.51.0 (#127) * Update rubocop to latest Signed-off-by: Clinton Wolfe * Code tweaks for rubocop Signed-off-by: Clinton Wolfe --- .rubocop.yml | 100 +++++++++++++++++++------------ Gemfile | 2 +- libraries/aws_ec2_instance.rb | 2 +- libraries/aws_iam_access_keys.rb | 2 +- 4 files changed, 64 insertions(+), 42 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 3d4edac4b..46550f92f 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,5 +1,6 @@ --- AllCops: + TargetRubyVersion: 2.3 Exclude: - Gemfile - Rakefile @@ -7,31 +8,76 @@ AllCops: - 'examples/**/*' - 'vendor/**/*' - 'lib/bundles/inspec-init/templates/**/*' -Documentation: - Enabled: false + - 'www/tutorial/**/*' AlignParameters: Enabled: true -Encoding: - Enabled: true +BlockDelimiters: + Enabled: false +Documentation: + Enabled: false +EmptyLinesAroundBlockBody: + Enabled: false +FrozenStringLiteralComment: + Enabled: false HashSyntax: Enabled: true LineLength: Enabled: false -EmptyLinesAroundBlockBody: +Layout/AlignHash: + Enabled: false +Layout/EmptyLineAfterMagicComment: + Enabled: false +Layout/EndOfLine: + Enabled: true + EnforcedStyle: lf +Layout/SpaceAroundOperators: Enabled: false MethodLength: Max: 40 -NumericLiterals: - MinDigits: 10 -Metrics/LineLength: - Enabled: true - Max: 100 +Metrics/AbcSize: + Max: 33 +Metrics/BlockLength: + Max: 50 Metrics/CyclomaticComplexity: Max: 10 Metrics/PerceivedComplexity: Max: 11 -Metrics/AbcSize: - Max: 33 +Naming/FileName: + Enabled: false +Naming/HeredocDelimiterNaming: + Enabled: false +Naming/PredicateName: + Enabled: false +NumericLiterals: + MinDigits: 10 +Security/YAMLLoad: + Enabled: false +Style/AndOr: + Enabled: false +Style/BracesAroundHashParameters: + Enabled: false +Style/ClassAndModuleChildren: + Enabled: false +Style/ConditionalAssignment: + Enabled: false +Style/EmptyMethod: + Enabled: false +Style/Encoding: + Enabled: false +Style/IfUnlessModifier: + Enabled: false +Style/MethodMissing: + Enabled: false +Style/MultilineIfModifier: + Enabled: false +Style/NegatedIf: + Enabled: false +Style/Not: + Enabled: false +Style/NumericLiteralPrefix: + Enabled: false +Style/NumericPredicate: + Enabled: false Style/PercentLiteralDelimiters: PreferredDelimiters: '%': '{}' @@ -43,35 +89,11 @@ Style/PercentLiteralDelimiters: '%w': '{}' '%W': () '%x': () -Style/AlignHash: +Style/SymbolArray: Enabled: false -Style/IndentHash: - Enabled: false -Style/PredicateName: - Enabled: false -Style/ClassAndModuleChildren: - Enabled: false -Style/ConditionalAssignment: - Enabled: false -Style/BracesAroundHashParameters: - Enabled: false -Style/AndOr: - Enabled: false -Style/Not: - Enabled: false -Style/FileName: - Enabled: false -Style/TrailingCommaInLiteral: - EnforcedStyleForMultiline: comma Style/TrailingCommaInArguments: EnforcedStyleForMultiline: comma -Style/NegatedIf: - Enabled: false +Style/TrailingCommaInLiteral: + EnforcedStyleForMultiline: comma Style/UnlessElse: Enabled: false -BlockDelimiters: - Enabled: false -Style/SpaceAroundOperators: - Enabled: false -Style/IfUnlessModifier: - Enabled: false diff --git a/Gemfile b/Gemfile index 48bdf8619..8610273f9 100644 --- a/Gemfile +++ b/Gemfile @@ -2,7 +2,7 @@ source 'https://rubygems.org' gem 'rake' gem 'inspec', '~> 1' -gem 'rubocop', '~> 0.44.0' +gem 'rubocop', '~> 0.51.0' gem 'highline', '~> 1.6.0' gem 'aws-sdk', '~> 2' gem 'nokogiri' diff --git a/libraries/aws_ec2_instance.rb b/libraries/aws_ec2_instance.rb index f2a048308..ff473a982 100644 --- a/libraries/aws_ec2_instance.rb +++ b/libraries/aws_ec2_instance.rb @@ -49,7 +49,7 @@ class AwsEc2Instance < Inspec.resource(1) # returns the instance state def state - instance.state.name if instance + instance&.state&.name end # helper methods for each state diff --git a/libraries/aws_iam_access_keys.rb b/libraries/aws_iam_access_keys.rb index 241876a78..92fbab10e 100644 --- a/libraries/aws_iam_access_keys.rb +++ b/libraries/aws_iam_access_keys.rb @@ -36,7 +36,7 @@ class AwsIamAccessKeys < Inspec.resource(1) 'by 16 letters or numbers' end - criteria.keys.each do |criterion| + criteria.each_key do |criterion| unless VALUED_CRITERIA.include?(criterion) # rubocop:disable Style/Next raise 'Unrecognized filter criterion for aws_iam_access_keys, ' \ "'#{criterion}'. Valid choices are " \ From 9277e0585e99ef2e36c0eb28e47c9c78a6bf2aa9 Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Thu, 7 Dec 2017 13:35:23 -0500 Subject: [PATCH 134/206] Rename the resource pack loading shim to a filename that will sort first on all OS's (#133) Signed-off-by: Clinton Wolfe --- libraries/{_shim.rb => aws_aaa_shim.rb} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename libraries/{_shim.rb => aws_aaa_shim.rb} (100%) diff --git a/libraries/_shim.rb b/libraries/aws_aaa_shim.rb similarity index 100% rename from libraries/_shim.rb rename to libraries/aws_aaa_shim.rb From f5251f3c295f6db4eb814960def854934b7ca30b Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Fri, 8 Dec 2017 13:34:09 -0500 Subject: [PATCH 135/206] Re-work unit tests for user and users (#125) * Constructor unit tests Signed-off-by: Clinton Wolfe * Constructor tests pass, all others gutted Signed-off-by: Clinton Wolfe * Basic 'where' test in place, no criteria Signed-off-by: Clinton Wolfe * Wired up filter table to backend list users Signed-off-by: Clinton Wolfe * Unit testing for has_mfa_enabled and has_console_password Signed-off-by: Clinton Wolfe * Simple AWS client implementation for Users Signed-off-by: Clinton Wolfe * Rework resource parameters and validation; copy in code from #121 Signed-off-by: Clinton Wolfe * Add constructor tests Signed-off-by: Clinton Wolfe * Add search/recall tests Signed-off-by: Clinton Wolfe * Recall unit tests pass Signed-off-by: Clinton Wolfe * Failing unit tests for username and has_console_password Signed-off-by: Clinton Wolfe * has_console_password works in unit tests Signed-off-by: Clinton Wolfe * has_mfa_enabled failing unit tests Signed-off-by: Clinton Wolfe * has_mfa_enabled passes unit tests Signed-off-by: Clinton Wolfe * Failing unit tests for Access Keys Signed-off-by: Clinton Wolfe * CLean up bad rebase commit Signed-off-by: Clinton Wolfe * Access keys property works, as an uncooked AWS response Signed-off-by: Clinton Wolfe * De-linting Signed-off-by: Clinton Wolfe * Integration tests work Signed-off-by: Clinton Wolfe * Remove provider support libraries Signed-off-by: Clinton Wolfe * Integration tests pass for users resource Signed-off-by: Clinton Wolfe * De-lint Signed-off-by: Clinton Wolfe * Remove aws connection load from user Signed-off-by: Clinton Wolfe * Adapt aws_iam_user to rely on AwsResourceMixin Signed-off-by: Clinton Wolfe --- libraries/aws_iam_user.rb | 119 ++++-- libraries/aws_iam_user_details_provider.rb | 38 -- libraries/aws_iam_user_provider.rb | 18 - libraries/aws_iam_users.rb | 96 +++-- .../verify/controls/aws_iam_user.rb | 15 +- .../verify/controls/aws_iam_users.rb | 3 +- .../aws_iam_user_details_provider_test.rb | 85 ----- .../resources/aws_iam_user_provider_test.rb | 52 --- test/unit/resources/aws_iam_user_test.rb | 346 ++++++++++++------ test/unit/resources/aws_iam_users_test.rb | 239 +++++++----- 10 files changed, 537 insertions(+), 474 deletions(-) delete mode 100644 libraries/aws_iam_user_details_provider.rb delete mode 100644 libraries/aws_iam_user_provider.rb delete mode 100644 test/unit/resources/aws_iam_user_details_provider_test.rb delete mode 100644 test/unit/resources/aws_iam_user_provider_test.rb diff --git a/libraries/aws_iam_user.rb b/libraries/aws_iam_user.rb index 4dad2f3c6..db0cbc77e 100644 --- a/libraries/aws_iam_user.rb +++ b/libraries/aws_iam_user.rb @@ -2,57 +2,106 @@ # author: Steffanie Freeman # author: Simon Varlow # author: Chris Redekop - class AwsIamUser < Inspec.resource(1) name 'aws_iam_user' desc 'Verifies settings for AWS IAM user' example " - describe aws_iam_user(name: 'test_user') do + describe aws_iam_user(username: 'test_user') do it { should have_mfa_enabled } it { should_not have_console_password } end " - def initialize( - opts, - aws_user_provider = AwsIam::UserProvider.new, - aws_user_details_provider_ini = AwsIam::UserDetailsProviderInitializer.new, - access_key_factory = AwsIamAccessKeyFactory.new - ) - user = opts[:user] - user = aws_user_provider.user(opts[:name]) if user.nil? - @aws_user_details_provider = aws_user_details_provider_ini.create(user) - @access_key_factory = access_key_factory - end - def exists? - @aws_user_details_provider.exists? - end - - def has_mfa_enabled? - @aws_user_details_provider.has_mfa_enabled? - end - - def has_console_password? - @aws_user_details_provider.has_console_password? - end - - def access_keys - @aws_user_details_provider.access_keys.map { |access_key| - @access_key_factory.create_access_key(access_key) - } - end + include AwsResourceMixin + attr_reader :username, :has_mfa_enabled, :has_console_password, :access_keys + alias has_mfa_enabled? has_mfa_enabled + alias has_console_password? has_console_password def name - @aws_user_details_provider.name + warn "[DEPRECATION] - Property ':name' is deprecated on the aws_iam_user resource. Use ':username' instead." + username end def to_s - "IAM User #{name}" + "IAM User #{username}" end - class AwsIamAccessKeyFactory - def create_access_key(access_key) - AwsIamAccessKey.new({ access_key: access_key }) + private + + def validate_params(raw_params) + validated_params = check_resource_param_names( + raw_params: raw_params, + allowed_params: [:username, :aws_user_struct, :name, :user], + allowed_scalar_name: :username, + allowed_scalar_type: String, + ) + # If someone passed :name, rename it to :username + if validated_params.key?(:name) + warn "[DEPRECATION] - Resource parameter ':name' is deprecated on the aws_iam_user resource. Use ':username' instead." + validated_params[:username] = validated_params.delete(:name) + end + + # If someone passed :user, rename it to :aws_user_struct + if validated_params.key?(:user) + warn "[DEPRECATION] - Resource parameter ':user' is deprecated on the aws_iam_user resource. Use ':aws_user_struct' instead." + validated_params[:aws_user_struct] = validated_params.delete(:user) + end + + if validated_params.empty? + raise ArgumentError, 'You must provide a username to aws_iam_user.' + end + validated_params + end + + def fetch_from_aws + backend = BackendFactory.create + @aws_user_struct ||= nil # silence unitialized warning + unless @aws_user_struct + begin + @aws_user_struct = backend.get_user(user_name: username) + rescue Aws::IAM::Errors::NoSuchEntity + @exists = false + return + end + end + # TODO: extract properties from aws_user_struct? + + @exists = true + + begin + _login_profile = backend.get_login_profile(user_name: username) + @has_console_password = true + # Password age also available here + rescue Aws::IAM::Errors::NoSuchEntity + @has_console_password = false + end + + mfa_info = backend.list_mfa_devices(user_name: username) + @has_mfa_enabled = !mfa_info.mfa_devices.empty? + + # TODO: consider returning Inspec AwsIamAccessKey objects + @access_keys = backend.list_access_keys(user_name: username).access_key_metadata + end + + class Backend + class AwsClientApi + BackendFactory.set_default_backend(self) + + def get_user(criteria) + AWSConnection.new.iam_client.get_user(criteria) + end + + def get_login_profile(criteria) + AWSConnection.new.iam_client.get_login_profile(criteria) + end + + def list_mfa_devices(criteria) + AWSConnection.new.iam_client.list_mfa_devices(criteria) + end + + def list_access_keys(criteria) + AWSConnection.new.iam_client.list_access_keys(criteria) + end end end end diff --git a/libraries/aws_iam_user_details_provider.rb b/libraries/aws_iam_user_details_provider.rb deleted file mode 100644 index c268fdfe8..000000000 --- a/libraries/aws_iam_user_details_provider.rb +++ /dev/null @@ -1,38 +0,0 @@ -# author: Adnan Duric -# author: Steffanie Freeman - -module AwsIam - class UserDetailsProvider - def initialize(user) - @aws_user = user - end - - def exists? - @aws_user.exists? - end - - def name - @aws_user.name - end - - def has_mfa_enabled? - !@aws_user.mfa_devices.first.nil? - end - - def has_console_password? - return !@aws_user.login_profile.create_date.nil? - rescue Aws::IAM::Errors::NoSuchEntity - return false - end - - def access_keys - @aws_user.access_keys - end - end - - class UserDetailsProviderInitializer - def create(user) - UserDetailsProvider.new(user) - end - end -end diff --git a/libraries/aws_iam_user_provider.rb b/libraries/aws_iam_user_provider.rb deleted file mode 100644 index f70057d99..000000000 --- a/libraries/aws_iam_user_provider.rb +++ /dev/null @@ -1,18 +0,0 @@ -# author: Alex Bedley -# author: Steffanie Freeman - -module AwsIam - class UserProvider - def initialize(conn = AWSConnection.new) - @iam_resource = conn.iam_resource - end - - def user(name) - @iam_resource.user(name) - end - - def list_users - @iam_resource.users - end - end -end diff --git a/libraries/aws_iam_users.rb b/libraries/aws_iam_users.rb index 03391e502..40cef1c94 100644 --- a/libraries/aws_iam_users.rb +++ b/libraries/aws_iam_users.rb @@ -19,46 +19,84 @@ class AwsIamUsers < Inspec.resource(1) filter.add_accessor(:where) .add_accessor(:entries) .add(:exists?) { |x| !x.entries.empty? } + .add(:has_mfa_enabled?, field: :has_mfa_enabled) + .add(:has_console_password?, field: :has_console_password) + .add(:username, field: :user_name) filter.connect(self, :collect_user_details) - def initialize( - aws_user_provider = AwsIam::UserProvider.new, - aws_user_details_provider_ini = AwsIam::UserDetailsProviderInitializer.new, - user_factory = AwsIamUserFactory.new - ) - @user_provider = aws_user_provider - @aws_user_details_provider_ini = aws_user_details_provider_ini - @user_factory = user_factory - end + # No resource params => no overridden constructor + # AWS API only offers filtering on path prefix; + # little other opportunity for server-side filtering. def collect_user_details - @users_cache ||= @user_provider.list_users unless @user_provider.nil? - @users_cache.map do |aws_user| - details_provider = @aws_user_details_provider_ini.create(aws_user) - { - name: details_provider.name, - has_mfa_enabled?: details_provider.has_mfa_enabled?, - has_console_password?: details_provider.has_console_password?, - access_keys: details_provider.access_keys, - } - end - end + backend = Backend.create + users = backend.list_users.users.map(&:to_h) - def users - users = [] - users ||= @user_provider.list_users unless @user_provider.nil? - users.map { |user| - @user_factory.create_user(user) - } + # TODO: lazy columns - https://github.com/chef/inspec-aws/issues/100 + users.each do |user| + begin + _login_profile = backend.get_login_profile(user_name: user[:user_name]) + user[:has_console_password] = true + rescue Aws::IAM::Errors::NoSuchEntity + user[:has_console_password] = false + end + user[:has_console_password?] = user[:has_console_password] + + begin + aws_mfa_devices = backend.list_mfa_devices(user_name: user[:user_name]) + user[:has_mfa_enabled] = !aws_mfa_devices.mfa_devices.empty? + rescue Aws::IAM::Errors::NoSuchEntity + user[:has_mfa_enabled] = false + end + user[:has_mfa_enabled?] = user[:has_mfa_enabled] + end + users end def to_s 'IAM Users' end - class AwsIamUserFactory - def create_user(user) - AwsIamUser.new(user: user) + # Entry cooker. Needs discussion. + # def users + # end + + #===========================================================================# + # Backend Implementation + #===========================================================================# + class Backend + #=====================================================# + # Concrete Implementation + #=====================================================# + # Uses AWS API to really talk to AWS + class AwsClientApi < Backend + # TODO: delegate this out + def list_users(query = {}) + AWSConnection.new.iam_client.list_users(query) + end + + def get_login_profile(query) + AWSConnection.new.iam_client.get_login_profile(query) + end + + def list_mfa_devices(query) + AWSConnection.new.iam_client.list_mfa_devices(query) + end + end + + #=====================================================# + # Factory Interface + #=====================================================# + # TODO: move this to a mix-in + DEFAULT_BACKEND = AwsClientApi + @selected_backend = DEFAULT_BACKEND + + def self.create + @selected_backend.new + end + + def self.select(klass) + @selected_backend = klass end end end diff --git a/test/integration/verify/controls/aws_iam_user.rb b/test/integration/verify/controls/aws_iam_user.rb index 10a931c8b..f67fe6bb4 100644 --- a/test/integration/verify/controls/aws_iam_user.rb +++ b/test/integration/verify/controls/aws_iam_user.rb @@ -8,22 +8,11 @@ console_password_enabled_user = attribute( default: 'default.console_password_enabled_user', description: 'Name of IAM user console_password_enabled_user') -access_key_user = attribute( - 'access_key_user', - default: 'default.access_key_user', - description: 'Name of IAM user access_key_user') - -describe aws_iam_user(name: mfa_not_enabled_user) do +describe aws_iam_user(mfa_not_enabled_user) do it { should_not have_mfa_enabled } it { should_not have_console_password } end -describe aws_iam_user(name: console_password_enabled_user) do +describe aws_iam_user(console_password_enabled_user) do it { should have_console_password } end - -aws_iam_user(name: access_key_user).access_keys.each { |access_key| - describe access_key do - it { should be_active } - end -} diff --git a/test/integration/verify/controls/aws_iam_users.rb b/test/integration/verify/controls/aws_iam_users.rb index 75c7487f0..6c53f2544 100644 --- a/test/integration/verify/controls/aws_iam_users.rb +++ b/test/integration/verify/controls/aws_iam_users.rb @@ -1,3 +1,4 @@ -describe aws_iam_users.where(has_console_password?: true).where(has_mfa_enabled?: false) do +describe aws_iam_users.where(has_console_password?: true) + .where(has_mfa_enabled?: false) do it { should exist } end diff --git a/test/unit/resources/aws_iam_user_details_provider_test.rb b/test/unit/resources/aws_iam_user_details_provider_test.rb deleted file mode 100644 index 2e70ad262..000000000 --- a/test/unit/resources/aws_iam_user_details_provider_test.rb +++ /dev/null @@ -1,85 +0,0 @@ -# author: Adnan Duric -# author: Steffanie Freeman -require 'helper' -require 'aws_iam_user_provider' -require 'aws_iam_user_details_provider' - -class AwsIamUserDetailsProviderTest < Minitest::Test - Username = 'test'.freeze - - def setup - @mock_iam_resource = Minitest::Mock.new - @mock_iam_resource_user = Minitest::Mock.new - end - - def test_exists_returns_true - @mock_iam_resource_user.expect :exists?, true - provider = AwsIam::UserDetailsProvider.new(@mock_iam_resource_user) - assert provider.exists? - end - - def test_exists_returns_false - @mock_iam_resource_user.expect :exists?, false - provider = AwsIam::UserDetailsProvider.new(@mock_iam_resource_user) - refute provider.exists? - end - - def test_has_mfa_enabled_returns_true - @mock_iam_resource_user.expect :mfa_devices, ['device'] - provider = AwsIam::UserDetailsProvider.new(@mock_iam_resource_user) - assert provider.has_mfa_enabled? - end - - def test_has_mfa_enabled_returns_false - @mock_iam_resource_user.expect :mfa_devices, [] - provider = AwsIam::UserDetailsProvider.new(@mock_iam_resource_user) - refute provider.has_mfa_enabled? - end - - def test_has_console_password_returns_true - mock_login_profile = Minitest::Mock.new - mock_login_profile.expect :create_date, 'date' - @mock_iam_resource_user.expect :login_profile, mock_login_profile - provider = AwsIam::UserDetailsProvider.new(@mock_iam_resource_user) - assert provider.has_console_password? - end - - def test_has_console_password_returns_false - mock_login_profile = Minitest::Mock.new - mock_login_profile.expect :create_date, nil - @mock_iam_resource_user.expect :login_profile, mock_login_profile - provider = AwsIam::UserDetailsProvider.new(@mock_iam_resource_user) - refute provider.has_console_password? - end - - def test_has_console_password_returns_false_when_nosuchentity - mock_login_profile = Minitest::Mock.new - mock_login_profile.expect :create_date, nil do || - raise Aws::IAM::Errors::NoSuchEntity.new(nil, nil) - end - @mock_iam_resource_user.expect :login_profile, mock_login_profile - provider = AwsIam::UserDetailsProvider.new(@mock_iam_resource_user) - refute provider.has_console_password? - end - - def test_has_console_password_throws - mock_login_profile = Minitest::Mock.new - mock_login_profile.expect :create_date, nil do || - raise ArgumentError - end - @mock_iam_resource_user.expect :login_profile, mock_login_profile - - assert_raises ArgumentError do - provider = AwsIam::UserDetailsProvider.new(@mock_iam_resource_user) - provider.has_console_password? - end - end - - def test_access_keys_returns_access_keys - access_key = Object.new - @mock_iam_resource_user.expect :access_keys, [access_key] - - provider = AwsIam::UserDetailsProvider.new(@mock_iam_resource_user) - assert_equal [access_key], provider.access_keys - end -end diff --git a/test/unit/resources/aws_iam_user_provider_test.rb b/test/unit/resources/aws_iam_user_provider_test.rb deleted file mode 100644 index 88bfb8b5c..000000000 --- a/test/unit/resources/aws_iam_user_provider_test.rb +++ /dev/null @@ -1,52 +0,0 @@ -# author: Simon Varlow -# author: Jeffrey Lyons -# author: Steffanie Freeman -# author: Alex Bedley -require 'helper' -require 'aws_iam_user_provider' - -class AwsIamUserProviderTest < Minitest::Test - Username = 'test'.freeze - - def setup - @mock_iam_resource = Minitest::Mock.new - @mock_aws_connection = Minitest::Mock.new - @mock_aws_connection.expect :iam_resource, @mock_iam_resource - @user_provider = AwsIam::UserProvider.new(@mock_aws_connection) - @mock_iam_resource_user = Minitest::Mock.new - end - - def test_user - @mock_iam_resource_user.expect :nil?, false - @mock_iam_resource_user.expect :name, Username - @mock_iam_resource.expect :user, @mock_iam_resource_user, [Username] - refute @user_provider.user(Username).nil? - end - - def test_list_users - mock_users = - [ - @mock_iam_resource_user, - @mock_iam_resource_user, - @mock_iam_resource_user, - ] - mock_users.map do || - @mock_iam_resource_user.expect :name, Username - end - - @mock_iam_resource.expect :users, mock_users - assert_equal( - @user_provider.list_users, - [ - @mock_iam_resource_user, - @mock_iam_resource_user, - @mock_iam_resource_user, - ], - ) - end - - def test_list_users_no_users - @mock_iam_resource.expect :users, [] - assert @user_provider.list_users == [] - end -end diff --git a/test/unit/resources/aws_iam_user_test.rb b/test/unit/resources/aws_iam_user_test.rb index aa4d065da..3585b8824 100644 --- a/test/unit/resources/aws_iam_user_test.rb +++ b/test/unit/resources/aws_iam_user_test.rb @@ -2,121 +2,255 @@ require 'helper' require 'aws_iam_user' -# rubocop:disable Metrics/ClassLength -class AwsIamUserTest < Minitest::Test - Username = 'test'.freeze +# MAUIB = MockAwsIamUserBackend +# Abbreviation not used outside this file + +#=============================================================================# +# Constructor Tests +#=============================================================================# +class AwsIamUserConstructorTest < Minitest::Test def setup - @mock_user_provider = Minitest::Mock.new - @mock_dets_provider = Minitest::Mock.new - @mock_dets_prov_ini = Minitest::Mock.new - @mock_user = { name: Username } + AwsIamUser::BackendFactory.select(MAIUB::Three) end - def test_that_exists_returns_true_if_user_exists - @mock_user_provider.expect :user, @mock_user, [Username] - @mock_dets_provider.expect :exists?, true - @mock_dets_prov_ini.expect :create, @mock_dets_provider, [@mock_user] - assert AwsIamUser.new( - @mock_user, - @mock_user_provider, - @mock_dets_prov_ini, - ).exists? + def test_empty_params_throws_exception + assert_raises(ArgumentError) { AwsIamUser.new } end - def test_that_exists_returns_false_if_user_does_not_exist - @mock_user_provider.expect :user, @mock_user, [Username] - @mock_dets_provider.expect :exists?, false - @mock_dets_prov_ini.expect :create, @mock_dets_provider, [@mock_user] - refute AwsIamUser.new( - @mock_user, - @mock_user_provider, - @mock_dets_prov_ini, - ).exists? + def test_accepts_username_as_scalar + AwsIamUser.new('erin') end - def test_that_mfa_enable_returns_true_if_mfa_enabled - @mock_user_provider.expect :user, @mock_user, [Username] - @mock_dets_provider.expect :has_mfa_enabled?, true - @mock_dets_prov_ini.expect :create, @mock_dets_provider, [@mock_user] - assert AwsIamUser.new( - @mock_user, - @mock_user_provider, - @mock_dets_prov_ini, - ).has_mfa_enabled? + def test_accepts_username_as_hash + AwsIamUser.new(username: 'erin') end - def test_that_mfa_enable_returns_false_if_mfa_is_not_enabled - @mock_user_provider.expect :user, @mock_user, [Username] - @mock_dets_provider.expect :has_mfa_enabled?, false - @mock_dets_prov_ini.expect :create, @mock_dets_provider, [@mock_user] - refute AwsIamUser.new( - @mock_user, - @mock_user_provider, - @mock_dets_prov_ini, - ).has_mfa_enabled? - end - - def test_that_console_password_returns_true_if_console_password_set - @mock_user_provider.expect :user, @mock_user, [Username] - @mock_dets_provider.expect :has_console_password?, true - @mock_dets_prov_ini.expect :create, @mock_dets_provider, [@mock_user] - assert AwsIamUser.new( - @mock_user, - @mock_user_provider, - @mock_dets_prov_ini, - ).has_console_password? - end - - def test_that_console_password_returns_false_if_console_password_not_set - @mock_user_provider.expect :user, @mock_user, [Username] - @mock_dets_provider.expect :has_console_password?, false - @mock_dets_prov_ini.expect :create, @mock_dets_provider, [@mock_user] - refute AwsIamUser.new( - @mock_user, - @mock_user_provider, - @mock_dets_prov_ini, - ).has_console_password? - end - - def test_that_access_keys_returns_aws_iam_access_key_resources - stub_aws_access_key = Object.new - stub_access_key_resource = Object.new - mock_access_key_factory = Minitest::Mock.new - - @mock_user_provider.expect :user, @mock_user, [Username] - @mock_dets_provider.expect :access_keys, [stub_aws_access_key] - @mock_dets_prov_ini.expect :create, @mock_dets_provider, [@mock_user] - mock_access_key_factory.expect( - :create_access_key, - stub_access_key_resource, - [stub_aws_access_key], - ) - - assert_equal( - stub_access_key_resource, - AwsIamUser.new( - @mock_user, - @mock_user_provider, - @mock_dets_prov_ini, - mock_access_key_factory, - ).access_keys[0], - ) - - mock_access_key_factory.verify - end - - def test_to_s - test_user = { name: Username, has_mfa_enabled?: true } - @mock_user_provider.expect :user, test_user, [Username] - @mock_dets_provider.expect :name, Username - @mock_dets_prov_ini.expect :create, @mock_dets_provider, [test_user] - expected = "IAM User #{Username}" - test = AwsIamUser.new( - { name: Username }, - @mock_user_provider, - @mock_dets_prov_ini, - ).to_s - assert_equal expected, test + def test_rejects_unrecognized_params + assert_raises(ArgumentError) { AwsIamUser.new(shoe_size: 9) } end end + +#=============================================================================# +# Search / Recall +#=============================================================================# +class AwsIamUserRecallTest < Minitest::Test + def setup + AwsIamUser::BackendFactory.select(MAIUB::Three) + end + + def test_search_miss_is_not_an_exception + user = AwsIamUser.new('tommy') + refute user.exists? + end + + def test_search_hit_via_scalar_works + user = AwsIamUser.new('erin') + assert user.exists? + assert_equal('erin', user.username) + end + + def test_search_hit_via_hash_works + user = AwsIamUser.new(username: 'erin') + assert user.exists? + assert_equal('erin', user.username) + end +end + +#=============================================================================# +# Properties +#=============================================================================# + +class AwsIamUserPropertiesTest < Minitest::Test + def setup + AwsIamUser::BackendFactory.select(MAIUB::Three) + end + + #-----------------------------------------------------# + # username property + #-----------------------------------------------------# + def test_property_username_correct_on_hit + user = AwsIamUser.new(username: 'erin') + assert_equal('erin', user.username) + end + + #-----------------------------------------------------# + # has_console_password property and predicate + #-----------------------------------------------------# + def test_property_password_positive + user = AwsIamUser.new(username: 'erin') + assert_equal(true, user.has_console_password) + assert_equal(true, user.has_console_password?) + end + + def test_property_password_negative + user = AwsIamUser.new(username: 'leslie') + assert_equal(false, user.has_console_password) + assert_equal(false, user.has_console_password?) + end + + #-----------------------------------------------------# + # has_mfa_enabled property and predicate + #-----------------------------------------------------# + def test_property_mfa_positive + user = AwsIamUser.new(username: 'erin') + assert_equal(true, user.has_mfa_enabled) + assert_equal(true, user.has_mfa_enabled?) + end + + def test_property_mfa_negative + user = AwsIamUser.new(username: 'leslie') + assert_equal(false, user.has_mfa_enabled) + assert_equal(false, user.has_mfa_enabled?) + end + + #-----------------------------------------------------# + # access_keys property + #-----------------------------------------------------# + def test_property_access_keys_positive + keys = AwsIamUser.new(username: 'erin').access_keys + assert_kind_of(Array, keys) + assert_equal(keys.length, 2) + # We don't currently promise that the results + # will be Inspec resource objects. + # assert_kind_of(AwsIamAccessKey, keys.first) + end + + def test_property_access_keys_negative + keys = AwsIamUser.new(username: 'leslie').access_keys + assert_kind_of(Array, keys) + assert(keys.empty?) + end +end + +#=============================================================================# +# Test Fixtures +#=============================================================================# + +module MAIUB + class Three < AwsIamUser::Backend + def get_user(criteria) + people = { + 'erin' => OpenStruct.new({ + user: OpenStruct.new({ + arn: "arn:aws:iam::123456789012:user/erin", + create_date: Time.parse("2016-09-21T23:03:13Z"), + path: "/", + user_id: "AKIAIOSFODNN7EXAERIN", + user_name: "erin", + }), + }), + 'leslie' => OpenStruct.new({ + user: OpenStruct.new({ + arn: "arn:aws:iam::123456789012:user/leslie", + create_date: Time.parse("2017-09-21T23:03:13Z"), + path: "/", + user_id: "AKIAIOSFODNN7EXAERIN", + user_name: "leslie", + }), + }), + 'jared' => OpenStruct.new({ + user: OpenStruct.new({ + arn: "arn:aws:iam::123456789012:user/jared", + create_date: Time.parse("2017-09-21T23:03:13Z"), + path: "/", + user_id: "AKIAIOSFODNN7EXAERIN", + user_name: "jared", + }), + }), + } + raise Aws::IAM::Errors::NoSuchEntity.new(nil, nil) unless people.key?(criteria[:user_name]) + people[criteria[:user_name]] + end + + def get_login_profile(criteria) + # Leslie has no password + # Jared's is expired + people = { + 'erin' => OpenStruct.new({ + login_profile: OpenStruct.new({ + user_name: 'erin', + password_reset_required: false, + create_date: Time.parse("2016-09-21T23:03:13Z"), + }), + }), + 'jared' => OpenStruct.new({ + login_profile: OpenStruct.new({ + user_name: 'jared', + password_reset_required: true, + create_date: Time.parse("2017-09-21T23:03:13Z"), + }), + }), + } + raise Aws::IAM::Errors::NoSuchEntity.new(nil, nil) unless people.key?(criteria[:user_name]) + people[criteria[:user_name]] + end + def list_mfa_devices(criteria) + # Erin has 2, one soft and one hw + # Leslie has none + # Jared has one soft + people = { + 'erin' => OpenStruct.new({ + mfa_devices: [ + OpenStruct.new({ + user_name: 'erin', + serial_number: 'arn:blahblahblah', + enable_date: Time.parse("2016-09-21T23:03:13Z"), + }), + OpenStruct.new({ + user_name: 'erin', + serial_number: '1234567890', + enable_date: Time.parse("2016-09-21T23:03:13Z"), + }), + ] + }), + 'leslie' => OpenStruct.new({mfa_devices: []}), + 'jared' => OpenStruct.new({ + mfa_devices: [ + OpenStruct.new({ + user_name: 'jared', + serial_number: 'arn:blahblahblah', + enable_date: Time.parse("2016-09-21T23:03:13Z"), + }), + ] + }), + } + people[criteria[:user_name]] + end + def list_access_keys(criteria) + # Erin has 2 + # Leslie has none + # Jared has one + people = { + 'erin' => OpenStruct.new({ + access_key_metadata: [ + OpenStruct.new({ + user_name: 'erin', + access_key_id: 'AKIA111111111EXAMPLE', + create_date: Time.parse("2016-09-21T23:03:13Z"), + status: 'Active', + }), + OpenStruct.new({ + user_name: 'erin', + access_key_id: 'AKIA222222222EXAMPLE', + create_date: Time.parse("2016-09-21T23:03:13Z"), + status: 'Active', + }), + ] + }), + 'leslie' => OpenStruct.new({access_key_metadata: []}), + 'jared' => OpenStruct.new({ + access_key_metadata: [ + OpenStruct.new({ + user_name: 'jared', + access_key_id: 'AKIA3333333333EXAMPLE', + create_date: Time.parse("2017-10-21T23:03:13Z"), + status: 'Active', + }), + ] + }), + } + people[criteria[:user_name]] + end + end +end \ No newline at end of file diff --git a/test/unit/resources/aws_iam_users_test.rb b/test/unit/resources/aws_iam_users_test.rb index 6b6a5f921..f10f32a82 100644 --- a/test/unit/resources/aws_iam_users_test.rb +++ b/test/unit/resources/aws_iam_users_test.rb @@ -1,107 +1,152 @@ -# author: Adnan Duric -# author: Steffanie Freeman -# author: Simon Varlow -# author: Chris Redekop require 'helper' +require 'ostruct' require 'aws_iam_users' -class AwsIamUsersTest < Minitest::Test +# Maiusb = Mock AwsIamUsers::Backend +# Abbreviation not used outside of this file + +class AwsIamUsersTestConstructor < Minitest::Test def setup - @mock_user_factory = Minitest::Mock.new - @mock_user_details_provider_initializer = Minitest::Mock.new + AwsIamUsers::Backend.select(Maiusb::Empty) end - def test_users_nil_user_provider_returns_empty_list - cut = AwsIamUsers.new(nil, nil, @mock_user_factory) - - assert_equal(cut.users, []) + def test_users_no_params_does_not_explode + AwsIamUsers.new end - def test_users_empty_list_user_provider_returns_empty_list - cut = AwsIamUsers.new( - create_mock_user_provider, - create_mock_user_details_provider_ini, - @mock_user_factory, - ) - - assert_equal(cut.users, []) - end - - def test_users_returns_true_for_all_users_if_mfa_enabled - cut = AwsIamUsers.new( - create_mock_user_provider(create_mock_users([true, true])), - create_mock_user_details_provider_ini, - @mock_user_factory, - ) - - cut.users.each do |user| - assert user.has_mfa_enabled? - end - end - - [ - { - name: 'test_where_returns_no_matching_rows', - user_material: [false], - }, { - name: 'test_where_returns_some_matching_rows', - user_material: [true, false], - }, { - name: 'test_where_returns_all_matching_rows', - user_material: [true], - } - ].each do |test_material| - define_method(test_material[:name]) do - cut = create_cut(test_material) - - results = cut.where(has_mfa_enabled?: true) - expected_count = test_material[:user_material].count { |x| x } - - assert_equal expected_count > 0, results.exists? - assert_equal expected_count, results.entries.length - end - end - - def create_cut(user_list = []) - mock_user_details_provider_ini = create_mock_user_details_provider_ini( - user_list[:user_material], - ) - - AwsIamUsers.new( - create_mock_user_provider( - user_list[:user_material], - ), - mock_user_details_provider_ini, - @mock_user_factory, - ) - end - - def create_mock_user_details_provider_ini(attr_value_list = []) - mock_dets_provider_ini = Minitest::Mock.new - attr_value_list.each do |attr_val| - mock_dets_provider = Minitest::Mock.new - mock_dets_provider.expect :name, nil - mock_dets_provider.expect :has_mfa_enabled?, attr_val - mock_dets_provider.expect :has_console_password?, nil - mock_dets_provider.expect :access_keys, [] - mock_dets_provider_ini.expect :create, mock_dets_provider, [Object] - end - mock_dets_provider_ini - end - - def create_mock_user_provider(user_list = []) - mock_user_provider = Minitest::Mock.new - - mock_user_provider.expect :list_users, user_list - mock_user_provider.expect :nil?, false - mock_user_provider - end - - def create_mock_users(has_mfa_enableds = []) - has_mfa_enableds.map { |x| create_mock_user(x) } - end - - def create_mock_user(has_mfa_enabled = true) - { has_mfa_enabled?: has_mfa_enabled } + def test_users_all_params_rejected + assert_raises(ArgumentError) { AwsIamUsers.new(something: 'somevalue') } + end +end + +class AwsIamUsersTestFilterCriteria < Minitest::Test + def setup + # Reset to empty, that's harmless + AwsIamUsers::Backend.select(Maiusb::Empty) + end + + #------------------------------------------# + # Open Filter + #------------------------------------------# + def test_users_empty_result_when_no_users_no_criteria + users = AwsIamUsers.new.where {} + assert users.entries.empty? + end + + def test_users_all_returned_when_some_users_no_criteria + AwsIamUsers::Backend.select(Maiusb::Basic) + users = AwsIamUsers.new.where {} + assert(3, users.entries.count) + end + + #------------------------------------------# + # has_mfa_enabled? + #------------------------------------------# + def test_users_criteria_has_mfa_enabled + AwsIamUsers::Backend.select(Maiusb::Basic) + users = AwsIamUsers.new.where { has_mfa_enabled } + assert(1, users.entries.count) + assert_includes users.entries.map{ |u| u[:user_name] }, 'carol' + refute_includes users.entries.map{ |u| u[:user_name] }, 'alice' + end + + #------------------------------------------# + # has_console_password? + #------------------------------------------# + def test_users_criteria_has_console_password? + AwsIamUsers::Backend.select(Maiusb::Basic) + users = AwsIamUsers.new.where { has_console_password } + assert(2, users.entries.count) + assert_includes users.entries.map{ |u| u[:user_name] }, 'carol' + refute_includes users.entries.map{ |u| u[:user_name] }, 'alice' + end +end + +#=============================================================================# +# Test Fixture Classes +#=============================================================================# +module Maiusb + + # -------------------------------- + # Empty - No users + # -------------------------------- + class Empty < AwsIamUsers::Backend + def list_users + OpenStruct.new({ + users: [] + }) + end + + def get_login_profile(criteria) + raise Aws::IAM::Errors::NoSuchEntity.new("No login profile for #{criteria[:user_name]}", 'Nope') + end + + def list_mfa_devices(_criteria) + OpenStruct.new({ + mfa_devices: [] + }) + end + end + + # -------------------------------- + # Basic - 3 Users + # -------------------------------- + # Alice has no password or MFA device + # Bob has a password but no MFA device + # Carol has a password and MFA device + class Basic < AwsIamUsers::Backend + # arn, path, user_id omitted + def list_users + OpenStruct.new({ + users: [ + OpenStruct.new({ + user_name: 'alice', + create_date: DateTime.parse('2017-10-10T16:19:30Z'), + # Password last used is absent, never logged in w/ password + }), + OpenStruct.new({ + user_name: 'bob', + create_date: DateTime.parse('2017-11-06T16:19:30Z'), + password_last_used: DateTime.parse('2017-11-06T19:19:30Z'), + }), + OpenStruct.new({ + user_name: 'carol', + create_date: DateTime.parse('2017-10-10T16:19:30Z'), + password_last_used: DateTime.parse('2017-10-28T19:19:30Z'), + }), + ] + }) + end + + def get_login_profile(criteria) + if ['bob', 'carol'].include?(criteria[:user_name]) + OpenStruct.new({ + login_profile: OpenStruct.new({ + user_name: criteria[:user_name], + created_date: DateTime.parse('2017-10-10T16:19:30Z') + }) + }) + else + raise Aws::IAM::Errors::NoSuchEntity.new("No login profile for #{criteria[:user_name]}", 'Nope') + end + end + + def list_mfa_devices(criteria) + if ['carol'].include?(criteria[:user_name]) + OpenStruct.new({ + mfa_devices: [ + OpenStruct.new({ + user_name: criteria[:user_name], + serial_number: '1234567890', + enable_date: DateTime.parse('2017-10-10T16:19:30Z'), + }) + ] + }) + else + OpenStruct.new({ + mfa_devices: [] + }) + end + end end end From cdbe8c1016ebb64374176ae6889f92586575ce2f Mon Sep 17 00:00:00 2001 From: Russell Seymour Date: Tue, 12 Dec 2017 18:20:22 +0000 Subject: [PATCH 136/206] Create azure_generic_resource * Rewrite of Inspec Azure Resource pack to allow the testing of _any_ value Azure reosurce. Closes #36 Closes #37 This fixes #56 so that it works with the latest version of the SDK. In fact it will only work to version >= 0.15 Signed-off-by: Russell Seymour --- .gitignore | 3 + .rspec | 2 + .rubocop.yml | 8 +- Gemfile | 4 +- README.md | 174 ++----- docs/resources/azure_generic_resource.md | 81 +++ docs/resources/azure_resource_group.md | 147 ++++-- docs/resources/azure_virtual_machine.md | 312 +++++++++--- .../azure_virtual_machine_datadisk.md | 200 ++++++++ .../azure_virtual_machine_datadisks.md | 77 --- images/linux_internal_vm_resource.png | Bin 0 -> 197489 bytes inspec.yml | 2 +- lib/resources/azure/azure_backend.rb | 477 ++++++++++++++---- lib/resources/azure/azure_generic_resource.rb | 54 ++ lib/resources/azure/azure_resource_group.rb | 298 +++++------ lib/resources/azure/azure_virtual_machine.rb | 455 +++++++---------- .../azure/azure_virtual_machine_data_disk.rb | 131 +++++ libraries/azure_virtual_machine_datadisks.rb | 137 ----- test/integration/azure/build/azure.tf | 126 ++++- .../azure/verify/controls/datadisks.rb | 24 - .../verify/controls/generic_external_vm.rb | 47 ++ .../controls/generic_external_vm_nic.rb | 32 ++ .../verify/controls/generic_internal_vm.rb | 47 ++ .../controls/generic_internal_vm_nic.rb | 32 ++ .../generic_linux_vm_managed_os_disk.rb | 32 ++ .../generic_network_security_group.rb | 21 + .../controls/generic_public_ip_address.rb | 26 + .../verify/controls/generic_resources.rb | 26 + .../controls/generic_storage_account.rb | 34 ++ .../controls/generic_virtual_network.rb | 27 + .../controls/generic_windows_internal_vm.rb | 49 ++ .../generic_windows_internal_vm_nic.rb | 32 ++ .../azure/verify/controls/resource_group.rb | 59 +++ .../azure/verify/controls/resources.rb | 19 - .../controls/virtual_machine_external_vm.rb | 69 +++ .../controls/virtual_machine_internal_vm.rb | 67 +++ ...tual_machine_linux_external_vm_datadisk.rb | 20 + .../virtual_machine_windows_internal_vm.rb | 71 +++ ...al_machine_windows_internal_vm_datadisk.rb | 25 + test/integration/azure/verify/controls/vm.rb | 37 -- 40 files changed, 2407 insertions(+), 1077 deletions(-) create mode 100644 .rspec create mode 100644 docs/resources/azure_generic_resource.md create mode 100644 docs/resources/azure_virtual_machine_datadisk.md delete mode 100644 docs/resources/azure_virtual_machine_datadisks.md create mode 100644 images/linux_internal_vm_resource.png create mode 100644 lib/resources/azure/azure_generic_resource.rb create mode 100644 lib/resources/azure/azure_virtual_machine_data_disk.rb delete mode 100644 libraries/azure_virtual_machine_datadisks.rb delete mode 100644 test/integration/azure/verify/controls/datadisks.rb create mode 100644 test/integration/azure/verify/controls/generic_external_vm.rb create mode 100644 test/integration/azure/verify/controls/generic_external_vm_nic.rb create mode 100644 test/integration/azure/verify/controls/generic_internal_vm.rb create mode 100644 test/integration/azure/verify/controls/generic_internal_vm_nic.rb create mode 100644 test/integration/azure/verify/controls/generic_linux_vm_managed_os_disk.rb create mode 100644 test/integration/azure/verify/controls/generic_network_security_group.rb create mode 100644 test/integration/azure/verify/controls/generic_public_ip_address.rb create mode 100644 test/integration/azure/verify/controls/generic_resources.rb create mode 100644 test/integration/azure/verify/controls/generic_storage_account.rb create mode 100644 test/integration/azure/verify/controls/generic_virtual_network.rb create mode 100644 test/integration/azure/verify/controls/generic_windows_internal_vm.rb create mode 100644 test/integration/azure/verify/controls/generic_windows_internal_vm_nic.rb create mode 100644 test/integration/azure/verify/controls/resource_group.rb delete mode 100644 test/integration/azure/verify/controls/resources.rb create mode 100644 test/integration/azure/verify/controls/virtual_machine_external_vm.rb create mode 100644 test/integration/azure/verify/controls/virtual_machine_internal_vm.rb create mode 100644 test/integration/azure/verify/controls/virtual_machine_linux_external_vm_datadisk.rb create mode 100644 test/integration/azure/verify/controls/virtual_machine_windows_internal_vm.rb create mode 100644 test/integration/azure/verify/controls/virtual_machine_windows_internal_vm_datadisk.rb delete mode 100644 test/integration/azure/verify/controls/vm.rb diff --git a/.gitignore b/.gitignore index ea4f7bd3c..a2936e121 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ inspec.lock .kitchen *.plan *.tfstate* +local +.vscode +.terraform \ No newline at end of file diff --git a/.rspec b/.rspec new file mode 100644 index 000000000..83e16f804 --- /dev/null +++ b/.rspec @@ -0,0 +1,2 @@ +--color +--require spec_helper diff --git a/.rubocop.yml b/.rubocop.yml index c777f777f..29d0273ab 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -42,9 +42,9 @@ Style/PercentLiteralDelimiters: '%w': '{}' '%W': () '%x': () -Style/AlignHash: +Layout/AlignHash: Enabled: false -Style/PredicateName: +Naming/PredicateName: Enabled: false Style/ClassAndModuleChildren: Enabled: false @@ -56,7 +56,7 @@ Style/AndOr: Enabled: false Style/Not: Enabled: false -Style/FileName: +Naming/FileName: Enabled: false Style/TrailingCommaInLiteral: EnforcedStyleForMultiline: comma @@ -68,7 +68,7 @@ Style/UnlessElse: Enabled: false BlockDelimiters: Enabled: false -Style/SpaceAroundOperators: +Layout/SpaceAroundOperators: Enabled: false Style/IfUnlessModifier: Enabled: false diff --git a/Gemfile b/Gemfile index f2afcefbd..10150d521 100644 --- a/Gemfile +++ b/Gemfile @@ -1,9 +1,7 @@ source 'https://rubygems.org' gem 'inifile' -gem 'azure_mgmt_compute', '= 0.14.0' -gem 'azure_mgmt_resources', '= 0.14.0' -gem 'azure_mgmt_network', '= 0.14.0' +gem 'azure_mgmt_resources', '~> 0.15' gem 'bundle' group :development do diff --git a/README.md b/README.md index 8edd6a297..f031f7b72 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,18 @@ # InSpec for Azure +This Resource Pack has been been completely rewritten so that it is able to check for _any_ resource in Azure, previous versions only supported 4 resource types. + +Whenever an Azure resource is retrieved its attributes are interrogated and turned into things that can be called during tests on that particular resource. By making the profile dynamic it means that more or less any resource that is available in Azure can be tested. + +To determine what methods can be called against an Azure resource please find the resource in https://resources.azure.com. The attributes of the resource are turned into the methods that can be called. Please refer to the examples further down the page or the integration tests for more information. + +The only caveat to this is that the generic resource cannot interrogate an Azure resource group itself. There is an InSpec resource called `azure_resource_group` that permits this. + +**The resource pack not only contains the generic resource but also expliclit tests for some resources. These are similar to the ones that were in previous versions, but now have different properties and attributes that may break existing tests. Please review the documentation for the resource you are using.** + ## Roadmap -This repository is the development repository for InSpec for VmWare. Once [RFC Platforms](https://github.com/chef/inspec/issues/1661) is fully implemented in InSpec, this repository is going to be merged into core InSpec. +This repository is the development repository for InSpec for Azure. Once [RFC Platforms](https://github.com/chef/inspec/issues/1661) is fully implemented in InSpec, this repository is going to be merged into core InSpec. As of now, Azure resources are implemented as an InSpec resource pack. It will ship with the required resources to write your own Azure tests: @@ -14,7 +24,7 @@ As of now, Azure resources are implemented as an InSpec resource pack. It will s ## Get Started -This profile uses the Azure Ruby SDK and as such requires a Service Principal Name (SPN) to be created in the Azure subscription that is being tested. +This profile uses one of the Azure Ruby SDK libraries 'Azure Resource Management' and as such requires a Service Principal Name (SPN) to be created in the Azure subscription that is being tested. This can be done on the command line or from the Azure Portal @@ -100,149 +110,39 @@ depends: ### Add controls -Since your profile depends on the resource pack, you can use those resources in your own profile: +Since your profile depends on the InSpec resource pack, you can use those resources in your own profile. In this example a virtual machine in the specified Azure resource group is being tested. + +Using the https://resources.azure.com portal the virtual machine has the following attributes. + +![Virtual Machine Attributes](images/linux_internal_vm_resource.png) + +Using this information tests can be written, for example: ```ruby control 'azure-1' do impact 1.0 title 'Checks that the machine was built from the correct image' - describe azure_virtual_machine(name: 'example-01', resource_group: 'MyResourceGroup') do - its('sku') { should eq '16.04.0-LTS' } - its('publisher') { should ieq 'Canonical' } - its('offer') { should ieq 'UbuntuServer' } + describe azure_generic_resource(group_name: 'Inspec-Azure', name: 'Linux-Internal-VM') do + its('properties.storageProfile.imageReference.publisher') { should cmp 'Canonical' } + its('properties.storageProfile.imageReference.offer') { should cmp 'UbuntuServer' } + its('properties.storageProfile.imageReference.sku') { should cmp '16.04.0-LTS' } end end ``` +There are a few different ways in which tests can be written, which mean that arrays can be tested. A lot of the different ways and techniques are shown and documented in the integrations tests which are highly recommended reading. + ### Available Resources -- `azure_resource_group` - This resource reads information about the resources in the specified resource group +The following resources are available in the Inspec Azure Profile -| Resource Name | Resources | Description | -|---------------|-----------|-------------| -| azure_rg | location | Where the item has been deployed | -| | total | The total number of resources in the resource group | -| | nic_count | Helper method to return the number of Network Interface Cards (NIC) that exist | -| | vm_count | Helper method to return the number of Virtual Machines(VM) that exist | -| | vnet_count | Helper method to return the number of Virtual Networks (VNET) that exist | -| | nsg_count | Helper method to return the number of Network Security Groups (NSG) that exist | -| | sa_count | Helper method to return the number of Storage Accounts (SA) that exist | -| | public_ip_count | Helper method to return the number of Public IP Addresses that exist | -| | managed_disk_image_count | Helper method to return the number of Managed Disk Images that exist | -| | managed_disk_count | Helper method to return the number of Managed Disks that exist | -| | contains | Used to determine if a specific item exists | + - [Azure Generic Resource](docs/resources/azure_generic_resource.md) + - [Azure Resource Group](docs/resources/azure_resource_group.md) + - [Azure Virtual Machine](docs/resources/azure_virtual_machine.md) + - [Azure Data Disk](docs/resources/azure_virtual_machine_data_disk.md) -This resource also has a FilterTable which means that it is possible to check for items that do not yet have a helper method. - -#### Test for the number of storage accounts - -```ruby -control 'azure-1' do - impact 1.0 - title 'Checks that there is only one storage account in the resource group' - - describe azure_resource_group(name: 'MyResourceGroup').where { type == 'Microsoft.Storage/storageAccounts' }.entries do - its('count') { should eq 1 } - end -end -``` - -#### Ensure that a specific item exists - -```ruby -control 'azure-1' do - impact 1.0 - title 'Checks a resource with the name "example-VM" exists' - - describe azure_resource_group(name: 'MyResourceGroup').contains(parameter: 'name', value: 'example-VM') do - it { should be true } - end -end -``` - -- `azure_vm` - This resource reads information about a virtual machine in the specified resource group - -| Resource Name | Resources | Description | -|---------------|-----------|-------------| -| azure_vm | publisher | Publisher that provided the image in the marketplace | -| | offer | The offer of the image | -| | sku | The SKU being used | -| | size | The size of the machine | -| | location | Where the machine has been deployed | -| | has_boot_diagnostics? | Whether boot diagnostics have been enabled or not | -| | nic_count | How many network cards are attached to the machine | -| | admin_username | The admin username that has been assigned to the machine | -| | computername | Computer name of the machine in the operating system. This maybe different to the VM name as seen in Azure | -| | hostname | Alias for computername | -| | password_authentication? | If password authentication is enabled. For Windows machines this is always true | -| | ssh_key_count | How many SSH public keys have been added to the machine. For Windows this is always 0 | -| | os_type | The type of operating system. Linux or Windows | -| | private_ipaddresses | Returns an array of all the IP addresses for all the NICs on the machine | -| | has_public_ipaddress? | Whether the machine has been allocated an IP address or not | -| | domain_name_label | If the machine has a public IP address then return the domain name label it has been assigned | - -For the resources that start with `has_` the following construct can be used - -```ruby -it { should have_boot_diagnostics } -``` - -- `azure_vm_datadisks` - Resource to read the data disks for a machine and check that they are of the correct size etc - -| Resource Name | Resources | Description | -|---------------|-----------|-------------| -| azure_vm_datadisks | has_data_disks? | Boolean test to see if a machine has datadisks | -| | count | Returns the number of data disks attached to the machine | -| | where | Filter that allows for different tests to be performed, see examples below | - -The data disks resource now supports Managed Disks, however the data that is returned is different from that for a VHD. - -When data disks are retrieved from a machine they are given as an array. The `where` filter will interogate the array according the criteria it is given. - -For items that do not exist a `nil` value will be returned. - -###### VHD propertiies - -The followin attributes are available in the filter: - - - `disk` - Disk number (0 index based) - - `name` - Name of the disk - - `size` - The size of the disk in GB - - `caching` - What sort of caching is enabled on the data disk - - `create_option` - How the disk was created - - `lun` - The LUN number - - `uri` - Full URI to the disk in Blob storage - - `storage_account` - The name of the storage account in which the Blob storage exists - -###### Managed Disk Properties - -The following attributes are available in the filter: - - - `disk` - Disk number (0 index based) - - `name` - Name of the disk - - `size` - The size of the disk in GB - - `location` - Location of the disk - - `account_type` - The stoarge type. e.g. `Standard_LRS` - - -#### Test for 1 disk with a size greater than 10gb - -```ruby -control 'azure-1' do - impact 1.0 - title 'Checks that the machine has exactly one data disk and it is over 10gb in size' - - describe azure_virtual_machine(name: 'example-01', resource_group: 'MyResourceGroup') do - its('has_disks?') { should be true } - its('count') { should eq 1 } - end - - describe azure_virtual_machine_datadisks(name: 'example-01', resource_group: 'MyResourceGroup').where { disk == 0 and size > 10 } do - its('entries') { should_not be_empty } - end -end -``` +When multiple resources are returned a FilterTable is created. This means it is possible to retrieve all of hte resources in a resource group and interrogate them within the testing block using the `.where` function on the FilterTable. Please refer to the `test/integration/verify/controls/resources.rb` file for an example of this. ## Integration Testing @@ -257,7 +157,17 @@ rake rubocop # Run Rubocop lint checks rake test:integration # Perform Integration Tests ``` -As with using the resources themselves the integration tests rely on a Service Principal Name being defined. Please see the information at the start of this page on how to generate this. +As with using the InSpec resources themselves the integration tests rely on a Service Principal Name being defined. Please see the information at the start of this page on how to generate this. + +Due to the fact that any Azure resource can now be tested the integration tests check the following Azure resource types: + + - Microsoft.Compute/virtualMachines + - Microsoft.Compute/disks + - Microsoft.Network/networkInterfaces + - Microsoft.Network/virtualNetworks + - Microsoft.Network/networkSecurityGroups + - Microsoft.Network/publicIPAddresses + - Microsoft.Storage/storageAccounts ## License diff --git a/docs/resources/azure_generic_resource.md b/docs/resources/azure_generic_resource.md new file mode 100644 index 000000000..2fc2d60bb --- /dev/null +++ b/docs/resources/azure_generic_resource.md @@ -0,0 +1,81 @@ +--- +title: About the azure_generic_resource Resource +--- + +# azure_generic_resource + +Use the `azure_generic_resource` Inspec audit resource to test any valid Azure Resource. This is very useful if you need to test something that we do not yet have a specific Inspec resource for. + +## Syntax + +```ruby +describe azure_generic_resource(group_name: 'MyResourceGroup', name: 'MyResource') do + its('property') { should eq 'value' } +end +``` + +where + +* Resource Parameters + * `MyResourceGroup` is the name of the resource group that contains the Azure Resource to be validated + * `MyResource` is the name of the resource that needs to be checked +* `property` - This generic resource dynamically creates the properties on the fly based on the type of resource that has been targetted. +* `value` is the expected output from the chosen property + +The options that can be passed to the resource are as follows. + +| Name | Description | Required | Example | +|-------------|---------------------------------------------------------------------------------------------------------------------|----------|-----------------------------------| +| group_name: | Azure Resource Group to be tested | yes | MyResourceGroup | +| name: | Name of the Azure resource to test | no | MyVM | +| type: | Azure Resource Type to look for | no | Microsoft.Compute/virtualMachines | +| apiversion: | API Version to use when interrogating the resource. If not set then the latest version for the resoure type is used | no | 2017-10-9 | + +These options can also be set using the environment variables: + + - `AZURE_RESOURCE_GROUP_NAME` + - `AZURE_RESOURCE_NAME` + - `AZURE_RESOURCE_TYPE` + - `AZURE_RESOURCE_API_VERSION` + +When the options have been set as well as the environment variables, the environment variables take priority. + +There are _normally_ three standard tests that can be performed on a resource. + +| Name | Description | +|------|-------------| +| name | Name of the resource | +| type | Type of resource | +| location | Location of the resource within Azure | + +For example: + +```ruby +describe azure_generic_resource(group_name: 'Inspec-Azure', name: 'Linux-Internal-VM') do + its('location') { should eq 'westeurope' } +end +``` + +## Properties + +The properties that can be tested are entirely dependent on the Azure Resource that is under scrutiny. That means the properties vary. The best way to see what is available please use the [Azure Resources Portal](https://resources.azure.com) to select the resource you are interested in and see what can be tested. + +![Virtual Machine Properties](images/linux_internal_vm_resource.png) + +This resource allows you to test _any_ valid Azure Resource. The trade off for this is that the language to check each item is not as natural as it would be for a native Inspec resource. + +Please see the integration tests for in depth examples of how this resource can be used. + + - [Generic External VM NIC](../test/integration/verify/controls/generic_external_vm_nic.rb) + - [Generic External VM](../test/integration/verify/controls/generic_external_vm.rb) + - [Generic Internal VM NIC](../test/integration/verify/controls/generic_internal_vm_nic.rb) + - [Generic Internal VM](../test/integration/verify/controls/generic_internal_vm.rb) + - [Generic Linux VM Managed OS Disk](../test/integration/verify/controls/generic_linux_vm_manmaged_osdisk.rb) + - [Generic Network Security Group](../test/integration/verify/controls/generic_network_security_group.rb) + - [Generic Public IP Address](../test/integration/verify/controls/generic_public_ip_address.rb) + - [Generic Resources](../test/integration/verify/controls/generic_resources.rb) + - [Generic Storage Account](../test/integration/verify/controls/generic_storage_account.rb) + - [Generic Virtual Network](../test/integration/verify/controls/generic_virtual_network.rb) + - [Generic Windows Internal VM NIC](../test/integration/verify/controls/generic_windows_internal_vm_nic.rb) + - [Generic Windows Internal VM](../test/integration/verify/controls/generic_windows_internal_vm.rb) + \ No newline at end of file diff --git a/docs/resources/azure_resource_group.md b/docs/resources/azure_resource_group.md index afa4f54da..703ab10d4 100644 --- a/docs/resources/azure_resource_group.md +++ b/docs/resources/azure_resource_group.md @@ -1,10 +1,10 @@ --- -title: About the azure_resource_group Resource +title: About the azure_resource_group_resource_counts Resource --- -# azure_resource_group +# azure_resource_group_resource_counts -Use the `azure_resource_group` InSpec audit resource to ensure that an Azure Resource group has the correct resources. +Use the `azure_resource_group_resource_counts` InSpec audit resource to check the number of Azure resources in a resource group ## References @@ -12,49 +12,114 @@ Use the `azure_resource_group` InSpec audit resource to ensure that an Azure Res ## Syntax -The name of the resource group is specified as an attribute on the resource: +The name of the resource group is specified as a parameter on the resource: ```ruby describe azure_resource_group(name: 'MyResourceGroup') do - its('matcher') { should eq 'value' } + its('property') { should eq 'value' } end ``` where -* `MyResourceGroup` is the name of the resource group being interrogated -* `matcher` is one of +* Resource Parameters + * `MyResourceGroup` is the name of the resource group being interrogated +* `property` is one of + - `name` + - `location` + - `id` + - `provisioning_state` + - `subscription_id` - `total` - - `count` - `nic_count` - `vm_count` + - `extension_count` - `vnet_count` - `sa_count` - `public_ip_count` - - `contains` - `managed_disk_image_count` - `managed_disk_count` + - `tag_count` * `value` is the expected output from the matcher +The options that can be passed to the resource are as follows. + +| Name | Description | Required | Example | +|-------------|---------------------------------------------------------------------------------------------------------------------|----------|-----------------------------------| +| group_name: | Azure Resource Group to be tested | yes | MyResourceGroup | +| name: | Name of the Azure resource to test | no | MyVM | + +If both `group_name` and `name` is set then `name` will take priority + +These options can also be set using the environment variables: + + - `AZURE_RESOURCE_GROUP_NAME` + - `AZURE_RESOURCE_NAME` + +When the options have been set as well as the environment variables, the environment variables take priority. + For example: ```ruby -describe azure_resource_group(name: 'ChefAutomate') do +describe azure_resource_group_resource_counts(name: 'ChefAutomate') do its('total') { should eq 7} its('nic_count') { should eq 1 } its('vm_count') { should eq 1 } end ``` -## Matchers +## 'have' Methods + +This resource has a number of `have_xxxx` methods that provide a simple way to test of a specific Azure Resoure Type exists in the resource group. + +The following table shows the methods that are currently supported and what their associated Azure Resource Type is. + +| Method Name | Azure Resource Type | +|-------------|---------------------| +| have_nics | Microsoft.Network/networkInterfaces | +| have_vms | Microsoft.Compute/virtualMachines | +| have_extensions | Microsoft.Compute/virtualMachines/extensions | +| have_nsgs | Microsoft.Network/networkSecurityGroups | +| have_vnets | Microsoft.Network/virtualNetworks | +| have_managed_disks | Microsoft.Compute/disks | +| have_managed_disk_images | Microsoft.Compute/images | +| have_sas | Microsoft.Storage/storageAccounts | +| have_public_ips | Microsoft.Network/publicIPAddresses | + +With these methods the following tests are possible + +```ruby +it { should have_nics } +it { should_not have_extensions } +``` + +## Properties This InSpec audit resource has the following matchers: -### eq +### name -Use the `eq` matcher to test the equality of two values: `its('Port') { should eq '22' }`. +Returns the name of the resource group. -Using `its('Port') { should eq 22 }` will fail because `22` is not a string value! Use the `cmp` matcher for less restrictive value comparisons. +### location + +Returns where in Azure the resource group is located. + +### id + +Returns the full qualified ID of the resource group. + +This is in the format `/subscriptions//resourceGroups/`. + +### provisioning_state + +The provisioning state of the resource group. + +### subscription_id + +Returns the subscription ID which contains the resource group. + +This is derived from the `id`. ### total @@ -92,49 +157,71 @@ The number of managed disks in the resource group. If a resource group contains one virtual machine with an OS disk and 2 data disks that are all Managed Disks, then the count would be 3. -### contains +## Tags -The `contains` filter allows testing of resources that are not directly supported by the resource pack: +It is possible to test the tags that have been assigned to the resource. There are a number of properties that can be called to check that it has tags, that it has the correct number and that the correct ones are assigned. + +### have_tags + +This is a simple test to see if the machine has tags assigned to it or not. ```ruby -its('contains') { should be true } +it { should have_tags } ``` -This matcher is best used in conjunction with filters, for example the following tests that a Managed Disk image exists in the resource group +### tag_count + +Returns the number of tags that are assigned to the resource ```ruby -describe azure_resource_group(name: 'MyResourceGroup').where { type: 'Microsoft.Compute/images' } do - its('contains') { should be true } -end +its ('tag_count') { should eq 2 } ``` -### count +### tags -The `count` filter allows testing for the number of resources that are not directly supported by the resource pack: - -As before it is best used in conjunction with a filter. The following checks that there is at least 1 Managed Disk Image in the resource group. +It is possible to check if a specific tag has been set on the resource. ```ruby -describe azure_resource_group(name: 'MyResourceGroup').where { type: 'Microsoft.Compute/images' } do - its('count') { should > 1 } -end +its('tags') { should include 'Owner' } ``` +### xxx_tag + +To get the value of the tag, a number of preoprties have been created from the tags that are set. + +For example, if the following tag is set on a resource: + +| Tag Name | Value | +|----------|-------| +| Owner | Russell Seymour | + +Then a property is available called `Owner_tag`. + +```ruby +its('Owner_tag') { should cmp 'Russell Seymour' } +``` + +Note: The tag name is case sensitive which makes the test case sensitive. E.g. `owner_tag` does not equal `Owner_tag`. + ## Examples The following examples show how to use this InSpec audit resource +Please refer the integration tests for more in depth examples: + + - [Resource Group](../../test/integration/verify/controls/resource_group.rb) + ### Test Resource Group has the correct number of resources ```ruby -describe azure_resource_group(name: 'ChefAutomate') do +describe azure_resource_group_resource_counts(name: 'Inspec-Azure') do its('total') { should eq 7} ``` ### Ensure that the Resource Group contains the correct resources ```ruby -describe azure_resource_group(name: 'ChefAutomate') do +describe azure_resource_group_resource_counts(name: 'Inspec-Azure') do its('total') { should eq 7 } its('vm_count') { should eq 2 } its('nic_count') { should eq 2 } diff --git a/docs/resources/azure_virtual_machine.md b/docs/resources/azure_virtual_machine.md index 03dc0735b..df7878910 100644 --- a/docs/resources/azure_virtual_machine.md +++ b/docs/resources/azure_virtual_machine.md @@ -8,59 +8,107 @@ Use the `azure_virtual_machine` InSpec audit resource to ensure that a Virtual M ## References -- [Azure Ruby SDK - Compute](https://github.com/Azure/azure-sdk-for-ruby/tree/master/management/azure_mgmt_compute) +- [Azure Ruby SDK - Resources](https://github.com/Azure/azure-sdk-for-ruby/tree/master/management/azure_mgmt_resources) ## Syntax -The name of the machine and the resourece group are required as attributes to the resource. +The name of the machine and the resourece group are required as properties to the resource. ```ruby -describe azure_virtual_machine(name: 'MyVM', resource_group: 'MyResourceGroup') do - its('matcher') { should eq 'value' } +describe azure_virtual_machine(group_name: 'MyResourceGroup', name: 'MyVM') do + its('property') { should eq 'value' } end ``` where -* `MyVm` is the name of the virtual machine as seen in Azure. (It is **not** the hostname of the machine) -* `MyResourceGroup` is the name of the resouce group that the machine is in. -* `matcher` is one of - - `publisher` - - `offer` - - `sku` - - `size` - - `location` - - `boot_diagnostics?` - - `nic_count` +* Resource Parameters + * `MyVm` is the name of the virtual machine as seen in Azure. (It is **not** the hostname of the machine) + * `MyResourceGroup` is the name of the resource group that the machine is in. +* `property` is one of + - [`type`](#type) + - [`location`](#location) + - [`name`](#name) + - [`publisher`](#publisher) + - [`offer`](#offer) + - [`sku`](#sku) + - [`os_type`](#"os_type") + - [`os_disk_name`](#os_disk_name) + - [`have_managed_osdisk`](#have_managed_osdisk?) + - [`caching`](#caching) + - `create_option` + - `disk_size_gb` + - `have_data_disks` + - `data_disk_count` + - `storage_account_type` + - `vm_size` + - `computer_name` - `admin_username` - - `computername` - - `hostname` + - `have_nics` + - `nic_count` + - `connected_nics` + - `have_password_authentication` - `password_authentication?` + - `have_custom_data` + - `custom_data?` + - `have_ssh_keys` + - `ssh_keys?` - `ssh_key_count` - - `os_type` - - `private_ipaddresses` - - `has_public_ipaddress?` - - `domain_name_label` + - `ssh_keys` + - `have_boot_diagnostics` + - `boot_diagnostics_storage_uri` * `value` is the expected output from the matcher +The options that can be passed to the resource are as follows. + +| Name | Description | Required | Example | +|-------------|---------------------------------------------------------------------------------------------------------------------|----------|-----------------------------------| +| group_name: | Azure Resource Group to be tested | yes | MyResourceGroup | +| name: | Name of the Azure resource to test | no | MyVM | +| apiversion: | API Version to use when interrogating the resource. If not set then the latest version for the resoure type is used | no | 2017-10-9 | + +These options can also be set using the environment variables: + + - `AZURE_RESOURCE_GROUP_NAME` + - `AZURE_RESOURCE_NAME` + - `AZURE_RESOURCE_API_VERSION` + +When the options have been set as well as the environment variables, the environment variables take priority. + For example: ```ruby -describe azure_virtual_machine(name: 'chef-automate-01', resource_group: 'ChefAutomate') do +describe azure_virtual_machine(group_name: 'Inspec-Azure', name: 'Linux-Internal-VM') do its('os_type') { should eq 'Linux' } - its('boot_diagnostics?') { should be false } + it { should have_boot_diagnostics } end ``` -## Matchers +## Testers -This InSpec audit resource has the following matchers: +There are a number of built in comparison operrtors that are available to test the result with an expected value. -### eq +For information on all that are available please refer to the [Inspec Matchers Reference](https://www.inspec.io/docs/reference/matchers/) page. -Use the `eq` matcher to test the equality of two values: `its('Port') { should eq '22' }`. +## Properties -Using `its('Port') { should eq 22 }` will fail because `22` is not a string value! Use the `cmp` matcher for less restrictive value comparisons. +This InSpec audit resource has the following properties that can be tested: + +### type + +THe Azure Resource type. For a virtual machine this will always return `Microsoft.Compute/virtualMachines` + +### location + +Where the machine is located + +```ruby +its('location') { should eq 'westeurope' } +``` + +### name + +Name of the Virtual Machine in Azure. Be aware that this is not the computer name or hostname, rather the name of the machine when seen in the Azure Portal. ### publisher @@ -80,47 +128,103 @@ The item from the publisher that was used to create the image. This will be `nil` if the machine was created from a custom image. -### size +### os_type + +Test that returns the classification in Azure of the operating system type. Ostensibly this will be either `Linux` or `Windows`. + +### os_disk_name + +Return the name of the operating system disk attached to the machine. + +### have_managed_osdisk + +Determine if the operating system disk is a Managed Disks or not. + +This test can be used in the following way: + +```ruby +it { should have_managed_osdisk } +``` + +### caching + +Returns the type of caching that has been set on the operating system disk. + +### create_option + +When the operating system disk is created, how it was created is set as an property. This property will return has the disk was created. + +### disk_size_gb + +Return the size of the operating system disk. + +### have_data_disks + +Denotes if the machine has data disks attached to it or not. + +```ruby +it { should have_data_disks } +``` + +### data_disk_count + +Return the number of data disks that are attached to the machine + +### storage_account_type + +This provides the storage account type for a machine that is using managed disks for the operating system disk. + +### vm_size The size of the machine in Azure ```ruby -its('size') { should eq 'Standard_DS2_v2' } +its('vm_size') { should eq 'Standard_DS2_v2' } ``` -### location +### computer_name -Where the machine is located +The computername of the machine. This is what was assigned to the machine during deployment and is what _should_ be returned by the `hostname` command. + +### admin_username + +The admin username that was assigned to the machine + +NOTE: Azure does not allow the use of `Administrator` as the admin username on a Windows machine + +## have_nics + +Returns a boolean to state if the machine has NICs connected or not. + +This has can be used in the following way: ```ruby -its('location') { should eq 'West Europe' } -``` - -### boot_diagnostics? - -Boolean test to see if boot diagnostics have been enabled on the machine - -```ruby -it { should have_boot_diagnostics } +it { should have_nics } ``` ### nic_count The number of network interface cards that have been attached to the machine -### admin_username +### connected_nics -The admin username that was assigned to the machine +This returns an array of the NIC ids that are connected to the machine. This means that it possible to check that the machine has the correct NIC(s) attached and thus on the correct subnet. -NOTE: Azure does not allow the use of `Administrator` as the admin username on a Windows machine +```ruby +its('connected_nics') { should include /Inspec-NIC-1/ } +``` -### computername +Note the use of the regular expression here. This is because the NIC id is a long string that contains the subscription id, resource group, machine id as well as other things. By using the regular expression the NIC can be checked withouth breaking this string up. It also means that other tests can be performed. -The computername of the machine. This is what was assigned to the machine during deployment and is what _should_ be returned by the `hostname` command. +An example of the id string is `/subscriptions/1e0b427a-d58b-494e-ae4f-ee558463ebbf/resourceGroups/Inspec-Azure/providers/Microsoft.Network/networkInterfaces/Inspec-NIC-1` -### hostname +### have_password_authentication -Alias for computername. +Returns a boolean to denote if the machine is accessible using a password. + +```ruby +it { should have_password_authentication } +``` ### password_authentication? @@ -132,44 +236,130 @@ its('password_authentication?') { should be false } This only applies to Linux machines and will always return `true` on Windows. +### have_custom_data + +Returns a boolean stating if the machine has custom data assigned to it. + +```ruby +it { should have_custom_data } +``` + +### custom_data? + +Boolean to state if the machine has custom data or not + +```ruby +its('custom_data') { should be true } +``` + +### have_ssh_keys + +Boolean to state if the machine has SSH keys assigned to it + +```ruby +it { should have_ssh_keys } +``` + +For a Windows machine this will always be false. + +### ssh_keys? + +Boolean to state of the machine is accessible using SSH keys + +```ruby +its('ssh_keys?') { should be true } +``` + ### ssh_key_count Returns how many SSH keys have been applied to the machine. This only applies to Linux machines and will always return `0` on Windows. -### os_type +### ssh_keys -Generic test that returns either `Linux` or `Windows`. +Returns an array of the keys that are assigned to the machine. This is check if the correct keys are assigned. -### private_ipaddresses - -Returns an array of all the private IP addresses that are assigned to the machine. This is because a machine can multiple NICs and each NIC can have multiple IP Configurations. +Most SSH public keys have a signature at the end of them that can be tested. For example: ```ruby -its('private_ipaddresses') { should include '10.1.1.10' } +its('ssh_keys') { should include /azure@inspec.local/ } ``` -### has_public_ipaddress? +### boot_diagnostics? -Returns boolean to state if the machine has been allocated a Public IP Address. +Boolean test to see if boot diagnostics have been enabled on the machine ```ruby -it { should have_public_ip_address } +it { should have_boot_diagnostics } ``` -### domain_name_label +### boot_diagnostics_storage_uri -If a machine has been allocated a Public IP Addresse test to see what domain name label has been set. +If boot diagnostics are enabled for the machine they will be saved in a storage account. This method returns the URI for the storage account. + +```ruby +its('boot_diagnostics_storage_uri') { should match 'ghjgjhgjg' } +``` +## Tags + +It is possible to test the tags that have been assigned to the resource. There are a number of properties that can be called to check that it has tags, that it has the correct number and that the correct ones are assigned. + +### have_tags + +This is a simple test to see if the machine has tags assigned to it or not. + +```ruby +it { should have_tags } +``` + +### tag_count + +Returns the number of tags that are assigned to the resource + +```ruby +its ('tag_count') { should eq 2 } +``` + +### tags + +It is possible to check if a specific tag has been set on the resource. + +```ruby +its('tags') { should include 'Owner' } +``` + +### xxx_tag + +To get the value of the tag, a number of tests have been craeted from the tags that are set. + +For example, if the following tag is set on a resource: + +| Tag Name | Value | +|----------|-------| +| Owner | Russell Seymour | + +Then a test is available called `Owner_tag`. + +```ruby +its('Owner_tag') { should cmp 'Russell Seymour' } +``` + +Note: The tag name is case sensitive which makes the test case sensitive. E.g. `owner_tag` does not equal `Owner_tag`. ## Examples The following examples show how to use this InSpec audit resource. +Please refer the integration tests for more in depth examples: + + - [Virtual Machine External VM](../../test/integration/verify/controls/virtual_machine_external_vm.rb) + - [Virtual Machine Internal VM](../../test/integration/verify/controls/virtual_machine_internal_vm.rb) + ### Test that the machine was built from a Windows image ```ruby -describe azure_virtual_machine(name: 'chef-ws-01', resource_group: 'ChefAutomate') do +describe azure_virtual_machine(name: 'Windows-Internal-VM', group_name: 'Inspec-Azure') do its('publisher') { should eq 'MicrosoftWindowsServer' } its('offer') { should eq 'WindowsServer' } its('sku') { should eq '2012-R2-Datacenter' } @@ -179,6 +369,6 @@ end ### Ensure the machine is in the correct location ```ruby -describe azure_virtual_machine(name: 'chef-ws-01', resource_group: 'ChefAutomate') do - its('location') { should eq 'West Europe' } +describe azure_virtual_machine(name: 'Linux-Internal-VM', resource_group: 'Inspec-Azure') do + its('location') { should eq 'westeurope' } end diff --git a/docs/resources/azure_virtual_machine_datadisk.md b/docs/resources/azure_virtual_machine_datadisk.md new file mode 100644 index 000000000..47cebc003 --- /dev/null +++ b/docs/resources/azure_virtual_machine_datadisk.md @@ -0,0 +1,200 @@ +--- +title: About the azure_virtual_machine_datadisk Resource +--- + +# azure_virtual_machine_datadisk + +Use this resource to ensure that a specific data disk attached to a machine has been created properly. + +## References + +- [Azure Ruby SDK - Compute](https://github.com/Azure/azure-sdk-for-ruby/tree/master/management/azure_mgmt_compute) + +## Syntax + +The name of the resource group and machine are required to use this resource. + +```ruby +describe azure_virtual_machine_data_disk(group_name: 'MyResourceGroup', name: 'MyVM') do + its('property') { should eq 'value' } +end +``` + +where + +* Resource Parameters + * `MyVm` is the name of the virtual machine as seen in Azure. (It is **not** the hostname of the machine) + * `MyResourceGroup` is the name of the resouce group that the machine is in. +* `property` is one of + - `count` the number of data disks attached to the machine + - `have_data_disks` boolean test denoting if data disks are attached + - `have_managed_disks` boolean test denoting if the data disks are all managed disks or not + - `disk` the zero based index of the disk attached to the machine + - `number` disk number of the disk attached to the machine, starting at 1 + - `name` name of the disk + - `size` size of the disk + - `lun` the lun of the disk as reported by Azure + - `caching` the caching that has been set on the disk + - `create_option` how the disk was created + - `is_managed_disk?` if this particular disk is a managed disk or not + - `vhd_uri` URI of the disk if it is in a storage account + - `storage_account_name` the storage account name that the disk is stored in + - `storage_account_type` if this is a managed disk what is the the storage type + - `id` the fully qualified id to the disk in Azure + - `subscription_id` if this is a managed disk the subscription that it is located in + - `resource_group` if this is a managed disk the resource group that it is in +* `value` is the expected output fdrom the matcher + +The `count`, `have_data_disks` and `have_managed_disks` are catchall tests that give information about the virtual machine. The specific tests need to be used in conjunction with the `where` option as shown below. + +| Name | Description | Required | Example | +|-------------|---------------------------------------------------------------------------------------------------------------------|----------|-----------------------------------| +| group_name: | Azure Resource Group to be tested | yes | MyResourceGroup | +| name: | Name of the Azure resource to test | no | MyVM | +| apiversion: | API Version to use when interrogating the resource. If not set then the latest version for the resoure type is used | no | 2017-10-9 | + +These options can also be set using the environment variables: + + - `AZURE_RESOURCE_GROUP_NAME` + - `AZURE_RESOURCE_NAME` + - `AZURE_RESOURCE_API_VERSION` + +When the options have been set as well as the environment variables, the environment variables take priority. + +For example: + +```ruby +describe azure_virtual_machine_data_disk(group_name: 'Inspec-Azure', name: 'Linux-Internal-VM') do + its('count') { should cmp > 0 } + it { should have_data_disks } +end +``` + +## Matchers + +This InSpec audit resource has the following matchers: + +### eq + +Use the `eq` matcher to test the equality of two values: `its('Port') { should eq '22' }`. + +Using `its('Port') { should eq 22 }` will fail because `22` is not a string value! Use the `cmp` matcher for less restrictive value comparisons. + +**The following properties are ones that are applied to the virtual machine itself and not specfic disks** + +## Properties + +### count + +Returns the number of data disks attached to the machine + +```ruby +its('count') { should eq 1 } +``` + +### have_data_disks + +Returns a boolean denoting if any data disks are attached to the machine + +```ruby +it { should have_data_disks } +``` + +### have_managed_disks + +Returns a boolean stating if the machine has Managed Disks for data disks. + +```ruby +it { should have_managed_disks } +``` + +**The next set of attributes require the `where` operation to be used on the describe.** + +The following code shows an example of how to use the where clause. + +```ruby +describe azure_virtual_machine_data_disk(group_name: 'Inspec-Azure', name: 'Windows-Internal-VM').where(number: 1) +end +``` + +### disk + +The zero based index of the disk attached to the machine. + +Typically used in the `where` clause + +### number + +The '1' based index of the disk attached to the machine. + +Typically used in the `where` clause as showm above. + +### name + +Returns a string of the name of the disk. + +### size + +Returns an integer of size of this disk in GB. + +### lun + +The disk number as reported by Azure. This is a zero based index value. + +### caching + +String stating the caching that has been set on the disk. + +### create_option + +How the disk was created. Typically for data disks this will be the string value 'Empty'. + +### is_managed_disk? + +Boolean stating if the disk is a managed disk or not. If it is not a managed disk then it is one that is stored in a Storage Account. + +### vhd_uri + +If this _not_ a managed disk then the `vhd_uri` will be the full URI to the disk in the storage account. + +### storage_account_name + +If this is _not_ a managed disk this will be the storage account name in which the disk is stored. + +This derived from the `vhd_uri`. + +### storage_account_type + +If this is a managed disk this is the storage account type, e.g. `Standard_LRS` + +### id + +If this is a managed disk then this is the fully qualified id to the disk in Azure. + +### subscription_id + +If this is a managed disk, this will return the subscription id of where the disk is stored. + +This is derived from the `id`. + +### resource_group + +If this is a managed disk, this returns the resource group in which the disk is stored. + +This is derived from the `id`. + +## Examples + +The following examples show to use this InSpec audit resource. + +Please refer to the following integration tests for more in depth examples: + + - [Linux Internal Data Disks](../../test/integration/verify/controls/virtual_machine_linux_external_vm_datadisk.rb) + - [Windows Internal Data Disk](../../test/integration/verify/controls/virtual_machine_windows_internal_vm_datadisk.rb) + +### Check that the first data disk is of the correct size +```ruby +describe azure_virtual_machine_data_disk(group_name: 'Inspec-Azure', name: 'Linux-Internal-VM').where(number: 1) do + its('size') { should cmp >= 15 } +end +``` \ No newline at end of file diff --git a/docs/resources/azure_virtual_machine_datadisks.md b/docs/resources/azure_virtual_machine_datadisks.md deleted file mode 100644 index 6de0732bd..000000000 --- a/docs/resources/azure_virtual_machine_datadisks.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -title: About the azure_virtual_machine_datadisks Resource ---- - -# azure_virtual_machine_datadisks - -Use this resource to check that the correct number of data disks have been applied to the machine and that they are of the correct size. - -## References - -- [Azure Ruby SDK - Compute](https://github.com/Azure/azure-sdk-for-ruby/tree/master/management/azure_mgmt_compute) - -## Syntax - -The name of the resource group and machine are required to use this resource. - -```ruby -describe azure_virtual_machine(name: 'MyVM', resource_group: 'MyResourceGroup') do - its('matcher') { should eq 'value' } -end -``` - -where - -* `MyVm` is the name of the virtual machine as seen in Azure. (It is **not** the hostname of the machine) -* `MyResourceGroup` is the name of the resouce group that the machine is in. -* `matcher` is one of - - `count` the number of data disks attached to the machine - - `has_data_disks?` boolean test denoting if data disks are attached - - `entries` used with the `where` filter to check the size of a disk -* `value` is the expected output fdrom the matcher - -## Matchers - -This InSpec audit resource has the following matchers: - -### eq - -Use the `eq` matcher to test the equality of two values: `its('Port') { should eq '22' }`. - -Using `its('Port') { should eq 22 }` will fail because `22` is not a string value! Use the `cmp` matcher for less restrictive value comparisons. - -### count - -Returns the number of data disks attached to the machine - -```ruby -its('count') { should eq 1 } -``` - -### has_data_disks? - -Returns a boolean denoting if any data disks are attached to the machine - -```ruby -it { should have_data_disks } -``` - -### entries - -The `entries` filter can be used to check the attributes of indivdual data disks: - -```ruby -its('entries') { should_not be_empty } -``` - -This matcher is best used in conjunction with filters. For example the following tests that the first data disk has a capacity greater than 10gb. - -```ruby -describe azure_virtual_machine_datadisks(name: 'MyVM', resource_group: 'MyResourceGroup').where { disk.zero? and size > 10 } do - its('entries') { should_not be_empty } -end -``` - -## Examples - -None \ No newline at end of file diff --git a/images/linux_internal_vm_resource.png b/images/linux_internal_vm_resource.png new file mode 100644 index 0000000000000000000000000000000000000000..67202894d29fad62705b1fd5dfde6aad4c829ba0 GIT binary patch literal 197489 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

o~UPyQv_uzc;hsr8!q z#mwm%-cN2v7Rve>%1NXIy>e#o(kl%|(GZZZ^nWIj7V(*Lama2<^BJB8abnw4#8-Jz zVopCO_&7O4oow4gXrD3tBKpV(FP@*sBLic7``%MvggMIap|GZ#lj!YZ7x@XXixFeh z%%}__I~kiEc|kMY@@#%lYmezAn~=zpjMjiHGfgeVt&e-XQ|25Q}x4#L>6JKBe zvu?uGBG^E8*X#9YY+|W=nIb&R(#dZ zt)?ft1DP&0F=PQh_ZYxs0GT?p91d~;0N4!HpUABS&6nRK+=;DX$HN(Ah#Jye^9Omt znB_bgufSG%oeo4-?bi-H8*%8FYyxA_TTqjOjQH^g?!6!d<>>l1!AdQk7y0ql`#{_| z>tfOP)*B$(@M+Xgq1w|;?d_fQ=J-1|(#J8+>)69=mIZCQP7%B*5b68OgLq@)H~Hf zX@qnvUuGZRJ`77oxE`c*9mDZBmDGu*o47ttzXL63y3Jx#F&o$3uCy-VG_2u}*ZgEJ zcObYGMR&v8>rcr!@wH}enDuhsO-K1IhC7KX&OoXXk07@tZWzirjy>X(K5 zAxyvfc}-)|Px+i9*RCck78iv-NzGldgLpILZf_w#W02iY;GHu&C!s?3C^ax%_2!P4Z{$D?kc9tF(b4 zOqLO-at~o6i-G1|_2>n#cK&f;OGEiszyl9rMQ4`?+3A=zd&u3bV+uG{WM=%Dt=xaO z4Td;#O_OrBXJ&SpHIGVvUNmu*1|-3QQ$z+S+g#A}d~bF4->+XG3C1j7fTUvI&2DWD z0L~9&b9AIRLd;#s4tkbdD_4zwP9Va%;2aB%-ll0VWZ1F_J75eB2(hI`dW^z9DrW&H zw<@^K3Z3CklMz|^{h|24^_k)V4bvfX)Ob|W91VRZi7~NRSTwbl!kz;}9b;T+DK((h z41@qaY^?}6_oPSP&j8XJ5h@4uNS6ih_aZpCd(EbxY}t~g5wghS8FR-kBVR_%f_FPa z<6ew|08+|mbu^N2eAf}$svcrX5q?guF%vfD-ag~kWyT$%83S7iHA*s2 z;oI@qfg<-f>p0@?esgJ468@2fMF2zg{pMu?7*whxY#((T_`AEsQLzHZiC`MZ)h}9? z$WZf<6rLC51cms*ozp+951}Lf zGK+Y{BlT-ZPssI8_>pPqc*vKL9sKlv)!Xua2P+~4U2H-$L3JJ`u$|<#ErGels0(`P zcG85(NR^&T{3IG;CPkqI(#%<43$gBJq2&G&==^A$@OU1_ph(|==#;|DZA}L%2=ZtQ zE11u$`amE+ZzXY5Bv053AQWo}=x=b}G8t!IjOml%MhbhKy1X}k8I)^5_cimO`RF^B z?x7qP7p{CZBC}^Nxc*-Epn*|Us2_U6xUpdI8W8UP5ueFbD+-8D;;s5uyaUwRMv|A6 zUy!7ALf~=!BX6fR1C%UUN8ZAQ#rGerEm+ni55-;Lx4c#ERIR+}U3b-|^H0`QqLmCo zyxf}(JQ1Tsv)V3u?FmlY^ccBkW>;gi&&gr-8N}c)`?~Bns zpYX2&PwgWcpjlxY2jZwCFSBi((Y@2Y+4^9-b?u01O<%MNPzS3lttaA1;q1z;D7{Tt z0YGPm%n*+qS7JlETW-hO9?N`~8%y#}_(bBZ^2kC$REmnDo8cgG{br=TRyO~_*6anU zBQ9j+&<%Y`vpvpza=VsFeeg;)B9E+c-lvCwyc)%s8PXHr#ieJ`YZvo((bw5@9Frda ziQOaQ$;bN+>AbejcMTqOHhbpal&dc!gYm3&;$7PFw2 zlv(aQZr?2$4^~*bF#e***XEg577_*IkF@HfI?2U?O7NZq4|J(+oo<`+|IIpLnxe zs?Ze(VbU=UgE9rdV9XZSJA=zNmbn4%`Dx2&vo9UBV4V0Hc8CVhU4E6Q-agYs8>v&_q zVVQ-Dxd&v)OshB|-+KwL8C6niSox?&Eow(4@)fd2{i|nkK?Bt@I-HUkMP_N4@-x*Z+RJ%K_cY+m2`%w953k_-`R?YEKM|2{kHZ^v z7MQX><|Pf5pk~w>&CNK=pp^5_E=z%B(i*{ec`S1{I_)3pxcDeQ6t)TaHK!=$$;E3oLS1ft^#j50Yz1w;5sN}=kwayX zh?zWfw+DhvU7XP>j@IH0tDY!1RMM<@b0k8+;)jZxh?<)(u)}%Md;P%TCNo(r0uIjB z%n8m5v9I$!o6K^6!n>QDI#QK;Pmz5}?X^04Hmbj6gew=~79Yb|V&48dpk+%lF0zDt zU6`Pf!bD6&oH!Ou*1>LB$xhenF6=fs(Iouyvnhc&g5k8JQ&mDyFYwn6JLEb=u&S9D zD)2jdDSlFZ(p83$9#@y>57hZy1e*Vo>61*Uo%KDKsH=Dc$O}R3bw(f5wR}4wjTA$# zDj`(sJ=FT`WThy_|7q2b4))*WL71fIf-Pv0v!^%2YL9k zfo;$9H(VF~k!2>soiTguPM#RiA6{EI87jXBqt&U*TbWzF4niC25g;|l?49c9o8#tM z!x=Evq>Dx}X~Gc|y4?K2%Qu=G^EUZfU~eAm1wl^RZ;8+KrtFfmx-;A235aoeE5r4?7_^__jT-O zoYK}RI3~e-pNHQ8hiYIaq3Dl19M!GahcKq7M*@a%FYlPGa-EN2+q?UWPs*JHE0;F^^=up^w*OP#8c>=rw9u;Y)fY|Nhz|9n= zEGn=781n7%@%JP7@xnz(dzT9wN47Uh%LOV=z2v0U?i0W>_GLk4oqe<}pGZRUxLeEt zu}`1T<5gi6=YC}b_pcw0u=p3AGF`9B3iNcKv#ZA$eX^*c$LneLt8p% zt>DN$pqmMv+TIrBObZ>XMl#+Ui$c#mp^nZ$tI`w9xh$SmOU~KfmZMhHF+Q1)J65~{ z;{5W|+4tzheTHru2U!VP@d>fbn|<_J>dFA}kSVNBOhM;yWb6zo)x(#zsx#R1&b zu=73rW2cZ*fG}xb@k0?_HP*IS8TIny73J*~ES}&7Mhy+Ny&9D}_i^{`IS=>EPX%-I zYf3D$@c1@WGvLi6wctP8@V(f$$Bsq35EK1ZR!`irUB`9plDjlAgxQtM9IF4=44PMn zs&F|a2RimL^2R)C1&>cfPaZ&GOb}_zT0FW`yUvGzq?Z6lqj6XC__P~vA15qU?cPEZ zR+`J|iz5p|6el<1E!=n~>5+3wR|yV8bXn$K7E&MqX`k8dNcmb5Y1f%ka4NmoxEm)a zAv3O%DEKZ?Xuu0}SHeB^-(aHY0|meJ@>?Rubrb^@k_Uux!l6yP`%Uxl8Me=mfp@!eJ(EG&6p8h zP4>(J;ZSMItkFPE>3fsKy5(;w#tpju``YitRZ7l+i%hQxnjwjTPE&z(HpPKGG);!e zieTRtX;6%?y^Yi6bsnP$7wf7Ed`V8N`tyZZZ zV(UO5+H;~k(Cg_ z3Ly!B=Z;p}=Y4+9d%S_K#k46CzS<0G$`OZJKij#vHX{xXgi#(-jKQ$s8Q zr^~q|;ThDTfQzAudSBk6yYkh`LjUGviLiTsv-*Jc>4(qp<<5oZH^xRghNWz;jB9vd zGgh*hV^u8(w?v_)Hx@PkY-sC*a7)}o#c7WS%*6IBEOYu4_k+F59vk`VmE9JJEHxD1 zqsjX&J!@4h?$jJ*KLCRL`%y&C?;s|k40~EaWK&@8#|ICdo8)#t&O0|h|2HEBEQU6j ziVRVonbaDJXyUr7Yvl6I@W*yEpZo9I%+~aljas_xT-6^r}CJkNg+Gj z^L6v_b{{aZl*vS=>MsA2Z#y5+1IAF`w&)M^PtIb}A;h#X1wbg~yh=g~?JjJiA+B+A zZ)LBkpzHZk)Ctt$Pe1XCgoGM`O=wo4e2!@8AVC36d0i(D&TuVhfkgZR{J{@ zn;aH;O_To6*pbasHW4S7!z?I`?y7tJW&K?yt&E@ul03=ILuviFrou6dE6O3I0lf;%?g?oH)GgoEujgyp<0*-$# zO4Fv_a22<%|1--NX#+`U)rsle-DqLTNcCA3yUD*Y$g{`$-+sPmI#2)gupj@`x zwHRDD+Oh^%i)KK>VRPbg@%p0W2-h*OYKw1untR0$0QII!|0iT4-jN^SCoeBIagXT@Di^`!98_Gb^StnBah*D+9Ke(r#5%)w#zK%~gW0Rv>zP zWybu_<1cRfzv>UaZp>_hy^=cfSquL0&yu;P$3=Tfzgpu25e(ZwYdFV`6gq7JvQrP_ z5Jsm>#Pr5Y&{dLmE9pwoS2;ksj*ulenDUcCuODYVP z{tVKPS1^ zJz2>~Qy96{1^do|6f~caE|=annBygo1ogV$Lit5;5Fn*s=ALc%%R|^3z;I(fGu((N z|9|AL!U+PP2ukJtdor7*s{>h+jKByx$IK3nt<xGjM?Fub)%B>jlJXGU`tyiWZNPI1~SO0@Q;5sH-EWMvljcD*y@NCo*t4=Npolev% zk0#`VC>M6`&6mLx!vUXTx!beXh$4_axqlCs7Yd%lpOxV>(KtFK&;b%uwA? z`njF8{Gcv`J{U^_C`EmG1!Vb5xQqoDLSkKGY~f=O(A={&(eFb*-<9dGlwHQx`!$q& zT>4MB_y~$d-q%9WzHsO={9i3?lUeR>n^_j@In)$qLC+WMUx5sV=aUp*-4yK+5ZgA$ z#8_l=KIK0BN9Ai04qw2vKjH2BLVp7Nd&8oq@I!F-8V5K=_WM{@c2yer+5?U$AZk9D zafp~pHX8Gy&y5##sTNHh7TiYaui0gJFFo4}`Pp(1vtb)N#P2Bc;qlqLg#LZ=9_h9| zRlVJ=0^D6G*Q-L=@;sikqG<vZ@;HiI&aVKB(9{ zt^3jMFI^|t156L)FWQUY_!BU`>3wr$?j0J__5;fYG9V7@qCRpo{+!)UeuGY02Y1M5(~@=8dc48p zcAD3wYTQX~v(1zi0d5JyXw&nx$gXqi_*sv=(p?S>^SIahXa{Amxg=_9@QDbfx}}tg z3_V?YfO1hAYyTE7PAGk7BqH8+2_GgvEe7r&rZZ3vDW?bXlW5WATFVl&JTnd$8hntW1oWoiahTbhN+H`TzIK{BDMSv6q$jy5oV zswk3F;{Q~nmWBVm^@HOfX_3cpeo&Cg7d49LK*ec^Fc!LF&8fj9A)FvDh`K4*sByYj zXF9rM59G_zUBFbHun1N7>`bAgIBkaY)(+s2tC0H<^qTZgoIV1`d zVl19d*QN*>+e+dnWbA=~r(a1XuI2%5lm`VFFVwC&-!ac5c8b1N65(KtW1E>yt)M;& zU{2kpMN{;aXnKekuVm&p$$v6i!!60FsM@*R42;h;h^Z_+5-z~C+yJ<&dJ!5`9_CY} z(jufdX0+H;!IK?pnzKCjM5dvkA;f?b9P=Mq-zoj>Z8TPZCbbCSR%lzRt_%^Y=t=m7 z?)w8smRo6RhMfmoam424j@ru#IV51TNzAnA_mFPdMpH9jS{esIG4OP>IlQXl8rFNM zeYZT=od^g~86`-gdzZX0iwN1ZTu%&Z{F&&0Eu0ZVf4q}5UP%e?{Wv$o^7RUVcvu%( z&{SR12@AnB%8hYLW-l-owTGV7*Sop7`RQzMTzuA6sZ|L_Mi2jxcK%q;0+tOV+7nX@ z6S*4&OFU^H<7eMn)UW?5kaNEI*&o`#A~5@UzIUnA8<^j*E3-e6C1jEkf?bhS9Z4@XuDRBLTPd&95xw$SFuJE768KW{p2nYZLF3zO`&z3+RK6Y zqeSB5-R^WomrmG=6bQqa@UcQW}AB{~@RFV}5j+5nrOreFEQh z{BbD}N8U$t-TL{fOG6Ok+6;@R5V$VI^luLq%@J!P^3jni$^FLN9qQ#=GIe3hx2l6% z37DL3CS1U@m&yoPTPS2dMN124pEZ~m!=sMVsq}N@r!}QRdMaPUfEAtj9(a4?B*Kp)4Z>mCj(ja<8JJy;a*3Rp1U87*xvp?}@M7hPzf znd{?`6c@`VSpSp~3}BlZnWO((;((XXOSG{&%q80T+`D%0AkM&d$*kIG3(UO$9U!5I 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�*Zk+Dp@Vn3K=@wWxCre4mqqo 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

{%utGru^@2#r}^#|6E)`qOaN&#plQ*XEQ{! zfD4zZ_{u-Zs&~U1G;bjJFfMy~Kz&e%eG;5FuFGdCX0|)ECaez|gb@Jy7kLecZ(CKu zc=s;>ruR!F;Q>@;(~W4hE)ZD-tA~m6gq#}nSOj*L8@z?gGCQ@>5Bz9QPAU8|Z4&s? z^M``Az@PkoONkdMI5b=@b8On+5URDtH!98izfz=bJm(*p3Eq+w5=E)wuir%4!SL}J zUj>E>3_-saK+l2r=})gO>9F{+tGi8&&b?9n(dKyl>3>!gu;`wl^0M*o0Z!o0a9}5X zxGPK1mvrDH+*YN!@Gj0OMOf}M2p!)uJ0MJMRW`HlLt`j7GPi%lb zNJlBpj5TFoMy-WS->7AzBwv%Tp>M4e*)Bi}GX!+Us(-<9U{v|nR@xZsB*j5i=(b7x z{jx)OH_WqT@3|KAKis9mxZrh3(6iUS;6vRe@pr)AO;y)x-#87Z?)1HU5NuW zRUj%H`#^Bse4vno5WXb^JDjwcWyB>YsH)}yh?P-OF`u))5K7<%Xsn$L}T71

>v<{g` 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

RqoMLVMsQp0OR7?0g{MU!{ zn1Cf|3peGo2gZ&LQ2&zS5zS+?tyjozre5~N?DA=1|JfwiPXTxaU1fZb=A24Xs6;Kt zj{3sZ%p`0cwOT0|HrCf5mExqVkvAOX8#&u!vO#ix^tk-{^VxbwgbFBUAPJjz2Y+-{Tz{f z%i`VG%w@y%Bd(?1f%uDz3ey#MDa-nG(Qy@@#`Tyd4&6z}jL$I%+zeGB`;)Jdy{Tmr z2%aLJr@-sMQR?c5)hBQ*V(hNX zIz%t{&Z$dIWRDU$9OCcnQgSVO_V83E)7XF)^{Q_V$G!$ulym|T^VXUvF+E7Ns~(-3 za$NR5%4po(GK6zKNoI)jLp6gA28D%Tc)gmlP%?dXyg{K!I?XLmDs_UZ_l)^8Kp`|= z)46LBe&%^oq$N$=*0C}2+Eepc5W<+6f$3MrFOuz!i$nKdcP3OSyq(QF#ht`V*L##8 zbgr5va@4US;|>ci7iK7&6}s|4vS{ba^{`cn8~YOB!ui$J>TX_m-BRvcIP8*#>tgkV zOh~5kh;21{`*5B|6xJHw-xbJeyURNSsz0ry*@=oWl1SrH$~xAW8(2F>r(AFj$D|cO z4V*<^idvg2hS_E9cX-%07<1K?*2W2MP${`GQW`p+SKQOd5RwU-w)>rS4=sLE!ujtt z7$5QWRm?DY`A>c2O&IC&u@>*p?G;-39U(k2)|{GSX6O=YN-qhyWSk=vu-I&JLH<43 zXJ#wY=m|uonpY!b!1KE^Aq0`4c-;uB)L zbku*&rAL-URCojwao^hBZ-MbXXhWi=4eg8Am)$BII=J)6I9e$6V9^2;fm;6V=(K?F~CGr-=gncuE<&8Md9q~+{jgLAA@%F<1-#9oS+ zw@Ylb5zZr!6X*B=nq0<{3<0Um@hy8JW6&It5FEt{Unt+*Fc0Ls@1F1x>s_;H4742$ z2~td7idwo_85+CgQC@WhTEs=}@I|Imbtcr_L$jp8`$p{SAH=1rK_j+^`Lx~(5@Bd5 zmQH;XKDf7vVa*8=A}#I3iL2LpMd7HnNR=AgQ?gde;9WDC=m~CLFrbLhGR{4 zBbgMVMyqldqKOQlPQ!(2uOdbOekN>UU24?mDDak+JTtat7`P6kjXo1wxh@ZphBK=j zu&8!}g&rj$rf5sO1hx$`^wlkfH+zTZyze%x!6?HNCU&hgbt&EUttfVkpfuePGW@jn zV!&=GMB^Pt`tWn~GxM&2=eRfrt|6&as867TP^2QwpA)Dn(3XqX>*1XYCbC&VO!`rBx-}22#j`9 zmB>^zfi}3S(852bv#I+oc0Encbn9a>Vq0}jvMfAlHOgZrWcR|kG{=e zX?7^>R=pz9$xxW)Qm~!gb>#sak^DiHMQgdkZ*gf!Yx1m7c}86a>Cu}DjHemf1jXmk zC3`TfC#lQ7*eJ*6RbQ8xu&+D|)3Ry3v~1&e2m55{QV|6h_PvS^SvQ*>)meYP1ks~k zYY)os8(QjH>r*9(I8=DlOH@VN(MZWmP@wLYr8Yb-W$(@g*==Qp=!8R#0nXc9U9r(D zX6Mp){p4R-Mup3wiz3k7D-Y>d&O3($If1q3m5_0;S_eX!^gCXyt}At`1WvS z1Ue2}b<^dwU0bRrD#rZ@ZLC&bp1Q(;=8SF+}ljXi`-!~pp z^RB)aD$iQ@9bhjEu%d#~_j(n-wk2JeSPCZ;4MHvO(_#nKCKCd~tlQ6s_mww}xVo-% z*2H@yOf3K6vNA)MOJ;HZiucb%I+yg%KC&64rlI3B>|!bYo(*W%3+X4(3f11KThGVH zs=r*id{1sbQN}92@7-ornF+Cyc_s+o(rnL|@Q1*4{X(CXV6zLzQ*)p-WhiH-4uYs1ouhhk zC8AY+8FM#`$qM^Qf1Wl;2wq2#g1*K@9xj#49$eB-Bnroq&quz6wN6UJt@c!l;LD9F z%cRKx-Hximl-!5-{Wb2_{<<;YrRJ8g{kk*$2v&n=+^y3MSFp8u0)OoeBkysC^!F)Q z9lNF6n-VlrcN#|Y@zgXNRPCdsh3aMyWL7dLB#!#EWeQwF8Y$RKpnuqkCaA@_d_HWsh+BCZ0A|yIQ!kfe};f zFV{92hJxn>In#xn!*=&5>NUsyl${=CQ+9QtJjj65ygP8_82xs09PZGO)>iam+*3zt zeOE`dII~=)aDS9gQ(@BKz^JpsqHU3qx&8hFcO+POm+&WkCY%Odo5a-WakV`p2b-zo z%CfFFCsr3mp?gC(X35N+Y9t5=N&2eOz006GPHUTeAwe7kG8Q z%1Eh2283mIXIu~VIvz&ep7>%Zc)wZ(W4KW2*y7>GlLzE=oalMnPlNE3Bi$9Uq&7)J z-`v%yM%nkYe5oX-Onp^Cvy@*=gDgAHEmH_B95+7GaL>1wav9v}eN~cD`WS1y49$q# zfs-~@G7sl@0!euIJLYkNdbV<4cdV>T1-?KBv-N*-L8g_WYSk^|wAra_tScJNg=*!AFz7oOwBcIa%C<)=BIx zFQvMh4^xX7%3izaWtJi@PaL!m*~w{=z>fu1-5i7MQPbQTsA+4Rs%(5BSRsMc&~pZ* zdaNUPs5|uVtXk|X9`Z!T8FA8;GidURn4Qtgu=y#qHP7VjsR0f{Iw#uZlpsayR3&(+ zeXK<75=66pT}O%XmFrC4mX#7u$Vf?UOUYDEd*y8WCaF{N{b(vG@=`2Cuw|*9a5Av; z2>XF$b7MJ-el0AbtaeOw5$ZTN-&e-7S&B%IJ9aVS4|>|f-*JQ6@Kmt&f!tv$lUVwZ zF?5V^+(?&A6UxAmSyi<*SzLr6vs%?9zimBtF@R`xZ9P3%$nDitZ7tuBx`reW_DDTqk=2oWzl-_~Uk zG}WmL;Zd7>t*Xsx3%V9_^gJ6HjY@x{ATECgw*mF=QJ@AW9$sMPCt3ht})|EzwxL0Nns;&NF3&;tEY?H7UNTJ z{)72onrAFG`=vcnG_;pn=_RI~m!B#iOoTn`I8z}`$n~Si<&BiSyPoc(eCmDlwuGxP zw~#U3A7|Ruzo;IqjeD!l)5>AwS_N^wnm$6%IL!ClwPebp;iZ(OYKh?qY)vqnkzQYU zhOouA$P^!;To>W3S}Nqp>&bF%^}Yx7oHl%dek`Ye_F-9;kU!BT|HzJ7lN$Yn6fCWp zkzz|&UYVXYm@g!P}_dH)=-}Z@8Pd(EXcg-UO_#|lh zgPTKs-2<|gB3q`5vG<|EG6533P#dCyoHaEqUDvkY{Uj0o6{TR>HodsH0I{P#W(KAh z{wNP(T58AiJupng?4zj`cSkg$4-$YZitS?XE=1#0t1ctv*2|O$`x>{!%@vb*hluSY z*Q@i7MjwgBu{h#=nCT06jC(o~VXHi#4;2b~8CS}EmA3G^O^}?aTp>QgiKvoAupUc- z8W$8)IU}1ZXN1(&_WnU%IO6&E+Dy^hEeJxLO2G)*Wp=8`=ca+p*Vdc*@(~GYOfv2; zGrTTmN6nK{OAAZ+s`}D$W>h{nG~O>Jj~wMthf%u)URR* zE^0#+FB+^`qr=N3$L1KCahO?R0J-0_=Q9zt)C`^V4!=$a!B%%~Aw6olYt#L-v{g4& zLSd{v?ikXf?X0ae#3NufZ(L!uRHU}#7EdFVh0xaIOt)$xlWac^Qgdl279MtNRKJ@;&;n-_KQ;LWrBw#fu2v+bO#CO zpx@<=9#?-)`w!7$+L@PZA2kYHPiHLSSD~d-FZ&w@G|+AwSd2xx>ve*^;z`E049d&7 zrU74L+xx~Vc^spKdFj^wn=5ZhP*`{L6f&Cs6GN0e(=P-!zc!|bJpj4Gd zy_O;JGeWiD`Mrz7x_PTs@vN7^s17$LQfGb?M3?h>J!|!n^|S>J14g4 zdxWDtSJGSL-c*%!<{KM!#4X$vo8aE{Ym^97)B>L7Uu@QK!@2d=Fx6nWzSzr~t-@JN zTUoT``AX2O8f}*@U-yZsr5Fc5@KCJ@ueDPYB4{)XD}WDb^DDc}Yxn^BIIJOmY-FeE zqs8_Zmej{?WD^aU@kfAcQBunnc#@)DIoFt>CQMw!h?IpaVC;wdqK6tsU0rz~e}Z4{ zntQ>jQX2`&8wpDA&rD}wZ(5B(uPpQiNkFzi)s3P}O=Mb@BGPxCa?ZTE*_fWq>V?r8 zkAUO}Oi9f{I-Seb%n*fMDT&7Z#5cD$vE4R ze(?oG?paOXY_}F}*HDafM25L2JZnlQTVsaxAn>GAzMJUsphy}>kVNvzTTq1_R!~Ol z4i}`|8w$?%?r5Z)^wK+=hNUy)$nl{qj`*pL7vw+pm*{4TJoqB)QEH^ZN|aSjlP~T}&6PS7#mPFK; zaX@zJPKGmC&u4Vb33WH{T(N>WXFhJEmX>d*T%l^Us1x6~JlH7gQ_W~FE7_DFm~^w% zbtba$%Ne_wt*7*ri$N~s_VEZd+lbp|`3lt>K*5gjH=DW_?NoTxAEAXN--`RoC};JAK}i`+L6g+ewQln3LlS>yL6o zZq?X(gzSmMjHSj(Udrkw5OM}itK|>0)HMR(NGGDNK4}e}-E9YFYSc}s;WX^tHC~|k zn}j7*@v|Y%)OS-Iw`Oi0hi4BYnX3y*CR#sb-KzBhOK1s;o6PY}-|~ zR;7uGC9)8|=du()=ZpX{1;yetdVadvm|u&A$W!%Pr`B@9Q6-1gSJ7>^gzomDi5fkP zJsQ2B#u$(195`mvJz!P=8Z(X+UauCc*s8P_o#-l--m7iT9BT!X3HVFT;Dm=a&0`^PmhvmUiP1JuLMZEj zN`=zV3hrZHEPcK0=ERJUokN{mTqRS;f>vM5`p5~s$?Ec(^)Y~;&Zjh?7eeerCm#(d zYBx7iT(+O2zGQU3(derL3Lwc@O&pZ6(ZqU%CBi7zy5Q*an7mMBS>5@U{qSgXGEXBl z@S{Nm{N{Kwy;-Zu*@2E^h{{;F-Pcm@8JHeUrIBCR>9T&Fu$J>R4wf4rL3lIxarum? zeO_Kc=}3=}5f2z99948+VzN~HrJ4Fac~MEc7M$3r%w^|FISg=xI)-)?CUN?cp^*h5 zV!R7g=&_3a)Us-!2Ke&B@tFsTD`O|OPwwfNzK;@W5+)jFso%`s7obpvX$zZ^u@oU& z7L|G{IgQ87+|RW4E^<M!p_Mip> zqteewcj_unY!|-f;HdFdDzup%8GV_DlnU?n(`k;Ch79`&rI(D|O^P46vR#zbY&YNP zlNvd-t~64(#dkj4PE~oa#b}n$C+f+)YT5cCy5LJx6Tya_VSk)^Rj!_?-zJ5x>bDE% zG-)Ks?C~a+Qi~_OW^+vuHZ|R^s>L}@B+Xl-GxhSZ7rke6Ig5q9^?~h| z@DZ9R@20kiLW2u}k0xb;7%{APAy|%Bu1$TZk0^We#8}*-9hW|t7wkS7h{yxQNPp$t zmu{&ZQnYPqTT~*VRn&GmY2GmKB;_-NQXMbIcc{n(cJ{{jS8S7vt@J)lYI39RE^*HG zKlYxMHo@=7RIMCsbx~KSl$4SdQg|zs^yK7({i%Xco2HmM+(OTJ|4UBn-AlZqQDrwV zi9?|vgZQl^#BX$$o%NPUv80QRjMf>aOkIwrnTo{b3a@KjjUT{vUN1p7_nvt?7N5j{ zAE5`|gzn~Psu;%(sB}Q_h+C!_zqo6j5)IKi%GkfNHIfqkjBts*OLBZ7wDH}fg%QXh zENA=&qOGyk<>NXXl?&xPcRdV>;>cA;q#o(TO*E9lt*06dC?^-mzXyljUO^V7IV;5n znjFpJ=LcxiqPk&|$JE6qQ3_N9KHl6_4TUV*chOyy#BLj}<|lZdy8`W+K1evTO9->Q zm!(IN^SqlDA81^u8aZC<;+Q+2mXG4Til1;Hi?-dp$#ZI7;U@r(3?K?uQKmHLY`#s{ zen`RBmld#w>c8ZEd)ot5n(7lPk?krPm54wOSb}hE?c;Ra;oE55_Fi-?*Ap8i%fe#PqU6-{JMeza{||9*9uD>T|BpL4)rrbE zr%*{#r$uFJAt6hg(q^edmf;}TmoSE5NJ1r4LXj=m&DaeyW>ktw_GK^&vJ5k3#x{l- z!|$Hzl=tWJ`Tnl&?{{6_>)Rh)7iM|AUiW=JpO5GEcy!+D(Pw0p`O6mheeXWrvh~r* z>t@Q*$ElWUod_%fa-4+PU{wsS#HmVE#Jynb5Q9k%tS`M_DvAw;ScC4}h=%k{VOZ_k zWv5^{a9*CcIdV7SFK63lTkr1E`3%y%{^i?*k&eRRX?vAZ{$V}M1=zrL>63=ayhlh6 zCKIo{5RDw)*SEH=cfJ0eY;tvi726*2C}L~j)kCv}tUCKy1=IDE>bjDdq(IaTPAE*@ zA}`vrT_QkZe$G+#gm%OHnM{Eftb}v$U5IIUBDZFUyYkqrcUq5n%GLrI4UwiQypj#C zimhUz#1Cq@KN?*U%a1+>3;@7kgn;+`7+q%o2itw%E~ z^*!*>a_H`CIpm8fM9?d3Bkx!4sx~;m}9y-A3q@>X@y+(_MU_jZfHym4RFP zcSZCjsql~GE6*UlKpS0WWUoiG1j+2OS}|FOxOM@BX=nUlFLCpkH?DvB4|(Y_-)`#p zmDg{*xV%dxZWjHUkcrInD*Thi7+K@@y-8nuxM%j1&i)kxQ_XR_OO)kLLur?KzgUy{ z*J!w*uC@?LhEMvF@2ypfXprlJsgjKV0O;i?kfj=8pL?f@r8+dwAt|<{F7X%en9f=i z<7QGzvC8PKa7zeMs&9Jld{%5ofLPMwD(LvrdbE_|ZKHz@5j$G1rq=L#6TllM!!QlX`ciV}D z%M1H>MyWf!_~>U-U(YN~iB#WZiALOSoRNNKLKFRE>c$W0Wy zgGeL1i`qn`_wbkP7!dirRUQts2wBmgYS%t#7f$R%6XDU7#3Zu=Rmx9>AEXwV88w~T z5@K^h>!yA-I*Bgpw(WN@u1Ef?Y85p4{I2p*s#DTzirXRBVeZ0s@qoaA_Bg?K3)RU@ z!>}0Etr0bA?7)d`*0G@7ke$?i7h-fC3y*f? z!3)z-y2xiKpgEI751P|U0S#YM!@4$6d8ejGe{eqRG8{ChnI4QfB%+NMCORCW0t z6J=Wd@%BwgX&OD!`;Z^#`i9WlyN=-Cy0)k6%w2K2J`?`8LhYp>#08y(ar~QPs`d?< zvTQ1MZwe~jk`~b^b%nJ)`_O0uij0uv-&S~aJ+(+4tAKj;p!q87A}P|f9R(@ay3?u} z%{|vh&vr1j)tfx~mpb8hlJpQFs*L3(Ywo%|V{szC<3>`z{N4vGaV2hYn(G`G|3L54 zFUD9NwCKC?E>={QhRs)2y(I0Hnl>+5x%O6aF-OGig(Akr80SvHtRu5$2!FzpeJ}{Erhn| zOh}d(@<5-HSw3lhm25dnyp&Py5p6PKiMDg$8{0nW-hX3e_U#P{H=7EkD!XedA6|cM z(!;sezHAzhMf?RM;eBt=s2Ja2=JJt?j29KiKX3z|5k|DC&WYiZW(S_aG%YY4maW&# z!~ZPIN;NL^RnC6_!g9n`4K_H5=ikT1nH*B=d2*po%>{6CoKGP1u>x4D#Jl659#nH{hbkbnSC-#j3Eh49L&N4DlQvT|>MD|``luyj_ zL8sVDWM3cuC%rHG)T<}{8>5CD>`dKpNj{zjIF2jl577z^jrw-)n!2bt{{X5F+@uFd zkJ8Y1LtWXg`CtY69jmD$w$&bBV`41~vzYl&r{`9tLdw6oO36ebX8=)kB_nBm`|kNcG=bRAkW%5{)~I_D4dUh= z#GIq;xkF7C<+qT4vwYvueWfX)Y*%^al|(0r&O9n86m>GL$(1@ZjL9rhpB`WkVh?e& zHJh}I@}7EJV@=zYEwLb;UR!YN3-XXGY<_HZ2*AefseY8UV=$;fRSSrVwdS${_YGL+ zn7HFyo@bNeOz*7fHZ_y!8KZlSZ|GuMw{L%{I*>6s7u0T9q-lsxTqHl>aCzb{mBxoo;$F*zekhgN@G-h>8IlrrUG06g ze^eGEaIV5~Ci1+Rgyq?Mw)#h;iGS$t6QYOA4j3L%^K{`<^#qqfbjzu?pWDOAApYWl zbNPeUZC}=xlne3j6-;K@n3*&o;u9(9FoT|B3M^40^qkI4=ipLh_lDTq>*78nIB)vB z<^FrA(jQ@Mb96C_;mOE`PlQK19rye4oj5= zPnLaSMu=|Z;2!^$ch^gHqT`W!DwS(Kx=hw8sG77QVlIfqwO-kZ`-Rd!mps6=bMwR9 zd^aYY#iL`h7;$6QT$8XCeTWI`_T^2D`6-@wh+?4D2Oo6*wB>`|O6*fcfV#rNgPdsB zP%k$a;cUt`}cN@ruNcZH?z> z8k?P1v429m$y7#qZq?|~AKe*l(IWm4!S*T(1`)6gNFXKnQnoUaO3q+Gl3>T00CC=wp zmM7*5@|cB4Pg+u}gFqI2RhcbW`>7ruZssj}^7ZkcKE~ot&50I9h&PyO~jL|#8M4L$xdzv!>ga*^*-bF?dmiq|pa`Q)x+4vo@O7dbD9*p85RLsL z7?E?Zc5qm}>K5MYIe&3cw)fqrAQ{lVKGO5GRGl`}_IJ*&H&4cdoa>o-ZXDWe zoF&2RmVwMj!!{*Neer@6xjrZRJ15yjdTP)46;n9{5_67)xg(1Dr*jfJv7uL4W5z2z zUV;6}jo9mDGxJ%MW1KeM0S7JKrTCd!kr@!X01gnU>=JDP1OGB-rN4zS2`wV08^su3U85P8jy1Bntszd<=vPSIz-FLy~mWjWNGBfuv8O# zteY8rn?eVIu8f=8 z9K;k=1kqYXldQ%K@*yqVtcM=rcdl?4^hFriskS{ zmUH7W*&CH|N4vQRi`~k%?q2RWQKd1ytFg+%2U$+_O7t}%>0hP)bs2i2-|2Q@Hpm*8 z^B|akde9%;B8!|^$ zXDD2+Agjl=@$?}ba)R&1n_9}BYSKS^4WB{8daVW4*{koOku*2#ekffQ;O|6fnc_fa z8%w!LY!`uu=ibTInWPV>&<)-fD($5%zJ$>auODMXB%MrArF0 zUJP6^HHYryJTLYr3aY&EyaJj1`-M8kgTHNVO!BRBnY1zZ<%Kpz9J#LdR^E(8l6xO7 zBrnK>VtlvJRvp--TQ9cC7dzDN`lPsZq@R=j^pQL>`4!Au_U$c@XzQTMUS$!EWGg5$ z!2fB5{ZtDbWqXcN0?KzLWbcikfQ9FB8tATn&M*%5pEwaix0A420S*20MB|h?dKO}W z@a|KR@8?8eW__2ODKKHQg%6!WPPJ?oGKnjI4A)K%k& zX}?vG7?c-Br)O-KqoCJ#e#wP*9!!zkRvK7u!`K@)c4tb@wBeKdL9d-^X!Ga1RzWr? zXaMQi2`7+>zjiVB(%Qbx3a*P5=t4h5eD>7!8;Wj^GM+Kmj?|q9KWt$p4dYL3R$iWe zV|g!a#*k{-7j@~OcJG`GwWRiX6Qx(hQVltE-2yi#7nOV7O0JWLhz^|zELq#3X zhD^KIKMjUmJ+>zrTgf#WQ1DW?z%41pGwTX^Yg|>3CsQkph|18I?6k6vEKz><$^C1R)yM5tk>Wl z+Zu0uvj=I%*d=c@UVG3lrlTqiGqPkuR7u^7nA(3L##wzR4jlnZhHN~7_Ky$sC1>XJ z+=Mdmi9gyEN$o$9YmWcgfiR;-E4VD56CRb~JF4$ObGnikgtJel9JPphW6$XcHyje& zeFaTXwSHqV-le&k$A`Cabc2b$n%3feCFuPsQf z;q7U`J~1uS+|;SR#wDHf=dM;E6lsn2Vaf4k`R67Rd52kSDoH=l)pG24$`&QncB zU3FhRP(z5A$LPJ{ONSH^LGcc2(ITwBUArK#uAJ3Cz2Hdw=_#*yvvVKKVv;IvK}~#I z<(XL$m9)=8JoWex(!6wd@4VIJn#Q8ukc~Q9#Lw5ozGD||jl9bVoyoq8|MW^WR4mh- zH?mN~P04A)$KEBKZt4I7wngcj9glY^fwTsbt;a|<&8wAd)X3TD7cpQ}Ik0u)DI$cl zD9u->@OZM*d=vjK6!j=bw^uN7_R&CY@R0(|wWl>&kE0ZU^7WC`>z9*?m76F(gUaKq zOf5XJ=Z+z@Zq_>-Ip8lm%elQy*L`fC`Ua1)M}iF}%QaE8Em@%2ww%tVH4+`Puq+M6$1<9@VM$;L5Iyfdz`^OwangAe zBwqbxv;fT2-52mFfJUL9ECQ(H(>yzWJk%CzU4=#O)q zK!TB>kNn-n)X9XIJ&8+qQeR-c4zSqu*^|zL45N|GR{Ghod4rWx@Zp!CPM=roiv_#; zlf?R9zPTy2-qw4vC!=rRD__Krq!u~%C?@-xqGKn5q=VJXm(?VebI1Mvq?C+btns|` zq_Jo$UcvTUri?;rbTS6zwdZ4slrRf$W>42un(`vx&xlM@=$W~gYP_RT@5;89j`y&8 zJAIf@zoNWKm`f5pS>RiEpfeR_de$Odku~_zn%Z(o&OrgV<0}=0d8| z8L#fRRnXdbL)O0ObD6R`#UuC-?aT}|ACz>DU*mgylp&6x;&VD_MI8Ifmq6tu{k&yp zLT62GtB0)UN6v*1bbla9j?_7CC+x(tL<_3QmbD?j9w=6e&{n=wI@{^I%Ywyxfb<8M zw{2ap%ZdjppciUdc6jI;IuP$xc*t9@)R?vRQf*!QUQC?&jo6eZsj2g+#?v)uAKR^x zGUt$DNr`Ga4{XRj%{cc|ciXKR>#jvP*(_S13I3tJ;~#87C-=pD9O3A9%tO^1qrd=0 zrOXAAQN%v(nf}F_jYdA!J?y=sRDNMp?9_tzem3J${y<<^iKk1wxzoGracAd^SiRGa zpPou9-xWUkLb^adpS$HS2{K^N(Gu-beA%Kn%!; zS179|tT!-aPvYiO84ZJ~Nkk3Nzhgk7rEJ;Ely%gNW;zq(z2{@AwpHB-r+(b-7q?9G zpdL5)<1v@U^oTc>XWfH)fcu{w{I70a9&Gcf8@J7h zrbiUdf)RPW^-@D2KBpkP% zWh^_tL$owLZo@u#|8<^3HsNiWMrV|xty1hbu0lpE_$@UlAEN5lxm{jV%|Yv={xjz9+cm}UDh{OA23+Qt9|`?$XD{@@vYkuI(&ECUcRbWSK~7PoA0j+ zwM8dj5%*#fJ_=_=+eS4HhY>d3MAA2kMnSqSWGMyAsbpg*(J|{q2HoG|We>m6UAdi5 z`Q9@73pI57FpdA4&g5ppVDuWNNSI5JP3n{4L95aWY4BrsI3Hae4d%?PZ`Gt|a`#}o1_;hI zKHul@AH9on1uxo0U-+d^h0ITfo>T@uu3OKAjw}D49pe8V*#NX~EpggDDFnV2hSj?A zmWf@wsWaIo&VL4(pbevYrCXZP1BsjFA2hppWMjmz!vF!v3R};D zCt3U4FU;RQCZ%k%Q}GAk!0qE%fce?gcT>|C_9OA3Po-*oaLH9oL~Y8_f)BBas@Ziy zu@K@LU(9c|lf%WZt|9Mht>ioJ9sn^8VbY_oAYFbWV zXQhYDxF_~{Chs~XZz6t1aOhisyD#$!q^0rYea`1N5BWVPf6s45~yVB10)5IQXCmM1AI%aHQfVYryT} zfDFnjRM2xyoP$s^*u#I;XzgHfB3C%fGu)``r}&*pK?p2zy*UD}bXu&cYE3=6=$4fd z{xfQ2&Z!pAE{y~7tC0kZQoUj@)7gD;nJ;F(Qd2kA>5g0SA(lEgQB{1ty#574OA8`5 z)Ga1U_+Fb2k5=+oZ(}oN1cQwxRJLK65z>MgIN;i1D?#f|*q$U8)AB!riybBfH!0SX z@ytWz7`v-BR(7!H+ zsj5HnXh73sAcv}*Y-dm?*t}UGxO2c`wk=(_p6Y7Tbi!|JD5O*x+a?%%>y6I+{ z(Z8F>NK%(|fTGmj>}np}S_IN@Oad?PN7wEQToE{1w0p-$G?9)aBN9P|dwcft z{#i@!U)NkG@F;e9*bqTFzq!~ZHR$&4d>yM75G_QPVcMzuTzIUfD9n152B9fow+UwV z(w3}TMYtk#F>JmO0>@s1b8D@Tb0`@44Hbum=c%D@Md!qD029=od9CR>I@jEj{~h6B zEL&AAdp`xE@ZA_!R&Qy&>Wk1Y2!TD3>pQAw(wZ_BE)mRdsr+@|7@`7#?inR44b|7! zX`&Y+G()iOLx}BJAq27Tuf}Hqh4E?nEgWz*^N1lfVoy{JJH2uihDgP{8ZQgIs!awp4|e-ZVN<7^wWN`e>1RTU#PpYxOpf?y*7HQIAyIz`~c$HEAXp)>-rW(KL+ zrr2fdN2CFfKcB3#f;J|(50~_lj=(e$kde6-2Y&8`e3GctpnzwOeeF0HkyTt{}~mo{}Oc5xtni56jzH)oEmhQR< zjV8x;?z8v@(2KntU+6v7q30GM6;gAn}tWlL_PD;t7W*FD%~?Z-EnS_~IC#Yw2w9uf-NIjSR@}#9ESH zZV>w%IymMT`!G(uf^2B^bu=w5M#6dRJd;GDBDqjF5eg6F)kV5kXSx*p0VPP(R_Idn z1_{5?U1pPdk3ER!*|AG$An|cdhbXN33`~ruegN>0R#=@~`4B}v>@b7~;=-~ztWa_T z%^{rb;SH-?lQklPiVE>JUO4Uq4Gz9SD!Wfuf?c$HIUHZ{XJf3auNPr4M_3*$D!MCg zYxs5ZY?BqQjq~guIS~x1!Dp&r>(gr@c=ZzeMXtFxXA7WKVmwPky0EFM zp7|)jGN&*C`_?al?pgyF;~X62W3a)YoNjZ<8R?i+EI_x#%=eji-GUJ!c)ywxcgUo- zRg};u^vZbh44^LA>Pk^4y_Na>xYypX^W)wkYOw#%QDWmM+eJB5(l&Fmy&>JLP;&!= z<%Km(=fqKQ?&R?#eBir#WL$$}Phs#Z6O%;#3)dRPGpzg6#lv0CKx6ZaWLC!9&<><3 zo8jXVHpPPIP+K!W%d0J5Y8jBwHO1`U=wp0oEzUogT3-Zy<5&6gmV)7zZStHLjwefs zb-G0+ku~r;zxRy!;6F-Sroc>GR z&Wb^XJuXwNOu|_usg5Qwoqpba);l1#y3PF@a9I=DeMs7NX&`UCLj&3>cvn z*l9M*xG-?5WhCGR^8x$U_H?Mva%o_;hgBA*WF{2_iWeWi6#aRh6svO(d3nT#*Vy;$ z!Vz4{`U2Y8p9C|vKS@h;DeoF>Fn-RU_eeUs>{>^sI2Kpme3F8@5#y$@^$@A`8~=v( zlznEgr7iGfuEak!s5WirIy2jGN|kNjbZ;%4p(EJNeRD>-1`yi$g6avvaIa;$_x8+X zv99bwJcWtp+HjXKgw&K!cN+^)SbO*yp}!~1;n{pCu3euEX=&tqogn#up}$PsJjYrc zMNA7~-N)Qzi(2OWuw=RqYW49m-+PS*Eyhze`nSF!+@);mUyT|eU#VlbVFnqo7hdWj z0si7rgJfO~|G<^rV}PFqKx;_Auk^fqtva?e+mu_wF>1W1%LSQ9V*eVN+phXw*|(5` zf8=F730@eA@?2U7oyx>o)CSzDQ26&{A}t%&aKuh0u|>oLYS3 zj9=VKER;gUouXs-k;H&DH4t(z#Vw7HV#)J$A_E=gt=#-j&C;9&G3abT7v;TPojuI>*?-#T{~> zWLIiAx)~Vzag|78{kXpz`QLNb^6_`9)>bogI4Ot&P)$Ov zA6UrT44gi2!?!TjGfaT4zMtvXDnJLOHh0z>W;qPCAU7^aLh$nTC@OJ@SMMZz_n zH@vfa6PoE4K<%mSqNwM{nF{G3mR0^8B1)LJ?pq$TLZXeXrT1_ao_k3m{atM9sNJE< z;(@4X6h?#@e`E3VwUxh(+XUoR?8>g!X**Wk1*LH1c~gGqaUCAw;~C`=aZuNf(uC3Q)+Pjzhu}Tcib*zOJ)ykgA<3y~)L!UKTdGRWVNKJ6Wb?M7-+3P*HwhUDd5fNe zK|KH|b8UNaW|v!(X<2eY9n2?gV-f8CA3LYVqvHmGmwV4)LG+|P6{6K#s~|;8dV+)RqaBddjq;w zxIgx~QphzDuocR7MWS5xItrB1M4fq}sk-tcP3enj3>6o$QX@Pnh=#_TXVF z>c?WbKeux4cE8Wl3`p_hGql(iaS2!N2@3AaJWbmL%I<24KDn&ra_X!! zs=P-_p_q$TUOHb}>rB?gZ~0+AGTw54G#X&hqaw9^BCR?@G-{Q$<~2r!R&-E;YAi7l zzFA&Sde14%H!GpLsad*)(nNx>pg1nlN5Vf|g>Vnhx7YR+D`E(h9n|FjsQ1NZG$Nh$c50 z|6na$F1F^XaqReWq6~aQS-;78Dx5kSI@^+VWMx{Ml8=|cM#0!#l@|tT-lig^+_Yx< z7yOog^3pkNfaIxtzS=Z1#A=abrr#y*2V93TW7uf~8^N7Z)50}Z!X?}+^fJ;U0XPQR zNB*E0W=u|VHrz@)mD}B-2`60byjg6w>f=tc+{OMma(}L&c3;0 z0;n+q7Qre#cEw#?tfjjWr#IEjg-giKJZgFzN6wET^lf5(l_?>hu**A*Pakf}*Fn$%{>)cI}c;$7)p{2SP>WVRsH zA>0oUjbe}Ehb4+FNA*XJELGjM3U-^y2tEYv$qEwEyW-K*8AJu4=S;nQ7?v82W|5e( zQ#&9Rl$0?qSfTYp5nB<)Uz@m8(jQ#*$q4C4?YE{GX`N7@D&p3_VB(O}Dyu_Hfp8n+ z473?dB7~A^sfm$08JFN;Gi=nn;K$~iyK>G8Mfrh5TK=LbBxmRbKe3`F{#Go$mJW3z z<9CEuGDnY-3*zofQ|N5=#BwS;FDX`OAc_F4s33X@Mz`f zda((&3?6dpDmLvoN%5}k z_t-TfCAhf@8@o^m3psTV&^dz=Vn3f*`3f*B4~rNRYefaYg9dfkCK9+X$e$NBU^%QA zbz9a3OwtlLRilCyf6Fe3E%+l(rt*@xI4okq5^aet4qb|$mdbPMvyAfoDMR>lOCBSY zp)IIq1*(Qq6Du*N$tJ}|q`<}t)Uru*j3%UKRG z1fz(!z|i${bva7@wa%a%r0a-Wy@bCqce%LwYUc_@vwP0p%Y^?#Az;pA}FH$oUqwlg|moVF;2xoIWj%rRk7T zHA)JDdMA9ZD0ZjUVV(M&LU&&3)LP~aBO(X5Nm>j&BI}2LQU?%)Vwrf!yK)EQD8e7_ z7I&%h$KJ5WK>wqDmXZRo!E1OW7lmOH;+hBU&K?GOUTSPt)m<%}?n^wiU=gZq81}7i z#Q2HOpmap2tDBpzL!-?^vEg0=o3R}-GFhX%S5I~E<@KqEnU`rk=iw_B#`4oQGCWcJ z4xP_RaQuoYjhNPpAD7OcMj<>zZS7X^#UCfE;Cf#ew!#bL3%5B=_bbsR zLlNEkxVUb`_AnNFl_Wi@QJB?s0y{1Thc3 z?`fwn#H1|!AOOL5V<`JtT*8mL6KVPNTkN3^f*$|IMiEn~Zx{RXHzpR}%Q8QH z^^MjP&@2D=jYvYo|NaL#flT=L+1T38(14)`7LQTvE2HN5D&c4D{dNf8tA#6h54%Rz z?09ZMv$S_Kac^(``3ah;yhP?fhRu#GLHeqK)*T{aI6nE1CC zMNLHL&7c8ch-b$WQ>h!d6vPtxBhe zeE3Vp#LJ3xfVkOL)nN=9sRd(U_4WidUVyVzeJRO3X?0eKpc(?)JGZ$#%YZWSaW$2- z$Xv>96EApSPQEx75EVumalv>~Mw)I074El4Ra=GjMR-<9CSJCh^-#;>pJNDhRG90w zFkZ&XCN5!Blq=FUfLOzcMW{J_K4O0SJ5)Ph0x=bST4Njp4n$*N4h>7as%mNmO+O!n zu9UWk*E6+G-CY~uaJ2KvzS{3^g14TUtd$o~7wb)N@vyE0Kee-%m@iF^8ta4`Xou3F&CKDB>|!+%twL`2FCDw|+#>NrHb~!EM*CBFQgm_0}<$@_ZFbk5T$DJvEv_ zLUd%`5UZo`K+OQk^L`>z8D!|fUBy!rO}LFFYHJyFD*}7H9|ZFvBKl|9Aanj+G1*(= zlJd4Z3@$Qo*m(WK`FaZM;yNYMIAG6B-ICbClOU`}LjBr*gLhwBAwZ5Ps7q574a`rWq-f{6!|5CwQ> zhgbx@lA6zmBUrMdmaF#HVZ(3v0rWK!ZKCRAzAsTytV_oe&WY0*>)Cl>vzL!}NDi13 z!ug(YvbSClLMqcB^YQYV&HH|G;)@)4^=lXcXQk><4GPGkWA5XA5KPWmdPH|CYeU!i z0umSGnNI_D?$zlRk&UV~x_BgWBiyC=h#GGhLCm*vTuWEn-I6?tvaxg`(Yg;RHktVw=+oSZ!0IRsM&anoAQ7hg48{LVCRg`-QSE8&aUUer@+ z_J5;-*!WHD)Bo-$5fKv?K+bx^>#5hZ5ZZ%zo^1f!wnEO~<^}cED-%kKq z-xq|Z0GHxL=&P)#FcpZd+P{$m-=b@Q%!&lG*2d!UU+GXqG;19`6!}a>3#T2bEl>cS z!-NSXq@Qe`7;6pf5H4@%i*uF4pPu-2n}5s84fWQB%zc)!eag_ul(lGtGB{r*K`Kl7&|868h0^Dqj}9}#`DJb?sFb2|Df14IG73BKAg=?hIv z-n`Pls>%u23K60lG0Cq%`hS%%c$tg~*17fNE{@N_tv1LT4+*)$Ex{jwp$!^MaAduV zK3jcjxB)BRa}An)fDRGeY$4PU^2=LUrO0T`eRlxIaw zekzmh5ug(d_K@&i&aZYlkCgvAUbz#7*dw$Al~>XS>6Q+|Uu7na^g;=8tE8kwbU~3@ z2vsJK<5}bvn^IZBJ;&B?&>k$fPF4g$9SCmxT-(DJ|0`Iz-lL26NU_oo_?TNACsXGB zWSg`;_)qZ-&<6r?OfAps&^JdmVsZq20Z=_5sq-BPJ71;LK`D3VxNjZ+j5e=Yj!$1i zIY5=c+OXLhPJ6I>qp}>(;cB9wnv!3RewBm(o!F~4%YP&;+$8E5MNk5~qPl_TgqyAu z>)A3Ctn&@`uLjPEzEXxJg)`?p1Fin5ZP}5Rev@rq{Ji3nP6k`_xq9<=+k$U}zaPmH5naa#t}fn};y$2<_xDHo1>1h_4_oyFn}nc+(W%+hh+V6> z*O7PnTQkJ!J9#RdSDcdSR!P0c9A!}@Jr+m#m;d{#<^W|V2)Y*khSNHmpq-h2_k~FP z_p|@MHVOY*5-IC&P28%g6GBj>{kOz_Y#Rx15|4t-w5nf1L|1q`Z{^@tt)s}uD%}?m zk=?6=b3{aL|F+tyiTt(NG>P2Xy~;TX--utMXj9t@u&l4J%PQrl1D1sGvRw`R{}}{a zgsI}>>SR0ZF4VUuWTpaltuvDQlzqYpT69V1eEY9wcs9hXtmh6zfjZt#vm~ zE%pI4y?x#FEFU1Yk&5{7D2iIh=uq+E8Kqk8-Pdv z1%}Tuqpq%zIN(ZaczwUp1*mUGar-Kj2$7ATX%EBAi1YD1;fIa00%Z8cb#D0i$h2eN z4WMrJbLX`@w9fEkpN1eq@rYKXUOA8mvhuI-U6Ji2#;--s--DmlK>C_~%C zf3kN7O+eHRR2>5dA3itUzN%|MM0e$6E=_A(Sd#xc0J__x6};6(KV;GKSGs8U>L;*n zKwVo!`SBee!2Eh;P{2S6HAKpl1`I%0iwBO8_3nO)v--~`rn>^%ly`G;v*+h$5S*~^ zo_g}uU#o#%+e*v*h#s3a@H{~9$QBfX1;QAmtGeCv3qD7A-TLe(WfLSM6DOF@C%}S= zR>i0pC&58U+Hd0J;Q<4=ndgi>g9+w|XI42}kq1By$s5S~#mgCE6XeaSK~+Q$a?o+d zs_U~J0>B@bnC7xy>1ClS1Z#QD`)KLa0pPb!Nl{a)j#eh*IRlBj2@plGVl`P)c0A*d zV+~&e##Ki^_^l>Vj|RX#KVy}GsjM8uYYITg^%lFf{6B(qlO5m=UQLnT-l_pAD2v`& zW@ZW|bzdGd2}?IMNdEkP+{^`#_XtxkF~2xV@=B-APxi_FY>os6;N)soCi3_1KP%HB zBEPQ^5EBv6{dM*37y0A={f#zZoAJ|;R$N->5YWh}VvMB-22uwhh`lm}~!V(hLmF^@%SdxVMT@7bw z_`LJLeaQ{!ou3|tg8Eqd#JY|SjHf@5>KmJKl3|M#N}9wDucsi5k?$o^07E!-IMv`~ zXXdFYq(@NWr@4f&!bLZA%d|}rTvSYX*$$!d39b2ux7k=5kU{VblQBR3x%obC`lGIc^%kXW^g$z%A6>R0E+G5zs>mNEmgBvFR5hoL&WHMxYByJ$5tT3Ta&jT^K)fQ;5T?LK|6McP>DFXIa|Fm49l$lN=pyX z?)V~9qWO6gU+e*mv@A128J4T-*&1~j4N4>GSGs;!#519&U+PKk*OmCWC*OKh8aV0v zi;#8xshiiutew|>I=L$sZtn$DXEDp2>C>H)wbKo(WBi$%KO$m4s|43^>yZMt86ZE% z1mbqHpDIsH-G{^`bQ>NItkg*(GYJOd&t zes{5_j}YAi(NeqHkA=T^+q+&GnL7f{wsPZMhkrRy*L$)OF;FtPUCm)x8W|msz~{UO z9Zs`ap0%Dzf7R0?9J4*3HPuL{#pa|hk4Tdfp~_r@i0-T{X(}&;_hH3*OU}yPuL`E( zP051w7@V9>xp%xKRv8zqV+e-RB`i}%vYFkWDRG}n>Bshx zi;A38_GZJq21fii=>lu2#z#0(x$`Z5r&yHWCN!e%jO(ZAcWVMka1=ZUOr4yxLs>M_4X4;c2HgiW_Z&8kYm?#-@QTs>bD;H)2|`H^aMU=Z2fWOEydozB{&GkQNlMcq-xhAvGym`JU+ZvL>{ z(~v(gg?*~43e_5wip7U9}o=5kv4yT`f0! z{Ln(ITS$AkGM0E=YrV5JLXALu3{jFZEuu>06<(^g>6lA7q}7CH@oL+sScO)UG51IU zu_D=E7^v=@uDCO)#l6Jkbo$8<=0;g{>}gKbv7_1USF=2-hIuke^^#TYirlsblFQGK zY;y70flg13aU>1z2XVr&YnzCfpyrppGlkOBb}KXLkBXg)J(>huj}IAV@2d6AjO=+vMqZT6*r{P zGm5F4YQgIxsC)6BBAd)ly4Fit9{yk)i4*hd_R-q-(gAdtF!3{>BAi|IGWO8^rDjVn znH-n5QavxZw+iJK?CpH>!?5!d(1dLGkTg>7g$nO^AL$lKeh%mL!;`luSQ7|HnpH`pfQ#AYBQFWWzXJ7<1zK@pb-goEFW%}%kSIIb3H zuYO-jlhbCifaXrHZu-u;$_YuxS%%ikiOEzhWF7{{_~RKbE(d4>!7@s-?{X?>_A(xl z6UxD?@mf1zx2VT9v7H(FwT|4ZY4cfL*+64{v4@2q)&QYkJFnUxOmX?0Ae8=f8?cV6 z*MSyX3t(LC@4-Up!$@L&7~*e(Xt!Wwm5eQEOF>*nuvM)!t1#1_yurWS9A^B341(Jdgr=d3dW;6CrI(+ z;+rdO5LQ8U6;@HA>Bgq6ZWzw;p8PALb?=Y5Dx%@B@(<0n?ULV8R$|p;m=$Pw(Jz8g zdTbBqLV}K7gKWCmE30G$g3saDMiR1p2IeORIPP$fKmV*Vq3FUpCjA3Qm4o)g#j(9#FPT=y%2U`pf51-;V|6tbu8@W&A&^y=PccS@EPw-2q$3I_B%ucqLc}Nt2uMp1NFve+B(xA9 zB)KcjjQT(4+~<6_*RMVaJA1Fa*89G{Hc*K^Tav_%3XsnG&Sdk~5}_eDWMx8J&1 z7S$E?r}#ISgBC~d*4fMx4Y%{lG;K5@S!MiK3_Enb(LhFu9!2*DQ(M?aEVB&`Ka5I| zbPQ>cj20hT_Y*hE?Pk5!^N>bdcEwJI@)u{Yte)eFlds6^$cH!BH?*i z9Z&MaXqbK=BghGdEc4KHyB@x7V&L8Z7hMu1WMaRl%(ieG_^rQB+!7K~c5E3`eAT?D z{6>kclcG0pCh2#`p{o`>={C_j3XOZ{AYyr{XwbjsukL89f-U z+QDVGJF+qO*{1hkjTuY5>At9s348yR={EuM%yjwPiW6+P>?-oPtFZZ(=48qm?rmFP ze4z+rk&`mhX6vIo^__+TK2DHEd)I@&^OEJU9iJ$_z6Xp?O~6n4%q2v%b2RlbZNvC% zEjn&=CC%YX1)|YlsZKNo;cYiaGNvBna~#QeuD{@rPgt-aNT5H9*nYxvp>{UkLE(z^ z2X8vHEo_8U%dM56l{eEE1H^i80NF^!;BUG~CDL@ij;aYm3}e3+M|Yc$Vj{~45E1*C z(4tBXuPN--sMvBH=23GUObLB8%YMD0`b4&7rPO-^c0lU4H1D(trY+m}J(b$8VZR1( zSs5wOF!4M@(R=kUT(8W-sO`7ETRFu9*?AYB0XtpitpT0 zg!=O%X$ArGc^*YdO?kU8Z(YAV>!16FzbRE$0h`Bn?>|5IjmcL$xea+sB<$ypRlwpj zHNNpfY2FRpwD}2Z!_Z%D@(ovv+2|?Lg)p>y8b;P|a#VGS#R4kG*x61{!QdS>l{-R4 zTDhD^%cS2hY1GSe?K&Qez{eZ^1Or4~p(A}rOy%db>MqG9ZIqKJlWN$e23%F>h>Xec zj5DIj$-XfKyP!;2U+?gt@M`0!5Wh}whNfZYNT(y0axIKoA03)qG_ZO7gp$P`e_2sj z3g8z}MD%Z`ETFu-SEe#q3_GMHw#M^wg}9_8`_Rlq4Q!CeFP_A$bhU*y8H?Ua3N%G$1H-FY>7gRUB`VLd=5z&0h(_0+|msy1aY^n84K z9FiPD)+dW+&|f|8?88PqF_p4TP;F4XtqQm-zlsyf&yT8efbb1Fc+Zm2xW_sh^DE4G z>pB)`qQp;bYr}|l{Jv6zj6xY`i+mOJwGDw{!6tvhy~!f3nvzu;gahb6XT1<;#WY+E zl3gvV`xD!VA;A?&O3%w4-8!*e1cX&Xq&CJ-H-E^Kb|S3YHOx{$qNiWjJ=)ln74rH~1*CmfJnf<1`35K$^I+KBBk z)G6+!YjP|@%$?#nhit1z^HZkP#X95v_T5jF$)YctkhH<9n54wpJ@S<*af#dYJ#P#< zf1Wtf&b`c<`DSf#Vk<#C6n?!<=YpO0R2+zkfkBt>c=KPJRS9Sy~x=2(_F zptZjOio6Gjk^j$z{msi;r)`^VME52s>Yl_27oWC zk5&E}HFne+Ms8@jEt53-uI?Td)c4mZ#i{)|uTCw2<|NQW)9YgeRudG4>c%9v;{juT z|3l#YQ(ryd06W7I)4Aa*y)&5@a{K>bg1{E!623yc-qwnEcFDU0|}H^WStb5%&*w;&j?JX=TmuqO4f1{#os>ei|3n}HvB}1JB6K>+Nz>20V*ne1cAmM9ff#$zcVagB$8bSB z0$lzTXizjjBN0U)VA~K6!e(FS*1E)no751@X(4QGEq`eKzbgR({KrPe1e4w5MG1zF=8CzXU@&t9OeI2 z0g}=xXG^|~O$%)qW_R>1g)5b(#(v+DbK4iTv4xh>b> zfQ29?T{#Q{9RBN+(w+ybgDcbDP^najTWlKr? zL!k(=yil9diSAF`T;QF_QkzmhjvLD?Q6vG{<|6fDm9AQK75C4~LblO_>B!MJr2=O1 z!Cp0-33TiIm4SPJw081m-o&ccwwBq9%bbzNJ4f8lxB7VWT1L~3M4$wo2%bTmD#VsE z!A={)?s%0nwM(-y|krTJ{uvqY0qCt-&ZYgT+sig#Sl-5EuiDQ zlxe~Zv0UWP{JPPKe_`a1(Q)4lRj(GoAPkeYTbp)j$ed;f9z;Uy9__D`dGQMKPWV4i zy6)x-rk|5)@KCBAhClfBz3e1Q>uiI%?uz>q^91O0S@{flOX0g`e|$gjz-!k%zDF@N z*|n9BX+;!S(DUh+T{ zR3X)3QmyOg?Uw<|kH(*>B0x=+iUl2?x!9B1FvGvDeZ8cOnlDHLN8I-{=p?52jI?EO zi1*0@_gVC4#jqVeqZ9=jzj1RNQpem+89ZN@{fw<8@ifG|#*+ulva#$hN){{5Ub|b; zwA6p*99ivb+SK(D&9X*v-d(P~K?V{z)(|sl8<0|VS1+M<6;h4VPRn|;`>$(UJ`&- ziMJzv&KdmO(Dx6wwU^$CSe6Yx-C>?(KtFZr)c?SG#3ZuwK=N>bywK}d)m>=Y+4~dn z_4?PJU%&qwfb$bu_5X(axO6SGn*jSY@+eiX6#)sqQxr zTLq4ktKkBm!2JdJ^-TnbPA^{@oz^eTUMDVO`u97vISxg4dj~g((1OR|Up3)4)+zI9 zomjsN=~>j zf_Prx^pxYGN5Wl-%2!?`)gt^E23G_X@*1A4W#Oz?q{0J>|4^a=PavuvU89A)lT!G% zIN{hEr0%8ppsv5(fW@9l$A-T?yT`W=yZA~r{-1nzPsa2xt|~We7f_M{oe@Gj zl9z{6pU|o8c;)b#`5&Of27uC3&QyH*o$RQa?+4YdPej{iXKu`Sn~ff<9QC7|8>o8g z7efXEOu~M7{%pP3j({WY6y49X-RQWt@#Yh+TXorM?BH|a(52msta9A9pm)@lI05SL zG|8ErRrS)_(oeo&z39x7x@;Z8nXk@X?5~{UZSF~fJu`t?{AW1g5m|u??`EH5`-$OC zloNp)j}$*nJd)fhuw^V^(!{gvR+6_#^Txbs9j9BPF@MT#)#uLcK+bkG*32yUQ2q-< zukl^mIALXxt>Dmkjy7diebG7Vq*ja27W-;Aqh`6s*zw=D?QTv!GMmx;RW2$*I|7w? z%*y*`=MxaqBWD0*v2*IzaEoKbm{f`pJyKJ`MCwWWzNZrMvj8ITr@Z_ZNBYvRO{%#< z1yZoxCFgZyjf7eFP;VUAOIo4n_QL8Ii79tgL^>OU=ca~L7~@* zW61+$j%2#NiLL2m1a{j0 z9oDyddHTOCAzkL5I>B!WP@?~nW$w4~4jPlh=H00x`d@STM!-4Vqq0X_>IpfqQ+@P$ zTzKwlz)?W1{4C6zN9bIMAlTW>cu@w09DT)ZV%q2m(V?#DtbddqNJ%-O9$ISUaPx~( zTijkNPHxf7USPH0engxhzz-I&qaF#EC4SxR#l|foRHT3+J&|$aFr1aMrvTcvtF0FG zGW^2f$zWreu71~Z$@9N-Nyc@f>sLexAsK%zNPpiBH^TpS)XaI^L^X9BFeq$qhk{e< z`Q|7XWxLZp{tTUnzCrq~owWSSo9_%u9qIE%=38Wb1jvkwq3@Tw9wFlg@#7&(9!Cc! z>USgnWX~TqHFLKx7s&P%4H?OudKLX79s^n&TI~?*{ER5Jb%NvR6S3(L*nx95??-Mz zC}NO-(8U+498|Tp6)X2wAep@Za*OvhKGtu4Zep%()t z?}8+V$h3u?{LrP{Y(c&kgX7U(M`;P3O9HU?vJLHM?h9Tt}7-n|2NlbM0dgznEd2ndcp;3$W9i zl%ZJ=N9RAexR19GB3lJA+MDCst~nu&UiF6NVc{s5C~l2!&D>vP&9PlBtd*t-s9&dB zxECDN-2(NiHLN`4(074$zg&PBgwUk9Lw^T8?m>;8IY^ovcxD?m!73UaOD((8xj&d@ z^gy2ICaYy1tbgI9z@2s$lQ1y@yms5i1H#6yS906QZnW|un)$z*oe`o5sHZ(#s>}dm zshvk7TF5Bxx587$6CB}>VmY#a`}Q0N%_N4TnofNrB($ZC4p6+B$J((%TEM#+T8ri_DzZ}fp zu+~cAa?M zSJz2(b)U7Jv-*^G_qffWBW5HxxdpY1L-*Wq-dd@@)FC^UCk@@ick&!jtdiU=fCXr$ z(g0dhJ!^lq-|q0vgpFO-I7$wCcB(4x%0FRaU@A_c93rH}^kw?f+7^|BI}A}p7+R2g zIK*N{6_oy#dm%GzpDs2}Z$69ej}sZI)oc>OGzk7C%}wsvUWiBSr_SOI{?Z%`=sFYb z*G04nrO*o>sM>lBaKg~?U$Lf9?;GDh=T@dDB2^u8Kj7u<{t^?N96wy)O${&J>n$vC z!ap^FHC!!HYed9PZ~N7>1lSi6HvIcc3mo(#P$s0blb{;dsZGJS8Ol4j$aG;4%O9$- zH}697&5AY~pLqqVRX%Kc1N-IrYLsVsq`&kB$`=Prhj;|&J&yIM1x{bgi5jtUK{OMk ztd->y6XP|gRXffQwIT)62^kq-LBIwG3jnq-1L_(^-uR=7?m5^e=Qu$&X7kIcmi#x@ zRuh{Ri%)KTmr`!j5(>s>+x7GTa>m?jKk#%-V_wp!T92h^`q!IP`Nvo}qqS*i=T(PK zS#XA~-N>7TAW8XnO?fmN7!5QVV?QqMqhmX=%>LAf?WJu;P2_T>cp`U$nauSqKeylzB$pmZx zVmS%c>?fNx=4hVJdWdx#;2O`_NQbwWG~9#RUM7j5{u&2;PtsL`8jD7d2l+M*)v@VgP+4y#={apaUanxLv|l$G2`O}N8u)un#CsMVw9qLGs<=)CmvG`-+Tt(AZ%ZpST zxhIse_qaI~)fd)TX_JzFqLPDQm%wkbxUMja><)^vYnEHs&7PFNkDpgM>$mmE2C5Z! zEK^LNAu)KX-JG9=?FM^`~0Et-yoyTF3dp zl0&DWK(OJ3Gq9@Y&Ube70CTfIju$I;*fn?{;E-9if`>g;K_{NysIdI$l%|TZ4lSUS zt~bhDN?O#^YesDO_t{y|TbF|Vt^PAAJ1U|F5J`ZHU7dElkUCxlGPps>@bKEL!-s*@ z%}_|ksY{p!E#MSKRwT_)EYHeFcl~D}*ze;Hyg(EyJ(g-$N}PnG@zn6tf1C>(7Ga-_ zW8NknJ9c^T#i#cWKlhXDopznjN1vZy;t=<;_Nb#y=X?q*J*KL9Oxg{a2cPphV$~Je30W0gO6_Y>zQ6!n z2u1IPxQfVF_@naWqwlV!0Do%T5l^Ap;KM0Nc}bnj^2ZucgjT%Kb$#^%iqpS*_Io>L z9-we^>{4xr{_O_RL@p{3dgxGd0LTy0Kky_}5_uRd+6vWzDpsh_n3d{pS(cup@+{z9 z`q}W^4bEoSgUK$kGY6o&?YEjAcek{rm7}0L+eJ>!Apft>cvsY@R687msr)!EbzR#3 zyot>V{6|rKa@_<~2i;$0-aaEXEX>&p1^{Y(TIf-tfe}zW+?+^-!SG7NTk1e=ol-x% zhMWCpQKNfDLG3a3URpVP{NBD8{F4jxQ^-C1 z7fO-k$3DJ*=UeB=xPe^<@9WCW3m= zCb#$q!c!FeKM~JW{J*-s-}m7wEN~ekHZndeAa=QR_`~J` z3fyAP5+lJE58TqQrsXqh>m~Qwu;n}njd;1OW{|)3SFiHCGvGCtaB)N2<28E_hUpbi zs0$e7Qy2&oazTSNPHv2Xy2CLxG-FZ}0YTFP#Jw*No_B2024@V?lgvG3g=ZI2yxv^1 z7nyc1J%bV3FIk8?!ZU;yYO2TfwSJc9KOZqgcfl126cERmZ z%L$)@6Kpuv*avR>-|byWbY8#RGXV&V+2{Uy%tChm-yLr7-Thk9!-=q^xvZK7}C4jn!>`J?dh8a@0X z_UOxI-a;bV0<6DhV@ZdIj}`ZpT-+!DTJ_m|3&6=+96J(BuWUGc+V(CsTMt)(D(xRP zA_?aleIy;iK9l#uDBagW=ipZa0ky0}G3*-62& zD!p-3{3HtIy*)!Ae>qi7LPm&hY?7NSRrwD*bjzD1PrDEF^b@F(IyZULmw%eGhH1jqD1w(x+tD@J2b39Rm0;zlkSE;%LObyj!_4A}cUZ6*MB7VweP7prC=p%fJ zc71IOct?r{+|nT)%3ttqmmn!}P8=QEg2JWb&4J=15ij&2nGY|*CQCaP%vZ2GE z!eT)d;PYJSiE_tGN8j8Kame_r{e*mFvmmdR8XFz51T?JpU;jG67$acp=DTX^hN;L09*+CiYVGRs$$Jw0)J!1(AyCT#R_Q94)#a$gw$G^C4*uA99! z2xV*=iKsWFcf=-tXhtqUZ$0jUd4Vwa)vGGPK;dYQw21r>vNQ0iAG%!c7D@cT$CLAixwU|KU_0pcT3p z#a`&k&-}a1^uIRfCR$2dAitEAvG`}dI!y>J>s!=voXbI3=XibDmaY7&?Xzk0M9wYF zAG8y)z-dt{27={OyP&)n$AO_&s+M{6)~%N^^%@nGN&}~QU8^H**Ik7e?WabSN=(R2 zy4vk)3_kk8etF@5V*gWXM$%QFpAgJf7=J_+O zrHDdP>CRhQ6_m3dtL9r-Ee@IG`Z=Q@ryJsqzzadxwQ`mEyM>C|ufSI4yrIi!E68&? zrmQ~J8889SvA02Vtlj!LT;*z)Fr6F_fEzzh?R{0LTNNZftS&6LMxgHN4|{3l5r8Rs z3)K&@E64GBR2#9DsJhKm|4+H^YUtA^n`A+i*$J#rs2>(rZ!Uai)!yDS27Hz( zI88tk3jAB7fE{MxV}e^;;*qTdWB_nu#Vqxiq(y#q_gUfeYka@hj#;%kp_6But>!`i zT)(K-L^gl~qBP{}>=zR@UO8J5e6+^RGmJtfPeZGd3Z(qHrb_QeN4!$a?IA>UU9?sW z6;ltEYLM_^mMr4u;w&oI4mfo3cyod{X2hZ3uAl<_t(OuYq5% zdLEcn1IzQ}ukRn_en*B})&s|9o#2j&kC}teN1U30^GtJ~q>0cu761m8u+@af0I)cy z07`mlf$4-oApP>l{sGE-*2?Y29$sFtNfQ5FN%~HpFD+n=U8XI6x%Um$Wkz6mxQ3<; z;_td@ER9$t$i-k(IrMSVsU}@c(N7=M1|R){V=c>c!XncY!GOcn9=Hc=ln0>nZVd|Iumk{rez6r{oE9K`Q~bIoKlOPN#qPq`n|3k4u~-#y z=U9Ne_SZ_-%oVciJWjZY*CvdT&h3;bf9Xx1#F9q$X!%mf0lEuCV2m$I;AbvBR0M&p zWJH5*+#mUr&k=*)LESGO|92+hXMA;*(V-H^}!ndCzIa=$4%?;Y9b{0~-P6(=#= zy|sXKGjas#<*SP&GQ6Yhp6$>Nx`6=G`F7W5b+=d@LBn=5#Jnp**dF%S{N$$zyxuUS zJ?)N2nbtLH;c#QQXKwqs{8xV{5n_q|6FJyRbk{cccX_N@b^kfzKPN*Y2Qd9da?4E6 z)_KXmzk?95q1!2O$EBOuUIRY-WiA#J4TpC>A_vhTmF+3+DUsF^c@u11`&#SY3S=Mu&tJXrZ_k(= z`k%GoW1v9)0)6fXl*`^#w2FFq;kdgC@XioB8@{_yX45JrEQ;tQXkyDCwd_Bi-j04gZNG2+@sg+ku}Akyv_5GP%oBtD=kNY^-p%NQ@xNK5 z3>XOp5oKuQd0-#efH+tWM;DC>SP4nQ1)m^8&`lJ8Zo*?lthf?e8cM$5hI7y12PX<) zfKgLkbND0EzfJ!e?a}fZOG<1vTtVhzbrG|-9X@Z7lqCQfZ9m$l{akH&llTk~%8xfd zA%4S?g6a1Mj02^aloRJzbW&`zEoqph4bOO2ISf=WGdYWZq8OOg%GWiFsq6+i!CB4) z+5GWG{|m3JFxPo~>f0+(1%O$oAn)CU3KjzdC*7XkgjW2q}VjO#aM2U{#;g+5Y;3#xvf8X z&{IBs&@<#!bAUv9Tg~hhBshfUDPe%?kIb@aj=KxzsDi7k{>Kt0(uV-$;jGGue9g=w?hK|{Pcgw5S)7Synu_nLg@fPa5*s% zfp-(TFbkzWN2p@U%34mTRCmL2&3VD}R@9VMJZ|b9u+bzV0<{6!>=m$D-OYW%#MbRBwea zD7i5~sXAA%gPMHt4B>5WxlJSWV(9EzQC$TaK)gVb`B8u7>jgvj(Q-0#C;yH(?J zMe67D^WW=HQsL8={ zD?Abel)Qb}KI6A|;StaYWmOXuL`AwC?PYD-CXV)_Zwv%uLG|q&g-QBEgxC<>;?PZ! z&AajX$!XCb(^l#c3$K?U9kk6x`EHmvT>Lm#nYuQ1g zzab}sh~C$*13-|+%ox*QHl`tb;^?5daEjE_r{h)y;gIL4 zIAVkRY$vJ8?|BvtpfvfqJq+~qOo#j;?sq4aH_!f=W`^zv`^S*vRs;&4=iJ#}^@w6b z%72fdQ*V5;6Ull0^hPjGzBAOQ=onp+UUxdpp>D2@#{3|I=S6}dqds%5PRb!=kH4{0 zdZX{!Z3ReqU1}%k8(0XE~c%8TVwdRt$;uZCob z$IOx?qVN!&g4Z8&@^5D19dR%`$y8Xds zc#&z>ryIY!|Ng*wOHIA*7m43Ae!KkZ8JUM)CAOaF`O9-%HT(%lLNi%r_rgJ;ZW#aM z0C9)Mh2Da}tygJhRvV8*zMI>owbW+Y(S1Mgy9vZLK^YL?C~ zPh2sA#zpcvo3Nza^cUmdmy@)h^_Ka<2q@9*j6F(Odf0fW9oap!`}?V;L^W0PdX@-C zfA276P0ZlTomSrdGFP_#$$1vWsPJOn*QB+V_M7wT{%&}q#JTDCS?dhf?$G*&4o$$bhBweBw8&Bp z#}iP51-S>z<)q(uC`hoj*ZQLUlqwYX2Ez|Jui<>r6QW79BBKqGb=)!nwxi9S(wY#v3Sh&XEn(FJo`7oDn<^Y<100cyOC|xb_S`>D&e43 zh-)fQ`S>-JTk=+FLvU`sv*C1m;lh1w(`CJguNSx0gb!o-=r5v$dK(reej9}b*0d{U zB;lCtoN**&JP-VeR2Lqjg?(RA1jV_XTQgDl7)8&)f_y7iAn4jq^c1F;glvs=B zVYMuu^|DB;<5HT+=w@SCZfa$SaOF0{#t3#MTtY+{cRM;v^k5p`!#--o4Y9bi5Oc#7 zA>VYGN&a-BV0{;2v^ugB#jK1Rmy+wt{W40{F<63js%FDGx2|JSXKir9+uFP7<0CZz z!R}8WXKqP6!jX7LdfpU{o9gx2UP<~qliTA+Trh4>BaBVz4TUZBr`*nP-%z0F${Y@x z8B>ZU^0`5LpXF96w=rS${D(k&;414Z@xf`FRjvB{UGxb}-@3lY?1aKn?M!=a9D=-P zgZFVNWEGnb@mkgQcMitsZOSyMzN;GQFUt%9^dMXT(?1Dm}bYNFd7o(uxOIjK!d9*WDi!P7ZJ5!U4^U=~<%zyc& zn(-`bjuJke*TP55F8b@QhcQ_e<4SU9rDrDNO!v_8uY>Opyrs23&u&BFr-0+*ieYRR zPPbbTwOn-De&Ir}qYG^ylPQ9nfW_sAvbl5zw->314*4a*Nl_{>ypObGVM%AP1Bg5? zKVF-*zI|-hW~=RH;dHd3Df_5xu>-5OtEQi>GdFZQm2GWnkjPjwag;ZuSagYMTB)h7 z{4(5g;M~jk?jfQah!g>~53z{c^DzOyiSIG1)we|E`{Qc5w#`aZjHxpx*&%cCclbU? zBa~u7Gj$#~KQ}Bh@|6n-B_4iKGc{@$mzRL7MFCBn)-P8Bk;J5(C+V*fEFZLPsZF4- znJy_ONMBz)XJ-Vf^OT@F%|)FNK3}5JdArp~DybF$l0*d=$BW_VYbTU#iVk;k9&EuO zCH);PS$WI7yJkg|-P=d(q;Sr~outuesd9tNb_L=2kE$nyNA2@o@-Qf*&F1+{qZ`6= z$8?Sy#(W7-nbMC7-xs_GjaiK#-3yzYB(o|)bGh6(*sFAQCRZDe6jhco52tb+d&}X9en5l#_R1t=4n3@RNQY)YQ9_8_Fr~EejjHY zNO~YFa=oG5-aWjaCyER~b`^BU4{|^nmRAEENyL_$V30`SS`TVfi|zHxQb}6r%cgj@ z{;DDVP%J`yN~o2rXfJHcKS0hAv9x4s<}5b<6)W*ilmc)w$1i-uB}zr57%PT1X>#rZUVW-n7?~T4q&a1&N*6V(d4G=D*kM>QMr1eegck85 zOohcQypD%y^xCz$@i$Q5E@}rJz|qU~hjYk_{eUudZLDoI zx3zT>9)-9^obTWqTFr%eRclu}=_md0 zuIIY0)!9-4?d&ji_2{99`Oz%L4;eqM>C|5FU)u2t=ZBS#5gdHy_lpMh9zPDBZ-zh< zKmHGlM1Fiu>>YIXCsHb)j{b;^Sq0pJvK_1%S~I++cDfJ}75U0|E`b%#Ss=|`oJCTU zED-!;Ro1Dt8#jUh1C)70v>XD+^;rM5OozEW-o(HuKOQqYYqvhCe7EMLik3H{rgR(w zETBu9)exiU{*}a)+2;QEgScuon_c?D2z=FzudBJLW&OjH){BwxlzMfamJ$v9)Z~_c zEe&Cmm6QBl48^}Zet zh0kR^IZ(rqG%sfS!S-=M#_Y=#jLy4Lrh32IgtO5w~;hKZL+YbRhWUhGAuGmWlBB&C+GrrQ-@B?KUcpxy z{^k&MuCoZ*UT=e?UOSGAXePE@Q7^f)$C93r;Wc;7Hd&%ewWr9Wj`jESfZmBuYbT;B z;r^V^;rY_Lgjt;4=Uk5Ei^c>F0(ijs6xwS2ksqxSQaLz<7rl+y=^xPtTqreu?4x&c zFnZYxLXY_#-=y7vGa9r-;Tq&Mjcmry9klJ4IS=|<8xeoMP7)(^-B4Q=qsIfH|L%lu zjlEAO$4vcTINrN=h#ERxenuX2M-4c^#7=DP%Y#c^ymW2T%w;nplB-Wm>JR(gRU73U zj7Tl2Bt#WhLT9xQh-iNCLDg)=m%Iu7BNsx9GgGm5zyyg{CAbtQpsZfiR$ zE-AcNe`XzM?g^yGyex-BZ>lyQtgpVNl=Zce9+bf9Cu%J@h$g(zWYVZ3iBHmK14%K0 z6z@YEV*}-| zQ1-KSL1-7`Tb#ZitA9XVx2c2WjaFp%GOAtTpjv*nON%P6$vDq#)X2z?_Zn_WWM_$% zen(DK7^-b8b}YU9KzB5~jE>On%d4*bj6fnyI=?p*Kon!K?9;4*Tq-W>{|lO2rD+(H z0Ysk}&Gh|RkNvl2AT{oWF`~TsWjg!cBBbf?<&br&5su&?*oF}MPVqPJ*aer{8%p`B zXZ4T;9)ym>uv}ta#mAIqmpW&)1S}q1NOM%T>}*?#i?_8}X;Ka!)7&1)BSy}|=`Sd2 zxp#f&h}w(hUO@eL+1Iw2`Zp*qqDj#HUF&el5?!5>(8)vYwXE&Ql4IgiI%2Q45iDZX zoGA*kU!}j5;Tx8HvjQrpZZqkuXH;j7u><)IAuSkPWY7_e1g%fCz5z_m%CH7agz~#jJ|CB6P55z`zAm$TNSteGSV37? zCq0a>-XFC+tP}pZ^kC|tJInLq?3%gr)d<$E+@Bu)M!3@ZNa}-{xKPf758OP)Yao|K z^j1zW@C?eLXQ-82+PDsE*XiqC(_;3F_iA}wkO^a1_`dYe65%HgFLSp|XfXT1u$ z@-Ydmf43GX`Wc6JLybJbaVIsO-O_s4+7EjFma^vTKf_0!xm-tLwu>`d|NS?{dkxN~ z@=t%6^}pDj+SEz47vHnU9@JApWcNPN@oCf_{N7H>xIF0Gd-dx3R}AgttZ3xj>SVoC z2gcH@ZNKD67Xo%+!HMOk@r$CFIww=B7g1jRw1f70C@t8Llv{&aJFyTHG(7P25qY>0 z-X~}NpEhFEsvFY<`q|*EVD}kTG0fA*{imj+JGc?zA;Utn-oKGCd^u z`d6EbSzr9b&=v7aVI)wTlUWXoEko*`k-#<;!oH557!+yQvvnQybLSNh%*rg$N>2t0 z$V`nWfGV$o5m+=oeugc?U_8<8r*^Qd3lAs^UujY_N5y)e$O^| z^O^XWNuI-w1<<>Nt+p15n^$vzzg`ud=^&q>Ffln9fM*IHU9X$@zm<4zzML^a-z~el z+>6T^UclK65L7<(br1Jy)r@M!>`uPK)L3wAX5LrKbA|l40r#X=b5~;l#}q$O9)2Nm z#7#r+rMG@;>BAOatgZ7l`|2!B~9p;TP51N`dypRCIgD&fV0|6~#Tzb3O zC52IT{*M=Z)lUPF|Bp54e`*DA3yjvS=0@rSvI9KYPIqEfdK{}UG+1&do|9-BPu^|d z`?`IE6jxz5|#Rh8`+;R>^gS1XXGA-))m^C8q%xN=1X ziyge;-Jgg|f4Wjg)*_R;V7g0&2brW<>QwvEEk@|*J}qHu_^2GN>Yxq@*R$Na+|aR6 z!$YeDY4~h5OFUuehkMxnX6|3ZZ9u&44l#d3kK+j9*fXQ*fpqeCY~Hx~9fOlc-4bM2 zig6Yu&PR0`$ai`k&R6^L^kRB#3~B&_)?<*IB)n!3L`o}&_kxq**l4CtM&^Lm+(6z%dy=WSEz z0$QmBx^dUs(C2X4$qPNURNrBF*ReOZo3=Rmf$0UaUlPMP-#Nbx%B{Mbr)Fy5XH0cDHLBM^{@YMOxU6ze}{ZG5&{*O3eCjf8gH*3*m6&++~C9 zvs)tf?D}9(vFHfn=jf9F?^QYe1`S3B`XinMOYrcwKhFP z*rk@}f=OuVs4YwIA3*VMIluJls3Zu5BI}F!qBfKeN@_Z~KtyAlk-2G$Zxoft{4zI@ zK5B2SW^ov6&eYMTJA<6~LV%YP9?))lVW59W5klrhy%4z8)^&+&tPe27bJi^!5ze&N zssg?gRFDUACI4#Xa*InYBz9t4(+9l57S+uVR_NIJEw9t;BP7g2mWLIeCS_GPT0dxz`Ou}VkK3;umZa_;P&VMyDG`3>fkc`f9Nk6%xo}v^_ z#x1q$i|XL6kgeugDQ;xz@u1tq(|uQado&((C+mSVcoa@{cPg;6qNpvm&%4dl%E^4~ z={Y-=-0^BfF?8aiwf)jtU-yd9OAkNQhH#ga;(hb#(;%`_6vS^s^>AF5bN^?>@x=>> zrIILsNb@8rlq=LFWLevOk%>#5KzrsJSdZSkYSX=EZC4tzEv08Bqi0Dr2B*UI^}aX0vjf-UXdnHNe1 zi%~J6fHS#CTHMC)fN0&=_mgJ>SHI|(@`O3|QcI+7sKSHvoR&V7xX z&YQT4>T5c}es0&RupevxC6#};K%x2F%#b8b^)RE-J<(&aD_?m%jz6)93-%y&d#B3S zT4x@?devS|E9VafdvE!!8xxP0XOH^t{<4+6Q>IdxiP&tcbTYu2BCGIPcr^P2n5|!2 zt#GBS?h6=n9=c~6zBE!lE}~5Jm*uFSRqut{*DeeU-K`JXUzv_+X+|{-S(>?p4(PmJ zfBLxK73^pmL@C^A_ohXsx24wF1$wvK4yqLPa25_qSSX+a1KiNJBttKr9DUi`%>Fnn zn5{nkZ1s+eeqso9^x}AQ(~aT_wNnSGJLra^^Q*T#DawQOoI$1*3k$ zKRRFTJc=~m6flu29zf!BSj|x8{aw1Rc1zzH-Z}bf)$5qVnYMgExT)bw-P&8@>&waq z|E{~hKURUy35`behIK0%M_K;T^}s$OySu!+WEHYt)M>lbc#y4g-rYczjClmQu$-fy zvYMDOi1t(=xT?wZW3D+e9zdIq8#xx1%fJaA!v^9@-GoacgRr(o3eijM^k}Y${nYc` zyM&F2x?N?h@+l~;ztVH&r1}KOu(Fm*coqKidcm(|xyMs8u3SSe;1NMC)yr#OlQ;VM zy-zMcg@i(p6#)>TWY0UV=Dt!MJTvLgn^e^oPwa>$Zm`rOP;=|%s4<}3*si$Ywc{yr zH7^wLD_ARC^v~m(jtGSPbnhBEYLcKpR%pGV4ko}`sx9n`jzb$f<+F1H?D9hoVqqy!_O)mWCZbrLx0#-ITb}k?!t>^yM}n(?0J0e z-|Xy~IomALFjl)uoBHk-v=fU=64HP6@D+MVD8RSk)bex7^mndXjM$Dzh@3PGA8U&b zez@-10b`v+RSjXonOR^#E1hWaLe z5pE~*TBq7Wg#cEoTlX2R`fFsM^%b}~+l;9TX`PwvMkct3&?nfRIuUO*UAi2{AM8%W zZfU_`2z^r?A%+!-R~*28@;Fm--frC8Ib(W-+mUquelboGPRGBbg`V3w)$FlzuqOzc zYGPmb*aGZyON-m{#@=;D(f|uxef&TjE=u#=(K~adB(qdq z)__zp67hoE>3i*Orxw&<)E>>0Ywp|6#Z9&2xrNqgyMaHGVdd~Ey>7+#9b@Tn%ZH^2 zeZku@8feGyt5yQt6O|6%Rjsk};237wq}l|wnJB*c`% zuuvq&p|Z(wl}aU48bxzH&oH);u_Z+aF*7zSF^A18hdFG<_v!U{zhCe7=llEPclli| zzy8g|<>~o++z*fY{eHXNg$%JvtF;iup_h!=OGPSKL-V8FmV&uL?V7|8f$;u-qY>p< zbXZEjjzX##5R=o5YNP*G5l2eeE>#M@e)>$RN9m)%5=vv-(y7}$FKR1ECX(91T$16r z!%BgF-b>$p0B=c3HrV5s32i<&eIVdgQtS(=i;>mkji^Rbn>va z+m^i5i_y1AlblTQjcuYC9FhHI(d=1DZ{GyxRTya#;x8MN3;70 zpE6=tao%B9Cu^*mJv!A!G?)!$AeI_tSV0pNNTadsIC~*iHuQ=;^ARS zx~4s-=CDpd)R`n8fLd(TFIBb! z9`LJ6k><1(B`vgE67o&2HE)ys`K8IykNb-H5EUm6{+JXIbUUTuNsKD>1!Vj2*3_YL zwA9`d9}(K3a;lFHFP}du;XD2*lCDW~M$IcDVn|-Fi<~lk4=)aSnc}df2GYM4L~$S| zy%W7X!CM##=puGCF2ZFanN`zc4&grda&0HcS@}PGzi#mK2>QM_BN&Z5ol)js;-Bs9 z_rWPerEt4_jL9^ z>J3^#VR}E(SuP^4wrRX~+m(XF9x&b#@zW>N;weDULQOxZuFYooeTs2SE_8FS4)2}i z2L(oE*3Xv57;)VGnA3{Wq6(3SdZpmjT>giW5w=X_kWq#n)12{ipV5TVd4I^{?zulD z5AZK=@%pZCy;I-~Xl_vqoyP`%eiNpOpo-GH+|2nY)T{T6C3kvMHm2ljY4zFFE3eHDq zEib+mxj6e^J|_0e4UK&Bg8u5$jt(2W@TDnF#01)fqhTikUgh3PGhUmz?S`{e(L`Ei zgmuz$3BZ7klQPJYYa^q?8zf@_J<3UDRj!ds>|)b(*#b?=axuO{=7k zsceK7qoa`7tN*0g(vOQ*Qq{!!V5(3zUh-MJ@{}ixZz*ydVUINRS)*S}I(P`ykU~Y5 za#O>|hwLXG%310z+&4}IE2=!lH#wP#_FkXMhOAn%E>4d?@~euf~f&~2^Bpgc4$v9Ihb!L6ZV9muREkxTVCFi zl{?XO7anPk-7TB0f_zFEU`HHJ6w<3^c`FFvA{ij!rOD70g z5y!G)7(9v!-qDM5km2qLvy(}h$T=cmJn4Cq@5g3nmv?k(>yTa2%IUD{{o@CHIC}gY zyHLmNAwPLVYj%-W7#9BW>Xg)!V1~4LXRuY*%%!dvN-9<36?3h*AnqkW0+}6ypbaG$ zMfV-IR6yd@OOs|fBrJ)MS>WyOl4jxkyO4I^GVd5of5>E9^@A|sSZP@v(xppqw@zRK zd}4u;Nqq-6fb7Y4Vt}CRL3N3?6iqt9_tglk9~bRhbX@_w3k|~R{#q32%2&;+9Oo)> zTV&yeWyT$?-d&ZMNOeg?r~MoGTlE|_&Yh;*$X$yD))JkY{BryJsm0@?63>DDGWt{& ziHD~}w2z>DOrwYD!t=R0;j{H!+KP|m&Y`7xP;ao>f+6{{a#GWQmGBE#R4pu!uIyJa zrU~gO<^<^?&YROLabD@zo0qifeFxPruqF1#%!p-SzS*1L?s@W+t&+M)tVU2WzOkai zRlE^y$D#!LZ$RUy@`t6}6uA#@RrrSBD+YH>u1xU`?7x}NE@dpZG4G7SG5?5PJALTL z#Aw~LHdU?=t8Gl4^5lQ%d#HXxVTPB8GmPT}bVWSnE%Q0==ud4=7#Xe&Oz^(av$nSC zE9VKYzy&&=r4;StegRIyZ}Q+Oi*INWJi*Q4br0wAd$#9>WE^u4*U_?JNdJ~R2aUYF zYvJO;Y9v&(UKS#Lw$}4n(#zhkTMvKIOA49+08};Ab;8sP+MqXmX5}>JPt)KQlymLK4dF{?L>9C-`E_gDrddHp42`F0uBf1`afR4s zq{%I$^8AJ5rLjDBN_I*kG5BMNyNEc^!rlOT)&R>Hb~O^%~cI zk`zX-|(h2|e=u4)^z?Piy-`C}%f^Pjt&1y9|=G*V777Uhz9cETIItk3mCA z&NP3{!7^h(sD4$R(3}G~q>A9Qai<5J8H9!Rmqkd# z#w=)jUuEFHU$#}upWk}Jt3A}cJ{mS32OK`jMzICft*r}s&pRe6fadnKSYRbsUmgqUkwxtN$Ade2GG5BAt;jb;hKR57bvj23CR z*M&U<+-H(W=Jsl9fZ`a6I12b7s5R0{t^R8GZ(R3X=h9c6r30nAzI89DAn!{@vLCkE zh9~8}Q0{iZ58@K(8LNb!2k1P8Xl{>_6WgTcj#@(3y%kYDJ{MQ{GgwGb;g6gujipXC zM!{3FHU8R`lu)YRG~_8$+jkt%XxzE*jpXdMi!y#N4DiY$i!Af%r)$xH{nz~z?r;tU zX?nrz$dlrk`#x5#HTR;^U@B)iY-7%jocqy{yVRD1v3Dhjpw~uM?f_l}%6zm6H^RntlDx_w>-|u47o$Q*ayUP6 zxMZ~)KH+iVHtTole%rdrhl10MgUdiA{nP-ZYS{I07QF58@uW}%@@E}Jm_cd{QS)HN zkI1gPr$Nif7AZ6t-nArPip=h>8x4O-9}0ke*^QBYAL73Ti==5-&UdfZL<9W+64(O< zG^poa*IXPdE2UY!=?ld+n{%up42m)6q)@!5bev4()F-+aWcS$)^4{wd{cL$4MuwSY z6EERn^dIs;MKW%Ru&Ic+lCiGfNexe8fGs#>vc5E;E-t@%x5uQpK}dbPD>+;BOV3?S zBqpwXq^)Y*JIx0GkZU;C??^iIv8Zl}tuJxq3FDZW zqL?DABIjPW<4;%HrHT6n+o%!RrI~K}Aw89$rsW2Xe1x?^CG1C}dCjg4TntuUc|pl{ zExJeGzTA^_X_>&RVlBs7yG8qLJjk*!lNB))4!98iq81j6v07$BQ#0h|pKT2c4t>H&m=G+s6l>_CGR*bYBjeTeM|I+F9yG0&UGYX zn19-IN@H^(3Z?v9!ahz|U4_Pl7|Xp|0wb;PQ2aL5Me&ENM7Kw>=m22VLO05~SMSBv zClSoD*}Vi9>X(C%`r<%+nkmyI9KEv<4$gC1n%kf4ZIiyp&oKMrmCvN{CM=KXJ8vtG zD8GYxIcmT&1C`dzcGnhX__^UbT*IQv>y$vP>9f;JIVWwVYjqCy*bib#v4Lyl^eHFV zpDmsyz9q|{7G_h^T5?mqM!2hOv7(>{zdZRKk9@hH6~(q9#P?dt(I0KVylFP1ez?UQ zhEIcD!)aF67Vzd?-Cbe%{Ey3Xb2gcMb$9Bth~9}_17{%J=ZY1&E#=lMH<>T;;dZ4= zJoX6*?4qooLj1Gt0iDEG{HUW0_mLc)s7}>{zTh85;O1R{;A{6R%wY9@jW;qCmxWfW zi(W3sQCbDG_AZzcLYGPzqQ{419)QMk!503yb_=FEQAK%ZgHX4JW-k7}II-M}Ub{ zllp4#J~@z5Na5$?-Zm5XwN-qKYiGzxwqNZ5`?ej;+gvRM)mc0+I-5!13B00IM=Cn6 z=0rpkc3)$Jg__in_^25lLed z7m*Jw@LT=%&Rl52a)Vm8?T!Trc5&0aC{a#v$m+gO(X&J`wN9rsToiRbwWUI?SHG!+ zk}3m5XIrgjczl)>dS#-+3iXp3(Wf6Ls>e>cM8E43%jvZ!-aQf7ULMVP3@%r(_Q1LA z8}#rc9Rs41nYsn6f&L_B9H^wcy+_ej^&xw7>{YF<^LBlQryOfjr#nH3eh}Y*j2ofR zJl-j0G4H2TtNeQ*bu?I3VaXfmQ|D3~){=PTyAR_m z25QxHHx&!bx#tuvq(o7J;y-CdYZfc8n@d6VYj?aJmJ-!ep%7dn`~>8VH##0roZ?2C7l@j!pR+ps-5t=xuhyV zA^eu`HmbQ>0O@!Bq?Dj8q3CX9IvAFnK3^$wKynisiZ1+g_xQV@n)E-Tk!9@=`HlR0 z;hWe%d)spQ0uvaSC?i&_bnSyzM;dKri{9wpgnjh`7t6x5*begH3w1pWWtsts`xwPQ z@Q};VH#Vt<)eU#lOz8Jhi}h!+xJ2E~-HrMI+y_%7%3d5< z#z8RgZ0p%fxRR?Pw@4)ScFEfl{IEYPO|j0PeOhBDqO(kzvKVpuRdi-tuVw5tsKu@+ zFTV@A=P3G3>^xstG_Yc6Jl2eCAic6qqbLWay#8j{|ZDqc8FU_{8V1|*A;`itxbvq;x zst5G9x=E=(LiW*JlHTF!QO_G7sn*&8j^Z{Fp^hU{W59}EzC_1Qd_@y77w#{(bwz#^(1(nMwV!Rm#@ZSkydrZ{;AhS$Gb~yo@Lo=H zxSzp_1M`u?p%BHjhvx$jzLO$9^Eob}5ReSRPB`RY=nK1q2ij6QJ1u&Rm8Ysh_nIk6 zIS_VyUGg)RxyrsEivg}@OqcV^%w=56FfNDt#X}SIuo9YBMO`_jGDrva#%uK zPln@a?UFN72=Y2dc-0#HE;AT$H9h|dvFHwBJw6Dsjely~<4@*nP;cv1{wPm)q!!+a zJLD8Zs0YrVIio}b71i)}xjr+xc0^4AQeHRGZpF%F722SCB;Wfl+%jg+Zi{`TdK5Ev zBz;>CCqGu=PhWH*xG*TQ={^H2P!;s8Q>KkN<5*L#9_MnL6~k_h9!$UcFk?2`xSjo4 zqvvPe6|cOIRXe)Ai(4nKz#^hNE0dWDQ9%0%)DiL?`B4mlU0U6^Ui@^Uzue|=KN(N& zbVqr=S)2qzn!dIya-|c#`%5lFV*TJl)?UZ?$`N0Qg8d1{<5S#DrWE4NpbKK&TfsUV zD8cl~xrJ*f?tG;>B{SiBLylkR)i1KQJpEqIdxiG_LjFE#qQ%PnbNYqvd(t!D*<@-^ z@P`?&k&xAysv%!7V#J5)D%jSY$QrT+#{~rVhur7xnLd8%(>GzFEil~UQ~Pa!G{0wL z07r%9FI#Wq`?w~lSJ`@gIG{AUrKj3G%xyMfUSu7qHsz6gLGqUaBYMpe%9N=3 z4BMjaGsxAi*tAf=NYvS*Wm1wKI3=(&^8s{qWK*u%ltN;Nl(Xo_3k^N|kRu6WdS@QfvxMEn6E>H;VALY^=8p+a_ccMdS(g$(Q;Y;f#mjs&tJ& zV{-jdPuRsTi*Zj&%fMB(fYG8IR!PT}38HIin9j$^K(w;Uhwr%_K52Dd2(6E6Bip-C zU`IZzEJQ0zw$N5aH+b-!nZ?GgS(ktiE`4onZ^jnF-A7#TE9i0l*|V7kKa|GD*td1B z!A{nV&BfN7-m!txHR9^DBm`{eHoxJPM;T9ritjtnJX&0sZRlm7Zr#>X$(IE#(o|Kd zCi$wT1Gv<7{~!%w^hYu(8e7>;m&VTA>576d=0K)C%0PG)-r=9d8nV{}8U)Y|7v?+Z z1Mez!JE2N$>XESV zT^QeSg+gFEi4C=Ah9*220H*O>kl*ZM9U7{bCr^VQd9tvW$mToJCz#eyz;){FM`a;(Z zeD{^ZX~f8t-+_EhvA66Zu+caVJRcj=D^L=pAg0Z{QwSa5vw3{S^GfG(kJzZ$6#eZu zj+zVZp%whR^iY3+O^;x9-NqN&>F`fT;$`s)cy&D1InAz%;6KUDBSB>mj5zOXLzQyE zP6(m6nKaY~zseE6sw33O6dbqd-0|ExknAJEZEA4wiZ>Jm4Xr#=flAqP33eMm<@bMn zd*F|V`Q+rvvDYP zFwdC10+dVl4NALk8)7sun7;EU^t1r+_KZYqzp;?}-pcz8Qn8qTd6`n|T}U;uw;_KB z>p!Jh4=oHyYch$LO>83Ri%*v)<*^_t$aHMPU%);VSfhsXOj{#Kg7J~nztE^lm=k8= z>PEi46z{PC=celdLt>G_C@4jE`Di;+mKHy%JV%E^RZ#QH~4x!ynE1CYaIzgs-e7Hg7;xvx|zOHl7--^|yQ>Nb1U+U1dfcx$aFy+XS12r7XS zDz>YL%1BAnIm6}NM-Qhj+nGp{^r&oe*YG@Saz6aRRdJu%;}>9#Amcxj|1tL}=pG2d z+#I7MX&&phtVlM3q`j zk}I$h2U>U2xwnNGg4skaPEFmujX+Y0$HaM@Ivvw*^(H#n>62AyVAa?QWB`z^qz}!e zHPK6`Go=LCw%pZno_0G(Ho0*3Q*TFQreH9{wU_W5YLFY&%HE`9FzK%-4f}HH@Y?&j zu(74tz_!xL*4Ef*!)aq^=O2WKl^lS5sI(O{@(YW?M1zF{Nr+o{Ib9 zZ{%Gs-p!crgIC;JbeEa=kxH7tYAZ_g;&wHxGGbTRr{dmTj#&g@74UUVrUM)k*$holK(;1likTQ*(vOTDeNPs-B{K)C9*lgTnESNGQL7SFPgknFTd*$;X@9`!Gb# zHMMTJlDXLSohR-EtL*8B5a{rOUBn4TA)5TTT5DyltuhMNb?!1SR<*M*PK_BO$YEUg z5-}+uycaDabhGz+m8=tdbqI@sQtDtO6A@n>=rqQX7H<$D&-%H$@9sGj;MW08tn5W&!DJP|Ezt0dW0_x|e56`Py>il_!b!c# zuk=czqgP-@IhR6u30F8ms7bQV$Gl|~YMi!tqQy%>e9lOszrY3?e|_q9F1Kp#M&4V; zIML+lQ{Pig)6~FtS3>5fANo{h3{@~&@$sTc6%N*stQcKBxq8~_q&eMOM+hV|qyhPp zvS?!#cxIR6Vq;MezM`cda4OXkNoI2 zRC@*qu*7$~k0XICCHBLDueVu{O2SiLV}xt|eE7bDn`HnO(R$n_11R`HilQ8Bu?u#S zHAB-AG^t&%opyeQT`;AFmR*8jw8P@neAx3AVqh|Ih>&bzBfOIk>9{yXny;@c^dIxv z%=cCFn>r z3cGlGkl{4I>Yy2Vjn@?1T-TeNwj14E6ijCIj>@qC@vMfB6tEUkz03}H>lb@ItIYN? zDjPu!y>_USo%kn2F~H#ApG%2@Z~lUN_WFH;XDld<{=tU&b@#T|u0G)f+1LlfW%{g^ zqTUa-%NS&-s}nzewpYO!NRtFA6$R7Q8AvI78?|Wk_L$H~ziVf@Te|nrA z?6_mY^QIG4JhC+bC>Q2w$^b`fwoJRJB~9-Xug|uv`-}*h_HA0<(dfJIIak|H7-8@| zK}_9&Kz0`-->yaM9U_> zipJa%IFc&TJ};PX`m*yg7oeZR?N$;6pr>uyGsUWB2R;BrWo{Kco$rVBI~~DJa<|^I z>fTF;PY^-tJ}eYX9!OW@39rOgvcP*6+Uf4};5;fU6B@4-Qnb>CzWfDxOZdTRi;J|4gEQR@Kv)A?uAA9S3Ml(k0h zVufQ|y9kGZs-5K|+P(w7WGWsBDh=)>VB??6t^{So zR9`2jHW4iI<8PMqOA|ye?6XYw&^LGT`R%RA7|`EF3iT1Wwd=dAWrlr(lJ{^n2S$5R zuI58s=+eqCkGZFmm(z{iN^34&zW{#+3eRf+MyZ10aSN|?2vP=Rl+#rLO>0>L83-CM zRULk#^B^ZahLR$O);sMo2{l-zOc_v(P^o+hhaXXGG&|E1Wu1Qy{WQT%oA6W2OMvS8 zgf!$f%zn__+VEnJWKkIIQvve+Kwf;((2E$4Sk9k9Mr4o0tNZq;I-e4A!-}iR(phDP zvcsPd8SVxPw)}{fK-c2ClpS+Tz-f_V2du8fmqDp2i|GJa<6p{UORf5$jeHEGx)$rz zT#HyvjUQ}EV1*6K1;b(2hNQmJuT74I05cK!QfCF#w?tEURb9UZJQ9uWjjWdk59hH* zCo6R%ftp}+-wT(*{AL>tw$4fulAekjdUFU^;}?q3woy#MsSV2I$6f-$u~ye-77`f- z$Rg;&;~$uC&)Q=o7_;}bPcc8rXQVDej+%K&e2b*W$Dft+(fQs@Tldu-w|P|Q$TJ?>P2g!nmvg-tSHiw97Cpgo;QMEo zTVq{==pFWtEaZy51BKp_K8;XCE`Wu2lSkWF!G2B1WS6otVpI*4>|7>>OR!+QHbq(8BvPug}%z!trT>|72I4Ij=7#%M@b?tVos;lAw?W7nVNZXFE@p9wjt8?NN zR07qNb^i4p3~|$aV1CIiLNcm1*CE6FTMqTusB=Ot?v@y z<@F>-QGG5j5(wtl1t6seNpnA&FT!Z_R&fhzv!#Ap@t9Bz^JXdbS=``!eN1zcnyyik zMo*Waq%rJSz!3hFi(-4>GXFsU0`<~Pp8KU4ceg<7%u2FLYi_Rx{|M+$OGJxC48VBXz8egc-th>Y`m!w{$=$<9N4Z)n!J$f0ij(w_}yM+VcRfn?O?u%;=ME`-msGgjd4T z98c}-rTF(2PnAfsV?n~6uOxacq#-1>9MQ@$Izg z34Oo9G88Z{J#jk|anq&!VNUmgQ^najcKcB5Z-vi3EhP%4epFAtuaYu49TR~?KlSqS zN;=Z&s;F)O=8hdaz_|D2(ZgL4joxwEWD`JLmoc72vF&b35mJf~??-cc8{!mh{3-4* z6tzkwBSeNkdTF-$O*M$?Smwg84L$aS99fb=aE&kB`~eq*UbEM-7axuhX!pu{F)+ZX zenN;x9oVU?5J7*w%A@fkF!oL)8>eNOm8eLp#On)FEscj09QETK!z6VF+h!u*<;Iw9 zV@Gl_{6bG5-$~F1rx3aV={tPr8W0kM36k}YP z5ht9}_9re}dT{_s3xVV%oW*A(`yjC3gVInLpu;zD>H2|z>&BrziI4XVt6uO_p_p_?yX?%~&>Ym{BQky)GbPdBnR zNUO_ylMD313N-tS{hGXWSm%hx72BbFXnaLb< z%Pk`5B~Li%gnHPcpV9Y81L)n-gVNM_mXnF6C)9Cv8cY$yy>{DjM-<+x|KU%yDu4VI z;wJf;vSh);YR+}hb3SX;{`LixV5%4*aJ)*F#gnQUHS+P?_j!)S1(Qh?;6wJIE6#bC zXAE_xX5dy5{hoZoBe2dT)FT+TyH-o>BWOk6x!w3G(2PI6&yShIyl&7N<+@g?*zer$ zJ;Qm=B{sq^Cn7zZhCG1!ob>bEl=uaTe)ULsa6UoB$p@3PixcuaqwXdjH=+N60C{tl z03|vr+~hW8I-Ouz1&4#s2!SZXwJX{=ANG=;PQ^*6CTZW2!&SEbPK5&9#zFarB(6gl zJjfOFer{2sH?xG?aX|_iZR5Zr$%xuDFMw2BxlsleshIejH0GTo5ct4wAGq@;vbEpBedmI;?9lg zQEUlY_3}~SDtWz})+$wtYK?D&nvc91Ezv|Q8?%B%z2k#!NzsHr3rG^`eGHgf&abGf z2XswuZ(!t%OG?r)F>`QBLecMF8iyi1@9gE)>+NY+_cj_w|1fd1b#s;p!?F{=mNKW*@W2@St)D9@)g}% zBCs=!gSAD@s=Kf}Bul_0g=~*T!>=(_S4bkkQJ_)X7hD1z?Jw*=uv#;i`UDU)N`C@h z=D>Zhtbxw%&$Ix3RLc#|^6 zPs$b9#A#84XSl@TxJ#*AKLPC^juaoT=I~Nx z1HGoA#o;FyM+cXPux|r6Vss5^FBR~^9 z{qcSWe4YM;2A3xmT=WLu8SjSk4go6Rt@@kdmbDfXTzzFWB&Igpi__n*VKZ{tiZ(LH zbz}nTfy*EjH#deA(jGq4e|YAj10W0kY-;gKOQC7?q6Bx-=a~xtPv@aj+a@-1W$ZxM zI4Z2RprF84f7HTM&h(&d37~SmnRPtmy`kda(Bk*JboUtmf5LX#I5UC=r+ld%H6D=Y{>?s&`vN``H7|^5$!_uvYS_ zyWYJ#&OlWm%`h6;xq5055Sk9Ug*RIUjh)SX%RAI-lCcuacUv364C1nAhE#u(IvOzN z`|rjj5Ov5E^S%-t@BU4yodgQLpkeW?ZtQtlVPXe2WRx7D_$T0lD{-;A|yS1 z_A9Uj>TgZAc8-Ku=&k4ZbDE|ub3^vUe)xplIG&14)$*yU%h9x+l{+pFTs-6-#r@V; z_9nI)rflXZalp~*S!$uI=SDt|q@Q5oF|Mpb#{9{nd+ZkHYDR`yM5nTXHV*Bwi62Os zUY3MO76lAt--{E>>?~4UDq$&`%BCB?cZn{wFm;`~fHUfWXLU29%NMkuL$tTN|6rdUHsq;g8N<8{pUe42&IoeN z=7)m@VgY1Evc=}vi$}7C{;eMbkVNef`|8&5j%N=XrW4xh=a3ZOXt$DE8|0%Ab<1Xk z5`SE^&Pi*RpUg6Lpc+ib9?NnkSK@2SkJLOK#%iO7P^>2AGSA8^3@OAfu9gvjkTGB* zxfWc#W_n+lD;ppmVuBKgTW&B8JPRLwxmFKti*O742Llml4 zV2DAmMfHt*0r1C#vE2v+LuBFO;oXsh$UmQvp7DtE@JGiJ2csCwA@Ozc}$i#B& zTE*-Ob0#cqH%~RaSso<^DG*bKy7~a8ziqtf#3T=(#fAN`3vdw(M(Pm(Ria0W+;>ef zKfcnB+A1}#{x;ZT0%EE5ofOF{$T(AT#HFlz!UGU|HR2Ywg{oVaF#+aZv(5XJ$d)iy zGAlsA_VJIDA;MFQ4qu=!@hM&iu>1^^JJS_btrw?Lo_e>*us8|Tyf8prnr!I`DHM)H z^+VTgI+o9q480-mST5y?+dQl*s-cnBO{VVhQryX8IAvwjXmX)1%qDYP55EykHHie* zlK}c{4WTDbq1vLjS!p8r7b>hZrTn32Y-XelU>ym$ zEhGKhb*9xRGk7|vAzC6Y?og+3vs?@Uh>q&J{LD<>QGEFj*M~1s=8w8w(nA=p^*1BJR@dVJpW;ZLLiMSV2l-;XJiGxiGt99xrGCE zOq_^1x@Q!N6Sz}E^U^mm)&L9<0MW$J3?mdsM*XINz*2=a(8=)al;+mYF~?64Y}emH z&%e14MzEFK7Gbx@Z+$)y(3Yxal`%6HXZPnflQ_HKLNeRKh_>h7&gbS4Wr-q%vjZqZ z%gLZGMiMs~yP9i#j%H=pZVZ-iC^uQ#v|ca;AWx|P)m3GCqH15+ns9C^;06Q5Y+Cs z_G@UFeK2T^Q%;;tOzaL0B+e9^_(SH7r4I(zCJoj@;vYrD)oQ9Pso$ztT0LJ#lyqgn z+y+t8?O_Ec!?faf)f$KFQq>KHt~|!OW=yN~hSf4$8?K8n8VzUxZxkmWHl&2vg77cD z!hJm$!WOsRu1SDCP+jZ^65uC^EN5lyGnU>Ogf8___OIMr1Qo1|kJ{dAFVx;4&jE@i z0~#R}(4zvmeA?6P0&b+w8g#aPtcumvrW$k~C@hvL1}9j?@KMuSGO;%wymgn^yEGXc zy~hq5KJj?Npkw)BzK}BYW@@1(YzFxJO&r_#XZ)(a3*6W+tB#FVi)b!j2 z=tWZ_lvv&VfoVEkEd-COtcg32Lz2ywwNBOid@3kkr{alle-B3Fn zLv#BRV_LtwDoi$A1u#Z{!pKdy@QLq5>gOabH2iw7wWpEzmH+*&(O#pSSDmEnQuik$ zoiMv?^d+)A67*?ZH~h^Y2YpRD3lQp^3N{tBhsj$#{%Q>dP3yD|Epkm-Z>VLJHIMwQ zXOmZjV5)mPnEiz3$lU4MAcpN}LX_LDpYn!d%u!{A{|^K#sRJ=$q+V z2bF&TYOjVR@l1V+O~gd!;IjgA%e3*78=NwLoEq6WitqjpS^uK>UOQesx)HCVQ}ZcV z!*H*X!s!~qcy+*n)b!xB+;zxVM&8VF(u-;qrz2DZ z-NkN>)O&+#ABghWRL@ zS3dOLYq&OO9r(7HU4ZVgk-a}^%P(C-OWA?I+F4~AmI`f^{+rsUTka+dy}=l~&{-4RC;isfPc+0IxsxT{qwbb6B4-CC6&f!~ zdUO7|{z(Z%Rzs{>d8;lku&m}@>h$Hsw#H4Y)sZ3dIU%=UsG)2=<=KB9%F4}b1zn=p z$o3*yj<2a3PkWu()gq)W)>RbE$K1!>3^&@uUZ_@&RI1o8X1cwIvxgDU0}o7-N0&9D zIj1Lp7#-I_mSEP9=AZK-TBPPNr#cT5w$rR zVM1FTm=@V9nvWR&G!*S_ZR*B^0v+1h8E#cRiU`U5(x0c2Hu4WjJWCa8+`ok?to9-o zhVIBveZKmW;`1qGFkmo&vSu;cw}?+4OU=hP0v=drd7)4_DZ(QBTzjDb8rgx+d~gzY z@M=~Z!?qV1qY?hKw$+?90y-PsN^o6x9tMEg^|P6Fe{PtCT6>W*^|IuL2LH*kwf248 z%$9?e`LPb`P5wR}R_GB5G_IcK8~o4n^9S#bonY!@L?;F?>&`EidC@dzv!7$jhR=fd2UeM-M0oIq4l6AbsN8G1Fom~bK&&8 z-nISj2Uiw)+sJqrH71C-0NgF^&vP(uKixSsg5Xd~lW zo5yBYfcZ}jPO8|eLpCBXZ9tx;^gr(*qx$ea+1b`)U|v;=b>&xG_3$)9PaWRwW_FY4 z{fMZNHl+7WXKAhn;PZ%Fx*iI$3rr>F5XV$xB zo47H5fgA3Lh471JfNVn}IymVet**ApEv$xt%Tar9xGcF-v(SGQN|<_Zyv1uuZ{IHh za9A|m?^@sMGF8JodGsge(XSjJD<4b<`n?%(gRuK@(lD*IE+F(uspCLererjf z)z3x^5c}W&gB*N>l)?FjnXC2s(q{JYrW?PT2DK>aL*3H5{H5$V%Kji}ZK-;s@85uV@Zd7U^3z%%-D1#MHOTfcjSg2l zlSP749*=&JP`~f`rzWgxa6Y2dEdOCG(z?F)AMWhUBP}s;5hnZE{&9Uk(!sy{{~Jp7 zA5;4q+x354Y5%!4Zuozp*X}HN7!eSFCQema51t?Y$kiaPoTQ4Y8l?RPvb*7- z8qi@0Uwxn#*yl1?pnWNp0hN`mVZ?-F%f88zo$gSGtdG3ya$g%-|Mko*wX7fx=CgnT z>O5ZbUyrF&+WIgeJ>aA9wC@ok?EyC(GVd`Pd-cg~1>-4c^@xT90MV8pCp4<|>CEs2 zY6}1S>wYKJQ&{#&92?nxt}PnUFc+`*NsQx~6h))54=KnZw77F_SUTIWfw~lo#5RLJ&|o(rd*)Xa`XLb;?ZB9+=kWw(DR%j+}Cf z8l9expAHC-Tb(aErMI}{|BMB!b+Y>X*T;Hx9>~43!_Z0=5kL(l=+lVJ4VAz#mx)^z zo``)5JU7Ti3Q;!$10c&4vs6QhE}pWR?*rbqC(ZOgIjk>Wt)q0nd&udB+$iR);yDG} zWqP>%?jl$H9f6D8cAeJWf9CDIz1Fjk{MTTjzW389Z6pzfDb^=H0=4nW+tmOMKgPwV vs^wVm8K*2v<3xVOQoP216fVDheCL1p)TJVC&+ e - e.error_message + # It is necessary to be able to test the tags of a resource. It is possible to say of the + # resource has tags or not, and it is possible to check that the tags include a specific tag + # However the value is not accessible, this function creates methods for all the tags that + # are available. + # + # The format of the method name is '_tag' and will return the value of that tag + # + # Disabling rubopcop check. If this is set as a normal if..then..end statement there is a + # violation stating it should use a guard. When using a guard it throws this error + # + # rubocop:disable Style/MultilineIfModifier + # + # @author Russell Seymour + def create_tag_methods + # Iterate around the items of the tags and create the necessary access methods + tags.item.each do |name, value| + method_name = format('%s_tag', name) + define_singleton_method method_name do + value + end + end if defined?(tags.item) + end + + private + + # Filter the resources that are returned by the options that have been specified + # + def filter_resources(resources, opts) + if opts[:type] && opts[:name] + resources.select { |r| r.type == opts[:type] && r.name == opts[:name] } + elsif opts[:type] + resources.select { |r| r.type == opts[:type] } + elsif opts[:name] + resources.select { |r| r.name == opts[:name] } + else + resources end end end -# Class to return a NetworkManagement client for use with NICs and Public IP Addresses +# Class to create methods on the calling object at run time. +# Each of the Azure Resources have different attributes and properties, and they all need +# to be testable. To do this no methods are hardcoded, each on is craeted based on the +# information returned from Azure. +# +# The class is a helper class essentially as it creates the methods on the calling class +# rather than itself. This means that there is less duplication of code and it can be +# reused easily. # # @author Russell Seymour -# @attr_reader [Azure::ARM::Network::NetworkManagementClient] client Azure Network Management cient -class ResourceManagement - attr_reader :client - - # Constructor for the class. Creates the new Network Management client object +# @since 0.2.0 +class AzureResourceDynamicMethods + # Given the calling object and its data, create the methods on the object according + # to the data that has been retrieved. Various types of data can be returned so the method + # checks the type to ensure that the necessary methods are configured correctly # - # @author Russell Seymour - # - # @param [MsRest::TokenCredentials] azure Connection object for Azure - def initialize(azure) - @client = Azure::ARM::Resources::ResourceManagementClient.new(azure.connection) - client.subscription_id = azure.subscription_id + # @param AzureResourceProbe|AzureResource object The object on which the methods should be craeted + # @param variant data The data from which the methods should be created + def create_methods(object, data) + # Check the type of data as this affects the setup of the methods + # If it is an Azure Generic Resource then setup methods for each of + # the instance variables + case data.class.to_s + when /^Azure::Resources::Mgmt::.*::Models::GenericResource$/, + /^Azure::Resources::Mgmt::.*::Models::ResourceGroup$/ + # iterate around the instance variables + data.instance_variables.each do |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 + # craete a method for each one. + when 'Hash' + data.each do |key, value| + create_method(object, key, value) + end + end end - # Determine if the specified resource group exists in the subscription_id - # - # @author Russell Seymour - # - # @param [String] name Name of the resource group - # - # @return [Boolean] Whether the resource group exists or not - def exists(name) - client.resource_groups.check_existence(name) - end + private - # Retrieve the named resource group if it exists + # Method that is responsible for creating the method on the calling object. This is + # because some nesting maybe required. For example of the value is a Hash then it will + # need to have an AzureResourceProbe create for each key, whereas if it is a simple + # string then the value just needs to be returned # - # @author Russell Seymour + # @private # - # @param [String] name Name of the resource group - # - # @return [Azure::ARM::Resources::Models::ResourceGroup] Object containing information about the resource group - def get_resource_group(name) - client.resource_groups.get(name) if exists(name) - end - - # Get all of the resources that are contained within the resource group if it exists - # - # @author Russell Seymour - # - # @param [String] name Name of the resource group - # - # @return [Azure::ARM::Resources::Models::ResourceListResult] Object containing array of all the resources - def get_resources(name) - client.resources.list_by_resource_group_as_lazy(name) if exists(name) + # @param AzureResourceProbe|AzureResource object Object on which the methods need to be created + # @param string name The name of the method + # @param variant value The value that needs to be returned by the method + def create_method(object, name, value) + # Create the necessary method based on the var that has been passed + # Test the value for its type so that the method can be setup correctly + case value.class.to_s + when 'String', 'Integer', 'TrueClass', 'FalseClass', 'Fixnum' + object.define_singleton_method name do + value + end + when 'Hash' + value.count.zero? ? return_value = value : return_value = AzureResourceProbe.new(value) + object.define_singleton_method name do + return_value + end + when /^Azure::Resources::Mgmt::.*::Models::ResourceGroupProperties$/ + # This is a special case where the properties of the resource group is not a simple JSON model + # This is because the plugin is using the Azure SDK to get this information so it is an SDK object + # that has to be interrogated in a different way. This is the only object type that behaves like this + value.instance_variables.each do |var| + create_method(object, var.to_s.delete('@'), value.instance_variable_get(var)) + end + when 'Array' + # Some things are just string or integer arrays + # Check this by seeing if the first element is a string / integer / boolean or + # a hashtable + # This may not be the best methid, but short of testing all elements in the array, this is + # the quickest test + case value[0].class.to_s + when 'String', 'Integer', 'TrueClass', 'FalseClass', 'Fixnum' + probes = value + else + probes = [] + value.each do |value_item| + probes << AzureResourceProbe.new(value_item) + end + end + object.define_singleton_method name do + probes + end + end end end -# Class to return a ComputeManagement client to get information about VMs +# Class object to maintain a count of the Azure Resource types that are found +# when a less specific test is carried out. For example if all the resoures of a resource +# group are called for, there will be variaous types and number of those types. +# +# Each type is namespaced, so for example a virtual machine has the type 'Microsoft.Compute/virtualMachines' +# This is broken down into the 'Microsoft' class with the type 'Compute/virtualMachines' +# This has been done for two reasons: +# 1. Enable the dotted notation to work in the test +# 2. Allow third party resource types ot be catered for if they are ever enabled by Microsoft # # @author Russell Seymour -# -# @attr_reader [Azure::ARM::Compute::ComputeManagementClient] client ComputeManagement client object -class ComputeManagement - attr_reader :client - - # Constructor for the class. Creates the new Network Management client object +# @since 0.2.0 +class AzureResourceTypeCounts + # Constructor to setup a new class for a specific Azure Resource type. + # It should be passed a hashtable with information such as: + # { + # "Compute/virtualMachines" => 2, + # "Network/networkInterfaces" => 3 + # } + # This will result in two methods being created on the class: + # - Compute/virtualNetworks + # - Network/networkInterfaces + # Each of which will return the corresponding count value # - # @author Russell Seymour + # @param Hash counts Hash table of types and the count of each one # - # @param [MsRest::TokenCredentials] azure Connection object for Azure - def initialize(azure) - @client = Azure::ARM::Compute::ComputeManagementClient.new(azure.connection) - client.subscription_id = azure.subscription_id + # @return AzureResourceTypeCounts + def initialize(counts) + counts.each do |type, count| + define_singleton_method type do + count + end + end end end -# Class to return a NetworkManagement client for use with NICs and Public IP Addresses +# Class object that is created for each element that is returned by Azure. +# This is what is interogated by Inspec. If they are nested hashes, then this results +# in nested AzureResourceProbe objects. +# +# For example, if the following was seen in an Azure Resource +# properties -> storageProfile -> imageReference +# Would result in the following nestec classes +# AzureResource -> AzureResourceProbe -> AzureResourceProbe +# +# The methods for each of the classes are dynamically defined at run time and will +# match the items that are retrieved from Azure. See the 'test/integration/verify/controls' for +# examples +# +# This class will not be called externally # # @author Russell Seymour -# @attr_reader [Azure::ARM::Network::NetworkManagementClient] client Azure Network Management cient -class NetworkManagement - attr_reader :client +# @since 0.2.0 +# @attr_reader string name Name of the Azure resource +# @attr_reader string type Type of the Azure Resource +# @attr_reader string location Location in Azure of the resource +class AzureResourceProbe + attr_reader :name, :type, :location, :item, :count - # Constructor for the class. Creates the new Network Management client object + # Initialize method for the class. Accepts an item, be it a scalar value, hash or Azure object + # It will then create the necessary dynamic methods so that they can be called in the tests + # This is accomplished by call the AzureResourceDynamicMethods + # + # @param varaint The item from which the class will be initialized + # + # @return AzureResourceProbe + def initialize(item) + dm = AzureResourceDynamicMethods.new + dm.create_methods(self, item) + + # Set the item as a property on the class + # This is so that it is possible to interrogate what has been added to the class and isolate them from + # the standard methods that a Ruby class has. + # This used for checking Tags on a resource for example + # It also allows direct access if so required + @item = item + + # Set how many items have been set + @count = item.length + end + + # Allows resources to respond to the include test + # This means that things like tags can be checked for and then their value tested # # @author Russell Seymour # - # @param [MsRest::TokenCredentials] azure Connection object for Azure - def initialize(azure) - @client = Azure::ARM::Network::NetworkManagementClient.new(azure.connection) - client.subscription_id = azure.subscription_id + # @param [String] key Name of the item to look for in the @item property + def include?(key) + @item.key?(key) + end + + # Give a sting like `computer_name` return the camelCase version, e.g. + # computerName + # + # @param string data Data that needs to be converted from snake_case to camelCase + # + # @return string + def camel_case(data) + camel_case_data = data.split('_').inject([]) { |buffer, e| buffer.push(buffer.empty? ? e : e.capitalize) }.join + + # Ensure that gb (as in gigabytes) is uppercased + camel_case_data.gsub(/[gb]/, &:upcase) end end diff --git a/lib/resources/azure/azure_generic_resource.rb b/lib/resources/azure/azure_generic_resource.rb new file mode 100644 index 000000000..faa6d89c5 --- /dev/null +++ b/lib/resources/azure/azure_generic_resource.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'azure_backend' + +class AzureGenericResource < AzureResourceBase + name 'azure_generic_resource' + + desc ' + Inspec Resource to interrogate any Resource type in Azure + ' + + attr_accessor :filter, :total, :counts, :name, :type, :location, :probes + + def initialize(opts = {}) + # Call the parent class constructor + super(opts) + + # Get the resource group + resource_group + + # Get the resources + resources + + # Create the tag methods + create_tag_methods + end + + # Define the filter table so that it can be interrogated + @filter = FilterTable.create + @filter.add_accessor(:count) + .add_accessor(:entries) + .add_accessor(:where) + .add_accessor(:contains) + .add(:exist?, field: 'exist?') + .add(:type, field: 'type') + .add(:name, field: 'name') + .add(:location, field: 'location') + .add(:properties, field: 'properties') + + @filter.connect(self, :probes) + + def parse_resource(resource) + # return a hash of information + parsed = { + 'location' => resource.location, + 'name' => resource.name, + 'type' => resource.type, + 'exist?' => true, + 'properties' => AzureResourceProbe.new(resource.properties), + } + + parsed + end +end diff --git a/lib/resources/azure/azure_resource_group.rb b/lib/resources/azure/azure_resource_group.rb index ce269d0bb..998069d0c 100644 --- a/lib/resources/azure/azure_resource_group.rb +++ b/lib/resources/azure/azure_resource_group.rb @@ -1,207 +1,149 @@ +# frozen_string_literal: true require 'azure_backend' -# Class to test the resources in Resource Groups -# -# @author Russell Seymour -# -# @attr_reader [Hashtable] items List of items in the resource group -# @attr_reader [Azure::ARM::Resources::Models::ResourceGroup] rg Resource group under interrogation -# @attr_reader [Hashtable] counts Hashtable containing the counts of the different types in the resource group -class AzureRg < Inspec.resource(1) +class AzureResourceGroup < AzureResourceBase name 'azure_resource_group' - desc " - This resource returns information about the specified resource group - " + desc ' + Inspec Resource to get metadata about a specific Resource Group + ' - example " - describe azure_rg(name: 'ACME') do - its('nic_count') { should eq 4 } - its('vm_count) { should eq 2 } - end - " + attr_reader :name, :location, :id, :total, :counts, :mapping - attr_reader :items, :rg, :counts + # Constructor to get the resource group itself and perform some analysis on the + # resources that in the resource group. + # + # This analysis is defined by the the mapping hashtable which is used to define + # the 'has_xxx?' methods (see AzureResourceGroup#create_has_methods) and return + # the counts for each type + # + # @author Russell Seymour + def initialize(opts) + opts.key?(:name) ? opts[:group_name] = opts[:name] : false + # Ensure that the opts only have the name of the resource group set + opts.select! { |k, _v| k == :group_name } + super(opts) - # Constructor which retrieves the named resource group and parses all of its items + # set the mapping for the Azure Resources + @mapping = { + nic: 'Microsoft.Network/networkInterfaces', + vm: 'Microsoft.Compute/virtualMachines', + extension: 'Microsoft.Compute/virtualMachines/extensions', + nsg: 'Microsoft.Network/networkSecurityGroups', + vnet: 'Microsoft.Network/virtualNetworks', + managed_disk: 'Microsoft.Compute/disks', + managed_disk_image: 'Microsoft.Compute/images', + sa: 'Microsoft.Storage/storageAccounts', + public_ip: 'Microsoft.Network/publicIPAddresses', + } + + # Get information about the resource group itself + resource_group + + # Get information about the resources in the resource group + resources + + # Call method to create the has_xxxx? methods + create_has_methods + + # Call method to allow access to the tag values + create_tag_methods + end + + # Return the provisioning state of the resource group + # + # @author Russell Seymour + def provisioning_state + properties.provisioningState + end + + # Analyze the fully qualified id of the resource group to return the subscription id + # that this resource group is part of + # + # The format of the id is + # /subscriptions//resourceGroups/ + # + # @author Russell Seymour + def subscription_id + id.split(%r{\/}).reject(&:empty?)[1] + end + + # Method to parse the resources that have been returned + # This allows the calculations of the amount of resources to be determined # # @author Russell Seymour # - # @param [Hash] opts Hashtable of options - # opts[:name] The name of the resource group - def initialize(opts) - opts = opts - helpers = Helpers.new + # @param [Hash] resource A hashtable representing the resource group + def parse_resource(resource) + # return a hash of information + parsed = { + 'name' => resource.name, + 'type' => resource.type, + } - # Get the named resource group - @rg = helpers.resource_mgmt.get_resource_group(opts[:name]) - - # If the rg is nil raise error - raise format("Unable to find resource group '%s' in Azure subscription '%s'", opts[:name], helpers.azure.subscription_id) if rg.nil? - - # Retrieve the items within the resource group - rg_items = helpers.resource_mgmt.get_resources(opts[:name]) - - # Parse the resources - @items = parse_rg_resources(rg_items.value) + parsed end - # Create a FilterTable so that items can be selected - filter = FilterTable.create - filter.add_accessor(:where) - .add_accessor(:entries) - .add_accessor(:count) - .add_accessor(:contains) - .add(:type, field: 'type') - .add(:name, field: 'name') - .add(:location, field: 'location') + # This method catches the xxx_count calls that are made on the resource. + # + # The method that is called is stripped of '_count' and then compared with the + # mappings table. If that type exists then the number of those items is returned. + # However if that type is not in the Resource Group then the method will return + # a NoMethodError exception + # + # @author Russell Seymour + # + # @param [Symbol] method_id The name of the method that was called + # + # rubocop:disable Style/MethodMissing + def method_missing(method_id) + # Determine the mapping_key based on the method_id + mapping_key = method_id.to_s.chomp('_count').to_sym - filter.connect(self, :items) + if mapping.key?(mapping_key) + # based on the method id get the + namespace, type_name = mapping[mapping_key].split(/\./) - # Determine the location of the resource group - # - # @return [String Location of the resource group - # - def location - rg.location - end - - # Determime how many resources in total there are - # - # @return [Integer] Total number of items in the resource group - # - def total - counts['total'] - end - - # Determine how many of a certain type there are - # - # @return [Integer] Number of specific items in the FilterTable - # - def count - entries.length - end - - # Allows tests to be performed on the resources - # For example it is possible to check that a resource of a certain name exists - # - # @param [Hashtable] settings Hashtable of settings which will be used to perform the filter - # settings[:parameter] Name of the parameter being interrogated [name, type, location] - # settings[:value] The expected value of the specified paramater - # - # @return [Boolean] Whether or not the specified item exists in the resources - # - def contains(settings) - result = false - - entries.each do |entry| - if entry[settings[:parameter]] == settings[:value] - result = true - break + # check that the type_name is defined, if not return 0 + if send(namespace).methods.include?(type_name.to_sym) + # return the count for the method id + send(namespace).send(type_name) + else + 0 end + else + msg = format('undefined method `%s` for %s', method_id, self.class) + raise NoMethodError, msg end - - result - end - - # Helper method to determine the number of NICs in the resource group - # - # @return [Integer] Number of NICs in the resource group - # - def nic_count - counts['Microsoft.Network/networkInterfaces'] - end - - # Helper method to determine the number of VMs in the resource group - # - # @return [Integer] Number of VMs in the resource group - # - def vm_count - counts['Microsoft.Compute/virtualMachines'] - end - - # Helper method to determine the number of NSGs in the resource group - # - # @return [Integer] Number of NSGs in the resource group - # - def nsg_count - counts['Microsoft.Network/networkSecurityGroups'] - end - - # Helper method to determine the number of Virtual Networks in the resource group - # - # @return [Integer] Number of VNETs in the resource group - # - def vnet_count - counts['Microsoft.Network/virtualNetworks'] - end - - # Helper method to determine the number of Storage Accounts in the resource group - # - # @return [Integer] Number of SAs in the resource group - # - def sa_count - counts['Microsoft.Storage/storageAccounts'] - end - - # Helper method to determine the number of Public IP Addresses in the resource group - # - # @return [Integer] Number of Public IP Addresses in the resource group - # - def public_ip_count - counts['Microsoft.Network/publicIPAddresses'] - end - - # Helper method to determine the number of Managed Disk images in the resource group - # - # @return [Integer] Number of Managed Disk images - # - def managed_disk_image_count - counts['Microsoft.Compute/images'] - end - - # Helper method to determine the number of Managed Disks in the resource group - # - # @return [Integer] Number of Managed Disks - # - def managed_disk_count - counts['Microsoft.Compute/disks'] end private - # Parse the Resource Group Resources + # For each of the mappings this method creates the has_xxx? method. This allows the use + # of the following type of test # - # @param [Array] resources Array of resources in the resource group + # it { should have_nics } # - # @return [Array] Array of hashes providing the information about the resources for the FilterTable - def parse_rg_resources(resources) - # Declare the hashtable of counts - @counts = { - 'total' => 0, - } - - resources.each.map do |resource| - parse_item(resource) - end.compact - end - - # Parses each resource item and extracts the information to be tested + # For example, it will create a has_nics? method that returns a boolean to state of the + # resource group has any nics at all. # - # @return [Hash] Resource information - # - def parse_item(item) - # Increment the count total - counts['total'] += 1 + # @author Russell Seymour + # @private + def create_has_methods + # Create the has methods for each of the mappings + # This is a quick test to show that the resource group has at least one of these things + mapping.each do |name, type| + # Determine the name of the method name + method_name = format('has_%ss?', name) + namespace, type_name = type.split(/\./) - # Update the count for the resource type in the count table - counts.key?(item.type) ? counts[item.type] +=1 : counts[item.type] = 1 + # use the namespace and the type_name to determine if the resource group has this type or not + result = send(namespace).methods.include?(type_name.to_sym) ? true : false - { - 'location' => item.location, - 'name' => item.name, - 'type' => item.type, - } + define_singleton_method method_name do + result + end + end end end diff --git a/lib/resources/azure/azure_virtual_machine.rb b/lib/resources/azure/azure_virtual_machine.rb index 2187425e4..3d8003996 100644 --- a/lib/resources/azure/azure_virtual_machine.rb +++ b/lib/resources/azure/azure_virtual_machine.rb @@ -1,324 +1,261 @@ +# frozen_string_literal: true require 'azure_backend' -# Class to retrieve information about the specified virtual machine -# -# @author Russell Seymour -# -# @attr_reader [Azure::ARM::Compute::Models::VirtualMachine] vm VM object as retrieved from Azure -class AzureVm < Inspec.resource(1) +class AzureVirtualMachine < AzureResourceBase name 'azure_virtual_machine' - desc " - This resource gathers information about which image the vm was created from - " + desc ' + Inspec Resource to test Azure Virtual Machines + ' - example " - describe azure_vm(name: 'acme-test-01', resource_group: 'ACME') do - its('sku') { should eq '16.04.0-LTS'} - end - " - - attr_accessor :vm, :nics, :helpers - - # Constructor to retrieve the VM from Azure + # Constructor for the resource. This calls the parent constructor to + # get the generic resource for the specified machine. This will provide + # static methods that are documented # # @author Russell Seymour - # - # @param [Hash] opts Hashtable of options - # opts[:host] The name of the host in the resource group. NOTE, this is the name as seen in Azure and not the name of the machine in the Operating System - # opts[:resource_group] Name of the resource group in which the host will be found - def initialize(opts) - opts = opts - @helpers = Helpers.new - @vm = helpers.get_vm(opts[:name], opts[:resource_group]) + def initialize(opts = {}) + # The generic resource needs to pass back a Microsoft.Compute/virtualMachines object so force it + opts[:type] = 'Microsoft.Compute/virtualMachines' + super(opts) - # Ensure that the vm is an object - raise format('An error has occured: %s', vm) if vm.instance_of?(String) + # Find the virtual machines + resources - # Parse the Network Interface Cards attached to the machine - @nics = parse_nics(vm.network_profile.network_interfaces) + create_tag_methods end - filter = FilterTable.create - filter.add_accessor(:where) - .add_accessor(:entries) - .add(:accelerated_networking, field: 'enable_accelerated_networking') - .add(:ip_forwarding, field: 'enable_ip_forwarding') - .add(:location, field: 'location') - .add(:name, field: 'name') - .add(:primary, field: 'primary') - .add(:ip_configurations, field: 'ip_configurations') + # Method to catch calls that are not explicitly defined. + # This allows the simple attributes of the virtual machine to be read without having + # to define each one in turn. + # + # rubocop:disable Style/MethodMissing + # rubocop:disable Metrics/AbcSize + # + # @param symobl method_id The symbol of the method that has been called + # + # @return Value of attribute that has been called + def method_missing(method_id) + # Depending on the method that has been called, determine what value should be returned + # These are set as camel case methods to comply with rubocop + image_reference_attrs = %w{sku publisher offer} + osdisk_attrs = %w{os_type caching create_option disk_size_gb} + hardware_profile_attrs = %w{vm_size} + os_profile_attrs = %w{computer_name admin_username} + osdisk_managed_disk_attrs = %w{storage_account_type} - filter.connect(self, :nics) + # determine the method name to call by converting the snake_case to camelCase + # method_name = self.camel_case(method_id.to_s) + method_name = method_id.to_s.split('_').inject([]) { |buffer, e| buffer.push(buffer.empty? ? e : e.capitalize) }.join + method_name.end_with?('Gb') ? method_name.gsub!(/Gb/, &:upcase) : false - # Determine the SKU used to create the machine - # - # @return [String] Showing the sku, e.g. 16.04.0-LTS - # - def sku - vm.storage_profile.image_reference.sku - end - - # Determine the publisher of the SKU - # - # @return [String] Publisher, e.g. Canonical - # - def publisher - vm.storage_profile.image_reference.publisher - end - - # Determine the offer from the publisher - # - # @return [String] offer, e.g. UbuntuServer - # - def offer - vm.storage_profile.image_reference.offer - end - - # Determine the size of the machine - # - # @return [String] Size of the machine, e.g. Standard_DS1_v2 - # - def size - vm.hardware_profile.vm_size - end - - # Determine the location of the vm - # - # @return [String] location of the machinem, e.g. westeurope - # - def location - vm.location - end - - # State if boot diagnostics is enabled - # - # @return [Boolean] - # - def has_boot_diagnostics? - if vm.diagnostics_profile - vm.diagnostics_profile.boot_diagnostics.enabled - else - false + if image_reference_attrs.include?(method_id.to_s) + properties.storageProfile.imageReference.send(method_name) + elsif osdisk_attrs.include?(method_id.to_s) + properties.storageProfile.osDisk.send(method_name) + elsif hardware_profile_attrs.include?(method_id.to_s) + properties.hardwareProfile.send(method_name) + elsif os_profile_attrs.include?(method_id.to_s) + properties.osProfile.send(method_name) + elsif osdisk_managed_disk_attrs.include?(method_id.to_s) + properties.storageProfile.osDisk.managedDisk.send(method_name) end end - # Determine how many network cards are connected to the machine + # Return the name of the os disk # - # @return [Integer] + # @return string Name of the OS disk + def os_disk_name + properties.storageProfile.osDisk.name + end + + # Determine if the OS disk is a managed disk # + # @return boolean + def has_managed_osdisk? + defined?(properties.storageProfile.osDisk.managedDisk) + end + + # Does the machine have any NICs connected + # + # @return boolean + def has_nics? + properties.networkProfile.networkInterfaces.count != 0 + end + + # How many NICs are connected to the machine + # + # @return integer def nic_count - vm.network_profile.network_interfaces.length + properties.networkProfile.networkInterfaces.count end - # The admin username for the machine + # Return an array of the connected NICs so that it can be tested to ensure + # the machine is connected properly # - # @return [String] Admin username when the machine was created, e.g. azure - # - def admin_username - vm.os_profile.admin_username + # @return array Array of NIC names connected to the machine + def connected_nics + nic_names = [] + properties.networkProfile.networkInterfaces.each do |nic| + nic_names << nic.id.split(%r{/}).last + end + nic_names end - # The computername as seen by the operating system - # This might be different to the VM name as seen in Azure + # Whether the machine has data disks or not # - # @return [String] - # - def computername - vm.os_profile.computer_name + # @return boolean + def has_data_disks? + properties.storageProfile.dataDisks.count != 0 end - # Alias for computername + # How many data disks are connected # - # @return [String] - # - def hostname - computername + # @return integer + def data_disk_count + properties.storageProfile.dataDisks.count end - # Determine if password authentication is enabled - # For Windows this is always True. On Linux this will be determined + # Does the machine allow password authentication # - # @return [Boolean] + # This allows the use of + # it { should have_password_authentication } + # within the Inspec profile # + # @return boolean + def has_password_authentication? + password_authentication? + end + + # Deteremine if the machine allows password authentication + # + # @return boolean def password_authentication? - # if the vm has a linux configuration then interrogate that, otherwise return true - if !vm.os_profile.linux_configuration.nil? - !vm.os_profile.linux_configuration.disable_password_authentication + # if the osProfile property has a linuxConfiguration section then interrogate that + # otherwise it is a Windows machine and that always has password auth + if defined?(properties.osProfile.linuxConfiguration) + !properties.osProfile.linuxConfiguration.disablePasswordAuthentication else true end end - # How many SSH keys have been added to the machine - # For Windows this will be 0, for Linux this will be determined + # Has the machine been given Custom Data at creation # - # @return [Integer] + # This allows the use of + # it { should have_custom_data } + # within the Inspec Profile # + # @return boolean + def has_custom_data? + custom_data? + end + + # Determine if custom data has been set + # + # @return boolean + def custom_data? + if defined?(properties.osProfile.CustomData) + true + else + false + end + end + + # Are any SSH Keys assigned to the machine + # + # This allows the use of + # it { should have_ssh_keys } + # within the Inspec Profile + # + # @return boolean + def has_ssh_keys? + ssh_keys? + end + + # Determine if any ssh keys have been asigned to the machine + # + # @return boolean + def ssh_keys? + if defined?(properties.osProfile.linuxConfiguration.ssh) + properties.osProfile.linuxConfiguration.ssh.publicKeys != 0 + else + false + end + end + + # Return the number of ssh keys that have been assigned to the machine + # + # @return integer def ssh_key_count - if !vm.os_profile.linux_configuration.nil? && !vm.os_profile.linux_configuration.ssh.nil? - vm.os_profile.linux_configuration.ssh.public_keys.length + if defined?(properties.osProfile.linuxConfiguration.ssh) + properties.osProfile.linuxConfiguration.ssh.publicKeys.count else 0 end end - # Determine the Operating system type using the os_disk object + # Determine is the specified key is in the ssh_keys list # - # @return [String] OS type, e.g. Windows or Linux - # - def os_type - vm.storage_profile.os_disk.os_type - end - - # Return an array of the private IP addresses so that it is possible - # to check if the machine has the correct assigned address - # - # @return [Array] Array of private ip addresses - # - def private_ipaddresses - # Create an array to hold the addresses - addresses = [] - - # Iterate around the filter that has been populated - entries.each do |entry| - entry.ip_configurations.each do |ip_config| - addresses << ip_config['private_ipaddress'] - end + # @return array Array of the public keys that are assigned to allow for testing of that key + def ssh_keys + # iterate around the keys + keys = [] + properties.osProfile.linuxConfiguration.ssh.publicKeys.each do |key| + keys << key.keyData end - - # return the array to the calling function - addresses + keys end - # Boolean test to check that the machine has a public IP address + # Does the machine have boot diagnostics enabled # - # @return [boolean] - # - def has_public_ipaddress? - # Define the test value - test = false - - entries.each do |entry| - entry.ip_configurations.each do |ip_config| - if ip_config['public_ipaddress']['attached'] - test = true - break - end - end + # @return boolean + def has_boot_diagnostics? + if defined?(properties.diagnosticsProfile) + properties.diagnosticsProfile.bootDiagnostics.enabled + else + false end - - test end - # Return the domain name label that has been assigned to the machine + # Return the URI that has been set for the boot diagnostics storage # - # @return [String] The domain name label + # @return string + def boot_diagnostics_storage_uri + properties.diagnosticsProfile.bootDiagnostics.storageUri + end + + # If this is a windows machine, returns whether the agent was provisioned or not # - def domain_name_label - label = nil - entries.each do |entry| - entry.ip_configurations.each do |ip_config| - if ip_config['public_ipaddress']['attached'] - label = ip_config['public_ipaddress']['domain_name_label'] - end - end + # @return boolean + def has_provision_vmagent? + if defined?(properties.osProfile.windowsConfiguration) + properties.osProfile.windowsConfiguration.provisionVMAgent + else + false end - - label end - private - - # Parse the array of NICs attached to the machine + # If a windows machine see if automatic updates for the agent are enabled # - # @return [Array] Array of all the NICs - # - def parse_nics(attached_nics) - # Iterate around the attached NICs - attached_nics.each.map do |attached_nic| - - # Get the name of the resource group and the name of the NIC - # This is required as the card might be in a different resource group - nic_raw = attached_nic.id.split(%r{/}) - nic_resource_group_name = nic_raw[4] - nic_name = nic_raw.last - - # Interrogate Azure for the NIC details - nic = helpers.network_mgmt.client.network_interfaces.get(nic_resource_group_name, nic_name) - - # Parse the NIC - parse_nic(nic) - end.compact - end - - # Parse the indivdual NIC - # - # @return [Hash] Properties of the indvidual NIC - # - def parse_nic(nic) - # Create the hash table that contains all the information about the NIC - { - 'enable_accelerated_networking' => nic.enable_accelerated_networking, - 'enable_ip_forwarding' => nic.enable_ipforwarding, - 'location' => nic.location, - 'name' => nic.name, - 'primary' => nic.primary, - - # Parse all the IP configurations for the NIC - 'ip_configurations' => parse_ip_configurations(nic.ip_configurations), - } - end - - # Parse the array of IP configurations that are applied to the NIC - # - # @returns [Array] Array of all the IP configurations - # - def parse_ip_configurations(ip_configurations) - # Iterate around all of the IP configurations - ip_configurations.each.map do |ip_configuration| - parse_ip_configuration(ip_configuration) - end.compact - end - - # Parse the IP configuration item - # - # @return [Hash] Hashtable of the ip_configuration attributes - # - def parse_ip_configuration(ip_configuration) - config = { - 'name' => ip_configuration.name, - 'primary' => ip_configuration.primary, - 'private_ipaddress' => ip_configuration.private_ipaddress, - 'public_ipaddress' => { - 'attached' => !ip_configuration.public_ipaddress.nil?, - }, - } - - # if there is a public IP address attached get its details - if config['public_ipaddress']['attached'] - - # Get the name of the resource group and the name of the NIC - # This is required as the card might be in a different resource group - public_ip_raw = ip_configuration.public_ipaddress.id.split(%r{/}) - public_ip_resource_group_name = public_ip_raw[4] - public_ip_name = public_ip_raw.last - - # Interrogate Azure for the NIC details - public_ip = helpers.network_mgmt.client.public_ipaddresses.get(public_ip_resource_group_name, public_ip_name) - - # update the config with the information about the public IP if public dns settings are available - if !public_ip.dns_settings.nil? - config['public_ipaddress']['domain_name_label'] = public_ip.dns_settings.domain_name_label - config['public_ipaddress']['dns_fqdn'] = public_ip.dns_settings.fqdn - else - config['public_ipaddress']['domain_name_label'] = nil - config['public_ipaddress']['dns_fqdn'] = nil - end + # @return boolean + def has_automatic_agent_update? + if defined?(properties.osProfile.windowsConfiguration) + properties.osProfile.windowsConfiguration.enableAutomaticUpdates + else + false end + end - # return object - config + # If this is a windows machine return a boolean to state of the WinRM options + # have been set + # + # @return boolean + def has_winrm_options? + if defined?(properties.osProfile.windowsConfiguration) && defined?(properties.osProfile.windowsConfiguration.winrm) + properties.osProfile.windowsConfiguration.winrm.protocol + else + false + end end end diff --git a/lib/resources/azure/azure_virtual_machine_data_disk.rb b/lib/resources/azure/azure_virtual_machine_data_disk.rb new file mode 100644 index 000000000..5b0534c6d --- /dev/null +++ b/lib/resources/azure/azure_virtual_machine_data_disk.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require 'azure_backend' +require 'uri' + +class AzureVirtualMachineDataDisk < AzureResourceBase + name 'azure_virtual_machine_data_disk' + + desc ' + Inspec Resource to ensure that the data disks attached to a machine are correct + ' + + # Create a filter table so that tests on the disk can be performed + filter = FilterTable.create + filter.add_accessor(:where) + .add_accessor(:entries) + .add_accessor(:has_data_disks?) + .add_accessor(:count) + .add(:exists?) { |x| !x.entries.empty? } + .add(:disk, field: :disk) + .add(:number, field: :number) + .add(:name, field: :name) + .add(:size, field: :size) + .add(:vhd_uri, field: :vhd_uri) + .add(:storage_account_name, field: :storage_account_name) + .add(:lun, field: :lun) + .add(:caching, field: :caching) + .add(:create_option, field: :create_option) + .add(:is_managed_disk?, field: :is_managed_disk?) + .add(:storage_account_type, field: :storage_account_type) + .add(:subscription_id, field: :subscription_id) + .add(:resource_group, field: :resource_group) + filter.connect(self, :datadisk_details) + + # Constructor for the resource. This calls the parent constructor to + # get the generic resource for the specified machine. This will provide + # static methods that are documented + # + # @author Russell Seymour + def initialize(opts = {}) + # The generic resource needs to pass back a Microsoft.Compute/virtualMachines object so force it + opts[:type] = 'Microsoft.Compute/virtualMachines' + super(opts) + + # Get the data disks + resources + end + + # Return information about the disks and add to the filter table so that + # assertions can be performed + # + # @author Russell Seymour + def datadisk_details + # Iterate around the data disks on the machine + properties.storageProfile.dataDisks.each_with_index.map do |datadisk, index| + # Call function to parse the data disks and return an object based on the parameters + parse_datadisk(datadisk, index) + end + end + + # Return boolean to denote if the machine has data disks attached or not + def has_data_disks? + !entries.empty? + end + + # Return an integer stating how many data disks are attached to the machine + def count + entries.count + end + + # Return boolean to state if the machine is using managed disks for data disks + def has_managed_disks? + # iterate around the entries + result = entries.each.select { |e| e[:is_managed_disk?] } + result.empty? ? false : true + end + + private + + # Parse the data disk to determine if these are managed disks or in a storage account + # for example. The disk index, name and size will be returned + # + # params object disk Object containing the details of the disk + # params integer index Index denoting which disk number this is on the machine + # + # return hashtable + def parse_datadisk(disk, index) + # Configure parsed hashtable to hold the information + # Initialise this with common attributes from the different types of disk + parsed = { + disk: index, + number: index + 1, + lun: disk.lun, + name: disk.name, + size: disk.diskSizeGB, + caching: disk.caching, + create_option: disk.createOption, + } + + # Determine if the current disk is a managed disk or not + if defined?(disk.vhd) + # As this is in a storage account this is not a managed disk + parsed[:is_managed_disk?] = false + + # Set information about the disk + # Parse the uri of the disk URI so that the storage account can be retrieved + uri = URI.parse(disk.vhd.uri) + parsed[:vhd_uri] = disk.vhd.uri + parsed[:storage_account_name] = uri.host.split('.').first + + elsif defined?(disk.managedDisk) + # State that this is a managed disk + parsed[:is_managed_disk?] = true + + # Get information about the managed disk + parsed[:storage_account_type] = disk.managedDisk.storageAccountType + parsed[:id] = disk.managedDisk.id + + # Break up the ID string so that the following information can get retreived + # - subscription_id + # - resource_group + id_parts = parsed[:id].split(%r{/}).reject(&:empty?) + + parsed[:subscription_id] = id_parts[1] + parsed[:resource_group] = id_parts[3] + end + + # return the parsed object + parsed + end +end diff --git a/libraries/azure_virtual_machine_datadisks.rb b/libraries/azure_virtual_machine_datadisks.rb deleted file mode 100644 index d56404a00..000000000 --- a/libraries/azure_virtual_machine_datadisks.rb +++ /dev/null @@ -1,137 +0,0 @@ - -require 'azure_backend' -require 'uri' - -# Class to test the data disks that are attached to the specified VM -# -# @author Russell Seymour -# -# @attr_reader [Array] params Array of hashes containing information about all the data disks attached to the machine -class AzureVmDataDisks < Inspec.resource(1) - name 'azure_virtual_machine_datadisks' - - desc " - This resource gather information about the data disks attached to a virtual machine - " - - example " - describe azure_vm_datadisks(host: 'example-01', resource_group: 'MyResourceGroup').where { (disk == 1 and size >= 10) } do - it { should be true } - end - " - - attr_reader :params - - # Constructor to retrieve all the data disks for the specified machines and populate the params property - # - # @author Russell Seymour - # - # @param [Hash] opts Hashtable of options - # opts[:host] The name of the host in the resource group. NOTE, this is the name as seen in Azure and not the name of the machine in the Operating System - # opts[:resource_group] Name of the resource group in which the host will be found - def initialize(opts) - @opts = opts - @helpers = Helpers.new - - # Get the VM that needs to be interrogated - vm = @helpers.get_vm(@opts[:name], @opts[:resource_group]) - - # Parse the data disks - @params = parse_data_disks(vm.storage_profile.data_disks) - end - - # Create a FilterTable which can be used by controls to interogate the data disks - filter = FilterTable.create - filter.add_accessor(:where) - .add_accessor(:entries) - .add_accessor(:count) - .add_accessor(:has_disks?) - .add(:disk, field: 'disk') - .add(:caching, field: 'caching') - .add(:create_option, field: 'create_option') - .add(:size, field: 'size') - .add(:lun, field: 'lun') - .add(:name, field: 'name') - .add(:uri, field: 'uri') - .add(:storage_account, field: 'storage_account') - .add(:account_type, field: 'account_type') - .add(:location, filed: 'location') - - filter.connect(self, :params) - - # Determine how many data disks have been applied to the machine - # - # @return [Integer] Number of data disks attached to the machine - # - def count - entries.length - end - - # Determine if any data disks are attached to the machine - # - # @return [Boolean] States if the VM has any disks attached - # - def has_data_disks? - !entries.empty? - end - - private - - # Parse the data disks and extract the necssary information - # - # @param [Array] data_disks Array of data disk objects - # - # @return [Array] Array of hashes providing the information about the data disks attached to the machine - # - def parse_data_disks(data_disks) - data_disks.each_with_index.map do |disk, index| - parse_data_disk_item(disk, index) - end.compact - end - - # Parses each data disk item and extracts the information to be tested - # - # @return [Hash] Data disk information - # - def parse_data_disk_item(disk, index) - # determine if using managed disks or a VHD - if !disk.vhd.nil? - - # Parse the uri of the disk so that the storage account can be retrieved - uri = URI.parse(disk.vhd.uri) - - { - 'disk' => index, - 'name' => disk.name, - 'size' => disk.disk_size_gb, - - 'caching' => disk.caching, - 'create_option' => disk.create_option, - 'lun' => disk.lun, - 'uri' => disk.vhd.uri, - 'storage_account' => uri.host.split('.').first, - } - - elsif !disk.managed_disk.nil? - - # Parse the ID so that the resource group and name are found - id_parts = disk.managed_disk.id.split(%r{/}) - resource_group_name = id_parts[4] - disk_name = id_parts.last - - # Use the compute client to get information about the disk - managed_disk = @helpers.compute_mgmt.client.disks.get(resource_group_name, disk_name) - - # Build up the hash table to return - { - 'disk' => index, - 'size' => managed_disk.disk_size_gb, - 'name' => managed_disk.name, - - 'account_type' => managed_disk.type, - 'location' => managed_disk.location, - } - - end - end -end diff --git a/test/integration/azure/build/azure.tf b/test/integration/azure/build/azure.tf index 7613e90ea..e41fec8fd 100644 --- a/test/integration/azure/build/azure.tf +++ b/test/integration/azure/build/azure.tf @@ -27,6 +27,10 @@ provider "azurerm" { resource "azurerm_resource_group" "rg" { name = "Inspec-Azure" location = "${var.location}" + + tags { + CreatedBy = "Inspec Azure Integration Tests" + } } # Create the storage account to be used @@ -34,7 +38,8 @@ resource "azurerm_storage_account" "sa" { name = "${var.storage_account_name}" location = "${var.location}" resource_group_name = "${azurerm_resource_group.rg.name}" - account_type = "Standard_LRS" + account_tier = "Standard" + account_replication_type = "LRS" } # Create the container in which the hard disks for the machine(s) will be stored @@ -54,6 +59,25 @@ resource "azurerm_public_ip" "public_ip_1" { domain_name_label = "linux-external-1-${var.suffix}" } +# Create a network security group so it can be tested +resource "azurerm_network_security_group" "nsg" { + name = "Inspec-NSG" + location = "${var.location}" + resource_group_name = "${azurerm_resource_group.rg.name}" + + security_rule { + name = "SSH-22" + priority = 100 + direction = "Inbound" + access = "Allow" + protocol = "Tcp" + source_port_range = "*" + destination_port_range = "22" + source_address_prefix = "*" + destination_address_prefix = "*" + } +} + # Create the virtual network for the machines resource "azurerm_virtual_network" "vnet" { name = "Inspec-VNet" @@ -68,6 +92,9 @@ resource "azurerm_subnet" "subnet" { resource_group_name = "${azurerm_resource_group.rg.name}" virtual_network_name = "${azurerm_virtual_network.vnet.name}" address_prefix = "10.1.1.0/24" + + # Attach the NSG to the subnet + network_security_group_id = "${azurerm_network_security_group.nsg.id}" } # Create the NIC for the internal machine @@ -98,6 +125,18 @@ resource "azurerm_network_interface" "nic2" { } } +resource "azurerm_network_interface" "nic3" { + name = "Inspec-NIC-3" + location = "${var.location}" + resource_group_name = "${azurerm_resource_group.rg.name}" + + ip_configuration { + name = "ipConfiguration1" + subnet_id = "${azurerm_subnet.subnet.id}" + private_ip_address_allocation = "dynamic" + } +} + # Create the machine for testing resource "azurerm_virtual_machine" "vm_linux_internal" { name = "Linux-Internal-VM" @@ -116,19 +155,10 @@ resource "azurerm_virtual_machine" "vm_linux_internal" { # Create the OS disk storage_os_disk { - name = "linux-internal-osdisk" - vhd_uri = "${azurerm_storage_account.sa.primary_blob_endpoint}${azurerm_storage_container.container.name}/linux-internal-osdisk.vhd" + name = "Linux-Internal-OSDisk-MD" caching = "ReadWrite" create_option = "FromImage" - } - - # Create 1 data disk to be used for testing - storage_data_disk { - name = "linux-datadisk-1" - vhd_uri = "${azurerm_storage_account.sa.primary_blob_endpoint}${azurerm_storage_container.container.name}/linux-internal-datadisk-1.vhd" - disk_size_gb = 15 - create_option = "empty" - lun = 0 + managed_disk_type = "Standard_LRS" } # Specify the name of the machine and the access credentials @@ -141,6 +171,13 @@ resource "azurerm_virtual_machine" "vm_linux_internal" { os_profile_linux_config { disable_password_authentication = false } + + # Add boot diagnostics to the machine. These will be added to the + # created storage acccount + boot_diagnostics { + enabled = true + storage_uri = "${azurerm_storage_account.sa.primary_blob_endpoint}" + } } resource "azurerm_virtual_machine" "vm_linux_external" { @@ -150,6 +187,10 @@ resource "azurerm_virtual_machine" "vm_linux_external" { network_interface_ids = ["${azurerm_network_interface.nic2.id}"] vm_size = "Standard_DS2_v2" + tags { + Description = "Externally facing Linux machine to be used as a web server" + } + # Configure machine with Ubuntu storage_image_reference { publisher = "Canonical" @@ -166,6 +207,15 @@ resource "azurerm_virtual_machine" "vm_linux_external" { create_option = "FromImage" } + # Create 1 data disk to be used for testing + storage_data_disk { + name = "linux-external-datadisk-1" + vhd_uri = "${azurerm_storage_account.sa.primary_blob_endpoint}${azurerm_storage_container.container.name}/linux-internal-datadisk-1.vhd" + disk_size_gb = 15 + create_option = "empty" + lun = 0 + } + # Specify the name of the machine and the access credentials os_profile { computer_name = "linux-external-1" @@ -174,6 +224,56 @@ resource "azurerm_virtual_machine" "vm_linux_external" { } os_profile_linux_config { - disable_password_authentication = false + disable_password_authentication = true + ssh_keys { + path = "/home/azure/.ssh/authorized_keys" + key_data = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDOxB7GqUxppqRBG5pB2fkkhlWkWUWmFjO3ZEc+VW70erOJWfUvhzBDDQziAOVKtNF2NsY0uyRJqwaP1idL0F7GDQtQl+HhkKW1gOCoTrNptJiYfIm05jTETRWObP0kGMPoAWlkWPBluUAI74B4nkvg7SKNpe36IZhuA8/kvVjxBfWy0r/b/dh+QEIb1eE8HfELAN8SrvrydT7My7g0YFT65V00A2HVa5X3oZaBXRKbmd5gZXBJXEbgHZqA9+NnIQkZXH0vkYYOQTANB8taVwjNVftpXzf2zEupONCYOOoIAep2tXuv2YmWuHr/Y5rCv2mK28ZVcM7W9UmwM0CMHZE7 azure@inspec.local" + } } } + +resource "azurerm_virtual_machine" "vm_windows_internal" { + name = "Windows-Internal-VM" + location = "${var.location}" + resource_group_name = "${azurerm_resource_group.rg.name}" + network_interface_ids = ["${azurerm_network_interface.nic3.id}"] + vm_size = "Standard_DS2_v2" + + # Configure machine with Ubuntu + storage_image_reference { + publisher = "MicrosoftWindowsServer" + offer = "WindowsServer" + sku = "2016-Datacenter" + version = "latest" + } + + # Create the OS disk + storage_os_disk { + name = "Windows-Internal-OSDisk-MD" + caching = "ReadWrite" + create_option = "FromImage" + managed_disk_type = "Standard_LRS" + } + + # Create 1 data disk to be used for testing + storage_data_disk { + name = "Windows-Internal-DataDisk-1-MD" + create_option = "Empty" + managed_disk_type = "Standard_LRS" + lun = 0 + disk_size_gb = "1024" + } + + # Specify the name of the machine and the access credentials + os_profile { + computer_name = "win-internal-1" + admin_username = "azure" + admin_password = "${var.admin_password}" + } + + os_profile_windows_config { + provision_vm_agent = true + } +} + + diff --git a/test/integration/azure/verify/controls/datadisks.rb b/test/integration/azure/verify/controls/datadisks.rb deleted file mode 100644 index 00af1ba11..000000000 --- a/test/integration/azure/verify/controls/datadisks.rb +++ /dev/null @@ -1,24 +0,0 @@ - -title 'Virtual Machine Data Disks' - -control 'azure-vm-datadisks-1.0' do - - impact 1.0 - title 'Ensure VM has 1 data disk and it is of the correct size' - - # Set the name of the vm and resource_group - vm_internal = 'Linux-Internal-VM' - resource_group_name = 'Inspec-Azure' - - # Ensure that the named machine has 1 data disk - describe azure_virtual_machine_datadisks(name: vm_internal, resource_group: resource_group_name) do - it { should have_data_disks } - its('count') { should eq 1 } - end - - # Ensure that the first data disk on the same machine size is greater than 10gb - describe azure_virtual_machine_datadisks(name: vm_internal, resource_group: resource_group_name).where { disk.zero? and size > 10 } do - its('entries') { should_not be_empty } - end - -end \ No newline at end of file diff --git a/test/integration/azure/verify/controls/generic_external_vm.rb b/test/integration/azure/verify/controls/generic_external_vm.rb new file mode 100644 index 000000000..7e0158da9 --- /dev/null +++ b/test/integration/azure/verify/controls/generic_external_vm.rb @@ -0,0 +1,47 @@ + +title 'External Virtual Machine Properties' + +control 'azure-generic-vm-linux-external-2.0' do + + impact 1.0 + title 'Ensure External VM was built with the correct Image and has the correct properties' + + # Ensure that the virtual machine has been created with the correct attributes + describe azure_generic_resource(group_name: 'Inspec-Azure', + name: 'Linux-External-VM') do + + its('location') { should cmp 'westeurope' } + + # check the storage profile for the machine + its('properties.storageProfile.imageReference.publisher') { should cmp 'Canonical' } + its('properties.storageProfile.imageReference.offer') { should cmp 'UbuntuServer' } + its('properties.storageProfile.imageReference.sku') { should cmp '16.04.0-LTS' } + + # Check the disk for the machine + its('properties.storageProfile.osDisk.osType') { should cmp 'Linux' } + its('properties.storageProfile.osDisk.createOption') { should cmp 'FromImage' } + its('properties.storageProfile.osDisk.name') { should cmp 'linux-external-osdisk' } + its('properties.storageProfile.osDisk.caching') { should cmp 'ReadWrite' } + + # Esnure that the machine has no data disks attached + its('properties.storageProfile.dataDisks.count') { should eq 1 } + + # Check the hardwareProfile + its('properties.hardwareProfile.vmSize') { should cmp 'Standard_DS2_v2' } + + # Check the network interfaces + its('properties.networkProfile.networkInterfaces.count') { should eq 1 } + + # Determine the authentication and OS type + its('properties.osProfile.computerName') { should eq 'linux-external-1' } + its('properties.osProfile.adminUsername') { should eq 'azure' } + its('properties.osProfile.linuxConfiguration.disablePasswordAuthentication') { should be true } + + # Check that the tags have been set properly + it { should have_tags } + its('tag_count') { should be 1 } + its('tags') { should include 'Description' } + its('Description_tag') { should match 'Externally facing' } + end + +end diff --git a/test/integration/azure/verify/controls/generic_external_vm_nic.rb b/test/integration/azure/verify/controls/generic_external_vm_nic.rb new file mode 100644 index 000000000..e9e2ca5d2 --- /dev/null +++ b/test/integration/azure/verify/controls/generic_external_vm_nic.rb @@ -0,0 +1,32 @@ +title 'External VM Network Interface Card' + +control 'azure-generic-vm-external-nic-2.0' do + + impact 1.0 + title 'Ensure that the NIC for the external VM is correctly setup' + + # Ensure that the virtual machine has been created with the correct attributes + describe azure_generic_resource(group_name: 'Inspec-Azure', + name: 'Inspec-NIC-2') do + + # There should be no tags + it { should_not have_tags } + its('tags.count') { should eq 0 } + + # The resources should be a network interface + its('type') { should cmp 'Microsoft.Network/networkInterfaces' } + + # It should have only 1 ipConfiguration + its('properties.ipConfigurations.count') { should eq 1 } + + # There should be no custom dns settings + its('properties.dnsSettings.dnsServers.count') { should eq 0 } + its('properties.dnsSettings.appliedDnsServers.count') { should eq 0 } + + # This NIC should be connected to the correct machine + its('properties.virtualMachine.id') { should match 'Linux-External-VM' } + + its('properties.enableAcceleratedNetworking') { should be false } + its('properties.enableIPForwarding') { should be false } + end +end \ No newline at end of file diff --git a/test/integration/azure/verify/controls/generic_internal_vm.rb b/test/integration/azure/verify/controls/generic_internal_vm.rb new file mode 100644 index 000000000..de4381fab --- /dev/null +++ b/test/integration/azure/verify/controls/generic_internal_vm.rb @@ -0,0 +1,47 @@ + +title 'Internal Virtual Machine Properties' + +control 'azure-generic-vm-linux-internal-2.0' do + + impact 1.0 + title 'Ensure Internal VM was built with the correct Image and has the correct properties' + + # Ensure that the virtual machine has been created with the correct attributes + describe azure_generic_resource(group_name: 'Inspec-Azure', + name: 'Linux-Internal-VM') do + + its('location') { should cmp 'westeurope' } + + # check the storage profile for the machine + its('properties.storageProfile.imageReference.publisher') { should cmp 'Canonical' } + its('properties.storageProfile.imageReference.offer') { should cmp 'UbuntuServer' } + its('properties.storageProfile.imageReference.sku') { should cmp '16.04.0-LTS' } + + # Check the disk for the machine + its('properties.storageProfile.osDisk.osType') { should cmp 'Linux' } + its('properties.storageProfile.osDisk.name') { should cmp 'Linux-Internal-OSDisk-MD' } + its('properties.storageProfile.osDisk.caching') { should cmp 'ReadWrite' } + + # This machine has been setup with a Managed Disk for the OSDisk, ensure that + # it is linked to the correct disk + its('properties.storageProfile.osDisk.managedDisk.id') { should match 'Linux-Internal-OSDisk-MD' } + + # Esnure that the machine has a data disk attached + its('properties.storageProfile.dataDisks.count') { should eq 0 } + + # Check the hardwareProfile + its('properties.hardwareProfile.vmSize') { should cmp 'Standard_DS2_v2' } + + # Check the network interfaces + its('properties.networkProfile.networkInterfaces.count') { should eq 1 } + + # Determine the authentication and OS type + its('properties.osProfile.computerName') { should eq 'linux-internal-1' } + its('properties.osProfile.adminUsername') { should eq 'azure' } + its('properties.osProfile.linuxConfiguration.disablePasswordAuthentication') { should be false } + + # There should be no tags on the machine + its('tags.count') { should eq 0 } + end + +end diff --git a/test/integration/azure/verify/controls/generic_internal_vm_nic.rb b/test/integration/azure/verify/controls/generic_internal_vm_nic.rb new file mode 100644 index 000000000..572d339dd --- /dev/null +++ b/test/integration/azure/verify/controls/generic_internal_vm_nic.rb @@ -0,0 +1,32 @@ +title 'Internal VM Network Interface Card' + +control 'azure-generic-vm-internal-nic-2.0' do + + impact 1.0 + title 'Ensure that the NIC for the internal VM is correctly setup' + + # Ensure that the virtual machine has been created with the correct attributes + describe azure_generic_resource(group_name: 'Inspec-Azure', + name: 'Inspec-NIC-1') do + + # There should be no tags + it { should_not have_tags } + its('tags.count') { should eq 0 } + + # The resources should be a network interface + its('type') { should cmp 'Microsoft.Network/networkInterfaces' } + + # It should have only 1 ipConfiguration + its('properties.ipConfigurations.count') { should eq 1 } + + # There should be no custom dns settings + its('properties.dnsSettings.dnsServers.count') { should eq 0 } + its('properties.dnsSettings.appliedDnsServers.count') { should eq 0 } + + # This NIC should be connected to the correct machine + its('properties.virtualMachine.id') { should match 'Linux-Internal-VM' } + + its('properties.enableAcceleratedNetworking') { should be false } + its('properties.enableIPForwarding') { should be false } + end +end \ No newline at end of file diff --git a/test/integration/azure/verify/controls/generic_linux_vm_managed_os_disk.rb b/test/integration/azure/verify/controls/generic_linux_vm_managed_os_disk.rb new file mode 100644 index 000000000..843dd24bf --- /dev/null +++ b/test/integration/azure/verify/controls/generic_linux_vm_managed_os_disk.rb @@ -0,0 +1,32 @@ +title 'Managed Disk' + +control 'azure-generic-managed-os-disk-1.0' do + + impact 1.0 + title 'A managed disk was created for on the of machines, ensure that it is configured correctly' + + # Ensure that the virtual machine has been created with the correct attributes + describe azure_generic_resource(group_name: 'Inspec-Azure', name: 'Linux-Internal-OSDisk-MD') do + + # Ensure that it is attached, or 'managedBy' the correct machine + its('managed_by') { should match 'Linux-Internal-VM' } + + # Check some of the properties + its('properties.osType') { should cmp 'Linux' } + + # This disk should have been created from an image + # Ensure that it is the correct one + its('properties.creationData.createOption') { should eq 'FromImage' } + + # This disk should be an ubuntu image + its('properties.creationData.imageReference.id') { should match 'Canonical' } + its('properties.creationData.imageReference.id') { should match 'UbuntuServer' } + its('properties.creationData.imageReference.id') { should match '16.04.0-LTS' } + + its('properties.diskSizeGB') { should be > 25 } + + # ensure the disk is attached + its('properties.diskState') { should cmp 'Attached' } + end + +end \ No newline at end of file diff --git a/test/integration/azure/verify/controls/generic_network_security_group.rb b/test/integration/azure/verify/controls/generic_network_security_group.rb new file mode 100644 index 000000000..f67e1e829 --- /dev/null +++ b/test/integration/azure/verify/controls/generic_network_security_group.rb @@ -0,0 +1,21 @@ +title 'Network Security Group Properties' + +control 'azure-generic-network-security-group-1.0' do + + impact 1.0 + title 'Ensure that the NSG has been setup as expected' + + describe azure_generic_resource(group_name: 'Inspec-Azure', + name: 'Inspec-NSG') do + + # Check that the NSG is in the correct location + its('location') { should cmp 'westeurope' } + + # It should not have any tags + it { should_not have_tags } + + # It has been provisionned successfully + its('properties.provisioningState') { should eq 'Succeeded' } + + end +end \ No newline at end of file diff --git a/test/integration/azure/verify/controls/generic_public_ip_address.rb b/test/integration/azure/verify/controls/generic_public_ip_address.rb new file mode 100644 index 000000000..01bc46c4e --- /dev/null +++ b/test/integration/azure/verify/controls/generic_public_ip_address.rb @@ -0,0 +1,26 @@ +title 'Public IP Address Properties' + +control 'azure-generic-public-ip-address-1.0' do + + impact 1.0 + title 'Ensure that the Public IP Address has been configured correctly' + + describe azure_generic_resource(group_name: 'Inspec-Azure', name: 'Inspec-PublicIP-1') do + + its('type') { should cmp 'Microsoft.Network/publicIPAddresses' } + its('location') { should cmp 'westeurope' } + + # There should be no tags + it { should_not have_tags } + + its('properties.provisioningState') { should cmp 'Succeeded' } + + # The IP address should be dynamically assigned + its('properties.publicIPAllocationMethod') { should cmp 'Dynamic' } + + its('properties.dnsSettings.domainNameLabel') { should match 'linux-external-1' } + + # Ensure that this Public IP is assigned to the Nic that is assigned to the external vm + its('properties.ipConfiguration.id') { should match 'Inspec-NIC-2' } + end +end \ No newline at end of file diff --git a/test/integration/azure/verify/controls/generic_resources.rb b/test/integration/azure/verify/controls/generic_resources.rb new file mode 100644 index 000000000..d3e78bdc5 --- /dev/null +++ b/test/integration/azure/verify/controls/generic_resources.rb @@ -0,0 +1,26 @@ + +title 'Check Azure Resources' + +control 'azure-generic-resource-group-resources-1.0' do + + impact 1.0 + title 'Check that the resource group has the correct resources' + + # Ensure that the expected resources have been deployed + describe azure_generic_resource(group_name: 'Inspec-Azure') do + its('total') { should eq 13 } + its('Microsoft.Compute/virtualMachines') { should eq 3 } + its('Microsoft.Network/networkInterfaces') { should eq 3 } + its('Microsoft.Network/publicIPAddresses') { should eq 1 } + its('Microsoft.Network/networkSecurityGroups') { should eq 1 } + its('Microsoft.Storage/storageAccounts') { should eq 1 } + its('Microsoft.Network/virtualNetworks') { should eq 1 } + its('Microsoft.Compute/disks') { should eq 3 } + + # Check the tags + it { should have_tags } + its('tag_count') { should be 1 } + its('tags') { should include 'CreatedBy' } + its('CreatedBy_tag') { should cmp 'Inspec Azure Integration Tests' } + end +end \ No newline at end of file diff --git a/test/integration/azure/verify/controls/generic_storage_account.rb b/test/integration/azure/verify/controls/generic_storage_account.rb new file mode 100644 index 000000000..67e74c313 --- /dev/null +++ b/test/integration/azure/verify/controls/generic_storage_account.rb @@ -0,0 +1,34 @@ + +title 'Check Azure Resources' + +control 'azure-generic-storage-account-2.0' do + + impact 1.0 + title 'Check the storage account' + + # Get the storage account by type, this is because in the tests + # the storage account name is randomly generated so it cannot be known to perform + # these inspec tests + describe azure_generic_resource(group_name: 'Inspec-Azure', + type: 'Microsoft.Storage/storageAccounts') do + its('total') { should be 1 } + + # There should be no tags + it { should_not have_tags } + + its('properties.encryption.keySource') { should cmp 'Microsoft.Storage' } + + # Check that the blob and file services are enabled + its('properties.encryption.services.blob.enabled') { should be true } + its('properties.encryption.services.file.enabled') { should be true } + its('properties.encryption.keySource') { should cmp "Microsoft.Storage" } + + its('properties.provisioningState') { should cmp "Succeeded" } + + its("properties.primaryLocation") { should cmp "westeurope" } + its("properties.statusOfPrimary") { should cmp "available" } + + # Determine if it only supports HTTPS traffic + its('properties.supportsHttpsTrafficOnly') { should be false } + end +end \ No newline at end of file diff --git a/test/integration/azure/verify/controls/generic_virtual_network.rb b/test/integration/azure/verify/controls/generic_virtual_network.rb new file mode 100644 index 000000000..239f1a2b6 --- /dev/null +++ b/test/integration/azure/verify/controls/generic_virtual_network.rb @@ -0,0 +1,27 @@ +title 'Virtual Network Properties' + +control 'azure-generic-virtual-network-2.0' do + + impact 1.0 + title 'Ensure that the virtual network has been created with the correct address space and subnet' + + describe azure_generic_resource(group_name: 'Inspec-Azure', + name: 'Inspec-VNet') do + + # Check that this named resource is indeed a virtual network + its('type') { should cmp 'Microsoft.Network/virtualNetworks' } + its('location') { should cmp 'westeurope' } + + # There should be no tags + it { should_not have_tags } + + # Ensure that the address prefix for the VNet is correct + # This will return an array so the `include` matcher needs to be used to + # see if the specified address prefix is present + its('properties.addressSpace.addressPrefixes') { should include '10.1.1.0/24'} + + # There should be one subnet + its('properties.subnets.count') { should eq 1 } + + end +end diff --git a/test/integration/azure/verify/controls/generic_windows_internal_vm.rb b/test/integration/azure/verify/controls/generic_windows_internal_vm.rb new file mode 100644 index 000000000..a04f8055f --- /dev/null +++ b/test/integration/azure/verify/controls/generic_windows_internal_vm.rb @@ -0,0 +1,49 @@ + +title 'Internal Virtual Machine Properties' + +control 'azure-generic-vm-windows-internal-2.0' do + + impact 1.0 + title 'Ensure Windows Internal VM was built with the correct Image and has the correct properties' + + # Ensure that the virtual machine has been created with the correct attributes + describe azure_generic_resource(group_name: 'Inspec-Azure', + name: 'Windows-Internal-VM') do + + its('location') { should cmp 'westeurope' } + + # check the storage profile for the machine + its('properties.storageProfile.imageReference.publisher') { should cmp 'MicrosoftWindowsServer' } + its('properties.storageProfile.imageReference.offer') { should cmp 'WindowsServer' } + its('properties.storageProfile.imageReference.sku') { should cmp '2016-Datacenter' } + + # Check the disk for the machine + its('properties.storageProfile.osDisk.osType') { should cmp 'Windows' } + its('properties.storageProfile.osDisk.name') { should cmp 'Windows-Internal-OSDisk-MD' } + its('properties.storageProfile.osDisk.caching') { should cmp 'ReadWrite' } + + # This machine has been setup with a Managed Disk for the OSDisk, ensure that + # it is linked to the correct disk + its('properties.storageProfile.osDisk.managedDisk.id') { should match 'Windows-Internal-OSDisk-MD' } + its('properties.storageProfile.osDisk.managedDisk.storageAccountType') { should cmp 'Standard_LRS' } + + # Esnure that the machine has a data disk attached + its('properties.storageProfile.dataDisks.count') { should eq 1 } + + # Check the hardwareProfile + its('properties.hardwareProfile.vmSize') { should cmp 'Standard_DS2_v2' } + + # Check the network interfaces + its('properties.networkProfile.networkInterfaces.count') { should eq 1 } + + # Determine the authentication and OS type + its('properties.osProfile.computerName') { should eq 'win-internal-1' } + its('properties.osProfile.adminUsername') { should eq 'azure' } + its('properties.osProfile.windowsConfiguration.provisionVMAgent') { should be true } + its('properties.osProfile.windowsConfiguration.enableAutomaticUpdates') { should be false } + + # There should be no tags + it { should_not have_tags } + end + +end diff --git a/test/integration/azure/verify/controls/generic_windows_internal_vm_nic.rb b/test/integration/azure/verify/controls/generic_windows_internal_vm_nic.rb new file mode 100644 index 000000000..ac57bb5fd --- /dev/null +++ b/test/integration/azure/verify/controls/generic_windows_internal_vm_nic.rb @@ -0,0 +1,32 @@ +title 'Windows Internal VM Network Interface Card' + +control 'azure-generic-vm-windows-internal-nic-1.0' do + + impact 1.0 + title 'Ensure that the NIC for the Windows internal VM is correctly setup' + + # Ensure that the virtual machine has been created with the correct attributes + describe azure_generic_resource(group_name: 'Inspec-Azure', + name: 'Inspec-NIC-3') do + + # There should be no tags + it { should_not have_tags } + its('tags.count') { should eq 0 } + + # The resources should be a network interface + its('type') { should cmp 'Microsoft.Network/networkInterfaces' } + + # It should have only 1 ipConfiguration + its('properties.ipConfigurations.count') { should eq 1 } + + # There should be no custom dns settings + its('properties.dnsSettings.dnsServers.count') { should eq 0 } + its('properties.dnsSettings.appliedDnsServers.count') { should eq 0 } + + # This NIC should be connected to the correct machine + its('properties.virtualMachine.id') { should match 'Windows-Internal-VM' } + + its('properties.enableAcceleratedNetworking') { should be false } + its('properties.enableIPForwarding') { should be false } + end +end \ No newline at end of file diff --git a/test/integration/azure/verify/controls/resource_group.rb b/test/integration/azure/verify/controls/resource_group.rb new file mode 100644 index 000000000..750bffd80 --- /dev/null +++ b/test/integration/azure/verify/controls/resource_group.rb @@ -0,0 +1,59 @@ +title 'Resource Group Type Counts' + +control 'azure-resource-group-1.0' do + impact 1.0 + title 'Ensure that the specified resource group has the correct number of Azure resources and metadata' + + # Obtain counts for all resources in the resource group + describe azure_resource_group(name: 'Inspec-Azure') do + # Ensure that the name is correct - this is a little superfluous as this was used to select the resource + its('name') { should cmp 'Inspec-Azure' } + + # Where in Azure is the resource group located + its('location') { should cmp 'westeurope' } + + # Was the resource group provisionned successfully? + its('provisioning_state') { should cmp 'Succeeded' } + + # Make sure that the resource group has tags on it + it { should have_tags } + its('tag_count') { should be 1 } + its('tags') { should include 'CreatedBy' } + its('CreatedBy_tag') { should cmp 'Inspec Azure Integration Tests' } + + # How many resources are in the resource group in total + its('total') { should eq 13 } + + # Does the RG have virtual machines and how many + it { should have_vms } + its('vm_count') { should eq 3 } + + # Does the RG have network cards and how many + it { should have_nics } + its('nic_count') { should eq 3 } + + # Does the RG have any public ip addresses and how many + it { should have_public_ips } + its('public_ip_count') { should eq 1 } + + # Does the RG have network security groups and how many + it { should have_nsgs } + its('nsg_count') { should eq 1 } + + # Does the RG have storage accounts and how many + it { should have_sas } + its('sa_count') { should eq 1 } + + # Does the RG have virtual networks and how many + it { should have_vnets } + its('vnet_count') { should eq 1 } + + # Does the RG have managed disks and how many + it { should have_managed_disks } + its('managed_disk_count') { should eq 3 } + + # Does the RG have managed disk images + it { should_not have_managed_disk_images } + its('managed_disk_image_count') { should eq 0 } + end +end diff --git a/test/integration/azure/verify/controls/resources.rb b/test/integration/azure/verify/controls/resources.rb deleted file mode 100644 index 9417f5443..000000000 --- a/test/integration/azure/verify/controls/resources.rb +++ /dev/null @@ -1,19 +0,0 @@ - -title 'Check Azure Resources' - -control 'azure-resources-1.0' do - - impact 1.0 - title 'Check that the resource group has the correct resources' - - # Ensure that the expected resources have been deployed - describe azure_resource_group(name: 'Inspec-Azure') do - its('total') { should eq 7 } - its('vm_count') { should eq 2 } - its('nic_count') { should eq 2 } - its('public_ip_count') { should eq 1 } - its('sa_count') { should eq 1 } - its('vnet_count') { should eq 1 } - end - -end \ No newline at end of file diff --git a/test/integration/azure/verify/controls/virtual_machine_external_vm.rb b/test/integration/azure/verify/controls/virtual_machine_external_vm.rb new file mode 100644 index 000000000..35e29cadd --- /dev/null +++ b/test/integration/azure/verify/controls/virtual_machine_external_vm.rb @@ -0,0 +1,69 @@ +title 'Internal Virtual Machine Properties' + +control 'azure-virtual-machine-vm-external-2.0' do + + impact 1.0 + title 'Ensure External VM was built with the correct Image and has the correct properties' + + # Ensure that the virtual machine has been created with the correct attributes + describe azure_virtual_machine(group_name: 'Inspec-Azure', + name: 'Linux-External-VM') do + + # Ensure that the location of the resource is correct + its('location') { should cmp 'westeurope' } + + # Check the type. However this should always be this type because the + # resource azure_virtual_machine has been used which forces this type + its('type') { should cmp 'Microsoft.Compute/virtualMachines' } + + # Check the name, although this has beeb specified in the options + its('name') { should cmp 'Linux-External-VM' } + + # Ensure that tags have been set + it { should have_tags } + its('tags') { should include 'Description' } + its('Description_tag') { should match 'Externally facing Linux' } + + # Ensure that the machine has been created from the correct image + its('publisher') { should cmp 'Canonical' } + its('offer') { should cmp 'UbuntuServer' } + its('sku') { should cmp '16.04.0-LTS' } + + # Check the type of the machine and the disk that it is using + its('os_type') { should cmp 'Linux' } + its('os_disk_name') { should cmp 'linux-external-osdisk' } + its('caching') { should cmp 'ReadWrite' } + its('disk_size_gb') { should be >= 25 } + its('create_option') { should cmp 'FromImage' } + + # The OS disk should be a managed disk + it { should_not have_managed_osdisk } + + # Ensure that the machine is of the correct size + its('vm_size') { should cmp 'Standard_DS2_v2'} + + # Check the admin username for the machine and the hostname + its('admin_username') { should cmp 'azure' } + its('computer_name') { should cmp 'linux-external-1' } + + # Check that the machine has a NIC and that the correct one is connected + it { should have_nics } + its('nic_count') { should eq 1 } + its('connected_nics') { should include 'Inspec-NIC-2' } + + # This machine should not have any data disks + it { should have_data_disks } + + # Password authentication should be enabled + it { should_not have_password_authentication } + its('password_authentication?') { should be false } + + # No ssh keys should be assigned to the machine + it { should have_ssh_keys } + its('ssh_keys?') { should be true } + its('ssh_keys') { should include /azure@inspec.local/ } + + # The machine should have boot diagnostics enabled + it { should_not have_boot_diagnostics } + end +end \ No newline at end of file diff --git a/test/integration/azure/verify/controls/virtual_machine_internal_vm.rb b/test/integration/azure/verify/controls/virtual_machine_internal_vm.rb new file mode 100644 index 000000000..e3865c23b --- /dev/null +++ b/test/integration/azure/verify/controls/virtual_machine_internal_vm.rb @@ -0,0 +1,67 @@ +title 'Internal Virtual Machine Properties' + +control 'azure-virtual-machine-vm-internal-2.0' do + + impact 1.0 + title 'Ensure Internal VM was built with the correct Image and has the correct properties' + + # Ensure that the virtual machine has been created with the correct attributes + describe azure_virtual_machine(group_name: 'Inspec-Azure', + name: 'Linux-Internal-VM') do + + # Ensure that the location of the resource is correct + its('location') { should cmp 'westeurope' } + + # Check the type. However this should always be this type because the + # resource azure_virtual_machine has been used which forces this type + its('type') { should cmp 'Microsoft.Compute/virtualMachines' } + + # Check the name, although this has beeb specified in the options + its('name') { should cmp 'Linux-Internal-VM' } + + # Make sure there are no tags on the machine + it { should_not have_tags } + + # Ensure that the machine has been created from the correct image + its('publisher') { should cmp 'Canonical' } + its('offer') { should cmp 'UbuntuServer' } + its('sku') { should cmp '16.04.0-LTS' } + + # Check the type of the machine and the disk that it is using + its('os_type') { should cmp 'Linux' } + its('os_disk_name') { should cmp 'Linux-Internal-OSDisk-MD' } + its('caching') { should cmp 'ReadWrite' } + its('disk_size_gb') { should be >= 30 } + its('create_option') { should cmp 'FromImage' } + its('storage_account_type') { should cmp 'Standard_LRS' } + + # The OS disk should be a managed disk + it { should have_managed_osdisk } + + # Ensure that the machine is of the correct size + its('vm_size') { should cmp 'Standard_DS2_v2'} + + # Check the admin username for the machine and the hostname + its('admin_username') { should cmp 'azure' } + its('computer_name') { should cmp 'linux-internal-1' } + + # Check that the machine has a NIC and that the correct one is connected + it { should have_nics } + its('nic_count') { should eq 1 } + its('connected_nics') { should include 'Inspec-NIC-1' } + + # This machine should not have any data disks + it { should_not have_data_disks } + + # Password authentication should be enabled + it { should have_password_authentication } + its('password_authentication?') { should be true } + + # No ssh keys should be assigned to the machine + it { should_not have_ssh_keys } + its('ssh_keys?') { should be false } + + # The machine should have boot diagnostics enabled + it { should have_boot_diagnostics } + end +end \ No newline at end of file diff --git a/test/integration/azure/verify/controls/virtual_machine_linux_external_vm_datadisk.rb b/test/integration/azure/verify/controls/virtual_machine_linux_external_vm_datadisk.rb new file mode 100644 index 000000000..1bdc0e2af --- /dev/null +++ b/test/integration/azure/verify/controls/virtual_machine_linux_external_vm_datadisk.rb @@ -0,0 +1,20 @@ +title 'Windows Internal Machine Data Disks' + +control 'azure-virtual-machine-vm-linux-external-datadisks-1.0' do + + # Select the first data disk of the Windows Internal Machine + describe azure_virtual_machine_data_disk(group_name: 'Inspec-Azure', name: 'Linux-External-VM').where(number: 1) do + its('lun') { should cmp 0 } + + # Ensure that the the name of the managed disk is correct + its('name') { should cmp 'linux-external-datadisk-1' } + + # Ensure that its size, in GB, is correct + its('size') { should cmp >= 15 } + its('caching') { should cmp 'None' } + its('create_option') { should cmp 'Empty' } + + # This should be a managed disk + its('is_managed_disk?') { should cmp false } + end +end diff --git a/test/integration/azure/verify/controls/virtual_machine_windows_internal_vm.rb b/test/integration/azure/verify/controls/virtual_machine_windows_internal_vm.rb new file mode 100644 index 000000000..031f91310 --- /dev/null +++ b/test/integration/azure/verify/controls/virtual_machine_windows_internal_vm.rb @@ -0,0 +1,71 @@ +title 'Windows Internal Virtual Machine Properties' + +control 'azure-virtual-machine-vm-windows-internal-1.0' do + + impact 1.0 + title 'Ensure Internal VM was built with the correct Image and has the correct properties' + + # Ensure that the virtual machine has been created with the correct attributes + describe azure_virtual_machine(group_name: 'Inspec-Azure', + name: 'Windows-Internal-VM') do + + # Ensure that the location of the resource is correct + its('location') { should cmp 'westeurope' } + + # Check the type. However this should always be this type because the + # resource azure_virtual_machine has been used which forces this type + its('type') { should cmp 'Microsoft.Compute/virtualMachines' } + + # Check the name, although this has beeb specified in the options + its('name') { should cmp 'Windows-Internal-VM' } + + # There should be no tags + it { should_not have_tags } + + # Ensure that the machine has been created from the correct image + its('publisher') { should cmp 'MicrosoftWindowsServer' } + its('offer') { should cmp 'WindowsServer' } + its('sku') { should cmp '2016-Datacenter' } + + # Check the type of the machine and the disk that it is using + its('os_type') { should cmp 'Windows' } + its('os_disk_name') { should cmp 'Windows-Internal-OSDisk-MD' } + its('caching') { should cmp 'ReadWrite' } + its('disk_size_gb') { should be >= 30 } + its('create_option') { should cmp 'FromImage' } + its('storage_account_type') { should cmp 'Standard_LRS' } + + # The OS disk should be a managed disk + it { should have_managed_osdisk } + + # Ensure that the machine is of the correct size + its('vm_size') { should cmp 'Standard_DS2_v2'} + + # Check the admin username for the machine and the hostname + its('admin_username') { should cmp 'azure' } + its('computer_name') { should cmp 'win-internal-1' } + + # Check that the machine has a NIC and that the correct one is connected + it { should have_nics } + its('nic_count') { should eq 1 } + its('connected_nics') { should include 'Inspec-NIC-3' } + + # This machine should not have any data disks + it { should have_data_disks } + + # Password authentication should be enabled + it { should have_password_authentication } + its('password_authentication?') { should be true } + + # No ssh keys should be assigned to the machine + it { should_not have_ssh_keys } + its('ssh_keys?') { should be false } + + # The machine should have boot diagnostics enabled + it { should_not have_boot_diagnostics } + + # Ensure that the agent has been provisionned and automatic updates are disabled + it { should have_provision_vmagent } + it { should_not have_automatic_agent_update } + end +end \ No newline at end of file diff --git a/test/integration/azure/verify/controls/virtual_machine_windows_internal_vm_datadisk.rb b/test/integration/azure/verify/controls/virtual_machine_windows_internal_vm_datadisk.rb new file mode 100644 index 000000000..ef9c11105 --- /dev/null +++ b/test/integration/azure/verify/controls/virtual_machine_windows_internal_vm_datadisk.rb @@ -0,0 +1,25 @@ +title 'Windows Internal Machine Data Disks' + +control 'azure-virtual-machine-vm-windows-internal-datadisks-1.0' do + + # Select the first data disk of the Windows Internal Machine + describe azure_virtual_machine_data_disk(group_name: 'Inspec-Azure', name: 'Windows-Internal-VM').where(number: 1) do + its('lun') { should cmp 0 } + + # Ensure that the the name of the managed disk is correct + its('name') { should cmp 'Windows-Internal-DataDisk-1-MD' } + + # Ensure that its size, in GB, is correct + its('size') { should cmp >= 15 } + its('caching') { should cmp 'None' } + its('create_option') { should cmp 'Empty' } + + # This should be a managed disk + its('is_managed_disk?') { should cmp true } + + # If the disk is a managed disk then the following values should be available + # The storage account type and the resource group that the managed disk is in + its('storage_account_type') { should cmp 'Standard_LRS' } + its('resource_group') { should cmp 'Inspec-Azure' } + end +end diff --git a/test/integration/azure/verify/controls/vm.rb b/test/integration/azure/verify/controls/vm.rb deleted file mode 100644 index 99d3614d5..000000000 --- a/test/integration/azure/verify/controls/vm.rb +++ /dev/null @@ -1,37 +0,0 @@ - -title 'Virtual Machine Properties' - -control 'azure-vm-internal-1.0' do - - impact 1.0 - title 'Ensure Internal VM was built with the correct Image and has the correct properties' - - # Ensure that the virtual machine has been created with the correct attributes - describe azure_virtual_machine(name: 'Linux-Internal-VM', resource_group: 'Inspec-Azure') do - its('sku') { should eq '16.04.0-LTS' } - its('publisher') { should eq 'Canonical' } - its('offer') { should eq 'UbuntuServer' } - its('size') { should eq 'Standard_DS2_v2' } - its('location') { should eq 'westeurope' } - it { should_not have_boot_diagnostics } - its('nic_count') { should eq 1 } - its('admin_username') { should eq 'azure' } - its('password_authentication?') { should be true } - its('ssh_key_count') { should eq 0 } - its('os_type') { should eq 'Linux' } - its('private_ipaddresses') { should include '10.1.1.10' } - it { should_not have_public_ipaddress } - end - -end - -control 'azure-vm-external-1.0' do - - impact 1.0 - title 'Ensure External VM has external access' - - describe azure_virtual_machine(name: 'Linux-External-VM', resource_group: 'Inspec-Azure') do - it { should have_public_ipaddress } - its('domain_name_label') { should include 'linux-external-1' } - end -end From e5dc4a1c2946775f1b386f88b0fe6bbe7c765351 Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Wed, 13 Dec 2017 22:34:52 -0500 Subject: [PATCH 137/206] Add skeleton of aws_iam_role Signed-off-by: Clinton Wolfe --- docs/resources/aws_iam_role.md | 54 +++++++++ libraries/aws_iam_role.rb | 49 +++++++++ .../verify/controls/aws_iam_role.rb | 6 + test/unit/resources/aws_iam_role_test.rb | 103 ++++++++++++++++++ 4 files changed, 212 insertions(+) create mode 100644 docs/resources/aws_iam_role.md create mode 100644 libraries/aws_iam_role.rb create mode 100644 test/integration/verify/controls/aws_iam_role.rb create mode 100644 test/unit/resources/aws_iam_role_test.rb diff --git a/docs/resources/aws_iam_role.md b/docs/resources/aws_iam_role.md new file mode 100644 index 000000000..57756d4a9 --- /dev/null +++ b/docs/resources/aws_iam_role.md @@ -0,0 +1,54 @@ +--- +title: About the aws_iam_role Resource +--- + +# aws_iam_role + +Use the `aws_iam_role` InSpec audit resource to test properties of a single IAM Role. A Role is a collection of permissions that may be temporarily assumed by a user, EC2 Instance, Lambda Function, or certain other resources. + +
+ +## Syntax + + # Ensure that a certain role exists by name + describe aws_iam_role('my-role') do + it { should exist } + end + +## Resource Parameters + +### role_name + +This resource expects a single parameter that uniquely identifes the IAM Role, the Role Name. You may pass it as a string, or as the value in a hash: + + describe aws_iam_role('my-role') do + it { should exist } + end + # Same + describe aws_iam_role(role_name: 'my-role') do + it { should exist } + end + +## Matchers + +### exist + +Indicates that the Role Name provided was found. Use should_not to test for IAM Roles that should not exist. + + describe aws_iam_role('should-be-there') do + it { should exist } + end + + describe aws_iam_role('should-not-be-there') do + it { should_not exist } + end + +## Properties + +### description + +A textual description of the IAM Role. + + describe aws_iam_role('my-role') do + its('description') { should be('Our most important Role')} + end diff --git a/libraries/aws_iam_role.rb b/libraries/aws_iam_role.rb new file mode 100644 index 000000000..a34724fbd --- /dev/null +++ b/libraries/aws_iam_role.rb @@ -0,0 +1,49 @@ +class AwsIamRole < Inspec.resource(1) + name 'aws_iam_role' + desc 'Verifies settings for an IAM Role' + example " + describe aws_iam_role('my-role') do + it { should exist } + end + " + + include AwsResourceMixin + attr_reader :role_name, :description + + private + + def validate_params(raw_params) + validated_params = check_resource_param_names( + raw_params: raw_params, + allowed_params: [:role_name], + allowed_scalar_name: :role_name, + allowed_scalar_type: String, + ) + if validated_params.empty? + raise ArgumentError, 'You must provide a role_name to aws_iam_role.' + end + validated_params + end + + def fetch_from_aws + role_info = nil + begin + role_info = AwsIamRole::BackendFactory.create.get_role(role_name: role_name) + rescue Aws::IAM::Errors::NoSuchEntity + @exists = false + return + end + @exists = true + @description = role_info.role.description + end + + # Uses the SDK API to really talk to AWS + class Backend + class AwsClientApi + BackendFactory.set_default_backend(self) + def get_role(query) + AWSConnection.new.iam_client.get_role(query) + end + end + end +end diff --git a/test/integration/verify/controls/aws_iam_role.rb b/test/integration/verify/controls/aws_iam_role.rb new file mode 100644 index 000000000..b77585b4f --- /dev/null +++ b/test/integration/verify/controls/aws_iam_role.rb @@ -0,0 +1,6 @@ +control 'AWS IAM Role search for default AWS role' do + # Every AWS account comes with this one by default + describe aws_iam_role('AWSServiceRoleForOrganizations') do + it { should exist } + end +end \ No newline at end of file diff --git a/test/unit/resources/aws_iam_role_test.rb b/test/unit/resources/aws_iam_role_test.rb new file mode 100644 index 000000000..418328dc4 --- /dev/null +++ b/test/unit/resources/aws_iam_role_test.rb @@ -0,0 +1,103 @@ +require 'helper' +require 'aws_iam_role' + +# MIRB = MockIamRoleBackend +# Abbreviation not used outside this file + +#=============================================================================# +# Constructor Tests +#=============================================================================# +class AwsIamRoleConstructorTest < Minitest::Test + def setup + AwsIamRole::BackendFactory.select(AwsMIRB::Basic) + end + + def test_constructor_some_args_required + assert_raises(ArgumentError) { AwsIamRole.new } + end + + def test_constructor_accepts_scalar_role_name + AwsIamRole.new('alpha') + end + + def test_constructor_accepts_role_name_as_hash + AwsIamRole.new(role_name: 'alpha') + end + + def test_constructor_rejects_unrecognized_resource_params + assert_raises(ArgumentError) { AwsIamRole.new(beep: 'boop') } + end +end + +#=============================================================================# +# Search / Recall +#=============================================================================# +class AwsIamRoleRecallTest < Minitest::Test + # No setup here - each test needs to explicitly declare + # what they want from the backend. + + def test_recall_no_match_is_no_exception + AwsIamRole::BackendFactory.select(AwsMIRB::Miss) + refute AwsIamRole.new('nonesuch').exists? + end + + def test_recall_match_single_result_works + AwsIamRole::BackendFactory.select(AwsMIRB::Basic) + assert AwsIamRole.new('alpha').exists? + end +end + + +#=============================================================================# +# Properties +#=============================================================================# + +class AwsIamRolePropertiesTest < Minitest::Test + def setup + AwsIamRole::BackendFactory.select(AwsMIRB::Basic) + end + + #--------------------------------------- + # description + #--------------------------------------- + def test_property_description + assert_equal('alpha role', AwsIamRole.new('alpha').description) + end + + def test_prop_conf_sub_count_zero + assert_empty(AwsIamRole.new('beta').description) + end +end + + +#=============================================================================# +# Test Fixtures +#=============================================================================# +module AwsMIRB + class Miss + def get_role(query) + raise Aws::IAM::Errors::NoSuchEntity.new('Nope', 'Nope') + end + end + + class Basic + def get_role(query) + fixtures = { + 'alpha' => OpenStruct.new({ + role_name: 'alpha', + description: 'alpha role', + }), + 'beta' => OpenStruct.new({ + role_name: 'beta', + description: '', + }), + } + unless fixtures.key?(query[:role_name]) + raise Aws::IAM::Errors::NoSuchEntity.new('Nope', 'Nope') + end + OpenStruct.new({ + role: fixtures[query[:role_name]] + }) + end + end +end \ No newline at end of file From 4229974e7dead53ffb83e14802d4d1d9e50c0803 Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Wed, 13 Dec 2017 22:36:23 -0500 Subject: [PATCH 138/206] Skeleton resource for aws_ec2_security_groups Signed-off-by: Clinton Wolfe --- docs/resources/aws_ec2_security_groups.md | 81 ++++++++++++++ libraries/aws_ec2_security_groups.rb | 88 +++++++++++++++ test/integration/build/ec2.tf | 21 ++++ .../controls/aws_ec2_security_groups.rb | 43 +++++++ .../resources/aws_ec2_security_groups_test.rb | 105 ++++++++++++++++++ 5 files changed, 338 insertions(+) create mode 100644 docs/resources/aws_ec2_security_groups.md create mode 100644 libraries/aws_ec2_security_groups.rb create mode 100644 test/integration/build/ec2.tf create mode 100644 test/integration/verify/controls/aws_ec2_security_groups.rb create mode 100644 test/unit/resources/aws_ec2_security_groups_test.rb diff --git a/docs/resources/aws_ec2_security_groups.md b/docs/resources/aws_ec2_security_groups.md new file mode 100644 index 000000000..ed3fa6496 --- /dev/null +++ b/docs/resources/aws_ec2_security_groups.md @@ -0,0 +1,81 @@ +--- +title: About the aws_ec2_security_groups Resource +--- + +# aws_ec2_security_groups + +Use the `aws_ec2_security_groups` InSpec audit resource to test properties of some or all security groups. + +Security groups are a networking construct which contain ingress and egress rules for network communications. Security groups may be attached to EC2 instances, as well as certain other AWS resources. Along with Network Access Control Lists, Security Groups are one of the two main mechanisms of enforcing network-level security. + +
+ +## Syntax + +An `aws_ec2_security_groups` resource block uses an optional filter to select a group of security groups and then tests that group. + + # Verify you have more than the default security group + describe aws_ec2_security_groups do + its('count') { should be > 1 } + end + +
+ +## Examples + +The following examples show how to use this InSpec audit resource. + +As this is the initial release of `aws_ec2_security_groups`, its limited functionality precludes examples. + +
+ +## Matchers + +### exists + +The control will pass if the filter returns at least one result. Use should_not if you expect zero matches. + + # You will always have at least one SG, the VPC default SG + describe aws_ec2_security_groups + it { should exist } + end + +## Filter Criteria + +### vpc_id + +A string identifying the VPC which contains the security group. + + # Look for a particular security group in just one VPC + describe aws_ec2_security_groups.where( vpc_id: 'vpc-12345678') do + its('group_ids') { should include('sg-abcdef12')} + end + +### group_name + +A string identifying a group. Since groups are contained in VPCs, group names are unique within the AWS account, but not across VPCs. + + # Examine the default security group in all VPCs + describe aws_ec2_security_groups.where( group_name: 'default') do + it { should exist } + end + + +## Properties + +### group_ids + +Provides a list of all security group IDs matched. + + describe aws_ec2_security_groups do + its('group_ids') { should include('sg-12345678') } + end + +### entries + +Provides access to the raw results of the query. This can be useful for checking counts and other advanced operations. + + # Allow at most 100 security groups on the account + describe aws_ec2_security_groups do + its('entries.count') { should be <= 100} + end diff --git a/libraries/aws_ec2_security_groups.rb b/libraries/aws_ec2_security_groups.rb new file mode 100644 index 000000000..74878432e --- /dev/null +++ b/libraries/aws_ec2_security_groups.rb @@ -0,0 +1,88 @@ +class AwsEc2SecurityGroups < Inspec.resource(1) + name 'aws_ec2_security_groups' + desc 'Verifies settings for AWS Security Groups in bulk' + example ' + describe aws_ec2_security_groups do + it { should exist } + end + ' + + # Constructor. Args are reserved for row fetch filtering. + def initialize(raw_criteria = {}) + validated_criteria = validate_filter_criteria(raw_criteria) + fetch_from_backend(validated_criteria) + end + + # Underlying FilterTable implementation. + filter = FilterTable.create + filter.add_accessor(:where) + .add_accessor(:entries) + .add(:exists?) { |x| !x.entries.empty? } + .add(:group_ids, field: :group_id) + filter.connect(self, :access_key_data) + + def access_key_data + @table + end + + def to_s + 'EC2 Security Groups' + end + + private + + def validate_filter_criteria(raw_criteria) + unless raw_criteria.is_a? Hash + raise 'Unrecognized criteria for fetching Security Groups. ' \ + "Use 'criteria: value' format." + end + + # No criteria yet + recognized_criteria = check_criteria_names(raw_criteria) + + recognized_criteria + end + + def check_criteria_names(raw_criteria: {}, allowed_criteria: []) + # Remove all expected criteria from the raw criteria hash + recognized_criteria = {} + allowed_criteria.each do |expected_criterion| + recognized_criteria[expected_criterion] = raw_criteria.delete(expected_criterion) if raw_criteria.key?(expected_criterion) + end + + # Any leftovers are unwelcome + unless raw_criteria.empty? + raise ArgumentError, "Unrecognized filter criterion '#{raw_criteria.keys.first}'. Expected criteria: #{allowed_criteria.join(', ')}" + end + recognized_criteria + end + + def fetch_from_backend(criteria) + @table = [] + backend = AwsEc2SecurityGroups::BackendFactory.create + # Note: should we ever implement server-side filtering + # (and this is a very good resource for that), + # we will need to reformat the criteria we are sending to AWS. + backend.describe_security_groups(criteria).security_groups.each do |sg_info| + @table.push({ + group_id: sg_info.group_id, + group_name: sg_info.group_name, + vpc_id: sg_info.vpc_id, + }) + end + end + + class BackendFactory + extend AwsBackendFactoryMixin + end + + class Backend + class AwsClientApi < Backend + AwsEc2SecurityGroups::BackendFactory.set_default_backend self + + def describe_security_groups(query) + AWSConnection.new.ec2_client.describe_security_groups(query) + end + end + end +end diff --git a/test/integration/build/ec2.tf b/test/integration/build/ec2.tf new file mode 100644 index 000000000..e8ae6c17f --- /dev/null +++ b/test/integration/build/ec2.tf @@ -0,0 +1,21 @@ +#============================================================# +# Security Groups +#============================================================# + +# Look up the default VPC and the default security group for it +data "aws_vpc" "default" { + default = "true" +} + +data "aws_security_group" "default" { + vpc_id = "${data.aws_vpc.default.id}" + name = "default" +} + +output "ec2_security_group_default_vpc_id" { + value = "${data.aws_vpc.default.id}" +} + +output "ec2_security_group_default_group_id" { + value = "${data.aws_security_group.default.id}" +} \ No newline at end of file diff --git a/test/integration/verify/controls/aws_ec2_security_groups.rb b/test/integration/verify/controls/aws_ec2_security_groups.rb new file mode 100644 index 000000000..fc170d823 --- /dev/null +++ b/test/integration/verify/controls/aws_ec2_security_groups.rb @@ -0,0 +1,43 @@ +fixtures = {} +[ + 'ec2_security_group_default_vpc_id', + 'ec2_security_group_default_group_id', +].each do |fixture_name| + fixtures[fixture_name] = attribute( + fixture_name, + default: "default.#{fixture_name}", + description: 'See ../build/ec2.tf', + ) +end + +control "aws_security_groups client-side filtering" do + all_groups = aws_ec2_security_groups + + # You should always have at least one security group + describe all_groups do + it { should exist } + end + + # You should be able to find a security group in the default VPC + describe all_groups.where(vpc_id: fixtures['ec2_security_group_default_vpc_id']) do + it { should exist } + end + describe all_groups.where(vpc_id: 'vpc-12345678') do + it { should_not exist } + end + + # You should be able to find the security group named default + describe all_groups.where(group_name: 'default') do + it { should exist } + end + describe all_groups.where(group_name: 'no-such-security-group') do + it { should_not exist } + end +end + +control "aws_security_groups properties" do + # You should be able to find the default security group's ID. + describe aws_ec2_security_groups.where(vpc_id: fixtures['ec2_security_group_default_vpc_id']) do + its('group_ids') { should include fixtures['ec2_security_group_default_group_id'] } + end +end \ No newline at end of file diff --git a/test/unit/resources/aws_ec2_security_groups_test.rb b/test/unit/resources/aws_ec2_security_groups_test.rb new file mode 100644 index 000000000..c977aca6f --- /dev/null +++ b/test/unit/resources/aws_ec2_security_groups_test.rb @@ -0,0 +1,105 @@ +require 'ostruct' +require 'helper' +require 'aws_ec2_security_groups' + +# MESGB = MockEc2SecurityGroupBackend +# Abbreviation not used outside this file + +#=============================================================================# +# Constructor Tests +#=============================================================================# +class AwsESGConstructor < Minitest::Test + def setup + AwsEc2SecurityGroups::BackendFactory.select(AwsMESGB::Empty) + end + + def test_constructor_no_args_ok + AwsEc2SecurityGroups.new + end + + def test_constructor_reject_unknown_resource_params + assert_raises(ArgumentError) { AwsEc2SecurityGroups.new(beep: 'boop') } + end +end + +#=============================================================================# +# Filter Criteria +#=============================================================================# +class AwsESGFilterCriteria < Minitest::Test + def setup + AwsEc2SecurityGroups::BackendFactory.select(AwsMESGB::Basic) + end + + def test_filter_vpc_id + hit = AwsEc2SecurityGroups.new.where(vpc_id: 'vpc-12345678') + assert(hit.exists?) + + miss = AwsEc2SecurityGroups.new.where(vpc_id: 'vpc-87654321') + refute(miss.exists?) + end + + def test_filter_group_name + hit = AwsEc2SecurityGroups.new.where(group_name: 'alpha') + assert(hit.exists?) + + miss = AwsEc2SecurityGroups.new.where(group_name: 'nonesuch') + refute(miss.exists?) + end + +end + +#=============================================================================# +# Properties +#=============================================================================# +class AwsESGProperties < Minitest::Test + def setup + AwsEc2SecurityGroups::BackendFactory.select(AwsMESGB::Basic) + end + + def test_property_group_ids + basic = AwsEc2SecurityGroups.new + assert_kind_of(Array, basic.group_ids) + assert(basic.group_ids.include?('sg-aaaabbbb')) + refute(basic.group_ids.include?(nil)) + end +end + +#=============================================================================# +# Test Fixtures +#=============================================================================# + +module AwsMESGB + class Empty < AwsEc2SecurityGroups::Backend + def describe_security_groups(_query) + OpenStruct.new({ + security_groups: [], + }) + end + end + + class Basic < AwsEc2SecurityGroups::Backend + def describe_security_groups(query) + fixtures = [ + OpenStruct.new({ + group_id: 'sg-aaaabbbb', + group_name: 'alpha', + vpc_id: 'vpc-aaaabbbb', + }), + OpenStruct.new({ + group_id: 'sg-12345678', + group_name: 'beta', + vpc_id: 'vpc-12345678', + }), + ] + + selected = fixtures.select do |sg| + query.keys.all? do |criterion| + query[criterion] == sg[criterion] + end + end + + OpenStruct.new({ security_groups: selected }) + end + end + +end From a33146f9a46847e5e14f2c9d4ee6f535c608db6d Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Thu, 14 Dec 2017 09:28:29 -0500 Subject: [PATCH 139/206] Skeleton aws_ec2_security_group resource Signed-off-by: Clinton Wolfe --- docs/resources/aws_ec2_security_group.md | 142 ++++++++++++++++++ libraries/aws_ec2_security_group.rb | 89 +++++++++++ test/integration/build/ec2.tf | 14 +- .../verify/controls/aws_ec2_security_group.rb | 41 +++++ .../resources/aws_ec2_security_group_test.rb | 121 +++++++++++++++ 5 files changed, 406 insertions(+), 1 deletion(-) create mode 100644 docs/resources/aws_ec2_security_group.md create mode 100644 libraries/aws_ec2_security_group.rb create mode 100644 test/integration/verify/controls/aws_ec2_security_group.rb create mode 100644 test/unit/resources/aws_ec2_security_group_test.rb diff --git a/docs/resources/aws_ec2_security_group.md b/docs/resources/aws_ec2_security_group.md new file mode 100644 index 000000000..b46919175 --- /dev/null +++ b/docs/resources/aws_ec2_security_group.md @@ -0,0 +1,142 @@ +--- +title: About the aws_ec2_security_group Resource +--- + +# aws_ec2_security_group + +Use the `aws_ec2_security_group` InSpec audit resource to test detailed properties of an individual Security Group (SG). + +SGs are a networking construct which contain ingress and egress rules for network communications. SGs may be attached to EC2 instances, as well as certain other AWS resources. Along with Network Access Control Lists, SGs are one of the two main mechanisms of enforcing network-level security. + +
+ +## Syntax + +An `aws_ec2_security_group` resource block uses resource parameters to search for a Security Group, and then tests that Security Group. If no SGs match, no error is raised, but the `exists` matcher will return `false` and all properties will be `nil`. If more than one SG matches (due to vague search parameters), an error is raised. + + # Ensure you have a security group with a certain ID + # This is "safe" - SG IDs are unique within an account + describe aws_ec2_security_group('sg-12345678') do + it { should exist } + end + + # Ensure you have a security group with a certain ID + # This uses hash syntax + describe aws_ec2_security_group(id: 'sg-12345678') do + it { should exist } + end + +
+ +## Examples + +The following examples show how to use this InSpec audit resource. + +As this is the initial release of `aws_ec2_security_group`, its limited functionality precludes examples. + +
+ +## Resource Parameters + +This InSpec resource accepts the following parameters, which are used to search for the Security Group. + +### id, group_id + +The Security Group ID of the Security Group. This is of the format `sg-` followed by 8 hexadecimal characters. The ID is unique within your AWS account; using ID ensures that you will never match more than one SG. The ID is also the default resource parameter, so you may omit the hash syntax. + + # Using Hash syntax + describe aws_ec2_security_group(id: 'sg-12345678') do + it { should exist } + end + + # group_id is an alias for id + describe aws_ec2_security_group(group_id: 'sg-12345678') do + it { should exist } + end + + # Or omit hash syntax, rely on it being the default parameter + describe aws_ec2_security_group('sg-12345678') do + it { should exist } + end + +### group_name + +The string Name of the Security Group. Every VPC has a security group named 'default'. Names are unique within a VPC, but not within an AWS account. + + # Get default security group for a certain VPC + describe aws_ec2_security_group(group_name: 'default', vpc_id: vpc_id: 'vpc-12345678') do + it { should exist } + end + + # This will throw an error if there is a 'backend' SG in more than one VPC. + describe aws_ec2_security_group(group_name: 'backend') do + it { should exist } + end + +### vpc_id + +A string identifying the VPC which contains the security group. Since VPCs commonly contain many SGs, you should add additional parameters to ensure you find exactly one SG. + + # This will error if there is more than the default SG + describe aws_ec2_security_group(vpc_id: 'vpc-12345678') do + it { should exist } + end + +
+ +## Matchers + +### exists + +The control will pass if the specified SG was found. Use should_not if you want to verify that the specified SG does not exist. + + # You will always have at least one SG, the VPC default SG + describe aws_ec2_security_group(group_name: 'default') + it { should exist } + end + + # Make sure we don't have any security groups with the name 'nogood' + describe aws_ec2_security_group(group_name: 'nogood') + it { should_not exist } + end + +## Properties + +### group_id + +Provides the Security Group ID. + + # Inspect the group ID of the default group + describe aws_ec2_security_group(group_name: 'default', vpc_id: vpc_id: 'vpc-12345678') do + its('group_id') { should cmp 'sg-12345678' } + end + + # Store the group ID in a Ruby variable for use elsewhere + sg_id = aws_ec2_security_group(group_name: 'default', vpc_id: vpc_id: 'vpc-12345678').group_id + +### group_name + +A String reflecting the name that was given to the SG at creation time. + + # Inspect the group name of a particular group + describe aws_ec2_security_group('sg-12345678') do + its('group_name') { should cmp 'my_group' } + end + +### description + +A String reflecting the human-meaningful description that was given to the SG at creation time. + + # Require a description of a particular group + describe aws_ec2_security_group('sg-12345678') do + its('description') { should_not be_empty } + end + +### vpc_id + +A String in the format 'vpc-' followed by 8 hexadecimal characters reflecting VPC that contains the security group. + + # Inspec the VPC ID of a particular group + describe aws_ec2_security_group('sg-12345678') do + its('vpc_id') { should cmp 'vpc-12345678' } + end \ No newline at end of file diff --git a/libraries/aws_ec2_security_group.rb b/libraries/aws_ec2_security_group.rb new file mode 100644 index 000000000..61e019493 --- /dev/null +++ b/libraries/aws_ec2_security_group.rb @@ -0,0 +1,89 @@ +class AwsEc2SecurityGroup < Inspec.resource(1) + name 'aws_ec2_security_group' + desc 'Verifies settings for an individual AWS Security Group.' + example ' + describe aws_ec2_security_group("sg-12345678") do + it { should exist } + end + ' + + include AwsResourceMixin + attr_reader :description, :group_id, :group_name, :vpc_id + + def to_s + 'EC2 Security Group' + end + + private + + def validate_params(raw_params) + recognized_params = check_resource_param_names( + raw_params: raw_params, + allowed_params: [:id, :group_id, :group_name, :vpc_id], + allowed_scalar_name: :group_id, + allowed_scalar_type: String, + ) + + # id is an alias for group_id + recognized_params[:group_id] = recognized_params.delete(:id) if recognized_params.key?(:id) + + if recognized_params.key?(:group_id) && recognized_params[:group_id] !~ /^sg\-[0-9a-f]{8}/ + raise ArgumentError, 'aws_ec2_security_group security group ID must be in the format "sg-" followed by 8 hexadecimal characters.' + end + + if recognized_params.key?(:vpc_id) && recognized_params[:vpc_id] !~ /^vpc\-[0-9a-f]{8}/ + raise ArgumentError, 'aws_ec2_security_group VPC ID must be in the format "vpc-" followed by 8 hexadecimal characters.' + end + + validated_params = recognized_params + + if validated_params.empty? + raise ArgumentError, 'You must provide parameters to aws_ec2_security_group, such as group_name, group_id, or vpc_id.g_group.' + end + validated_params + end + + def fetch_from_aws + backend = AwsEc2SecurityGroup::BackendFactory.create + + # Transform into filter format expected by AWS + filters = [] + [ + :description, + :group_id, + :group_name, + :vpc_id, + ].each do |criterion_name| + val = instance_variable_get("@#{criterion_name}".to_sym) + next if val.nil? + filters.push( + { + name: criterion_name.to_s.tr('_', '-'), + values: [val], + }, + ) + end + dsg_response = backend.describe_security_groups(filters: filters) + + if dsg_response.security_groups.empty? + @exists = false + return + end + + @exists = true + @description = dsg_response.security_groups[0].description + @group_id = dsg_response.security_groups[0].group_id + @group_name = dsg_response.security_groups[0].group_name + @vpc_id = dsg_response.security_groups[0].vpc_id + end + + class Backend + class AwsClientApi < Backend + AwsEc2SecurityGroup::BackendFactory.set_default_backend self + + def describe_security_groups(query) + AWSConnection.new.ec2_client.describe_security_groups(query) + end + end + end +end diff --git a/test/integration/build/ec2.tf b/test/integration/build/ec2.tf index e8ae6c17f..3558eaca0 100644 --- a/test/integration/build/ec2.tf +++ b/test/integration/build/ec2.tf @@ -18,4 +18,16 @@ output "ec2_security_group_default_vpc_id" { output "ec2_security_group_default_group_id" { value = "${data.aws_security_group.default.id}" -} \ No newline at end of file +} + +# Create a security group with a known description +# in the default VPC +resource "aws_security_group" "alpha" { + name = "alpha" + description = "SG alpha" + vpc_id = "${data.aws_vpc.default.id}" +} + +output "ec2_security_group_alpha_group_id" { + value = "${aws_security_group.alpha.id}" +} diff --git a/test/integration/verify/controls/aws_ec2_security_group.rb b/test/integration/verify/controls/aws_ec2_security_group.rb new file mode 100644 index 000000000..5bd77934f --- /dev/null +++ b/test/integration/verify/controls/aws_ec2_security_group.rb @@ -0,0 +1,41 @@ +fixtures = {} +[ + 'ec2_security_group_default_vpc_id', + 'ec2_security_group_default_group_id', + 'ec2_security_group_alpha_group_id', +].each do |fixture_name| + fixtures[fixture_name] = attribute( + fixture_name, + default: "default.#{fixture_name}", + description: 'See ../build/ec2.tf', + ) +end + +control "aws_ec2_security_group recall of default VPC" do + + describe aws_ec2_security_group(fixtures['ec2_security_group_default_group_id']) do + it { should exist } + end + + describe aws_ec2_security_group(group_name: 'default', vpc_id: fixtures['ec2_security_group_default_vpc_id']) do + it { should exist } + end + + describe aws_ec2_security_group(group_name: 'no-such-security-group') do + it { should_not exist } + end +end + +control "aws_ec2_security_group properties" do + # You should be able to find the default security group's ID. + describe aws_ec2_security_group(fixtures['ec2_security_group_default_group_id']) do + its('group_id') { should cmp fixtures['ec2_security_group_default_group_id'] } + end + + describe aws_ec2_security_group(fixtures['ec2_security_group_alpha_group_id']) do + its('group_name') { should cmp 'alpha' } + its('vpc_id') { should cmp fixtures['ec2_security_group_default_vpc_id'] } + its('description') { should cmp 'SG alpha' } + end + +end \ No newline at end of file diff --git a/test/unit/resources/aws_ec2_security_group_test.rb b/test/unit/resources/aws_ec2_security_group_test.rb new file mode 100644 index 000000000..324c6330f --- /dev/null +++ b/test/unit/resources/aws_ec2_security_group_test.rb @@ -0,0 +1,121 @@ +require 'ostruct' +require 'helper' +require 'aws_ec2_security_group' + +# MESGSB = MockEc2SecurityGroupSingleBackend +# Abbreviation not used outside this file + +#=============================================================================# +# Constructor Tests +#=============================================================================# +class AwsESGSConstructor < Minitest::Test + def setup + AwsEc2SecurityGroup::BackendFactory.select(AwsMESGSB::Empty) + end + + def test_constructor_no_args_raises + assert_raises(ArgumentError) { AwsEc2SecurityGroup.new } + end + + def test_constructor_accept_scalar_param + AwsEc2SecurityGroup.new('sg-12345678') + end + + def test_constructor_expected_well_formed_args + { + id: 'sg-1234abcd', + group_id: 'sg-1234abcd', + vpc_id: 'vpc-1234abcd', + group_name: 'some-group', + }.each do |param, value| + AwsEc2SecurityGroup.new(param => value) + end + end + + def test_constructor_reject_malformed_args + { + id: 'sg-xyz-123', + group_id: '1234abcd', + vpc_id: 'vpc_1234abcd', + }.each do |param, value| + assert_raises(ArgumentError) { AwsEc2SecurityGroup.new(param => value) } + end + end + + def test_constructor_reject_unknown_resource_params + assert_raises(ArgumentError) { AwsEc2SecurityGroup.new(beep: 'boop') } + end +end + +#=============================================================================# +# Properties +#=============================================================================# + +class AwsESGSConstructor < Minitest::Test + def setup + AwsEc2SecurityGroup::BackendFactory.select(AwsMESGSB::Basic) + end + + def test_property_group_id + assert_equal('sg-12345678', AwsEc2SecurityGroup.new('sg-12345678').group_id) + assert_nil(AwsEc2SecurityGroup.new(group_name: 'my-group').group_id) + end + + def test_property_group_name + assert_equal('beta', AwsEc2SecurityGroup.new('sg-12345678').group_name) + assert_nil(AwsEc2SecurityGroup.new('sg-87654321').group_name) + end + + def test_property_vpc_id + assert_equal('vpc-aaaabbbb', AwsEc2SecurityGroup.new('sg-aaaabbbb').vpc_id) + assert_nil(AwsEc2SecurityGroup.new('sg-87654321').vpc_id) + end + + def test_property_description + assert_equal('Awesome Group', AwsEc2SecurityGroup.new('sg-12345678').description) + assert_nil(AwsEc2SecurityGroup.new('sg-87654321').description) + end + +end + +#=============================================================================# +# Test Fixtures +#=============================================================================# + +module AwsMESGSB + class Empty < AwsEc2SecurityGroup::Backend + def describe_security_groups(_query) + OpenStruct.new({ + security_groups: [], + }) + end + end + + class Basic < AwsEc2SecurityGroup::Backend + def describe_security_groups(query) + fixtures = [ + OpenStruct.new({ + description: 'Some Group', + group_id: 'sg-aaaabbbb', + group_name: 'alpha', + vpc_id: 'vpc-aaaabbbb', + }), + OpenStruct.new({ + description: 'Awesome Group', + group_id: 'sg-12345678', + group_name: 'beta', + vpc_id: 'vpc-12345678', + }), + ] + + selected = fixtures.select do |sg| + query[:filters].all? do |filter| + filter[:values].include?(sg[filter[:name].tr('-','_')]) + end + end + + OpenStruct.new({ security_groups: selected }) + end + end + +end From e317fff2edce93d89cee81773eb703a3049a9130 Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Thu, 14 Dec 2017 23:41:12 -0500 Subject: [PATCH 140/206] Move files under lib back to libraries Signed-off-by: Clinton Wolfe --- lib/inspec/aws.rb | 4 ---- libraries/_aws.rb | 7 +++++++ .../_aws_backend_factory_mixin.rb | 0 .../aws/connection.rb => libraries/_aws_connection.rb | 0 .../resource_mixin.rb => libraries/_aws_resource_mixin.rb | 0 libraries/aws_aaa_shim.rb | 2 +- test/unit/helper.rb | 2 +- 7 files changed, 9 insertions(+), 6 deletions(-) delete mode 100644 lib/inspec/aws.rb create mode 100644 libraries/_aws.rb rename lib/inspec/aws/backend_factory_mixin.rb => libraries/_aws_backend_factory_mixin.rb (100%) rename lib/inspec/aws/connection.rb => libraries/_aws_connection.rb (100%) rename lib/inspec/aws/resource_mixin.rb => libraries/_aws_resource_mixin.rb (100%) diff --git a/lib/inspec/aws.rb b/lib/inspec/aws.rb deleted file mode 100644 index 5a3d20244..000000000 --- a/lib/inspec/aws.rb +++ /dev/null @@ -1,4 +0,0 @@ -require 'aws-sdk' -require_relative 'aws/backend_factory_mixin' -require_relative 'aws/resource_mixin' -require_relative 'aws/connection' diff --git a/libraries/_aws.rb b/libraries/_aws.rb new file mode 100644 index 000000000..5adece020 --- /dev/null +++ b/libraries/_aws.rb @@ -0,0 +1,7 @@ +# Main AWS loader file. The intent is for this to be +# loaded only if AWS resources are needed. + +require 'aws-sdk' # TODO: split once ADK v3 is in use +require '_aws_backend_factory_mixin' +require '_aws_resource_mixin' +require '_aws_connection' diff --git a/lib/inspec/aws/backend_factory_mixin.rb b/libraries/_aws_backend_factory_mixin.rb similarity index 100% rename from lib/inspec/aws/backend_factory_mixin.rb rename to libraries/_aws_backend_factory_mixin.rb diff --git a/lib/inspec/aws/connection.rb b/libraries/_aws_connection.rb similarity index 100% rename from lib/inspec/aws/connection.rb rename to libraries/_aws_connection.rb diff --git a/lib/inspec/aws/resource_mixin.rb b/libraries/_aws_resource_mixin.rb similarity index 100% rename from lib/inspec/aws/resource_mixin.rb rename to libraries/_aws_resource_mixin.rb diff --git a/libraries/aws_aaa_shim.rb b/libraries/aws_aaa_shim.rb index 4fd325755..685c9f0c7 100644 --- a/libraries/aws_aaa_shim.rb +++ b/libraries/aws_aaa_shim.rb @@ -1,3 +1,3 @@ # This file simply acts as a loader when inspec-aws # is being used as a resource pack. -require_relative '../lib/inspec/aws' +require '_aws' diff --git a/test/unit/helper.rb b/test/unit/helper.rb index 18106b6e2..5aa8a7a20 100644 --- a/test/unit/helper.rb +++ b/test/unit/helper.rb @@ -7,4 +7,4 @@ require 'json' require 'ostruct' require 'inspec/resource' -require_relative '../../lib/inspec/aws' +require_relative '../../libraries/_aws' From e00ec2df5e38b0616d0ac6afd56ff1f7f93eb3a1 Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Fri, 15 Dec 2017 00:07:28 -0500 Subject: [PATCH 141/206] Add created_with_user filter criteria to aws_iam_access_keys Signed-off-by: Clinton Wolfe --- docs/resources/aws_iam_access_keys.md | 18 +++++++++ libraries/aws_iam_access_keys.rb | 24 +++++++++--- .../verify/controls/aws_iam_access_key.rb | 3 ++ .../resources/aws_iam_access_keys_test.rb | 37 ++++++++++++++++++- 4 files changed, 75 insertions(+), 7 deletions(-) diff --git a/docs/resources/aws_iam_access_keys.md b/docs/resources/aws_iam_access_keys.md index 90ca9f8dc..c5c94874e 100644 --- a/docs/resources/aws_iam_access_keys.md +++ b/docs/resources/aws_iam_access_keys.md @@ -91,6 +91,15 @@ An integer, representing how old the access key is. it { should_not exist } end +### created_with_user + +A true / false value indicating if the Access Key was likely created at the same time as the user, by checking if the difference between created_date and user_created_date is less than 1 hour. + + # Do not automatically create keys for users + describe aws_iam_access_keys.where { created_with_user } do + it { should_not exist } + end + ### ever_used A true / false value indicating if the Access Key has ever been used, based on the last_used_date. See also: `never_used`. @@ -145,6 +154,15 @@ Searches for access keys owned by the named user. Each user may have zero, one, it { should exist } end +### user_created_date + +The date at which the user was created. + + # Users have to be a week old to have a key + describe aws_iam_access_keys.where { user_created_date > Date.now - 7 } + it { should_not exist } + end + ## Properties ### access_key_ids diff --git a/libraries/aws_iam_access_keys.rb b/libraries/aws_iam_access_keys.rb index 92fbab10e..61c24a4e6 100644 --- a/libraries/aws_iam_access_keys.rb +++ b/libraries/aws_iam_access_keys.rb @@ -55,6 +55,7 @@ class AwsIamAccessKeys < Inspec.resource(1) .add(:access_key_ids, field: :access_key_id) .add(:created_date, field: :created_date) .add(:created_days_ago, field: :created_days_ago) + .add(:created_with_user, field: :created_with_user) .add(:created_hours_ago, field: :created_hours_ago) .add(:usernames, field: :username) .add(:active, field: :active) @@ -64,6 +65,7 @@ class AwsIamAccessKeys < Inspec.resource(1) .add(:last_used_days_ago, field: :last_used_days_ago) .add(:ever_used, field: :ever_used) .add(:never_used, field: :never_used) + .add(:user_created_date, field: :user_created_date) filter.connect(self, :access_key_data) def access_key_data @@ -86,16 +88,23 @@ class AwsIamAccessKeys < Inspec.resource(1) class AwsUserIterator < AccessKeyProvider def fetch(criteria) iam_client = AWSConnection.new.iam_client - usernames = [] + + user_details = {} if criteria.key?(:username) - usernames.push criteria[:username] + begin + user_details[criteria[:username]] = iam_client.get_user(user_name: criteria[:username]).user + rescue Aws::IAM::Errors::NoSuchEntity # rubocop:disable Lint/HandleExceptions + # Swallow - a miss on search results should return an empty table + end else # TODO: pagination check and resume - usernames = iam_client.list_users.users.map(&:user_name) + iam_client.list_users.users.each do |info| + user_details[info.user_name] = info + end end access_key_data = [] - usernames.each do |username| + user_details.each_key do |username| begin user_keys = iam_client.list_access_keys(user_name: username) .access_key_metadata @@ -108,9 +117,10 @@ class AwsIamAccessKeys < Inspec.resource(1) } end + # Copy in from user data # Synthetics user_keys.each do |key_info| - add_synthetic_fields(key_info) + add_synthetic_fields(key_info, user_details[username]) end access_key_data.concat(user_keys) rescue Aws::IAM::Errors::NoSuchEntity # rubocop:disable Lint/HandleExceptions @@ -120,12 +130,14 @@ class AwsIamAccessKeys < Inspec.resource(1) access_key_data end - def add_synthetic_fields(key_info) # rubocop:disable Metrics/AbcSize + def add_synthetic_fields(key_info, user_details) # rubocop:disable Metrics/AbcSize key_info[:id] = key_info[:access_key_id] key_info[:active] = key_info[:status] == 'Active' key_info[:inactive] = key_info[:status] != 'Active' key_info[:created_hours_ago] = ((Time.now - key_info[:create_date]) / (60*60)).to_i key_info[:created_days_ago] = (key_info[:created_hours_ago] / 24).to_i + key_info[:user_created_date] = user_details[:create_date] + key_info[:created_with_user] = (key_info[:create_date] - key_info[:user_created_date]).abs < 1.0/24.0 # Last used is a separate API call iam_client = AWSConnection.new.iam_client diff --git a/test/integration/verify/controls/aws_iam_access_key.rb b/test/integration/verify/controls/aws_iam_access_key.rb index 6cec7002b..86dbfe5ff 100644 --- a/test/integration/verify/controls/aws_iam_access_key.rb +++ b/test/integration/verify/controls/aws_iam_access_key.rb @@ -42,6 +42,9 @@ control 'IAM Access Keys' do .where { last_used_days_ago > 0 } do it { should exist } end + describe all_keys.where { created_with_user } do + it { should exist } + end end control 'AKS3' do diff --git a/test/unit/resources/aws_iam_access_keys_test.rb b/test/unit/resources/aws_iam_access_keys_test.rb index 1d1d01fb8..15d5fe4e1 100644 --- a/test/unit/resources/aws_iam_access_keys_test.rb +++ b/test/unit/resources/aws_iam_access_keys_test.rb @@ -64,7 +64,7 @@ end class AwsIamAccessKeysFilterCriteriaTest < Minitest::Test def setup - # Here we always want no rseults. + # Here we always want no results. AwsIamAccessKeys::AccessKeyProvider.select(AlwaysEmptyMAKP) @valued_criteria = { username: 'bob', @@ -169,6 +169,21 @@ class AwsIamAccessKeysPropertiesTest < Minitest::Test assert_equal(2, block_filtered.entries.count) end + #----------------------------------------------------------# + # created_with_user # + #----------------------------------------------------------# + def test_property_created_with_user + assert_kind_of(TrueClass, @all_basic.entries[0].created_with_user) + assert_kind_of(FalseClass, @all_basic.entries[1].created_with_user) + + arg_filtered = @all_basic.where(created_with_user: true) + assert_equal(2, arg_filtered.entries.count) + assert arg_filtered.access_key_ids.first.end_with?('BOB') + + block_filtered = @all_basic.where { created_with_user } + assert_equal(2, block_filtered.entries.count) + end + #----------------------------------------------------------# # active / inactive # #----------------------------------------------------------# @@ -262,6 +277,20 @@ class AwsIamAccessKeysPropertiesTest < Minitest::Test assert_equal(1, block_filtered.entries.count) assert block_filtered.access_key_ids.first.end_with?('BOB') end + + #----------------------------------------------------------# + # user_created_date # + #----------------------------------------------------------# + def test_property_user_created_date + assert_kind_of(DateTime, @all_basic.entries.first.user_created_date) + arg_filtered = @all_basic.where(user_created_date: DateTime.parse('2017-10-21T17:58:00Z')) + assert_equal(1, arg_filtered.entries.count) + assert arg_filtered.access_key_ids.first.end_with?('SALLY') + + block_filtered = @all_basic.where { user_created_date.saturday? } + assert_equal(1, block_filtered.entries.count) + assert block_filtered.access_key_ids.first.end_with?('SALLY') + end end #==========================================================# # Mock Support Classes # @@ -286,6 +315,7 @@ class BasicMAKP < AwsIamAccessKeys::AccessKeyProvider created_date: DateTime.parse('2017-10-27T17:58:00Z'), created_days_ago: 4, created_hours_ago: 102, + created_with_user: true, status: 'Active', active: true, inactive: false, @@ -294,6 +324,7 @@ class BasicMAKP < AwsIamAccessKeys::AccessKeyProvider last_used_hours_ago: nil, ever_used: false, never_used: true, + user_created_date: DateTime.parse('2017-10-27T17:58:00Z'), }, { username: 'sally', @@ -302,6 +333,7 @@ class BasicMAKP < AwsIamAccessKeys::AccessKeyProvider created_date: DateTime.parse('2017-10-22T17:58:00Z'), created_days_ago: 9, created_hours_ago: 222, + created_with_user: false, status: 'Active', active: true, inactive: false, @@ -310,6 +342,7 @@ class BasicMAKP < AwsIamAccessKeys::AccessKeyProvider last_used_hours_ago: 102, ever_used: true, never_used: false, + user_created_date: DateTime.parse('2017-10-21T17:58:00Z'), }, { username: 'robin', @@ -318,6 +351,7 @@ class BasicMAKP < AwsIamAccessKeys::AccessKeyProvider created_date: DateTime.parse('2017-10-31T17:58:00Z'), created_days_ago: 1, created_hours_ago: 12, + created_with_user: true, status: 'Inactive', active: false, inactive: true, @@ -326,6 +360,7 @@ class BasicMAKP < AwsIamAccessKeys::AccessKeyProvider last_used_hours_ago: 5, ever_used: true, never_used: false, + user_created_date: DateTime.parse('2017-10-31T17:58:00Z'), }, ] end From c75252ae1c820e8932efe9a870db51f2e71e74f5 Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Fri, 15 Dec 2017 01:37:36 -0500 Subject: [PATCH 142/206] Rework Integration Testing to Support Multiple Accounts (#128) Signed-off-by: Clinton Wolfe --- README.md | 21 +- Rakefile | 104 ++++----- TESTING_AGAINST_AWS.md | 108 +++++++++ libraries/_aws_connection.rb | 14 +- test/integration/build/aws.tf | 211 ------------------ test/integration/build/ec2.tf | 33 --- test/integration/build/terraform.tfvars | 1 - test/integration/build/variables.tf | 1 - test/integration/default/build/aws.tf | 10 + test/integration/default/build/cloudwatch.tf | 92 ++++++++ test/integration/default/build/ec2.tf | 183 +++++++++++++++ test/integration/default/build/iam.tf | 90 ++++++++ test/integration/default/build/sns.tf | 37 +++ .../verify/controls/aws_cloudwatch_alarm.rb | 29 +++ .../aws_cloudwatch_log_metric_filter.rb | 73 ++++++ .../verify/controls/aws_ec2_instance.rb | 78 +++++++ .../verify/controls/aws_ec2_security_group.rb | 0 .../controls/aws_ec2_security_groups.rb | 0 .../verify/controls/aws_iam_access_key.rb | 89 ++++++++ .../verify/controls/aws_iam_role.rb | 0 .../verify/controls/aws_iam_root_user.rb | 28 +++ .../default/verify/controls/aws_iam_user.rb | 47 ++++ .../verify/controls/aws_iam_users.rb | 0 .../default/verify/controls/aws_sns_topic.rb | 39 ++++ .../{ => default}/verify/inspec.yml | 2 +- test/integration/minimal/build/aws.tf | 10 + .../verify/controls/aws_iam_root_user.rb | 27 +++ test/integration/minimal/verify/inspec.yml | 4 + .../verify/controls/aws_cloudwatch_alarm.rb | 30 --- .../aws_cloudwatch_log_metric_filter.rb | 46 ---- .../verify/controls/aws_ec2_instance.rb | 37 --- .../verify/controls/aws_iam_access_key.rb | 60 ----- .../verify/controls/aws_iam_user.rb | 18 -- .../verify/controls/aws_sns_topic.rb | 34 --- 34 files changed, 1014 insertions(+), 542 deletions(-) create mode 100644 TESTING_AGAINST_AWS.md delete mode 100644 test/integration/build/aws.tf delete mode 100644 test/integration/build/ec2.tf delete mode 100644 test/integration/build/terraform.tfvars delete mode 100644 test/integration/build/variables.tf create mode 100644 test/integration/default/build/aws.tf create mode 100644 test/integration/default/build/cloudwatch.tf create mode 100644 test/integration/default/build/ec2.tf create mode 100644 test/integration/default/build/iam.tf create mode 100644 test/integration/default/build/sns.tf create mode 100644 test/integration/default/verify/controls/aws_cloudwatch_alarm.rb create mode 100644 test/integration/default/verify/controls/aws_cloudwatch_log_metric_filter.rb create mode 100644 test/integration/default/verify/controls/aws_ec2_instance.rb rename test/integration/{ => default}/verify/controls/aws_ec2_security_group.rb (100%) rename test/integration/{ => default}/verify/controls/aws_ec2_security_groups.rb (100%) create mode 100644 test/integration/default/verify/controls/aws_iam_access_key.rb rename test/integration/{ => default}/verify/controls/aws_iam_role.rb (100%) create mode 100644 test/integration/default/verify/controls/aws_iam_root_user.rb create mode 100644 test/integration/default/verify/controls/aws_iam_user.rb rename test/integration/{ => default}/verify/controls/aws_iam_users.rb (100%) create mode 100644 test/integration/default/verify/controls/aws_sns_topic.rb rename test/integration/{ => default}/verify/inspec.yml (71%) create mode 100644 test/integration/minimal/build/aws.tf create mode 100644 test/integration/minimal/verify/controls/aws_iam_root_user.rb create mode 100644 test/integration/minimal/verify/inspec.yml delete mode 100644 test/integration/verify/controls/aws_cloudwatch_alarm.rb delete mode 100644 test/integration/verify/controls/aws_cloudwatch_log_metric_filter.rb delete mode 100644 test/integration/verify/controls/aws_ec2_instance.rb delete mode 100644 test/integration/verify/controls/aws_iam_access_key.rb delete mode 100644 test/integration/verify/controls/aws_iam_user.rb delete mode 100644 test/integration/verify/controls/aws_sns_topic.rb diff --git a/README.md b/README.md index 7f4668d82..8b2cfd279 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ As of now, AWS resources are implemented as an InSpec resource pack. It will shi ## Get started -Before running the profile with InSpec, define environment variables with your AWS region and credentials. InSpec supports the following variables: +Before running the profile with InSpec, define environment variables with your AWS region and credentials. InSpec supports the following standard AWS variables: - `AWS_REGION` - `AWS_DEFAULT_REGION` @@ -23,7 +23,9 @@ Before running the profile with InSpec, define environment variables with your A Those variables are defined in [AWS CLI Docs](http://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html#cli-environment) -Now you can use `inspec exec inspec-aws`. Please note, that you have to define the AWS target in future: `inspec exec inspec-aws -t aws://accesskey:secret@region`. +Now you can use `inspec exec inspec-aws`. + +Our future intent is to support an `aws` target for InSpec/Train, so you may also pass credentials `inspec exec inspec-aws -t aws://accesskey:secret@region`. ## Use the resources @@ -93,20 +95,7 @@ bundle exec rake test ### Integration tests -To run the integration tests, please make sure all required environment variables like `AWS_ACCESS_KEY_ID` -, `AWS_SECRET_ACCESS_KEY` and `AWS_REGION` are set properly. (`AWS_REGION` **must** be set to **us-east-1** when running the integration tests.) We use terraform to create the AWS setup and InSpec to verify the all aspects. If you want to use a specific terraform environment, set environment variable `INSPEC_TERRAFORM_ENV`. Integration tests can be executed via: - -``` -bundle exec rake test:integration -``` - -This task sets up test AWS resources, runs the integration tests, and then cleans up the resources. To perform these tasks independently, please call them individually: - -* `bundle exec rake test:configure_test_environment` -* `bundle exec rake test:setup_integration_tests` -* `bundle exec rake test:run_integration_tests` -* `bundle exec rake test:cleanup_integration_tests` -* `bundle exec rake test:destroy_test_environment` +Please see TESTING_AGAINST_AWS.md for details on how to setup the needed AWS accounts to perform testing. ## Kudos diff --git a/Rakefile b/Rakefile index 59ae50dae..8169744c5 100644 --- a/Rakefile +++ b/Rakefile @@ -32,60 +32,64 @@ task default: [:lint, :test] namespace :test do project_dir = File.dirname(__FILE__) - attribute_file = File.join(project_dir, ".attribute.yml") - integration_dir = File.join(project_dir, "test/integration") - + # run inspec check to verify that the profile is properly configured task :check do sh("bundle exec inspec check #{project_dir}") end + + namespace :aws do + ['default', 'minimal'].each do |account| + integration_dir = File.join(project_dir, 'test', 'integration', account) + attribute_file = File.join(integration_dir, '.attribute.yml') + + task :"setup:#{account}", :tf_workspace do |t, args| + tf_workspace = args[:tf_workspace] || ENV['INSPEC_TERRAFORM_ENV'] + abort("You must either call the top-level test:aws:#{account} task, or set the INSPEC_TERRAFORM_ENV variable.") unless tf_workspace + puts "----> Setup" + abort("You must set the environment variable AWS_REGION") unless ENV['AWS_REGION'] + puts "----> Checking for required AWS profile..." + sh("aws configure get aws_access_key_id --profile inspec-aws-test-#{account} > /dev/null") + sh("cd #{integration_dir}/build/ && terraform init") + sh("cd #{integration_dir}/build/ && terraform workspace new #{tf_workspace}") + sh("cd #{integration_dir}/build/ && AWS_PROFILE=inspec-aws-test-#{account} terraform plan") + sh("cd #{integration_dir}/build/ && AWS_PROFILE=inspec-aws-test-#{account} terraform apply") + Rake::Task["test:aws:dump_attrs:#{account}"].execute + end + + task :"dump_attrs:#{account}" do + sh("cd #{integration_dir}/build/ && AWS_PROFILE=inspec-aws-test-#{account} terraform output > #{attribute_file}") + raw_output = File.read(attribute_file) + yaml_output = raw_output.gsub(" = ", " : ") + File.open(attribute_file, "w") {|file| file.puts yaml_output} + end - task :configure_test_environment, :namespace do |t, args| - puts "----> Creating terraform environment" - sh("cd #{integration_dir}/build/ && terraform init") - sh("cd #{integration_dir}/build/ && terraform workspace new #{args[:namespace]}") - end - - task :setup_integration_tests do - puts "----> Setup" - sh("cd #{integration_dir}/build/ && terraform plan") - sh("cd #{integration_dir}/build/ && terraform apply") - sh("cd #{integration_dir}/build/ && terraform output > #{attribute_file}") - - raw_output = File.read(attribute_file) - yaml_output = raw_output.gsub(" = ", " : ") - File.open(attribute_file, "w") {|file| file.puts yaml_output} - end - - - task :run_integration_tests do - puts "----> Run" - sh("bundle exec inspec exec #{integration_dir}/verify --attrs #{attribute_file}") - end - - task :cleanup_integration_tests do - puts "----> Cleanup" - sh("cd #{integration_dir}/build/ && terraform destroy -force") - end - - task :destroy_test_environment, :namespace do |t, args| - puts "----> Destroying terraform environment" - sh("cd #{integration_dir}/build/ && terraform workspace select default") - sh("cd #{integration_dir}/build && terraform workspace delete #{args[:namespace]}") - end - - task :integration do - namespace = ENV['INSPEC_TERRAFORM_ENV'] || prompt("Please enter a namespace for your integration tests to run in: ") - begin - Rake::Task["test:configure_test_environment"].execute({:namespace => namespace}) - Rake::Task["test:cleanup_integration_tests"].execute - Rake::Task["test:setup_integration_tests"].execute - Rake::Task["test:run_integration_tests"].execute - rescue - abort("Integration testing has failed") - ensure - Rake::Task["test:cleanup_integration_tests"].execute - Rake::Task["test:destroy_test_environment"].execute({:namespace => namespace}) + task :"run:#{account}" do + puts "----> Run" + sh("AWS_PROFILE=inspec-aws-test-#{account} bundle exec inspec exec #{integration_dir}/verify --attrs #{attribute_file}") + end + + task :"cleanup:#{account}", :tf_workspace do |t, args| + tf_workspace = args[:tf_workspace] || ENV['INSPEC_TERRAFORM_ENV'] + abort("You must either call the top-level test:aws:#{account} task, or set the INSPEC_TERRAFORM_ENV variable.") unless tf_workspace + puts "----> Cleanup" + sh("cd #{integration_dir}/build/ && AWS_PROFILE=inspec-aws-test-#{account} terraform destroy -force") + sh("cd #{integration_dir}/build/ && terraform workspace select default") + sh("cd #{integration_dir}/build && terraform workspace delete #{tf_workspace}") + end + + task :"#{account}" do + tf_workspace = ENV['INSPEC_TERRAFORM_ENV'] || prompt("Please enter a workspace for your integration tests to run in: ") + begin + Rake::Task["test:aws:setup:#{account}"].execute({:tf_workspace => tf_workspace}) + Rake::Task["test:aws:run:#{account}"].execute + rescue + abort("Integration testing has failed for the #{account} account") + ensure + Rake::Task["test:aws:cleanup:#{account}"].execute({:tf_workspace => tf_workspace}) + end + end end end -end + task aws: [:'aws:default', :'aws:minimal'] +end \ No newline at end of file diff --git a/TESTING_AGAINST_AWS.md b/TESTING_AGAINST_AWS.md new file mode 100644 index 000000000..51fc73ad4 --- /dev/null +++ b/TESTING_AGAINST_AWS.md @@ -0,0 +1,108 @@ +# Testing Against AWS - Integration Testing + +## Problem Statement + +We want to be able to test AWS-related InSpec resources against AWS itself. This means we need to create constructs ("test fixtures") in AWS to examine using InSpec. For cost management, we also want to be able to destroy + +## General Approach + +We use Terraform to setup test fixtures in AWS, then run a defined set of InSpec controls against these (which should all pass), and finally tear down the test fixtures with Terraform. For fixtures that cannot be managed by Terraform, we manually setup fixtures using instructions below. + +We use the AWS CLI credentials system to manage credentials. + + +### Installing Terraform + +Download [Terraform](https://www.terraform.io/downloads.html). We require at least v0.10 . To install and choose from multiple Terraform versions, consider using [tfenv](https://github.com/kamatama41/tfenv). + +### Installing AWS CLI + +Install the [AWS CLI](http://docs.aws.amazon.com/cli/latest/userguide/installing.html). We will store profiles for testing in the `~/.aws/credentials` file. + +## Limitations + +There are some things that we can't (or very much shouldn't) do via Terraform - like manipulating the root account MFA settings. + +Also, there are some singleton resources (such as the default VPC, or Config status) that we should not manipulate without consequences. + +## Current Solution + +Our solution is to create two AWS accounts, each dedicated to the task of integration testing inspec-aws. + +In the "default" account, we setup all fixtures that can be handled by Terraform. For any remaining fixtures, +such as enabling MFA on the root account, we manually set one value in the "default" account, and manually set the opposing value in the "minimal" account. This allows use to perform testing on any reachable resource or property, regardless of whether or not Terraform can manage it. + +All tests (and test fixtures) that do not require special handling are placed in the "default" set. That includes both positive and negative checks. + +Note that some tests will fail for the first day or two after you set up the accounts, due to the tests checking properties such as the last usage time of an access key, for example. + +Additionally, the first time you run the tests, you will need to accept the user agreement in the AWS marketplace for the linux AMIs we use. You'll need to do it 4 times, once for each of debian and centos on the two accounts. + +### Creating the Default account + +Follow these instructions carefully. Do not perform any action not specified. + +1. Create an AWS account. Make a note of the account email and root password in a secure secret storage system. +2. Create an IAM user named `test-fixture-maker`. + * Enable programmatic access (to generate an access key) + * Direct-attach the policy AdministratorAccess + * Note the access key and secret key ID that are generated. +3. Using the aws command line tool, store the access key and secret key in a profile with a special name: + `aws configure --profile inspec-aws-test-default` + +#### Test Fixtures for the Default Account + +1. As the root user, enable a virtual MFA device. +2. Create an IAM user named 'test-user-last-key-use'. + * Enable programmatic access (to generate an access key) + * Note the access key and secret key ID that are generated. + * Direct-attach the policy AmazonEC2ReadOnlyAccess + * Using the AWS CLI and the credentials, execute the command `aws ec2 describe-instances`. + * The goal here is to have an access key that was used at one point. + +### Creating the Minimal Account + +Follow these instructions carefully. Do not perform any action not specified. + +1. Create an AWS account. Make a note of the account email and root password in a secure secret storage system. +2. Create an IAM user named `test-fixture-maker`. + * Enable programmatic access (to generate an access key) + * Direct-attach the policy AdministratorAccess + * Note the access key and secret key ID that are generated. +3. Using the aws command line tool, store the access key and secret key in a profile with a special name: + `aws configure --profile inspec-aws-test-minimal` + +#### Test Fixtures for the Minimal Account + +1. Create an Access Key for the root user. You do not have to save the access key. + +## Running the integration tests + +To run all AWS integration tests, run: + + ``` + bundle exec rake test:aws + ``` + +To run the tests against one account only: + + ``` + bundle exec rake test:aws:default + ``` + + or + + ``` + bundle exec rake test:aws:minimal + ``` + +Each account has separate tasks for setup, running the tests, and cleanup. You may run them separately: + +``` +bundle exec rake test:aws:setup:default +bundle exec rake test:aws:run:default +bundle exec rake test:aws:cleanup:default +``` + + + diff --git a/libraries/_aws_connection.rb b/libraries/_aws_connection.rb index fbed8acac..ad5ad91dd 100644 --- a/libraries/_aws_connection.rb +++ b/libraries/_aws_connection.rb @@ -8,13 +8,19 @@ class AWSConnection def initialize - opts = { - region: ENV['AWS_REGION'] || ENV['AWS_DEFAULT_REGION'], - credentials: Aws::Credentials.new( + creds = nil + if ENV['AWS_PROFILE'] + creds = Aws::SharedCredentials.new(profile_name: ENV['AWS_PROFILE']) + else + creds = Aws::Credentials.new( ENV['AWS_ACCESS_KEY_ID'], ENV['AWS_SECRET_ACCESS_KEY'], ENV['AWS_SESSION_TOKEN'], - ), + ) + end + opts = { + region: ENV['AWS_REGION'] || ENV['AWS_DEFAULT_REGION'], + credentials: creds, } Aws.config.update(opts) end diff --git a/test/integration/build/aws.tf b/test/integration/build/aws.tf deleted file mode 100644 index f923d3490..000000000 --- a/test/integration/build/aws.tf +++ /dev/null @@ -1,211 +0,0 @@ -terraform { - required_version = "~> 0.10.0" -} - -provider "aws" {} - -resource "aws_iam_role" "example" { - name = "${terraform.env}.example" - - assume_role_policy = < 0 } do + it { should exist } + end + describe all_keys.where { created_with_user } do + it { should exist } + end +end + +control 'IAM Access Keys - fetch-time filtering' do + describe aws_iam_access_keys(username: fixtures['iam_user_with_access_key']) do + its('entries.length') { should be 1 } + its('access_key_ids.first') { should eq fixtures['iam_access_key_recall_hit'] } + end + + describe aws_iam_access_keys(username: fixtures['iam_user_without_access_key']) do + it { should_not exist } + end +end \ No newline at end of file diff --git a/test/integration/verify/controls/aws_iam_role.rb b/test/integration/default/verify/controls/aws_iam_role.rb similarity index 100% rename from test/integration/verify/controls/aws_iam_role.rb rename to test/integration/default/verify/controls/aws_iam_role.rb diff --git a/test/integration/default/verify/controls/aws_iam_root_user.rb b/test/integration/default/verify/controls/aws_iam_root_user.rb new file mode 100644 index 000000000..097e99e45 --- /dev/null +++ b/test/integration/default/verify/controls/aws_iam_root_user.rb @@ -0,0 +1,28 @@ + +fixtures = {} +[ + 'aws_account_id', +].each do |fixture_name| + fixtures[fixture_name] = attribute( + fixture_name, + default: "default.#{fixture_name}", + description: 'See ../build/iam.tf', + ) +end + +#------------- Property - has_mfa_enabled -------------# +# Negative test in 'minimal' test set. See TESTING_AGAINST_AWS.md +# for fixture instructions. +control "aws_iam_root_user has_mfa_enabled property" do + describe aws_iam_root_user do + it { should have_mfa_enabled } + end +end + +#------------- Property - access_key_count -------------# +# test for = 1 in 'minimal' test set +control "aws_iam_root_user access_key_count property" do + describe aws_iam_root_user do + its('access_key_count') { should be 0 } + end +end \ No newline at end of file diff --git a/test/integration/default/verify/controls/aws_iam_user.rb b/test/integration/default/verify/controls/aws_iam_user.rb new file mode 100644 index 000000000..fc57feab1 --- /dev/null +++ b/test/integration/default/verify/controls/aws_iam_user.rb @@ -0,0 +1,47 @@ + +fixtures = {} +[ + 'iam_user_recall_hit', + 'iam_user_recall_miss', + 'iam_user_no_mfa_enabled', + 'iam_user_has_console_password', + 'iam_user_with_access_key', +].each do |fixture_name| + fixtures[fixture_name] = attribute( + fixture_name, + default: "default.#{fixture_name}", + description: 'See ../build/iam.tf', + ) +end + +#------------------- Recall / Miss -------------------# +describe aws_iam_user(username: fixtures['iam_user_recall_hit']) do + it { should exist } +end + +describe aws_iam_user(username: fixtures['iam_user_recall_miss']) do + it { should_not exist } +end + +#------------- Property - has_mfa_enabled -------------# + +# TODO: fixture and test for has_mfa_enabled + +describe aws_iam_user(username: fixtures['iam_user_no_mfa_enabled']) do + it { should_not have_mfa_enabled } + it { should_not have_console_password } # TODO: this is working by accident, we should have a dedicated fixture +end + +#---------- Property - has_console_password -----------# + +describe aws_iam_user(username: fixtures['iam_user_has_console_password']) do + it { should have_console_password } +end + +#------------- Property - access_keys -------------# + +aws_iam_user(username: fixtures['iam_user_with_access_key']).access_keys.each { |access_key| + describe access_key do + its('status') { should eq 'Active' } + end +} diff --git a/test/integration/verify/controls/aws_iam_users.rb b/test/integration/default/verify/controls/aws_iam_users.rb similarity index 100% rename from test/integration/verify/controls/aws_iam_users.rb rename to test/integration/default/verify/controls/aws_iam_users.rb diff --git a/test/integration/default/verify/controls/aws_sns_topic.rb b/test/integration/default/verify/controls/aws_sns_topic.rb new file mode 100644 index 000000000..949c1323c --- /dev/null +++ b/test/integration/default/verify/controls/aws_sns_topic.rb @@ -0,0 +1,39 @@ +fixtures = {} +[ + 'sns_topic_recall_hit_arn', + 'sns_topic_with_subscription_arn', + 'sns_topic_no_subscription_arn', +].each do |fixture_name| + fixtures[fixture_name] = attribute( + fixture_name, + default: "default.#{fixture_name}", + description: 'See ../build/sns.tf', + ) +end + +control 'aws_sns_topic recall' do + + # Split the ARNs so we can test various ways of missing + scheme, partition, service, region, account, topic = fixtures['sns_topic_recall_hit_arn'].split(':') + arn_prefix = [scheme, partition, service].join(':') + + # Search miss + no_such_topic_arn = [arn_prefix, region, account, 'no-such-topic-for-realz'].join(':') + describe aws_sns_topic(no_such_topic_arn) do + it { should_not exist } + end + + # Search hit + describe aws_sns_topic(fixtures['sns_topic_recall_hit_arn']) do + it { should exist } + end +end + +control "aws_sns_topic confirmed_subscription_count property" do + describe aws_sns_topic(fixtures['sns_topic_with_subscription_arn']) do + its('confirmed_subscription_count') { should_not be_zero } + end + describe aws_sns_topic(fixtures['sns_topic_no_subscription_arn']) do + its('confirmed_subscription_count') { should be_zero } + end +end \ No newline at end of file diff --git a/test/integration/verify/inspec.yml b/test/integration/default/verify/inspec.yml similarity index 71% rename from test/integration/verify/inspec.yml rename to test/integration/default/verify/inspec.yml index 402de99d5..9b140cc86 100644 --- a/test/integration/verify/inspec.yml +++ b/test/integration/default/verify/inspec.yml @@ -1,4 +1,4 @@ name: inspec-aws-integration-tests depends: - name: aws - path: ../../../ + path: ../../../../ diff --git a/test/integration/minimal/build/aws.tf b/test/integration/minimal/build/aws.tf new file mode 100644 index 000000000..382aaf4eb --- /dev/null +++ b/test/integration/minimal/build/aws.tf @@ -0,0 +1,10 @@ +terraform { + required_version = "~> 0.10.0" +} + +provider "aws" {} + +data "aws_caller_identity" "creds" {} +output "aws_account_id" { + value = "${data.aws_caller_identity.creds.account_id}" +} \ No newline at end of file diff --git a/test/integration/minimal/verify/controls/aws_iam_root_user.rb b/test/integration/minimal/verify/controls/aws_iam_root_user.rb new file mode 100644 index 000000000..0a6e3e3e8 --- /dev/null +++ b/test/integration/minimal/verify/controls/aws_iam_root_user.rb @@ -0,0 +1,27 @@ + +fixtures = {} +[ + 'aws_account_id', +].each do |fixture_name| + fixtures[fixture_name] = attribute( + fixture_name, + default: "default.#{fixture_name}", + description: 'See ../build/iam.tf', + ) +end + +#------------- Property - has_mfa_enabled -------------# +# Positive test in 'default' test set +control "aws_iam_root_user has_mfa_enabled property" do + describe aws_iam_root_user do + it { should_not have_mfa_enabled } + end +end + +#------------- Property - access_key_count -------------# +# test for = 0 in 'default' test set +control "aws_iam_root_user access_key_count property" do + describe aws_iam_root_user do + its('access_key_count') { should be 1 } + end +end \ No newline at end of file diff --git a/test/integration/minimal/verify/inspec.yml b/test/integration/minimal/verify/inspec.yml new file mode 100644 index 000000000..9b140cc86 --- /dev/null +++ b/test/integration/minimal/verify/inspec.yml @@ -0,0 +1,4 @@ +name: inspec-aws-integration-tests +depends: + - name: aws + path: ../../../../ diff --git a/test/integration/verify/controls/aws_cloudwatch_alarm.rb b/test/integration/verify/controls/aws_cloudwatch_alarm.rb deleted file mode 100644 index 3e24d8b43..000000000 --- a/test/integration/verify/controls/aws_cloudwatch_alarm.rb +++ /dev/null @@ -1,30 +0,0 @@ -alarm_01 = attribute( - 'cloudwatch_alarm_01', - default: 'default.cloudwatch_alarm', - description: 'Name of Cloudwatch Alarm') - -metric_01_name = attribute( - 'lmf_1_metric_1_name', - default: 'default.lmf_1_metric_1_name', - description: 'A test metric name') - -metric_01_namespace = attribute( - 'lmf_1_metric_1_namespace', - default: 'default.lmf_1_metric_1_namespace', - description: 'A test metric namespace') - -control 'AWS Cloudwatch Alarm' do - describe aws_cloudwatch_alarm( - metric_name: metric_01_name, - metric_namespace: metric_01_namespace, - ) do - it { should exist } - end - - describe aws_cloudwatch_alarm( - metric_name: 'NopeNope', - metric_namespace: 'Nope', - ) do - it { should_not exist } - end -end \ No newline at end of file diff --git a/test/integration/verify/controls/aws_cloudwatch_log_metric_filter.rb b/test/integration/verify/controls/aws_cloudwatch_log_metric_filter.rb deleted file mode 100644 index b8cd92267..000000000 --- a/test/integration/verify/controls/aws_cloudwatch_log_metric_filter.rb +++ /dev/null @@ -1,46 +0,0 @@ -lmf_1_name = attribute( - 'lmf_1_name', - default: 'default.lmf_1_name', - description: 'Name of a Cloudwatch Log Metric Filter', -) - -lmf_2_name = attribute( - 'lmf_2_name', - default: 'default.lmf_2_name', - description: 'Name of a Cloudwatch Log Metric Filter', -) - -lmf_lg_1_name = attribute( - 'lmf_lg_1_name', - default: 'default.lmf_lg_1_name', - description: 'Name of a Cloudwatch Log Group', -) - -lmf_lg_2_name = attribute( - 'lmf_lg_2_name', - default: 'default.lmf_lg_2_name', - description: 'Name of a Cloudwatch Log Group', -) - -lmf_1_metric_1_name = attribute( - 'lmf_1_metric_1_name', - default: 'default.lmf_1_metric_1_name', - description: 'Name of a Cloudwatch Metric', -) - -describe aws_cloudwatch_log_metric_filter( - filter_name: lmf_1_name, - log_group_name: lmf_lg_1_name, -) do - it { should exist } - its('pattern') { should cmp 'testpattern01'} - its('metric_name') { should cmp lmf_1_metric_1_name } -end - -describe aws_cloudwatch_log_metric_filter( - pattern: 'testpattern02', -) do - it { should exist } - its('log_group_name') { should cmp lmf_lg_2_name } - its('filter_name') { should cmp lmf_2_name } -end diff --git a/test/integration/verify/controls/aws_ec2_instance.rb b/test/integration/verify/controls/aws_ec2_instance.rb deleted file mode 100644 index 12a5804cf..000000000 --- a/test/integration/verify/controls/aws_ec2_instance.rb +++ /dev/null @@ -1,37 +0,0 @@ -example_ec2_id = attribute( - 'example_ec2_id', - default: 'default.example_ec2_id', - description: 'ID of example ec2 instance') - -example_ec2_name = attribute( - 'example_ec2_name', - default: 'default.Example', - description: 'Name of example ec2 instance') - -no_roles_ec2_id = attribute( - 'no_roles_ec2_id', - default: 'default.no_roles_ec2_id', - description: 'ID of no-roles ec2 instance') - -describe aws_ec2_instance(name: example_ec2_name) do - it { should exist } - its('image_id') { should eq 'ami-0d729a60' } - its('instance_type') { should eq 't2.micro' } -end - -describe aws_ec2_instance(example_ec2_id) do - it { should exist } - its('image_id') { should eq 'ami-0d729a60' } - its('instance_type') { should eq 't2.micro' } - it { should have_roles } -end - -describe aws_ec2_instance(no_roles_ec2_id) do - it { should exist } - it { should_not have_roles } -end - -# must use a real EC2 instance name, as the SDK will first check to see if it's well formed before sending requests -describe aws_ec2_instance('i-06b4bc106e0d03dfd') do - it { should_not exist } -end diff --git a/test/integration/verify/controls/aws_iam_access_key.rb b/test/integration/verify/controls/aws_iam_access_key.rb deleted file mode 100644 index 86dbfe5ff..000000000 --- a/test/integration/verify/controls/aws_iam_access_key.rb +++ /dev/null @@ -1,60 +0,0 @@ -access_key_user = attribute( - 'access_key_user', - default: 'default.access_key_user', - description: 'Name of IAM user access_key_user') - -access_key_id = attribute( - 'access_key_id', - default: 'AKIA1234567890AZFAKE', - description: 'Access Key ID of access key of IAM user access_key_user') - -describe aws_iam_access_key(username: 'not-a-user', 'id': 'not-an-id') do - it { should_not exist } -end - -describe aws_iam_access_key(username: access_key_user, 'id': access_key_id) do - it { should exist } - # TODO - check last used, created, other key metadata -end - -control 'IAM Access Keys' do - title 'Fetch all' - describe aws_iam_access_keys do - it { should exist } - end -end - - -control 'IAM Access Keys' do - title 'Client-side filtering' - all_keys = aws_iam_access_keys - describe all_keys.where(username: access_key_user) do - its('entries.length') { should be 1 } - its('access_key_ids.first') { should eq access_key_id } - end - describe all_keys.where(created_days_ago: 0) do - it { should exist } - end - describe all_keys.where { active } do - it { should exist } - end - describe all_keys.where { ever_used } - .where { last_used_days_ago > 0 } do - it { should exist } - end - describe all_keys.where { created_with_user } do - it { should exist } - end -end - -control 'AKS3' do - title 'Fetch-time filtering' - describe aws_iam_access_keys(username: access_key_user) do - its('entries.length') { should be 1 } - its('access_key_ids.first') { should eq access_key_id } - end - - describe aws_iam_access_keys(username: 'i-dont-exist-presumably') do - it { should_not exist } - end -end \ No newline at end of file diff --git a/test/integration/verify/controls/aws_iam_user.rb b/test/integration/verify/controls/aws_iam_user.rb deleted file mode 100644 index f67fe6bb4..000000000 --- a/test/integration/verify/controls/aws_iam_user.rb +++ /dev/null @@ -1,18 +0,0 @@ -mfa_not_enabled_user = attribute( - 'mfa_not_enabled_user', - default: 'default.mfa_not_enabled_user', - description: 'Name of IAM user mfa_not_enabled_user') - -console_password_enabled_user = attribute( - 'console_password_enabled_user', - default: 'default.console_password_enabled_user', - description: 'Name of IAM user console_password_enabled_user') - -describe aws_iam_user(mfa_not_enabled_user) do - it { should_not have_mfa_enabled } - it { should_not have_console_password } -end - -describe aws_iam_user(console_password_enabled_user) do - it { should have_console_password } -end diff --git a/test/integration/verify/controls/aws_sns_topic.rb b/test/integration/verify/controls/aws_sns_topic.rb deleted file mode 100644 index 0a34030de..000000000 --- a/test/integration/verify/controls/aws_sns_topic.rb +++ /dev/null @@ -1,34 +0,0 @@ -sns_topic_with_subscription_arn = attribute( - 'sns_test_topic_01_arn', - default: 'default.sns_test_topic_01_arn', - description: 'ARN of an SNS topic with at least one subscription') - -sns_topic_with_no_subscriptions_arn = attribute( - 'sns_test_topic_02_arn', - default: 'default.sns_test_topic_02_arn', - description: 'ARN of an SNS topic with no subscriptions') - -control 'SNS Topics' do - # Split the ARNs so we can test things - scheme, partition, service, region, account, topic = sns_topic_with_subscription_arn.split(':') - arn_prefix = [scheme, partition, service].join(':') - - # Search miss - no_such_topic_arn = [arn_prefix, region, account, 'no-such-topic-for-realz'].join(':') - describe aws_sns_topic(no_such_topic_arn) do - it { should_not exist } - end - - # Search hit, fully specified, has subscriptions - describe aws_sns_topic(sns_topic_with_subscription_arn) do - it { should exist } - its('confirmed_subscription_count') { should_not be_zero } - end - - # Search hit, fully specified, has no subscriptions - describe aws_sns_topic(sns_topic_with_no_subscriptions_arn) do - it { should exist } - its('confirmed_subscription_count') { should be_zero } - end - -end \ No newline at end of file From efffcfd9289cf5fa8b77ad6ce771d4b54057ef66 Mon Sep 17 00:00:00 2001 From: Sam Cornwell <14048146+samcornwell@users.noreply.github.com> Date: Wed, 20 Dec 2017 12:04:43 -0500 Subject: [PATCH 143/206] pin Terraform aws provider verson to 1.1 (#151) Signed-off-by: Sam Cornwell <14048146+samcornwell@users.noreply.github.com> --- test/integration/default/build/aws.tf | 4 +++- test/integration/minimal/build/aws.tf | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/test/integration/default/build/aws.tf b/test/integration/default/build/aws.tf index 382aaf4eb..ca40f2d15 100644 --- a/test/integration/default/build/aws.tf +++ b/test/integration/default/build/aws.tf @@ -2,7 +2,9 @@ terraform { required_version = "~> 0.10.0" } -provider "aws" {} +provider "aws" { + version = "= 1.1" +} data "aws_caller_identity" "creds" {} output "aws_account_id" { diff --git a/test/integration/minimal/build/aws.tf b/test/integration/minimal/build/aws.tf index 382aaf4eb..ca40f2d15 100644 --- a/test/integration/minimal/build/aws.tf +++ b/test/integration/minimal/build/aws.tf @@ -2,7 +2,9 @@ terraform { required_version = "~> 0.10.0" } -provider "aws" {} +provider "aws" { + version = "= 1.1" +} data "aws_caller_identity" "creds" {} output "aws_account_id" { From 9784520d836567826556510c5af23c2575182188 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Wed, 20 Dec 2017 12:20:09 -0500 Subject: [PATCH 144/206] fixed 'count' to 'entries.count' in `aws_ec2_security_groups` as per the docs (#142) * * added 'count' to `aws_ec2_security_groups` Fixes #141 Signed-off-by: Aaron Lippold --- docs/resources/aws_ec2_security_groups.md | 2 +- libraries/aws_ec2_security_groups.rb | 10 ++++++++-- test/integration/default/build/aws.tf | 2 +- test/integration/default/build/ec2.tf | 4 ++-- .../default/verify/controls/aws_ec2_security_groups.rb | 9 ++++++++- test/integration/minimal/build/aws.tf | 2 +- 6 files changed, 21 insertions(+), 8 deletions(-) diff --git a/docs/resources/aws_ec2_security_groups.md b/docs/resources/aws_ec2_security_groups.md index ed3fa6496..efddd8f36 100644 --- a/docs/resources/aws_ec2_security_groups.md +++ b/docs/resources/aws_ec2_security_groups.md @@ -16,7 +16,7 @@ An `aws_ec2_security_groups` resource block uses an optional filter to select a # Verify you have more than the default security group describe aws_ec2_security_groups do - its('count') { should be > 1 } + its('entries.count') { should be > 1 } end
diff --git a/libraries/aws_ec2_security_groups.rb b/libraries/aws_ec2_security_groups.rb index 74878432e..98daee437 100644 --- a/libraries/aws_ec2_security_groups.rb +++ b/libraries/aws_ec2_security_groups.rb @@ -1,11 +1,17 @@ class AwsEc2SecurityGroups < Inspec.resource(1) name 'aws_ec2_security_groups' desc 'Verifies settings for AWS Security Groups in bulk' - example ' + example " + # Verify that you have security groups defined describe aws_ec2_security_groups do it { should exist } end - ' + + # Verify you have more than the default security group + describe aws_ec2_security_groups do + its('entries.count') { should be > 1 } + end + " # Constructor. Args are reserved for row fetch filtering. def initialize(raw_criteria = {}) diff --git a/test/integration/default/build/aws.tf b/test/integration/default/build/aws.tf index ca40f2d15..3c6d6a68e 100644 --- a/test/integration/default/build/aws.tf +++ b/test/integration/default/build/aws.tf @@ -9,4 +9,4 @@ provider "aws" { data "aws_caller_identity" "creds" {} output "aws_account_id" { value = "${data.aws_caller_identity.creds.account_id}" -} \ No newline at end of file +} diff --git a/test/integration/default/build/ec2.tf b/test/integration/default/build/ec2.tf index 062609834..06f69f871 100644 --- a/test/integration/default/build/ec2.tf +++ b/test/integration/default/build/ec2.tf @@ -170,7 +170,7 @@ output "ec2_security_group_default_group_id" { value = "${data.aws_security_group.default.id}" } -# Create a security group with a known description +# Create a security group with a known description # in the default VPC resource "aws_security_group" "alpha" { name = "alpha" @@ -180,4 +180,4 @@ resource "aws_security_group" "alpha" { output "ec2_security_group_alpha_group_id" { value = "${aws_security_group.alpha.id}" -} \ No newline at end of file +} diff --git a/test/integration/default/verify/controls/aws_ec2_security_groups.rb b/test/integration/default/verify/controls/aws_ec2_security_groups.rb index fc170d823..2e5c33121 100644 --- a/test/integration/default/verify/controls/aws_ec2_security_groups.rb +++ b/test/integration/default/verify/controls/aws_ec2_security_groups.rb @@ -40,4 +40,11 @@ control "aws_security_groups properties" do describe aws_ec2_security_groups.where(vpc_id: fixtures['ec2_security_group_default_vpc_id']) do its('group_ids') { should include fixtures['ec2_security_group_default_group_id'] } end -end \ No newline at end of file +end + +control "aws_ec2_security_groups" do + # Verify you have more than the default security group + describe aws_ec2_security_groups do + its('entries.count') { should be >= 2 } + end +end diff --git a/test/integration/minimal/build/aws.tf b/test/integration/minimal/build/aws.tf index ca40f2d15..3c6d6a68e 100644 --- a/test/integration/minimal/build/aws.tf +++ b/test/integration/minimal/build/aws.tf @@ -9,4 +9,4 @@ provider "aws" { data "aws_caller_identity" "creds" {} output "aws_account_id" { value = "${data.aws_caller_identity.creds.account_id}" -} \ No newline at end of file +} From 16add67a8091b2ed543efb03de3fb2e6e807f4d7 Mon Sep 17 00:00:00 2001 From: Mo Shark Date: Fri, 5 Jan 2018 13:12:06 -0500 Subject: [PATCH 145/206] adding group id to the to_s method. (#170) Signed-off-by: HackerShark --- libraries/aws_ec2_security_group.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/aws_ec2_security_group.rb b/libraries/aws_ec2_security_group.rb index 61e019493..50874b5d1 100644 --- a/libraries/aws_ec2_security_group.rb +++ b/libraries/aws_ec2_security_group.rb @@ -11,7 +11,7 @@ class AwsEc2SecurityGroup < Inspec.resource(1) attr_reader :description, :group_id, :group_name, :vpc_id def to_s - 'EC2 Security Group' + "EC2 Security Group #{@group_id}" end private From 3d731a81f8940dff8ec103c5a20214263f11c08d Mon Sep 17 00:00:00 2001 From: Sam Cornwell <14048146+samcornwell@users.noreply.github.com> Date: Fri, 12 Jan 2018 01:51:17 -0500 Subject: [PATCH 146/206] aws_vpc resource skeleton (#172) Signed-off-by: Clinton Wolfe Signed-off-by: Sam Cornwell <14048146+samcornwell@users.noreply.github.com> --- docs/resources/aws_vpc.md | 110 ++++++++++++ libraries/aws_vpc.rb | 67 +++++++ test/integration/default/build/ec2.tf | 16 ++ .../default/verify/controls/aws_vpc.rb | 59 +++++++ test/unit/resources/aws_vpc_test.rb | 163 ++++++++++++++++++ 5 files changed, 415 insertions(+) create mode 100644 docs/resources/aws_vpc.md create mode 100644 libraries/aws_vpc.rb create mode 100644 test/integration/default/verify/controls/aws_vpc.rb create mode 100644 test/unit/resources/aws_vpc_test.rb diff --git a/docs/resources/aws_vpc.md b/docs/resources/aws_vpc.md new file mode 100644 index 000000000..7edd8eff9 --- /dev/null +++ b/docs/resources/aws_vpc.md @@ -0,0 +1,110 @@ +--- +title: About the aws_vpc Resource +--- + +# aws_vpc + +Use the `aws_vpc` InSpec audit resource to test properties of a single AWS Virtual Private Cloud (VPC). + +To test properties of all or multiple VPCs, use the `aws_vpcs` resource. + +A VPC is a networking construct that provides an isolated environment. A VPC is contained in a geographic region, but spans availability zones in that region. Within a VPC, you may have multiple subnets, internet gateways, and other networking resources. Computing resources such as EC2 instances reside on subnets within the VPC. + +Each VPC is uniquely identified by its VPC ID. In addition, each VPC has a non-unique CIDR IP Address range (such as 10.0.0.0/16) which it manages. + +Every AWS account has at least one VPC, the "default" VPC, in every region. + +
+ +## Syntax + +An `aws_vpc` resource block identifies a VPC by id. If no VPC ID is provided, the default VPC is used. + + # Find the default VPC + describe aws_vpc do + it { should exist } + end + + # Find a VPC by ID + describe aws_vpc('vpc-12345678') do + it { should exist } + end + + # Hash syntax for ID + describe aws_vpc(vpc_id: 'vpc-12345678') do + it { should exist } + end + +
+ +## Examples + +The following examples show how to use this InSpec audit resource. + +### Test that a VPC does not exist + + describe aws_vpc('vpc-87654321') do + it { should_not exist } + end + +### Test the CIDR of a named VPC + + describe aws_vpc('vpc-87654321') do + its('cidr_block') { should cmp '10.0.0.0/16' } + end + +
+ +## Matchers + +This InSpec audit resource has the following special matchers. For a full list of available matchers (such as `exist`) please visit our [matchers page](https://www.inspec.io/docs/reference/matchers/). + +### be_default + +The test will pass if the identified VPC is the default VPC for the region. + + describe aws_vpc('vpc-87654321') do + it { should be_default } + end + +## Properties + +### cidr_block + +The IPv4 address range that is managed by the VPC. + + describe aws_vpc('vpc-87654321') do + its('cidr_block') { should cmp '10.0.0.0/16' } + end + +### dhcp\_options\_id + +The ID of the set of DHCP options you've associated with the VPC (or `default` if the default options are associated with the VPC). + + describe aws_vpc do + its ('dhcp_options_id') { should eq 'dopt-a94671d0' } + end + +### state + +The state of the VPC (`pending` | `available`). + + describe aws_vpc do + its ('state') { should eq 'available' } + end + +### vpc_id + +The ID of the VPC. + + describe aws_vpc do + its('vpc_id') { should eq 'vpc-87654321' } + end + +### instance_tenancy + +The allowed tenancy of instances launched into the VPC. + + describe aws_vpc do + its ('instance_tenancy') { should eq 'default' } + end diff --git a/libraries/aws_vpc.rb b/libraries/aws_vpc.rb new file mode 100644 index 000000000..3ec01b1c1 --- /dev/null +++ b/libraries/aws_vpc.rb @@ -0,0 +1,67 @@ +class AwsVpc < Inspec.resource(1) + name 'aws_vpc' + desc 'Verifies settings for AWS VPC' + example " + describe aws_vpc do + it { should be_default } + its('cidr_block') { should cmp '10.0.0.0/16' } + end + " + + include AwsResourceMixin + + def to_s + "VPC #{vpc_id}" + end + + [:cidr_block, :dhcp_options_id, :state, :vpc_id, :instance_tenancy, :is_default].each do |property| + define_method(property) do + @vpc[property] + end + end + + alias default? is_default + + private + + def validate_params(raw_params) + validated_params = check_resource_param_names( + raw_params: raw_params, + allowed_params: [:vpc_id], + allowed_scalar_name: :vpc_id, + allowed_scalar_type: String, + ) + + if validated_params.key?(:vpc_id) && validated_params[:vpc_id] !~ /^vpc\-[0-9a-f]{8}/ + raise ArgumentError, 'aws_vpc VPC ID must be in the format "vpc-" followed by 8 hexadecimal characters.' + end + + validated_params + end + + def fetch_from_aws + backend = AwsVpc::BackendFactory.create + + if @vpc_id.nil? + filter = { name: 'isDefault', values: ['true'] } + else + filter = { name: 'vpc-id', values: [@vpc_id] } + end + + resp = backend.describe_vpcs({ filters: [filter] }) + + @vpc = resp.vpcs[0].to_h + @vpc_id = @vpc[:vpc_id] + @exists = !@vpc.empty? + end + + class Backend + class AwsClientApi + BackendFactory.set_default_backend(self) + + def describe_vpcs(query) + AWSConnection.new.ec2_client.describe_vpcs(query) + end + end + end +end diff --git a/test/integration/default/build/ec2.tf b/test/integration/default/build/ec2.tf index 06f69f871..d2be103a7 100644 --- a/test/integration/default/build/ec2.tf +++ b/test/integration/default/build/ec2.tf @@ -170,6 +170,22 @@ output "ec2_security_group_default_group_id" { value = "${data.aws_security_group.default.id}" } +resource "aws_vpc" "non_default" { + cidr_block = "172.32.0.0/16" +} + +output "vpc_non_default_id" { + value = "${aws_vpc.non_default.id}" +} + +output "vpc_non_default_cidr_block" { + value = "${aws_vpc.non_default.cidr_block}" +} + +output "vpc_non_default_instance_tenancy" { + value = "${aws_vpc.non_default.instance_tenancy}" +} + # Create a security group with a known description # in the default VPC resource "aws_security_group" "alpha" { diff --git a/test/integration/default/verify/controls/aws_vpc.rb b/test/integration/default/verify/controls/aws_vpc.rb new file mode 100644 index 000000000..4fff46542 --- /dev/null +++ b/test/integration/default/verify/controls/aws_vpc.rb @@ -0,0 +1,59 @@ +fixtures = {} +[ + 'ec2_security_group_default_vpc_id', + 'vpc_non_default_id', + 'vpc_non_default_cidr_block', + 'vpc_non_default_instance_tenancy' +].each do |fixture_name| + fixtures[fixture_name] = attribute( + fixture_name, + default: "default.#{fixture_name}", + description: 'See ../build/ec2.tf', + ) +end + +control "aws_vpc recall" do + describe aws_vpc(fixtures['ec2_security_group_default_vpc_id']) do + it { should exist} + end + + describe aws_vpc do + it { should exist } + end + + describe aws_vpc(vpc_id: fixtures['vpc_non_default_id']) do + it { should exist } + end + + describe aws_vpc('vpc-12345678') do + it { should_not exist } + end +end + +control "aws_vpc properties" do + describe aws_vpc(fixtures['vpc_non_default_id']) do + its('vpc_id') { should eq fixtures['vpc_non_default_id'] } + its('state') { should eq 'available' } + its('cidr_block') { should eq fixtures['vpc_non_default_cidr_block']} + its('instance_tenancy') { should eq fixtures['vpc_non_default_instance_tenancy']} + # TODO: figure out how to access the dhcp_options_id + end + + describe aws_vpc do + its('vpc_id') { should eq fixtures['ec2_security_group_default_vpc_id'] } + end +end + +control "aws_vpc matchers" do + describe aws_vpc do + it { should be_default } + end + + describe aws_vpc(fixtures['ec2_security_group_default_vpc_id']) do + it { should be_default } + end + + describe aws_vpc(fixtures['vpc_non_default_id']) do + it { should_not be_default } + end +end diff --git a/test/unit/resources/aws_vpc_test.rb b/test/unit/resources/aws_vpc_test.rb new file mode 100644 index 000000000..8a067b054 --- /dev/null +++ b/test/unit/resources/aws_vpc_test.rb @@ -0,0 +1,163 @@ +require 'helper' +require 'aws_vpc' + +# MAVSB = MockAwsVpcSingularBackend +# Abbreviation not used outside this file + +#=============================================================================# +# Constructor Tests +#=============================================================================# +class AwsVpcConstructorTest < Minitest::Test + + def setup + AwsVpc::BackendFactory.select(MAVSB::Empty) + end + + def test_empty_params_ok + AwsVpc.new + end + + def test_accepts_vpc_id_as_scalar + AwsVpc.new('vpc-12345678') + end + + def test_accepts_vpc_id_as_hash + AwsVpc.new(vpc_id: 'vpc-1234abcd') + end + + def test_rejects_unrecognized_params + assert_raises(ArgumentError) { AwsVpc.new(shoe_size: 9) } + end + + def test_rejects_invalid_vpc_id + assert_raises(ArgumentError) { AwsVpc.new('vpc-rofl') } + end +end + + +#=============================================================================# +# Search / Recall +#=============================================================================# +class AwsVpcRecallTest < Minitest::Test + + def setup + AwsVpc::BackendFactory.select(MAVSB::Basic) + end + + def test_search_hit_via_default_works + assert AwsVpc.new.exists? + end + + def test_search_hit_via_scalar_works + assert AwsVpc.new('vpc-12344321').exists? + end + + def test_search_hit_via_hash_works + assert AwsVpc.new(vpc_id: 'vpc-12344321').exists? + end + + def test_search_miss_is_not_an_exception + refute AwsVpc.new(vpc_id: 'vpc-00000000').exists? + end +end + +#=============================================================================# +# Properties +#=============================================================================# +class AwsVpcPropertiesTest < Minitest::Test + + def setup + AwsVpc::BackendFactory.select(MAVSB::Basic) + end + + def test_property_vpc_id + assert_equal('vpc-aaaabbbb', AwsVpc.new('vpc-aaaabbbb').vpc_id) + assert_nil(AwsVpc.new(vpc_id: 'vpc-00000000').vpc_id) + end + + def test_property_cidr_block + assert_equal('10.0.0.0/16', AwsVpc.new('vpc-aaaabbbb').cidr_block) + assert_nil(AwsVpc.new('vpc-00000000').cidr_block) + end + + def test_property_dhcp_options_id + assert_equal('dopt-aaaabbbb', AwsVpc.new('vpc-aaaabbbb').dhcp_options_id) + assert_nil(AwsVpc.new('vpc-00000000').dhcp_options_id) + end + + def test_property_state + assert_equal('available', AwsVpc.new('vpc-12344321').state) + assert_nil(AwsVpc.new('vpc-00000000').state) + end + + def test_property_instance_tenancy + assert_equal('default', AwsVpc.new('vpc-12344321').instance_tenancy) + assert_nil(AwsVpc.new('vpc-00000000').instance_tenancy) + end +end + + +#=============================================================================# +# Matchers +#=============================================================================# +class AwsVpcMatchersTest < Minitest::Test + + def setup + AwsVpc::BackendFactory.select(MAVSB::Basic) + end + + def test_matcher_default_positive + assert AwsVpc.new('vpc-aaaabbbb').default? + end + + def test_matcher_default_negative + refute AwsVpc.new('vpc-12344321').default? + end + +end + +#=============================================================================# +# Test Fixtures +#=============================================================================# +module MAVSB + class Empty < AwsVpc::Backend + def describe_vpcs(query) + OpenStruct.new(vpcs: []) + end + end + + class Basic < AwsVpc::Backend + def describe_vpcs(query) + fixtures = [ + OpenStruct.new({ + cidr_block: '10.0.0.0/16', + dhcp_options_id: 'dopt-aaaabbbb', + state: 'available', + vpc_id: 'vpc-aaaabbbb', + instance_tenancy: 'default', + is_default: true + }), + OpenStruct.new({ + cidr_block: '10.1.0.0/16', + dhcp_options_id: 'dopt-43211234', + state: 'available', + vpc_id: 'vpc-12344321', + instance_tenancy: 'default', + is_default: false + }), + ] + + selected = fixtures.select do |vpc| + query[:filters].all? do |filter| + if filter[:name].eql? "isDefault" + filter[:name] = "is_default" + end + filter[:values].include?(vpc[filter[:name].tr('-','_')].to_s) + end + end + + OpenStruct.new({ vpcs: selected }) + end + end + +end From e81937413b473e280aaa7173e1300b1912ff0e6f Mon Sep 17 00:00:00 2001 From: Sam Cornwell <14048146+samcornwell@users.noreply.github.com> Date: Thu, 18 Jan 2018 10:51:06 -0500 Subject: [PATCH 147/206] skeletal aws_vpcs (#182) Signed-off-by: Sam Cornwell <14048146+samcornwell@users.noreply.github.com> --- docs/resources/aws_vpcs.md | 45 +++++++++ libraries/aws_vpcs.rb | 42 ++++++++ .../default/verify/controls/aws_vpcs.rb | 5 + test/unit/resources/aws_vpcs_test.rb | 97 +++++++++++++++++++ 4 files changed, 189 insertions(+) create mode 100644 docs/resources/aws_vpcs.md create mode 100644 libraries/aws_vpcs.rb create mode 100644 test/integration/default/verify/controls/aws_vpcs.rb create mode 100644 test/unit/resources/aws_vpcs_test.rb diff --git a/docs/resources/aws_vpcs.md b/docs/resources/aws_vpcs.md new file mode 100644 index 000000000..a83537102 --- /dev/null +++ b/docs/resources/aws_vpcs.md @@ -0,0 +1,45 @@ +--- +title: About the aws_vpcs Resource +--- + +# aws_vpcs + +Use the `aws_vpcs` InSpec audit resource to test properties of some or all AWS Virtual Private Clouds (VPCs). + +A VPC is a networking construct that provides an isolated environment. A VPC is contained in a geographic region, but spans availability zones in that region. Within a VPC, you may have multiple subnets, internet gateways, and other networking resources. Computing resources such as EC2 instances reside on subnets within the VPC. + +Each VPC is uniquely identified by its VPC ID. In addition, each VPC has a non-unique CIDR IP Address range (such as 10.0.0.0/16) which it manages. + +Every AWS account has at least one VPC, the "default" VPC, in every region. + +
+ +## Syntax + +An `aws_vpcs` resource block uses an optional filter to select a group of VPCs and then tests that group. + + # The control will pass if the filter returns at least one result. Use should_not if you expect zero matches. + describe aws_vpcs do + it { should exist } + end + +
+ +## Examples + +The following examples show how to use this InSpec audit resource. + +As this is the initial release of `aws_vpcs`, its limited functionality precludes examples. + +
+ +## Matchers + +### exists + +The control will pass if the filter returns at least one result. Use should_not if you expect zero matches. + + # You will always have at least one VPC + describe aws_vpcs + it { should exist } + end diff --git a/libraries/aws_vpcs.rb b/libraries/aws_vpcs.rb new file mode 100644 index 000000000..45da8dc7f --- /dev/null +++ b/libraries/aws_vpcs.rb @@ -0,0 +1,42 @@ +class AwsVpcs < Inspec.resource(1) + name 'aws_vpcs' + desc 'Verifies settings for AWS VPCs in bulk' + example ' + describe aws_vpcs do + it { should exist } + end + ' + + # Underlying FilterTable implementation. + filter = FilterTable.create + filter.add_accessor(:entries) + .add(:exists?) { |x| !x.entries.empty? } + filter.connect(self, :vpc_data) + + def vpc_data + @table + end + + def to_s + 'VPCs' + end + + def initialize + backend = AwsVpcs::BackendFactory.create + @table = backend.describe_vpcs.to_h[:vpcs] + end + + class BackendFactory + extend AwsBackendFactoryMixin + end + + class Backend + class AwsClientApi + BackendFactory.set_default_backend(self) + + def describe_vpcs(query = {}) + AWSConnection.new.ec2_client.describe_vpcs(query) + end + end + end +end diff --git a/test/integration/default/verify/controls/aws_vpcs.rb b/test/integration/default/verify/controls/aws_vpcs.rb new file mode 100644 index 000000000..bc6461dea --- /dev/null +++ b/test/integration/default/verify/controls/aws_vpcs.rb @@ -0,0 +1,5 @@ +control "aws_vpcs recall" do + describe aws_vpcs do + it { should exist } + end +end diff --git a/test/unit/resources/aws_vpcs_test.rb b/test/unit/resources/aws_vpcs_test.rb new file mode 100644 index 000000000..bd3786f6e --- /dev/null +++ b/test/unit/resources/aws_vpcs_test.rb @@ -0,0 +1,97 @@ +require 'helper' +require 'aws_vpcs' + +# MAVPB = MockAwsVpcsPluralBackend +# Abbreviation not used outside this file + +#=============================================================================# +# Constructor Tests +#=============================================================================# +class AwsVpcsConstructorTest < Minitest::Test + + def setup + AwsVpcs::BackendFactory.select(MAVPB::Empty) + end + + def test_empty_params_ok + AwsVpcs.new + end + + def test_rejects_unrecognized_params + assert_raises(ArgumentError) { AwsVpcs.new(shoe_size: 9) } + end +end + + +#=============================================================================# +# Search / Recall +#=============================================================================# +class AwsVpcsRecallEmptyTest < Minitest::Test + + def setup + AwsVpcs::BackendFactory.select(MAVPB::Empty) + end + + def test_search_miss_via_empty_vpcs + refute AwsVpcs.new.exists? + end +end + +class AwsVpcsRecallBasicTest < Minitest::Test + + def setup + AwsVpcs::BackendFactory.select(MAVPB::Basic) + end + + def test_search_hit_via_empty_filter + assert AwsVpcs.new.exists? + end +end + +#=============================================================================# +# Test Fixtures +#=============================================================================# +module MAVPB + class Empty < AwsVpcs::Backend + def describe_vpcs(query = {}) + OpenStruct.new({ vpcs: [] }) + end + end + + class Basic < AwsVpcs::Backend + def describe_vpcs(query = {}) + fixtures = [ + OpenStruct.new({ + cidr_block: '10.0.0.0/16', + dhcp_options_id: 'dopt-aaaabbbb', + state: 'available', + vpc_id: 'vpc-aaaabbbb', + instance_tenancy: 'default', + is_default: true + }), + OpenStruct.new({ + cidr_block: '10.1.0.0/16', + dhcp_options_id: 'dopt-43211234', + state: 'available', + vpc_id: 'vpc-12344321', + instance_tenancy: 'default', + is_default: false + }), + ] + + query[:filters] = [] if query[:filters].nil? + + selected = fixtures.select do |vpc| + query[:filters].all? do |filter| + if filter[:name].eql? "isDefault" + filter[:name] = "is_default" + end + filter[:values].include?(vpc[filter[:name].tr('-','_')].to_s) + end + end + + OpenStruct.new({ vpcs: selected }) + end + end + +end From 5ab68ecf0329a596674aa26d3592cdac4db745ee Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Fri, 19 Jan 2018 11:50:08 -0500 Subject: [PATCH 148/206] aws_s3_bucket with modified interface (#183) Signed-off-by: Matthew Dromazos Signed-off-by: Aaron Lippold Signed-off-by: Sam Cornwell <14048146+samcornwell@users.noreply.github.com> Signed-off-by: Clinton Wolfe --- docs/resources/aws_s3_bucket.md | 123 ++++++++ libraries/_aws_connection.rb | 4 + libraries/aws_s3_bucket.rb | 100 ++++++ .../integration/default/build/inspec-logo.png | Bin 0 -> 8501 bytes test/integration/default/build/s3.tf | 100 ++++++ .../verify/controls/aws_iam_root_user.rb | 1 - .../default/verify/controls/aws_iam_user.rb | 1 - .../default/verify/controls/aws_s3_bucket.rb | 113 +++++++ test/unit/resources/aws_s3_bucket_test.rb | 289 ++++++++++++++++++ test/unit/resources/aws_vpc.notes | 91 ++++++ 10 files changed, 820 insertions(+), 2 deletions(-) create mode 100644 docs/resources/aws_s3_bucket.md create mode 100644 libraries/aws_s3_bucket.rb create mode 100644 test/integration/default/build/inspec-logo.png create mode 100644 test/integration/default/build/s3.tf create mode 100644 test/integration/default/verify/controls/aws_s3_bucket.rb create mode 100644 test/unit/resources/aws_s3_bucket_test.rb create mode 100644 test/unit/resources/aws_vpc.notes diff --git a/docs/resources/aws_s3_bucket.md b/docs/resources/aws_s3_bucket.md new file mode 100644 index 000000000..5f43e4e71 --- /dev/null +++ b/docs/resources/aws_s3_bucket.md @@ -0,0 +1,123 @@ +--- +title: About the aws_s3_bucket Resource +--- + +# aws_s3_bucket + +Use the `aws_s3_bucket` InSpec audit resource to test properties of a single AWS bucket. + +To test properties of a multiple S3 buckets, use the `aws_s3_buckets` resource. + +
+ +## Limitations + +S3 bucket security is a complex matter. For details on how AWS evaluates requests for access, please see [the AWS documentation](https://docs.aws.amazon.com/AmazonS3/latest/dev/how-s3-evaluates-access-control.html). S3 buckets and the objects they contain support three different types of access control: bucket ACLs, bucket policies, and object ACLs. + +As of January 2018, this resource supports evaluating bucket ACLs and bucket policies. We do not support evaluating object ACLs because it introduces scalability concerns in the AWS API; we recommend using AWS mechanisms such as CloudTrail and Config to detect insecure object ACLs. + +In particular, users of the `be_public` matcher should carefully examine the conditions under which the matcher will detect an insecure bucket. See the `be_public` section under the Matchers section below. + +## Syntax + +An `aws_s3_bucket` resource block declares a bucket by name, and then lists tests to be performed. + + describe aws_s3_bucket(bucket_name: 'test_bucket') do + it { should exist } + it { should_not be_public } + end + + describe aws_s3_bucket('test_bucket') do + it { should exist } + end + +
+ +## Examples + +The following examples show how to use this InSpec audit resource. + +### Test a bucket's bucket-level ACL + + describe aws_s3_bucket('test_bucket') do + its('bucket_acl.count') { should eq 1 } + end + +### Check to see if a bucket has a bucket policy + + describe aws_s3_bucket('test_bucket') do + its('bucket_policy') { should be_empty } + end + +### Check to see if a bucket appears to be exposed to the public + + # See Limitations section above + describe aws_s3_bucket('test_bucket') do + it { should_not be_public } + end +
+ +## Supported Properties + +### region + +The `region` property identifies the AWS Region in which the S3 bucket is located. + + describe aws_s3_bucket('test_bucket') do + # Check if the correct region is set + its('region') { should eq 'us-east-1' } + end + +## Unsupported Properties + +### bucket_acl + +The `bucket_acl` property is a low-level property that lists the individual Bucket ACL grants that are in effect on the bucket. Other higher-level properties, such as be\_public, are more concise and easier to use. You can use the `bucket_acl` property to investigate which grants are in effect, causing be\_public to fail. + +The value of bucket_acl is an Array of simple objects. Each object has a `permission` property and a `grantee` property. The `permission` property will be a string such as 'READ', 'WRITE' etc (See the [AWS documentation](https://docs.aws.amazon.com/sdkforruby/api/Aws/S3/Client.html#get_bucket_acl-instance_method) for a full list). The `grantee` property contains sub-properties, such as `type` and `uri`. + + + bucket_acl = aws_s3_bucket('my-bucket') + + # Look for grants to "AllUsers" (that is, the public) + all_users_grants = bucket_acl.select do |g| + g.grantee.type == 'Group' && g.grantee.uri =~ /AllUsers/ + end + + # Look for grants to "AuthenticatedUsers" (that is, any authenticated AWS user - nearly public) + auth_grants = bucket_acl.select do |g| + g.grantee.type == 'Group' && g.grantee.uri =~ /AuthenticatedUsers/ + end + +### bucket_policy + +The `bucket_policy` is a low-level property that describes the IAM policy document controlling access to the bucket. The `bucket_policy` property returns a Ruby structure that you can probe to check for particular statements. We recommend using a higher-level property, such as `be_public`, which is concise and easier to implement in your policy files. + +The `bucket_policy` property returns an Array of simple objects, each object being an IAM Policy Statement. See the [AWS documentation](https://docs.aws.amazon.com/AmazonS3/latest/dev/example-bucket-policies.html#example-bucket-policies-use-case-2) for details about the structure of this data. + +If there is no bucket policy, this property will return an empty Array. + + bucket_policy = aws_s3_bucket('my-bucket') + + # Look for statements that allow the general public to do things + # This may be a false positive; it's possible these statements + # could be protected by conditions, such as IP restrictions. + public_statements = bucket_policy.select do |s| + s.effect == 'Allow' && s.principal == '*' + end + +## Matchers + +This InSpec audit resource has the following special matchers. For a full list of available matchers (such as `exist`) please visit our [matchers page](https://www.inspec.io/docs/reference/matchers/). + +### be_public + +The `be_public` matcher tests if the bucket has potentially insecure access controls. This high-level matcher detects several insecure conditions, which may be enhanced in the future. Currently, the matcher reports an insecure bucket if any of the following conditions are met: + + 1. A bucket ACL grant exists for the 'AllUsers' group + 2. A bucket ACL grant exists for the 'AuthenticatedUsers' group + 3. A bucket policy has an effect 'Allow' and principal '*' + +Note: This resource does not detect insecure object ACLs. + + it { should_not be_public } diff --git a/libraries/_aws_connection.rb b/libraries/_aws_connection.rb index ad5ad91dd..6922dbb37 100644 --- a/libraries/_aws_connection.rb +++ b/libraries/_aws_connection.rb @@ -52,4 +52,8 @@ class AWSConnection def iam_client @iam_client ||= Aws::IAM::Client.new end + + def s3_client + @s3_client ||= Aws::S3::Client.new + end end diff --git a/libraries/aws_s3_bucket.rb b/libraries/aws_s3_bucket.rb new file mode 100644 index 000000000..db9584b27 --- /dev/null +++ b/libraries/aws_s3_bucket.rb @@ -0,0 +1,100 @@ +# author: Matthew Dromazos +class AwsS3Bucket < Inspec.resource(1) + name 'aws_s3_bucket' + desc 'Verifies settings for a s3 bucket' + example " + describe aws_s3_bucket(bucket_name: 'test_bucket') do + it { should exist } + end + " + + include AwsResourceMixin + attr_reader :bucket_name, :region + + def to_s + "S3 Bucket #{@bucket_name}" + end + + def bucket_acl + # This is simple enough to inline it. + @bucket_acl ||= AwsS3Bucket::BackendFactory.create.get_bucket_acl(bucket: bucket_name).grants + end + + def bucket_policy + @bucket_policy ||= fetch_bucket_policy + end + + # RSpec will alias this to be_public + def public? + # first line just for formatting + false || \ + bucket_acl.any? { |g| g.grantee.type == 'Group' && g.grantee.uri =~ /AllUsers/ } || \ + bucket_acl.any? { |g| g.grantee.type == 'Group' && g.grantee.uri =~ /AuthenticatedUsers/ } || \ + bucket_policy.any? { |s| s.effect == 'Allow' && s.principal == '*' } + end + + private + + def validate_params(raw_params) + validated_params = check_resource_param_names( + raw_params: raw_params, + allowed_params: [:bucket_name], + allowed_scalar_name: :bucket_name, + allowed_scalar_type: String, + ) + if validated_params.empty? or !validated_params.key?(:bucket_name) + raise ArgumentError, 'You must provide a bucket_name to aws_s3_bucket.' + end + + validated_params + end + + def fetch_from_aws + backend = AwsS3Bucket::BackendFactory.create + + # Since there is no basic "get_bucket" API call, use the + # region fetch as the existence check. + begin + @region = backend.get_bucket_location(bucket: bucket_name).location_constraint + rescue Aws::S3::Errors::NoSuchBucket + @exists = false + return + end + @exists = true + end + + def fetch_bucket_policy + backend = AwsS3Bucket::BackendFactory.create + + begin + # AWS SDK returns a StringIO, we have to read() + raw_policy = backend.get_bucket_policy(bucket: bucket_name).policy + return JSON.parse(raw_policy.read)['Statement'].map do |statement| + lowercase_hash = {} + statement.each_key { |k| lowercase_hash[k.downcase] = statement[k] } + OpenStruct.new(lowercase_hash) + end + rescue Aws::S3::Errors::NoSuchBucketPolicy + return [] + end + end + + # Uses the SDK API to really talk to AWS + class Backend + class AwsClientApi + BackendFactory.set_default_backend(self) + + def get_bucket_acl(query) + AWSConnection.new.s3_client.get_bucket_acl(query) + end + + def get_bucket_location(query) + AWSConnection.new.s3_client.get_bucket_location(query) + end + + def get_bucket_policy(query) + AWSConnection.new.s3_client.get_bucket_policy(query) + end + end + end +end diff --git a/test/integration/default/build/inspec-logo.png b/test/integration/default/build/inspec-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..4fe193b5d45468e9ee3099c58eca4abc64cd06da GIT binary patch literal 8501 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 0) + assert(bucket_acl.all? { |g| g.respond_to?(:permission)}) + assert(bucket_acl.all? { |g| g.respond_to?(:grantee)}) + assert(bucket_acl.all? { |g| g.grantee.respond_to?(:type)}) + end + + def test_property_bucket_acl_public + bucket_acl = AwsS3Bucket.new('public').bucket_acl + + public_grants = bucket_acl.select do |g| + g.grantee.type == 'Group' && g.grantee.uri =~ /AllUsers/ + end + refute_empty(public_grants) + end + + def test_property_bucket_acl_private + bucket_acl = AwsS3Bucket.new('private').bucket_acl + + public_grants = bucket_acl.select do |g| + g.grantee.type == 'Group' && g.grantee.uri =~ /AllUsers/ + end + assert_empty(public_grants) + + auth_users_grants = bucket_acl.select do |g| + g.grantee.type == 'Group' && g.grantee.uri =~ /AuthenticatedUsers/ + end + assert_empty(auth_users_grants) + end + + def test_property_bucket_acl_auth_users + bucket_acl = AwsS3Bucket.new('auth-users').bucket_acl + + public_grants = bucket_acl.select do |g| + g.grantee.type == 'Group' && g.grantee.uri =~ /AllUsers/ + end + assert_empty(public_grants) + + auth_users_grants = bucket_acl.select do |g| + g.grantee.type == 'Group' && g.grantee.uri =~ /AuthenticatedUsers/ + end + refute_empty(auth_users_grants) + end + + #---------------------- bucket_policy -------------------------------# + def test_property_bucket_policy_structure + bucket_policy = AwsS3Bucket.new('public').bucket_policy + assert_kind_of(Array, bucket_policy) + assert_kind_of(OpenStruct, bucket_policy.first) + [:effect, :principal, :action, :resource].each do |field| + assert_respond_to(bucket_policy.first, field) + end + end + + def test_property_bucket_policy_public + bucket_policy = AwsS3Bucket.new('public').bucket_policy + allow_all = bucket_policy.select { |s| s.effect == 'Allow' && s.principal == '*' } + assert_equal(1, allow_all.count) + end + + def test_property_bucket_policy_private + bucket_policy = AwsS3Bucket.new('private').bucket_policy + allow_all = bucket_policy.select { |s| s.effect == 'Allow' && s.principal == '*' } + assert_equal(0, allow_all.count) + end + + def test_property_bucket_policy_auth + bucket_policy = AwsS3Bucket.new('auth').bucket_policy + assert_empty(bucket_policy) + end + +end + +#=============================================================================# +# Test Matchers +#=============================================================================# + +class AwsS3BucketPropertiesTest < Minitest::Test + def setup + AwsS3Bucket::BackendFactory.select(AwsMSBSB::Basic) + end + + def test_be_public_public_acl + assert(AwsS3Bucket.new('public').public?) + end + def test_be_public_auth_acl + assert(AwsS3Bucket.new('auth-users').public?) + end + def test_be_public_private_acl + refute(AwsS3Bucket.new('private').public?) + end + def test_be_public_public_acl + assert(AwsS3Bucket.new('public').public?) + end + +end + +#=============================================================================# +# Test Fixtures +#=============================================================================# + +module AwsMSBSB + class Basic < AwsS3Bucket::Backend + def get_bucket_acl(query) + owner_full_control = OpenStruct.new({ + grantee: OpenStruct.new({ + type: 'CanonicalUser', + }), + permission: 'FULL_CONTROL', + }) + + buckets = { + 'public' => OpenStruct.new({ + :grants => [ + owner_full_control, + OpenStruct.new({ + grantee: OpenStruct.new({ + type: 'Group', + uri: 'http://acs.amazonaws.com/groups/global/AllUsers' + }), + permission: 'READ', + }), + ] + }), + 'auth-users' => OpenStruct.new({ + :grants => [ + owner_full_control, + OpenStruct.new({ + grantee: OpenStruct.new({ + type: 'Group', + uri: 'http://acs.amazonaws.com/groups/global/AuthenticatedUsers' + }), + permission: 'READ', + }), + ] + }), + 'private' => OpenStruct.new({ :grants => [ owner_full_control ] }), + 'private-acl-public-policy' => OpenStruct.new({ :grants => [ owner_full_control ] }), + } + buckets[query[:bucket]] + end + + def get_bucket_location(query) + buckets = { + 'public' => OpenStruct.new({ location_constraint: 'us-east-2' }), + 'private' => OpenStruct.new({ location_constraint: 'EU' }), + 'auth-users' => OpenStruct.new({ location_constraint: 'ap-southeast-1' }), + 'private-acl-public-policy' => OpenStruct.new({ location_constraint: 'ap-southeast-2' }), + } + unless buckets.key?(query[:bucket]) + raise Aws::S3::Errors::NoSuchBucket.new(nil, nil) + end + buckets[query[:bucket]] + end + + def get_bucket_policy(query) + buckets = { + 'public' => OpenStruct.new({ + policy: StringIO.new(<<'EOP') +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowGetObject", + "Effect": "Allow", + "Principal": "*", + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::public/*" + } + ] +} +EOP + }), + 'private' => OpenStruct.new({ + policy: StringIO.new(<<'EOP') +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "DenyGetObject", + "Effect": "Deny", + "Principal": "*", + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::private/*" + } + ] +} +EOP + }), + 'private-acl-public-policy' => OpenStruct.new({ + policy: StringIO.new(<<'EOP') +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowGetObject", + "Effect": "Allow", + "Principal": "*", + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::private-acl-public-policy/*" + } + ] +} +EOP + }), + # No policies for auth bucket + } + unless buckets.key?(query[:bucket]) + raise Aws::S3::Errors::NoSuchBucketPolicy.new(nil, nil) + end + buckets[query[:bucket]] + end + end +end diff --git a/test/unit/resources/aws_vpc.notes b/test/unit/resources/aws_vpc.notes new file mode 100644 index 000000000..5112797bf --- /dev/null +++ b/test/unit/resources/aws_vpc.notes @@ -0,0 +1,91 @@ +#=============================================================================# +# Search / Recall +#=============================================================================# +class AwsVpcRecallTest < Minitest::Test + def setup + AwsVpc::BackendFactory.select(MAVSB::Three) + end + + def test_search_miss_is_not_an_exception + user = AwsVpc.new('vpc-87654321') + refute user.exists? + end + + def test_search_hit_via_scalar_works + user = AwsVpc.new('') + assert user.exists? + assert_equal('erin', user.username) + end + + def test_search_hit_via_hash_works + user = AwsVpc.new(username: 'erin') + assert user.exists? + assert_equal('erin', user.username) + end +end + +#=============================================================================# +# Properties +#=============================================================================# + +class AwsVpcPropertiesTest < Minitest::Test + def setup + AwsVpc::BackendFactory.select(MAVSB::Three) + end + + #-----------------------------------------------------# + # username property + #-----------------------------------------------------# + def test_property_username_correct_on_hit + user = AwsVpc.new(username: 'erin') + assert_equal('erin', user.username) + end + + #-----------------------------------------------------# + # has_console_password property and predicate + #-----------------------------------------------------# + def test_property_password_positive + user = AwsVpc.new(username: 'erin') + assert_equal(true, user.has_console_password) + assert_equal(true, user.has_console_password?) + end + + def test_property_password_negative + user = AwsVpc.new(username: 'leslie') + assert_equal(false, user.has_console_password) + assert_equal(false, user.has_console_password?) + end + + #-----------------------------------------------------# + # has_mfa_enabled property and predicate + #-----------------------------------------------------# + def test_property_mfa_positive + user = AwsVpc.new(username: 'erin') + assert_equal(true, user.has_mfa_enabled) + assert_equal(true, user.has_mfa_enabled?) + end + + def test_property_mfa_negative + user = AwsVpc.new(username: 'leslie') + assert_equal(false, user.has_mfa_enabled) + assert_equal(false, user.has_mfa_enabled?) + end + + #-----------------------------------------------------# + # access_keys property + #-----------------------------------------------------# + def test_property_access_keys_positive + keys = AwsVpc.new(username: 'erin').access_keys + assert_kind_of(Array, keys) + assert_equal(keys.length, 2) + # We don't currently promise that the results + # will be Inspec resource objects. + # assert_kind_of(AwsIamAccessKey, keys.first) + end + + def test_property_access_keys_negative + keys = AwsVpc.new(username: 'leslie').access_keys + assert_kind_of(Array, keys) + assert(keys.empty?) + end +end From deb3f2c121d3c98a0dd80a4b20d1d755bea90929 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Mon, 22 Jan 2018 23:03:06 -0500 Subject: [PATCH 149/206] README clarifications about creating a profile that relies on the inspec-aws resource pack (#153) Signed-off-by: Aaron Lippold --- README.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 8b2cfd279..e172bde5f 100644 --- a/README.md +++ b/README.md @@ -19,14 +19,10 @@ Before running the profile with InSpec, define environment variables with your A - `AWS_DEFAULT_REGION` - `AWS_ACCESS_KEY_ID` - `AWS_SECRET_ACCESS_KEY` -- `AWS_SESSION_TOKEN` +- `AWS_SESSION_TOKEN` (optional) Those variables are defined in [AWS CLI Docs](http://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html#cli-environment) -Now you can use `inspec exec inspec-aws`. - -Our future intent is to support an `aws` target for InSpec/Train, so you may also pass credentials `inspec exec inspec-aws -t aws://accesskey:secret@region`. - ## Use the resources Since this is a InSpec resource pack, it only defines InSpec resources. It includes example tests only. You can easily use the AWS InSpec resources in your tests do the following: @@ -63,6 +59,14 @@ control "aws-1" do end ``` +### Running your profile + +Then use `inspec exec my-profile` to execute your new profile. + +Our future intent is to support an `aws` target for InSpec/Train, so you may also pass credentials `inspec exec my-profile -t aws://accesskey:secret@region`. + +* See [train/issues/229](https://github.com/chef/train/issues/229). + ### Available Resources * `aws_ec2_instance` - This resource reads information about an ec2 instance @@ -80,10 +84,8 @@ end * `aws_iam_group` * `aws_iam_policy` * `aws_iam_role` - ... - -## Tests +## Developing and Testing the AWS Resources Pack ### Unit tests From 42b6b315666910c4ed7e7ed91da790ffe2ffb1dd Mon Sep 17 00:00:00 2001 From: Sam Cornwell <14048146+samcornwell@users.noreply.github.com> Date: Mon, 22 Jan 2018 23:22:37 -0500 Subject: [PATCH 150/206] added require statement for _aws.rb for all resources (#199) Signed-off-by: Sam Cornwell <14048146+samcornwell@users.noreply.github.com> --- libraries/aws_cloudwatch_alarm.rb | 2 ++ libraries/aws_cloudwatch_log_metric_filter.rb | 2 ++ libraries/aws_ec2_instance.rb | 2 ++ libraries/aws_ec2_security_group.rb | 2 ++ libraries/aws_ec2_security_groups.rb | 2 ++ libraries/aws_iam_access_key.rb | 2 ++ libraries/aws_iam_access_keys.rb | 2 ++ libraries/aws_iam_password_policy.rb | 2 ++ libraries/aws_iam_role.rb | 2 ++ libraries/aws_iam_root_user.rb | 2 ++ libraries/aws_iam_user.rb | 2 ++ libraries/aws_iam_users.rb | 2 ++ libraries/aws_s3_bucket.rb | 2 ++ libraries/aws_sns_topic.rb | 2 ++ libraries/aws_vpc.rb | 2 ++ libraries/aws_vpcs.rb | 2 ++ 16 files changed, 32 insertions(+) diff --git a/libraries/aws_cloudwatch_alarm.rb b/libraries/aws_cloudwatch_alarm.rb index 25f0a9905..360710761 100644 --- a/libraries/aws_cloudwatch_alarm.rb +++ b/libraries/aws_cloudwatch_alarm.rb @@ -1,3 +1,5 @@ +require '_aws' + class AwsCloudwatchAlarm < Inspec.resource(1) name 'aws_cloudwatch_alarm' desc <<-EOD diff --git a/libraries/aws_cloudwatch_log_metric_filter.rb b/libraries/aws_cloudwatch_log_metric_filter.rb index bb1a31678..55fa1e096 100644 --- a/libraries/aws_cloudwatch_log_metric_filter.rb +++ b/libraries/aws_cloudwatch_log_metric_filter.rb @@ -1,3 +1,5 @@ +require '_aws' + class AwsCloudwatchLogMetricFilter < Inspec.resource(1) name 'aws_cloudwatch_log_metric_filter' desc 'Verifies individual Cloudwatch Log Metric Filters' diff --git a/libraries/aws_ec2_instance.rb b/libraries/aws_ec2_instance.rb index ff473a982..9ae09bc7f 100644 --- a/libraries/aws_ec2_instance.rb +++ b/libraries/aws_ec2_instance.rb @@ -1,3 +1,5 @@ +require '_aws' + # author: Christoph Hartmann class AwsEc2Instance < Inspec.resource(1) name 'aws_ec2_instance' diff --git a/libraries/aws_ec2_security_group.rb b/libraries/aws_ec2_security_group.rb index 50874b5d1..8cc280d14 100644 --- a/libraries/aws_ec2_security_group.rb +++ b/libraries/aws_ec2_security_group.rb @@ -1,3 +1,5 @@ +require '_aws' + class AwsEc2SecurityGroup < Inspec.resource(1) name 'aws_ec2_security_group' desc 'Verifies settings for an individual AWS Security Group.' diff --git a/libraries/aws_ec2_security_groups.rb b/libraries/aws_ec2_security_groups.rb index 98daee437..4200153e1 100644 --- a/libraries/aws_ec2_security_groups.rb +++ b/libraries/aws_ec2_security_groups.rb @@ -1,3 +1,5 @@ +require '_aws' + class AwsEc2SecurityGroups < Inspec.resource(1) name 'aws_ec2_security_groups' desc 'Verifies settings for AWS Security Groups in bulk' diff --git a/libraries/aws_iam_access_key.rb b/libraries/aws_iam_access_key.rb index 5d7276cf8..164cb9ec8 100644 --- a/libraries/aws_iam_access_key.rb +++ b/libraries/aws_iam_access_key.rb @@ -1,3 +1,5 @@ +require '_aws' + # author: Chris Redekop class AwsIamAccessKey < Inspec.resource(1) name 'aws_iam_access_key' diff --git a/libraries/aws_iam_access_keys.rb b/libraries/aws_iam_access_keys.rb index 61c24a4e6..430423fb9 100644 --- a/libraries/aws_iam_access_keys.rb +++ b/libraries/aws_iam_access_keys.rb @@ -1,3 +1,5 @@ +require '_aws' + class AwsIamAccessKeys < Inspec.resource(1) name 'aws_iam_access_keys' desc 'Verifies settings for AWS IAM Access Keys in bulk' diff --git a/libraries/aws_iam_password_policy.rb b/libraries/aws_iam_password_policy.rb index 9c6e22f5f..bd1c7717a 100644 --- a/libraries/aws_iam_password_policy.rb +++ b/libraries/aws_iam_password_policy.rb @@ -1,3 +1,5 @@ +require '_aws' + # author: Viktor Yakovlyev class AwsIamPasswordPolicy < Inspec.resource(1) name 'aws_iam_password_policy' diff --git a/libraries/aws_iam_role.rb b/libraries/aws_iam_role.rb index a34724fbd..ea11b88cf 100644 --- a/libraries/aws_iam_role.rb +++ b/libraries/aws_iam_role.rb @@ -1,3 +1,5 @@ +require '_aws' + class AwsIamRole < Inspec.resource(1) name 'aws_iam_role' desc 'Verifies settings for an IAM Role' diff --git a/libraries/aws_iam_root_user.rb b/libraries/aws_iam_root_user.rb index cf803bf2f..75d1683b8 100644 --- a/libraries/aws_iam_root_user.rb +++ b/libraries/aws_iam_root_user.rb @@ -1,3 +1,5 @@ +require '_aws' + # author: Miles Tjandrawidjaja class AwsIamRootUser < Inspec.resource(1) name 'aws_iam_root_user' diff --git a/libraries/aws_iam_user.rb b/libraries/aws_iam_user.rb index db0cbc77e..1d9f3726b 100644 --- a/libraries/aws_iam_user.rb +++ b/libraries/aws_iam_user.rb @@ -1,3 +1,5 @@ +require '_aws' + # author: Alex Bedley # author: Steffanie Freeman # author: Simon Varlow diff --git a/libraries/aws_iam_users.rb b/libraries/aws_iam_users.rb index 40cef1c94..5ca9faff3 100644 --- a/libraries/aws_iam_users.rb +++ b/libraries/aws_iam_users.rb @@ -1,3 +1,5 @@ +require '_aws' + # author: Alex Bedley # author: Steffanie Freeman # author: Simon Varlow diff --git a/libraries/aws_s3_bucket.rb b/libraries/aws_s3_bucket.rb index db9584b27..07cb6690f 100644 --- a/libraries/aws_s3_bucket.rb +++ b/libraries/aws_s3_bucket.rb @@ -1,3 +1,5 @@ +require '_aws' + # author: Matthew Dromazos class AwsS3Bucket < Inspec.resource(1) name 'aws_s3_bucket' diff --git a/libraries/aws_sns_topic.rb b/libraries/aws_sns_topic.rb index 3f305fdeb..d9c790a0c 100644 --- a/libraries/aws_sns_topic.rb +++ b/libraries/aws_sns_topic.rb @@ -1,3 +1,5 @@ +require '_aws' + class AwsSnsTopic < Inspec.resource(1) name 'aws_sns_topic' desc 'Verifies settings for an SNS Topic' diff --git a/libraries/aws_vpc.rb b/libraries/aws_vpc.rb index 3ec01b1c1..f8453dc59 100644 --- a/libraries/aws_vpc.rb +++ b/libraries/aws_vpc.rb @@ -1,3 +1,5 @@ +require '_aws' + class AwsVpc < Inspec.resource(1) name 'aws_vpc' desc 'Verifies settings for AWS VPC' diff --git a/libraries/aws_vpcs.rb b/libraries/aws_vpcs.rb index 45da8dc7f..a9891be4f 100644 --- a/libraries/aws_vpcs.rb +++ b/libraries/aws_vpcs.rb @@ -1,3 +1,5 @@ +require '_aws' + class AwsVpcs < Inspec.resource(1) name 'aws_vpcs' desc 'Verifies settings for AWS VPCs in bulk' From b6788d80d5265c727b9b51873b64ab8c6d0bdbb6 Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Tue, 23 Jan 2018 10:17:16 -0500 Subject: [PATCH 151/206] Remove highline and nokogiri, and group test gems in Gemfile (#203) Signed-off-by: Clinton Wolfe --- Gemfile | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Gemfile b/Gemfile index 8610273f9..6037f6b29 100644 --- a/Gemfile +++ b/Gemfile @@ -1,13 +1,14 @@ source 'https://rubygems.org' -gem 'rake' gem 'inspec', '~> 1' -gem 'rubocop', '~> 0.51.0' -gem 'highline', '~> 1.6.0' gem 'aws-sdk', '~> 2' -gem 'nokogiri' -gem 'minitest', '5.10.1' group :tools do gem 'github_changelog_generator', '~> 1.12.0' end + +group :test do + gem 'rake' + gem 'rubocop', '~> 0.51.0' + gem 'minitest', '5.10.1' +end \ No newline at end of file From 86843320df26574489b3a1c69a0448d4bf58cbdd Mon Sep 17 00:00:00 2001 From: Rony Xavier Date: Tue, 23 Jan 2018 11:01:51 -0500 Subject: [PATCH 152/206] Correct access_key detection on aws_iam_root_user (#198) Signed-off-by: Rony Xavier --- docs/resources/aws_iam_root_user.md | 10 ++++++++-- libraries/aws_iam_root_user.rb | 6 +++--- .../default/verify/controls/aws_iam_root_user.rb | 8 ++++---- .../minimal/verify/controls/aws_iam_root_user.rb | 8 ++++---- test/unit/resources/aws_iam_root_user_test.rb | 16 ++++++++++++---- 5 files changed, 31 insertions(+), 17 deletions(-) diff --git a/docs/resources/aws_iam_root_user.md b/docs/resources/aws_iam_root_user.md index 89a8fdf6d..2b31b1503 100644 --- a/docs/resources/aws_iam_root_user.md +++ b/docs/resources/aws_iam_root_user.md @@ -26,10 +26,10 @@ An `aws_iam_root_user` resource block requires no parameters but has several mat The following examples show how to use this InSpec audit resource. -### Test that the AWS root account has only one access key +### Test that the AWS root account has at-least one access key describe aws_iam_root_user do - its('access_key_count') { should eq 1 } + it { should have_access_key } end ### Test that the AWS root account has Multi-Factor Authentication enabled @@ -49,3 +49,9 @@ This InSpec audit resource has the following special matchers. For a full list o The `have_mfa_enabled` matcher tests if the AWS root user has Multi-Factor Authentication enabled, requiring them to enter a secondary code when they login to the web console. it { should have_mfa_enabled } + +### have_access_key + +The `have_access_key` matcher tests if the AWS root user has at least one access key. + + it { should have_access_key } diff --git a/libraries/aws_iam_root_user.rb b/libraries/aws_iam_root_user.rb index 75d1683b8..89a9fae35 100644 --- a/libraries/aws_iam_root_user.rb +++ b/libraries/aws_iam_root_user.rb @@ -6,7 +6,7 @@ class AwsIamRootUser < Inspec.resource(1) desc 'Verifies settings for AWS root account' example " describe aws_iam_root_user do - its('access_key_count') { should eq 0 } + it { should have_access_key } end " @@ -14,8 +14,8 @@ class AwsIamRootUser < Inspec.resource(1) @client = conn.iam_client end - def access_key_count - summary_account['AccountAccessKeysPresent'] + def has_access_key? + summary_account['AccountAccessKeysPresent'] == 1 end def has_mfa_enabled? diff --git a/test/integration/default/verify/controls/aws_iam_root_user.rb b/test/integration/default/verify/controls/aws_iam_root_user.rb index 56d573c7a..e5ff11c84 100644 --- a/test/integration/default/verify/controls/aws_iam_root_user.rb +++ b/test/integration/default/verify/controls/aws_iam_root_user.rb @@ -18,10 +18,10 @@ control "aws_iam_root_user has_mfa_enabled property" do end end -#------------- Property - access_key_count -------------# -# test for = 1 in 'minimal' test set -control "aws_iam_root_user access_key_count property" do +#------------- Property - has_access_key -------------# +# Positive test in 'minimal' test set +control "aws_iam_root_user has_access_key property" do describe aws_iam_root_user do - its('access_key_count') { should be 0 } + it { should_not have_access_key } end end \ No newline at end of file diff --git a/test/integration/minimal/verify/controls/aws_iam_root_user.rb b/test/integration/minimal/verify/controls/aws_iam_root_user.rb index 0a6e3e3e8..63a6c040a 100644 --- a/test/integration/minimal/verify/controls/aws_iam_root_user.rb +++ b/test/integration/minimal/verify/controls/aws_iam_root_user.rb @@ -18,10 +18,10 @@ control "aws_iam_root_user has_mfa_enabled property" do end end -#------------- Property - access_key_count -------------# -# test for = 0 in 'default' test set -control "aws_iam_root_user access_key_count property" do +#------------- Property - has_access_key -------------# +# Negative test in 'default' test set +control "aws_iam_root_user has_access_key property" do describe aws_iam_root_user do - its('access_key_count') { should be 1 } + it { should have_access_key } end end \ No newline at end of file diff --git a/test/unit/resources/aws_iam_root_user_test.rb b/test/unit/resources/aws_iam_root_user_test.rb index 3bb6834e0..a616e1549 100644 --- a/test/unit/resources/aws_iam_root_user_test.rb +++ b/test/unit/resources/aws_iam_root_user_test.rb @@ -10,14 +10,22 @@ class AwsIamRootUserTest < Minitest::Test @mock_conn.expect :iam_client, @mock_client end - def test_access_key_count_returns_from_summary_account - expected_keys = 2 + def test_has_access_key_returns_true_from_summary_account test_summary_map = OpenStruct.new( - summary_map: { 'AccountAccessKeysPresent' => expected_keys }, + summary_map: { 'AccountAccessKeysPresent' => 1 }, ) @mock_client.expect :get_account_summary, test_summary_map - assert_equal expected_keys, AwsIamRootUser.new(@mock_conn).access_key_count + assert_equal true, AwsIamRootUser.new(@mock_conn).has_access_key? + end + + def test_has_access_key_returns_false_from_summary_account + test_summary_map = OpenStruct.new( + summary_map: { 'AccountAccessKeysPresent' => 0 }, + ) + @mock_client.expect :get_account_summary, test_summary_map + + assert_equal false, AwsIamRootUser.new(@mock_conn).has_access_key? end def test_has_mfa_enabled_returns_true_when_account_mfa_devices_is_one From 3a786babcf456b2f54384457cd681956b14501c5 Mon Sep 17 00:00:00 2001 From: Rony Xavier Date: Tue, 23 Jan 2018 11:06:05 -0500 Subject: [PATCH 153/206] Skeletal aws_cloudtrail_trails resource (#191) Signed-off-by: Rony Xavier --- docs/resources/aws_cloudtrail_trails.md | 70 +++++++++++ libraries/_aws_connection.rb | 4 + libraries/aws_cloudtrail_trails.rb | 44 +++++++ .../verify/controls/aws_cloudtrail_trails.rb | 5 + .../resources/aws_cloudtrail_trails_test.rb | 110 ++++++++++++++++++ 5 files changed, 233 insertions(+) create mode 100644 docs/resources/aws_cloudtrail_trails.md create mode 100644 libraries/aws_cloudtrail_trails.rb create mode 100644 test/integration/default/verify/controls/aws_cloudtrail_trails.rb create mode 100644 test/unit/resources/aws_cloudtrail_trails_test.rb diff --git a/docs/resources/aws_cloudtrail_trails.md b/docs/resources/aws_cloudtrail_trails.md new file mode 100644 index 000000000..04c405c33 --- /dev/null +++ b/docs/resources/aws_cloudtrail_trails.md @@ -0,0 +1,70 @@ +--- +title: About the aws_cloudtrail_trails Resource +--- + +# aws_cloudtrail_trails + +Use the `aws_cloudtrail_trails` InSpec audit resource to test properties of some or all AWS CloudTrail Trails. + +AWS CloudTrail is a service that enables governance, compliance, operational auditing, and risk auditing of your AWS account. With CloudTrail, you can log, continuously monitor, and retain account activity related to actions across your AWS infrastructure. CloudTrail provides event history of your AWS account activity, including actions taken through the AWS Management Console, AWS SDKs, command line tools, and other AWS services. This event history simplifies security analysis, resource change tracking, and troubleshooting. + +Each AWS CloudTrail Trails is uniquely identified by its trail name or trail arn. + +
+ +## Syntax + +An `aws_cloudtrail_trails` resource block collects a group of CloudTrail Trails and then tests that group. + + # Verify the number of CloudTrail Trails in the AWS account + describe aws_cloudtrail_trails do + its('entries.count') { should cmp 10 } + end + +
+ +## Examples + +The following examples show how to use this InSpec audit resource. + +As this is the initial release of `aws_cloudtrail_trails`, its limited functionality precludes examples. + +
+ +## Matchers + +### exists + +The control will pass if the filter returns at least one result. Use should_not if you expect zero matches. + + # Verify that at least one CloudTrail Trail exists. + describe aws_cloudtrail_trails + it { should exist } + end + +## Properties + +### names + +Provides a list of trail names for all CloudTrail Trails in the AWS account. + + describe aws_cloudtrail_trails do + its('names') { should include('trail-1') } + end + +### trail_arns + +Provides a list of trail arns for all CloudTrail Trails in the AWS account. + + describe aws_cloudtrail_trails do + its('trail_arns') { should include('arn:aws:cloudtrail:us-east-1::trail/trail-1') } + end + +### entries + +Provides access to the raw results of the query. This can be useful for checking counts and other advanced operations. + + # Allow at most 100 CloudTrail Trails on the account + describe aws_cloudtrail_trails do + its('entries.count') { should be <= 100} + end diff --git a/libraries/_aws_connection.rb b/libraries/_aws_connection.rb index 6922dbb37..06c3b30f2 100644 --- a/libraries/_aws_connection.rb +++ b/libraries/_aws_connection.rb @@ -37,6 +37,10 @@ class AWSConnection @cloudwatch_logs_client ||= Aws::CloudWatchLogs::Client.new end + def cloudtrail_client + @cloudtrail_client ||= Aws::CloudTrail::Client.new + end + def ec2_resource @ec2_resource ||= Aws::EC2::Resource.new end diff --git a/libraries/aws_cloudtrail_trails.rb b/libraries/aws_cloudtrail_trails.rb new file mode 100644 index 000000000..97cb9cb24 --- /dev/null +++ b/libraries/aws_cloudtrail_trails.rb @@ -0,0 +1,44 @@ +class AwsCloudTrailTrails < Inspec.resource(1) + name 'aws_cloudtrail_trails' + desc 'Verifies settings for AWS CloudTrail Trails in bulk' + example ' + describe aws_cloudtrail_trails do + it { should exist } + end + ' + + # Underlying FilterTable implementation. + filter = FilterTable.create + filter.add_accessor(:entries) + .add(:exists?) { |x| !x.entries.empty? } + .add(:names, field: :name) + .add(:trail_arns, field: :trail_arn) + filter.connect(self, :trail_data) + + def trail_data + @table + end + + def to_s + 'CloudTrail Trails' + end + + def initialize + backend = AwsCloudTrailTrails::BackendFactory.create + @table = backend.describe_trails({}).to_h[:trail_list] + end + + class BackendFactory + extend AwsBackendFactoryMixin + end + + class Backend + class AwsClientApi + BackendFactory.set_default_backend(self) + + def describe_trails(query) + AWSConnection.new.cloudtrail_client.describe_trails(query) + end + end + end +end diff --git a/test/integration/default/verify/controls/aws_cloudtrail_trails.rb b/test/integration/default/verify/controls/aws_cloudtrail_trails.rb new file mode 100644 index 000000000..679d8d97a --- /dev/null +++ b/test/integration/default/verify/controls/aws_cloudtrail_trails.rb @@ -0,0 +1,5 @@ +control "aws_cloudtrail_trails recall" do + describe aws_cloudtrail_trails do + it { should exist } + end +end diff --git a/test/unit/resources/aws_cloudtrail_trails_test.rb b/test/unit/resources/aws_cloudtrail_trails_test.rb new file mode 100644 index 000000000..e4fcc5df9 --- /dev/null +++ b/test/unit/resources/aws_cloudtrail_trails_test.rb @@ -0,0 +1,110 @@ +require 'helper' +require 'aws_cloudtrail_trails' + +# MACTTPB = MockAwsCloudTrailTrailsPluralBackend +# Abbreviation not used outside this file + +#=============================================================================# +# Constructor Tests +#=============================================================================# +class AwsCloudTrailTrailsConstructorTest < Minitest::Test + + def setup + AwsCloudTrailTrails::BackendFactory.select(MACTTPB::Empty) + end + + def test_empty_params_ok + AwsCloudTrailTrails.new + end + + def test_rejects_unrecognized_params + assert_raises(ArgumentError) { AwsCloudTrailTrails.new(shoe_size: 9) } + end +end + + +#=============================================================================# +# Search / Recall +#=============================================================================# +class AwsCloudTrailTrailsRecallEmptyTest < Minitest::Test + + def setup + AwsCloudTrailTrails::BackendFactory.select(MACTTPB::Empty) + end + + def test_search_miss_trail_empty_trail_list + refute AwsCloudTrailTrails.new.exists? + end +end + +class AwsCloudTrailTrailsRecallBasicTest < Minitest::Test + + def setup + AwsCloudTrailTrails::BackendFactory.select(MACTTPB::Basic) + end + + def test_search_hit_via_empty_filter + assert AwsCloudTrailTrails.new.exists? + end +end + +#=============================================================================# +# Properties +#=============================================================================# +class AwsCloudTrailTrailsProperties < Minitest::Test + def setup + AwsCloudTrailTrails::BackendFactory.select(MACTTPB::Basic) + end + + def test_property_names + basic = AwsCloudTrailTrails.new + assert_kind_of(Array, basic.names) + assert(basic.names.include?('test-trail-1')) + refute(basic.names.include?(nil)) + end + + def test_property_trail_arns + basic = AwsCloudTrailTrails.new + assert_kind_of(Array, basic.trail_arns) + assert(basic.trail_arns.include?('arn:aws:cloudtrail:us-east-1::trail/test-trail-1')) + refute(basic.trail_arns.include?(nil)) + end +end +#=============================================================================# +# Test Fixtures +#=============================================================================# +module MACTTPB + class Empty < AwsCloudTrailTrails::Backend + def describe_trails(query = {}) + OpenStruct.new({ trail_list: [] }) + end + end + + class Basic < AwsCloudTrailTrails::Backend + def describe_trails(query = {}) + fixtures = [ + OpenStruct.new({ + name: "test-trail-1", + s3_bucket_name: "aws-s3-bucket-test-trail-1", + is_multi_region_trail: true, + home_region: "us-east-1", + trail_arn: "arn:aws:cloudtrail:us-east-1::trail/test-trail-1", + log_file_validation_enabled: true, + cloud_watch_logs_log_group_arn: "arn:aws:logs:us-east-1::log-group:test:*", + cloud_watch_logs_role_arn: "arn:aws:iam:::role/CloudTrail_CloudWatchLogs_Role", + kms_key_id: "arn:aws:kms:us-east-1::key/88197884-041f-4f8e-a801-cf120e4845a8" + }), + OpenStruct.new({ + name: "test-trail-2", + s3_bucket_name: "aws-s3-bucket-test-trail-2", + home_region: "us-east-1", + trail_arn: "arn:aws:cloudtrail:us-east-1::trail/test-trail-2", + cloud_watch_logs_log_group_arn: "arn:aws:logs:us-east-1::log-group:test:*", + cloud_watch_logs_role_arn: "arn:aws:iam:::role/CloudTrail_CloudWatchLogs_Role", + }), + ] + + OpenStruct.new({ trail_list: fixtures }) + end + end +end From 0af7105ebab0ea96b031af56e5687fcb577c5aa7 Mon Sep 17 00:00:00 2001 From: Rony Xavier Date: Tue, 23 Jan 2018 11:26:27 -0500 Subject: [PATCH 154/206] Functional aws_cloudtrail_trail resource (#186) Signed-off-by: Rony Xavier --- docs/resources/aws_cloudtrail_trail.md | 132 ++++++++++ libraries/aws_cloudtrail_trail.rb | 74 ++++++ test/integration/default/build/aws.tf | 9 + test/integration/default/build/cloudtrail.tf | 230 ++++++++++++++++++ .../verify/controls/aws_cloudtrail_trail.rb | 70 ++++++ .../resources/aws_cloudtrail_trail_test.rb | 171 +++++++++++++ 6 files changed, 686 insertions(+) create mode 100644 docs/resources/aws_cloudtrail_trail.md create mode 100644 libraries/aws_cloudtrail_trail.rb create mode 100644 test/integration/default/build/cloudtrail.tf create mode 100644 test/integration/default/verify/controls/aws_cloudtrail_trail.rb create mode 100644 test/unit/resources/aws_cloudtrail_trail_test.rb diff --git a/docs/resources/aws_cloudtrail_trail.md b/docs/resources/aws_cloudtrail_trail.md new file mode 100644 index 000000000..b956bc63d --- /dev/null +++ b/docs/resources/aws_cloudtrail_trail.md @@ -0,0 +1,132 @@ +--- +title: About the aws_cloudtrail_trail Resource +--- + +# aws_cloudtrail_trail + +Use the `aws_cloudtrail_trail` InSpec audit resource to test properties of a single AWS Cloudtrail Trail. + +AWS CloudTrail is a service that enables governance, compliance, operational auditing, and risk auditing of your AWS account. With CloudTrail, you can log, continuously monitor, and retain account activity related to actions across your AWS infrastructure. CloudTrail provides event history of your AWS account activity, including actions taken through the AWS Management Console, AWS SDKs, command line tools, and other AWS services. This event history simplifies security analysis, resource change tracking, and troubleshooting. + +Each AWS Cloudtrail Trail is uniquely identified by its trail_name or trail_arn. + +
+ +## Syntax + +An `aws_cloudtrail_trail` resource block identifies a trail by trail_name. + + # Find a trail by name + describe aws_cloudtrail_trail('trail-name') do + it { should exist } + end + + # Hash syntax for trail name + describe aws_cloudtrail_trail(trail_name: 'trail-name') do + it { should exist } + end + +
+ +## Examples + +The following examples show how to use this InSpec audit resource. + +### Test that the specified trail does exist + + describe aws_cloudtrail_trail('trail-name') do + it { should exist } + end + +### Test that the specified trail is encrypted using SSE-KMS + + describe aws_cloudtrail_trail('trail-name') do + it { should be_encrypted } + end + +### Test that the specified trail is a multi region trail + + describe aws_cloudtrail_trail('trail-name') do + it { should be_multi_region_trail } + end + +
+ +## Properties + +### s3_bucket_name + +Specifies the name of the Amazon S3 bucket designated for publishing log files. + + describe aws_cloudtrail_trail('trail-name') do + its('s3_bucket_name') { should cmp "s3-bucket-name" } + end + +### trail_arn + +The ARN identifier of the specified trail. An ARN uniquely identifies the trail within AWS. + + describe aws_cloudtrail_trail('trail-name') do + its('trail_arn') { should cmp "arn:aws:cloudtrail:us-east-1:484747447281:trail/trail-name" } + end + +### cloud_watch_logs_role_arn + +Specifies the role for the CloudWatch Logs endpoint to assume to write to a user\'s log group. + + describe aws_cloudtrail_trail('trail-name') do + its('cloud_watch_logs_role_arn') { should include "arn:aws:iam:::role/CloudTrail_CloudWatchLogs_Role" } + end + +### cloud_watch_logs_log_group_arn + +Specifies a log group name using an Amazon Resource Name (ARN), a unique identifier that represents the log group to which CloudTrail logs will be delivered. + + describe aws_cloudtrail_trail('trail-name') do + its('cloud_watch_logs_log_group_arn') { should include "arn:aws:logs:us-east-1::log-group:test:*" } + end + +### kms_key_id + +Specifies the KMS key ID to used to encrypt the logs delivered by CloudTrail. + + describe aws_cloudtrail_trail('trail-name') do + its('kms_key_id') { should include "key-arn" } + end + +### home_region + +Specifies the region in which the trail was created. + + describe aws_cloudtrail_trail('trail-name') do + its('home_region') { should include "us-east-1" } + end + + +## Matchers + +This InSpec audit resource has the following special matchers. For a full list of available matchers (such as `exist`) please visit our [matchers page](https://www.inspec.io/docs/reference/matchers/). + +### be_multi_region_trail + +The test will pass if the identified trail is a multi region trail. + + describe aws_cloudtrail_trail('trail-name') do + it { should be_multi_region_trail } + end + +### be_encrypted + +The test will pass if the logs delivered by the identified trail is encrypted. + + describe aws_cloudtrail_trail('trail-name') do + it { should be_encrypted } + end + +### be_log_file_validation_enabled + +The test will pass if the identified trail has log file integrity validation is enabled. + + describe aws_cloudtrail_trail('trail-name') do + it { should be_log_file_validation_enabled } + end diff --git a/libraries/aws_cloudtrail_trail.rb b/libraries/aws_cloudtrail_trail.rb new file mode 100644 index 000000000..fc9b2e0e5 --- /dev/null +++ b/libraries/aws_cloudtrail_trail.rb @@ -0,0 +1,74 @@ +class AwsCloudTrailTrail < Inspec.resource(1) + name 'aws_cloudtrail_trail' + desc 'Verifies settings for an individual AWS CloudTrail Trail' + example " + describe aws_cloudtrail_trail('trail-name') do + it { should exist } + end + " + + include AwsResourceMixin + attr_reader :s3_bucket_name, :trail_arn, :cloud_watch_logs_role_arn, + :cloud_watch_logs_log_group_arn, :kms_key_id, :home_region + + def to_s + "CloudTrail #{@trail_name}" + end + + def multi_region_trail? + @is_multi_region_trail + end + + def log_file_validation_enabled? + @log_file_validation_enabled + end + + def encrypted? + !kms_key_id.nil? + end + + private + + def validate_params(raw_params) + validated_params = check_resource_param_names( + raw_params: raw_params, + allowed_params: [:trail_name], + allowed_scalar_name: :trail_name, + allowed_scalar_type: String, + ) + + if validated_params.empty? + raise ArgumentError, "You must provide the parameter 'trail_name' to aws_cloudtrail_trail." + end + + validated_params + end + + def fetch_from_aws + backend = AwsCloudTrailTrail::BackendFactory.create + + query = { trail_name_list: [@trail_name] } + resp = backend.describe_trails(query) + + @trail = resp.trail_list[0].to_h + @exists = !@trail.empty? + @s3_bucket_name = @trail[:s3_bucket_name] + @is_multi_region_trail = @trail[:is_multi_region_trail] + @trail_arn = @trail[:trail_arn] + @log_file_validation_enabled = @trail[:log_file_validation_enabled] + @cloud_watch_logs_role_arn = @trail[:cloud_watch_logs_role_arn] + @cloud_watch_logs_log_group_arn = @trail[:cloud_watch_logs_log_group_arn] + @kms_key_id = @trail[:kms_key_id] + @home_region = @trail[:home_region] + end + + class Backend + class AwsClientApi + BackendFactory.set_default_backend(self) + + def describe_trails(query) + AWSConnection.new.cloudtrail_client.describe_trails(query) + end + end + end +end diff --git a/test/integration/default/build/aws.tf b/test/integration/default/build/aws.tf index 3c6d6a68e..95f046906 100644 --- a/test/integration/default/build/aws.tf +++ b/test/integration/default/build/aws.tf @@ -7,6 +7,15 @@ provider "aws" { } data "aws_caller_identity" "creds" {} + output "aws_account_id" { value = "${data.aws_caller_identity.creds.account_id}" } + +data "aws_region" "region" { + current = true +} + +output "aws_region" { + value = "${data.aws_region.region.name}" +} diff --git a/test/integration/default/build/cloudtrail.tf b/test/integration/default/build/cloudtrail.tf new file mode 100644 index 000000000..965db8cd3 --- /dev/null +++ b/test/integration/default/build/cloudtrail.tf @@ -0,0 +1,230 @@ +resource "aws_s3_bucket" "trail_1_bucket" { + bucket = "${terraform.env}-trail-01-bucket" + force_destroy = true + + policy = < Date: Tue, 23 Jan 2018 22:16:00 -0500 Subject: [PATCH 155/206] Make cloudwatch log metric filter test fixtures more unique (#201) Signed-off-by: Clinton Wolfe --- test/integration/default/build/cloudwatch.tf | 5 ++++- .../verify/controls/aws_cloudwatch_log_metric_filter.rb | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/test/integration/default/build/cloudwatch.tf b/test/integration/default/build/cloudwatch.tf index 9f9a46b6f..b080e31fc 100644 --- a/test/integration/default/build/cloudwatch.tf +++ b/test/integration/default/build/cloudwatch.tf @@ -40,7 +40,7 @@ output "log_metric_filter_1_metric_name" { resource "aws_cloudwatch_log_metric_filter" "lmf_2" { name = "${terraform.env}_lmf" - pattern = "testpattern02" + pattern = "${terraform.env}testpattern02" log_group_name = "${aws_cloudwatch_log_group.lmf_lg_2.name}" metric_transformation { @@ -62,6 +62,9 @@ output "log_metric_filter_2_log_group_name" { value = "${aws_cloudwatch_log_group.lmf_lg_2.name}" } +output "log_metric_filter_2_pattern" { + value = "${terraform.env}testpattern02" +} #======================================================# # Cloudwatch Alarms #======================================================# diff --git a/test/integration/default/verify/controls/aws_cloudwatch_log_metric_filter.rb b/test/integration/default/verify/controls/aws_cloudwatch_log_metric_filter.rb index 0a46f92e6..d64f9934c 100644 --- a/test/integration/default/verify/controls/aws_cloudwatch_log_metric_filter.rb +++ b/test/integration/default/verify/controls/aws_cloudwatch_log_metric_filter.rb @@ -5,6 +5,7 @@ fixtures = {} 'log_metric_filter_1_metric_name', 'log_metric_filter_2_name', 'log_metric_filter_2_log_group_name', +'log_metric_filter_2_pattern', ].each do |fixture_name| fixtures[fixture_name] = attribute( fixture_name, @@ -25,7 +26,7 @@ control "aws_cloudwatch_log_metric_filter recall" do end describe aws_cloudwatch_log_metric_filter( - pattern: 'testpattern02', + pattern: fixtures['log_metric_filter_2_pattern'], ) do it { should exist } end From f09d4f52663251b6d4df0de5eb55fbb2c4b0d2c6 Mon Sep 17 00:00:00 2001 From: Rony Xavier Date: Fri, 26 Jan 2018 15:21:49 -0500 Subject: [PATCH 156/206] aws_iam_policy resource (#184) Signed-off-by: Rony Xavier Signed-off-by: Clinton Wolfe --- docs/resources/aws_iam_policy.md | 139 ++++++++++++ libraries/aws_iam_policy.rb | 114 ++++++++++ .../default/verify/controls/aws_iam_policy.rb | 29 +++ test/unit/resources/aws_iam_policy_test.rb | 202 ++++++++++++++++++ 4 files changed, 484 insertions(+) create mode 100644 docs/resources/aws_iam_policy.md create mode 100644 libraries/aws_iam_policy.rb create mode 100644 test/integration/default/verify/controls/aws_iam_policy.rb create mode 100644 test/unit/resources/aws_iam_policy_test.rb diff --git a/docs/resources/aws_iam_policy.md b/docs/resources/aws_iam_policy.md new file mode 100644 index 000000000..c837eef03 --- /dev/null +++ b/docs/resources/aws_iam_policy.md @@ -0,0 +1,139 @@ +--- +title: About the aws_iam_policy Resource +--- + +# aws_iam_policy + +Use the `aws_iam_policy` InSpec audit resource to test properties of a single managed AWS IAM Policy. + +A policy is an entity in AWS that, when attached to an identity or resource, defines their permissions. AWS evaluates these policies when a principal, such as a user, makes a request. Permissions in the policies determine whether the request is allowed or denied. + +Each IAM Policy is uniquely identified by either its policy_name or arn. + +
+ +## Syntax + +An `aws_iam_policy` resource block identifies a policy by policy name. + + # Find a policy by name + describe aws_iam_policy('AWSSupportAccess') do + it { should exist } + end + + # Find a customer-managed by name + describe aws_iam_policy('customer-managed-policy') do + it { should exist } + end + + # Hash syntax for policy name + describe aws_iam_policy(policy_name: 'AWSSupportAccess') do + it { should exist } + end + +
+ +## Examples + +The following examples show how to use this InSpec audit resource. + +### Test that a policy does exist + + describe aws_iam_policy('AWSSupportAccess') do + it { should exist } + end + +### Test that a policy is attached to at least one entity + + describe aws_iam_policy('AWSSupportAccess') do + it { should be_attached } + end + +
+ +## Properties + +### arn + +"The ARN identifier of the specified policy. An ARN uniquely identifies the policy within AWS." + + describe aws_iam_policy('AWSSupportAccess') do + its('arn') { should cmp "arn:aws:iam::aws:policy/AWSSupportAccess" } + end + +### default_version_id + +The default_version_id value of the specified policy. + + describe aws_iam_policy('AWSSupportAccess') do + its('default_version_id') { should cmp "v1" } + end + +### attachment_count + +The count of attached entities for the specified policy. + + describe aws_iam_policy('AWSSupportAccess') do + its('attachment_count') { should cmp 1 } + end + +### attached_users + +The list of usernames of the users attached to the policy. + + describe aws_iam_policy('AWSSupportAccess') do + its('attached_users') { should include "test-user" } + end + +### attached_groups + +The list of groupnames of the groups attached to the policy. + + describe aws_iam_policy('AWSSupportAccess') do + its('attached_groups') { should include "test-group" } + end + +### attached_roles + +The list of rolenames of the roles attached to the policy. + + describe aws_iam_policy('AWSSupportAccess') do + its('attached_roles') { should include "test-role" } + end + +## Matchers + +This InSpec audit resource has the following special matchers. For a full list of available matchers (such as `exist`) please visit our [matchers page](https://www.inspec.io/docs/reference/matchers/). + +### be_attached + +The test will pass if the identified policy is attached to at least one IAM user, group, or role. + + describe aws_iam_policy('AWSSupportAccess') do + it { should be_attached } + end + +### be_attached_to_user(USERNAME) + +The test will pass if the identified policy attached the specified user. + + describe aws_iam_policy('AWSSupportAccess') do + it { should be_attached_to_user(USERNAME) } + end + +### be_attached_to_role(ROLENAME) + +The test will pass if the identified policy attached the specified role. + + describe aws_iam_policy('AWSSupportAccess') do + it { should be_attached_to_role(ROLENAME) } + end + + +### be_attached_to_group(GROUPNAME) + +The test will pass if the identified policy attached the specified group. + + describe aws_iam_policy('AWSSupportAccess') do + it { should be_attached_to_group(GROUPNAME) } + end diff --git a/libraries/aws_iam_policy.rb b/libraries/aws_iam_policy.rb new file mode 100644 index 000000000..6c4731151 --- /dev/null +++ b/libraries/aws_iam_policy.rb @@ -0,0 +1,114 @@ +class AwsIamPolicy < Inspec.resource(1) + name 'aws_iam_policy' + desc 'Verifies settings for individual AWS IAM Policy' + example " + describe aws_iam_policy('AWSSupportAccess') do + it { should be_attached } + end + " + + include AwsResourceMixin + + attr_reader :arn, :default_version_id, :attachment_count + + def to_s + "Policy #{@policy_name}" + end + + def attached? + !attachment_count.zero? + end + + def attached_users + return @attached_users if defined? @attached_users + fetch_attached_entities + @attached_users + end + + def attached_groups + return @attached_groups if defined? @attached_groups + fetch_attached_entities + @attached_groups + end + + def attached_roles + return @attached_roles if defined? @attached_roles + fetch_attached_entities + @attached_roles + end + + def attached_to_user?(user_name) + attached_users.include?(user_name) + end + + def attached_to_group?(group_name) + attached_groups.include?(group_name) + end + + def attached_to_role?(role_name) + attached_roles.include?(role_name) + end + + private + + def validate_params(raw_params) + validated_params = check_resource_param_names( + raw_params: raw_params, + allowed_params: [:policy_name], + allowed_scalar_name: :policy_name, + allowed_scalar_type: String, + ) + + if validated_params.empty? + raise ArgumentError, "You must provide the parameter 'policy_name' to aws_iam_policy." + end + + validated_params + end + + def fetch_from_aws + backend = AwsIamPolicy::BackendFactory.create + + criteria = { max_items: 1000 } # maxItems max value is 1000 + resp = backend.list_policies(criteria) + @policy = resp.policies.detect do |policy| + policy.policy_name == @policy_name + end + + @exists = !@policy.nil? + + return unless @exists + @arn = @policy[:arn] + @default_version_id = @policy[:default_version_id] + @attachment_count = @policy[:attachment_count] + end + + def fetch_attached_entities + unless @exists + @attached_groups = nil + @attached_users = nil + @attached_roles = nil + return + end + backend = AwsIamPolicy::BackendFactory.create + criteria = { policy_arn: arn } + resp = backend.list_entities_for_policy(criteria) + @attached_groups = resp.policy_groups.map(&:group_name) + @attached_users = resp.policy_users.map(&:user_name) + @attached_roles = resp.policy_roles.map(&:role_name) + end + + class Backend + class AwsClientApi + BackendFactory.set_default_backend(self) + + def list_policies(criteria) + AWSConnection.new.iam_client.list_policies(criteria) + end + + def list_entities_for_policy(criteria) + AWSConnection.new.iam_client.list_entities_for_policy(criteria) + end + end + end +end diff --git a/test/integration/default/verify/controls/aws_iam_policy.rb b/test/integration/default/verify/controls/aws_iam_policy.rb new file mode 100644 index 000000000..eaebd5c0e --- /dev/null +++ b/test/integration/default/verify/controls/aws_iam_policy.rb @@ -0,0 +1,29 @@ +control "aws_iam_policy recall" do + describe aws_iam_policy("AWSSupportAccess") do + it { should exist } + end + + describe aws_iam_policy(policy_name: "AWSSupportAccess") do + it { should exist } + end +end + +control "aws_iam_policy properties" do + describe aws_iam_policy("AdministratorAccess") do + its('arn') { should cmp "arn:aws:iam::aws:policy/AdministratorAccess" } + its('default_version_id') { should cmp 'v1' } + its('attachment_count') { should cmp 1 } + its('attached_users') { should include "test-fixture-maker" } + its('attached_groups') { should be_empty } + its('attached_roles') { should be_empty } + end +end + +control "aws_iam_policy matchers" do + describe aws_iam_policy("AdministratorAccess") do + it { should be_attached } + end + describe aws_iam_policy("AdministratorAccess") do + it { should be_attached_to_user("test-fixture-maker") } + end +end \ No newline at end of file diff --git a/test/unit/resources/aws_iam_policy_test.rb b/test/unit/resources/aws_iam_policy_test.rb new file mode 100644 index 000000000..164e19f03 --- /dev/null +++ b/test/unit/resources/aws_iam_policy_test.rb @@ -0,0 +1,202 @@ +require 'helper' +require 'aws_iam_policy' + +# MAIPSB = MockAwsIamPolicySingularBackend +# Abbreviation not used outside this file + +#=============================================================================# +# Constructor Tests +#=============================================================================# +class AwsIamPolicyConstructorTest < Minitest::Test + + def setup + AwsIamPolicy::BackendFactory.select(MAIPSB::Empty) + end + + def test_rejects_empty_params + assert_raises(ArgumentError) { AwsIamPolicy.new } + end + + def test_accepts_policy_name_as_scalar + AwsIamPolicy.new('test-policy-1') + end + + def test_accepts_policy_name_as_hash + AwsIamPolicy.new(policy_name: 'test-policy-1') + end + + def test_rejects_unrecognized_params + assert_raises(ArgumentError) { AwsIamPolicy.new(shoe_size: 9) } + end +end + + +#=============================================================================# +# Search / Recall +#=============================================================================# +class AwsIamPolicyRecallTest < Minitest::Test + + def setup + AwsIamPolicy::BackendFactory.select(MAIPSB::Basic) + end + + def test_search_hit_via_scalar_works + assert AwsIamPolicy.new('test-policy-1').exists? + end + + def test_search_hit_via_hash_works + assert AwsIamPolicy.new(policy_name: 'test-policy-1').exists? + end + + def test_search_miss_is_not_an_exception + refute AwsIamPolicy.new(policy_name: 'non-existant').exists? + end +end + +#=============================================================================# +# Properties +#=============================================================================# +class AwsIamPolicyPropertiesTest < Minitest::Test + + def setup + AwsIamPolicy::BackendFactory.select(MAIPSB::Basic) + end + + def test_property_arn + assert_equal('arn:aws:iam::aws:policy/test-policy-1', AwsIamPolicy.new('test-policy-1').arn) + assert_nil(AwsIamPolicy.new(policy_name: 'non-existant').arn) + end + + def test_property_default_version_id + assert_equal('v1', AwsIamPolicy.new('test-policy-1').default_version_id) + assert_nil(AwsIamPolicy.new(policy_name: 'non-existant').default_version_id) + end + + def test_property_attachment_count + assert_equal(3, AwsIamPolicy.new('test-policy-1').attachment_count) + assert_nil(AwsIamPolicy.new(policy_name: 'non-existant').attachment_count) + end + + def test_property_attached_users + assert_equal(['test-user'], AwsIamPolicy.new('test-policy-1').attached_users) + assert_nil(AwsIamPolicy.new(policy_name: 'non-existant').attached_users) + end + + def test_property_attached_groups + assert_equal(['test-group'], AwsIamPolicy.new('test-policy-1').attached_groups) + assert_nil(AwsIamPolicy.new(policy_name: 'non-existant').attached_groups) + end + + def test_property_attached_roles + assert_equal(['test-role'], AwsIamPolicy.new('test-policy-1').attached_roles) + assert_nil(AwsIamPolicy.new(policy_name: 'non-existant').attached_roles) + end +end + + +#=============================================================================# +# Matchers +#=============================================================================# +class AwsIamPolicyMatchersTest < Minitest::Test + + def setup + AwsIamPolicy::BackendFactory.select(MAIPSB::Basic) + end + + def test_matcher_attached_positive + assert AwsIamPolicy.new('test-policy-1').attached? + end + + def test_matcher_attached_negative + refute AwsIamPolicy.new('test-policy-2').attached? + end + + def test_matcher_attached_to_user_positive + assert AwsIamPolicy.new('test-policy-1').attached_to_user?('test-user') + end + + def test_matcher_attached_to_user_negative + refute AwsIamPolicy.new('test-policy-2').attached_to_user?('test-user') + end + + def test_matcher_attached_to_group_positive + assert AwsIamPolicy.new('test-policy-1').attached_to_group?('test-group') + end + + def test_matcher_attached_to_group_negative + refute AwsIamPolicy.new('test-policy-2').attached_to_group?('test-group') + end + + def test_matcher_attached_to_role_positive + assert AwsIamPolicy.new('test-policy-1').attached_to_role?('test-role') + end + + def test_matcher_attached_to_role_negative + refute AwsIamPolicy.new('test-policy-2').attached_to_role?('test-role') + end +end + +#=============================================================================# +# Test Fixtures +#=============================================================================# +module MAIPSB + class Empty < AwsIamPolicy::Backend + def list_policies(query) + OpenStruct.new(policies: []) + end + end + + class Basic < AwsIamPolicy::Backend + def list_policies(query) + fixtures = [ + OpenStruct.new({ + policy_name: 'test-policy-1', + arn: 'arn:aws:iam::aws:policy/test-policy-1', + default_version_id: 'v1', + attachment_count: 3, + is_attachable: true, + }), + OpenStruct.new({ + policy_name: 'test-policy-2', + arn: 'arn:aws:iam::aws:policy/test-policy-2', + default_version_id: 'v2', + attachment_count: 0, + is_attachable: false, + }), + ] + OpenStruct.new({ policies: fixtures }) + end + + def list_entities_for_policy(query) + policy = {} + policy['arn:aws:iam::aws:policy/test-policy-1'] = + { + policy_groups: [ + OpenStruct.new({ + group_name: 'test-group', + group_id: 'AIDAIJ3FUBXLZ4VXV34LE', + }), + ], + policy_users: [ + OpenStruct.new({ + user_name: 'test-user', + user_id: 'AIDAIJ3FUBXLZ4VXV34LE', + }), + ], + policy_roles: [ + OpenStruct.new({ + role_name: 'test-role', + role_id: 'AIDAIJ3FUBXLZ4VXV34LE', + }), + ], + } + policy['arn:aws:iam::aws:policy/test-policy-2'] = + { + policy_groups: [], + policy_users: [], + policy_roles: [], + } + OpenStruct.new( policy[query[:policy_arn]] ) + end + end +end \ No newline at end of file From 820547aa9d142cb906b61d313be5ec7d6b9bc704 Mon Sep 17 00:00:00 2001 From: Miah Johnson Date: Wed, 31 Jan 2018 18:51:43 -0800 Subject: [PATCH 157/206] Ensure unique security group name while integration testing. (#218) Signed-off-by: Miah Johnson --- test/integration/default/build/ec2.tf | 6 +++++- .../default/verify/controls/aws_ec2_security_group.rb | 5 +++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/test/integration/default/build/ec2.tf b/test/integration/default/build/ec2.tf index d2be103a7..e9571f41f 100644 --- a/test/integration/default/build/ec2.tf +++ b/test/integration/default/build/ec2.tf @@ -189,7 +189,7 @@ output "vpc_non_default_instance_tenancy" { # Create a security group with a known description # in the default VPC resource "aws_security_group" "alpha" { - name = "alpha" + name = "${terraform.env}-alpha" description = "SG alpha" vpc_id = "${data.aws_vpc.default.id}" } @@ -197,3 +197,7 @@ resource "aws_security_group" "alpha" { output "ec2_security_group_alpha_group_id" { value = "${aws_security_group.alpha.id}" } + +output "ec2_security_group_alpha_group_name" { + value = "${aws_security_group.alpha.name}" +} diff --git a/test/integration/default/verify/controls/aws_ec2_security_group.rb b/test/integration/default/verify/controls/aws_ec2_security_group.rb index 5bd77934f..8f0ba1d87 100644 --- a/test/integration/default/verify/controls/aws_ec2_security_group.rb +++ b/test/integration/default/verify/controls/aws_ec2_security_group.rb @@ -3,6 +3,7 @@ fixtures = {} 'ec2_security_group_default_vpc_id', 'ec2_security_group_default_group_id', 'ec2_security_group_alpha_group_id', + 'ec2_security_group_alpha_group_name', ].each do |fixture_name| fixtures[fixture_name] = attribute( fixture_name, @@ -33,9 +34,9 @@ control "aws_ec2_security_group properties" do end describe aws_ec2_security_group(fixtures['ec2_security_group_alpha_group_id']) do - its('group_name') { should cmp 'alpha' } + its('group_name') { should cmp fixtures['ec2_security_group_alpha_group_name'] } its('vpc_id') { should cmp fixtures['ec2_security_group_default_vpc_id'] } its('description') { should cmp 'SG alpha' } end -end \ No newline at end of file +end From b645f093e90cbf7b6a35012217aeacdc80581ebf Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Wed, 31 Jan 2018 21:52:28 -0500 Subject: [PATCH 158/206] Remove accidentally committed notes file (#194) Signed-off-by: Clinton Wolfe --- test/unit/resources/aws_vpc.notes | 91 ------------------------------- 1 file changed, 91 deletions(-) delete mode 100644 test/unit/resources/aws_vpc.notes diff --git a/test/unit/resources/aws_vpc.notes b/test/unit/resources/aws_vpc.notes deleted file mode 100644 index 5112797bf..000000000 --- a/test/unit/resources/aws_vpc.notes +++ /dev/null @@ -1,91 +0,0 @@ -#=============================================================================# -# Search / Recall -#=============================================================================# -class AwsVpcRecallTest < Minitest::Test - def setup - AwsVpc::BackendFactory.select(MAVSB::Three) - end - - def test_search_miss_is_not_an_exception - user = AwsVpc.new('vpc-87654321') - refute user.exists? - end - - def test_search_hit_via_scalar_works - user = AwsVpc.new('') - assert user.exists? - assert_equal('erin', user.username) - end - - def test_search_hit_via_hash_works - user = AwsVpc.new(username: 'erin') - assert user.exists? - assert_equal('erin', user.username) - end -end - -#=============================================================================# -# Properties -#=============================================================================# - -class AwsVpcPropertiesTest < Minitest::Test - def setup - AwsVpc::BackendFactory.select(MAVSB::Three) - end - - #-----------------------------------------------------# - # username property - #-----------------------------------------------------# - def test_property_username_correct_on_hit - user = AwsVpc.new(username: 'erin') - assert_equal('erin', user.username) - end - - #-----------------------------------------------------# - # has_console_password property and predicate - #-----------------------------------------------------# - def test_property_password_positive - user = AwsVpc.new(username: 'erin') - assert_equal(true, user.has_console_password) - assert_equal(true, user.has_console_password?) - end - - def test_property_password_negative - user = AwsVpc.new(username: 'leslie') - assert_equal(false, user.has_console_password) - assert_equal(false, user.has_console_password?) - end - - #-----------------------------------------------------# - # has_mfa_enabled property and predicate - #-----------------------------------------------------# - def test_property_mfa_positive - user = AwsVpc.new(username: 'erin') - assert_equal(true, user.has_mfa_enabled) - assert_equal(true, user.has_mfa_enabled?) - end - - def test_property_mfa_negative - user = AwsVpc.new(username: 'leslie') - assert_equal(false, user.has_mfa_enabled) - assert_equal(false, user.has_mfa_enabled?) - end - - #-----------------------------------------------------# - # access_keys property - #-----------------------------------------------------# - def test_property_access_keys_positive - keys = AwsVpc.new(username: 'erin').access_keys - assert_kind_of(Array, keys) - assert_equal(keys.length, 2) - # We don't currently promise that the results - # will be Inspec resource objects. - # assert_kind_of(AwsIamAccessKey, keys.first) - end - - def test_property_access_keys_negative - keys = AwsVpc.new(username: 'leslie').access_keys - assert_kind_of(Array, keys) - assert(keys.empty?) - end -end From 032eda1063079f1e88d70f1d8fc21d514cc5f5f2 Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Wed, 31 Jan 2018 21:54:47 -0500 Subject: [PATCH 159/206] Silence some test warnings (#140) Signed-off-by: Clinton Wolfe --- libraries/aws_ec2_security_group.rb | 4 +++- test/unit/resources/aws_ec2_security_group_test.rb | 2 +- test/unit/resources/aws_sns_topic_test.rb | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/libraries/aws_ec2_security_group.rb b/libraries/aws_ec2_security_group.rb index 8cc280d14..d03113d88 100644 --- a/libraries/aws_ec2_security_group.rb +++ b/libraries/aws_ec2_security_group.rb @@ -56,7 +56,9 @@ class AwsEc2SecurityGroup < Inspec.resource(1) :group_name, :vpc_id, ].each do |criterion_name| - val = instance_variable_get("@#{criterion_name}".to_sym) + instance_var = "@#{criterion_name}".to_sym + next unless instance_variable_defined?(instance_var) + val = instance_variable_get(instance_var) next if val.nil? filters.push( { diff --git a/test/unit/resources/aws_ec2_security_group_test.rb b/test/unit/resources/aws_ec2_security_group_test.rb index 324c6330f..2f9b01bad 100644 --- a/test/unit/resources/aws_ec2_security_group_test.rb +++ b/test/unit/resources/aws_ec2_security_group_test.rb @@ -51,7 +51,7 @@ end # Properties #=============================================================================# -class AwsESGSConstructor < Minitest::Test +class AwsESGSProperties < Minitest::Test def setup AwsEc2SecurityGroup::BackendFactory.select(AwsMESGSB::Basic) end diff --git a/test/unit/resources/aws_sns_topic_test.rb b/test/unit/resources/aws_sns_topic_test.rb index 77f5f4bb6..3bb2c455e 100644 --- a/test/unit/resources/aws_sns_topic_test.rb +++ b/test/unit/resources/aws_sns_topic_test.rb @@ -80,7 +80,7 @@ class AwsSnsTopicPropertiesTest < Minitest::Test assert_equal(0, topic.confirmed_subscription_count) end - def test_prop_conf_sub_count_zero + def test_prop_conf_sub_count_one AwsSnsTopic::BackendFactory.select(AwsMSNB::OneSubscription) topic = AwsSnsTopic.new('arn:aws:sns:us-east-1:123456789012:does-not-matter') assert_equal(1, topic.confirmed_subscription_count) From 1b170dcfb6922ae46ba3395a3dcbb6352951f827 Mon Sep 17 00:00:00 2001 From: Rony Xavier Date: Wed, 31 Jan 2018 22:16:30 -0500 Subject: [PATCH 160/206] aws_iam_access_keys incorrectly populates created_date (#215) Signed-off-by: Rony Xavier --- docs/resources/aws_iam_access_keys.md | 4 ++-- libraries/aws_iam_access_keys.rb | 2 +- test/unit/resources/aws_iam_access_keys_test.rb | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/resources/aws_iam_access_keys.md b/docs/resources/aws_iam_access_keys.md index c5c94874e..e1ff7303e 100644 --- a/docs/resources/aws_iam_access_keys.md +++ b/docs/resources/aws_iam_access_keys.md @@ -73,12 +73,12 @@ A true / false value indicating if an Access Key is currently "Active" (the norm its('access_key_ids') { should include('AKIA1234567890ABCDEF')} end -### created_date +### create_date A DateTime identifying when the Access Key was created. See also `created_days_ago` and `created_hours_ago`. # Detect keys older than 2017 - describe aws_iam_access_keys.where { created_date < DateTime.parse('2017-01-01') } do + describe aws_iam_access_keys.where { create_date < DateTime.parse('2017-01-01') } do it { should_not exist } end diff --git a/libraries/aws_iam_access_keys.rb b/libraries/aws_iam_access_keys.rb index 430423fb9..e2224f8f2 100644 --- a/libraries/aws_iam_access_keys.rb +++ b/libraries/aws_iam_access_keys.rb @@ -55,7 +55,7 @@ class AwsIamAccessKeys < Inspec.resource(1) .add_accessor(:entries) .add(:exists?) { |x| !x.entries.empty? } .add(:access_key_ids, field: :access_key_id) - .add(:created_date, field: :created_date) + .add(:created_date, field: :create_date) .add(:created_days_ago, field: :created_days_ago) .add(:created_with_user, field: :created_with_user) .add(:created_hours_ago, field: :created_hours_ago) diff --git a/test/unit/resources/aws_iam_access_keys_test.rb b/test/unit/resources/aws_iam_access_keys_test.rb index 15d5fe4e1..f087f0ca8 100644 --- a/test/unit/resources/aws_iam_access_keys_test.rb +++ b/test/unit/resources/aws_iam_access_keys_test.rb @@ -136,13 +136,13 @@ class AwsIamAccessKeysPropertiesTest < Minitest::Test # created_date / created_days_ago / created_hours_ago # #----------------------------------------------------------# def test_property_created_date - assert_kind_of(DateTime, @all_basic.entries.first.created_date) + assert_kind_of(DateTime, @all_basic.entries.first.create_date) - arg_filtered = @all_basic.where(created_date: DateTime.parse('2017-10-27T17:58:00Z')) + arg_filtered = @all_basic.where(create_date: DateTime.parse('2017-10-27T17:58:00Z')) assert_equal(1, arg_filtered.entries.count) assert arg_filtered.access_key_ids.first.end_with?('BOB') - block_filtered = @all_basic.where { created_date.friday? } + block_filtered = @all_basic.where { create_date.friday? } assert_equal(1, block_filtered.entries.count) assert block_filtered.access_key_ids.first.end_with?('BOB') end @@ -312,7 +312,7 @@ class BasicMAKP < AwsIamAccessKeys::AccessKeyProvider username: 'bob', access_key_id: 'AKIA1234567890123BOB', id: 'AKIA1234567890123BOB', - created_date: DateTime.parse('2017-10-27T17:58:00Z'), + create_date: DateTime.parse('2017-10-27T17:58:00Z'), created_days_ago: 4, created_hours_ago: 102, created_with_user: true, @@ -330,7 +330,7 @@ class BasicMAKP < AwsIamAccessKeys::AccessKeyProvider username: 'sally', access_key_id: 'AKIA12345678901SALLY', id: 'AKIA12345678901SALLY', - created_date: DateTime.parse('2017-10-22T17:58:00Z'), + create_date: DateTime.parse('2017-10-22T17:58:00Z'), created_days_ago: 9, created_hours_ago: 222, created_with_user: false, @@ -348,7 +348,7 @@ class BasicMAKP < AwsIamAccessKeys::AccessKeyProvider username: 'robin', access_key_id: 'AKIA12345678901ROBIN', id: 'AKIA12345678901ROBIN', - created_date: DateTime.parse('2017-10-31T17:58:00Z'), + create_date: DateTime.parse('2017-10-31T17:58:00Z'), created_days_ago: 1, created_hours_ago: 12, created_with_user: true, From 6ae80ad6f7835c834e7007ef6db3991e25f52a0d Mon Sep 17 00:00:00 2001 From: Rony Xavier Date: Wed, 31 Jan 2018 22:45:02 -0500 Subject: [PATCH 161/206] skeletal aws_iam_ policies resource (#193) Signed-off-by: Rony Xavier --- docs/resources/aws_iam_policies.md | 72 ++++++++++++ libraries/aws_iam_policies.rb | 44 ++++++++ .../verify/controls/aws_iam_policies.rb | 5 + test/unit/resources/aws_iam_policies_test.rb | 105 ++++++++++++++++++ 4 files changed, 226 insertions(+) create mode 100644 docs/resources/aws_iam_policies.md create mode 100644 libraries/aws_iam_policies.rb create mode 100644 test/integration/default/verify/controls/aws_iam_policies.rb create mode 100644 test/unit/resources/aws_iam_policies_test.rb diff --git a/docs/resources/aws_iam_policies.md b/docs/resources/aws_iam_policies.md new file mode 100644 index 000000000..c53f159b5 --- /dev/null +++ b/docs/resources/aws_iam_policies.md @@ -0,0 +1,72 @@ +--- +title: About the aws_iam_policies Resource +--- + +# aws_iam_policies + +Use the `aws_iam_policies` InSpec audit resource to test properties of some or all AWS IAM Policies. + +To test properties of all managed policies that are available in your AWS account, including your own customer-defined managed policies and all AWS managed policies. + +A policy is an entity in AWS that, when attached to an identity or resource, defines their permissions. AWS evaluates these policies when a principal, such as a user, makes a request. Permissions in the policies determine whether the request is allowed or denied. + +Each IAM Policy is uniquely identified by either its policy_name or arn. + +
+ +## Syntax + +An `aws_iam_policies` resource block collects a group of IAM Policies and then tests that group. + + # Verify the policy specified by the policy name is included in IAM Policies in the AWS account. + describe aws_iam_policies do + its('policy_names') { should include('test-policy-1') } + end + +
+ +## Examples + +The following examples show how to use this InSpec audit resource. + +As this is the initial release of `aws_iam_policies`, its limited functionality precludes examples. + +
+ +## Matchers + +### exists + +The control will pass if the filter returns at least one result. Use should_not if you expect zero matches. + + # Verify that at least one IAM Policies exists. + describe aws_iam_policies + it { should exist } + end + +## Properties + +### policy_names + +Provides a list of policy names for all IAM Policies in the AWS account. + + describe aws_iam_policies do + its('policy_names') { should include('test-policy-1') } + end + +### arns + +Provides a list of policy arns for all IAM Policies in the AWS account. + + describe aws_iam_policies do + its('arns') { should include('arn:aws:iam::aws:policy/test-policy-1') } + end + +### entries + +Provides access to the raw results of the query. This can be useful for checking counts and other advanced operations. + + # Allow at most 100 IAM Policies on the account + describe aws_iam_policies do + its('entries.count') { should be <= 100} + end diff --git a/libraries/aws_iam_policies.rb b/libraries/aws_iam_policies.rb new file mode 100644 index 000000000..5e903d05f --- /dev/null +++ b/libraries/aws_iam_policies.rb @@ -0,0 +1,44 @@ +class AwsIamPolicies < Inspec.resource(1) + name 'aws_iam_policies' + desc 'Verifies settings for AWS IAM Policies in bulk' + example ' + describe aws_iam_policies do + it { should exist } + end + ' + + # Underlying FilterTable implementation. + filter = FilterTable.create + filter.add_accessor(:entries) + .add(:exists?) { |x| !x.entries.empty? } + .add(:policy_names, field: :policy_name) + .add(:arns, field: :arn) + filter.connect(self, :policy_data) + + def policy_data + @table + end + + def to_s + 'IAM Policies' + end + + def initialize + backend = AwsIamPolicies::BackendFactory.create + @table = backend.list_policies({}).to_h[:policies] + end + + class BackendFactory + extend AwsBackendFactoryMixin + end + + class Backend + class AwsClientApi + BackendFactory.set_default_backend(self) + + def list_policies(query) + AWSConnection.new.iam_client.list_policies(query) + end + end + end +end diff --git a/test/integration/default/verify/controls/aws_iam_policies.rb b/test/integration/default/verify/controls/aws_iam_policies.rb new file mode 100644 index 000000000..4cc3ec2f9 --- /dev/null +++ b/test/integration/default/verify/controls/aws_iam_policies.rb @@ -0,0 +1,5 @@ +control "aws_iam_policies recall" do + describe aws_iam_policies do + it { should exist } + end +end diff --git a/test/unit/resources/aws_iam_policies_test.rb b/test/unit/resources/aws_iam_policies_test.rb new file mode 100644 index 000000000..e4563f767 --- /dev/null +++ b/test/unit/resources/aws_iam_policies_test.rb @@ -0,0 +1,105 @@ +require 'helper' +require 'aws_iam_policies' + +# MAIPPB = MockAwsIamPoliciesPluralBackend +# Abbreviation not used outside this file + +#=============================================================================# +# Constructor Tests +#=============================================================================# +class AwsIamPoliciesConstructorTest < Minitest::Test + + def setup + AwsIamPolicies::BackendFactory.select(MAIPPB::Empty) + end + + def test_empty_params_ok + AwsIamPolicies.new + end + + def test_rejects_unrecognized_params + assert_raises(ArgumentError) { AwsIamPolicies.new(shoe_size: 9) } + end +end + + +#=============================================================================# +# Search / Recall +#=============================================================================# +class AwsIamPoliciesRecallEmptyTest < Minitest::Test + + def setup + AwsIamPolicies::BackendFactory.select(MAIPPB::Empty) + end + + def test_search_miss_policy_empty_policy_list + refute AwsIamPolicies.new.exists? + end +end + +class AwsIamPoliciesRecallBasicTest < Minitest::Test + + def setup + AwsIamPolicies::BackendFactory.select(MAIPPB::Basic) + end + + def test_search_hit_via_empty_filter + assert AwsIamPolicies.new.exists? + end +end + +#=============================================================================# +# Properties +#=============================================================================# +class AwsIamPoliciesProperties < Minitest::Test + def setup + AwsIamPolicies::BackendFactory.select(MAIPPB::Basic) + end + + def test_property_policy_names + basic = AwsIamPolicies.new + assert_kind_of(Array, basic.policy_names) + assert(basic.policy_names.include?('test-policy-1')) + refute(basic.policy_names.include?(nil)) + end + + def test_property_arns + basic = AwsIamPolicies.new + assert_kind_of(Array, basic.arns) + assert(basic.arns.include?('arn:aws:iam::aws:policy/test-policy-1')) + refute(basic.arns.include?(nil)) + end +end +#=============================================================================# +# Test Fixtures +#=============================================================================# +module MAIPPB + class Empty < AwsIamPolicies::Backend + def list_policies(query = {}) + OpenStruct.new({ policies: [] }) + end + end + + class Basic < AwsIamPolicies::Backend + def list_policies(query = {}) + fixtures = [ + OpenStruct.new({ + policy_name: 'test-policy-1', + arn: 'arn:aws:iam::aws:policy/test-policy-1', + default_version_id: 'v1', + attachment_count: 3, + is_attachable: true, + }), + OpenStruct.new({ + policy_name: 'test-policy-2', + arn: 'arn:aws:iam::aws:policy/test-policy-2', + default_version_id: 'v2', + attachment_count: 0, + is_attachable: false, + }), + ] + + OpenStruct.new({ policies: fixtures }) + end + end +end From e2e9915aa4543accd3556f186d5713a381b6445e Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Thu, 1 Feb 2018 10:21:54 -0500 Subject: [PATCH 162/206] Skeletal aws_kms_keys resource Signed-off-by: Rony Xavier Signed-off-by: Clinton Wolfe --- docs/resources/aws_kms_keys.md | 70 ++++++++++++ libraries/_aws_connection.rb | 4 + libraries/aws_kms_keys.rb | 44 ++++++++ .../default/verify/controls/aws_kms_keys.rb | 5 + test/unit/resources/aws_kms_keys_test.rb | 103 ++++++++++++++++++ 5 files changed, 226 insertions(+) create mode 100644 docs/resources/aws_kms_keys.md create mode 100644 libraries/aws_kms_keys.rb create mode 100644 test/integration/default/verify/controls/aws_kms_keys.rb create mode 100644 test/unit/resources/aws_kms_keys_test.rb diff --git a/docs/resources/aws_kms_keys.md b/docs/resources/aws_kms_keys.md new file mode 100644 index 000000000..e7519bf95 --- /dev/null +++ b/docs/resources/aws_kms_keys.md @@ -0,0 +1,70 @@ +--- +title: About the aws_kms_keys Resource +--- + +# aws_kms_keys + +Use the `aws_kms_keys` InSpec audit resource to test properties of some or all AWS KMS Keys. + +AWS Key Management Service (KMS) is a managed service that makes it easy for you to create and control the encryption keys used to encrypt your data, and uses Hardware Security Modules (HSMs) to protect the security of your keys. AWS Key Management Service is integrated with several other AWS services to help you protect the data you store with these services. + +Each AWS KMS Key is uniquely identified by its key-id or key-arn. + +
+ +## Syntax + +An `aws_kms_keys` resource block uses an optional filter to select a group of KMS Keys and then tests that group. + + # Verify the number of KMS keys in the AWS account + describe aws_kms_keys do + its('entries.count') { should cmp 10 } + end + +
+ +## Examples + +The following examples show how to use this InSpec audit resource. + +As this is the initial release of `aws_kms_keys`, its limited functionality precludes examples. + +
+ +## Matchers + +### exists + +The control will pass if the filter returns at least one result. Use should_not if you expect zero matches. + + # Verify that at least one KMS Key exists. + describe aws_kms_keys + it { should exist } + end + +## Properties + +### key_arns + +Provides a list of key arns for all KMS Keys in the AWS account. + + describe aws_kms_keys do + its('key_arns') { should include('arn:aws:kms:us-east-1::key/key-id') } + end + +### key_ids + +Provides a list of key ids for all KMS Keys in the AWS account. + + describe aws_kms_keys do + its('key_ids') { should include('fd7e608b-f435-4186-b8b5-111111111111') } + end + +### entries + +Provides access to the raw results of the query. This can be useful for checking counts and other advanced operations. + + # Allow at most 100 KMS Keys on the account + describe aws_kms_keys do + its('entries.count') { should be <= 100} + end diff --git a/libraries/_aws_connection.rb b/libraries/_aws_connection.rb index 06c3b30f2..f723fa5b1 100644 --- a/libraries/_aws_connection.rb +++ b/libraries/_aws_connection.rb @@ -60,4 +60,8 @@ class AWSConnection def s3_client @s3_client ||= Aws::S3::Client.new end + + def kms_client + @kms_client ||= Aws::KMS::Client.new + end end diff --git a/libraries/aws_kms_keys.rb b/libraries/aws_kms_keys.rb new file mode 100644 index 000000000..2b28925b6 --- /dev/null +++ b/libraries/aws_kms_keys.rb @@ -0,0 +1,44 @@ +class AwsKmsKeys < Inspec.resource(1) + name 'aws_kms_keys' + desc 'Verifies settings for AWS KMS Keys in bulk' + example ' + describe aws_kms_keys do + it { should exist } + end + ' + + # Underlying FilterTable implementation. + filter = FilterTable.create + filter.add_accessor(:entries) + .add(:exists?) { |x| !x.entries.empty? } + .add(:key_arns, field: :key_arn) + .add(:key_ids, field: :key_id) + filter.connect(self, :key_data) + + def key_data + @table + end + + def to_s + 'KMS Keys' + end + + def initialize + backend = AwsKmsKeys::BackendFactory.create + @table = backend.list_keys({ limit: 1000 }).to_h[:keys] # max value for limit is 1000 + end + + class BackendFactory + extend AwsBackendFactoryMixin + end + + class Backend + class AwsClientApi + BackendFactory.set_default_backend(self) + + def list_keys(query = {}) + AWSConnection.new.kms_client.list_keys(query) + end + end + end +end diff --git a/test/integration/default/verify/controls/aws_kms_keys.rb b/test/integration/default/verify/controls/aws_kms_keys.rb new file mode 100644 index 000000000..881d535ee --- /dev/null +++ b/test/integration/default/verify/controls/aws_kms_keys.rb @@ -0,0 +1,5 @@ +control "aws_kms_keys recall" do + describe aws_kms_keys do + it { should exist } + end +end diff --git a/test/unit/resources/aws_kms_keys_test.rb b/test/unit/resources/aws_kms_keys_test.rb new file mode 100644 index 000000000..9f7217d83 --- /dev/null +++ b/test/unit/resources/aws_kms_keys_test.rb @@ -0,0 +1,103 @@ +require 'helper' +require 'aws_kms_keys' + +# MAKKPB = MockAwsKmsKeysPluralBackend +# Abbreviation not used outside this file + +#=============================================================================# +# Constructor Tests +#=============================================================================# +class AwsKmsKeysConstructorTest < Minitest::Test + + def setup + AwsKmsKeys::BackendFactory.select(MAKKPB::Empty) + end + + def test_empty_params_ok + AwsKmsKeys.new + end + + def test_rejects_unrecognized_params + assert_raises(ArgumentError) { AwsKmsKeys.new(shoe_size: 9) } + end +end + + +#=============================================================================# +# Search / Recall +#=============================================================================# +class AwsKmsKeysRecallEmptyTest < Minitest::Test + + def setup + AwsKmsKeys::BackendFactory.select(MAKKPB::Empty) + end + + def test_search_miss_key_empty_kms_key_list + refute AwsKmsKeys.new.exists? + end +end + +class AwsKmsKeysRecallBasicTest < Minitest::Test + + def setup + AwsKmsKeys::BackendFactory.select(MAKKPB::Basic) + end + + def test_search_hit_via_empty_filter + assert AwsKmsKeys.new.exists? + end +end + +#=============================================================================# +# Properties +#=============================================================================# +class AwsKmsKeysProperties < Minitest::Test + def setup + AwsKmsKeys::BackendFactory.select(MAKKPB::Basic) + end + + def test_property_key_ids + basic = AwsKmsKeys.new + assert_kind_of(Array, basic.key_ids) + assert(basic.key_ids.include?('012026a4-b657-42bf-99ae-111111111111')) + refute(basic.key_ids.include?(nil)) + end + + def test_property_key_arns + basic = AwsKmsKeys.new + assert_kind_of(Array, basic.key_arns) + assert(basic.key_arns.include?('arn:aws:kms:us-east-1::key/012026a4-b657-42bf-99ae-111111111111')) + refute(basic.key_arns.include?(nil)) + end +end +#=============================================================================# +# Test Fixtures +#=============================================================================# +module MAKKPB + class Empty < AwsKmsKeys::Backend + def list_keys(query = {}) + OpenStruct.new({ keys: [] }) + end + end + + class Basic < AwsKmsKeys::Backend + def list_keys(query = {}) + fixtures = [ + OpenStruct.new({ + key_id: '012026a4-b657-42bf-99ae-111111111111', + key_arn: 'arn:aws:kms:us-east-1::key/012026a4-b657-42bf-99ae-111111111111', + }), + OpenStruct.new({ + key_id: '012026a4-b657-42bf-99ae-222222222222', + key_arn: 'arn:aws:kms:us-east-1::key/012026a4-b657-42bf-99ae-222222222222', + }), + OpenStruct.new({ + key_id: '012026a4-b657-42bf-99ae-333333333333', + key_arn: 'arn:aws:kms:us-east-1::key/012026a4-b657-42bf-99ae-333333333333', + }), + ] + + OpenStruct.new({ keys: fixtures }) + end + end +end From 7d530567518061131ee013f15ad25edfc14e1389 Mon Sep 17 00:00:00 2001 From: Rony Xavier Date: Thu, 1 Feb 2018 11:23:25 -0500 Subject: [PATCH 164/206] Password usage properties for aws_iam_users (#213) Signed-off-by: Rony Xavier --- docs/resources/aws_iam_users.md | 27 ++++++++++++++++ libraries/aws_iam_users.rb | 9 +++++- test/unit/resources/aws_iam_users_test.rb | 38 +++++++++++++++++++++-- 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/docs/resources/aws_iam_users.md b/docs/resources/aws_iam_users.md index 128884268..ff24be030 100644 --- a/docs/resources/aws_iam_users.md +++ b/docs/resources/aws_iam_users.md @@ -48,6 +48,33 @@ The following examples show how to use this InSpec audit resource. it { should_not exist } end +### Test that all users that have a console password should have used it at-least once + + console_users_with_unused_password = aws_iam_users + .where(has_console_password?: true) + .where(password_never_used?: false) + + describe console_users_with_unused_password do + it { should_not exist } + end + +### Test that atleast one user exists with console password and used it atleast once + + console_users_with_used_password = aws_iam_users + .where(has_console_password?: true) + .where(password_ever_used?: false) + + describe console_users_with_used_password do + it { should exist } + end + + +### Test that users with used passwords longer that 90 days should not exists + + describe aws_iam_users.where { password_last_used_days_ago > 90 } do + it { should_not exist } + end +
## Matchers diff --git a/libraries/aws_iam_users.rb b/libraries/aws_iam_users.rb index 5ca9faff3..4e3276791 100644 --- a/libraries/aws_iam_users.rb +++ b/libraries/aws_iam_users.rb @@ -11,7 +11,6 @@ class AwsIamUsers < Inspec.resource(1) describe aws_iam_users.where(has_mfa_enabled?: false) do it { should_not exist } end - describe aws_iam_users.where(has_console_password?: true) do it { should exist } end @@ -23,6 +22,9 @@ class AwsIamUsers < Inspec.resource(1) .add(:exists?) { |x| !x.entries.empty? } .add(:has_mfa_enabled?, field: :has_mfa_enabled) .add(:has_console_password?, field: :has_console_password) + .add(:password_ever_used?, field: :password_ever_used?) + .add(:password_never_used?, field: :password_never_used?) + .add(:password_last_used_days_ago, field: :password_last_used_days_ago) .add(:username, field: :user_name) filter.connect(self, :collect_user_details) @@ -51,6 +53,11 @@ class AwsIamUsers < Inspec.resource(1) user[:has_mfa_enabled] = false end user[:has_mfa_enabled?] = user[:has_mfa_enabled] + password_last_used = user[:password_last_used] + user[:password_ever_used?] = !password_last_used.nil? + user[:password_never_used?] = password_last_used.nil? + next unless user[:password_ever_used?] + user[:password_last_used_days_ago] = ((Time.now - password_last_used) / (24*60*60)).to_i end users end diff --git a/test/unit/resources/aws_iam_users_test.rb b/test/unit/resources/aws_iam_users_test.rb index f10f32a82..c60e72dc6 100644 --- a/test/unit/resources/aws_iam_users_test.rb +++ b/test/unit/resources/aws_iam_users_test.rb @@ -60,6 +60,40 @@ class AwsIamUsersTestFilterCriteria < Minitest::Test assert_includes users.entries.map{ |u| u[:user_name] }, 'carol' refute_includes users.entries.map{ |u| u[:user_name] }, 'alice' end + + #------------------------------------------# + # password_ever_used? + #------------------------------------------# + def test_users_criteria_password_ever_used? + AwsIamUsers::Backend.select(Maiusb::Basic) + users = AwsIamUsers.new.where { password_ever_used? } + assert(2, users.entries.count) + assert_includes users.entries.map{ |u| u[:user_name] }, 'carol' + refute_includes users.entries.map{ |u| u[:user_name] }, 'alice' + end + + #------------------------------------------# + # password_never_used? + #------------------------------------------# + def test_users_criteria_password_never_used? + AwsIamUsers::Backend.select(Maiusb::Basic) + users = AwsIamUsers.new.where { password_never_used? } + assert(1, users.entries.count) + assert_includes users.entries.map{ |u| u[:user_name] }, 'alice' + refute_includes users.entries.map{ |u| u[:user_name] }, 'carol' + end + + #------------------------------------------# + # password_last_used_days_ago + #------------------------------------------# + def test_users_criteria_has_password_last_used_days_ago_10 + AwsIamUsers::Backend.select(Maiusb::Basic) + users = AwsIamUsers.new.where(password_last_used_days_ago: 10) + puts users + assert(1, users.entries.count) + assert_includes users.entries.map{ |u| u[:user_name] }, 'bob' + refute_includes users.entries.map{ |u| u[:user_name] }, 'alice' + end end #=============================================================================# @@ -107,12 +141,12 @@ module Maiusb OpenStruct.new({ user_name: 'bob', create_date: DateTime.parse('2017-11-06T16:19:30Z'), - password_last_used: DateTime.parse('2017-11-06T19:19:30Z'), + password_last_used: Time.now - 10*24*60*60, }), OpenStruct.new({ user_name: 'carol', create_date: DateTime.parse('2017-10-10T16:19:30Z'), - password_last_used: DateTime.parse('2017-10-28T19:19:30Z'), + password_last_used: Time.now - 91*24*60*60, }), ] }) From 23b57ab591912160b3e52593c647f53ed6d10aa2 Mon Sep 17 00:00:00 2001 From: Rony Xavier Date: Thu, 1 Feb 2018 11:50:38 -0500 Subject: [PATCH 165/206] Add have_access_logging_enabled matcher to aws_s3_bucket (#212) Signed-off-by: Rony Xavier --- docs/resources/aws_s3_bucket.md | 6 ++++ libraries/aws_s3_bucket.rb | 10 ++++++ test/integration/default/build/s3.tf | 34 +++++++++++++++++++ .../default/verify/controls/aws_s3_bucket.rb | 10 ++++++ test/unit/resources/aws_s3_bucket_test.rb | 19 +++++++++++ 5 files changed, 79 insertions(+) diff --git a/docs/resources/aws_s3_bucket.md b/docs/resources/aws_s3_bucket.md index 5f43e4e71..2080dab8e 100644 --- a/docs/resources/aws_s3_bucket.md +++ b/docs/resources/aws_s3_bucket.md @@ -121,3 +121,9 @@ The `be_public` matcher tests if the bucket has potentially insecure access cont Note: This resource does not detect insecure object ACLs. it { should_not be_public } + +### have_access_logging_enabled + +The `have_access_logging_enabled` matcher tests if access logging is enabled for the s3 bucket. + + it { should have_access_logging_enabled } diff --git a/libraries/aws_s3_bucket.rb b/libraries/aws_s3_bucket.rb index 07cb6690f..e8ad98d61 100644 --- a/libraries/aws_s3_bucket.rb +++ b/libraries/aws_s3_bucket.rb @@ -35,6 +35,12 @@ class AwsS3Bucket < Inspec.resource(1) bucket_policy.any? { |s| s.effect == 'Allow' && s.principal == '*' } end + def has_access_logging_enabled? + return unless @exists + # This is simple enough to inline it. + !AwsS3Bucket::BackendFactory.create.get_bucket_logging(bucket: bucket_name).logging_enabled.nil? + end + private def validate_params(raw_params) @@ -97,6 +103,10 @@ class AwsS3Bucket < Inspec.resource(1) def get_bucket_policy(query) AWSConnection.new.s3_client.get_bucket_policy(query) end + + def get_bucket_logging(query) + AWSConnection.new.s3_client.get_bucket_logging(query) + end end end end diff --git a/test/integration/default/build/s3.tf b/test/integration/default/build/s3.tf index 943a00d89..818e5eaf1 100644 --- a/test/integration/default/build/s3.tf +++ b/test/integration/default/build/s3.tf @@ -42,6 +42,40 @@ output "s3_bucket_private_acl_public_policy_name" { value = "${aws_s3_bucket.private_acl_public_policy.id}" } + +resource "aws_s3_bucket" "log_bucket" { + bucket = "inspec-testing-log-bucket-${terraform.env}.chef.io" + force_destroy = true + acl = "log-delivery-write" +} + +output "s3_bucket_log_bucket_name" { + value = "${aws_s3_bucket.log_bucket.id}" +} + +resource "aws_s3_bucket" "acess_logging_enabled" { + bucket = "inspec-testing-acess-logging-enabled-${terraform.env}.chef.io" + acl = "private" + + logging { + target_bucket = "${aws_s3_bucket.log_bucket.id}" + target_prefix = "log/" + } +} + +output "s3_bucket_access_logging_enabled_name" { + value = "${aws_s3_bucket.acess_logging_enabled.id}" +} + +resource "aws_s3_bucket" "acess_logging_not_enabled" { + bucket = "inspec-testing-acess-logging-not-enabled-${terraform.env}.chef.io" + acl = "private" +} + +output "s3_bucket_access_logging_not_enabled_name" { + value = "${aws_s3_bucket.acess_logging_not_enabled.id}" +} + #=================================================================# # S3 Bucket Policies #=================================================================# diff --git a/test/integration/default/verify/controls/aws_s3_bucket.rb b/test/integration/default/verify/controls/aws_s3_bucket.rb index 45dc9010b..b917cc88f 100644 --- a/test/integration/default/verify/controls/aws_s3_bucket.rb +++ b/test/integration/default/verify/controls/aws_s3_bucket.rb @@ -5,6 +5,8 @@ fixtures = {} 's3_bucket_auth_name', 's3_bucket_private_acl_public_policy_name', 's3_bucket_public_region', + 's3_bucket_access_logging_enabled_name', + 's3_bucket_access_logging_not_enabled_name', ].each do |fixture_name| fixtures[fixture_name] = attribute( fixture_name, @@ -110,4 +112,12 @@ control 'aws_s3_bucket matchers test' do describe aws_s3_bucket(bucket_name: fixtures['s3_bucket_private_acl_public_policy_name']) do it { should be_public } end + + #----------------- have_access_logging_enabled -----------------# + describe aws_s3_bucket(bucket_name: fixtures['s3_bucket_access_logging_enabled_name']) do + it { should have_access_logging_enabled } + end + describe aws_s3_bucket(bucket_name: fixtures['s3_bucket_access_logging_not_enabled_name']) do + it { should_not have_access_logging_enabled } + end end diff --git a/test/unit/resources/aws_s3_bucket_test.rb b/test/unit/resources/aws_s3_bucket_test.rb index d64d51f6a..b92cfd38a 100644 --- a/test/unit/resources/aws_s3_bucket_test.rb +++ b/test/unit/resources/aws_s3_bucket_test.rb @@ -167,6 +167,14 @@ class AwsS3BucketPropertiesTest < Minitest::Test def test_be_public_public_acl assert(AwsS3Bucket.new('public').public?) end + + def test_has_access_logging_enabled_positive + assert(AwsS3Bucket.new('public').has_access_logging_enabled?) + end + + def test_has_access_logging_enabled_negative + refute(AwsS3Bucket.new('private').has_access_logging_enabled?) + end end @@ -285,5 +293,16 @@ EOP end buckets[query[:bucket]] end + + def get_bucket_logging(query) + buckets = { + 'public' => OpenStruct.new({ logging_enabled: OpenStruct.new({ target_bucket: 'log-bucket' }) }), + 'private' => OpenStruct.new({ logging_enabled: nil }), + } + unless buckets.key?(query[:bucket]) + raise Aws::S3::Errors::NoSuchBucket.new(nil, nil) + end + buckets[query[:bucket]] + end end end From d722827ebd3b8c3ca7e7c0b2c064e70dfde1b98f Mon Sep 17 00:00:00 2001 From: Sam Cornwell <14048146+samcornwell@users.noreply.github.com> Date: Thu, 1 Feb 2018 13:09:48 -0500 Subject: [PATCH 166/206] Skeletal aws_iam_groups resource (#208) Signed-off-by: Sam Cornwell <14048146+samcornwell@users.noreply.github.com> --- docs/resources/aws_iam_groups.md | 40 ++++++++ libraries/aws_iam_groups.rb | 42 +++++++++ test/integration/default/build/iam.tf | 12 +++ .../default/verify/controls/aws_iam_groups.rb | 5 + test/unit/resources/aws_iam_groups_test.rb | 93 +++++++++++++++++++ 5 files changed, 192 insertions(+) create mode 100644 docs/resources/aws_iam_groups.md create mode 100644 libraries/aws_iam_groups.rb create mode 100644 test/integration/default/verify/controls/aws_iam_groups.rb create mode 100644 test/unit/resources/aws_iam_groups_test.rb diff --git a/docs/resources/aws_iam_groups.md b/docs/resources/aws_iam_groups.md new file mode 100644 index 000000000..0712079b6 --- /dev/null +++ b/docs/resources/aws_iam_groups.md @@ -0,0 +1,40 @@ +--- +title: About the aws_iam_groups Resource +--- + +# aws_iam_groups + +Use the `aws_iam_groups` InSpec audit resource to test properties of all or multiple groups. + +To test properties of a single group, use the `aws_iam_group` resource. + +
+ +## Syntax + +An `aws_iam_groups` resource block uses an optional filter to select a collection of IAM groups and then tests that collection. + + # The control will pass if the filter returns at least one result. Use should_not if you expect zero matches. + describe aws_iam_groups do + it { should exist } + end + +
+ +## Examples + +The following examples show how to use this InSpec audit resource. + +As this is the initial release of `aws_iam_groups`, its limited functionality precludes examples. + +
+ +## Matchers + +### exists + +The control will pass if the filter returns at least one result. Use should_not if you expect zero matches. + + describe aws_iam_groups + it { should exist } + end diff --git a/libraries/aws_iam_groups.rb b/libraries/aws_iam_groups.rb new file mode 100644 index 000000000..ce19aaed8 --- /dev/null +++ b/libraries/aws_iam_groups.rb @@ -0,0 +1,42 @@ +class AwsIamGroups < Inspec.resource(1) + name 'aws_iam_groups' + desc 'Verifies settings for AWS IAM groups in bulk' + example ' + describe aws_iam_groups do + it { should exist } + end + ' + + # Underlying FilterTable implementation. + filter = FilterTable.create + filter.add_accessor(:entries) + .add(:exists?) { |x| !x.entries.empty? } + filter.connect(self, :group_data) + + def group_data + @table + end + + def to_s + 'IAM Groups' + end + + def initialize + backend = AwsIamGroups::BackendFactory.create + @table = backend.list_groups.to_h[:groups] + end + + class BackendFactory + extend AwsBackendFactoryMixin + end + + class Backend + class AwsClientApi + BackendFactory.set_default_backend(self) + + def list_groups(query = {}) + AWSConnection.new.iam_client.list_groups(query) + end + end + end +end diff --git a/test/integration/default/build/iam.tf b/test/integration/default/build/iam.tf index 152b56295..8bf5c978f 100644 --- a/test/integration/default/build/iam.tf +++ b/test/integration/default/build/iam.tf @@ -87,4 +87,16 @@ output "iam_access_key_recall_hit" { output "iam_access_key_recall_miss" { value = "AKIAFAKEFAKEFAKEFAKE" +} + +#======================================================# +# IAM Groups +#======================================================# + +resource "aws_iam_group" "administrators" { + name = "${terraform.env}.administrators" +} + +output "iam_group_administrators" { + value = "${aws_iam_group.administrators.name}" } \ No newline at end of file diff --git a/test/integration/default/verify/controls/aws_iam_groups.rb b/test/integration/default/verify/controls/aws_iam_groups.rb new file mode 100644 index 000000000..3bfdf6d10 --- /dev/null +++ b/test/integration/default/verify/controls/aws_iam_groups.rb @@ -0,0 +1,5 @@ +control "aws_iam_groups recall" do + describe aws_iam_groups do + it { should exist } + end +end \ No newline at end of file diff --git a/test/unit/resources/aws_iam_groups_test.rb b/test/unit/resources/aws_iam_groups_test.rb new file mode 100644 index 000000000..f79d2c2cd --- /dev/null +++ b/test/unit/resources/aws_iam_groups_test.rb @@ -0,0 +1,93 @@ +require 'helper' +require 'aws_iam_groups' +require 'date' + +# MAIGPB = MockAwsIamGroupsPluralBackend +# Abbreviation not used outside this file + +#=============================================================================# +# Constructor Tests +#=============================================================================# +class AwsIamGroupsConstructorTest < Minitest::Test + + def setup + AwsIamGroups::BackendFactory.select(MAIGPB::Empty) + end + + def test_empty_params_ok + AwsIamGroups.new + end + + def test_rejects_unrecognized_params + assert_raises(ArgumentError) { AwsIamGroups.new(shoe_size: 9) } + end +end + + +#=============================================================================# +# Search / Recall +#=============================================================================# +class AwsIamGroupsRecallEmptyTest < Minitest::Test + + def setup + AwsIamGroups::BackendFactory.select(MAIGPB::Empty) + end + + def test_search_miss_via_empty_groups + refute AwsIamGroups.new.exists? + end +end + +class AwsIamGroupsRecallBasicTest < Minitest::Test + + def setup + AwsIamGroups::BackendFactory.select(MAIGPB::Basic) + end + + def test_search_hit_via_empty_query + assert AwsIamGroups.new.exists? + end +end + +#=============================================================================# +# Test Fixtures +#=============================================================================# +module MAIGPB + class Empty < AwsIamGroups::Backend + def list_groups(query = {}) + OpenStruct.new({ groups: [] }) + end + end + + class Basic < AwsIamGroups::Backend + def list_groups(query = {}) + fixtures = [ + OpenStruct.new({ + path: '/', + group_name: 'Administrator', + group_id: 'AGPAQWERQWERQWERQWERQ', + arn: 'arn:aws:iam::111111111111:group/Administrator', + create_date: DateTime.parse('2017-12-14 05:29:57 UTC') + }), + OpenStruct.new({ + path: '/', + group_name: 'AmazonEC2ReadOnlyAccess', + group_id: 'AGPAASDFASDFASDFASDFA', + arn: 'arn:aws:iam::111111111111:group/AmazonEC2ReadOnlyAccess', + create_date: DateTime.parse('2017-12-15 17:37:14 UTC') + }), + ] + + if query[:path_prefix].nil? + selected = fixtures + else + selected = fixtures.select do |group| + group[:path].start_with? query[:path_prefix] + end + end + + OpenStruct.new({ groups: selected }) + end + end + +end From 14efd94050176c3465df50c851703a08b890514c Mon Sep 17 00:00:00 2001 From: Sam Cornwell <14048146+samcornwell@users.noreply.github.com> Date: Thu, 1 Feb 2018 15:55:54 -0500 Subject: [PATCH 167/206] Skeletal aws_iam_group resource (#221) Signed-off-by: Sam Cornwell <14048146+samcornwell@users.noreply.github.com> --- docs/resources/aws_iam_group.md | 45 +++++++++ libraries/aws_iam_group.rb | 54 ++++++++++ .../default/verify/aws_iam_group.rb | 0 .../default/verify/controls/aws_iam_group.rb | 20 ++++ test/unit/resources/aws_iam_group_test.rb | 98 +++++++++++++++++++ 5 files changed, 217 insertions(+) create mode 100644 docs/resources/aws_iam_group.md create mode 100644 libraries/aws_iam_group.rb create mode 100644 test/integration/default/verify/aws_iam_group.rb create mode 100644 test/integration/default/verify/controls/aws_iam_group.rb create mode 100644 test/unit/resources/aws_iam_group_test.rb diff --git a/docs/resources/aws_iam_group.md b/docs/resources/aws_iam_group.md new file mode 100644 index 000000000..f5cd6f79d --- /dev/null +++ b/docs/resources/aws_iam_group.md @@ -0,0 +1,45 @@ +--- +title: About the aws_iam_group Resource +--- + +# aws_iam_group + +Use the `aws_iam_group` InSpec audit resource to test properties of a single IAM group. + +To test properties of multiple or all groups, use the `aws_iam_groups` resource. + +
+ +## Syntax + +An `aws_iam_group` resource block identifies a group by group name. + + # Find a group by group name + describe aws_iam_group('mygroup') do + it { should exist } + end + + # Hash syntax for group name + describe aws_iam_group(group_name: 'mygroup') do + it { should exist } + end + +
+ +## Examples + +The following examples show how to use this InSpec audit resource. + +As this is the initial release of `aws_iam_group`, its limited functionality precludes examples. + +
+ +## Matchers + +### exists + +The control will pass if a group with the given group name exists. + + describe aws_iam_group('mygroup') + it { should exist } + end diff --git a/libraries/aws_iam_group.rb b/libraries/aws_iam_group.rb new file mode 100644 index 000000000..2b4215ae3 --- /dev/null +++ b/libraries/aws_iam_group.rb @@ -0,0 +1,54 @@ +require '_aws' + +class AwsIamGroup < Inspec.resource(1) + name 'aws_iam_group' + desc 'Verifies settings for AWS IAM Group' + example " + describe aws_iam_group('mygroup') do + it { should exist } + end + " + + include AwsResourceMixin + attr_reader :group_name + + def to_s + "IAM Group #{group_name}" + end + + private + + def validate_params(raw_params) + validated_params = check_resource_param_names( + raw_params: raw_params, + allowed_params: [:group_name], + allowed_scalar_name: :group_name, + allowed_scalar_type: String, + ) + + if validated_params.empty? + raise ArgumentError, 'You must provide a group_name to aws_iam_group.' + end + + validated_params + end + + def fetch_from_aws + backend = AwsIamGroup::BackendFactory.create + + begin + @aws_group_struct = backend.get_group(group_name: group_name)[:group] + @exists = true + rescue Aws::IAM::Errors::NoSuchEntity + @exists = false + end + end + + class Backend + BackendFactory.set_default_backend(self) + + def get_group(query) + AWSConnection.new.iam_client.get_group(query) + end + end +end diff --git a/test/integration/default/verify/aws_iam_group.rb b/test/integration/default/verify/aws_iam_group.rb new file mode 100644 index 000000000..e69de29bb diff --git a/test/integration/default/verify/controls/aws_iam_group.rb b/test/integration/default/verify/controls/aws_iam_group.rb new file mode 100644 index 000000000..45bad1b18 --- /dev/null +++ b/test/integration/default/verify/controls/aws_iam_group.rb @@ -0,0 +1,20 @@ +fixtures = {} +[ + 'iam_group_administrators', +].each do |fixture_name| + fixtures[fixture_name] = attribute( + fixture_name, + default: "default.#{fixture_name}", + description: 'See ../build/iam.tf', + ) +end + +control "aws_iam_group recall" do + describe aws_iam_group(fixtures['iam_group_administrators']) do + it { should exist } + end + + describe aws_iam_group('fakegroup') do + it { should_not exist } + end +end \ No newline at end of file diff --git a/test/unit/resources/aws_iam_group_test.rb b/test/unit/resources/aws_iam_group_test.rb new file mode 100644 index 000000000..424f339ef --- /dev/null +++ b/test/unit/resources/aws_iam_group_test.rb @@ -0,0 +1,98 @@ +require 'helper' +require 'aws_iam_group' +require 'date' + +# MAIGSB = MockAwsIamGroupSingularBackend +# Abbreviation not used outside this file + +#=============================================================================# +# Constructor Tests +#=============================================================================# +class AwsIamGroupConstructorTest < Minitest::Test + + def setup + AwsIamGroup::BackendFactory.select(MAIGSB::Empty) + end + + def test_rejects_empty_params + assert_raises(ArgumentError) { AwsIamGroup.new } + end + + def test_accepts_group_name_as_scalar + AwsIamGroup.new('Whatever') + end + + def test_accepts_group_name_as_hash + AwsIamGroup.new(group_name: 'Whatever') + end + + def test_rejects_unrecognized_params + assert_raises(ArgumentError) { AwsIamGroup.new(shoe_size: 9) } + end +end + + +#=============================================================================# +# Search / Recall +#=============================================================================# +class AwsIamGroupRecallTest < Minitest::Test + + def setup + AwsIamGroup::BackendFactory.select(MAIGSB::Basic) + end + + def test_search_hit_via_scalar_works + assert AwsIamGroup.new('Administrator').exists? + end + + def test_search_hit_via_hash_works + assert AwsIamGroup.new(group_name: 'Administrator').exists? + end + + def test_search_miss_is_not_an_exception + refute AwsIamGroup.new(group_name: 'Whatever').exists? + end +end + +#=============================================================================# +# Test Fixtures +#=============================================================================# +module MAIGSB + class Empty < AwsIamGroup::Backend + def get_group(query = {}) + raise Aws::IAM::Errors::NoSuchEntity.new(nil,nil) + end + end + + class Basic < AwsIamGroup::Backend + def get_group(query = {}) + fixtures = [ + OpenStruct.new({ + path: '/', + group_name: 'Administrator', + group_id: 'AGPAQWERQWERQWERQWERQ', + arn: 'arn:aws:iam::111111111111:group/Administrator', + create_date: DateTime.parse('2017-12-14 05:29:57 UTC') + }), + OpenStruct.new({ + path: '/', + group_name: 'AmazonEC2ReadOnlyAccess', + group_id: 'AGPAASDFASDFASDFASDFA', + arn: 'arn:aws:iam::111111111111:group/AmazonEC2ReadOnlyAccess', + create_date: DateTime.parse('2017-12-15 17:37:14 UTC') + }), + ] + + selected = fixtures.select do |group| + group[:group_name].eql? query[:group_name] + end + + if selected.empty? + raise Aws::IAM::Errors::NoSuchEntity.new(nil,nil) + end + + OpenStruct.new({ group: selected[0] }) + end + end + +end From 018e78c6cbbe90bc7098a4d12147eb80c801ef51 Mon Sep 17 00:00:00 2001 From: Jerry Aldrich Date: Thu, 1 Feb 2018 22:34:27 +0000 Subject: [PATCH 168/206] Add `release-2.0` target branch to AppVeyor/Travis (#2510) Signed-off-by: Jerry Aldrich --- .travis.yml | 1 + appveyor.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.travis.yml b/.travis.yml index 47f5c1622..890d4ea1e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,6 +2,7 @@ sudo: required branches: only: - master + - release-2.0 language: ruby cache: bundler dist: trusty diff --git a/appveyor.yml b/appveyor.yml index 6902d8482..f42a1af97 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -13,6 +13,7 @@ skip_tags: true branches: only: - master + - release-2.0 cache: - vendor/bundle -> appveyor.yml From f425a70f799d8a9f545ca9d0e9704706a7511732 Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Mon, 5 Feb 2018 15:58:51 -0500 Subject: [PATCH 169/206] Rearrange AWS files for merge into core Signed-off-by: Clinton Wolfe --- .rubocop.yml | 99 ----------- .travis.yml | 9 - CONTRIBUTING.md | 155 ------------------ LICENSE | 13 -- inspec.yml | 7 - .../resource_support/aws/aws.rb | 0 .../aws/aws_backend_factory_mixin.rb | 0 .../resource_support/aws/aws_connection.rb | 0 .../aws/aws_resource_mixin.rb | 0 .../resources/aws}/aws_cloudtrail_trail.rb | 0 .../resources/aws}/aws_cloudtrail_trails.rb | 0 .../resources/aws}/aws_cloudwatch_alarm.rb | 0 .../aws}/aws_cloudwatch_log_metric_filter.rb | 0 .../resources/aws}/aws_ec2_instance.rb | 0 .../resources/aws}/aws_ec2_security_group.rb | 0 .../resources/aws}/aws_ec2_security_groups.rb | 0 .../resources/aws}/aws_iam_access_key.rb | 0 .../resources/aws}/aws_iam_access_keys.rb | 0 .../resources/aws}/aws_iam_group.rb | 0 .../resources/aws}/aws_iam_groups.rb | 0 .../resources/aws}/aws_iam_password_policy.rb | 0 .../resources/aws}/aws_iam_policies.rb | 0 .../resources/aws}/aws_iam_policy.rb | 0 .../resources/aws}/aws_iam_role.rb | 0 .../resources/aws}/aws_iam_root_user.rb | 0 .../resources/aws}/aws_iam_user.rb | 0 .../resources/aws}/aws_iam_users.rb | 0 .../resources/aws}/aws_kms_keys.rb | 0 .../resources/aws}/aws_s3_bucket.rb | 0 .../resources/aws}/aws_sns_topic.rb | 0 {libraries => lib/resources/aws}/aws_vpc.rb | 0 {libraries => lib/resources/aws}/aws_vpcs.rb | 0 libraries/aws_aaa_shim.rb | 3 - .../{integration => aws}/default/build/aws.tf | 0 .../default/build/cloudtrail.tf | 0 .../default/build/cloudwatch.tf | 0 .../{integration => aws}/default/build/ec2.tf | 0 .../{integration => aws}/default/build/iam.tf | 0 .../default/build/inspec-logo.png | Bin test/{integration => aws}/default/build/s3.tf | 0 .../{integration => aws}/default/build/sns.tf | 0 .../default/verify/aws_iam_group.rb | 0 .../verify/controls/aws_cloudtrail_trail.rb | 0 .../verify/controls/aws_cloudtrail_trails.rb | 0 .../verify/controls/aws_cloudwatch_alarm.rb | 0 .../aws_cloudwatch_log_metric_filter.rb | 0 .../verify/controls/aws_ec2_instance.rb | 0 .../verify/controls/aws_ec2_security_group.rb | 0 .../controls/aws_ec2_security_groups.rb | 0 .../verify/controls/aws_iam_access_key.rb | 0 .../default/verify/controls/aws_iam_group.rb | 0 .../default/verify/controls/aws_iam_groups.rb | 0 .../verify/controls/aws_iam_policies.rb | 0 .../default/verify/controls/aws_iam_policy.rb | 0 .../default/verify/controls/aws_iam_role.rb | 0 .../verify/controls/aws_iam_root_user.rb | 0 .../default/verify/controls/aws_iam_user.rb | 0 .../default/verify/controls/aws_iam_users.rb | 0 .../default/verify/controls/aws_kms_keys.rb | 0 .../default/verify/controls/aws_s3_bucket.rb | 0 .../default/verify/controls/aws_sns_topic.rb | 0 .../default/verify/controls/aws_vpc.rb | 0 .../default/verify/controls/aws_vpcs.rb | 0 .../default/verify/inspec.yml | 0 .../{integration => aws}/minimal/build/aws.tf | 0 .../verify/controls/aws_iam_root_user.rb | 0 .../minimal/verify/inspec.yml | 0 67 files changed, 286 deletions(-) delete mode 100644 .rubocop.yml delete mode 100644 .travis.yml delete mode 100644 CONTRIBUTING.md delete mode 100644 LICENSE delete mode 100644 inspec.yml rename libraries/_aws.rb => lib/resource_support/aws/aws.rb (100%) rename libraries/_aws_backend_factory_mixin.rb => lib/resource_support/aws/aws_backend_factory_mixin.rb (100%) rename libraries/_aws_connection.rb => lib/resource_support/aws/aws_connection.rb (100%) rename libraries/_aws_resource_mixin.rb => lib/resource_support/aws/aws_resource_mixin.rb (100%) rename {libraries => lib/resources/aws}/aws_cloudtrail_trail.rb (100%) rename {libraries => lib/resources/aws}/aws_cloudtrail_trails.rb (100%) rename {libraries => lib/resources/aws}/aws_cloudwatch_alarm.rb (100%) rename {libraries => lib/resources/aws}/aws_cloudwatch_log_metric_filter.rb (100%) rename {libraries => lib/resources/aws}/aws_ec2_instance.rb (100%) rename {libraries => lib/resources/aws}/aws_ec2_security_group.rb (100%) rename {libraries => lib/resources/aws}/aws_ec2_security_groups.rb (100%) rename {libraries => lib/resources/aws}/aws_iam_access_key.rb (100%) rename {libraries => lib/resources/aws}/aws_iam_access_keys.rb (100%) rename {libraries => lib/resources/aws}/aws_iam_group.rb (100%) rename {libraries => lib/resources/aws}/aws_iam_groups.rb (100%) rename {libraries => lib/resources/aws}/aws_iam_password_policy.rb (100%) rename {libraries => lib/resources/aws}/aws_iam_policies.rb (100%) rename {libraries => lib/resources/aws}/aws_iam_policy.rb (100%) rename {libraries => lib/resources/aws}/aws_iam_role.rb (100%) rename {libraries => lib/resources/aws}/aws_iam_root_user.rb (100%) rename {libraries => lib/resources/aws}/aws_iam_user.rb (100%) rename {libraries => lib/resources/aws}/aws_iam_users.rb (100%) rename {libraries => lib/resources/aws}/aws_kms_keys.rb (100%) rename {libraries => lib/resources/aws}/aws_s3_bucket.rb (100%) rename {libraries => lib/resources/aws}/aws_sns_topic.rb (100%) rename {libraries => lib/resources/aws}/aws_vpc.rb (100%) rename {libraries => lib/resources/aws}/aws_vpcs.rb (100%) delete mode 100644 libraries/aws_aaa_shim.rb rename test/{integration => aws}/default/build/aws.tf (100%) rename test/{integration => aws}/default/build/cloudtrail.tf (100%) rename test/{integration => aws}/default/build/cloudwatch.tf (100%) rename test/{integration => aws}/default/build/ec2.tf (100%) rename test/{integration => aws}/default/build/iam.tf (100%) rename test/{integration => aws}/default/build/inspec-logo.png (100%) rename test/{integration => aws}/default/build/s3.tf (100%) rename test/{integration => aws}/default/build/sns.tf (100%) rename test/{integration => aws}/default/verify/aws_iam_group.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_cloudtrail_trail.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_cloudtrail_trails.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_cloudwatch_alarm.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_cloudwatch_log_metric_filter.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_ec2_instance.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_ec2_security_group.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_ec2_security_groups.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_iam_access_key.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_iam_group.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_iam_groups.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_iam_policies.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_iam_policy.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_iam_role.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_iam_root_user.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_iam_user.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_iam_users.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_kms_keys.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_s3_bucket.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_sns_topic.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_vpc.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_vpcs.rb (100%) rename test/{integration => aws}/default/verify/inspec.yml (100%) rename test/{integration => aws}/minimal/build/aws.tf (100%) rename test/{integration => aws}/minimal/verify/controls/aws_iam_root_user.rb (100%) rename test/{integration => aws}/minimal/verify/inspec.yml (100%) diff --git a/.rubocop.yml b/.rubocop.yml deleted file mode 100644 index 46550f92f..000000000 --- a/.rubocop.yml +++ /dev/null @@ -1,99 +0,0 @@ ---- -AllCops: - TargetRubyVersion: 2.3 - Exclude: - - Gemfile - - Rakefile - - 'test/**/*' - - 'examples/**/*' - - 'vendor/**/*' - - 'lib/bundles/inspec-init/templates/**/*' - - 'www/tutorial/**/*' -AlignParameters: - Enabled: true -BlockDelimiters: - Enabled: false -Documentation: - Enabled: false -EmptyLinesAroundBlockBody: - Enabled: false -FrozenStringLiteralComment: - Enabled: false -HashSyntax: - Enabled: true -LineLength: - Enabled: false -Layout/AlignHash: - Enabled: false -Layout/EmptyLineAfterMagicComment: - Enabled: false -Layout/EndOfLine: - Enabled: true - EnforcedStyle: lf -Layout/SpaceAroundOperators: - Enabled: false -MethodLength: - Max: 40 -Metrics/AbcSize: - Max: 33 -Metrics/BlockLength: - Max: 50 -Metrics/CyclomaticComplexity: - Max: 10 -Metrics/PerceivedComplexity: - Max: 11 -Naming/FileName: - Enabled: false -Naming/HeredocDelimiterNaming: - Enabled: false -Naming/PredicateName: - Enabled: false -NumericLiterals: - MinDigits: 10 -Security/YAMLLoad: - Enabled: false -Style/AndOr: - Enabled: false -Style/BracesAroundHashParameters: - Enabled: false -Style/ClassAndModuleChildren: - Enabled: false -Style/ConditionalAssignment: - Enabled: false -Style/EmptyMethod: - Enabled: false -Style/Encoding: - Enabled: false -Style/IfUnlessModifier: - Enabled: false -Style/MethodMissing: - Enabled: false -Style/MultilineIfModifier: - Enabled: false -Style/NegatedIf: - Enabled: false -Style/Not: - Enabled: false -Style/NumericLiteralPrefix: - Enabled: false -Style/NumericPredicate: - Enabled: false -Style/PercentLiteralDelimiters: - PreferredDelimiters: - '%': '{}' - '%i': () - '%q': '{}' - '%Q': () - '%r': '{}' - '%s': () - '%w': '{}' - '%W': () - '%x': () -Style/SymbolArray: - Enabled: false -Style/TrailingCommaInArguments: - EnforcedStyleForMultiline: comma -Style/TrailingCommaInLiteral: - EnforcedStyleForMultiline: comma -Style/UnlessElse: - Enabled: false diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index cb954c827..000000000 --- a/.travis.yml +++ /dev/null @@ -1,9 +0,0 @@ -sudo: false -language: ruby -cache: bundler - -rvm: - - 2.3.1 - -bundler_args: --without integration -script: bundle exec rake diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index b92422962..000000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,155 +0,0 @@ -# Contributing to InSpec - -We are glad you want to contribute to InSpec! This document will help answer common questions you may have during your first contribution. - -## Submitting Issues - -We utilize **Github Issues** for issue tracking and contributions. You can contribute in two ways: - -1. Reporting an issue or making a feature request [here](#issues). -2. Adding features or fixing bugs yourself and contributing your code to InSpec. - -We ask you not to submit security concerns via Github. For details on submitting potential security issues please see - -## Contribution Process - -We have a 3 step process for contributions: - -1. Commit changes to a git branch, making sure to sign-off those changes for the [Developer Certificate of Origin](#developer-certification-of-origin-dco). -2. Create a Github Pull Request for your change, following the instructions in the pull request template. -3. Perform a [Code Review](#code-review-process) with the project maintainers on the pull request. - -### Pull Request Requirements - -Chef Projects are built to last. We strive to ensure high quality throughout the experience. In order to ensure this, we require that all pull requests to Chef projects meet these specifications: - -1. **Tests:** To ensure high quality code and protect against future regressions, we require all the code in Chef Projects to have at least unit test coverage. See the [test/unit](https://github.com/chef/inspec/tree/master/test/unit) -directory for the existing tests and use ```bundle exec rake test``` to run them. -2. **Green CI Tests:** We use [Travis CI](https://travis-ci.org/) and/or [AppVeyor](https://www.appveyor.com/) CI systems to test all pull requests. We require these test runs to succeed on every pull request before being merged. -3. **Up-to-date Documentation:** Every code change should be reflected in an update for our [documentation](https://github.com/chef/inspec/tree/master/docs). We expect PRs to update the documentation with the code change. - -In addition to this it would be nice to include the description of the problem you are solving - with your change. You can use [Issue Template](#issuetemplate) in the description section - of the pull request. - -### Code Review Process - -Code review takes place in Github pull requests. See [this article](https://help.github.com/articles/about-pull-requests/) if you're not familiar with Github Pull Requests. - -Once you open a pull request, project maintainers will review your code and respond to your pull request with any feedback they might have. The process at this point is as follows: - -1. Two thumbs-up (:+1:) are required from project maintainers. See the master maintainers document for InSpec projects at . -2. When ready, your pull request will be merged into `master`, we may require you to rebase your PR to the latest `master`. -3. Once the PR is merged, you will be included in `CHANGELOG.md`. - -If you would like to learn about when your code will be available in a release of Chef, read more about [Chef Release Cycles](#release-cycles). - - -### Developer Certification of Origin (DCO) - -Licensing is very important to open source projects. It helps ensure the software continues to be available under the terms that the author desired. - -Chef uses [the Apache 2.0 license](https://github.com/chef/chef/blob/master/LICENSE) to strike a balance between open contribution and allowing you to use the software however you would like to. - -The license tells you what rights you have that are provided by the copyright holder. It is important that the contributor fully understands what rights they are licensing and agrees to them. Sometimes the copyright holder isn't the contributor, such as when the contributor is doing work on behalf of a company. - -To make a good faith effort to ensure these criteria are met, Chef requires the Developer Certificate of Origin (DCO) process to be followed. - -The DCO is an attestation attached to every contribution made by every developer. In the commit message of the contribution, the developer simply adds a Signed-off-by statement and thereby agrees to the DCO, which you can find below or at . - -``` -Developer's Certificate of Origin 1.1 - -By making a contribution to this project, I certify that: - -(a) The contribution was created in whole or in part by me and I - have the right to submit it under the open source license - indicated in the file; or - -(b) The contribution is based upon previous work that, to the - best of my knowledge, is covered under an appropriate open - source license and I have the right under that license to - submit that work with modifications, whether created in whole - or in part by me, under the same open source license (unless - I am permitted to submit under a different license), as - Indicated in the file; or - -(c) The contribution was provided directly to me by some other - person who certified (a), (b) or (c) and I have not modified - it. - -(d) I understand and agree that this project and the contribution - are public and that a record of the contribution (including - all personal information I submit with it, including my - sign-off) is maintained indefinitely and may be redistributed - consistent with this project or the open source license(s) - involved. -``` - -For more information on the change see the Chef Blog post [Introducing Developer Certificate of Origin](https://blog.chef.io/2016/09/19/introducing-developer-certificate-of-origin/) - -#### DCO Sign-Off Methods - -The DCO requires a sign-off message in the following format appear on each commit in the pull request: - -``` -Signed-off-by: Julia Child -``` - -The DCO text can either be manually added to your commit body, or you can add either **-s** or **--signoff** to your usual git commit commands. If you forget to add the sign-off you can also amend a previous commit with the sign-off by running **git commit --amend -s**. If you've pushed your changes to Github already you'll need to force push your branch after this with **git push -f**. - -### Obvious Fix Policy - -Small contributions, such as fixing spelling errors, where the content is small enough to not be considered intellectual property, can be submitted without signing the contribution for the DCO. - -As a rule of thumb, changes are obvious fixes if they do not introduce any new functionality or creative thinking. Assuming the change does not affect functionality, some common obvious fix examples include the following: - -- Spelling / grammar fixes -- Typo correction, white space and formatting changes -- Comment clean up -- Bug fixes that change default return values or error codes stored in constants -- Adding logging messages or debugging output -- Changes to 'metadata' files like Gemfile, .gitignore, build scripts, etc. -- Moving source files from one directory or package to another - -**Whenever you invoke the "obvious fix" rule, please say so in your commit message:** - -``` ------------------------------------------------------------------------- -commit 370adb3f82d55d912b0cf9c1d1e99b132a8ed3b5 -Author: Julia Child -Date: Wed Sep 18 11:44:40 2015 -0700 - - Fix typo in the README. - - Obvious fix. - ------------------------------------------------------------------------- -``` - -## Release Cycles - -Our primary shipping vehicle is operating system specific packages that includes all the requirements of InSpec. We call these [Omnibus packages](https://github.com/chef/omnibus) - -We also release our software as gems to [Rubygems](https://rubygems.org/) but we strongly recommend using InSpec or ChefDK packages. - -Our version numbering roughly follows [Semantic Versioning](http://semver.org/) standard. Our standard version numbers look like X.Y.Z which mean: - -- X is a major release, which may not be fully compatible with prior major releases -- Y is a minor release, which adds both new features and bug fixes -- Z is a patch release, which adds just bug fixes - -After shipping a release of InSpec we bump at least the `Minor` version by one to start development of the next minor release. We do a release approximately every week. Announcements of releases are made to the [InSpec mailing list](https://discourse.chef.io/c/chef-release) when they are available. - -## InSpec Community - -InSpec is made possible by a strong community of developers, system administrators, auditor and security experts. If you have any questions or if you would like to get involved in the InSpec community you can check out: - -- [InSpec Mailing List](https://discourse.chef.io/c/inspec) -- [Chef Community Slack](https://community-slack.chef.io/) - -Also here are some additional pointers to some awesome Chef content: - -- [InSpec Docs](http://inspec.io/docs/) -- [Learn Chef](https://learn.chef.io/) -- [Chef Website](https://www.chef.io/) diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 161d799fa..000000000 --- a/LICENSE +++ /dev/null @@ -1,13 +0,0 @@ -Copyright (c) 2016 Chef Software Inc. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/inspec.yml b/inspec.yml deleted file mode 100644 index afc622c0e..000000000 --- a/inspec.yml +++ /dev/null @@ -1,7 +0,0 @@ -name: inspec-aws -title: InSpec AWS Resource Pack -maintainer: Chef Software Inc. -copyright: chris@lollyrock.com -copyright_email: chris@lollyrock.com -license: Apache 2 license -version: 1.0.0 diff --git a/libraries/_aws.rb b/lib/resource_support/aws/aws.rb similarity index 100% rename from libraries/_aws.rb rename to lib/resource_support/aws/aws.rb diff --git a/libraries/_aws_backend_factory_mixin.rb b/lib/resource_support/aws/aws_backend_factory_mixin.rb similarity index 100% rename from libraries/_aws_backend_factory_mixin.rb rename to lib/resource_support/aws/aws_backend_factory_mixin.rb diff --git a/libraries/_aws_connection.rb b/lib/resource_support/aws/aws_connection.rb similarity index 100% rename from libraries/_aws_connection.rb rename to lib/resource_support/aws/aws_connection.rb diff --git a/libraries/_aws_resource_mixin.rb b/lib/resource_support/aws/aws_resource_mixin.rb similarity index 100% rename from libraries/_aws_resource_mixin.rb rename to lib/resource_support/aws/aws_resource_mixin.rb diff --git a/libraries/aws_cloudtrail_trail.rb b/lib/resources/aws/aws_cloudtrail_trail.rb similarity index 100% rename from libraries/aws_cloudtrail_trail.rb rename to lib/resources/aws/aws_cloudtrail_trail.rb diff --git a/libraries/aws_cloudtrail_trails.rb b/lib/resources/aws/aws_cloudtrail_trails.rb similarity index 100% rename from libraries/aws_cloudtrail_trails.rb rename to lib/resources/aws/aws_cloudtrail_trails.rb diff --git a/libraries/aws_cloudwatch_alarm.rb b/lib/resources/aws/aws_cloudwatch_alarm.rb similarity index 100% rename from libraries/aws_cloudwatch_alarm.rb rename to lib/resources/aws/aws_cloudwatch_alarm.rb diff --git a/libraries/aws_cloudwatch_log_metric_filter.rb b/lib/resources/aws/aws_cloudwatch_log_metric_filter.rb similarity index 100% rename from libraries/aws_cloudwatch_log_metric_filter.rb rename to lib/resources/aws/aws_cloudwatch_log_metric_filter.rb diff --git a/libraries/aws_ec2_instance.rb b/lib/resources/aws/aws_ec2_instance.rb similarity index 100% rename from libraries/aws_ec2_instance.rb rename to lib/resources/aws/aws_ec2_instance.rb diff --git a/libraries/aws_ec2_security_group.rb b/lib/resources/aws/aws_ec2_security_group.rb similarity index 100% rename from libraries/aws_ec2_security_group.rb rename to lib/resources/aws/aws_ec2_security_group.rb diff --git a/libraries/aws_ec2_security_groups.rb b/lib/resources/aws/aws_ec2_security_groups.rb similarity index 100% rename from libraries/aws_ec2_security_groups.rb rename to lib/resources/aws/aws_ec2_security_groups.rb diff --git a/libraries/aws_iam_access_key.rb b/lib/resources/aws/aws_iam_access_key.rb similarity index 100% rename from libraries/aws_iam_access_key.rb rename to lib/resources/aws/aws_iam_access_key.rb diff --git a/libraries/aws_iam_access_keys.rb b/lib/resources/aws/aws_iam_access_keys.rb similarity index 100% rename from libraries/aws_iam_access_keys.rb rename to lib/resources/aws/aws_iam_access_keys.rb diff --git a/libraries/aws_iam_group.rb b/lib/resources/aws/aws_iam_group.rb similarity index 100% rename from libraries/aws_iam_group.rb rename to lib/resources/aws/aws_iam_group.rb diff --git a/libraries/aws_iam_groups.rb b/lib/resources/aws/aws_iam_groups.rb similarity index 100% rename from libraries/aws_iam_groups.rb rename to lib/resources/aws/aws_iam_groups.rb diff --git a/libraries/aws_iam_password_policy.rb b/lib/resources/aws/aws_iam_password_policy.rb similarity index 100% rename from libraries/aws_iam_password_policy.rb rename to lib/resources/aws/aws_iam_password_policy.rb diff --git a/libraries/aws_iam_policies.rb b/lib/resources/aws/aws_iam_policies.rb similarity index 100% rename from libraries/aws_iam_policies.rb rename to lib/resources/aws/aws_iam_policies.rb diff --git a/libraries/aws_iam_policy.rb b/lib/resources/aws/aws_iam_policy.rb similarity index 100% rename from libraries/aws_iam_policy.rb rename to lib/resources/aws/aws_iam_policy.rb diff --git a/libraries/aws_iam_role.rb b/lib/resources/aws/aws_iam_role.rb similarity index 100% rename from libraries/aws_iam_role.rb rename to lib/resources/aws/aws_iam_role.rb diff --git a/libraries/aws_iam_root_user.rb b/lib/resources/aws/aws_iam_root_user.rb similarity index 100% rename from libraries/aws_iam_root_user.rb rename to lib/resources/aws/aws_iam_root_user.rb diff --git a/libraries/aws_iam_user.rb b/lib/resources/aws/aws_iam_user.rb similarity index 100% rename from libraries/aws_iam_user.rb rename to lib/resources/aws/aws_iam_user.rb diff --git a/libraries/aws_iam_users.rb b/lib/resources/aws/aws_iam_users.rb similarity index 100% rename from libraries/aws_iam_users.rb rename to lib/resources/aws/aws_iam_users.rb diff --git a/libraries/aws_kms_keys.rb b/lib/resources/aws/aws_kms_keys.rb similarity index 100% rename from libraries/aws_kms_keys.rb rename to lib/resources/aws/aws_kms_keys.rb diff --git a/libraries/aws_s3_bucket.rb b/lib/resources/aws/aws_s3_bucket.rb similarity index 100% rename from libraries/aws_s3_bucket.rb rename to lib/resources/aws/aws_s3_bucket.rb diff --git a/libraries/aws_sns_topic.rb b/lib/resources/aws/aws_sns_topic.rb similarity index 100% rename from libraries/aws_sns_topic.rb rename to lib/resources/aws/aws_sns_topic.rb diff --git a/libraries/aws_vpc.rb b/lib/resources/aws/aws_vpc.rb similarity index 100% rename from libraries/aws_vpc.rb rename to lib/resources/aws/aws_vpc.rb diff --git a/libraries/aws_vpcs.rb b/lib/resources/aws/aws_vpcs.rb similarity index 100% rename from libraries/aws_vpcs.rb rename to lib/resources/aws/aws_vpcs.rb diff --git a/libraries/aws_aaa_shim.rb b/libraries/aws_aaa_shim.rb deleted file mode 100644 index 685c9f0c7..000000000 --- a/libraries/aws_aaa_shim.rb +++ /dev/null @@ -1,3 +0,0 @@ -# This file simply acts as a loader when inspec-aws -# is being used as a resource pack. -require '_aws' diff --git a/test/integration/default/build/aws.tf b/test/aws/default/build/aws.tf similarity index 100% rename from test/integration/default/build/aws.tf rename to test/aws/default/build/aws.tf diff --git a/test/integration/default/build/cloudtrail.tf b/test/aws/default/build/cloudtrail.tf similarity index 100% rename from test/integration/default/build/cloudtrail.tf rename to test/aws/default/build/cloudtrail.tf diff --git a/test/integration/default/build/cloudwatch.tf b/test/aws/default/build/cloudwatch.tf similarity index 100% rename from test/integration/default/build/cloudwatch.tf rename to test/aws/default/build/cloudwatch.tf diff --git a/test/integration/default/build/ec2.tf b/test/aws/default/build/ec2.tf similarity index 100% rename from test/integration/default/build/ec2.tf rename to test/aws/default/build/ec2.tf diff --git a/test/integration/default/build/iam.tf b/test/aws/default/build/iam.tf similarity index 100% rename from test/integration/default/build/iam.tf rename to test/aws/default/build/iam.tf diff --git a/test/integration/default/build/inspec-logo.png b/test/aws/default/build/inspec-logo.png similarity index 100% rename from test/integration/default/build/inspec-logo.png rename to test/aws/default/build/inspec-logo.png diff --git a/test/integration/default/build/s3.tf b/test/aws/default/build/s3.tf similarity index 100% rename from test/integration/default/build/s3.tf rename to test/aws/default/build/s3.tf diff --git a/test/integration/default/build/sns.tf b/test/aws/default/build/sns.tf similarity index 100% rename from test/integration/default/build/sns.tf rename to test/aws/default/build/sns.tf diff --git a/test/integration/default/verify/aws_iam_group.rb b/test/aws/default/verify/aws_iam_group.rb similarity index 100% rename from test/integration/default/verify/aws_iam_group.rb rename to test/aws/default/verify/aws_iam_group.rb diff --git a/test/integration/default/verify/controls/aws_cloudtrail_trail.rb b/test/aws/default/verify/controls/aws_cloudtrail_trail.rb similarity index 100% rename from test/integration/default/verify/controls/aws_cloudtrail_trail.rb rename to test/aws/default/verify/controls/aws_cloudtrail_trail.rb diff --git a/test/integration/default/verify/controls/aws_cloudtrail_trails.rb b/test/aws/default/verify/controls/aws_cloudtrail_trails.rb similarity index 100% rename from test/integration/default/verify/controls/aws_cloudtrail_trails.rb rename to test/aws/default/verify/controls/aws_cloudtrail_trails.rb diff --git a/test/integration/default/verify/controls/aws_cloudwatch_alarm.rb b/test/aws/default/verify/controls/aws_cloudwatch_alarm.rb similarity index 100% rename from test/integration/default/verify/controls/aws_cloudwatch_alarm.rb rename to test/aws/default/verify/controls/aws_cloudwatch_alarm.rb diff --git a/test/integration/default/verify/controls/aws_cloudwatch_log_metric_filter.rb b/test/aws/default/verify/controls/aws_cloudwatch_log_metric_filter.rb similarity index 100% rename from test/integration/default/verify/controls/aws_cloudwatch_log_metric_filter.rb rename to test/aws/default/verify/controls/aws_cloudwatch_log_metric_filter.rb diff --git a/test/integration/default/verify/controls/aws_ec2_instance.rb b/test/aws/default/verify/controls/aws_ec2_instance.rb similarity index 100% rename from test/integration/default/verify/controls/aws_ec2_instance.rb rename to test/aws/default/verify/controls/aws_ec2_instance.rb diff --git a/test/integration/default/verify/controls/aws_ec2_security_group.rb b/test/aws/default/verify/controls/aws_ec2_security_group.rb similarity index 100% rename from test/integration/default/verify/controls/aws_ec2_security_group.rb rename to test/aws/default/verify/controls/aws_ec2_security_group.rb diff --git a/test/integration/default/verify/controls/aws_ec2_security_groups.rb b/test/aws/default/verify/controls/aws_ec2_security_groups.rb similarity index 100% rename from test/integration/default/verify/controls/aws_ec2_security_groups.rb rename to test/aws/default/verify/controls/aws_ec2_security_groups.rb diff --git a/test/integration/default/verify/controls/aws_iam_access_key.rb b/test/aws/default/verify/controls/aws_iam_access_key.rb similarity index 100% rename from test/integration/default/verify/controls/aws_iam_access_key.rb rename to test/aws/default/verify/controls/aws_iam_access_key.rb diff --git a/test/integration/default/verify/controls/aws_iam_group.rb b/test/aws/default/verify/controls/aws_iam_group.rb similarity index 100% rename from test/integration/default/verify/controls/aws_iam_group.rb rename to test/aws/default/verify/controls/aws_iam_group.rb diff --git a/test/integration/default/verify/controls/aws_iam_groups.rb b/test/aws/default/verify/controls/aws_iam_groups.rb similarity index 100% rename from test/integration/default/verify/controls/aws_iam_groups.rb rename to test/aws/default/verify/controls/aws_iam_groups.rb diff --git a/test/integration/default/verify/controls/aws_iam_policies.rb b/test/aws/default/verify/controls/aws_iam_policies.rb similarity index 100% rename from test/integration/default/verify/controls/aws_iam_policies.rb rename to test/aws/default/verify/controls/aws_iam_policies.rb diff --git a/test/integration/default/verify/controls/aws_iam_policy.rb b/test/aws/default/verify/controls/aws_iam_policy.rb similarity index 100% rename from test/integration/default/verify/controls/aws_iam_policy.rb rename to test/aws/default/verify/controls/aws_iam_policy.rb diff --git a/test/integration/default/verify/controls/aws_iam_role.rb b/test/aws/default/verify/controls/aws_iam_role.rb similarity index 100% rename from test/integration/default/verify/controls/aws_iam_role.rb rename to test/aws/default/verify/controls/aws_iam_role.rb diff --git a/test/integration/default/verify/controls/aws_iam_root_user.rb b/test/aws/default/verify/controls/aws_iam_root_user.rb similarity index 100% rename from test/integration/default/verify/controls/aws_iam_root_user.rb rename to test/aws/default/verify/controls/aws_iam_root_user.rb diff --git a/test/integration/default/verify/controls/aws_iam_user.rb b/test/aws/default/verify/controls/aws_iam_user.rb similarity index 100% rename from test/integration/default/verify/controls/aws_iam_user.rb rename to test/aws/default/verify/controls/aws_iam_user.rb diff --git a/test/integration/default/verify/controls/aws_iam_users.rb b/test/aws/default/verify/controls/aws_iam_users.rb similarity index 100% rename from test/integration/default/verify/controls/aws_iam_users.rb rename to test/aws/default/verify/controls/aws_iam_users.rb diff --git a/test/integration/default/verify/controls/aws_kms_keys.rb b/test/aws/default/verify/controls/aws_kms_keys.rb similarity index 100% rename from test/integration/default/verify/controls/aws_kms_keys.rb rename to test/aws/default/verify/controls/aws_kms_keys.rb diff --git a/test/integration/default/verify/controls/aws_s3_bucket.rb b/test/aws/default/verify/controls/aws_s3_bucket.rb similarity index 100% rename from test/integration/default/verify/controls/aws_s3_bucket.rb rename to test/aws/default/verify/controls/aws_s3_bucket.rb diff --git a/test/integration/default/verify/controls/aws_sns_topic.rb b/test/aws/default/verify/controls/aws_sns_topic.rb similarity index 100% rename from test/integration/default/verify/controls/aws_sns_topic.rb rename to test/aws/default/verify/controls/aws_sns_topic.rb diff --git a/test/integration/default/verify/controls/aws_vpc.rb b/test/aws/default/verify/controls/aws_vpc.rb similarity index 100% rename from test/integration/default/verify/controls/aws_vpc.rb rename to test/aws/default/verify/controls/aws_vpc.rb diff --git a/test/integration/default/verify/controls/aws_vpcs.rb b/test/aws/default/verify/controls/aws_vpcs.rb similarity index 100% rename from test/integration/default/verify/controls/aws_vpcs.rb rename to test/aws/default/verify/controls/aws_vpcs.rb diff --git a/test/integration/default/verify/inspec.yml b/test/aws/default/verify/inspec.yml similarity index 100% rename from test/integration/default/verify/inspec.yml rename to test/aws/default/verify/inspec.yml diff --git a/test/integration/minimal/build/aws.tf b/test/aws/minimal/build/aws.tf similarity index 100% rename from test/integration/minimal/build/aws.tf rename to test/aws/minimal/build/aws.tf diff --git a/test/integration/minimal/verify/controls/aws_iam_root_user.rb b/test/aws/minimal/verify/controls/aws_iam_root_user.rb similarity index 100% rename from test/integration/minimal/verify/controls/aws_iam_root_user.rb rename to test/aws/minimal/verify/controls/aws_iam_root_user.rb diff --git a/test/integration/minimal/verify/inspec.yml b/test/aws/minimal/verify/inspec.yml similarity index 100% rename from test/integration/minimal/verify/inspec.yml rename to test/aws/minimal/verify/inspec.yml From b245b72a7c68db1e88a33014391dac65f87c20a8 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Tue, 6 Feb 2018 13:14:17 -0500 Subject: [PATCH 170/206] Add 'require _aws' to newly added resources (#224) Signed-off-by: Aaron Lippold --- libraries/aws_cloudtrail_trail.rb | 2 ++ libraries/aws_cloudtrail_trails.rb | 2 ++ libraries/aws_ec2_instance.rb | 4 ++-- libraries/aws_ec2_security_groups.rb | 4 ++-- libraries/aws_iam_groups.rb | 2 ++ libraries/aws_iam_password_policy.rb | 4 ++-- libraries/aws_iam_policies.rb | 2 ++ libraries/aws_iam_policy.rb | 2 ++ libraries/aws_kms_keys.rb | 2 ++ 9 files changed, 18 insertions(+), 6 deletions(-) diff --git a/libraries/aws_cloudtrail_trail.rb b/libraries/aws_cloudtrail_trail.rb index fc9b2e0e5..518d5d5b8 100644 --- a/libraries/aws_cloudtrail_trail.rb +++ b/libraries/aws_cloudtrail_trail.rb @@ -1,3 +1,5 @@ +require '_aws' + class AwsCloudTrailTrail < Inspec.resource(1) name 'aws_cloudtrail_trail' desc 'Verifies settings for an individual AWS CloudTrail Trail' diff --git a/libraries/aws_cloudtrail_trails.rb b/libraries/aws_cloudtrail_trails.rb index 97cb9cb24..12932b892 100644 --- a/libraries/aws_cloudtrail_trails.rb +++ b/libraries/aws_cloudtrail_trails.rb @@ -1,3 +1,5 @@ +require '_aws' + class AwsCloudTrailTrails < Inspec.resource(1) name 'aws_cloudtrail_trails' desc 'Verifies settings for AWS CloudTrail Trails in bulk' diff --git a/libraries/aws_ec2_instance.rb b/libraries/aws_ec2_instance.rb index 9ae09bc7f..baeed0ca1 100644 --- a/libraries/aws_ec2_instance.rb +++ b/libraries/aws_ec2_instance.rb @@ -5,7 +5,7 @@ class AwsEc2Instance < Inspec.resource(1) name 'aws_ec2_instance' desc 'Verifies settings for an EC2 instance' - example " + example <<-EOX describe aws_ec2_instance('i-123456') do it { should be_running } it { should have_roles } @@ -15,7 +15,7 @@ class AwsEc2Instance < Inspec.resource(1) it { should be_running } it { should have_roles } end - " +EOX def initialize(opts, conn = AWSConnection.new) @opts = opts diff --git a/libraries/aws_ec2_security_groups.rb b/libraries/aws_ec2_security_groups.rb index 4200153e1..ff4f3da72 100644 --- a/libraries/aws_ec2_security_groups.rb +++ b/libraries/aws_ec2_security_groups.rb @@ -3,7 +3,7 @@ require '_aws' class AwsEc2SecurityGroups < Inspec.resource(1) name 'aws_ec2_security_groups' desc 'Verifies settings for AWS Security Groups in bulk' - example " + example <<-EOX # Verify that you have security groups defined describe aws_ec2_security_groups do it { should exist } @@ -13,7 +13,7 @@ class AwsEc2SecurityGroups < Inspec.resource(1) describe aws_ec2_security_groups do its('entries.count') { should be > 1 } end - " +EOX # Constructor. Args are reserved for row fetch filtering. def initialize(raw_criteria = {}) diff --git a/libraries/aws_iam_groups.rb b/libraries/aws_iam_groups.rb index ce19aaed8..9e3b4ed55 100644 --- a/libraries/aws_iam_groups.rb +++ b/libraries/aws_iam_groups.rb @@ -1,3 +1,5 @@ +require '_aws' + class AwsIamGroups < Inspec.resource(1) name 'aws_iam_groups' desc 'Verifies settings for AWS IAM groups in bulk' diff --git a/libraries/aws_iam_password_policy.rb b/libraries/aws_iam_password_policy.rb index bd1c7717a..d2b53426b 100644 --- a/libraries/aws_iam_password_policy.rb +++ b/libraries/aws_iam_password_policy.rb @@ -5,7 +5,7 @@ class AwsIamPasswordPolicy < Inspec.resource(1) name 'aws_iam_password_policy' desc 'Verifies iam password policy' - example " + example <<-EOX describe aws_iam_password_policy do its('requires_lowercase_characters?') { should be true } end @@ -13,7 +13,7 @@ class AwsIamPasswordPolicy < Inspec.resource(1) describe aws_iam_password_policy do its('requires_uppercase_characters?') { should be true } end - " +EOX def initialize(conn = AWSConnection.new) @policy = conn.iam_resource.account_password_policy diff --git a/libraries/aws_iam_policies.rb b/libraries/aws_iam_policies.rb index 5e903d05f..c27f7e062 100644 --- a/libraries/aws_iam_policies.rb +++ b/libraries/aws_iam_policies.rb @@ -1,3 +1,5 @@ +require '_aws' + class AwsIamPolicies < Inspec.resource(1) name 'aws_iam_policies' desc 'Verifies settings for AWS IAM Policies in bulk' diff --git a/libraries/aws_iam_policy.rb b/libraries/aws_iam_policy.rb index 6c4731151..5573e8698 100644 --- a/libraries/aws_iam_policy.rb +++ b/libraries/aws_iam_policy.rb @@ -1,3 +1,5 @@ +require '_aws' + class AwsIamPolicy < Inspec.resource(1) name 'aws_iam_policy' desc 'Verifies settings for individual AWS IAM Policy' diff --git a/libraries/aws_kms_keys.rb b/libraries/aws_kms_keys.rb index 2b28925b6..a1649b1fc 100644 --- a/libraries/aws_kms_keys.rb +++ b/libraries/aws_kms_keys.rb @@ -1,3 +1,5 @@ +require '_aws' + class AwsKmsKeys < Inspec.resource(1) name 'aws_kms_keys' desc 'Verifies settings for AWS KMS Keys in bulk' From 8f20ba38f5a14d3abb0457af6185d551748bf9ee Mon Sep 17 00:00:00 2001 From: Jared Quick Date: Tue, 6 Feb 2018 11:57:17 -0500 Subject: [PATCH 171/206] clanup for merge --- .gitignore | 11 -- .rspec | 2 - .rubocop.yml | 76 ----------- .travis.yml | 12 -- CHANGELOG.md | 61 --------- CONTRIBUTING.md | 155 --------------------- Gemfile | 17 --- LICENSE | 13 -- README.md | 190 -------------------------- Rakefile | 101 -------------- images/linux_internal_vm_resource.png | Bin 197489 -> 0 bytes inspec.yml | 7 - 12 files changed, 645 deletions(-) delete mode 100644 .gitignore delete mode 100644 .rspec delete mode 100644 .rubocop.yml delete mode 100644 .travis.yml delete mode 100644 CHANGELOG.md delete mode 100644 CONTRIBUTING.md delete mode 100644 Gemfile delete mode 100644 LICENSE delete mode 100644 README.md delete mode 100644 Rakefile delete mode 100644 images/linux_internal_vm_resource.png delete mode 100644 inspec.yml diff --git a/.gitignore b/.gitignore deleted file mode 100644 index a2936e121..000000000 --- a/.gitignore +++ /dev/null @@ -1,11 +0,0 @@ -.envrc -.ruby-version -.direnv -Gemfile.lock -inspec.lock -.kitchen -*.plan -*.tfstate* -local -.vscode -.terraform \ No newline at end of file diff --git a/.rspec b/.rspec deleted file mode 100644 index 83e16f804..000000000 --- a/.rspec +++ /dev/null @@ -1,2 +0,0 @@ ---color ---require spec_helper diff --git a/.rubocop.yml b/.rubocop.yml deleted file mode 100644 index 29d0273ab..000000000 --- a/.rubocop.yml +++ /dev/null @@ -1,76 +0,0 @@ ---- -AllCops: - Exclude: - - Gemfile - - Rakefile - - 'test/**/*' - - 'examples/**/*' - - 'vendor/**/*' - - 'lib/bundles/inspec-init/templates/**/*' -Documentation: - Enabled: false -AlignParameters: - Enabled: true -Encoding: - Enabled: true -HashSyntax: - Enabled: true -ClassLength: - Max: 200 -LineLength: - Enabled: false -EmptyLinesAroundBlockBody: - Enabled: false -MethodLength: - Max: 40 -NumericLiterals: - MinDigits: 10 -Metrics/CyclomaticComplexity: - Max: 10 -Metrics/PerceivedComplexity: - Max: 11 -Metrics/AbcSize: - Max: 33 -Style/PercentLiteralDelimiters: - PreferredDelimiters: - '%': '{}' - '%i': () - '%q': '{}' - '%Q': () - '%r': '{}' - '%s': () - '%w': '{}' - '%W': () - '%x': () -Layout/AlignHash: - Enabled: false -Naming/PredicateName: - Enabled: false -Style/ClassAndModuleChildren: - Enabled: false -Style/ConditionalAssignment: - Enabled: false -Style/BracesAroundHashParameters: - Enabled: false -Style/AndOr: - Enabled: false -Style/Not: - Enabled: false -Naming/FileName: - Enabled: false -Style/TrailingCommaInLiteral: - EnforcedStyleForMultiline: comma -Style/TrailingCommaInArguments: - EnforcedStyleForMultiline: comma -Style/NegatedIf: - Enabled: false -Style/UnlessElse: - Enabled: false -BlockDelimiters: - Enabled: false -Layout/SpaceAroundOperators: - Enabled: false -Style/IfUnlessModifier: - Enabled: false -Lint/RescueWithoutErrorClass: - Enabled: false diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index f51c94f66..000000000 --- a/.travis.yml +++ /dev/null @@ -1,12 +0,0 @@ -sudo: false -language: ruby -cache: bundler - -rvm: - - 2.3.1 - -env: - - AZURE_VM_NAME="example-01" AZURE_RESOURCE_GROUP_NAME="MyResourceGroup" AZURE_CREDS_FILE=".travis_azure_creds" - -bundler_args: --without integration -script: bundle exec rake diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 0b9c634b9..000000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,61 +0,0 @@ -# Change Log - -## [0.5.0](https://github.com/chef/inspec-azure/tree/0.5.0) (2017-03-01) -[Full Changelog](https://github.com/chef/inspec-azure/compare/0.4.0...0.5.0) - -**Implemented enhancements:** - -- Add integration tests [\#19](https://github.com/chef/inspec-azure/issues/19) -- Specify the subscription to be used by index [\#15](https://github.com/chef/inspec-azure/issues/15) - -**Fixed bugs:** - -- Alternative subscriptions cannot be loaded from the credentials file [\#14](https://github.com/chef/inspec-azure/issues/14) - -**Closed issues:** - -- Fix how internal libraries are loaded [\#11](https://github.com/chef/inspec-azure/issues/11) - -**Merged pull requests:** - -- Added integration tests for current resources [\#20](https://github.com/chef/inspec-azure/pull/20) ([russellseymour](https://github.com/russellseymour)) -- add contribution guidelines and license [\#18](https://github.com/chef/inspec-azure/pull/18) ([chris-rock](https://github.com/chris-rock)) -- remove .kitchen logs [\#17](https://github.com/chef/inspec-azure/pull/17) ([chris-rock](https://github.com/chris-rock)) -- Using Credentials [\#16](https://github.com/chef/inspec-azure/pull/16) ([russellseymour](https://github.com/russellseymour)) - -## [0.4.0](https://github.com/chef/inspec-azure/tree/0.4.0) (2017-02-23) -[Full Changelog](https://github.com/chef/inspec-azure/compare/0.3.1...0.4.0) - -**Merged pull requests:** - -- Fixed loading of internal classes [\#13](https://github.com/chef/inspec-azure/pull/13) ([russellseymour](https://github.com/russellseymour)) -- Updated how internal libraries are located [\#12](https://github.com/chef/inspec-azure/pull/12) ([russellseymour](https://github.com/russellseymour)) - -## [0.3.1](https://github.com/chef/inspec-azure/tree/0.3.1) (2017-02-21) -[Full Changelog](https://github.com/chef/inspec-azure/compare/0.3.0...0.3.1) - -**Closed issues:** - -- Remove Azure resource class helpers [\#9](https://github.com/chef/inspec-azure/issues/9) - -**Merged pull requests:** - -- Reconfigured the way in which Helpers work [\#10](https://github.com/chef/inspec-azure/pull/10) ([russellseymour](https://github.com/russellseymour)) - -## [0.3.0](https://github.com/chef/inspec-azure/tree/0.3.0) (2017-02-20) -**Closed issues:** - -- Add resource to check the status of a Resource Group [\#6](https://github.com/chef/inspec-azure/issues/6) -- Add resources for checking the VM [\#5](https://github.com/chef/inspec-azure/issues/5) -- Cannot determine the return for a filter [\#3](https://github.com/chef/inspec-azure/issues/3) -- Add resource to check for presence and size of data disk [\#1](https://github.com/chef/inspec-azure/issues/1) - -**Merged pull requests:** - -- Added support for checking Resource Group resources [\#8](https://github.com/chef/inspec-azure/pull/8) ([russellseymour](https://github.com/russellseymour)) -- Added more VM resource controls [\#7](https://github.com/chef/inspec-azure/pull/7) ([russellseymour](https://github.com/russellseymour)) -- Testing Machine data disks [\#4](https://github.com/chef/inspec-azure/pull/4) ([russellseymour](https://github.com/russellseymour)) - - - -\* *This Change Log was automatically generated by [github_changelog_generator](https://github.com/skywinder/Github-Changelog-Generator)* \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index b92422962..000000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,155 +0,0 @@ -# Contributing to InSpec - -We are glad you want to contribute to InSpec! This document will help answer common questions you may have during your first contribution. - -## Submitting Issues - -We utilize **Github Issues** for issue tracking and contributions. You can contribute in two ways: - -1. Reporting an issue or making a feature request [here](#issues). -2. Adding features or fixing bugs yourself and contributing your code to InSpec. - -We ask you not to submit security concerns via Github. For details on submitting potential security issues please see - -## Contribution Process - -We have a 3 step process for contributions: - -1. Commit changes to a git branch, making sure to sign-off those changes for the [Developer Certificate of Origin](#developer-certification-of-origin-dco). -2. Create a Github Pull Request for your change, following the instructions in the pull request template. -3. Perform a [Code Review](#code-review-process) with the project maintainers on the pull request. - -### Pull Request Requirements - -Chef Projects are built to last. We strive to ensure high quality throughout the experience. In order to ensure this, we require that all pull requests to Chef projects meet these specifications: - -1. **Tests:** To ensure high quality code and protect against future regressions, we require all the code in Chef Projects to have at least unit test coverage. See the [test/unit](https://github.com/chef/inspec/tree/master/test/unit) -directory for the existing tests and use ```bundle exec rake test``` to run them. -2. **Green CI Tests:** We use [Travis CI](https://travis-ci.org/) and/or [AppVeyor](https://www.appveyor.com/) CI systems to test all pull requests. We require these test runs to succeed on every pull request before being merged. -3. **Up-to-date Documentation:** Every code change should be reflected in an update for our [documentation](https://github.com/chef/inspec/tree/master/docs). We expect PRs to update the documentation with the code change. - -In addition to this it would be nice to include the description of the problem you are solving - with your change. You can use [Issue Template](#issuetemplate) in the description section - of the pull request. - -### Code Review Process - -Code review takes place in Github pull requests. See [this article](https://help.github.com/articles/about-pull-requests/) if you're not familiar with Github Pull Requests. - -Once you open a pull request, project maintainers will review your code and respond to your pull request with any feedback they might have. The process at this point is as follows: - -1. Two thumbs-up (:+1:) are required from project maintainers. See the master maintainers document for InSpec projects at . -2. When ready, your pull request will be merged into `master`, we may require you to rebase your PR to the latest `master`. -3. Once the PR is merged, you will be included in `CHANGELOG.md`. - -If you would like to learn about when your code will be available in a release of Chef, read more about [Chef Release Cycles](#release-cycles). - - -### Developer Certification of Origin (DCO) - -Licensing is very important to open source projects. It helps ensure the software continues to be available under the terms that the author desired. - -Chef uses [the Apache 2.0 license](https://github.com/chef/chef/blob/master/LICENSE) to strike a balance between open contribution and allowing you to use the software however you would like to. - -The license tells you what rights you have that are provided by the copyright holder. It is important that the contributor fully understands what rights they are licensing and agrees to them. Sometimes the copyright holder isn't the contributor, such as when the contributor is doing work on behalf of a company. - -To make a good faith effort to ensure these criteria are met, Chef requires the Developer Certificate of Origin (DCO) process to be followed. - -The DCO is an attestation attached to every contribution made by every developer. In the commit message of the contribution, the developer simply adds a Signed-off-by statement and thereby agrees to the DCO, which you can find below or at . - -``` -Developer's Certificate of Origin 1.1 - -By making a contribution to this project, I certify that: - -(a) The contribution was created in whole or in part by me and I - have the right to submit it under the open source license - indicated in the file; or - -(b) The contribution is based upon previous work that, to the - best of my knowledge, is covered under an appropriate open - source license and I have the right under that license to - submit that work with modifications, whether created in whole - or in part by me, under the same open source license (unless - I am permitted to submit under a different license), as - Indicated in the file; or - -(c) The contribution was provided directly to me by some other - person who certified (a), (b) or (c) and I have not modified - it. - -(d) I understand and agree that this project and the contribution - are public and that a record of the contribution (including - all personal information I submit with it, including my - sign-off) is maintained indefinitely and may be redistributed - consistent with this project or the open source license(s) - involved. -``` - -For more information on the change see the Chef Blog post [Introducing Developer Certificate of Origin](https://blog.chef.io/2016/09/19/introducing-developer-certificate-of-origin/) - -#### DCO Sign-Off Methods - -The DCO requires a sign-off message in the following format appear on each commit in the pull request: - -``` -Signed-off-by: Julia Child -``` - -The DCO text can either be manually added to your commit body, or you can add either **-s** or **--signoff** to your usual git commit commands. If you forget to add the sign-off you can also amend a previous commit with the sign-off by running **git commit --amend -s**. If you've pushed your changes to Github already you'll need to force push your branch after this with **git push -f**. - -### Obvious Fix Policy - -Small contributions, such as fixing spelling errors, where the content is small enough to not be considered intellectual property, can be submitted without signing the contribution for the DCO. - -As a rule of thumb, changes are obvious fixes if they do not introduce any new functionality or creative thinking. Assuming the change does not affect functionality, some common obvious fix examples include the following: - -- Spelling / grammar fixes -- Typo correction, white space and formatting changes -- Comment clean up -- Bug fixes that change default return values or error codes stored in constants -- Adding logging messages or debugging output -- Changes to 'metadata' files like Gemfile, .gitignore, build scripts, etc. -- Moving source files from one directory or package to another - -**Whenever you invoke the "obvious fix" rule, please say so in your commit message:** - -``` ------------------------------------------------------------------------- -commit 370adb3f82d55d912b0cf9c1d1e99b132a8ed3b5 -Author: Julia Child -Date: Wed Sep 18 11:44:40 2015 -0700 - - Fix typo in the README. - - Obvious fix. - ------------------------------------------------------------------------- -``` - -## Release Cycles - -Our primary shipping vehicle is operating system specific packages that includes all the requirements of InSpec. We call these [Omnibus packages](https://github.com/chef/omnibus) - -We also release our software as gems to [Rubygems](https://rubygems.org/) but we strongly recommend using InSpec or ChefDK packages. - -Our version numbering roughly follows [Semantic Versioning](http://semver.org/) standard. Our standard version numbers look like X.Y.Z which mean: - -- X is a major release, which may not be fully compatible with prior major releases -- Y is a minor release, which adds both new features and bug fixes -- Z is a patch release, which adds just bug fixes - -After shipping a release of InSpec we bump at least the `Minor` version by one to start development of the next minor release. We do a release approximately every week. Announcements of releases are made to the [InSpec mailing list](https://discourse.chef.io/c/chef-release) when they are available. - -## InSpec Community - -InSpec is made possible by a strong community of developers, system administrators, auditor and security experts. If you have any questions or if you would like to get involved in the InSpec community you can check out: - -- [InSpec Mailing List](https://discourse.chef.io/c/inspec) -- [Chef Community Slack](https://community-slack.chef.io/) - -Also here are some additional pointers to some awesome Chef content: - -- [InSpec Docs](http://inspec.io/docs/) -- [Learn Chef](https://learn.chef.io/) -- [Chef Website](https://www.chef.io/) diff --git a/Gemfile b/Gemfile deleted file mode 100644 index 10150d521..000000000 --- a/Gemfile +++ /dev/null @@ -1,17 +0,0 @@ -source 'https://rubygems.org' - -gem 'inifile' -gem 'azure_mgmt_resources', '~> 0.15' -gem 'bundle' - -group :development do - gem 'rake' - gem 'rubocop' - gem 'github_changelog_generator' - gem 'pry-coolline' - gem 'passgen' -end - -group :inspec do - gem 'inspec', '~> 1.1' -end diff --git a/LICENSE b/LICENSE deleted file mode 100644 index c198000bf..000000000 --- a/LICENSE +++ /dev/null @@ -1,13 +0,0 @@ -Copyright (c) 2017 Chef Software Inc. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/README.md b/README.md deleted file mode 100644 index f031f7b72..000000000 --- a/README.md +++ /dev/null @@ -1,190 +0,0 @@ -# InSpec for Azure - -This Resource Pack has been been completely rewritten so that it is able to check for _any_ resource in Azure, previous versions only supported 4 resource types. - -Whenever an Azure resource is retrieved its attributes are interrogated and turned into things that can be called during tests on that particular resource. By making the profile dynamic it means that more or less any resource that is available in Azure can be tested. - -To determine what methods can be called against an Azure resource please find the resource in https://resources.azure.com. The attributes of the resource are turned into the methods that can be called. Please refer to the examples further down the page or the integration tests for more information. - -The only caveat to this is that the generic resource cannot interrogate an Azure resource group itself. There is an InSpec resource called `azure_resource_group` that permits this. - -**The resource pack not only contains the generic resource but also expliclit tests for some resources. These are similar to the ones that were in previous versions, but now have different properties and attributes that may break existing tests. Please review the documentation for the resource you are using.** - -## Roadmap - -This repository is the development repository for InSpec for Azure. Once [RFC Platforms](https://github.com/chef/inspec/issues/1661) is fully implemented in InSpec, this repository is going to be merged into core InSpec. - -As of now, Azure resources are implemented as an InSpec resource pack. It will ship with the required resources to write your own Azure tests: - -``` -├── README.md - this readme -└── libraries - contains Azure resources -└── test - contains integration tests -``` - -## Get Started - -This profile uses one of the Azure Ruby SDK libraries 'Azure Resource Management' and as such requires a Service Principal Name (SPN) to be created in the Azure subscription that is being tested. - -This can be done on the command line or from the Azure Portal - -- Azure CLI: https://docs.microsoft.com/en-us/azure/azure-resource-manager/resource-group-authenticate-service-principal-cli -- PowerShell: https://docs.microsoft.com/en-us/azure/azure-resource-manager/resource-group-authenticate-service-principal -- Azure Portal: https://docs.microsoft.com/en-us/azure/azure-resource-manager/resource-group-create-service-principal-portal - -The information from the SPN can be specified either in a file `~/.azure/credentials`, a different file or as environment variables. - -### Credentials File - -The simplest way is to create the file `~/.azure/credentials` with the following format. The profile is configured to look for this file by default do no settings are required. - -``` -[] -client_id = "" -client_secret = "" -tenant_id = "" -``` - -So to run the profile now it is as simple as running: - -```bash -inspec exec inspec-azure -``` - -A different credentials file, with the same format, can be specified as an environment variable `AZURE_CREDS_FILE`: - -```bash -AZURE_CREDS_FILE="/path/to/another/file" inspec exec inspec-azure -``` - -Note that this file format supports multiple subscription_ids. By default `inspec-azure` will pick the first subscription in the file. However if another subscription should be used then specify it in the environment variable `AZURE_SUBSCRIPTION_ID`. - -```bash -AZURE_SUBSCRIPTION_ID="2fbdbb02-df2e-11e6-bf01-fe55135034f3" inspec exec inspec-azure -``` - -Alternatively, if you know that the subscription you want to use is the second one in you credentials file then you can use the following: - -```bash -AZURE_SUBSCRIPTION_NUMBER=2 inspec exec inspec-azure -``` - -### Environment variables - -It is possible to not have a credentials file at all and specify all of the required information as the following environment variables: - -- `AZURE_SUBSCRIPTION_ID` -- `AZURE_CLIENT_ID` -- `AZURE_CLIENT_SECRET` -- `AZURE_TENANT_ID` - -For example: - -```bash -AZURE_SUBSCRIPTION_ID="2fbdbb02-df2e-11e6-bf01-fe55135034f3" \ -AZURE_CLIENT_ID="58dc4f6c-df2e-11e6-bf01-fe55135034f3" \ -AZURE_CLIENT_SECRET="Jibr4iwwaaZwBb6W" \ -AZURE_TENANT_ID="6ad89b58-df2e-11e6-bf01-fe55135034f3" inspec exec inspec-azure -``` - -## Use the resources - -Since this is an InSpec resource pack, it only defines InSpec resources. It includes example tests only. To easily use the Azure InSpec resources in your tests do the following: - -### Create a new profile - -```bash -inspec init profile my-profile -``` - -### Adapt the `inspec.yml` - -```yaml -name: my-profile -title: My own Azure profile -version: 0.1.0 -depends: - - name: azure - url: https://github.com/chef/inspec-azure/archive/master.tar.gz -``` - -### Add controls - -Since your profile depends on the InSpec resource pack, you can use those resources in your own profile. In this example a virtual machine in the specified Azure resource group is being tested. - -Using the https://resources.azure.com portal the virtual machine has the following attributes. - -![Virtual Machine Attributes](images/linux_internal_vm_resource.png) - -Using this information tests can be written, for example: - -```ruby -control 'azure-1' do - impact 1.0 - title 'Checks that the machine was built from the correct image' - - describe azure_generic_resource(group_name: 'Inspec-Azure', name: 'Linux-Internal-VM') do - its('properties.storageProfile.imageReference.publisher') { should cmp 'Canonical' } - its('properties.storageProfile.imageReference.offer') { should cmp 'UbuntuServer' } - its('properties.storageProfile.imageReference.sku') { should cmp '16.04.0-LTS' } - end -end -``` - -There are a few different ways in which tests can be written, which mean that arrays can be tested. A lot of the different ways and techniques are shown and documented in the integrations tests which are highly recommended reading. - -### Available Resources - -The following resources are available in the Inspec Azure Profile - - - [Azure Generic Resource](docs/resources/azure_generic_resource.md) - - [Azure Resource Group](docs/resources/azure_resource_group.md) - - [Azure Virtual Machine](docs/resources/azure_virtual_machine.md) - - [Azure Data Disk](docs/resources/azure_virtual_machine_data_disk.md) - -When multiple resources are returned a FilterTable is created. This means it is possible to retrieve all of hte resources in a resource group and interrogate them within the testing block using the `.where` function on the FilterTable. Please refer to the `test/integration/verify/controls/resources.rb` file for an example of this. - -## Integration Testing - -Our integration tests spin up an environment with terraform 0.10+ and verify the result with InSpec. The `test/integration/verify/controls` directory contains all of the tests that are run during integration tests. These can be used as examples of how to use this resource pack. - -Rake tasks have been configured to enable the running of the integration tests: - -```bash -rake changelog # Generate a Change log from GitHub -rake lint # Run robocop linter -rake rubocop # Run Rubocop lint checks -rake test:integration # Perform Integration Tests -``` - -As with using the InSpec resources themselves the integration tests rely on a Service Principal Name being defined. Please see the information at the start of this page on how to generate this. - -Due to the fact that any Azure resource can now be tested the integration tests check the following Azure resource types: - - - Microsoft.Compute/virtualMachines - - Microsoft.Compute/disks - - Microsoft.Network/networkInterfaces - - Microsoft.Network/virtualNetworks - - Microsoft.Network/networkSecurityGroups - - Microsoft.Network/publicIPAddresses - - Microsoft.Storage/storageAccounts - -## License - -| | | -| ------ | --- | -| **Author:** | Russell Seymour () | -| **Copyright:** | Copyright (c) 2017 Chef Software Inc. | -| **License:** | Apache License, Version 2.0 | - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. diff --git a/Rakefile b/Rakefile deleted file mode 100644 index 6c1aae234..000000000 --- a/Rakefile +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env rake - -require 'rake/testtask' -require 'rubocop/rake_task' -require 'inifile' -require 'passgen' -require_relative 'libraries/azure_backend' - -# Rubocop -desc 'Run Rubocop lint checks' -task :rubocop do - RuboCop::RakeTask.new -end - -# lint the project -desc 'Run robocop linter' -task lint: [:rubocop] - -# run tests -task default: [:lint] - -namespace :test do - - # Specify the directory for the integration tests - integration_dir = "test/integration" - - # run inspec check to verify that the profile is properly configured - #task :check do - # dir = File.join(File.dirname(__FILE__)) - # sh("bundle exec inspec check #{dir}") - #end - - task :init_workspace do - # Initialize terraform workspace - cmd = format("cd %s/build/ && terraform init", integration_dir) - sh(cmd) - end - - task :setup_integration_tests do - - azure_backend = AzureConnection.new - creds = azure_backend.spn - - # Determine the storage account name and the admin password - sa_name = (0...15).map { (65 + rand(26)).chr }.join.downcase - admin_password = Passgen::generate(length: 12, uppercase: true, lowercase: true, symbols: true, digits: true) - - # Use the first 4 characters of the storage account to create a suffix - suffix = sa_name[0..3] - - puts "----> Setup" - - # Create the plan that can be applied to Azure - cmd = format("cd %s/build/ && terraform plan -var 'subscription_id=%s' -var 'client_id=%s' -var 'client_secret=%s' -var 'tenant_id=%s' -var 'storage_account_name=%s' -var 'admin_password=%s' -var 'suffix=%s' -out inspec-azure.plan", integration_dir, creds[:subscription_id], creds[:client_id], creds[:client_secret], creds[:tenant_id], sa_name, admin_password, suffix) - sh(cmd) - - # Apply the plan on Azure - cmd = format("cd %s/build/ && terraform apply inspec-azure.plan", integration_dir) - sh(cmd) - end - - task :run_integration_tests do - puts "----> Run" - - cmd = format("bundle exec inspec exec %s/verify", integration_dir) - sh(cmd) - end - - task :cleanup_integration_tests do - - azure_backend = AzureConnection.new - creds = azure_backend.spn - - puts "----> Cleanup" - cmd = format("cd %s/build/ && terraform destroy -force -var 'subscription_id=%s' -var 'client_id=%s' -var 'client_secret=%s' -var 'tenant_id=%s' -var 'admin_password=dummy' -var 'storage_account_name=dummy' -var 'suffix=dummy'", integration_dir, creds[:subscription_id], creds[:client_id], creds[:client_secret], creds[:tenant_id]) - sh(cmd) - - end - - desc "Perform Integration Tests" - task :integration do - Rake::Task["test:init_workspace"].execute - Rake::Task["test:cleanup_integration_tests"].execute - Rake::Task["test:setup_integration_tests"].execute - Rake::Task["test:run_integration_tests"].execute - Rake::Task["test:cleanup_integration_tests"].execute - end -end - -# Automatically generate a changelog for this project. Only loaded if -# the necessary gem is installed. -# use `rake changelog to=1.2.0` -begin - v = ENV['to'] - require 'github_changelog_generator/task' - GitHubChangelogGenerator::RakeTask.new :changelog do |config| - config.future_release = v - end -rescue LoadError - puts '>>>>> GitHub Changelog Generator not loaded, omitting tasks' -end diff --git a/images/linux_internal_vm_resource.png b/images/linux_internal_vm_resource.png deleted file mode 100644 index 67202894d29fad62705b1fd5dfde6aad4c829ba0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 197489 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

o~UPyQv_uzc;hsr8!q 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%0AkM&d$*kIG3(UO$9U!5I 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�*Zk+Dp@Vn3K=@wWxCre4mqqo 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

{%utGru^@2#r}^#|6E)`qOaN&#plQ*XEQ{! zfD4zZ_{u-Zs&~U1G;bjJFfMy~Kz&e%eG;5FuFGdCX0|)ECaez|gb@Jy7kLecZ(CKu zc=s;>ruR!F;Q>@;(~W4hE)ZD-tA~m6gq#}nSOj*L8@z?gGCQ@>5Bz9QPAU8|Z4&s? z^M``Az@PkoONkdMI5b=@b8On+5URDtH!98izfz=bJm(*p3Eq+w5=E)wuir%4!SL}J zUj>E>3_-saK+l2r=})gO>9F{+tGi8&&b?9n(dKyl>3>!gu;`wl^0M*o0Z!o0a9}5X zxGPK1mvrDH+*YN!@Gj0OMOf}M2p!)uJ0MJMRW`HlLt`j7GPi%lb zNJlBpj5TFoMy-WS->7AzBwv%Tp>M4e*)Bi}GX!+Us(-<9U{v|nR@xZsB*j5i=(b7x z{jx)OH_WqT@3|KAKis9mxZrh3(6iUS;6vRe@pr)AO;y)x-#87Z?)1HU5NuW zRUj%H`#^Bse4vno5WXb^JDjwcWyB>YsH)}yh?P-OF`u))5K7<%Xsn$L}T71

>v<{g` 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

RqoMLVMsQp0OR7?0g{MU!{ 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 vs^wVm8K*2v<3xVOQoP216fVDheCL1p)TJVC&+ Date: Wed, 7 Feb 2018 10:03:11 -0500 Subject: [PATCH 172/206] Skeletal Resource: aws_vpc_subnet (#209) Signed-off-by: Matthew Dromazos --- docs/aws_vpc_subnet.md | 148 ++++++++++++++++ docs/resources/aws_vpc_subnet.md | 123 ++++++++++++++ libraries/aws_vpc_subnet.rb | 89 ++++++++++ test/integration/default/build/ec2.tf | 13 ++ .../default/verify/controls/aws_vpc_subnet.rb | 47 +++++ test/unit/resources/aws_vpc_subnet_test.rb | 160 ++++++++++++++++++ 6 files changed, 580 insertions(+) create mode 100644 docs/aws_vpc_subnet.md create mode 100644 docs/resources/aws_vpc_subnet.md create mode 100644 libraries/aws_vpc_subnet.rb create mode 100644 test/integration/default/verify/controls/aws_vpc_subnet.rb create mode 100644 test/unit/resources/aws_vpc_subnet_test.rb diff --git a/docs/aws_vpc_subnet.md b/docs/aws_vpc_subnet.md new file mode 100644 index 000000000..1dc8b2104 --- /dev/null +++ b/docs/aws_vpc_subnet.md @@ -0,0 +1,148 @@ +--- +title: About the aws_vpc_subnet Resource +--- + +# aws_vpc_subnet + +Use the `aws_vpc_subnet` InSpec audit resource to test properties of a vpc subnet. + +To test properties of a single VPC subnet, use the `aws_vpc_subnet` resource. + +To test properties of all or a group of VPC subnets, use the `aws_vpc_subnets` resource. + +
+ +## Syntax + +An `aws_vpc_subnet` resource block uses the parameter to select a VPC and a subnet in the VPC. + + describe aws_vpc_subnet(vpc_id: 'vpc-01234567', subnet_id: 'subnet-1234567') do + it { should exist } + its('cidr_block') { should eq ['10.0.1.0/24'] } + end + +
+ +## Resource Parameters + +This InSpec resource accepts the following parameters, which are used to search for the VPCs subnet. + +### vpc_id + +A string identifying the VPC which contains zero or more subnets. + + # This will error if there is more than the default SG + describe aws_vpc_subnet(vpc_id: 'vpc-12345678', 'subnet-1234567') do + it { should exist } + end + +### subnet_id + +A string identifying the subnet that the VPC contains. + + # This will error if there is more than the default SG + describe aws_vpc_subnet(vpc_id: 'vpc-12345678', subnet_id: 'subnet-12345678') do + it { should exist } + end + +
+ +## Properties + +### assign_ipv_6_address_on_creation + +Detects whether the network interface on the subnet accepts IPv6 addresses. + + describe aws_vpc_subnet(vpc_id: 'vpc-12345678' , subnet_id: 'subnet-12345678') do + its('assign_ipv_6_address_on_creation') { should eq false } + end + +### availability_zone + +Provides the Availability Zone of the subnet. + + describe aws_vpc_subnet(vpc_id: 'vpc-12345678' , subnet_id: 'subnet-12345678') do + its('availability_zone') { should eq 'us-east-1c' } + end + +### available_ip_address_count + +Provides the number of available IPv4 addresses on the subnet. + + describe aws_vpc_subnet(vpc_id: 'vpc-12345678' , subnet_id: 'subnet-12345678') do + its('available_ip_address_count') { should eq 251 } + end + +### cidr_block + +Provides the block of ip addresses specified to the subnet. + + describe aws_vpc_subnet(vpc_id: 'vpc-12345678' , subnet_id: 'subnet-12345678') do + its('cidr_block') { should eq '10.0.1.0/24' } + end + +### default_for_az + +Detects if this is the default subnet for the Availability Zone. + + describe aws_vpc_subnet(vpc_id: 'vpc-12345678' , subnet_id: 'subnet-12345678') do + its('default_for_az') { should eq false } + end + +### ipv_6_cidr_block_association_set + +Provides information about the IPv6 cidr_block associatied with the subnet. + + describe aws_vpc_subnet(vpc_id: 'vpc-12345678' , subnet_id: 'subnet-12345678') do + its('ipv_6_cidr_block_association_set') { should eq [ + { + "Ipv6CidrBlock": "2001:db8:1234:a101::/64", + "AssociationId": "subnet-cidr-assoc-30e7e348", + "Ipv6CidrBlockState": { + "State": "ASSOCIATED" + } + } + ] } + end + +### map_public_ip_on_launch + +Provides the ID of the VPC the subnet is in. + + describe aws_vpc_subnet(vpc_id: 'vpc-12345678' , subnet_id: 'subnet-12345678') do + its('map_public_ip_on_launch') { should eq false } + end + +### state + +Provides the ID of the VPC the subnet is in. + + describe aws_vpc_subnet(vpc_id: 'vpc-12345678' , subnet_id: 'subnet-12345678') do + its('state') { should eq 'available' } + end + +### subnet_id + +Provides the ID of the VPC the subnet is in. + + describe aws_vpc_subnet(vpc_id: 'vpc-12345678' , subnet_id: 'subnet-12345678') do + its('subnet_id') { should eq 'subnet-12345678' } + end + +### vpc_id + +Provides the ID of the VPC the subnet is in. + + describe aws_vpc_subnet(vpc_id: 'vpc-12345678' , subnet_id: 'subnet-12345678') do + its('vpc_id') { should eq 'vpc-12345678' } + end + +## Matchers + +### exist + +The `exist` matcher indicates that a subnet exists for the specified vpc. + + describe aws_vpc_subnet(vpc_id: 'vpc-1234567', subnet_id: 'subnet-12345678') do + it { should exist } + end diff --git a/docs/resources/aws_vpc_subnet.md b/docs/resources/aws_vpc_subnet.md new file mode 100644 index 000000000..702925bf7 --- /dev/null +++ b/docs/resources/aws_vpc_subnet.md @@ -0,0 +1,123 @@ +--- +title: About the aws_vpc_subnet Resource +--- + +# aws_vpc_subnet + +Use the `aws_vpc_subnet` InSpec audit resource to test properties of a vpc subnet. + +To test properties of a single VPC subnet, use the `aws_vpc_subnet` resource. + +To test properties of all or a group of VPC subnets, use the `aws_vpc_subnets` resource. + +
+ +## Syntax + +An `aws_vpc_subnet` resource block uses the parameter to select a VPC and a subnet in the VPC. + + describe aws_vpc_subnet(subnet_id: 'subnet-1234567') do + it { should exist } + its('cidr_block') { should eq '10.0.1.0/24' } + end + +
+ +## Resource Parameters + +This InSpec resource accepts the following parameters, which are used to search for the VPCs subnet. + +### subnet_id + +A string identifying the subnet that the VPC contains. + + # This will error if there is more than the default SG + describe aws_vpc_subnet(subnet_id: 'subnet-12345678') do + it { should exist } + end + +
+ +## Matchers + +### assigning_ipv_6_address_on_creation + +Detects whether the network interface on the subnet accepts IPv6 addresses. + + describe aws_vpc_subnet(subnet_id: 'subnet-12345678') do + it { should be_assigning_ipv_6_address_on_creation } + end + +### available + +Provides the current state of the subnet. + + describe aws_vpc_subnet(subnet_id: 'subnet-12345678') do + it { should be_available } + end + +### default_for_az + +Detects if this is the default subnet for the Availability Zone. + + describe aws_vpc_subnet(subnet_id: 'subnet-12345678') do + it { should be_default_for_az } + end + +### exist + +The `exist` matcher indicates that a subnet exists for the specified vpc. + + describe aws_vpc_subnet(subnet_id: 'subnet-12345678') do + it { should exist } + end + +### mapping_public_ip_on_launch + +Provides the ID of the VPC the subnet is in. + + describe aws_vpc_subnet(subnet_id: 'subnet-12345678') do + it { should be_mapping_public_ip_on_launch } + end + +## Properties + +### availability_zone + +Provides the Availability Zone of the subnet. + + describe aws_vpc_subnet(subnet_id: 'subnet-12345678') do + its('availability_zone') { should eq 'us-east-1c' } + end + +### available_ip_address_count + +Provides the number of available IPv4 addresses on the subnet. + + describe aws_vpc_subnet(subnet_id: 'subnet-12345678') do + its('available_ip_address_count') { should eq 251 } + end + +### cidr_block + +Provides the block of ip addresses specified to the subnet. + + describe aws_vpc_subnet(subnet_id: 'subnet-12345678') do + its('cidr_block') { should eq '10.0.1.0/24' } + end + +### subnet_id + +Provides the ID of the Subnet. + + describe aws_vpc_subnet(subnet_id: 'subnet-12345678') do + its('subnet_id') { should eq 'subnet-12345678' } + end + +### vpc_id + +Provides the ID of the VPC the subnet is in. + + describe aws_vpc_subnet(subnet_id: 'subnet-12345678') do + its('vpc_id') { should eq 'vpc-12345678' } + end diff --git a/libraries/aws_vpc_subnet.rb b/libraries/aws_vpc_subnet.rb new file mode 100644 index 000000000..8d9c1ad90 --- /dev/null +++ b/libraries/aws_vpc_subnet.rb @@ -0,0 +1,89 @@ +# author: Matthew Dromazos + +require '_aws' + +class AwsVpcSubnet < Inspec.resource(1) + name 'aws_vpc_subnet' + desc 'This resource is used to test the attributes of a VPC subnet' + example " + describe aws_vpc_subnet(subnet_id: 'subnet-12345678') do + it { should exist } + its('cidr_block') { should eq '10.0.1.0/24' } + end + " + + include AwsResourceMixin + attr_reader :vpc_id, :subnet_id, :cidr_block, :availability_zone, :available_ip_address_count, + :default_for_az, :mapping_public_ip_on_launch, :available, :ipv_6_cidr_block_association_set, + :assigning_ipv_6_address_on_creation + alias available? available + alias default_for_az? default_for_az + alias mapping_public_ip_on_launch? mapping_public_ip_on_launch + alias assigning_ipv_6_address_on_creation? assigning_ipv_6_address_on_creation + + def to_s + "VPC Subnet #{@subnet_id}" + end + + private + + def validate_params(raw_params) + validated_params = check_resource_param_names( + raw_params: raw_params, + allowed_params: [:subnet_id], + allowed_scalar_name: :subnet_id, + allowed_scalar_type: String, + ) + + # Make sure the subnet_id parameter was specified and in the correct form. + if validated_params.key?(:subnet_id) && validated_params[:subnet_id] !~ /^subnet\-[0-9a-f]{8}/ + raise ArgumentError, 'aws_vpc_subnet Subnet ID must be in the format "subnet-" followed by 8 hexadecimal characters.' + end + + if validated_params.empty? + raise ArgumentError, 'You must provide a subnet_id to aws_vpc_subnet.' + end + + validated_params + end + + def fetch_from_aws + backend = AwsVpcSubnet::BackendFactory.create + + # Transform into filter format expected by AWS + filters = [] + filters.push({ name: 'subnet-id', values: [@subnet_id] }) + ds_response = backend.describe_subnets(filters: filters) + + # If no subnets exist in the VPC, exist is false. + if ds_response.subnets.empty? + @exists = false + return + end + @exists = true + assign_properties(ds_response) + end + + def assign_properties(ds_response) + @vpc_id = ds_response.subnets[0].vpc_id + @subnet_id = ds_response.subnets[0].subnet_id + @cidr_block = ds_response.subnets[0].cidr_block + @availability_zone = ds_response.subnets[0].availability_zone + @available_ip_address_count = ds_response.subnets[0].available_ip_address_count + @default_for_az = ds_response.subnets[0].default_for_az + @mapping_public_ip_on_launch = ds_response.subnets[0].map_public_ip_on_launch + @available = ds_response.subnets[0].state == 'available' + @ipv_6_cidr_block_association_set = ds_response.subnets[0].ipv_6_cidr_block_association_set + @assigning_ipv_6_address_on_creation = ds_response.subnets[0].assign_ipv_6_address_on_creation + end + + # Uses the SDK API to really talk to AWS + class Backend + class AwsClientApi + BackendFactory.set_default_backend(self) + def describe_subnets(query) + AWSConnection.new.ec2_client.describe_subnets(query) + end + end + end +end diff --git a/test/integration/default/build/ec2.tf b/test/integration/default/build/ec2.tf index e9571f41f..a2ed72648 100644 --- a/test/integration/default/build/ec2.tf +++ b/test/integration/default/build/ec2.tf @@ -198,6 +198,19 @@ output "ec2_security_group_alpha_group_id" { value = "${aws_security_group.alpha.id}" } +#============================================================# +# VPC Subnets +#============================================================# + +resource "aws_subnet" "subnet_01" { + vpc_id = "${data.aws_vpc.default.id}" + cidr_block = "172.31.96.0/20" +} + +output "ec2_default_vpc_subnet_01_id" { + value = "${aws_subnet.subnet_01.id}" +} + output "ec2_security_group_alpha_group_name" { value = "${aws_security_group.alpha.name}" } diff --git a/test/integration/default/verify/controls/aws_vpc_subnet.rb b/test/integration/default/verify/controls/aws_vpc_subnet.rb new file mode 100644 index 000000000..c41d9ca0f --- /dev/null +++ b/test/integration/default/verify/controls/aws_vpc_subnet.rb @@ -0,0 +1,47 @@ +fixtures = {} +[ + 'ec2_security_group_default_vpc_id', + 'ec2_default_vpc_subnet_01_id', +].each do |fixture_name| + fixtures[fixture_name] = attribute( + fixture_name, + default: "default.#{fixture_name}", + description: 'See ../build/ec2.tf', + ) +end + +control "aws_vpc_subnet recall of subnet_01" do + # Test hash given subnet_id + describe aws_vpc_subnet(subnet_id: fixtures['ec2_default_vpc_subnet_01_id']) do + it { should exist } + end + + # Test scalar works + describe aws_vpc_subnet(fixtures['ec2_default_vpc_subnet_01_id']) do + it { should exist } + end + + describe aws_vpc_subnet(subnet_id: 'subnet-00000000') do + it { should_not exist } + end +end + +control "aws_vpc_subnet properties of subnet_01" do + describe aws_vpc_subnet(subnet_id: fixtures['ec2_default_vpc_subnet_01_id']) do + its('vpc_id') { should eq fixtures['ec2_security_group_default_vpc_id'] } + its('subnet_id') { should eq fixtures['ec2_default_vpc_subnet_01_id'] } + its('cidr_block') { should eq '172.31.96.0/20' } + its('available_ip_address_count') { should eq 4091 } + its('availability_zone') { should eq 'us-east-1c' } + its('ipv_6_cidr_block_association_set') { should eq [] } + end +end + +control "aws_vpc_subnet matchers of subnet_01" do + describe aws_vpc_subnet(subnet_id: fixtures['ec2_default_vpc_subnet_01_id']) do + it { should be_available } + it { should_not be_mapping_public_ip_on_launch } + it { should_not be_default_for_az } + it { should_not be_assigning_ipv_6_address_on_creation } + end +end diff --git a/test/unit/resources/aws_vpc_subnet_test.rb b/test/unit/resources/aws_vpc_subnet_test.rb new file mode 100644 index 000000000..5995ed910 --- /dev/null +++ b/test/unit/resources/aws_vpc_subnet_test.rb @@ -0,0 +1,160 @@ +# encoding: utf-8 +require 'helper' +require 'aws_vpc_subnet' + +# MVSSB = MockVpcSubnetSingleBackend +# Abbreviation not used outside this file + +#=============================================================================# +# Constructor Tests +#=============================================================================# +class AwsVpcSubnetConstructorTest < Minitest::Test + def setup + AwsVpcSubnet::BackendFactory.select(AwsMVSSB::Basic) + end + + def test_constructor_no_args_raises + assert_raises(ArgumentError) { AwsVpcSubnet.new } + end + + def test_constructor_expected_well_formed_args + AwsVpcSubnet.new(subnet_id: 'subnet-12345678') + end + + def test_constructor_reject_unknown_resource_params + assert_raises(ArgumentError) { AwsVpcSubnet.new(bla: 'blabla') } + end +end + +#=============================================================================# +# Recall +#=============================================================================# + +class AwsVpcSubnetRecallTest < Minitest::Test + def setup + AwsVpcSubnet::BackendFactory.select(AwsMVSSB::Basic) + end + + def test_search_hit_via_hash_with_vpc_id_and_subnet_id_works + assert AwsVpcSubnet.new(subnet_id: 'subnet-12345678').exists? + end + + def test_search_miss_is_not_an_exception + refute AwsVpcSubnet.new(subnet_id: 'subnet-00000000').exists? + end +end + +#=============================================================================# +# properties +#=============================================================================# + +class AwsVpcSubnetPropertiesTest < Minitest::Test + def setup + AwsVpcSubnet::BackendFactory.select(AwsMVSSB::Basic) + end + + def test_property_subnet_id + assert_equal('subnet-12345678', AwsVpcSubnet.new(subnet_id: 'subnet-12345678').subnet_id) + end + + def test_property_vpc_id + assert_equal('vpc-12345678', AwsVpcSubnet.new(subnet_id: 'subnet-12345678').vpc_id) + end + + def test_property_cidr_block + assert_equal('10.0.1.0/24', AwsVpcSubnet.new(subnet_id: 'subnet-12345678').cidr_block) + assert_nil(AwsVpcSubnet.new(subnet_id: 'subnet-00000000').cidr_block) + end + + def test_property_availability_zone + assert_equal('us-east-1', AwsVpcSubnet.new(subnet_id: 'subnet-12345678').availability_zone) + assert_nil(AwsVpcSubnet.new(subnet_id: 'subnet-00000000').availability_zone) + end + + def test_property_available_ip_address_count + assert_equal(251, AwsVpcSubnet.new(subnet_id: 'subnet-12345678').available_ip_address_count) + assert_nil(AwsVpcSubnet.new(subnet_id: 'subnet-00000000').available_ip_address_count) + end + + def test_property_ipv_6_cidr_block_association_set + assert_equal([], AwsVpcSubnet.new(subnet_id: 'subnet-12345678').ipv_6_cidr_block_association_set) + assert_nil(AwsVpcSubnet.new(subnet_id: 'subnet-00000000').ipv_6_cidr_block_association_set) + end +end + +#=============================================================================# +# Test Matchers +#=============================================================================# +class AwsVpcSubnetPropertiesTest < Minitest::Test + def test_matcher_assign_ipv_6_address_on_creation + assert AwsVpcSubnet.new(subnet_id: 'subnet-12345678').assigning_ipv_6_address_on_creation + refute AwsVpcSubnet.new(subnet_id: 'subnet-87654321').assigning_ipv_6_address_on_creation + end + + def test_matcher_available + assert AwsVpcSubnet.new(subnet_id: 'subnet-12345678').available? + refute AwsVpcSubnet.new(subnet_id: 'subnet-87654321').available? + end + + def test_matcher_default_for_az + assert AwsVpcSubnet.new(subnet_id: 'subnet-12345678').default_for_az? + refute AwsVpcSubnet.new(subnet_id: 'subnet-87654321').default_for_az? + end + + def test_matcher_map_public_ip_on_launch + assert AwsVpcSubnet.new(subnet_id: 'subnet-12345678').mapping_public_ip_on_launch + refute AwsVpcSubnet.new(subnet_id: 'subnet-87654321').mapping_public_ip_on_launch + end +end + + +#=============================================================================# +# Test Fixtures +#=============================================================================# + +module AwsMVSSB + class Basic < AwsVpcSubnet::Backend + def describe_subnets(query) + subnets = { + 'subnet-12345678' => OpenStruct.new({ + :subnets => [ + OpenStruct.new({ + availability_zone: "us-east-1", + available_ip_address_count: 251, + cidr_block: "10.0.1.0/24", + default_for_az: true, + map_public_ip_on_launch: true, + state: "available", + subnet_id: "subnet-12345678", + vpc_id: "vpc-12345678", + ipv_6_cidr_block_association_set: [], + assign_ipv_6_address_on_creation: true, + }), + ] + }), + 'subnet-87654321' => OpenStruct.new({ + :subnets => [ + OpenStruct.new({ + availability_zone: "us-east-1", + available_ip_address_count: 251, + cidr_block: "10.0.1.0/24", + default_for_az: false, + map_public_ip_on_launch: false, + state: "pending", + subnet_id: "subnet-87654321", + vpc_id: "vpc-87654321", + ipv_6_cidr_block_association_set: [], + assign_ipv_6_address_on_creation: false, + }), + ] + }), + 'empty' => OpenStruct.new({ + :subnets => [] + }) + } + + return subnets[query[:filters][0][:values][0]] unless subnets[query[:filters][0][:values][0]].nil? + subnets['empty'] + end + end +end From 046b2ef419d010f0e7be0c4aad75306f0021285c Mon Sep 17 00:00:00 2001 From: Miah Johnson Date: Wed, 7 Feb 2018 07:48:55 -0800 Subject: [PATCH 173/206] Skeletal Resource: aws_route_table (#217) Signed-off-by: Miah Johnson --- docs/resources/aws_route_table.md | 44 ++++++++++++ libraries/aws_route_table.rb | 59 +++++++++++++++ test/integration/default/build/route_table.tf | 48 +++++++++++++ .../verify/controls/aws_route_table.rb | 33 +++++++++ test/unit/resources/aws_route_table_test.rb | 71 +++++++++++++++++++ 5 files changed, 255 insertions(+) create mode 100644 docs/resources/aws_route_table.md create mode 100644 libraries/aws_route_table.rb create mode 100644 test/integration/default/build/route_table.tf create mode 100644 test/integration/default/verify/controls/aws_route_table.rb create mode 100644 test/unit/resources/aws_route_table_test.rb diff --git a/docs/resources/aws_route_table.md b/docs/resources/aws_route_table.md new file mode 100644 index 000000000..4fa2c553b --- /dev/null +++ b/docs/resources/aws_route_table.md @@ -0,0 +1,44 @@ +--- +title: About the aws_route_table Resource +--- + +# aws_route_table + +Use the `aws_route_table` InSpec audit resource to test properties of a single Route Table. A route table contains a set of rules, called routes, that are used to determine where network traffic is directed. + +
+ +## Syntax + + # Ensure that a certain route table exists by name + describe aws_route_table('rtb-123abcde') do + it { should exist } + end + +## Resource Parameters + +### route_table_id + +This resource expects a single parameter that uniquely identifes the Route Table. You may pass it as a string, or as the value in a hash: + + describe aws_route_table('rtb-123abcde') do + it { should exist } + end + # Same + describe aws_route_table(route_table_id: 'rtb-123abcde') do + it { should exist } + end + +## Matchers + +### exist + +Indicates that the Route Table provided was found. Use should_not to test for Route Tables that should not exist. + + describe aws_route_table('should-be-there') do + it { should exist } + end + + describe aws_route_table('should-not-be-there') do + it { should_not exist } + end diff --git a/libraries/aws_route_table.rb b/libraries/aws_route_table.rb new file mode 100644 index 000000000..4f96d3f0d --- /dev/null +++ b/libraries/aws_route_table.rb @@ -0,0 +1,59 @@ +class AwsRouteTable < Inspec.resource(1) + name 'aws_route_table' + desc 'Verifies settings for an AWS Route Table' + example " + describe aws_route_table do + its('route_table_id') { should cmp 'rtb-2c60ec44' } + end + " + + include AwsResourceMixin + + def to_s + "Route Table #{@route_table_id}" + end + + attr_reader :route_table_id, :vpc_id + + private + + def validate_params(raw_params) + validated_params = check_resource_param_names( + raw_params: raw_params, + allowed_params: [:route_table_id], + allowed_scalar_name: :route_table_id, + allowed_scalar_type: String, + ) + + if validated_params.key?(:route_table_id) && validated_params[:route_table_id] !~ /^rtb\-[0-9a-f]{8}/ + raise ArgumentError, 'aws_route_table Route Table ID must be in the' \ + ' format "rtb-" followed by 8 hexadecimal characters.' + end + + validated_params + end + + def fetch_from_aws + backend = AwsRouteTable::BackendFactory.create + + if @route_table_id.nil? + args = nil + else + args = { filters: [{ name: 'route-table-id', values: [@route_table_id] }] } + end + + resp = backend.describe_route_tables(args) + routetable = resp.to_h[:route_tables] + @exists = !routetable.empty? + end + + class Backend + class AwsClientApi + BackendFactory.set_default_backend(self) + + def describe_route_tables(query) + AWSConnection.new.ec2_client.describe_route_tables(query) + end + end + end +end diff --git a/test/integration/default/build/route_table.tf b/test/integration/default/build/route_table.tf new file mode 100644 index 000000000..2ac9977e3 --- /dev/null +++ b/test/integration/default/build/route_table.tf @@ -0,0 +1,48 @@ +#===========================================================================# +# Route Tables +#===========================================================================# + + +data "aws_internet_gateway" "default" { + filter { + name = "attachment.vpc-id" + values = ["${data.aws_vpc.default.id}"] + } +} + +resource "aws_route_table" "rtb" { + vpc_id = "${data.aws_vpc.default.id}" + + route { + cidr_block = "172.32.1.0/24" + gateway_id = "${data.aws_internet_gateway.default.id}" + } + + tags { + Name = "InSpec" + } +} + +output "routetable_rtb_route_table_id" { + value = "${aws_route_table.rtb.id}" +} + +output "routetable_rtb_associations" { + value = "${aws_route_table.rtb.associations}" +} + +output "routetable_rtb_propagating_vgws" { + value = "${aws_route_table.rtb.propagating_vgws}" +} + +output "routetable_rtb_routes" { + value = "${aws_route_table.rtb.routes}" +} + +output "routetable_rtb_tags" { + value = "${aws_route_table.rtb.tags}" +} + +output "routetable_rtb_vpc_id" { + value = "${aws_route_table.rtb.vpc_id}" +} diff --git a/test/integration/default/verify/controls/aws_route_table.rb b/test/integration/default/verify/controls/aws_route_table.rb new file mode 100644 index 000000000..900cf7a62 --- /dev/null +++ b/test/integration/default/verify/controls/aws_route_table.rb @@ -0,0 +1,33 @@ +fixtures = {} +[ + 'routetable_rtb_route_table_id', + 'routetable_rtb_vpc_id', +].each do |fixture_name| + fixtures[fixture_name] = attribute( + fixture_name, + default: "default.#{fixture_name}", + description: 'See ../build/route_table.tf', + ) +end + +control "aws_route_table exists" do + describe aws_route_table do + it { should exist } + end +end + +control "aws_route_table recall" do + describe aws_route_table(fixtures['routetable_rtb_route_table_id']) do + it { should exist} + end +end + +control "aws_route_tables dont exist" do + describe aws_route_table('rtb-123abcde') do + it { should_not exist } + end + + describe aws_route_table(route_table_id: 'rtb-123abcde') do + it { should_not exist } + end +end diff --git a/test/unit/resources/aws_route_table_test.rb b/test/unit/resources/aws_route_table_test.rb new file mode 100644 index 000000000..edd8b12d5 --- /dev/null +++ b/test/unit/resources/aws_route_table_test.rb @@ -0,0 +1,71 @@ +require 'helper' +require 'aws_route_table' + +class EmptyAwsRouteTableTest < Minitest::Test + def setup + AwsRouteTable::BackendFactory.select(AwsMRtbB::Empty) + end + + def test_search_hit_via_scalar_works + assert_empty AwsRouteTable.new[:routetables] + end + + def test_search_hit_via_scalar_works + refute AwsRouteTable.new('rtb-123abcde').exists? + end +end + +class BasicAwsRouteTableTest2 < Minitest::Test + def setup + AwsRouteTable::BackendFactory.select(AwsMRtbB::Basic) + end + + def test_search_hit + assert AwsRouteTable.new('rtb-2c60ec44').exists? + assert AwsRouteTable.new('rtb-58508630').exists? + end +end + +# MRtbB = Mock Routetable Backend +module AwsMRtbB + class Empty < AwsRouteTable::Backend + def describe_route_tables(query) + OpenStruct.new(route_tables: []) + end + end + + class Basic < AwsRouteTable::Backend + def describe_route_tables(query) + fixtures = [ + OpenStruct.new({associations: [], + propagating_vgws: [], + route_table_id: 'rtb-2c60ec44', + routes: [ + {destination_cidr_block: '172.32.1.0/24', gateway_id: 'igw-4fb9e626', origin: 'CreateRoute', state: 'active'}, + {destination_cidr_block: '172.31.0.0/16', gateway_id: 'local', origin: 'CreateRouteTable', state: 'active'} + ], + tags: [{key: 'Name', value: 'InSpec'}], + vpc_id: 'vpc-169f777e' + }), + OpenStruct.new({associations: [], + propagating_vgws: [], + route_table_id: 'rtb-58508630', + routes: [ + {destination_cidr_block: '172.33.0.0/16', gateway_id: 'local', origin: 'CreateRouteTable', state: 'active'}, + {destination_cidr_block: '0.0.0.0/0', gateway_id: 'igw-4fb9e626', origin: 'CreateRoute', state: 'active'} + ], + tags: [{key: 'Name', value: 'InSpec'}], + vpc_id: 'vpc-169f777e' + }) + ] + + selected = fixtures.select do |rtb| + query[:filters].all? do |filter| + filter[:values].include?(rtb[filter[:name].tr('-','_')]) + end + end + + OpenStruct.new({ route_tables: selected }) + end + end +end From 0ca012891bf24046e323e9ba7969b10cfd5b5438 Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Wed, 7 Feb 2018 12:09:08 -0500 Subject: [PATCH 174/206] Rely on unit test helper to load resources, not individual AWS tests Signed-off-by: Clinton Wolfe --- test/unit/helper.rb | 3 +-- test/unit/resources/aws_cloudtrail_trail_test.rb | 1 - test/unit/resources/aws_cloudtrail_trails_test.rb | 1 - test/unit/resources/aws_cloudwatch_alarm_test.rb | 2 -- test/unit/resources/aws_cloudwatch_log_metric_filter_test.rb | 2 -- test/unit/resources/aws_ec2_instance_test.rb | 1 - test/unit/resources/aws_ec2_security_group_test.rb | 2 -- test/unit/resources/aws_ec2_security_groups_test.rb | 2 -- test/unit/resources/aws_iam_access_key_test.rb | 2 -- test/unit/resources/aws_iam_access_keys_test.rb | 3 --- test/unit/resources/aws_iam_group_test.rb | 2 -- test/unit/resources/aws_iam_groups_test.rb | 2 -- test/unit/resources/aws_iam_password_policy_test.rb | 1 - test/unit/resources/aws_iam_policies_test.rb | 1 - test/unit/resources/aws_iam_policy_test.rb | 1 - test/unit/resources/aws_iam_role_test.rb | 1 - test/unit/resources/aws_iam_root_user_test.rb | 1 - test/unit/resources/aws_iam_user_test.rb | 1 - test/unit/resources/aws_iam_users_test.rb | 2 -- test/unit/resources/aws_kms_keys_test.rb | 1 - test/unit/resources/aws_s3_bucket_test.rb | 1 - test/unit/resources/aws_sns_topic_test.rb | 1 - test/unit/resources/aws_vpc_test.rb | 1 - test/unit/resources/aws_vpcs_test.rb | 1 - 24 files changed, 1 insertion(+), 35 deletions(-) diff --git a/test/unit/helper.rb b/test/unit/helper.rb index 5aa8a7a20..2342fcc01 100644 --- a/test/unit/helper.rb +++ b/test/unit/helper.rb @@ -6,5 +6,4 @@ require 'minitest/pride' require 'json' require 'ostruct' -require 'inspec/resource' -require_relative '../../libraries/_aws' +require_relative 'lib/resource_support/aws' diff --git a/test/unit/resources/aws_cloudtrail_trail_test.rb b/test/unit/resources/aws_cloudtrail_trail_test.rb index ea373279b..6ffe45074 100644 --- a/test/unit/resources/aws_cloudtrail_trail_test.rb +++ b/test/unit/resources/aws_cloudtrail_trail_test.rb @@ -1,5 +1,4 @@ require 'helper' -require 'aws_cloudtrail_trail' # MACTTSB = MockAwsCloudTrailTrailSingularBackend # Abbreviation not used outside this file diff --git a/test/unit/resources/aws_cloudtrail_trails_test.rb b/test/unit/resources/aws_cloudtrail_trails_test.rb index e4fcc5df9..2c447a26e 100644 --- a/test/unit/resources/aws_cloudtrail_trails_test.rb +++ b/test/unit/resources/aws_cloudtrail_trails_test.rb @@ -1,5 +1,4 @@ require 'helper' -require 'aws_cloudtrail_trails' # MACTTPB = MockAwsCloudTrailTrailsPluralBackend # Abbreviation not used outside this file diff --git a/test/unit/resources/aws_cloudwatch_alarm_test.rb b/test/unit/resources/aws_cloudwatch_alarm_test.rb index 947bb2836..f27f98bbc 100644 --- a/test/unit/resources/aws_cloudwatch_alarm_test.rb +++ b/test/unit/resources/aws_cloudwatch_alarm_test.rb @@ -1,6 +1,4 @@ -require 'ostruct' require 'helper' -require 'aws_cloudwatch_alarm' # MCWAB = MockCloudwatchAlarmBackend # Abbreviation not used outside this file diff --git a/test/unit/resources/aws_cloudwatch_log_metric_filter_test.rb b/test/unit/resources/aws_cloudwatch_log_metric_filter_test.rb index dbe5d2f71..fd3e78eb8 100644 --- a/test/unit/resources/aws_cloudwatch_log_metric_filter_test.rb +++ b/test/unit/resources/aws_cloudwatch_log_metric_filter_test.rb @@ -1,6 +1,4 @@ -require 'ostruct' require 'helper' -require 'aws_cloudwatch_log_metric_filter' # CWLMF = CloudwatchLogMetricFilter # Abbreviation not used outside this file diff --git a/test/unit/resources/aws_ec2_instance_test.rb b/test/unit/resources/aws_ec2_instance_test.rb index 70b0ecfaf..5cc65069e 100644 --- a/test/unit/resources/aws_ec2_instance_test.rb +++ b/test/unit/resources/aws_ec2_instance_test.rb @@ -1,5 +1,4 @@ require 'helper' -require 'aws_ec2_instance' class TestEc2 < Minitest::Test Id = 'instance-id'.freeze diff --git a/test/unit/resources/aws_ec2_security_group_test.rb b/test/unit/resources/aws_ec2_security_group_test.rb index 2f9b01bad..61130c5b3 100644 --- a/test/unit/resources/aws_ec2_security_group_test.rb +++ b/test/unit/resources/aws_ec2_security_group_test.rb @@ -1,6 +1,4 @@ -require 'ostruct' require 'helper' -require 'aws_ec2_security_group' # MESGSB = MockEc2SecurityGroupSingleBackend # Abbreviation not used outside this file diff --git a/test/unit/resources/aws_ec2_security_groups_test.rb b/test/unit/resources/aws_ec2_security_groups_test.rb index c977aca6f..30b8728a3 100644 --- a/test/unit/resources/aws_ec2_security_groups_test.rb +++ b/test/unit/resources/aws_ec2_security_groups_test.rb @@ -1,6 +1,4 @@ -require 'ostruct' require 'helper' -require 'aws_ec2_security_groups' # MESGB = MockEc2SecurityGroupBackend # Abbreviation not used outside this file diff --git a/test/unit/resources/aws_iam_access_key_test.rb b/test/unit/resources/aws_iam_access_key_test.rb index b07f4c761..c05f42a11 100644 --- a/test/unit/resources/aws_iam_access_key_test.rb +++ b/test/unit/resources/aws_iam_access_key_test.rb @@ -1,7 +1,5 @@ # author: Chris Redekop - require 'helper' -require 'aws_iam_access_key' class AwsIamAccessKeyTest < Minitest::Test Username = 'test'.freeze diff --git a/test/unit/resources/aws_iam_access_keys_test.rb b/test/unit/resources/aws_iam_access_keys_test.rb index f087f0ca8..a443aa21a 100644 --- a/test/unit/resources/aws_iam_access_keys_test.rb +++ b/test/unit/resources/aws_iam_access_keys_test.rb @@ -1,7 +1,4 @@ - -require 'aws-sdk' require 'helper' -require 'aws_iam_access_keys' #==========================================================# # Constructor Tests # diff --git a/test/unit/resources/aws_iam_group_test.rb b/test/unit/resources/aws_iam_group_test.rb index 424f339ef..a387c57be 100644 --- a/test/unit/resources/aws_iam_group_test.rb +++ b/test/unit/resources/aws_iam_group_test.rb @@ -1,6 +1,4 @@ require 'helper' -require 'aws_iam_group' -require 'date' # MAIGSB = MockAwsIamGroupSingularBackend # Abbreviation not used outside this file diff --git a/test/unit/resources/aws_iam_groups_test.rb b/test/unit/resources/aws_iam_groups_test.rb index f79d2c2cd..8ae47c58a 100644 --- a/test/unit/resources/aws_iam_groups_test.rb +++ b/test/unit/resources/aws_iam_groups_test.rb @@ -1,6 +1,4 @@ require 'helper' -require 'aws_iam_groups' -require 'date' # MAIGPB = MockAwsIamGroupsPluralBackend # Abbreviation not used outside this file diff --git a/test/unit/resources/aws_iam_password_policy_test.rb b/test/unit/resources/aws_iam_password_policy_test.rb index 37e9dec6e..35aedb7c9 100644 --- a/test/unit/resources/aws_iam_password_policy_test.rb +++ b/test/unit/resources/aws_iam_password_policy_test.rb @@ -1,5 +1,4 @@ require 'helper' -require 'aws_iam_password_policy' class AwsIamPasswordPolicyTest < Minitest::Test def setup diff --git a/test/unit/resources/aws_iam_policies_test.rb b/test/unit/resources/aws_iam_policies_test.rb index e4563f767..a4fef32fa 100644 --- a/test/unit/resources/aws_iam_policies_test.rb +++ b/test/unit/resources/aws_iam_policies_test.rb @@ -1,5 +1,4 @@ require 'helper' -require 'aws_iam_policies' # MAIPPB = MockAwsIamPoliciesPluralBackend # Abbreviation not used outside this file diff --git a/test/unit/resources/aws_iam_policy_test.rb b/test/unit/resources/aws_iam_policy_test.rb index 164e19f03..7f90c1c50 100644 --- a/test/unit/resources/aws_iam_policy_test.rb +++ b/test/unit/resources/aws_iam_policy_test.rb @@ -1,5 +1,4 @@ require 'helper' -require 'aws_iam_policy' # MAIPSB = MockAwsIamPolicySingularBackend # Abbreviation not used outside this file diff --git a/test/unit/resources/aws_iam_role_test.rb b/test/unit/resources/aws_iam_role_test.rb index 418328dc4..c5e64b56b 100644 --- a/test/unit/resources/aws_iam_role_test.rb +++ b/test/unit/resources/aws_iam_role_test.rb @@ -1,5 +1,4 @@ require 'helper' -require 'aws_iam_role' # MIRB = MockIamRoleBackend # Abbreviation not used outside this file diff --git a/test/unit/resources/aws_iam_root_user_test.rb b/test/unit/resources/aws_iam_root_user_test.rb index a616e1549..6af024057 100644 --- a/test/unit/resources/aws_iam_root_user_test.rb +++ b/test/unit/resources/aws_iam_root_user_test.rb @@ -1,6 +1,5 @@ # author: Miles Tjandrawidjaja require 'helper' -require 'aws_iam_root_user' class AwsIamRootUserTest < Minitest::Test def setup diff --git a/test/unit/resources/aws_iam_user_test.rb b/test/unit/resources/aws_iam_user_test.rb index 3585b8824..93bd31956 100644 --- a/test/unit/resources/aws_iam_user_test.rb +++ b/test/unit/resources/aws_iam_user_test.rb @@ -1,6 +1,5 @@ # author: Simon Varlow require 'helper' -require 'aws_iam_user' # MAUIB = MockAwsIamUserBackend # Abbreviation not used outside this file diff --git a/test/unit/resources/aws_iam_users_test.rb b/test/unit/resources/aws_iam_users_test.rb index c60e72dc6..9c5084be7 100644 --- a/test/unit/resources/aws_iam_users_test.rb +++ b/test/unit/resources/aws_iam_users_test.rb @@ -1,6 +1,4 @@ require 'helper' -require 'ostruct' -require 'aws_iam_users' # Maiusb = Mock AwsIamUsers::Backend # Abbreviation not used outside of this file diff --git a/test/unit/resources/aws_kms_keys_test.rb b/test/unit/resources/aws_kms_keys_test.rb index 9f7217d83..12b1c4cbe 100644 --- a/test/unit/resources/aws_kms_keys_test.rb +++ b/test/unit/resources/aws_kms_keys_test.rb @@ -1,5 +1,4 @@ require 'helper' -require 'aws_kms_keys' # MAKKPB = MockAwsKmsKeysPluralBackend # Abbreviation not used outside this file diff --git a/test/unit/resources/aws_s3_bucket_test.rb b/test/unit/resources/aws_s3_bucket_test.rb index b92cfd38a..8a7974c3f 100644 --- a/test/unit/resources/aws_s3_bucket_test.rb +++ b/test/unit/resources/aws_s3_bucket_test.rb @@ -1,6 +1,5 @@ # encoding: utf-8 require 'helper' -require 'aws_s3_bucket' # MSBSB = MockS3BucketSingleBackend # Abbreviation not used outside this file diff --git a/test/unit/resources/aws_sns_topic_test.rb b/test/unit/resources/aws_sns_topic_test.rb index 3bb2c455e..e11ae97ed 100644 --- a/test/unit/resources/aws_sns_topic_test.rb +++ b/test/unit/resources/aws_sns_topic_test.rb @@ -1,5 +1,4 @@ require 'helper' -require 'aws_sns_topic' # MSNB = MockSnsBackend # Abbreviation not used outside this file diff --git a/test/unit/resources/aws_vpc_test.rb b/test/unit/resources/aws_vpc_test.rb index 8a067b054..2e2e399b7 100644 --- a/test/unit/resources/aws_vpc_test.rb +++ b/test/unit/resources/aws_vpc_test.rb @@ -1,5 +1,4 @@ require 'helper' -require 'aws_vpc' # MAVSB = MockAwsVpcSingularBackend # Abbreviation not used outside this file diff --git a/test/unit/resources/aws_vpcs_test.rb b/test/unit/resources/aws_vpcs_test.rb index bd3786f6e..bb5494fe3 100644 --- a/test/unit/resources/aws_vpcs_test.rb +++ b/test/unit/resources/aws_vpcs_test.rb @@ -1,5 +1,4 @@ require 'helper' -require 'aws_vpcs' # MAVPB = MockAwsVpcsPluralBackend # Abbreviation not used outside this file From 67713f99e4b1f0f6e72116e8fcc5c745a5d75ced Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Wed, 7 Feb 2018 12:10:07 -0500 Subject: [PATCH 175/206] Rely on core resource loader to load AWS support facilities Signed-off-by: Clinton Wolfe --- lib/resources/aws/aws_cloudtrail_trails.rb | 1 + lib/resources/aws/aws_cloudwatch_alarm.rb | 2 -- lib/resources/aws/aws_cloudwatch_log_metric_filter.rb | 2 -- lib/resources/aws/aws_ec2_instance.rb | 2 -- lib/resources/aws/aws_ec2_security_group.rb | 2 -- lib/resources/aws/aws_ec2_security_groups.rb | 2 -- lib/resources/aws/aws_iam_access_key.rb | 2 -- lib/resources/aws/aws_iam_access_keys.rb | 2 -- lib/resources/aws/aws_iam_group.rb | 2 -- lib/resources/aws/aws_iam_password_policy.rb | 2 -- lib/resources/aws/aws_iam_role.rb | 2 -- lib/resources/aws/aws_iam_root_user.rb | 2 -- lib/resources/aws/aws_iam_user.rb | 2 -- lib/resources/aws/aws_iam_users.rb | 2 -- lib/resources/aws/aws_s3_bucket.rb | 2 -- lib/resources/aws/aws_sns_topic.rb | 2 -- lib/resources/aws/aws_vpc.rb | 2 -- lib/resources/aws/aws_vpcs.rb | 2 -- 18 files changed, 1 insertion(+), 34 deletions(-) diff --git a/lib/resources/aws/aws_cloudtrail_trails.rb b/lib/resources/aws/aws_cloudtrail_trails.rb index 97cb9cb24..af430fbff 100644 --- a/lib/resources/aws/aws_cloudtrail_trails.rb +++ b/lib/resources/aws/aws_cloudtrail_trails.rb @@ -42,3 +42,4 @@ class AwsCloudTrailTrails < Inspec.resource(1) end end end + diff --git a/lib/resources/aws/aws_cloudwatch_alarm.rb b/lib/resources/aws/aws_cloudwatch_alarm.rb index 360710761..25f0a9905 100644 --- a/lib/resources/aws/aws_cloudwatch_alarm.rb +++ b/lib/resources/aws/aws_cloudwatch_alarm.rb @@ -1,5 +1,3 @@ -require '_aws' - class AwsCloudwatchAlarm < Inspec.resource(1) name 'aws_cloudwatch_alarm' desc <<-EOD diff --git a/lib/resources/aws/aws_cloudwatch_log_metric_filter.rb b/lib/resources/aws/aws_cloudwatch_log_metric_filter.rb index 55fa1e096..bb1a31678 100644 --- a/lib/resources/aws/aws_cloudwatch_log_metric_filter.rb +++ b/lib/resources/aws/aws_cloudwatch_log_metric_filter.rb @@ -1,5 +1,3 @@ -require '_aws' - class AwsCloudwatchLogMetricFilter < Inspec.resource(1) name 'aws_cloudwatch_log_metric_filter' desc 'Verifies individual Cloudwatch Log Metric Filters' diff --git a/lib/resources/aws/aws_ec2_instance.rb b/lib/resources/aws/aws_ec2_instance.rb index 9ae09bc7f..ff473a982 100644 --- a/lib/resources/aws/aws_ec2_instance.rb +++ b/lib/resources/aws/aws_ec2_instance.rb @@ -1,5 +1,3 @@ -require '_aws' - # author: Christoph Hartmann class AwsEc2Instance < Inspec.resource(1) name 'aws_ec2_instance' diff --git a/lib/resources/aws/aws_ec2_security_group.rb b/lib/resources/aws/aws_ec2_security_group.rb index d03113d88..5bf8c4803 100644 --- a/lib/resources/aws/aws_ec2_security_group.rb +++ b/lib/resources/aws/aws_ec2_security_group.rb @@ -1,5 +1,3 @@ -require '_aws' - class AwsEc2SecurityGroup < Inspec.resource(1) name 'aws_ec2_security_group' desc 'Verifies settings for an individual AWS Security Group.' diff --git a/lib/resources/aws/aws_ec2_security_groups.rb b/lib/resources/aws/aws_ec2_security_groups.rb index 4200153e1..98daee437 100644 --- a/lib/resources/aws/aws_ec2_security_groups.rb +++ b/lib/resources/aws/aws_ec2_security_groups.rb @@ -1,5 +1,3 @@ -require '_aws' - class AwsEc2SecurityGroups < Inspec.resource(1) name 'aws_ec2_security_groups' desc 'Verifies settings for AWS Security Groups in bulk' diff --git a/lib/resources/aws/aws_iam_access_key.rb b/lib/resources/aws/aws_iam_access_key.rb index 164cb9ec8..5d7276cf8 100644 --- a/lib/resources/aws/aws_iam_access_key.rb +++ b/lib/resources/aws/aws_iam_access_key.rb @@ -1,5 +1,3 @@ -require '_aws' - # author: Chris Redekop class AwsIamAccessKey < Inspec.resource(1) name 'aws_iam_access_key' diff --git a/lib/resources/aws/aws_iam_access_keys.rb b/lib/resources/aws/aws_iam_access_keys.rb index e2224f8f2..2d332cc9f 100644 --- a/lib/resources/aws/aws_iam_access_keys.rb +++ b/lib/resources/aws/aws_iam_access_keys.rb @@ -1,5 +1,3 @@ -require '_aws' - class AwsIamAccessKeys < Inspec.resource(1) name 'aws_iam_access_keys' desc 'Verifies settings for AWS IAM Access Keys in bulk' diff --git a/lib/resources/aws/aws_iam_group.rb b/lib/resources/aws/aws_iam_group.rb index 2b4215ae3..3573baf92 100644 --- a/lib/resources/aws/aws_iam_group.rb +++ b/lib/resources/aws/aws_iam_group.rb @@ -1,5 +1,3 @@ -require '_aws' - class AwsIamGroup < Inspec.resource(1) name 'aws_iam_group' desc 'Verifies settings for AWS IAM Group' diff --git a/lib/resources/aws/aws_iam_password_policy.rb b/lib/resources/aws/aws_iam_password_policy.rb index bd1c7717a..9c6e22f5f 100644 --- a/lib/resources/aws/aws_iam_password_policy.rb +++ b/lib/resources/aws/aws_iam_password_policy.rb @@ -1,5 +1,3 @@ -require '_aws' - # author: Viktor Yakovlyev class AwsIamPasswordPolicy < Inspec.resource(1) name 'aws_iam_password_policy' diff --git a/lib/resources/aws/aws_iam_role.rb b/lib/resources/aws/aws_iam_role.rb index ea11b88cf..a34724fbd 100644 --- a/lib/resources/aws/aws_iam_role.rb +++ b/lib/resources/aws/aws_iam_role.rb @@ -1,5 +1,3 @@ -require '_aws' - class AwsIamRole < Inspec.resource(1) name 'aws_iam_role' desc 'Verifies settings for an IAM Role' diff --git a/lib/resources/aws/aws_iam_root_user.rb b/lib/resources/aws/aws_iam_root_user.rb index 89a9fae35..e61e194e4 100644 --- a/lib/resources/aws/aws_iam_root_user.rb +++ b/lib/resources/aws/aws_iam_root_user.rb @@ -1,5 +1,3 @@ -require '_aws' - # author: Miles Tjandrawidjaja class AwsIamRootUser < Inspec.resource(1) name 'aws_iam_root_user' diff --git a/lib/resources/aws/aws_iam_user.rb b/lib/resources/aws/aws_iam_user.rb index 1d9f3726b..db0cbc77e 100644 --- a/lib/resources/aws/aws_iam_user.rb +++ b/lib/resources/aws/aws_iam_user.rb @@ -1,5 +1,3 @@ -require '_aws' - # author: Alex Bedley # author: Steffanie Freeman # author: Simon Varlow diff --git a/lib/resources/aws/aws_iam_users.rb b/lib/resources/aws/aws_iam_users.rb index 4e3276791..cc5768778 100644 --- a/lib/resources/aws/aws_iam_users.rb +++ b/lib/resources/aws/aws_iam_users.rb @@ -1,5 +1,3 @@ -require '_aws' - # author: Alex Bedley # author: Steffanie Freeman # author: Simon Varlow diff --git a/lib/resources/aws/aws_s3_bucket.rb b/lib/resources/aws/aws_s3_bucket.rb index e8ad98d61..08a59340c 100644 --- a/lib/resources/aws/aws_s3_bucket.rb +++ b/lib/resources/aws/aws_s3_bucket.rb @@ -1,5 +1,3 @@ -require '_aws' - # author: Matthew Dromazos class AwsS3Bucket < Inspec.resource(1) name 'aws_s3_bucket' diff --git a/lib/resources/aws/aws_sns_topic.rb b/lib/resources/aws/aws_sns_topic.rb index d9c790a0c..3f305fdeb 100644 --- a/lib/resources/aws/aws_sns_topic.rb +++ b/lib/resources/aws/aws_sns_topic.rb @@ -1,5 +1,3 @@ -require '_aws' - class AwsSnsTopic < Inspec.resource(1) name 'aws_sns_topic' desc 'Verifies settings for an SNS Topic' diff --git a/lib/resources/aws/aws_vpc.rb b/lib/resources/aws/aws_vpc.rb index f8453dc59..3ec01b1c1 100644 --- a/lib/resources/aws/aws_vpc.rb +++ b/lib/resources/aws/aws_vpc.rb @@ -1,5 +1,3 @@ -require '_aws' - class AwsVpc < Inspec.resource(1) name 'aws_vpc' desc 'Verifies settings for AWS VPC' diff --git a/lib/resources/aws/aws_vpcs.rb b/lib/resources/aws/aws_vpcs.rb index a9891be4f..45da8dc7f 100644 --- a/lib/resources/aws/aws_vpcs.rb +++ b/lib/resources/aws/aws_vpcs.rb @@ -1,5 +1,3 @@ -require '_aws' - class AwsVpcs < Inspec.resource(1) name 'aws_vpcs' desc 'Verifies settings for AWS VPCs in bulk' From d15a21b85f29d66500283d7ec110805056a17c0f Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Wed, 7 Feb 2018 12:11:10 -0500 Subject: [PATCH 176/206] Use dedicated AWS resource loading file. Always load FilterTable. Signed-off-by: Clinton Wolfe --- lib/inspec/resource.rb | 6 ++++++ lib/resource_support/aws.rb | 35 +++++++++++++++++++++++++++++++++ lib/resource_support/aws/aws.rb | 7 ------- 3 files changed, 41 insertions(+), 7 deletions(-) create mode 100644 lib/resource_support/aws.rb delete mode 100644 lib/resource_support/aws/aws.rb diff --git a/lib/inspec/resource.rb b/lib/inspec/resource.rb index 1ca2753e8..ea9af97b4 100644 --- a/lib/inspec/resource.rb +++ b/lib/inspec/resource.rb @@ -74,6 +74,12 @@ module Inspec end end +# Many resources use FilterTable. +require 'utils/filter' + +# AWS resources are included via their own file. +require 'resource_support/aws' + require 'resources/aide_conf' require 'resources/apache' require 'resources/apache_conf' diff --git a/lib/resource_support/aws.rb b/lib/resource_support/aws.rb new file mode 100644 index 000000000..4a8c47ee7 --- /dev/null +++ b/lib/resource_support/aws.rb @@ -0,0 +1,35 @@ +# Main AWS loader file. The intent is for this to be +# loaded only if AWS resources are needed. + +require 'aws-sdk' # TODO: split once ADK v3 is in use + +require 'resource_support/aws/aws_backend_factory_mixin' +require 'resource_support/aws/aws_resource_mixin' +require 'resource_support/aws/aws_connection' + +# Load all AWS resources +# TODO: loop over and load entire directory +# for f in ls lib/resources/aws/*; do t=$(echo $f | cut -c 5- | cut -f1 -d. ); echo "require '${t}'"; done +require 'resources/aws/aws_cloudtrail_trail' +require 'resources/aws/aws_cloudtrail_trails' +require 'resources/aws/aws_cloudwatch_alarm' +require 'resources/aws/aws_cloudwatch_log_metric_filter' +require 'resources/aws/aws_ec2_instance' +require 'resources/aws/aws_ec2_security_group' +require 'resources/aws/aws_ec2_security_groups' +require 'resources/aws/aws_iam_access_key' +require 'resources/aws/aws_iam_access_keys' +require 'resources/aws/aws_iam_group' +require 'resources/aws/aws_iam_groups' +require 'resources/aws/aws_iam_password_policy' +require 'resources/aws/aws_iam_policies' +require 'resources/aws/aws_iam_policy' +require 'resources/aws/aws_iam_role' +require 'resources/aws/aws_iam_root_user' +require 'resources/aws/aws_iam_user' +require 'resources/aws/aws_iam_users' +require 'resources/aws/aws_kms_keys' +require 'resources/aws/aws_s3_bucket' +require 'resources/aws/aws_sns_topic' +require 'resources/aws/aws_vpc' +require 'resources/aws/aws_vpcs' \ No newline at end of file diff --git a/lib/resource_support/aws/aws.rb b/lib/resource_support/aws/aws.rb deleted file mode 100644 index 5adece020..000000000 --- a/lib/resource_support/aws/aws.rb +++ /dev/null @@ -1,7 +0,0 @@ -# Main AWS loader file. The intent is for this to be -# loaded only if AWS resources are needed. - -require 'aws-sdk' # TODO: split once ADK v3 is in use -require '_aws_backend_factory_mixin' -require '_aws_resource_mixin' -require '_aws_connection' From aec79551ffbcd20cbaa4e1531bd6aa114841ae68 Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Wed, 7 Feb 2018 12:29:01 -0500 Subject: [PATCH 177/206] Rubocop Signed-off-by: Clinton Wolfe --- lib/resource_support/aws.rb | 2 +- lib/resources/aws/aws_cloudtrail_trails.rb | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/resource_support/aws.rb b/lib/resource_support/aws.rb index 4a8c47ee7..3f59705be 100644 --- a/lib/resource_support/aws.rb +++ b/lib/resource_support/aws.rb @@ -32,4 +32,4 @@ require 'resources/aws/aws_kms_keys' require 'resources/aws/aws_s3_bucket' require 'resources/aws/aws_sns_topic' require 'resources/aws/aws_vpc' -require 'resources/aws/aws_vpcs' \ No newline at end of file +require 'resources/aws/aws_vpcs' diff --git a/lib/resources/aws/aws_cloudtrail_trails.rb b/lib/resources/aws/aws_cloudtrail_trails.rb index af430fbff..97cb9cb24 100644 --- a/lib/resources/aws/aws_cloudtrail_trails.rb +++ b/lib/resources/aws/aws_cloudtrail_trails.rb @@ -42,4 +42,3 @@ class AwsCloudTrailTrails < Inspec.resource(1) end end end - From 7bea049f057c7b432700fc0e3eff39340c19e879 Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Wed, 7 Feb 2018 12:29:27 -0500 Subject: [PATCH 178/206] Treat integration tests as core, not relying on a resource pack Signed-off-by: Clinton Wolfe --- test/aws/default/verify/inspec.yml | 3 --- test/aws/minimal/verify/inspec.yml | 5 +---- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/test/aws/default/verify/inspec.yml b/test/aws/default/verify/inspec.yml index 9b140cc86..4f41268d8 100644 --- a/test/aws/default/verify/inspec.yml +++ b/test/aws/default/verify/inspec.yml @@ -1,4 +1 @@ name: inspec-aws-integration-tests -depends: - - name: aws - path: ../../../../ diff --git a/test/aws/minimal/verify/inspec.yml b/test/aws/minimal/verify/inspec.yml index 9b140cc86..666adc8bc 100644 --- a/test/aws/minimal/verify/inspec.yml +++ b/test/aws/minimal/verify/inspec.yml @@ -1,4 +1 @@ -name: inspec-aws-integration-tests -depends: - - name: aws - path: ../../../../ +name: inspec-aws-integration-tests \ No newline at end of file From 3fd25e2ac3bd9ac4ec20e9eb9e6f89dca50f9c47 Mon Sep 17 00:00:00 2001 From: Jerry Aldrich Date: Thu, 1 Feb 2018 22:34:27 +0000 Subject: [PATCH 179/206] Add `release-2.0` target branch to AppVeyor/Travis (#2510) Signed-off-by: Jerry Aldrich --- .travis.yml | 1 + appveyor.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.travis.yml b/.travis.yml index 3b1da0749..84048e250 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,6 +2,7 @@ sudo: required branches: only: - master + - release-2.0 language: ruby cache: bundler dist: trusty diff --git a/appveyor.yml b/appveyor.yml index 6902d8482..f42a1af97 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -13,6 +13,7 @@ skip_tags: true branches: only: - master + - release-2.0 cache: - vendor/bundle -> appveyor.yml From 42779e91a7c8aea5ba2cb6bf17173e15f4169473 Mon Sep 17 00:00:00 2001 From: Jared Quick Date: Tue, 6 Feb 2018 13:40:21 -0500 Subject: [PATCH 180/206] Setup azure resources into inspec. Signed-off-by: Jared Quick --- Gemfile | 5 +- Rakefile | 55 +++ inspec.gemspec | 2 +- lib/inspec/resource.rb | 5 + lib/resources/azure/azure_backend.rb | 421 +++++----------- lib/resources/azure/azure_generic_resource.rb | 83 ++-- lib/resources/azure/azure_resource_group.rb | 260 +++++----- lib/resources/azure/azure_virtual_machine.rb | 461 +++++++++--------- .../azure/azure_virtual_machine_data_disk.rb | 242 ++++----- lib/resources/platform.rb | 4 +- test/{integration => }/azure/build/azure.tf | 0 .../verify/controls/generic_external_vm.rb | 0 .../controls/generic_external_vm_nic.rb | 0 .../verify/controls/generic_internal_vm.rb | 0 .../controls/generic_internal_vm_nic.rb | 0 .../generic_linux_vm_managed_os_disk.rb | 0 .../generic_network_security_group.rb | 0 .../controls/generic_public_ip_address.rb | 0 .../verify/controls/generic_resources.rb | 0 .../controls/generic_storage_account.rb | 0 .../controls/generic_virtual_network.rb | 0 .../controls/generic_windows_internal_vm.rb | 0 .../generic_windows_internal_vm_nic.rb | 0 .../azure/verify/controls/resource_group.rb | 0 .../controls/virtual_machine_external_vm.rb | 0 .../controls/virtual_machine_internal_vm.rb | 0 ...tual_machine_linux_external_vm_datadisk.rb | 0 .../virtual_machine_windows_internal_vm.rb | 0 ...al_machine_windows_internal_vm_datadisk.rb | 0 test/azure/verify/inspec.yml | 1 + test/integration/azure/verify/inspec.yml | 4 - 31 files changed, 723 insertions(+), 820 deletions(-) rename test/{integration => }/azure/build/azure.tf (100%) rename test/{integration => }/azure/verify/controls/generic_external_vm.rb (100%) rename test/{integration => }/azure/verify/controls/generic_external_vm_nic.rb (100%) rename test/{integration => }/azure/verify/controls/generic_internal_vm.rb (100%) rename test/{integration => }/azure/verify/controls/generic_internal_vm_nic.rb (100%) rename test/{integration => }/azure/verify/controls/generic_linux_vm_managed_os_disk.rb (100%) rename test/{integration => }/azure/verify/controls/generic_network_security_group.rb (100%) rename test/{integration => }/azure/verify/controls/generic_public_ip_address.rb (100%) rename test/{integration => }/azure/verify/controls/generic_resources.rb (100%) rename test/{integration => }/azure/verify/controls/generic_storage_account.rb (100%) rename test/{integration => }/azure/verify/controls/generic_virtual_network.rb (100%) rename test/{integration => }/azure/verify/controls/generic_windows_internal_vm.rb (100%) rename test/{integration => }/azure/verify/controls/generic_windows_internal_vm_nic.rb (100%) rename test/{integration => }/azure/verify/controls/resource_group.rb (100%) rename test/{integration => }/azure/verify/controls/virtual_machine_external_vm.rb (100%) rename test/{integration => }/azure/verify/controls/virtual_machine_internal_vm.rb (100%) rename test/{integration => }/azure/verify/controls/virtual_machine_linux_external_vm_datadisk.rb (100%) rename test/{integration => }/azure/verify/controls/virtual_machine_windows_internal_vm.rb (100%) rename test/{integration => }/azure/verify/controls/virtual_machine_windows_internal_vm_datadisk.rb (100%) create mode 100644 test/azure/verify/inspec.yml delete mode 100644 test/integration/azure/verify/inspec.yml diff --git a/Gemfile b/Gemfile index 564031cea..4b9945d8b 100644 --- a/Gemfile +++ b/Gemfile @@ -2,6 +2,8 @@ source 'https://rubygems.org' gemspec +gem 'train', :git => 'https://github.com/chef/train' + if Gem::Version.new(RUBY_VERSION) < Gem::Version.new('2.2.2') gem 'json', '~> 1.8' gem 'rack', '< 2.0' @@ -15,11 +17,12 @@ group :test do gem 'rake', '>= 10' gem 'rubocop', '= 0.49.1' gem 'simplecov', '~> 0.10' - gem 'concurrent-ruby', '~> 0.9' + gem 'concurrent-ruby', '~> 1.0' gem 'mocha', '~> 1.1' gem 'ruby-progressbar', '~> 1.8' gem 'webmock', '~> 2.3.2' gem 'jsonschema', '~> 2.0.2' + gem 'passgen' gem 'm' end diff --git a/Rakefile b/Rakefile index 89db3224c..63bfb7e61 100644 --- a/Rakefile +++ b/Rakefile @@ -4,6 +4,8 @@ require 'bundler' require 'bundler/gem_tasks' require 'rake/testtask' +require 'passgen' +require 'train' require_relative 'tasks/maintainers' require_relative 'tasks/spdx' @@ -84,6 +86,59 @@ namespace :test do sh('sh', '-c', sh_cmd) end + + namespace :azure do + # Specify the directory for the integration tests + integration_dir = 'test/azure' + + + task :init_workspace do + # Initialize terraform workspace + sh("cd #{integration_dir}/build/ && terraform init") + end + + task :setup_integration_tests do + puts '----> Setup' + creds = Train.create('azure').connection.connect + + # Determine the storage account name and the admin password + sa_name = (0...15).map { (65 + rand(26)).chr }.join.downcase + admin_password = Passgen::generate(length: 12, uppercase: true, lowercase: true, symbols: true, digits: true) + + # Use the first 4 characters of the storage account to create a suffix + suffix = sa_name[0..3] + + # Create the plan that can be applied to Azure + cmd = format("cd %s/build/ && terraform plan -var 'subscription_id=%s' -var 'client_id=%s' -var 'client_secret=%s' -var 'tenant_id=%s' -var 'storage_account_name=%s' -var 'admin_password=%s' -var 'suffix=%s' -out inspec-azure.plan", integration_dir, creds[:subscription_id], creds[:client_id], creds[:client_secret], creds[:tenant_id], sa_name, admin_password, suffix) + sh(cmd) + + # Apply the plan on Azure + cmd = format("cd %s/build/ && terraform apply inspec-azure.plan", integration_dir) + sh(cmd) + end + + task :run_integration_tests do + puts '----> Run' + sh("bundle exec inspec exec #{integration_dir}/verify -t azure://1e0b427a-d58b-494e-ae4f-ee558463ebbf") + end + + task :cleanup_integration_tests do + puts '----> Cleanup' + creds = Train.create('azure').connection.connect + + cmd = format("cd %s/build/ && terraform destroy -force -var 'subscription_id=%s' -var 'client_id=%s' -var 'client_secret=%s' -var 'tenant_id=%s' -var 'admin_password=dummy' -var 'storage_account_name=dummy' -var 'suffix=dummy'", integration_dir, creds[:subscription_id], creds[:client_id], creds[:client_secret], creds[:tenant_id]) + sh(cmd) + end + end + + desc "Perform Azure Integration Tests" + task :azure do + Rake::Task['test:azure:init_workspace'].execute + Rake::Task['test:azure:cleanup_integration_tests'].execute + Rake::Task['test:azure:setup_integration_tests'].execute + Rake::Task['test:azure:run_integration_tests'].execute + Rake::Task['test:azure:cleanup_integration_tests'].execute + end end # Print the current version of this gem or update it. diff --git a/inspec.gemspec b/inspec.gemspec index 49ffc4a65..0fbddd118 100644 --- a/inspec.gemspec +++ b/inspec.gemspec @@ -26,7 +26,7 @@ Gem::Specification.new do |spec| spec.required_ruby_version = '>= 2.3' - spec.add_dependency 'train', '~> 0.32' + # spec.add_dependency 'train', '~> 0.32' spec.add_dependency 'thor', '~> 0.19' spec.add_dependency 'json', '>= 1.8', '< 3.0' spec.add_dependency 'method_source', '~> 0.8' diff --git a/lib/inspec/resource.rb b/lib/inspec/resource.rb index 1ca2753e8..1ad9d9957 100644 --- a/lib/inspec/resource.rb +++ b/lib/inspec/resource.rb @@ -74,6 +74,11 @@ module Inspec end end +require 'resources/azure/azure_backend.rb' +require 'resources/azure/azure_generic_resource.rb' +require 'resources/azure/azure_resource_group.rb' +require 'resources/azure/azure_virtual_machine.rb' +require 'resources/azure/azure_virtual_machine_data_disk.rb' require 'resources/aide_conf' require 'resources/apache' require 'resources/apache_conf' diff --git a/lib/resources/azure/azure_backend.rb b/lib/resources/azure/azure_backend.rb index 665afd8f6..46149ec17 100644 --- a/lib/resources/azure/azure_backend.rb +++ b/lib/resources/azure/azure_backend.rb @@ -1,318 +1,155 @@ -# frozen_string_literal: true - -require 'ms_rest_azure' -require 'azure_mgmt_resources' -require 'inifile' -require 'inspec' - -# Class to manage the connection to Azure to retrieve the information required about the resources -# -# @author Russell Seymour -# -# @attr_reader [String] subscription_id ID of the subscription in which resources are to be tested -class AzureConnection - attr_reader :subscription_id, :apis - - # Constructor which reads in the credentials file - # - # @author Russell Seymour - def initialize - # Ensure that the apis is a hash table - @apis = {} - - # If an INSPEC_AZURE_CREDS environment has been specified set the - # the credentials file to that, otherwise set the one in home - azure_creds_file = ENV['AZURE_CREDS_FILE'] - if azure_creds_file.nil? - - # The environment file has not be set, so default to one in the home directory - azure_creds_file = File.join(Dir.home, '.azure', 'credentials') - end - - # Check to see if the credentials file exists - if File.file?(azure_creds_file) - @credentials = IniFile.load(File.expand_path(azure_creds_file)) - else - @credentials = nil - warn format('%s was not found or not accessible', azure_creds_file) - end - end - - # Connect to Azure using the specified credentials - # - # @author Russell Seymour - def client - # If a connection already exists then return it - return @client if defined?(@client) - - creds = spn - - # Create a new connection - token_provider = MsRestAzure::ApplicationTokenProvider.new(creds[:tenant_id], creds[:client_id], creds[:client_secret]) - token_creds = MsRest::TokenCredentials.new(token_provider) - - # Create the options hash - options = { - credentials: token_creds, - subscription_id: azure_subscription_id, - tenant_id: creds[:tenant_id], - client_id: creds[:client_id], - client_secret: creds[:client_secret], - } - - @client = Azure::Resources::Profiles::Latest::Mgmt::Client.new(options) - end - - # Method to retrieve the SPN credentials - # This is also used by the Rakefile to get the necessary creds to run the tests on the environment - # that has been created - # - # @author Russell Seymour - def spn - @subscription_id = azure_subscription_id - - # Check that the credential exists - unless @credentials.nil? - raise format('The specified Azure Subscription cannot be found in your credentials: %s', subscription_id) unless @credentials.sections.include?(subscription_id) - end - - # Determine the client_id, tenant_id and the client_secret - tenant_id = ENV['AZURE_TENANT_ID'] || @credentials[subscription_id]['tenant_id'] - client_id = ENV['AZURE_CLIENT_ID'] || @credentials[subscription_id]['client_id'] - client_secret = ENV['AZURE_CLIENT_SECRET'] || @credentials[subscription_id]['client_secret'] - - # Return hash of the SPN information - { subscription_id: subscription_id, client_id: client_id, client_secret: client_secret, tenant_id: tenant_id } - end - - # Returns the api version for the specified resource type - # - # If an api version has been specified in the options then the apis version table is updated - # with that value and it is returned - # - # However if it is not specified, or multiple types are being interrogated then this method - # will interrogate Azure for each of the types versions and pick the latest one. This is added - # to the apis table so that it can be retrieved quickly again of another one of those resources - # is encountered again in the resource collection. - # - # @param string resource_type The resource type for which the API is required - # @param hash options Options have that have been passed to the resource during the test. - # @option opts [String] :group_name Resource group name - # @option opts [String] :type Azure resource type - # @option opts [String] :name Name of specific resource to look for - # @option opts [String] :apiversion If looking for a specific item or type specify the api version to use - # - # @return string API Version of the specified resource type - def get_api_version(resource_type, options) - # if an api version has been set in the options, add to the apis hashtable with - # the resource type - if options[:apiversion] - apis[resource_type] = options[:apiversion] - else - # only attempt to get the api version from Azure if the resource type - # is not present in the apis hashtable - unless apis.key?(resource_type) - - # determine the namespace for the resource type - namespace, type = resource_type.split(%r{/}) - - provider = client.providers.get(namespace) - - # get the latest API version for the type - # assuming that this is the first one in the list - api_versions = (provider.resource_types.find { |v| v.resource_type == type }).api_versions - apis[resource_type] = api_versions[0] - - end - end - - # return the api version for the type - apis[resource_type] - end - - private - - # Return the subscrtiption ID to use - # - # @author Russell Seymour - def azure_subscription_id - # If a subscription has been specified as an environment variable use that - # If an index has been specified with AZURE_SUBSCRIPTION_INDEX attempt to use that value - # Otherwise use the first entry in the file - if !ENV['AZURE_SUBSCRIPTION_ID'].nil? - id = ENV['AZURE_SUBSCRIPTION_ID'] - elsif !ENV['AZURE_SUBSCRIPTION_NUMBER'].nil? - - subscription_number = ENV['AZURE_SUBSCRIPTION_NUMBER'].to_i - subscription_index = subscription_number - 1 - - # Check that the specified index is not greater than the number of subscriptions - if subscription_number > @credentials.sections.length - raise format('Your credentials file only contains %s subscriptions. You specified number %s.', @credentials.sections.length, subscription_number) - end - - id = @credentials.sections[subscription_index] - else - id = @credentials.sections[0] - end - - # Return the ID to the calling function - id - end -end - # Base class for Azure Resources. This allows the generic class to work # as well as the specific target resources for Azure Resources # # @author Russell Seymour -class AzureResourceBase < Inspec.resource(1) - attr_reader :opts, :client, :azure +module Inspec::Resources + class AzureResourceBase < Inspec.resource(1) + attr_reader :opts, :client, :azure - # Constructor that retreives the specified resource - # - # The opts hash should contain the following - # :group_name - name of the resource group in which to look for items - # :type - the type of Azure resource to look for - # :apiversion - API version to use when looking for a specific resource - # :name - name of the resource to find - # - # @author Russell Seymour - # - # @param [Hash] opts Hashtable of options as highlighted above - # rubocop:disable Metrics/AbcSize - def initialize(opts) - # declare the hashtable of counts - @counts = {} - @total = 0 - @opts = opts + # Constructor that retreives the specified resource + # + # The opts hash should contain the following + # :group_name - name of the resource group in which to look for items + # :type - the type of Azure resource to look for + # :apiversion - API version to use when looking for a specific resource + # :name - name of the resource to find + # + # @author Russell Seymour + # + # @param [Hash] opts Hashtable of options as highlighted above + # rubocop:disable Metrics/AbcSize + def initialize(opts) + # declare the hashtable of counts + @counts = {} + @total = 0 + @opts = opts - # Determine if the environment variables for the options have been set - option_var_names = { - group_name: 'AZURE_RESOURCE_GROUP_NAME', - name: 'AZURE_RESOURCE_NAME', - type: 'AZURE_RESOURCE_TYPE', - apiversion: 'AZURE_RESOURCE_API_VERSION', - } - option_var_names.each do |option_name, env_var_name| - opts[option_name] = ENV[env_var_name] unless ENV[env_var_name].nil? + # Determine if the environment variables for the options have been set + option_var_names = { + group_name: 'AZURE_RESOURCE_GROUP_NAME', + name: 'AZURE_RESOURCE_NAME', + type: 'AZURE_RESOURCE_TYPE', + apiversion: 'AZURE_RESOURCE_API_VERSION', + } + option_var_names.each do |option_name, env_var_name| + opts[option_name] = ENV[env_var_name] unless ENV[env_var_name].nil? + end + + @azure = inspec.backend + @client = azure.azure_client end - @azure = AzureConnection.new - @client = azure.client - end - - # Return information about the resource group - def resource_group - resource_group = client.resource_groups.get(opts[:group_name]) - - # create the methods for the resource group object - dm = AzureResourceDynamicMethods.new - dm.create_methods(self, resource_group) - end - - def resources - resources = client.resources.list_by_resource_group(opts[:group_name]) - - # filter the resources based on the type, and the name if they been specified - resources = filter_resources(resources, opts) - - # if there is one resource then define methods on this class - if resources.count == 1 - @total = 1 - - # get the apiversion for the resource, if one has not been specified - apiversion = azure.get_api_version(resources[0].type, opts) - - # get the resource by id so it can be interrogated - resource = client.resources.get_by_id(resources[0].id, apiversion) + # Return information about the resource group + def resource_group + resource_group = client.resource_groups.get(opts[:group_name]) + # create the methods for the resource group object dm = AzureResourceDynamicMethods.new + dm.create_methods(self, resource_group) + end - dm.create_methods(self, resource) - else + def resources + resources = client.resources.list_by_resource_group(opts[:group_name]) - # As there are many resources, parse each one so that it can be - # interrogated by the FilterTable - # @probes = parse_resources(resources, azure) - @probes = resources.each.map do |item| - # update the total - @total += 1 + # filter the resources based on the type, and the name if they been specified + resources = filter_resources(resources, opts) - # determine the counts for each type - namespace, type_name = item.type.split(/\./) - counts.key?(namespace) ? false : counts[namespace] = {} - counts[namespace].key?(type_name) ? counts[namespace][type_name] += 1 : counts[namespace][type_name] = 1 + # if there is one resource then define methods on this class + if resources.count == 1 + @total = 1 - # get the detail about the resource - apiversion = azure.get_api_version(item.type, opts) - resource = client.resources.get_by_id(item.id, apiversion) + # get the apiversion for the resource, if one has not been specified + apiversion = azure.get_api_version(resources[0].type, opts) - # parse the resource - parse_resource(resource) - end.compact + # get the resource by id so it can be interrogated + resource = client.resources.get_by_id(resources[0].id, apiversion) - # Iterate around the counts and create the necessary classes - counts.each do |namespace, ns_counts| - define_singleton_method namespace do - AzureResourceTypeCounts.new(ns_counts) + dm = AzureResourceDynamicMethods.new + + dm.create_methods(self, resource) + else + + # As there are many resources, parse each one so that it can be + # interrogated by the FilterTable + # @probes = parse_resources(resources, azure) + @probes = resources.each.map do |item| + # update the total + @total += 1 + + # determine the counts for each type + namespace, type_name = item.type.split(/\./) + counts.key?(namespace) ? false : counts[namespace] = {} + counts[namespace].key?(type_name) ? counts[namespace][type_name] += 1 : counts[namespace][type_name] = 1 + + # get the detail about the resource + apiversion = azure.get_api_version(item.type, opts) + resource = client.resources.get_by_id(item.id, apiversion) + + # parse the resource + parse_resource(resource) + end.compact + + # Iterate around the counts and create the necessary classes + counts.each do |namespace, ns_counts| + define_singleton_method namespace do + AzureResourceTypeCounts.new(ns_counts) + end end end end - end - # Does the resource have any tags? - # - # If it is a Hashtable then it does not, because there was nothing to parse so there is not - # a nested object to work with - # - # @author Russell Seymour - def has_tags? - tags.is_a?(Hash) ? false : true - end + # Does the resource have any tags? + # + # If it is a Hashtable then it does not, because there was nothing to parse so there is not + # a nested object to work with + # + # @author Russell Seymour + def has_tags? + tags.is_a?(Hash) ? false : true + end - # Returns how many tags have been set on the resource - # - # @author Russell Seymour - def tag_count - tags.count - end + # Returns how many tags have been set on the resource + # + # @author Russell Seymour + def tag_count + tags.count + end - # It is necessary to be able to test the tags of a resource. It is possible to say of the - # resource has tags or not, and it is possible to check that the tags include a specific tag - # However the value is not accessible, this function creates methods for all the tags that - # are available. - # - # The format of the method name is '_tag' and will return the value of that tag - # - # Disabling rubopcop check. If this is set as a normal if..then..end statement there is a - # violation stating it should use a guard. When using a guard it throws this error - # - # rubocop:disable Style/MultilineIfModifier - # - # @author Russell Seymour - def create_tag_methods - # Iterate around the items of the tags and create the necessary access methods - tags.item.each do |name, value| - method_name = format('%s_tag', name) - define_singleton_method method_name do - value + # It is necessary to be able to test the tags of a resource. It is possible to say of the + # resource has tags or not, and it is possible to check that the tags include a specific tag + # However the value is not accessible, this function creates methods for all the tags that + # are available. + # + # The format of the method name is '_tag' and will return the value of that tag + # + # Disabling rubopcop check. If this is set as a normal if..then..end statement there is a + # violation stating it should use a guard. When using a guard it throws this error + # + # @author Russell Seymour + def create_tag_methods + # Iterate around the items of the tags and create the necessary access methods + tags.item.each do |name, value| + method_name = format('%s_tag', name) + define_singleton_method method_name do + value + end + end if defined?(tags.item) + end + + private + + # Filter the resources that are returned by the options that have been specified + # + def filter_resources(resources, opts) + if opts[:type] && opts[:name] + resources.select { |r| r.type == opts[:type] && r.name == opts[:name] } + elsif opts[:type] + resources.select { |r| r.type == opts[:type] } + elsif opts[:name] + resources.select { |r| r.name == opts[:name] } + else + resources end - end if defined?(tags.item) - end - - private - - # Filter the resources that are returned by the options that have been specified - # - def filter_resources(resources, opts) - if opts[:type] && opts[:name] - resources.select { |r| r.type == opts[:type] && r.name == opts[:name] } - elsif opts[:type] - resources.select { |r| r.type == opts[:type] } - elsif opts[:name] - resources.select { |r| r.name == opts[:name] } - else - resources end end end diff --git a/lib/resources/azure/azure_generic_resource.rb b/lib/resources/azure/azure_generic_resource.rb index faa6d89c5..7479e2ca4 100644 --- a/lib/resources/azure/azure_generic_resource.rb +++ b/lib/resources/azure/azure_generic_resource.rb @@ -1,54 +1,57 @@ -# frozen_string_literal: true +# encoding: utf-8 -require 'azure_backend' +require 'resources/azure/azure_backend' +require 'utils/filter' -class AzureGenericResource < AzureResourceBase - name 'azure_generic_resource' +module Inspec::Resources + class AzureGenericResource < AzureResourceBase + name 'azure_generic_resource' - desc ' - Inspec Resource to interrogate any Resource type in Azure - ' + desc ' + Inspec Resource to interrogate any Resource type in Azure + ' - attr_accessor :filter, :total, :counts, :name, :type, :location, :probes + attr_accessor :filter, :total, :counts, :name, :type, :location, :probes - def initialize(opts = {}) - # Call the parent class constructor - super(opts) + def initialize(opts = {}) + # Call the parent class constructor + super(opts) - # Get the resource group - resource_group + # Get the resource group + resource_group - # Get the resources - resources + # Get the resources + resources - # Create the tag methods - create_tag_methods - end + # Create the tag methods + create_tag_methods + end - # Define the filter table so that it can be interrogated - @filter = FilterTable.create - @filter.add_accessor(:count) - .add_accessor(:entries) - .add_accessor(:where) - .add_accessor(:contains) - .add(:exist?, field: 'exist?') - .add(:type, field: 'type') - .add(:name, field: 'name') - .add(:location, field: 'location') - .add(:properties, field: 'properties') + # Define the filter table so that it can be interrogated + @filter = FilterTable.create + @filter.add_accessor(:count) + .add_accessor(:entries) + .add_accessor(:where) + .add_accessor(:contains) + .add(:exist?, field: 'exist?') + .add(:type, field: 'type') + .add(:name, field: 'name') + .add(:location, field: 'location') + .add(:properties, field: 'properties') - @filter.connect(self, :probes) + @filter.connect(self, :probes) - def parse_resource(resource) - # return a hash of information - parsed = { - 'location' => resource.location, - 'name' => resource.name, - 'type' => resource.type, - 'exist?' => true, - 'properties' => AzureResourceProbe.new(resource.properties), - } + def parse_resource(resource) + # return a hash of information + parsed = { + 'location' => resource.location, + 'name' => resource.name, + 'type' => resource.type, + 'exist?' => true, + 'properties' => AzureResourceProbe.new(resource.properties), + } - parsed + parsed + end end end diff --git a/lib/resources/azure/azure_resource_group.rb b/lib/resources/azure/azure_resource_group.rb index 998069d0c..6c7fc08f1 100644 --- a/lib/resources/azure/azure_resource_group.rb +++ b/lib/resources/azure/azure_resource_group.rb @@ -1,148 +1,148 @@ -# frozen_string_literal: true +# encoding: utf-8 -require 'azure_backend' +require 'resources/azure/azure_backend' -class AzureResourceGroup < AzureResourceBase - name 'azure_resource_group' +module Inspec::Resources + class AzureResourceGroup < AzureResourceBase + name 'azure_resource_group' - desc ' - Inspec Resource to get metadata about a specific Resource Group - ' + desc ' + Inspec Resource to get metadata about a specific Resource Group + ' - attr_reader :name, :location, :id, :total, :counts, :mapping + attr_reader :name, :location, :id, :total, :counts, :mapping - # Constructor to get the resource group itself and perform some analysis on the - # resources that in the resource group. - # - # This analysis is defined by the the mapping hashtable which is used to define - # the 'has_xxx?' methods (see AzureResourceGroup#create_has_methods) and return - # the counts for each type - # - # @author Russell Seymour - def initialize(opts) - opts.key?(:name) ? opts[:group_name] = opts[:name] : false - # Ensure that the opts only have the name of the resource group set - opts.select! { |k, _v| k == :group_name } - super(opts) + # Constructor to get the resource group itself and perform some analysis on the + # resources that in the resource group. + # + # This analysis is defined by the the mapping hashtable which is used to define + # the 'has_xxx?' methods (see AzureResourceGroup#create_has_methods) and return + # the counts for each type + # + # @author Russell Seymour + def initialize(opts) + opts.key?(:name) ? opts[:group_name] = opts[:name] : false + # Ensure that the opts only have the name of the resource group set + opts.select! { |k, _v| k == :group_name } + super(opts) - # set the mapping for the Azure Resources - @mapping = { - nic: 'Microsoft.Network/networkInterfaces', - vm: 'Microsoft.Compute/virtualMachines', - extension: 'Microsoft.Compute/virtualMachines/extensions', - nsg: 'Microsoft.Network/networkSecurityGroups', - vnet: 'Microsoft.Network/virtualNetworks', - managed_disk: 'Microsoft.Compute/disks', - managed_disk_image: 'Microsoft.Compute/images', - sa: 'Microsoft.Storage/storageAccounts', - public_ip: 'Microsoft.Network/publicIPAddresses', - } + # set the mapping for the Azure Resources + @mapping = { + nic: 'Microsoft.Network/networkInterfaces', + vm: 'Microsoft.Compute/virtualMachines', + extension: 'Microsoft.Compute/virtualMachines/extensions', + nsg: 'Microsoft.Network/networkSecurityGroups', + vnet: 'Microsoft.Network/virtualNetworks', + managed_disk: 'Microsoft.Compute/disks', + managed_disk_image: 'Microsoft.Compute/images', + sa: 'Microsoft.Storage/storageAccounts', + public_ip: 'Microsoft.Network/publicIPAddresses', + } - # Get information about the resource group itself - resource_group + # Get information about the resource group itself + resource_group - # Get information about the resources in the resource group - resources + # Get information about the resources in the resource group + resources - # Call method to create the has_xxxx? methods - create_has_methods + # Call method to create the has_xxxx? methods + create_has_methods - # Call method to allow access to the tag values - create_tag_methods - end - - # Return the provisioning state of the resource group - # - # @author Russell Seymour - def provisioning_state - properties.provisioningState - end - - # Analyze the fully qualified id of the resource group to return the subscription id - # that this resource group is part of - # - # The format of the id is - # /subscriptions//resourceGroups/ - # - # @author Russell Seymour - def subscription_id - id.split(%r{\/}).reject(&:empty?)[1] - end - - # Method to parse the resources that have been returned - # This allows the calculations of the amount of resources to be determined - # - # @author Russell Seymour - # - # @param [Hash] resource A hashtable representing the resource group - def parse_resource(resource) - # return a hash of information - parsed = { - 'name' => resource.name, - 'type' => resource.type, - } - - parsed - end - - # This method catches the xxx_count calls that are made on the resource. - # - # The method that is called is stripped of '_count' and then compared with the - # mappings table. If that type exists then the number of those items is returned. - # However if that type is not in the Resource Group then the method will return - # a NoMethodError exception - # - # @author Russell Seymour - # - # @param [Symbol] method_id The name of the method that was called - # - # rubocop:disable Style/MethodMissing - def method_missing(method_id) - # Determine the mapping_key based on the method_id - mapping_key = method_id.to_s.chomp('_count').to_sym - - if mapping.key?(mapping_key) - # based on the method id get the - namespace, type_name = mapping[mapping_key].split(/\./) - - # check that the type_name is defined, if not return 0 - if send(namespace).methods.include?(type_name.to_sym) - # return the count for the method id - send(namespace).send(type_name) - else - 0 - end - else - msg = format('undefined method `%s` for %s', method_id, self.class) - raise NoMethodError, msg + # Call method to allow access to the tag values + create_tag_methods end - end - private + # Return the provisioning state of the resource group + # + # @author Russell Seymour + def provisioning_state + properties.provisioningState + end - # For each of the mappings this method creates the has_xxx? method. This allows the use - # of the following type of test - # - # it { should have_nics } - # - # For example, it will create a has_nics? method that returns a boolean to state of the - # resource group has any nics at all. - # - # @author Russell Seymour - # @private - def create_has_methods - # Create the has methods for each of the mappings - # This is a quick test to show that the resource group has at least one of these things - mapping.each do |name, type| - # Determine the name of the method name - method_name = format('has_%ss?', name) - namespace, type_name = type.split(/\./) + # Analyze the fully qualified id of the resource group to return the subscription id + # that this resource group is part of + # + # The format of the id is + # /subscriptions//resourceGroups/ + # + # @author Russell Seymour + def subscription_id + id.split(%r{\/}).reject(&:empty?)[1] + end - # use the namespace and the type_name to determine if the resource group has this type or not - result = send(namespace).methods.include?(type_name.to_sym) ? true : false + # Method to parse the resources that have been returned + # This allows the calculations of the amount of resources to be determined + # + # @author Russell Seymour + # + # @param [Hash] resource A hashtable representing the resource group + def parse_resource(resource) + # return a hash of information + parsed = { + 'name' => resource.name, + 'type' => resource.type, + } - define_singleton_method method_name do - result + parsed + end + + # This method catches the xxx_count calls that are made on the resource. + # + # The method that is called is stripped of '_count' and then compared with the + # mappings table. If that type exists then the number of those items is returned. + # However if that type is not in the Resource Group then the method will return + # a NoMethodError exception + # + # @author Russell Seymour + # + # @param [Symbol] method_id The name of the method that was called + def method_missing(method_id) + # Determine the mapping_key based on the method_id + mapping_key = method_id.to_s.chomp('_count').to_sym + + if mapping.key?(mapping_key) + # based on the method id get the + namespace, type_name = mapping[mapping_key].split(/\./) + + # check that the type_name is defined, if not return 0 + if send(namespace).methods.include?(type_name.to_sym) + # return the count for the method id + send(namespace).send(type_name) + else + 0 + end + else + msg = format('undefined method `%s` for %s', method_id, self.class) + raise NoMethodError, msg + end + end + + private + + # For each of the mappings this method creates the has_xxx? method. This allows the use + # of the following type of test + # + # it { should have_nics } + # + # For example, it will create a has_nics? method that returns a boolean to state of the + # resource group has any nics at all. + # + # @author Russell Seymour + # @private + def create_has_methods + # Create the has methods for each of the mappings + # This is a quick test to show that the resource group has at least one of these things + mapping.each do |name, type| + # Determine the name of the method name + method_name = format('has_%ss?', name) + namespace, type_name = type.split(/\./) + + # use the namespace and the type_name to determine if the resource group has this type or not + result = send(namespace).methods.include?(type_name.to_sym) ? true : false + + define_singleton_method method_name do + result + end end end end diff --git a/lib/resources/azure/azure_virtual_machine.rb b/lib/resources/azure/azure_virtual_machine.rb index 3d8003996..f969fb511 100644 --- a/lib/resources/azure/azure_virtual_machine.rb +++ b/lib/resources/azure/azure_virtual_machine.rb @@ -1,261 +1,262 @@ -# frozen_string_literal: true +# encoding: utf-8 -require 'azure_backend' +require 'resources/azure/azure_backend' -class AzureVirtualMachine < AzureResourceBase - name 'azure_virtual_machine' +module Inspec::Resources + class AzureVirtualMachine < AzureResourceBase + name 'azure_virtual_machine' - desc ' - Inspec Resource to test Azure Virtual Machines - ' + desc ' + Inspec Resource to test Azure Virtual Machines + ' - # Constructor for the resource. This calls the parent constructor to - # get the generic resource for the specified machine. This will provide - # static methods that are documented - # - # @author Russell Seymour - def initialize(opts = {}) - # The generic resource needs to pass back a Microsoft.Compute/virtualMachines object so force it - opts[:type] = 'Microsoft.Compute/virtualMachines' - super(opts) + # Constructor for the resource. This calls the parent constructor to + # get the generic resource for the specified machine. This will provide + # static methods that are documented + # + # @author Russell Seymour + def initialize(opts = {}) + # The generic resource needs to pass back a Microsoft.Compute/virtualMachines object so force it + opts[:type] = 'Microsoft.Compute/virtualMachines' + super(opts) - # Find the virtual machines - resources + # Find the virtual machines + resources - create_tag_methods - end - - # Method to catch calls that are not explicitly defined. - # This allows the simple attributes of the virtual machine to be read without having - # to define each one in turn. - # - # rubocop:disable Style/MethodMissing - # rubocop:disable Metrics/AbcSize - # - # @param symobl method_id The symbol of the method that has been called - # - # @return Value of attribute that has been called - def method_missing(method_id) - # Depending on the method that has been called, determine what value should be returned - # These are set as camel case methods to comply with rubocop - image_reference_attrs = %w{sku publisher offer} - osdisk_attrs = %w{os_type caching create_option disk_size_gb} - hardware_profile_attrs = %w{vm_size} - os_profile_attrs = %w{computer_name admin_username} - osdisk_managed_disk_attrs = %w{storage_account_type} - - # determine the method name to call by converting the snake_case to camelCase - # method_name = self.camel_case(method_id.to_s) - method_name = method_id.to_s.split('_').inject([]) { |buffer, e| buffer.push(buffer.empty? ? e : e.capitalize) }.join - method_name.end_with?('Gb') ? method_name.gsub!(/Gb/, &:upcase) : false - - if image_reference_attrs.include?(method_id.to_s) - properties.storageProfile.imageReference.send(method_name) - elsif osdisk_attrs.include?(method_id.to_s) - properties.storageProfile.osDisk.send(method_name) - elsif hardware_profile_attrs.include?(method_id.to_s) - properties.hardwareProfile.send(method_name) - elsif os_profile_attrs.include?(method_id.to_s) - properties.osProfile.send(method_name) - elsif osdisk_managed_disk_attrs.include?(method_id.to_s) - properties.storageProfile.osDisk.managedDisk.send(method_name) + create_tag_methods end - end - # Return the name of the os disk - # - # @return string Name of the OS disk - def os_disk_name - properties.storageProfile.osDisk.name - end + # Method to catch calls that are not explicitly defined. + # This allows the simple attributes of the virtual machine to be read without having + # to define each one in turn. + # + # rubocop:disable Metrics/AbcSize + # + # @param symobl method_id The symbol of the method that has been called + # + # @return Value of attribute that has been called + def method_missing(method_id) + # Depending on the method that has been called, determine what value should be returned + # These are set as camel case methods to comply with rubocop + image_reference_attrs = %w{sku publisher offer} + osdisk_attrs = %w{os_type caching create_option disk_size_gb} + hardware_profile_attrs = %w{vm_size} + os_profile_attrs = %w{computer_name admin_username} + osdisk_managed_disk_attrs = %w{storage_account_type} - # Determine if the OS disk is a managed disk - # - # @return boolean - def has_managed_osdisk? - defined?(properties.storageProfile.osDisk.managedDisk) - end + # determine the method name to call by converting the snake_case to camelCase + # method_name = self.camel_case(method_id.to_s) + method_name = method_id.to_s.split('_').inject([]) { |buffer, e| buffer.push(buffer.empty? ? e : e.capitalize) }.join + method_name.end_with?('Gb') ? method_name.gsub!(/Gb/, &:upcase) : false - # Does the machine have any NICs connected - # - # @return boolean - def has_nics? - properties.networkProfile.networkInterfaces.count != 0 - end - - # How many NICs are connected to the machine - # - # @return integer - def nic_count - properties.networkProfile.networkInterfaces.count - end - - # Return an array of the connected NICs so that it can be tested to ensure - # the machine is connected properly - # - # @return array Array of NIC names connected to the machine - def connected_nics - nic_names = [] - properties.networkProfile.networkInterfaces.each do |nic| - nic_names << nic.id.split(%r{/}).last + if image_reference_attrs.include?(method_id.to_s) + properties.storageProfile.imageReference.send(method_name) + elsif osdisk_attrs.include?(method_id.to_s) + properties.storageProfile.osDisk.send(method_name) + elsif hardware_profile_attrs.include?(method_id.to_s) + properties.hardwareProfile.send(method_name) + elsif os_profile_attrs.include?(method_id.to_s) + properties.osProfile.send(method_name) + elsif osdisk_managed_disk_attrs.include?(method_id.to_s) + properties.storageProfile.osDisk.managedDisk.send(method_name) + end end - nic_names - end - # Whether the machine has data disks or not - # - # @return boolean - def has_data_disks? - properties.storageProfile.dataDisks.count != 0 - end - - # How many data disks are connected - # - # @return integer - def data_disk_count - properties.storageProfile.dataDisks.count - end - - # Does the machine allow password authentication - # - # This allows the use of - # it { should have_password_authentication } - # within the Inspec profile - # - # @return boolean - def has_password_authentication? - password_authentication? - end - - # Deteremine if the machine allows password authentication - # - # @return boolean - def password_authentication? - # if the osProfile property has a linuxConfiguration section then interrogate that - # otherwise it is a Windows machine and that always has password auth - if defined?(properties.osProfile.linuxConfiguration) - !properties.osProfile.linuxConfiguration.disablePasswordAuthentication - else - true + # Return the name of the os disk + # + # @return string Name of the OS disk + def os_disk_name + properties.storageProfile.osDisk.name end - end - # Has the machine been given Custom Data at creation - # - # This allows the use of - # it { should have_custom_data } - # within the Inspec Profile - # - # @return boolean - def has_custom_data? - custom_data? - end - - # Determine if custom data has been set - # - # @return boolean - def custom_data? - if defined?(properties.osProfile.CustomData) - true - else - false + # Determine if the OS disk is a managed disk + # + # @return boolean + def has_managed_osdisk? + defined?(properties.storageProfile.osDisk.managedDisk) end - end - # Are any SSH Keys assigned to the machine - # - # This allows the use of - # it { should have_ssh_keys } - # within the Inspec Profile - # - # @return boolean - def has_ssh_keys? - ssh_keys? - end - - # Determine if any ssh keys have been asigned to the machine - # - # @return boolean - def ssh_keys? - if defined?(properties.osProfile.linuxConfiguration.ssh) - properties.osProfile.linuxConfiguration.ssh.publicKeys != 0 - else - false + # Does the machine have any NICs connected + # + # @return boolean + def has_nics? + properties.networkProfile.networkInterfaces.count != 0 end - end - # Return the number of ssh keys that have been assigned to the machine - # - # @return integer - def ssh_key_count - if defined?(properties.osProfile.linuxConfiguration.ssh) - properties.osProfile.linuxConfiguration.ssh.publicKeys.count - else - 0 + # How many NICs are connected to the machine + # + # @return integer + def nic_count + properties.networkProfile.networkInterfaces.count end - end - # Determine is the specified key is in the ssh_keys list - # - # @return array Array of the public keys that are assigned to allow for testing of that key - def ssh_keys - # iterate around the keys - keys = [] - properties.osProfile.linuxConfiguration.ssh.publicKeys.each do |key| - keys << key.keyData + # Return an array of the connected NICs so that it can be tested to ensure + # the machine is connected properly + # + # @return array Array of NIC names connected to the machine + def connected_nics + nic_names = [] + properties.networkProfile.networkInterfaces.each do |nic| + nic_names << nic.id.split(%r{/}).last + end + nic_names end - keys - end - # Does the machine have boot diagnostics enabled - # - # @return boolean - def has_boot_diagnostics? - if defined?(properties.diagnosticsProfile) - properties.diagnosticsProfile.bootDiagnostics.enabled - else - false + # Whether the machine has data disks or not + # + # @return boolean + def has_data_disks? + properties.storageProfile.dataDisks.count != 0 end - end - # Return the URI that has been set for the boot diagnostics storage - # - # @return string - def boot_diagnostics_storage_uri - properties.diagnosticsProfile.bootDiagnostics.storageUri - end - - # If this is a windows machine, returns whether the agent was provisioned or not - # - # @return boolean - def has_provision_vmagent? - if defined?(properties.osProfile.windowsConfiguration) - properties.osProfile.windowsConfiguration.provisionVMAgent - else - false + # How many data disks are connected + # + # @return integer + def data_disk_count + properties.storageProfile.dataDisks.count end - end - # If a windows machine see if automatic updates for the agent are enabled - # - # @return boolean - def has_automatic_agent_update? - if defined?(properties.osProfile.windowsConfiguration) - properties.osProfile.windowsConfiguration.enableAutomaticUpdates - else - false + # Does the machine allow password authentication + # + # This allows the use of + # it { should have_password_authentication } + # within the Inspec profile + # + # @return boolean + def has_password_authentication? + password_authentication? end - end - # If this is a windows machine return a boolean to state of the WinRM options - # have been set - # - # @return boolean - def has_winrm_options? - if defined?(properties.osProfile.windowsConfiguration) && defined?(properties.osProfile.windowsConfiguration.winrm) - properties.osProfile.windowsConfiguration.winrm.protocol - else - false + # Deteremine if the machine allows password authentication + # + # @return boolean + def password_authentication? + # if the osProfile property has a linuxConfiguration section then interrogate that + # otherwise it is a Windows machine and that always has password auth + if defined?(properties.osProfile.linuxConfiguration) + !properties.osProfile.linuxConfiguration.disablePasswordAuthentication + else + true + end + end + + # Has the machine been given Custom Data at creation + # + # This allows the use of + # it { should have_custom_data } + # within the Inspec Profile + # + # @return boolean + def has_custom_data? + custom_data? + end + + # Determine if custom data has been set + # + # @return boolean + def custom_data? + if defined?(properties.osProfile.CustomData) + true + else + false + end + end + + # Are any SSH Keys assigned to the machine + # + # This allows the use of + # it { should have_ssh_keys } + # within the Inspec Profile + # + # @return boolean + def has_ssh_keys? + ssh_keys? + end + + # Determine if any ssh keys have been asigned to the machine + # + # @return boolean + def ssh_keys? + if defined?(properties.osProfile.linuxConfiguration.ssh) + properties.osProfile.linuxConfiguration.ssh.publicKeys != 0 + else + false + end + end + + # Return the number of ssh keys that have been assigned to the machine + # + # @return integer + def ssh_key_count + if defined?(properties.osProfile.linuxConfiguration.ssh) + properties.osProfile.linuxConfiguration.ssh.publicKeys.count + else + 0 + end + end + + # Determine is the specified key is in the ssh_keys list + # + # @return array Array of the public keys that are assigned to allow for testing of that key + def ssh_keys + # iterate around the keys + keys = [] + properties.osProfile.linuxConfiguration.ssh.publicKeys.each do |key| + keys << key.keyData + end + keys + end + + # Does the machine have boot diagnostics enabled + # + # @return boolean + def has_boot_diagnostics? + if defined?(properties.diagnosticsProfile) + properties.diagnosticsProfile.bootDiagnostics.enabled + else + false + end + end + + # Return the URI that has been set for the boot diagnostics storage + # + # @return string + def boot_diagnostics_storage_uri + properties.diagnosticsProfile.bootDiagnostics.storageUri + end + + # If this is a windows machine, returns whether the agent was provisioned or not + # + # @return boolean + def has_provision_vmagent? + if defined?(properties.osProfile.windowsConfiguration) + properties.osProfile.windowsConfiguration.provisionVMAgent + else + false + end + end + + # If a windows machine see if automatic updates for the agent are enabled + # + # @return boolean + def has_automatic_agent_update? + if defined?(properties.osProfile.windowsConfiguration) + properties.osProfile.windowsConfiguration.enableAutomaticUpdates + else + false + end + end + + # If this is a windows machine return a boolean to state of the WinRM options + # have been set + # + # @return boolean + def has_winrm_options? + if defined?(properties.osProfile.windowsConfiguration) && defined?(properties.osProfile.windowsConfiguration.winrm) + properties.osProfile.windowsConfiguration.winrm.protocol + else + false + end end end end diff --git a/lib/resources/azure/azure_virtual_machine_data_disk.rb b/lib/resources/azure/azure_virtual_machine_data_disk.rb index 5b0534c6d..8bffe7fe7 100644 --- a/lib/resources/azure/azure_virtual_machine_data_disk.rb +++ b/lib/resources/azure/azure_virtual_machine_data_disk.rb @@ -1,131 +1,133 @@ -# frozen_string_literal: true +# encoding: utf-8 -require 'azure_backend' +require 'resources/azure/azure_backend' require 'uri' -class AzureVirtualMachineDataDisk < AzureResourceBase - name 'azure_virtual_machine_data_disk' +module Inspec::Resources + class AzureVirtualMachineDataDisk < AzureResourceBase + name 'azure_virtual_machine_data_disk' - desc ' - Inspec Resource to ensure that the data disks attached to a machine are correct - ' + desc ' + Inspec Resource to ensure that the data disks attached to a machine are correct + ' - # Create a filter table so that tests on the disk can be performed - filter = FilterTable.create - filter.add_accessor(:where) - .add_accessor(:entries) - .add_accessor(:has_data_disks?) - .add_accessor(:count) - .add(:exists?) { |x| !x.entries.empty? } - .add(:disk, field: :disk) - .add(:number, field: :number) - .add(:name, field: :name) - .add(:size, field: :size) - .add(:vhd_uri, field: :vhd_uri) - .add(:storage_account_name, field: :storage_account_name) - .add(:lun, field: :lun) - .add(:caching, field: :caching) - .add(:create_option, field: :create_option) - .add(:is_managed_disk?, field: :is_managed_disk?) - .add(:storage_account_type, field: :storage_account_type) - .add(:subscription_id, field: :subscription_id) - .add(:resource_group, field: :resource_group) - filter.connect(self, :datadisk_details) + # Create a filter table so that tests on the disk can be performed + filter = FilterTable.create + filter.add_accessor(:where) + .add_accessor(:entries) + .add_accessor(:has_data_disks?) + .add_accessor(:count) + .add(:exists?) { |x| !x.entries.empty? } + .add(:disk, field: :disk) + .add(:number, field: :number) + .add(:name, field: :name) + .add(:size, field: :size) + .add(:vhd_uri, field: :vhd_uri) + .add(:storage_account_name, field: :storage_account_name) + .add(:lun, field: :lun) + .add(:caching, field: :caching) + .add(:create_option, field: :create_option) + .add(:is_managed_disk?, field: :is_managed_disk?) + .add(:storage_account_type, field: :storage_account_type) + .add(:subscription_id, field: :subscription_id) + .add(:resource_group, field: :resource_group) + filter.connect(self, :datadisk_details) - # Constructor for the resource. This calls the parent constructor to - # get the generic resource for the specified machine. This will provide - # static methods that are documented - # - # @author Russell Seymour - def initialize(opts = {}) - # The generic resource needs to pass back a Microsoft.Compute/virtualMachines object so force it - opts[:type] = 'Microsoft.Compute/virtualMachines' - super(opts) + # Constructor for the resource. This calls the parent constructor to + # get the generic resource for the specified machine. This will provide + # static methods that are documented + # + # @author Russell Seymour + def initialize(opts = {}) + # The generic resource needs to pass back a Microsoft.Compute/virtualMachines object so force it + opts[:type] = 'Microsoft.Compute/virtualMachines' + super(opts) - # Get the data disks - resources - end - - # Return information about the disks and add to the filter table so that - # assertions can be performed - # - # @author Russell Seymour - def datadisk_details - # Iterate around the data disks on the machine - properties.storageProfile.dataDisks.each_with_index.map do |datadisk, index| - # Call function to parse the data disks and return an object based on the parameters - parse_datadisk(datadisk, index) - end - end - - # Return boolean to denote if the machine has data disks attached or not - def has_data_disks? - !entries.empty? - end - - # Return an integer stating how many data disks are attached to the machine - def count - entries.count - end - - # Return boolean to state if the machine is using managed disks for data disks - def has_managed_disks? - # iterate around the entries - result = entries.each.select { |e| e[:is_managed_disk?] } - result.empty? ? false : true - end - - private - - # Parse the data disk to determine if these are managed disks or in a storage account - # for example. The disk index, name and size will be returned - # - # params object disk Object containing the details of the disk - # params integer index Index denoting which disk number this is on the machine - # - # return hashtable - def parse_datadisk(disk, index) - # Configure parsed hashtable to hold the information - # Initialise this with common attributes from the different types of disk - parsed = { - disk: index, - number: index + 1, - lun: disk.lun, - name: disk.name, - size: disk.diskSizeGB, - caching: disk.caching, - create_option: disk.createOption, - } - - # Determine if the current disk is a managed disk or not - if defined?(disk.vhd) - # As this is in a storage account this is not a managed disk - parsed[:is_managed_disk?] = false - - # Set information about the disk - # Parse the uri of the disk URI so that the storage account can be retrieved - uri = URI.parse(disk.vhd.uri) - parsed[:vhd_uri] = disk.vhd.uri - parsed[:storage_account_name] = uri.host.split('.').first - - elsif defined?(disk.managedDisk) - # State that this is a managed disk - parsed[:is_managed_disk?] = true - - # Get information about the managed disk - parsed[:storage_account_type] = disk.managedDisk.storageAccountType - parsed[:id] = disk.managedDisk.id - - # Break up the ID string so that the following information can get retreived - # - subscription_id - # - resource_group - id_parts = parsed[:id].split(%r{/}).reject(&:empty?) - - parsed[:subscription_id] = id_parts[1] - parsed[:resource_group] = id_parts[3] + # Get the data disks + resources end - # return the parsed object - parsed + # Return information about the disks and add to the filter table so that + # assertions can be performed + # + # @author Russell Seymour + def datadisk_details + # Iterate around the data disks on the machine + properties.storageProfile.dataDisks.each_with_index.map do |datadisk, index| + # Call function to parse the data disks and return an object based on the parameters + parse_datadisk(datadisk, index) + end + end + + # Return boolean to denote if the machine has data disks attached or not + def has_data_disks? + !entries.empty? + end + + # Return an integer stating how many data disks are attached to the machine + def count + entries.count + end + + # Return boolean to state if the machine is using managed disks for data disks + def has_managed_disks? + # iterate around the entries + result = entries.each.select { |e| e[:is_managed_disk?] } + result.empty? ? false : true + end + + private + + # Parse the data disk to determine if these are managed disks or in a storage account + # for example. The disk index, name and size will be returned + # + # params object disk Object containing the details of the disk + # params integer index Index denoting which disk number this is on the machine + # + # return hashtable + def parse_datadisk(disk, index) + # Configure parsed hashtable to hold the information + # Initialise this with common attributes from the different types of disk + parsed = { + disk: index, + number: index + 1, + lun: disk.lun, + name: disk.name, + size: disk.diskSizeGB, + caching: disk.caching, + create_option: disk.createOption, + } + + # Determine if the current disk is a managed disk or not + if defined?(disk.vhd) + # As this is in a storage account this is not a managed disk + parsed[:is_managed_disk?] = false + + # Set information about the disk + # Parse the uri of the disk URI so that the storage account can be retrieved + uri = URI.parse(disk.vhd.uri) + parsed[:vhd_uri] = disk.vhd.uri + parsed[:storage_account_name] = uri.host.split('.').first + + elsif defined?(disk.managedDisk) + # State that this is a managed disk + parsed[:is_managed_disk?] = true + + # Get information about the managed disk + parsed[:storage_account_type] = disk.managedDisk.storageAccountType + parsed[:id] = disk.managedDisk.id + + # Break up the ID string so that the following information can get retreived + # - subscription_id + # - resource_group + id_parts = parsed[:id].split(%r{/}).reject(&:empty?) + + parsed[:subscription_id] = id_parts[1] + parsed[:resource_group] = id_parts[3] + end + + # return the parsed object + parsed + end end end diff --git a/lib/resources/platform.rb b/lib/resources/platform.rb index 970fd9ba9..e14e0bf92 100644 --- a/lib/resources/platform.rb +++ b/lib/resources/platform.rb @@ -15,13 +15,13 @@ module Inspec::Resources " def initialize - @platform = inspec.backend.os + @platform = inspec.backend.platform end # add helper methods for easy access of properties %w{family release arch}.each do |property| define_method(property.to_sym) do - @platform.send(property) + @platform[property] end end diff --git a/test/integration/azure/build/azure.tf b/test/azure/build/azure.tf similarity index 100% rename from test/integration/azure/build/azure.tf rename to test/azure/build/azure.tf diff --git a/test/integration/azure/verify/controls/generic_external_vm.rb b/test/azure/verify/controls/generic_external_vm.rb similarity index 100% rename from test/integration/azure/verify/controls/generic_external_vm.rb rename to test/azure/verify/controls/generic_external_vm.rb diff --git a/test/integration/azure/verify/controls/generic_external_vm_nic.rb b/test/azure/verify/controls/generic_external_vm_nic.rb similarity index 100% rename from test/integration/azure/verify/controls/generic_external_vm_nic.rb rename to test/azure/verify/controls/generic_external_vm_nic.rb diff --git a/test/integration/azure/verify/controls/generic_internal_vm.rb b/test/azure/verify/controls/generic_internal_vm.rb similarity index 100% rename from test/integration/azure/verify/controls/generic_internal_vm.rb rename to test/azure/verify/controls/generic_internal_vm.rb diff --git a/test/integration/azure/verify/controls/generic_internal_vm_nic.rb b/test/azure/verify/controls/generic_internal_vm_nic.rb similarity index 100% rename from test/integration/azure/verify/controls/generic_internal_vm_nic.rb rename to test/azure/verify/controls/generic_internal_vm_nic.rb diff --git a/test/integration/azure/verify/controls/generic_linux_vm_managed_os_disk.rb b/test/azure/verify/controls/generic_linux_vm_managed_os_disk.rb similarity index 100% rename from test/integration/azure/verify/controls/generic_linux_vm_managed_os_disk.rb rename to test/azure/verify/controls/generic_linux_vm_managed_os_disk.rb diff --git a/test/integration/azure/verify/controls/generic_network_security_group.rb b/test/azure/verify/controls/generic_network_security_group.rb similarity index 100% rename from test/integration/azure/verify/controls/generic_network_security_group.rb rename to test/azure/verify/controls/generic_network_security_group.rb diff --git a/test/integration/azure/verify/controls/generic_public_ip_address.rb b/test/azure/verify/controls/generic_public_ip_address.rb similarity index 100% rename from test/integration/azure/verify/controls/generic_public_ip_address.rb rename to test/azure/verify/controls/generic_public_ip_address.rb diff --git a/test/integration/azure/verify/controls/generic_resources.rb b/test/azure/verify/controls/generic_resources.rb similarity index 100% rename from test/integration/azure/verify/controls/generic_resources.rb rename to test/azure/verify/controls/generic_resources.rb diff --git a/test/integration/azure/verify/controls/generic_storage_account.rb b/test/azure/verify/controls/generic_storage_account.rb similarity index 100% rename from test/integration/azure/verify/controls/generic_storage_account.rb rename to test/azure/verify/controls/generic_storage_account.rb diff --git a/test/integration/azure/verify/controls/generic_virtual_network.rb b/test/azure/verify/controls/generic_virtual_network.rb similarity index 100% rename from test/integration/azure/verify/controls/generic_virtual_network.rb rename to test/azure/verify/controls/generic_virtual_network.rb diff --git a/test/integration/azure/verify/controls/generic_windows_internal_vm.rb b/test/azure/verify/controls/generic_windows_internal_vm.rb similarity index 100% rename from test/integration/azure/verify/controls/generic_windows_internal_vm.rb rename to test/azure/verify/controls/generic_windows_internal_vm.rb diff --git a/test/integration/azure/verify/controls/generic_windows_internal_vm_nic.rb b/test/azure/verify/controls/generic_windows_internal_vm_nic.rb similarity index 100% rename from test/integration/azure/verify/controls/generic_windows_internal_vm_nic.rb rename to test/azure/verify/controls/generic_windows_internal_vm_nic.rb diff --git a/test/integration/azure/verify/controls/resource_group.rb b/test/azure/verify/controls/resource_group.rb similarity index 100% rename from test/integration/azure/verify/controls/resource_group.rb rename to test/azure/verify/controls/resource_group.rb diff --git a/test/integration/azure/verify/controls/virtual_machine_external_vm.rb b/test/azure/verify/controls/virtual_machine_external_vm.rb similarity index 100% rename from test/integration/azure/verify/controls/virtual_machine_external_vm.rb rename to test/azure/verify/controls/virtual_machine_external_vm.rb diff --git a/test/integration/azure/verify/controls/virtual_machine_internal_vm.rb b/test/azure/verify/controls/virtual_machine_internal_vm.rb similarity index 100% rename from test/integration/azure/verify/controls/virtual_machine_internal_vm.rb rename to test/azure/verify/controls/virtual_machine_internal_vm.rb diff --git a/test/integration/azure/verify/controls/virtual_machine_linux_external_vm_datadisk.rb b/test/azure/verify/controls/virtual_machine_linux_external_vm_datadisk.rb similarity index 100% rename from test/integration/azure/verify/controls/virtual_machine_linux_external_vm_datadisk.rb rename to test/azure/verify/controls/virtual_machine_linux_external_vm_datadisk.rb diff --git a/test/integration/azure/verify/controls/virtual_machine_windows_internal_vm.rb b/test/azure/verify/controls/virtual_machine_windows_internal_vm.rb similarity index 100% rename from test/integration/azure/verify/controls/virtual_machine_windows_internal_vm.rb rename to test/azure/verify/controls/virtual_machine_windows_internal_vm.rb diff --git a/test/integration/azure/verify/controls/virtual_machine_windows_internal_vm_datadisk.rb b/test/azure/verify/controls/virtual_machine_windows_internal_vm_datadisk.rb similarity index 100% rename from test/integration/azure/verify/controls/virtual_machine_windows_internal_vm_datadisk.rb rename to test/azure/verify/controls/virtual_machine_windows_internal_vm_datadisk.rb diff --git a/test/azure/verify/inspec.yml b/test/azure/verify/inspec.yml new file mode 100644 index 000000000..6edf1804a --- /dev/null +++ b/test/azure/verify/inspec.yml @@ -0,0 +1 @@ +name: inspec-azure-integration-tests diff --git a/test/integration/azure/verify/inspec.yml b/test/integration/azure/verify/inspec.yml deleted file mode 100644 index 5bd38ef01..000000000 --- a/test/integration/azure/verify/inspec.yml +++ /dev/null @@ -1,4 +0,0 @@ -name: inspec-azure-integration-tests -depends: - - name: azure - path: ../../../ \ No newline at end of file From 4d8eb48855e94ad44b8d7b649bba1df8fa07c52e Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Wed, 7 Feb 2018 20:12:02 -0500 Subject: [PATCH 181/206] Skeletal aws_vpc_subnets resource (#228) Signed-off-by: Matthew Dromazos Signed-off-by: Clinton Wolfe --- docs/resources/aws_vpc_subnets.md | 110 ++++++++++++++++ libraries/aws_vpc_subnets.rb | 54 ++++++++ test/integration/default/build/ec2.tf | 1 - .../verify/controls/aws_vpc_subnets.rb | 49 +++++++ test/unit/resources/aws_vpc_subnets_test.rb | 120 ++++++++++++++++++ 5 files changed, 333 insertions(+), 1 deletion(-) create mode 100644 docs/resources/aws_vpc_subnets.md create mode 100644 libraries/aws_vpc_subnets.rb create mode 100644 test/integration/default/verify/controls/aws_vpc_subnets.rb create mode 100644 test/unit/resources/aws_vpc_subnets_test.rb diff --git a/docs/resources/aws_vpc_subnets.md b/docs/resources/aws_vpc_subnets.md new file mode 100644 index 000000000..7f47a6b11 --- /dev/null +++ b/docs/resources/aws_vpc_subnets.md @@ -0,0 +1,110 @@ +--- +title: About the aws_vpc_subnets Resource +--- + +# aws_vpc_subnets + +Use the `aws_vpc_subnets` InSpec audit resource to test properties of some or all subnets. + +Subnets are networks within a VPC that can have their own block of IP address's and ACL's. +VPCs span across all availability zones in AWS, while a subnet in a VPC can only span a single availability zone. +Separating IP addresses allows for protection if there is a failure in one availability zone. + +
+ +## Syntax + +An `aws_vpc_subnets` resource block uses an optional filter to select a group of subnets and then tests that group. + + # Test all subnets within a single vpc + describe aws_vpc_subnets.where(vpc_id: 'vpc-12345678') do + its('subnet_ids') { should include 'subnet-12345678' } + its('subnet_ids') { should include 'subnet-98765432' } + end + +
+ +## Examples + +The following examples show how to use this InSpec audit resource. + +As this is the initial release of `aws_vpc_subnets`, its limited functionality precludes examples. + +
+ +## Matchers + +### exists + +The control will pass if the filter returns at least one result. Use should_not if you expect zero matches. + + # You dont always have subnets, so you can test if there are any. + describe aws_vpc_subnets + it { should exist } + end + + # Test that there are subnets in a vpc + describe aws_vpc_subnets.where(vpc_id: 'vpc-12345678') + it { should exist } + end + +## Filter Criteria + +### vpc_id + +A string identifying the VPC which may or may not contain subnets. + + # Look for all subnts within a vpc. + describe aws_vpc_subnets.where( vpc_id: 'vpc-12345678') do + its('subnet_ids') { should include 'subnet-12345678' } + its('subnet_ids') { should include 'subnet-98765432' } + end + +### subnet_id + +A string identifying a specific subnet. + + # Examine a specific subnet + describe aws_vpc_subnets.where(subnet_id: 'subnet-12345678') do + its('cidr_blocks') { should eq ['10.0.1.0/24'] } + end + + +## Properties + +### cidr_blocks + +Provides a string that contains the cidr block of ip addresses that can be given in the subnet. + + # Examine a specific subnets cidr_blocks + describe aws_vpc_subnets.where( subnet_id: 'subnet-12345678') do + its('cidr_blocks') { should eq ['10.0.1.0/24'] } + end + +### vpc_ids + +Provides an array containing a string of the vpc_id associated with a subnet. + + # Examine a specific subnets VPC IDS + describe aws_vpc_subnets.where( subnet_id: 'subnet-12345678') do + its('vpc_ids') { should include 'vpc-12345678' } + end + +### subnet_ids + +Provides an array of strings containing the subnet IDs associated with a vpc. + + # Examine a specific vpcs Subnet IDs + describe aws_vpc_subnets.where( vpc_id: 'vpc-12345678') do + its('subnet_ids') { should include 'subnet-12345678' } + its('subnet_ids') { should include 'subnet-98765432' } + end + +### states + +Provides an array of strings including whether the subnets are available or not. + + # Examine a specific vpcs Subnet IDs + describe aws_vpc_subnets.where( vpc_id: 'vpc-12345678') do + its('states') { should_not include 'pending' } + end diff --git a/libraries/aws_vpc_subnets.rb b/libraries/aws_vpc_subnets.rb new file mode 100644 index 000000000..11d264a0b --- /dev/null +++ b/libraries/aws_vpc_subnets.rb @@ -0,0 +1,54 @@ +# @author: Matthew Dromazos + +require '_aws' + +class AwsVpcSubnets < Inspec.resource(1) + name 'aws_vpc_subnets' + desc 'Verifies settings for VPC Subnets in bulk' + example " + # you should be able to test the cidr_block of a subnet + describe aws_vpc_subnets.where(vpc_id: 'vpc-123456789') do + its('subnet_ids') { should eq ['subnet-12345678', 'subnet-87654321'] } + its('cidr_blocks') { should eq ['172.31.96.0/20'] } + its('states') { should_not include 'pending' } + end + " + + def initialize + backend = AwsVpcSubnets::BackendFactory.create + @table = backend.describe_subnets.subnets.map(&:to_h) + end + + # Underlying FilterTable implementation. + filter = FilterTable.create + filter.add_accessor(:where) + .add_accessor(:entries) + .add(:exists?) { |x| !x.entries.empty? } + .add(:vpc_ids, field: :vpc_id) + .add(:subnet_ids, field: :subnet_id) + .add(:cidr_blocks, field: :cidr_block) + .add(:states, field: :state) + filter.connect(self, :access_key_data) + + def access_key_data + @table + end + + def to_s + 'EC2 VPC Subnets' + end + + class BackendFactory + extend AwsBackendFactoryMixin + end + + class Backend + class AwsClientApi < Backend + AwsVpcSubnets::BackendFactory.set_default_backend self + + def describe_subnets(query = {}) + AWSConnection.new.ec2_client.describe_subnets(query) + end + end + end +end diff --git a/test/integration/default/build/ec2.tf b/test/integration/default/build/ec2.tf index a2ed72648..56ebae176 100644 --- a/test/integration/default/build/ec2.tf +++ b/test/integration/default/build/ec2.tf @@ -201,7 +201,6 @@ output "ec2_security_group_alpha_group_id" { #============================================================# # VPC Subnets #============================================================# - resource "aws_subnet" "subnet_01" { vpc_id = "${data.aws_vpc.default.id}" cidr_block = "172.31.96.0/20" diff --git a/test/integration/default/verify/controls/aws_vpc_subnets.rb b/test/integration/default/verify/controls/aws_vpc_subnets.rb new file mode 100644 index 000000000..828a0650b --- /dev/null +++ b/test/integration/default/verify/controls/aws_vpc_subnets.rb @@ -0,0 +1,49 @@ +fixtures = {} +[ + 'ec2_security_group_default_vpc_id', + 'ec2_default_vpc_subnet_id', +].each do |fixture_name| + fixtures[fixture_name] = attribute( + fixture_name, + default: "default.#{fixture_name}", + description: 'See ../build/ec2.tf', + ) +end + +control "aws_vpc_subnets recall" do + all_subnets = aws_vpc_subnets + + # You should be able to get a specific subnet given its id + describe all_subnets.where(subnet_id: fixtures['ec2_default_vpc_subnet_id']) do + it { should exist } + end + + # You should be able to get subnets given a vpc_id + describe all_subnets.where(vpc_id: fixtures['ec2_security_group_default_vpc_id']) do + it { should exist } + end + + describe all_subnets.where(vpc_id: 'vpc-00000000') do + it { should_not exist } + end + + describe all_subnets.where(subnet_id: 'subnet-00000000') do + it { should_not exist } + end +end + +control "aws_vpc_subnets properties of default VPC subnet" do + # you should be able to test the cidr_block of a subnet + describe aws_vpc_subnets.where(subnet_id: fixtures['ec2_default_vpc_subnet_id']) do + its('cidr_blocks') { should include '172.31.96.0/20' } + its('states') { should_not include 'pending' } + end +end + +control "aws_vpc_subnets properties of default VPC" do + # you should be able to test the cidr_block of a subnet + describe aws_vpc_subnets.where(vpc_id: fixtures['ec2_security_group_default_vpc_id']) do + its('cidr_blocks') { should include '172.31.96.0/20' } + its('states') { should include 'available' } + end +end diff --git a/test/unit/resources/aws_vpc_subnets_test.rb b/test/unit/resources/aws_vpc_subnets_test.rb new file mode 100644 index 000000000..4ad0e5e3e --- /dev/null +++ b/test/unit/resources/aws_vpc_subnets_test.rb @@ -0,0 +1,120 @@ +require 'ostruct' +require 'helper' +require 'aws_vpc_subnets' + +# MVSB = MockVpcSubnetsBackend +# Abbreviation not used outside this file + +#=============================================================================# +# Constructor Tests +#=============================================================================# +class AwsVpcSubnetsConstructor < Minitest::Test + def setup + AwsVpcSubnets::BackendFactory.select(AwsMVSB::Basic) + end + + def test_constructor_no_args_ok + AwsVpcSubnets.new + end + + def test_constructor_reject_unknown_resource_params + assert_raises(ArgumentError) { AwsVpcSubnets.new(bla: 'blabla') } + end +end + +#=============================================================================# +# Filter Criteria +#=============================================================================# +class AwsVpcSubnetsFilterCriteria < Minitest::Test + def setup + AwsVpcSubnets::BackendFactory.select(AwsMVSB::Basic) + end + + def test_filter_vpc_id + hit = AwsVpcSubnets.new.where(vpc_id: 'vpc-01234567') + assert(hit.exists?) + + miss = AwsVpcSubnets.new.where(vpc_id: 'vpc-87654321') + refute(miss.exists?) + + end + + def test_filter_subnet_id + hit = AwsVpcSubnets.new.where(subnet_id: 'subnet-01234567') + assert(hit.exists?) + + miss = AwsVpcSubnets.new.where(subnet_id: 'subnet-98765432') + refute(miss.exists?) + end + +end + +#=============================================================================# +# Properties +#=============================================================================# +class AwsVpcSubnetProperties < Minitest::Test + def setup + AwsVpcSubnets::BackendFactory.select(AwsMVSB::Basic) + end + + def test_property_vpc_ids + basic = AwsVpcSubnets.new + assert_kind_of(Array, basic.vpc_ids) + assert(basic.vpc_ids.include?('vpc-01234567')) + refute(basic.vpc_ids.include?(nil)) + end + + def test_property_subnet_ids + basic = AwsVpcSubnets.new + assert_kind_of(Array, basic.subnet_ids) + assert(basic.subnet_ids.include?('subnet-01234567')) + refute(basic.subnet_ids.include?(nil)) + end + + def test_property_cidr_blocks + basic = AwsVpcSubnets.new + assert_kind_of(Array, basic.cidr_blocks) + assert(basic.cidr_blocks.include?('10.0.1.0/24')) + refute(basic.cidr_blocks.include?(nil)) + end + + def test_property_states + basic = AwsVpcSubnets.new + assert_kind_of(Array, basic.states) + assert(basic.states.include?('available')) + refute(basic.states.include?(nil)) + end +end + +#=============================================================================# +# Test Fixtures +#=============================================================================# +module AwsMVSB + class Basic < AwsVpcSubnets::Backend + def describe_subnets + fixtures = [ + OpenStruct.new({ + availability_zone: "us-east-1c", + available_ip_address_count: 251, + cidr_block: "10.0.1.0/24", + default_for_az: false, + map_public_ip_on_launch: false, + state: "available", + subnet_id: "subnet-01234567", + vpc_id: "vpc-01234567", + }), + OpenStruct.new({ + availability_zone: "us-east-1b", + available_ip_address_count: 251, + cidr_block: "10.0.2.0/24", + default_for_az: false, + map_public_ip_on_launch: false, + state: "available", + subnet_id: "subnet-00112233", + vpc_id: "vpc-00112233", + }), + ] + OpenStruct.new({ subnets: fixtures }) + end + end +end From 162335aa60f4634ea5039a8eb4e05855e4465f5c Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Wed, 7 Feb 2018 17:10:40 -0500 Subject: [PATCH 182/206] Move files for rename Signed-off-by: Clinton Wolfe --- .../{aws_ec2_security_group.md => aws_security_group.md} | 0 .../{aws_ec2_security_groups.md => aws_security_groups.md} | 0 docs/resources/{aws_vpc_subnet.md => aws_subnet.md} | 0 docs/resources/{aws_vpc_subnets.md => aws_subnets.md} | 0 libraries/{aws_ec2_security_group.rb => aws_security_group.rb} | 0 libraries/{aws_ec2_security_groups.rb => aws_security_groups.rb} | 0 libraries/{aws_vpc_subnet.rb => aws_subnet.rb} | 0 libraries/{aws_vpc_subnets.rb => aws_subnets.rb} | 0 .../controls/{aws_ec2_security_group.rb => aws_security_group.rb} | 0 .../{aws_ec2_security_groups.rb => aws_security_groups.rb} | 0 .../default/verify/controls/{aws_vpc_subnet.rb => aws_subnet.rb} | 0 .../verify/controls/{aws_vpc_subnets.rb => aws_subnets.rb} | 0 ...{aws_ec2_security_group_test.rb => aws_security_group_test.rb} | 0 ...ws_ec2_security_groups_test.rb => aws_security_groups_test.rb} | 0 .../unit/resources/{aws_vpc_subnet_test.rb => aws_subnet_test.rb} | 0 .../resources/{aws_vpc_subnets_test.rb => aws_subnets_test.rb} | 0 16 files changed, 0 insertions(+), 0 deletions(-) rename docs/resources/{aws_ec2_security_group.md => aws_security_group.md} (100%) rename docs/resources/{aws_ec2_security_groups.md => aws_security_groups.md} (100%) rename docs/resources/{aws_vpc_subnet.md => aws_subnet.md} (100%) rename docs/resources/{aws_vpc_subnets.md => aws_subnets.md} (100%) rename libraries/{aws_ec2_security_group.rb => aws_security_group.rb} (100%) rename libraries/{aws_ec2_security_groups.rb => aws_security_groups.rb} (100%) rename libraries/{aws_vpc_subnet.rb => aws_subnet.rb} (100%) rename libraries/{aws_vpc_subnets.rb => aws_subnets.rb} (100%) rename test/integration/default/verify/controls/{aws_ec2_security_group.rb => aws_security_group.rb} (100%) rename test/integration/default/verify/controls/{aws_ec2_security_groups.rb => aws_security_groups.rb} (100%) rename test/integration/default/verify/controls/{aws_vpc_subnet.rb => aws_subnet.rb} (100%) rename test/integration/default/verify/controls/{aws_vpc_subnets.rb => aws_subnets.rb} (100%) rename test/unit/resources/{aws_ec2_security_group_test.rb => aws_security_group_test.rb} (100%) rename test/unit/resources/{aws_ec2_security_groups_test.rb => aws_security_groups_test.rb} (100%) rename test/unit/resources/{aws_vpc_subnet_test.rb => aws_subnet_test.rb} (100%) rename test/unit/resources/{aws_vpc_subnets_test.rb => aws_subnets_test.rb} (100%) diff --git a/docs/resources/aws_ec2_security_group.md b/docs/resources/aws_security_group.md similarity index 100% rename from docs/resources/aws_ec2_security_group.md rename to docs/resources/aws_security_group.md diff --git a/docs/resources/aws_ec2_security_groups.md b/docs/resources/aws_security_groups.md similarity index 100% rename from docs/resources/aws_ec2_security_groups.md rename to docs/resources/aws_security_groups.md diff --git a/docs/resources/aws_vpc_subnet.md b/docs/resources/aws_subnet.md similarity index 100% rename from docs/resources/aws_vpc_subnet.md rename to docs/resources/aws_subnet.md diff --git a/docs/resources/aws_vpc_subnets.md b/docs/resources/aws_subnets.md similarity index 100% rename from docs/resources/aws_vpc_subnets.md rename to docs/resources/aws_subnets.md diff --git a/libraries/aws_ec2_security_group.rb b/libraries/aws_security_group.rb similarity index 100% rename from libraries/aws_ec2_security_group.rb rename to libraries/aws_security_group.rb diff --git a/libraries/aws_ec2_security_groups.rb b/libraries/aws_security_groups.rb similarity index 100% rename from libraries/aws_ec2_security_groups.rb rename to libraries/aws_security_groups.rb diff --git a/libraries/aws_vpc_subnet.rb b/libraries/aws_subnet.rb similarity index 100% rename from libraries/aws_vpc_subnet.rb rename to libraries/aws_subnet.rb diff --git a/libraries/aws_vpc_subnets.rb b/libraries/aws_subnets.rb similarity index 100% rename from libraries/aws_vpc_subnets.rb rename to libraries/aws_subnets.rb diff --git a/test/integration/default/verify/controls/aws_ec2_security_group.rb b/test/integration/default/verify/controls/aws_security_group.rb similarity index 100% rename from test/integration/default/verify/controls/aws_ec2_security_group.rb rename to test/integration/default/verify/controls/aws_security_group.rb diff --git a/test/integration/default/verify/controls/aws_ec2_security_groups.rb b/test/integration/default/verify/controls/aws_security_groups.rb similarity index 100% rename from test/integration/default/verify/controls/aws_ec2_security_groups.rb rename to test/integration/default/verify/controls/aws_security_groups.rb diff --git a/test/integration/default/verify/controls/aws_vpc_subnet.rb b/test/integration/default/verify/controls/aws_subnet.rb similarity index 100% rename from test/integration/default/verify/controls/aws_vpc_subnet.rb rename to test/integration/default/verify/controls/aws_subnet.rb diff --git a/test/integration/default/verify/controls/aws_vpc_subnets.rb b/test/integration/default/verify/controls/aws_subnets.rb similarity index 100% rename from test/integration/default/verify/controls/aws_vpc_subnets.rb rename to test/integration/default/verify/controls/aws_subnets.rb diff --git a/test/unit/resources/aws_ec2_security_group_test.rb b/test/unit/resources/aws_security_group_test.rb similarity index 100% rename from test/unit/resources/aws_ec2_security_group_test.rb rename to test/unit/resources/aws_security_group_test.rb diff --git a/test/unit/resources/aws_ec2_security_groups_test.rb b/test/unit/resources/aws_security_groups_test.rb similarity index 100% rename from test/unit/resources/aws_ec2_security_groups_test.rb rename to test/unit/resources/aws_security_groups_test.rb diff --git a/test/unit/resources/aws_vpc_subnet_test.rb b/test/unit/resources/aws_subnet_test.rb similarity index 100% rename from test/unit/resources/aws_vpc_subnet_test.rb rename to test/unit/resources/aws_subnet_test.rb diff --git a/test/unit/resources/aws_vpc_subnets_test.rb b/test/unit/resources/aws_subnets_test.rb similarity index 100% rename from test/unit/resources/aws_vpc_subnets_test.rb rename to test/unit/resources/aws_subnets_test.rb From b46bd350edb8dad75d4e644333f5ab18e1bc1305 Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Wed, 7 Feb 2018 23:21:28 -0500 Subject: [PATCH 183/206] Modify train integration to support aws:// targeting in core Signed-off-by: Clinton Wolfe --- Gemfile | 2 ++ inspec.gemspec | 2 +- lib/resources/platform.rb | 4 ++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Gemfile b/Gemfile index cd6a3a46b..947200d27 100644 --- a/Gemfile +++ b/Gemfile @@ -2,6 +2,8 @@ source 'https://rubygems.org' gemspec +gem 'train', :git => 'https://github.com/chef/train' + if Gem::Version.new(RUBY_VERSION) < Gem::Version.new('2.2.2') gem 'json', '~> 1.8' gem 'rack', '< 2.0' diff --git a/inspec.gemspec b/inspec.gemspec index 49ffc4a65..0fbddd118 100644 --- a/inspec.gemspec +++ b/inspec.gemspec @@ -26,7 +26,7 @@ Gem::Specification.new do |spec| spec.required_ruby_version = '>= 2.3' - spec.add_dependency 'train', '~> 0.32' + # spec.add_dependency 'train', '~> 0.32' spec.add_dependency 'thor', '~> 0.19' spec.add_dependency 'json', '>= 1.8', '< 3.0' spec.add_dependency 'method_source', '~> 0.8' diff --git a/lib/resources/platform.rb b/lib/resources/platform.rb index 970fd9ba9..e14e0bf92 100644 --- a/lib/resources/platform.rb +++ b/lib/resources/platform.rb @@ -15,13 +15,13 @@ module Inspec::Resources " def initialize - @platform = inspec.backend.os + @platform = inspec.backend.platform end # add helper methods for easy access of properties %w{family release arch}.each do |property| define_method(property.to_sym) do - @platform.send(property) + @platform[property] end end From 4e07508317b0c031723fde69c43477bd84608243 Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Wed, 7 Feb 2018 23:23:05 -0500 Subject: [PATCH 184/206] Rename classes and resource names in files Signed-off-by: Clinton Wolfe --- docs/aws_vpc_subnet.md | 148 ------------------ docs/resources/aws_security_group.md | 40 ++--- docs/resources/aws_security_groups.md | 22 +-- docs/resources/aws_subnet.md | 36 ++--- docs/resources/aws_subnets.md | 28 ++-- libraries/aws_security_group.rb | 16 +- libraries/aws_security_groups.rb | 12 +- libraries/aws_subnet.rb | 12 +- libraries/aws_subnets.rb | 10 +- .../verify/controls/aws_security_group.rb | 14 +- .../verify/controls/aws_security_groups.rb | 8 +- .../default/verify/controls/aws_subnet.rb | 16 +- .../default/verify/controls/aws_subnets.rb | 12 +- .../unit/resources/aws_security_group_test.rb | 40 ++--- .../resources/aws_security_groups_test.rb | 34 ++-- test/unit/resources/aws_subnet_test.rb | 64 ++++---- test/unit/resources/aws_subnets_test.rb | 36 ++--- 17 files changed, 200 insertions(+), 348 deletions(-) delete mode 100644 docs/aws_vpc_subnet.md diff --git a/docs/aws_vpc_subnet.md b/docs/aws_vpc_subnet.md deleted file mode 100644 index 1dc8b2104..000000000 --- a/docs/aws_vpc_subnet.md +++ /dev/null @@ -1,148 +0,0 @@ ---- -title: About the aws_vpc_subnet Resource ---- - -# aws_vpc_subnet - -Use the `aws_vpc_subnet` InSpec audit resource to test properties of a vpc subnet. - -To test properties of a single VPC subnet, use the `aws_vpc_subnet` resource. - -To test properties of all or a group of VPC subnets, use the `aws_vpc_subnets` resource. - -
- -## Syntax - -An `aws_vpc_subnet` resource block uses the parameter to select a VPC and a subnet in the VPC. - - describe aws_vpc_subnet(vpc_id: 'vpc-01234567', subnet_id: 'subnet-1234567') do - it { should exist } - its('cidr_block') { should eq ['10.0.1.0/24'] } - end - -
- -## Resource Parameters - -This InSpec resource accepts the following parameters, which are used to search for the VPCs subnet. - -### vpc_id - -A string identifying the VPC which contains zero or more subnets. - - # This will error if there is more than the default SG - describe aws_vpc_subnet(vpc_id: 'vpc-12345678', 'subnet-1234567') do - it { should exist } - end - -### subnet_id - -A string identifying the subnet that the VPC contains. - - # This will error if there is more than the default SG - describe aws_vpc_subnet(vpc_id: 'vpc-12345678', subnet_id: 'subnet-12345678') do - it { should exist } - end - -
- -## Properties - -### assign_ipv_6_address_on_creation - -Detects whether the network interface on the subnet accepts IPv6 addresses. - - describe aws_vpc_subnet(vpc_id: 'vpc-12345678' , subnet_id: 'subnet-12345678') do - its('assign_ipv_6_address_on_creation') { should eq false } - end - -### availability_zone - -Provides the Availability Zone of the subnet. - - describe aws_vpc_subnet(vpc_id: 'vpc-12345678' , subnet_id: 'subnet-12345678') do - its('availability_zone') { should eq 'us-east-1c' } - end - -### available_ip_address_count - -Provides the number of available IPv4 addresses on the subnet. - - describe aws_vpc_subnet(vpc_id: 'vpc-12345678' , subnet_id: 'subnet-12345678') do - its('available_ip_address_count') { should eq 251 } - end - -### cidr_block - -Provides the block of ip addresses specified to the subnet. - - describe aws_vpc_subnet(vpc_id: 'vpc-12345678' , subnet_id: 'subnet-12345678') do - its('cidr_block') { should eq '10.0.1.0/24' } - end - -### default_for_az - -Detects if this is the default subnet for the Availability Zone. - - describe aws_vpc_subnet(vpc_id: 'vpc-12345678' , subnet_id: 'subnet-12345678') do - its('default_for_az') { should eq false } - end - -### ipv_6_cidr_block_association_set - -Provides information about the IPv6 cidr_block associatied with the subnet. - - describe aws_vpc_subnet(vpc_id: 'vpc-12345678' , subnet_id: 'subnet-12345678') do - its('ipv_6_cidr_block_association_set') { should eq [ - { - "Ipv6CidrBlock": "2001:db8:1234:a101::/64", - "AssociationId": "subnet-cidr-assoc-30e7e348", - "Ipv6CidrBlockState": { - "State": "ASSOCIATED" - } - } - ] } - end - -### map_public_ip_on_launch - -Provides the ID of the VPC the subnet is in. - - describe aws_vpc_subnet(vpc_id: 'vpc-12345678' , subnet_id: 'subnet-12345678') do - its('map_public_ip_on_launch') { should eq false } - end - -### state - -Provides the ID of the VPC the subnet is in. - - describe aws_vpc_subnet(vpc_id: 'vpc-12345678' , subnet_id: 'subnet-12345678') do - its('state') { should eq 'available' } - end - -### subnet_id - -Provides the ID of the VPC the subnet is in. - - describe aws_vpc_subnet(vpc_id: 'vpc-12345678' , subnet_id: 'subnet-12345678') do - its('subnet_id') { should eq 'subnet-12345678' } - end - -### vpc_id - -Provides the ID of the VPC the subnet is in. - - describe aws_vpc_subnet(vpc_id: 'vpc-12345678' , subnet_id: 'subnet-12345678') do - its('vpc_id') { should eq 'vpc-12345678' } - end - -## Matchers - -### exist - -The `exist` matcher indicates that a subnet exists for the specified vpc. - - describe aws_vpc_subnet(vpc_id: 'vpc-1234567', subnet_id: 'subnet-12345678') do - it { should exist } - end diff --git a/docs/resources/aws_security_group.md b/docs/resources/aws_security_group.md index b46919175..746ade110 100644 --- a/docs/resources/aws_security_group.md +++ b/docs/resources/aws_security_group.md @@ -1,10 +1,10 @@ --- -title: About the aws_ec2_security_group Resource +title: About the aws_security_group Resource --- -# aws_ec2_security_group +# aws_security_group -Use the `aws_ec2_security_group` InSpec audit resource to test detailed properties of an individual Security Group (SG). +Use the `aws_security_group` InSpec audit resource to test detailed properties of an individual Security Group (SG). SGs are a networking construct which contain ingress and egress rules for network communications. SGs may be attached to EC2 instances, as well as certain other AWS resources. Along with Network Access Control Lists, SGs are one of the two main mechanisms of enforcing network-level security. @@ -12,17 +12,17 @@ SGs are a networking construct which contain ingress and egress rules for networ ## Syntax -An `aws_ec2_security_group` resource block uses resource parameters to search for a Security Group, and then tests that Security Group. If no SGs match, no error is raised, but the `exists` matcher will return `false` and all properties will be `nil`. If more than one SG matches (due to vague search parameters), an error is raised. +An `aws_security_group` resource block uses resource parameters to search for a Security Group, and then tests that Security Group. If no SGs match, no error is raised, but the `exists` matcher will return `false` and all properties will be `nil`. If more than one SG matches (due to vague search parameters), an error is raised. # Ensure you have a security group with a certain ID # This is "safe" - SG IDs are unique within an account - describe aws_ec2_security_group('sg-12345678') do + describe aws_security_group('sg-12345678') do it { should exist } end # Ensure you have a security group with a certain ID # This uses hash syntax - describe aws_ec2_security_group(id: 'sg-12345678') do + describe aws_security_group(id: 'sg-12345678') do it { should exist } end @@ -32,7 +32,7 @@ An `aws_ec2_security_group` resource block uses resource parameters to search fo The following examples show how to use this InSpec audit resource. -As this is the initial release of `aws_ec2_security_group`, its limited functionality precludes examples. +As this is the initial release of `aws_security_group`, its limited functionality precludes examples.
@@ -45,17 +45,17 @@ This InSpec resource accepts the following parameters, which are used to search The Security Group ID of the Security Group. This is of the format `sg-` followed by 8 hexadecimal characters. The ID is unique within your AWS account; using ID ensures that you will never match more than one SG. The ID is also the default resource parameter, so you may omit the hash syntax. # Using Hash syntax - describe aws_ec2_security_group(id: 'sg-12345678') do + describe aws_security_group(id: 'sg-12345678') do it { should exist } end # group_id is an alias for id - describe aws_ec2_security_group(group_id: 'sg-12345678') do + describe aws_security_group(group_id: 'sg-12345678') do it { should exist } end # Or omit hash syntax, rely on it being the default parameter - describe aws_ec2_security_group('sg-12345678') do + describe aws_security_group('sg-12345678') do it { should exist } end @@ -64,12 +64,12 @@ The Security Group ID of the Security Group. This is of the format `sg-` follow The string Name of the Security Group. Every VPC has a security group named 'default'. Names are unique within a VPC, but not within an AWS account. # Get default security group for a certain VPC - describe aws_ec2_security_group(group_name: 'default', vpc_id: vpc_id: 'vpc-12345678') do + describe aws_security_group(group_name: 'default', vpc_id: vpc_id: 'vpc-12345678') do it { should exist } end # This will throw an error if there is a 'backend' SG in more than one VPC. - describe aws_ec2_security_group(group_name: 'backend') do + describe aws_security_group(group_name: 'backend') do it { should exist } end @@ -78,7 +78,7 @@ The string Name of the Security Group. Every VPC has a security group named 'de A string identifying the VPC which contains the security group. Since VPCs commonly contain many SGs, you should add additional parameters to ensure you find exactly one SG. # This will error if there is more than the default SG - describe aws_ec2_security_group(vpc_id: 'vpc-12345678') do + describe aws_security_group(vpc_id: 'vpc-12345678') do it { should exist } end @@ -91,12 +91,12 @@ A string identifying the VPC which contains the security group. Since VPCs comm The control will pass if the specified SG was found. Use should_not if you want to verify that the specified SG does not exist. # You will always have at least one SG, the VPC default SG - describe aws_ec2_security_group(group_name: 'default') + describe aws_security_group(group_name: 'default') it { should exist } end # Make sure we don't have any security groups with the name 'nogood' - describe aws_ec2_security_group(group_name: 'nogood') + describe aws_security_group(group_name: 'nogood') it { should_not exist } end @@ -107,19 +107,19 @@ The control will pass if the specified SG was found. Use should_not if you want Provides the Security Group ID. # Inspect the group ID of the default group - describe aws_ec2_security_group(group_name: 'default', vpc_id: vpc_id: 'vpc-12345678') do + describe aws_security_group(group_name: 'default', vpc_id: vpc_id: 'vpc-12345678') do its('group_id') { should cmp 'sg-12345678' } end # Store the group ID in a Ruby variable for use elsewhere - sg_id = aws_ec2_security_group(group_name: 'default', vpc_id: vpc_id: 'vpc-12345678').group_id + sg_id = aws_security_group(group_name: 'default', vpc_id: vpc_id: 'vpc-12345678').group_id ### group_name A String reflecting the name that was given to the SG at creation time. # Inspect the group name of a particular group - describe aws_ec2_security_group('sg-12345678') do + describe aws_security_group('sg-12345678') do its('group_name') { should cmp 'my_group' } end @@ -128,7 +128,7 @@ A String reflecting the name that was given to the SG at creation time. A String reflecting the human-meaningful description that was given to the SG at creation time. # Require a description of a particular group - describe aws_ec2_security_group('sg-12345678') do + describe aws_security_group('sg-12345678') do its('description') { should_not be_empty } end @@ -137,6 +137,6 @@ A String reflecting the human-meaningful description that was given to the SG at A String in the format 'vpc-' followed by 8 hexadecimal characters reflecting VPC that contains the security group. # Inspec the VPC ID of a particular group - describe aws_ec2_security_group('sg-12345678') do + describe aws_security_group('sg-12345678') do its('vpc_id') { should cmp 'vpc-12345678' } end \ No newline at end of file diff --git a/docs/resources/aws_security_groups.md b/docs/resources/aws_security_groups.md index efddd8f36..e4d642b64 100644 --- a/docs/resources/aws_security_groups.md +++ b/docs/resources/aws_security_groups.md @@ -1,10 +1,10 @@ --- -title: About the aws_ec2_security_groups Resource +title: About the aws_security_groups Resource --- -# aws_ec2_security_groups +# aws_security_groups -Use the `aws_ec2_security_groups` InSpec audit resource to test properties of some or all security groups. +Use the `aws_security_groups` InSpec audit resource to test properties of some or all security groups. Security groups are a networking construct which contain ingress and egress rules for network communications. Security groups may be attached to EC2 instances, as well as certain other AWS resources. Along with Network Access Control Lists, Security Groups are one of the two main mechanisms of enforcing network-level security. @@ -12,10 +12,10 @@ Security groups are a networking construct which contain ingress and egress rule ## Syntax -An `aws_ec2_security_groups` resource block uses an optional filter to select a group of security groups and then tests that group. +An `aws_security_groups` resource block uses an optional filter to select a group of security groups and then tests that group. # Verify you have more than the default security group - describe aws_ec2_security_groups do + describe aws_security_groups do its('entries.count') { should be > 1 } end @@ -25,7 +25,7 @@ An `aws_ec2_security_groups` resource block uses an optional filter to select a The following examples show how to use this InSpec audit resource. -As this is the initial release of `aws_ec2_security_groups`, its limited functionality precludes examples. +As this is the initial release of `aws_security_groups`, its limited functionality precludes examples.
@@ -36,7 +36,7 @@ As this is the initial release of `aws_ec2_security_groups`, its limited functio The control will pass if the filter returns at least one result. Use should_not if you expect zero matches. # You will always have at least one SG, the VPC default SG - describe aws_ec2_security_groups + describe aws_security_groups it { should exist } end @@ -47,7 +47,7 @@ The control will pass if the filter returns at least one result. Use should_not A string identifying the VPC which contains the security group. # Look for a particular security group in just one VPC - describe aws_ec2_security_groups.where( vpc_id: 'vpc-12345678') do + describe aws_security_groups.where( vpc_id: 'vpc-12345678') do its('group_ids') { should include('sg-abcdef12')} end @@ -56,7 +56,7 @@ A string identifying the VPC which contains the security group. A string identifying a group. Since groups are contained in VPCs, group names are unique within the AWS account, but not across VPCs. # Examine the default security group in all VPCs - describe aws_ec2_security_groups.where( group_name: 'default') do + describe aws_security_groups.where( group_name: 'default') do it { should exist } end @@ -67,7 +67,7 @@ A string identifying a group. Since groups are contained in VPCs, group names a Provides a list of all security group IDs matched. - describe aws_ec2_security_groups do + describe aws_security_groups do its('group_ids') { should include('sg-12345678') } end @@ -76,6 +76,6 @@ Provides a list of all security group IDs matched. Provides access to the raw results of the query. This can be useful for checking counts and other advanced operations. # Allow at most 100 security groups on the account - describe aws_ec2_security_groups do + describe aws_security_groups do its('entries.count') { should be <= 100} end diff --git a/docs/resources/aws_subnet.md b/docs/resources/aws_subnet.md index 702925bf7..2129574fa 100644 --- a/docs/resources/aws_subnet.md +++ b/docs/resources/aws_subnet.md @@ -1,22 +1,22 @@ --- -title: About the aws_vpc_subnet Resource +title: About the aws_subnet Resource --- -# aws_vpc_subnet +# aws_subnet -Use the `aws_vpc_subnet` InSpec audit resource to test properties of a vpc subnet. +Use the `aws_subnet` InSpec audit resource to test properties of a vpc subnet. -To test properties of a single VPC subnet, use the `aws_vpc_subnet` resource. +To test properties of a single VPC subnet, use the `aws_subnet` resource. -To test properties of all or a group of VPC subnets, use the `aws_vpc_subnets` resource. +To test properties of all or a group of VPC subnets, use the `aws_subnets` resource.
## Syntax -An `aws_vpc_subnet` resource block uses the parameter to select a VPC and a subnet in the VPC. +An `aws_subnet` resource block uses the parameter to select a VPC and a subnet in the VPC. - describe aws_vpc_subnet(subnet_id: 'subnet-1234567') do + describe aws_subnet(subnet_id: 'subnet-1234567') do it { should exist } its('cidr_block') { should eq '10.0.1.0/24' } end @@ -32,7 +32,7 @@ This InSpec resource accepts the following parameters, which are used to search A string identifying the subnet that the VPC contains. # This will error if there is more than the default SG - describe aws_vpc_subnet(subnet_id: 'subnet-12345678') do + describe aws_subnet(subnet_id: 'subnet-12345678') do it { should exist } end @@ -44,7 +44,7 @@ A string identifying the subnet that the VPC contains. Detects whether the network interface on the subnet accepts IPv6 addresses. - describe aws_vpc_subnet(subnet_id: 'subnet-12345678') do + describe aws_subnet(subnet_id: 'subnet-12345678') do it { should be_assigning_ipv_6_address_on_creation } end @@ -52,7 +52,7 @@ Detects whether the network interface on the subnet accepts IPv6 addresses. Provides the current state of the subnet. - describe aws_vpc_subnet(subnet_id: 'subnet-12345678') do + describe aws_subnet(subnet_id: 'subnet-12345678') do it { should be_available } end @@ -60,7 +60,7 @@ Provides the current state of the subnet. Detects if this is the default subnet for the Availability Zone. - describe aws_vpc_subnet(subnet_id: 'subnet-12345678') do + describe aws_subnet(subnet_id: 'subnet-12345678') do it { should be_default_for_az } end @@ -68,7 +68,7 @@ Detects if this is the default subnet for the Availability Zone. The `exist` matcher indicates that a subnet exists for the specified vpc. - describe aws_vpc_subnet(subnet_id: 'subnet-12345678') do + describe aws_subnet(subnet_id: 'subnet-12345678') do it { should exist } end @@ -76,7 +76,7 @@ The `exist` matcher indicates that a subnet exists for the specified vpc. Provides the ID of the VPC the subnet is in. - describe aws_vpc_subnet(subnet_id: 'subnet-12345678') do + describe aws_subnet(subnet_id: 'subnet-12345678') do it { should be_mapping_public_ip_on_launch } end @@ -86,7 +86,7 @@ Provides the ID of the VPC the subnet is in. Provides the Availability Zone of the subnet. - describe aws_vpc_subnet(subnet_id: 'subnet-12345678') do + describe aws_subnet(subnet_id: 'subnet-12345678') do its('availability_zone') { should eq 'us-east-1c' } end @@ -94,7 +94,7 @@ Provides the Availability Zone of the subnet. Provides the number of available IPv4 addresses on the subnet. - describe aws_vpc_subnet(subnet_id: 'subnet-12345678') do + describe aws_subnet(subnet_id: 'subnet-12345678') do its('available_ip_address_count') { should eq 251 } end @@ -102,7 +102,7 @@ Provides the number of available IPv4 addresses on the subnet. Provides the block of ip addresses specified to the subnet. - describe aws_vpc_subnet(subnet_id: 'subnet-12345678') do + describe aws_subnet(subnet_id: 'subnet-12345678') do its('cidr_block') { should eq '10.0.1.0/24' } end @@ -110,7 +110,7 @@ Provides the block of ip addresses specified to the subnet. Provides the ID of the Subnet. - describe aws_vpc_subnet(subnet_id: 'subnet-12345678') do + describe aws_subnet(subnet_id: 'subnet-12345678') do its('subnet_id') { should eq 'subnet-12345678' } end @@ -118,6 +118,6 @@ Provides the ID of the Subnet. Provides the ID of the VPC the subnet is in. - describe aws_vpc_subnet(subnet_id: 'subnet-12345678') do + describe aws_subnet(subnet_id: 'subnet-12345678') do its('vpc_id') { should eq 'vpc-12345678' } end diff --git a/docs/resources/aws_subnets.md b/docs/resources/aws_subnets.md index 7f47a6b11..6d9251ea4 100644 --- a/docs/resources/aws_subnets.md +++ b/docs/resources/aws_subnets.md @@ -1,10 +1,10 @@ --- -title: About the aws_vpc_subnets Resource +title: About the aws_subnets Resource --- -# aws_vpc_subnets +# aws_subnets -Use the `aws_vpc_subnets` InSpec audit resource to test properties of some or all subnets. +Use the `aws_subnets` InSpec audit resource to test properties of some or all subnets. Subnets are networks within a VPC that can have their own block of IP address's and ACL's. VPCs span across all availability zones in AWS, while a subnet in a VPC can only span a single availability zone. @@ -14,10 +14,10 @@ Separating IP addresses allows for protection if there is a failure in one avail ## Syntax -An `aws_vpc_subnets` resource block uses an optional filter to select a group of subnets and then tests that group. +An `aws_subnets` resource block uses an optional filter to select a group of subnets and then tests that group. # Test all subnets within a single vpc - describe aws_vpc_subnets.where(vpc_id: 'vpc-12345678') do + describe aws_subnets.where(vpc_id: 'vpc-12345678') do its('subnet_ids') { should include 'subnet-12345678' } its('subnet_ids') { should include 'subnet-98765432' } end @@ -28,7 +28,7 @@ An `aws_vpc_subnets` resource block uses an optional filter to select a group of The following examples show how to use this InSpec audit resource. -As this is the initial release of `aws_vpc_subnets`, its limited functionality precludes examples. +As this is the initial release of `aws_subnets`, its limited functionality precludes examples.
@@ -39,12 +39,12 @@ As this is the initial release of `aws_vpc_subnets`, its limited functionality p The control will pass if the filter returns at least one result. Use should_not if you expect zero matches. # You dont always have subnets, so you can test if there are any. - describe aws_vpc_subnets + describe aws_subnets it { should exist } end # Test that there are subnets in a vpc - describe aws_vpc_subnets.where(vpc_id: 'vpc-12345678') + describe aws_subnets.where(vpc_id: 'vpc-12345678') it { should exist } end @@ -55,7 +55,7 @@ The control will pass if the filter returns at least one result. Use should_not A string identifying the VPC which may or may not contain subnets. # Look for all subnts within a vpc. - describe aws_vpc_subnets.where( vpc_id: 'vpc-12345678') do + describe aws_subnets.where( vpc_id: 'vpc-12345678') do its('subnet_ids') { should include 'subnet-12345678' } its('subnet_ids') { should include 'subnet-98765432' } end @@ -65,7 +65,7 @@ A string identifying the VPC which may or may not contain subnets. A string identifying a specific subnet. # Examine a specific subnet - describe aws_vpc_subnets.where(subnet_id: 'subnet-12345678') do + describe aws_subnets.where(subnet_id: 'subnet-12345678') do its('cidr_blocks') { should eq ['10.0.1.0/24'] } end @@ -77,7 +77,7 @@ A string identifying a specific subnet. Provides a string that contains the cidr block of ip addresses that can be given in the subnet. # Examine a specific subnets cidr_blocks - describe aws_vpc_subnets.where( subnet_id: 'subnet-12345678') do + describe aws_subnets.where( subnet_id: 'subnet-12345678') do its('cidr_blocks') { should eq ['10.0.1.0/24'] } end @@ -86,7 +86,7 @@ Provides a string that contains the cidr block of ip addresses that can be given Provides an array containing a string of the vpc_id associated with a subnet. # Examine a specific subnets VPC IDS - describe aws_vpc_subnets.where( subnet_id: 'subnet-12345678') do + describe aws_subnets.where( subnet_id: 'subnet-12345678') do its('vpc_ids') { should include 'vpc-12345678' } end @@ -95,7 +95,7 @@ Provides an array containing a string of the vpc_id associated with a subnet. Provides an array of strings containing the subnet IDs associated with a vpc. # Examine a specific vpcs Subnet IDs - describe aws_vpc_subnets.where( vpc_id: 'vpc-12345678') do + describe aws_subnets.where( vpc_id: 'vpc-12345678') do its('subnet_ids') { should include 'subnet-12345678' } its('subnet_ids') { should include 'subnet-98765432' } end @@ -105,6 +105,6 @@ Provides an array of strings containing the subnet IDs associated with a vpc. Provides an array of strings including whether the subnets are available or not. # Examine a specific vpcs Subnet IDs - describe aws_vpc_subnets.where( vpc_id: 'vpc-12345678') do + describe aws_subnets.where( vpc_id: 'vpc-12345678') do its('states') { should_not include 'pending' } end diff --git a/libraries/aws_security_group.rb b/libraries/aws_security_group.rb index d03113d88..bdd67c9be 100644 --- a/libraries/aws_security_group.rb +++ b/libraries/aws_security_group.rb @@ -1,10 +1,10 @@ require '_aws' -class AwsEc2SecurityGroup < Inspec.resource(1) - name 'aws_ec2_security_group' +class AwsSecurityGroup < Inspec.resource(1) + name 'aws_security_group' desc 'Verifies settings for an individual AWS Security Group.' example ' - describe aws_ec2_security_group("sg-12345678") do + describe aws_security_group("sg-12345678") do it { should exist } end ' @@ -30,23 +30,23 @@ class AwsEc2SecurityGroup < Inspec.resource(1) recognized_params[:group_id] = recognized_params.delete(:id) if recognized_params.key?(:id) if recognized_params.key?(:group_id) && recognized_params[:group_id] !~ /^sg\-[0-9a-f]{8}/ - raise ArgumentError, 'aws_ec2_security_group security group ID must be in the format "sg-" followed by 8 hexadecimal characters.' + raise ArgumentError, 'aws_security_group security group ID must be in the format "sg-" followed by 8 hexadecimal characters.' end if recognized_params.key?(:vpc_id) && recognized_params[:vpc_id] !~ /^vpc\-[0-9a-f]{8}/ - raise ArgumentError, 'aws_ec2_security_group VPC ID must be in the format "vpc-" followed by 8 hexadecimal characters.' + raise ArgumentError, 'aws_security_group VPC ID must be in the format "vpc-" followed by 8 hexadecimal characters.' end validated_params = recognized_params if validated_params.empty? - raise ArgumentError, 'You must provide parameters to aws_ec2_security_group, such as group_name, group_id, or vpc_id.g_group.' + raise ArgumentError, 'You must provide parameters to aws_security_group, such as group_name, group_id, or vpc_id.g_group.' end validated_params end def fetch_from_aws - backend = AwsEc2SecurityGroup::BackendFactory.create + backend = AwsSecurityGroup::BackendFactory.create # Transform into filter format expected by AWS filters = [] @@ -83,7 +83,7 @@ class AwsEc2SecurityGroup < Inspec.resource(1) class Backend class AwsClientApi < Backend - AwsEc2SecurityGroup::BackendFactory.set_default_backend self + AwsSecurityGroup::BackendFactory.set_default_backend self def describe_security_groups(query) AWSConnection.new.ec2_client.describe_security_groups(query) diff --git a/libraries/aws_security_groups.rb b/libraries/aws_security_groups.rb index ff4f3da72..e67e39c32 100644 --- a/libraries/aws_security_groups.rb +++ b/libraries/aws_security_groups.rb @@ -1,16 +1,16 @@ require '_aws' -class AwsEc2SecurityGroups < Inspec.resource(1) - name 'aws_ec2_security_groups' +class AwsSecurityGroups < Inspec.resource(1) + name 'aws_security_groups' desc 'Verifies settings for AWS Security Groups in bulk' example <<-EOX # Verify that you have security groups defined - describe aws_ec2_security_groups do + describe aws_security_groups do it { should exist } end # Verify you have more than the default security group - describe aws_ec2_security_groups do + describe aws_security_groups do its('entries.count') { should be > 1 } end EOX @@ -67,7 +67,7 @@ EOX def fetch_from_backend(criteria) @table = [] - backend = AwsEc2SecurityGroups::BackendFactory.create + backend = AwsSecurityGroups::BackendFactory.create # Note: should we ever implement server-side filtering # (and this is a very good resource for that), # we will need to reformat the criteria we are sending to AWS. @@ -86,7 +86,7 @@ EOX class Backend class AwsClientApi < Backend - AwsEc2SecurityGroups::BackendFactory.set_default_backend self + AwsSecurityGroups::BackendFactory.set_default_backend self def describe_security_groups(query) AWSConnection.new.ec2_client.describe_security_groups(query) diff --git a/libraries/aws_subnet.rb b/libraries/aws_subnet.rb index 8d9c1ad90..cc4cc1adf 100644 --- a/libraries/aws_subnet.rb +++ b/libraries/aws_subnet.rb @@ -2,11 +2,11 @@ require '_aws' -class AwsVpcSubnet < Inspec.resource(1) - name 'aws_vpc_subnet' +class AwsSubnet < Inspec.resource(1) + name 'aws_subnet' desc 'This resource is used to test the attributes of a VPC subnet' example " - describe aws_vpc_subnet(subnet_id: 'subnet-12345678') do + describe aws_subnet(subnet_id: 'subnet-12345678') do it { should exist } its('cidr_block') { should eq '10.0.1.0/24' } end @@ -37,18 +37,18 @@ class AwsVpcSubnet < Inspec.resource(1) # Make sure the subnet_id parameter was specified and in the correct form. if validated_params.key?(:subnet_id) && validated_params[:subnet_id] !~ /^subnet\-[0-9a-f]{8}/ - raise ArgumentError, 'aws_vpc_subnet Subnet ID must be in the format "subnet-" followed by 8 hexadecimal characters.' + raise ArgumentError, 'aws_subnet Subnet ID must be in the format "subnet-" followed by 8 hexadecimal characters.' end if validated_params.empty? - raise ArgumentError, 'You must provide a subnet_id to aws_vpc_subnet.' + raise ArgumentError, 'You must provide a subnet_id to aws_subnet.' end validated_params end def fetch_from_aws - backend = AwsVpcSubnet::BackendFactory.create + backend = AwsSubnet::BackendFactory.create # Transform into filter format expected by AWS filters = [] diff --git a/libraries/aws_subnets.rb b/libraries/aws_subnets.rb index 11d264a0b..01518b63f 100644 --- a/libraries/aws_subnets.rb +++ b/libraries/aws_subnets.rb @@ -2,12 +2,12 @@ require '_aws' -class AwsVpcSubnets < Inspec.resource(1) - name 'aws_vpc_subnets' +class AwsSubnets < Inspec.resource(1) + name 'aws_subnets' desc 'Verifies settings for VPC Subnets in bulk' example " # you should be able to test the cidr_block of a subnet - describe aws_vpc_subnets.where(vpc_id: 'vpc-123456789') do + describe aws_subnets.where(vpc_id: 'vpc-123456789') do its('subnet_ids') { should eq ['subnet-12345678', 'subnet-87654321'] } its('cidr_blocks') { should eq ['172.31.96.0/20'] } its('states') { should_not include 'pending' } @@ -15,7 +15,7 @@ class AwsVpcSubnets < Inspec.resource(1) " def initialize - backend = AwsVpcSubnets::BackendFactory.create + backend = AwsSubnets::BackendFactory.create @table = backend.describe_subnets.subnets.map(&:to_h) end @@ -44,7 +44,7 @@ class AwsVpcSubnets < Inspec.resource(1) class Backend class AwsClientApi < Backend - AwsVpcSubnets::BackendFactory.set_default_backend self + AwsSubnets::BackendFactory.set_default_backend self def describe_subnets(query = {}) AWSConnection.new.ec2_client.describe_subnets(query) diff --git a/test/integration/default/verify/controls/aws_security_group.rb b/test/integration/default/verify/controls/aws_security_group.rb index 8f0ba1d87..d976d90a6 100644 --- a/test/integration/default/verify/controls/aws_security_group.rb +++ b/test/integration/default/verify/controls/aws_security_group.rb @@ -12,28 +12,28 @@ fixtures = {} ) end -control "aws_ec2_security_group recall of default VPC" do +control "aws_security_group recall of default VPC" do - describe aws_ec2_security_group(fixtures['ec2_security_group_default_group_id']) do + describe aws_security_group(fixtures['ec2_security_group_default_group_id']) do it { should exist } end - describe aws_ec2_security_group(group_name: 'default', vpc_id: fixtures['ec2_security_group_default_vpc_id']) do + describe aws_security_group(group_name: 'default', vpc_id: fixtures['ec2_security_group_default_vpc_id']) do it { should exist } end - describe aws_ec2_security_group(group_name: 'no-such-security-group') do + describe aws_security_group(group_name: 'no-such-security-group') do it { should_not exist } end end -control "aws_ec2_security_group properties" do +control "aws_security_group properties" do # You should be able to find the default security group's ID. - describe aws_ec2_security_group(fixtures['ec2_security_group_default_group_id']) do + describe aws_security_group(fixtures['ec2_security_group_default_group_id']) do its('group_id') { should cmp fixtures['ec2_security_group_default_group_id'] } end - describe aws_ec2_security_group(fixtures['ec2_security_group_alpha_group_id']) do + describe aws_security_group(fixtures['ec2_security_group_alpha_group_id']) do its('group_name') { should cmp fixtures['ec2_security_group_alpha_group_name'] } its('vpc_id') { should cmp fixtures['ec2_security_group_default_vpc_id'] } its('description') { should cmp 'SG alpha' } diff --git a/test/integration/default/verify/controls/aws_security_groups.rb b/test/integration/default/verify/controls/aws_security_groups.rb index 2e5c33121..723000d11 100644 --- a/test/integration/default/verify/controls/aws_security_groups.rb +++ b/test/integration/default/verify/controls/aws_security_groups.rb @@ -11,7 +11,7 @@ fixtures = {} end control "aws_security_groups client-side filtering" do - all_groups = aws_ec2_security_groups + all_groups = aws_security_groups # You should always have at least one security group describe all_groups do @@ -37,14 +37,14 @@ end control "aws_security_groups properties" do # You should be able to find the default security group's ID. - describe aws_ec2_security_groups.where(vpc_id: fixtures['ec2_security_group_default_vpc_id']) do + describe aws_security_groups.where(vpc_id: fixtures['ec2_security_group_default_vpc_id']) do its('group_ids') { should include fixtures['ec2_security_group_default_group_id'] } end end -control "aws_ec2_security_groups" do +control "aws_security_groups" do # Verify you have more than the default security group - describe aws_ec2_security_groups do + describe aws_security_groups do its('entries.count') { should be >= 2 } end end diff --git a/test/integration/default/verify/controls/aws_subnet.rb b/test/integration/default/verify/controls/aws_subnet.rb index c41d9ca0f..22055fdd0 100644 --- a/test/integration/default/verify/controls/aws_subnet.rb +++ b/test/integration/default/verify/controls/aws_subnet.rb @@ -10,24 +10,24 @@ fixtures = {} ) end -control "aws_vpc_subnet recall of subnet_01" do +control "aws_subnet recall of subnet_01" do # Test hash given subnet_id - describe aws_vpc_subnet(subnet_id: fixtures['ec2_default_vpc_subnet_01_id']) do + describe aws_subnet(subnet_id: fixtures['ec2_default_vpc_subnet_01_id']) do it { should exist } end # Test scalar works - describe aws_vpc_subnet(fixtures['ec2_default_vpc_subnet_01_id']) do + describe aws_subnet(fixtures['ec2_default_vpc_subnet_01_id']) do it { should exist } end - describe aws_vpc_subnet(subnet_id: 'subnet-00000000') do + describe aws_subnet(subnet_id: 'subnet-00000000') do it { should_not exist } end end -control "aws_vpc_subnet properties of subnet_01" do - describe aws_vpc_subnet(subnet_id: fixtures['ec2_default_vpc_subnet_01_id']) do +control "aws_subnet properties of subnet_01" do + describe aws_subnet(subnet_id: fixtures['ec2_default_vpc_subnet_01_id']) do its('vpc_id') { should eq fixtures['ec2_security_group_default_vpc_id'] } its('subnet_id') { should eq fixtures['ec2_default_vpc_subnet_01_id'] } its('cidr_block') { should eq '172.31.96.0/20' } @@ -37,8 +37,8 @@ control "aws_vpc_subnet properties of subnet_01" do end end -control "aws_vpc_subnet matchers of subnet_01" do - describe aws_vpc_subnet(subnet_id: fixtures['ec2_default_vpc_subnet_01_id']) do +control "aws_subnet matchers of subnet_01" do + describe aws_subnet(subnet_id: fixtures['ec2_default_vpc_subnet_01_id']) do it { should be_available } it { should_not be_mapping_public_ip_on_launch } it { should_not be_default_for_az } diff --git a/test/integration/default/verify/controls/aws_subnets.rb b/test/integration/default/verify/controls/aws_subnets.rb index 828a0650b..7ea7dc6e9 100644 --- a/test/integration/default/verify/controls/aws_subnets.rb +++ b/test/integration/default/verify/controls/aws_subnets.rb @@ -10,8 +10,8 @@ fixtures = {} ) end -control "aws_vpc_subnets recall" do - all_subnets = aws_vpc_subnets +control "aws_subnets recall" do + all_subnets = aws_subnets # You should be able to get a specific subnet given its id describe all_subnets.where(subnet_id: fixtures['ec2_default_vpc_subnet_id']) do @@ -32,17 +32,17 @@ control "aws_vpc_subnets recall" do end end -control "aws_vpc_subnets properties of default VPC subnet" do +control "aws_subnets properties of default VPC subnet" do # you should be able to test the cidr_block of a subnet - describe aws_vpc_subnets.where(subnet_id: fixtures['ec2_default_vpc_subnet_id']) do + describe aws_subnets.where(subnet_id: fixtures['ec2_default_vpc_subnet_id']) do its('cidr_blocks') { should include '172.31.96.0/20' } its('states') { should_not include 'pending' } end end -control "aws_vpc_subnets properties of default VPC" do +control "aws_subnets properties of default VPC" do # you should be able to test the cidr_block of a subnet - describe aws_vpc_subnets.where(vpc_id: fixtures['ec2_security_group_default_vpc_id']) do + describe aws_subnets.where(vpc_id: fixtures['ec2_security_group_default_vpc_id']) do its('cidr_blocks') { should include '172.31.96.0/20' } its('states') { should include 'available' } end diff --git a/test/unit/resources/aws_security_group_test.rb b/test/unit/resources/aws_security_group_test.rb index 2f9b01bad..e89158940 100644 --- a/test/unit/resources/aws_security_group_test.rb +++ b/test/unit/resources/aws_security_group_test.rb @@ -1,6 +1,6 @@ require 'ostruct' require 'helper' -require 'aws_ec2_security_group' +require 'aws_security_group' # MESGSB = MockEc2SecurityGroupSingleBackend # Abbreviation not used outside this file @@ -8,17 +8,17 @@ require 'aws_ec2_security_group' #=============================================================================# # Constructor Tests #=============================================================================# -class AwsESGSConstructor < Minitest::Test +class AwsSGSConstructor < Minitest::Test def setup - AwsEc2SecurityGroup::BackendFactory.select(AwsMESGSB::Empty) + AwsSecurityGroup::BackendFactory.select(AwsMESGSB::Empty) end def test_constructor_no_args_raises - assert_raises(ArgumentError) { AwsEc2SecurityGroup.new } + assert_raises(ArgumentError) { AwsSecurityGroup.new } end def test_constructor_accept_scalar_param - AwsEc2SecurityGroup.new('sg-12345678') + AwsSecurityGroup.new('sg-12345678') end def test_constructor_expected_well_formed_args @@ -28,7 +28,7 @@ class AwsESGSConstructor < Minitest::Test vpc_id: 'vpc-1234abcd', group_name: 'some-group', }.each do |param, value| - AwsEc2SecurityGroup.new(param => value) + AwsSecurityGroup.new(param => value) end end @@ -38,12 +38,12 @@ class AwsESGSConstructor < Minitest::Test group_id: '1234abcd', vpc_id: 'vpc_1234abcd', }.each do |param, value| - assert_raises(ArgumentError) { AwsEc2SecurityGroup.new(param => value) } + assert_raises(ArgumentError) { AwsSecurityGroup.new(param => value) } end end def test_constructor_reject_unknown_resource_params - assert_raises(ArgumentError) { AwsEc2SecurityGroup.new(beep: 'boop') } + assert_raises(ArgumentError) { AwsSecurityGroup.new(beep: 'boop') } end end @@ -51,29 +51,29 @@ end # Properties #=============================================================================# -class AwsESGSProperties < Minitest::Test +class AwsSGSProperties < Minitest::Test def setup - AwsEc2SecurityGroup::BackendFactory.select(AwsMESGSB::Basic) + AwsSecurityGroup::BackendFactory.select(AwsMESGSB::Basic) end def test_property_group_id - assert_equal('sg-12345678', AwsEc2SecurityGroup.new('sg-12345678').group_id) - assert_nil(AwsEc2SecurityGroup.new(group_name: 'my-group').group_id) + assert_equal('sg-12345678', AwsSecurityGroup.new('sg-12345678').group_id) + assert_nil(AwsSecurityGroup.new(group_name: 'my-group').group_id) end def test_property_group_name - assert_equal('beta', AwsEc2SecurityGroup.new('sg-12345678').group_name) - assert_nil(AwsEc2SecurityGroup.new('sg-87654321').group_name) + assert_equal('beta', AwsSecurityGroup.new('sg-12345678').group_name) + assert_nil(AwsSecurityGroup.new('sg-87654321').group_name) end def test_property_vpc_id - assert_equal('vpc-aaaabbbb', AwsEc2SecurityGroup.new('sg-aaaabbbb').vpc_id) - assert_nil(AwsEc2SecurityGroup.new('sg-87654321').vpc_id) + assert_equal('vpc-aaaabbbb', AwsSecurityGroup.new('sg-aaaabbbb').vpc_id) + assert_nil(AwsSecurityGroup.new('sg-87654321').vpc_id) end def test_property_description - assert_equal('Awesome Group', AwsEc2SecurityGroup.new('sg-12345678').description) - assert_nil(AwsEc2SecurityGroup.new('sg-87654321').description) + assert_equal('Awesome Group', AwsSecurityGroup.new('sg-12345678').description) + assert_nil(AwsSecurityGroup.new('sg-87654321').description) end end @@ -83,7 +83,7 @@ end #=============================================================================# module AwsMESGSB - class Empty < AwsEc2SecurityGroup::Backend + class Empty < AwsSecurityGroup::Backend def describe_security_groups(_query) OpenStruct.new({ security_groups: [], @@ -91,7 +91,7 @@ module AwsMESGSB end end - class Basic < AwsEc2SecurityGroup::Backend + class Basic < AwsSecurityGroup::Backend def describe_security_groups(query) fixtures = [ OpenStruct.new({ diff --git a/test/unit/resources/aws_security_groups_test.rb b/test/unit/resources/aws_security_groups_test.rb index c977aca6f..0ea41b1a6 100644 --- a/test/unit/resources/aws_security_groups_test.rb +++ b/test/unit/resources/aws_security_groups_test.rb @@ -1,48 +1,48 @@ require 'ostruct' require 'helper' -require 'aws_ec2_security_groups' +require 'aws_security_groups' -# MESGB = MockEc2SecurityGroupBackend +# MESGB = MockSecurityGroupBackend # Abbreviation not used outside this file #=============================================================================# # Constructor Tests #=============================================================================# -class AwsESGConstructor < Minitest::Test +class AwsSGConstructor < Minitest::Test def setup - AwsEc2SecurityGroups::BackendFactory.select(AwsMESGB::Empty) + AwsSecurityGroups::BackendFactory.select(AwsMESGB::Empty) end def test_constructor_no_args_ok - AwsEc2SecurityGroups.new + AwsSecurityGroups.new end def test_constructor_reject_unknown_resource_params - assert_raises(ArgumentError) { AwsEc2SecurityGroups.new(beep: 'boop') } + assert_raises(ArgumentError) { AwsSecurityGroups.new(beep: 'boop') } end end #=============================================================================# # Filter Criteria #=============================================================================# -class AwsESGFilterCriteria < Minitest::Test +class AwsSGFilterCriteria < Minitest::Test def setup - AwsEc2SecurityGroups::BackendFactory.select(AwsMESGB::Basic) + AwsSecurityGroups::BackendFactory.select(AwsMESGB::Basic) end def test_filter_vpc_id - hit = AwsEc2SecurityGroups.new.where(vpc_id: 'vpc-12345678') + hit = AwsSecurityGroups.new.where(vpc_id: 'vpc-12345678') assert(hit.exists?) - miss = AwsEc2SecurityGroups.new.where(vpc_id: 'vpc-87654321') + miss = AwsSecurityGroups.new.where(vpc_id: 'vpc-87654321') refute(miss.exists?) end def test_filter_group_name - hit = AwsEc2SecurityGroups.new.where(group_name: 'alpha') + hit = AwsSecurityGroups.new.where(group_name: 'alpha') assert(hit.exists?) - miss = AwsEc2SecurityGroups.new.where(group_name: 'nonesuch') + miss = AwsSecurityGroups.new.where(group_name: 'nonesuch') refute(miss.exists?) end @@ -51,13 +51,13 @@ end #=============================================================================# # Properties #=============================================================================# -class AwsESGProperties < Minitest::Test +class AwsSGProperties < Minitest::Test def setup - AwsEc2SecurityGroups::BackendFactory.select(AwsMESGB::Basic) + AwsSecurityGroups::BackendFactory.select(AwsMESGB::Basic) end def test_property_group_ids - basic = AwsEc2SecurityGroups.new + basic = AwsSecurityGroups.new assert_kind_of(Array, basic.group_ids) assert(basic.group_ids.include?('sg-aaaabbbb')) refute(basic.group_ids.include?(nil)) @@ -69,7 +69,7 @@ end #=============================================================================# module AwsMESGB - class Empty < AwsEc2SecurityGroups::Backend + class Empty < AwsSecurityGroups::Backend def describe_security_groups(_query) OpenStruct.new({ security_groups: [], @@ -77,7 +77,7 @@ module AwsMESGB end end - class Basic < AwsEc2SecurityGroups::Backend + class Basic < AwsSecurityGroups::Backend def describe_security_groups(query) fixtures = [ OpenStruct.new({ diff --git a/test/unit/resources/aws_subnet_test.rb b/test/unit/resources/aws_subnet_test.rb index 5995ed910..2260dbf33 100644 --- a/test/unit/resources/aws_subnet_test.rb +++ b/test/unit/resources/aws_subnet_test.rb @@ -1,6 +1,6 @@ # encoding: utf-8 require 'helper' -require 'aws_vpc_subnet' +require 'aws_subnet' # MVSSB = MockVpcSubnetSingleBackend # Abbreviation not used outside this file @@ -8,21 +8,21 @@ require 'aws_vpc_subnet' #=============================================================================# # Constructor Tests #=============================================================================# -class AwsVpcSubnetConstructorTest < Minitest::Test +class AwsSubnetConstructorTest < Minitest::Test def setup - AwsVpcSubnet::BackendFactory.select(AwsMVSSB::Basic) + AwsSubnet::BackendFactory.select(AwsMVSSB::Basic) end def test_constructor_no_args_raises - assert_raises(ArgumentError) { AwsVpcSubnet.new } + assert_raises(ArgumentError) { AwsSubnet.new } end def test_constructor_expected_well_formed_args - AwsVpcSubnet.new(subnet_id: 'subnet-12345678') + AwsSubnet.new(subnet_id: 'subnet-12345678') end def test_constructor_reject_unknown_resource_params - assert_raises(ArgumentError) { AwsVpcSubnet.new(bla: 'blabla') } + assert_raises(ArgumentError) { AwsSubnet.new(bla: 'blabla') } end end @@ -30,17 +30,17 @@ end # Recall #=============================================================================# -class AwsVpcSubnetRecallTest < Minitest::Test +class AwsSubnetRecallTest < Minitest::Test def setup - AwsVpcSubnet::BackendFactory.select(AwsMVSSB::Basic) + AwsSubnet::BackendFactory.select(AwsMVSSB::Basic) end def test_search_hit_via_hash_with_vpc_id_and_subnet_id_works - assert AwsVpcSubnet.new(subnet_id: 'subnet-12345678').exists? + assert AwsSubnet.new(subnet_id: 'subnet-12345678').exists? end def test_search_miss_is_not_an_exception - refute AwsVpcSubnet.new(subnet_id: 'subnet-00000000').exists? + refute AwsSubnet.new(subnet_id: 'subnet-00000000').exists? end end @@ -48,62 +48,62 @@ end # properties #=============================================================================# -class AwsVpcSubnetPropertiesTest < Minitest::Test +class AwsSubnetPropertiesTest < Minitest::Test def setup - AwsVpcSubnet::BackendFactory.select(AwsMVSSB::Basic) + AwsSubnet::BackendFactory.select(AwsMVSSB::Basic) end def test_property_subnet_id - assert_equal('subnet-12345678', AwsVpcSubnet.new(subnet_id: 'subnet-12345678').subnet_id) + assert_equal('subnet-12345678', AwsSubnet.new(subnet_id: 'subnet-12345678').subnet_id) end def test_property_vpc_id - assert_equal('vpc-12345678', AwsVpcSubnet.new(subnet_id: 'subnet-12345678').vpc_id) + assert_equal('vpc-12345678', AwsSubnet.new(subnet_id: 'subnet-12345678').vpc_id) end def test_property_cidr_block - assert_equal('10.0.1.0/24', AwsVpcSubnet.new(subnet_id: 'subnet-12345678').cidr_block) - assert_nil(AwsVpcSubnet.new(subnet_id: 'subnet-00000000').cidr_block) + assert_equal('10.0.1.0/24', AwsSubnet.new(subnet_id: 'subnet-12345678').cidr_block) + assert_nil(AwsSubnet.new(subnet_id: 'subnet-00000000').cidr_block) end def test_property_availability_zone - assert_equal('us-east-1', AwsVpcSubnet.new(subnet_id: 'subnet-12345678').availability_zone) - assert_nil(AwsVpcSubnet.new(subnet_id: 'subnet-00000000').availability_zone) + assert_equal('us-east-1', AwsSubnet.new(subnet_id: 'subnet-12345678').availability_zone) + assert_nil(AwsSubnet.new(subnet_id: 'subnet-00000000').availability_zone) end def test_property_available_ip_address_count - assert_equal(251, AwsVpcSubnet.new(subnet_id: 'subnet-12345678').available_ip_address_count) - assert_nil(AwsVpcSubnet.new(subnet_id: 'subnet-00000000').available_ip_address_count) + assert_equal(251, AwsSubnet.new(subnet_id: 'subnet-12345678').available_ip_address_count) + assert_nil(AwsSubnet.new(subnet_id: 'subnet-00000000').available_ip_address_count) end def test_property_ipv_6_cidr_block_association_set - assert_equal([], AwsVpcSubnet.new(subnet_id: 'subnet-12345678').ipv_6_cidr_block_association_set) - assert_nil(AwsVpcSubnet.new(subnet_id: 'subnet-00000000').ipv_6_cidr_block_association_set) + assert_equal([], AwsSubnet.new(subnet_id: 'subnet-12345678').ipv_6_cidr_block_association_set) + assert_nil(AwsSubnet.new(subnet_id: 'subnet-00000000').ipv_6_cidr_block_association_set) end end #=============================================================================# # Test Matchers #=============================================================================# -class AwsVpcSubnetPropertiesTest < Minitest::Test +class AwsSubnetPropertiesTest < Minitest::Test def test_matcher_assign_ipv_6_address_on_creation - assert AwsVpcSubnet.new(subnet_id: 'subnet-12345678').assigning_ipv_6_address_on_creation - refute AwsVpcSubnet.new(subnet_id: 'subnet-87654321').assigning_ipv_6_address_on_creation + assert AwsSubnet.new(subnet_id: 'subnet-12345678').assigning_ipv_6_address_on_creation + refute AwsSubnet.new(subnet_id: 'subnet-87654321').assigning_ipv_6_address_on_creation end def test_matcher_available - assert AwsVpcSubnet.new(subnet_id: 'subnet-12345678').available? - refute AwsVpcSubnet.new(subnet_id: 'subnet-87654321').available? + assert AwsSubnet.new(subnet_id: 'subnet-12345678').available? + refute AwsSubnet.new(subnet_id: 'subnet-87654321').available? end def test_matcher_default_for_az - assert AwsVpcSubnet.new(subnet_id: 'subnet-12345678').default_for_az? - refute AwsVpcSubnet.new(subnet_id: 'subnet-87654321').default_for_az? + assert AwsSubnet.new(subnet_id: 'subnet-12345678').default_for_az? + refute AwsSubnet.new(subnet_id: 'subnet-87654321').default_for_az? end def test_matcher_map_public_ip_on_launch - assert AwsVpcSubnet.new(subnet_id: 'subnet-12345678').mapping_public_ip_on_launch - refute AwsVpcSubnet.new(subnet_id: 'subnet-87654321').mapping_public_ip_on_launch + assert AwsSubnet.new(subnet_id: 'subnet-12345678').mapping_public_ip_on_launch + refute AwsSubnet.new(subnet_id: 'subnet-87654321').mapping_public_ip_on_launch end end @@ -113,7 +113,7 @@ end #=============================================================================# module AwsMVSSB - class Basic < AwsVpcSubnet::Backend + class Basic < AwsSubnet::Backend def describe_subnets(query) subnets = { 'subnet-12345678' => OpenStruct.new({ diff --git a/test/unit/resources/aws_subnets_test.rb b/test/unit/resources/aws_subnets_test.rb index 4ad0e5e3e..d502c9a0f 100644 --- a/test/unit/resources/aws_subnets_test.rb +++ b/test/unit/resources/aws_subnets_test.rb @@ -1,6 +1,6 @@ require 'ostruct' require 'helper' -require 'aws_vpc_subnets' +require 'aws_subnets' # MVSB = MockVpcSubnetsBackend # Abbreviation not used outside this file @@ -8,42 +8,42 @@ require 'aws_vpc_subnets' #=============================================================================# # Constructor Tests #=============================================================================# -class AwsVpcSubnetsConstructor < Minitest::Test +class AwsSubnetsConstructor < Minitest::Test def setup - AwsVpcSubnets::BackendFactory.select(AwsMVSB::Basic) + AwsSubnets::BackendFactory.select(AwsMVSB::Basic) end def test_constructor_no_args_ok - AwsVpcSubnets.new + AwsSubnets.new end def test_constructor_reject_unknown_resource_params - assert_raises(ArgumentError) { AwsVpcSubnets.new(bla: 'blabla') } + assert_raises(ArgumentError) { AwsSubnets.new(bla: 'blabla') } end end #=============================================================================# # Filter Criteria #=============================================================================# -class AwsVpcSubnetsFilterCriteria < Minitest::Test +class AwsSubnetsFilterCriteria < Minitest::Test def setup - AwsVpcSubnets::BackendFactory.select(AwsMVSB::Basic) + AwsSubnets::BackendFactory.select(AwsMVSB::Basic) end def test_filter_vpc_id - hit = AwsVpcSubnets.new.where(vpc_id: 'vpc-01234567') + hit = AwsSubnets.new.where(vpc_id: 'vpc-01234567') assert(hit.exists?) - miss = AwsVpcSubnets.new.where(vpc_id: 'vpc-87654321') + miss = AwsSubnets.new.where(vpc_id: 'vpc-87654321') refute(miss.exists?) end def test_filter_subnet_id - hit = AwsVpcSubnets.new.where(subnet_id: 'subnet-01234567') + hit = AwsSubnets.new.where(subnet_id: 'subnet-01234567') assert(hit.exists?) - miss = AwsVpcSubnets.new.where(subnet_id: 'subnet-98765432') + miss = AwsSubnets.new.where(subnet_id: 'subnet-98765432') refute(miss.exists?) end @@ -52,34 +52,34 @@ end #=============================================================================# # Properties #=============================================================================# -class AwsVpcSubnetProperties < Minitest::Test +class AwsSubnetProperties < Minitest::Test def setup - AwsVpcSubnets::BackendFactory.select(AwsMVSB::Basic) + AwsSubnets::BackendFactory.select(AwsMVSB::Basic) end def test_property_vpc_ids - basic = AwsVpcSubnets.new + basic = AwsSubnets.new assert_kind_of(Array, basic.vpc_ids) assert(basic.vpc_ids.include?('vpc-01234567')) refute(basic.vpc_ids.include?(nil)) end def test_property_subnet_ids - basic = AwsVpcSubnets.new + basic = AwsSubnets.new assert_kind_of(Array, basic.subnet_ids) assert(basic.subnet_ids.include?('subnet-01234567')) refute(basic.subnet_ids.include?(nil)) end def test_property_cidr_blocks - basic = AwsVpcSubnets.new + basic = AwsSubnets.new assert_kind_of(Array, basic.cidr_blocks) assert(basic.cidr_blocks.include?('10.0.1.0/24')) refute(basic.cidr_blocks.include?(nil)) end def test_property_states - basic = AwsVpcSubnets.new + basic = AwsSubnets.new assert_kind_of(Array, basic.states) assert(basic.states.include?('available')) refute(basic.states.include?(nil)) @@ -90,7 +90,7 @@ end # Test Fixtures #=============================================================================# module AwsMVSB - class Basic < AwsVpcSubnets::Backend + class Basic < AwsSubnets::Backend def describe_subnets fixtures = [ OpenStruct.new({ From a0b6bac87b906a2139757fdf916f79b75c8db10e Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Wed, 7 Feb 2018 23:26:37 -0500 Subject: [PATCH 185/206] Use train for AWS connection (#219) Signed-off-by: Clinton Wolfe --- Rakefile | 2 +- docs/resources/aws_iam_access_key.md | 63 ++- libraries/_aws.rb | 4 +- libraries/_aws_backend_base.rb | 12 + libraries/_aws_backend_factory_mixin.rb | 4 +- libraries/_aws_connection.rb | 67 --- libraries/_aws_plural_resource_mixin.rb | 21 + libraries/_aws_resource_mixin.rb | 30 +- libraries/_aws_singular_resource_mixin.rb | 24 + libraries/aws_cloudtrail_trail.rb | 19 +- libraries/aws_cloudtrail_trails.rb | 31 +- libraries/aws_cloudwatch_alarm.rb | 19 +- libraries/aws_cloudwatch_log_metric_filter.rb | 17 +- libraries/aws_ec2_instance.rb | 22 +- libraries/aws_ec2_security_group.rb | 12 +- libraries/aws_ec2_security_groups.rb | 52 +- libraries/aws_iam_access_key.rb | 141 +++--- libraries/aws_iam_access_keys.rb | 86 +--- libraries/aws_iam_group.rb | 16 +- libraries/aws_iam_groups.rb | 29 +- libraries/aws_iam_password_policy.rb | 17 +- libraries/aws_iam_policies.rb | 28 +- libraries/aws_iam_policy.rb | 18 +- libraries/aws_iam_role.rb | 14 +- libraries/aws_iam_root_user.rb | 16 +- libraries/aws_iam_user.rb | 20 +- libraries/aws_iam_users.rb | 59 +-- libraries/aws_kms_keys.rb | 28 +- libraries/aws_route_table.rb | 14 +- libraries/aws_s3_bucket.rb | 24 +- libraries/aws_sns_topic.rb | 12 +- libraries/aws_vpc.rb | 12 +- libraries/aws_vpc_subnet.rb | 19 +- libraries/aws_vpc_subnets.rb | 31 +- libraries/aws_vpcs.rb | 27 +- .../verify/controls/aws_iam_access_key.rb | 46 -- .../verify/controls/aws_iam_access_keys.rb | 58 +++ .../resources/aws_cloudtrail_trail_test.rb | 4 +- .../resources/aws_cloudtrail_trails_test.rb | 4 +- .../resources/aws_cloudwatch_alarm_test.rb | 4 +- .../aws_cloudwatch_log_metric_filter_test.rb | 4 +- .../resources/aws_ec2_security_group_test.rb | 4 +- .../resources/aws_ec2_security_groups_test.rb | 5 +- .../unit/resources/aws_iam_access_key_test.rb | 474 ++++++++---------- .../resources/aws_iam_access_keys_test.rb | 32 +- test/unit/resources/aws_iam_group_test.rb | 4 +- test/unit/resources/aws_iam_groups_test.rb | 4 +- test/unit/resources/aws_iam_policies_test.rb | 4 +- test/unit/resources/aws_iam_policy_test.rb | 4 +- test/unit/resources/aws_iam_role_test.rb | 4 +- test/unit/resources/aws_iam_user_test.rb | 2 +- test/unit/resources/aws_iam_users_test.rb | 22 +- test/unit/resources/aws_kms_keys_test.rb | 4 +- test/unit/resources/aws_route_table_test.rb | 4 +- test/unit/resources/aws_s3_bucket_test.rb | 2 +- test/unit/resources/aws_sns_topic_test.rb | 6 +- test/unit/resources/aws_vpc_subnet_test.rb | 2 +- test/unit/resources/aws_vpc_subnets_test.rb | 2 +- test/unit/resources/aws_vpc_test.rb | 4 +- test/unit/resources/aws_vpcs_test.rb | 5 +- 60 files changed, 851 insertions(+), 867 deletions(-) create mode 100644 libraries/_aws_backend_base.rb delete mode 100644 libraries/_aws_connection.rb create mode 100644 libraries/_aws_plural_resource_mixin.rb create mode 100644 libraries/_aws_singular_resource_mixin.rb create mode 100644 test/integration/default/verify/controls/aws_iam_access_keys.rb diff --git a/Rakefile b/Rakefile index 8169744c5..a9bd892a1 100644 --- a/Rakefile +++ b/Rakefile @@ -66,7 +66,7 @@ namespace :test do task :"run:#{account}" do puts "----> Run" - sh("AWS_PROFILE=inspec-aws-test-#{account} bundle exec inspec exec #{integration_dir}/verify --attrs #{attribute_file}") + sh("bundle exec inspec exec #{integration_dir}/verify -t aws://${AWS_REGION}/inspec-aws-test-#{account} --attrs #{attribute_file}") end task :"cleanup:#{account}", :tf_workspace do |t, args| diff --git a/docs/resources/aws_iam_access_key.md b/docs/resources/aws_iam_access_key.md index dfb784c13..f4e8d2177 100644 --- a/docs/resources/aws_iam_access_key.md +++ b/docs/resources/aws_iam_access_key.md @@ -10,15 +10,37 @@ Use the `aws_iam_access_key` InSpec audit resource to test properties of a singl ## Syntax -An `aws_iam_access_key` resource block declares the tests for a single AWS IAM access key by username and id. +An `aws_iam_access_key` resource block declares the tests for a single AWS IAM access key. An access key is uniquely identified by its access key id. - describe aws_iam_access_key(username: 'username', id: 'access-key-id') do + # This is unique - the key will either exist or it won't, but it will never be an error. + describe aws_iam_access_key(access_key_id: 'AKIA12345678ABCD') do it { should exist } it { should_not be_active } its('create_date') { should be > Time.now - 365 * 86400 } its('last_used_date') { should be > Time.now - 90 * 86400 } end + # id is an alias for access_key_id + describe aws_iam_access_key(id: 'AKIA12345678ABCD') do + # Same + end + + +Access keys are associated with IAM users, who may have zero, one or two access keys. You may also lookup an access key by username. If the user has more than one access key, an error occurs (You may use `aws_iam_access_keys` with the `username` resource parameter to access a user's keys when they have multiple keys.) + + # This is not unique. If the user has zero or one keys, it is not an error. + # If they have two, it is an error. + describe aws_iam_access_key(username: 'roderick') do + it { should exist } + it { should be_active } + end + +You may also use both username and access key id to ensure a particular key is associated with a particular user. + + describe aws_iam_access_key(username: 'roderick', access_key_id: 'AKIA12345678ABCD') do + it { should exist } + end +
## Examples @@ -45,6 +67,43 @@ The following examples show how to use this InSpec audit resource.
+## Properties + +### access_key_id + +The unique ID of this access key. + + describe aws_iam_access_key(username: 'bob') + its('access_key_id') { should cmp 'AKIA12345678ABCD' } + end + +### create_date + +The date and time, as a Ruby DateTime, at which the access key was created. + + # Is the access key less than a year old? + describe aws_iam_access_key(username: 'bob') + its('create_date') { should be > Time.now - 365 * 86400 } + end + +### last_used_date + +The date and time, as a Ruby DateTime, at which the access key was last_used. + + # Has the access key been used in the last year? + describe aws_iam_access_key(username: 'bob') + its('last_used_date') { should be > Time.now - 365 * 86400 } + end + +### username + +The IAM user that owns this key. + + describe aws_iam_access_key(access_key_id: 'AKIA12345678ABCD') + its('username') { should cmp 'bob' } + end + + ## Matchers This InSpec audit resource has the following special matchers. For a full list of available matchers (such as `exist`) please visit our [matchers page](https://www.inspec.io/docs/reference/matchers/). diff --git a/libraries/_aws.rb b/libraries/_aws.rb index 5adece020..757d97502 100644 --- a/libraries/_aws.rb +++ b/libraries/_aws.rb @@ -4,4 +4,6 @@ require 'aws-sdk' # TODO: split once ADK v3 is in use require '_aws_backend_factory_mixin' require '_aws_resource_mixin' -require '_aws_connection' +require '_aws_singular_resource_mixin' +require '_aws_plural_resource_mixin' +require '_aws_backend_base' diff --git a/libraries/_aws_backend_base.rb b/libraries/_aws_backend_base.rb new file mode 100644 index 000000000..ecf5ef5b3 --- /dev/null +++ b/libraries/_aws_backend_base.rb @@ -0,0 +1,12 @@ +class AwsBackendBase + attr_reader :aws_transport + class << self; attr_accessor :aws_client_class end + + def initialize(inspec = nil) + @aws_transport = inspec ? inspec.backend : nil + end + + def aws_service_client + aws_transport.aws_client(self.class.aws_client_class) + end +end diff --git a/libraries/_aws_backend_factory_mixin.rb b/libraries/_aws_backend_factory_mixin.rb index 67191af5a..36b3a33f3 100644 --- a/libraries/_aws_backend_factory_mixin.rb +++ b/libraries/_aws_backend_factory_mixin.rb @@ -1,7 +1,7 @@ # Intended to be pulled in via extend, not include module AwsBackendFactoryMixin - def create - @selected_backend.new + def create(inspec) + @selected_backend.new(inspec) end def select(klass) diff --git a/libraries/_aws_connection.rb b/libraries/_aws_connection.rb deleted file mode 100644 index f723fa5b1..000000000 --- a/libraries/_aws_connection.rb +++ /dev/null @@ -1,67 +0,0 @@ -# author: Christoph Hartmann - -# This class exists so that we can intercept AWS API connection setup -# and have an opportunity to provide credentials from another mechanism -# (such as a train transport URI) in the future. -# -# We commit to always supporting the standard AWS environment variables. - -class AWSConnection - def initialize - creds = nil - if ENV['AWS_PROFILE'] - creds = Aws::SharedCredentials.new(profile_name: ENV['AWS_PROFILE']) - else - creds = Aws::Credentials.new( - ENV['AWS_ACCESS_KEY_ID'], - ENV['AWS_SECRET_ACCESS_KEY'], - ENV['AWS_SESSION_TOKEN'], - ) - end - opts = { - region: ENV['AWS_REGION'] || ENV['AWS_DEFAULT_REGION'], - credentials: creds, - } - Aws.config.update(opts) - end - - def sns_client - @sns_client ||= Aws::SNS::Client.new - end - - def cloudwatch_client - @cloudwatch_client ||= Aws::CloudWatch::Client.new - end - - def cloudwatch_logs_client - @cloudwatch_logs_client ||= Aws::CloudWatchLogs::Client.new - end - - def cloudtrail_client - @cloudtrail_client ||= Aws::CloudTrail::Client.new - end - - def ec2_resource - @ec2_resource ||= Aws::EC2::Resource.new - end - - def ec2_client - @ec2_client ||= Aws::EC2::Client.new - end - - def iam_resource - @iam_resource ||= Aws::IAM::Resource.new - end - - def iam_client - @iam_client ||= Aws::IAM::Client.new - end - - def s3_client - @s3_client ||= Aws::S3::Client.new - end - - def kms_client - @kms_client ||= Aws::KMS::Client.new - end -end diff --git a/libraries/_aws_plural_resource_mixin.rb b/libraries/_aws_plural_resource_mixin.rb new file mode 100644 index 000000000..1c44746c6 --- /dev/null +++ b/libraries/_aws_plural_resource_mixin.rb @@ -0,0 +1,21 @@ +module AwsPluralResourceMixin + include AwsResourceMixin + attr_reader :table + + # This sets up a class, AwsSomeResource::BackendFactory, that + # provides a mechanism to create and use backends without + # having to know which is selected. This is mainly used for + # unit testing. + # TODO: DRY up. This code exists in both the Singular and Plural mixins. + # We'd like to put it in AwsResourceMixin, but included only sees the + # directly-including class - we can't see second-order includers. + def self.included(base) + # Create a new class, whose body is simply to extend the + # backend factory mixin + resource_backend_factory_class = Class.new(Object) do + extend AwsBackendFactoryMixin + end + # Name that class + base.const_set('BackendFactory', resource_backend_factory_class) + end +end diff --git a/libraries/_aws_resource_mixin.rb b/libraries/_aws_resource_mixin.rb index 6b0a117d0..2d25356e2 100644 --- a/libraries/_aws_resource_mixin.rb +++ b/libraries/_aws_resource_mixin.rb @@ -3,7 +3,12 @@ module AwsResourceMixin validate_params(resource_params).each do |param, value| instance_variable_set(:"@#{param}", value) end - fetch_from_aws + fetch_from_api + end + + # Default implementation of validate params accepts everything. + def validate_params(resource_params) + resource_params end def check_resource_param_names(raw_params: {}, allowed_params: [], allowed_scalar_name: nil, allowed_scalar_type: nil) @@ -32,21 +37,12 @@ module AwsResourceMixin recognized_params end - def exists? - @exists - end - - # This sets up a class, AwsSomeResource::BackendFactory, that - # provides a mechanism to create and use backends without - # having to know which is selected. This is mainly used for - # unit testing. - def self.included(base) - # Create a new class, whose body is simply to extend the - # backend factory mixin - resource_backend_factory_class = Class.new(Object) do - extend AwsBackendFactoryMixin - end - # Name that class - base.const_set('BackendFactory', resource_backend_factory_class) + def inspec_runner + # When running under inspec-cli, we have an 'inspec' method that + # returns the runner. When running under unit tests, we don't + # have that, but we still have to call this to pass something + # (nil is OK) to the backend. + # TODO: remove with https://github.com/chef/inspec-aws/issues/216 + inspec if respond_to?(:inspec) end end diff --git a/libraries/_aws_singular_resource_mixin.rb b/libraries/_aws_singular_resource_mixin.rb new file mode 100644 index 000000000..a34556f88 --- /dev/null +++ b/libraries/_aws_singular_resource_mixin.rb @@ -0,0 +1,24 @@ +module AwsSingularResourceMixin + include AwsResourceMixin + + def exists? + @exists + end + + # This sets up a class, AwsSomeResource::BackendFactory, that + # provides a mechanism to create and use backends without + # having to know which is selected. This is mainly used for + # unit testing. + # TODO: DRY up. This code exists in both the Singular and Plural mixins. + # We'd like to put it in AwsResourceMixin, but included only sees the + # directly-including class - we can't see second-order includers. + def self.included(base) + # Create a new class, whose body is simply to extend the + # backend factory mixin + resource_backend_factory_class = Class.new(Object) do + extend AwsBackendFactoryMixin + end + # Name that class + base.const_set('BackendFactory', resource_backend_factory_class) + end +end diff --git a/libraries/aws_cloudtrail_trail.rb b/libraries/aws_cloudtrail_trail.rb index 518d5d5b8..81c439064 100644 --- a/libraries/aws_cloudtrail_trail.rb +++ b/libraries/aws_cloudtrail_trail.rb @@ -9,9 +9,11 @@ class AwsCloudTrailTrail < Inspec.resource(1) end " - include AwsResourceMixin - attr_reader :s3_bucket_name, :trail_arn, :cloud_watch_logs_role_arn, - :cloud_watch_logs_log_group_arn, :kms_key_id, :home_region + supports platform: 'aws' + + include AwsSingularResourceMixin + attr_reader :cloud_watch_logs_log_group_arn, :cloud_watch_logs_role_arn, :home_region, + :kms_key_id, :s3_bucket_name, :trail_arn def to_s "CloudTrail #{@trail_name}" @@ -46,8 +48,8 @@ class AwsCloudTrailTrail < Inspec.resource(1) validated_params end - def fetch_from_aws - backend = AwsCloudTrailTrail::BackendFactory.create + def fetch_from_api + backend = BackendFactory.create(inspec_runner) query = { trail_name_list: [@trail_name] } resp = backend.describe_trails(query) @@ -65,11 +67,12 @@ class AwsCloudTrailTrail < Inspec.resource(1) end class Backend - class AwsClientApi - BackendFactory.set_default_backend(self) + class AwsClientApi < AwsBackendBase + AwsCloudTrailTrail::BackendFactory.set_default_backend(self) + self.aws_client_class = Aws::CloudTrail::Client def describe_trails(query) - AWSConnection.new.cloudtrail_client.describe_trails(query) + aws_service_client.describe_trails(query) end end end diff --git a/libraries/aws_cloudtrail_trails.rb b/libraries/aws_cloudtrail_trails.rb index 12932b892..9903b970b 100644 --- a/libraries/aws_cloudtrail_trails.rb +++ b/libraries/aws_cloudtrail_trails.rb @@ -8,6 +8,16 @@ class AwsCloudTrailTrails < Inspec.resource(1) it { should exist } end ' + supports platform: 'aws' + + include AwsPluralResourceMixin + + def validate_params(resource_params) + unless resource_params.empty? + raise ArgumentError, 'aws_cloudtrail_trails does not accept resource parameters.' + end + resource_params + end # Underlying FilterTable implementation. filter = FilterTable.create @@ -15,31 +25,24 @@ class AwsCloudTrailTrails < Inspec.resource(1) .add(:exists?) { |x| !x.entries.empty? } .add(:names, field: :name) .add(:trail_arns, field: :trail_arn) - filter.connect(self, :trail_data) - - def trail_data - @table - end + filter.connect(self, :table) def to_s 'CloudTrail Trails' end - def initialize - backend = AwsCloudTrailTrails::BackendFactory.create + def fetch_from_api + backend = BackendFactory.create(inspec_runner) @table = backend.describe_trails({}).to_h[:trail_list] end - class BackendFactory - extend AwsBackendFactoryMixin - end - class Backend - class AwsClientApi - BackendFactory.set_default_backend(self) + class AwsClientApi < AwsBackendBase + AwsCloudTrailTrails::BackendFactory.set_default_backend(self) + self.aws_client_class = Aws::CloudTrail::Client def describe_trails(query) - AWSConnection.new.cloudtrail_client.describe_trails(query) + aws_service_client.describe_trails(query) end end end diff --git a/libraries/aws_cloudwatch_alarm.rb b/libraries/aws_cloudwatch_alarm.rb index 360710761..a8db79927 100644 --- a/libraries/aws_cloudwatch_alarm.rb +++ b/libraries/aws_cloudwatch_alarm.rb @@ -11,9 +11,10 @@ class AwsCloudwatchAlarm < Inspec.resource(1) it { should exist } end EOD + supports platform: 'aws' - include AwsResourceMixin - attr_reader :alarm_name, :metric_name, :metric_namespace, :alarm_actions + include AwsSingularResourceMixin + attr_reader :alarm_actions, :alarm_name, :metric_name, :metric_namespace private @@ -32,8 +33,8 @@ class AwsCloudwatchAlarm < Inspec.resource(1) validated_params end - def fetch_from_aws - aws_alarms = BackendFactory.create.describe_alarms_for_metric( + def fetch_from_api + aws_alarms = BackendFactory.create(inspec_runner).describe_alarms_for_metric( metric_name: @metric_name, namespace: @metric_namespace, ) @@ -51,10 +52,12 @@ class AwsCloudwatchAlarm < Inspec.resource(1) end class Backend - class AwsClientApi < Backend - BackendFactory.set_default_backend(self) - def describe_alarms_for_metric(criteria) - AWSConnection.new.cloudwatch_client.describe_alarms_for_metric(criteria) + class AwsClientApi < AwsBackendBase + AwsCloudwatchAlarm::BackendFactory.set_default_backend(self) + self.aws_client_class = Aws::CloudWatch::Client + + def describe_alarms_for_metric(query) + aws_service_client.describe_alarms_for_metric(query) end end end diff --git a/libraries/aws_cloudwatch_log_metric_filter.rb b/libraries/aws_cloudwatch_log_metric_filter.rb index 55fa1e096..6b5514ba0 100644 --- a/libraries/aws_cloudwatch_log_metric_filter.rb +++ b/libraries/aws_cloudwatch_log_metric_filter.rb @@ -22,9 +22,9 @@ class AwsCloudwatchLogMetricFilter < Inspec.resource(1) it { should exist } end EOX - - include AwsResourceMixin - attr_reader :filter_name, :log_group_name, :pattern, :metric_name, :metric_namespace + supports platform: 'aws' + include AwsSingularResourceMixin + attr_reader :filter_name, :log_group_name, :metric_name, :metric_namespace, :pattern private @@ -39,9 +39,9 @@ EOX validated_params end - def fetch_from_aws + def fetch_from_api # get a backend - backend = BackendFactory.create + backend = BackendFactory.create(inspec_runner) # Perform query with remote filtering aws_search_criteria = {} @@ -78,17 +78,18 @@ EOX class Backend # Uses the cloudwatch API to really talk to AWS - class AwsClientApi < Backend + class AwsClientApi < AwsBackendBase BackendFactory.set_default_backend(self) + self.aws_client_class = Aws::CloudWatchLogs::Client + def describe_metric_filters(criteria) - cwl_client = AWSConnection.new.cloudwatch_logs_client query = {} query[:filter_name_prefix] = criteria[:filter_name] if criteria[:filter_name] query[:log_group_name] = criteria[:log_group_name] if criteria[:log_group_name] # 'pattern' is not available as a remote filter, # we filter it after the fact locally # TODO: handle pagination? Max 50/page. Maybe you want a plural resource? - aws_response = cwl_client.describe_metric_filters(query) + aws_response = aws_service_client.describe_metric_filters(query) aws_response.metric_filters end end diff --git a/libraries/aws_ec2_instance.rb b/libraries/aws_ec2_instance.rb index baeed0ca1..a57fed193 100644 --- a/libraries/aws_ec2_instance.rb +++ b/libraries/aws_ec2_instance.rb @@ -16,13 +16,25 @@ class AwsEc2Instance < Inspec.resource(1) it { should have_roles } end EOX + supports platform: 'aws' - def initialize(opts, conn = AWSConnection.new) + # TODO: rewrite to avoid direct injection, match other resources, use AwsSingularResourceMixin + def initialize(opts, conn = nil) @opts = opts @opts.is_a?(Hash) ? @display_name = @opts[:name] : @display_name = opts - @ec2_client = conn.ec2_client - @ec2_resource = conn.ec2_resource - @iam_resource = conn.iam_resource + @ec2_client = conn ? conn.ec2_client : inspec_runner.backend.aws_client(Aws::EC2::Client) + @ec2_resource = conn ? conn.ec2_resource : inspec_runner.backend.aws_resource(Aws::EC2::Resource, {}) + @iam_resource = conn ? conn.iam_resource : inspec_runner.backend.aws_resource(Aws::IAM::Resource, {}) + end + + def inspec_runner + # When running under inspec-cli, we have an 'inspec' method that + # returns the runner. When running under unit tests, we don't + # have that, but we still have to call this to pass something + # (nil is OK) to the backend. + # TODO: remove with https://github.com/chef/inspec-aws/issues/216 + # TODO: remove after rewrite to include AwsSingularResource + inspec if respond_to?(:inspec) end def id @@ -115,7 +127,7 @@ end class AwsEc2 < AwsEc2Instance name 'aws_ec2' - def initialize(opts, conn = AWSConnection.new) + def initialize(opts, conn = nil) deprecated super(opts, conn) end diff --git a/libraries/aws_ec2_security_group.rb b/libraries/aws_ec2_security_group.rb index d03113d88..f6b728592 100644 --- a/libraries/aws_ec2_security_group.rb +++ b/libraries/aws_ec2_security_group.rb @@ -8,8 +8,9 @@ class AwsEc2SecurityGroup < Inspec.resource(1) it { should exist } end ' + supports platform: 'aws' - include AwsResourceMixin + include AwsSingularResourceMixin attr_reader :description, :group_id, :group_name, :vpc_id def to_s @@ -45,8 +46,8 @@ class AwsEc2SecurityGroup < Inspec.resource(1) validated_params end - def fetch_from_aws - backend = AwsEc2SecurityGroup::BackendFactory.create + def fetch_from_api + backend = BackendFactory.create(inspec_runner) # Transform into filter format expected by AWS filters = [] @@ -82,11 +83,12 @@ class AwsEc2SecurityGroup < Inspec.resource(1) end class Backend - class AwsClientApi < Backend + class AwsClientApi < AwsBackendBase AwsEc2SecurityGroup::BackendFactory.set_default_backend self + self.aws_client_class = Aws::EC2::Client def describe_security_groups(query) - AWSConnection.new.ec2_client.describe_security_groups(query) + aws_service_client.describe_security_groups(query) end end end diff --git a/libraries/aws_ec2_security_groups.rb b/libraries/aws_ec2_security_groups.rb index ff4f3da72..c23c7e882 100644 --- a/libraries/aws_ec2_security_groups.rb +++ b/libraries/aws_ec2_security_groups.rb @@ -14,12 +14,9 @@ class AwsEc2SecurityGroups < Inspec.resource(1) its('entries.count') { should be > 1 } end EOX + supports platform: 'aws' - # Constructor. Args are reserved for row fetch filtering. - def initialize(raw_criteria = {}) - validated_criteria = validate_filter_criteria(raw_criteria) - fetch_from_backend(validated_criteria) - end + include AwsPluralResourceMixin # Underlying FilterTable implementation. filter = FilterTable.create @@ -27,11 +24,7 @@ EOX .add_accessor(:entries) .add(:exists?) { |x| !x.entries.empty? } .add(:group_ids, field: :group_id) - filter.connect(self, :access_key_data) - - def access_key_data - @table - end + filter.connect(self, :table) def to_s 'EC2 Security Groups' @@ -39,39 +32,23 @@ EOX private - def validate_filter_criteria(raw_criteria) + def validate_params(raw_criteria) unless raw_criteria.is_a? Hash raise 'Unrecognized criteria for fetching Security Groups. ' \ "Use 'criteria: value' format." end # No criteria yet - recognized_criteria = check_criteria_names(raw_criteria) - - recognized_criteria - end - - def check_criteria_names(raw_criteria: {}, allowed_criteria: []) - # Remove all expected criteria from the raw criteria hash - recognized_criteria = {} - allowed_criteria.each do |expected_criterion| - recognized_criteria[expected_criterion] = raw_criteria.delete(expected_criterion) if raw_criteria.key?(expected_criterion) - end - - # Any leftovers are unwelcome unless raw_criteria.empty? - raise ArgumentError, "Unrecognized filter criterion '#{raw_criteria.keys.first}'. Expected criteria: #{allowed_criteria.join(', ')}" + raise ArgumentError, 'aws_ec2_security_groups does not currently accept resource parameters.' end - recognized_criteria + raw_criteria end - def fetch_from_backend(criteria) + def fetch_from_api @table = [] - backend = AwsEc2SecurityGroups::BackendFactory.create - # Note: should we ever implement server-side filtering - # (and this is a very good resource for that), - # we will need to reformat the criteria we are sending to AWS. - backend.describe_security_groups(criteria).security_groups.each do |sg_info| + backend = BackendFactory.create(inspec_runner) + backend.describe_security_groups({}).security_groups.each do |sg_info| @table.push({ group_id: sg_info.group_id, group_name: sg_info.group_name, @@ -80,16 +57,13 @@ EOX end end - class BackendFactory - extend AwsBackendFactoryMixin - end - class Backend - class AwsClientApi < Backend - AwsEc2SecurityGroups::BackendFactory.set_default_backend self + class AwsClientApi < AwsBackendBase + BackendFactory.set_default_backend self + self.aws_client_class = Aws::EC2::Client def describe_security_groups(query) - AWSConnection.new.ec2_client.describe_security_groups(query) + aws_service_client.describe_security_groups(query) end end end diff --git a/libraries/aws_iam_access_key.rb b/libraries/aws_iam_access_key.rb index 164cb9ec8..07946bd47 100644 --- a/libraries/aws_iam_access_key.rb +++ b/libraries/aws_iam_access_key.rb @@ -3,7 +3,7 @@ require '_aws' # author: Chris Redekop class AwsIamAccessKey < Inspec.resource(1) name 'aws_iam_access_key' - desc 'Verifies settings for AWS IAM access keys' + desc 'Verifies settings for an individual IAM access key' example " describe aws_iam_access_key(username: 'username', id: 'access-key id') do it { should exist } @@ -12,95 +12,96 @@ class AwsIamAccessKey < Inspec.resource(1) its('last_used_date') { should be > Time.now - 90 * 86400 } end " + supports platform: 'aws' - def initialize(opts, decorator = IamClientDecorator.new) - @access_key = opts[:access_key] - @username = opts[:username] - @id = @access_key ? @access_key.access_key_id : opts[:id] + include AwsSingularResourceMixin + attr_reader :access_key_id, :create_date, :status, :username + alias id access_key_id - @decorator = decorator - end + def validate_params(raw_params) + recognized_params = check_resource_param_names( + raw_params: raw_params, + allowed_params: [:username, :id, :access_key_id], + allowed_scalar_name: :access_key_id, + allowed_scalar_type: String, + ) - def exists? - !access_key.nil? - rescue AccessKeyNotFoundError, Aws::IAM::Errors::NoSuchEntity - false - end + # id and access_key_id are aliases; standardize on access_key_id + recognized_params[:access_key_id] = recognized_params.delete(:id) if recognized_params.key?(:id) - def id - access_key.access_key_id + # Validate format of access_key_id + if recognized_params[:access_key_id] and + recognized_params[:access_key_id] !~ /^AKIA[0-9A-Z]{16}$/ + raise ArgumentError, 'Incorrect format for Access Key ID - expected AKIA followed ' \ + 'by 16 letters or numbers' + end + + # One of username and access_key_id is required + if recognized_params[:username].nil? && recognized_params[:access_key_id].nil? + raise ArgumentError, 'You must provide at lease one of access_key_id or username to aws_iam_access_key' + end + + recognized_params end def active? - 'Active'.eql? access_key.status - end - - def create_date - access_key.create_date - end - - def last_used_date - access_key_last_used.last_used_date + return nil unless exists? + status == 'Active' end def to_s - "IAM Access-Key #{@id}" + "IAM Access-Key #{access_key_id}" end - class AccessKeyNotFoundError < StandardError + def last_used_date + return nil unless exists? + return @last_used_date if defined? @last_used_date + backend = BackendFactory.create(inspec_runner) + @last_used_date = backend.get_access_key_last_used({ access_key_id: access_key_id }).access_key_last_used.last_used_date end - class IamClientDecorator - def initialize(validator = ArgumentValidator.new, - conn = AWSConnection.new) + def fetch_from_api + backend = BackendFactory.create(inspec_runner) + query = {} + query[:user_name] = username if username - @validator = validator - @client = conn.iam_client - end + response = backend.list_access_keys(query) - def get_access_key(username, id) - @validator.validate_username(username) - @validator.validate_id(id) - - access_key = - @client.list_access_keys({ user_name: username }) - .access_key_metadata.select { |x| x.access_key_id.eql? id }.first - - if access_key.nil? - raise AccessKeyNotFoundError, 'access key not found '.concat( - "[username = \"#{username}\", id = \"#{id}\"]", - ) - end - - access_key - end - - def get_access_key_last_used(id) - @validator.validate_id(id) - - @client.get_access_key_last_used({ access_key_id: id }) - .access_key_last_used - end - - class ArgumentValidator - [:username, :id].each do |argument| - define_method "validate_#{argument}" do |value| - return unless value.nil? - - raise ArgumentError, - "missing required resource argument \"#{argument}\"" - end + access_keys = response.access_key_metadata.select do |key| + if access_key_id + key.access_key_id == access_key_id + else + true end end + + if access_keys.empty? + @exists = false + return + end + + if access_keys.count > 1 + raise 'More than one access key matched for aws_iam_access_key. Use more specific paramaters, such as access_key_id.' + end + + @exists = true + @access_key_id = access_keys[0].access_key_id + @username = access_keys[0].user_name + @create_date = access_keys[0].create_date + @status = access_keys[0].status + # Last used date is lazily loaded, separate API call + rescue Aws::IAM::Errors::NoSuchEntity + @exists = false end - private + class Backend + class AwsClientApi < AwsBackendBase + BackendFactory.set_default_backend(self) + self.aws_client_class = Aws::IAM::Client - def access_key - @access_key ||= @decorator.get_access_key(@username, @id) - end - - def access_key_last_used - @access_key_last_used ||= @decorator.get_access_key_last_used(@id) + def list_access_keys(query) + aws_service_client.list_access_keys(query) + end + end end end diff --git a/libraries/aws_iam_access_keys.rb b/libraries/aws_iam_access_keys.rb index e2224f8f2..43467d521 100644 --- a/libraries/aws_iam_access_keys.rb +++ b/libraries/aws_iam_access_keys.rb @@ -8,45 +8,34 @@ class AwsIamAccessKeys < Inspec.resource(1) it { should_not exist } end ' + supports platform: 'aws' - VALUED_CRITERIA = [ - :username, - :id, - :access_key_id, - :created_date, - ].freeze + include AwsPluralResourceMixin - # Constructor. Args are reserved for row fetch filtering. - def initialize(filter_criteria = {}) - filter_criteria = validate_filter_criteria(filter_criteria) - @table = AccessKeyProvider.create.fetch(filter_criteria) - end - - def validate_filter_criteria(criteria) - # Allow passing a scalar string, the Access Key ID. - criteria = { access_key_id: criteria } if criteria.is_a? String - unless criteria.is_a? Hash - raise 'Unrecognized criteria for fetching Access Keys. ' \ - "Use 'criteria: value' format." - end + def validate_params(raw_params) + recognized_params = check_resource_param_names( + raw_params: raw_params, + allowed_params: [:username, :id, :access_key_id, :created_date], + allowed_scalar_name: :access_key_id, + allowed_scalar_type: String, + ) # id and access_key_id are aliases; standardize on access_key_id - criteria[:access_key_id] = criteria.delete(:id) if criteria.key?(:id) - if criteria[:access_key_id] and - criteria[:access_key_id] !~ /^AKIA[0-9A-Z]{16}$/ + recognized_params[:access_key_id] = recognized_params.delete(:id) if recognized_params.key?(:id) + if recognized_params[:access_key_id] and + recognized_params[:access_key_id] !~ /^AKIA[0-9A-Z]{16}$/ raise 'Incorrect format for Access Key ID - expected AKIA followed ' \ 'by 16 letters or numbers' end - criteria.each_key do |criterion| - unless VALUED_CRITERIA.include?(criterion) # rubocop:disable Style/Next - raise 'Unrecognized filter criterion for aws_iam_access_keys, ' \ - "'#{criterion}'. Valid choices are " \ - "#{VALUED_CRITERIA.join(', ')}." - end - end + recognized_params + end - criteria + def fetch_from_api + # TODO: this interface should be normalized to match the AWS API + criteria = {} + criteria[:username] = @username if defined? @username + @table = BackendFactory.create(inspec_runner).fetch(criteria) end # Underlying FilterTable implementation. @@ -68,11 +57,7 @@ class AwsIamAccessKeys < Inspec.resource(1) .add(:ever_used, field: :ever_used) .add(:never_used, field: :never_used) .add(:user_created_date, field: :user_created_date) - filter.connect(self, :access_key_data) - - def access_key_data - @table - end + filter.connect(self, :table) def to_s 'IAM Access Keys' @@ -82,14 +67,17 @@ class AwsIamAccessKeys < Inspec.resource(1) # the users and access keys. We have an abstract # class with a concrete AWS implementation provided here; # a few mock implementations are also provided in the unit tests. - class AccessKeyProvider + class Backend # Implementation of AccessKeyProvider which operates by looping over # all users, then fetching their access keys. # TODO: An alternate, more scalable implementation could be made # using the Credential Report. - class AwsUserIterator < AccessKeyProvider + class AwsUserIterator < AwsBackendBase + BackendFactory.set_default_backend(self) + self.aws_client_class = Aws::IAM::Client + def fetch(criteria) - iam_client = AWSConnection.new.iam_client + iam_client = aws_service_client user_details = {} if criteria.key?(:username) @@ -142,7 +130,7 @@ class AwsIamAccessKeys < Inspec.resource(1) key_info[:created_with_user] = (key_info[:create_date] - key_info[:user_created_date]).abs < 1.0/24.0 # Last used is a separate API call - iam_client = AWSConnection.new.iam_client + iam_client = aws_service_client last_used = iam_client.get_access_key_last_used(access_key_id: key_info[:access_key_id]) .access_key_last_used.last_used_date @@ -154,25 +142,5 @@ class AwsIamAccessKeys < Inspec.resource(1) key_info[:last_used_days_ago] = (key_info[:last_used_hours_ago]/24).to_i end end - - DEFAULT_PROVIDER = AwsIamAccessKeys::AccessKeyProvider::AwsUserIterator - @selected_implementation = DEFAULT_PROVIDER - - # Use this to change what class is created by create(). - def self.select(klass) - @selected_implementation = klass - end - - def self.reset - @selected_implementation = DEFAULT_PROVIDER - end - - def self.create - @selected_implementation.new - end - - def fetch(_filter_criteria) - raise 'Unimplemented abstract method - internal error.' - end end end diff --git a/libraries/aws_iam_group.rb b/libraries/aws_iam_group.rb index 2b4215ae3..d70b50ac9 100644 --- a/libraries/aws_iam_group.rb +++ b/libraries/aws_iam_group.rb @@ -8,8 +8,9 @@ class AwsIamGroup < Inspec.resource(1) it { should exist } end " + supports platform: 'aws' - include AwsResourceMixin + include AwsSingularResourceMixin attr_reader :group_name def to_s @@ -33,8 +34,8 @@ class AwsIamGroup < Inspec.resource(1) validated_params end - def fetch_from_aws - backend = AwsIamGroup::BackendFactory.create + def fetch_from_api + backend = AwsIamGroup::BackendFactory.create(inspec_runner) begin @aws_group_struct = backend.get_group(group_name: group_name)[:group] @@ -45,10 +46,13 @@ class AwsIamGroup < Inspec.resource(1) end class Backend - BackendFactory.set_default_backend(self) + class AwsClientApi < AwsBackendBase + BackendFactory.set_default_backend(self) + self.aws_client_class = Aws::IAM::Client - def get_group(query) - AWSConnection.new.iam_client.get_group(query) + def get_group(query) + aws_service_client.get_group(query) + end end end end diff --git a/libraries/aws_iam_groups.rb b/libraries/aws_iam_groups.rb index 9e3b4ed55..b6af33825 100644 --- a/libraries/aws_iam_groups.rb +++ b/libraries/aws_iam_groups.rb @@ -8,36 +8,39 @@ class AwsIamGroups < Inspec.resource(1) it { should exist } end ' + supports platform: 'aws' + + include AwsPluralResourceMixin + + def validate_params(resource_params) + unless resource_params.empty? + raise ArgumentError, 'aws_iam_groups does not accept resource parameters.' + end + resource_params + end # Underlying FilterTable implementation. filter = FilterTable.create filter.add_accessor(:entries) .add(:exists?) { |x| !x.entries.empty? } - filter.connect(self, :group_data) - - def group_data - @table - end + filter.connect(self, :table) def to_s 'IAM Groups' end - def initialize - backend = AwsIamGroups::BackendFactory.create + def fetch_from_api + backend = BackendFactory.create(inspec_runner) @table = backend.list_groups.to_h[:groups] end - class BackendFactory - extend AwsBackendFactoryMixin - end - class Backend - class AwsClientApi + class AwsClientApi < AwsBackendBase BackendFactory.set_default_backend(self) + self.aws_client_class = Aws::IAM::Client def list_groups(query = {}) - AWSConnection.new.iam_client.list_groups(query) + aws_service_client.list_groups(query) end end end diff --git a/libraries/aws_iam_password_policy.rb b/libraries/aws_iam_password_policy.rb index d2b53426b..bcdf0925e 100644 --- a/libraries/aws_iam_password_policy.rb +++ b/libraries/aws_iam_password_policy.rb @@ -14,13 +14,26 @@ class AwsIamPasswordPolicy < Inspec.resource(1) its('requires_uppercase_characters?') { should be true } end EOX + supports platform: 'aws' - def initialize(conn = AWSConnection.new) - @policy = conn.iam_resource.account_password_policy + # TODO: rewrite to avoid direct injection, match other resources, use AwsSingularResourceMixin + def initialize(conn = nil) + iam_resource = conn ? conn.iam_resource : inspec_runner.backend.aws_resource(Aws::IAM::Resource, {}) + @policy = iam_resource.account_password_policy rescue Aws::IAM::Errors::NoSuchEntity @policy = nil end + def inspec_runner + # When running under inspec-cli, we have an 'inspec' method that + # returns the runner. When running under unit tests, we don't + # have that, but we still have to call this to pass something + # (nil is OK) to the backend. + # TODO: remove with https://github.com/chef/inspec-aws/issues/216 + # TODO: remove after rewrite to include AwsSingularResource + inspec if respond_to?(:inspec) + end + def exists? !@policy.nil? end diff --git a/libraries/aws_iam_policies.rb b/libraries/aws_iam_policies.rb index c27f7e062..38d51f02c 100644 --- a/libraries/aws_iam_policies.rb +++ b/libraries/aws_iam_policies.rb @@ -8,6 +8,15 @@ class AwsIamPolicies < Inspec.resource(1) it { should exist } end ' + supports platform: 'aws' + + include AwsPluralResourceMixin + def validate_params(resource_params) + unless resource_params.empty? + raise ArgumentError, 'aws_iam_policies does not accept resource parameters.' + end + resource_params + end # Underlying FilterTable implementation. filter = FilterTable.create @@ -15,31 +24,24 @@ class AwsIamPolicies < Inspec.resource(1) .add(:exists?) { |x| !x.entries.empty? } .add(:policy_names, field: :policy_name) .add(:arns, field: :arn) - filter.connect(self, :policy_data) - - def policy_data - @table - end + filter.connect(self, :table) def to_s 'IAM Policies' end - def initialize - backend = AwsIamPolicies::BackendFactory.create + def fetch_from_api + backend = BackendFactory.create(inspec_runner) @table = backend.list_policies({}).to_h[:policies] end - class BackendFactory - extend AwsBackendFactoryMixin - end - class Backend - class AwsClientApi + class AwsClientApi < AwsBackendBase BackendFactory.set_default_backend(self) + self.aws_client_class = Aws::IAM::Client def list_policies(query) - AWSConnection.new.iam_client.list_policies(query) + aws_service_client.list_policies(query) end end end diff --git a/libraries/aws_iam_policy.rb b/libraries/aws_iam_policy.rb index 5573e8698..785e6ae47 100644 --- a/libraries/aws_iam_policy.rb +++ b/libraries/aws_iam_policy.rb @@ -8,10 +8,11 @@ class AwsIamPolicy < Inspec.resource(1) it { should be_attached } end " + supports platform: 'aws' - include AwsResourceMixin + include AwsSingularResourceMixin - attr_reader :arn, :default_version_id, :attachment_count + attr_reader :arn, :attachment_count, :default_version_id def to_s "Policy #{@policy_name}" @@ -68,8 +69,8 @@ class AwsIamPolicy < Inspec.resource(1) validated_params end - def fetch_from_aws - backend = AwsIamPolicy::BackendFactory.create + def fetch_from_api + backend = BackendFactory.create(inspec_runner) criteria = { max_items: 1000 } # maxItems max value is 1000 resp = backend.list_policies(criteria) @@ -92,7 +93,7 @@ class AwsIamPolicy < Inspec.resource(1) @attached_roles = nil return end - backend = AwsIamPolicy::BackendFactory.create + backend = AwsIamPolicy::BackendFactory.create(inspec_runner) criteria = { policy_arn: arn } resp = backend.list_entities_for_policy(criteria) @attached_groups = resp.policy_groups.map(&:group_name) @@ -101,15 +102,16 @@ class AwsIamPolicy < Inspec.resource(1) end class Backend - class AwsClientApi + class AwsClientApi < AwsBackendBase BackendFactory.set_default_backend(self) + self.aws_client_class = Aws::IAM::Client def list_policies(criteria) - AWSConnection.new.iam_client.list_policies(criteria) + aws_service_client.list_policies(criteria) end def list_entities_for_policy(criteria) - AWSConnection.new.iam_client.list_entities_for_policy(criteria) + aws_service_client.list_entities_for_policy(criteria) end end end diff --git a/libraries/aws_iam_role.rb b/libraries/aws_iam_role.rb index ea11b88cf..8d9a8fad5 100644 --- a/libraries/aws_iam_role.rb +++ b/libraries/aws_iam_role.rb @@ -8,9 +8,10 @@ class AwsIamRole < Inspec.resource(1) it { should exist } end " + supports platform: 'aws' - include AwsResourceMixin - attr_reader :role_name, :description + include AwsSingularResourceMixin + attr_reader :description, :role_name private @@ -27,10 +28,10 @@ class AwsIamRole < Inspec.resource(1) validated_params end - def fetch_from_aws + def fetch_from_api role_info = nil begin - role_info = AwsIamRole::BackendFactory.create.get_role(role_name: role_name) + role_info = BackendFactory.create(inspec_runner).get_role(role_name: role_name) rescue Aws::IAM::Errors::NoSuchEntity @exists = false return @@ -41,10 +42,11 @@ class AwsIamRole < Inspec.resource(1) # Uses the SDK API to really talk to AWS class Backend - class AwsClientApi + class AwsClientApi < AwsBackendBase BackendFactory.set_default_backend(self) + self.aws_client_class = Aws::IAM::Client def get_role(query) - AWSConnection.new.iam_client.get_role(query) + aws_service_client.get_role(query) end end end diff --git a/libraries/aws_iam_root_user.rb b/libraries/aws_iam_root_user.rb index 89a9fae35..5d82a124c 100644 --- a/libraries/aws_iam_root_user.rb +++ b/libraries/aws_iam_root_user.rb @@ -9,9 +9,21 @@ class AwsIamRootUser < Inspec.resource(1) it { should have_access_key } end " + supports platform: 'aws' - def initialize(conn = AWSConnection.new) - @client = conn.iam_client + # TODO: rewrite to avoid direct injection, match other resources, use AwsSingularResourceMixin + def initialize(conn = nil) + @client = conn ? conn.iam_client : inspec_runner.backend.aws_client(Aws::IAM::Client) + end + + def inspec_runner + # When running under inspec-cli, we have an 'inspec' method that + # returns the runner. When running under unit tests, we don't + # have that, but we still have to call this to pass something + # (nil is OK) to the backend. + # TODO: remove with https://github.com/chef/inspec-aws/issues/216 + # TODO: remove after rewrite to include AwsSingularResource + inspec if respond_to?(:inspec) end def has_access_key? diff --git a/libraries/aws_iam_user.rb b/libraries/aws_iam_user.rb index 1d9f3726b..65b88f5c6 100644 --- a/libraries/aws_iam_user.rb +++ b/libraries/aws_iam_user.rb @@ -13,9 +13,10 @@ class AwsIamUser < Inspec.resource(1) it { should_not have_console_password } end " + supports platform: 'aws' - include AwsResourceMixin - attr_reader :username, :has_mfa_enabled, :has_console_password, :access_keys + include AwsSingularResourceMixin + attr_reader :access_keys, :has_console_password, :has_mfa_enabled, :username alias has_mfa_enabled? has_mfa_enabled alias has_console_password? has_console_password @@ -55,8 +56,8 @@ class AwsIamUser < Inspec.resource(1) validated_params end - def fetch_from_aws - backend = BackendFactory.create + def fetch_from_api + backend = BackendFactory.create(inspec_runner) @aws_user_struct ||= nil # silence unitialized warning unless @aws_user_struct begin @@ -86,23 +87,24 @@ class AwsIamUser < Inspec.resource(1) end class Backend - class AwsClientApi + class AwsClientApi < AwsBackendBase BackendFactory.set_default_backend(self) + self.aws_client_class = Aws::IAM::Client def get_user(criteria) - AWSConnection.new.iam_client.get_user(criteria) + aws_service_client.get_user(criteria) end def get_login_profile(criteria) - AWSConnection.new.iam_client.get_login_profile(criteria) + aws_service_client.get_login_profile(criteria) end def list_mfa_devices(criteria) - AWSConnection.new.iam_client.list_mfa_devices(criteria) + aws_service_client.list_mfa_devices(criteria) end def list_access_keys(criteria) - AWSConnection.new.iam_client.list_access_keys(criteria) + aws_service_client.list_access_keys(criteria) end end end diff --git a/libraries/aws_iam_users.rb b/libraries/aws_iam_users.rb index 4e3276791..8f6b17577 100644 --- a/libraries/aws_iam_users.rb +++ b/libraries/aws_iam_users.rb @@ -15,6 +15,9 @@ class AwsIamUsers < Inspec.resource(1) it { should exist } end ' + supports platform: 'aws' + + include AwsPluralResourceMixin filter = FilterTable.create filter.add_accessor(:where) @@ -26,18 +29,22 @@ class AwsIamUsers < Inspec.resource(1) .add(:password_never_used?, field: :password_never_used?) .add(:password_last_used_days_ago, field: :password_last_used_days_ago) .add(:username, field: :user_name) - filter.connect(self, :collect_user_details) + filter.connect(self, :table) - # No resource params => no overridden constructor - # AWS API only offers filtering on path prefix; - # little other opportunity for server-side filtering. + def validate_params(raw_params) + # No params yet + unless raw_params.empty? + raise ArgumentError, 'aws_iam_users does not accept resource parameters' + end + raw_params + end - def collect_user_details - backend = Backend.create - users = backend.list_users.users.map(&:to_h) + def fetch_from_api + backend = BackendFactory.create(inspec_runner) + @table = backend.list_users.users.map(&:to_h) # TODO: lazy columns - https://github.com/chef/inspec-aws/issues/100 - users.each do |user| + @table.each do |user| begin _login_profile = backend.get_login_profile(user_name: user[:user_name]) user[:has_console_password] = true @@ -59,53 +66,33 @@ class AwsIamUsers < Inspec.resource(1) next unless user[:password_ever_used?] user[:password_last_used_days_ago] = ((Time.now - password_last_used) / (24*60*60)).to_i end - users + @table end def to_s 'IAM Users' end - # Entry cooker. Needs discussion. - # def users - # end - #===========================================================================# # Backend Implementation #===========================================================================# class Backend - #=====================================================# - # Concrete Implementation - #=====================================================# - # Uses AWS API to really talk to AWS - class AwsClientApi < Backend + class AwsClientApi < AwsBackendBase + BackendFactory.set_default_backend(self) + self.aws_client_class = Aws::IAM::Client + # TODO: delegate this out def list_users(query = {}) - AWSConnection.new.iam_client.list_users(query) + aws_service_client.list_users(query) end def get_login_profile(query) - AWSConnection.new.iam_client.get_login_profile(query) + aws_service_client.get_login_profile(query) end def list_mfa_devices(query) - AWSConnection.new.iam_client.list_mfa_devices(query) + aws_service_client.list_mfa_devices(query) end end - - #=====================================================# - # Factory Interface - #=====================================================# - # TODO: move this to a mix-in - DEFAULT_BACKEND = AwsClientApi - @selected_backend = DEFAULT_BACKEND - - def self.create - @selected_backend.new - end - - def self.select(klass) - @selected_backend = klass - end end end diff --git a/libraries/aws_kms_keys.rb b/libraries/aws_kms_keys.rb index a1649b1fc..e0bdc60a9 100644 --- a/libraries/aws_kms_keys.rb +++ b/libraries/aws_kms_keys.rb @@ -8,6 +8,15 @@ class AwsKmsKeys < Inspec.resource(1) it { should exist } end ' + supports platform: 'aws' + + include AwsPluralResourceMixin + def validate_params(resource_params) + unless resource_params.empty? + raise ArgumentError, 'aws_kms_keys does not accept resource parameters.' + end + resource_params + end # Underlying FilterTable implementation. filter = FilterTable.create @@ -15,31 +24,24 @@ class AwsKmsKeys < Inspec.resource(1) .add(:exists?) { |x| !x.entries.empty? } .add(:key_arns, field: :key_arn) .add(:key_ids, field: :key_id) - filter.connect(self, :key_data) - - def key_data - @table - end + filter.connect(self, :table) def to_s 'KMS Keys' end - def initialize - backend = AwsKmsKeys::BackendFactory.create + def fetch_from_api + backend = BackendFactory.create(inspec_runner) @table = backend.list_keys({ limit: 1000 }).to_h[:keys] # max value for limit is 1000 end - class BackendFactory - extend AwsBackendFactoryMixin - end - class Backend - class AwsClientApi + class AwsClientApi < AwsBackendBase BackendFactory.set_default_backend(self) + self.aws_client_class = Aws::KMS::Client def list_keys(query = {}) - AWSConnection.new.kms_client.list_keys(query) + aws_service_client.list_keys(query) end end end diff --git a/libraries/aws_route_table.rb b/libraries/aws_route_table.rb index 4f96d3f0d..7e313f9f5 100644 --- a/libraries/aws_route_table.rb +++ b/libraries/aws_route_table.rb @@ -1,3 +1,5 @@ +require '_aws' + class AwsRouteTable < Inspec.resource(1) name 'aws_route_table' desc 'Verifies settings for an AWS Route Table' @@ -6,8 +8,9 @@ class AwsRouteTable < Inspec.resource(1) its('route_table_id') { should cmp 'rtb-2c60ec44' } end " + supports platform: 'aws' - include AwsResourceMixin + include AwsSingularResourceMixin def to_s "Route Table #{@route_table_id}" @@ -33,8 +36,8 @@ class AwsRouteTable < Inspec.resource(1) validated_params end - def fetch_from_aws - backend = AwsRouteTable::BackendFactory.create + def fetch_from_api + backend = BackendFactory.create(inspec_runner) if @route_table_id.nil? args = nil @@ -48,11 +51,12 @@ class AwsRouteTable < Inspec.resource(1) end class Backend - class AwsClientApi + class AwsClientApi < AwsBackendBase BackendFactory.set_default_backend(self) + self.aws_client_class = Aws::EC2::Client def describe_route_tables(query) - AWSConnection.new.ec2_client.describe_route_tables(query) + aws_service_client.describe_route_tables(query) end end end diff --git a/libraries/aws_s3_bucket.rb b/libraries/aws_s3_bucket.rb index e8ad98d61..9c01a2ab2 100644 --- a/libraries/aws_s3_bucket.rb +++ b/libraries/aws_s3_bucket.rb @@ -9,8 +9,9 @@ class AwsS3Bucket < Inspec.resource(1) it { should exist } end " + supports platform: 'aws' - include AwsResourceMixin + include AwsSingularResourceMixin attr_reader :bucket_name, :region def to_s @@ -19,7 +20,7 @@ class AwsS3Bucket < Inspec.resource(1) def bucket_acl # This is simple enough to inline it. - @bucket_acl ||= AwsS3Bucket::BackendFactory.create.get_bucket_acl(bucket: bucket_name).grants + @bucket_acl ||= BackendFactory.create(inspec_runner).get_bucket_acl(bucket: bucket_name).grants end def bucket_policy @@ -38,7 +39,7 @@ class AwsS3Bucket < Inspec.resource(1) def has_access_logging_enabled? return unless @exists # This is simple enough to inline it. - !AwsS3Bucket::BackendFactory.create.get_bucket_logging(bucket: bucket_name).logging_enabled.nil? + !BackendFactory.create(inspec_runner).get_bucket_logging(bucket: bucket_name).logging_enabled.nil? end private @@ -57,8 +58,8 @@ class AwsS3Bucket < Inspec.resource(1) validated_params end - def fetch_from_aws - backend = AwsS3Bucket::BackendFactory.create + def fetch_from_api + backend = BackendFactory.create(inspec_runner) # Since there is no basic "get_bucket" API call, use the # region fetch as the existence check. @@ -72,7 +73,7 @@ class AwsS3Bucket < Inspec.resource(1) end def fetch_bucket_policy - backend = AwsS3Bucket::BackendFactory.create + backend = BackendFactory.create(inspec_runner) begin # AWS SDK returns a StringIO, we have to read() @@ -89,23 +90,24 @@ class AwsS3Bucket < Inspec.resource(1) # Uses the SDK API to really talk to AWS class Backend - class AwsClientApi + class AwsClientApi < AwsBackendBase BackendFactory.set_default_backend(self) + self.aws_client_class = Aws::S3::Client def get_bucket_acl(query) - AWSConnection.new.s3_client.get_bucket_acl(query) + aws_service_client.get_bucket_acl(query) end def get_bucket_location(query) - AWSConnection.new.s3_client.get_bucket_location(query) + aws_service_client.get_bucket_location(query) end def get_bucket_policy(query) - AWSConnection.new.s3_client.get_bucket_policy(query) + aws_service_client.get_bucket_policy(query) end def get_bucket_logging(query) - AWSConnection.new.s3_client.get_bucket_logging(query) + aws_service_client.get_bucket_logging(query) end end end diff --git a/libraries/aws_sns_topic.rb b/libraries/aws_sns_topic.rb index d9c790a0c..961eb624d 100644 --- a/libraries/aws_sns_topic.rb +++ b/libraries/aws_sns_topic.rb @@ -9,8 +9,9 @@ class AwsSnsTopic < Inspec.resource(1) its('confirmed_subscription_count') { should_not be_zero } end " + supports platform: 'aws' - include AwsResourceMixin + include AwsSingularResourceMixin attr_reader :arn, :confirmed_subscription_count private @@ -30,8 +31,8 @@ class AwsSnsTopic < Inspec.resource(1) validated_params end - def fetch_from_aws - aws_response = AwsSnsTopic::BackendFactory.create.get_topic_attributes(topic_arn: @arn).attributes + def fetch_from_api + aws_response = BackendFactory.create(inspec_runner).get_topic_attributes(topic_arn: @arn).attributes @exists = true # The response has a plain hash with CamelCase plain string keys and string values @@ -42,11 +43,12 @@ class AwsSnsTopic < Inspec.resource(1) # Uses the SDK API to really talk to AWS class Backend - class AwsClientApi + class AwsClientApi < AwsBackendBase BackendFactory.set_default_backend(self) + self.aws_client_class = Aws::SNS::Client def get_topic_attributes(criteria) - AWSConnection.new.sns_client.get_topic_attributes(criteria) + aws_service_client.get_topic_attributes(criteria) end end end diff --git a/libraries/aws_vpc.rb b/libraries/aws_vpc.rb index f8453dc59..0fbb895b3 100644 --- a/libraries/aws_vpc.rb +++ b/libraries/aws_vpc.rb @@ -9,8 +9,9 @@ class AwsVpc < Inspec.resource(1) its('cidr_block') { should cmp '10.0.0.0/16' } end " + supports platform: 'aws' - include AwsResourceMixin + include AwsSingularResourceMixin def to_s "VPC #{vpc_id}" @@ -41,8 +42,8 @@ class AwsVpc < Inspec.resource(1) validated_params end - def fetch_from_aws - backend = AwsVpc::BackendFactory.create + def fetch_from_api + backend = BackendFactory.create(inspec_runner) if @vpc_id.nil? filter = { name: 'isDefault', values: ['true'] } @@ -58,11 +59,12 @@ class AwsVpc < Inspec.resource(1) end class Backend - class AwsClientApi + class AwsClientApi < AwsBackendBase BackendFactory.set_default_backend(self) + self.aws_client_class = Aws::EC2::Client def describe_vpcs(query) - AWSConnection.new.ec2_client.describe_vpcs(query) + aws_service_client.describe_vpcs(query) end end end diff --git a/libraries/aws_vpc_subnet.rb b/libraries/aws_vpc_subnet.rb index 8d9c1ad90..728e1ec11 100644 --- a/libraries/aws_vpc_subnet.rb +++ b/libraries/aws_vpc_subnet.rb @@ -11,11 +11,12 @@ class AwsVpcSubnet < Inspec.resource(1) its('cidr_block') { should eq '10.0.1.0/24' } end " + supports platform: 'aws' - include AwsResourceMixin - attr_reader :vpc_id, :subnet_id, :cidr_block, :availability_zone, :available_ip_address_count, - :default_for_az, :mapping_public_ip_on_launch, :available, :ipv_6_cidr_block_association_set, - :assigning_ipv_6_address_on_creation + include AwsSingularResourceMixin + attr_reader :assigning_ipv_6_address_on_creation, :availability_zone, :available_ip_address_count, + :available, :cidr_block, :default_for_az, :ipv_6_cidr_block_association_set, + :mapping_public_ip_on_launch, :subnet_id, :vpc_id alias available? available alias default_for_az? default_for_az alias mapping_public_ip_on_launch? mapping_public_ip_on_launch @@ -47,8 +48,8 @@ class AwsVpcSubnet < Inspec.resource(1) validated_params end - def fetch_from_aws - backend = AwsVpcSubnet::BackendFactory.create + def fetch_from_api + backend = BackendFactory.create(inspec_runner) # Transform into filter format expected by AWS filters = [] @@ -79,10 +80,12 @@ class AwsVpcSubnet < Inspec.resource(1) # Uses the SDK API to really talk to AWS class Backend - class AwsClientApi + class AwsClientApi < AwsBackendBase BackendFactory.set_default_backend(self) + self.aws_client_class = Aws::EC2::Client + def describe_subnets(query) - AWSConnection.new.ec2_client.describe_subnets(query) + aws_service_client.describe_subnets(query) end end end diff --git a/libraries/aws_vpc_subnets.rb b/libraries/aws_vpc_subnets.rb index 11d264a0b..ed6dcdb31 100644 --- a/libraries/aws_vpc_subnets.rb +++ b/libraries/aws_vpc_subnets.rb @@ -1,5 +1,3 @@ -# @author: Matthew Dromazos - require '_aws' class AwsVpcSubnets < Inspec.resource(1) @@ -13,9 +11,19 @@ class AwsVpcSubnets < Inspec.resource(1) its('states') { should_not include 'pending' } end " + supports platform: 'aws' - def initialize - backend = AwsVpcSubnets::BackendFactory.create + include AwsPluralResourceMixin + + def validate_params(resource_params) + unless resource_params.empty? + raise ArgumentError, 'aws_vpc_subnets does not accept resource parameters.' + end + resource_params + end + + def fetch_from_api + backend = BackendFactory.create(inspec_runner) @table = backend.describe_subnets.subnets.map(&:to_h) end @@ -28,26 +36,19 @@ class AwsVpcSubnets < Inspec.resource(1) .add(:subnet_ids, field: :subnet_id) .add(:cidr_blocks, field: :cidr_block) .add(:states, field: :state) - filter.connect(self, :access_key_data) - - def access_key_data - @table - end + filter.connect(self, :table) def to_s 'EC2 VPC Subnets' end - class BackendFactory - extend AwsBackendFactoryMixin - end - class Backend - class AwsClientApi < Backend + class AwsClientApi < AwsBackendBase AwsVpcSubnets::BackendFactory.set_default_backend self + self.aws_client_class = Aws::EC2::Client def describe_subnets(query = {}) - AWSConnection.new.ec2_client.describe_subnets(query) + aws_service_client.describe_subnets(query) end end end diff --git a/libraries/aws_vpcs.rb b/libraries/aws_vpcs.rb index a9891be4f..654056737 100644 --- a/libraries/aws_vpcs.rb +++ b/libraries/aws_vpcs.rb @@ -8,36 +8,39 @@ class AwsVpcs < Inspec.resource(1) it { should exist } end ' + supports platform: 'aws' + + include AwsPluralResourceMixin # Underlying FilterTable implementation. filter = FilterTable.create filter.add_accessor(:entries) .add(:exists?) { |x| !x.entries.empty? } - filter.connect(self, :vpc_data) + filter.connect(self, :table) - def vpc_data - @table + def validate_params(raw_params) + # No params yet + unless raw_params.empty? + raise ArgumentError, 'aws_vpcs does not accept resource parameters' + end + raw_params end def to_s 'VPCs' end - def initialize - backend = AwsVpcs::BackendFactory.create - @table = backend.describe_vpcs.to_h[:vpcs] - end - - class BackendFactory - extend AwsBackendFactoryMixin + def fetch_from_api + @table = BackendFactory.create(inspec_runner).describe_vpcs.to_h[:vpcs] end class Backend - class AwsClientApi + class AwsClientApi < AwsBackendBase BackendFactory.set_default_backend(self) + self.aws_client_class = Aws::EC2::Client def describe_vpcs(query = {}) - AWSConnection.new.ec2_client.describe_vpcs(query) + aws_service_client.describe_vpcs(query) end end end diff --git a/test/integration/default/verify/controls/aws_iam_access_key.rb b/test/integration/default/verify/controls/aws_iam_access_key.rb index 12f565f71..21c4e30c7 100644 --- a/test/integration/default/verify/controls/aws_iam_access_key.rb +++ b/test/integration/default/verify/controls/aws_iam_access_key.rb @@ -41,49 +41,3 @@ end #------------- Property - last_used_date -------------# # TODO: last_used_date tests - -#======================================================# -# IAM Access Key - Plural -#======================================================# - -control 'IAM Access Keys - fetch all' do - describe aws_iam_access_keys do - it { should exist } - end -end - -control 'IAM Access Keys - Client-side filtering' do - all_keys = aws_iam_access_keys - describe all_keys.where(username: fixtures['iam_user_with_access_key']) do - its('entries.length') { should be 1 } - its('access_key_ids.first') { should eq fixtures['iam_access_key_recall_hit'] } - end - describe all_keys.where(created_days_ago: 0) do - it { should exist } - end - describe all_keys.where { active } do - it { should exist } - end - - # This would presumably refer to your test-user-last-key-use IAM user - # This test will fail if you have very recently setup your - # testing environment - describe all_keys.where { ever_used } - .where { last_used_days_ago > 0 } do - it { should exist } - end - describe all_keys.where { created_with_user } do - it { should exist } - end -end - -control 'IAM Access Keys - fetch-time filtering' do - describe aws_iam_access_keys(username: fixtures['iam_user_with_access_key']) do - its('entries.length') { should be 1 } - its('access_key_ids.first') { should eq fixtures['iam_access_key_recall_hit'] } - end - - describe aws_iam_access_keys(username: fixtures['iam_user_without_access_key']) do - it { should_not exist } - end -end \ No newline at end of file diff --git a/test/integration/default/verify/controls/aws_iam_access_keys.rb b/test/integration/default/verify/controls/aws_iam_access_keys.rb new file mode 100644 index 000000000..36b07926f --- /dev/null +++ b/test/integration/default/verify/controls/aws_iam_access_keys.rb @@ -0,0 +1,58 @@ +fixtures = {} +[ + 'iam_user_with_access_key', + 'iam_user_without_access_key', + 'iam_access_key_recall_hit', +].each do |fixture_name| + fixtures[fixture_name] = attribute( + fixture_name, + default: "default.#{fixture_name}", + description: 'See ../build/iam.tf', + ) +end + +#======================================================# +# IAM Access Key - Plural +#======================================================# + +control 'IAM Access Keys - fetch all' do + describe aws_iam_access_keys do + it { should exist } + end +end + +control 'IAM Access Keys - Client-side filtering' do + all_keys = aws_iam_access_keys + describe all_keys.where(username: fixtures['iam_user_with_access_key']) do + its('entries.length') { should be 1 } + its('access_key_ids.first') { should eq fixtures['iam_access_key_recall_hit'] } + end + describe all_keys.where(created_days_ago: 0) do + it { should exist } + end + describe all_keys.where { active } do + it { should exist } + end + + # This would presumably refer to your test-user-last-key-use IAM user + # This test will fail if you have very recently setup your + # testing environment + describe all_keys.where { ever_used } + .where { last_used_days_ago > 0 } do + it { should exist } + end + describe all_keys.where { created_with_user } do + it { should exist } + end +end + +control 'IAM Access Keys - fetch-time filtering' do + describe aws_iam_access_keys(username: fixtures['iam_user_with_access_key']) do + its('entries.length') { should be 1 } + its('access_key_ids.first') { should eq fixtures['iam_access_key_recall_hit'] } + end + + describe aws_iam_access_keys(username: fixtures['iam_user_without_access_key']) do + it { should_not exist } + end +end \ No newline at end of file diff --git a/test/unit/resources/aws_cloudtrail_trail_test.rb b/test/unit/resources/aws_cloudtrail_trail_test.rb index ea373279b..115073fa7 100644 --- a/test/unit/resources/aws_cloudtrail_trail_test.rb +++ b/test/unit/resources/aws_cloudtrail_trail_test.rb @@ -132,13 +132,13 @@ end # Test Fixtures #=============================================================================# module MACTTSB - class Empty < AwsCloudTrailTrail::Backend + class Empty < AwsBackendBase def describe_trails(query) OpenStruct.new(trail_list: []) end end - class Basic < AwsCloudTrailTrail::Backend + class Basic < AwsBackendBase def describe_trails(query) fixtures = [ OpenStruct.new({ diff --git a/test/unit/resources/aws_cloudtrail_trails_test.rb b/test/unit/resources/aws_cloudtrail_trails_test.rb index e4fcc5df9..d67553de9 100644 --- a/test/unit/resources/aws_cloudtrail_trails_test.rb +++ b/test/unit/resources/aws_cloudtrail_trails_test.rb @@ -74,13 +74,13 @@ end # Test Fixtures #=============================================================================# module MACTTPB - class Empty < AwsCloudTrailTrails::Backend + class Empty < AwsBackendBase def describe_trails(query = {}) OpenStruct.new({ trail_list: [] }) end end - class Basic < AwsCloudTrailTrails::Backend + class Basic < AwsBackendBase def describe_trails(query = {}) fixtures = [ OpenStruct.new({ diff --git a/test/unit/resources/aws_cloudwatch_alarm_test.rb b/test/unit/resources/aws_cloudwatch_alarm_test.rb index 947bb2836..c0a6c761d 100644 --- a/test/unit/resources/aws_cloudwatch_alarm_test.rb +++ b/test/unit/resources/aws_cloudwatch_alarm_test.rb @@ -108,7 +108,7 @@ end #=============================================================================# module AwsMCWAB - class Empty < AwsCloudwatchAlarm::Backend + class Empty < AwsBackendBase def describe_alarms_for_metric(_criteria) OpenStruct.new({ metric_alarms: [], @@ -116,7 +116,7 @@ module AwsMCWAB end end - class Basic < AwsCloudwatchAlarm::Backend + class Basic < AwsBackendBase def describe_alarms_for_metric(criteria) OpenStruct.new({ metric_alarms: [ # rubocop:disable Metrics/BlockLength diff --git a/test/unit/resources/aws_cloudwatch_log_metric_filter_test.rb b/test/unit/resources/aws_cloudwatch_log_metric_filter_test.rb index dbe5d2f71..d03c6326a 100644 --- a/test/unit/resources/aws_cloudwatch_log_metric_filter_test.rb +++ b/test/unit/resources/aws_cloudwatch_log_metric_filter_test.rb @@ -97,12 +97,12 @@ end # Support Classes - Mock Data Providers # #=============================================================================# class AwsMockCWLMFBackend - class Empty < AwsCloudwatchLogMetricFilter::Backend + class Empty < AwsBackendBase def describe_metric_filters(_criteria) [] end end - class Basic < AwsCloudwatchLogMetricFilter::Backend + class Basic < AwsBackendBase def describe_metric_filters(criteria) # rubocop:disable Metrics/MethodLength everything = [ OpenStruct.new({ diff --git a/test/unit/resources/aws_ec2_security_group_test.rb b/test/unit/resources/aws_ec2_security_group_test.rb index 2f9b01bad..a7c77e1c7 100644 --- a/test/unit/resources/aws_ec2_security_group_test.rb +++ b/test/unit/resources/aws_ec2_security_group_test.rb @@ -83,7 +83,7 @@ end #=============================================================================# module AwsMESGSB - class Empty < AwsEc2SecurityGroup::Backend + class Empty < AwsBackendBase def describe_security_groups(_query) OpenStruct.new({ security_groups: [], @@ -91,7 +91,7 @@ module AwsMESGSB end end - class Basic < AwsEc2SecurityGroup::Backend + class Basic < AwsBackendBase def describe_security_groups(query) fixtures = [ OpenStruct.new({ diff --git a/test/unit/resources/aws_ec2_security_groups_test.rb b/test/unit/resources/aws_ec2_security_groups_test.rb index c977aca6f..0b4bb1445 100644 --- a/test/unit/resources/aws_ec2_security_groups_test.rb +++ b/test/unit/resources/aws_ec2_security_groups_test.rb @@ -69,7 +69,7 @@ end #=============================================================================# module AwsMESGB - class Empty < AwsEc2SecurityGroups::Backend + class Empty < AwsBackendBase def describe_security_groups(_query) OpenStruct.new({ security_groups: [], @@ -77,7 +77,7 @@ module AwsMESGB end end - class Basic < AwsEc2SecurityGroups::Backend + class Basic < AwsBackendBase def describe_security_groups(query) fixtures = [ OpenStruct.new({ @@ -101,5 +101,4 @@ module AwsMESGB OpenStruct.new({ security_groups: selected }) end end - end diff --git a/test/unit/resources/aws_iam_access_key_test.rb b/test/unit/resources/aws_iam_access_key_test.rb index b07f4c761..dfb0604b6 100644 --- a/test/unit/resources/aws_iam_access_key_test.rb +++ b/test/unit/resources/aws_iam_access_key_test.rb @@ -1,287 +1,209 @@ -# author: Chris Redekop - require 'helper' require 'aws_iam_access_key' -class AwsIamAccessKeyTest < Minitest::Test - Username = 'test'.freeze - Id = 'id'.freeze - Date = 'date'.freeze +class AwsIamAccessKeyConstructorTest < Minitest::Test + def setup + AwsIamAccessKey::BackendFactory.select(EmptyMAIKSB) + end - module AccessKeyFactory - def aws_iam_access_key(decorator = mock_decorator(stub_access_key)) - AwsIamAccessKey.new({ username: Username, id: Id }, decorator) - end + # Username or ID are required + def test_username_sufficient + AwsIamAccessKey.new(username: 'bob') + end - def stub_access_key( - id: Id, - status: 'Active', - create_date: Date - ) - OpenStruct.new( - { - nil?: nil, - access_key_id: id, - status: status, - create_date: create_date, - }, - ) + def test_access_key_id_sufficient + AwsIamAccessKey.new(access_key_id: 'AKIA1234567890123BOB') + end + + def test_id_is_alias_for_access_key_id + AwsIamAccessKey.new(id: 'AKIA1234567890123BOB') + end + + def test_access_key_id_and_username_ok + AwsIamAccessKey.new(username: 'bob', access_key_id: 'AKIA1234567890123BOB') + end + + def test_access_key_id_gets_validated + assert_raises(ArgumentError) do + AwsIamAccessKey.new(access_key_id: 'martians') end end - include AccessKeyFactory - - def test_initialize_accepts_fields - assert_equal( - Id, - AwsIamAccessKey.new({ id: Id, username: Username }, nil) - .instance_variable_get('@id'), - ) - end - - def test_initialize_accepts_access_key - assert_equal( - Id, - AwsIamAccessKey.new( - { - access_key: OpenStruct.new(access_key_id: Id), - }, nil - ).instance_variable_get('@id'), - ) - end - - def test_initialize_prefers_access_key - assert_equal( - Id, - AwsIamAccessKey.new( - { - id: 'foo', - access_key: OpenStruct.new(access_key_id: Id), - }, nil - ).instance_variable_get('@id'), - ) - end - - def test_exists_returns_true_when_access_key_exists - assert aws_iam_access_key.exists? - end - - def test_exists_returns_false_when_sdk_raises - mock_decorator = mock_decorator_raise( - Aws::IAM::Errors::NoSuchEntity.new(nil, nil), - ) - - refute aws_iam_access_key(mock_decorator).exists? - - mock_decorator.verify - end - - def test_exists_returns_false_when_access_key_does_not_exist - mock_decorator = mock_decorator_raise( - AwsIamAccessKey::AccessKeyNotFoundError.new, - ) - - refute aws_iam_access_key(mock_decorator).exists? - - mock_decorator.verify - end - - def test_id_returns_id_when_access_key_exists - assert_equal Id, aws_iam_access_key.id - end - - def test_active_returns_true_when_access_key_is_active - assert aws_iam_access_key.active? - end - - def test_active_returns_false_when_access_key_is_not_active - refute aws_iam_access_key(mock_decorator(stub_access_key(status: 'Foo'))) - .active? - end - - def test_create_date_returns_create_date_always - assert_equal Date, aws_iam_access_key.create_date - end - - def test_last_used_date_returns_last_used_date_always - assert_equal( - Date, - aws_iam_access_key( - mock_decorator( - nil, - OpenStruct.new({ last_used_date: Date }), - ), - ).last_used_date, - ) - end - - class IamClientDecoratorTest < Minitest::Test - include AccessKeyFactory - - def test_get_access_key_raises_when_no_access_keys_found - validator = mock_validator - - e = assert_raises AwsIamAccessKey::AccessKeyNotFoundError do - iam_client_decorator(validator).get_access_key(Username, Id) - end - - assert_match(/.*access key not found.*/, e.message) - assert_match(/.*#{Username}.*/, e.message) - assert_match(/.*#{Id}.*/, e.message) - - validator.verify - end - - def test_get_access_key_raises_when_matching_access_key_not_found - validator = mock_validator - - e = assert_raises AwsIamAccessKey::AccessKeyNotFoundError do - iam_client_decorator( - validator, - [stub_access_key(id: 'Foo')], - ).get_access_key(Username, Id) - end - - assert_match(/.*access key not found.*/, e.message) - assert_match(/.*#{Username}.*/, e.message) - assert_match(/.*#{Id}.*/, e.message) - - validator.verify - end - - def test_get_access_key_returns_access_key_when_access_key_found - access_key = stub_access_key - validator = mock_validator - - assert_equal( - access_key, - iam_client_decorator( - validator, - [access_key], - ).get_access_key(Username, Id), - ) - - validator.verify - end - - def test_get_access_key_last_used_returns_last_used_when_last_used_found - access_key_last_used = Object.new - validator = mock_validator(false) - - assert_equal( - access_key_last_used, - iam_client_decorator( - validator, - nil, - access_key_last_used, - ).get_access_key_last_used(Id), - ) - - validator.verify - end - - class ArgumentValidatorTest < Minitest::Test - def test_validate_id_raises_when_id_is_nil - argument_validator.validate_id(nil) - flunk - rescue ArgumentError => e - assert_match(/.*missing.*"id".*/, e.message) - end - - def test_validate_id_does_nothing_when_id_is_not_nil - argument_validator.validate_id(Id) - end - - def test_validate_username_raises_when_username_is_nil - argument_validator.validate_username(nil) - flunk - rescue ArgumentError => e - assert_match(/.*missing.*"username".*/, e.message) - end - - def test_validate_username_does_nothing_when_username_is_not_nil - argument_validator.validate_username(Username) - end - - def argument_validator - AwsIamAccessKey::IamClientDecorator::ArgumentValidator.new - end - end - - def mock_validator(validate_username = true) - mock_validator = Minitest::Mock.new.expect :validate_id, nil, [Id] - - if validate_username - mock_validator.expect :validate_username, nil, [Username] - end - - mock_validator - end - - def mock_conn(access_keys, access_key_last_used = nil) - Minitest::Mock.new.expect( - :iam_client, - mock_client(access_keys, access_key_last_used), - ) - end - - def mock_client(access_keys, access_key_last_used) - mock_iam_client = Minitest::Mock.new - - if access_keys - mock_iam_client.expect( - :list_access_keys, - OpenStruct.new({ 'access_key_metadata' => access_keys }), - [{ user_name: Username }], - ) - end - - if access_key_last_used - mock_iam_client.expect( - :get_access_key_last_used, - OpenStruct.new({ 'access_key_last_used' => access_key_last_used }), - [{ access_key_id: Id }], - ) - end - - mock_iam_client - end - - def iam_client_decorator( - validator, - access_keys = [], - access_key_last_used = nil - ) - AwsIamAccessKey::IamClientDecorator.new( - validator, mock_conn(access_keys, access_key_last_used) - ) - end - end - - def mock_decorator(access_key, access_key_last_used = nil) - mock_decorator = Minitest::Mock.new - - if access_key - mock_decorator.expect :get_access_key, access_key, [Username, Id] - end - - if access_key_last_used - mock_decorator.expect( - :get_access_key_last_used, - access_key_last_used, - [Id], - ) - end - - mock_decorator - end - - def mock_decorator_raise(error) - Minitest::Mock.new.expect(:get_access_key, nil) do |username, id| - assert_equal Username, username - assert_equal Id, id - - raise error + def test_reject_other_params + assert_raises(ArgumentError) do + AwsIamAccessKey.new(shoe_size: 9) end end end + +#==========================================================# +# Search / Recall # +#==========================================================# +class AwsIamAccessKeyRecallTest < Minitest::Test + def setup + AwsIamAccessKey::BackendFactory.select(BasicMAIKSB) + end + + def test_recall_access_key_id_hit + key = AwsIamAccessKey.new(access_key_id: 'AKIA1234567890123BOB') + assert(key.exists?) + end + + def test_recall_access_key_id_miss + key = AwsIamAccessKey.new(access_key_id: 'AKIA123456789012NOPE') + refute(key.exists?) + end + + def test_recall_username_hit + key = AwsIamAccessKey.new(username: 'bob') + assert(key.exists?) + end + + # Recall miss by username + def test_recall_username_miss + key = AwsIamAccessKey.new(username: 'nope') + refute(key.exists?) + end + + # Recall multiple hit by username + def test_recall_username_multiple + assert_raises(RuntimeError) do + AwsIamAccessKey.new(username: 'sally') + end + end +end + +#==========================================================# +# Properties # +#==========================================================# +class AwsIamAccessKeyPropertiesTest < Minitest::Test + def setup + AwsIamAccessKey::BackendFactory.select(BasicMAIKSB) + end + + def test_property_access_key_id + bob = AwsIamAccessKey.new(username: 'bob') + assert_equal('AKIA1234567890123BOB', bob.access_key_id) + noone = AwsIamAccessKey.new(username: 'roderick') + assert_nil(noone.access_key_id) + end + + def test_property_username + sally1 = AwsIamAccessKey.new(access_key_id: 'AKIA12345678901SALLY') + assert_equal('sally', sally1.username) + noone = AwsIamAccessKey.new(access_key_id: 'AKIA12345678901STEVE') + assert_nil(noone.username) + end + + def test_property_status + sally1 = AwsIamAccessKey.new(access_key_id: 'AKIA12345678901SALLY') + assert_equal('Active', sally1.status) + sally2 = AwsIamAccessKey.new(access_key_id: 'AKIA12345678901SALL2') + assert_equal('Inactive', sally2.status) + noone = AwsIamAccessKey.new(access_key_id: 'AKIA12345678901STEVE') + assert_nil(noone.status) + end + + def test_property_create_date + bob = AwsIamAccessKey.new(username: 'bob') + assert_kind_of(DateTime, bob.create_date) + assert_equal(DateTime.parse('2017-10-27T17:58:00Z'), bob.create_date) + noone = AwsIamAccessKey.new(username: 'roderick') + assert_nil(noone.create_date) + end + + def test_property_last_used_date + bob = AwsIamAccessKey.new(username: 'bob') + assert_kind_of(DateTime, bob.last_used_date) + assert_equal(DateTime.parse('2017-11-30T17:58:00Z'), bob.last_used_date) + noone = AwsIamAccessKey.new(username: 'roderick') + assert_nil(noone.last_used_date) + end + +end + +#==========================================================# +# Matchers # +#==========================================================# +class AwsIamAccessKeyMatchersTest < Minitest::Test + def setup + AwsIamAccessKey::BackendFactory.select(BasicMAIKSB) + end + + def test_matcher_be_active + sally1 = AwsIamAccessKey.new(access_key_id: 'AKIA12345678901SALLY') + assert(sally1.active?) + sally2 = AwsIamAccessKey.new(access_key_id: 'AKIA12345678901SALL2') + refute(sally2.active?) + noone = AwsIamAccessKey.new(access_key_id: 'AKIA12345678901STEVE') + assert_nil(noone.active?) + end +end + +#==========================================================# +# Mock Support Classes # +#==========================================================# + +# MIAKSB = Mock IAM Access Key Singular Backend. Abbreviation not used +# outside this file. + +class EmptyMAIKSB < AwsBackendBase + def list_access_keys(query) + raise Aws::IAM::Errors::NoSuchEntity.new(nil, nil) + end +end + +class BasicMAIKSB < AwsBackendBase + def list_access_keys(query) + fixtures = [ + # Bob has one active key + OpenStruct.new({ + user_name: 'bob', + access_key_id: 'AKIA1234567890123BOB', + create_date: DateTime.parse('2017-10-27T17:58:00Z'), + status: 'Active', + }), + # Sally has one active and one inactive key + OpenStruct.new({ + user_name: 'sally', + access_key_id: 'AKIA12345678901SALLY', + create_date: DateTime.parse('2017-10-22T17:58:00Z'), + status: 'Active', + }), + OpenStruct.new({ + user_name: 'sally', + access_key_id: 'AKIA12345678901SALL2', + create_date: DateTime.parse('2017-10-22T17:58:00Z'), + status: 'Inactive', + }), + ] + matches = [] + if query.key?(:user_name) + matches = fixtures.select { |k| k.user_name == query[:user_name] } + if matches.empty? + raise Aws::IAM::Errors::NoSuchEntity.new(nil,nil) + end + else + matches = fixtures + end + OpenStruct.new({ access_key_metadata: matches }) + end + + def get_access_key_last_used(query) + fixtures = { + 'AKIA1234567890123BOB' => OpenStruct.new({ + user_name: 'bob', + access_key_last_used: OpenStruct.new({ + last_used_date: DateTime.parse('2017-11-30T17:58:00Z'), + }), + }), + 'AKIA12345678901SALLY' => OpenStruct.new({ + user_name: 'sally', + access_key_last_used: OpenStruct.new({ + last_used_date: DateTime.parse('2017-11-25T17:58:00Z'), + }), + }), + } + fixtures[query[:access_key_id]] + end +end \ No newline at end of file diff --git a/test/unit/resources/aws_iam_access_keys_test.rb b/test/unit/resources/aws_iam_access_keys_test.rb index f087f0ca8..3a2bc4146 100644 --- a/test/unit/resources/aws_iam_access_keys_test.rb +++ b/test/unit/resources/aws_iam_access_keys_test.rb @@ -8,14 +8,11 @@ require 'aws_iam_access_keys' #==========================================================# class AwsIamAccessKeysConstructorTest < Minitest::Test - # Reset provider back to the implementation default prior - # to each test. Tests must explicitly select an alternate. def setup - AwsIamAccessKeys::AccessKeyProvider.reset + AwsIamAccessKeys::BackendFactory.select(AlwaysEmptyMAKP) end def test_bare_constructor_does_not_explode - AwsIamAccessKeys::AccessKeyProvider.select(AlwaysEmptyMAKP) AwsIamAccessKeys.new end end @@ -25,14 +22,9 @@ end #==========================================================# class AwsIamAccessKeysFilterTest < Minitest::Test - # Reset provider back to the implementation default prior - # to each test. Tests must explicitly select an alternate. - def setup - AwsIamAccessKeys::AccessKeyProvider.reset - end def test_filter_methods_should_exist - AwsIamAccessKeys::AccessKeyProvider.select(AlwaysEmptyMAKP) + AwsIamAccessKeys::BackendFactory.select(AlwaysEmptyMAKP) resource = AwsIamAccessKeys.new [:where, :'exists?'].each do |meth| assert_respond_to(resource, meth) @@ -40,19 +32,19 @@ class AwsIamAccessKeysFilterTest < Minitest::Test end def test_filter_method_where_should_be_chainable - AwsIamAccessKeys::AccessKeyProvider.select(AlwaysEmptyMAKP) + AwsIamAccessKeys::BackendFactory.select(AlwaysEmptyMAKP) resource = AwsIamAccessKeys.new assert_respond_to(resource.where, :where) end def test_filter_method_exists_should_probe_empty_when_empty - AwsIamAccessKeys::AccessKeyProvider.select(AlwaysEmptyMAKP) + AwsIamAccessKeys::BackendFactory.select(AlwaysEmptyMAKP) resource = AwsIamAccessKeys.new refute(resource.exists?) end def test_filter_method_exists_should_probe_present_when_present - AwsIamAccessKeys::AccessKeyProvider.select(BasicMAKP) + AwsIamAccessKeys::BackendFactory.select(BasicMAKP) resource = AwsIamAccessKeys.new assert(resource.exists?) end @@ -65,7 +57,7 @@ end class AwsIamAccessKeysFilterCriteriaTest < Minitest::Test def setup # Here we always want no results. - AwsIamAccessKeys::AccessKeyProvider.select(AlwaysEmptyMAKP) + AwsIamAccessKeys::BackendFactory.select(AlwaysEmptyMAKP) @valued_criteria = { username: 'bob', id: 'AKIA1234567890ABCDEF', @@ -87,13 +79,13 @@ class AwsIamAccessKeysFilterCriteriaTest < Minitest::Test # Negative cases def test_criteria_when_used_in_constructor_with_bad_criterion - assert_raises(RuntimeError) do + assert_raises(ArgumentError) do AwsIamAccessKeys.new(nope: 'some_val') end end def test_criteria_when_used_in_where_with_bad_criterion - assert_raises(RuntimeError) do + assert_raises(ArgumentError) do AwsIamAccessKeys.new(nope: 'some_val') end end @@ -128,7 +120,7 @@ end class AwsIamAccessKeysPropertiesTest < Minitest::Test def setup # Reset back to the basic kit each time. - AwsIamAccessKeys::AccessKeyProvider.select(BasicMAKP) + AwsIamAccessKeys::BackendFactory.select(BasicMAKP) @all_basic = AwsIamAccessKeys.new end @@ -299,14 +291,14 @@ end # MAKP = MockAccessKeyProvider. Abbreviation not used # outside this file. -class AlwaysEmptyMAKP < AwsIamAccessKeys::AccessKeyProvider +class AlwaysEmptyMAKP < AwsBackendBase def fetch(_filter_criteria) [] end end -class BasicMAKP < AwsIamAccessKeys::AccessKeyProvider - def fetch(_filter_criteria) # rubocop:disable Metrics/MethodLength +class BasicMAKP < AwsBackendBase + def fetch(_filter_criteria) [ { username: 'bob', diff --git a/test/unit/resources/aws_iam_group_test.rb b/test/unit/resources/aws_iam_group_test.rb index 424f339ef..0be4c43fe 100644 --- a/test/unit/resources/aws_iam_group_test.rb +++ b/test/unit/resources/aws_iam_group_test.rb @@ -58,13 +58,13 @@ end # Test Fixtures #=============================================================================# module MAIGSB - class Empty < AwsIamGroup::Backend + class Empty < AwsBackendBase def get_group(query = {}) raise Aws::IAM::Errors::NoSuchEntity.new(nil,nil) end end - class Basic < AwsIamGroup::Backend + class Basic < AwsBackendBase def get_group(query = {}) fixtures = [ OpenStruct.new({ diff --git a/test/unit/resources/aws_iam_groups_test.rb b/test/unit/resources/aws_iam_groups_test.rb index f79d2c2cd..4cd274743 100644 --- a/test/unit/resources/aws_iam_groups_test.rb +++ b/test/unit/resources/aws_iam_groups_test.rb @@ -53,13 +53,13 @@ end # Test Fixtures #=============================================================================# module MAIGPB - class Empty < AwsIamGroups::Backend + class Empty < AwsBackendBase def list_groups(query = {}) OpenStruct.new({ groups: [] }) end end - class Basic < AwsIamGroups::Backend + class Basic < AwsBackendBase def list_groups(query = {}) fixtures = [ OpenStruct.new({ diff --git a/test/unit/resources/aws_iam_policies_test.rb b/test/unit/resources/aws_iam_policies_test.rb index e4563f767..e3e6ae1a5 100644 --- a/test/unit/resources/aws_iam_policies_test.rb +++ b/test/unit/resources/aws_iam_policies_test.rb @@ -74,13 +74,13 @@ end # Test Fixtures #=============================================================================# module MAIPPB - class Empty < AwsIamPolicies::Backend + class Empty < AwsBackendBase def list_policies(query = {}) OpenStruct.new({ policies: [] }) end end - class Basic < AwsIamPolicies::Backend + class Basic < AwsBackendBase def list_policies(query = {}) fixtures = [ OpenStruct.new({ diff --git a/test/unit/resources/aws_iam_policy_test.rb b/test/unit/resources/aws_iam_policy_test.rb index 164e19f03..23a088b36 100644 --- a/test/unit/resources/aws_iam_policy_test.rb +++ b/test/unit/resources/aws_iam_policy_test.rb @@ -140,13 +140,13 @@ end # Test Fixtures #=============================================================================# module MAIPSB - class Empty < AwsIamPolicy::Backend + class Empty < AwsBackendBase def list_policies(query) OpenStruct.new(policies: []) end end - class Basic < AwsIamPolicy::Backend + class Basic < AwsBackendBase def list_policies(query) fixtures = [ OpenStruct.new({ diff --git a/test/unit/resources/aws_iam_role_test.rb b/test/unit/resources/aws_iam_role_test.rb index 418328dc4..755f097c9 100644 --- a/test/unit/resources/aws_iam_role_test.rb +++ b/test/unit/resources/aws_iam_role_test.rb @@ -74,13 +74,13 @@ end # Test Fixtures #=============================================================================# module AwsMIRB - class Miss + class Miss < AwsBackendBase def get_role(query) raise Aws::IAM::Errors::NoSuchEntity.new('Nope', 'Nope') end end - class Basic + class Basic < AwsBackendBase def get_role(query) fixtures = { 'alpha' => OpenStruct.new({ diff --git a/test/unit/resources/aws_iam_user_test.rb b/test/unit/resources/aws_iam_user_test.rb index 3585b8824..eeab82311 100644 --- a/test/unit/resources/aws_iam_user_test.rb +++ b/test/unit/resources/aws_iam_user_test.rb @@ -128,7 +128,7 @@ end #=============================================================================# module MAIUB - class Three < AwsIamUser::Backend + class Three < AwsBackendBase def get_user(criteria) people = { 'erin' => OpenStruct.new({ diff --git a/test/unit/resources/aws_iam_users_test.rb b/test/unit/resources/aws_iam_users_test.rb index c60e72dc6..10caac477 100644 --- a/test/unit/resources/aws_iam_users_test.rb +++ b/test/unit/resources/aws_iam_users_test.rb @@ -2,12 +2,12 @@ require 'helper' require 'ostruct' require 'aws_iam_users' -# Maiusb = Mock AwsIamUsers::Backend +# Maiusb = Mock AwsIamUsers::BackendFactory # Abbreviation not used outside of this file class AwsIamUsersTestConstructor < Minitest::Test def setup - AwsIamUsers::Backend.select(Maiusb::Empty) + AwsIamUsers::BackendFactory.select(Maiusb::Empty) end def test_users_no_params_does_not_explode @@ -22,7 +22,7 @@ end class AwsIamUsersTestFilterCriteria < Minitest::Test def setup # Reset to empty, that's harmless - AwsIamUsers::Backend.select(Maiusb::Empty) + AwsIamUsers::BackendFactory.select(Maiusb::Empty) end #------------------------------------------# @@ -34,7 +34,7 @@ class AwsIamUsersTestFilterCriteria < Minitest::Test end def test_users_all_returned_when_some_users_no_criteria - AwsIamUsers::Backend.select(Maiusb::Basic) + AwsIamUsers::BackendFactory.select(Maiusb::Basic) users = AwsIamUsers.new.where {} assert(3, users.entries.count) end @@ -43,7 +43,7 @@ class AwsIamUsersTestFilterCriteria < Minitest::Test # has_mfa_enabled? #------------------------------------------# def test_users_criteria_has_mfa_enabled - AwsIamUsers::Backend.select(Maiusb::Basic) + AwsIamUsers::BackendFactory.select(Maiusb::Basic) users = AwsIamUsers.new.where { has_mfa_enabled } assert(1, users.entries.count) assert_includes users.entries.map{ |u| u[:user_name] }, 'carol' @@ -54,7 +54,7 @@ class AwsIamUsersTestFilterCriteria < Minitest::Test # has_console_password? #------------------------------------------# def test_users_criteria_has_console_password? - AwsIamUsers::Backend.select(Maiusb::Basic) + AwsIamUsers::BackendFactory.select(Maiusb::Basic) users = AwsIamUsers.new.where { has_console_password } assert(2, users.entries.count) assert_includes users.entries.map{ |u| u[:user_name] }, 'carol' @@ -65,7 +65,7 @@ class AwsIamUsersTestFilterCriteria < Minitest::Test # password_ever_used? #------------------------------------------# def test_users_criteria_password_ever_used? - AwsIamUsers::Backend.select(Maiusb::Basic) + AwsIamUsers::BackendFactory.select(Maiusb::Basic) users = AwsIamUsers.new.where { password_ever_used? } assert(2, users.entries.count) assert_includes users.entries.map{ |u| u[:user_name] }, 'carol' @@ -76,7 +76,7 @@ class AwsIamUsersTestFilterCriteria < Minitest::Test # password_never_used? #------------------------------------------# def test_users_criteria_password_never_used? - AwsIamUsers::Backend.select(Maiusb::Basic) + AwsIamUsers::BackendFactory.select(Maiusb::Basic) users = AwsIamUsers.new.where { password_never_used? } assert(1, users.entries.count) assert_includes users.entries.map{ |u| u[:user_name] }, 'alice' @@ -87,7 +87,7 @@ class AwsIamUsersTestFilterCriteria < Minitest::Test # password_last_used_days_ago #------------------------------------------# def test_users_criteria_has_password_last_used_days_ago_10 - AwsIamUsers::Backend.select(Maiusb::Basic) + AwsIamUsers::BackendFactory.select(Maiusb::Basic) users = AwsIamUsers.new.where(password_last_used_days_ago: 10) puts users assert(1, users.entries.count) @@ -104,7 +104,7 @@ module Maiusb # -------------------------------- # Empty - No users # -------------------------------- - class Empty < AwsIamUsers::Backend + class Empty < AwsBackendBase def list_users OpenStruct.new({ users: [] @@ -128,7 +128,7 @@ module Maiusb # Alice has no password or MFA device # Bob has a password but no MFA device # Carol has a password and MFA device - class Basic < AwsIamUsers::Backend + class Basic < AwsBackendBase # arn, path, user_id omitted def list_users OpenStruct.new({ diff --git a/test/unit/resources/aws_kms_keys_test.rb b/test/unit/resources/aws_kms_keys_test.rb index 9f7217d83..5d80c8281 100644 --- a/test/unit/resources/aws_kms_keys_test.rb +++ b/test/unit/resources/aws_kms_keys_test.rb @@ -74,13 +74,13 @@ end # Test Fixtures #=============================================================================# module MAKKPB - class Empty < AwsKmsKeys::Backend + class Empty < AwsBackendBase def list_keys(query = {}) OpenStruct.new({ keys: [] }) end end - class Basic < AwsKmsKeys::Backend + class Basic < AwsBackendBase def list_keys(query = {}) fixtures = [ OpenStruct.new({ diff --git a/test/unit/resources/aws_route_table_test.rb b/test/unit/resources/aws_route_table_test.rb index edd8b12d5..93ca869e0 100644 --- a/test/unit/resources/aws_route_table_test.rb +++ b/test/unit/resources/aws_route_table_test.rb @@ -28,13 +28,13 @@ end # MRtbB = Mock Routetable Backend module AwsMRtbB - class Empty < AwsRouteTable::Backend + class Empty < AwsBackendBase def describe_route_tables(query) OpenStruct.new(route_tables: []) end end - class Basic < AwsRouteTable::Backend + class Basic < AwsBackendBase def describe_route_tables(query) fixtures = [ OpenStruct.new({associations: [], diff --git a/test/unit/resources/aws_s3_bucket_test.rb b/test/unit/resources/aws_s3_bucket_test.rb index b92cfd38a..a769a41fa 100644 --- a/test/unit/resources/aws_s3_bucket_test.rb +++ b/test/unit/resources/aws_s3_bucket_test.rb @@ -183,7 +183,7 @@ end #=============================================================================# module AwsMSBSB - class Basic < AwsS3Bucket::Backend + class Basic < AwsBackendBase def get_bucket_acl(query) owner_full_control = OpenStruct.new({ grantee: OpenStruct.new({ diff --git a/test/unit/resources/aws_sns_topic_test.rb b/test/unit/resources/aws_sns_topic_test.rb index 3bb2c455e..fc6d15713 100644 --- a/test/unit/resources/aws_sns_topic_test.rb +++ b/test/unit/resources/aws_sns_topic_test.rb @@ -93,13 +93,13 @@ end module AwsMSNB - class Miss + class Miss < AwsBackendBase def get_topic_attributes(criteria) raise Aws::SNS::Errors::NotFound.new("No SNS topic for #{criteria[:topic_arn]}", 'Nope') end end - class NoSubscriptions + class NoSubscriptions < AwsBackendBase def get_topic_attributes(_criteria) OpenStruct.new({ attributes: { # Note that this is a plain hash, odd for AWS SDK @@ -111,7 +111,7 @@ module AwsMSNB end end - class OneSubscription + class OneSubscription < AwsBackendBase def get_topic_attributes(_criteria) OpenStruct.new({ attributes: { # Note that this is a plain hash, odd for AWS SDK diff --git a/test/unit/resources/aws_vpc_subnet_test.rb b/test/unit/resources/aws_vpc_subnet_test.rb index 5995ed910..e1e34f689 100644 --- a/test/unit/resources/aws_vpc_subnet_test.rb +++ b/test/unit/resources/aws_vpc_subnet_test.rb @@ -113,7 +113,7 @@ end #=============================================================================# module AwsMVSSB - class Basic < AwsVpcSubnet::Backend + class Basic < AwsBackendBase def describe_subnets(query) subnets = { 'subnet-12345678' => OpenStruct.new({ diff --git a/test/unit/resources/aws_vpc_subnets_test.rb b/test/unit/resources/aws_vpc_subnets_test.rb index 4ad0e5e3e..80c29ef30 100644 --- a/test/unit/resources/aws_vpc_subnets_test.rb +++ b/test/unit/resources/aws_vpc_subnets_test.rb @@ -90,7 +90,7 @@ end # Test Fixtures #=============================================================================# module AwsMVSB - class Basic < AwsVpcSubnets::Backend + class Basic < AwsBackendBase def describe_subnets fixtures = [ OpenStruct.new({ diff --git a/test/unit/resources/aws_vpc_test.rb b/test/unit/resources/aws_vpc_test.rb index 8a067b054..422013c89 100644 --- a/test/unit/resources/aws_vpc_test.rb +++ b/test/unit/resources/aws_vpc_test.rb @@ -120,13 +120,13 @@ end # Test Fixtures #=============================================================================# module MAVSB - class Empty < AwsVpc::Backend + class Empty < AwsBackendBase def describe_vpcs(query) OpenStruct.new(vpcs: []) end end - class Basic < AwsVpc::Backend + class Basic < AwsBackendBase def describe_vpcs(query) fixtures = [ OpenStruct.new({ diff --git a/test/unit/resources/aws_vpcs_test.rb b/test/unit/resources/aws_vpcs_test.rb index bd3786f6e..4726ee83f 100644 --- a/test/unit/resources/aws_vpcs_test.rb +++ b/test/unit/resources/aws_vpcs_test.rb @@ -52,13 +52,13 @@ end # Test Fixtures #=============================================================================# module MAVPB - class Empty < AwsVpcs::Backend + class Empty < AwsBackendBase def describe_vpcs(query = {}) OpenStruct.new({ vpcs: [] }) end end - class Basic < AwsVpcs::Backend + class Basic < AwsBackendBase def describe_vpcs(query = {}) fixtures = [ OpenStruct.new({ @@ -93,5 +93,4 @@ module MAVPB OpenStruct.new({ vpcs: selected }) end end - end From 84817366a1dcf15b2a526b0e77528517d26cb997 Mon Sep 17 00:00:00 2001 From: Jerry Aldrich Date: Thu, 8 Feb 2018 10:05:21 +0000 Subject: [PATCH 186/206] Remove deprecations for InSpec 2.0 (#2506) * Add `release-2.0` target branch to AppVeyor/Travis (#2510) Signed-off-by: Jerry Aldrich * simpleconfig: Remove deprecated config keys Signed-off-by: Jerry Aldrich * cli (exec): Remove `--cache` command line argument Signed-off-by: Jerry Aldrich * platform: Remove lowercase os name protection Signed-off-by: Jerry Aldrich * matcher: Remove `contain_legacy_plus` matcher Signed-off-by: Jerry Aldrich * matcher: Remove `contain_match` matcher Signed-off-by: Jerry Aldrich * matcher: Remove `with_version` matcher Signed-off-by: Jerry Aldrich * matcher: Remove `belong_to_group` matcher Signed-off-by: Jerry Aldrich * matcher: Remove `belong_to_primary_group` matcher Signed-off-by: Jerry Aldrich * matcher: Remove `contain` matcher Signed-off-by: Jerry Aldrich * passwd: Remove deprecated properties This removes: - `passwd.count` - `passwd.username` - `passwd.usernames` - `passwd.uid` Signed-off-by: Jerry Aldrich * auditd_rules: Remove in favor of `auditd` resource Signed-off-by: Jerry Aldrich * cli: Remove `login_automate` command Signed-off-by: Jerry Aldrich * Remove `resource_skipped` message method Signed-off-by: Jerry Aldrich --- docs/resources/auditd_rules.md.erb | 116 ---------- lib/bundles/inspec-compliance/cli.rb | 32 --- lib/inspec/base_cli.rb | 2 - lib/inspec/cli.rb | 7 - lib/inspec/plugins/resource.rb | 11 +- lib/inspec/resource.rb | 1 - lib/matchers/matchers.rb | 58 ----- lib/resources/auditd_rules.rb | 205 ------------------ lib/resources/passwd.rb | 21 -- lib/resources/platform.rb | 16 +- lib/utils/simpleconfig.rb | 12 - .../default/controls/auditd_rules_spec.rb | 32 --- .../default/controls/cmp_matcher_spec.rb | 2 +- .../integration/default/controls/file_spec.rb | 1 - .../profile_resource_exceptions_test.rb | 7 - test/unit/resources/auditd_rules_test.rb | 106 --------- test/unit/resources/package_test.rb | 2 +- test/unit/resources/passwd_test.rb | 18 +- test/unit/resources/platform_test.rb | 19 -- test/unit/utils/simpleconfig_test.rb | 14 -- 20 files changed, 7 insertions(+), 675 deletions(-) delete mode 100644 docs/resources/auditd_rules.md.erb delete mode 100644 lib/resources/auditd_rules.rb delete mode 100644 test/integration/default/controls/auditd_rules_spec.rb delete mode 100644 test/unit/resources/auditd_rules_test.rb diff --git a/docs/resources/auditd_rules.md.erb b/docs/resources/auditd_rules.md.erb deleted file mode 100644 index 0a18515f2..000000000 --- a/docs/resources/auditd_rules.md.erb +++ /dev/null @@ -1,116 +0,0 @@ ---- -title: About the auditd_rules Resource ---- - -# auditd_rules - -Use the `auditd_rules` InSpec audit resource to test the rules for logging that exist on the system. The `audit.rules` file is typically located under `/etc/audit/` and contains the list of rules that define what is captured in log files. This resource uses `auditctl` to query the run-time `auditd` rules setup, which may be different from `audit.rules`. - -
- -## Syntax - -An `auditd_rules` resource block declares one (or more) rules to be tested, and then what that rule should do. The syntax depends on the version of `audit`: - -For `audit` >= 2.3: - - describe auditd_rules do - its('lines') { should contain_match(rule) } - end - -For `audit` < 2.3: - - describe audit_daemon_rules do - its("LIST_RULES") { - rule - } - end - -For example: - - describe auditd_rules do - its('LIST_RULES') { should eq [ - 'exit,always syscall=rmdir,unlink', - 'exit,always auid=1001 (0x3e9) syscall=open', - 'exit,always watch=/etc/group perm=wa', - 'exit,always watch=/etc/passwd perm=wa', - 'exit,always watch=/etc/shadow perm=wa', - 'exit,always watch=/etc/sudoers perm=wa', - 'exit,always watch=/etc/secret_directory perm=r', - ] } - end - -or test that individual rules are defined: - - describe auditd_rules do - its('LIST_RULES') { - should contain_match(/^exit,always watch=\/etc\/group perm=wa key=identity/) - } - its('LIST_RULES') { - should contain_match(/^exit,always watch=\/etc\/passwd perm=wa key=identity/) - } - its('LIST_RULES') { - should contain_match(/^exit,always watch=\/etc\/gshadow perm=wa key=identity/) - } - its('LIST_RULES') { - should contain_match(/^exit,always watch=\/etc\/shadow perm=wa key=identity/) - } - its('LIST_RULES') { - should contain_match(/^exit,always watch=\/etc\/security\/opasswd perm=wa key=identity/) - } - end - -where each test must declare one (or more) rules to be tested. - -
- -## Examples - -The following examples show how to use this InSpec audit resource. - -### Test if a rule contains a matching element that is identified by a regular expression - -For `audit` >= 2.3: - - describe auditd_rules do - its('lines') { should contain_match(%r{-w /etc/ssh/sshd_config/}) } - end - -For `audit` < 2.3: - - describe audit_daemon_rules do - its("LIST_RULES") { - should contain_match(/^exit,always arch=.*\ - key=time-change\ - syscall=adjtimex,settimeofday/) - } - end - - -### Query the audit daemon status - - describe auditd_rules.status('backlog') do - it { should cmp 0 } - end - -### Query properties of rules targeting specific syscalls or files - - describe auditd_rules.syscall('open').action do - it { should eq(['always']) } - end - - describe auditd_rules.key('sshd_config') do - its('permissions') { should contain_match(/x/) } - end - -Filters may be chained. For example: - - describe auditd_rules.syscall('open').action('always').list do - it { should eq(['exit']) } - end - -
- -## Matchers - -For a full list of available matchers please visit our [matchers page](https://www.inspec.io/docs/reference/matchers/). diff --git a/lib/bundles/inspec-compliance/cli.rb b/lib/bundles/inspec-compliance/cli.rb index 4b60a1f85..92720d22a 100644 --- a/lib/bundles/inspec-compliance/cli.rb +++ b/lib/bundles/inspec-compliance/cli.rb @@ -46,38 +46,6 @@ module Compliance Compliance::API.login(options) end - desc "login_automate https://SERVER --insecure --user='USER' --ent='ENTERPRISE' --usertoken='TOKEN'", 'Log in to a Chef Automate SERVER (DEPRECATED: Please use `login`)' - long_desc <<-LONGDESC - This commmand is deprecated and will be removed, please use `--login`. - - `login_automate` allows you to use InSpec with Chef Automate. - - You need to a token for communication. More information about token retrieval - is available at: - https://docs.chef.io/api_automate.html#authentication-methods - https://docs.chef.io/api_compliance.html#obtaining-an-api-token - LONGDESC - option :insecure, aliases: :k, type: :boolean, - desc: 'Explicitly allows InSpec to perform "insecure" SSL connections and transfers' - option :user, type: :string, required: true, - desc: 'Username' - option :usertoken, type: :string, required: false, - desc: 'Access token (DEPRECATED: Please use `--token`)' - option :token, type: :string, required: false, - desc: 'Access token' - option :dctoken, type: :string, required: false, - desc: 'Data Collector token' - option :ent, type: :string, required: true, - desc: 'Enterprise for Chef Automate reporting' - def login_automate(server) - warn '[DEPRECATION] `inspec compliance login_automate` is deprecated. Please use `inspec compliance login`' - options['server'] = server - - options['token'] = options['usertoken'] if options['usertoken'] - - Compliance::API.login(options) - end - desc 'profiles', 'list all available profiles in Chef Compliance' option :owner, type: :string, required: false, desc: 'owner whose profiles to list' diff --git a/lib/inspec/base_cli.rb b/lib/inspec/base_cli.rb index e206f3d91..eb6114d95 100644 --- a/lib/inspec/base_cli.rb +++ b/lib/inspec/base_cli.rb @@ -63,8 +63,6 @@ module Inspec desc: 'Use colors in output.' option :attrs, type: :array, desc: 'Load attributes file (experimental)' - option :cache, type: :string, - desc: '[DEPRECATED] Please use --vendor-cache - this will be removed in InSpec 2.0' option :vendor_cache, type: :string, desc: 'Use the given path for caching dependencies. (default: ~/.inspec/cache)' option :create_lockfile, type: :boolean, diff --git a/lib/inspec/cli.rb b/lib/inspec/cli.rb index 4d1dba4a5..08cf4952a 100644 --- a/lib/inspec/cli.rb +++ b/lib/inspec/cli.rb @@ -156,13 +156,6 @@ class Inspec::InspecCLI < Inspec::BaseCLI o = opts(:exec).dup configure_logger(o) - # check for deprecated --cache - # TODO: REMOVE for inspec 2.0 - if o.key?('cache') - o[:vendor_cache] = o[:cache] - o[:logger].warn '[DEPRECATED] The use of `--cache` is being deprecated in InSpec 2.0. Please use `--vendor-cache` instead.' - end - # run tests run_tests(targets, o) rescue StandardError => e diff --git a/lib/inspec/plugins/resource.rb b/lib/inspec/plugins/resource.rb index b21cbe5f7..4a9d7811c 100644 --- a/lib/inspec/plugins/resource.rb +++ b/lib/inspec/plugins/resource.rb @@ -43,8 +43,8 @@ module Inspec Inspec::Resource.registry end - def __register(name, obj) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize - cl = Class.new(obj) do # rubocop:disable Metrics/BlockLength + def __register(name, obj) # rubocop:disable Metrics/MethodLength + cl = Class.new(obj) do attr_reader :resource_exception_message def initialize(backend, name, *args) @@ -94,13 +94,6 @@ module Inspec @resource_skipped end - def resource_skipped - warn('[DEPRECATION] Use `resource_exception_message` for the resource skipped message. This method will be removed in InSpec 2.0.') - # Returning `nil` here to match previous behavior - return nil if @resource_skipped == false - @resource_exception_message - end - def fail_resource(message) @resource_failed = true @resource_exception_message = message diff --git a/lib/inspec/resource.rb b/lib/inspec/resource.rb index 1ad9d9957..a214b5657 100644 --- a/lib/inspec/resource.rb +++ b/lib/inspec/resource.rb @@ -86,7 +86,6 @@ require 'resources/apt' require 'resources/audit_policy' require 'resources/auditd' require 'resources/auditd_conf' -require 'resources/auditd_rules' require 'resources/bash' require 'resources/bond' require 'resources/bridge' diff --git a/lib/matchers/matchers.rb b/lib/matchers/matchers.rb index 03dbba361..ba572760b 100644 --- a/lib/matchers/matchers.rb +++ b/lib/matchers/matchers.rb @@ -66,25 +66,6 @@ RSpec::Matchers.define :be_executable do end end -# matcher to check /etc/passwd, /etc/shadow and /etc/group -RSpec::Matchers.define :contain_legacy_plus do - match do |file| - warn '[DEPRECATION] `contain_legacy_plus` is deprecated and will be removed in the next major version. Please use `describe file(\'/etc/passwd\') do its(\'content\') { should_not match /^\+:/ } end`' - file.content =~ /^\+:/ - end -end - -# verifies that no entry in an array contains a value -RSpec::Matchers.define :contain_match do |regex| - match do |arr| - warn '[DEPRECATION] `contain_match` is deprecated and will be removed in the next major version. See https://github.com/chef/inspec/issues/738 for more details' - arr.inject { |result, i| - result = i.match(regex) - result || i.match(/$/) - } - end -end - RSpec::Matchers.define :contain_duplicates do match do |arr| dup = arr.select { |element| arr.count(element) > 1 } @@ -105,11 +86,6 @@ RSpec::Matchers.define :be_installed do chain :by do raise "[UNSUPPORTED] Please use the new resources 'gem', 'npm' or 'pip'." end - - chain :with_version do |version| - warn "[DEPRECATION] `with_version` is deprecated. Please use `its('version') { should eq '1.4.1' }` instead." - @version = version - end end # for services @@ -143,32 +119,6 @@ RSpec::Matchers.define :be_running do end end -# user resource matcher for serverspec compatibility -# Deprecated: You should not use this matcher anymore -RSpec::Matchers.define :belong_to_group do |compare_group| - match do |user| - warn "[DEPRECATION] `belong_to_group` is deprecated. Please use `its('groups') { should include('root') }` instead." - user.groups.include?(compare_group) - end - - failure_message do |group| - "expected that the user belongs to group `#{group}`" - end -end - -# user resource matcher for serverspec compatibility -# Deprecated: You should not use this matcher anymore -RSpec::Matchers.define :belong_to_primary_group do |compare_group| - match do |user| - warn "[DEPRECATION] `belong_to_primary_group` is deprecated. Please use `its('group') { should eq 'root' }` instead." - user.group == compare_group - end - - failure_message do |group| - "expected that the user belongs to primary group `#{group}`" - end -end - # matcher to check if host is reachable RSpec::Matchers.define :be_reachable do match do |host| @@ -214,14 +164,6 @@ RSpec::Matchers.define :have_rule do |rule| end end -# deprecated -RSpec::Matchers.define :contain do |rule| - match do |resource| - warn "[DEPRECATION] `contain` matcher. Please use the following syntax `its('content') { should include('value') }`." - expect(resource).to include(rule) - end -end - # `be_in` matcher # You can use it in the following cases: # - check if an item or array is included in a given array diff --git a/lib/resources/auditd_rules.rb b/lib/resources/auditd_rules.rb deleted file mode 100644 index a29b6d89d..000000000 --- a/lib/resources/auditd_rules.rb +++ /dev/null @@ -1,205 +0,0 @@ -# encoding: utf-8 -# copyright: 2015, Vulcano Security GmbH -# author: Christoph Hartmann -# author: Dominik Richter - -require 'forwardable' -require 'utils/filter_array' - -module Inspec::Resources - class AuditdRulesLegacy - def initialize(content) - @content = content - @opts = { - assignment_regex: /^\s*([^:]*?)\s*:\s*(.*?)\s*$/, - multiple_values: true, - } - end - - def params - @params ||= SimpleConfig.new(@content, @opts).params - end - - def method_missing(name) - params[name.to_s] - end - - def status(name) - @status_opts = { - assignment_regex: /^\s*([^:]*?)\s*:\s*(.*?)\s*$/, - multiple_values: false, - } - @status_content ||= inspec.command('/sbin/auditctl -s').stdout.chomp - @status_params = SimpleConfig.new(@status_content, @status_opts).params - - status = @status_params['AUDIT_STATUS'] - return nil if status.nil? - - items = Hash[status.scan(/([^=]+)=(\w*)\s*/)] - items[name] - end - - def to_s - 'Audit Daemon Rules (for auditd version < 2.3)' - end - end - class AuditDaemonRules < Inspec.resource(1) - extend Forwardable - attr_accessor :rules, :lines - - name 'auditd_rules' - desc 'Use the auditd_rules InSpec audit resource to test the rules for logging that exist on the system. The audit.rules file is typically located under /etc/audit/ and contains the list of rules that define what is captured in log files.' - example " - # syntax for auditd < 2.3 - describe auditd_rules do - its('LIST_RULES') {should contain_match(/^exit,always arch=.* key=time-change syscall=adjtimex,settimeofday/) } - its('LIST_RULES') {should contain_match(/^exit,always arch=.* key=time-change syscall=stime,settimeofday,adjtimex/) } - its('LIST_RULES') {should contain_match(/^exit,always arch=.* key=time-change syscall=clock_settime/)} - its('LIST_RULES') {should contain_match(/^exit,always watch=\/etc\/localtime perm=wa key=time-change/)} - end - - # syntax for auditd >= 2.3 - describe auditd_rules.syscall('open').action do - it { should eq(['always']) } - end - - describe auditd_rules.key('sshd_config') do - its('permissions') { should contain_match(/x/) } - end - - describe auditd_rules do - its('lines') { should contain_match(%r{-w /etc/ssh/sshd_config/}) } - end - " - - def initialize - @content = inspec.command('/sbin/auditctl -l').stdout.chomp - - if @content =~ /^LIST_RULES:/ - # do not warn on centos 5 - unless inspec.os[:name] == 'centos' && inspec.os[:release].to_i == 5 - warn '[WARN] this version of auditd is outdated. Updating it allows for using more precise matchers.' - end - @legacy = AuditdRulesLegacy.new(@content) - else - parse_content - @legacy = nil - end - - warn '[DEPRECATION] The `auditd_rules` resource is deprecated and will be removed in InSpec 2.0. Use the `auditd` resource instead.' - end - - # non-legacy instances are not asked for `its('LIST_RULES')` - # rubocop:disable Style/MethodName - def LIST_RULES - return @legacy.LIST_RULES if @legacy - raise 'Using legacy auditd_rules LIST_RULES interface with non-legacy audit package. Please use the new syntax.' - end - - def status(name = nil) - return @legacy.status(name) if @legacy - - @status_content ||= inspec.command('/sbin/auditctl -s').stdout.chomp - @status_params ||= Hash[@status_content.scan(/^([^ ]+) (.*)$/)] - - return @status_params[name] if name - @status_params - end - - def parse_content - @rules = { - syscalls: [], - files: [], - } - @lines = @content.lines.map(&:chomp) - - lines.each do |line| - if is_syscall?(line) - syscalls = get_syscalls line - action, list = get_action_list line - fields, opts = get_fields line - - # create a 'flatter' structure because sanity - syscalls.each do |s| - @rules[:syscalls] << { syscall: s, list: list, action: action, fields: fields }.merge(opts) - end - elsif is_file?(line) - file = get_file line - perms = get_permissions line - key = get_key line - - @rules[:files] << { file: file, key: key, permissions: perms } - end - end - end - - def syscall(name) - select_name(:syscall, name) - end - - def file(name) - select_name(:file, name) - end - - # both files and syscalls have `key` identifiers - def key(name) - res = rules.values.flatten.find_all { |rule| rule[:key] == name } - FilterArray.new(res) - end - - def to_s - 'Audit Daemon Rules' - end - - private - - def select_name(key, name) - plural = "#{key}s".to_sym - res = rules[plural].find_all { |rule| rule[key] == name } - FilterArray.new(res) - end - - def is_syscall?(line) - line.match(/\ -S /) - end - - def is_file?(line) - line.match(/-w /) - end - - def get_syscalls(line) - line.scan(/-S ([^ ]+) /).flatten.first.split(',') - end - - def get_action_list(line) - line.scan(/-a ([^,]+),([^ ]+)/).flatten - end - - # NB only in file lines - def get_key(line) - line.match(/-k ([^ ]+)/)[1] if line.include?('-k ') - end - - # NOTE there are NO precautions wrt. filenames containing spaces in auditctl - # `auditctl -w /foo\ bar` gives the following line: `-w /foo bar -p rwxa` - def get_file(line) - line.match(/-w (.+) -p/)[1] - end - - def get_permissions(line) - line.match(/-p ([^ ]+)/)[1] - end - - def get_fields(line) - fields = line.gsub(/-[aS] [^ ]+ /, '').split('-F ').map { |l| l.split(' ') }.flatten - - opts = {} - fields.find_all { |x| x.match(/[a-z]+=.*/) }.each do |kv| - k, v = kv.split('=') - opts[k.to_sym] = v - end - - [fields, opts] - end - end -end diff --git a/lib/resources/passwd.rb b/lib/resources/passwd.rb index 8d2f47454..376045e15 100644 --- a/lib/resources/passwd.rb +++ b/lib/resources/passwd.rb @@ -26,7 +26,6 @@ module Inspec::Resources describe passwd.uids(0) do its('users') { should cmp 'root' } - its('count') { should eq 1 } end describe passwd.shells(/nologin/) do @@ -60,21 +59,6 @@ module Inspec::Resources .add(:homes, field: 'home') .add(:shells, field: 'shell') - filter.add(:count) { |t, _| - warn '[DEPRECATION] `passwd.count` is deprecated. Please use `passwd.entries.length` instead. It will be removed in the next major version.' - t.entries.length - } - - filter.add(:usernames) { |t, x| - warn '[DEPRECATION] `passwd.usernames` is deprecated. Please use `passwd.users` instead. It will be removed in the next major version.' - t.users(x) - } - - filter.add(:username) { |t, x| - warn '[DEPRECATION] `passwd.username` is deprecated. Please use `passwd.users` instead. It will be removed in the next major version.' - t.users(x)[0] - } - # rebuild the passwd line from raw content filter.add(:content) { |t, _| t.entries.map do |e| @@ -82,11 +66,6 @@ module Inspec::Resources end.join("\n") } - def uid(x) - warn '[DEPRECATION] `passwd.uid(arg)` is deprecated. Please use `passwd.uids(arg)` instead. It will be removed in the next major version.' - uids(x) - end - filter.connect(self, :params) def to_s diff --git a/lib/resources/platform.rb b/lib/resources/platform.rb index e14e0bf92..97a7e661a 100644 --- a/lib/resources/platform.rb +++ b/lib/resources/platform.rb @@ -25,22 +25,8 @@ module Inspec::Resources end end - # This is a string override for platform.name. - # TODO: removed in inspec 2.0 - class NameCleaned < String - def ==(other) - if other =~ /[A-Z ]/ - cleaned = other.downcase.tr(' ', '_') - Inspec::Log.warn "[DEPRECATED] Platform names will become lowercase in InSpec 2.0. Please match on '#{cleaned}' instead of '#{other}'" - super(cleaned) - else - super(other) - end - end - end - def name - NameCleaned.new(@platform.name) + @platform.name end def [](key) diff --git a/lib/utils/simpleconfig.rb b/lib/utils/simpleconfig.rb index b84b49621..1011c2581 100644 --- a/lib/utils/simpleconfig.rb +++ b/lib/utils/simpleconfig.rb @@ -54,18 +54,6 @@ class SimpleConfig end def parse_params_line(line, opts) - # Deprecation handling - if opts.key?(:assignment_re) - warn '[DEPRECATION] `:assignment_re` is deprecated in favor of `:assignment_regex` '\ - 'and will be removed in the next major version. See: https://github.com/chef/inspec/issues/1709' - opts[:assignment_regex] = opts[:assignment_re] - end - if opts.key?(:key_vals) - warn '[DEPRECATION] `:key_vals` is deprecated in favor of `:key_values` '\ - 'and will be removed in the next major version. See: https://github.com/chef/inspec/issues/1709' - opts[:key_values] = opts[:key_vals] - end - # now line contains what we are interested in parsing # check if it is an assignment m = opts[:assignment_regex].match(line) diff --git a/test/integration/default/controls/auditd_rules_spec.rb b/test/integration/default/controls/auditd_rules_spec.rb deleted file mode 100644 index b9a026308..000000000 --- a/test/integration/default/controls/auditd_rules_spec.rb +++ /dev/null @@ -1,32 +0,0 @@ -# encoding: utf-8 - -# based on operating system we select the available service -return unless os[:family] == 'centos' - -describe auditd_rules.syscall('open') do - its('action') { should eq(['always']) } -end - -describe auditd_rules.syscall('open').action do - it { should eq(['always']) } -end - -describe auditd_rules.key('sshd_config') do - its('permissions') { should contain_match(/x/) } -end - -describe auditd_rules.file('/etc/ssh/sshd_config').permissions do - it { should eq(['rwxa']) } -end - -describe auditd_rules do - its('lines') { should contain_match(%r{-w /etc/ssh/sshd_config/}) } -end - -describe auditd_rules.syscall('open').action('always').list do - it { should eq(['exit']) } -end - -describe auditd_rules.status('backlog') do - it { should cmp 0 } -end diff --git a/test/integration/default/controls/cmp_matcher_spec.rb b/test/integration/default/controls/cmp_matcher_spec.rb index 27b4aa7b5..3e1df6677 100644 --- a/test/integration/default/controls/cmp_matcher_spec.rb +++ b/test/integration/default/controls/cmp_matcher_spec.rb @@ -25,7 +25,7 @@ if os.linux? it { should cmp 'x' } end - describe passwd.usernames do + describe passwd.users do it { should include 'root' } it { should_not cmp 'root' } end diff --git a/test/integration/default/controls/file_spec.rb b/test/integration/default/controls/file_spec.rb index 3837957ef..5684e2692 100644 --- a/test/integration/default/controls/file_spec.rb +++ b/test/integration/default/controls/file_spec.rb @@ -117,7 +117,6 @@ if os.unix? its('content') { should eq 'hello world' } its('content') { should match('world') } - its('content') { should contain('hello') } its('content') { should include('hello') } its('size') { should eq 11 } its('md5sum') { should eq '5eb63bbbe01eeed093cb22bb8f5acdc3' } diff --git a/test/unit/profiles/profile_resource_exceptions_test.rb b/test/unit/profiles/profile_resource_exceptions_test.rb index 704ecfd6a..01d9eb700 100644 --- a/test/unit/profiles/profile_resource_exceptions_test.rb +++ b/test/unit/profiles/profile_resource_exceptions_test.rb @@ -111,12 +111,5 @@ describe 'resource exception' do it 'does not affect regular FilterTable usage' do checks[12][0][1][0].another_filter.must_equal ['example'] end - end - - describe 'when using deprecated `resource_skip` method' do - it 'warns the user' do - _, err = capture_io { checks[0][0][1][0].resource_skipped } - err.must_match(/DEPRECATION/) - end end end diff --git a/test/unit/resources/auditd_rules_test.rb b/test/unit/resources/auditd_rules_test.rb deleted file mode 100644 index b8342c5af..000000000 --- a/test/unit/resources/auditd_rules_test.rb +++ /dev/null @@ -1,106 +0,0 @@ -# encoding: utf-8 -# author: Christoph Hartmann -# author: Dominik Richter - -require 'helper' -require 'inspec/resource' - -describe 'Inspec::Resources::AuditDaemonRules' do - it 'auditd_rules interface' do - resource = MockLoader.new(:centos7).load_resource('auditd_rules') - _(resource.send('lines')).must_equal [ - '-a always,exit -F arch=b64 -S open,openat -F exit=-EACCES -F key=access', - '-a always,exit -F arch=b32 -S open,openat -F exit=-EPERM -F key=access', - '-a always,exit -F arch=b32 -S chmod,fchmod,fchmodat -F auid>=500 f24!=0 -F key=perm_mod', - '-a always,exit -S all -F path=/usr/bin/chage -F perm=x -F auid>=1000 -F auid!=-1 -F key=privileged', - '-a always,exit -S all -F path=/usr/bin/mount -F perm=x -F auid>=1000 -F auid!=-1 -F key=privileged', - '-w /etc/ssh/sshd_config -p rwxa -k CFG_sshd_config', - '-w /etc/sudoers -p wa', - '-w /etc/private-keys -p x', - ] - end - - it 'auditd_rules syscall interface' do - resource = MockLoader.new(:centos7).load_resource('auditd_rules') - _(resource.send('syscall', 'open').send('rules')).must_equal [ - {:syscall=>"open", :list=>"exit", :action=>"always", :fields=>["arch=b64", "exit=-EACCES", "key=access"], :arch=>"b64", :exit=>"-EACCES", :key=>"access"}, - {:syscall=>"open", :list=>"exit", :action=>"always", :fields=>["arch=b32", "exit=-EPERM", "key=access"], :arch=>"b32", :exit=>"-EPERM", :key=>"access"} - ] - end - - it 'auditd_rules syscall query chaining' do - resource = MockLoader.new(:centos7).load_resource('auditd_rules') - _(resource.send('syscall', 'open').field('key', 'access').send('rules')).must_equal [ - {:syscall=>"open", :list=>"exit", :action=>"always", :fields=>["arch=b64", "exit=-EACCES", "key=access"], :arch=>"b64", :exit=>"-EACCES", :key=>"access"}, - {:syscall=>"open", :list=>"exit", :action=>"always", :fields=>["arch=b32", "exit=-EPERM", "key=access"], :arch=>"b32", :exit=>"-EPERM", :key=>"access"} - ] - end - - it 'auditd_rules syscall query chaining with short syntax' do - resource = MockLoader.new(:centos7).load_resource('auditd_rules') - _(resource.send('syscall', 'open').key('access').list('exit').send('rules')).must_equal [ - {:syscall=>"open", :list=>"exit", :action=>"always", :fields=>["arch=b64", "exit=-EACCES", "key=access"], :arch=>"b64", :exit=>"-EACCES", :key=>"access"}, - {:syscall=>"open", :list=>"exit", :action=>"always", :fields=>["arch=b32", "exit=-EPERM", "key=access"], :arch=>"b32", :exit=>"-EPERM", :key=>"access"} - ] - end - - it 'check auditd_rules syscall query chaining empty results' do - resource = MockLoader.new(:centos7).load_resource('auditd_rules') - _(resource.send('syscall', 'open').field('key', 'access').field('foo', 'bar').send('rules')).must_equal [] - end - - - it 'check auditd_rules file interface' do - resource = MockLoader.new(:centos7).load_resource('auditd_rules') - _(resource.send('file', '/etc/ssh/sshd_config').send('rules')).must_equal [ - { file: '/etc/ssh/sshd_config', key: 'CFG_sshd_config', permissions: 'rwxa'}, - ] - end - - it 'check auditd_rules key interface' do - resource = MockLoader.new(:centos7).load_resource('auditd_rules') - _(resource.send('key', 'CFG_sshd_config').send('rules')).must_equal [ - { file: '/etc/ssh/sshd_config', key: 'CFG_sshd_config', permissions: 'rwxa'}, - ] - end - - it 'check auditd_rules file interface with no keys' do - resource = MockLoader.new(:centos7).load_resource('auditd_rules') - _(resource.send('file', '/etc/private-keys').send('rules')).must_equal [ - { file: '/etc/private-keys', key: nil, permissions: 'x'}, - ] - end - - it 'check auditd_rules status interface' do - resource = MockLoader.new(:centos7).load_resource('auditd_rules') - _(resource.send('status')).must_equal({ - 'enabled' => '1', - 'flag' => '2', - 'pid' => '547', - 'rate_limit' => '0', - 'backlog_limit' => '8192', - 'lost' => '0', - 'backlog' => '0', - 'loginuid_immutable' => '0 unlocked', - }) - end - - it 'check auditd_rules status interface querying a key' do - resource = MockLoader.new(:centos7).load_resource('auditd_rules') - _(resource.send('status', 'enabled')).must_equal('1') - end - - # TODO(sr) figure out how to feed resource the legacy auditctl mock cmd output - # it 'check legacy audit policy parsing' do - # resource = MockLoader.new(:undefined).load_resource('auditd_rules') - # _(resource.send('LIST_RULES')).must_equal [ - # 'exit,always syscall=rmdir,unlink', - # 'exit,always auid=1001 (0x3e9) syscall=open', - # 'exit,always watch=/etc/group perm=wa', - # 'exit,always watch=/etc/passwd perm=wa', - # 'exit,always watch=/etc/shadow perm=wa', - # 'exit,always watch=/etc/sudoers perm=wa', - # 'exit,always watch=/etc/secret_directory perm=r', - # ] - # end -end diff --git a/test/unit/resources/package_test.rb b/test/unit/resources/package_test.rb index 8d7084ad3..31f7b2ca9 100644 --- a/test/unit/resources/package_test.rb +++ b/test/unit/resources/package_test.rb @@ -72,7 +72,7 @@ describe 'Inspec::Resources::Package' do _(resource.info).must_equal pkg end - it 'can add to `resource_skipped` when `--rpmdb` path does not exist' do + it 'can set `resource_skipped` when `--rpmdb` path does not exist' do resource = MockLoader.new(:centos7).load_resource( 'package', 'curl', diff --git a/test/unit/resources/passwd_test.rb b/test/unit/resources/passwd_test.rb index 7ad0490d7..67a30040e 100644 --- a/test/unit/resources/passwd_test.rb +++ b/test/unit/resources/passwd_test.rb @@ -47,7 +47,7 @@ describe 'Inspec::Resources::Passwd' do it 'retrieves singular elements instead of arrays when filter has only one entry' do _(child.users).must_equal ['root'] - _(child.count).must_equal 1 + _(child.entries.length).must_equal 1 end end @@ -55,7 +55,7 @@ describe 'Inspec::Resources::Passwd' do let(:child) { passwd.users(/^www/) } it 'filters by user via name (regex)' do _(child.users).must_equal ['www-data'] - _(child.count).must_equal 1 + _(child.entries.length).must_equal 1 end it 'prints a nice to_s string' do @@ -63,20 +63,6 @@ describe 'Inspec::Resources::Passwd' do end end - describe 'deprecated calls' do - it 'retrieves a username via uid' do - _(passwd.uid(0).username).must_equal 'root' - end - - it 'retrieves a usercount via uid' do - _(passwd.uid(0).count).must_equal 1 - end - - it 'retrieves usernames' do - _(passwd.usernames).must_equal ['root', 'www-data'] - end - end - describe 'where clause' do it 'retrieves username via uids < x' do _(passwd.where { uid.to_i < 33 }.entries.length).must_equal 1 diff --git a/test/unit/resources/platform_test.rb b/test/unit/resources/platform_test.rb index f698479da..f002a5442 100644 --- a/test/unit/resources/platform_test.rb +++ b/test/unit/resources/platform_test.rb @@ -6,25 +6,6 @@ require 'inspec/resource' describe 'Inspec::Resources::Platform' do let(:resource) { resource = MockLoader.new(:ubuntu1504).load_resource('platform') } - describe 'NamedCleaned' do - let(:cleaner) { Inspec::Resources::PlatformResource::NameCleaned } - - it 'verify name cleaned with uppercase' do - name = cleaner.new('upper_case_with_spaces') - _(name == 'Upper Case with Spaces').must_equal true - end - - it 'verify name cleaned with uppercase fail' do - name = cleaner.new('upper_case_with_spaces') - _(name == 'Upper Case withFAIL Spaces').must_equal false - end - - it 'verify name cleaned with lowercase' do - name = cleaner.new('lower_case_with_underscore') - _(name == 'lower_case_with_underscore').must_equal true - end - end - it 'verify platform parsing on Ubuntu' do _(resource.name).must_equal 'ubuntu' _(resource.family).must_equal 'debian' diff --git a/test/unit/utils/simpleconfig_test.rb b/test/unit/utils/simpleconfig_test.rb index 59402d091..540ab20b9 100644 --- a/test/unit/utils/simpleconfig_test.rb +++ b/test/unit/utils/simpleconfig_test.rb @@ -90,13 +90,6 @@ describe 'SimpleConfig Default Parser' do cur.params.must_equal({'key' => 'val'}) end - it 'supports :assignment_re for specifying the assignment with a deprecation warning' do - assert_output(nil, /DEPRECATION/) do - cur = SimpleConfig.new("key:::val", assignment_re: /^(.*):::(.*)$/) - cur.params.must_equal({'key' => 'val'}) - end - end - it 'only reads the first assignment match group by default' do cur = SimpleConfig.new("1:2:3", assignment_regex: /^(.*):(.*):(.*)$/) cur.params.must_equal({'1' => '2'}) @@ -111,11 +104,4 @@ describe 'SimpleConfig Default Parser' do cur = SimpleConfig.new("1:2:3", assignment_regex: /^(.*):(.*):(.*)$/, key_values: 4) cur.params.must_equal({'1' => ['2', '3', nil, nil]}) end - - it 'supports :key_vals for specifying the assignment with a deprecation warning' do - assert_output(nil, /DEPRECATION/) do - cur = SimpleConfig.new("1:2:3", assignment_regex: /^(.*):(.*):(.*)$/, key_vals: 2) - cur.params.must_equal({'1' => ['2', '3']}) - end - end end From 16fe52b084c6e42c5f2765fbd256a851d4ff621b Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Mon, 5 Feb 2018 15:58:51 -0500 Subject: [PATCH 187/206] Rearrange AWS files for merge into core Signed-off-by: Clinton Wolfe --- .rubocop.yml | 99 ----------- .travis.yml | 9 - CONTRIBUTING.md | 155 ------------------ LICENSE | 13 -- inspec.yml | 7 - .../resource_support/aws/aws.rb | 0 .../aws/aws_backend_factory_mixin.rb | 0 .../aws/aws_resource_mixin.rb | 0 .../resources/aws}/aws_cloudtrail_trail.rb | 0 .../resources/aws}/aws_cloudtrail_trails.rb | 0 .../resources/aws}/aws_cloudwatch_alarm.rb | 0 .../aws}/aws_cloudwatch_log_metric_filter.rb | 0 .../resources/aws}/aws_ec2_instance.rb | 0 .../resources/aws}/aws_ec2_security_group.rb | 0 .../resources/aws}/aws_ec2_security_groups.rb | 0 .../resources/aws}/aws_iam_access_key.rb | 0 .../resources/aws}/aws_iam_access_keys.rb | 0 .../resources/aws}/aws_iam_group.rb | 0 .../resources/aws}/aws_iam_groups.rb | 0 .../resources/aws}/aws_iam_password_policy.rb | 0 .../resources/aws}/aws_iam_policies.rb | 0 .../resources/aws}/aws_iam_policy.rb | 0 .../resources/aws}/aws_iam_role.rb | 0 .../resources/aws}/aws_iam_root_user.rb | 0 .../resources/aws}/aws_iam_user.rb | 0 .../resources/aws}/aws_iam_users.rb | 0 .../resources/aws}/aws_kms_keys.rb | 0 .../resources/aws}/aws_s3_bucket.rb | 0 .../resources/aws}/aws_sns_topic.rb | 0 {libraries => lib/resources/aws}/aws_vpc.rb | 0 {libraries => lib/resources/aws}/aws_vpcs.rb | 0 libraries/aws_aaa_shim.rb | 3 - .../{integration => aws}/default/build/aws.tf | 0 .../default/build/cloudtrail.tf | 0 .../default/build/cloudwatch.tf | 0 .../{integration => aws}/default/build/ec2.tf | 0 .../{integration => aws}/default/build/iam.tf | 0 .../default/build/inspec-logo.png | Bin test/{integration => aws}/default/build/s3.tf | 0 .../{integration => aws}/default/build/sns.tf | 0 .../default/verify/aws_iam_group.rb | 0 .../verify/controls/aws_cloudtrail_trail.rb | 0 .../verify/controls/aws_cloudtrail_trails.rb | 0 .../verify/controls/aws_cloudwatch_alarm.rb | 0 .../aws_cloudwatch_log_metric_filter.rb | 0 .../verify/controls/aws_ec2_instance.rb | 0 .../verify/controls/aws_ec2_security_group.rb | 0 .../controls/aws_ec2_security_groups.rb | 0 .../verify/controls/aws_iam_access_key.rb | 0 .../default/verify/controls/aws_iam_group.rb | 0 .../default/verify/controls/aws_iam_groups.rb | 0 .../verify/controls/aws_iam_policies.rb | 0 .../default/verify/controls/aws_iam_policy.rb | 0 .../default/verify/controls/aws_iam_role.rb | 0 .../verify/controls/aws_iam_root_user.rb | 0 .../default/verify/controls/aws_iam_user.rb | 0 .../default/verify/controls/aws_iam_users.rb | 0 .../default/verify/controls/aws_kms_keys.rb | 0 .../default/verify/controls/aws_s3_bucket.rb | 0 .../default/verify/controls/aws_sns_topic.rb | 0 .../default/verify/controls/aws_vpc.rb | 0 .../default/verify/controls/aws_vpcs.rb | 0 .../default/verify/inspec.yml | 0 .../{integration => aws}/minimal/build/aws.tf | 0 .../verify/controls/aws_iam_root_user.rb | 0 .../minimal/verify/inspec.yml | 0 66 files changed, 286 deletions(-) delete mode 100644 .rubocop.yml delete mode 100644 .travis.yml delete mode 100644 CONTRIBUTING.md delete mode 100644 LICENSE delete mode 100644 inspec.yml rename libraries/_aws.rb => lib/resource_support/aws/aws.rb (100%) rename libraries/_aws_backend_factory_mixin.rb => lib/resource_support/aws/aws_backend_factory_mixin.rb (100%) rename libraries/_aws_resource_mixin.rb => lib/resource_support/aws/aws_resource_mixin.rb (100%) rename {libraries => lib/resources/aws}/aws_cloudtrail_trail.rb (100%) rename {libraries => lib/resources/aws}/aws_cloudtrail_trails.rb (100%) rename {libraries => lib/resources/aws}/aws_cloudwatch_alarm.rb (100%) rename {libraries => lib/resources/aws}/aws_cloudwatch_log_metric_filter.rb (100%) rename {libraries => lib/resources/aws}/aws_ec2_instance.rb (100%) rename {libraries => lib/resources/aws}/aws_ec2_security_group.rb (100%) rename {libraries => lib/resources/aws}/aws_ec2_security_groups.rb (100%) rename {libraries => lib/resources/aws}/aws_iam_access_key.rb (100%) rename {libraries => lib/resources/aws}/aws_iam_access_keys.rb (100%) rename {libraries => lib/resources/aws}/aws_iam_group.rb (100%) rename {libraries => lib/resources/aws}/aws_iam_groups.rb (100%) rename {libraries => lib/resources/aws}/aws_iam_password_policy.rb (100%) rename {libraries => lib/resources/aws}/aws_iam_policies.rb (100%) rename {libraries => lib/resources/aws}/aws_iam_policy.rb (100%) rename {libraries => lib/resources/aws}/aws_iam_role.rb (100%) rename {libraries => lib/resources/aws}/aws_iam_root_user.rb (100%) rename {libraries => lib/resources/aws}/aws_iam_user.rb (100%) rename {libraries => lib/resources/aws}/aws_iam_users.rb (100%) rename {libraries => lib/resources/aws}/aws_kms_keys.rb (100%) rename {libraries => lib/resources/aws}/aws_s3_bucket.rb (100%) rename {libraries => lib/resources/aws}/aws_sns_topic.rb (100%) rename {libraries => lib/resources/aws}/aws_vpc.rb (100%) rename {libraries => lib/resources/aws}/aws_vpcs.rb (100%) delete mode 100644 libraries/aws_aaa_shim.rb rename test/{integration => aws}/default/build/aws.tf (100%) rename test/{integration => aws}/default/build/cloudtrail.tf (100%) rename test/{integration => aws}/default/build/cloudwatch.tf (100%) rename test/{integration => aws}/default/build/ec2.tf (100%) rename test/{integration => aws}/default/build/iam.tf (100%) rename test/{integration => aws}/default/build/inspec-logo.png (100%) rename test/{integration => aws}/default/build/s3.tf (100%) rename test/{integration => aws}/default/build/sns.tf (100%) rename test/{integration => aws}/default/verify/aws_iam_group.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_cloudtrail_trail.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_cloudtrail_trails.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_cloudwatch_alarm.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_cloudwatch_log_metric_filter.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_ec2_instance.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_ec2_security_group.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_ec2_security_groups.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_iam_access_key.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_iam_group.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_iam_groups.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_iam_policies.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_iam_policy.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_iam_role.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_iam_root_user.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_iam_user.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_iam_users.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_kms_keys.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_s3_bucket.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_sns_topic.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_vpc.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_vpcs.rb (100%) rename test/{integration => aws}/default/verify/inspec.yml (100%) rename test/{integration => aws}/minimal/build/aws.tf (100%) rename test/{integration => aws}/minimal/verify/controls/aws_iam_root_user.rb (100%) rename test/{integration => aws}/minimal/verify/inspec.yml (100%) diff --git a/.rubocop.yml b/.rubocop.yml deleted file mode 100644 index 46550f92f..000000000 --- a/.rubocop.yml +++ /dev/null @@ -1,99 +0,0 @@ ---- -AllCops: - TargetRubyVersion: 2.3 - Exclude: - - Gemfile - - Rakefile - - 'test/**/*' - - 'examples/**/*' - - 'vendor/**/*' - - 'lib/bundles/inspec-init/templates/**/*' - - 'www/tutorial/**/*' -AlignParameters: - Enabled: true -BlockDelimiters: - Enabled: false -Documentation: - Enabled: false -EmptyLinesAroundBlockBody: - Enabled: false -FrozenStringLiteralComment: - Enabled: false -HashSyntax: - Enabled: true -LineLength: - Enabled: false -Layout/AlignHash: - Enabled: false -Layout/EmptyLineAfterMagicComment: - Enabled: false -Layout/EndOfLine: - Enabled: true - EnforcedStyle: lf -Layout/SpaceAroundOperators: - Enabled: false -MethodLength: - Max: 40 -Metrics/AbcSize: - Max: 33 -Metrics/BlockLength: - Max: 50 -Metrics/CyclomaticComplexity: - Max: 10 -Metrics/PerceivedComplexity: - Max: 11 -Naming/FileName: - Enabled: false -Naming/HeredocDelimiterNaming: - Enabled: false -Naming/PredicateName: - Enabled: false -NumericLiterals: - MinDigits: 10 -Security/YAMLLoad: - Enabled: false -Style/AndOr: - Enabled: false -Style/BracesAroundHashParameters: - Enabled: false -Style/ClassAndModuleChildren: - Enabled: false -Style/ConditionalAssignment: - Enabled: false -Style/EmptyMethod: - Enabled: false -Style/Encoding: - Enabled: false -Style/IfUnlessModifier: - Enabled: false -Style/MethodMissing: - Enabled: false -Style/MultilineIfModifier: - Enabled: false -Style/NegatedIf: - Enabled: false -Style/Not: - Enabled: false -Style/NumericLiteralPrefix: - Enabled: false -Style/NumericPredicate: - Enabled: false -Style/PercentLiteralDelimiters: - PreferredDelimiters: - '%': '{}' - '%i': () - '%q': '{}' - '%Q': () - '%r': '{}' - '%s': () - '%w': '{}' - '%W': () - '%x': () -Style/SymbolArray: - Enabled: false -Style/TrailingCommaInArguments: - EnforcedStyleForMultiline: comma -Style/TrailingCommaInLiteral: - EnforcedStyleForMultiline: comma -Style/UnlessElse: - Enabled: false diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index cb954c827..000000000 --- a/.travis.yml +++ /dev/null @@ -1,9 +0,0 @@ -sudo: false -language: ruby -cache: bundler - -rvm: - - 2.3.1 - -bundler_args: --without integration -script: bundle exec rake diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index b92422962..000000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,155 +0,0 @@ -# Contributing to InSpec - -We are glad you want to contribute to InSpec! This document will help answer common questions you may have during your first contribution. - -## Submitting Issues - -We utilize **Github Issues** for issue tracking and contributions. You can contribute in two ways: - -1. Reporting an issue or making a feature request [here](#issues). -2. Adding features or fixing bugs yourself and contributing your code to InSpec. - -We ask you not to submit security concerns via Github. For details on submitting potential security issues please see - -## Contribution Process - -We have a 3 step process for contributions: - -1. Commit changes to a git branch, making sure to sign-off those changes for the [Developer Certificate of Origin](#developer-certification-of-origin-dco). -2. Create a Github Pull Request for your change, following the instructions in the pull request template. -3. Perform a [Code Review](#code-review-process) with the project maintainers on the pull request. - -### Pull Request Requirements - -Chef Projects are built to last. We strive to ensure high quality throughout the experience. In order to ensure this, we require that all pull requests to Chef projects meet these specifications: - -1. **Tests:** To ensure high quality code and protect against future regressions, we require all the code in Chef Projects to have at least unit test coverage. See the [test/unit](https://github.com/chef/inspec/tree/master/test/unit) -directory for the existing tests and use ```bundle exec rake test``` to run them. -2. **Green CI Tests:** We use [Travis CI](https://travis-ci.org/) and/or [AppVeyor](https://www.appveyor.com/) CI systems to test all pull requests. We require these test runs to succeed on every pull request before being merged. -3. **Up-to-date Documentation:** Every code change should be reflected in an update for our [documentation](https://github.com/chef/inspec/tree/master/docs). We expect PRs to update the documentation with the code change. - -In addition to this it would be nice to include the description of the problem you are solving - with your change. You can use [Issue Template](#issuetemplate) in the description section - of the pull request. - -### Code Review Process - -Code review takes place in Github pull requests. See [this article](https://help.github.com/articles/about-pull-requests/) if you're not familiar with Github Pull Requests. - -Once you open a pull request, project maintainers will review your code and respond to your pull request with any feedback they might have. The process at this point is as follows: - -1. Two thumbs-up (:+1:) are required from project maintainers. See the master maintainers document for InSpec projects at . -2. When ready, your pull request will be merged into `master`, we may require you to rebase your PR to the latest `master`. -3. Once the PR is merged, you will be included in `CHANGELOG.md`. - -If you would like to learn about when your code will be available in a release of Chef, read more about [Chef Release Cycles](#release-cycles). - - -### Developer Certification of Origin (DCO) - -Licensing is very important to open source projects. It helps ensure the software continues to be available under the terms that the author desired. - -Chef uses [the Apache 2.0 license](https://github.com/chef/chef/blob/master/LICENSE) to strike a balance between open contribution and allowing you to use the software however you would like to. - -The license tells you what rights you have that are provided by the copyright holder. It is important that the contributor fully understands what rights they are licensing and agrees to them. Sometimes the copyright holder isn't the contributor, such as when the contributor is doing work on behalf of a company. - -To make a good faith effort to ensure these criteria are met, Chef requires the Developer Certificate of Origin (DCO) process to be followed. - -The DCO is an attestation attached to every contribution made by every developer. In the commit message of the contribution, the developer simply adds a Signed-off-by statement and thereby agrees to the DCO, which you can find below or at . - -``` -Developer's Certificate of Origin 1.1 - -By making a contribution to this project, I certify that: - -(a) The contribution was created in whole or in part by me and I - have the right to submit it under the open source license - indicated in the file; or - -(b) The contribution is based upon previous work that, to the - best of my knowledge, is covered under an appropriate open - source license and I have the right under that license to - submit that work with modifications, whether created in whole - or in part by me, under the same open source license (unless - I am permitted to submit under a different license), as - Indicated in the file; or - -(c) The contribution was provided directly to me by some other - person who certified (a), (b) or (c) and I have not modified - it. - -(d) I understand and agree that this project and the contribution - are public and that a record of the contribution (including - all personal information I submit with it, including my - sign-off) is maintained indefinitely and may be redistributed - consistent with this project or the open source license(s) - involved. -``` - -For more information on the change see the Chef Blog post [Introducing Developer Certificate of Origin](https://blog.chef.io/2016/09/19/introducing-developer-certificate-of-origin/) - -#### DCO Sign-Off Methods - -The DCO requires a sign-off message in the following format appear on each commit in the pull request: - -``` -Signed-off-by: Julia Child -``` - -The DCO text can either be manually added to your commit body, or you can add either **-s** or **--signoff** to your usual git commit commands. If you forget to add the sign-off you can also amend a previous commit with the sign-off by running **git commit --amend -s**. If you've pushed your changes to Github already you'll need to force push your branch after this with **git push -f**. - -### Obvious Fix Policy - -Small contributions, such as fixing spelling errors, where the content is small enough to not be considered intellectual property, can be submitted without signing the contribution for the DCO. - -As a rule of thumb, changes are obvious fixes if they do not introduce any new functionality or creative thinking. Assuming the change does not affect functionality, some common obvious fix examples include the following: - -- Spelling / grammar fixes -- Typo correction, white space and formatting changes -- Comment clean up -- Bug fixes that change default return values or error codes stored in constants -- Adding logging messages or debugging output -- Changes to 'metadata' files like Gemfile, .gitignore, build scripts, etc. -- Moving source files from one directory or package to another - -**Whenever you invoke the "obvious fix" rule, please say so in your commit message:** - -``` ------------------------------------------------------------------------- -commit 370adb3f82d55d912b0cf9c1d1e99b132a8ed3b5 -Author: Julia Child -Date: Wed Sep 18 11:44:40 2015 -0700 - - Fix typo in the README. - - Obvious fix. - ------------------------------------------------------------------------- -``` - -## Release Cycles - -Our primary shipping vehicle is operating system specific packages that includes all the requirements of InSpec. We call these [Omnibus packages](https://github.com/chef/omnibus) - -We also release our software as gems to [Rubygems](https://rubygems.org/) but we strongly recommend using InSpec or ChefDK packages. - -Our version numbering roughly follows [Semantic Versioning](http://semver.org/) standard. Our standard version numbers look like X.Y.Z which mean: - -- X is a major release, which may not be fully compatible with prior major releases -- Y is a minor release, which adds both new features and bug fixes -- Z is a patch release, which adds just bug fixes - -After shipping a release of InSpec we bump at least the `Minor` version by one to start development of the next minor release. We do a release approximately every week. Announcements of releases are made to the [InSpec mailing list](https://discourse.chef.io/c/chef-release) when they are available. - -## InSpec Community - -InSpec is made possible by a strong community of developers, system administrators, auditor and security experts. If you have any questions or if you would like to get involved in the InSpec community you can check out: - -- [InSpec Mailing List](https://discourse.chef.io/c/inspec) -- [Chef Community Slack](https://community-slack.chef.io/) - -Also here are some additional pointers to some awesome Chef content: - -- [InSpec Docs](http://inspec.io/docs/) -- [Learn Chef](https://learn.chef.io/) -- [Chef Website](https://www.chef.io/) diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 161d799fa..000000000 --- a/LICENSE +++ /dev/null @@ -1,13 +0,0 @@ -Copyright (c) 2016 Chef Software Inc. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/inspec.yml b/inspec.yml deleted file mode 100644 index afc622c0e..000000000 --- a/inspec.yml +++ /dev/null @@ -1,7 +0,0 @@ -name: inspec-aws -title: InSpec AWS Resource Pack -maintainer: Chef Software Inc. -copyright: chris@lollyrock.com -copyright_email: chris@lollyrock.com -license: Apache 2 license -version: 1.0.0 diff --git a/libraries/_aws.rb b/lib/resource_support/aws/aws.rb similarity index 100% rename from libraries/_aws.rb rename to lib/resource_support/aws/aws.rb diff --git a/libraries/_aws_backend_factory_mixin.rb b/lib/resource_support/aws/aws_backend_factory_mixin.rb similarity index 100% rename from libraries/_aws_backend_factory_mixin.rb rename to lib/resource_support/aws/aws_backend_factory_mixin.rb diff --git a/libraries/_aws_resource_mixin.rb b/lib/resource_support/aws/aws_resource_mixin.rb similarity index 100% rename from libraries/_aws_resource_mixin.rb rename to lib/resource_support/aws/aws_resource_mixin.rb diff --git a/libraries/aws_cloudtrail_trail.rb b/lib/resources/aws/aws_cloudtrail_trail.rb similarity index 100% rename from libraries/aws_cloudtrail_trail.rb rename to lib/resources/aws/aws_cloudtrail_trail.rb diff --git a/libraries/aws_cloudtrail_trails.rb b/lib/resources/aws/aws_cloudtrail_trails.rb similarity index 100% rename from libraries/aws_cloudtrail_trails.rb rename to lib/resources/aws/aws_cloudtrail_trails.rb diff --git a/libraries/aws_cloudwatch_alarm.rb b/lib/resources/aws/aws_cloudwatch_alarm.rb similarity index 100% rename from libraries/aws_cloudwatch_alarm.rb rename to lib/resources/aws/aws_cloudwatch_alarm.rb diff --git a/libraries/aws_cloudwatch_log_metric_filter.rb b/lib/resources/aws/aws_cloudwatch_log_metric_filter.rb similarity index 100% rename from libraries/aws_cloudwatch_log_metric_filter.rb rename to lib/resources/aws/aws_cloudwatch_log_metric_filter.rb diff --git a/libraries/aws_ec2_instance.rb b/lib/resources/aws/aws_ec2_instance.rb similarity index 100% rename from libraries/aws_ec2_instance.rb rename to lib/resources/aws/aws_ec2_instance.rb diff --git a/libraries/aws_ec2_security_group.rb b/lib/resources/aws/aws_ec2_security_group.rb similarity index 100% rename from libraries/aws_ec2_security_group.rb rename to lib/resources/aws/aws_ec2_security_group.rb diff --git a/libraries/aws_ec2_security_groups.rb b/lib/resources/aws/aws_ec2_security_groups.rb similarity index 100% rename from libraries/aws_ec2_security_groups.rb rename to lib/resources/aws/aws_ec2_security_groups.rb diff --git a/libraries/aws_iam_access_key.rb b/lib/resources/aws/aws_iam_access_key.rb similarity index 100% rename from libraries/aws_iam_access_key.rb rename to lib/resources/aws/aws_iam_access_key.rb diff --git a/libraries/aws_iam_access_keys.rb b/lib/resources/aws/aws_iam_access_keys.rb similarity index 100% rename from libraries/aws_iam_access_keys.rb rename to lib/resources/aws/aws_iam_access_keys.rb diff --git a/libraries/aws_iam_group.rb b/lib/resources/aws/aws_iam_group.rb similarity index 100% rename from libraries/aws_iam_group.rb rename to lib/resources/aws/aws_iam_group.rb diff --git a/libraries/aws_iam_groups.rb b/lib/resources/aws/aws_iam_groups.rb similarity index 100% rename from libraries/aws_iam_groups.rb rename to lib/resources/aws/aws_iam_groups.rb diff --git a/libraries/aws_iam_password_policy.rb b/lib/resources/aws/aws_iam_password_policy.rb similarity index 100% rename from libraries/aws_iam_password_policy.rb rename to lib/resources/aws/aws_iam_password_policy.rb diff --git a/libraries/aws_iam_policies.rb b/lib/resources/aws/aws_iam_policies.rb similarity index 100% rename from libraries/aws_iam_policies.rb rename to lib/resources/aws/aws_iam_policies.rb diff --git a/libraries/aws_iam_policy.rb b/lib/resources/aws/aws_iam_policy.rb similarity index 100% rename from libraries/aws_iam_policy.rb rename to lib/resources/aws/aws_iam_policy.rb diff --git a/libraries/aws_iam_role.rb b/lib/resources/aws/aws_iam_role.rb similarity index 100% rename from libraries/aws_iam_role.rb rename to lib/resources/aws/aws_iam_role.rb diff --git a/libraries/aws_iam_root_user.rb b/lib/resources/aws/aws_iam_root_user.rb similarity index 100% rename from libraries/aws_iam_root_user.rb rename to lib/resources/aws/aws_iam_root_user.rb diff --git a/libraries/aws_iam_user.rb b/lib/resources/aws/aws_iam_user.rb similarity index 100% rename from libraries/aws_iam_user.rb rename to lib/resources/aws/aws_iam_user.rb diff --git a/libraries/aws_iam_users.rb b/lib/resources/aws/aws_iam_users.rb similarity index 100% rename from libraries/aws_iam_users.rb rename to lib/resources/aws/aws_iam_users.rb diff --git a/libraries/aws_kms_keys.rb b/lib/resources/aws/aws_kms_keys.rb similarity index 100% rename from libraries/aws_kms_keys.rb rename to lib/resources/aws/aws_kms_keys.rb diff --git a/libraries/aws_s3_bucket.rb b/lib/resources/aws/aws_s3_bucket.rb similarity index 100% rename from libraries/aws_s3_bucket.rb rename to lib/resources/aws/aws_s3_bucket.rb diff --git a/libraries/aws_sns_topic.rb b/lib/resources/aws/aws_sns_topic.rb similarity index 100% rename from libraries/aws_sns_topic.rb rename to lib/resources/aws/aws_sns_topic.rb diff --git a/libraries/aws_vpc.rb b/lib/resources/aws/aws_vpc.rb similarity index 100% rename from libraries/aws_vpc.rb rename to lib/resources/aws/aws_vpc.rb diff --git a/libraries/aws_vpcs.rb b/lib/resources/aws/aws_vpcs.rb similarity index 100% rename from libraries/aws_vpcs.rb rename to lib/resources/aws/aws_vpcs.rb diff --git a/libraries/aws_aaa_shim.rb b/libraries/aws_aaa_shim.rb deleted file mode 100644 index 685c9f0c7..000000000 --- a/libraries/aws_aaa_shim.rb +++ /dev/null @@ -1,3 +0,0 @@ -# This file simply acts as a loader when inspec-aws -# is being used as a resource pack. -require '_aws' diff --git a/test/integration/default/build/aws.tf b/test/aws/default/build/aws.tf similarity index 100% rename from test/integration/default/build/aws.tf rename to test/aws/default/build/aws.tf diff --git a/test/integration/default/build/cloudtrail.tf b/test/aws/default/build/cloudtrail.tf similarity index 100% rename from test/integration/default/build/cloudtrail.tf rename to test/aws/default/build/cloudtrail.tf diff --git a/test/integration/default/build/cloudwatch.tf b/test/aws/default/build/cloudwatch.tf similarity index 100% rename from test/integration/default/build/cloudwatch.tf rename to test/aws/default/build/cloudwatch.tf diff --git a/test/integration/default/build/ec2.tf b/test/aws/default/build/ec2.tf similarity index 100% rename from test/integration/default/build/ec2.tf rename to test/aws/default/build/ec2.tf diff --git a/test/integration/default/build/iam.tf b/test/aws/default/build/iam.tf similarity index 100% rename from test/integration/default/build/iam.tf rename to test/aws/default/build/iam.tf diff --git a/test/integration/default/build/inspec-logo.png b/test/aws/default/build/inspec-logo.png similarity index 100% rename from test/integration/default/build/inspec-logo.png rename to test/aws/default/build/inspec-logo.png diff --git a/test/integration/default/build/s3.tf b/test/aws/default/build/s3.tf similarity index 100% rename from test/integration/default/build/s3.tf rename to test/aws/default/build/s3.tf diff --git a/test/integration/default/build/sns.tf b/test/aws/default/build/sns.tf similarity index 100% rename from test/integration/default/build/sns.tf rename to test/aws/default/build/sns.tf diff --git a/test/integration/default/verify/aws_iam_group.rb b/test/aws/default/verify/aws_iam_group.rb similarity index 100% rename from test/integration/default/verify/aws_iam_group.rb rename to test/aws/default/verify/aws_iam_group.rb diff --git a/test/integration/default/verify/controls/aws_cloudtrail_trail.rb b/test/aws/default/verify/controls/aws_cloudtrail_trail.rb similarity index 100% rename from test/integration/default/verify/controls/aws_cloudtrail_trail.rb rename to test/aws/default/verify/controls/aws_cloudtrail_trail.rb diff --git a/test/integration/default/verify/controls/aws_cloudtrail_trails.rb b/test/aws/default/verify/controls/aws_cloudtrail_trails.rb similarity index 100% rename from test/integration/default/verify/controls/aws_cloudtrail_trails.rb rename to test/aws/default/verify/controls/aws_cloudtrail_trails.rb diff --git a/test/integration/default/verify/controls/aws_cloudwatch_alarm.rb b/test/aws/default/verify/controls/aws_cloudwatch_alarm.rb similarity index 100% rename from test/integration/default/verify/controls/aws_cloudwatch_alarm.rb rename to test/aws/default/verify/controls/aws_cloudwatch_alarm.rb diff --git a/test/integration/default/verify/controls/aws_cloudwatch_log_metric_filter.rb b/test/aws/default/verify/controls/aws_cloudwatch_log_metric_filter.rb similarity index 100% rename from test/integration/default/verify/controls/aws_cloudwatch_log_metric_filter.rb rename to test/aws/default/verify/controls/aws_cloudwatch_log_metric_filter.rb diff --git a/test/integration/default/verify/controls/aws_ec2_instance.rb b/test/aws/default/verify/controls/aws_ec2_instance.rb similarity index 100% rename from test/integration/default/verify/controls/aws_ec2_instance.rb rename to test/aws/default/verify/controls/aws_ec2_instance.rb diff --git a/test/integration/default/verify/controls/aws_ec2_security_group.rb b/test/aws/default/verify/controls/aws_ec2_security_group.rb similarity index 100% rename from test/integration/default/verify/controls/aws_ec2_security_group.rb rename to test/aws/default/verify/controls/aws_ec2_security_group.rb diff --git a/test/integration/default/verify/controls/aws_ec2_security_groups.rb b/test/aws/default/verify/controls/aws_ec2_security_groups.rb similarity index 100% rename from test/integration/default/verify/controls/aws_ec2_security_groups.rb rename to test/aws/default/verify/controls/aws_ec2_security_groups.rb diff --git a/test/integration/default/verify/controls/aws_iam_access_key.rb b/test/aws/default/verify/controls/aws_iam_access_key.rb similarity index 100% rename from test/integration/default/verify/controls/aws_iam_access_key.rb rename to test/aws/default/verify/controls/aws_iam_access_key.rb diff --git a/test/integration/default/verify/controls/aws_iam_group.rb b/test/aws/default/verify/controls/aws_iam_group.rb similarity index 100% rename from test/integration/default/verify/controls/aws_iam_group.rb rename to test/aws/default/verify/controls/aws_iam_group.rb diff --git a/test/integration/default/verify/controls/aws_iam_groups.rb b/test/aws/default/verify/controls/aws_iam_groups.rb similarity index 100% rename from test/integration/default/verify/controls/aws_iam_groups.rb rename to test/aws/default/verify/controls/aws_iam_groups.rb diff --git a/test/integration/default/verify/controls/aws_iam_policies.rb b/test/aws/default/verify/controls/aws_iam_policies.rb similarity index 100% rename from test/integration/default/verify/controls/aws_iam_policies.rb rename to test/aws/default/verify/controls/aws_iam_policies.rb diff --git a/test/integration/default/verify/controls/aws_iam_policy.rb b/test/aws/default/verify/controls/aws_iam_policy.rb similarity index 100% rename from test/integration/default/verify/controls/aws_iam_policy.rb rename to test/aws/default/verify/controls/aws_iam_policy.rb diff --git a/test/integration/default/verify/controls/aws_iam_role.rb b/test/aws/default/verify/controls/aws_iam_role.rb similarity index 100% rename from test/integration/default/verify/controls/aws_iam_role.rb rename to test/aws/default/verify/controls/aws_iam_role.rb diff --git a/test/integration/default/verify/controls/aws_iam_root_user.rb b/test/aws/default/verify/controls/aws_iam_root_user.rb similarity index 100% rename from test/integration/default/verify/controls/aws_iam_root_user.rb rename to test/aws/default/verify/controls/aws_iam_root_user.rb diff --git a/test/integration/default/verify/controls/aws_iam_user.rb b/test/aws/default/verify/controls/aws_iam_user.rb similarity index 100% rename from test/integration/default/verify/controls/aws_iam_user.rb rename to test/aws/default/verify/controls/aws_iam_user.rb diff --git a/test/integration/default/verify/controls/aws_iam_users.rb b/test/aws/default/verify/controls/aws_iam_users.rb similarity index 100% rename from test/integration/default/verify/controls/aws_iam_users.rb rename to test/aws/default/verify/controls/aws_iam_users.rb diff --git a/test/integration/default/verify/controls/aws_kms_keys.rb b/test/aws/default/verify/controls/aws_kms_keys.rb similarity index 100% rename from test/integration/default/verify/controls/aws_kms_keys.rb rename to test/aws/default/verify/controls/aws_kms_keys.rb diff --git a/test/integration/default/verify/controls/aws_s3_bucket.rb b/test/aws/default/verify/controls/aws_s3_bucket.rb similarity index 100% rename from test/integration/default/verify/controls/aws_s3_bucket.rb rename to test/aws/default/verify/controls/aws_s3_bucket.rb diff --git a/test/integration/default/verify/controls/aws_sns_topic.rb b/test/aws/default/verify/controls/aws_sns_topic.rb similarity index 100% rename from test/integration/default/verify/controls/aws_sns_topic.rb rename to test/aws/default/verify/controls/aws_sns_topic.rb diff --git a/test/integration/default/verify/controls/aws_vpc.rb b/test/aws/default/verify/controls/aws_vpc.rb similarity index 100% rename from test/integration/default/verify/controls/aws_vpc.rb rename to test/aws/default/verify/controls/aws_vpc.rb diff --git a/test/integration/default/verify/controls/aws_vpcs.rb b/test/aws/default/verify/controls/aws_vpcs.rb similarity index 100% rename from test/integration/default/verify/controls/aws_vpcs.rb rename to test/aws/default/verify/controls/aws_vpcs.rb diff --git a/test/integration/default/verify/inspec.yml b/test/aws/default/verify/inspec.yml similarity index 100% rename from test/integration/default/verify/inspec.yml rename to test/aws/default/verify/inspec.yml diff --git a/test/integration/minimal/build/aws.tf b/test/aws/minimal/build/aws.tf similarity index 100% rename from test/integration/minimal/build/aws.tf rename to test/aws/minimal/build/aws.tf diff --git a/test/integration/minimal/verify/controls/aws_iam_root_user.rb b/test/aws/minimal/verify/controls/aws_iam_root_user.rb similarity index 100% rename from test/integration/minimal/verify/controls/aws_iam_root_user.rb rename to test/aws/minimal/verify/controls/aws_iam_root_user.rb diff --git a/test/integration/minimal/verify/inspec.yml b/test/aws/minimal/verify/inspec.yml similarity index 100% rename from test/integration/minimal/verify/inspec.yml rename to test/aws/minimal/verify/inspec.yml From 532f42df892c8ca929393134703016520345e062 Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Thu, 8 Feb 2018 09:36:20 -0500 Subject: [PATCH 188/206] Move files to locations for core - inspec AWS PR 219 and other new resources Signed-off-by: Clinton Wolfe --- .../resource_support/aws/aws_backend_base.rb | 0 .../resource_support/aws/aws_plural_resource_mixin.rb | 0 .../resource_support/aws/aws_singular_resource_mixin.rb | 0 {libraries => lib/resources/aws}/aws_route_table.rb | 0 {libraries => lib/resources/aws}/aws_vpc_subnet.rb | 0 {libraries => lib/resources/aws}/aws_vpc_subnets.rb | 0 test/{integration => aws}/default/build/route_table.tf | 0 test/aws/default/verify/aws_iam_group.rb | 0 .../default/verify/controls/aws_iam_access_keys.rb | 0 .../default/verify/controls/aws_route_table.rb | 0 .../default/verify/controls/aws_vpc_subnet.rb | 0 .../default/verify/controls/aws_vpc_subnets.rb | 0 12 files changed, 0 insertions(+), 0 deletions(-) rename libraries/_aws_backend_base.rb => lib/resource_support/aws/aws_backend_base.rb (100%) rename libraries/_aws_plural_resource_mixin.rb => lib/resource_support/aws/aws_plural_resource_mixin.rb (100%) rename libraries/_aws_singular_resource_mixin.rb => lib/resource_support/aws/aws_singular_resource_mixin.rb (100%) rename {libraries => lib/resources/aws}/aws_route_table.rb (100%) rename {libraries => lib/resources/aws}/aws_vpc_subnet.rb (100%) rename {libraries => lib/resources/aws}/aws_vpc_subnets.rb (100%) rename test/{integration => aws}/default/build/route_table.tf (100%) delete mode 100644 test/aws/default/verify/aws_iam_group.rb rename test/{integration => aws}/default/verify/controls/aws_iam_access_keys.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_route_table.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_vpc_subnet.rb (100%) rename test/{integration => aws}/default/verify/controls/aws_vpc_subnets.rb (100%) diff --git a/libraries/_aws_backend_base.rb b/lib/resource_support/aws/aws_backend_base.rb similarity index 100% rename from libraries/_aws_backend_base.rb rename to lib/resource_support/aws/aws_backend_base.rb diff --git a/libraries/_aws_plural_resource_mixin.rb b/lib/resource_support/aws/aws_plural_resource_mixin.rb similarity index 100% rename from libraries/_aws_plural_resource_mixin.rb rename to lib/resource_support/aws/aws_plural_resource_mixin.rb diff --git a/libraries/_aws_singular_resource_mixin.rb b/lib/resource_support/aws/aws_singular_resource_mixin.rb similarity index 100% rename from libraries/_aws_singular_resource_mixin.rb rename to lib/resource_support/aws/aws_singular_resource_mixin.rb diff --git a/libraries/aws_route_table.rb b/lib/resources/aws/aws_route_table.rb similarity index 100% rename from libraries/aws_route_table.rb rename to lib/resources/aws/aws_route_table.rb diff --git a/libraries/aws_vpc_subnet.rb b/lib/resources/aws/aws_vpc_subnet.rb similarity index 100% rename from libraries/aws_vpc_subnet.rb rename to lib/resources/aws/aws_vpc_subnet.rb diff --git a/libraries/aws_vpc_subnets.rb b/lib/resources/aws/aws_vpc_subnets.rb similarity index 100% rename from libraries/aws_vpc_subnets.rb rename to lib/resources/aws/aws_vpc_subnets.rb diff --git a/test/integration/default/build/route_table.tf b/test/aws/default/build/route_table.tf similarity index 100% rename from test/integration/default/build/route_table.tf rename to test/aws/default/build/route_table.tf diff --git a/test/aws/default/verify/aws_iam_group.rb b/test/aws/default/verify/aws_iam_group.rb deleted file mode 100644 index e69de29bb..000000000 diff --git a/test/integration/default/verify/controls/aws_iam_access_keys.rb b/test/aws/default/verify/controls/aws_iam_access_keys.rb similarity index 100% rename from test/integration/default/verify/controls/aws_iam_access_keys.rb rename to test/aws/default/verify/controls/aws_iam_access_keys.rb diff --git a/test/integration/default/verify/controls/aws_route_table.rb b/test/aws/default/verify/controls/aws_route_table.rb similarity index 100% rename from test/integration/default/verify/controls/aws_route_table.rb rename to test/aws/default/verify/controls/aws_route_table.rb diff --git a/test/integration/default/verify/controls/aws_vpc_subnet.rb b/test/aws/default/verify/controls/aws_vpc_subnet.rb similarity index 100% rename from test/integration/default/verify/controls/aws_vpc_subnet.rb rename to test/aws/default/verify/controls/aws_vpc_subnet.rb diff --git a/test/integration/default/verify/controls/aws_vpc_subnets.rb b/test/aws/default/verify/controls/aws_vpc_subnets.rb similarity index 100% rename from test/integration/default/verify/controls/aws_vpc_subnets.rb rename to test/aws/default/verify/controls/aws_vpc_subnets.rb From ded1394e8b1df8a6135cea2bd4159c7d0a416afc Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Fri, 9 Feb 2018 10:22:56 -0500 Subject: [PATCH 189/206] Add resource support declarations for Azure Signed-off-by: Clinton Wolfe --- lib/resources/azure/azure_generic_resource.rb | 2 ++ lib/resources/azure/azure_resource_group.rb | 2 ++ lib/resources/azure/azure_virtual_machine.rb | 2 ++ lib/resources/azure/azure_virtual_machine_data_disk.rb | 2 ++ 4 files changed, 8 insertions(+) diff --git a/lib/resources/azure/azure_generic_resource.rb b/lib/resources/azure/azure_generic_resource.rb index 7479e2ca4..6fcb76232 100644 --- a/lib/resources/azure/azure_generic_resource.rb +++ b/lib/resources/azure/azure_generic_resource.rb @@ -11,6 +11,8 @@ module Inspec::Resources Inspec Resource to interrogate any Resource type in Azure ' + supports platform: 'azure' + attr_accessor :filter, :total, :counts, :name, :type, :location, :probes def initialize(opts = {}) diff --git a/lib/resources/azure/azure_resource_group.rb b/lib/resources/azure/azure_resource_group.rb index 6c7fc08f1..969c1c8aa 100644 --- a/lib/resources/azure/azure_resource_group.rb +++ b/lib/resources/azure/azure_resource_group.rb @@ -10,6 +10,8 @@ module Inspec::Resources Inspec Resource to get metadata about a specific Resource Group ' + supports platform: 'azure' + attr_reader :name, :location, :id, :total, :counts, :mapping # Constructor to get the resource group itself and perform some analysis on the diff --git a/lib/resources/azure/azure_virtual_machine.rb b/lib/resources/azure/azure_virtual_machine.rb index f969fb511..8215e2b86 100644 --- a/lib/resources/azure/azure_virtual_machine.rb +++ b/lib/resources/azure/azure_virtual_machine.rb @@ -10,6 +10,8 @@ module Inspec::Resources Inspec Resource to test Azure Virtual Machines ' + supports platform: 'azure' + # Constructor for the resource. This calls the parent constructor to # get the generic resource for the specified machine. This will provide # static methods that are documented diff --git a/lib/resources/azure/azure_virtual_machine_data_disk.rb b/lib/resources/azure/azure_virtual_machine_data_disk.rb index 8bffe7fe7..fdaca68a9 100644 --- a/lib/resources/azure/azure_virtual_machine_data_disk.rb +++ b/lib/resources/azure/azure_virtual_machine_data_disk.rb @@ -11,6 +11,8 @@ module Inspec::Resources Inspec Resource to ensure that the data disks attached to a machine are correct ' + supports platform: 'azure' + # Create a filter table so that tests on the disk can be performed filter = FilterTable.create filter.add_accessor(:where) From 469369fa32472edb63a48998ce94e6e366cf74e2 Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Tue, 13 Feb 2018 09:47:12 -0500 Subject: [PATCH 190/206] Capture ResourceNotFound exception from LMF (#2623) Signed-off-by: Clinton Wolfe --- lib/resources/aws/aws_cloudwatch_log_metric_filter.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/resources/aws/aws_cloudwatch_log_metric_filter.rb b/lib/resources/aws/aws_cloudwatch_log_metric_filter.rb index f5b5f8b2d..ca1315c52 100644 --- a/lib/resources/aws/aws_cloudwatch_log_metric_filter.rb +++ b/lib/resources/aws/aws_cloudwatch_log_metric_filter.rb @@ -45,7 +45,12 @@ EOX aws_search_criteria = {} aws_search_criteria[:filter_name] = filter_name if filter_name aws_search_criteria[:log_group_name] = log_group_name if log_group_name - aws_results = backend.describe_metric_filters(aws_search_criteria) + begin + aws_results = backend.describe_metric_filters(aws_search_criteria) + rescue Aws::CloudWatchLogs::Errors::ResourceNotFoundException + @exists = false + return + end # Then perform local filtering if pattern From ecbede56fa4f20be59e76799106f80b96daf8cbe Mon Sep 17 00:00:00 2001 From: Miah Johnson Date: Tue, 13 Feb 2018 09:39:47 -0800 Subject: [PATCH 191/206] Ruby 2.5.0 is released and stable we need to start testing (#2604) Add Ruby 2.5.0 to test matrix Signed-off-by: Miah Johnson --- .travis.yml | 2 ++ appveyor.yml | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 84048e250..62d723796 100644 --- a/.travis.yml +++ b/.travis.yml @@ -18,6 +18,7 @@ matrix: include: - rvm: 2.3.6 - rvm: 2.4.3 + - rvm: 2.5.0 - rvm: 2.4.3 script: bundle exec rake $SUITE env: SUITE="test:functional" @@ -66,6 +67,7 @@ matrix: script: ./support/ci/deploy_website_to_acceptance.sh allow_failures: + - rvm: 2.5.0 - env: - AFFECTED_DIRS="www" - secure: "jdzXUhP1o7RkfSikZLKgUcCIaKqLjqWa35dnxWnz7qAQ2draRKa7I7cXmUv76BZkW8HBUUH11dOi8YOVxPYPOzaqvcTCfqNqGVxsT9epgWa7rA8aXMXkECp548ry1rYJQpti9zpwsoe2GQyNPr9vNiWMiyj51CaABmZ6JzmFEEqlZc8vqpqWeqJvIqaibQGk7ByLKmi4R44fVwFKIG39RuxV+alc/G4nnQ2zmNTFuy8uFGs5EghQvRytzWY+s2AKtDiZ0YXYOII1Nl1unXNnNoQt9oI209ztlSm1+XOuTPelW6bEIx5i7OZFaSRPgJzWnkGN85C9nBE08L2az9Jz18/rYJF4fdVRttdGskueyYI21lh1FwlAg51ZG0RfLTYk2Pq+k4c+NO1cfmGcaXBwihfD5BWqrILU5HHkYszXCSmgl4hscC7/BS4Kgcq2z32JJwV8B+x4XngM0G4uzIn1Soia3lZXEKdnfVsxFDdMQ7FK60F3uQlq/44LRkZujRhqfAKOiz+0tsLexWzj7wK+DJY9Y00CUfh7xcxRxDxFNpOv1FWYFB9lUlaOt3HDHgUoksqbURiUzhOZZzTE/1MAtF2K6mbpME5CbN08J88L5JBlb+CX79XCzj30lNMeS0I/dCRQEmkygr2eJYxvRO2qsBNuphs4SWk8NZyS/llVZFI=" diff --git a/appveyor.yml b/appveyor.yml index f42a1af97..a92961450 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -6,7 +6,7 @@ environment: matrix: - ruby_version: "23" - ruby_version: "24" - + - ruby_version: "25" clone_folder: c:\projects\inspec clone_depth: 1 skip_tags: true @@ -15,6 +15,10 @@ branches: - master - release-2.0 +matrix: + allow_failures: + - ruby_version: "25" + cache: - vendor/bundle -> appveyor.yml From 9ebd807ea4ad0152ea03abf3abfa12101a7e7c91 Mon Sep 17 00:00:00 2001 From: Jerry Aldrich Date: Tue, 13 Feb 2018 11:42:16 -0600 Subject: [PATCH 192/206] http resource: Make remote worker the default (#2520) * http resource: Make remote worker the default Signed-off-by: Jerry Aldrich --- docs/resources/http.md.erb | 16 --------------- lib/resources/http.rb | 40 ++++++++++++++++++-------------------- 2 files changed, 19 insertions(+), 37 deletions(-) diff --git a/docs/resources/http.md.erb b/docs/resources/http.md.erb index e05ddd033..c4524c2b4 100644 --- a/docs/resources/http.md.erb +++ b/docs/resources/http.md.erb @@ -6,13 +6,6 @@ title: About the http Resource Use the `http` InSpec audit resource to test an http endpoint. -

In InSpec 1.40 and earlier, this resource always executes on the host on which inspec exec is run, even if you use the --target option to remotely scan a different host.
-
-Beginning with InSpec 1.41, you can enable the ability to have the HTTP test execute on the remote target, provided curl is available. See the "Local vs. Remote" section below.
-
-Executing the HTTP test on the remote target will be the default behavior in InSpec 2.0. -

-
## Syntax @@ -38,15 +31,6 @@ where * `ssl_verify` may be specified to enable or disable verification of SSL certificates (default to `true`)
-## Local vs. Remote - -Beginning with InSpec 1.41, you can enable the ability to have the HTTP test execute on the remote target: - - describe http('http://www.example.com', enable_remote_worker: true) do - its('body') { should cmp 'awesome' } - end - -In InSpec 2.0, the HTTP test will automatically execute remotely whenever InSpec is testing a remote node. ## Examples diff --git a/lib/resources/http.rb b/lib/resources/http.rb index b6f5d3f9c..d3adbf3d3 100644 --- a/lib/resources/http.rb +++ b/lib/resources/http.rb @@ -22,23 +22,27 @@ module Inspec::Resources its('Content-Length') { should cmp 258 } its('Content-Type') { should cmp 'text/html; charset=UTF-8' } end - - # properly execute the HTTP call on the scanned machine instead of the - # machine executing InSpec. This will be the default behavior in InSpec 2.0. - describe http('http://localhost:8080', enable_remote_worker: true) do - its('body') { should cmp 'local web server on target machine' } - end " def initialize(url, opts = {}) @url = url @opts = opts - if use_remote_worker? - return skip_resource 'curl is not available on the target machine' unless inspec.command('curl').exist? - @worker = Worker::Remote.new(inspec, http_method, url, opts) - else + # Prior to InSpec 2.0 the HTTP test had to be instructed to run on the + # remote target machine. This warning will be removed after a few months + # to give users an opportunity to remove the unused option from their + # profiles. + if opts.key?(:enable_remote_worker) && !inspec.local_transport? + warn 'Ignoring `enable_remote_worker` option, the `http` resource ', + 'remote worker is enabled by default for remote targets and ', + 'cannot be disabled' + end + + # Run locally if InSpec is ran locally and remotely if ran remotely + if inspec.local_transport? @worker = Worker::Local.new(http_method, url, opts) + else + @worker = Worker::Remote.new(inspec, http_method, url, opts) end end @@ -62,17 +66,6 @@ module Inspec::Resources "http #{http_method} on #{@url}" end - private - - def use_remote_worker? - return false if inspec.local_transport? - return true if @opts[:enable_remote_worker] - - warn "[DEPRECATION] #{self} will execute locally instead of the target machine. To execute remotely, add `enable_remote_worker: true`." - warn '[DEPRECATION] `enable_remote_worker: true` will be the default behavior in InSpec 2.0.' - false - end - class Worker class Base attr_reader :http_method, :opts, :url @@ -154,6 +147,11 @@ module Inspec::Resources attr_reader :inspec def initialize(inspec, http_method, url, opts) + unless inspec.command('curl').exist? + raise Inspec::Exceptions::ResourceSkipped, + 'curl is not available on the target machine' + end + @inspec = inspec super(http_method, url, opts) end From 86652992b0c2b967221dcf0459ba962d38662cde Mon Sep 17 00:00:00 2001 From: kagarmoe Date: Tue, 13 Feb 2018 11:30:07 -0800 Subject: [PATCH 193/206] AWS resource edits Signed-off-by: kagarmoe --- docs/resources/aws_cloudtrail_trail.md | 52 ++++++++++--------- docs/resources/aws_cloudtrail_trails.md | 51 +++++++++++------- docs/resources/aws_cloudwatch_alarm.md | 32 +++++++----- .../aws_cloudwatch_log_metric_filter.md | 32 ++++++++---- docs/resources/aws_ec2_instance.md | 7 +++ docs/resources/aws_iam_access_key.md | 52 +++++++++++-------- docs/resources/aws_iam_access_keys.md | 43 +++++++++------ docs/resources/aws_iam_group.md | 1 + docs/resources/aws_iam_groups.md | 3 ++ docs/resources/aws_iam_password_policy.md | 7 ++- docs/resources/aws_iam_policies.md | 38 +++++++++----- 11 files changed, 196 insertions(+), 122 deletions(-) diff --git a/docs/resources/aws_cloudtrail_trail.md b/docs/resources/aws_cloudtrail_trail.md index b956bc63d..997221ce0 100644 --- a/docs/resources/aws_cloudtrail_trail.md +++ b/docs/resources/aws_cloudtrail_trail.md @@ -1,5 +1,6 @@ --- title: About the aws_cloudtrail_trail Resource +platform: aws --- # aws_cloudtrail_trail @@ -28,32 +29,10 @@ An `aws_cloudtrail_trail` resource block identifies a trail by trail_name.
-## Examples - -The following examples show how to use this InSpec audit resource. - -### Test that the specified trail does exist - - describe aws_cloudtrail_trail('trail-name') do - it { should exist } - end - -### Test that the specified trail is encrypted using SSE-KMS - - describe aws_cloudtrail_trail('trail-name') do - it { should be_encrypted } - end - -### Test that the specified trail is a multi region trail - - describe aws_cloudtrail_trail('trail-name') do - it { should be_multi_region_trail } - end - -
- ## Properties +* `s3_bucket_name`, `trail_arn`, `cloud_watch_logs_role_arn`, `cloud_watch_logs_log_group_arn`, `kms_key_id`, `home_region`, + ### s3_bucket_name Specifies the name of the Amazon S3 bucket designated for publishing log files. @@ -102,6 +81,31 @@ Specifies the region in which the trail was created. its('home_region') { should include "us-east-1" } end +
+ +## Examples + +The following examples show how to use this InSpec audit resource. + +### Test that the specified trail does exist + + describe aws_cloudtrail_trail('trail-name') do + it { should exist } + end + +### Test that the specified trail is encrypted using SSE-KMS + + describe aws_cloudtrail_trail('trail-name') do + it { should be_encrypted } + end + +### Test that the specified trail is a multi region trail + + describe aws_cloudtrail_trail('trail-name') do + it { should be_multi_region_trail } + end + +
## Matchers diff --git a/docs/resources/aws_cloudtrail_trails.md b/docs/resources/aws_cloudtrail_trails.md index 04c405c33..9dbcccd59 100644 --- a/docs/resources/aws_cloudtrail_trails.md +++ b/docs/resources/aws_cloudtrail_trails.md @@ -1,5 +1,6 @@ --- title: About the aws_cloudtrail_trails Resource +platform: aws --- # aws_cloudtrail_trails @@ -23,26 +24,21 @@ An `aws_cloudtrail_trails` resource block collects a group of CloudTrail Trails
-## Examples - -The following examples show how to use this InSpec audit resource. - -As this is the initial release of `aws_cloudtrail_trails`, its limited functionality precludes examples. +## Properties +* `entries`, `names`, `trail_arns`
-## Matchers +## Examples -### exists +### entries -The control will pass if the filter returns at least one result. Use should_not if you expect zero matches. +Provides access to the raw results of the query. This can be useful for checking counts and other advanced operations. - # Verify that at least one CloudTrail Trail exists. - describe aws_cloudtrail_trails - it { should exist } - end - -## Properties + # Allow at most 100 CloudTrail Trails on the account + describe aws_cloudtrail_trails do + its('entries.count') { should be <= 100} + end ### names @@ -60,11 +56,26 @@ Provides a list of trail arns for all CloudTrail Trails in the AWS account. its('trail_arns') { should include('arn:aws:cloudtrail:us-east-1::trail/trail-1') } end -### entries +
-Provides access to the raw results of the query. This can be useful for checking counts and other advanced operations. +## Examples + +The following examples show how to use this InSpec audit resource. + +As this is the initial release of `aws_cloudtrail_trails`, its limited functionality precludes examples. + +
+ +## Matchers + +For a full list of available matchers please visit our [matchers page](https://www.inspec.io/docs/reference/matchers/). + +### exists + +The control will pass if the filter returns at least one result. Use should_not if you expect zero matches. + + # Verify that at least one CloudTrail Trail exists. + describe aws_cloudtrail_trails + it { should exist } + end - # Allow at most 100 CloudTrail Trails on the account - describe aws_cloudtrail_trails do - its('entries.count') { should be <= 100} - end diff --git a/docs/resources/aws_cloudwatch_alarm.md b/docs/resources/aws_cloudwatch_alarm.md index 77548c5b6..916ac72aa 100644 --- a/docs/resources/aws_cloudwatch_alarm.md +++ b/docs/resources/aws_cloudwatch_alarm.md @@ -1,5 +1,6 @@ --- title: About the aws_cloudwatch_alarm Resource +platform: aws --- # aws_cloudwatch_alarm @@ -24,6 +25,22 @@ An `aws_cloudwatch_alarm` resource block searches for a Cloudwatch Alarm, specif
+## Properties + +### alarm_actions + +`alarm_actions` returns a list of strings. Each string is the ARN of an action that will be taken should the alarm be triggered. + + # Ensure that the alarm has at least one action + describe aws_cloudwatch_alarm( + metric: 'bed-metric', + metric_namespace: 'my-metric-namespace', + ) do + its('alarm_actions') { should_not be_empty } + end + +
+ ## Examples The following examples show how to use this InSpec audit resource. @@ -41,6 +58,8 @@ The following examples show how to use this InSpec audit resource. ## Matchers +For a full list of available matchers please visit our [matchers page](https://www.inspec.io/docs/reference/matchers/). + ### exists The control will pass if a Cloudwatch Alarm could be found. Use should_not if you expect zero matches. @@ -61,16 +80,3 @@ The control will pass if a Cloudwatch Alarm could be found. Use should_not if yo it { should_not exist } end -## Properties - -### alarm_actions - -`alarm_actions` returns a list of strings. Each string is the ARN of an action that will be taken should the alarm be triggered. - - # Ensure that the alarm has at least one action - describe aws_cloudwatch_alarm( - metric: 'bed-metric', - metric_namespace: 'my-metric-namespace', - ) do - its('alarm_actions') { should_not be_empty } - end \ No newline at end of file diff --git a/docs/resources/aws_cloudwatch_log_metric_filter.md b/docs/resources/aws_cloudwatch_log_metric_filter.md index 5fe6c85af..009d4438a 100644 --- a/docs/resources/aws_cloudwatch_log_metric_filter.md +++ b/docs/resources/aws_cloudwatch_log_metric_filter.md @@ -1,5 +1,6 @@ --- title: About the aws_cloudwatch_log_metric_filter Resource +platform: aws --- # aws_cloudwatch_log_metric_filter @@ -66,19 +67,12 @@ The filter pattern used to match entries from the logs in the log group. it { should exist } end -## Matchers - -### exist - -Matches (i.e., passes the test) if the resource parameters (search criteria) were able to locate exactly one LMF. - - describe aws_cloudwatch_log_metric_filter( - log_group_name: 'my-log-group', - ) do - it { should exist } - end +
## Properties +* `filter_name`, `log_group_name`,` metric_name`, `metric_namespace`, `pattern` + +## Examples ### filter_name @@ -128,3 +122,19 @@ The pattern used to match entries from the logs in the log group. its('pattern') { should cmp 'ERROR' } end +
+ +## Matchers + +### exist + +Matches (i.e., passes the test) if the resource parameters (search criteria) were able to locate exactly one LMF. + + describe aws_cloudwatch_log_metric_filter( + log_group_name: 'my-log-group', + ) do + it { should exist } + end + + + diff --git a/docs/resources/aws_ec2_instance.md b/docs/resources/aws_ec2_instance.md index 8ef74e629..43088ae03 100644 --- a/docs/resources/aws_ec2_instance.md +++ b/docs/resources/aws_ec2_instance.md @@ -1,5 +1,6 @@ --- title: About the aws_ec2_instance Resource +platform: aws --- # aws_ec2_instance @@ -22,6 +23,12 @@ An `aws_ec2_instance` resource block declares the tests for a single AWS EC2 ins
+## Supported Properties + +* `architecture`, `client_token`, `image_id`,`instance_type`, `key_name`, `launch_time`,`private_ip_address`, `private_dns_name`, `public_dns_name`, `public_ip_address`, `root_device_type`, `root_device_name`, `subnet_id`, `tags`,`virtualization_type`, `vpc_id` + +
+ ## Examples The following examples show how to use this InSpec audit resource. diff --git a/docs/resources/aws_iam_access_key.md b/docs/resources/aws_iam_access_key.md index f4e8d2177..c2c3de031 100644 --- a/docs/resources/aws_iam_access_key.md +++ b/docs/resources/aws_iam_access_key.md @@ -1,5 +1,6 @@ --- title: About the aws_iam_access_key Resource +platform: aws --- # aws_iam_access_key @@ -35,7 +36,7 @@ Access keys are associated with IAM users, who may have zero, one or two access it { should be_active } end -You may also use both username and access key id to ensure a particular key is associated with a particular user. +You may also use both username and access key id to ensure that a particular key is associated with a particular user. describe aws_iam_access_key(username: 'roderick', access_key_id: 'AKIA12345678ABCD') do it { should exist } @@ -43,31 +44,13 @@ You may also use both username and access key id to ensure a particular key is a
-## Examples +## Properties -The following examples show how to use this InSpec audit resource. - -### Test that an IAM access key is not active - - describe aws_iam_access_key(username: 'username', id: 'access-key-id') do - it { should_not be_active } - end - -### Test that an IAM access key is older than one year - - describe aws_iam_access_key(username: 'username', id: 'access-key-id') do - its('create_date') { should be > Time.now - 365 * 86400 } - end - -### Test that an IAM access key has been used in the past 90 days - - describe aws_iam_access_key(username: 'username', id: 'access-key-id') do - its('last_used_date') { should be > Time.now - 90 * 86400 } - end +* `access_key_id`, `create_date`, `last_used_date`, `username`
-## Properties +## Examples ### access_key_id @@ -103,6 +86,31 @@ The IAM user that owns this key. its('username') { should cmp 'bob' } end +
+ +## Examples + +The following examples show how to use this InSpec audit resource. + +### Test that an IAM access key is not active + + describe aws_iam_access_key(username: 'username', id: 'access-key-id') do + it { should_not be_active } + end + +### Test that an IAM access key is older than one year + + describe aws_iam_access_key(username: 'username', id: 'access-key-id') do + its('create_date') { should be > Time.now - 365 * 86400 } + end + +### Test that an IAM access key has been used in the past 90 days + + describe aws_iam_access_key(username: 'username', id: 'access-key-id') do + its('last_used_date') { should be > Time.now - 90 * 86400 } + end + +
## Matchers diff --git a/docs/resources/aws_iam_access_keys.md b/docs/resources/aws_iam_access_keys.md index e1ff7303e..ec9d30cbf 100644 --- a/docs/resources/aws_iam_access_keys.md +++ b/docs/resources/aws_iam_access_keys.md @@ -46,22 +46,6 @@ The following examples show how to use this InSpec audit resource.
-## Matchers - -### exists - -The control will pass if the filter returns at least one result. Use should_not if you expect zero matches. - - # Sally should have at least one access key - describe aws_iam_access_keys.where(username: 'sally') do - it { should exist } - end - - # Don't let fred have access keys - describe aws_iam_access_keys.where(username: 'fred') do - it { should_not exist } - end - ## Filter Criteria ### active @@ -109,7 +93,6 @@ A true / false value indicating if the Access Key has ever been used, based on t its('access_key_ids') { should include('AKIA1234567890ABCDEF')} end - ### inactive A true / false value indicating if the Access Key has been marked Inactive in the AWS console. See also: `active`. @@ -163,8 +146,14 @@ The date at which the user was created. it { should_not exist } end +
+ ## Properties +* `access_key_ids`, `entries` + +## Examples + ### access_key_ids Provides a list of all access key IDs matched. @@ -181,3 +170,23 @@ Provides access to the raw results of the query. This can be useful for checkin describe aws_iam_access_keys do its('entries.count') { should be <= 100} end + +
+ +## Matchers + +This InSpec audit resource has the following special matchers. For a full list of available matchers (such as `exist`) please visit our [matchers page](https://www.inspec.io/docs/reference/matchers/). + +### exists + +The control will pass if the filter returns at least one result. Use should_not if you expect zero matches. + + # Sally should have at least one access key + describe aws_iam_access_keys.where(username: 'sally') do + it { should exist } + end + + # Don't let fred have access keys + describe aws_iam_access_keys.where(username: 'fred') do + it { should_not exist } + end diff --git a/docs/resources/aws_iam_group.md b/docs/resources/aws_iam_group.md index f5cd6f79d..8f619f810 100644 --- a/docs/resources/aws_iam_group.md +++ b/docs/resources/aws_iam_group.md @@ -1,5 +1,6 @@ --- title: About the aws_iam_group Resource +platform: aws --- # aws_iam_group diff --git a/docs/resources/aws_iam_groups.md b/docs/resources/aws_iam_groups.md index 0712079b6..bbe0c9d19 100644 --- a/docs/resources/aws_iam_groups.md +++ b/docs/resources/aws_iam_groups.md @@ -1,5 +1,6 @@ --- title: About the aws_iam_groups Resource +platform: aws --- # aws_iam_groups @@ -31,6 +32,8 @@ As this is the initial release of `aws_iam_groups`, its limited functionality pr ## Matchers +For a full list of available matchers please visit our [matchers page](https://www.inspec.io/docs/reference/matchers/). + ### exists The control will pass if the filter returns at least one result. Use should_not if you expect zero matches. diff --git a/docs/resources/aws_iam_password_policy.md b/docs/resources/aws_iam_password_policy.md index 57050ea25..88336c85a 100644 --- a/docs/resources/aws_iam_password_policy.md +++ b/docs/resources/aws_iam_password_policy.md @@ -1,5 +1,6 @@ --- title: About the aws_iam_password_policy Resource +platform: aws --- # aws_iam_password_policy @@ -18,6 +19,10 @@ An `aws_iam_password_policy` resource block takes no parameters, but uses severa
+## Supported Properties + +* `allows_users_to_change_password?`, `expires_passwords`, `max_password_age`, `minimum_password_length`, `number_of_passwords_to_remember`, `prevents_password_reuse?`, `requires_lowercase_characters` , `requires_uppercase_characters?`, `requires_numbers?`, `requires_symbols?` + ## Examples The following examples show how to use this InSpec audit resource. @@ -66,4 +71,4 @@ The following examples show how to use this InSpec audit resource. ## Matchers -For a full list of available matchers (such as `exist`) please visit our [matchers page](https://www.inspec.io/docs/reference/matchers/). +For a full list of available matchers please visit our [matchers page](https://www.inspec.io/docs/reference/matchers/). diff --git a/docs/resources/aws_iam_policies.md b/docs/resources/aws_iam_policies.md index c53f159b5..a4f5d2cc7 100644 --- a/docs/resources/aws_iam_policies.md +++ b/docs/resources/aws_iam_policies.md @@ -1,14 +1,13 @@ --- title: About the aws_iam_policies Resource +platform: aws --- # aws_iam_policies Use the `aws_iam_policies` InSpec audit resource to test properties of some or all AWS IAM Policies. -To test properties of all managed policies that are available in your AWS account, including your own customer-defined managed policies and all AWS managed policies. - -A policy is an entity in AWS that, when attached to an identity or resource, defines their permissions. AWS evaluates these policies when a principal, such as a user, makes a request. Permissions in the policies determine whether the request is allowed or denied. +An AWS IAM policy attaches to an identity or resource, defining their permissions. AWS evaluates these policies when a principal, such as a user, makes a request. Permissions defined in the identity or resource policies determines whether the request is allowed or denied. Each IAM Policy is uniquely identified by either its policy_name or arn. @@ -33,19 +32,14 @@ As this is the initial release of `aws_iam_policies`, its limited functionality
-## Matchers - -### exists - -The control will pass if the filter returns at least one result. Use should_not if you expect zero matches. - - # Verify that at least one IAM Policies exists. - describe aws_iam_policies - it { should exist } - end - ## Properties +* `arns`, `entries`, `policy_names` + +
+ +## Examples + ### policy_names Provides a list of policy names for all IAM Policies in the AWS account. @@ -70,3 +64,19 @@ Provides access to the raw results of the query. This can be useful for checkin describe aws_iam_policies do its('entries.count') { should be <= 100} end + +
+ +## Matchers + +For a full list of available matchers please visit our [matchers page](https://www.inspec.io/docs/reference/matchers/). + +### exists + +The control will pass if the filter returns at least one result. Use should_not if you expect zero matches. + + # Verify that at least one IAM Policies exists. + describe aws_iam_policies + it { should exist } + end + From c741e51484dbcbb8a58dda23a8ae9cfd9dcae61a Mon Sep 17 00:00:00 2001 From: kagarmoe Date: Tue, 13 Feb 2018 17:42:39 -0800 Subject: [PATCH 194/206] AWS resource edits Signed-off-by: kagarmoe --- docs/resources/aws_cloudtrail_trail.md | 54 ++++++----- docs/resources/aws_cloudtrail_trails.md | 22 ++--- docs/resources/aws_cloudwatch_alarm.md | 40 ++++---- .../aws_cloudwatch_log_metric_filter.md | 15 ++- docs/resources/aws_ec2_instance.md | 14 +-- docs/resources/aws_iam_access_key.md | 52 +++++----- docs/resources/aws_iam_access_keys.md | 14 ++- docs/resources/aws_iam_groups.md | 4 +- docs/resources/aws_iam_password_policy.md | 2 +- docs/resources/aws_iam_policies.md | 6 +- docs/resources/aws_iam_policy.md | 57 ++++++----- docs/resources/aws_iam_role.md | 39 +++++--- docs/resources/aws_iam_root_user.md | 3 +- docs/resources/aws_iam_user.md | 5 +- docs/resources/aws_iam_users.md | 16 +++- docs/resources/aws_kms_keys.md | 50 ++++++---- docs/resources/aws_route_table.md | 5 +- docs/resources/aws_s3_bucket.md | 27 +++--- docs/resources/aws_security_group.md | 10 +- docs/resources/aws_security_groups.md | 45 +++++---- docs/resources/aws_sns_topic.md | 33 ++++--- docs/resources/aws_subnet.md | 95 +++++++++++-------- docs/resources/aws_subnets.md | 70 ++++++++------ docs/resources/aws_vpc.md | 50 ++++++---- docs/resources/aws_vpcs.md | 11 ++- docs/resources/azure_virtual_machine.md | 6 +- .../azure_virtual_machine_datadisk.md | 2 +- 27 files changed, 434 insertions(+), 313 deletions(-) diff --git a/docs/resources/aws_cloudtrail_trail.md b/docs/resources/aws_cloudtrail_trail.md index 997221ce0..cb7cd5ae8 100644 --- a/docs/resources/aws_cloudtrail_trail.md +++ b/docs/resources/aws_cloudtrail_trail.md @@ -29,10 +29,38 @@ An `aws_cloudtrail_trail` resource block identifies a trail by trail_name.
+## Examples + +The following examples show how to use this InSpec audit resource. + +### Test that the specified trail does exist + + describe aws_cloudtrail_trail('trail-name') do + it { should exist } + end + +### Test that the specified trail is encrypted using SSE-KMS + + describe aws_cloudtrail_trail('trail-name') do + it { should be_encrypted } + end + +### Test that the specified trail is a multi region trail + + describe aws_cloudtrail_trail('trail-name') do + it { should be_multi_region_trail } + end + +
+ ## Properties * `s3_bucket_name`, `trail_arn`, `cloud_watch_logs_role_arn`, `cloud_watch_logs_log_group_arn`, `kms_key_id`, `home_region`, +
+ +## Property Examples + ### s3_bucket_name Specifies the name of the Amazon S3 bucket designated for publishing log files. @@ -83,33 +111,9 @@ Specifies the region in which the trail was created.
-## Examples - -The following examples show how to use this InSpec audit resource. - -### Test that the specified trail does exist - - describe aws_cloudtrail_trail('trail-name') do - it { should exist } - end - -### Test that the specified trail is encrypted using SSE-KMS - - describe aws_cloudtrail_trail('trail-name') do - it { should be_encrypted } - end - -### Test that the specified trail is a multi region trail - - describe aws_cloudtrail_trail('trail-name') do - it { should be_multi_region_trail } - end - -
- ## Matchers -This InSpec audit resource has the following special matchers. For a full list of available matchers (such as `exist`) please visit our [matchers page](https://www.inspec.io/docs/reference/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/). ### be_multi_region_trail diff --git a/docs/resources/aws_cloudtrail_trails.md b/docs/resources/aws_cloudtrail_trails.md index 9dbcccd59..7cf4cb3a3 100644 --- a/docs/resources/aws_cloudtrail_trails.md +++ b/docs/resources/aws_cloudtrail_trails.md @@ -24,12 +24,20 @@ An `aws_cloudtrail_trails` resource block collects a group of CloudTrail Trails
+## Examples + +The following examples show how to use this InSpec audit resource. + +As this is the initial release of `aws_cloudtrail_trails`, its limited functionality precludes examples. + +
+ ## Properties * `entries`, `names`, `trail_arns`
-## Examples +## Property Examples ### entries @@ -58,21 +66,13 @@ Provides a list of trail arns for all CloudTrail Trails in the AWS account.
-## Examples - -The following examples show how to use this InSpec audit resource. - -As this is the initial release of `aws_cloudtrail_trails`, its limited functionality precludes examples. - -
- ## Matchers -For a full list of available matchers please visit our [matchers page](https://www.inspec.io/docs/reference/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/). ### exists -The control will pass if the filter returns at least one result. Use should_not if you expect zero matches. +The control will pass if the filter returns at least one result. Use `should_not` if you expect zero matches. # Verify that at least one CloudTrail Trail exists. describe aws_cloudtrail_trails diff --git a/docs/resources/aws_cloudwatch_alarm.md b/docs/resources/aws_cloudwatch_alarm.md index 916ac72aa..91979947f 100644 --- a/docs/resources/aws_cloudwatch_alarm.md +++ b/docs/resources/aws_cloudwatch_alarm.md @@ -25,22 +25,6 @@ An `aws_cloudwatch_alarm` resource block searches for a Cloudwatch Alarm, specif
-## Properties - -### alarm_actions - -`alarm_actions` returns a list of strings. Each string is the ARN of an action that will be taken should the alarm be triggered. - - # Ensure that the alarm has at least one action - describe aws_cloudwatch_alarm( - metric: 'bed-metric', - metric_namespace: 'my-metric-namespace', - ) do - its('alarm_actions') { should_not be_empty } - end - -
- ## Examples The following examples show how to use this InSpec audit resource. @@ -56,13 +40,33 @@ The following examples show how to use this InSpec audit resource.
+## Properties + +* `alarm_actions` + +## Property Examples + +### alarm_actions + +`alarm_actions` returns a list of strings. Each string is the ARN of an action that will be taken should the alarm be triggered. + + # Ensure that the alarm has at least one action + describe aws_cloudwatch_alarm( + metric: 'bed-metric', + metric_namespace: 'my-metric-namespace', + ) do + its('alarm_actions') { should_not be_empty } + end + +
+ ## Matchers -For a full list of available matchers please visit our [matchers page](https://www.inspec.io/docs/reference/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/). ### exists -The control will pass if a Cloudwatch Alarm could be found. Use should_not if you expect zero matches. +The control will pass if a Cloudwatch Alarm could be found. Use `should_not` if you expect zero matches. # Expect good metric describe aws_cloudwatch_alarm( diff --git a/docs/resources/aws_cloudwatch_log_metric_filter.md b/docs/resources/aws_cloudwatch_log_metric_filter.md index 009d4438a..9dcf1c283 100644 --- a/docs/resources/aws_cloudwatch_log_metric_filter.md +++ b/docs/resources/aws_cloudwatch_log_metric_filter.md @@ -35,7 +35,13 @@ An `aws_cloudwatch_log_metric_filter` resource block searches for an LMF, specif
-## Resource Parameters +## Filter Attributes + +* `filter_name`, `log_group_name`, `pattern` + +
+ +## Filter Examples ### filter_name @@ -70,9 +76,12 @@ The filter pattern used to match entries from the logs in the log group.
## Properties + * `filter_name`, `log_group_name`,` metric_name`, `metric_namespace`, `pattern` -## Examples +
+ +## Property Examples ### filter_name @@ -126,6 +135,8 @@ The pattern used to match entries from the logs in the log group. ## 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/). + ### exist Matches (i.e., passes the test) if the resource parameters (search criteria) were able to locate exactly one LMF. diff --git a/docs/resources/aws_ec2_instance.md b/docs/resources/aws_ec2_instance.md index 43088ae03..aec53f8a2 100644 --- a/docs/resources/aws_ec2_instance.md +++ b/docs/resources/aws_ec2_instance.md @@ -23,12 +23,6 @@ An `aws_ec2_instance` resource block declares the tests for a single AWS EC2 ins
-## Supported Properties - -* `architecture`, `client_token`, `image_id`,`instance_type`, `key_name`, `launch_time`,`private_ip_address`, `private_dns_name`, `public_dns_name`, `public_ip_address`, `root_device_type`, `root_device_name`, `subnet_id`, `tags`,`virtualization_type`, `vpc_id` - -
- ## Examples The following examples show how to use this InSpec audit resource. @@ -59,9 +53,15 @@ The following examples show how to use this InSpec audit resource.
+## Properties + +* `architecture`, `client_token`, `image_id`,`instance_type`, `key_name`, `launch_time`,`private_ip_address`, `private_dns_name`, `public_dns_name`, `public_ip_address`, `root_device_type`, `root_device_name`, `subnet_id`, `tags`,`virtualization_type`, `vpc_id` + +
+ ## Matchers -This InSpec audit resource has the following special matchers. For a full list of available matchers (such as `exist`) please visit our [matchers page](https://www.inspec.io/docs/reference/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/). ### be_pending diff --git a/docs/resources/aws_iam_access_key.md b/docs/resources/aws_iam_access_key.md index c2c3de031..fe6fb17ec 100644 --- a/docs/resources/aws_iam_access_key.md +++ b/docs/resources/aws_iam_access_key.md @@ -44,13 +44,37 @@ You may also use both username and access key id to ensure that a particular key
+## Examples + +The following examples show how to use this InSpec audit resource. + +### Test that an IAM access key is not active + + describe aws_iam_access_key(username: 'username', id: 'access-key-id') do + it { should_not be_active } + end + +### Test that an IAM access key is older than one year + + describe aws_iam_access_key(username: 'username', id: 'access-key-id') do + its('create_date') { should be > Time.now - 365 * 86400 } + end + +### Test that an IAM access key has been used in the past 90 days + + describe aws_iam_access_key(username: 'username', id: 'access-key-id') do + its('last_used_date') { should be > Time.now - 90 * 86400 } + end + +
+ ## Properties * `access_key_id`, `create_date`, `last_used_date`, `username`
-## Examples +## Property Examples ### access_key_id @@ -88,33 +112,9 @@ The IAM user that owns this key.
-## Examples - -The following examples show how to use this InSpec audit resource. - -### Test that an IAM access key is not active - - describe aws_iam_access_key(username: 'username', id: 'access-key-id') do - it { should_not be_active } - end - -### Test that an IAM access key is older than one year - - describe aws_iam_access_key(username: 'username', id: 'access-key-id') do - its('create_date') { should be > Time.now - 365 * 86400 } - end - -### Test that an IAM access key has been used in the past 90 days - - describe aws_iam_access_key(username: 'username', id: 'access-key-id') do - its('last_used_date') { should be > Time.now - 90 * 86400 } - end - -
- ## Matchers -This InSpec audit resource has the following special matchers. For a full list of available matchers (such as `exist`) please visit our [matchers page](https://www.inspec.io/docs/reference/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/). ### be_active diff --git a/docs/resources/aws_iam_access_keys.md b/docs/resources/aws_iam_access_keys.md index ec9d30cbf..85ba23939 100644 --- a/docs/resources/aws_iam_access_keys.md +++ b/docs/resources/aws_iam_access_keys.md @@ -1,5 +1,6 @@ --- title: About the aws_iam_access_keys Resource +platform: aws --- # aws_iam_access_keys @@ -47,12 +48,17 @@ The following examples show how to use this InSpec audit resource.
## Filter Criteria +* `active`, `create_date`, `created_days_ago`, `created_hours_ago`, `created_with_user`, `ever_used`, `inactive`, `last_used_date`, `last_used_hours_ago`, `last_used_days_ago`, `never_used`, `user_created_date` + +
+ +## Filter Examples ### active A true / false value indicating if an Access Key is currently "Active" (the normal state) in the AWS console. See also: `inactive`. - # Check whether a particular key is enabled + # Check if a particular key is enabled describe aws_iam_access_keys.where { active } do its('access_key_ids') { should include('AKIA1234567890ABCDEF')} end @@ -152,7 +158,7 @@ The date at which the user was created. * `access_key_ids`, `entries` -## Examples +## Property Examples ### access_key_ids @@ -175,11 +181,11 @@ Provides access to the raw results of the query. This can be useful for checkin ## Matchers -This InSpec audit resource has the following special matchers. For a full list of available matchers (such as `exist`) please visit our [matchers page](https://www.inspec.io/docs/reference/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/). ### exists -The control will pass if the filter returns at least one result. Use should_not if you expect zero matches. +The control will pass if the filter returns at least one result. Use `should_not` if you expect zero matches. # Sally should have at least one access key describe aws_iam_access_keys.where(username: 'sally') do diff --git a/docs/resources/aws_iam_groups.md b/docs/resources/aws_iam_groups.md index bbe0c9d19..4fdd0b28b 100644 --- a/docs/resources/aws_iam_groups.md +++ b/docs/resources/aws_iam_groups.md @@ -15,7 +15,7 @@ To test properties of a single group, use the `aws_iam_group` resource. An `aws_iam_groups` resource block uses an optional filter to select a collection of IAM groups and then tests that collection. - # The control will pass if the filter returns at least one result. Use should_not if you expect zero matches. + # The control will pass if the filter returns at least one result. Use `should_not` if you expect zero matches. describe aws_iam_groups do it { should exist } end @@ -36,7 +36,7 @@ For a full list of available matchers please visit our [matchers page](https://w ### exists -The control will pass if the filter returns at least one result. Use should_not if you expect zero matches. +The control will pass if the filter returns at least one result. Use `should_not` if you expect zero matches. describe aws_iam_groups it { should exist } diff --git a/docs/resources/aws_iam_password_policy.md b/docs/resources/aws_iam_password_policy.md index 88336c85a..1ace36c46 100644 --- a/docs/resources/aws_iam_password_policy.md +++ b/docs/resources/aws_iam_password_policy.md @@ -19,7 +19,7 @@ An `aws_iam_password_policy` resource block takes no parameters, but uses severa
-## Supported Properties +## Properties * `allows_users_to_change_password?`, `expires_passwords`, `max_password_age`, `minimum_password_length`, `number_of_passwords_to_remember`, `prevents_password_reuse?`, `requires_lowercase_characters` , `requires_uppercase_characters?`, `requires_numbers?`, `requires_symbols?` diff --git a/docs/resources/aws_iam_policies.md b/docs/resources/aws_iam_policies.md index a4f5d2cc7..08ba7851d 100644 --- a/docs/resources/aws_iam_policies.md +++ b/docs/resources/aws_iam_policies.md @@ -7,7 +7,7 @@ platform: aws Use the `aws_iam_policies` InSpec audit resource to test properties of some or all AWS IAM Policies. -An AWS IAM policy attaches to an identity or resource, defining their permissions. AWS evaluates these policies when a principal, such as a user, makes a request. Permissions defined in the identity or resource policies determines whether the request is allowed or denied. +A policy is an entity in AWS that, when attached to an identity or resource, defines their permissions. AWS evaluates these policies when a principal, such as a user, makes a request. Permissions in the policies determine if the request is allowed or denied. Each IAM Policy is uniquely identified by either its policy_name or arn. @@ -38,7 +38,7 @@ As this is the initial release of `aws_iam_policies`, its limited functionality
-## Examples +## Property Examples ### policy_names @@ -73,7 +73,7 @@ For a full list of available matchers please visit our [matchers page](https://w ### exists -The control will pass if the filter returns at least one result. Use should_not if you expect zero matches. +The control will pass if the filter returns at least one result. Use `should_not` if you expect zero matches. # Verify that at least one IAM Policies exists. describe aws_iam_policies diff --git a/docs/resources/aws_iam_policy.md b/docs/resources/aws_iam_policy.md index c837eef03..626e3c7d7 100644 --- a/docs/resources/aws_iam_policy.md +++ b/docs/resources/aws_iam_policy.md @@ -1,12 +1,13 @@ --- title: About the aws_iam_policy Resource +platform: aws --- # aws_iam_policy Use the `aws_iam_policy` InSpec audit resource to test properties of a single managed AWS IAM Policy. -A policy is an entity in AWS that, when attached to an identity or resource, defines their permissions. AWS evaluates these policies when a principal, such as a user, makes a request. Permissions in the policies determine whether the request is allowed or denied. +A policy is an entity in AWS that, when attached to an identity or resource, defines their permissions. AWS evaluates these policies when a principal, such as a user, makes a request. Permissions in the policies determine if the request is allowed or denied. Each IAM Policy is uniquely identified by either its policy_name or arn. @@ -53,6 +54,10 @@ The following examples show how to use this InSpec audit resource. ## Properties +* `arn`, `attachment_count`, `attached_groups`, `attached_roles`,`attached_users`, `default_version_id` + +## Property Examples + ### arn "The ARN identifier of the specified policy. An ARN uniquely identifies the policy within AWS." @@ -61,14 +66,6 @@ The following examples show how to use this InSpec audit resource. its('arn') { should cmp "arn:aws:iam::aws:policy/AWSSupportAccess" } end -### default_version_id - -The default_version_id value of the specified policy. - - describe aws_iam_policy('AWSSupportAccess') do - its('default_version_id') { should cmp "v1" } - end - ### attachment_count The count of attached entities for the specified policy. @@ -77,14 +74,6 @@ The count of attached entities for the specified policy. its('attachment_count') { should cmp 1 } end -### attached_users - -The list of usernames of the users attached to the policy. - - describe aws_iam_policy('AWSSupportAccess') do - its('attached_users') { should include "test-user" } - end - ### attached_groups The list of groupnames of the groups attached to the policy. @@ -101,9 +90,26 @@ The list of rolenames of the roles attached to the policy. its('attached_roles') { should include "test-role" } end +### attached_users + +The list of usernames of the users attached to the policy. + + describe aws_iam_policy('AWSSupportAccess') do + its('attached_users') { should include "test-user" } + end + +### default_version_id + +The default_version_id value of the specified policy. + + describe aws_iam_policy('AWSSupportAccess') do + its('default_version_id') { should cmp "v1" } + end + + ## Matchers -This InSpec audit resource has the following special matchers. For a full list of available matchers (such as `exist`) please visit our [matchers page](https://www.inspec.io/docs/reference/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/). ### be_attached @@ -113,6 +119,14 @@ The test will pass if the identified policy is attached to at least one IAM user it { should be_attached } end +### be_attached_to_group(GROUPNAME) + +The test will pass if the identified policy attached the specified group. + + describe aws_iam_policy('AWSSupportAccess') do + it { should be_attached_to_group(GROUPNAME) } + end + ### be_attached_to_user(USERNAME) The test will pass if the identified policy attached the specified user. @@ -130,10 +144,3 @@ The test will pass if the identified policy attached the specified role. end -### be_attached_to_group(GROUPNAME) - -The test will pass if the identified policy attached the specified group. - - describe aws_iam_policy('AWSSupportAccess') do - it { should be_attached_to_group(GROUPNAME) } - end diff --git a/docs/resources/aws_iam_role.md b/docs/resources/aws_iam_role.md index 57756d4a9..a1c33931f 100644 --- a/docs/resources/aws_iam_role.md +++ b/docs/resources/aws_iam_role.md @@ -1,10 +1,11 @@ --- title: About the aws_iam_role Resource +platform: aws --- # aws_iam_role -Use the `aws_iam_role` InSpec audit resource to test properties of a single IAM Role. A Role is a collection of permissions that may be temporarily assumed by a user, EC2 Instance, Lambda Function, or certain other resources. +Use the `aws_iam_role` InSpec audit resource to test properties of a single IAM Role. A Role is a collection of permissions that may be temporarily assumed by a user, EC2 Instance, Lambda Function, or certain other resources.
@@ -15,11 +16,13 @@ Use the `aws_iam_role` InSpec audit resource to test properties of a single IAM it { should exist } end +
+ ## Resource Parameters ### role_name -This resource expects a single parameter that uniquely identifes the IAM Role, the Role Name. You may pass it as a string, or as the value in a hash: +This resource expects a single parameter that uniquely identifies the IAM Role, the Role Name. You may pass it as a string, or as the value in a hash: describe aws_iam_role('my-role') do it { should exist } @@ -29,19 +32,7 @@ This resource expects a single parameter that uniquely identifes the IAM Role, t it { should exist } end -## Matchers - -### exist - -Indicates that the Role Name provided was found. Use should_not to test for IAM Roles that should not exist. - - describe aws_iam_role('should-be-there') do - it { should exist } - end - - describe aws_iam_role('should-not-be-there') do - it { should_not exist } - end +
## Properties @@ -52,3 +43,21 @@ A textual description of the IAM Role. describe aws_iam_role('my-role') do its('description') { should be('Our most important Role')} end + +
+ +## Matchers + +### exist + +Indicates that the Role Name provided was found. Use should_not to test for IAM Roles that should not exist. + + describe aws_iam_role('should-be-there') do + it { should exist } + end + + describe aws_iam_role('should-not-be-there') do + it { should_not exist } + end + + diff --git a/docs/resources/aws_iam_root_user.md b/docs/resources/aws_iam_root_user.md index 2b31b1503..a959592c2 100644 --- a/docs/resources/aws_iam_root_user.md +++ b/docs/resources/aws_iam_root_user.md @@ -1,5 +1,6 @@ --- title: About the aws_iam_root_user Resource +platform: aws --- # aws_iam_root_user @@ -42,7 +43,7 @@ The following examples show how to use this InSpec audit resource. ## Matchers -This InSpec audit resource has the following special matchers. For a full list of available matchers (such as `exist`) please visit our [matchers page](https://www.inspec.io/docs/reference/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/). ### have_mfa_enabled diff --git a/docs/resources/aws_iam_user.md b/docs/resources/aws_iam_user.md index 16319b666..71332a3b9 100644 --- a/docs/resources/aws_iam_user.md +++ b/docs/resources/aws_iam_user.md @@ -1,12 +1,13 @@ --- title: About the aws_iam_user Resource +platform: aws --- # aws_iam_user Use the `aws_iam_user` InSpec audit resource to test properties of a single AWS IAM user. -To test properties of all or multiple users, use the `aws_iam_users` resource. +To test properties of more than one user, use the `aws_iam_users` resource. To test properties of the special AWS root user (which owns the account), use the `aws_iam_root_user` resource. @@ -48,7 +49,7 @@ The following examples show how to use this InSpec audit resource. ## Matchers -This InSpec audit resource has the following special matchers. For a full list of available matchers (such as `exist`) please visit our [matchers page](https://www.inspec.io/docs/reference/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/). ### have_console_password diff --git a/docs/resources/aws_iam_users.md b/docs/resources/aws_iam_users.md index ff24be030..4c991440a 100644 --- a/docs/resources/aws_iam_users.md +++ b/docs/resources/aws_iam_users.md @@ -1,5 +1,6 @@ --- title: About the aws_iam_users Resource +platform: aws --- # aws_iam_users @@ -22,6 +23,10 @@ An `aws_iam_users` resource block users a filter to select a group of users and
+## Filter Criteria + +*`has_mfa_enabled`, `has_console_password`, `password_ever_used?`, `password_never_used?`, `password_last_used_days_ago`, `username` + ## Examples The following examples show how to use this InSpec audit resource. @@ -38,7 +43,7 @@ The following examples show how to use this InSpec audit resource. it { should exist } end -### Test that all users that have a console password have Multi-Factor Authentication enabled +### Test that all users who have a console password have Multi-Factor Authentication enabled console_users_without_mfa = aws_iam_users .where(has_console_password?: true) @@ -48,7 +53,7 @@ The following examples show how to use this InSpec audit resource. it { should_not exist } end -### Test that all users that have a console password should have used it at-least once +### Test that all users who have a console password have used it at least once console_users_with_unused_password = aws_iam_users .where(has_console_password?: true) @@ -58,7 +63,7 @@ The following examples show how to use this InSpec audit resource. it { should_not exist } end -### Test that atleast one user exists with console password and used it atleast once +### Test that at least one user exists who has a console password and has used it at least once console_users_with_used_password = aws_iam_users .where(has_console_password?: true) @@ -69,7 +74,7 @@ The following examples show how to use this InSpec audit resource. end -### Test that users with used passwords longer that 90 days should not exists +### Test that users with passwords that have not been used for 90 days do not describe aws_iam_users.where { password_last_used_days_ago > 90 } do it { should_not exist } @@ -79,4 +84,5 @@ The following examples show how to use this InSpec audit resource. ## Matchers -This InSpec audit resource has no specific matchers. \ No newline at end of file +This InSpec audit resource has no specific matchers. +For a full list of available matchers please visit our [matchers page](https://www.inspec.io/docs/reference/matchers/). \ No newline at end of file diff --git a/docs/resources/aws_kms_keys.md b/docs/resources/aws_kms_keys.md index e7519bf95..55a66cc6b 100644 --- a/docs/resources/aws_kms_keys.md +++ b/docs/resources/aws_kms_keys.md @@ -1,12 +1,15 @@ --- title: About the aws_kms_keys Resource +platform: aws --- # aws_kms_keys Use the `aws_kms_keys` InSpec audit resource to test properties of some or all AWS KMS Keys. -AWS Key Management Service (KMS) is a managed service that makes it easy for you to create and control the encryption keys used to encrypt your data, and uses Hardware Security Modules (HSMs) to protect the security of your keys. AWS Key Management Service is integrated with several other AWS services to help you protect the data you store with these services. +AWS Key Management Service (KMS) is a managed service that makes creating and controlling your encryption keys for your data easier. KMS uses Hardware Security Modules (HSMs) to protect the security of your keys. + +AWS Key Management Service is integrated with several other AWS services to help you protect the data you store with these services. Each AWS KMS Key is uniquely identified by its key-id or key-arn. @@ -31,19 +34,23 @@ As this is the initial release of `aws_kms_keys`, its limited functionality prec
-## Matchers - -### exists - -The control will pass if the filter returns at least one result. Use should_not if you expect zero matches. - - # Verify that at least one KMS Key exists. - describe aws_kms_keys - it { should exist } - end - ## Properties +* `entries`, `key_arns`, `key_ids` + +
+ +## Property Examples + +### entries + +Provides access to the raw results of a query. This can be useful for checking counts and other advanced operations. + + # Allow at most 100 KMS Keys on the account + describe aws_kms_keys do + its('entries.count') { should be <= 100} + end + ### key_arns Provides a list of key arns for all KMS Keys in the AWS account. @@ -60,11 +67,18 @@ Provides a list of key ids for all KMS Keys in the AWS account. its('key_ids') { should include('fd7e608b-f435-4186-b8b5-111111111111') } end -### entries +
-Provides access to the raw results of the query. This can be useful for checking counts and other advanced operations. +## Matchers + +For a full list of available matchers please visit our [matchers page](https://www.inspec.io/docs/reference/matchers/). + +### exists + +The control will pass if the filter returns at least one result. Use `should_not` if you expect zero matches. + + # Verify that at least one KMS Key exists. + describe aws_kms_keys + it { should exist } + end - # Allow at most 100 KMS Keys on the account - describe aws_kms_keys do - its('entries.count') { should be <= 100} - end diff --git a/docs/resources/aws_route_table.md b/docs/resources/aws_route_table.md index 4fa2c553b..b9ddd109f 100644 --- a/docs/resources/aws_route_table.md +++ b/docs/resources/aws_route_table.md @@ -1,5 +1,6 @@ --- title: About the aws_route_table Resource +platform: aws --- # aws_route_table @@ -19,7 +20,7 @@ Use the `aws_route_table` InSpec audit resource to test properties of a single R ### route_table_id -This resource expects a single parameter that uniquely identifes the Route Table. You may pass it as a string, or as the value in a hash: +This resource expects a single parameter that uniquely identifies the Route Table. You may pass it as a string, or as the value in a hash: describe aws_route_table('rtb-123abcde') do it { should exist } @@ -31,6 +32,8 @@ This resource expects a single parameter that uniquely identifes the Route Table ## Matchers +For a full list of available matchers please visit our [matchers page](https://www.inspec.io/docs/reference/matchers/). + ### exist Indicates that the Route Table provided was found. Use should_not to test for Route Tables that should not exist. diff --git a/docs/resources/aws_s3_bucket.md b/docs/resources/aws_s3_bucket.md index 2080dab8e..247a5ad7f 100644 --- a/docs/resources/aws_s3_bucket.md +++ b/docs/resources/aws_s3_bucket.md @@ -1,5 +1,6 @@ --- title: About the aws_s3_bucket Resource +platform: aws --- # aws_s3_bucket @@ -12,11 +13,13 @@ To test properties of a multiple S3 buckets, use the `aws_s3_buckets` resource. ## Limitations -S3 bucket security is a complex matter. For details on how AWS evaluates requests for access, please see [the AWS documentation](https://docs.aws.amazon.com/AmazonS3/latest/dev/how-s3-evaluates-access-control.html). S3 buckets and the objects they contain support three different types of access control: bucket ACLs, bucket policies, and object ACLs. +S3 bucket security is a complex matter. For details on how AWS evaluates requests for access, please see [the AWS documentation](https://docs.aws.amazon.com/AmazonS3/latest/dev/how-s3-evaluates-access-control.html). S3 buckets and the objects they contain support three different types of access control: bucket ACLs, bucket policies, and object ACLs. As of January 2018, this resource supports evaluating bucket ACLs and bucket policies. We do not support evaluating object ACLs because it introduces scalability concerns in the AWS API; we recommend using AWS mechanisms such as CloudTrail and Config to detect insecure object ACLs. -In particular, users of the `be_public` matcher should carefully examine the conditions under which the matcher will detect an insecure bucket. See the `be_public` section under the Matchers section below. +In particular, users of the `be_public` matcher should carefully examine the conditions under which the matcher will detect an insecure bucket. See the `be_public` section under the Matchers section below. + +
## Syntax @@ -37,19 +40,19 @@ An `aws_s3_bucket` resource block declares a bucket by name, and then lists test The following examples show how to use this InSpec audit resource. -### Test a bucket's bucket-level ACL +### Test the bucket-level ACL describe aws_s3_bucket('test_bucket') do its('bucket_acl.count') { should eq 1 } end -### Check to see if a bucket has a bucket policy +### Check if a bucket has a bucket policy describe aws_s3_bucket('test_bucket') do its('bucket_policy') { should be_empty } end -### Check to see if a bucket appears to be exposed to the public +### Check if a bucket appears to be exposed to the public # See Limitations section above describe aws_s3_bucket('test_bucket') do @@ -57,7 +60,7 @@ The following examples show how to use this InSpec audit resource. end
-## Supported Properties +## Properties ### region @@ -72,9 +75,9 @@ The `region` property identifies the AWS Region in which the S3 bucket is locate ### bucket_acl -The `bucket_acl` property is a low-level property that lists the individual Bucket ACL grants that are in effect on the bucket. Other higher-level properties, such as be\_public, are more concise and easier to use. You can use the `bucket_acl` property to investigate which grants are in effect, causing be\_public to fail. +The `bucket_acl` property is a low-level property that lists the individual Bucket ACL grants in effect on the bucket. Other higher-level properties, such as be\_public, are more concise and easier to use. You can use the `bucket_acl` property to investigate which grants are in effect, causing be\_public to fail. -The value of bucket_acl is an Array of simple objects. Each object has a `permission` property and a `grantee` property. The `permission` property will be a string such as 'READ', 'WRITE' etc (See the [AWS documentation](https://docs.aws.amazon.com/sdkforruby/api/Aws/S3/Client.html#get_bucket_acl-instance_method) for a full list). The `grantee` property contains sub-properties, such as `type` and `uri`. +The value of bucket_acl is an array of simple objects. Each object has a `permission` property and a `grantee` property. The `permission` property will be a string such as 'READ', 'WRITE' etc (See the [AWS documentation](https://docs.aws.amazon.com/sdkforruby/api/Aws/S3/Client.html#get_bucket_acl-instance_method) for a full list). The `grantee` property contains sub-properties, such as `type` and `uri`. bucket_acl = aws_s3_bucket('my-bucket') @@ -93,14 +96,14 @@ The value of bucket_acl is an Array of simple objects. Each object has a `permi The `bucket_policy` is a low-level property that describes the IAM policy document controlling access to the bucket. The `bucket_policy` property returns a Ruby structure that you can probe to check for particular statements. We recommend using a higher-level property, such as `be_public`, which is concise and easier to implement in your policy files. -The `bucket_policy` property returns an Array of simple objects, each object being an IAM Policy Statement. See the [AWS documentation](https://docs.aws.amazon.com/AmazonS3/latest/dev/example-bucket-policies.html#example-bucket-policies-use-case-2) for details about the structure of this data. +The `bucket_policy` property returns an array of simple objects, each object being an IAM Policy Statement. See the [AWS documentation](https://docs.aws.amazon.com/AmazonS3/latest/dev/example-bucket-policies.html#example-bucket-policies-use-case-2) for details about the structure of this data. -If there is no bucket policy, this property will return an empty Array. +If there is no bucket policy, this property returns an empty array. bucket_policy = aws_s3_bucket('my-bucket') # Look for statements that allow the general public to do things - # This may be a false positive; it's possible these statements + # This may be a false positive; it's possible these statements # could be protected by conditions, such as IP restrictions. public_statements = bucket_policy.select do |s| s.effect == 'Allow' && s.principal == '*' @@ -108,7 +111,7 @@ If there is no bucket policy, this property will return an empty Array. ## Matchers -This InSpec audit resource has the following special matchers. For a full list of available matchers (such as `exist`) please visit our [matchers page](https://www.inspec.io/docs/reference/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/). ### be_public diff --git a/docs/resources/aws_security_group.md b/docs/resources/aws_security_group.md index 746ade110..c2f354c33 100644 --- a/docs/resources/aws_security_group.md +++ b/docs/resources/aws_security_group.md @@ -12,7 +12,7 @@ SGs are a networking construct which contain ingress and egress rules for networ ## Syntax -An `aws_security_group` resource block uses resource parameters to search for a Security Group, and then tests that Security Group. If no SGs match, no error is raised, but the `exists` matcher will return `false` and all properties will be `nil`. If more than one SG matches (due to vague search parameters), an error is raised. +An `aws_security_group` resource block uses resource parameters to search for a Security Group and then tests that Security Group. If no SGs match, no error is raised, but the `exists` matcher returns `false` and all properties will be `nil`. If more than one SG matches (due to vague search parameters), an error is raised. # Ensure you have a security group with a certain ID # This is "safe" - SG IDs are unique within an account @@ -42,7 +42,7 @@ This InSpec resource accepts the following parameters, which are used to search ### id, group_id -The Security Group ID of the Security Group. This is of the format `sg-` followed by 8 hexadecimal characters. The ID is unique within your AWS account; using ID ensures that you will never match more than one SG. The ID is also the default resource parameter, so you may omit the hash syntax. +The Security Group ID of the Security Group. This is of the format `sg-` followed by 8 hexadecimal characters. The ID is unique within your AWS account; using ID ensures that you will never match more than one SG. The ID is also the default resource parameter, so you may omit the hash syntax. # Using Hash syntax describe aws_security_group(id: 'sg-12345678') do @@ -61,7 +61,7 @@ The Security Group ID of the Security Group. This is of the format `sg-` follow ### group_name -The string Name of the Security Group. Every VPC has a security group named 'default'. Names are unique within a VPC, but not within an AWS account. +The string name of the Security Group. Every VPC has a security group named 'default'. Names are unique within a VPC, but not within an AWS account. # Get default security group for a certain VPC describe aws_security_group(group_name: 'default', vpc_id: vpc_id: 'vpc-12345678') do @@ -75,7 +75,7 @@ The string Name of the Security Group. Every VPC has a security group named 'de ### vpc_id -A string identifying the VPC which contains the security group. Since VPCs commonly contain many SGs, you should add additional parameters to ensure you find exactly one SG. +A string identifying the VPC that contains the security group. Since VPCs commonly contain many SGs, you should add additional parameters to ensure you find exactly one SG. # This will error if there is more than the default SG describe aws_security_group(vpc_id: 'vpc-12345678') do @@ -86,6 +86,8 @@ A string identifying the VPC which contains the security group. Since VPCs comm ## 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/). + ### exists The control will pass if the specified SG was found. Use should_not if you want to verify that the specified SG does not exist. diff --git a/docs/resources/aws_security_groups.md b/docs/resources/aws_security_groups.md index e4d642b64..bd0f03afe 100644 --- a/docs/resources/aws_security_groups.md +++ b/docs/resources/aws_security_groups.md @@ -1,12 +1,13 @@ --- title: About the aws_security_groups Resource +platform: aws --- # aws_security_groups Use the `aws_security_groups` InSpec audit resource to test properties of some or all security groups. -Security groups are a networking construct which contain ingress and egress rules for network communications. Security groups may be attached to EC2 instances, as well as certain other AWS resources. Along with Network Access Control Lists, Security Groups are one of the two main mechanisms of enforcing network-level security. +Security groups are a networking construct that contain ingress and egress rules for network communications. Security groups may be attached to EC2 instances, as well as certain other AWS resources. Along with Network Access Control Lists, Security Groups are one of the two main mechanisms of enforcing network-level security.
@@ -29,17 +30,6 @@ As this is the initial release of `aws_security_groups`, its limited functionali
-## Matchers - -### exists - -The control will pass if the filter returns at least one result. Use should_not if you expect zero matches. - - # You will always have at least one SG, the VPC default SG - describe aws_security_groups - it { should exist } - end - ## Filter Criteria ### vpc_id @@ -53,16 +43,26 @@ A string identifying the VPC which contains the security group. ### group_name -A string identifying a group. Since groups are contained in VPCs, group names are unique within the AWS account, but not across VPCs. +A string identifying a group. Since groups are contained in VPCs, group names are unique within the AWS account, but not across VPCs. # Examine the default security group in all VPCs describe aws_security_groups.where( group_name: 'default') do it { should exist } end - ## Properties +* `entries`, `group_ids` + +### entries + +Provides access to the raw results of the query. This can be useful for checking counts and other advanced operations. + + # Allow at most 100 security groups on the account + describe aws_security_groups do + its('entries.count') { should be <= 100} + end + ### group_ids Provides a list of all security group IDs matched. @@ -71,11 +71,16 @@ Provides a list of all security group IDs matched. its('group_ids') { should include('sg-12345678') } end -### entries +## Matchers -Provides access to the raw results of the query. This can be useful for checking counts and other advanced operations. +For a full list of available matchers please visit our [matchers page](https://www.inspec.io/docs/reference/matchers/). + +### exists + +The control will pass if the filter returns at least one result. Use `should_not` if you expect zero matches. + + # You will always have at least one SG, the VPC default SG + describe aws_security_groups + it { should exist } + end - # Allow at most 100 security groups on the account - describe aws_security_groups do - its('entries.count') { should be <= 100} - end diff --git a/docs/resources/aws_sns_topic.md b/docs/resources/aws_sns_topic.md index 11960560c..802cdd3bb 100644 --- a/docs/resources/aws_sns_topic.md +++ b/docs/resources/aws_sns_topic.md @@ -4,7 +4,7 @@ title: About the aws_sns_topic Resource # aws_sns_topic -Use the `aws_sns_topic` InSpec audit resource to test properties of a single AWS Simple Notification Service Topic. SNS topics are channels for related events. AWS resources will place events in the SNS topic, while other AWS resources will _subscribe_ to receive notifications when new events have appeared. +Use the `aws_sns_topic` InSpec audit resource to test properties of a single AWS Simple Notification Service Topic. SNS topics are channels for related events. AWS resources place events in the SNS topic, while other AWS resources _subscribe_ to receive notifications when new events have appeared.
@@ -20,7 +20,6 @@ Use the `aws_sns_topic` InSpec audit resource to test properties of a single AWS describe aws_sns_topic(arn: 'arn:aws:sns:*::my-topic-name') do it { should exist } end - ## Resource Parameters @@ -30,8 +29,25 @@ This resource expects a single parameter that uniquely identifes the SNS Topic, See also the (AWS documentation on ARNs)[http://docs.aws.amazon.com/general/latest/gr/aws-arns-and-namespaces.html]. +
+ +## Properties + +### confirmed_subscription_count + +An integer indicating the number of currently active subscriptions. + + # Make sure someone is listening + describe aws_sns_topic('arn:aws:sns:*::my-topic-name') do + its('confirmed_subscription_count') { should_not be_zero} + 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/). + ### exist Indicates that the ARN provided was found. Use should_not to test for SNS topics that should not exist. @@ -44,15 +60,4 @@ Indicates that the ARN provided was found. Use should_not to test for SNS topic # No bad news allowed describe aws_sns_topic('arn:aws:sns:*::bad-news') do it { should_not exist } - end - -## Properties - -### confirmed_subscription_count - -An integer indicating the number of currently active subscriptions. - - # Make sure someone is listening - describe aws_sns_topic('arn:aws:sns:*::my-topic-name') do - its('confirmed_subscription_count') { should_not be_zero} - end + end \ No newline at end of file diff --git a/docs/resources/aws_subnet.md b/docs/resources/aws_subnet.md index 2129574fa..396ddcabd 100644 --- a/docs/resources/aws_subnet.md +++ b/docs/resources/aws_subnet.md @@ -1,5 +1,6 @@ --- title: About the aws_subnet Resource +platform: aws --- # aws_subnet @@ -38,50 +39,14 @@ A string identifying the subnet that the VPC contains.
-## Matchers - -### assigning_ipv_6_address_on_creation - -Detects whether the network interface on the subnet accepts IPv6 addresses. - - describe aws_subnet(subnet_id: 'subnet-12345678') do - it { should be_assigning_ipv_6_address_on_creation } - end - -### available - -Provides the current state of the subnet. - - describe aws_subnet(subnet_id: 'subnet-12345678') do - it { should be_available } - end - -### default_for_az - -Detects if this is the default subnet for the Availability Zone. - - describe aws_subnet(subnet_id: 'subnet-12345678') do - it { should be_default_for_az } - end - -### exist - -The `exist` matcher indicates that a subnet exists for the specified vpc. - - describe aws_subnet(subnet_id: 'subnet-12345678') do - it { should exist } - end - -### mapping_public_ip_on_launch - -Provides the ID of the VPC the subnet is in. - - describe aws_subnet(subnet_id: 'subnet-12345678') do - it { should be_mapping_public_ip_on_launch } - end - ## Properties +* `availavailability_zone`, `available_ip_address_count`, `cidr_block`, `subnet_id`, `vpc_id` + +
+ +## Property Examples + ### availability_zone Provides the Availability Zone of the subnet. @@ -121,3 +86,49 @@ Provides the ID of the VPC the subnet is in. describe aws_subnet(subnet_id: 'subnet-12345678') do its('vpc_id') { should eq 'vpc-12345678' } 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/). + +### assigning_ipv_6_address_on_creation + +Detects if the network interface on the subnet accepts IPv6 addresses. + + describe aws_subnet(subnet_id: 'subnet-12345678') do + it { should be_assigning_ipv_6_address_on_creation } + end + +### available + +Provides the current state of the subnet. + + describe aws_subnet(subnet_id: 'subnet-12345678') do + it { should be_available } + end + +### default_for_az + +Detects if the subnet is the default subnet for the Availability Zone. + + describe aws_subnet(subnet_id: 'subnet-12345678') do + it { should be_default_for_az } + end + +### exist + +The `exist` matcher indicates that a subnet exists for the specified vpc. + + describe aws_subnet(subnet_id: 'subnet-12345678') do + it { should exist } + end + +### mapping_public_ip_on_launch + +Provides the VPC ID for the subnet. + + describe aws_subnet(subnet_id: 'subnet-12345678') do + it { should be_mapping_public_ip_on_launch } + end \ No newline at end of file diff --git a/docs/resources/aws_subnets.md b/docs/resources/aws_subnets.md index 6d9251ea4..2efb44f08 100644 --- a/docs/resources/aws_subnets.md +++ b/docs/resources/aws_subnets.md @@ -1,5 +1,6 @@ --- title: About the aws_subnets Resource +platform: aws --- # aws_subnets @@ -32,24 +33,12 @@ As this is the initial release of `aws_subnets`, its limited functionality precl
-## Matchers - -### exists - -The control will pass if the filter returns at least one result. Use should_not if you expect zero matches. - - # You dont always have subnets, so you can test if there are any. - describe aws_subnets - it { should exist } - end - - # Test that there are subnets in a vpc - describe aws_subnets.where(vpc_id: 'vpc-12345678') - it { should exist } - end - ## Filter Criteria +* `vpc_id`, `subnet_id` + +## Filter Examples + ### vpc_id A string identifying the VPC which may or may not contain subnets. @@ -69,9 +58,16 @@ A string identifying a specific subnet. its('cidr_blocks') { should eq ['10.0.1.0/24'] } end +
## Properties +* `cidr_blocks`, `states`, `subnet_ids`,`vpc_ids` + +
+ +## Property Examples + ### cidr_blocks Provides a string that contains the cidr block of ip addresses that can be given in the subnet. @@ -81,13 +77,13 @@ Provides a string that contains the cidr block of ip addresses that can be given its('cidr_blocks') { should eq ['10.0.1.0/24'] } end -### vpc_ids +### states -Provides an array containing a string of the vpc_id associated with a subnet. +Provides an array of strings including if the subnets are available. - # Examine a specific subnets VPC IDS - describe aws_subnets.where( subnet_id: 'subnet-12345678') do - its('vpc_ids') { should include 'vpc-12345678' } + # Examine a specific vpcs Subnet IDs + describe aws_subnets.where( vpc_id: 'vpc-12345678') do + its('states') { should_not include 'pending' } end ### subnet_ids @@ -99,12 +95,32 @@ Provides an array of strings containing the subnet IDs associated with a vpc. its('subnet_ids') { should include 'subnet-12345678' } its('subnet_ids') { should include 'subnet-98765432' } end - -### states -Provides an array of strings including whether the subnets are available or not. +### vpc_ids - # Examine a specific vpcs Subnet IDs - describe aws_subnets.where( vpc_id: 'vpc-12345678') do - its('states') { should_not include 'pending' } +Provides an array containing a string of the vpc_id associated with a subnet. + + # Examine a specific subnets VPC IDS + describe aws_subnets.where( subnet_id: 'subnet-12345678') do + its('vpc_ids') { should include 'vpc-12345678' } + end + +
+ +## Matchers + +For a full list of available matchers please visit our [matchers page](https://www.inspec.io/docs/reference/matchers/). + +### exists + +The control will pass if the filter returns at least one result. Use `should_not` if you expect zero matches. + + # You dont always have subnets, so you can test if there are any. + describe aws_subnets + it { should exist } + end + + # Test that there are subnets in a vpc + describe aws_subnets.where(vpc_id: 'vpc-12345678') + it { should exist } end diff --git a/docs/resources/aws_vpc.md b/docs/resources/aws_vpc.md index 7edd8eff9..f431d452f 100644 --- a/docs/resources/aws_vpc.md +++ b/docs/resources/aws_vpc.md @@ -1,5 +1,6 @@ --- title: About the aws_vpc Resource +platform: aws --- # aws_vpc @@ -8,9 +9,9 @@ Use the `aws_vpc` InSpec audit resource to test properties of a single AWS Virtu To test properties of all or multiple VPCs, use the `aws_vpcs` resource. -A VPC is a networking construct that provides an isolated environment. A VPC is contained in a geographic region, but spans availability zones in that region. Within a VPC, you may have multiple subnets, internet gateways, and other networking resources. Computing resources such as EC2 instances reside on subnets within the VPC. +A VPC is a networking construct that provides an isolated environment. A VPC is contained in a geographic region, but spans availability zones in that region. A VPC may have multiple subnets, internet gateways, and other networking resources. Computing resources--such as EC2 instances--reside on subnets within the VPC. -Each VPC is uniquely identified by its VPC ID. In addition, each VPC has a non-unique CIDR IP Address range (such as 10.0.0.0/16) which it manages. +Each VPC is uniquely identified by its VPC ID. In addition, each VPC has a non-unique CIDR IP Address range (such as 10.0.0.0/16) which it manages. Every AWS account has at least one VPC, the "default" VPC, in every region. @@ -18,7 +19,7 @@ Every AWS account has at least one VPC, the "default" VPC, in every region. ## Syntax -An `aws_vpc` resource block identifies a VPC by id. If no VPC ID is provided, the default VPC is used. +An `aws_vpc` resource block identifies a VPC by id. If no VPC ID is provided, the default VPC is used. # Find the default VPC describe aws_vpc do @@ -55,20 +56,14 @@ The following examples show how to use this InSpec audit resource.
-## Matchers - -This InSpec audit resource has the following special matchers. For a full list of available matchers (such as `exist`) please visit our [matchers page](https://www.inspec.io/docs/reference/matchers/). - -### be_default - -The test will pass if the identified VPC is the default VPC for the region. - - describe aws_vpc('vpc-87654321') do - it { should be_default } - end - ## Properties +* `cidr_block`, `dhcp_options_id`, `state`, `vpc_id`, `instance_tenancy` + +
+ +## Property Examples + ### cidr_block The IPv4 address range that is managed by the VPC. @@ -79,12 +74,20 @@ The IPv4 address range that is managed by the VPC. ### dhcp\_options\_id -The ID of the set of DHCP options you've associated with the VPC (or `default` if the default options are associated with the VPC). +The ID of the set of DHCP options associated with the VPC (or `default` if the default options are associated with the VPC). describe aws_vpc do its ('dhcp_options_id') { should eq 'dopt-a94671d0' } end +### instance_tenancy + +The allowed tenancy of instances launched into the VPC. + + describe aws_vpc do + its ('instance_tenancy') { should eq 'default' } + end + ### state The state of the VPC (`pending` | `available`). @@ -101,10 +104,17 @@ The ID of the VPC. its('vpc_id') { should eq 'vpc-87654321' } end -### instance_tenancy +
-The allowed tenancy of instances launched into the VPC. +## Matchers - describe aws_vpc do - its ('instance_tenancy') { should eq 'default' } +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/). + +### be_default + +The test will pass if the identified VPC is the default VPC for the region. + + describe aws_vpc('vpc-87654321') do + it { should be_default } end + diff --git a/docs/resources/aws_vpcs.md b/docs/resources/aws_vpcs.md index a83537102..0deaeeff2 100644 --- a/docs/resources/aws_vpcs.md +++ b/docs/resources/aws_vpcs.md @@ -1,14 +1,15 @@ --- title: About the aws_vpcs Resource +platform: aws --- # aws_vpcs Use the `aws_vpcs` InSpec audit resource to test properties of some or all AWS Virtual Private Clouds (VPCs). -A VPC is a networking construct that provides an isolated environment. A VPC is contained in a geographic region, but spans availability zones in that region. Within a VPC, you may have multiple subnets, internet gateways, and other networking resources. Computing resources such as EC2 instances reside on subnets within the VPC. +A VPC is a networking construct that provides an isolated environment. A VPC is contained in a geographic region, but spans availability zones in that region. A VPC may have multiple subnets, internet gateways, and other networking resources. Computing resources--such as EC2 instances--reside on subnets within the VPC. -Each VPC is uniquely identified by its VPC ID. In addition, each VPC has a non-unique CIDR IP Address range (such as 10.0.0.0/16) which it manages. +Each VPC is uniquely identified by its VPC ID. In addition, each VPC has a non-unique CIDR IP Address range (such as 10.0.0.0/16) which it manages. Every AWS account has at least one VPC, the "default" VPC, in every region. @@ -18,7 +19,7 @@ Every AWS account has at least one VPC, the "default" VPC, in every region. An `aws_vpcs` resource block uses an optional filter to select a group of VPCs and then tests that group. - # The control will pass if the filter returns at least one result. Use should_not if you expect zero matches. + # The control will pass if the filter returns at least one result. Use `should_not` if you expect zero matches. describe aws_vpcs do it { should exist } end @@ -35,9 +36,11 @@ As this is the initial release of `aws_vpcs`, its limited functionality preclude ## Matchers +For a full list of available matchers please visit our [matchers page](https://www.inspec.io/docs/reference/matchers/). + ### exists -The control will pass if the filter returns at least one result. Use should_not if you expect zero matches. +The control will pass if the filter returns at least one result. Use `should_not` if you expect zero matches. # You will always have at least one VPC describe aws_vpcs diff --git a/docs/resources/azure_virtual_machine.md b/docs/resources/azure_virtual_machine.md index df7878910..348c41513 100644 --- a/docs/resources/azure_virtual_machine.md +++ b/docs/resources/azure_virtual_machine.md @@ -152,11 +152,11 @@ Returns the type of caching that has been set on the operating system disk. ### create_option -When the operating system disk is created, how it was created is set as an property. This property will return has the disk was created. +When the operating system disk is created, how it was created is set as an property. This property returns how the disk was created. ### disk_size_gb -Return the size of the operating system disk. +Returns the size of the operating system disk. ### have_data_disks @@ -184,7 +184,7 @@ its('vm_size') { should eq 'Standard_DS2_v2' } ### computer_name -The computername of the machine. This is what was assigned to the machine during deployment and is what _should_ be returned by the `hostname` command. +The name of the machine. This is what was assigned to the machine during deployment and is what _should_ be returned by the `hostname` command. ### admin_username diff --git a/docs/resources/azure_virtual_machine_datadisk.md b/docs/resources/azure_virtual_machine_datadisk.md index 47cebc003..a03507200 100644 --- a/docs/resources/azure_virtual_machine_datadisk.md +++ b/docs/resources/azure_virtual_machine_datadisk.md @@ -173,7 +173,7 @@ If this is a managed disk then this is the fully qualified id to the disk in Azu ### subscription_id -If this is a managed disk, this will return the subscription id of where the disk is stored. +If this is a managed disk, this returns the subscription id of where the disk is stored. This is derived from the `id`. From 59fd0e8775df70ad3487f53edfaf675fb88eb8a9 Mon Sep 17 00:00:00 2001 From: Jared Quick Date: Wed, 14 Feb 2018 11:54:20 -0500 Subject: [PATCH 195/206] Update reporter with breaking inspec 2.0 changes. (#2487) * Update reporter with breaking inspec 2.0 changes. Signed-off-by: Jared Quick --- lib/inspec/formatters/base.rb | 44 +++++++++++++- lib/inspec/metadata.rb | 12 +--- lib/inspec/reporters/cli.rb | 63 ++++----------------- lib/inspec/reporters/json.rb | 28 +-------- lib/inspec/runner.rb | 7 +-- lib/inspec/runner_rspec.rb | 20 ++++++- lib/inspec/schema.rb | 29 ++++++++-- lib/resources/platform.rb | 6 +- test/functional/inheritance_test.rb | 2 +- test/functional/inspec_exec_json_test.rb | 6 +- test/functional/inspec_exec_jsonmin_test.rb | 2 +- test/functional/inspec_exec_junit_test.rb | 2 +- test/functional/inspec_exec_test.rb | 30 +++++----- test/functional/inspec_shell_test.rb | 12 ++-- test/functional/inspec_vendor_test.rb | 2 +- test/unit/mock/reporters/cli_output | 2 +- test/unit/mock/reporters/cli_output_windows | 2 +- test/unit/mock/reporters/json_output | 2 +- test/unit/reporters/cli_test.rb | 9 +-- test/unit/reporters/json_test.rb | 30 ---------- 20 files changed, 137 insertions(+), 173 deletions(-) diff --git a/lib/inspec/formatters/base.rb b/lib/inspec/formatters/base.rb index d6c06c315..5d5ce92fa 100644 --- a/lib/inspec/formatters/base.rb +++ b/lib/inspec/formatters/base.rb @@ -23,6 +23,7 @@ module Inspec::Formatters run_data[:version] = Inspec::VERSION run_data[:statistics] = { duration: summary.duration, + controls: statistics, } end @@ -84,6 +85,47 @@ module Inspec::Formatters private + def all_unique_controls + unique_controls = Set.new + run_data[:profiles].each do |profile| + profile[:controls].map { |control| unique_controls.add(control) } + end + + unique_controls + end + + def statistics + failed = 0 + skipped = 0 + passed = 0 + + all_unique_controls.each do |control| + next unless control[:results] + if control[:results].any? { |r| r[:status] == 'failed' } + failed += 1 + elsif control[:results].any? { |r| r[:status] == 'skipped' } + skipped += 1 + else + passed += 1 + end + end + + total = failed + passed + skipped + + { + total: total, + passed: { + total: passed, + }, + skipped: { + total: skipped, + }, + failed: { + total: failed, + }, + } + end + def exception_message(exception) if exception.is_a?(RSpec::Core::MultipleExceptionError) exception.all_exceptions.map(&:message).uniq.join("\n\n") @@ -111,7 +153,7 @@ module Inspec::Formatters status: example.execution_result.status.to_s, code_desc: code_description, run_time: example.execution_result.run_time, - start_time: example.execution_result.started_at.to_s, + start_time: example.execution_result.started_at.to_datetime.rfc3339.to_s, resource_title: example.metadata[:described_class] || example.metadata[:example_group][:description], expectation_message: format_expectation_message(example), } diff --git a/lib/inspec/metadata.rb b/lib/inspec/metadata.rb index 0db4fa814..36fbee396 100644 --- a/lib/inspec/metadata.rb +++ b/lib/inspec/metadata.rb @@ -54,15 +54,9 @@ module Inspec end def inspec_requirement - inspec_in_supports = params[:supports].find { |x| !x[:inspec].nil? } - if inspec_in_supports - warn '[DEPRECATED] The use of inspec.yml `supports:inspec` is deprecated and will be removed in InSpec 2.0. Please use `inspec_version` instead.' - Gem::Requirement.create(inspec_in_supports[:inspec]) - else - # using Gem::Requirement here to allow nil values which - # translate to [">= 0"] - Gem::Requirement.create(params[:inspec_version]) - end + # using Gem::Requirement here to allow nil values which + # translate to [">= 0"] + Gem::Requirement.create(params[:inspec_version]) end def supports_runtime? diff --git a/lib/inspec/reporters/cli.rb b/lib/inspec/reporters/cli.rb index ba578a88b..ebcac60fc 100644 --- a/lib/inspec/reporters/cli.rb +++ b/lib/inspec/reporters/cli.rb @@ -7,9 +7,6 @@ module Inspec::Reporters # Most currently available Windows terminals have poor support # for ANSI extended colors COLORS = { - 'critical' => "\033[0;1;31m", - 'major' => "\033[0;1;31m", - 'minor' => "\033[0;36m", 'failed' => "\033[0;1;31m", 'passed' => "\033[0;1;32m", 'skipped' => "\033[0;37m", @@ -19,9 +16,6 @@ module Inspec::Reporters # Most currently available Windows terminals have poor support # for UTF-8 characters so use these boring indicators INDICATORS = { - 'critical' => '[CRIT]', - 'major' => '[MAJR]', - 'minor' => '[MINR]', 'failed' => '[FAIL]', 'skipped' => '[SKIP]', 'passed' => '[PASS]', @@ -30,9 +24,6 @@ module Inspec::Reporters else # Extended colors for everyone else COLORS = { - 'critical' => "\033[38;5;9m", - 'major' => "\033[38;5;208m", - 'minor' => "\033[0;36m", 'failed' => "\033[38;5;9m", 'passed' => "\033[38;5;41m", 'skipped' => "\033[38;5;247m", @@ -42,9 +33,6 @@ module Inspec::Reporters # Groovy UTF-8 characters for everyone else... # ...even though they probably only work on Mac INDICATORS = { - 'critical' => '×', - 'major' => '∅', - 'minor' => '⊚', 'failed' => '×', 'skipped' => '↺', 'passed' => '✔', @@ -177,27 +165,15 @@ module Inspec::Reporters end def profile_summary - return @profile_summary unless @profile_summary.nil? - failed = 0 skipped = 0 passed = 0 - critical = 0 - major = 0 - minor = 0 all_unique_controls.each do |control| next if control[:id].start_with? '(generated from ' next unless control[:results] if control[:results].any? { |r| r[:status] == 'failed' } failed += 1 - if control[:impact] >= 0.7 - critical += 1 - elsif control[:impact] >= 0.4 - major += 1 - else - minor += 1 - end elsif control[:results].any? { |r| r[:status] == 'skipped' } skipped += 1 else @@ -207,22 +183,15 @@ module Inspec::Reporters total = failed + passed + skipped - @profile_summary = { + { 'total' => total, - 'failed' => { - 'total' => failed, - 'critical' => critical, - 'major' => major, - 'minor' => minor, - }, + 'failed' => failed, 'skipped' => skipped, 'passed' => passed, } end def tests_summary - return @tests_summary unless @tests_summary.nil? - total = 0 failed = 0 skipped = 0 @@ -241,7 +210,12 @@ module Inspec::Reporters end end - @tests_summary = { 'total' => total, 'failed' => failed, 'skipped' => skipped, 'passed' => passed } + { + 'total' => total, + 'failed' => failed, + 'skipped' => skipped, + 'passed' => passed, + } end def print_profile_summary @@ -249,11 +223,11 @@ module Inspec::Reporters return unless summary['total'] > 0 success_str = summary['passed'] == 1 ? '1 successful control' : "#{summary['passed']} successful controls" - failed_str = summary['failed']['total'] == 1 ? '1 control failure' : "#{summary['failed']['total']} control failures" + failed_str = summary['failed'] == 1 ? '1 control failure' : "#{summary['failed']} control failures" skipped_str = summary['skipped'] == 1 ? '1 control skipped' : "#{summary['skipped']} controls skipped" success_color = summary['passed'] > 0 ? 'passed' : 'no_color' - failed_color = summary['failed']['total'] > 0 ? 'failed' : 'no_color' + failed_color = summary['failed'] > 0 ? 'failed' : 'no_color' skipped_color = summary['skipped'] > 0 ? 'skipped' : 'no_color' s = format( @@ -301,11 +275,6 @@ module Inspec::Reporters end class Control - IMPACT_SCORES = { - critical: 0.7, - major: 0.4, - }.freeze - attr_reader :data def initialize(control_hash) @@ -358,12 +327,8 @@ module Inspec::Reporters 'skipped' elsif results.nil? || results.empty? || results.all? { |r| r[:status] == 'passed' } 'passed' - elsif impact >= IMPACT_SCORES[:critical] - 'critical' - elsif impact >= IMPACT_SCORES[:major] - 'major' else - 'minor' + 'failed' end end @@ -374,12 +339,8 @@ module Inspec::Reporters 'passed' elsif impact.nil? 'unknown' - elsif impact >= IMPACT_SCORES[:critical] - 'critical' - elsif impact >= IMPACT_SCORES[:major] - 'major' else - 'minor' + 'failed' end end diff --git a/lib/inspec/reporters/json.rb b/lib/inspec/reporters/json.rb index 50f2ee6ef..17f944831 100644 --- a/lib/inspec/reporters/json.rb +++ b/lib/inspec/reporters/json.rb @@ -12,10 +12,10 @@ module Inspec::Reporters { platform: platform, profiles: profiles, - statistics: { duration: run_data[:statistics][:duration] }, + statistics: { + duration: run_data[:statistics][:duration], + }, version: run_data[:version], - controls: controls, - other_checks: run_data[:other_checks], } end @@ -28,28 +28,6 @@ module Inspec::Reporters } end - def controls - controls = [] - return controls if run_data[:controls].nil? - - run_data[:controls].each do |c| - control = { - status: c[:status], - start_time: c[:start_time], - run_time: c[:run_time], - code_desc: c[:code_desc], - } - control[:resource] = c[:resource] if c[:resource] - control[:skip_message] = c[:skip_message] if c[:skip_message] - control[:message] = c[:message] if c[:message] - control[:exception] = c[:exception] if c[:exception] - control[:backtrace] = c[:backtrace] if c[:backtrace] - - controls << control - end - controls - end - def profile_results(control) results = [] return results if control[:results].nil? diff --git a/lib/inspec/runner.rb b/lib/inspec/runner.rb index 73178b6f7..5ff1c296e 100644 --- a/lib/inspec/runner.rb +++ b/lib/inspec/runner.rb @@ -129,10 +129,9 @@ module Inspec end def run_tests(with = nil) - status, @run_data = @test_collector.run(with) - # dont output anything if we want a report - render_output(@run_data) unless @conf['report'] - status + run_data = @test_collector.run(with) + render_output(run_data) + @test_collector.exit_code end # determine all attributes before the execution, fetch data from secrets backend diff --git a/lib/inspec/runner_rspec.rb b/lib/inspec/runner_rspec.rb index 0412edd77..7cf19e39d 100644 --- a/lib/inspec/runner_rspec.rb +++ b/lib/inspec/runner_rspec.rb @@ -74,8 +74,24 @@ module Inspec # @return [int] 0 if all went well; otherwise nonzero def run(with = nil) with ||= RSpec::Core::Runner.new(nil) - status = with.run_specs(tests) - [status, @formatter.run_data] + @rspec_exit_code = with.run_specs(tests) + @formatter.results + end + + # Return a proper exit code to the runner + # + # @return [int] exit code + def exit_code + stats = @formatter.results[:statistics][:controls] + if stats[:failed][:total] == 0 && stats[:skipped][:total] == 0 + 0 + elsif stats[:failed][:total] > 0 + 100 + elsif stats[:skipped][:total] > 0 + 101 + else + @rspec_exit_code + end end # Empty the list of registered tests. diff --git a/lib/inspec/schema.rb b/lib/inspec/schema.rb index 04eea2478..ef7e30823 100644 --- a/lib/inspec/schema.rb +++ b/lib/inspec/schema.rb @@ -8,6 +8,31 @@ module Inspec 'additionalProperties' => false, 'properties' => { 'duration' => { 'type' => 'number' }, + 'controls' => { + 'type' => 'object', + 'optional' => true, + 'properties' => { + 'total' => { 'type' => 'number' }, + 'passed' => { + 'type' => 'object', + 'properties' => { + 'total' => { 'type' => 'number' }, + }, + }, + 'skipped' => { + 'type' => 'object', + 'properties' => { + 'total' => { 'type' => 'number' }, + }, + }, + 'failed' => { + 'type' => 'object', + 'properties' => { + 'total' => { 'type' => 'number' }, + }, + }, + }, + }, }, }.freeze @@ -137,10 +162,6 @@ module Inspec }, 'statistics' => STATISTICS, 'version' => { 'type' => 'string' }, - - # DEPRECATED PROPERTIES!! These will be removed with the next major version bump - 'controls' => 'array', - 'other_checks' => 'array', }, }.freeze diff --git a/lib/resources/platform.rb b/lib/resources/platform.rb index 97a7e661a..b64ff89cc 100644 --- a/lib/resources/platform.rb +++ b/lib/resources/platform.rb @@ -56,11 +56,7 @@ module Inspec::Resources status = true supports.each do |s| s.each do |k, v| - # ignore the inspec check for supports - # TODO: remove in inspec 2.0 - if k == :inspec - next - elsif %i(os_family os-family platform_family platform-family).include?(k) + if %i(os_family os-family platform_family platform-family).include?(k) status = in_family?(v) elsif %i(os platform).include?(k) status = platform?(v) diff --git a/test/functional/inheritance_test.rb b/test/functional/inheritance_test.rb index 6feaf313b..7c23668a0 100644 --- a/test/functional/inheritance_test.rb +++ b/test/functional/inheritance_test.rb @@ -63,7 +63,7 @@ describe 'example inheritance profile' do it 'can execute a profile inheritance' do out = inspec('exec ' + path + ' --reporter json --no-create-lockfile --attrs ' + attrs) out.stderr.must_equal '' - out.exit_status.must_equal 0 + out.exit_status.must_equal 101 JSON.load(out.stdout).must_be_kind_of Hash end end diff --git a/test/functional/inspec_exec_json_test.rb b/test/functional/inspec_exec_json_test.rb index 219f9da69..6c82f5c05 100644 --- a/test/functional/inspec_exec_json_test.rb +++ b/test/functional/inspec_exec_json_test.rb @@ -21,7 +21,7 @@ describe 'inspec exec with json formatter' do it 'can execute a profile and validate the json schema' do out = inspec('exec ' + example_profile + ' --reporter json --no-create-lockfile') out.stderr.must_equal '' - out.exit_status.must_equal 0 + out.exit_status.must_equal 101 data = JSON.parse(out.stdout) sout = inspec('schema exec-json') schema = JSON.parse(sout.stdout) @@ -78,10 +78,6 @@ describe 'inspec exec with json formatter' do controls.find { |x| x['id'].nil? }.must_be :nil? end - it 'has no missing checks' do - json['other_checks'].must_equal([]) - end - it 'has results for every control' do ex1['results'].length.must_equal 1 ex2['results'].length.must_equal 1 diff --git a/test/functional/inspec_exec_jsonmin_test.rb b/test/functional/inspec_exec_jsonmin_test.rb index ecd16ff12..32a31686d 100644 --- a/test/functional/inspec_exec_jsonmin_test.rb +++ b/test/functional/inspec_exec_jsonmin_test.rb @@ -11,7 +11,7 @@ describe 'inspec exec' do it 'can execute a profile with the mini json formatter and validate its schema' do out = inspec('exec ' + example_profile + ' --reporter json-min --no-create-lockfile') out.stderr.must_equal '' - out.exit_status.must_equal 0 + out.exit_status.must_equal 101 data = JSON.parse(out.stdout) sout = inspec('schema exec-jsonmin') schema = JSON.parse(sout.stdout) diff --git a/test/functional/inspec_exec_junit_test.rb b/test/functional/inspec_exec_junit_test.rb index c3764b677..614f4613a 100644 --- a/test/functional/inspec_exec_junit_test.rb +++ b/test/functional/inspec_exec_junit_test.rb @@ -18,7 +18,7 @@ describe 'inspec exec with junit formatter' do it 'can execute the profile with the junit formatter' do out = inspec('exec ' + example_profile + ' --reporter junit --no-create-lockfile') out.stderr.must_equal '' - out.exit_status.must_equal 0 + out.exit_status.must_equal 101 doc = REXML::Document.new(out.stdout) doc.has_elements?.must_equal true end diff --git a/test/functional/inspec_exec_test.rb b/test/functional/inspec_exec_test.rb index f614d6bf2..86d7cfbe5 100644 --- a/test/functional/inspec_exec_test.rb +++ b/test/functional/inspec_exec_test.rb @@ -10,7 +10,7 @@ describe 'inspec exec' do it 'can execute the profile' do out = inspec('exec ' + example_profile + ' --no-create-lockfile') out.stderr.must_equal '' - out.exit_status.must_equal 0 + out.exit_status.must_equal 101 stdout = out.stdout.force_encoding(Encoding::UTF_8) stdout.must_include "\e[38;5;41m ✔ ssh-1: Allow only SSH Protocol 2\e[0m\n" stdout.must_include "\e[38;5;41m ✔ tmp-1.0: Create /tmp directory\e[0m\n" @@ -62,14 +62,14 @@ Test Summary: 0 successful, 0 failures, 0 skipped it 'executes a specs-only profile' do out = inspec('exec ' + File.join(profile_path, 'spec_only') + ' --no-create-lockfile') out.stderr.must_equal '' - out.exit_status.must_equal 1 + out.exit_status.must_equal 100 out.stdout.force_encoding(Encoding::UTF_8).must_include "Target: local://" out.stdout.force_encoding(Encoding::UTF_8).must_include "working" out.stdout.force_encoding(Encoding::UTF_8).must_include "✔ should eq \"working\"" out.stdout.force_encoding(Encoding::UTF_8).must_include "skippy\n" out.stdout.force_encoding(Encoding::UTF_8).must_include "↺ This will be skipped intentionally" out.stdout.force_encoding(Encoding::UTF_8).must_include "failing" - out.stdout.force_encoding(Encoding::UTF_8).must_include "∅ should eq \"as intended\"" + out.stdout.force_encoding(Encoding::UTF_8).must_include "× should eq \"as intended\"" out.stdout.force_encoding(Encoding::UTF_8).must_include "Test Summary: \e[38;5;41m1 successful\e[0m, \e[38;5;9m1 failure\e[0m, \e[38;5;247m1 skipped\e[0m\n" end @@ -106,7 +106,7 @@ Test Summary: 0 successful, 0 failures, 0 skipped out.stdout.force_encoding(Encoding::UTF_8).must_include "skippy\e[0m\n\e[38;5;247m ↺ This will be skipped super intentionally.\e[0m\n" out.stdout.force_encoding(Encoding::UTF_8).must_include " ↺ CONTROL database: MySQL Session\e[0m\n\e[38;5;247m ↺ Can't run MySQL SQL checks without authentication\e[0m\n" out.stdout.force_encoding(Encoding::UTF_8).must_include "Profile Summary: 0 successful controls, 0 control failures, \e[38;5;247m2 controls skipped\e[0m\nTest Summary: 0 successful, 0 failures, \e[38;5;247m2 skipped\e[0m\n" - out.exit_status.must_equal 0 + out.exit_status.must_equal 101 end end @@ -169,11 +169,11 @@ Test Summary: \e[38;5;41m2 successful\e[0m, 0 failures, 0 skipped\n" it 'should print all the results' do out.stdout.force_encoding(Encoding::UTF_8).must_include "× tmp-1.0: Create /tmp directory (1 failed)\e[0m" - out.stdout.force_encoding(Encoding::UTF_8).must_include "∅ should not be directory\n" - out.stdout.force_encoding(Encoding::UTF_8).must_include "∅ undefined method `should_nota'" - out.stdout.force_encoding(Encoding::UTF_8).must_include "∅ should not be directory\n expected `File /tmp.directory?` to return false, got true\e[0m" - out.stdout.force_encoding(Encoding::UTF_8).must_include "∅ 7 should cmp >= 9\n" - out.stdout.force_encoding(Encoding::UTF_8).must_include "∅ 7 should not cmp == /^\\d$/\n" + out.stdout.force_encoding(Encoding::UTF_8).must_include "× should not be directory\n" + out.stdout.force_encoding(Encoding::UTF_8).must_include "× undefined method `should_nota'" + out.stdout.force_encoding(Encoding::UTF_8).must_include "× should not be directory\n expected `File /tmp.directory?` to return false, got true\e[0m" + out.stdout.force_encoding(Encoding::UTF_8).must_include "× 7 should cmp >= 9\n" + out.stdout.force_encoding(Encoding::UTF_8).must_include "× 7 should not cmp == /^\\d$/\n" out.stdout.force_encoding(Encoding::UTF_8).must_include "✔ 7 should cmp == \"7\"" out.stdout.force_encoding(Encoding::UTF_8).must_include " expected: \"01147\" got: \"01777\"\n" @@ -185,9 +185,9 @@ Test Summary: \e[38;5;41m2 successful\e[0m, 0 failures, 0 skipped\n" it 'should print all the results' do out.stdout.force_encoding(Encoding::UTF_8).must_include "× tmp-1.0: Create /tmp directory (1 failed)\e[0m" - out.stdout.force_encoding(Encoding::UTF_8).must_include "∅ should not be directory" - out.stdout.force_encoding(Encoding::UTF_8).must_include "∅ undefined method `should_nota'" - out.stdout.force_encoding(Encoding::UTF_8).must_include "∅ should not be directory\n expected `File /tmp.directory?` to return false, got true\e[0m" + out.stdout.force_encoding(Encoding::UTF_8).must_include "× should not be directory" + out.stdout.force_encoding(Encoding::UTF_8).must_include "× undefined method `should_nota'" + out.stdout.force_encoding(Encoding::UTF_8).must_include "× should not be directory\n expected `File /tmp.directory?` to return false, got true\e[0m" out.stdout.force_encoding(Encoding::UTF_8).must_include "✔ profiled-1: Create /tmp directory (profile d)" end end @@ -283,12 +283,12 @@ Test Summary: \e[38;5;41m2 successful\e[0m, 0 failures, 0 skipped\n" it 'hides sensitive output' do out = inspec('exec ' + sensitive_profile + ' --no-create-lockfile') out.stderr.must_equal '' - out.exit_status.must_equal 1 + out.exit_status.must_equal 100 stdout = out.stdout.force_encoding(Encoding::UTF_8) - stdout.must_include '∅ should eq "billy"' + stdout.must_include '× should eq "billy"' stdout.must_include 'expected: "billy"' stdout.must_include 'got: "bob"' - stdout.must_include '∅ should eq "secret"' + stdout.must_include '× should eq "secret"' stdout.must_include '*** sensitive output suppressed ***' stdout.must_include "\nTest Summary: \e[38;5;41m2 successful\e[0m, \e[38;5;9m2 failures\e[0m, 0 skipped\n" end diff --git a/test/functional/inspec_shell_test.rb b/test/functional/inspec_shell_test.rb index 693308f62..4feb44e09 100644 --- a/test/functional/inspec_shell_test.rb +++ b/test/functional/inspec_shell_test.rb @@ -79,7 +79,6 @@ describe 'inspec shell tests' do j = JSON.load(out.stdout) j.keys.must_include 'version' j.keys.must_include 'profiles' - j.keys.must_include 'other_checks' j.keys.must_include 'statistics' end @@ -90,16 +89,15 @@ describe 'inspec shell tests' do end it 'runs anonymous tests that fail (json output)' do - out = do_shell_c("describe file(\"foo/bar/baz\") do it { should exist } end", 1, true) + out = do_shell_c("describe file(\"foo/bar/baz\") do it { should exist } end", 100, true) j = JSON.load(out.stdout) j.keys.must_include 'version' j.keys.must_include 'profiles' - j.keys.must_include 'other_checks' j.keys.must_include 'statistics' end it 'runs anonymous tests that fail' do - out = do_shell_c("describe file(\"foo/bar/baz\") do it { should exist } end", 1) + out = do_shell_c("describe file(\"foo/bar/baz\") do it { should exist } end", 100) out.stdout.must_include '0 successful' out.stdout.must_include '1 failure' end @@ -109,7 +107,6 @@ describe 'inspec shell tests' do j = JSON.load(out.stdout) j.keys.must_include 'version' j.keys.must_include 'profiles' - j.keys.must_include 'other_checks' j.keys.must_include 'statistics' end @@ -120,16 +117,15 @@ describe 'inspec shell tests' do end it 'runs controls with multiple tests (json output)' do - out = do_shell_c("control \"test\" do describe file(\"#{__FILE__}\") do it { should exist } end; describe file(\"foo/bar/baz\") do it { should exist } end end", 1, true) + out = do_shell_c("control \"test\" do describe file(\"#{__FILE__}\") do it { should exist } end; describe file(\"foo/bar/baz\") do it { should exist } end end", 100, true) j = JSON.load(out.stdout) j.keys.must_include 'version' j.keys.must_include 'profiles' - j.keys.must_include 'other_checks' j.keys.must_include 'statistics' end it 'runs controls with multiple tests' do - out = do_shell_c("control \"test\" do describe file(\"#{__FILE__}\") do it { should exist } end; describe file(\"foo/bar/baz\") do it { should exist } end end", 1) + out = do_shell_c("control \"test\" do describe file(\"#{__FILE__}\") do it { should exist } end; describe file(\"foo/bar/baz\") do it { should exist } end end", 100) out.stdout.must_include '0 successful' out.stdout.must_include '1 failure' end diff --git a/test/functional/inspec_vendor_test.rb b/test/functional/inspec_vendor_test.rb index 49c1e4686..728a0eee2 100644 --- a/test/functional/inspec_vendor_test.rb +++ b/test/functional/inspec_vendor_test.rb @@ -44,7 +44,7 @@ describe 'example inheritance profile' do File.exist?(File.join(dir, 'inspec.lock')).must_equal true out = inspec('exec ' + dir + ' -l debug --no-create-lockfile') - out.stderr.must_equal "[DEPRECATED] The use of inspec.yml `supports:inspec` is deprecated and will be removed in InSpec 2.0. Please use `inspec_version` instead.\n" + out.stderr.must_equal '' out.stdout.must_include 'Using cached dependency for {:url=>"https://github.com/dev-sec/ssh-baseline/archive/master.tar.gz"' out.stdout.must_include 'Using cached dependency for {:url=>"https://github.com/dev-sec/ssl-baseline/archive/master.tar.gz"' out.stdout.must_include 'Using cached dependency for {:url=>"https://github.com/chris-rock/windows-patch-benchmark/archive/master.tar.gz"' diff --git a/test/unit/mock/reporters/cli_output b/test/unit/mock/reporters/cli_output index 936f910ff..0beeba2d6 100644 --- a/test/unit/mock/reporters/cli_output +++ b/test/unit/mock/reporters/cli_output @@ -9,7 +9,7 @@ Target: local:// File /tmp  ✔ should be directory gem package rubocop - ∅ should be installed + × should be installed rubocop is not installed stdout  ✔ stdout should eq "jquick\n" diff --git a/test/unit/mock/reporters/cli_output_windows b/test/unit/mock/reporters/cli_output_windows index 224c5da50..9d707a3ff 100644 --- a/test/unit/mock/reporters/cli_output_windows +++ b/test/unit/mock/reporters/cli_output_windows @@ -9,7 +9,7 @@ Target: local:// File /tmp  [PASS] should be directory gem package rubocop - [MAJR] should be installed + [FAIL] should be installed rubocop is not installed stdout  [PASS] stdout should eq "jquick\n" diff --git a/test/unit/mock/reporters/json_output b/test/unit/mock/reporters/json_output index 834d666fb..e96408e88 100644 --- a/test/unit/mock/reporters/json_output +++ b/test/unit/mock/reporters/json_output @@ -1 +1 @@ -{"platform":{"name":"mac_os_x","release":"17.2.0"},"profiles":[{"name":"long_commands","version":"0.1.0","sha256":"4f816f8cf18f165f05f1cf20936aaad06a15287de3f578891197647ca05c7df4","title":"InSpec Profile","maintainer":"The Authors","summary":"An InSpec Compliance Profile","license":"Apache-2.0","copyright":"The Authors","copyright_email":"you@example.com","supports":[{"os-family":"bds"},{"os-name":"mac_os_x","release":"17.*"}],"attributes":[],"groups":[{"id":"controls/example.rb","controls":["(generated from example.rb:7 871cd54043069c5c4f6e382fd5627830)","tmp-1.0","(generated from example.rb:21 2ff474c5357e7070f4c3efa932032dcb)"],"title":"sample section"},{"id":"controls/run_command.rb","controls":["(generated from run_command.rb:5 a411d4ded1530b2f48170840e1127584)"]}],"controls":[{"id":"(generated from example.rb:7 871cd54043069c5c4f6e382fd5627830)","title":null,"desc":null,"impact":0.5,"refs":[],"tags":{},"code":"","source_location":{"line":89,"ref":"/Users/jquick/Chef/inspec/lib/inspec/control_eval_context.rb"},"results":[{"status":"passed","code_desc":"File /tmp should be directory","run_time":0.002058,"start_time":"2018-01-05 11:43:04 -0500"}]},{"id":"tmp-1.0","title":"Create /tmp directory","desc":"An optional description...","impact":0.7,"refs":[],"tags":{},"code":"control 'tmp-1.0' do # A unique ID for this control\n impact 0.7 # The criticality, if this control fails.\n title 'Create /tmp directory' # A human-readable title\n desc 'An optional description...'\n describe file('/tmp') do # The actual test\n it { should be_directory }\n end\nend\n","source_location":{"line":12,"ref":"../inspec-demo/_test/long_commands/controls/example.rb"},"results":[{"status":"passed","code_desc":"File /tmp should be directory","run_time":0.000102,"start_time":"2018-01-05 11:43:04 -0500"}]},{"id":"(generated from example.rb:21 2ff474c5357e7070f4c3efa932032dcb)","title":null,"desc":null,"impact":0.5,"refs":[],"tags":{},"code":"","source_location":{"line":89,"ref":"/Users/jquick/Chef/inspec/lib/inspec/control_eval_context.rb"},"results":[{"status":"failed","code_desc":"gem package rubocop should be installed","run_time":0.000168,"start_time":"2018-01-05 11:43:04 -0500","message":"rubocop is not installed"}]},{"id":"(generated from run_command.rb:5 a411d4ded1530b2f48170840e1127584)","title":null,"desc":null,"impact":0.5,"refs":[],"tags":{},"code":"","source_location":{"line":89,"ref":"/Users/jquick/Chef/inspec/lib/inspec/control_eval_context.rb"},"results":[{"status":"passed","code_desc":"Command whoami stdout should eq \"jquick\\n\"","run_time":0.034938,"start_time":"2018-01-05 11:43:04 -0500"}]}]}],"statistics":{"duration":0.039182},"version":"1.49.2","controls":[{"status":"passed","start_time":"2018-01-05 11:43:04 -0500","run_time":0.002058,"code_desc":"File /tmp should be directory"},{"status":"passed","start_time":"2018-01-05 11:43:04 -0500","run_time":0.000102,"code_desc":"File /tmp should be directory"},{"status":"passed","start_time":"2018-01-05 11:43:04 -0500","run_time":0.000168,"code_desc":"gem package rubocop should be installed"},{"status":"passed","start_time":"2018-01-05 11:43:04 -0500","run_time":0.034938,"code_desc":"Command whoami stdout should eq \"jquick\\n\""}],"other_checks":[]} \ No newline at end of file +{"platform":{"name":"mac_os_x","release":"17.2.0"},"profiles":[{"name":"long_commands","version":"0.1.0","sha256":"4f816f8cf18f165f05f1cf20936aaad06a15287de3f578891197647ca05c7df4","title":"InSpec Profile","maintainer":"The Authors","summary":"An InSpec Compliance Profile","license":"Apache-2.0","copyright":"The Authors","copyright_email":"you@example.com","supports":[{"os-family":"bds"},{"os-name":"mac_os_x","release":"17.*"}],"attributes":[],"groups":[{"id":"controls/example.rb","controls":["(generated from example.rb:7 871cd54043069c5c4f6e382fd5627830)","tmp-1.0","(generated from example.rb:21 2ff474c5357e7070f4c3efa932032dcb)"],"title":"sample section"},{"id":"controls/run_command.rb","controls":["(generated from run_command.rb:5 a411d4ded1530b2f48170840e1127584)"]}],"controls":[{"id":"(generated from example.rb:7 871cd54043069c5c4f6e382fd5627830)","title":null,"desc":null,"impact":0.5,"refs":[],"tags":{},"code":"","source_location":{"line":89,"ref":"/Users/jquick/Chef/inspec/lib/inspec/control_eval_context.rb"},"results":[{"status":"passed","code_desc":"File /tmp should be directory","run_time":0.002058,"start_time":"2018-01-05 11:43:04 -0500"}]},{"id":"tmp-1.0","title":"Create /tmp directory","desc":"An optional description...","impact":0.7,"refs":[],"tags":{},"code":"control 'tmp-1.0' do # A unique ID for this control\n impact 0.7 # The criticality, if this control fails.\n title 'Create /tmp directory' # A human-readable title\n desc 'An optional description...'\n describe file('/tmp') do # The actual test\n it { should be_directory }\n end\nend\n","source_location":{"line":12,"ref":"../inspec-demo/_test/long_commands/controls/example.rb"},"results":[{"status":"passed","code_desc":"File /tmp should be directory","run_time":0.000102,"start_time":"2018-01-05 11:43:04 -0500"}]},{"id":"(generated from example.rb:21 2ff474c5357e7070f4c3efa932032dcb)","title":null,"desc":null,"impact":0.5,"refs":[],"tags":{},"code":"","source_location":{"line":89,"ref":"/Users/jquick/Chef/inspec/lib/inspec/control_eval_context.rb"},"results":[{"status":"failed","code_desc":"gem package rubocop should be installed","run_time":0.000168,"start_time":"2018-01-05 11:43:04 -0500","message":"rubocop is not installed"}]},{"id":"(generated from run_command.rb:5 a411d4ded1530b2f48170840e1127584)","title":null,"desc":null,"impact":0.5,"refs":[],"tags":{},"code":"","source_location":{"line":89,"ref":"/Users/jquick/Chef/inspec/lib/inspec/control_eval_context.rb"},"results":[{"status":"passed","code_desc":"Command whoami stdout should eq \"jquick\\n\"","run_time":0.034938,"start_time":"2018-01-05 11:43:04 -0500"}]}]}],"statistics":{"duration":0.039182},"version":"1.49.2"} \ No newline at end of file diff --git a/test/unit/reporters/cli_test.rb b/test/unit/reporters/cli_test.rb index 9506ac661..b996c4d0c 100644 --- a/test/unit/reporters/cli_test.rb +++ b/test/unit/reporters/cli_test.rb @@ -19,7 +19,6 @@ describe Inspec::Reporters::CLI do def windowize(string) string.gsub!('✔', '[PASS]') - string.gsub!('∅', '[MAJR]') string.gsub!('↺', '[SKIP]') string.gsub!('×', '[FAIL]') string.gsub!("\e[38;5;41m", "\e[0;1;32m") @@ -76,7 +75,7 @@ describe Inspec::Reporters::CLI do File /tmp \e[38;5;41m ✔ should be directory\e[0m gem package rubocop - \e[38;5;208m ∅ should be installed + \e[38;5;9m × should be installed rubocop is not installed\e[0m stdout \e[38;5;41m ✔ stdout should eq \"jquick\\n\"\e[0m @@ -301,10 +300,8 @@ describe Inspec::Reporters::CLI do describe '#profile_summary' do it 'correct profile summary' do - expect = {"total"=>1, "failed"=>{"total"=>0, "critical"=>0, "major"=>0, "minor"=>0}, "skipped"=>0, "passed"=>1} + expect = {"total"=>1, "failed"=>0, "skipped"=>0, "passed"=>1} report.send(:profile_summary).must_equal expect - assert = report.instance_variable_get(:@profile_summary) - assert.count.must_equal 4 end end @@ -312,8 +309,6 @@ describe Inspec::Reporters::CLI do it 'correct tests summary' do expect = {"total"=>0, "failed"=>1, "skipped"=>0, "passed"=>3} report.send(:tests_summary).must_equal expect - assert = report.instance_variable_get(:@tests_summary) - assert.count.must_equal 4 end end end diff --git a/test/unit/reporters/json_test.rb b/test/unit/reporters/json_test.rb index 6595cf348..c8a070710 100644 --- a/test/unit/reporters/json_test.rb +++ b/test/unit/reporters/json_test.rb @@ -34,36 +34,6 @@ describe Inspec::Reporters::Json do end end - describe '#controls' do - it 'confirm control output' do - hash = { - status: 'passed', - start_time: '2018-01-05 11:43:04 -0500', - run_time: 0.002058, - code_desc: 'File /tmp should be directory', - } - controls = report.send(:controls) - controls.first.must_equal hash - controls.count.must_equal 4 - end - - it 'confirm control output with optional' do - report.run_data[:controls].first[:resource] = 'File' - report.run_data[:controls].first[:skip_message] = 'skipping' - hash = { - status: 'passed', - start_time: '2018-01-05 11:43:04 -0500', - run_time: 0.002058, - code_desc: 'File /tmp should be directory', - resource: 'File', - skip_message: 'skipping', - } - controls = report.send(:controls) - controls.first.must_equal hash - controls.count.must_equal 4 - end - end - describe '#profile_results' do it 'confirm profile_results output' do hash = { From 4f341acfbcb3ae0d648527884a3450abc6e61796 Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Wed, 14 Feb 2018 14:15:20 -0500 Subject: [PATCH 196/206] Catch cloud exceptions and document connection info (#2636) Signed-off-by: Clinton Wolfe --- README.md | 5 + docs/platforms.md | 111 ++++++++++++++++++ .../aws/aws_resource_mixin.rb | 16 ++- lib/resources/aws/aws_ec2_instance.rb | 85 +++++++++----- lib/resources/aws/aws_iam_access_key.rb | 4 +- lib/resources/aws/aws_iam_password_policy.rb | 21 +++- lib/resources/aws/aws_iam_policy.rb | 5 +- lib/resources/aws/aws_iam_root_user.rb | 19 ++- lib/resources/aws/aws_iam_user.rb | 2 + lib/resources/aws/aws_s3_bucket.rb | 33 +++--- lib/resources/azure/azure_backend.rb | 43 +++++-- lib/resources/azure/azure_resource_group.rb | 1 + .../azure/azure_virtual_machine_data_disk.rb | 1 + 13 files changed, 287 insertions(+), 59 deletions(-) create mode 100644 docs/platforms.md diff --git a/README.md b/README.md index d35d471b9..4464b696d 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,11 @@ inspec exec test.rb -t aws:// # or store your AWS credentials in your ~/.aws/credentials profiles file inspec exec test.rb -t aws://us-east-2/my-profile +# run a profile targeting Azure using env vars +inspec exec test.rb -t azure:// + +# or store your Azure credentials in your ~/.azure/credentials profiles file +inspec exec test.rb -t azure://subscription_id ``` ### detect diff --git a/docs/platforms.md b/docs/platforms.md new file mode 100644 index 000000000..d809de58b --- /dev/null +++ b/docs/platforms.md @@ -0,0 +1,111 @@ +# Using InSpec 2.0 with Platforms + +New in InSpec 2.0, you may now use certain InSpec resources to audit properties of things that aren't individual machines - for example, an Amazon Web Services S3 bucket. + +In the initial release of InSpec 2.0, support for selected AWS and Azure resources is included. + +## AWS Platform Support + +### Setting up AWS credentials for InSpec + +InSpec uses the standard AWS authentication mechanisms. Typically, you will create an IAM user which will be used for auditing activities. + +1. Create an IAM user in the AWS console, with your choice of username. Check the box marked "Programmatic Access." +2. On the Permissions screen, choose Direct Attach. Select the AWS-managed IAM Profile named "ReadOnlyAccess." If you wish to restrict the user further, you may do so; see individual InSpec resources to identify which permissions are required. +3. After the key is generated, record the Access Key ID and Secret Key. + +#### Using Environment Variables to provide credentials + +You may provide the credentials to InSpec by setting the following environment variables: `AWS_REGION`, `AWS_ACCESS_KEY_ID`, and `AWS_SECRET_KEY_ID`. You may also use `AWS_PROFILE`, or if you are using MFA, `AWS_SESSION_TOKEN`. See the [AWS Command Line Interface Docs](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html) for details. + +Once you have your environment variables set, you can verify your credentials by running: + +```bash +you$ inspec detect -t aws:// + +== Platform Details +Name: aws +Families: cloud, api +``` + +#### Using the InSpec target option to provide credentials + +Look for a file in your home directory named `~/.aws/credentials`. If it does not exist, create it. Choose a name for your profile; here, we're using the name 'auditing'. Add your credentials as a new profile, in INI format: + +``` +[auditing] +aws_access_key_id = AKIA.... +aws_secret_access_key = 1234....abcd +``` + +You may now run InSpec using the `--target` / `-t` option, using the format `-t aws://region/profile`. For example, to connect to the Ohio region using a profile named 'auditing', use `-t aws://us-east-2/auditing`. + +To verify your credentials, +```bash +you$ inspec detect -t aws:// + +== Platform Details +Name: aws +Families: cloud, api +``` + +#### Verifying your credentials + +To verify your credentials + +## Azure Platform Support in InSpec 2.0 + +### Setting up Azure credentials for InSpec + +To use InSpec Azure resources, you will need a Service Principal Name (SPN) to be created in the Azure subscription that is being audited. + +This can be done on the command line or from the Azure Portal: + +- Azure CLI: https://docs.microsoft.com/en-us/azure/azure-resource-manager/resource-group-authenticate-service-principal-cli +- PowerShell: https://docs.microsoft.com/en-us/azure/azure-resource-manager/resource-group-authenticate-service-principal +- Azure Portal: https://docs.microsoft.com/en-us/azure/azure-resource-manager/resource-group-create-service-principal-portal + +The information from the SPN can be specified either in a file `~/.azure/credentials`, as environment variables, or using InSpec target URIs. + +#### Using the Azure Credentials File + +By default InSpec is configured to look at ~/.azure/credentials, and it should contain: + +``` +[] +client_id = "" +client_secret = "" +tenant_id = "" +``` + +With the credentials are in place you may now execute InSpec: + +```bash +inspec exec my-inspec-profile -t azure:// +``` + +#### Using Environment variables + +You may also set the Azure credentials via environment variables: + +- `AZURE_SUBSCRIPTION_ID` +- `AZURE_CLIENT_ID` +- `AZURE_CLIENT_SECRET` +- `AZURE_TENANT_ID` + +For example: + +```bash +AZURE_SUBSCRIPTION_ID="2fbdbb02-df2e-11e6-bf01-fe55135034f3" \ +AZURE_CLIENT_ID="58dc4f6c-df2e-11e6-bf01-fe55135034f3" \ +AZURE_CLIENT_SECRET="Jibr4iwwaaZwBb6W" \ +AZURE_TENANT_ID="6ad89b58-df2e-11e6-bf01-fe55135034f3" inspec exec my-profile -t azure:// +``` + +#### Using InSpec Target Syntax + +If you have created a `~/.azure/credentials` file as above, you may also use the InSpec command line `--target` / `-t` option to select a subscription ID. For example: + +```bash +inspec exec my-profile -t azure://2fbdbb02-df2e-11e6-bf01-fe55135034f3 +``` diff --git a/lib/resource_support/aws/aws_resource_mixin.rb b/lib/resource_support/aws/aws_resource_mixin.rb index 2d25356e2..0926ea1fa 100644 --- a/lib/resource_support/aws/aws_resource_mixin.rb +++ b/lib/resource_support/aws/aws_resource_mixin.rb @@ -3,7 +3,9 @@ module AwsResourceMixin validate_params(resource_params).each do |param, value| instance_variable_set(:"@#{param}", value) end - fetch_from_api + catch_aws_errors do + fetch_from_api + end end # Default implementation of validate params accepts everything. @@ -45,4 +47,16 @@ module AwsResourceMixin # TODO: remove with https://github.com/chef/inspec-aws/issues/216 inspec if respond_to?(:inspec) end + + # Intercept AWS exceptions + def catch_aws_errors + yield + rescue Aws::Errors::MissingCredentialsError + # The AWS error here is unhelpful: + # "unable to sign request without credentials set" + Inspec::Log.error "It appears that you have not set your AWS credentials. You may set them using environment variables, or using the 'aws://region/aws_credentials_profile' target. See https://www.inspec.io/docs/reference/platforms for details." + fail_resource('No AWS credentials available') + rescue Aws::Errors::ServiceError => e + fail_resource e.message + end end diff --git a/lib/resources/aws/aws_ec2_instance.rb b/lib/resources/aws/aws_ec2_instance.rb index 556633f06..73a67dc50 100644 --- a/lib/resources/aws/aws_ec2_instance.rb +++ b/lib/resources/aws/aws_ec2_instance.rb @@ -25,6 +25,21 @@ EOX @iam_resource = conn ? conn.iam_resource : inspec_runner.backend.aws_resource(Aws::IAM::Resource, {}) end + # TODO: DRY up, see https://github.com/chef/inspec/issues/2633 + # Copied from resource_support/aws/aws_resource_mixin.rb + def catch_aws_errors + yield + rescue Aws::Errors::MissingCredentialsError + # The AWS error here is unhelpful: + # "unable to sign request without credentials set" + Inspec::Log.error "It appears that you have not set your AWS credentials. You may set them using environment variables, or using the 'aws://region/aws_credentials_profile' target. See https://www.inspec.io/docs/reference/platforms for details." + fail_resource('No AWS credentials available') + rescue Aws::Errors::ServiceError => e + fail_resource e.message + end + + # TODO: DRY up, see https://github.com/chef/inspec/issues/2633 + # Copied from resource_support/aws/aws_singular_resource_mixin.rb def inspec_runner # When running under inspec-cli, we have an 'inspec' method that # returns the runner. When running under unit tests, we don't @@ -37,19 +52,21 @@ EOX def id return @instance_id if defined?(@instance_id) - if @opts.is_a?(Hash) - first = @ec2_resource.instances( - { - filters: [{ - name: 'tag:Name', - values: [@opts[:name]], - }], - }, - ).first - # catch case where the instance is not known - @instance_id = first.id unless first.nil? - else - @instance_id = @opts + catch_aws_errors do + if @opts.is_a?(Hash) + first = @ec2_resource.instances( + { + filters: [{ + name: 'tag:Name', + values: [@opts[:name]], + }], + }, + ).first + # catch case where the instance is not known + @instance_id = first.id unless first.nil? + else + @instance_id = @opts + end end end alias instance_id id @@ -61,7 +78,9 @@ EOX # returns the instance state def state - instance&.state&.name + catch_aws_errors do + instance&.state&.name + end end # helper methods for each state @@ -82,18 +101,24 @@ EOX instance_type image_id vpc_id }.each do |attribute| define_method attribute do - instance.send(attribute) if instance + catch_aws_errors do + instance.send(attribute) if instance + end end end def security_groups - @security_groups ||= instance.security_groups.map { |sg| - { id: sg.group_id, name: sg.group_name } - } + catch_aws_errors do + @security_groups ||= instance.security_groups.map { |sg| + { id: sg.group_id, name: sg.group_name } + } + end end def tags - @tags ||= instance.tags.map { |tag| { key: tag.key, value: tag.value } } + catch_aws_errors do + @tags ||= instance.tags.map { |tag| { key: tag.key, value: tag.value } } + end end def to_s @@ -101,23 +126,25 @@ EOX end def has_roles? - instance_profile = instance.iam_instance_profile + catch_aws_errors do + instance_profile = instance.iam_instance_profile - if instance_profile - roles = @iam_resource.instance_profile( - instance_profile.arn.gsub(%r{^.*\/}, ''), - ).roles - else - roles = nil + if instance_profile + roles = @iam_resource.instance_profile( + instance_profile.arn.gsub(%r{^.*\/}, ''), + ).roles + else + roles = nil + end + + roles && !roles.empty? end - - roles && !roles.empty? end private def instance - @instance ||= @ec2_resource.instance(id) + catch_aws_errors { @instance ||= @ec2_resource.instance(id) } end end diff --git a/lib/resources/aws/aws_iam_access_key.rb b/lib/resources/aws/aws_iam_access_key.rb index 2f7ae9725..af1bc7255 100644 --- a/lib/resources/aws/aws_iam_access_key.rb +++ b/lib/resources/aws/aws_iam_access_key.rb @@ -54,7 +54,9 @@ class AwsIamAccessKey < Inspec.resource(1) return nil unless exists? return @last_used_date if defined? @last_used_date backend = BackendFactory.create(inspec_runner) - @last_used_date = backend.get_access_key_last_used({ access_key_id: access_key_id }).access_key_last_used.last_used_date + catch_aws_errors do + @last_used_date = backend.get_access_key_last_used({ access_key_id: access_key_id }).access_key_last_used.last_used_date + end end def fetch_from_api diff --git a/lib/resources/aws/aws_iam_password_policy.rb b/lib/resources/aws/aws_iam_password_policy.rb index a4fc7c065..700819553 100644 --- a/lib/resources/aws/aws_iam_password_policy.rb +++ b/lib/resources/aws/aws_iam_password_policy.rb @@ -16,12 +16,29 @@ EOX # TODO: rewrite to avoid direct injection, match other resources, use AwsSingularResourceMixin def initialize(conn = nil) - iam_resource = conn ? conn.iam_resource : inspec_runner.backend.aws_resource(Aws::IAM::Resource, {}) - @policy = iam_resource.account_password_policy + catch_aws_errors do + iam_resource = conn ? conn.iam_resource : inspec_runner.backend.aws_resource(Aws::IAM::Resource, {}) + @policy = iam_resource.account_password_policy + end rescue Aws::IAM::Errors::NoSuchEntity @policy = nil end + # TODO: DRY up, see https://github.com/chef/inspec/issues/2633 + # Copied from resource_support/aws/aws_resource_mixin.rb + def catch_aws_errors + yield + rescue Aws::Errors::MissingCredentialsError + # The AWS error here is unhelpful: + # "unable to sign request without credentials set" + Inspec::Log.error "It appears that you have not set your AWS credentials. You may set them using environment variables, or using the 'aws://region/aws_credentials_profile' target. See https://www.inspec.io/docs/reference/platforms for details." + fail_resource('No AWS credentials available') + rescue Aws::Errors::ServiceError => e + fail_resource e.message + end + + # TODO: DRY up, see https://github.com/chef/inspec/issues/2633 + # Copied from resource_support/aws/aws_singular_resource_mixin.rb def inspec_runner # When running under inspec-cli, we have an 'inspec' method that # returns the runner. When running under unit tests, we don't diff --git a/lib/resources/aws/aws_iam_policy.rb b/lib/resources/aws/aws_iam_policy.rb index 187cbde6a..b8f280be3 100644 --- a/lib/resources/aws/aws_iam_policy.rb +++ b/lib/resources/aws/aws_iam_policy.rb @@ -93,7 +93,10 @@ class AwsIamPolicy < Inspec.resource(1) end backend = AwsIamPolicy::BackendFactory.create(inspec_runner) criteria = { policy_arn: arn } - resp = backend.list_entities_for_policy(criteria) + resp = nil + catch_aws_errors do + resp = backend.list_entities_for_policy(criteria) + end @attached_groups = resp.policy_groups.map(&:group_name) @attached_users = resp.policy_users.map(&:user_name) @attached_roles = resp.policy_roles.map(&:role_name) diff --git a/lib/resources/aws/aws_iam_root_user.rb b/lib/resources/aws/aws_iam_root_user.rb index f2fd19502..4dd839d89 100644 --- a/lib/resources/aws/aws_iam_root_user.rb +++ b/lib/resources/aws/aws_iam_root_user.rb @@ -13,6 +13,21 @@ class AwsIamRootUser < Inspec.resource(1) @client = conn ? conn.iam_client : inspec_runner.backend.aws_client(Aws::IAM::Client) end + # TODO: DRY up, see https://github.com/chef/inspec/issues/2633 + # Copied from resource_support/aws/aws_resource_mixin.rb + def catch_aws_errors + yield + rescue Aws::Errors::MissingCredentialsError + # The AWS error here is unhelpful: + # "unable to sign request without credentials set" + Inspec::Log.error "It appears that you have not set your AWS credentials. You may set them using environment variables, or using the 'aws://region/aws_credentials_profile' target. See https://www.inspec.io/docs/reference/platforms for details." + fail_resource('No AWS credentials available') + rescue Aws::Errors::ServiceError => e + fail_resource e.message + end + + # TODO: DRY up, see https://github.com/chef/inspec/issues/2633 + # Copied from resource_support/aws/aws_singular_resource_mixin.rb def inspec_runner # When running under inspec-cli, we have an 'inspec' method that # returns the runner. When running under unit tests, we don't @@ -38,6 +53,8 @@ class AwsIamRootUser < Inspec.resource(1) private def summary_account - @summary_account ||= @client.get_account_summary.summary_map + catch_aws_errors do + @summary_account ||= @client.get_account_summary.summary_map + end end end diff --git a/lib/resources/aws/aws_iam_user.rb b/lib/resources/aws/aws_iam_user.rb index 218e55bc0..96ed15fdc 100644 --- a/lib/resources/aws/aws_iam_user.rb +++ b/lib/resources/aws/aws_iam_user.rb @@ -82,6 +82,8 @@ class AwsIamUser < Inspec.resource(1) # TODO: consider returning Inspec AwsIamAccessKey objects @access_keys = backend.list_access_keys(user_name: username).access_key_metadata + # If the above call fails, we get nil here; but we promise access_keys will be an array. + @access_keys ||= [] end class Backend diff --git a/lib/resources/aws/aws_s3_bucket.rb b/lib/resources/aws/aws_s3_bucket.rb index 03a1f9ba2..c7ee0e6cc 100644 --- a/lib/resources/aws/aws_s3_bucket.rb +++ b/lib/resources/aws/aws_s3_bucket.rb @@ -10,15 +10,16 @@ class AwsS3Bucket < Inspec.resource(1) supports platform: 'aws' include AwsSingularResourceMixin - attr_reader :bucket_name, :region + attr_reader :bucket_name, :has_access_logging_enabled, :region def to_s "S3 Bucket #{@bucket_name}" end def bucket_acl - # This is simple enough to inline it. - @bucket_acl ||= BackendFactory.create(inspec_runner).get_bucket_acl(bucket: bucket_name).grants + catch_aws_errors do + @bucket_acl ||= BackendFactory.create(inspec_runner).get_bucket_acl(bucket: bucket_name).grants + end end def bucket_policy @@ -36,8 +37,9 @@ class AwsS3Bucket < Inspec.resource(1) def has_access_logging_enabled? return unless @exists - # This is simple enough to inline it. - !BackendFactory.create(inspec_runner).get_bucket_logging(bucket: bucket_name).logging_enabled.nil? + catch_aws_errors do + @has_access_logging_enabled ||= !BackendFactory.create(inspec_runner).get_bucket_logging(bucket: bucket_name).logging_enabled.nil? + end end private @@ -72,17 +74,18 @@ class AwsS3Bucket < Inspec.resource(1) def fetch_bucket_policy backend = BackendFactory.create(inspec_runner) - - begin - # AWS SDK returns a StringIO, we have to read() - raw_policy = backend.get_bucket_policy(bucket: bucket_name).policy - return JSON.parse(raw_policy.read)['Statement'].map do |statement| - lowercase_hash = {} - statement.each_key { |k| lowercase_hash[k.downcase] = statement[k] } - OpenStruct.new(lowercase_hash) + catch_aws_errors do + begin + # AWS SDK returns a StringIO, we have to read() + raw_policy = backend.get_bucket_policy(bucket: bucket_name).policy + return JSON.parse(raw_policy.read)['Statement'].map do |statement| + lowercase_hash = {} + statement.each_key { |k| lowercase_hash[k.downcase] = statement[k] } + @bucket_policy = OpenStruct.new(lowercase_hash) + end + rescue Aws::S3::Errors::NoSuchBucketPolicy + @bucket_policy = [] end - rescue Aws::S3::Errors::NoSuchBucketPolicy - return [] end end diff --git a/lib/resources/azure/azure_backend.rb b/lib/resources/azure/azure_backend.rb index 46149ec17..99cb234a8 100644 --- a/lib/resources/azure/azure_backend.rb +++ b/lib/resources/azure/azure_backend.rb @@ -37,19 +37,40 @@ module Inspec::Resources @azure = inspec.backend @client = azure.azure_client + @failed_resource = false + end + + def failed_resource? + @failed_resource + end + + def catch_azure_errors + yield + rescue MsRestAzure::AzureOperationError => e + # e.message is actually a massive stringified JSON, which might be useful in the future. + # You want error_message here. + fail_resource e.error_message + @failed_resource = true + nil end # Return information about the resource group def resource_group - resource_group = client.resource_groups.get(opts[:group_name]) + catch_azure_errors do + resource_group = client.resource_groups.get(opts[:group_name]) - # create the methods for the resource group object - dm = AzureResourceDynamicMethods.new - dm.create_methods(self, resource_group) + # create the methods for the resource group object + dm = AzureResourceDynamicMethods.new + dm.create_methods(self, resource_group) + end end def resources - resources = client.resources.list_by_resource_group(opts[:group_name]) + resources = nil + catch_azure_errors do + resources = client.resources.list_by_resource_group(opts[:group_name]) + end + return if failed_resource? # filter the resources based on the type, and the name if they been specified resources = filter_resources(resources, opts) @@ -58,11 +79,15 @@ module Inspec::Resources if resources.count == 1 @total = 1 - # get the apiversion for the resource, if one has not been specified - apiversion = azure.get_api_version(resources[0].type, opts) + resource = nil + catch_azure_errors do + # get the apiversion for the resource, if one has not been specified + apiversion = azure.get_api_version(resources[0].type, opts) - # get the resource by id so it can be interrogated - resource = client.resources.get_by_id(resources[0].id, apiversion) + # get the resource by id so it can be interrogated + resource = client.resources.get_by_id(resources[0].id, apiversion) + end + return if failed_resource? dm = AzureResourceDynamicMethods.new diff --git a/lib/resources/azure/azure_resource_group.rb b/lib/resources/azure/azure_resource_group.rb index 969c1c8aa..e73ec23f1 100644 --- a/lib/resources/azure/azure_resource_group.rb +++ b/lib/resources/azure/azure_resource_group.rb @@ -132,6 +132,7 @@ module Inspec::Resources # @author Russell Seymour # @private def create_has_methods + return if failed_resource? # Create the has methods for each of the mappings # This is a quick test to show that the resource group has at least one of these things mapping.each do |name, type| diff --git a/lib/resources/azure/azure_virtual_machine_data_disk.rb b/lib/resources/azure/azure_virtual_machine_data_disk.rb index fdaca68a9..f879395d5 100644 --- a/lib/resources/azure/azure_virtual_machine_data_disk.rb +++ b/lib/resources/azure/azure_virtual_machine_data_disk.rb @@ -54,6 +54,7 @@ module Inspec::Resources # # @author Russell Seymour def datadisk_details + return if failed_resource? # Iterate around the data disks on the machine properties.storageProfile.dataDisks.each_with_index.map do |datadisk, index| # Call function to parse the data disks and return an object based on the parameters From e77b99235ff741b16a175d5c78799bf8660594ff Mon Sep 17 00:00:00 2001 From: Jerry Aldrich Date: Wed, 14 Feb 2018 14:06:39 -0600 Subject: [PATCH 197/206] Update `inspec detect` to support APIs/Families (#2634) This does the following to `inspec detect`: - Modifies it to use the `platform` resource - Changes the output to mention Platform and show the family hierarchy - Changes the JSON output by changing `family` to `families` - Adds better error messaging (no more stacktraces!) - Adds support for APIs such as AWS/Azure - Hides Arch from API platforms (not applicable) Signed-off-by: Jerry Aldrich --- lib/inspec/cli.rb | 20 ++++++--- lib/resources/os.rb | 10 ----- lib/resources/platform.rb | 19 ++++++++- test/functional/inspec_detect_test.rb | 59 +++++++++++++++++++++++++++ test/functional/inspec_shell_test.rb | 6 +-- test/functional/inspec_test.rb | 29 ------------- 6 files changed, 94 insertions(+), 49 deletions(-) create mode 100644 test/functional/inspec_detect_test.rb diff --git a/lib/inspec/cli.rb b/lib/inspec/cli.rb index 96e7163be..82fea7ab7 100644 --- a/lib/inspec/cli.rb +++ b/lib/inspec/cli.rb @@ -178,17 +178,27 @@ class Inspec::InspecCLI < Inspec::BaseCLI option :format, type: :string def detect o = opts(:detect).dup - o[:command] = 'os.params' + o[:command] = 'platform.params' (_, res) = run_command(o) if o['format'] == 'json' puts res.to_json else - headline('Operating System Details') - %w{name family release arch}.each { |item| - puts format('%-10s %s', item.to_s.capitalize + ':', - mark_text(res[item.to_sym])) + headline('Platform Details') + %w{name families release arch}.each { |item| + data = res[item.to_sym] + + # Format Array for better output if applicable + data = data.join(', ') if data.is_a?(Array) + + # Do not output fields of data is missing ('unknown' is fine) + next if data.nil? + + puts format('%-10s %s', item.to_s.capitalize + ':', mark_text(data)) } end + rescue ArgumentError, RuntimeError, Train::UserError => e + $stderr.puts e.message + exit 1 rescue StandardError => e pretty_handle_exception(e) end diff --git a/lib/resources/os.rb b/lib/resources/os.rb index 408ef5ef4..860977fea 100644 --- a/lib/resources/os.rb +++ b/lib/resources/os.rb @@ -29,16 +29,6 @@ module Inspec::Resources end end - # helper to collect a hash object easily - def params - { - name: name, - family: @platform[:family], - release: @platform[:release], - arch: @platform[:arch], - } - end - def to_s 'Operating System Detection' end diff --git a/lib/resources/platform.rb b/lib/resources/platform.rb index b64ff89cc..716b06111 100644 --- a/lib/resources/platform.rb +++ b/lib/resources/platform.rb @@ -25,6 +25,10 @@ module Inspec::Resources end end + def families + @platform.family_hierarchy + end + def name @platform.name end @@ -46,8 +50,19 @@ module Inspec::Resources @platform.family_hierarchy.include?(family) end - def families - @platform.family_hierarchy + def params + h = { + name: name, + families: families, + release: release, + } + + # Avoid adding Arch for APIs (not applicable) + unless in_family?('api') + h[:arch] = arch + end + + h end def supported?(supports) diff --git a/test/functional/inspec_detect_test.rb b/test/functional/inspec_detect_test.rb new file mode 100644 index 000000000..7a9e41592 --- /dev/null +++ b/test/functional/inspec_detect_test.rb @@ -0,0 +1,59 @@ +require 'functional/helper' + +describe 'inspec detect' do + include FunctionalHelper + + it 'outputs the correct data' do + res = inspec('detect') + res.stderr.must_equal '' + res.exit_status.must_equal 0 + + stdout = res.stdout + stdout.must_include "\n== Platform Details\n\n" + stdout.must_include "\nName: \e[0;36m" + stdout.must_include "\nFamilies: \e[0;36m" + stdout.must_include "\nArch: \e[0;36m" + stdout.must_include "\nRelease: \e[0;36m" + end + + it 'outputs the correct data when target the target an API' do + res = inspec('detect -t aws://') + res.stderr.must_equal '' + res.exit_status.must_equal 0 + + stdout = res.stdout + stdout.must_include "\n== Platform Details\n\n" + stdout.must_include "\nName: \e[0;36m" + stdout.must_include "\nFamilies: \e[0;36m" + stdout.must_include "\nRelease: \e[0;36m" + + stdout.wont_include "\nArch:" + end + + describe 'when `--format json` is used`' do + it 'outputs the correct JSON data' do + res = inspec('detect --format json') + res.stderr.must_equal '' + res.exit_status.must_equal 0 + + json = JSON.parse(res.stdout) + json.keys.must_include 'name' + json.keys.must_include 'families' + json.keys.must_include 'arch' + json.keys.must_include 'release' + end + + it 'outputs the correct JSON data when the target an API' do + res = inspec('detect -t aws:// --format json') + res.stderr.must_equal '' + res.exit_status.must_equal 0 + + json = JSON.parse(res.stdout) + json.keys.must_include 'name' + json.keys.must_include 'families' + json.keys.must_include 'release' + + json.keys.wont_include 'arch' + end + end +end diff --git a/test/functional/inspec_shell_test.rb b/test/functional/inspec_shell_test.rb index 4feb44e09..db2f1ac97 100644 --- a/test/functional/inspec_shell_test.rb +++ b/test/functional/inspec_shell_test.rb @@ -58,10 +58,10 @@ describe 'inspec shell tests' do end it 'retrieves resources (json output)' do - out = do_shell_c('os.params', 0, true) + out = do_shell_c('platform.params', 0, true) j = JSON.load(out.stdout) j.keys.must_include 'name' - j.keys.must_include 'family' + j.keys.must_include 'families' j.keys.must_include 'arch' j.keys.must_include 'release' end @@ -69,7 +69,7 @@ describe 'inspec shell tests' do it 'retrieves resources' do out = do_shell_c('os.params', 0) out.stdout.must_include 'name' - out.stdout.must_include 'family' + out.stdout.must_include 'families' out.stdout.must_include 'arch' out.stdout.must_include 'release' end diff --git a/test/functional/inspec_test.rb b/test/functional/inspec_test.rb index c1222bbef..599e2458c 100644 --- a/test/functional/inspec_test.rb +++ b/test/functional/inspec_test.rb @@ -1,39 +1,10 @@ # encoding: utf-8 -# author: Dominik Richter -# author: Christoph Hartmann require 'functional/helper' describe 'command tests' do include FunctionalHelper - describe 'detect with json' do - it 'runs well on all nodes' do - out = inspec('detect --format json') - out.stderr.must_equal '' - out.exit_status.must_equal 0 - j = JSON.load(out.stdout) - j.keys.must_include 'name' - j.keys.must_include 'family' - j.keys.must_include 'arch' - j.keys.must_include 'release' - end - end - - describe 'detect without json' do - it 'runs well on all nodes' do - out = inspec('detect') - out.stderr.must_equal '' - out.exit_status.must_equal 0 - std = out.stdout - std.must_include "\n== Operating System Details\n\n" - std.must_include "\nName: \e[0;36m" - std.must_include "\nFamily: \e[0;36m" - std.must_include "\nArch: \e[0;36m" - std.must_include "\nRelease: \e[0;36m" - end - end - describe 'version' do it 'provides the version number on stdout' do out = inspec('version') From 33787124a7a284658e00508c31256f033ad242cc Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Wed, 14 Feb 2018 15:08:34 -0500 Subject: [PATCH 198/206] Two deprecations in aws_ec2_instance (#2637) * Drop deprecation warning for old name of aws_ec2 Signed-off-by: Clinton Wolfe --- docs/resources/aws_ec2_instance.md | 2 +- lib/resources/aws/aws_ec2_instance.rb | 23 +++++++------------ .../resources/aws_iam_password_policy_test.rb | 2 ++ 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/docs/resources/aws_ec2_instance.md b/docs/resources/aws_ec2_instance.md index aec53f8a2..b64d51b9e 100644 --- a/docs/resources/aws_ec2_instance.md +++ b/docs/resources/aws_ec2_instance.md @@ -55,7 +55,7 @@ The following examples show how to use this InSpec audit resource. ## Properties -* `architecture`, `client_token`, `image_id`,`instance_type`, `key_name`, `launch_time`,`private_ip_address`, `private_dns_name`, `public_dns_name`, `public_ip_address`, `root_device_type`, `root_device_name`, `subnet_id`, `tags`,`virtualization_type`, `vpc_id` +* `architecture`, `client_token`, `image_id`,`instance_type`, `key_name`, `launch_time`,`private_ip_address`, `private_dns_name`, `public_dns_name`, `public_ip_address`, `root_device_type`, `root_device_name`, `security_group_ids`, `subnet_id`, `tags`,`virtualization_type`, `vpc_id`
diff --git a/lib/resources/aws/aws_ec2_instance.rb b/lib/resources/aws/aws_ec2_instance.rb index 73a67dc50..0864c41df 100644 --- a/lib/resources/aws/aws_ec2_instance.rb +++ b/lib/resources/aws/aws_ec2_instance.rb @@ -107,6 +107,8 @@ EOX end end + # Don't document this - it's a bit hard to use. Our current doctrine + # is to use dumb things, like arrays of strings - use security_group_ids instead. def security_groups catch_aws_errors do @security_groups ||= instance.security_groups.map { |sg| @@ -115,6 +117,12 @@ EOX end end + def security_group_ids + catch_aws_errors do + @security_group_ids ||= instance.security_groups.map(&:group_id) + end + end + def tags catch_aws_errors do @tags ||= instance.tags.map { |tag| { key: tag.key, value: tag.value } } @@ -147,18 +155,3 @@ EOX catch_aws_errors { @instance ||= @ec2_resource.instance(id) } end end - -# Deprecated -class AwsEc2 < AwsEc2Instance - name 'aws_ec2' - - def initialize(opts, conn = nil) - deprecated - super(opts, conn) - end - - def deprecated - warn '[DEPRECATION] `aws_ec2(parameter)` is deprecated. ' \ - 'Please use `aws_ec2_instance(parameter)` instead.' - end -end diff --git a/test/unit/resources/aws_iam_password_policy_test.rb b/test/unit/resources/aws_iam_password_policy_test.rb index 35aedb7c9..a19908ee7 100644 --- a/test/unit/resources/aws_iam_password_policy_test.rb +++ b/test/unit/resources/aws_iam_password_policy_test.rb @@ -15,7 +15,9 @@ class AwsIamPasswordPolicyTest < Minitest::Test assert_equal true, AwsIamPasswordPolicy.new(@mock_conn).exists? end + def test_policy_does_not_exists_when_no_policy + skip "Disabled until fix for issue 2633" @mock_resource.expect :account_password_policy, nil do raise Aws::IAM::Errors::NoSuchEntity.new nil, nil end From 6c0422fbf0af767453e909785649dc4dd04cd911 Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Wed, 14 Feb 2018 15:59:57 -0500 Subject: [PATCH 199/206] Improvements and matcher renaming on aws_iam_password_policy (#2638) Signed-off-by: Clinton Wolfe --- docs/resources/aws_iam_password_policy.md | 24 +++--- lib/resources/aws/aws_iam_password_policy.rb | 86 +++++++++++-------- test/aws/default/build/iam.tf | 15 ++++ .../controls/aws_iam_password_policy.rb | 33 +++++++ .../controls/aws_iam_password_policy.rb | 14 +++ .../resources/aws_iam_password_policy_test.rb | 6 +- 6 files changed, 127 insertions(+), 51 deletions(-) create mode 100644 test/aws/default/verify/controls/aws_iam_password_policy.rb create mode 100644 test/aws/minimal/verify/controls/aws_iam_password_policy.rb diff --git a/docs/resources/aws_iam_password_policy.md b/docs/resources/aws_iam_password_policy.md index 1ace36c46..b326591b5 100644 --- a/docs/resources/aws_iam_password_policy.md +++ b/docs/resources/aws_iam_password_policy.md @@ -11,17 +11,17 @@ Use the `aws_iam_password_policy` InSpec audit resource to test properties of th ## Syntax -An `aws_iam_password_policy` resource block takes no parameters, but uses several matchers. +An `aws_iam_password_policy` resource block takes no parameters. Several properties and matchers are available. describe aws_iam_password_policy do - its('requires_lowercase_characters?') { should be true } + it { should require_lowercase_characters } end
## Properties -* `allows_users_to_change_password?`, `expires_passwords`, `max_password_age`, `minimum_password_length`, `number_of_passwords_to_remember`, `prevents_password_reuse?`, `requires_lowercase_characters` , `requires_uppercase_characters?`, `requires_numbers?`, `requires_symbols?` +* `max_password_age_in_days`, `minimum_password_length`, `number_of_passwords_to_remember` ## Examples @@ -30,35 +30,35 @@ The following examples show how to use this InSpec audit resource. ### Test that the IAM Password Policy requires lowercase characters, uppercase characters, numbers, symbols, and a minimum length greater than eight describe aws_iam_password_policy do - its('requires_lowercase_characters?') { should be true } - its('requires_uppercase_characters?') { should be true } - its('requires_numbers?') { should be true } - its('requires_symbols?') { should be true } + it { should require_lowercase_characters } + it { should require_uppercase_characters } + it { should require_symbols } + it { should require_numbers } its('minimum_password_length') { should be > 8 } end ### Test that the IAM Password Policy allows users to change their password describe aws_iam_password_policy do - its('allows_user_to_change_password?') { should be true } + it { should allow_users_to_change_passwords } end ### Test that the IAM Password Policy expires passwords describe aws_iam_password_policy do - its('expires_passwords?') { should be true } + it { should expire_passwords } end ### Test that the IAM Password Policy has a max password age describe aws_iam_password_policy do - its('max_password_age') { should be > 90 * 86400 } + its('max_password_age_in_days') { should be 90 } end ### Test that the IAM Password Policy prevents password reuse describe aws_iam_password_policy do - its('prevents_password_reuse?') { should be true } + it { should prevent_password_reuse } end ### Test that the IAM Password Policy requires users to remember 3 previous passwords @@ -71,4 +71,4 @@ The following examples show how to use this InSpec audit resource. ## Matchers -For a full list of available matchers please visit our [matchers page](https://www.inspec.io/docs/reference/matchers/). +* `allows_users_to_change_passwords`, `expire_passwords`, `prevent_password_reuse`, `require_lowercase_characters` , `require_uppercase_characters`, `require_numbers`, `require_symbols` diff --git a/lib/resources/aws/aws_iam_password_policy.rb b/lib/resources/aws/aws_iam_password_policy.rb index 700819553..f781b6ae8 100644 --- a/lib/resources/aws/aws_iam_password_policy.rb +++ b/lib/resources/aws/aws_iam_password_policy.rb @@ -17,11 +17,20 @@ EOX # TODO: rewrite to avoid direct injection, match other resources, use AwsSingularResourceMixin def initialize(conn = nil) catch_aws_errors do - iam_resource = conn ? conn.iam_resource : inspec_runner.backend.aws_resource(Aws::IAM::Resource, {}) - @policy = iam_resource.account_password_policy + begin + if conn + # We're in a mocked unit test. + @policy = conn.iam_resource.account_password_policy + else + # Don't use the resource approach. It's a CRUD operation + # - if the policy does not exist, you get back a blank object to populate and save. + # Using the Client will throw an exception if no policy exists. + @policy = inspec_runner.backend.aws_client(Aws::IAM::Client).get_account_password_policy.password_policy + end + rescue Aws::IAM::Errors::NoSuchEntity + @policy = nil + end end - rescue Aws::IAM::Errors::NoSuchEntity - @policy = nil end # TODO: DRY up, see https://github.com/chef/inspec/issues/2633 @@ -49,54 +58,59 @@ EOX inspec if respond_to?(:inspec) end + def to_s + 'IAM Password-Policy' + end + def exists? !@policy.nil? end - def requires_lowercase_characters? - @policy.require_lowercase_characters - end - - def requires_uppercase_characters? - @policy.require_uppercase_characters - end + #-------------------------- Properties ----------------------------# def minimum_password_length @policy.minimum_password_length end - def requires_numbers? - @policy.require_numbers - end - - def requires_symbols? - @policy.require_symbols - end - - def allows_users_to_change_password? - @policy.allow_users_to_change_password - end - - def expires_passwords? - @policy.expire_passwords - end - - def max_password_age - raise 'this policy does not expire passwords' unless expires_passwords? + def max_password_age_in_days + raise 'this policy does not expire passwords' unless expire_passwords? @policy.max_password_age end - def prevents_password_reuse? - !@policy.password_reuse_prevention.nil? - end - def number_of_passwords_to_remember raise 'this policy does not prevent password reuse' \ - unless prevents_password_reuse? + unless prevent_password_reuse? @policy.password_reuse_prevention end - def to_s - 'IAM Password-Policy' + #-------------------------- Matchers ----------------------------# + [ + :require_lowercase_characters, + :require_uppercase_characters, + :require_symbols, + :require_numbers, + :expire_passwords, + ].each do |matcher_stem| + # Create our predicates (for example, 'require_symbols?') + stem_with_question_mark = (matcher_stem.to_s + '?').to_sym + define_method stem_with_question_mark do + @policy.send(matcher_stem) + end + # RSpec will expose that as (for example) `be_require_symbols`. + # To undo that, we have to make a matcher alias. + stem_with_be = ('be_' + matcher_stem.to_s).to_sym + RSpec::Matchers.alias_matcher matcher_stem, stem_with_be end + + # This one has an awkward name mapping + def allow_users_to_change_passwords? + @policy.allow_users_to_change_password + end + RSpec::Matchers.alias_matcher :allow_users_to_change_passwords, :be_allow_users_to_change_passwords + + # This one has custom logic and renaming + def prevent_password_reuse? + !@policy.password_reuse_prevention.nil? + end + RSpec::Matchers.alias_matcher :prevent_password_reuse, :be_prevent_password_reuse end diff --git a/test/aws/default/build/iam.tf b/test/aws/default/build/iam.tf index 8bf5c978f..5c34f6134 100644 --- a/test/aws/default/build/iam.tf +++ b/test/aws/default/build/iam.tf @@ -6,6 +6,21 @@ variable "login_profile_pgp_key" { default = "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" } +#======================================================# +# Accoount Password Policy +#======================================================# +# Only one of these is allowed +resource "aws_iam_account_password_policy" "fixture" { + minimum_password_length = 10 + require_lowercase_characters = true + require_numbers = true + require_uppercase_characters = true + require_symbols = true + allow_users_to_change_password = true + max_password_age = 365 + password_reuse_prevention = 7 +} + #======================================================# # IAM Users #======================================================# diff --git a/test/aws/default/verify/controls/aws_iam_password_policy.rb b/test/aws/default/verify/controls/aws_iam_password_policy.rb new file mode 100644 index 000000000..ef6fd46ab --- /dev/null +++ b/test/aws/default/verify/controls/aws_iam_password_policy.rb @@ -0,0 +1,33 @@ +# There are other tests in the "minimal" test account. + +#---------------------- Recall ------------------------# +# Password policy is a per-account singleton. If it's been configured, it exists. +control "aws_iam_password_policy existence" do + describe aws_iam_password_policy do + it { should exist } + end +end + +#------------- Properties -------------# + +control "aws_iam_password_policy properties" do + describe aws_iam_password_policy do + its('max_password_age_in_days') { should cmp 365 } + its('number_of_passwords_to_remember') { should cmp 7 } + end +end + +#------------- Matchers - Positive Case -------------# + +control "aws_iam_password_policy matchers" do + describe aws_iam_password_policy do + it { should require_lowercase_characters } + it { should require_uppercase_characters } + it { should require_numbers } + it { should require_symbols } + it { should allow_users_to_change_passwords } + it { should expire_passwords } + it { should prevent_password_reuse } + end +end + diff --git a/test/aws/minimal/verify/controls/aws_iam_password_policy.rb b/test/aws/minimal/verify/controls/aws_iam_password_policy.rb new file mode 100644 index 000000000..00f3d7bf5 --- /dev/null +++ b/test/aws/minimal/verify/controls/aws_iam_password_policy.rb @@ -0,0 +1,14 @@ + +#---------------------- Recall ------------------------# +# Password policy is a per-account singleton. If it's been configured, it exists. +control "aws_iam_password_policy properties" do + describe aws_iam_password_policy do + it { should_not exist } + end +end + +#------------- Properties - Negative Case -------------# +# No negative tests yet - we'd need a third account + +#------------- Matchers - Negative Case -------------# +# No negative tests yet - we'd need a third account \ No newline at end of file diff --git a/test/unit/resources/aws_iam_password_policy_test.rb b/test/unit/resources/aws_iam_password_policy_test.rb index a19908ee7..1dcf25245 100644 --- a/test/unit/resources/aws_iam_password_policy_test.rb +++ b/test/unit/resources/aws_iam_password_policy_test.rb @@ -30,7 +30,7 @@ class AwsIamPasswordPolicyTest < Minitest::Test @mock_resource.expect :account_password_policy, @mock_policy e = assert_raises Exception do - AwsIamPasswordPolicy.new(@mock_conn).max_password_age + AwsIamPasswordPolicy.new(@mock_conn).max_password_age_in_days end assert_equal e.message, 'this policy does not expire passwords' @@ -39,13 +39,13 @@ class AwsIamPasswordPolicyTest < Minitest::Test def test_prevents_password_reuse_returns_true_when_not_nil configure_policy_password_reuse_prevention(value: Object.new) - assert AwsIamPasswordPolicy.new(@mock_conn).prevents_password_reuse? + assert AwsIamPasswordPolicy.new(@mock_conn).prevent_password_reuse? end def test_prevents_password_reuse_returns_false_when_nil configure_policy_password_reuse_prevention(value: nil) - refute AwsIamPasswordPolicy.new(@mock_conn).prevents_password_reuse? + refute AwsIamPasswordPolicy.new(@mock_conn).prevent_password_reuse? end def test_number_of_passwords_to_remember_throws_when_nil From 11d46622cca9250b9ad9e6f32d8cf6fdeb5df44e Mon Sep 17 00:00:00 2001 From: kagarmoe Date: Wed, 14 Feb 2018 13:28:36 -0800 Subject: [PATCH 200/206] Changes aws docs extensions to .md.erb Signed-off-by: kagarmoe --- .../{aws_cloudtrail_trail.md => aws_cloudtrail_trail.md.erb} | 0 .../{aws_cloudtrail_trails.md => aws_cloudtrail_trails.md.erb} | 0 .../{aws_cloudwatch_alarm.md => aws_cloudwatch_alarm.md.erb} | 0 ...g_metric_filter.md => aws_cloudwatch_log_metric_filter.md.erb} | 0 docs/resources/{aws_ec2_instance.md => aws_ec2_instance.md.erb} | 0 .../{aws_iam_access_key.md => aws_iam_access_key.md.erb} | 0 .../{aws_iam_access_keys.md => aws_iam_access_keys.md.erb} | 0 docs/resources/{aws_iam_group.md => aws_iam_group.md.erb} | 0 docs/resources/{aws_iam_groups.md => aws_iam_groups.md.erb} | 0 ...{aws_iam_password_policy.md => aws_iam_password_policy.md.erb} | 0 docs/resources/{aws_iam_policies.md => aws_iam_policies.md.erb} | 0 docs/resources/{aws_iam_policy.md => aws_iam_policy.md.erb} | 0 docs/resources/{aws_iam_role.md => aws_iam_role.md.erb} | 0 docs/resources/{aws_iam_root_user.md => aws_iam_root_user.md.erb} | 0 docs/resources/{aws_iam_user.md => aws_iam_user.md.erb} | 0 docs/resources/{aws_iam_users.md => aws_iam_users.md.erb} | 0 docs/resources/{aws_kms_keys.md => aws_kms_keys.md.erb} | 0 docs/resources/{aws_route_table.md => aws_route_table.md.erb} | 0 docs/resources/{aws_s3_bucket.md => aws_s3_bucket.md.erb} | 0 .../{aws_security_group.md => aws_security_group.md.erb} | 0 .../{aws_security_groups.md => aws_security_groups.md.erb} | 0 docs/resources/{aws_sns_topic.md => aws_sns_topic.md.erb} | 0 docs/resources/{aws_subnet.md => aws_subnet.md.erb} | 0 docs/resources/{aws_subnets.md => aws_subnets.md.erb} | 0 docs/resources/{aws_vpc.md => aws_vpc.md.erb} | 0 docs/resources/{aws_vpcs.md => aws_vpcs.md.erb} | 0 .../{azure_generic_resource.md => azure_generic_resource.md.erb} | 0 .../{azure_resource_group.md => azure_resource_group.md.erb} | 0 .../{azure_virtual_machine.md => azure_virtual_machine.md.erb} | 0 ..._machine_datadisk.md => azure_virtual_machine_datadisk.md.erb} | 0 30 files changed, 0 insertions(+), 0 deletions(-) rename docs/resources/{aws_cloudtrail_trail.md => aws_cloudtrail_trail.md.erb} (100%) rename docs/resources/{aws_cloudtrail_trails.md => aws_cloudtrail_trails.md.erb} (100%) rename docs/resources/{aws_cloudwatch_alarm.md => aws_cloudwatch_alarm.md.erb} (100%) rename docs/resources/{aws_cloudwatch_log_metric_filter.md => aws_cloudwatch_log_metric_filter.md.erb} (100%) rename docs/resources/{aws_ec2_instance.md => aws_ec2_instance.md.erb} (100%) rename docs/resources/{aws_iam_access_key.md => aws_iam_access_key.md.erb} (100%) rename docs/resources/{aws_iam_access_keys.md => aws_iam_access_keys.md.erb} (100%) rename docs/resources/{aws_iam_group.md => aws_iam_group.md.erb} (100%) rename docs/resources/{aws_iam_groups.md => aws_iam_groups.md.erb} (100%) rename docs/resources/{aws_iam_password_policy.md => aws_iam_password_policy.md.erb} (100%) rename docs/resources/{aws_iam_policies.md => aws_iam_policies.md.erb} (100%) rename docs/resources/{aws_iam_policy.md => aws_iam_policy.md.erb} (100%) rename docs/resources/{aws_iam_role.md => aws_iam_role.md.erb} (100%) rename docs/resources/{aws_iam_root_user.md => aws_iam_root_user.md.erb} (100%) rename docs/resources/{aws_iam_user.md => aws_iam_user.md.erb} (100%) rename docs/resources/{aws_iam_users.md => aws_iam_users.md.erb} (100%) rename docs/resources/{aws_kms_keys.md => aws_kms_keys.md.erb} (100%) rename docs/resources/{aws_route_table.md => aws_route_table.md.erb} (100%) rename docs/resources/{aws_s3_bucket.md => aws_s3_bucket.md.erb} (100%) rename docs/resources/{aws_security_group.md => aws_security_group.md.erb} (100%) rename docs/resources/{aws_security_groups.md => aws_security_groups.md.erb} (100%) rename docs/resources/{aws_sns_topic.md => aws_sns_topic.md.erb} (100%) rename docs/resources/{aws_subnet.md => aws_subnet.md.erb} (100%) rename docs/resources/{aws_subnets.md => aws_subnets.md.erb} (100%) rename docs/resources/{aws_vpc.md => aws_vpc.md.erb} (100%) rename docs/resources/{aws_vpcs.md => aws_vpcs.md.erb} (100%) rename docs/resources/{azure_generic_resource.md => azure_generic_resource.md.erb} (100%) rename docs/resources/{azure_resource_group.md => azure_resource_group.md.erb} (100%) rename docs/resources/{azure_virtual_machine.md => azure_virtual_machine.md.erb} (100%) rename docs/resources/{azure_virtual_machine_datadisk.md => azure_virtual_machine_datadisk.md.erb} (100%) diff --git a/docs/resources/aws_cloudtrail_trail.md b/docs/resources/aws_cloudtrail_trail.md.erb similarity index 100% rename from docs/resources/aws_cloudtrail_trail.md rename to docs/resources/aws_cloudtrail_trail.md.erb diff --git a/docs/resources/aws_cloudtrail_trails.md b/docs/resources/aws_cloudtrail_trails.md.erb similarity index 100% rename from docs/resources/aws_cloudtrail_trails.md rename to docs/resources/aws_cloudtrail_trails.md.erb diff --git a/docs/resources/aws_cloudwatch_alarm.md b/docs/resources/aws_cloudwatch_alarm.md.erb similarity index 100% rename from docs/resources/aws_cloudwatch_alarm.md rename to docs/resources/aws_cloudwatch_alarm.md.erb diff --git a/docs/resources/aws_cloudwatch_log_metric_filter.md b/docs/resources/aws_cloudwatch_log_metric_filter.md.erb similarity index 100% rename from docs/resources/aws_cloudwatch_log_metric_filter.md rename to docs/resources/aws_cloudwatch_log_metric_filter.md.erb diff --git a/docs/resources/aws_ec2_instance.md b/docs/resources/aws_ec2_instance.md.erb similarity index 100% rename from docs/resources/aws_ec2_instance.md rename to docs/resources/aws_ec2_instance.md.erb diff --git a/docs/resources/aws_iam_access_key.md b/docs/resources/aws_iam_access_key.md.erb similarity index 100% rename from docs/resources/aws_iam_access_key.md rename to docs/resources/aws_iam_access_key.md.erb diff --git a/docs/resources/aws_iam_access_keys.md b/docs/resources/aws_iam_access_keys.md.erb similarity index 100% rename from docs/resources/aws_iam_access_keys.md rename to docs/resources/aws_iam_access_keys.md.erb diff --git a/docs/resources/aws_iam_group.md b/docs/resources/aws_iam_group.md.erb similarity index 100% rename from docs/resources/aws_iam_group.md rename to docs/resources/aws_iam_group.md.erb diff --git a/docs/resources/aws_iam_groups.md b/docs/resources/aws_iam_groups.md.erb similarity index 100% rename from docs/resources/aws_iam_groups.md rename to docs/resources/aws_iam_groups.md.erb diff --git a/docs/resources/aws_iam_password_policy.md b/docs/resources/aws_iam_password_policy.md.erb similarity index 100% rename from docs/resources/aws_iam_password_policy.md rename to docs/resources/aws_iam_password_policy.md.erb diff --git a/docs/resources/aws_iam_policies.md b/docs/resources/aws_iam_policies.md.erb similarity index 100% rename from docs/resources/aws_iam_policies.md rename to docs/resources/aws_iam_policies.md.erb diff --git a/docs/resources/aws_iam_policy.md b/docs/resources/aws_iam_policy.md.erb similarity index 100% rename from docs/resources/aws_iam_policy.md rename to docs/resources/aws_iam_policy.md.erb diff --git a/docs/resources/aws_iam_role.md b/docs/resources/aws_iam_role.md.erb similarity index 100% rename from docs/resources/aws_iam_role.md rename to docs/resources/aws_iam_role.md.erb diff --git a/docs/resources/aws_iam_root_user.md b/docs/resources/aws_iam_root_user.md.erb similarity index 100% rename from docs/resources/aws_iam_root_user.md rename to docs/resources/aws_iam_root_user.md.erb diff --git a/docs/resources/aws_iam_user.md b/docs/resources/aws_iam_user.md.erb similarity index 100% rename from docs/resources/aws_iam_user.md rename to docs/resources/aws_iam_user.md.erb diff --git a/docs/resources/aws_iam_users.md b/docs/resources/aws_iam_users.md.erb similarity index 100% rename from docs/resources/aws_iam_users.md rename to docs/resources/aws_iam_users.md.erb diff --git a/docs/resources/aws_kms_keys.md b/docs/resources/aws_kms_keys.md.erb similarity index 100% rename from docs/resources/aws_kms_keys.md rename to docs/resources/aws_kms_keys.md.erb diff --git a/docs/resources/aws_route_table.md b/docs/resources/aws_route_table.md.erb similarity index 100% rename from docs/resources/aws_route_table.md rename to docs/resources/aws_route_table.md.erb diff --git a/docs/resources/aws_s3_bucket.md b/docs/resources/aws_s3_bucket.md.erb similarity index 100% rename from docs/resources/aws_s3_bucket.md rename to docs/resources/aws_s3_bucket.md.erb diff --git a/docs/resources/aws_security_group.md b/docs/resources/aws_security_group.md.erb similarity index 100% rename from docs/resources/aws_security_group.md rename to docs/resources/aws_security_group.md.erb diff --git a/docs/resources/aws_security_groups.md b/docs/resources/aws_security_groups.md.erb similarity index 100% rename from docs/resources/aws_security_groups.md rename to docs/resources/aws_security_groups.md.erb diff --git a/docs/resources/aws_sns_topic.md b/docs/resources/aws_sns_topic.md.erb similarity index 100% rename from docs/resources/aws_sns_topic.md rename to docs/resources/aws_sns_topic.md.erb diff --git a/docs/resources/aws_subnet.md b/docs/resources/aws_subnet.md.erb similarity index 100% rename from docs/resources/aws_subnet.md rename to docs/resources/aws_subnet.md.erb diff --git a/docs/resources/aws_subnets.md b/docs/resources/aws_subnets.md.erb similarity index 100% rename from docs/resources/aws_subnets.md rename to docs/resources/aws_subnets.md.erb diff --git a/docs/resources/aws_vpc.md b/docs/resources/aws_vpc.md.erb similarity index 100% rename from docs/resources/aws_vpc.md rename to docs/resources/aws_vpc.md.erb diff --git a/docs/resources/aws_vpcs.md b/docs/resources/aws_vpcs.md.erb similarity index 100% rename from docs/resources/aws_vpcs.md rename to docs/resources/aws_vpcs.md.erb diff --git a/docs/resources/azure_generic_resource.md b/docs/resources/azure_generic_resource.md.erb similarity index 100% rename from docs/resources/azure_generic_resource.md rename to docs/resources/azure_generic_resource.md.erb diff --git a/docs/resources/azure_resource_group.md b/docs/resources/azure_resource_group.md.erb similarity index 100% rename from docs/resources/azure_resource_group.md rename to docs/resources/azure_resource_group.md.erb diff --git a/docs/resources/azure_virtual_machine.md b/docs/resources/azure_virtual_machine.md.erb similarity index 100% rename from docs/resources/azure_virtual_machine.md rename to docs/resources/azure_virtual_machine.md.erb diff --git a/docs/resources/azure_virtual_machine_datadisk.md b/docs/resources/azure_virtual_machine_datadisk.md.erb similarity index 100% rename from docs/resources/azure_virtual_machine_datadisk.md rename to docs/resources/azure_virtual_machine_datadisk.md.erb From ecb6d5b428ef0e695b69e433a631ca78ab0a50f2 Mon Sep 17 00:00:00 2001 From: Jared Quick Date: Wed, 14 Feb 2018 17:37:31 -0500 Subject: [PATCH 201/206] Pin inspec to train 1.1.x Signed-off-by: Jared Quick --- Gemfile | 2 -- inspec.gemspec | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/Gemfile b/Gemfile index c149972da..19d3eda91 100644 --- a/Gemfile +++ b/Gemfile @@ -2,8 +2,6 @@ source 'https://rubygems.org' gemspec -gem 'train', :git => 'https://github.com/chef/train' - if Gem::Version.new(RUBY_VERSION) < Gem::Version.new('2.2.2') gem 'json', '~> 1.8' gem 'rack', '< 2.0' diff --git a/inspec.gemspec b/inspec.gemspec index 0fbddd118..a447e3e5a 100644 --- a/inspec.gemspec +++ b/inspec.gemspec @@ -26,7 +26,7 @@ Gem::Specification.new do |spec| spec.required_ruby_version = '>= 2.3' - # spec.add_dependency 'train', '~> 0.32' + spec.add_dependency 'train', '~> 1.1' spec.add_dependency 'thor', '~> 0.19' spec.add_dependency 'json', '>= 1.8', '< 3.0' spec.add_dependency 'method_source', '~> 0.8' From 58629faf4637eab4f4072500d15ef3a278e0e10e Mon Sep 17 00:00:00 2001 From: Clinton Wolfe Date: Wed, 14 Feb 2018 18:52:36 -0500 Subject: [PATCH 202/206] Document the terminology changeup for Azure creds (#2640) Signed-off-by: Clinton Wolfe --- docs/platforms.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/platforms.md b/docs/platforms.md index d809de58b..de17cc1f1 100644 --- a/docs/platforms.md +++ b/docs/platforms.md @@ -65,6 +65,7 @@ This can be done on the command line or from the Azure Portal: - PowerShell: https://docs.microsoft.com/en-us/azure/azure-resource-manager/resource-group-authenticate-service-principal - Azure Portal: https://docs.microsoft.com/en-us/azure/azure-resource-manager/resource-group-create-service-principal-portal + The information from the SPN can be specified either in a file `~/.azure/credentials`, as environment variables, or using InSpec target URIs. #### Using the Azure Credentials File @@ -78,6 +79,11 @@ client_secret = "" tenant_id = "" ``` + NOTE: In the Azure web portal, these values are labelled differently: + * The client_id is referred to as the 'Application ID' + * The client_secret is referred to as the 'Key (Password Type)' + * The tenant_id is referred to as the 'Directory ID' + With the credentials are in place you may now execute InSpec: ```bash From b9162f5c1ba0b718a3301c644b4930ab59ce3dc3 Mon Sep 17 00:00:00 2001 From: kagarmoe Date: Wed, 14 Feb 2018 18:23:29 -0800 Subject: [PATCH 203/206] Escapes and formatting --- docs/resources/aws_cloudtrail_trail.md.erb | 24 +- docs/resources/aws_cloudtrail_trails.md.erb | 4 +- docs/resources/aws_cloudwatch_alarm.md.erb | 4 +- .../aws_cloudwatch_log_metric_filter.md.erb | 18 +- docs/resources/aws_ec2_instance.md.erb | 18 +- docs/resources/aws_iam_access_key.md.erb | 10 +- docs/resources/aws_iam_access_keys.md.erb | 22 +- docs/resources/aws_iam_group.md.erb | 2 +- docs/resources/aws_iam_groups.md.erb | 2 +- docs/resources/aws_iam_password_policy.md.erb | 4 +- docs/resources/aws_iam_policies.md.erb | 6 +- docs/resources/aws_iam_policy.md.erb | 20 +- docs/resources/aws_iam_role.md.erb | 4 +- docs/resources/aws_iam_root_user.md.erb | 6 +- docs/resources/aws_iam_user.md.erb | 6 +- docs/resources/aws_iam_users.md.erb | 6 +- docs/resources/aws_kms_keys.md.erb | 6 +- docs/resources/aws_route_table.md.erb | 4 +- docs/resources/aws_s3_bucket.md.erb | 10 +- docs/resources/aws_security_group.md.erb | 14 +- docs/resources/aws_security_groups.md.erb | 10 +- docs/resources/aws_sns_topic.md.erb | 4 +- docs/resources/aws_subnet.md.erb | 20 +- docs/resources/aws_subnets.md.erb | 12 +- docs/resources/aws_vpc.md.erb | 10 +- docs/resources/aws_vpcs.md.erb | 2 +- docs/resources/azure_generic_resource.md.erb | 14 +- docs/resources/azure_resource_group.md.erb | 175 ++++++------ docs/resources/azure_virtual_machine.md.erb | 269 +++++++++--------- .../azure_virtual_machine_datadisk.md.erb | 133 +++++---- 30 files changed, 410 insertions(+), 429 deletions(-) diff --git a/docs/resources/aws_cloudtrail_trail.md.erb b/docs/resources/aws_cloudtrail_trail.md.erb index cb7cd5ae8..36f63f6ee 100644 --- a/docs/resources/aws_cloudtrail_trail.md.erb +++ b/docs/resources/aws_cloudtrail_trail.md.erb @@ -3,19 +3,19 @@ title: About the aws_cloudtrail_trail Resource platform: aws --- -# aws_cloudtrail_trail +# aws\_cloudtrail\_trail Use the `aws_cloudtrail_trail` InSpec audit resource to test properties of a single AWS Cloudtrail Trail. AWS CloudTrail is a service that enables governance, compliance, operational auditing, and risk auditing of your AWS account. With CloudTrail, you can log, continuously monitor, and retain account activity related to actions across your AWS infrastructure. CloudTrail provides event history of your AWS account activity, including actions taken through the AWS Management Console, AWS SDKs, command line tools, and other AWS services. This event history simplifies security analysis, resource change tracking, and troubleshooting. -Each AWS Cloudtrail Trail is uniquely identified by its trail_name or trail_arn. +Each AWS Cloudtrail Trail is uniquely identified by its `trail_name` or `trail_arn`.
## Syntax -An `aws_cloudtrail_trail` resource block identifies a trail by trail_name. +An `aws_cloudtrail_trail` resource block identifies a trail by `trail_name`. # Find a trail by name describe aws_cloudtrail_trail('trail-name') do @@ -61,7 +61,7 @@ The following examples show how to use this InSpec audit resource. ## Property Examples -### s3_bucket_name +### s3\_bucket\_name Specifies the name of the Amazon S3 bucket designated for publishing log files. @@ -69,7 +69,7 @@ Specifies the name of the Amazon S3 bucket designated for publishing log files. its('s3_bucket_name') { should cmp "s3-bucket-name" } end -### trail_arn +### trail\_arn The ARN identifier of the specified trail. An ARN uniquely identifies the trail within AWS. @@ -77,7 +77,7 @@ The ARN identifier of the specified trail. An ARN uniquely identifies the trail its('trail_arn') { should cmp "arn:aws:cloudtrail:us-east-1:484747447281:trail/trail-name" } end -### cloud_watch_logs_role_arn +### cloud\_watch\_logs\_role\_arn Specifies the role for the CloudWatch Logs endpoint to assume to write to a user\'s log group. @@ -85,7 +85,7 @@ Specifies the role for the CloudWatch Logs endpoint to assume to write to a user its('cloud_watch_logs_role_arn') { should include "arn:aws:iam:::role/CloudTrail_CloudWatchLogs_Role" } end -### cloud_watch_logs_log_group_arn +### cloud\_watch\_logs\_log\_group\_arn Specifies a log group name using an Amazon Resource Name (ARN), a unique identifier that represents the log group to which CloudTrail logs will be delivered. @@ -93,7 +93,7 @@ Specifies a log group name using an Amazon Resource Name (ARN), a unique identif its('cloud_watch_logs_log_group_arn') { should include "arn:aws:logs:us-east-1::log-group:test:*" } end -### kms_key_id +### kms\_key\_id Specifies the KMS key ID to used to encrypt the logs delivered by CloudTrail. @@ -101,7 +101,7 @@ Specifies the KMS key ID to used to encrypt the logs delivered by CloudTrail. its('kms_key_id') { should include "key-arn" } end -### home_region +### home\_region Specifies the region in which the trail was created. @@ -115,7 +115,7 @@ Specifies the region in which the trail was created. 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/). -### be_multi_region_trail +### be\_multi\_region\_trail The test will pass if the identified trail is a multi region trail. @@ -123,7 +123,7 @@ The test will pass if the identified trail is a multi region trail. it { should be_multi_region_trail } end -### be_encrypted +### be\_encrypted The test will pass if the logs delivered by the identified trail is encrypted. @@ -131,7 +131,7 @@ The test will pass if the logs delivered by the identified trail is encrypted. it { should be_encrypted } end -### be_log_file_validation_enabled +### be\_log\_file\_validation\_enabled The test will pass if the identified trail has log file integrity validation is enabled. diff --git a/docs/resources/aws_cloudtrail_trails.md.erb b/docs/resources/aws_cloudtrail_trails.md.erb index 7cf4cb3a3..39a68326f 100644 --- a/docs/resources/aws_cloudtrail_trails.md.erb +++ b/docs/resources/aws_cloudtrail_trails.md.erb @@ -3,7 +3,7 @@ title: About the aws_cloudtrail_trails Resource platform: aws --- -# aws_cloudtrail_trails +# aws\_cloudtrail\_trails Use the `aws_cloudtrail_trails` InSpec audit resource to test properties of some or all AWS CloudTrail Trails. @@ -56,7 +56,7 @@ Provides a list of trail names for all CloudTrail Trails in the AWS account. its('names') { should include('trail-1') } end -### trail_arns +### trail\_arns Provides a list of trail arns for all CloudTrail Trails in the AWS account. diff --git a/docs/resources/aws_cloudwatch_alarm.md.erb b/docs/resources/aws_cloudwatch_alarm.md.erb index 91979947f..3f3fa8f83 100644 --- a/docs/resources/aws_cloudwatch_alarm.md.erb +++ b/docs/resources/aws_cloudwatch_alarm.md.erb @@ -3,7 +3,7 @@ title: About the aws_cloudwatch_alarm Resource platform: aws --- -# aws_cloudwatch_alarm +# aws\_cloudwatch\_alarm Use the `aws_cloudwatch_alarm` InSpec audit resource to test properties of a single Cloudwatch Alarm. @@ -46,7 +46,7 @@ The following examples show how to use this InSpec audit resource. ## Property Examples -### alarm_actions +### alarm\_actions `alarm_actions` returns a list of strings. Each string is the ARN of an action that will be taken should the alarm be triggered. diff --git a/docs/resources/aws_cloudwatch_log_metric_filter.md.erb b/docs/resources/aws_cloudwatch_log_metric_filter.md.erb index 9dcf1c283..dfe863d47 100644 --- a/docs/resources/aws_cloudwatch_log_metric_filter.md.erb +++ b/docs/resources/aws_cloudwatch_log_metric_filter.md.erb @@ -3,7 +3,7 @@ title: About the aws_cloudwatch_log_metric_filter Resource platform: aws --- -# aws_cloudwatch_log_metric_filter +# aws\_cloudwatch\_log\_metric\_filter Use the `aws_cloudwatch_log_metric_filter` InSpec audit resource to search for and test properties of individual AWS Cloudwatch Log Metric Filters. @@ -43,9 +43,9 @@ An `aws_cloudwatch_log_metric_filter` resource block searches for an LMF, specif ## Filter Examples -### filter_name +### filter\_name -This is the identifier of the log metric filter within its log group. To ensure you have a unique result, you must also provide the log_group_name. +This is the identifier of the log metric filter within its log group. To ensure you have a unique result, you must also provide the `log_group_name`. describe aws_cloudwatch_log_metric_filter( filter_name: 'my-filter' @@ -53,9 +53,9 @@ This is the identifier of the log metric filter within its log group. To ensure it { should exist } end -### log_group_name +### log\_group\_name -The name of the Cloudwatch Log Group that the LMF is watching. Together with filter_name, this uniquely identifies an LMF. +The name of the Cloudwatch Log Group that the LMF is watching. Together with `filter_name`, this uniquely identifies an LMF. describe aws_cloudwatch_log_metric_filter( log_group_name: 'my-log-group', @@ -83,9 +83,9 @@ The filter pattern used to match entries from the logs in the log group. ## Property Examples -### filter_name +### filter\_name -The name of the LMF within the log_group. +The name of the LMF within the `log_group`. # Check the name of the LMF that has a certain pattern describe aws_cloudwatch_log_metric_filter( @@ -95,7 +95,7 @@ The name of the LMF within the log_group. its('filter_name') { should cmp 'kaboom_lmf' } end -### log_group_name +### log\_group\_name The name of the log group that the LMF is watching. @@ -106,7 +106,7 @@ The name of the log group that the LMF is watching. its('log_group_name') { should cmp 'app-log-group' } end -### metric_name, metric_namespace +### metric\_name, metric\_namespace The name and namespace of the Cloudwatch Metric that will be updated when the LMF matches. You also need the `metric_namespace` to uniquely identify the metric. diff --git a/docs/resources/aws_ec2_instance.md.erb b/docs/resources/aws_ec2_instance.md.erb index b64d51b9e..f03343cbf 100644 --- a/docs/resources/aws_ec2_instance.md.erb +++ b/docs/resources/aws_ec2_instance.md.erb @@ -3,7 +3,7 @@ title: About the aws_ec2_instance Resource platform: aws --- -# aws_ec2_instance +# aws\_ec2\_instance Use the `aws_ec2_instance` InSpec audit resource to test properties of a single AWS EC2 instance. @@ -63,43 +63,43 @@ The following examples show how to use this InSpec audit resource. 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/). -### be_pending +### be\_pending -The `be_pending` matcher tests if the described EC2 instance state is `pending`. This indicates that an instance is provisioning. This state should be temporary. +The `be\_pending` matcher tests if the described EC2 instance state is `pending`. This indicates that an instance is provisioning. This state should be temporary. it { should be_pending } -### be_running +### be\_running The `be_running` matcher tests if the described EC2 instance state is `running`. This indicates the instance is fully operational from AWS's perspective. it { should be_running } -### be_shutting_down +### be\_shutting\_down The `be_shutting_down` matcher tests if the described EC2 instance state is `shutting-down`. This indicates the instance has received a termination command and is in the process of being permanently halted and de-provisioned. This state should be temporary. it { should be_shutting_down } -### be_stopped +### be\_stopped The `be_stopped` matcher tests if the described EC2 instance state is `stopped`. This indicates that the instance is suspended and may be started again. it { should be_stopped } -### be_stopping +### be\_stopping The `be_stopping` matcher tests if the described EC2 instance state is `stopping`. This indicates that an AWS stop command has been issued, which will suspend the instance in an OS-unaware manner. This state should be temporary. it { should be_stopping } -### be_terminated +### be\_terminated The `be_terminated` matcher tests if the described EC2 instance state is `terminated`. This indicates the instance is permanently halted and will be removed from the instance listing in a short period. This state should be temporary. it { should be_terminated } -### be_unknown +### be\_unknown The `be_unknown` matcher tests if the described EC2 instance state is `unknown`. This indicates an error condition in the AWS management system. This state should be temporary. diff --git a/docs/resources/aws_iam_access_key.md.erb b/docs/resources/aws_iam_access_key.md.erb index fe6fb17ec..ea561f93f 100644 --- a/docs/resources/aws_iam_access_key.md.erb +++ b/docs/resources/aws_iam_access_key.md.erb @@ -3,7 +3,7 @@ title: About the aws_iam_access_key Resource platform: aws --- -# aws_iam_access_key +# aws\_iam\_access\_key Use the `aws_iam_access_key` InSpec audit resource to test properties of a single AWS IAM access key. @@ -76,7 +76,7 @@ The following examples show how to use this InSpec audit resource. ## Property Examples -### access_key_id +### access\_key\_id The unique ID of this access key. @@ -84,7 +84,7 @@ The unique ID of this access key. its('access_key_id') { should cmp 'AKIA12345678ABCD' } end -### create_date +### create\_date The date and time, as a Ruby DateTime, at which the access key was created. @@ -93,7 +93,7 @@ The date and time, as a Ruby DateTime, at which the access key was created. its('create_date') { should be > Time.now - 365 * 86400 } end -### last_used_date +### last\_used\_date The date and time, as a Ruby DateTime, at which the access key was last_used. @@ -116,7 +116,7 @@ The IAM user that owns this key. 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/). -### be_active +### be\_active The `be_active` matcher tests if the described IAM access key is active. diff --git a/docs/resources/aws_iam_access_keys.md.erb b/docs/resources/aws_iam_access_keys.md.erb index 85ba23939..694234c66 100644 --- a/docs/resources/aws_iam_access_keys.md.erb +++ b/docs/resources/aws_iam_access_keys.md.erb @@ -3,7 +3,7 @@ title: About the aws_iam_access_keys Resource platform: aws --- -# aws_iam_access_keys +# aws\_iam\_access\_keys Use the `aws_iam_access_keys` InSpec audit resource to test properties of some or all IAM Access Keys. @@ -63,7 +63,7 @@ A true / false value indicating if an Access Key is currently "Active" (the norm its('access_key_ids') { should include('AKIA1234567890ABCDEF')} end -### create_date +### create\_date A DateTime identifying when the Access Key was created. See also `created_days_ago` and `created_hours_ago`. @@ -72,7 +72,7 @@ A DateTime identifying when the Access Key was created. See also `created_days_ it { should_not exist } end -### created_days_ago, created_hours_ago +### created\_days\_ago, created\_hours\_ago An integer, representing how old the access key is. @@ -81,7 +81,7 @@ An integer, representing how old the access key is. it { should_not exist } end -### created_with_user +### created\_with\_user A true / false value indicating if the Access Key was likely created at the same time as the user, by checking if the difference between created_date and user_created_date is less than 1 hour. @@ -90,7 +90,7 @@ A true / false value indicating if the Access Key was likely created at the same it { should_not exist } end -### ever_used +### ever\_used A true / false value indicating if the Access Key has ever been used, based on the last_used_date. See also: `never_used`. @@ -108,7 +108,7 @@ A true / false value indicating if the Access Key has been marked Inactive in th it { should_not exist } end -### last_used_date +### last\_used\_date A DateTime identifying when the Access Key was last used. Returns nil if the key has never been used. See also: `ever_used`, `last_used_days_ago`, `last_used_hours_ago`, and `never_used`. @@ -117,7 +117,7 @@ A DateTime identifying when the Access Key was last used. Returns nil if the key it { should_not exist } end -### last_used_days_ago, last_used_hours_ago +### last\_used\_days\_ago, last\_used\_hours\_ago An integer representing when the key was last used. See also: `ever_used`, `last_used_date`, and `never_used`. @@ -126,9 +126,9 @@ An integer representing when the key was last used. See also: `ever_used`, `last it { should_not exist } end -### never_used +### never\_used -A true / false value indicating if the Access Key has never been used, based on the last_used_date. See also: `ever_used`. +A true / false value indicating if the Access Key has never been used, based on the `last_used_date`. See also: `ever_used`. # Don't allow unused keys to lay around describe aws_iam_access_keys.where { never_used } do @@ -143,7 +143,7 @@ Searches for access keys owned by the named user. Each user may have zero, one, it { should exist } end -### user_created_date +### user\_created\_date The date at which the user was created. @@ -160,7 +160,7 @@ The date at which the user was created. ## Property Examples -### access_key_ids +### access\_key\_ids Provides a list of all access key IDs matched. diff --git a/docs/resources/aws_iam_group.md.erb b/docs/resources/aws_iam_group.md.erb index 8f619f810..e884d86a8 100644 --- a/docs/resources/aws_iam_group.md.erb +++ b/docs/resources/aws_iam_group.md.erb @@ -3,7 +3,7 @@ title: About the aws_iam_group Resource platform: aws --- -# aws_iam_group +# aws\_iam\_group Use the `aws_iam_group` InSpec audit resource to test properties of a single IAM group. diff --git a/docs/resources/aws_iam_groups.md.erb b/docs/resources/aws_iam_groups.md.erb index 4fdd0b28b..8ef410a2c 100644 --- a/docs/resources/aws_iam_groups.md.erb +++ b/docs/resources/aws_iam_groups.md.erb @@ -3,7 +3,7 @@ title: About the aws_iam_groups Resource platform: aws --- -# aws_iam_groups +# aws\_iam\_groups Use the `aws_iam_groups` InSpec audit resource to test properties of all or multiple groups. diff --git a/docs/resources/aws_iam_password_policy.md.erb b/docs/resources/aws_iam_password_policy.md.erb index b326591b5..db05cf66d 100644 --- a/docs/resources/aws_iam_password_policy.md.erb +++ b/docs/resources/aws_iam_password_policy.md.erb @@ -3,7 +3,7 @@ title: About the aws_iam_password_policy Resource platform: aws --- -# aws_iam_password_policy +# aws\_iam\_password\_policy Use the `aws_iam_password_policy` InSpec audit resource to test properties of the AWS IAM Password Policy. @@ -71,4 +71,6 @@ The following examples show how to use this InSpec audit resource. ## Matchers +This resource uses the following special matchers. For a full list of available matchers please visit our [matchers page](https://www.inspec.io/docs/reference/matchers/). + * `allows_users_to_change_passwords`, `expire_passwords`, `prevent_password_reuse`, `require_lowercase_characters` , `require_uppercase_characters`, `require_numbers`, `require_symbols` diff --git a/docs/resources/aws_iam_policies.md.erb b/docs/resources/aws_iam_policies.md.erb index 08ba7851d..6407ffb56 100644 --- a/docs/resources/aws_iam_policies.md.erb +++ b/docs/resources/aws_iam_policies.md.erb @@ -3,13 +3,13 @@ title: About the aws_iam_policies Resource platform: aws --- -# aws_iam_policies +# aws\_iam\_policies Use the `aws_iam_policies` InSpec audit resource to test properties of some or all AWS IAM Policies. A policy is an entity in AWS that, when attached to an identity or resource, defines their permissions. AWS evaluates these policies when a principal, such as a user, makes a request. Permissions in the policies determine if the request is allowed or denied. -Each IAM Policy is uniquely identified by either its policy_name or arn. +Each IAM Policy is uniquely identified by either its `policy_name` or `arn`.
@@ -40,7 +40,7 @@ As this is the initial release of `aws_iam_policies`, its limited functionality ## Property Examples -### policy_names +### policy\_names Provides a list of policy names for all IAM Policies in the AWS account. diff --git a/docs/resources/aws_iam_policy.md.erb b/docs/resources/aws_iam_policy.md.erb index 626e3c7d7..27e0f78e0 100644 --- a/docs/resources/aws_iam_policy.md.erb +++ b/docs/resources/aws_iam_policy.md.erb @@ -66,7 +66,7 @@ The following examples show how to use this InSpec audit resource. its('arn') { should cmp "arn:aws:iam::aws:policy/AWSSupportAccess" } end -### attachment_count +### attachment\_count The count of attached entities for the specified policy. @@ -74,7 +74,7 @@ The count of attached entities for the specified policy. its('attachment_count') { should cmp 1 } end -### attached_groups +### attached\_groups The list of groupnames of the groups attached to the policy. @@ -82,7 +82,7 @@ The list of groupnames of the groups attached to the policy. its('attached_groups') { should include "test-group" } end -### attached_roles +### attached\_roles The list of rolenames of the roles attached to the policy. @@ -90,7 +90,7 @@ The list of rolenames of the roles attached to the policy. its('attached_roles') { should include "test-role" } end -### attached_users +### attached\_users The list of usernames of the users attached to the policy. @@ -98,9 +98,9 @@ The list of usernames of the users attached to the policy. its('attached_users') { should include "test-user" } end -### default_version_id +### default\_version\_id -The default_version_id value of the specified policy. +The 'default_version_id' value of the specified policy. describe aws_iam_policy('AWSSupportAccess') do its('default_version_id') { should cmp "v1" } @@ -111,7 +111,7 @@ The default_version_id value of the specified policy. 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/). -### be_attached +### be\_attached The test will pass if the identified policy is attached to at least one IAM user, group, or role. @@ -119,7 +119,7 @@ The test will pass if the identified policy is attached to at least one IAM user it { should be_attached } end -### be_attached_to_group(GROUPNAME) +### be\_attached\_to\_group(GROUPNAME) The test will pass if the identified policy attached the specified group. @@ -127,7 +127,7 @@ The test will pass if the identified policy attached the specified group. it { should be_attached_to_group(GROUPNAME) } end -### be_attached_to_user(USERNAME) +### be\_attached\_to\_user(USERNAME) The test will pass if the identified policy attached the specified user. @@ -135,7 +135,7 @@ The test will pass if the identified policy attached the specified user. it { should be_attached_to_user(USERNAME) } end -### be_attached_to_role(ROLENAME) +### be\_attached\_to\_role(ROLENAME) The test will pass if the identified policy attached the specified role. diff --git a/docs/resources/aws_iam_role.md.erb b/docs/resources/aws_iam_role.md.erb index a1c33931f..436aa749e 100644 --- a/docs/resources/aws_iam_role.md.erb +++ b/docs/resources/aws_iam_role.md.erb @@ -3,7 +3,7 @@ title: About the aws_iam_role Resource platform: aws --- -# aws_iam_role +# aws\_iam\_role Use the `aws_iam_role` InSpec audit resource to test properties of a single IAM Role. A Role is a collection of permissions that may be temporarily assumed by a user, EC2 Instance, Lambda Function, or certain other resources. @@ -20,7 +20,7 @@ Use the `aws_iam_role` InSpec audit resource to test properties of a single IAM ## Resource Parameters -### role_name +### role\_name This resource expects a single parameter that uniquely identifies the IAM Role, the Role Name. You may pass it as a string, or as the value in a hash: diff --git a/docs/resources/aws_iam_root_user.md.erb b/docs/resources/aws_iam_root_user.md.erb index a959592c2..aecef290b 100644 --- a/docs/resources/aws_iam_root_user.md.erb +++ b/docs/resources/aws_iam_root_user.md.erb @@ -3,7 +3,7 @@ title: About the aws_iam_root_user Resource platform: aws --- -# aws_iam_root_user +# aws\_iam\_root\_user Use the `aws_iam_root_user` InSpec audit resource to test properties of the root user (owner of the account). @@ -45,13 +45,13 @@ The following examples show how to use this InSpec audit resource. 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/). -### have_mfa_enabled +### have\_mfa\_enabled The `have_mfa_enabled` matcher tests if the AWS root user has Multi-Factor Authentication enabled, requiring them to enter a secondary code when they login to the web console. it { should have_mfa_enabled } -### have_access_key +### have\_access\_key The `have_access_key` matcher tests if the AWS root user has at least one access key. diff --git a/docs/resources/aws_iam_user.md.erb b/docs/resources/aws_iam_user.md.erb index 71332a3b9..80508cd38 100644 --- a/docs/resources/aws_iam_user.md.erb +++ b/docs/resources/aws_iam_user.md.erb @@ -3,7 +3,7 @@ title: About the aws_iam_user Resource platform: aws --- -# aws_iam_user +# aws\_iam\_user Use the `aws_iam_user` InSpec audit resource to test properties of a single AWS IAM user. @@ -51,13 +51,13 @@ The following examples show how to use this InSpec audit resource. 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/). -### have_console_password +### have\_console\_password The `have_console_password` matcher tests if the user has a password that could be used to log into the AWS web console. it { should have_console_password } -### have_mfa_enabled +### have\_mfa\_enabled The `have_mfa_enabled` matcher tests if the user has Multi-Factor Authentication enabled, requiring them to enter a secondary code when they login to the web console. diff --git a/docs/resources/aws_iam_users.md.erb b/docs/resources/aws_iam_users.md.erb index 4c991440a..f78249602 100644 --- a/docs/resources/aws_iam_users.md.erb +++ b/docs/resources/aws_iam_users.md.erb @@ -3,7 +3,7 @@ title: About the aws_iam_users Resource platform: aws --- -# aws_iam_users +# aws\_iam\_users Use the `aws_iam_users` InSpec audit resource to test properties of a all or multiple users. @@ -25,7 +25,9 @@ An `aws_iam_users` resource block users a filter to select a group of users and ## Filter Criteria -*`has_mfa_enabled`, `has_console_password`, `password_ever_used?`, `password_never_used?`, `password_last_used_days_ago`, `username` +* `has_mfa_enabled`, `has_console_password`, `password_ever_used?`, `password_never_used?`, `password_last_used_days_ago`, `username` + +
## Examples diff --git a/docs/resources/aws_kms_keys.md.erb b/docs/resources/aws_kms_keys.md.erb index 55a66cc6b..58cfb8c57 100644 --- a/docs/resources/aws_kms_keys.md.erb +++ b/docs/resources/aws_kms_keys.md.erb @@ -3,7 +3,7 @@ title: About the aws_kms_keys Resource platform: aws --- -# aws_kms_keys +# aws\_kms\_keys Use the `aws_kms_keys` InSpec audit resource to test properties of some or all AWS KMS Keys. @@ -51,7 +51,7 @@ Provides access to the raw results of a query. This can be useful for checking c its('entries.count') { should be <= 100} end -### key_arns +### key\_arns Provides a list of key arns for all KMS Keys in the AWS account. @@ -59,7 +59,7 @@ Provides a list of key arns for all KMS Keys in the AWS account. its('key_arns') { should include('arn:aws:kms:us-east-1::key/key-id') } end -### key_ids +### key\_ids Provides a list of key ids for all KMS Keys in the AWS account. diff --git a/docs/resources/aws_route_table.md.erb b/docs/resources/aws_route_table.md.erb index b9ddd109f..cc7a7c179 100644 --- a/docs/resources/aws_route_table.md.erb +++ b/docs/resources/aws_route_table.md.erb @@ -3,7 +3,7 @@ title: About the aws_route_table Resource platform: aws --- -# aws_route_table +# aws\_route\_table Use the `aws_route_table` InSpec audit resource to test properties of a single Route Table. A route table contains a set of rules, called routes, that are used to determine where network traffic is directed. @@ -18,7 +18,7 @@ Use the `aws_route_table` InSpec audit resource to test properties of a single R ## Resource Parameters -### route_table_id +### route\_table\_id This resource expects a single parameter that uniquely identifies the Route Table. You may pass it as a string, or as the value in a hash: diff --git a/docs/resources/aws_s3_bucket.md.erb b/docs/resources/aws_s3_bucket.md.erb index 247a5ad7f..c493e2fb2 100644 --- a/docs/resources/aws_s3_bucket.md.erb +++ b/docs/resources/aws_s3_bucket.md.erb @@ -3,7 +3,7 @@ title: About the aws_s3_bucket Resource platform: aws --- -# aws_s3_bucket +# aws\_s3\_bucket Use the `aws_s3_bucket` InSpec audit resource to test properties of a single AWS bucket. @@ -73,7 +73,7 @@ The `region` property identifies the AWS Region in which the S3 bucket is locate ## Unsupported Properties -### bucket_acl +### bucket\_acl The `bucket_acl` property is a low-level property that lists the individual Bucket ACL grants in effect on the bucket. Other higher-level properties, such as be\_public, are more concise and easier to use. You can use the `bucket_acl` property to investigate which grants are in effect, causing be\_public to fail. @@ -92,7 +92,7 @@ The value of bucket_acl is an array of simple objects. Each object has a `perm g.grantee.type == 'Group' && g.grantee.uri =~ /AuthenticatedUsers/ end -### bucket_policy +### bucket\_policy The `bucket_policy` is a low-level property that describes the IAM policy document controlling access to the bucket. The `bucket_policy` property returns a Ruby structure that you can probe to check for particular statements. We recommend using a higher-level property, such as `be_public`, which is concise and easier to implement in your policy files. @@ -113,7 +113,7 @@ If there is no bucket policy, this property returns an empty array. 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/). -### be_public +### be\_public The `be_public` matcher tests if the bucket has potentially insecure access controls. This high-level matcher detects several insecure conditions, which may be enhanced in the future. Currently, the matcher reports an insecure bucket if any of the following conditions are met: @@ -125,7 +125,7 @@ Note: This resource does not detect insecure object ACLs. it { should_not be_public } -### have_access_logging_enabled +### have\_access\_logging\_enabled The `have_access_logging_enabled` matcher tests if access logging is enabled for the s3 bucket. diff --git a/docs/resources/aws_security_group.md.erb b/docs/resources/aws_security_group.md.erb index c2f354c33..6d68410c0 100644 --- a/docs/resources/aws_security_group.md.erb +++ b/docs/resources/aws_security_group.md.erb @@ -2,7 +2,7 @@ title: About the aws_security_group Resource --- -# aws_security_group +# aws\_security\_group Use the `aws_security_group` InSpec audit resource to test detailed properties of an individual Security Group (SG). @@ -40,7 +40,7 @@ As this is the initial release of `aws_security_group`, its limited functionalit This InSpec resource accepts the following parameters, which are used to search for the Security Group. -### id, group_id +### id, group\_id The Security Group ID of the Security Group. This is of the format `sg-` followed by 8 hexadecimal characters. The ID is unique within your AWS account; using ID ensures that you will never match more than one SG. The ID is also the default resource parameter, so you may omit the hash syntax. @@ -59,7 +59,7 @@ The Security Group ID of the Security Group. This is of the format `sg-` followe it { should exist } end -### group_name +### group\_name The string name of the Security Group. Every VPC has a security group named 'default'. Names are unique within a VPC, but not within an AWS account. @@ -73,7 +73,7 @@ The string name of the Security Group. Every VPC has a security group named 'de it { should exist } end -### vpc_id +### vpc\_id A string identifying the VPC that contains the security group. Since VPCs commonly contain many SGs, you should add additional parameters to ensure you find exactly one SG. @@ -104,7 +104,7 @@ The control will pass if the specified SG was found. Use should_not if you want ## Properties -### group_id +### group\_id Provides the Security Group ID. @@ -116,7 +116,7 @@ Provides the Security Group ID. # Store the group ID in a Ruby variable for use elsewhere sg_id = aws_security_group(group_name: 'default', vpc_id: vpc_id: 'vpc-12345678').group_id -### group_name +### group\_name A String reflecting the name that was given to the SG at creation time. @@ -134,7 +134,7 @@ A String reflecting the human-meaningful description that was given to the SG at its('description') { should_not be_empty } end -### vpc_id +### vpc\_id A String in the format 'vpc-' followed by 8 hexadecimal characters reflecting VPC that contains the security group. diff --git a/docs/resources/aws_security_groups.md.erb b/docs/resources/aws_security_groups.md.erb index bd0f03afe..456cee544 100644 --- a/docs/resources/aws_security_groups.md.erb +++ b/docs/resources/aws_security_groups.md.erb @@ -3,7 +3,7 @@ title: About the aws_security_groups Resource platform: aws --- -# aws_security_groups +# aws\_security\_groups Use the `aws_security_groups` InSpec audit resource to test properties of some or all security groups. @@ -32,7 +32,7 @@ As this is the initial release of `aws_security_groups`, its limited functionali ## Filter Criteria -### vpc_id +### vpc\_id A string identifying the VPC which contains the security group. @@ -41,7 +41,7 @@ A string identifying the VPC which contains the security group. its('group_ids') { should include('sg-abcdef12')} end -### group_name +### group\_name A string identifying a group. Since groups are contained in VPCs, group names are unique within the AWS account, but not across VPCs. @@ -52,7 +52,7 @@ A string identifying a group. Since groups are contained in VPCs, group names ar ## Properties -* `entries`, `group_ids` +* `entries`, `group\_ids` ### entries @@ -63,7 +63,7 @@ Provides access to the raw results of the query. This can be useful for checking its('entries.count') { should be <= 100} end -### group_ids +### group\_ids Provides a list of all security group IDs matched. diff --git a/docs/resources/aws_sns_topic.md.erb b/docs/resources/aws_sns_topic.md.erb index 802cdd3bb..4879aa7ba 100644 --- a/docs/resources/aws_sns_topic.md.erb +++ b/docs/resources/aws_sns_topic.md.erb @@ -2,7 +2,7 @@ title: About the aws_sns_topic Resource --- -# aws_sns_topic +# aws\_sns\_topic Use the `aws_sns_topic` InSpec audit resource to test properties of a single AWS Simple Notification Service Topic. SNS topics are channels for related events. AWS resources place events in the SNS topic, while other AWS resources _subscribe_ to receive notifications when new events have appeared. @@ -33,7 +33,7 @@ See also the (AWS documentation on ARNs)[http://docs.aws.amazon.com/general/late ## Properties -### confirmed_subscription_count +### confirmed\_subscription\_count An integer indicating the number of currently active subscriptions. diff --git a/docs/resources/aws_subnet.md.erb b/docs/resources/aws_subnet.md.erb index 396ddcabd..acd06914c 100644 --- a/docs/resources/aws_subnet.md.erb +++ b/docs/resources/aws_subnet.md.erb @@ -3,7 +3,7 @@ title: About the aws_subnet Resource platform: aws --- -# aws_subnet +# aws\_subnet Use the `aws_subnet` InSpec audit resource to test properties of a vpc subnet. @@ -28,7 +28,7 @@ An `aws_subnet` resource block uses the parameter to select a VPC and a subnet i This InSpec resource accepts the following parameters, which are used to search for the VPCs subnet. -### subnet_id +### subnet\_id A string identifying the subnet that the VPC contains. @@ -47,7 +47,7 @@ A string identifying the subnet that the VPC contains. ## Property Examples -### availability_zone +### availability\_zone Provides the Availability Zone of the subnet. @@ -55,7 +55,7 @@ Provides the Availability Zone of the subnet. its('availability_zone') { should eq 'us-east-1c' } end -### available_ip_address_count +### available\_ip\_address\_count Provides the number of available IPv4 addresses on the subnet. @@ -63,7 +63,7 @@ Provides the number of available IPv4 addresses on the subnet. its('available_ip_address_count') { should eq 251 } end -### cidr_block +### cidr\_block Provides the block of ip addresses specified to the subnet. @@ -71,7 +71,7 @@ Provides the block of ip addresses specified to the subnet. its('cidr_block') { should eq '10.0.1.0/24' } end -### subnet_id +### subnet\_id Provides the ID of the Subnet. @@ -79,7 +79,7 @@ Provides the ID of the Subnet. its('subnet_id') { should eq 'subnet-12345678' } end -### vpc_id +### vpc\_id Provides the ID of the VPC the subnet is in. @@ -93,7 +93,7 @@ Provides the ID of the VPC the subnet is in. 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/). -### assigning_ipv_6_address_on_creation +### assigning\_ipv\_6\_address\_on\_creation Detects if the network interface on the subnet accepts IPv6 addresses. @@ -109,7 +109,7 @@ Provides the current state of the subnet. it { should be_available } end -### default_for_az +### default\_for\_az Detects if the subnet is the default subnet for the Availability Zone. @@ -125,7 +125,7 @@ The `exist` matcher indicates that a subnet exists for the specified vpc. it { should exist } end -### mapping_public_ip_on_launch +### mapping\_public\_ip\_on\_launch Provides the VPC ID for the subnet. diff --git a/docs/resources/aws_subnets.md.erb b/docs/resources/aws_subnets.md.erb index 2efb44f08..a6d34f733 100644 --- a/docs/resources/aws_subnets.md.erb +++ b/docs/resources/aws_subnets.md.erb @@ -3,7 +3,7 @@ title: About the aws_subnets Resource platform: aws --- -# aws_subnets +# aws\_subnets Use the `aws_subnets` InSpec audit resource to test properties of some or all subnets. @@ -39,7 +39,7 @@ As this is the initial release of `aws_subnets`, its limited functionality precl ## Filter Examples -### vpc_id +### vpc\_id A string identifying the VPC which may or may not contain subnets. @@ -49,7 +49,7 @@ A string identifying the VPC which may or may not contain subnets. its('subnet_ids') { should include 'subnet-98765432' } end -### subnet_id +### subnet\_id A string identifying a specific subnet. @@ -68,7 +68,7 @@ A string identifying a specific subnet. ## Property Examples -### cidr_blocks +### cidr\_blocks Provides a string that contains the cidr block of ip addresses that can be given in the subnet. @@ -86,7 +86,7 @@ Provides an array of strings including if the subnets are available. its('states') { should_not include 'pending' } end -### subnet_ids +### subnet\_ids Provides an array of strings containing the subnet IDs associated with a vpc. @@ -96,7 +96,7 @@ Provides an array of strings containing the subnet IDs associated with a vpc. its('subnet_ids') { should include 'subnet-98765432' } end -### vpc_ids +### vpc\_ids Provides an array containing a string of the vpc_id associated with a subnet. diff --git a/docs/resources/aws_vpc.md.erb b/docs/resources/aws_vpc.md.erb index f431d452f..173ae1e99 100644 --- a/docs/resources/aws_vpc.md.erb +++ b/docs/resources/aws_vpc.md.erb @@ -3,7 +3,7 @@ title: About the aws_vpc Resource platform: aws --- -# aws_vpc +# aws\_vpc Use the `aws_vpc` InSpec audit resource to test properties of a single AWS Virtual Private Cloud (VPC). @@ -64,7 +64,7 @@ The following examples show how to use this InSpec audit resource. ## Property Examples -### cidr_block +### cidr\_block The IPv4 address range that is managed by the VPC. @@ -80,7 +80,7 @@ The ID of the set of DHCP options associated with the VPC (or `default` if the d its ('dhcp_options_id') { should eq 'dopt-a94671d0' } end -### instance_tenancy +### instance\_tenancy The allowed tenancy of instances launched into the VPC. @@ -96,7 +96,7 @@ The state of the VPC (`pending` | `available`). its ('state') { should eq 'available' } end -### vpc_id +### vpc\_id The ID of the VPC. @@ -110,7 +110,7 @@ The ID of the VPC. 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/). -### be_default +### be\_default The test will pass if the identified VPC is the default VPC for the region. diff --git a/docs/resources/aws_vpcs.md.erb b/docs/resources/aws_vpcs.md.erb index 0deaeeff2..0534aa3c5 100644 --- a/docs/resources/aws_vpcs.md.erb +++ b/docs/resources/aws_vpcs.md.erb @@ -3,7 +3,7 @@ title: About the aws_vpcs Resource platform: aws --- -# aws_vpcs +# aws\_vpcs Use the `aws_vpcs` InSpec audit resource to test properties of some or all AWS Virtual Private Clouds (VPCs). diff --git a/docs/resources/azure_generic_resource.md.erb b/docs/resources/azure_generic_resource.md.erb index 2fc2d60bb..592b116f1 100644 --- a/docs/resources/azure_generic_resource.md.erb +++ b/docs/resources/azure_generic_resource.md.erb @@ -2,17 +2,17 @@ title: About the azure_generic_resource Resource --- -# azure_generic_resource +# azure\_generic\_resource Use the `azure_generic_resource` Inspec audit resource to test any valid Azure Resource. This is very useful if you need to test something that we do not yet have a specific Inspec resource for. ## Syntax -```ruby -describe azure_generic_resource(group_name: 'MyResourceGroup', name: 'MyResource') do - its('property') { should eq 'value' } -end -``` + ```ruby + describe azure_generic_resource(group_name: 'MyResourceGroup', name: 'MyResource') do + its('property') { should eq 'value' } + end + ``` where @@ -60,8 +60,6 @@ end The properties that can be tested are entirely dependent on the Azure Resource that is under scrutiny. That means the properties vary. The best way to see what is available please use the [Azure Resources Portal](https://resources.azure.com) to select the resource you are interested in and see what can be tested. -![Virtual Machine Properties](images/linux_internal_vm_resource.png) - This resource allows you to test _any_ valid Azure Resource. The trade off for this is that the language to check each item is not as natural as it would be for a native Inspec resource. Please see the integration tests for in depth examples of how this resource can be used. diff --git a/docs/resources/azure_resource_group.md.erb b/docs/resources/azure_resource_group.md.erb index 703ab10d4..1da7c4073 100644 --- a/docs/resources/azure_resource_group.md.erb +++ b/docs/resources/azure_resource_group.md.erb @@ -2,14 +2,10 @@ title: About the azure_resource_group_resource_counts Resource --- -# azure_resource_group_resource_counts +# azure\_resource\_group\_resource\_counts Use the `azure_resource_group_resource_counts` InSpec audit resource to check the number of Azure resources in a resource group -## References - -- [Azure Ruby SDK - Resources](https://github.com/Azure/azure-sdk-for-ruby/tree/master/management/azure_mgmt_resources) - ## Syntax The name of the resource group is specified as a parameter on the resource: @@ -24,22 +20,7 @@ where * Resource Parameters * `MyResourceGroup` is the name of the resource group being interrogated -* `property` is one of - - `name` - - `location` - - `id` - - `provisioning_state` - - `subscription_id` - - `total` - - `nic_count` - - `vm_count` - - `extension_count` - - `vnet_count` - - `sa_count` - - `public_ip_count` - - `managed_disk_image_count` - - `managed_disk_count` - - `tag_count` +* `property` is one a resource property * `value` is the expected output from the matcher The options that can be passed to the resource are as follows. @@ -60,42 +41,23 @@ When the options have been set as well as the environment variables, the environ For example: -```ruby -describe azure_resource_group_resource_counts(name: 'ChefAutomate') do - its('total') { should eq 7} - its('nic_count') { should eq 1 } - its('vm_count') { should eq 1 } -end -``` + ```ruby + describe azure_resource_group_resource_counts(name: 'ChefAutomate') do + its('total') { should eq 7} + its('nic_count') { should eq 1 } + its('vm_count') { should eq 1 } + end + ``` -## 'have' Methods - -This resource has a number of `have_xxxx` methods that provide a simple way to test of a specific Azure Resoure Type exists in the resource group. - -The following table shows the methods that are currently supported and what their associated Azure Resource Type is. - -| Method Name | Azure Resource Type | -|-------------|---------------------| -| have_nics | Microsoft.Network/networkInterfaces | -| have_vms | Microsoft.Compute/virtualMachines | -| have_extensions | Microsoft.Compute/virtualMachines/extensions | -| have_nsgs | Microsoft.Network/networkSecurityGroups | -| have_vnets | Microsoft.Network/virtualNetworks | -| have_managed_disks | Microsoft.Compute/disks | -| have_managed_disk_images | Microsoft.Compute/images | -| have_sas | Microsoft.Storage/storageAccounts | -| have_public_ips | Microsoft.Network/publicIPAddresses | - -With these methods the following tests are possible - -```ruby -it { should have_nics } -it { should_not have_extensions } -``` ## Properties -This InSpec audit resource has the following matchers: +* `name`, `location` ,`id`, `provisioning_state`, `subscription_id`, `total`, `nic_count`, `vm_count`, `extension_count`, `vnet_count`, `sa_count`, `public_ip_count`,`managed_disk_image_count`, `managed_disk_count`, `tag_count` + + +
+ +## Property Examples ### name @@ -111,11 +73,11 @@ Returns the full qualified ID of the resource group. This is in the format `/subscriptions//resourceGroups/`. -### provisioning_state +### provisioning\_state The provisioning state of the resource group. -### subscription_id +### subscription\_id Returns the subscription ID which contains the resource group. @@ -125,67 +87,96 @@ This is derived from the `id`. The total number of resources in the resource group -### nic_count +### nic\_count The number of network interface cards in the resource group -### vm_count +### vm\_count The number of virtual machines in the resource group -### vnet_count +### vnet\_count The number of virtual networks in the resource group -### sa_count +### sa\_count The number of storage accounts in the resource group -### public_ip_count +### public\_ip\_count The number of Public IP Addresses in the resource group -### managed_disk_image_count +### managed\_disk\_image\_count The number of managed disk images that are in the resource group. These are the items from which managed disks are created which are attached to machines. Generally the images are created from a base image or a custom image (e.g. Packer) -### managed_disk_count +### managed\_disk\_count The number of managed disks in the resource group. If a resource group contains one virtual machine with an OS disk and 2 data disks that are all Managed Disks, then the count would be 3. +
+ + +## Matchers + +This resource has a number of `have_xxxx` matchers that provide a simple way to test of a specific Azure Resoure Type exists in the resource group. + +The following table shows the methods that are currently supported and what their associated Azure Resource Type is. + +| Matcher Name | Azure Resource Type | +|-------------|---------------------| +| have_nics | Microsoft.Network/networkInterfaces | +| have_vms | Microsoft.Compute/virtualMachines | +| have_extensions | Microsoft.Compute/virtualMachines/extensions | +| have_nsgs | Microsoft.Network/networkSecurityGroups | +| have_vnets | Microsoft.Network/virtualNetworks | +| have_managed_disks | Microsoft.Compute/disks | +| have_managed_disk_images | Microsoft.Compute/images | +| have_sas | Microsoft.Storage/storageAccounts | +| have_public_ips | Microsoft.Network/publicIPAddresses | + +With these methods the following tests are possible + + ```ruby + it { should have_nics } + it { should_not have_extensions } + ``` + + ## Tags It is possible to test the tags that have been assigned to the resource. There are a number of properties that can be called to check that it has tags, that it has the correct number and that the correct ones are assigned. -### have_tags +### have\_tags This is a simple test to see if the machine has tags assigned to it or not. -```ruby -it { should have_tags } -``` + ```ruby + it { should have_tags } + ``` -### tag_count +### tag\_count Returns the number of tags that are assigned to the resource -```ruby -its ('tag_count') { should eq 2 } -``` + ```ruby + its ('tag_count') { should eq 2 } + ``` ### tags It is possible to check if a specific tag has been set on the resource. -```ruby -its('tags') { should include 'Owner' } -``` + ```ruby + its('tags') { should include 'Owner' } + ``` -### xxx_tag +### xxx\_tag To get the value of the tag, a number of preoprties have been created from the tags that are set. @@ -197,8 +188,8 @@ For example, if the following tag is set on a resource: Then a property is available called `Owner_tag`. -```ruby -its('Owner_tag') { should cmp 'Russell Seymour' } + ```ruby + its('Owner_tag') { should cmp 'Russell Seymour' } ``` Note: The tag name is case sensitive which makes the test case sensitive. E.g. `owner_tag` does not equal `Owner_tag`. @@ -213,20 +204,24 @@ Please refer the integration tests for more in depth examples: ### Test Resource Group has the correct number of resources -```ruby -describe azure_resource_group_resource_counts(name: 'Inspec-Azure') do - its('total') { should eq 7} -``` + ```ruby + describe azure_resource_group_resource_counts(name: 'Inspec-Azure') do + its('total') { should eq 7} + ``` ### Ensure that the Resource Group contains the correct resources -```ruby -describe azure_resource_group_resource_counts(name: 'Inspec-Azure') do - its('total') { should eq 7 } - its('vm_count') { should eq 2 } - its('nic_count') { should eq 2 } - its('public_ip_count') { should eq 1 } - its('sa_count') { should eq 1 } - its('vnet_count') { should eq 1 } -end -``` \ No newline at end of file + ```ruby + describe azure_resource_group_resource_counts(name: 'Inspec-Azure') do + its('total') { should eq 7 } + its('vm_count') { should eq 2 } + its('nic_count') { should eq 2 } + its('public_ip_count') { should eq 1 } + its('sa_count') { should eq 1 } + its('vnet_count') { should eq 1 } + end + ``` + +## References + +- [Azure Ruby SDK - Resources](https://github.com/Azure/azure-sdk-for-ruby/tree/master/management/azure_mgmt_resources) \ No newline at end of file diff --git a/docs/resources/azure_virtual_machine.md.erb b/docs/resources/azure_virtual_machine.md.erb index 348c41513..de2cd2abc 100644 --- a/docs/resources/azure_virtual_machine.md.erb +++ b/docs/resources/azure_virtual_machine.md.erb @@ -2,23 +2,19 @@ title: About the azure_virtual_machine Resource --- -# azure_virtual_machine +# azure\_virtual\_machine Use the `azure_virtual_machine` InSpec audit resource to ensure that a Virtual Machine has been provisionned correctly. -## References - -- [Azure Ruby SDK - Resources](https://github.com/Azure/azure-sdk-for-ruby/tree/master/management/azure_mgmt_resources) - ## Syntax The name of the machine and the resourece group are required as properties to the resource. -```ruby -describe azure_virtual_machine(group_name: 'MyResourceGroup', name: 'MyVM') do - its('property') { should eq 'value' } -end -``` + ```ruby + describe azure_virtual_machine(group_name: 'MyResourceGroup', name: 'MyVM') do + its('property') { should eq 'value' } + end + ``` where @@ -26,37 +22,6 @@ where * `MyVm` is the name of the virtual machine as seen in Azure. (It is **not** the hostname of the machine) * `MyResourceGroup` is the name of the resource group that the machine is in. * `property` is one of - - [`type`](#type) - - [`location`](#location) - - [`name`](#name) - - [`publisher`](#publisher) - - [`offer`](#offer) - - [`sku`](#sku) - - [`os_type`](#"os_type") - - [`os_disk_name`](#os_disk_name) - - [`have_managed_osdisk`](#have_managed_osdisk?) - - [`caching`](#caching) - - `create_option` - - `disk_size_gb` - - `have_data_disks` - - `data_disk_count` - - `storage_account_type` - - `vm_size` - - `computer_name` - - `admin_username` - - `have_nics` - - `nic_count` - - `connected_nics` - - `have_password_authentication` - - `password_authentication?` - - `have_custom_data` - - `custom_data?` - - `have_ssh_keys` - - `ssh_keys?` - - `ssh_key_count` - - `ssh_keys` - - `have_boot_diagnostics` - - `boot_diagnostics_storage_uri` * `value` is the expected output from the matcher The options that can be passed to the resource are as follows. @@ -77,21 +42,51 @@ When the options have been set as well as the environment variables, the environ For example: -```ruby -describe azure_virtual_machine(group_name: 'Inspec-Azure', name: 'Linux-Internal-VM') do - its('os_type') { should eq 'Linux' } - it { should have_boot_diagnostics } -end -``` + ```ruby + describe azure_virtual_machine(group_name: 'Inspec-Azure', name: 'Linux-Internal-VM') do + its('os_type') { should eq 'Linux' } + it { should have_boot_diagnostics } + end + ``` -## Testers +
-There are a number of built in comparison operrtors that are available to test the result with an expected value. +## Examples -For information on all that are available please refer to the [Inspec Matchers Reference](https://www.inspec.io/docs/reference/matchers/) page. +The following examples show how to use this InSpec audit resource. + +Please refer the integration tests for more in depth examples: + + - [Virtual Machine External VM](../../test/integration/verify/controls/virtual_machine_external_vm.rb) + - [Virtual Machine Internal VM](../../test/integration/verify/controls/virtual_machine_internal_vm.rb) + +### Test that the machine was built from a Windows image + + ```ruby + describe azure_virtual_machine(name: 'Windows-Internal-VM', group_name: 'Inspec-Azure') do + its('publisher') { should eq 'MicrosoftWindowsServer' } + its('offer') { should eq 'WindowsServer' } + its('sku') { should eq '2012-R2-Datacenter' } + end + ``` + +### Ensure the machine is in the correct location + + ```ruby + describe azure_virtual_machine(name: 'Linux-Internal-VM', resource_group: 'Inspec-Azure') do + its('location') { should eq 'westeurope' } + end + ``` +
## Properties +* [`type`](#type), [`location`](#location), [`name`](#name), [`publisher`](#publisher), [`offer`](#offer), [`sku`](#sku), [`os_type`](#"os_type"), [`os_disk_name`](#os_disk_name), [`have_managed_osdisk`](#have_managed_osdisk?), [`caching`](#caching), `create_option`, `disk_size_gb`, `have_data_disks`, `data_disk_count` , `storage_account_type`, `vm_size`, `computer_name`, `admin_username`, `have_nics`, `nic_count`, `connected_nics`, `have_password_authentication`, `password_authentication?`, `have_custom_data`, `custom_data?`, `have_ssh_keys`, `ssh_keys?`, `ssh_key_count`, `ssh_keys`, `have_boot_diagnostics`, `boot_diagnostics_storage_uri` + +
+ +## Property Examples + This InSpec audit resource has the following properties that can be tested: ### type @@ -128,15 +123,15 @@ The item from the publisher that was used to create the image. This will be `nil` if the machine was created from a custom image. -### os_type +### os\_type Test that returns the classification in Azure of the operating system type. Ostensibly this will be either `Linux` or `Windows`. -### os_disk_name +### os\_disk\_name Return the name of the operating system disk attached to the machine. -### have_managed_osdisk +### have\_managed\_osdisk Determine if the operating system disk is a Managed Disks or not. @@ -150,15 +145,15 @@ it { should have_managed_osdisk } Returns the type of caching that has been set on the operating system disk. -### create_option +### create\_option When the operating system disk is created, how it was created is set as an property. This property returns how the disk was created. -### disk_size_gb +### disk\_size\_gb Returns the size of the operating system disk. -### have_data_disks +### have\_data\_disks Denotes if the machine has data disks attached to it or not. @@ -166,15 +161,15 @@ Denotes if the machine has data disks attached to it or not. it { should have_data_disks } ``` -### data_disk_count +### data\_disk\_count Return the number of data disks that are attached to the machine -### storage_account_type +### storage\_account\_type This provides the storage account type for a machine that is using managed disks for the operating system disk. -### vm_size +### vm\_size The size of the machine in Azure @@ -182,43 +177,43 @@ The size of the machine in Azure its('vm_size') { should eq 'Standard_DS2_v2' } ``` -### computer_name +### computer\_name The name of the machine. This is what was assigned to the machine during deployment and is what _should_ be returned by the `hostname` command. -### admin_username +### admin\_username The admin username that was assigned to the machine NOTE: Azure does not allow the use of `Administrator` as the admin username on a Windows machine -## have_nics +## have\_nics Returns a boolean to state if the machine has NICs connected or not. This has can be used in the following way: -```ruby -it { should have_nics } -``` + ```ruby + it { should have_nics } + ``` -### nic_count +### nic\_count The number of network interface cards that have been attached to the machine -### connected_nics +### connected\_nics This returns an array of the NIC ids that are connected to the machine. This means that it possible to check that the machine has the correct NIC(s) attached and thus on the correct subnet. -```ruby -its('connected_nics') { should include /Inspec-NIC-1/ } -``` + ```ruby + its('connected_nics') { should include /Inspec-NIC-1/ } + ``` Note the use of the regular expression here. This is because the NIC id is a long string that contains the subscription id, resource group, machine id as well as other things. By using the regular expression the NIC can be checked withouth breaking this string up. It also means that other tests can be performed. An example of the id string is `/subscriptions/1e0b427a-d58b-494e-ae4f-ee558463ebbf/resourceGroups/Inspec-Azure/providers/Microsoft.Network/networkInterfaces/Inspec-NIC-1` -### have_password_authentication +### have\_password\_authentication Returns a boolean to denote if the machine is accessible using a password. @@ -226,110 +221,122 @@ Returns a boolean to denote if the machine is accessible using a password. it { should have_password_authentication } ``` -### password_authentication? +### password\_authentication? Boolean to state of password authentication is enabled or not for the admin user. -```ruby -its('password_authentication?') { should be false } -``` + ```ruby + its('password_authentication?') { should be false } + ``` This only applies to Linux machines and will always return `true` on Windows. -### have_custom_data +### have\_custom\_data Returns a boolean stating if the machine has custom data assigned to it. -```ruby -it { should have_custom_data } -``` + ```ruby + it { should have_custom_data } + ``` -### custom_data? +### custom\_data? Boolean to state if the machine has custom data or not -```ruby -its('custom_data') { should be true } -``` + ```ruby + its('custom_data') { should be true } + ``` -### have_ssh_keys +### have\_ssh\_keys Boolean to state if the machine has SSH keys assigned to it -```ruby -it { should have_ssh_keys } -``` + ```ruby + it { should have_ssh_keys } + ``` For a Windows machine this will always be false. -### ssh_keys? +### ssh\_keys? Boolean to state of the machine is accessible using SSH keys -```ruby -its('ssh_keys?') { should be true } -``` + ```ruby + its('ssh_keys?') { should be true } + ``` -### ssh_key_count +### ssh\_key\_count Returns how many SSH keys have been applied to the machine. This only applies to Linux machines and will always return `0` on Windows. -### ssh_keys +### ssh\_keys Returns an array of the keys that are assigned to the machine. This is check if the correct keys are assigned. Most SSH public keys have a signature at the end of them that can be tested. For example: -```ruby -its('ssh_keys') { should include /azure@inspec.local/ } -``` + ```ruby + its('ssh_keys') { should include /azure@inspec.local/ } + ``` -### boot_diagnostics? +### boot\_diagnostics? Boolean test to see if boot diagnostics have been enabled on the machine -```ruby -it { should have_boot_diagnostics } -``` + ```ruby + it { should have_boot_diagnostics } + ``` -### boot_diagnostics_storage_uri +### boot\_diagnostics\_storage\_uri If boot diagnostics are enabled for the machine they will be saved in a storage account. This method returns the URI for the storage account. -```ruby -its('boot_diagnostics_storage_uri') { should match 'ghjgjhgjg' } -``` + ```ruby + its('boot_diagnostics_storage_uri') { should match 'ghjgjhgjg' } + ``` + +
+ +## Matchers + +There are a number of built in comparison operrtors that are available to test the result with an expected value. + +For information on all that are available please refer to the [Inspec Matchers Reference](https://www.inspec.io/docs/reference/matchers/) page. + +
+ + ## Tags It is possible to test the tags that have been assigned to the resource. There are a number of properties that can be called to check that it has tags, that it has the correct number and that the correct ones are assigned. -### have_tags +### have\_tags This is a simple test to see if the machine has tags assigned to it or not. -```ruby -it { should have_tags } -``` + ```ruby + it { should have_tags } + ``` -### tag_count +### tag\_count Returns the number of tags that are assigned to the resource -```ruby -its ('tag_count') { should eq 2 } -``` + ```ruby + its ('tag_count') { should eq 2 } + ``` ### tags It is possible to check if a specific tag has been set on the resource. -```ruby -its('tags') { should include 'Owner' } -``` + ```ruby + its('tags') { should include 'Owner' } + ``` -### xxx_tag +### xxx\_tag To get the value of the tag, a number of tests have been craeted from the tags that are set. @@ -341,34 +348,14 @@ For example, if the following tag is set on a resource: Then a test is available called `Owner_tag`. -```ruby -its('Owner_tag') { should cmp 'Russell Seymour' } -``` + ```ruby + its('Owner_tag') { should cmp 'Russell Seymour' } + ``` Note: The tag name is case sensitive which makes the test case sensitive. E.g. `owner_tag` does not equal `Owner_tag`. -## Examples -The following examples show how to use this InSpec audit resource. -Please refer the integration tests for more in depth examples: +## References - - [Virtual Machine External VM](../../test/integration/verify/controls/virtual_machine_external_vm.rb) - - [Virtual Machine Internal VM](../../test/integration/verify/controls/virtual_machine_internal_vm.rb) - -### Test that the machine was built from a Windows image - -```ruby -describe azure_virtual_machine(name: 'Windows-Internal-VM', group_name: 'Inspec-Azure') do - its('publisher') { should eq 'MicrosoftWindowsServer' } - its('offer') { should eq 'WindowsServer' } - its('sku') { should eq '2012-R2-Datacenter' } -end -``` - -### Ensure the machine is in the correct location - -```ruby -describe azure_virtual_machine(name: 'Linux-Internal-VM', resource_group: 'Inspec-Azure') do - its('location') { should eq 'westeurope' } -end +- [Azure Ruby SDK - Resources](https://github.com/Azure/azure-sdk-for-ruby/tree/master/management/azure_mgmt_resources) diff --git a/docs/resources/azure_virtual_machine_datadisk.md.erb b/docs/resources/azure_virtual_machine_datadisk.md.erb index a03507200..928b7e156 100644 --- a/docs/resources/azure_virtual_machine_datadisk.md.erb +++ b/docs/resources/azure_virtual_machine_datadisk.md.erb @@ -2,7 +2,7 @@ title: About the azure_virtual_machine_datadisk Resource --- -# azure_virtual_machine_datadisk +# azure\_virtual\_machine\_datadisk Use this resource to ensure that a specific data disk attached to a machine has been created properly. @@ -14,10 +14,10 @@ Use this resource to ensure that a specific data disk attached to a machine has The name of the resource group and machine are required to use this resource. -```ruby -describe azure_virtual_machine_data_disk(group_name: 'MyResourceGroup', name: 'MyVM') do - its('property') { should eq 'value' } -end + ```ruby + describe azure_virtual_machine_data_disk(group_name: 'MyResourceGroup', name: 'MyVM') do + its('property') { should eq 'value' } + end ``` where @@ -25,24 +25,7 @@ where * Resource Parameters * `MyVm` is the name of the virtual machine as seen in Azure. (It is **not** the hostname of the machine) * `MyResourceGroup` is the name of the resouce group that the machine is in. -* `property` is one of - - `count` the number of data disks attached to the machine - - `have_data_disks` boolean test denoting if data disks are attached - - `have_managed_disks` boolean test denoting if the data disks are all managed disks or not - - `disk` the zero based index of the disk attached to the machine - - `number` disk number of the disk attached to the machine, starting at 1 - - `name` name of the disk - - `size` size of the disk - - `lun` the lun of the disk as reported by Azure - - `caching` the caching that has been set on the disk - - `create_option` how the disk was created - - `is_managed_disk?` if this particular disk is a managed disk or not - - `vhd_uri` URI of the disk if it is in a storage account - - `storage_account_name` the storage account name that the disk is stored in - - `storage_account_type` if this is a managed disk what is the the storage type - - `id` the fully qualified id to the disk in Azure - - `subscription_id` if this is a managed disk the subscription that it is located in - - `resource_group` if this is a managed disk the resource group that it is in +* `property` is a resource property * `value` is the expected output fdrom the matcher The `count`, `have_data_disks` and `have_managed_disks` are catchall tests that give information about the virtual machine. The specific tests need to be used in conjunction with the `where` option as shown below. @@ -63,59 +46,75 @@ When the options have been set as well as the environment variables, the environ For example: -```ruby -describe azure_virtual_machine_data_disk(group_name: 'Inspec-Azure', name: 'Linux-Internal-VM') do - its('count') { should cmp > 0 } - it { should have_data_disks } -end -``` + ```ruby + describe azure_virtual_machine_data_disk(group_name: 'Inspec-Azure', name: 'Linux-Internal-VM') do + its('count') { should cmp > 0 } + it { should have_data_disks } + end + ``` -## Matchers +
-This InSpec audit resource has the following matchers: +## Examples -### eq +The following examples show to use this InSpec audit resource. -Use the `eq` matcher to test the equality of two values: `its('Port') { should eq '22' }`. +Please refer to the following integration tests for more in depth examples: -Using `its('Port') { should eq 22 }` will fail because `22` is not a string value! Use the `cmp` matcher for less restrictive value comparisons. + - [Linux Internal Data Disks](../../test/integration/verify/controls/virtual_machine_linux_external_vm_datadisk.rb) + - [Windows Internal Data Disk](../../test/integration/verify/controls/virtual_machine_windows_internal_vm_datadisk.rb) + +### Check that the first data disk is of the correct size -**The following properties are ones that are applied to the virtual machine itself and not specfic disks** + ```ruby + describe azure_virtual_machine_data_disk(group_name: 'Inspec-Azure', name: 'Linux-Internal-VM').where(number: 1) do + its('size') { should cmp >= 15 } + end + ``` + +
## Properties +* `count`, `have_data_disks`, `have_managed_disks`, `disk`, `number`, `name`, `size`, `lun` +, `caching`, `create_option`, `is_managed_disk?`, `vhd_uri`, `storage_account_name`, `storage_account_type`, `id`, `subscription_id`, `resource_group` + +
+ +## Property Examples + ### count Returns the number of data disks attached to the machine -```ruby -its('count') { should eq 1 } + ```ruby + its('count') { should eq 1 } ``` -### have_data_disks +### have\_data\_disks Returns a boolean denoting if any data disks are attached to the machine -```ruby -it { should have_data_disks } -``` + ```ruby + it { should have_data_disks } + ``` -### have_managed_disks +### have\_managed\_disks Returns a boolean stating if the machine has Managed Disks for data disks. -```ruby -it { should have_managed_disks } -``` + ```ruby + it { should have_managed_disks } + ``` **The next set of attributes require the `where` operation to be used on the describe.** The following code shows an example of how to use the where clause. -```ruby -describe azure_virtual_machine_data_disk(group_name: 'Inspec-Azure', name: 'Windows-Internal-VM').where(number: 1) -end -``` + ```ruby + describe azure_virtual_machine_data_disk(group_name: 'Inspec-Azure', name: 'Windows-Internal-VM').where(number: 1) + end + ``` ### disk @@ -145,25 +144,25 @@ The disk number as reported by Azure. This is a zero based index value. String stating the caching that has been set on the disk. -### create_option +### create\_option How the disk was created. Typically for data disks this will be the string value 'Empty'. -### is_managed_disk? +### is\_managed\_disk? Boolean stating if the disk is a managed disk or not. If it is not a managed disk then it is one that is stored in a Storage Account. -### vhd_uri +### vhd\_uri If this _not_ a managed disk then the `vhd_uri` will be the full URI to the disk in the storage account. -### storage_account_name +### storage\_account\_name If this is _not_ a managed disk this will be the storage account name in which the disk is stored. This derived from the `vhd_uri`. -### storage_account_type +### storage\_account\_type If this is a managed disk this is the storage account type, e.g. `Standard_LRS` @@ -171,30 +170,28 @@ If this is a managed disk this is the storage account type, e.g. `Standard_LRS` If this is a managed disk then this is the fully qualified id to the disk in Azure. -### subscription_id +### subscription\_id If this is a managed disk, this returns the subscription id of where the disk is stored. This is derived from the `id`. -### resource_group +### resource\_group If this is a managed disk, this returns the resource group in which the disk is stored. This is derived from the `id`. -## Examples +
-The following examples show to use this InSpec audit resource. +## Matchers -Please refer to the following integration tests for more in depth examples: +This InSpec audit resource has the following matchers: - - [Linux Internal Data Disks](../../test/integration/verify/controls/virtual_machine_linux_external_vm_datadisk.rb) - - [Windows Internal Data Disk](../../test/integration/verify/controls/virtual_machine_windows_internal_vm_datadisk.rb) - -### Check that the first data disk is of the correct size -```ruby -describe azure_virtual_machine_data_disk(group_name: 'Inspec-Azure', name: 'Linux-Internal-VM').where(number: 1) do - its('size') { should cmp >= 15 } -end -``` \ No newline at end of file +### eq + +Use the `eq` matcher to test the equality of two values: `its('Port') { should eq '22' }`. + +Using `its('Port') { should eq 22 }` will fail because `22` is not a string value! Use the `cmp` matcher for less restrictive value comparisons. + +**The following properties are ones that are applied to the virtual machine itself and not specfic disks** \ No newline at end of file From d63d15c45705778e4cd57fee1fffff415bc180de Mon Sep 17 00:00:00 2001 From: kagarmoe Date: Wed, 14 Feb 2018 20:31:56 -0800 Subject: [PATCH 204/206] Fixes formatting on aws/azure resources Signed-off-by: kagarmoe --- docs/resources/audit_policy.md.erb | 2 +- docs/resources/aws_iam_access_key.md.erb | 2 +- docs/resources/aws_iam_policy.md.erb | 2 +- docs/resources/aws_iam_role.md.erb | 2 + docs/resources/aws_kms_keys.md.erb | 2 +- docs/resources/aws_s3_bucket.md.erb | 2 + docs/resources/aws_security_group.md.erb | 66 ++++--- docs/resources/aws_security_groups.md.erb | 8 +- docs/resources/aws_vpcs.md.erb | 2 +- docs/resources/azure_generic_resource.md.erb | 42 ++-- docs/resources/azure_resource_group.md.erb | 193 +++++++++++++++---- docs/resources/windows_task.md.erb | 2 +- 12 files changed, 230 insertions(+), 95 deletions(-) diff --git a/docs/resources/audit_policy.md.erb b/docs/resources/audit_policy.md.erb index 1f23672d3..3c5b8b80d 100644 --- a/docs/resources/audit_policy.md.erb +++ b/docs/resources/audit_policy.md.erb @@ -4,7 +4,7 @@ title: About the audit_policy Resource # audit_policy -Use the `audit_policy` Inspec audit resource to test auditing policies on the Windows platform. An auditing policy is a category of security-related events to be audited. Auditing is disabled by default and may be enabled for categories like account management, logon events, policy changes, process tracking, privilege use, system events, or object access. For each enabled auditing category property, the auditing level may be set to `No Auditing`, `Not Specified`, `Success`, `Success and Failure`, or `Failure`. +Use the `audit_policy` InSpec audit resource to test auditing policies on the Windows platform. An auditing policy is a category of security-related events to be audited. Auditing is disabled by default and may be enabled for categories like account management, logon events, policy changes, process tracking, privilege use, system events, or object access. For each enabled auditing category property, the auditing level may be set to `No Auditing`, `Not Specified`, `Success`, `Success and Failure`, or `Failure`.
diff --git a/docs/resources/aws_iam_access_key.md.erb b/docs/resources/aws_iam_access_key.md.erb index ea561f93f..f8929b9ea 100644 --- a/docs/resources/aws_iam_access_key.md.erb +++ b/docs/resources/aws_iam_access_key.md.erb @@ -120,4 +120,4 @@ This InSpec audit resource has the following special matchers. For a full list o The `be_active` matcher tests if the described IAM access key is active. - it { should be_active } + it { should be_active } diff --git a/docs/resources/aws_iam_policy.md.erb b/docs/resources/aws_iam_policy.md.erb index 27e0f78e0..f728009c3 100644 --- a/docs/resources/aws_iam_policy.md.erb +++ b/docs/resources/aws_iam_policy.md.erb @@ -3,7 +3,7 @@ title: About the aws_iam_policy Resource platform: aws --- -# aws_iam_policy +# aws\_iam\_policy Use the `aws_iam_policy` InSpec audit resource to test properties of a single managed AWS IAM Policy. diff --git a/docs/resources/aws_iam_role.md.erb b/docs/resources/aws_iam_role.md.erb index 436aa749e..00b1e8d24 100644 --- a/docs/resources/aws_iam_role.md.erb +++ b/docs/resources/aws_iam_role.md.erb @@ -48,6 +48,8 @@ A textual description of the IAM Role. ## 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/). + ### exist Indicates that the Role Name provided was found. Use should_not to test for IAM Roles that should not exist. diff --git a/docs/resources/aws_kms_keys.md.erb b/docs/resources/aws_kms_keys.md.erb index 58cfb8c57..c28a85810 100644 --- a/docs/resources/aws_kms_keys.md.erb +++ b/docs/resources/aws_kms_keys.md.erb @@ -71,7 +71,7 @@ Provides a list of key ids for all KMS Keys in the AWS account. ## Matchers -For a full list of available matchers please visit our [matchers page](https://www.inspec.io/docs/reference/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/). ### exists diff --git a/docs/resources/aws_s3_bucket.md.erb b/docs/resources/aws_s3_bucket.md.erb index c493e2fb2..950dee41d 100644 --- a/docs/resources/aws_s3_bucket.md.erb +++ b/docs/resources/aws_s3_bucket.md.erb @@ -109,6 +109,8 @@ If there is no bucket policy, this property returns an empty array. s.effect == 'Allow' && s.principal == '*' 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/). diff --git a/docs/resources/aws_security_group.md.erb b/docs/resources/aws_security_group.md.erb index 6d68410c0..b0d4d674e 100644 --- a/docs/resources/aws_security_group.md.erb +++ b/docs/resources/aws_security_group.md.erb @@ -83,27 +83,23 @@ A string identifying the VPC that contains the security group. Since VPCs common 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/). - -### exists - -The control will pass if the specified SG was found. Use should_not if you want to verify that the specified SG does not exist. - - # You will always have at least one SG, the VPC default SG - describe aws_security_group(group_name: 'default') - it { should exist } - end - - # Make sure we don't have any security groups with the name 'nogood' - describe aws_security_group(group_name: 'nogood') - it { should_not exist } - end - ## Properties +* `description`, `group_id', `group_name`, `vpc_id` + +
+ +## Property Examples + +### description + +A String reflecting the human-meaningful description that was given to the SG at creation time. + + # Require a description of a particular group + describe aws_security_group('sg-12345678') do + its('description') { should_not be_empty } + end + ### group\_id Provides the Security Group ID. @@ -125,15 +121,6 @@ A String reflecting the name that was given to the SG at creation time. its('group_name') { should cmp 'my_group' } end -### description - -A String reflecting the human-meaningful description that was given to the SG at creation time. - - # Require a description of a particular group - describe aws_security_group('sg-12345678') do - its('description') { should_not be_empty } - end - ### vpc\_id A String in the format 'vpc-' followed by 8 hexadecimal characters reflecting VPC that contains the security group. @@ -141,4 +128,25 @@ A String in the format 'vpc-' followed by 8 hexadecimal characters reflecting VP # Inspec the VPC ID of a particular group describe aws_security_group('sg-12345678') do its('vpc_id') { should cmp 'vpc-12345678' } - end \ No newline at end of file + 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/). + +### exists + +The control will pass if the specified SG was found. Use should_not if you want to verify that the specified SG does not exist. + + # You will always have at least one SG, the VPC default SG + describe aws_security_group(group_name: 'default') + it { should exist } + end + + # Make sure we don't have any security groups with the name 'nogood' + describe aws_security_group(group_name: 'nogood') + it { should_not exist } + end + diff --git a/docs/resources/aws_security_groups.md.erb b/docs/resources/aws_security_groups.md.erb index 456cee544..634310717 100644 --- a/docs/resources/aws_security_groups.md.erb +++ b/docs/resources/aws_security_groups.md.erb @@ -50,10 +50,16 @@ A string identifying a group. Since groups are contained in VPCs, group names ar it { should exist } end +
+ ## Properties * `entries`, `group\_ids` +
+ +## Property Examples + ### entries Provides access to the raw results of the query. This can be useful for checking counts and other advanced operations. @@ -73,7 +79,7 @@ Provides a list of all security group IDs matched. ## Matchers -For a full list of available matchers please visit our [matchers page](https://www.inspec.io/docs/reference/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/). ### exists diff --git a/docs/resources/aws_vpcs.md.erb b/docs/resources/aws_vpcs.md.erb index 0534aa3c5..5d8b592be 100644 --- a/docs/resources/aws_vpcs.md.erb +++ b/docs/resources/aws_vpcs.md.erb @@ -36,7 +36,7 @@ As this is the initial release of `aws_vpcs`, its limited functionality preclude ## Matchers -For a full list of available matchers please visit our [matchers page](https://www.inspec.io/docs/reference/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/). ### exists diff --git a/docs/resources/azure_generic_resource.md.erb b/docs/resources/azure_generic_resource.md.erb index 592b116f1..9c2e8e075 100644 --- a/docs/resources/azure_generic_resource.md.erb +++ b/docs/resources/azure_generic_resource.md.erb @@ -4,7 +4,7 @@ title: About the azure_generic_resource Resource # azure\_generic\_resource -Use the `azure_generic_resource` Inspec audit resource to test any valid Azure Resource. This is very useful if you need to test something that we do not yet have a specific Inspec resource for. +Use the `azure_generic_resource` InSpec audit resource to test any valid Azure Resource. This is very useful if you need to test something that we do not yet have a specific Inspec resource for. ## Syntax @@ -16,20 +16,19 @@ Use the `azure_generic_resource` Inspec audit resource to test any valid Azure R where -* Resource Parameters - * `MyResourceGroup` is the name of the resource group that contains the Azure Resource to be validated - * `MyResource` is the name of the resource that needs to be checked +* `MyResourceGroup` is the name of the resource group that contains the Azure Resource to be validated +* `MyResource` is the name of the resource that needs to be checked * `property` - This generic resource dynamically creates the properties on the fly based on the type of resource that has been targetted. * `value` is the expected output from the chosen property The options that can be passed to the resource are as follows. -| Name | Description | Required | Example | -|-------------|---------------------------------------------------------------------------------------------------------------------|----------|-----------------------------------| -| group_name: | Azure Resource Group to be tested | yes | MyResourceGroup | -| name: | Name of the Azure resource to test | no | MyVM | -| type: | Azure Resource Type to look for | no | Microsoft.Compute/virtualMachines | -| apiversion: | API Version to use when interrogating the resource. If not set then the latest version for the resoure type is used | no | 2017-10-9 | +The options that can be passed to the resource are as follows. + +* `group_name`, the Azure resource group to be tested. Example: 'MyResourceGroup' (required) +* `name`, the name of the Azure resource to test. Example: 'MyVM' +* `type`, the Azure resource type. Example: 'Microsoft.Compute/virtualMachines' +* `apiversion`, the API Version to use when querying the resource. Defaults to the latest version for the resoure type is used. Example: 2017-10-9 These options can also be set using the environment variables: @@ -42,18 +41,17 @@ When the options have been set as well as the environment variables, the environ There are _normally_ three standard tests that can be performed on a resource. -| Name | Description | -|------|-------------| -| name | Name of the resource | -| type | Type of resource | -| location | Location of the resource within Azure | +* `name`: tests the resource name +* `type`: tests the resource type +* `location`: tests the resource's location within Azure -For example: +## Example -```ruby -describe azure_generic_resource(group_name: 'Inspec-Azure', name: 'Linux-Internal-VM') do - its('location') { should eq 'westeurope' } -end + ```ruby + describe azure_generic_resource(group_name: 'Inspec-Azure', name: 'Linux-Internal-VM') do + its('location') { should eq 'westeurope' } + end + ``` ``` ## Properties @@ -76,4 +74,8 @@ Please see the integration tests for in depth examples of how this resource can - [Generic Virtual Network](../test/integration/verify/controls/generic_virtual_network.rb) - [Generic Windows Internal VM NIC](../test/integration/verify/controls/generic_windows_internal_vm_nic.rb) - [Generic Windows Internal VM](../test/integration/verify/controls/generic_windows_internal_vm.rb) + + ## Matchers + +For a full list of available matchers please visit our [matchers page](https://www.inspec.io/docs/reference/matchers/). \ No newline at end of file diff --git a/docs/resources/azure_resource_group.md.erb b/docs/resources/azure_resource_group.md.erb index 1da7c4073..abea33b51 100644 --- a/docs/resources/azure_resource_group.md.erb +++ b/docs/resources/azure_resource_group.md.erb @@ -10,11 +10,11 @@ Use the `azure_resource_group_resource_counts` InSpec audit resource to check th The name of the resource group is specified as a parameter on the resource: -```ruby -describe azure_resource_group(name: 'MyResourceGroup') do - its('property') { should eq 'value' } -end -``` + ```ruby + describe azure_resource_group(name: 'MyResourceGroup') do + its('property') { should eq 'value' } + end + ``` where @@ -25,10 +25,51 @@ where The options that can be passed to the resource are as follows. -| Name | Description | Required | Example | -|-------------|---------------------------------------------------------------------------------------------------------------------|----------|-----------------------------------| -| group_name: | Azure Resource Group to be tested | yes | MyResourceGroup | -| name: | Name of the Azure resource to test | no | MyVM | +## Examples + +The following examples show how to use this InSpec audit resource + +Please refer the integration tests for more in depth examples: + + - [Resource Group](../../test/integration/verify/controls/resource_group.rb) + +### Test Resource Group has the correct number of resources + + ```ruby + describe azure_resource_group_resource_counts(name: 'Inspec-Azure') do + its('total') { should eq 7} + ``` + +### Ensure that the Resource Group contains the correct resources + + ```ruby + describe azure_resource_group_resource_counts(name: 'Inspec-Azure') do + its('total') { should eq 7 } + its('vm_count') { should eq 2 } + its('nic_count') { should eq 2 } + its('public_ip_count') { should eq 1 } + its('sa_count') { should eq 1 } + its('vnet_count') { should eq 1 } + end + ``` + +
+ +## Resource Parameters + +The options that can be passed to the resource are as follows. + +### `group_name` (required) + +Use this parameter to define the Azure Resource Group to be tested. + +example: MyResourceGroup + +### name + +Use this parameter to define the name of the Azure resource to test + +example: MyVM If both `group_name` and `name` is set then `name` will take priority @@ -39,106 +80,182 @@ These options can also be set using the environment variables: When the options have been set as well as the environment variables, the environment variables take priority. -For example: +### Parameter Example ```ruby describe azure_resource_group_resource_counts(name: 'ChefAutomate') do - its('total') { should eq 7} - its('nic_count') { should eq 1 } - its('vm_count') { should eq 1 } + its('total') { should eq 7} + its('nic_count') { should eq 1 } + its('vm_count') { should eq 1 } end ``` +
## Properties * `name`, `location` ,`id`, `provisioning_state`, `subscription_id`, `total`, `nic_count`, `vm_count`, `extension_count`, `vnet_count`, `sa_count`, `public_ip_count`,`managed_disk_image_count`, `managed_disk_count`, `tag_count` -
## Property Examples +This InSpec audit resource has the following properties: + ### name Returns the name of the resource group. + ```ruby + its(name) { should cmp 'nugget' } + ``` + ### location Returns where in Azure the resource group is located. + ```ruby + its(location) { should cmp 'us-west' } + ``` + ### id Returns the full qualified ID of the resource group. This is in the format `/subscriptions//resourceGroups/`. -### provisioning\_state + ```ruby + its(id) { should cmp 'FQDN' } + ``` + +### provisioning_state The provisioning state of the resource group. -### subscription\_id + ```ruby + its(provisioning_state) { should cmp '????' } + ``` + +### subscription_id Returns the subscription ID which contains the resource group. This is derived from the `id`. + ```ruby + its(subscription_id) { should cmp '????' } + ``` + ### total The total number of resources in the resource group -### nic\_count + ```ruby + its(total) { should eq 5 } + ``` + +### nic_count The number of network interface cards in the resource group -### vm\_count + ```ruby + its(nic_count) { should eq 2 } + ``` + +### vm_count The number of virtual machines in the resource group -### vnet\_count + ```ruby + its(vm_count) { should eq 5 } + ``` + +### vnet_count The number of virtual networks in the resource group -### sa\_count + ```ruby + its(vnet_count) { should eq 5 } + ``` + +### sa_count The number of storage accounts in the resource group -### public\_ip\_count + ```ruby + its(sa_count) { should eq 5 } + ``` + +### public_ip_count The number of Public IP Addresses in the resource group -### managed\_disk\_image\_count + ```ruby + its(public_ip_count) { should eq 5 } + ``` + +### managed_disk_image_count The number of managed disk images that are in the resource group. These are the items from which managed disks are created which are attached to machines. Generally the images are created from a base image or a custom image (e.g. Packer) -### managed\_disk\_count + ```ruby + its(managed_disk_image_count) { should eq 5 } + ``` + +### managed_disk_count The number of managed disks in the resource group. If a resource group contains one virtual machine with an OS disk and 2 data disks that are all Managed Disks, then the count would be 3. -
+ ```ruby + its(managed_disk_count) { should eq 3 } + ``` +
## Matchers This resource has a number of `have_xxxx` matchers that provide a simple way to test of a specific Azure Resoure Type exists in the resource group. -The following table shows the methods that are currently supported and what their associated Azure Resource Type is. +### `have_nics` -| Matcher Name | Azure Resource Type | -|-------------|---------------------| -| have_nics | Microsoft.Network/networkInterfaces | -| have_vms | Microsoft.Compute/virtualMachines | -| have_extensions | Microsoft.Compute/virtualMachines/extensions | -| have_nsgs | Microsoft.Network/networkSecurityGroups | -| have_vnets | Microsoft.Network/virtualNetworks | -| have_managed_disks | Microsoft.Compute/disks | -| have_managed_disk_images | Microsoft.Compute/images | -| have_sas | Microsoft.Storage/storageAccounts | -| have_public_ips | Microsoft.Network/publicIPAddresses | +Use this resource to test ` +Microsoft.Network/networkInterfaces` + +### `have_vms` + +Use this resource to test `Microsoft.Compute/virtualMachines` + +### `have_extensions` + +Use this resource to test `Microsoft.Compute/virtualMachines/extensions`` + +### `have_nsgs` + +Use this resource to test `Microsoft.Network/networkSecurityGroups` + +### `have_vnets` + +Use this resource to test `Microsoft.Network/virtualNetworks` + +### `have_managed_disks` + +Use this resource to test `Microsoft.Compute/disks` + +### `have_managed_disk_images` + +Use this resource to test `Microsoft.Compute/images` + +### `have_sas` + +Use this resource to test `Microsoft.Storage/storageAccounts` + +### `have_public_ips` + +Use this resource to test `Microsoft.Network/publicIPAddresses` With these methods the following tests are possible @@ -146,8 +263,6 @@ With these methods the following tests are possible it { should have_nics } it { should_not have_extensions } ``` - - ## Tags It is possible to test the tags that have been assigned to the resource. There are a number of properties that can be called to check that it has tags, that it has the correct number and that the correct ones are assigned. @@ -190,7 +305,7 @@ Then a property is available called `Owner_tag`. ```ruby its('Owner_tag') { should cmp 'Russell Seymour' } -``` + ``` Note: The tag name is case sensitive which makes the test case sensitive. E.g. `owner_tag` does not equal `Owner_tag`. diff --git a/docs/resources/windows_task.md.erb b/docs/resources/windows_task.md.erb index 63502dbef..0eed15bc3 100644 --- a/docs/resources/windows_task.md.erb +++ b/docs/resources/windows_task.md.erb @@ -4,7 +4,7 @@ title: About the windows_task Resource # windows_task -Use the `windows_task` Inspec audit resource to test a scheduled tasks configuration on a Windows platform. +Use the `windows_task` InSpec audit resource to test a scheduled tasks configuration on a Windows platform. Microsoft and application vendors use scheduled tasks to perform a variety of system maintaince tasks but system administrators can schedule their own.
From 7dc9ab8445e637076f27c7fef009cbb431f4fc5c Mon Sep 17 00:00:00 2001 From: Jared Quick Date: Thu, 15 Feb 2018 10:58:16 -0500 Subject: [PATCH 205/206] Fixed italics link names for resources. Signed-off-by: Jared Quick --- tasks/docs.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tasks/docs.rb b/tasks/docs.rb index 8224ff47e..dc10b3240 100644 --- a/tasks/docs.rb +++ b/tasks/docs.rb @@ -227,7 +227,7 @@ namespace :docs do # rubocop:disable Metrics/BlockLength list = '' resources.each do |file| name = File.basename(file).sub(/\.md\.erb$/, '') - list << f.li(f.a(name.sub('_', '\\_'), 'resources/' + name + '.html')) + list << f.li(f.a(name.gsub('_', '\\_'), 'resources/' + name + '.html')) end res << f.ul(list) dst = File.join(src, 'resources.md') From 36234541538d7aae495547183b4a21864a76b73b Mon Sep 17 00:00:00 2001 From: Jared Quick Date: Thu, 15 Feb 2018 13:24:21 -0500 Subject: [PATCH 206/206] Remove ruby headers from azure docs. Signed-off-by: Jared Quick --- docs/resources/azure_generic_resource.md.erb | 9 +-- docs/resources/azure_resource_group.md.erb | 64 +++---------------- docs/resources/azure_virtual_machine.md.erb | 61 ++---------------- .../azure_virtual_machine_datadisk.md.erb | 23 ++----- 4 files changed, 21 insertions(+), 136 deletions(-) diff --git a/docs/resources/azure_generic_resource.md.erb b/docs/resources/azure_generic_resource.md.erb index 9c2e8e075..40e282854 100644 --- a/docs/resources/azure_generic_resource.md.erb +++ b/docs/resources/azure_generic_resource.md.erb @@ -8,11 +8,9 @@ Use the `azure_generic_resource` InSpec audit resource to test any valid Azure R ## Syntax - ```ruby describe azure_generic_resource(group_name: 'MyResourceGroup', name: 'MyResource') do its('property') { should eq 'value' } end - ``` where @@ -23,8 +21,6 @@ where The options that can be passed to the resource are as follows. -The options that can be passed to the resource are as follows. - * `group_name`, the Azure resource group to be tested. Example: 'MyResourceGroup' (required) * `name`, the name of the Azure resource to test. Example: 'MyVM' * `type`, the Azure resource type. Example: 'Microsoft.Compute/virtualMachines' @@ -47,12 +43,9 @@ There are _normally_ three standard tests that can be performed on a resource. ## Example - ```ruby describe azure_generic_resource(group_name: 'Inspec-Azure', name: 'Linux-Internal-VM') do its('location') { should eq 'westeurope' } end - ``` -``` ## Properties @@ -78,4 +71,4 @@ Please see the integration tests for in depth examples of how this resource can ## Matchers For a full list of available matchers please visit our [matchers page](https://www.inspec.io/docs/reference/matchers/). - \ No newline at end of file + diff --git a/docs/resources/azure_resource_group.md.erb b/docs/resources/azure_resource_group.md.erb index abea33b51..f94ec395a 100644 --- a/docs/resources/azure_resource_group.md.erb +++ b/docs/resources/azure_resource_group.md.erb @@ -10,16 +10,13 @@ Use the `azure_resource_group_resource_counts` InSpec audit resource to check th The name of the resource group is specified as a parameter on the resource: - ```ruby describe azure_resource_group(name: 'MyResourceGroup') do - its('property') { should eq 'value' } + its('property') { should eq 'value' } end - ``` where -* Resource Parameters - * `MyResourceGroup` is the name of the resource group being interrogated +* `MyResourceGroup` is the name of the resource group being interrogated * `property` is one a resource property * `value` is the expected output from the matcher @@ -35,14 +32,12 @@ Please refer the integration tests for more in depth examples: ### Test Resource Group has the correct number of resources - ```ruby describe azure_resource_group_resource_counts(name: 'Inspec-Azure') do its('total') { should eq 7} - ``` + end ### Ensure that the Resource Group contains the correct resources - ```ruby describe azure_resource_group_resource_counts(name: 'Inspec-Azure') do its('total') { should eq 7 } its('vm_count') { should eq 2 } @@ -51,7 +46,6 @@ Please refer the integration tests for more in depth examples: its('sa_count') { should eq 1 } its('vnet_count') { should eq 1 } end - ```
@@ -82,13 +76,11 @@ When the options have been set as well as the environment variables, the environ ### Parameter Example - ```ruby describe azure_resource_group_resource_counts(name: 'ChefAutomate') do - its('total') { should eq 7} - its('nic_count') { should eq 1 } - its('vm_count') { should eq 1 } + its('total') { should eq 7} + its('nic_count') { should eq 1 } + its('vm_count') { should eq 1 } end - ```
@@ -106,17 +98,13 @@ This InSpec audit resource has the following properties: Returns the name of the resource group. - ```ruby its(name) { should cmp 'nugget' } - ``` ### location Returns where in Azure the resource group is located. - ```ruby its(location) { should cmp 'us-west' } - ``` ### id @@ -124,17 +112,13 @@ Returns the full qualified ID of the resource group. This is in the format `/subscriptions//resourceGroups/`. - ```ruby its(id) { should cmp 'FQDN' } - ``` ### provisioning_state The provisioning state of the resource group. - ```ruby its(provisioning_state) { should cmp '????' } - ``` ### subscription_id @@ -142,57 +126,43 @@ Returns the subscription ID which contains the resource group. This is derived from the `id`. - ```ruby its(subscription_id) { should cmp '????' } - ``` ### total The total number of resources in the resource group - ```ruby its(total) { should eq 5 } - ``` ### nic_count The number of network interface cards in the resource group - ```ruby its(nic_count) { should eq 2 } - ``` ### vm_count The number of virtual machines in the resource group - ```ruby its(vm_count) { should eq 5 } - ``` ### vnet_count The number of virtual networks in the resource group - ```ruby its(vnet_count) { should eq 5 } - ``` ### sa_count The number of storage accounts in the resource group - ```ruby its(sa_count) { should eq 5 } - ``` ### public_ip_count The number of Public IP Addresses in the resource group - ```ruby its(public_ip_count) { should eq 5 } - ``` ### managed_disk_image_count @@ -200,9 +170,7 @@ The number of managed disk images that are in the resource group. These are the items from which managed disks are created which are attached to machines. Generally the images are created from a base image or a custom image (e.g. Packer) - ```ruby its(managed_disk_image_count) { should eq 5 } - ``` ### managed_disk_count @@ -210,9 +178,7 @@ The number of managed disks in the resource group. If a resource group contains one virtual machine with an OS disk and 2 data disks that are all Managed Disks, then the count would be 3. - ```ruby its(managed_disk_count) { should eq 3 } - ```
@@ -259,10 +225,9 @@ Use this resource to test `Microsoft.Network/publicIPAddresses` With these methods the following tests are possible - ```ruby it { should have_nics } it { should_not have_extensions } - ``` + ## Tags It is possible to test the tags that have been assigned to the resource. There are a number of properties that can be called to check that it has tags, that it has the correct number and that the correct ones are assigned. @@ -271,25 +236,19 @@ It is possible to test the tags that have been assigned to the resource. There a This is a simple test to see if the machine has tags assigned to it or not. - ```ruby it { should have_tags } - ``` ### tag\_count Returns the number of tags that are assigned to the resource - ```ruby its ('tag_count') { should eq 2 } - ``` ### tags It is possible to check if a specific tag has been set on the resource. - ```ruby its('tags') { should include 'Owner' } - ``` ### xxx\_tag @@ -303,9 +262,7 @@ For example, if the following tag is set on a resource: Then a property is available called `Owner_tag`. - ```ruby its('Owner_tag') { should cmp 'Russell Seymour' } - ``` Note: The tag name is case sensitive which makes the test case sensitive. E.g. `owner_tag` does not equal `Owner_tag`. @@ -319,14 +276,12 @@ Please refer the integration tests for more in depth examples: ### Test Resource Group has the correct number of resources - ```ruby describe azure_resource_group_resource_counts(name: 'Inspec-Azure') do its('total') { should eq 7} - ``` + end ### Ensure that the Resource Group contains the correct resources - ```ruby describe azure_resource_group_resource_counts(name: 'Inspec-Azure') do its('total') { should eq 7 } its('vm_count') { should eq 2 } @@ -335,8 +290,7 @@ Please refer the integration tests for more in depth examples: its('sa_count') { should eq 1 } its('vnet_count') { should eq 1 } end - ``` ## References -- [Azure Ruby SDK - Resources](https://github.com/Azure/azure-sdk-for-ruby/tree/master/management/azure_mgmt_resources) \ No newline at end of file +- [Azure Ruby SDK - Resources](https://github.com/Azure/azure-sdk-for-ruby/tree/master/management/azure_mgmt_resources) diff --git a/docs/resources/azure_virtual_machine.md.erb b/docs/resources/azure_virtual_machine.md.erb index de2cd2abc..7284e7c36 100644 --- a/docs/resources/azure_virtual_machine.md.erb +++ b/docs/resources/azure_virtual_machine.md.erb @@ -10,17 +10,14 @@ Use the `azure_virtual_machine` InSpec audit resource to ensure that a Virtual M The name of the machine and the resourece group are required as properties to the resource. - ```ruby describe azure_virtual_machine(group_name: 'MyResourceGroup', name: 'MyVM') do its('property') { should eq 'value' } end - ``` where -* Resource Parameters - * `MyVm` is the name of the virtual machine as seen in Azure. (It is **not** the hostname of the machine) - * `MyResourceGroup` is the name of the resource group that the machine is in. +* `MyVm` is the name of the virtual machine as seen in Azure. (It is **not** the hostname of the machine) +* `MyResourceGroup` is the name of the resource group that the machine is in. * `property` is one of * `value` is the expected output from the matcher @@ -42,12 +39,10 @@ When the options have been set as well as the environment variables, the environ For example: - ```ruby describe azure_virtual_machine(group_name: 'Inspec-Azure', name: 'Linux-Internal-VM') do its('os_type') { should eq 'Linux' } it { should have_boot_diagnostics } end - ```
@@ -62,21 +57,17 @@ Please refer the integration tests for more in depth examples: ### Test that the machine was built from a Windows image - ```ruby describe azure_virtual_machine(name: 'Windows-Internal-VM', group_name: 'Inspec-Azure') do its('publisher') { should eq 'MicrosoftWindowsServer' } its('offer') { should eq 'WindowsServer' } its('sku') { should eq '2012-R2-Datacenter' } end - ``` ### Ensure the machine is in the correct location - ```ruby describe azure_virtual_machine(name: 'Linux-Internal-VM', resource_group: 'Inspec-Azure') do its('location') { should eq 'westeurope' } end - ```
## Properties @@ -97,9 +88,7 @@ THe Azure Resource type. For a virtual machine this will always return `Microsof Where the machine is located -```ruby -its('location') { should eq 'westeurope' } -``` + its('location') { should eq 'westeurope' } ### name @@ -137,9 +126,7 @@ Determine if the operating system disk is a Managed Disks or not. This test can be used in the following way: -```ruby -it { should have_managed_osdisk } -``` + it { should have_managed_osdisk } ### caching @@ -157,9 +144,7 @@ Returns the size of the operating system disk. Denotes if the machine has data disks attached to it or not. -```ruby -it { should have_data_disks } -``` + it { should have_data_disks } ### data\_disk\_count @@ -173,9 +158,7 @@ This provides the storage account type for a machine that is using managed disks The size of the machine in Azure -```ruby -its('vm_size') { should eq 'Standard_DS2_v2' } -``` + its('vm_size') { should eq 'Standard_DS2_v2' } ### computer\_name @@ -193,9 +176,7 @@ Returns a boolean to state if the machine has NICs connected or not. This has can be used in the following way: - ```ruby it { should have_nics } - ``` ### nic\_count @@ -205,9 +186,7 @@ The number of network interface cards that have been attached to the machine This returns an array of the NIC ids that are connected to the machine. This means that it possible to check that the machine has the correct NIC(s) attached and thus on the correct subnet. - ```ruby its('connected_nics') { should include /Inspec-NIC-1/ } - ``` Note the use of the regular expression here. This is because the NIC id is a long string that contains the subscription id, resource group, machine id as well as other things. By using the regular expression the NIC can be checked withouth breaking this string up. It also means that other tests can be performed. @@ -217,17 +196,13 @@ An example of the id string is `/subscriptions/1e0b427a-d58b-494e-ae4f-ee558463e Returns a boolean to denote if the machine is accessible using a password. -```ruby -it { should have_password_authentication } -``` + it { should have_password_authentication } ### password\_authentication? Boolean to state of password authentication is enabled or not for the admin user. - ```ruby its('password_authentication?') { should be false } - ``` This only applies to Linux machines and will always return `true` on Windows. @@ -235,25 +210,19 @@ This only applies to Linux machines and will always return `true` on Windows. Returns a boolean stating if the machine has custom data assigned to it. - ```ruby it { should have_custom_data } - ``` ### custom\_data? Boolean to state if the machine has custom data or not - ```ruby its('custom_data') { should be true } - ``` ### have\_ssh\_keys Boolean to state if the machine has SSH keys assigned to it - ```ruby it { should have_ssh_keys } - ``` For a Windows machine this will always be false. @@ -261,9 +230,7 @@ For a Windows machine this will always be false. Boolean to state of the machine is accessible using SSH keys - ```ruby its('ssh_keys?') { should be true } - ``` ### ssh\_key\_count @@ -277,25 +244,19 @@ Returns an array of the keys that are assigned to the machine. This is check if Most SSH public keys have a signature at the end of them that can be tested. For example: - ```ruby its('ssh_keys') { should include /azure@inspec.local/ } - ``` ### boot\_diagnostics? Boolean test to see if boot diagnostics have been enabled on the machine - ```ruby it { should have_boot_diagnostics } - ``` ### boot\_diagnostics\_storage\_uri If boot diagnostics are enabled for the machine they will be saved in a storage account. This method returns the URI for the storage account. - ```ruby its('boot_diagnostics_storage_uri') { should match 'ghjgjhgjg' } - ```
@@ -316,25 +277,19 @@ It is possible to test the tags that have been assigned to the resource. There a This is a simple test to see if the machine has tags assigned to it or not. - ```ruby it { should have_tags } - ``` ### tag\_count Returns the number of tags that are assigned to the resource - ```ruby its ('tag_count') { should eq 2 } - ``` ### tags It is possible to check if a specific tag has been set on the resource. - ```ruby its('tags') { should include 'Owner' } - ``` ### xxx\_tag @@ -348,9 +303,7 @@ For example, if the following tag is set on a resource: Then a test is available called `Owner_tag`. - ```ruby its('Owner_tag') { should cmp 'Russell Seymour' } - ``` Note: The tag name is case sensitive which makes the test case sensitive. E.g. `owner_tag` does not equal `Owner_tag`. diff --git a/docs/resources/azure_virtual_machine_datadisk.md.erb b/docs/resources/azure_virtual_machine_datadisk.md.erb index 928b7e156..350cb898a 100644 --- a/docs/resources/azure_virtual_machine_datadisk.md.erb +++ b/docs/resources/azure_virtual_machine_datadisk.md.erb @@ -14,17 +14,14 @@ Use this resource to ensure that a specific data disk attached to a machine has The name of the resource group and machine are required to use this resource. - ```ruby describe azure_virtual_machine_data_disk(group_name: 'MyResourceGroup', name: 'MyVM') do its('property') { should eq 'value' } end -``` -where +where -* Resource Parameters - * `MyVm` is the name of the virtual machine as seen in Azure. (It is **not** the hostname of the machine) - * `MyResourceGroup` is the name of the resouce group that the machine is in. +* `MyVm` is the name of the virtual machine as seen in Azure. (It is **not** the hostname of the machine) +* `MyResourceGroup` is the name of the resouce group that the machine is in. * `property` is a resource property * `value` is the expected output fdrom the matcher @@ -46,12 +43,10 @@ When the options have been set as well as the environment variables, the environ For example: - ```ruby describe azure_virtual_machine_data_disk(group_name: 'Inspec-Azure', name: 'Linux-Internal-VM') do its('count') { should cmp > 0 } it { should have_data_disks } end - ```
@@ -66,11 +61,9 @@ Please refer to the following integration tests for more in depth examples: ### Check that the first data disk is of the correct size - ```ruby describe azure_virtual_machine_data_disk(group_name: 'Inspec-Azure', name: 'Linux-Internal-VM').where(number: 1) do its('size') { should cmp >= 15 } end - ```
@@ -87,34 +80,26 @@ Please refer to the following integration tests for more in depth examples: Returns the number of data disks attached to the machine - ```ruby its('count') { should eq 1 } -``` ### have\_data\_disks Returns a boolean denoting if any data disks are attached to the machine - ```ruby it { should have_data_disks } - ``` ### have\_managed\_disks Returns a boolean stating if the machine has Managed Disks for data disks. - ```ruby it { should have_managed_disks } - ``` **The next set of attributes require the `where` operation to be used on the describe.** The following code shows an example of how to use the where clause. - ```ruby describe azure_virtual_machine_data_disk(group_name: 'Inspec-Azure', name: 'Windows-Internal-VM').where(number: 1) end - ``` ### disk @@ -194,4 +179,4 @@ Use the `eq` matcher to test the equality of two values: `its('Port') { should e Using `its('Port') { should eq 22 }` will fail because `22` is not a string value! Use the `cmp` matcher for less restrictive value comparisons. -**The following properties are ones that are applied to the virtual machine itself and not specfic disks** \ No newline at end of file +**The following properties are ones that are applied to the virtual machine itself and not specfic disks**