Repository: cloud-custodian/cloud-custodian

Issue:
Provide generic support for "missing" resources
As it stands, I *believe* there is no generic way of searching for "missing" resources short of writing a custom filter.

For example - if I'm fishing for an IAM user with UserName X - I can't look for it and then measure the resource_count.  If no resources come back, then that's that - no match.  I have to write a custom filter (at the account level?) that sends back a synthetic IAM user resource to match the missing resource.


Discussion:
Hi Eric,
What is it that you're trying to accomplish? If you don't find the IAM user named "X" then are you looking to create it?
In our case, we're just looking for a notification when a resource we expect to be there (in a managed account) is no longer there.  In our situation, we have the potential for a rogue admin mangling stuff, and proactively confirming the existence of certain resources is part of the mitigation strategy.
its an interesting use case, and agreed with the analysis, we effectively need a synthetic resource to mark the absence of something to run through on actions, which the account resource is already effectively. we could do it as a filter that does an embedded resource policy that matches when its . and then have action like deploy cft and notify as a remediation. 

so sketching it out, something like this where in the filter matches if the named bucket doesn't exist.

```
name: missing-s3-bucket
resource: account
filters:
   - type: missing
      policy:
        resource: s3
        filters:
           - Name: my-bucket
actions:
    - notify
```

thoughts? 
Perhaps this could be implemented using config's list_discovered_resources or get_resource_config_history where the policy could be something like

```
name: stack-status
resource: account
filters:
  - type: missing
     resource: s3
     names_from: 
        url: *list_of_s3_names
        format: txt
```
config is already transparently supported for custodian resources that config supports, its effectively orthogonal to the policy specification, doing the embedded policy allows for significantly more flexibility in the filtering on the missing then a simple name match.
This would be very useful for us as well.  Our use-case would be to have a policy which can determine if a specific Lambda function exists in a region based on the function name and if it doesn't then it can trigger an action.  I have a custom lambda function that applies a policy to any new users and roles created in the account to deny everyone from performing a s3:PutObjectACL action on all resources.  It's critical I have this Lambda function in every one of our accounts and this custodian policy would notify me if the Lambda function goes missing or if it never got setup in a new account.
I'm sure if this capability gets implemented I would find many more use cases for it as it would be great to verify resource existence as another way to confirm proper security, compliance, and configuration in a account. 
A potential use case for nested policies that I brought up in the gitter:
```
policies:
  - name: security-group-open-to-world
    resource: security-group
    comment: |
      Open to world outside of HTTP, HTTPS, and ICMP
    filters:
      - used
      - type: ingress
        Cidr: {
          value: '0.0.0.0',
          op: in,
          value_type: 'cidr'
        }
      - name: ec2-with-open-sgs
        resource: ec2
        comment: |
          If a EC2 instance currently exists with a public IP attached and has an open security group
        filters:
          - type: value
            key: "PublicIpAddress"
            value: not-null
```

Resolving patch:
diff --git a/c7n/filters/missing.py b/c7n/filters/missing.py
new file mode 100644
--- /dev/null
+++ b/c7n/filters/missing.py
@@ -0,0 +1,57 @@
+# 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 .core import Filter
+
+from c7n.exceptions import PolicyValidationError
+from c7n.utils import type_schema
+from c7n.policy import Policy
+
+
+class Missing(Filter):
+    """Assert the absence of a particular resource.
+
+    Intended for use at a logical account/subscription/project level
+
+    This works as an effectively an embedded policy thats evaluated.
+    """
+    schema = type_schema(
+        'missing', policy={'type': 'object'}, required=['policy'])
+
+    def __init__(self, data, manager):
+        super(Missing, self).__init__(data, manager)
+        self.data['policy']['name'] = self.manager.ctx.policy.name
+        self.embedded_policy = Policy(self.data['policy'], self.manager.config)
+
+    def validate(self):
+        if 'mode' in self.data['policy']:
+            raise PolicyValidationError(
+                "Execution mode can't be specified in "
+                "embedded policy %s" % self.data)
+        if 'actions' in self.data['policy']:
+            raise PolicyValidationError(
+                "Actions can't be specified in "
+                "embedded policy %s" % self.data)
+
+        self.embedded_policy.validate()
+        return self
+
+    def get_permissions(self):
+        return self.embedded_policy.get_permissions()
+
+    def process(self, resources, event=None):
+        check_resources = self.embedded_policy.poll()
+        if not check_resources:
+            return resources
+        return []
diff --git a/c7n/resources/account.py b/c7n/resources/account.py
--- a/c7n/resources/account.py
+++ b/c7n/resources/account.py
@@ -25,6 +25,7 @@
 from c7n.actions import ActionRegistry, BaseAction
 from c7n.exceptions import PolicyValidationError
 from c7n.filters import Filter, FilterRegistry, ValueFilter
