Repository: cloud-custodian/cloud-custodian

Issue:
azure - Network Security Group - Filter by Access: Allow/Deny
In Azure, the presence of a port rule in a Network Security Group does not imply that port is open. The security rule has a property named ```access``` with a value of either ```Allow``` or ```Deny``` (example below). The [example policies](http://capitalone.github.io/cloud-custodian/docs/azure/policy/resources/nsg.html#example-policies) for an Azure NSG show how to filter ingress and egress rules by whitelist/blacklist port numbers. They do not take into account the value of ```access```. Unless the port security rule is completely removed, any actions on that rule will be applied every time the policy is ran. I tried to use a JMESPath filter for ```access```, however it does not actually remove the security rule from the security group [like the port filters do](https://github.com/capitalone/cloud-custodian/blob/master/tools/c7n_azure/c7n_azure/resources/network_security_group.py#L94). 

I am proposing an additional property be added to the `ingress` and `egress` filters called `access` with two possible values: `Allow`, `Deny`


**Current filter. Applies action every time, even when previously closed**
```
policies:
  - name: close-ingress-22
    resource: azure.networksecuritygroup
    filters:
     - type: ingress
       ports: [22]
    actions:
     - type: close
```

**Proposed filter**
```
policies:
  - name: close-ingress-22
    resource: azure.networksecuritygroup
    filters:
     - type: ingress
       ports: [22]
       access: allow
    actions:
     - type: close
```

**Example NSG**
```
[
  {
    "id": "/subscriptions/[GUID]/resourceGroups/cloud-custodian/providers/Microsoft.Network/networkSecurityGroups/test-nsg",
    "name": "test-nsg",
    "type": "Microsoft.Network/networkSecurityGroups",
    "location": "eastus",
    "properties": {
      "securityRules": [
        {
          "id": "/subscriptions/[GUID]/resourceGroups/cloud-custodian/providers/Microsoft.Network/networkSecurityGroups/test-nsg/securityRules/SSH",
          "properties": {
            "protocol": "TCP",
            "sourcePortRange": "*",
            "destinationPortRange": "22",
            "sourceAddressPrefix": "*",
            "sourceAddressPrefixes": [],
            "destinationAddressPrefix": "*",
            "destinationAddressPrefixes": [],
            "sourcePortRanges": [],
            "destinationPortRanges": [],
            "access": "Deny",
            "priority": 100,
            "direction": "Inbound",
            "provisioningState": "Succeeded"
          },
          "name": "SSH",
          "etag": "W/\"ac2e0e9e-45e6-470a-8446-ad6af70a5b7a\""
        },
        {
          "id": "/subscriptions/[GUID]/resourceGroups/cloud-custodian/providers/Microsoft.Network/networkSecurityGroups/test-nsg/securityRules/SSH2",
          "properties": {
            "protocol": "*",
            "sourcePortRange": "*",
            "destinationPortRange": "22",
            "sourceAddressPrefix": "*",
            "sourceAddressPrefixes": [],
            "destinationAddressPrefix": "*",
            "destinationAddressPrefixes": [],
            "sourcePortRanges": [],
            "destinationPortRanges": [],
            "access": "Allow",
            "priority": 120,
            "direction": "Inbound",
            "provisioningState": "Succeeded"
          },
          "name": "SSH2",
          "etag": "W/\"ac2e0e9e-45e6-470a-8446-ad6af70a5b7a\""
        }
      ],
      "defaultSecurityRules": [
        
      ],
      "resourceGuid": "[GUID]",
      "provisioningState": "Succeeded"
    },
    "etag": "W/\"ac2e0e9e-45e6-470a-8446-ad6af70a5b7a\"",
    "resourceGroup": "cloud-custodian",
    "c7n:MatchedFilters": [
      "name",
      "properties.securityRules[].properties.access"
    ]
  }
]
```


Discussion:
@aluong @erwelch @stefangordon 

Resolving patch:
diff --git a/tools/c7n_azure/c7n_azure/resources/network_security_group.py b/tools/c7n_azure/c7n_azure/resources/network_security_group.py
--- a/tools/c7n_azure/c7n_azure/resources/network_security_group.py
+++ b/tools/c7n_azure/c7n_azure/resources/network_security_group.py
@@ -21,6 +21,7 @@
 from c7n.filters import Filter
 from c7n.filters.core import PolicyValidationError
 from c7n.utils import type_schema
+from c7n_azure.utils import StringUtils
 
 
 @resources.register('networksecuritygroup')
@@ -43,6 +44,7 @@ class resource_type(object):
 PORTS = 'ports'
 EXCEPT_PORTS = 'exceptPorts'
 IP_PROTOCOL = 'ipProtocol'
+ACCESS = 'access'
 
 
 class SecurityRuleFilter(Filter):
@@ -53,7 +55,7 @@ class SecurityRuleFilter(Filter):
     perm_attrs = set((
         IP_PROTOCOL, FROM_PORT, TO_PORT))
 
-    filter_attrs = set(('Cidr', PORTS, EXCEPT_PORTS))
+    filter_attrs = set(('Cidr', PORTS, EXCEPT_PORTS, ACCESS))
     attrs = perm_attrs.union(filter_attrs)
     attrs.add('match-operator')
 
@@ -79,6 +81,7 @@ def process(self, network_security_groups, event=None):
         self.to_port = self.data.get(TO_PORT)
         self.ports = self.data.get(PORTS)
         self.except_ports = self.data.get(EXCEPT_PORTS)
+        self.access = self.data.get(ACCESS)
         self.match_op = self.data.get('match-operator', 'and') == 'and' and all or any
 
         """
@@ -171,15 +174,22 @@ def get_port_range(self, range_str):
         {} - Lower bound of port range (inclusive)
         {} - Upper bound of port range (inclusive)
         {} - TCP/UDP protocol
-    """.format(PORTS, EXCEPT_PORTS, FROM_PORT, TO_PORT, IP_PROTOCOL)
+        {} - Allow/Deny access
+    """.format(PORTS, EXCEPT_PORTS, FROM_PORT, TO_PORT, IP_PROTOCOL, ACCESS)
 
     def is_match(self, security_rule):
-        if self.direction_key != security_rule['properties']['direction']:
+        if not StringUtils.equal(self.direction_key, security_rule['properties']['direction']):
             return False
         ranges_match = self.is_ranges_match(security_rule)
         protocol_match = (self.ip_protocol is None) or \
-                         (self.ip_protocol == security_rule['properties']['protocol'])
-        return self.match_op([ranges_match, protocol_match])
+                         (StringUtils.equal(self.ip_protocol,
+                                            security_rule['properties']['protocol']))
+
+        if self.access is not None:
+            access_match = StringUtils.equal(self.access, security_rule['properties']['access'])
+            return self.match_op([ranges_match, protocol_match, access_match])
+        else:
+            return self.match_op([ranges_match, protocol_match])
 
 
 @NetworkSecurityGroup.filter_registry.register('ingress')
@@ -195,7 +205,8 @@ class IngressFilter(SecurityRuleFilter):
             EXCEPT_PORTS: {'type': 'array', 'items': {'type': 'integer'}},
             FROM_PORT: {'type': 'integer'},
             TO_PORT: {'type': 'integer'},
-            IP_PROTOCOL: {'type': 'string', 'enum': ['TCP', 'UDP']}
+            IP_PROTOCOL: {'type': 'string', 'enum': ['TCP', 'UDP']},
+            ACCESS: {'type': 'string', 'enum': ['Allow', 'Deny']}
         },
         'required': ['type']
     }
@@ -215,7 +226,8 @@ class EgressFilter(SecurityRuleFilter):
             EXCEPT_PORTS: {'type': 'array', 'items': {'type': 'integer'}},
             FROM_PORT: {'type': 'integer'},
             TO_PORT: {'type': 'integer'},
-            IP_PROTOCOL: {'type': 'string', 'enum': ['TCP', 'UDP']}
+            IP_PROTOCOL: {'type': 'string', 'enum': ['TCP', 'UDP']},
+            ACCESS: {'type': 'string', 'enum': ['Allow', 'Deny']}
             # 'SelfReference': {'type': 'boolean'}
         },
         'required': ['type']}
diff --git a/tools/c7n_azure/c7n_azure/utils.py b/tools/c7n_azure/c7n_azure/utils.py
--- a/tools/c7n_azure/c7n_azure/utils.py
+++ b/tools/c7n_azure/c7n_azure/utils.py
@@ -12,6 +12,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 import datetime
+import six
 
 
 class ResourceIdParser(object):
@@ -33,6 +34,19 @@ def get_resource_name(resource_id):
         return resource_id.split('/')[8]
 
 
+class StringUtils(object):
+
+    @staticmethod
+    def equal(a, b, case_insensitive=True):
+        if isinstance(a, six.string_types) and isinstance(b, six.string_types):
+            if case_insensitive:
+                return a.strip().lower() == b.strip().lower()
+            else:
+                return a.strip() == b.strip()
+
+        return False
+
+
 def utcnow():
     """The datetime object for the current time in UTC
     """


Test patch:
diff --git a/tools/c7n_azure/tests/test_azure_utils.py b/tools/c7n_azure/tests/test_azure_utils.py
--- a/tools/c7n_azure/tests/test_azure_utils.py
+++ b/tools/c7n_azure/tests/test_azure_utils.py
@@ -16,6 +16,7 @@
 from azure_common import BaseTest
 from c7n_azure.utils import Math
 from c7n_azure.utils import ResourceIdParser
+from c7n_azure.utils import StringUtils
 
 RESOURCE_ID = (
     "/subscriptions/ea42f556-5106-4743-99b0-c129bfa71a47/resourceGroups/"
@@ -47,3 +48,27 @@ def test_math_sum(self):
         self.assertEqual(Math.sum([4, 5, None, 3]), 12)
         self.assertEqual(Math.sum([None]), 0)
         self.assertEqual(Math.sum([3.5, 4]), 7.5)
+
+    def test_string_utils_equal(self):
+        # Case insensitive matches
+        self.assertTrue(StringUtils.equal("FOO", "foo"))
+        self.assertTrue(StringUtils.equal("fOo", "FoO"))
+        self.assertTrue(StringUtils.equal("ABCDEFGH", "abcdefgh"))
+        self.assertFalse(StringUtils.equal("Foo", "Bar"))
+
+        # Case sensitive matches
+        self.assertFalse(StringUtils.equal("Foo", "foo", False))
+        self.assertTrue(StringUtils.equal("foo", "foo", False))
+        self.assertTrue(StringUtils.equal("fOo", "fOo", False))
+        self.assertFalse(StringUtils.equal("Foo", "Bar"))
+
+        # Strip whitespace matches
+        self.assertTrue(StringUtils.equal(" Foo ", "foo"))
+        self.assertTrue(StringUtils.equal("Foo", " foo "))
+        self.assertTrue(StringUtils.equal(" Foo ", "Foo", False))
+        self.assertTrue(StringUtils.equal("Foo", " Foo ", False))
+
+        # Returns false for non string types
+        self.assertFalse(StringUtils.equal(1, "foo"))
+        self.assertFalse(StringUtils.equal("foo", 1))
+        self.assertFalse(StringUtils.equal(True, False))
diff --git a/tools/c7n_azure/tests/test_networksecuritygroup.py b/tools/c7n_azure/tests/test_networksecuritygroup.py
--- a/tools/c7n_azure/tests/test_networksecuritygroup.py
+++ b/tools/c7n_azure/tests/test_networksecuritygroup.py
@@ -15,7 +15,7 @@
 
 from azure_common import BaseTest, arm_template
 from c7n_azure.resources.network_security_group \
-    import FROM_PORT, TO_PORT, PORTS, EXCEPT_PORTS, IP_PROTOCOL
+    import FROM_PORT, TO_PORT, PORTS, EXCEPT_PORTS, IP_PROTOCOL, ACCESS
 from mock import patch
 
 from c7n.filters.core import PolicyValidationError
@@ -82,6 +82,33 @@ def test_find_by_name(self):
                         'destinationPortRanges': ['10-12', '14-15', '16-19']
                     }
                 },
+                {
+                    'name': 'inbound_22_deny',
+                    'properties': {
+                        'direction': 'Inbound',
+                        'access': 'Deny',
+                        'protocol': 'TCP',
+                        'destinationPortRange': '22'
+                    }
+                },
+                {
+                    'name': 'outbound_22_allow',
+                    'properties': {
+                        'direction': 'Outbound',
+                        'access': 'Allow',
+                        'protocol': 'TCP',
+                        'destinationPortRange': '22'
+                    }
+                },
+                {
+                    'name': 'outbound_22_deny',
+                    'properties': {
+                        'direction': 'Outbound',
+                        'access': 'Deny',
+                        'protocol': 'TCP',
+                        'destinationPortRange': '22'
+                    }
+                },
             ]
         }
     }]
@@ -316,6 +343,15 @@ def test_protocol_or_range_filter(self, rules_action_mock, filter_mock):
                             'destinationPortRanges': ['10-12', '14-15', '16-19']
                         }
                     },
+                    {
+                        'name': 'inbound_22_deny',
+                        'properties': {
+                            'direction': 'Inbound',
+                            'access': 'Deny',
+                            'protocol': 'TCP',
+                            'destinationPortRange': '22'
+                        }
+                    },
                 ]
             }}]
         rules_action_mock.assert_called_with(expected)
@@ -357,3 +393,142 @@ def test_invalid_policy_params_only_ports(self):
                  PORTS: [8080]}],
             'actions': [
                 {'type': 'close'}]}))
+
+    @arm_template('networksecuritygroup.json')
+    @patch('c7n_azure.query.ResourceQuery.filter',
+           return_value=fake_nsgs)
+    @patch('c7n_azure.resources.network_security_group.RulesAction.process',
+           return_value='')
+    def test_access_ingress_allow(self, rules_action_mock, filter_mock):
+        p = self.load_policy({
+            'name': 'test-azure-network-security-group',
+            'resource': 'azure.networksecuritygroup',
+            'filters': [
+                {'type': 'ingress',
+                 PORTS: [22],
+                 ACCESS: 'Allow'}],
+            'actions': [
+                {'type': 'close'}]})
+        p.run()
+        expected = [{'resourceGroup': 'test_resource_group',
+                     'name': 'test_nsg',
+                     'properties': {
+                         'securityRules': [
+                             {
+                                 'name': 'test_2',
+                                 'properties': {
+                                     'direction': 'Inbound',
+                                     'access': 'Allow',
+                                     'protocol': 'TCP',
+                                     'destinationPortRange': '22'
+                                 }
+                             }
+                         ]
+                     }}
+                    ]
+        rules_action_mock.assert_called_with(expected)
+
+    @arm_template('networksecuritygroup.json')
+    @patch('c7n_azure.query.ResourceQuery.filter',
+           return_value=fake_nsgs)
+    @patch('c7n_azure.resources.network_security_group.RulesAction.process',
+           return_value='')
+    def test_access_ingress_deny(self, rules_action_mock, filter_mock):
+        p = self.load_policy({
+            'name': 'test-azure-network-security-group',
+            'resource': 'azure.networksecuritygroup',
+            'filters': [
+                {'type': 'ingress',
+                 PORTS: [22],
+                 ACCESS: 'Deny'
+                 }],
+            'actions': [
+                {'type': 'close'}]})
+        p.run()
+        expected = [{'resourceGroup': 'test_resource_group',
+                     'name': 'test_nsg',
+                     'properties': {
+                         'securityRules': [
+                             {
+                                 'name': 'inbound_22_deny',
+                                 'properties': {
+                                     'direction': 'Inbound',
+                                     'access': 'Deny',
+                                     'protocol': 'TCP',
+                                     'destinationPortRange': '22'
+                                 }
+                             }
+                         ]
+                     }}
+                    ]
+        rules_action_mock.assert_called_with(expected)
+
+    @arm_template('networksecuritygroup.json')
+    @patch('c7n_azure.query.ResourceQuery.filter',
+           return_value=fake_nsgs)
+    @patch('c7n_azure.resources.network_security_group.RulesAction.process',
+           return_value='')
+    def test_access_egress_allow(self, rules_action_mock, filter_mock):
+        p = self.load_policy({
+            'name': 'test-azure-network-security-group',
+            'resource': 'azure.networksecuritygroup',
+            'filters': [
+                {'type': 'egress',
+                 PORTS: [22],
+                 ACCESS: 'Allow'
+                 }],
+            'actions': [
+                {'type': 'close'}]})
+        p.run()
+        expected = [{'resourceGroup': 'test_resource_group',
+                     'name': 'test_nsg',
+                     'properties': {
+                         'securityRules': [
+                             {
+                                 'name': 'outbound_22_allow',
+                                 'properties': {
+                                     'direction': 'Outbound',
+                                     'access': 'Allow',
+                                     'protocol': 'TCP',
+                                     'destinationPortRange': '22'
+                                 }
+                             }
+                         ]
+                     }}
+                    ]
+        rules_action_mock.assert_called_with(expected)
+
+    @arm_template('networksecuritygroup.json')
+    @patch('c7n_azure.query.ResourceQuery.filter',
+           return_value=fake_nsgs)
+    @patch('c7n_azure.resources.network_security_group.RulesAction.process',
+           return_value='')
+    def test_access_egress_deny(self, rules_action_mock, filter_mock):
+        p = self.load_policy({
+            'name': 'test-azure-network-security-group',
+            'resource': 'azure.networksecuritygroup',
+            'filters': [
+                {'type': 'egress',
+                 PORTS: [22],
+                 ACCESS: 'Deny'
+                 }],
+            'actions': [
+                {'type': 'close'}]})
+        p.run()
+        expected = [{'resourceGroup': 'test_resource_group',
+                     'name': 'test_nsg',
+                     'properties': {
+                         'securityRules': [
+                             {
+                                 'name': 'outbound_22_deny',
+                                 'properties': {
+                                     'direction': 'Outbound',
+                                     'access': 'Deny',
+                                     'protocol': 'TCP',
+                                     'destinationPortRange': '22'
+                                 }
+                             }
+                         ]
+                     }}
+                    ]
+        rules_action_mock.assert_called_with(expected)