From 4e30eff651bd78fd0ad8783f3231670af1da6014 Mon Sep 17 00:00:00 2001 From: Will Thames <will@thames.id.au> Date: Sat, 3 Feb 2018 08:54:27 +1000 Subject: [PATCH] [cloud][aws] New module: aws_waf_rule module (#33124) Add a new module for managing AWS WAF rules Preceded by aws_waf_condition and to be succeeded by aws_waf_web_acl --- .../modules/cloud/amazon/aws_waf_rule.py | 316 ++++++++++++++++++ .../targets/aws_waf_web_acl/tasks/main.yml | 124 +++++++ 2 files changed, 440 insertions(+) create mode 100644 lib/ansible/modules/cloud/amazon/aws_waf_rule.py diff --git a/lib/ansible/modules/cloud/amazon/aws_waf_rule.py b/lib/ansible/modules/cloud/amazon/aws_waf_rule.py new file mode 100644 index 0000000000..1593cb3117 --- /dev/null +++ b/lib/ansible/modules/cloud/amazon/aws_waf_rule.py @@ -0,0 +1,316 @@ +#!/usr/bin/python +# Copyright (c) 2017 Will Thames +# Copyright (c) 2015 Mike Mochan +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = ''' +module: aws_waf_rule +short_description: create and delete WAF Rules +description: + - Read the AWS documentation for WAF + U(https://aws.amazon.com/documentation/waf/) +version_added: "2.5" + +author: + - Mike Mochan (@mmochan) + - Will Thames (@willthames) +extends_documentation_fragment: + - aws + - ec2 +options: + name: + description: Name of the Web Application Firewall rule + required: yes + metric_name: + description: + - A friendly name or description for the metrics for the rule + - The name can contain only alphanumeric characters (A-Z, a-z, 0-9); the name can't contain whitespace. + - You can't change metric_name after you create the rule + - Defaults to the same as name with disallowed characters removed + state: + description: whether the rule should be present or absent + choices: + - present + - absent + default: present + conditions: + description: > + list of conditions used in the rule. Each condition should + contain I(type): which is one of [C(byte), C(geo), C(ip), C(size), C(sql) or C(xss)] + I(negated): whether the condition should be negated, and C(condition), + the name of the existing condition. M(aws_waf_condition) can be used to + create new conditions + purge_conditions: + description: + - Whether or not to remove conditions that are not passed when updating `conditions`. + Defaults to false. +''' + +EXAMPLES = ''' + + - name: create WAF rule + aws_waf_rule: + name: my_waf_rule + conditions: + - name: my_regex_condition + type: regex + negated: no + - name: my_geo_condition + type: geo + negated: no + - name: my_byte_condition + type: byte + negated: yes + + - name: remove WAF rule + aws_waf_rule: + name: "my_waf_rule" + state: absent + +''' + +RETURN = ''' +rule: + description: WAF rule contents + returned: always + type: complex + contains: + metric_name: + description: Metric name for the rule + returned: always + type: string + sample: ansibletest1234rule + name: + description: Friendly name for the rule + returned: always + type: string + sample: ansible-test-1234_rule + predicates: + description: List of conditions used in the rule + returned: always + type: complex + contains: + data_id: + description: ID of the condition + returned: always + type: string + sample: 8251acdb-526c-42a8-92bc-d3d13e584166 + negated: + description: Whether the sense of the condition is negated + returned: always + type: bool + sample: false + type: + description: type of the condition + returned: always + type: string + sample: ByteMatch + rule_id: + description: ID of the WAF rule + returned: always + type: string + sample: 15de0cbc-9204-4e1f-90e6-69b2f415c261 +''' + +import re + +try: + import botocore +except ImportError: + pass # handled by AnsibleAWSModule + +from ansible.module_utils.aws.core import AnsibleAWSModule +from ansible.module_utils.ec2 import boto3_conn, get_aws_connection_info, ec2_argument_spec +from ansible.module_utils.ec2 import camel_dict_to_snake_dict +from ansible.module_utils.aws.waf import get_change_token, list_rules_with_backoff, MATCH_LOOKUP +from ansible.module_utils.aws.waf import get_web_acl_with_backoff, list_web_acls_with_backoff + + +def get_rule_by_name(client, module, name): + rules = [d['RuleId'] for d in list_rules(client, module) if d['Name'] == name] + if rules: + return rules[0] + + +def get_rule(client, module, rule_id): + try: + return client.get_rule(RuleId=rule_id)['Rule'] + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg='Could not get WAF rule') + + +def list_rules(client, module): + try: + return list_rules_with_backoff(client) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg='Could not list WAF rules') + + +def find_and_update_rule(client, module, rule_id): + rule = get_rule(client, module, rule_id) + rule_id = rule['RuleId'] + + existing_conditions = dict((condition_type, dict()) for condition_type in MATCH_LOOKUP) + desired_conditions = dict((condition_type, dict()) for condition_type in MATCH_LOOKUP) + all_conditions = dict() + + for condition_type in MATCH_LOOKUP: + method = 'list_' + MATCH_LOOKUP[condition_type]['method'] + 's' + all_conditions[condition_type] = dict() + try: + paginator = client.get_paginator(method) + func = paginator.paginate().build_full_result + except (KeyError, botocore.exceptions.OperationNotPageableError): + # list_geo_match_sets and list_regex_match_sets do not have a paginator + # and throw different exceptions + func = getattr(client, method) + try: + pred_results = func()[MATCH_LOOKUP[condition_type]['conditionset'] + 's'] + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg='Could not list %s conditions' % condition_type) + for pred in pred_results: + pred['DataId'] = pred[MATCH_LOOKUP[condition_type]['conditionset'] + 'Id'] + all_conditions[condition_type][pred['Name']] = camel_dict_to_snake_dict(pred) + all_conditions[condition_type][pred['DataId']] = camel_dict_to_snake_dict(pred) + + for condition in module.params['conditions']: + desired_conditions[condition['type']][condition['name']] = condition + + reverse_condition_types = dict((v['type'], k) for (k, v) in MATCH_LOOKUP.items()) + for condition in rule['Predicates']: + existing_conditions[reverse_condition_types[condition['Type']]][condition['DataId']] = camel_dict_to_snake_dict(condition) + + insertions = list() + deletions = list() + + for condition_type in desired_conditions: + for (condition_name, condition) in desired_conditions[condition_type].items(): + if condition_name not in all_conditions[condition_type]: + module.fail_json(msg="Condition %s of type %s does not exist" % (condition_name, condition_type)) + condition['data_id'] = all_conditions[condition_type][condition_name]['data_id'] + if condition['data_id'] not in existing_conditions[condition_type]: + insertions.append(format_for_insertion(condition)) + + if module.params['purge_conditions']: + for condition_type in existing_conditions: + deletions.extend([format_for_deletion(condition) for condition in existing_conditions[condition_type].values() + if not all_conditions[condition_type][condition['data_id']]['name'] in desired_conditions[condition_type]]) + + changed = bool(insertions or deletions) + if changed: + try: + client.update_rule(RuleId=rule_id, ChangeToken=get_change_token(client, module), + Updates=insertions + deletions) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg='Could not update rule conditions') + + return changed, get_rule(client, module, rule_id) + + +def format_for_insertion(condition): + return dict(Action='INSERT', + Predicate=dict(Negated=condition['negated'], + Type=MATCH_LOOKUP[condition['type']]['type'], + DataId=condition['data_id'])) + + +def format_for_deletion(condition): + return dict(Action='DELETE', + Predicate=dict(Negated=condition['negated'], + Type=condition['type'], + DataId=condition['data_id'])) + + +def remove_rule_conditions(client, module, rule_id): + conditions = get_rule(client, module, rule_id)['Predicates'] + updates = [format_for_deletion(camel_dict_to_snake_dict(condition)) for condition in conditions] + try: + client.update_rule(RuleId=rule_id, + ChangeToken=get_change_token(client, module), Updates=updates) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg='Could not remove rule conditions') + + +def ensure_rule_present(client, module): + name = module.params['name'] + rule_id = get_rule_by_name(client, module, name) + params = dict() + if rule_id: + return find_and_update_rule(client, module, rule_id) + else: + params['Name'] = module.params['name'] + metric_name = module.params['metric_name'] + if not metric_name: + metric_name = re.sub(r'[^a-zA-Z0-9]', '', module.params['name']) + params['MetricName'] = metric_name + params['ChangeToken'] = get_change_token(client, module) + try: + new_rule = client.create_rule(**params)['Rule'] + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg='Could not create rule') + return find_and_update_rule(client, module, new_rule['RuleId']) + + +def find_rule_in_web_acls(client, module, rule_id): + web_acls_in_use = [] + try: + all_web_acls = list_web_acls_with_backoff(client) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg='Could not list Web ACLs') + for web_acl in all_web_acls: + try: + web_acl_details = get_web_acl_with_backoff(client, web_acl['WebACLId']) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg='Could not get Web ACL details') + if rule_id in [rule['RuleId'] for rule in web_acl_details['Rules']]: + web_acls_in_use.append(web_acl_details['Name']) + return web_acls_in_use + + +def ensure_rule_absent(client, module): + rule_id = get_rule_by_name(client, module, module.params['name']) + in_use_web_acls = find_rule_in_web_acls(client, module, rule_id) + if in_use_web_acls: + web_acl_names = ', '.join(in_use_web_acls) + module.fail_json(msg="Rule %s is in use by Web ACL(s) %s" % + (module.params['name'], web_acl_names)) + if rule_id: + remove_rule_conditions(client, module, rule_id) + try: + return True, client.delete_rule(RuleId=rule_id, ChangeToken=get_change_token(client, module)) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg='Could not delete rule') + return False, {} + + +def main(): + argument_spec = ec2_argument_spec() + argument_spec.update( + dict( + name=dict(required=True), + metric_name=dict(), + state=dict(default='present', choices=['present', 'absent']), + conditions=dict(type='list'), + purge_conditions=dict(type='bool', default=False) + ), + ) + module = AnsibleAWSModule(argument_spec=argument_spec) + state = module.params.get('state') + + region, ec2_url, aws_connect_kwargs = get_aws_connection_info(module, boto3=True) + client = boto3_conn(module, conn_type='client', resource='waf', region=region, endpoint=ec2_url, **aws_connect_kwargs) + + if state == 'present': + (changed, results) = ensure_rule_present(client, module) + else: + (changed, results) = ensure_rule_absent(client, module) + + module.exit_json(changed=changed, rule=camel_dict_to_snake_dict(results)) + + +if __name__ == '__main__': + main() diff --git a/test/integration/targets/aws_waf_web_acl/tasks/main.yml b/test/integration/targets/aws_waf_web_acl/tasks/main.yml index 21bb9ca920..1e19ae4747 100644 --- a/test/integration/targets/aws_waf_web_acl/tasks/main.yml +++ b/test/integration/targets/aws_waf_web_acl/tasks/main.yml @@ -7,6 +7,11 @@ security_token: "{{ security_token }}" no_log: yes + + ################################################## + # aws_waf_condition tests + ################################################## + - name: create WAF IP condition aws_waf_condition: name: "{{ resource_prefix }}_ip_condition" @@ -228,10 +233,129 @@ recreate_waf_regex_condition.condition.regex_match_tuples[0].regex_pattern_set_id != create_waf_regex_condition.condition.regex_match_tuples[0].regex_pattern_set_id + ################################################## + # aws_waf_rule tests + ################################################## + + - name: create WAF rule + aws_waf_rule: + name: "{{ resource_prefix }}_rule" + conditions: + - name: "{{ resource_prefix }}_regex_condition" + type: regex + negated: no + - name: "{{ resource_prefix }}_geo_condition" + type: geo + negated: no + - name: "{{ resource_prefix }}_byte_condition" + type: byte + negated: no + purge_conditions: yes + <<: *aws_connection_info + register: create_aws_waf_rule + + - name: check WAF rule + assert: + that: + - create_aws_waf_rule.changed + - create_aws_waf_rule.rule.predicates|length == 3 + + - name: recreate WAF rule + aws_waf_rule: + name: "{{ resource_prefix }}_rule" + conditions: + - name: "{{ resource_prefix }}_regex_condition" + type: regex + negated: no + - name: "{{ resource_prefix }}_geo_condition" + type: geo + negated: no + - name: "{{ resource_prefix }}_byte_condition" + type: byte + negated: no + <<: *aws_connection_info + register: create_aws_waf_rule + + - name: check WAF rule did not change + assert: + that: + - not create_aws_waf_rule.changed + - create_aws_waf_rule.rule.predicates|length == 3 + + - name: add further WAF rules relying on purge_conditions defaulting to false + aws_waf_rule: + name: "{{ resource_prefix }}_rule" + conditions: + - name: "{{ resource_prefix }}_ip_condition" + type: ip + negated: yes + - name: "{{ resource_prefix }}_sql_condition" + type: sql + negated: no + - name: "{{ resource_prefix }}_xss_condition" + type: xss + negated: no + <<: *aws_connection_info + register: add_conditions_to_aws_waf_rule + + - name: check WAF rule added rules + assert: + that: + - add_conditions_to_aws_waf_rule.changed + - add_conditions_to_aws_waf_rule.rule.predicates|length == 6 + + - name: remove some rules through purging conditions + aws_waf_rule: + name: "{{ resource_prefix }}_rule" + conditions: + - name: "{{ resource_prefix }}_ip_condition" + type: ip + negated: yes + - name: "{{ resource_prefix }}_xss_condition" + type: xss + negated: no + - name: "{{ resource_prefix }}_byte_condition" + type: byte + negated: no + - name: "{{ resource_prefix }}_size_condition" + type: size + negated: no + purge_conditions: yes + <<: *aws_connection_info + register: add_and_remove_waf_rule_conditions + + - name: check WAF rules were updated as expected + assert: + that: + - add_and_remove_waf_rule_conditions.changed + - add_and_remove_waf_rule_conditions.rule.predicates|length == 4 + + - name: attempt to remove an in use condition + aws_waf_condition: + name: "{{ resource_prefix }}_size_condition" + type: size + state: absent + <<: *aws_connection_info + ignore_errors: yes + register: remove_in_use_condition + + - name: check failure was sensible + assert: + that: + - remove_in_use_condition.failed + - "'Condition {{ resource_prefix }}_size_condition is in use' in remove_in_use_condition.msg" + always: - debug: msg: "****** TEARDOWN STARTS HERE ******" + - name: remove WAF rule + aws_waf_rule: + name: "{{ resource_prefix }}_rule" + state: absent + <<: *aws_connection_info + ignore_errors: yes + - name: remove XSS condition aws_waf_condition: name: "{{ resource_prefix }}_xss_condition"