Repository: cloud-custodian/cloud-custodian

Issue:
filter for aws config compliance on non config rule policy
Read the recorded config status on resources and take actions like notify and delete on non-compliant resources which are marked by aws config from a custodian non-config policy


Discussion:
working on this, here's a filter schema, feedback welcome.

```
    schema = type_schema(
        'config-compliance',
        op={'enum': ['or', 'and']},
        states={'enum': [
            'COMPLIANT', 'NON_COMPLIANT', 'NOT_APPLICABLE', 'INSUFFICIENT_DATA']},
        rules={'type': 'array', 'items': {'type': 'string'}})
```
There's also potentially an option to do value type filters on the evaluations from each rule to determine additional match specifics, ie. only show non compliant from last 30 days, looking at the rule annotations, etc.

Resolving patch:
diff --git a/c7n/filters/__init__.py b/c7n/filters/__init__.py
--- a/c7n/filters/__init__.py
+++ b/c7n/filters/__init__.py
@@ -24,6 +24,7 @@
     ValueFilter,
     AgeFilter,
     EventFilter)
+from .config import ConfigCompliance
 from .iamaccess import CrossAccountAccessFilter, PolicyChecker
 from .metrics import MetricsFilter, ShieldMetrics
 from .vpc import DefaultVpcBase
diff --git a/c7n/filters/config.py b/c7n/filters/config.py
new file mode 100644
--- /dev/null
+++ b/c7n/filters/config.py
@@ -0,0 +1,134 @@
+# Copyright 2018 Capital One Services, LLC
+#
+# 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 __future__ import absolute_import, division, print_function, unicode_literals
+
+from c7n.filters import ValueFilter
+from c7n.manager import resources
+from c7n.utils import local_session, type_schema
+
+from .core import Filter
+
+
+class ConfigCompliance(Filter):
+    """Filter resources by their compliance with one or more AWS config rules.
+
+    An example of using the filter to find all ec2 instances that have
+    been registered as non compliant in the last 30 days against two
+    custom AWS Config rules.
+
+    :example:
+
+    .. code-block:: yaml
+
+       policies:
+         - name: non-compliant-ec2
+           resource: ec2
+           filters:
+            - type: config-compliance
+              eval_filters:
+               - type: value
+                 key: ResultRecordedTime
+                 type: age
+                 value: 30
+                 op: less-thanx
+              rules:
+               - custodian-ec2-encryption-required
+               - custodian-ec2-tags-required
+
+    Also note, custodian has direct support for deploying policies as config
+    rules see https://bit.ly/2mblVpq
+    """
+    permissions = ('config:DescribeComplianceByConfigRule',)
+    schema = type_schema(
+        'config-compliance',
+        required=('rules',),
+        op={'enum': ['or', 'and']},
+        eval_filters={'type': 'array', 'items': {
+            'oneOf': [
+                {'$ref': '#/definitions/filters/valuekv'},
+                {'$ref': '#/definitions/filters/value'}]}},
+        states={'type': 'array', 'items': {'enum': [
+            'COMPLIANT', 'NON_COMPLIANT',
+            'NOT_APPLICABLE', 'INSUFFICIENT_DATA']}},
+        rules={'type': 'array', 'items': {'type': 'string'}})
+
+    annotation_key = 'c7n:config-compliance'
+
+    def get_resource_map(self, filters, resource_model, resources):
+        rule_ids = self.data.get('rules')
+        states = self.data.get('states', ['NON_COMPLIANT'])
+        op = self.data.get('op', 'or') == 'or' and any or all
+
+        client = local_session(self.manager.session_factory).client('config')
+        resource_map = {}
+
+        for rid in rule_ids:
+            pager = client.get_paginator('get_compliance_details_by_config_rule')
+            for page in pager.paginate(
+                    ConfigRuleName=rid, ComplianceTypes=states):
+                evaluations = page.get('EvaluationResults', ())
+
+                for e in evaluations:
+                    rident = e['EvaluationResultIdentifier'][
+                        'EvaluationResultQualifier']
+                    # for multi resource type rules, only look at
+                    # results for the resource type currently being
+                    # processed.
+                    if rident['ResourceType'] != resource_model.config_type:
+                        continue
+
+                    if not filters:
+                        resource_map.setdefault(
+                            rident['ResourceId'], []).append(e)
+                        continue
+
+                    if op([f.match(e) for f in filters]):
+                        resource_map.setdefault(
+                            rident['ResourceId'], []).append(e)
+
+        return resource_map
+
+    def process(self, resources, event=None):
+        filters = []
+        for f in self.data.get('eval_filters', ()):
+            vf = ValueFilter(f)
+            vf.annotate = False
+            filters.append(vf)
+
+        resource_model = self.manager.get_model()
+        resource_map = self.get_resource_map(filters, resource_model, resources)
+
+        results = []
+        for r in resources:
+            if r[resource_model.id] not in resource_map:
+                continue
+            r[self.annotation_key] = resource_map[r[resource_model.id]]
+            results.append(r)
+        return results
+
+    @classmethod
+    def register_resources(klass, registry, resource_class):
+        """model resource subscriber on resource registration.
+
+        Watch for new resource types being registered if they support aws config,
+        automatically, register the config-compliance filter.
+        """
+
+        config_type = getattr(resource_class.resource_type, 'config_type', None)
+        if config_type is None:
+            return
+        resource_class.filter_registry.register('config-compliance', klass)
+
+
+resources.subscribe(resources.EVENT_REGISTER, ConfigCompliance.register_resources)


