diff --git a/.gitignore b/.gitignore index 6bd276bd..595156e8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ venv *.pyc *.sublime-* -tf_files/* +.idea/* +build +dist +example/tf_files/* +terraform_compliance.egg-info diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..293846cb --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2017-2018 Emre Erkunt, emre.erkunt@gmail.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md index fb484c8f..28de5b1e 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,159 @@ -# terraform-compliance ----------------------- - -A compliancy tool that runs against terraform files kept in local with BDD style configuration. - -This tool uses ; - -* [radish-bdd](https://github.com/radish-bdd/radish) -* [terraform-validate](https://github.com/elmundio87/terraform_validate) - +

terraform-compliance

+ +
+ BDD Testing for Terraform Files +
+
+ A lightweight BDD-testing Compliance Framework +
+ +
+ +
+ + Code Stability + + + + Build Status + + + + Test Coverage + + + + Downloads + +
+ +## Table of Contents +- [Features](#features) +- [Example](#example) +- [FAQ](#faq) +- [Installation](#installation) +- [Usage](#usage) + +## Features +- __compliance:__ Test your infrastructure as code before you deploy. Enforce your people to follow the policies. +- __behaviour driven development:__ We have BDD for nearly everything, why not for IaC ? +- __fixed steps:__ fixed steps coming with the package, just focus on your BDD feature/scenario files. +- __portable:__ just install it from `pip` +- __why ?:__ why not ? + +## Example ![Example Run](terraform-compliance-demo.gif) - -### Installation -Clone this repository - - # git clone https://github.com/eeerkunt/terraform-compliance - # cd terraform-compliance - - -(OPTIONAL) Create a virtualenv and activate it - - # virtualenv venv - # source venv/bin/activate - - -Install requirements - - # pip install -r requirements.txt - -and start playing +```sh +[~] $ terraform-compliance -f example/example_01 -t example/tf_files +terraform-compliance v0.0.1 initiated +Features : /Users/sharky/Repository/terraform-compliance/example/example_01 +Steps : /Users/sharky/Repository/venv2/lib/python2.7/site-packages/terraform_compliance/steps +TF Files : /Users/sharky/Repository/terraform-compliance/example/tf_files +Validating terraform files. +All HCL files look good. +Running tests. +Feature: Security Groups should be used to protect services/instances # /path/to/example/example_01/aws/security_groups.feature + In order to improve security + As engineers + We'll use AWS Security Groups as a Perimeter Defence + + Scenario Outline: Policy Structure + Given I define AWS Security Group + Then it must contain + + Examples: + | policy_name | + | ingress | + | egress | + + Scenario Outline: Well-known insecure protocol exposure on Public Network for ingress traffic + Given I define AWS Security Group + Then it must contain ingress + with protocol and not port for 0.0.0.0/0 + + Examples: + | ProtocolName | proto | portNumber | + | HTTP | tcp | 80 | + | Telnet | tcp | 23 | + | SSH | tcp | 22 | + | MySQL | tcp | 3306 | + | MSSQL | tcp | 1443 | + | NetBIOS | tcp | 139 | + | RDP | tcp | 3389 | + +Feature: Subnets should be defined properly for network security # /path/to/example/example_01/aws/subnets.feature + In order to improve security + And decrease impact radius + As engineers + We'll use a layered architecture in our AWS Environment + + Scenario: Subnet Count + Given I define AWS Subnet + When I count them + Then I expect the result is more than 2 + AssertionError: 0 is not more than 2 + +2 features (1 passed, 1 failed) +10 scenarios (9 passed, 1 failed) +28 steps (27 passed, 1 failed) + +[~] $ echo $? +1 +[~] $ +``` + +## FAQ + +- __Q.__ Where are the steps defined ? +- __A.__ They all comes with `terraform-compliance`, you can just focus on BDD feature/scenario files. +

+- __Q.__ What if I would like to add more steps ? +- __A.__ You are welcome to contribute on any test, or just add an issue it will be added. +

+- __Q.__ Where should `terraform-compliance` run ? +- __A.__ Ideally in a CI/CD tool, where company policies are defined as feature files and all IaC is tested against. Trust, but verify. + +## Installation +```sh +[~] $ pip install terraform-compliance +``` ### Usage -Store your terraform files into any directory and pass that directory as an argument to radish. - -For e.g. Assuming that your terraform files are stored in `/path/to/tf/files` and tests are stored in `./providers/aws`to run tests against it ; - - ~# radish providers/aws /path/to/tf/files - - -All radish features are available - -### Going further - -The capabilities are stored in [radish](radish) directory where you can add any steps as a capability. - -Tests, or _features/scenarios_ in radish-style, are stored in [provider](provider) directory. +```sh +[~] $ terraform-compliance --help +usage: terraform-compliance [-h] [--features feature_directory] + [--tfdir terraform_directory] + +BDD Test Framework for Hashicorp terraform + +optional arguments: + -h, --help show this help message and exit + --features feature_directory, -f feature_directory + Directory consists of BDD features + --tfdir terraform_directory, -t terraform_directory + Directory consists of Terraform Files +``` + +You can also push additional arguments that is specific for `radish`. Just to explain how it works ; + +For e.g. +```sh +[~] $ terraform-compliance -f /path/to/features -t /path/to/terraform_files -v +terraform-compliance v0.0.1 initiated +Features : /Users/sharky/Repository/terraform-compliance/example/example_01 +Steps : /Users/sharky/Repository/venv2/lib/python2.7/site-packages/terraform_compliance/steps +TF Files : /Users/sharky/Repository/terraform-compliance/example/tf_files +Validating terraform files. +All HCL files look good. +Running tests. +0.8.6 +``` +Please note that `0.8.6` is the `radish` version comes from `-v` parameter. + + +## License +[MIT](https://tldrlegal.com/license/mit-license) diff --git a/provider/aws/security_groups.feature b/example/example_01/aws/security_groups.feature similarity index 100% rename from provider/aws/security_groups.feature rename to example/example_01/aws/security_groups.feature diff --git a/provider/aws/subnets.feature b/example/example_01/aws/subnets.feature similarity index 100% rename from provider/aws/subnets.feature rename to example/example_01/aws/subnets.feature diff --git a/radish/terrain.py b/radish/terrain.py deleted file mode 100644 index 0bf0e7a2..00000000 --- a/radish/terrain.py +++ /dev/null @@ -1,12 +0,0 @@ -from radish import before, after, step -import terraform_validate -import os -import sys - -@before.each_scenario -def init_terraform_files(step): - if len(sys.argv) <= 2: - raise Exception("Usage: radish ") - - tf_dir = os.path.join(os.path.abspath(sys.argv[2])) - step.context.validator = terraform_validate.Validator(tf_dir) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 9b18737e..1fd11b49 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ terraform_validate -radish-bdd +radish +radish-bdd \ No newline at end of file diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..5e409001 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[wheel] +universal = 1 diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..eb0da791 --- /dev/null +++ b/setup.py @@ -0,0 +1,53 @@ +""" +BDD test framework for terraform +""" +from setuptools import find_packages, setup +from terraform_compliance.main import __app_name__, __version__ + +dependencies = [ + 'radish-bdd', + 'radish', + 'terraform-validate' +] + +setup( + name=__app_name__, + version=__version__, + url='https://github.com/eerkunt/terraform-compliance', + license='MIT', + author='Emre Erkunt', + author_email='emre.erkunt@gmail.com', + description='BDD test framework for terraform', + long_description=__doc__, + packages=find_packages(exclude=['tests']), + include_package_data=True, + zip_safe=False, + platforms='any', + install_requires=dependencies, + entry_points={ + 'console_scripts': [ + 'terraform-compliance=terraform_compliance.main:cli', + ], + }, + classifiers=[ + # As from http://pypi.python.org/pypi?%3Aaction=list_classifiers + # 'Development Status :: 1 - Planning', + # 'Development Status :: 2 - Pre-Alpha', + # 'Development Status :: 3 - Alpha', + 'Development Status :: 4 - Beta', + # 'Development Status :: 5 - Production/Stable', + # 'Development Status :: 6 - Mature', + # 'Development Status :: 7 - Inactive', + 'Environment :: Console', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: BSD License', + 'Operating System :: POSIX', + 'Operating System :: MacOS', + 'Operating System :: Unix', + 'Operating System :: Microsoft :: Windows', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 3', + 'Topic :: Software Development :: Libraries :: Python Modules', + ] +) diff --git a/terraform_compliance/__init__.py b/terraform_compliance/__init__.py new file mode 100644 index 00000000..ab83d11c --- /dev/null +++ b/terraform_compliance/__init__.py @@ -0,0 +1 @@ +from terraform_validate import Validator diff --git a/terraform_compliance/main.py b/terraform_compliance/main.py new file mode 100644 index 00000000..f08a2082 --- /dev/null +++ b/terraform_compliance/main.py @@ -0,0 +1,74 @@ +import sys +import os +from argparse import ArgumentParser, ArgumentTypeError, Action +from terraform_compliance import Validator +from radish.main import main as call_radish + +__app_name__ = "terraform-compliance" +__version__ = "0.0.1" + + +class ArgHandling(object): + def __init__(self): + self.features = None + self.steps = None + self.tf_dir = None + +class readable_dir(Action): + def __call__(self, parser, namespace, values, option_string=None): + prospective_dir = values + if not os.path.isdir(prospective_dir): + print('Invalid use or arguments: {}'.format(prospective_dir)) + sys.exit(1) + + if os.access(prospective_dir, os.R_OK): + setattr(namespace, self.dest, prospective_dir) + else: + print('Invalid use or arguments: {}'.format(prospective_dir)) + sys.exit(1) + + +def cli(): + argument = ArgHandling() + parser = ArgumentParser(prog=__app_name__, + description="BDD Test Framework for Hashicorp terraform") + try: + parser.add_argument("--features", "-f", dest="features", metavar='feature_directory', action=readable_dir, + help="Directory consists of BDD features", required=True) + parser.add_argument("--tfdir", "-t", dest="tf_dir", metavar='terraform_directory', action=readable_dir, + help="Directory consists of Terraform Files", required=True) + except argparse.ArgumentTypeError: + print('Invalid use or arguments: {}'.format(sys.exc_info()[1])) + sys.exit(1) + + # parser.parse_args(namespace=argument) + _, radish_arguments = parser.parse_known_args(namespace=argument) + + print('{} v{} initiated'.format(__app_name__, __version__)) + + features_directory = os.path.join(os.path.abspath(argument.features)) + steps_directory = os.path.join(os.path.split(os.path.abspath(__file__))[0], 'steps') + tf_directory = os.path.join(os.path.abspath(argument.tf_dir)) + + print('Features : {}'.format(features_directory)) + print('Steps : {}'.format(steps_directory)) + print('TF Files : {}'.format(tf_directory)) + + commands = ['radish', + features_directory, + '--basedir', steps_directory, + '--user-data=tf_dir={}'.format(tf_directory)] + commands.extend(radish_arguments) + + try: + print('Validating terraform files.') + Validator(tf_directory) + print('All HCL files look good.') + + except ValueError: + print('Unable to validate Terraform Files.') + print('ERROR: {}'.format(sys.exc_info()[1])) + sys.exit(1) + + print('Running tests.') + return call_radish(args=commands[1:]) diff --git a/terraform_compliance/steps/__init__.py b/terraform_compliance/steps/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/radish/steps.py b/terraform_compliance/steps/steps.py similarity index 58% rename from radish/steps.py rename to terraform_compliance/steps/steps.py index 1910fa83..373fd895 100644 --- a/radish/steps.py +++ b/terraform_compliance/steps/steps.py @@ -1,9 +1,6 @@ # -*- coding: utf-8 -*- -from radish import step, arg_expr -import terraform_validate -import os -import sys +from radish import step, world untaggable_resources = [ "aws_route_table", @@ -47,91 +44,84 @@ "Name": "^\${var.platform}_\${var.environment}_.*" } -# New Arguments -@arg_expr("ANY", r"[\.\/_\-A-Za-z0-9\s]+") -def arg_exp_for_secure_text(text): - return text -@step(r'Given I define {resource:ANY}') +@step(r'Given I define {resource}') def define_a_resource(step, resource): - - if(resource in resource_name.keys()): + if (resource in resource_name.keys()): resource = resource_name[resource] step.context.resource_type = resource - step.context.stash = step.context.resources = step.context.validator.resources(resource) + step.context.stash = step.context.resources = world.config.terraform.resources(resource) + -@step(r'When I {action:ANY} them') -def i_action_them(step, action): - if action == "count": +@step(r'When I {action_type} them') +def i_action_them(step, action_type): + if action_type == "count": step.context.stash = len(step.context.stash.resource_list) - elif action == "sum": + elif action_type == "sum": step.context.stash = sum(step.context.stash.resource_list) else: - AssertionError("Invalid action in the scenario: "+str(action)) + AssertionError("Invalid action_type in the scenario: {}".format(action)) + -@step(r'Then I expect the result is {operator:ANY} than {number:d}') -def i_expect_the_result_is_operator_than_number(step, operator, number): +@step(r'Then I expect the result is {operator} than {number:d}') +def func(step, operator, number): value = int(step.context.stash) if operator == "more": - assert value > number, str(value)+" is not more than "+str(number) + assert value > number, str(value) + " is not more than " + str(number) elif operator == "more and equal": - assert value >= number, str(value)+" is not more and equal than "+str(number) + assert value >= number, str(value) + " is not more and equal than " + str(number) elif operator == "less": - assert value < number, str(value)+" is not less than "+str(number) + assert value < number, str(value) + " is not less than " + str(number) elif operator == "less and equal": - assert value <= number, str(value)+" is not less and equal than "+str(number) + assert value <= number, str(value) + " is not less and equal than " + str(number) else: - AssertionError("Invalid operator: "+str(operator)) + AssertionError("Invalid operator: " + str(operator)) -@step('it contains {property:ANY}') -def it_contains_a(step, property): - if(property in resource_name.keys()): - property = resource_name[property] +@step('it must contain {something}') +def func(step, something): + world.config.terraform.error_if_property_missing() - step.context.resource_type = property - step.context.resources = step.context.resources.property(property) + if something in resource_name.keys(): + something = resource_name[something] -@step('it must contain {property:ANY}') -def it_must_contain_a(step, property): - step.context.validator.error_if_property_missing() - if(property in resource_name.keys()): - property = resource_name[property] + step.context.resource_type = something + step.context.resources = step.context.resources.property(something) - step.context.resource_type = property - step.context.resources = step.context.resources.property(property) @step('encryption must be enabled') -def encryption_must_be_enabled(step): - step.context.validator.error_if_property_missing() +def func(step): + world.config.terraform.error_if_property_missing() prop = encryption_property[step.context.resource_type] step.context.resources.property(prop).should_equal(True) @step(u'it must have the "([^"]*)" tag') -def it_must_have_the_tag(step, tag): - step.context.validator.error_if_property_missing() +def func(step, tag): + world.config.terraform.error_if_property_missing() step.context.tag = tag step.context.properties = step.context.resources.property('tags') step.context.properties.should_have_properties(tag) @step(u'And its value must match the "([^"]*)" regex') -def it_must_have_the_tag(step, regex_type): - step.context.validator.error_if_property_missing() +def func(step, regex_type): + world.config.terraform.error_if_property_missing() step.context.regex = regex[regex_type] step.context.properties.property(regex_type).should_match_regex(step.context.regex) + @step(u'And its value must be set by a variable') -def and_its_value_must_be_set_by_a_variable(step): +def func(step): step.context.resources.property('tags').property(step.context.tag).should_match_regex('\${var.(.*)}') -@step(r'with {proto:ANY} protocol and not port {value:d} for {cidr:ANY}') -def and_it_must_have_key_attribute_with_value_value(step, proto, value, cidr): + +@step(r'with {proto} protocol and not port {port} for {cidr}') +def func(step, proto, port, cidr): proto = str(proto) - value = int(value) + port = int(port) cidr = str(cidr) giveError = False @@ -161,19 +151,15 @@ def and_it_must_have_key_attribute_with_value_value(step, proto, value, cidr): else: cidr_blocks = str(i.property_value[y]) - - if int(to_port) > int(from_port): - if int(from_port) <= value <= int(to_port) and protocol == proto and cidr_blocks == cidr: + if int(from_port) <= port <= int(to_port) and protocol == proto and cidr_blocks == cidr: giveError = True elif int(from_port) > int(to_port): - if int(to_port) <= value <= int(from_port) or protocol == proto and cidr_blocks == cidr: + if int(to_port) <= port <= int(from_port) or protocol == proto and cidr_blocks == cidr: giveError = True elif int(from_port) == int(to_port): - if int(from_port) == value and protocol == proto and cidr_blocks == cidr: + if int(from_port) == port and protocol == proto and cidr_blocks == cidr: giveError = True if giveError: - raise AssertionError("Found "+proto+"/"+str(value)+" for "+str(cidr_blocks)) - - + raise AssertionError("Found " + proto + "/" + str(port) + " for " + str(cidr_blocks)) diff --git a/terraform_compliance/steps/terrain.py b/terraform_compliance/steps/terrain.py new file mode 100644 index 00000000..3b956425 --- /dev/null +++ b/terraform_compliance/steps/terrain.py @@ -0,0 +1,7 @@ +from radish import before, world +from terraform_compliance import Validator + + +@before.each_feature +def load_terraform_data(feature): + world.config.terraform = Validator(world.config.user_data['tf_dir']) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +