Repository: cloud-custodian/cloud-custodian

Issue:
sns topic - cross-account filters throwing false postivies results 
#### When an SNS topic has conditional statement like below, the Topic is flagged for cross-account access event hough the source account is matching from the account.json file.

```
{
    "Sid": "__console_sub_0",
    "Effect": "Allow",
    "Principal": {
        "AWS": "*"
    },
    "Action": [
        "SNS:Subscribe",
        "SNS:Receive"
    ],
    "Resource": "arn:aws:sns:us-east-1:xxxxxxxx:topicname",
    "Condition": {
        "StringEquals": {
            "AWS:SourceOwner": "123456789"
        },
        "StringLike": {
            "SNS:Endpoint": "*@foo.com"
        }
    }
}
```

Policy

```vars:
  accounts_url: &accounts_url accounts.json


policies:

  - name: sns_cross-account
    resource: sns
    filters:
      - type: cross-account
        whitelist_from:
          expr: "accounts.*.accountNumber"
          url: *accounts_url
```

#### error message

> no handler:handle_sns_endpoint op:StringLike key:sns:endpoint values:(u'@foo.com',)
> no handler:handle_sns_protocol op:StringEquals key:sns:protocol values:[u'email', u'sqs', u'lambda']


Discussion:

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,6 @@
     ValueFilter,
     AgeFilter,
     EventFilter)
-from .iamaccess import CrossAccountAccessFilter
+from .iamaccess import CrossAccountAccessFilter, PolicyChecker
 from .metrics import MetricsFilter, ShieldMetrics
 from .vpc import DefaultVpcBase
diff --git a/c7n/filters/iamaccess.py b/c7n/filters/iamaccess.py
--- a/c7n/filters/iamaccess.py
+++ b/c7n/filters/iamaccess.py
@@ -34,6 +34,7 @@
 from __future__ import absolute_import, division, print_function, unicode_literals
 
 import fnmatch
+import logging
 import json
 
 import six
@@ -42,6 +43,8 @@
 from c7n.resolver import ValuesFrom
 from c7n.utils import type_schema
 
+log = logging.getLogger('custodian.iamaccess')
+
 
 def _account(arn):
     # we could try except but some minor runtime cost, basically flag
@@ -173,7 +176,7 @@ def handle_condition(self, s, c):
         handler_name = "handle_%s" % c['key'].replace('-', '_').replace(':', '_')
         handler = getattr(self, handler_name, None)
         if handler is None:
