Repository: cloud-custodian/cloud-custodian

Issue:
How to Ensure guardduty is configured using CloudCustodian
How to Ensure guardduty is configured using CloudCustodian


Discussion:
Re ensure do you mean detect (filter) or configure (action)?

so we normally point folks to https://github.com/cloud-custodian/cloud-custodian/tree/master/tools/c7n_guardian for guard duty enablement, reporting, configuration re custodian support for guard duty. Its a separate cli tool uses the same account format as c7n-org.  

There is a pr that would add some notion of guard duty support directly to custodian #2628 but its been stalled because its not clear if thats adding value. I think adding at least an account filter for checking/detecting guard duty enablement definitely makes sense. 

Remediation/Enablement actions though are a bit tricky because it typically requires a multi-account handshake with multiple failure modes, which is why c7n_guardian exists as a separate tool. ie. it typically needs account root email, which afaicr is not discoverable from api minus organizations, and the handshake means role assuming into both guard duty master and target account. from a cli or lambda perspective that means custodian and a best practice on security boundary  would custodian having to be executing in the guard duty master and then role assuming into the target accounts derived from organizations, ie it feels a bit more like custom code then a typical custodian policy.. which is why c7n_guardian exists. Nutshell it requires a lot of contextual awareness of the account setup for custodian usage. c7n_guardian gets to defer that to the user and disambiguates the organizations api aspect to the config file generation. We have run c7n_guardian in a periodic lambda hooked up to organizations api for autoenablement in simpler organization setups.

Note that the c7n_guardian support predates cfn support for guard duty with stack sets as it was built right after the guard duty public announce. afaick most people today use cfn stack sets for guard duty setup today.



Resolving patch:
diff --git a/c7n/filters/multiattr.py b/c7n/filters/multiattr.py
new file mode 100644
--- /dev/null
+++ b/c7n/filters/multiattr.py
@@ -0,0 +1,61 @@
+# Copyright 2019 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 c7n.exceptions import PolicyValidationError
+from .core import Filter, ValueFilter
+
+
+class MultiAttrFilter(Filter):
+
+    multi_attrs = set()
+
+    def validate(self):
+        delta = set(self.data.keys()).difference(self.multi_attrs)
+        delta.remove('type')
+        if 'match-operator' in delta:
+            delta.remove('match-operator')
+        if delta:
+            raise PolicyValidationError(
+                "filter:{} unknown keys {} on {}".format(
+                    self.type, ", ".join(delta), self.manager.data))
+
+    def process(self, resources, event=None):
+        matched = []
+        attr_filters = list(self.get_attr_filters())
+        match_op = self.data.get('match-operator', 'and') == 'and' and all or any
+        for r in resources:
+            target = self.get_target(r)
+            if match_op([bool(af(target)) for af in attr_filters]):
+                matched.append(r)
+        return matched
+
+    def get_target(self, resource):
+        """Return the resource, or related resource that should be attribute matched.
+        """
+        return resource
+
+    def get_attr_filters(self):
+        """Return an iterator resource attribute filters configured.
+        """
+        for f in self.data.keys():
+            if f not in self.multi_attrs:
+                continue
+            fv = self.data[f]
+            if isinstance(fv, dict):
+                fv['key'] = f
+            else:
+                fv = {f: fv}
+            vf = ValueFilter(fv)
+            vf.annotate = False
+            yield vf
diff --git a/c7n/resources/account.py b/c7n/resources/account.py
--- a/c7n/resources/account.py
+++ b/c7n/resources/account.py
@@ -26,6 +26,7 @@
 from c7n.actions.securityhub import OtherResourcePostFinding
 from c7n.exceptions import PolicyValidationError
 from c7n.filters import Filter, FilterRegistry, ValueFilter
+from c7n.filters.multiattr import MultiAttrFilter
 from c7n.filters.missing import Missing
 from c7n.manager import ResourceManager, resources
 from c7n.utils import local_session, type_schema, generate_arn
@@ -168,6 +169,74 @@ def process(self, resources, event=None):
         return resources
 
 
+@filters.register('guard-duty')
+class GuardDutyEnabled(MultiAttrFilter):
+    """Check if the guard duty service is enabled.
+
+    This allows looking at account's detector and its associated
+    master if any.
+
+    :example:
+
+     Check to ensure guard duty is active on account and associated to a master.
+
+    .. code-block:: yaml
+
+            policies:
+              - name: guardduty-enabled
+                resource: account
+                filters:
+                  - type: guard-duty
+                    Detector.Status: ENABLED
+                    Master.AccountId: "00011001"
+                    Master.RelationshipStatus: ENABLED
+    """
+
+    schema = {
+        'type': 'object',
+        'additionalProperties': False,
+        'properties': {
+            'type': {'enum': ['guard-duty']},
+            'match-operator': {'enum': ['or', 'and']}},
+        'patternProperties': {
+            '^Detector': {'oneOf': [{'type': 'object'}, {'type': 'string'}]},
+            '^Master': {'oneOf': [{'type': 'object'}, {'type': 'string'}]}},
+    }
+
+    annotation = "c7n:guard-duty"
+    permissions = (
+        'guardduty:GetMasterAccount',
+        'guardduty:ListDetectors',
+        'guardduty:GetDetector')
+
+    def validate(self):
+        attrs = set()
+        for k in self.data:
+            if k.startswith('Detector') or k.startswith('Master'):
+                attrs.add(k)
+        self.multi_attrs = attrs
+        return super(GuardDutyEnabled, self).validate()
+
+    def get_target(self, resource):
+        if self.annotation in resource:
+            return resource[self.annotation]
+
+        client = local_session(self.manager.session_factory).client('guardduty')
+        # detectors are singletons too.
+        detector_ids = client.list_detectors().get('DetectorIds')
+
+        if not detector_ids:
+            return None
+        else:
+            detector_id = detector_ids.pop()
+
+        detector = client.get_detector(DetectorId=detector_id)
+        detector.pop('ResponseMetadata', None)
+        master = client.get_master_account(DetectorId=detector_id).get('master')
+        resource[self.annotation] = r = {'Detector': detector, 'Master': master}
+        return r
+
+
 @filters.register('check-config')
 class ConfigEnabled(Filter):
     """Is config service enabled for this account


Test patch:
diff --git a/tests/data/placebo/test_account_guard_duty_filter/guardduty.GetDetector_1.json b/tests/data/placebo/test_account_guard_duty_filter/guardduty.GetDetector_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_account_guard_duty_filter/guardduty.GetDetector_1.json
@@ -0,0 +1,27 @@
+{
+    "status_code": 200,
+    "data": {
+        "ResponseMetadata": {
+            "RequestId": "a30fbaeb-36dd-11e9-933b-dfd966ce4863",
+            "HTTPStatusCode": 200,
+            "HTTPHeaders": {
+                "content-type": "application/json",
+                "content-length": "260",
+                "connection": "keep-alive",
+                "date": "Fri, 22 Feb 2019 20:08:36 GMT",
+                "x-amzn-requestid": "a30fbaeb-36dd-11e9-933b-dfd966ce4863",
+                "x-amz-apigw-id": "VhJ-qFFNoAMFb7A=",
+                "x-amzn-trace-id": "Root=1-5c7056c4-2a664b82f0b60a96ff6f58e9;Sampled=0",
+                "x-cache": "Miss from cloudfront",
+                "via": "1.1 dca69919d6f10bd537498dd599f5104e.cloudfront.net (CloudFront)",
+                "x-amz-cf-id": "WEk3tkzABkl65_3QuQQcBiyx4uankbHGxRZRfPNSEmTi_AFAZGdzBQ=="
+            },
+            "RetryAttempts": 0
+        },
+        "CreatedAt": "2017-12-05T00:29:11.374Z",
+        "FindingPublishingFrequency": "SIX_HOURS",
+        "ServiceRole": "arn:aws:iam::644160558196:role/aws-service-role/guardduty.amazonaws.com/AWSServiceRoleForAmazonGuardDuty",
+        "Status": "ENABLED",
+        "UpdatedAt": "2019-02-22T02:50:23.795Z"
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_account_guard_duty_filter/guardduty.GetMasterAccount_1.json b/tests/data/placebo/test_account_guard_duty_filter/guardduty.GetMasterAccount_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_account_guard_duty_filter/guardduty.GetMasterAccount_1.json
@@ -0,0 +1,22 @@
+{
+    "status_code": 200,
+    "data": {
+        "ResponseMetadata": {
+            "RequestId": "a34aef59-36dd-11e9-a164-c194b6c10d6c",
+            "HTTPStatusCode": 200,
+            "HTTPHeaders": {
+                "content-type": "application/json",
+                "content-length": "15",
+                "connection": "keep-alive",
+                "date": "Fri, 22 Feb 2019 20:08:36 GMT",
+                "x-amzn-requestid": "a34aef59-36dd-11e9-a164-c194b6c10d6c",
+                "x-amz-apigw-id": "VhJ-uGQ2IAMF-zg=",
+                "x-amzn-trace-id": "Root=1-5c7056c4-d3f956b1760bb566fb3dd39e;Sampled=0",
+                "x-cache": "Miss from cloudfront",
+                "via": "1.1 dca69919d6f10bd537498dd599f5104e.cloudfront.net (CloudFront)",
+                "x-amz-cf-id": "bq7PyymvJ_SRmZz4zAlJeEbb_ARvP-u8LzNyw5rl8eJYExx2-GJTXw=="
+            },
+            "RetryAttempts": 0
+        }
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_account_guard_duty_filter/guardduty.ListDetectors_1.json b/tests/data/placebo/test_account_guard_duty_filter/guardduty.ListDetectors_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_account_guard_duty_filter/guardduty.ListDetectors_1.json
@@ -0,0 +1,25 @@
+{
+    "status_code": 200,
+    "data": {
+        "ResponseMetadata": {
+            "RequestId": "a302c283-36dd-11e9-874d-07ba13d8401d",
+            "HTTPStatusCode": 200,
+            "HTTPHeaders": {
+                "content-type": "application/json",
+                "content-length": "52",
+                "connection": "keep-alive",
+                "date": "Fri, 22 Feb 2019 20:08:36 GMT",
+                "x-amzn-requestid": "a302c283-36dd-11e9-874d-07ba13d8401d",
+                "x-amz-apigw-id": "VhJ-qFjDIAMF1jg=",
+                "x-amzn-trace-id": "Root=1-5c7056c4-5d7bf07c22bbf774906875d8;Sampled=0",
+                "x-cache": "Miss from cloudfront",
+                "via": "1.1 dca69919d6f10bd537498dd599f5104e.cloudfront.net (CloudFront)",
+                "x-amz-cf-id": "AvNkj1LfOJ5U_PJsPIy3Cs4kXBW9Wql3eQnpsre9GyHBZVvYp4tkYw=="
+            },
+            "RetryAttempts": 0
+        },
+        "DetectorIds": [
+            "06b01209ca77b0865dd973df0355fc1e"
+        ]
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_account_guard_duty_filter/iam.ListAccountAliases_1.json b/tests/data/placebo/test_account_guard_duty_filter/iam.ListAccountAliases_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_account_guard_duty_filter/iam.ListAccountAliases_1.json
@@ -0,0 +1,20 @@
+{
+    "status_code": 200,
+    "data": {
+        "AccountAliases": [
+            "custodian-skunk-works"
+        ],
+        "IsTruncated": false,
+        "ResponseMetadata": {
+            "RequestId": "a2f8d742-36dd-11e9-be3d-9f977a7e345d",
+            "HTTPStatusCode": 200,
+            "HTTPHeaders": {
+                "x-amzn-requestid": "a2f8d742-36dd-11e9-be3d-9f977a7e345d",
+                "content-type": "text/xml",
+                "content-length": "400",
+                "date": "Fri, 22 Feb 2019 20:08:35 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
@@ -82,6 +82,19 @@ def __call__(self, region=None, assume=None):
         resources = p.run()
         self.assertEqual(len(resources), 1)
 
+    def test_guard_duty_filter(self):
+        factory = self.replay_flight_data('test_account_guard_duty_filter')
+        p = self.load_policy({
+            'name': 'account',
+            'resource': 'account',
+            'filters': [{
+                'type': 'guard-duty',
+                'Detector.Status': 'ENABLED'}]},
+            session_factory=factory)
+        resources = p.run()
+        self.assertEqual(len(resources), 1)
+        self.assertTrue('c7n:guard-duty' in resources[0])
+
     def test_root_mfa_enabled(self):
         session_factory = self.replay_flight_data("test_account_root_mfa")
         p = self.load_policy(