+from c7n.filters.missing import Missing
 from c7n.manager import ResourceManager, resources
 from c7n.utils import local_session, type_schema
 
@@ -35,6 +36,9 @@
 actions = ActionRegistry('aws.account.filters')
 
 
+filters.register('missing', Missing)
+
+
 def get_account(session_factory, config):
     session = local_session(session_factory)
     client = session.client('iam')


Test patch:
diff --git a/tests/data/placebo/test_account_missing_resource_ec2/ec2.DescribeInstances_1.json b/tests/data/placebo/test_account_missing_resource_ec2/ec2.DescribeInstances_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_account_missing_resource_ec2/ec2.DescribeInstances_1.json
@@ -0,0 +1,17 @@
+{
+    "status_code": 200,
+    "data": {
+        "Reservations": [],
+        "ResponseMetadata": {
+            "RequestId": "8aba86c9-b5e5-427e-a370-a0ea7f591ce5",
+            "HTTPStatusCode": 200,
+            "HTTPHeaders": {
+                "content-type": "text/xml;charset=UTF-8",
+                "content-length": "230",
+                "date": "Mon, 17 Sep 2018 20:06:47 GMT",
+                "server": "AmazonEC2"
+            },
+            "RetryAttempts": 0
+        }
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_account_missing_resource_ec2/iam.ListAccountAliases_1.json b/tests/data/placebo/test_account_missing_resource_ec2/iam.ListAccountAliases_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_account_missing_resource_ec2/iam.ListAccountAliases_1.json
@@ -0,0 +1,20 @@
+{
+    "status_code": 200,
+    "data": {
+        "AccountAliases": [
+            "custodian-skunk-works"
+        ],
+        "IsTruncated": false,
+        "ResponseMetadata": {
+            "RequestId": "34302efd-bab5-11e8-a88a-5d222d4600b1",
+            "HTTPStatusCode": 200,
+            "HTTPHeaders": {
+                "x-amzn-requestid": "34302efd-bab5-11e8-a88a-5d222d4600b1",
+                "content-type": "text/xml",
+                "content-length": "400",
+                "date": "Mon, 17 Sep 2018 20:06:46 GMT"
+            },
+            "RetryAttempts": 0
+        }
+    }
+}
\ No newline at end of file
diff --git a/tests/test_account.py b/tests/test_account.py
--- a/tests/test_account.py
+++ b/tests/test_account.py
@@ -31,6 +31,22 @@
 
 class AccountTests(BaseTest):
 
+    def test_missing(self):
+        session_factory = self.replay_flight_data(
+            'test_account_missing_resource_ec2')
+        p = self.load_policy({
+            'name': 'missing-resource',
+            'resource': 'aws.account',
+            'filters': [{
+                'type': 'missing',
+                'policy': {
+                    'resource': 'aws.ec2'}
+            }]}, session_factory=session_factory)
+        resources = p.run()
+        self.assertEqual(len(resources), 1)
+        self.assertEqual(sorted(list(resources[0].keys())),
+                         sorted(['account_id', 'account_name']))
+
     def test_root_mfa_enabled(self):
         session_factory = self.replay_flight_data("test_account_root_mfa")
         p = self.load_policy(
diff --git a/tests/test_policy.py b/tests/test_policy.py
--- a/tests/test_policy.py
+++ b/tests/test_policy.py
@@ -169,9 +169,10 @@ def test_resource_permissions(self):
         self.capture_logging("c7n.cache")
         missing = []
         cfg = Config.empty()
-        for k, v in manager.resources.items():
 
+        for k, v in manager.resources.items():
             p = Bag({"name": "permcheck", "resource": k})
+
             ctx = self.get_context(config=cfg, policy=p)
 
             mgr = v(ctx, p)
@@ -190,7 +191,7 @@ def test_resource_permissions(self):
                     missing.append("%s.actions.%s" % (k, n))
 
             for n, f in v.filter_registry.items():
-                if n in ("and", "or", "not"):
+                if n in ("and", "or", "not", "missing"):
                     continue
                 p["filters"] = [n]
                 perms = f({}, mgr).get_permissions()