-            print("no handler:%s op:%s key:%s values:%s" % (
+            log.warning("no handler:%s op:%s key:%s values:%s" % (
                 handler_name, c['op'], c['key'], c['values']))
             return
         return not handler(s, c)
@@ -272,13 +275,15 @@ def process(self, resources, event=None):
         self.accounts = self.get_accounts()
         self.vpcs = self.get_vpcs()
         self.vpces = self.get_vpces()
-        self.checker = self.checker_factory(
+        self.checker_config = getattr(self, 'checker_config', None) or {}
+        self.checker_config.update(
             {'allowed_accounts': self.accounts,
              'allowed_vpc': self.vpcs,
              'allowed_vpce': self.vpces,
              'check_actions': self.actions,
              'everyone_only': self.everyone_only,
              'whitelist_conditions': self.conditions})
+        self.checker = self.checker_factory(self.checker_config)
         return super(CrossAccountAccessFilter, self).process(resources, event)
 
     def get_accounts(self):
diff --git a/c7n/resources/sns.py b/c7n/resources/sns.py
--- a/c7n/resources/sns.py
+++ b/c7n/resources/sns.py
@@ -16,10 +16,11 @@
 import json
 
 from c7n.actions import RemovePolicyBase
-from c7n.filters import CrossAccountAccessFilter
+from c7n.filters import CrossAccountAccessFilter, PolicyChecker
 from c7n.manager import resources
 from c7n.query import QueryResourceManager
-from c7n.utils import local_session
+from c7n.resolver import ValuesFrom
+from c7n.utils import local_session, type_schema
 
 
 @resources.register('sns')
@@ -46,6 +47,35 @@ class resource_type(object):
         )
 
 
+class SNSPolicyChecker(PolicyChecker):
+
+    @property
+    def allowed_endpoints(self):
+        return self.checker_config.get('allowed_endpoints', ())
+
+    @property
+    def allowed_protocols(self):
+        return self.checker_config.get('allowed_protocols', ())
+
+    def handle_sns_endpoint(self, s, c):
+        conditions = self.normalize_conditions(s)
+        # yield to aws:sourceowner
+        if not self.allowed_endpoints:
+            return not any(
+                single_condition.get('key',None) == 'aws:sourceowner'
+                for single_condition in conditions
+            )
+        # check if any of the allowed_endpoints are a substring
+        # to any of the values in the condition
+        for value in c['values']:
+            if not any(endpoint in value for endpoint in self.allowed_endpoints):
+                return True
+        return False
+
+    def handle_sns_protocol(self, s, c):
+        return bool(set(c['values']).difference(self.allowed_protocols))
+
+
 @SNS.filter_registry.register('cross-account')
 class SNSCrossAccount(CrossAccountAccessFilter):
     """Filter to return all SNS topics with cross account access permissions
@@ -65,8 +95,59 @@ class SNSCrossAccount(CrossAccountAccessFilter):
                       - permitted-account-01
                       - permitted-account-02
     """
+
+    valid_protocols = (
+        "http",
+        "https",
+        "email",
+        "email-json",
+        "sms",
+        "sqs",
+        "application",
+        "lambda"
+    )
+
+    schema = type_schema(
+        'cross-account',
+        rinherit=CrossAccountAccessFilter.schema,
+        whitelist_endpoints={'type': 'array', 'items': {'type': 'string'}},
+        whitelist_endpoints_from=ValuesFrom.schema,
+        whitelist_protocols={'type': 'array', 'items': {'type': 'string', 'enum': valid_protocols}},
+        whitelist_protocols_from=ValuesFrom.schema
+    )
+
     permissions = ('sns:GetTopicAttributes',)
 
+    checker_factory = SNSPolicyChecker
+
+    def process(self, resources, event=None):
+        self.endpoints = self.get_endpoints()
+        self.protocols = self.get_protocols()
+        self.checker_config = getattr(self, 'checker_config', None) or {}
+        self.checker_config.update(
+            {
+                'allowed_endpoints': self.endpoints,
+                'allowed_protocols': self.protocols
+            }
+        )
+        return super(SNSCrossAccount, self).process(resources, event)
+
+    def get_endpoints(self):
+        endpoints = set(self.data.get('whitelist_endpoints', ()))
+        if 'whitelist_endpoints_from' in self.data:
+            values = ValuesFrom(self.data['whitelist_endpoints_from'], self.manager)
+            endpoints = endpoints.union(values.get_values())
+        return endpoints
+
+    def get_protocols(self):
+        protocols = set(self.data.get('whitelist_protocols', ()))
+        if 'whitelist_protocols_from' in self.data:
+            values = ValuesFrom(self.data['whitelist_protocols_from'], self.manager)
+            protocols = protocols.union(
+                [p for p in values.get_values() if p in self.valid_protocols]
+            )
+        return protocols
+
 
 @SNS.action_registry.register('remove-statements')
 class RemovePolicyStatement(RemovePolicyBase):


Test patch:
diff --git a/tests/data/placebo/test_cross_account_sns_endpoint_condition/sns.CreateTopic_1.json b/tests/data/placebo/test_cross_account_sns_endpoint_condition/sns.CreateTopic_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_cross_account_sns_endpoint_condition/sns.CreateTopic_1.json
@@ -0,0 +1,17 @@
+{
+    "status_code": 200, 
+    "data": {
+        "ResponseMetadata": {
+            "RetryAttempts": 0, 
+            "HTTPStatusCode": 200, 
+            "RequestId": "80d5902b-6094-5255-a6d4-c3225b7c0f91", 
+            "HTTPHeaders": {
+                "x-amzn-requestid": "80d5902b-6094-5255-a6d4-c3225b7c0f91", 
+                "date": "Thu, 22 Feb 2018 21:44:08 GMT", 
+                "content-length": "334", 
+                "content-type": "text/xml"
+            }
+        }, 
+        "TopicArn": "arn:aws:sns:us-east-1:644160558196:c7n-endpoint-condition-test"
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_cross_account_sns_endpoint_condition/sns.DeleteTopic_1.json b/tests/data/placebo/test_cross_account_sns_endpoint_condition/sns.DeleteTopic_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_cross_account_sns_endpoint_condition/sns.DeleteTopic_1.json
@@ -0,0 +1,16 @@
+{
+    "status_code": 200, 
+    "data": {
+        "ResponseMetadata": {
+            "RetryAttempts": 0, 
+            "HTTPStatusCode": 200, 
+            "RequestId": "494da9dd-6abd-5da4-bcf6-fad451579060", 
+            "HTTPHeaders": {
+                "x-amzn-requestid": "494da9dd-6abd-5da4-bcf6-fad451579060", 
+                "date": "Thu, 22 Feb 2018 21:44:10 GMT", 
+                "content-length": "201", 
+                "content-type": "text/xml"
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_cross_account_sns_endpoint_condition/sns.GetTopicAttributes_1.json b/tests/data/placebo/test_cross_account_sns_endpoint_condition/sns.GetTopicAttributes_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_cross_account_sns_endpoint_condition/sns.GetTopicAttributes_1.json
@@ -0,0 +1,26 @@
+{
+    "status_code": 200, 
+    "data": {
+        "Attributes": {
+            "SubscriptionsConfirmed": "0", 
+            "DisplayName": "", 
+            "SubscriptionsDeleted": "0", 
+            "EffectiveDeliveryPolicy": "{\"http\":{\"defaultHealthyRetryPolicy\":{\"minDelayTarget\":20,\"maxDelayTarget\":20,\"numRetries\":3,\"numMaxDelayRetries\":0,\"numNoDelayRetries\":0,\"numMinDelayRetries\":0,\"backoffFunction\":\"linear\"},\"disableSubscriptionOverrides\":false}}", 
+            "Owner": "644160558196", 
+            "Policy": "{\"Version\":\"2012-10-17\",\"Id\":\"Foo\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":\"*\",\"Action\":\"SNS:Publish\",\"Resource\":\"arn:aws:sns:us-east-1:644160558196:c7n-endpoint-condition-test\",\"Condition\":{\"StringEquals\":{\"AWS:SourceOwner\":\"644160558196\"},\"StringLike\":{\"SNS:Endpoint\":\"@capitalone.com\"}}}]}", 
+            "TopicArn": "arn:aws:sns:us-east-1:644160558196:c7n-endpoint-condition-test", 
+            "SubscriptionsPending": "0"
+        }, 
+        "ResponseMetadata": {
+            "RetryAttempts": 0, 
+            "HTTPStatusCode": 200, 
+            "RequestId": "951e9a09-def4-5bfc-a88a-c900e51d5284", 
+            "HTTPHeaders": {
+                "x-amzn-requestid": "951e9a09-def4-5bfc-a88a-c900e51d5284", 
+                "date": "Thu, 22 Feb 2018 21:44:08 GMT", 
+                "content-length": "1910", 
+                "content-type": "text/xml"
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_cross_account_sns_endpoint_condition/sns.ListTopics_1.json b/tests/data/placebo/test_cross_account_sns_endpoint_condition/sns.ListTopics_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_cross_account_sns_endpoint_condition/sns.ListTopics_1.json
@@ -0,0 +1,21 @@
+{
+    "status_code": 200, 
+    "data": {
+        "Topics": [
+            {
+                "TopicArn": "arn:aws:sns:us-east-1:644160558196:c7n-endpoint-condition-test"
+            }
+        ], 
+        "ResponseMetadata": {
+            "RetryAttempts": 0, 
+            "HTTPStatusCode": 200, 
+            "RequestId": "a128c675-d59b-5168-bdf9-957cee720b16", 
+            "HTTPHeaders": {
+                "x-amzn-requestid": "a128c675-d59b-5168-bdf9-957cee720b16", 
+                "date": "Thu, 22 Feb 2018 21:44:08 GMT", 
+                "content-length": "1319", 
+                "content-type": "text/xml"
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_cross_account_sns_endpoint_condition/sns.ListTopics_2.json b/tests/data/placebo/test_cross_account_sns_endpoint_condition/sns.ListTopics_2.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_cross_account_sns_endpoint_condition/sns.ListTopics_2.json
@@ -0,0 +1,21 @@
+{
+    "status_code": 200, 
+    "data": {
+        "Topics": [
+            {
+                "TopicArn": "arn:aws:sns:us-east-1:644160558196:c7n-endpoint-condition-test"
+            }
+        ], 
+        "ResponseMetadata": {
+            "RetryAttempts": 0, 
+            "HTTPStatusCode": 200, 
+            "RequestId": "d1448453-d27f-5761-8a83-d75ae9956239", 
+            "HTTPHeaders": {
+                "x-amzn-requestid": "d1448453-d27f-5761-8a83-d75ae9956239", 
+                "date": "Thu, 22 Feb 2018 21:44:10 GMT", 
+                "content-length": "1319", 
+                "content-type": "text/xml"
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_cross_account_sns_endpoint_condition/sns.SetTopicAttributes_1.json b/tests/data/placebo/test_cross_account_sns_endpoint_condition/sns.SetTopicAttributes_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_cross_account_sns_endpoint_condition/sns.SetTopicAttributes_1.json
@@ -0,0 +1,16 @@
+{
+    "status_code": 200, 
+    "data": {
+        "ResponseMetadata": {
+            "RetryAttempts": 0, 
+            "HTTPStatusCode": 200, 
+            "RequestId": "7b276462-20f6-59cd-9465-376b1b1ef409", 
+            "HTTPHeaders": {
+                "x-amzn-requestid": "7b276462-20f6-59cd-9465-376b1b1ef409", 
+                "date": "Thu, 22 Feb 2018 21:44:08 GMT", 
+                "content-length": "215", 
+                "content-type": "text/xml"
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/tests/test_iam.py b/tests/test_iam.py
--- a/tests/test_iam.py
+++ b/tests/test_iam.py
@@ -732,6 +732,64 @@ def test_sns_cross_account(self):
         self.assertEqual(len(resources), 1)
         self.assertEqual(resources[0]['TopicArn'], arn)
 
+    @functional
+    def test_sns_cross_account_endpoint_condition(self):
+        self.patch(SNS, 'executor_factory', MainThreadExecutor)
+
+        session_factory = self.replay_flight_data('test_cross_account_sns_endpoint_condition')
+        client = session_factory().client('sns')
+        topic_name = 'c7n-endpoint-condition-test'
+        arn = client.create_topic(Name=topic_name)['TopicArn']
+        self.addCleanup(client.delete_topic, TopicArn=arn)
+
+        policy = {
+            'Id': 'Foo',
+            "Version": "2012-10-17",
+            'Statement': [
+                {'Action': 'SNS:Publish',
+                 'Effect': 'Allow',
+                 'Resource': arn,
+                 'Principal': '*',
+                 'Condition': {
+                      'StringLike': {
+                        'SNS:Endpoint': "@capitalone.com"
+                      },
+                      'StringEquals': {
+                        "AWS:SourceOwner": "644160558196"
+                      }
+                    }
+                }]}
+
+        client.set_topic_attributes(
+            TopicArn=arn, AttributeName='Policy',
+            AttributeValue=json.dumps(policy))
+
+        p = self.load_policy(
+            {'name': 'sns-cross',
+             'resource': 'sns',
+             'filters': [
+                {'TopicArn': arn},
+                'cross-account'
+                ]
+            },
+            session_factory=session_factory)
+
+        resources = p.run()
+        self.assertEqual(len(resources), 0)
+
+        p = self.load_policy(
+            {'name': 'sns-cross',
+             'resource': 'sns',
+             'filters': [
+                {'TopicArn': arn},
+                {'type':'cross-account',
+                 'whitelist_endpoints':['@whitelist.com']}
+                ]
+            },
+            session_factory=session_factory)
+
+        resources = p.run()
+        self.assertEqual(len(resources), 1)
 
 class CrossAccountChecker(TestCase):