diff --git a/.gitignore b/.gitignore index fc641cfe..0f24ee83 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ venv +*,cover +coverage.xml +.coverage .scannerwork .pytest_cache *.pyc diff --git a/.travis.yml b/.travis.yml index 5c284a1d..c95f681f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,11 +5,17 @@ services: - docker cache: pip: true +install: + - pip install coveralls + - pip install -r requirements.txt +after_success: coveralls jobs: include: - stage: Tests provider: script - script: py.test -v + script: + - py.test -v + - coverage run --source terraform_compliance setup.py test - stage: Code Quality addons: @@ -18,7 +24,10 @@ jobs: token: $SONAR_LOGIN script: - "echo 'Scanning source code with SonarCloud'" - - sonar-scanner -Dsonar.projectKey=terraform-compliance -Dsonar.organization=eerkunt-github -Dsonar.sources=. -Dsonar.host.url=https://sonarcloud.io -Dsonar.login=$SONAR_LOGIN + - coverage erase + - coverage run --branch --source=terraform_compliance setup.py test + - coverage xml -i + - sonar-scanner -Dsonar.projectKey=terraform-compliance -Dsonar.organization=eerkunt-github -Dsonar.sources=. -Dsonar.host.url=https://sonarcloud.io -Dsonar.login=$SONAR_LOGIN -Dsonar.python.coverage.reportPath=coverage.xml - stage: Build & Deploy (PYPI) script: "echo 'PYPI Build & Deploy'" diff --git a/example/example_01/aws/naming_standards.feature b/example/example_01/aws/naming_standards.feature new file mode 100644 index 00000000..e32b4f8f --- /dev/null +++ b/example/example_01/aws/naming_standards.feature @@ -0,0 +1,20 @@ +Feature: Resources should have a proper naming standard + In order to keep consistency between resources + As engineers + We'll enforce naming standards + + Scenario Outline: Naming Standard on all available resources + Given I have defined + Then it should contain + And its value must match the "\${var.project}-\${var.environment}-\${var.application}-.*" regex + + Examples: + | resource_name | name_key | + | AWS EC2 instance | name | + | AWS ELB resource | name | + | AWS RDS instance | name | + | AWS S3 Bucket | name | + | AWS EBS volume | name | + | AWS Auto-Scaling Group | name | + | aws_key_pair | key_name | + | aws_ecs_cluster | name | diff --git a/terraform_compliance/common/helper.py b/terraform_compliance/common/helper.py index d024e878..76047b7b 100644 --- a/terraform_compliance/common/helper.py +++ b/terraform_compliance/common/helper.py @@ -2,7 +2,7 @@ from netaddr import IPNetwork -# A helper function that will be used to flattan a multi-dimensional multi-nested list +# A helper function that will be used to flatten a multi-dimensional multi-nested list def flatten_list(input): new_list = [] for i in input: diff --git a/terraform_compliance/common/pyhcl_helper.py b/terraform_compliance/common/pyhcl_helper.py new file mode 100644 index 00000000..36937161 --- /dev/null +++ b/terraform_compliance/common/pyhcl_helper.py @@ -0,0 +1,45 @@ +from sys import exc_info, exit +from os.path import isdir +from terraform_compliance import Validator +from terraform_validate.terraform_validate import TerraformSyntaxException + + +def load_tf_files(tf_directory): + result = False + print('Reading terraform files.') + + if isdir('{}/.terraform'.format(tf_directory)): + print('ERROR: You already have a .terraform directory within your terraform files.') + print(' This will lead to run tests against those imported modules. Please delete the directory to continue.') + exit(2) + + while result is False: + try: + Validator(tf_directory) + print('All HCL files look good.') + result = True + + except ValueError: + print('Unable to validate Terraform Files.') + print('ERROR: {}'.format(exc_info()[1])) + exit(1) + except TerraformSyntaxException: + pad_invalid_tf_files(exc_info()[1]) + + return result + + +def pad_invalid_tf_files(exception_message): + exception_message = str(exception_message).split('\n') + if 'Unexpected end of file' in exception_message[1]: + filename = exception_message[0].split(' ')[-1:][0] + print('Invalid HCL file: {}. Fixing it.'.format(filename)) + pad_tf_file(filename) + return True + + return False + + +def pad_tf_file(file): + with open(file, 'a') as f: + f.write('variable {}') diff --git a/terraform_compliance/common/readable_dir.py b/terraform_compliance/common/readable_dir.py new file mode 100644 index 00000000..715937fd --- /dev/null +++ b/terraform_compliance/common/readable_dir.py @@ -0,0 +1,35 @@ +import sys +import os +from argparse import Action + + +class ReadableDir(Action): + def __init__(self, dest, required, help, option_strings=None, metavar=None): + super(ReadableDir, self).__init__(dest, required, help, option_strings, metavar) + self.dest = dest + self.required = required + self.help = help + self.option_strings = option_strings + self.metavar = metavar + + def __call__(self, parser, namespace, values, option_string=None): + prospective_dir = values + + # Check if the given directory is actually a git repo + if prospective_dir.startswith('git:'): + print('Using remote git repository: {}'.format(prospective_dir[4:])) + setattr(namespace, self.dest, prospective_dir[4:]) + return True + + # Check if the given path is a directory really + if not os.path.isdir(prospective_dir): + print('ERROR: {} is not a directory.'.format(prospective_dir)) + sys.exit(1) + + # Check if we have access to that directory + if os.access(prospective_dir, os.R_OK): + setattr(namespace, self.dest, prospective_dir) + return True + + print('ERROR: Can not read {}'.format(prospective_dir)) + sys.exit(1) \ No newline at end of file diff --git a/terraform_compliance/main.py b/terraform_compliance/main.py index e1c0fbba..f3074d0c 100644 --- a/terraform_compliance/main.py +++ b/terraform_compliance/main.py @@ -1,13 +1,16 @@ -import sys import os -from argparse import ArgumentParser, Action -from terraform_compliance import Validator +from argparse import ArgumentParser from radish.main import main as call_radish from tempfile import mkdtemp from git import Repo +from terraform_compliance.common.pyhcl_helper import load_tf_files +from distutils.dir_util import copy_tree +from shutil import rmtree +from terraform_compliance.common.readable_dir import ReadableDir + __app_name__ = "terraform-compliance" -__version__ = "0.3.6" +__version__ = "0.3.7" class ArgHandling(object): @@ -16,30 +19,6 @@ class ArgHandling(object): #TODO: Handle all directory/protocol handling via a better class structure here. #TODO: Extend git: (on features or tf files argument) into native URLs instead of using a prefix here. -class ReadableDir(Action): - def __call__(self, parser, namespace, values, option_string=None): - prospective_dir = values - - # Check if the given directory is actually a git repo - if prospective_dir.startswith('git:'): - print('Using remote git repository: {}'.format(prospective_dir[4:])) - setattr(namespace, self.dest, prospective_dir[4:]) - return True - - # Check if the given path is a directory really - if not os.path.isdir(prospective_dir): - print('ERROR: {} is not a directory.'.format(prospective_dir)) - sys.exit(1) - - # Check if we have access to that directory - if os.access(prospective_dir, os.R_OK): - setattr(namespace, self.dest, prospective_dir) - return True - - print('ERROR: Can not read {}'.format(prospective_dir)) - sys.exit(1) - - def cli(): argument = ArgHandling() parser = ArgumentParser(prog=__app_name__, @@ -57,6 +36,7 @@ def cli(): steps_directory = os.path.join(os.path.split(os.path.abspath(__file__))[0], 'steps') print('Steps : {}'.format(steps_directory)) + # A remote repository used here if argument.features.startswith('http'): features_git_repo = argument.features argument.features = mkdtemp() @@ -64,12 +44,20 @@ def cli(): features_directory = os.path.join(os.path.abspath(argument.features)) print('Features : {}{}'.format(features_directory, (' ({})'.format(features_git_repo) if 'features_git_repo' in locals() else ''))) + tf_tmp_dir = mkdtemp() + + # A remote repository is used here. if argument.tf_dir.startswith('http'): tf_git_repo = argument.tf_dir - argument.tf_dir = mkdtemp() - Repo.clone_from(tf_git_repo, argument.tf_dir) - tf_directory = os.path.join(os.path.abspath(argument.tf_dir)) - print('TF Files : {}{}'.format(tf_directory, (' ({})'.format(tf_git_repo) if 'tf_git_repo' in locals() else ''))) + Repo.clone_from(tf_git_repo, tf_tmp_dir) + + # A local directory is used here + else: + # Copy the given local directory to another place, since we may change some tf files for compatibility. + copy_tree(argument.tf_dir, tf_tmp_dir) + + tf_directory = os.path.join(os.path.abspath(tf_tmp_dir)) + print('TF Files : {} ({})'.format(tf_directory, argument.tf_dir)) commands = ['radish', features_directory, @@ -77,22 +65,14 @@ def cli(): '--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) - + load_tf_files(tf_directory) print('Running tests.') - return call_radish(args=commands[1:]) + result = call_radish(args=commands[1:]) + + # Delete temporary directory we created + print('Cleaning up.') + rmtree(tf_directory) if __name__ == '__main__': cli() - -#TODO: Implement a cleanup for temporary directories since they are not deleted. -#TODO: If .terraform directory exist in '.' the just exit with a different exit code. diff --git a/terraform_compliance/steps/steps.py b/terraform_compliance/steps/steps.py index e6d6625d..7f07b458 100644 --- a/terraform_compliance/steps/steps.py +++ b/terraform_compliance/steps/steps.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- -from radish import step, world, custom_type, then, when, given -from terraform_compliance.steps import untaggable_resources, regex, resource_name, encryption_property +from radish import step, world, custom_type, given +from terraform_compliance.steps import resource_name, encryption_property from terraform_compliance.common.helper import check_sg_rules from terraform_compliance.extensions.terraform_validate import normalise_tag_values @@ -13,17 +13,16 @@ def arg_exp_for_secure_text(text): @given(u'I have {resource:ANY} defined') def define_a_resource(step, resource): - world.config.terraform.error_if_property_missing() if (resource in resource_name.keys()): resource = resource_name[resource] step.context.resource_type = resource - step.context.stash = step.context.resources = world.config.terraform.resources(resource) + step.context.stash = world.config.terraform.resources(resource) @step(u'I {action_type:ANY} them') def i_action_them(step, action_type): - if not step.context.resources.resource_list: + if hasattr(step.context.stash, 'resource_list') and not step.context.stash.resource_list: return if action_type == "count": @@ -31,12 +30,12 @@ def i_action_them(step, action_type): elif action_type == "sum": step.context.stash = sum(step.context.stash.resource_list) else: - AssertionError("Invalid action_type in the scenario: {}".format(action)) + AssertionError("Invalid action_type in the scenario: {}".format(action_type)) @step(u'I expect the result is {operator:ANY} than {number:d}') -def func(step, operator, number): - if not step.context.resources.resource_list: +def i_expect_the_result_is(step, operator, number): + if hasattr(step.context.stash, 'resource_list') and not step.context.stash.resource_list: return value = int(step.context.stash) @@ -54,37 +53,38 @@ def func(step, operator, number): @step(u'it {condition:ANY} contain {something:ANY}') -def func(step, condition, something): - if not step.context.resources.resource_list: +def it_contain(step, condition, something): + if hasattr(step.context.stash, 'resource_list') and not step.context.stash.resource_list: return if condition == 'must': - world.config.terraform.error_if_property_missing() + step.context.stash.should_have_properties(something) if something in resource_name.keys(): something = resource_name[something] - step.context.resource_type = something - step.context.resources = step.context.resources.property(something) + if type(something) not in [str, unicode]: + step.context.resource_type = something + + step.context.stash = step.context.stash.property(something) if condition == 'must': - assert step.context.resources.properties + assert step.context.stash.properties @step(u'encryption is enabled') @step(u'encryption must be enabled') -def func(step): - if not step.context.resources.resource_list: +def encryption_is_enabled(step): + if hasattr(step.context.stash, 'resource_list') and not step.context.stash.resource_list: return - world.config.terraform.error_if_property_missing() prop = encryption_property[step.context.resource_type] - step.context.resources.property(prop).should_equal(True) + step.context.stash.property(prop).should_equal(True) @step(u'its value must match the "{regex_type}" regex') def func(step, regex_type): - if not step.context.resources.resource_list: + if hasattr(step.context.stash, 'resource_list') or not step.context.stash.resource_list: return normalise_tag_values(step.context.properties) @@ -92,20 +92,20 @@ def func(step, regex_type): @step(u'its value must be set by a variable') -def func(step): - if not step.context.resources.resource_list: +def its_value_must_be_set_by_a_variable(step): + if hasattr(step.context.stash, 'resource_list') and not step.context.stash.resource_list: return - step.context.resources.property(step.context.search_value).should_match_regex('\${var.(.*)}') + step.context.stash.property(step.context.search_value).should_match_regex(r'\${var.(.*)}') @step(u'it must not have {proto} protocol and port {port:d} for {cidr:ANY}') -def func(step, proto, port, cidr): +def it_must_not_have_sg_stuff(step, proto, port, cidr): proto = str(proto) port = int(port) cidr = str(cidr) - for item in step.context.resources.properties: + for item in step.context.stash.properties: if type(item.property_value) is list: for security_group in item.property_value: check_sg_rules(world.config.terraform.terraform_config, security_group, proto, port, cidr) diff --git a/tests/mocks.py b/tests/mocks.py index 7163996a..4965fbd5 100644 --- a/tests/mocks.py +++ b/tests/mocks.py @@ -1,3 +1,6 @@ +from terraform_validate.terraform_validate import TerraformSyntaxException +from os.path import exists +from os import remove, environ class MockedData(object): @@ -232,4 +235,20 @@ class MockedData(object): sg_params_ssh_with_2_cidrs = dict(protocol=['tcp'], from_port=22, to_port=22, cidr_blocks=['213.86.221.35/32', '195.99.231.117/32']) sg_params_ssh_with_2_cidrs_any_proto = dict(protocol=['tcp', 'udp'], from_port=22, to_port=22, cidr_blocks=['213.86.221.35/32', '195.99.231.117/32']) sg_params_all_port_all_ip = dict(protocol=['tcp'], from_port=0, to_port=65535, cidr_blocks=['0.0.0.0/0']) - sg_params_all_port_no_ip = dict(protocol=['tcp', 'udp'], from_port=0, to_port=65535, cidr_blocks=[]) \ No newline at end of file + sg_params_all_port_no_ip = dict(protocol=['tcp', 'udp'], from_port=0, to_port=65535, cidr_blocks=[]) + + +class MockedValidator(object): + def __init__(self, directory): + global state_file + + if directory == 'valueerror': + raise ValueError('detailed message') + elif directory == 'syntaxexception': + state_key = 'MockedValidator.state' + state = environ.get(state_key, None) + if state: + pass + else: + environ[state_key] = '1' + raise TerraformSyntaxException('detailed message') diff --git a/tests/terraform_compliance/common/test_helper.py b/tests/terraform_compliance/common/test_helper.py index 43b0a6dd..fb7c8455 100644 --- a/tests/terraform_compliance/common/test_helper.py +++ b/tests/terraform_compliance/common/test_helper.py @@ -1,8 +1,13 @@ from unittest import TestCase from terraform_compliance.common.helper import ( flatten_list, + generate_target_resource, + expand_variable, + check_if_cidr, + is_ip_in_cidr, assign_sg_params, - validate_sg_rule + validate_sg_rule, + change_value_in_dict ) from tests.mocks import MockedData from copy import deepcopy @@ -28,6 +33,46 @@ def test_flatten_multi_dimensional_nested_list(self): self.assertEqual(flatten_list(a), b) + def test_generate_target_resource(self): + self.assertEqual(['resource', 'target', 'test'], generate_target_resource('target.test')) + + def test_generate_target_resource_has_id_and_name_in_it(self): + self.assertEqual(['resource', 'target', 'test'], generate_target_resource('target.test.id')) + self.assertEqual(['resource', 'target', 'test'], generate_target_resource('target.test.name')) + + def test_expand_variable_found(self): + tf_conf = dict(variable=dict(key='value')) + self.assertEqual('value', expand_variable(tf_conf, '${var.key}')) + + def test_expand_variable_not_found(self): + tf_conf = dict(variable=dict(key='value')) + self.assertEqual('${var.invalid_key}', expand_variable(tf_conf, '${var.invalid_key}')) + self.assertEqual('${invalid.invalid_key}', expand_variable(tf_conf, '${invalid.invalid_key}')) + + def test_check_if_cidr_success(self): + self.assertTrue(check_if_cidr('10.0.0.0/8')) + self.assertTrue(check_if_cidr('10.14.0.0/16')) + self.assertTrue(check_if_cidr('10.0.0.0/24')) + self.assertTrue(check_if_cidr('10.0.0.7/32')) + + def test_check_if_cidr_failure(self): + self.assertFalse(check_if_cidr('256.0.0.0/8')) + self.assertFalse(check_if_cidr('10.256.0.0/16')) + self.assertFalse(check_if_cidr('10.0.256.0/24')) + self.assertFalse(check_if_cidr('10.0.0.256/32')) + self.assertFalse(check_if_cidr('10.0.0.256/33')) + + def test_is_ip_in_cidr_success(self): + self.assertTrue(is_ip_in_cidr('10.0.0.0/8', ['0.0.0.0/0'])) + self.assertTrue(is_ip_in_cidr('10.0.0.0/16', ['10.0.0.0/8'])) + self.assertTrue(is_ip_in_cidr('10.0.200.0/24', ['10.0.0.0/16'])) + self.assertTrue(is_ip_in_cidr('10.0.0.1/32', ['10.0.0.0/24'])) + + def test_is_ip_in_cidr_failure(self): + self.assertFalse(is_ip_in_cidr('200.0.0.0/16', ['10.0.0.0/8'])) + self.assertFalse(is_ip_in_cidr('10.200.0.0/24', ['10.0.0.0/16'])) + self.assertFalse(is_ip_in_cidr('10.0.1.1/32', ['10.0.0.0/24'])) + def test_assign_sg_params_one_port_with_two_cidrs(self): self.assertEqual(MockedData.sg_params_ssh_with_2_cidrs, assign_sg_params(MockedData.sg_ssh_with_2_cidrs)) @@ -52,23 +97,12 @@ def test_validate_sg_rule_port_found_in_cidr(self): self.assertTrue('Found' in context.exception) - def test_validate_sg_rule_port_found_but_cidr_is_different(self): - pass - - def test_validate_sg_rule_port_found_but_proto_is_different(self): - pass - - def test_check_sg_rules_fail(self): - pass - - def test_check_sg_rules_passed_because_of_different_protocol(self): - pass - - def test_check_sg_rules_fail_and_protocol_number_is_used(self): - pass - - def test_check_sg_rules_not_fail_because_of_cidr(self): - pass + def test_change_value_in_dict_with_str_path(self): + target_dict = dict(key=dict(another_key='value')) + change_value_in_dict(target_dict, 'key', dict(added_key='added_value')) + self.assertEqual(target_dict, dict(key=dict(another_key='value', added_key='added_value'))) - def test_check_sg_rules_fail_because_of_given_ip_is_a_member_of_cidr(self): - pass + def test_change_value_in_dict_with_dict_path(self): + target_dict = dict(key=dict(another_key='value')) + change_value_in_dict(target_dict, ['key'], dict(added_key='added_value')) + self.assertEqual(target_dict, dict(key=dict(another_key='value', added_key='added_value'))) diff --git a/tests/terraform_compliance/common/test_pyhcl_helper.py b/tests/terraform_compliance/common/test_pyhcl_helper.py new file mode 100644 index 00000000..88233c49 --- /dev/null +++ b/tests/terraform_compliance/common/test_pyhcl_helper.py @@ -0,0 +1,51 @@ +from unittest import TestCase +from terraform_compliance.common.pyhcl_helper import ( + load_tf_files, + pad_invalid_tf_files, + pad_tf_file +) +from tests.mocks import MockedData, MockedValidator +from copy import deepcopy +from os import remove, path, environ +from mock import patch + + +class TestPyHCLHelper(TestCase): + + def test_pad_tf_file(self): + tmpFile = '.terraform_compliance_unit_tests.tf' + pad_tf_file(tmpFile) + contents = open(tmpFile, 'r').read() + remove(tmpFile) + self.assertEqual(contents, 'variable {}') + + @patch('terraform_compliance.common.pyhcl_helper.pad_tf_file', return_value=None) + def test_pad_invalid_tf_files(self, *args): + self.assertTrue(pad_invalid_tf_files('filename\nUnexpected end of file')) + self.assertFalse(pad_invalid_tf_files('filename\nAnother message')) + + @patch.object(path, 'isdir', return_value=True) + def test_load_tf_files_exit_for_dot_terraform(self, *args): + with self.assertRaises(SystemExit): + load_tf_files('a_directory') + + @patch.object(path, 'isdir', return_value=False) + @patch('terraform_compliance.common.pyhcl_helper.Validator', side_effect=MockedValidator) + def test_load_tf_files_valueerror(self, *args): + with self.assertRaises(SystemExit): + load_tf_files('valueerror') + + @patch.object(path, 'isdir', return_value=False) + @patch('terraform_compliance.common.pyhcl_helper.Validator', side_effect=MockedValidator) + @patch('terraform_compliance.common.pyhcl_helper.pad_invalid_tf_files', return_value=None) + def test_load_tf_files_terraformsyntaxexception(self, *args): + self.assertTrue(load_tf_files('syntaxexception')) + self.assertEqual(environ['MockedValidator.state'], '1') + del environ['MockedValidator.state'] + + @patch.object(path, 'isdir', return_value=False) + @patch('terraform_compliance.common.pyhcl_helper.Validator', side_effect=MockedValidator) + @patch('terraform_compliance.common.pyhcl_helper.pad_invalid_tf_files', return_value=None) + def test_load_tf_files_success(self, *args): + self.assertTrue(load_tf_files('passed')) + self.assertTrue(environ.get('MockedValidator.state', None) is None) diff --git a/tests/terraform_compliance/test_main.py b/tests/terraform_compliance/test_main.py index 25cb193e..47b343c6 100644 --- a/tests/terraform_compliance/test_main.py +++ b/tests/terraform_compliance/test_main.py @@ -11,7 +11,7 @@ class Namespace(object): pass resp = ReadableDir('parser', 'value', 'git:value').__call__('parser', Namespace, 'git:value') - self.assertEqual(Namespace.value, 'value') + self.assertEqual(Namespace.parser, 'value') self.assertTrue(resp) @@ -30,7 +30,7 @@ class Namespace(object): pass resp = ReadableDir('parser', 'value', 'value').__call__('parser', Namespace, 'value') - self.assertEqual(Namespace.value, 'value') + self.assertEqual(Namespace.parser, 'value') self.assertTrue(resp) @patch.object(os.path, 'isdir', return_value=True)