Test patch:
diff --git a/tests/data/placebo/test_config_compliance/config.GetComplianceDetailsByConfigRule_1.json b/tests/data/placebo/test_config_compliance/config.GetComplianceDetailsByConfigRule_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_config_compliance/config.GetComplianceDetailsByConfigRule_1.json
@@ -0,0 +1,60 @@
+{
+    "status_code": 200, 
+    "data": {
+        "EvaluationResults": [
+            {
+                "Annotation": "Resource is not compliant with policy:good-vol", 
+                "EvaluationResultIdentifier": {
+                    "OrderingTimestamp": {
+                        "hour": 8, 
+                        "__class__": "datetime", 
+                        "month": 7, 
+                        "second": 59, 
+                        "microsecond": 0, 
+                        "year": 2018, 
+                        "day": 11, 
+                        "minute": 10
+                    }, 
+                    "EvaluationResultQualifier": {
+                        "ResourceType": "AWS::EC2::Volume", 
+                        "ResourceId": "vol-0c6efd2a9f5677a03", 
+                        "ConfigRuleName": "custodian-good-vol"
+                    }
+                }, 
+                "ResultRecordedTime": {
+                    "hour": 8, 
+                    "__class__": "datetime", 
+                    "month": 7, 
+                    "second": 23, 
+                    "microsecond": 955000, 
+                    "year": 2018, 
+                    "day": 11, 
+                    "minute": 20
+                }, 
+                "ConfigRuleInvokedTime": {
+                    "hour": 8, 
+                    "__class__": "datetime", 
+                    "month": 7, 
+                    "second": 21, 
+                    "microsecond": 715000, 
+                    "year": 2018, 
+                    "day": 11, 
+                    "minute": 20
+                }, 
+                "ComplianceType": "NON_COMPLIANT"
+            }
+        ], 
+        "ResponseMetadata": {
+            "RetryAttempts": 0, 
+            "HTTPStatusCode": 200, 
+            "RequestId": "860c6831-855e-11e8-8869-77efb7691252", 
+            "HTTPHeaders": {
+                "x-amzn-requestid": "860c6831-855e-11e8-8869-77efb7691252", 
+                "date": "Wed, 11 Jul 2018 23:02:45 GMT", 
+                "content-length": "403", 
+                "strict-transport-security": "max-age=86400", 
+                "content-type": "application/x-amz-json-1.1"
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_config_compliance/ec2.DescribeVolumes_1.json b/tests/data/placebo/test_config_compliance/ec2.DescribeVolumes_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_config_compliance/ec2.DescribeVolumes_1.json
@@ -0,0 +1,154 @@
+{
+    "status_code": 200, 
+    "data": {
+        "ResponseMetadata": {
+            "RetryAttempts": 0, 
+            "HTTPStatusCode": 200, 
+            "RequestId": "bc6465e3-3ca9-444e-b92e-7fa19755882a", 
+            "HTTPHeaders": {
+                "transfer-encoding": "chunked", 
+                "vary": "Accept-Encoding", 
+                "server": "AmazonEC2", 
+                "content-type": "text/xml;charset=UTF-8", 
+                "date": "Wed, 11 Jul 2018 23:02:43 GMT"
+            }
+        }, 
+        "Volumes": [
+            {
+                "AvailabilityZone": "us-east-2c", 
+                "Attachments": [
+                    {
+                        "AttachTime": {
+                            "hour": 16, 
+                            "__class__": "datetime", 
+                            "month": 5, 
+                            "second": 10, 
+                            "microsecond": 0, 
+                            "year": 2018, 
+                            "day": 22, 
+                            "minute": 9
+                        }, 
+                        "InstanceId": "i-0340f8e495ec140b7", 
+                        "VolumeId": "vol-007bb55074a82aafb", 
+                        "State": "attached", 
+                        "DeleteOnTermination": true, 
+                        "Device": "/dev/xvda"
+                    }
+                ], 
+                "Tags": [
+                    {
+                        "Value": "OK", 
+                        "Key": "Status"
+                    }
+                ], 
+                "Encrypted": false, 
+                "VolumeType": "gp2", 
+                "VolumeId": "vol-007bb55074a82aafb", 
+                "State": "in-use", 
+                "Iops": 100, 
+                "SnapshotId": "snap-097cfa8524eee2bac", 
+                "CreateTime": {
+                    "hour": 16, 
+                    "__class__": "datetime", 
+                    "month": 5, 
+                    "second": 10, 
+                    "microsecond": 387000, 
+                    "year": 2018, 
+                    "day": 22, 
+                    "minute": 9
+                }, 
+                "Size": 8
+            }, 
+            {
+                "AvailabilityZone": "us-east-2b", 
+                "Attachments": [
+                    {
+                        "AttachTime": {
+                            "hour": 16, 
+                            "__class__": "datetime", 
+                            "month": 5, 
+                            "second": 59, 
+                            "microsecond": 0, 
+                            "year": 2018, 
+                            "day": 29, 
+                            "minute": 41
+                        }, 
+                        "InstanceId": "i-09cc2926e06cfe715", 
+                        "VolumeId": "vol-09058b8a10974beac", 
+                        "State": "attached", 
+                        "DeleteOnTermination": true, 
+                        "Device": "/dev/sda1"
+                    }
+                ], 
+                "Tags": [
+                    {
+                        "Value": "Good", 
+                        "Key": "Status"
+                    }
+                ], 
+                "Encrypted": false, 
+                "VolumeType": "gp2", 
+                "VolumeId": "vol-09058b8a10974beac", 
+                "State": "in-use", 
+                "Iops": 100, 
+                "SnapshotId": "snap-0ea806abc1ef3a6cd", 
+                "CreateTime": {
+                    "hour": 16, 
+                    "__class__": "datetime", 
+                    "month": 5, 
+                    "second": 59, 
+                    "microsecond": 350000, 
+                    "year": 2018, 
+                    "day": 29, 
+                    "minute": 41
+                }, 
+                "Size": 8
+            }, 
+            {
+                "AvailabilityZone": "us-east-2a", 
+                "Attachments": [
+                    {
+                        "AttachTime": {
+                            "hour": 1, 
+                            "__class__": "datetime", 
+                            "month": 5, 
+                            "second": 3, 
+                            "microsecond": 0, 
+                            "year": 2018, 
+                            "day": 23, 
+                            "minute": 4
+                        }, 
+                        "InstanceId": "i-094207d64930768dc", 
+                        "VolumeId": "vol-0c6efd2a9f5677a03", 
+                        "State": "attached", 
+                        "DeleteOnTermination": true, 
+                        "Device": "/dev/sda1"
+                    }
+                ], 
+                "Tags": [
+                    {
+                        "Value": "Bad", 
+                        "Key": "Status"
+                    }
+                ], 
+                "Encrypted": false, 
+                "VolumeType": "gp2", 
+                "VolumeId": "vol-0c6efd2a9f5677a03", 
+                "State": "in-use", 
+                "Iops": 100, 
+                "SnapshotId": "snap-012ae2d1db927295f", 
+                "CreateTime": {
+                    "hour": 1, 
+                    "__class__": "datetime", 
+                    "month": 5, 
+                    "second": 3, 
+                    "microsecond": 553000, 
+                    "year": 2018, 
+                    "day": 23, 
+                    "minute": 4
+                }, 
+                "Size": 8
+            }
+        ]
+    }
+}
\ No newline at end of file
diff --git a/tests/test_config.py b/tests/test_config.py
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -16,6 +16,26 @@
 from .common import BaseTest
 
 
+class ConfigComplianceTest(BaseTest):
+
+    def test_compliance(self):
+        factory = self.replay_flight_data('test_config_compliance')
+        p = self.load_policy({
+            'name': 'compliance',
+            'resource': 'ebs',
+            'filters': [
+                {'type': 'config-compliance',
+                 'eval_filters': [{
+                     'EvaluationResultIdentifier.EvaluationResultQualifier.ResourceType': 'AWS::EC2::Volume'}], # noqa
+                 'rules': ['custodian-good-vol']}
+            ]}, session_factory=factory, config={'region': 'us-east-2'})
+        resources = p.run()
+        self.assertEqual(len(resources), 1)
+        self.assertEqual(resources[0]['VolumeId'], 'vol-0c6efd2a9f5677a03')
+        self.assertEqual(resources[0]['c7n:config-compliance'][0]['Annotation'],
+                         'Resource is not compliant with policy:good-vol')
+
+
 class ConfigRuleTest(BaseTest):
 
     def test_status(self):