diff --git a/README.md b/README.md index 68e56a0..b5e519e 100644 --- a/README.md +++ b/README.md @@ -91,8 +91,10 @@ The `ResponseObject` allows you to define a message to be sent to CloudFormation ```python import accustom -r = accustom.ResponseObject() -r.send(event) + +def handler(event, context): + r = accustom.ResponseObject() + r.send(event) ``` If you are using the decorator pattern it is strongly recommended that you do not invoke the `send()` method, and instead allow the decorator to process the sending of the events for you. @@ -108,7 +110,7 @@ To construct a response object you can provide the following optional parameters ## Redacting Confidential Information From Logs If you often pass confidential information like passwords and secrets in properties to Custom Resources, you may want to prevent certain properties from being printed to debug logs. To help with this we provide a functionality to either blacklist or whitelist Resource Properties based upon provided regular expressions. -To utilise this functionality you must initalise and include a `RedactionConfig`. A `RedactionConfig` consists of some flags to define the redaction mode and if the response URL should be redacted, as well as a series of `RedactionRuleSet` objects that define what to redact based upon regular expressions. There is a special case of `RedactionConfig` called a `StandaloneRedactionConfig` that has one, and only one, `RedactionRuleSet` that is provided at initialisation. +To utilise this functionality you must initialise and include a `RedactionConfig`. A `RedactionConfig` consists of some flags to define the redaction mode and if the response URL should be redacted, as well as a series of `RedactionRuleSet` objects that define what to redact based upon regular expressions. There is a special case of `RedactionConfig` called a `StandaloneRedactionConfig` that has one, and only one, `RedactionRuleSet` that is provided at initialisation. Each `RedactionRuleSet` defines a single regex that defines which ResourceTypes this rule set should applied too. You can then apply any number of rules, based upon explicit an property name, or a regex. Please see the definitions and an example below. @@ -117,11 +119,11 @@ The `RedactionRuleSet` object allows you to define a series of properties or reg - `resourceRegex` (String) : The regex used to work out what resources to apply this too. -#### `addPropertyRegex(propertiesRegex)` +#### `add_property_regex(propertiesRegex)` - `propertiesRegex` (String) : The regex used to work out what properties to whitelist/blacklist -#### `addProperty(propertyName)` +#### `add_property(propertyName)` - `propertyName` (String) : The name of the property to whitelist/blacklist @@ -132,7 +134,7 @@ The `RedactionConfig` object allows you to create a collection of `RedactionRule - `redactMode` (accustom.RedactMode) : What redaction mode should be used, if it should be a blacklist or whitelist - `redactResponseURL` (Boolean) : If the response URL should be not be logged. -#### `addRuleSet(ruleSet)` +#### `add_rule_set(ruleSet)` - `ruleSet` (accustom.RedactionRuleSet) : The rule set to be added to the RedactionConfig @@ -150,21 +152,21 @@ The below example takes in two rule sets. The first ruleset applies to all resou All resources will have properties called `Test` and `Example` redacted and replaced with `[REDATED]`. The `Custom::Test` resource will also additionally redact properties called `Custom` and those that *start with* `DeleteMe`. Finally, as `redactResponseURL` is set to `True`, the response URL will not be printed in the debug logs. - -from accustom import RedactionRuleSet, RedactionConfig, decorator - + ```python +from accustom import RedactionRuleSet, RedactionConfig, decorator + ruleSetDefault = RedactionRuleSet() -ruleSetDefault.addPropertyRegex('^Test$') -ruleSetDefault.addProperty('Example') +ruleSetDefault.add_property_regex('^Test$') +ruleSetDefault.add_property('Example') ruleSetCustom = RedactionRuleSet('^Custom::Test$') -ruleSetCustom.addProperty('Custom') -ruleSetCustom.addPropertyRegex('^DeleteMe.*$') +ruleSetCustom.add_property('Custom') +ruleSetCustom.add_property_regex('^DeleteMe.*$') rc = RedactionConfig(redactResponseURL=True) -rc.addRuleSet(self.ruleSetDefault) -rc.addRuleSet(self.ruleSetCustom) +rc.add_rule_set(ruleSetDefault) +rc.add_rule_set(ruleSetCustom) @decorator(redactConfig=rc) def resource_handler(event, context): diff --git a/accustom/Exceptions/__init__.py b/accustom/Exceptions/__init__.py index 2adf118..b04ad26 100644 --- a/accustom/Exceptions/__init__.py +++ b/accustom/Exceptions/__init__.py @@ -6,3 +6,4 @@ from .exceptions import InvalidResponseStatusException from .exceptions import DataIsNotDictException from .exceptions import FailedToSendResponseException +from .exceptions import NotValidRequestObjectException \ No newline at end of file diff --git a/accustom/Exceptions/exceptions.py b/accustom/Exceptions/exceptions.py index b668796..c3bf342 100644 --- a/accustom/Exceptions/exceptions.py +++ b/accustom/Exceptions/exceptions.py @@ -38,3 +38,8 @@ class FailedToSendResponseException(Exception): """Indicates there was a problem sending the response""" def __init__(self, *args, **kwargs): Exception.__init__(self, *args, **kwargs) + +class NotValidRequestObjectException(Exception): + """Indicates that the event passed in is not a valid Request Object""" + def __init__(self, *args, **kwargs): + Exception.__init__(self, *args, **kwargs) diff --git a/accustom/Testing/test_redaction.py b/accustom/Testing/test_redaction.py index 173d36f..7a37118 100644 --- a/accustom/Testing/test_redaction.py +++ b/accustom/Testing/test_redaction.py @@ -6,8 +6,9 @@ from unittest import TestCase, main as umain -REDACTED_STRING='[REDACTED]' -NOT_REDACTED_STRING='NotRedacted' +REDACTED_STRING = '[REDACTED]' +NOT_REDACTED_STRING = 'NotRedacted' + class RedactionRuleSetTests(TestCase): @@ -28,32 +29,32 @@ def test_default_regex(self): self.assertEqual(self.ruleSet.resourceRegex, '^.*$') def test_adding_regex(self): - self.ruleSet.addPropertyRegex('^Test$') + self.ruleSet.add_property_regex('^Test$') self.assertIn('^Test$', self.ruleSet._properties) def test_adding_invalid_regex(self): with self.assertRaises(TypeError): - self.ruleSet.addPropertyRegex(0) + self.ruleSet.add_property_regex(0) def test_adding_property(self): - self.ruleSet.addProperty('Test') + self.ruleSet.add_property('Test') self.assertIn('^Test$', self.ruleSet._properties) def test_adding_invalid_property(self): with self.assertRaises(TypeError): - self.ruleSet.addProperty(0) + self.ruleSet.add_property(0) class RedactionConfigTests(TestCase): def setUp(self): self.ruleSetDefault = RedactionRuleSet() - self.ruleSetDefault.addPropertyRegex('^Test$') - self.ruleSetDefault.addProperty('Example') + self.ruleSetDefault.add_property_regex('^Test$') + self.ruleSetDefault.add_property('Example') self.ruleSetCustom = RedactionRuleSet('^Custom::Test$') - self.ruleSetCustom.addProperty('Custom') - self.ruleSetCustom.addPropertyRegex('^DeleteMe.*$') + self.ruleSetCustom.add_property('Custom') + self.ruleSetCustom.add_property_regex('^DeleteMe.*$') def test_defaults(self): rc = RedactionConfig() @@ -75,8 +76,8 @@ def test_invalid_input_values(self): def test_structure(self): rc = RedactionConfig() - rc.addRuleSet(self.ruleSetDefault) - rc.addRuleSet(self.ruleSetCustom) + rc.add_rule_set(self.ruleSetDefault) + rc.add_rule_set(self.ruleSetCustom) self.assertIn('^.*$', rc._redactProperties) self.assertIn('^Custom::Test$', rc._redactProperties) @@ -87,8 +88,12 @@ def test_structure(self): def test_redactResponseURL(self): rc = RedactionConfig(redactResponseURL=True) - event = {'ResponseURL': True, - 'ResourceType' : 'Custom::Test'} + event = {'RequestType' : 'Create', + 'RequestId' : 'abcded', + 'ResponseURL' : 'https://localhost', + 'StackId': 'arn:...', + 'LogicalResourceId': 'Test', + 'ResourceType': 'Custom::Test'} revent = rc._redact(event) self.assertIn('ResponseURL', event) @@ -96,113 +101,139 @@ def test_redactResponseURL(self): def test_blacklist1(self): rc = RedactionConfig(redactMode=RedactMode.BLACKLIST) - rc.addRuleSet(self.ruleSetDefault) - rc.addRuleSet(self.ruleSetCustom) - event = { 'ResourceType' : 'Custom::Test', - 'ResourceProperties' : {'Test' : NOT_REDACTED_STRING, - 'Example' : NOT_REDACTED_STRING, - 'Custom' : NOT_REDACTED_STRING, - 'DeleteMe1': NOT_REDACTED_STRING, - 'DeleteMe2': NOT_REDACTED_STRING, - 'DoNotDelete' : NOT_REDACTED_STRING }} + rc.add_rule_set(self.ruleSetDefault) + rc.add_rule_set(self.ruleSetCustom) + event = {'RequestType' : 'Create', + 'RequestId' : 'abcded', + 'ResponseURL' : 'https://localhost', + 'StackId': 'arn:...', + 'LogicalResourceId': 'Test', + 'ResourceType': 'Custom::Test', + 'ResourceProperties': {'Test': NOT_REDACTED_STRING, + 'Example': NOT_REDACTED_STRING, + 'Custom': NOT_REDACTED_STRING, + 'DeleteMe1': NOT_REDACTED_STRING, + 'DeleteMe2': NOT_REDACTED_STRING, + 'DoNotDelete': NOT_REDACTED_STRING}} revent = rc._redact(event) - self.assertEqual(event['ResourceProperties']['Test'],NOT_REDACTED_STRING) - self.assertEqual(revent['ResourceProperties']['Test'],REDACTED_STRING) - self.assertEqual(event['ResourceProperties']['Example'],NOT_REDACTED_STRING) - self.assertEqual(revent['ResourceProperties']['Example'],REDACTED_STRING) - self.assertEqual(event['ResourceProperties']['Custom'],NOT_REDACTED_STRING) - self.assertEqual(revent['ResourceProperties']['Custom'],REDACTED_STRING) - self.assertEqual(event['ResourceProperties']['DeleteMe1'],NOT_REDACTED_STRING) - self.assertEqual(revent['ResourceProperties']['DeleteMe1'],REDACTED_STRING) - self.assertEqual(event['ResourceProperties']['DeleteMe2'],NOT_REDACTED_STRING) - self.assertEqual(revent['ResourceProperties']['DeleteMe2'],REDACTED_STRING) - self.assertEqual(event['ResourceProperties']['DoNotDelete'],NOT_REDACTED_STRING) - self.assertEqual(revent['ResourceProperties']['DoNotDelete'],NOT_REDACTED_STRING) + self.assertEqual(event['ResourceProperties']['Test'], NOT_REDACTED_STRING) + self.assertEqual(revent['ResourceProperties']['Test'], REDACTED_STRING) + self.assertEqual(event['ResourceProperties']['Example'], NOT_REDACTED_STRING) + self.assertEqual(revent['ResourceProperties']['Example'], REDACTED_STRING) + self.assertEqual(event['ResourceProperties']['Custom'], NOT_REDACTED_STRING) + self.assertEqual(revent['ResourceProperties']['Custom'], REDACTED_STRING) + self.assertEqual(event['ResourceProperties']['DeleteMe1'], NOT_REDACTED_STRING) + self.assertEqual(revent['ResourceProperties']['DeleteMe1'], REDACTED_STRING) + self.assertEqual(event['ResourceProperties']['DeleteMe2'], NOT_REDACTED_STRING) + self.assertEqual(revent['ResourceProperties']['DeleteMe2'], REDACTED_STRING) + self.assertEqual(event['ResourceProperties']['DoNotDelete'], NOT_REDACTED_STRING) + self.assertEqual(revent['ResourceProperties']['DoNotDelete'], NOT_REDACTED_STRING) def test_blacklist2(self): rc = RedactionConfig(redactMode=RedactMode.BLACKLIST) - rc.addRuleSet(self.ruleSetDefault) - rc.addRuleSet(self.ruleSetCustom) - event = { 'ResourceType' : 'Custom::Hello', - 'ResourceProperties' : {'Test' : NOT_REDACTED_STRING, - 'Example' : NOT_REDACTED_STRING, - 'Custom' : NOT_REDACTED_STRING, - 'DeleteMe1': NOT_REDACTED_STRING, - 'DeleteMe2': NOT_REDACTED_STRING, - 'DoNotDelete' : NOT_REDACTED_STRING }} + rc.add_rule_set(self.ruleSetDefault) + rc.add_rule_set(self.ruleSetCustom) + event = {'RequestType' : 'Create', + 'RequestId' : 'abcded', + 'ResponseURL' : 'https://localhost', + 'StackId': 'arn:...', + 'LogicalResourceId': 'Test', + 'ResourceType': 'Custom::Hello', + 'ResourceProperties': {'Test': NOT_REDACTED_STRING, + 'Example': NOT_REDACTED_STRING, + 'Custom': NOT_REDACTED_STRING, + 'DeleteMe1': NOT_REDACTED_STRING, + 'DeleteMe2': NOT_REDACTED_STRING, + 'DoNotDelete': NOT_REDACTED_STRING}} revent = rc._redact(event) - self.assertEqual(event['ResourceProperties']['Test'],NOT_REDACTED_STRING) - self.assertEqual(revent['ResourceProperties']['Test'],REDACTED_STRING) - self.assertEqual(event['ResourceProperties']['Example'],NOT_REDACTED_STRING) - self.assertEqual(revent['ResourceProperties']['Example'],REDACTED_STRING) - self.assertEqual(event['ResourceProperties']['Custom'],NOT_REDACTED_STRING) - self.assertEqual(revent['ResourceProperties']['Custom'],NOT_REDACTED_STRING) - self.assertEqual(event['ResourceProperties']['DeleteMe1'],NOT_REDACTED_STRING) - self.assertEqual(revent['ResourceProperties']['DeleteMe1'],NOT_REDACTED_STRING) - self.assertEqual(event['ResourceProperties']['DeleteMe2'],NOT_REDACTED_STRING) - self.assertEqual(revent['ResourceProperties']['DeleteMe2'],NOT_REDACTED_STRING) - self.assertEqual(event['ResourceProperties']['DoNotDelete'],NOT_REDACTED_STRING) - self.assertEqual(revent['ResourceProperties']['DoNotDelete'],NOT_REDACTED_STRING) + self.assertEqual(event['ResourceProperties']['Test'], NOT_REDACTED_STRING) + self.assertEqual(revent['ResourceProperties']['Test'], REDACTED_STRING) + self.assertEqual(event['ResourceProperties']['Example'], NOT_REDACTED_STRING) + self.assertEqual(revent['ResourceProperties']['Example'], REDACTED_STRING) + self.assertEqual(event['ResourceProperties']['Custom'], NOT_REDACTED_STRING) + self.assertEqual(revent['ResourceProperties']['Custom'], NOT_REDACTED_STRING) + self.assertEqual(event['ResourceProperties']['DeleteMe1'], NOT_REDACTED_STRING) + self.assertEqual(revent['ResourceProperties']['DeleteMe1'], NOT_REDACTED_STRING) + self.assertEqual(event['ResourceProperties']['DeleteMe2'], NOT_REDACTED_STRING) + self.assertEqual(revent['ResourceProperties']['DeleteMe2'], NOT_REDACTED_STRING) + self.assertEqual(event['ResourceProperties']['DoNotDelete'], NOT_REDACTED_STRING) + self.assertEqual(revent['ResourceProperties']['DoNotDelete'], NOT_REDACTED_STRING) def test_whitelist1(self): rc = RedactionConfig(redactMode=RedactMode.WHITELIST) - rc.addRuleSet(self.ruleSetDefault) - rc.addRuleSet(self.ruleSetCustom) - event = { 'ResourceType' : 'Custom::Test', - 'ResourceProperties' : {'Test' : NOT_REDACTED_STRING, - 'Example' : NOT_REDACTED_STRING, - 'Custom' : NOT_REDACTED_STRING, - 'DeleteMe1': NOT_REDACTED_STRING, - 'DeleteMe2': NOT_REDACTED_STRING, - 'DoNotDelete' : NOT_REDACTED_STRING }} + rc.add_rule_set(self.ruleSetDefault) + rc.add_rule_set(self.ruleSetCustom) + event = {'RequestType' : 'Create', + 'RequestId' : 'abcded', + 'ResponseURL' : 'https://localhost', + 'StackId': 'arn:...', + 'LogicalResourceId': 'Test', + 'ResourceType': 'Custom::Test', + 'ResourceProperties': {'Test': NOT_REDACTED_STRING, + 'Example': NOT_REDACTED_STRING, + 'Custom': NOT_REDACTED_STRING, + 'DeleteMe1': NOT_REDACTED_STRING, + 'DeleteMe2': NOT_REDACTED_STRING, + 'DoNotDelete': NOT_REDACTED_STRING}} revent = rc._redact(event) - self.assertEqual(event['ResourceProperties']['Test'],NOT_REDACTED_STRING) - self.assertEqual(revent['ResourceProperties']['Test'],NOT_REDACTED_STRING) - self.assertEqual(event['ResourceProperties']['Example'],NOT_REDACTED_STRING) - self.assertEqual(revent['ResourceProperties']['Example'],NOT_REDACTED_STRING) - self.assertEqual(event['ResourceProperties']['Custom'],NOT_REDACTED_STRING) - self.assertEqual(revent['ResourceProperties']['Custom'],NOT_REDACTED_STRING) - self.assertEqual(event['ResourceProperties']['DeleteMe1'],NOT_REDACTED_STRING) - self.assertEqual(revent['ResourceProperties']['DeleteMe1'],NOT_REDACTED_STRING) - self.assertEqual(event['ResourceProperties']['DeleteMe2'],NOT_REDACTED_STRING) - self.assertEqual(revent['ResourceProperties']['DeleteMe2'],NOT_REDACTED_STRING) - self.assertEqual(event['ResourceProperties']['DoNotDelete'],NOT_REDACTED_STRING) - self.assertEqual(revent['ResourceProperties']['DoNotDelete'],REDACTED_STRING) + self.assertEqual(event['ResourceProperties']['Test'], NOT_REDACTED_STRING) + self.assertEqual(revent['ResourceProperties']['Test'], NOT_REDACTED_STRING) + self.assertEqual(event['ResourceProperties']['Example'], NOT_REDACTED_STRING) + self.assertEqual(revent['ResourceProperties']['Example'], NOT_REDACTED_STRING) + self.assertEqual(event['ResourceProperties']['Custom'], NOT_REDACTED_STRING) + self.assertEqual(revent['ResourceProperties']['Custom'], NOT_REDACTED_STRING) + self.assertEqual(event['ResourceProperties']['DeleteMe1'], NOT_REDACTED_STRING) + self.assertEqual(revent['ResourceProperties']['DeleteMe1'], NOT_REDACTED_STRING) + self.assertEqual(event['ResourceProperties']['DeleteMe2'], NOT_REDACTED_STRING) + self.assertEqual(revent['ResourceProperties']['DeleteMe2'], NOT_REDACTED_STRING) + self.assertEqual(event['ResourceProperties']['DoNotDelete'], NOT_REDACTED_STRING) + self.assertEqual(revent['ResourceProperties']['DoNotDelete'], REDACTED_STRING) def test_whitelist2(self): rc = RedactionConfig(redactMode=RedactMode.WHITELIST) - rc.addRuleSet(self.ruleSetDefault) - rc.addRuleSet(self.ruleSetCustom) - event = { 'ResourceType' : 'Custom::Hello', - 'ResourceProperties' : {'Test' : NOT_REDACTED_STRING, - 'Example' : NOT_REDACTED_STRING, - 'Custom' : NOT_REDACTED_STRING, - 'DeleteMe1': NOT_REDACTED_STRING, - 'DeleteMe2': NOT_REDACTED_STRING, - 'DoNotDelete' : NOT_REDACTED_STRING }} + rc.add_rule_set(self.ruleSetDefault) + rc.add_rule_set(self.ruleSetCustom) + event = {'RequestType' : 'Create', + 'RequestId' : 'abcded', + 'ResponseURL' : 'https://localhost', + 'StackId': 'arn:...', + 'LogicalResourceId': 'Test', + 'ResourceType': 'Custom::Hello', + 'ResourceProperties': {'Test': NOT_REDACTED_STRING, + 'Example': NOT_REDACTED_STRING, + 'Custom': NOT_REDACTED_STRING, + 'DeleteMe1': NOT_REDACTED_STRING, + 'DeleteMe2': NOT_REDACTED_STRING, + 'DoNotDelete': NOT_REDACTED_STRING}} revent = rc._redact(event) - self.assertEqual(event['ResourceProperties']['Test'],NOT_REDACTED_STRING) - self.assertEqual(revent['ResourceProperties']['Test'],NOT_REDACTED_STRING) - self.assertEqual(event['ResourceProperties']['Example'],NOT_REDACTED_STRING) - self.assertEqual(revent['ResourceProperties']['Example'],NOT_REDACTED_STRING) - self.assertEqual(event['ResourceProperties']['Custom'],NOT_REDACTED_STRING) - self.assertEqual(revent['ResourceProperties']['Custom'],REDACTED_STRING) - self.assertEqual(event['ResourceProperties']['DeleteMe1'],NOT_REDACTED_STRING) - self.assertEqual(revent['ResourceProperties']['DeleteMe1'],REDACTED_STRING) - self.assertEqual(event['ResourceProperties']['DeleteMe2'],NOT_REDACTED_STRING) - self.assertEqual(revent['ResourceProperties']['DeleteMe2'],REDACTED_STRING) - self.assertEqual(event['ResourceProperties']['DoNotDelete'],NOT_REDACTED_STRING) - self.assertEqual(revent['ResourceProperties']['DoNotDelete'],REDACTED_STRING) + self.assertEqual(event['ResourceProperties']['Test'], NOT_REDACTED_STRING) + self.assertEqual(revent['ResourceProperties']['Test'], NOT_REDACTED_STRING) + self.assertEqual(event['ResourceProperties']['Example'], NOT_REDACTED_STRING) + self.assertEqual(revent['ResourceProperties']['Example'], NOT_REDACTED_STRING) + self.assertEqual(event['ResourceProperties']['Custom'], NOT_REDACTED_STRING) + self.assertEqual(revent['ResourceProperties']['Custom'], REDACTED_STRING) + self.assertEqual(event['ResourceProperties']['DeleteMe1'], NOT_REDACTED_STRING) + self.assertEqual(revent['ResourceProperties']['DeleteMe1'], REDACTED_STRING) + self.assertEqual(event['ResourceProperties']['DeleteMe2'], NOT_REDACTED_STRING) + self.assertEqual(revent['ResourceProperties']['DeleteMe2'], REDACTED_STRING) + self.assertEqual(event['ResourceProperties']['DoNotDelete'], NOT_REDACTED_STRING) + self.assertEqual(revent['ResourceProperties']['DoNotDelete'], REDACTED_STRING) def test_oldproperties1(self): rc = RedactionConfig(redactMode=RedactMode.BLACKLIST) - rc.addRuleSet(self.ruleSetDefault) - rc.addRuleSet(self.ruleSetCustom) - event = {'ResourceType': 'Custom::Hello', + rc.add_rule_set(self.ruleSetDefault) + rc.add_rule_set(self.ruleSetCustom) + event = {'RequestType' : 'Update', + 'RequestId' : 'abcded', + 'ResponseURL' : 'https://localhost', + 'StackId': 'arn:...', + 'LogicalResourceId': 'Test', + 'PhysicalResourceId': 'Test', + 'ResourceType': 'Custom::Hello', 'ResourceProperties': {'Test': NOT_REDACTED_STRING, 'Example': NOT_REDACTED_STRING, 'Custom': NOT_REDACTED_STRING, @@ -210,11 +241,11 @@ def test_oldproperties1(self): 'DeleteMe2': NOT_REDACTED_STRING, 'DoNotDelete': NOT_REDACTED_STRING}, 'OldResourceProperties': {'Test': NOT_REDACTED_STRING, - 'Example': NOT_REDACTED_STRING, - 'Custom': NOT_REDACTED_STRING, - 'DeleteMe1': NOT_REDACTED_STRING, - 'DeleteMe2': NOT_REDACTED_STRING, - 'DoNotDelete': NOT_REDACTED_STRING}} + 'Example': NOT_REDACTED_STRING, + 'Custom': NOT_REDACTED_STRING, + 'DeleteMe1': NOT_REDACTED_STRING, + 'DeleteMe2': NOT_REDACTED_STRING, + 'DoNotDelete': NOT_REDACTED_STRING}} revent = rc._redact(event) self.assertEqual(event['ResourceProperties']['Test'], NOT_REDACTED_STRING) @@ -245,9 +276,15 @@ def test_oldproperties1(self): def test_oldproperties2(self): rc = RedactionConfig(redactMode=RedactMode.WHITELIST) - rc.addRuleSet(self.ruleSetDefault) - rc.addRuleSet(self.ruleSetCustom) - event = {'ResourceType': 'Custom::Hello', + rc.add_rule_set(self.ruleSetDefault) + rc.add_rule_set(self.ruleSetCustom) + event = {'RequestType' : 'Update', + 'RequestId' : 'abcded', + 'ResponseURL' : 'https://localhost', + 'StackId': 'arn:...', + 'LogicalResourceId': 'Test', + 'PhysicalResourceId': 'Test', + 'ResourceType': 'Custom::Hello', 'ResourceProperties': {'Test': NOT_REDACTED_STRING, 'Example': NOT_REDACTED_STRING, 'Custom': NOT_REDACTED_STRING, @@ -255,11 +292,11 @@ def test_oldproperties2(self): 'DeleteMe2': NOT_REDACTED_STRING, 'DoNotDelete': NOT_REDACTED_STRING}, 'OldResourceProperties': {'Test': NOT_REDACTED_STRING, - 'Example': NOT_REDACTED_STRING, - 'Custom': NOT_REDACTED_STRING, - 'DeleteMe1': NOT_REDACTED_STRING, - 'DeleteMe2': NOT_REDACTED_STRING, - 'DoNotDelete': NOT_REDACTED_STRING}} + 'Example': NOT_REDACTED_STRING, + 'Custom': NOT_REDACTED_STRING, + 'DeleteMe1': NOT_REDACTED_STRING, + 'DeleteMe2': NOT_REDACTED_STRING, + 'DoNotDelete': NOT_REDACTED_STRING}} revent = rc._redact(event) self.assertEqual(event['ResourceProperties']['Test'], NOT_REDACTED_STRING) @@ -293,12 +330,12 @@ class StandaloneRedactionConfigTests(TestCase): def setUp(self): self.ruleSetDefault = RedactionRuleSet() - self.ruleSetDefault.addPropertyRegex('^Test$') - self.ruleSetDefault.addProperty('Example') + self.ruleSetDefault.add_property_regex('^Test$') + self.ruleSetDefault.add_property('Example') self.ruleSetCustom = RedactionRuleSet('^Custom::Test$') - self.ruleSetCustom.addProperty('Custom') - self.ruleSetCustom.addPropertyRegex('^DeleteMe.*$') + self.ruleSetCustom.add_property('Custom') + self.ruleSetCustom.add_property_regex('^DeleteMe.*$') def test_defaults(self): rc = StandaloneRedactionConfig(self.ruleSetDefault) @@ -319,7 +356,7 @@ def test_invalid_input_values(self): StandaloneRedactionConfig(self.ruleSetDefault, redactResponseURL=0) with self.assertRaises(CannotApplyRuleToStandaloneRedactionConfig): rc = StandaloneRedactionConfig(self.ruleSetDefault) - rc.addRuleSet(self.ruleSetCustom) + rc.add_rule_set(self.ruleSetCustom) def test_structure(self): rc = StandaloneRedactionConfig(self.ruleSetDefault) @@ -330,8 +367,12 @@ def test_structure(self): def test_redactResponseURL(self): rc = StandaloneRedactionConfig(self.ruleSetDefault, redactResponseURL=True) - event = {'ResponseURL': True, - 'ResourceType' : 'Custom::Test'} + event = {'RequestType' : 'Create', + 'RequestId' : 'abcded', + 'ResponseURL' : 'https://localhost', + 'StackId': 'arn:...', + 'LogicalResourceId': 'Test', + 'ResourceType': 'Custom::Test'} revent = rc._redact(event) self.assertIn('ResponseURL', event) @@ -339,55 +380,71 @@ def test_redactResponseURL(self): def test_blacklist(self): rc = StandaloneRedactionConfig(self.ruleSetDefault, redactMode=RedactMode.BLACKLIST) - event = { 'ResourceType' : 'Custom::Test', - 'ResourceProperties' : {'Test' : NOT_REDACTED_STRING, - 'Example' : NOT_REDACTED_STRING, - 'Custom' : NOT_REDACTED_STRING, - 'DeleteMe1': NOT_REDACTED_STRING, - 'DeleteMe2': NOT_REDACTED_STRING, - 'DoNotDelete' : NOT_REDACTED_STRING }} + event = {'RequestType' : 'Create', + 'RequestId' : 'abcded', + 'ResponseURL' : 'https://localhost', + 'StackId': 'arn:...', + 'LogicalResourceId': 'Test', + 'ResourceType': 'Custom::Test', + 'ResourceProperties': {'Test': NOT_REDACTED_STRING, + 'Example': NOT_REDACTED_STRING, + 'Custom': NOT_REDACTED_STRING, + 'DeleteMe1': NOT_REDACTED_STRING, + 'DeleteMe2': NOT_REDACTED_STRING, + 'DoNotDelete': NOT_REDACTED_STRING}} revent = rc._redact(event) - self.assertEqual(event['ResourceProperties']['Test'],NOT_REDACTED_STRING) - self.assertEqual(revent['ResourceProperties']['Test'],REDACTED_STRING) - self.assertEqual(event['ResourceProperties']['Example'],NOT_REDACTED_STRING) - self.assertEqual(revent['ResourceProperties']['Example'],REDACTED_STRING) - self.assertEqual(event['ResourceProperties']['Custom'],NOT_REDACTED_STRING) - self.assertEqual(revent['ResourceProperties']['Custom'],NOT_REDACTED_STRING) - self.assertEqual(event['ResourceProperties']['DeleteMe1'],NOT_REDACTED_STRING) - self.assertEqual(revent['ResourceProperties']['DeleteMe1'],NOT_REDACTED_STRING) - self.assertEqual(event['ResourceProperties']['DeleteMe2'],NOT_REDACTED_STRING) - self.assertEqual(revent['ResourceProperties']['DeleteMe2'],NOT_REDACTED_STRING) - self.assertEqual(event['ResourceProperties']['DoNotDelete'],NOT_REDACTED_STRING) - self.assertEqual(revent['ResourceProperties']['DoNotDelete'],NOT_REDACTED_STRING) + self.assertEqual(event['ResourceProperties']['Test'], NOT_REDACTED_STRING) + self.assertEqual(revent['ResourceProperties']['Test'], REDACTED_STRING) + self.assertEqual(event['ResourceProperties']['Example'], NOT_REDACTED_STRING) + self.assertEqual(revent['ResourceProperties']['Example'], REDACTED_STRING) + self.assertEqual(event['ResourceProperties']['Custom'], NOT_REDACTED_STRING) + self.assertEqual(revent['ResourceProperties']['Custom'], NOT_REDACTED_STRING) + self.assertEqual(event['ResourceProperties']['DeleteMe1'], NOT_REDACTED_STRING) + self.assertEqual(revent['ResourceProperties']['DeleteMe1'], NOT_REDACTED_STRING) + self.assertEqual(event['ResourceProperties']['DeleteMe2'], NOT_REDACTED_STRING) + self.assertEqual(revent['ResourceProperties']['DeleteMe2'], NOT_REDACTED_STRING) + self.assertEqual(event['ResourceProperties']['DoNotDelete'], NOT_REDACTED_STRING) + self.assertEqual(revent['ResourceProperties']['DoNotDelete'], NOT_REDACTED_STRING) def test_whitelist(self): rc = StandaloneRedactionConfig(self.ruleSetDefault, redactMode=RedactMode.WHITELIST) - event = { 'ResourceType' : 'Custom::Hello', - 'ResourceProperties' : {'Test' : NOT_REDACTED_STRING, - 'Example' : NOT_REDACTED_STRING, - 'Custom' : NOT_REDACTED_STRING, - 'DeleteMe1': NOT_REDACTED_STRING, - 'DeleteMe2': NOT_REDACTED_STRING, - 'DoNotDelete' : NOT_REDACTED_STRING }} + event = {'RequestType' : 'Create', + 'RequestId' : 'abcded', + 'ResponseURL' : 'https://localhost', + 'StackId': 'arn:...', + 'LogicalResourceId': 'Test', + 'ResourceType': 'Custom::Hello', + 'ResourceProperties': {'Test': NOT_REDACTED_STRING, + 'Example': NOT_REDACTED_STRING, + 'Custom': NOT_REDACTED_STRING, + 'DeleteMe1': NOT_REDACTED_STRING, + 'DeleteMe2': NOT_REDACTED_STRING, + 'DoNotDelete': NOT_REDACTED_STRING}} revent = rc._redact(event) - self.assertEqual(event['ResourceProperties']['Test'],NOT_REDACTED_STRING) - self.assertEqual(revent['ResourceProperties']['Test'],NOT_REDACTED_STRING) - self.assertEqual(event['ResourceProperties']['Example'],NOT_REDACTED_STRING) - self.assertEqual(revent['ResourceProperties']['Example'],NOT_REDACTED_STRING) - self.assertEqual(event['ResourceProperties']['Custom'],NOT_REDACTED_STRING) - self.assertEqual(revent['ResourceProperties']['Custom'],REDACTED_STRING) - self.assertEqual(event['ResourceProperties']['DeleteMe1'],NOT_REDACTED_STRING) - self.assertEqual(revent['ResourceProperties']['DeleteMe1'],REDACTED_STRING) - self.assertEqual(event['ResourceProperties']['DeleteMe2'],NOT_REDACTED_STRING) - self.assertEqual(revent['ResourceProperties']['DeleteMe2'],REDACTED_STRING) - self.assertEqual(event['ResourceProperties']['DoNotDelete'],NOT_REDACTED_STRING) - self.assertEqual(revent['ResourceProperties']['DoNotDelete'],REDACTED_STRING) + self.assertEqual(event['ResourceProperties']['Test'], NOT_REDACTED_STRING) + self.assertEqual(revent['ResourceProperties']['Test'], NOT_REDACTED_STRING) + self.assertEqual(event['ResourceProperties']['Example'], NOT_REDACTED_STRING) + self.assertEqual(revent['ResourceProperties']['Example'], NOT_REDACTED_STRING) + self.assertEqual(event['ResourceProperties']['Custom'], NOT_REDACTED_STRING) + self.assertEqual(revent['ResourceProperties']['Custom'], REDACTED_STRING) + self.assertEqual(event['ResourceProperties']['DeleteMe1'], NOT_REDACTED_STRING) + self.assertEqual(revent['ResourceProperties']['DeleteMe1'], REDACTED_STRING) + self.assertEqual(event['ResourceProperties']['DeleteMe2'], NOT_REDACTED_STRING) + self.assertEqual(revent['ResourceProperties']['DeleteMe2'], REDACTED_STRING) + self.assertEqual(event['ResourceProperties']['DoNotDelete'], NOT_REDACTED_STRING) + self.assertEqual(revent['ResourceProperties']['DoNotDelete'], REDACTED_STRING) def test_oldproperties(self): rc = StandaloneRedactionConfig(self.ruleSetDefault, redactMode=RedactMode.WHITELIST) - event = {'ResourceType': 'Custom::Hello', + event = {'RequestType' : 'Update', + 'RequestId' : 'abcded', + 'ResponseURL' : 'https://localhost', + 'StackId': 'arn:...', + 'LogicalResourceId': 'Test', + 'PhysicalResourceId' : 'Test', + 'ResourceType': 'Custom::Hello', 'ResourceProperties': {'Test': NOT_REDACTED_STRING, 'Example': NOT_REDACTED_STRING, 'Custom': NOT_REDACTED_STRING, @@ -395,11 +452,11 @@ def test_oldproperties(self): 'DeleteMe2': NOT_REDACTED_STRING, 'DoNotDelete': NOT_REDACTED_STRING}, 'OldResourceProperties': {'Test': NOT_REDACTED_STRING, - 'Example': NOT_REDACTED_STRING, - 'Custom': NOT_REDACTED_STRING, - 'DeleteMe1': NOT_REDACTED_STRING, - 'DeleteMe2': NOT_REDACTED_STRING, - 'DoNotDelete': NOT_REDACTED_STRING}} + 'Example': NOT_REDACTED_STRING, + 'Custom': NOT_REDACTED_STRING, + 'DeleteMe1': NOT_REDACTED_STRING, + 'DeleteMe2': NOT_REDACTED_STRING, + 'DoNotDelete': NOT_REDACTED_STRING}} revent = rc._redact(event) self.assertEqual(event['ResourceProperties']['Test'], NOT_REDACTED_STRING) @@ -428,5 +485,6 @@ def test_oldproperties(self): self.assertEqual(event['OldResourceProperties']['DoNotDelete'], NOT_REDACTED_STRING) self.assertEqual(revent['OldResourceProperties']['DoNotDelete'], REDACTED_STRING) + if __name__ == '__main__': umain() diff --git a/accustom/__init__.py b/accustom/__init__.py index 6b04b6e..2a450f1 100644 --- a/accustom/__init__.py +++ b/accustom/__init__.py @@ -4,6 +4,7 @@ from .constants import RedactMode from .response import ResponseObject from .response import cfnresponse +from .response import is_valid_event from .decorators import decorator from .decorators import rdecorator from .decorators import sdecorator diff --git a/accustom/decorators.py b/accustom/decorators.py index 0633742..9a95916 100644 --- a/accustom/decorators.py +++ b/accustom/decorators.py @@ -8,11 +8,15 @@ from .Exceptions import FailedToSendResponseException from .Exceptions import DataIsNotDictException from .Exceptions import InvalidResponseStatusException +from .Exceptions import NotValidRequestObjectException # Constants from .constants import RequestType from .constants import Status +# Response +from .response import is_valid_event + # RedactionConfig from .redaction import RedactionConfig from .redaction import StandaloneRedactionConfig @@ -24,35 +28,38 @@ from uuid import uuid4 from .response import ResponseObject import six -from boto3 import client as bclient +from boto3 import client from botocore.client import Config from botocore.vendored import requests logger = logging.getLogger(__name__) # Time in milliseconds to set the alarm for (in milliseconds) +# Should be set to twice the worst case response time to send to S3 +# Setting to 4 seconds for safety TIMEOUT_THRESHOLD = 4000 -def decorator(enforceUseOfClass=False, hideResourceDeleteFailure=False, redactConfig=None, timeoutFunction=False, - redactConfg=None): + +def decorator(enforceUseOfClass: bool = False, hideResourceDeleteFailure: bool = False, + redactConfig: RedactionConfig = None, timeoutFunction: bool = False): """Decorate a function to add exception handling and emit CloudFormation responses. Usage with Lambda: - >>> import accustom - >>> @accustom.decorator() - ... def function_handler(event, context) - ... sum = (float(event['ResourceProperties']['key1']) + - ... float(event['ResourceProperties']['key2'])) - ... return { 'sum' : sum } + import accustom + @accustom.decorator() + def function_handler(event, context) + sum = (float(event['ResourceProperties']['key1']) + + float(event['ResourceProperties']['key2'])) + return { 'sum' : sum } Usage outside Lambda: - >>> import accustom - >>> @accustom.decorator() - ... def function_handler(event) - ... sum = (float(event['ResourceProperties']['key1']) + - ... float(event['ResourceProperties']['key2'])) - ... r = accustom.ResponseObject(data={'sum':sum},physicalResourceId='abc') - ... return r + import accustom + @accustom.decorator() + def function_handler(event) + sum = (float(event['ResourceProperties']['key1']) + + float(event['ResourceProperties']['key2'])) + r = accustom.ResponseObject(data={'sum':sum},physicalResourceId='abc') + return r Args: enforceUseOfClass (boolean): When true send a FAILED signal if a ResponseObject class is not utilised. @@ -64,78 +71,88 @@ def decorator(enforceUseOfClass=False, hideResourceDeleteFailure=False, redactCo provided that this function is executed in Lambda Returns: - The response object sent to CloudFormation + dict: The response object sent to CloudFormation Raises: - FailedToSendResponseException - :param redactConfg: + FailedToSendResponseException + NotValidRequestObjectException + + Decorated Function Arguments: + event (dict): The request object being processed (Required). + context (dict): The Lambda context of this execution (optional) """ def inner_decorator(func): @wraps(func) - def handler_wrapper(event, lambdaContext=None): + def handler_wrapper(event: dict, context: dict = None): nonlocal redactConfig nonlocal timeoutFunction logger.info('Request received, processing...') + if not is_valid_event(event): + # If it is not a valid event we need to raise an exception + message = 'The event object passed is not a valid Request Object as per ' + \ + 'https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/crpg-ref-requests.html' + logger.error(message) + raise NotValidRequestObjectException(message) # Timeout Function Handler if 'LambdaParentRequestId' in event: logger.info('This request has been invoked as a child, for parent logs please see request ID: %s' % event['LambdaParentRequestId']) - elif lambdaContext is None and timeoutFunction: + elif context is None and timeoutFunction: logger.warning('You cannot use the timeoutFunction option outside of Lambda. To suppress this warning' + ', set timeoutFunction to False') elif timeoutFunction: # Attempt to invoke the function. Depending on the error we get may continue execution or return logger.info('Request has been invoked in Lambda with timeoutFunction set, attempting to invoke self') pevent = event.copy() - pevent['LambdaParentRequestId'] = lambdaContext.aws_request_id - payload = json.dumps(pevent).encode('UTF-8') - timeout = (lambdaContext.get_remaining_time_in_millis() - TIMEOUT_THRESHOLD) / 1000 + pevent['LambdaParentRequestId'] = context.aws_request_id + payload = json.dumps(pevent).encode('utf-8') + timeout = (context.get_remaining_time_in_millis() - TIMEOUT_THRESHOLD) / 1000 # Edge case where time is set to very low timeout, use half the timeout threshold as the timeout for the # the Lambda Function if timeout <= 0: timeout = TIMEOUT_THRESHOLD / 2000 config = Config(connect_timeout=2, read_timeout=timeout, retries={'max_attempts': 0}) - blambda = bclient('lambda', config=config) + b_lambda = client('lambda', config=config) # Normally we would just do a catch all error handler but in this case we want to be paranoid try: - response = blambda.invoke(FunctionName=lambdaContext.invoked_function_arn, - InvocationType='RequestResponse', Payload=payload) + response = b_lambda.invoke(FunctionName=context.invoked_function_arn, + InvocationType='RequestResponse', Payload=payload) # Further checks if 'FunctionError' in response: - response.get('Payload',''.encode('UTF-8')) + response.get('Payload', ''.encode('UTF-8')) message = 'Invocation got an error: %s' % payload.decode() logger.error(message) - return ResponseObject(reason=message, responseStatus=Status.FAILED).send(event, lambdaContext) + return ResponseObject(reason=message, responseStatus=Status.FAILED).send(event, context) else: # In this case the function returned without error which means we can assume the chained # invokation sent a response, so we do not have too. logger.info('Compeleted execution of chained invocation, returning payload') - response.get('Payload',''.encode('UTF-8')) + response.get('Payload', ''.encode('UTF-8')) return payload.decode() - except (bclient.exceptions.EC2AccessDeniedException, bclient.exceptions.KMSAccessDeniedException, - bclient.exceptions.KMSDisabledException) as e: + except (client.exceptions.EC2AccessDeniedException, client.exceptions.KMSAccessDeniedException, + client.exceptions.KMSDisabledException) as e: logger.warning('Caught exception %s while trying to invoke function. Running handler locally.' % str(e)) logger.warning('You cannot use the timeoutFunction option without the ability for the function to' + ' invoke itself. To suppress this warning, set timeoutFunction to False') - except (bclient.exceptions.EC2ThrottledException, bclient.exceptions.ENILimitReachedException, - bclient.exceptions.TooManyRequestsException, - bclient.exceptions.SubnetIPAddressLimitReachedException) as e: + except (client.exceptions.EC2ThrottledException, client.exceptions.ENILimitReachedException, + client.exceptions.TooManyRequestsException, + client.exceptions.SubnetIPAddressLimitReachedException) as e: logger.error('Caught exception %s while trying to invoke function. Running handler locally.' % str(e)) logger.error('You should make sure you have enough capacity and high enough limits to execute the' + ' chained function.') - except (bclient.exceptions.EC2UnexpectedException, bclient.exceptions.InvalidParameterValueException, - bclient.exceptions.InvalidRequestContentException, bclient.exceptions.InvalidRuntimeException, - bclient.exceptions.InvalidSecurityGroupIDException, bclient.exceptions.InvalidSubnetIDException, - bclient.exceptions.InvalidZipFileException, bclient.exceptions.KMSInvalidStateException, - bclient.exceptions.KMSNotFoundException, bclient.exceptions.RequestTooLargeException, - bclient.exceptions.ResourceNotFoundException, bclient.exceptions.ServiceException, - bclient.exceptions.UnsupportedMediaTypeException) as e: + except (client.exceptions.EC2UnexpectedException, client.exceptions.InvalidParameterValueException, + client.exceptions.InvalidRequestContentException, client.exceptions.InvalidRuntimeException, + client.exceptions.InvalidSecurityGroupIDException, client.exceptions.InvalidSubnetIDException, + client.exceptions.InvalidZipFileException, client.exceptions.KMSInvalidStateException, + client.exceptions.KMSNotFoundException, client.exceptions.RequestTooLargeException, + client.exceptions.ResourceNotFoundException, client.exceptions.ServiceException, + client.exceptions.UnsupportedMediaTypeException) as e: logger.error('Caught exception %s while trying to invoke function. Running handler locally.' % str(e)) except requests.exceptions.ConnectionError as e: @@ -147,18 +164,18 @@ def handler_wrapper(event, lambdaContext=None): # This should be a critical failure logger.error('Waited the read timeout and function did not return, returning an error') return ResponseObject(reason='Lambda function timed out, returning failure.', - responseStatus=Status.FAILED).send(event, lambdaContext) + responseStatus=Status.FAILED).send(event, context) except Exception as e: message = 'Got an exception I did not understand while trying to invoke child function: %s' % str(e) logger.error(message) - return ResponseObject(reason=message, responseStatus=Status.FAILED).send(event, lambdaContext) + return ResponseObject(reason=message, responseStatus=Status.FAILED).send(event, context) # Debug Logging Handler if logger.getEffectiveLevel() <= logging.DEBUG: - if lambdaContext is not None: - logger.debug('Running request with Lambda RequestId: %s' % lambdaContext.aws_request_id) + if context is not None: + logger.debug('Running request with Lambda RequestId: %s' % context.aws_request_id) if redactConfig is not None and isinstance(redactConfig, (StandaloneRedactionConfig, RedactionConfig)): - logger.debug('Request Body:\n' + json.dumps(redactConfig._redact(event))) + logger.debug('Request Body:\n' + json.dumps(redactConfig._redact(event))).encode('utf-8') elif redactConfig is not None: logger.warning('A non valid RedactionConfig was provided, and ignored') logger.debug('Request Body:\n' + json.dumps(event)) @@ -168,36 +185,38 @@ def handler_wrapper(event, lambdaContext=None): try: logger.info('Running CloudFormation request %s for stack: %s' % (event['RequestId'], event['StackId'])) # Run the function - if lambdaContext is not None: result = func(event, lambdaContext) - else: result = func(event) + if context is not None: + result = func(event, context) + else: + result = func(event) except Exception as e: # If there was an exception thrown by the function, send a failure response result = ResponseObject( - physicalResourceId=str(uuid4()) if lambdaContext is None else None, - reason='Function %s failed due to exception "%s"' % (func.__name__, str(e)), - responseStatus=Status.FAILED) + physicalResourceId=str(uuid4()) if context is None else None, + reason='Function %s failed due to exception "%s"' % (func.__name__, str(e)), + responseStatus=Status.FAILED) logger.error(result.reason) if not isinstance(result, ResponseObject): # If a ResponseObject is not provided, work out what kind of response object to pass, or return a # failure if it is an invalid response type, or if the enforceUseOfClass is explicitly or implicitly set - if lambdaContext is None: + if context is None: result = ResponseObject( - reason='Response Object of type %s was not a ResponseObject and there is no Lambda Context' - % result.__class__, - responseStatus=Status.FAILED) + reason='Response Object of type %s was not a ResponseObject and there is no Lambda Context' + % result.__class__, + responseStatus=Status.FAILED) logger.error(result.reason) elif enforceUseOfClass: result = ResponseObject( - reason='Response Object of type %s was not a ResponseObject instance and ' + - 'enforceUseOfClass set to true' % result.__class__, - responseStatus=Status.FAILED) + reason='Response Object of type %s was not a ResponseObject instance and ' + + 'enforceUseOfClass set to true' % result.__class__, + responseStatus=Status.FAILED) logger.error(result.reason) elif result is False: result = ResponseObject( - reason='Function %s returned False.' % func.__name__, - responseStatus=Status.FAILED) + reason='Function %s returned False.' % func.__name__, + responseStatus=Status.FAILED) logger.debug(result.reason) elif isinstance(result, dict): result = ResponseObject(data=result) @@ -207,80 +226,82 @@ def handler_wrapper(event, lambdaContext=None): result = ResponseObject() else: result = ResponseObject( - reason='Return value from Function %s is of unsupported type %s' % (func.__name__, - result.__class__), - responseStatus=Status.FAILED) + reason='Return value from Function %s is of unsupported type %s' % (func.__name__, + result.__class__), + responseStatus=Status.FAILED) logger.error(result.reason) # This block will hide resources on delete failure if the flag is set to true if event['RequestType'] == RequestType.DELETE and result.responseStatus == Status.FAILED \ and hideResourceDeleteFailure: - logger.warning('Hiding Resource DELETE request failure') - if result.data is not None: - if not result.squashPrintResponse: - logger.debug('Data:\n' + json.dumps(result.data)) - else: - logger.debug('Data: [REDACTED]') - if result.reason is not None: logger.debug('Reason: %s' % result.reason) - if result.physicalResourceId is not None: logger.debug('PhysicalResourceId: %s' - % result.physicalResourceId) - result = ResponseObject( - reason='There may be resources created by this Custom Resource that have not been cleaned' + - 'up despite the fact this resource is in DELETE_COMPLETE', - physicalResourceId=result.physicalResourceId, - responseStatus=Status.SUCCESS) + logger.warning('Hiding Resource DELETE request failure') + if result.data is not None: + if not result.squashPrintResponse: + logger.debug('Data:\n' + json.dumps(result.data)) + else: + logger.debug('Data: [REDACTED]') + if result.reason is not None: logger.debug('Reason: %s' % result.reason) + if result.physicalResourceId is not None: logger.debug('PhysicalResourceId: %s' + % result.physicalResourceId) + result = ResponseObject( + reason='There may be resources created by this Custom Resource that have not been cleaned' + + 'up despite the fact this resource is in DELETE_COMPLETE', + physicalResourceId=result.physicalResourceId, + responseStatus=Status.SUCCESS) try: - return_value = result.send(event, lambdaContext) + return_value = result.send(event, context) except Exception as e: if isinstance(e, FailedToSendResponseException): raise e logger.error('Malformed request, Exception: %s' % str(e)) if result.data is not None and not isinstance(e, DataIsNotDictException): - if not result.squashPrintResponse: - logger.debug('Data:\n' + json.dumps(result.data)) - else: - logger.debug('Data: [REDACTED]') + if not result.squashPrintResponse: + logger.debug('Data:\n' + json.dumps(result.data)) + else: + logger.debug('Data: [REDACTED]') if result.reason is not None: logger.debug('Reason: %s' % result.reason) if result.physicalResourceId is not None: logger.debug('PhysicalResourceId: %s' % result.physicalResourceId) if not isinstance(e, InvalidResponseStatusException): logger.debug('Status: %s' % result.responseStatus) result = ResponseObject( - reason='Malformed request, Exception: %s' % str(e), - physicalResourceId=result.physicalResourceId, - responseStatus=Status.FAILED) - return_value = result.send(event, lambdaContext) + reason='Malformed request, Exception: %s' % str(e), + physicalResourceId=result.physicalResourceId, + responseStatus=Status.FAILED) + return_value = result.send(event, context) return return_value + return handler_wrapper + return inner_decorator -def rdecorator(decoratorHandleDelete=False, expectedProperties=None, genUUID=True): +def rdecorator(decoratorHandleDelete: bool = False, expectedProperties: list = None, genUUID: bool = True): """Decorate a function to add input validation for resource handler functions. Usage with Lambda: - >>> import accustom - >>> @accustom.rdecorator(expectedProperties=['key1','key2'],genUUID=False) - ... def resource_function(event, context): - ... sum = (float(event['ResourceProperties']['key1']) + - ... float(event['ResourceProperties']['key2'])) - ... return { 'sum' : sum } - >>> @accustom.decorator() - ... def function_handler(event, context) - ... return resource_function(event,context) + import accustom + @accustom.rdecorator(expectedProperties=['key1','key2'],genUUID=False) + def resource_function(event, context): + sum = (float(event['ResourceProperties']['key1']) + + float(event['ResourceProperties']['key2'])) + return { 'sum' : sum } + @accustom.decorator() + def function_handler(event, context) + return resource_function(event,context) Usage outside Lambda: - >>> import accustom - >>> @accustom.rdecorator(expectedProperties=['key1','key2']) - ... def resource_function(event, context=None) - ... sum = (float(event['ResourceProperties']['key1']) + - ... float(event['ResourceProperties']['key2'])) - ... r = accustom.ResponseObject(data={'sum':sum},physicalResourceId=event['PhysicalResourceId']) - ... return r - >>> @accustom.decorator() - ... def function_handler(event) - ... return resource_function(event) + import accustom + @accustom.rdecorator(expectedProperties=['key1','key2']) + def resource_function(event, context=None) + sum = (float(event['ResourceProperties']['key1']) + + float(event['ResourceProperties']['key2'])) + r = accustom.ResponseObject(data={'sum':sum},physicalResourceId=event['PhysicalResourceId']) + return r + @accustom.decorator() + def function_handler(event) + return resource_function(event) Args: decoratorHandleDelete (boolean): When set to true, if a delete request is made in event the decorator will @@ -294,12 +315,23 @@ def rdecorator(decoratorHandleDelete=False, expectedProperties=None, genUUID=Tru The result of the decorated function, or a ResponseObject with SUCCESS depending on the event and flags. Raises: + NotValidRequestObjectException Any exception raised by the decorated function. + + Decorated Function Arguments: + event (dict): The request object being processed (Required). """ + def resource_decorator_inner(func): @wraps(func) - def resource_decorator_handler(event, context=None): + def resource_decorator_handler(event: dict, *args, **kwargs): + if not is_valid_event(event): + # If it is not a valid event we need to raise an exception + message = 'The event object passed is not a valid Request Object as per ' + \ + 'https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/crpg-ref-requests.html' + logger.error(message) + raise NotValidRequestObjectException(message) logger.info('Supported resource %s' % event['ResourceType']) # Set the Physical Resource ID to a randomly generated UUID if it is not present @@ -318,7 +350,7 @@ def resource_decorator_handler(event, context=None): if item not in event['ResourceProperties']: err_msg = 'Property %s missing, sending failure signal' % item logger.info(err_msg) - return ResponseObject(reason=err_msg,responseStatus=Status.FAILED, + return ResponseObject(reason=err_msg, responseStatus=Status.FAILED, physicalResourceId=event['PhysicalResourceId']) # If a list or tuple was not provided then log a warning @@ -326,33 +358,36 @@ def resource_decorator_handler(event, context=None): logger.warning('expectedProperties passed to decorator is not a list, properties were not validated.') # Pre-validation complete, calling function - return func(event, context) + return func(event, *args, **kwargs) + return resource_decorator_handler + return resource_decorator_inner -def sdecorator(decoratorHandleDelete=False, expectedProperties=None, genUUID=True, enforceUseOfClass=False, - hideResourceDeleteFailure=False, redactConfig=None, timeoutFunction=True): +def sdecorator(decoratorHandleDelete: bool = False, expectedProperties: list = None, genUUID: bool = True, + enforceUseOfClass: bool = False, hideResourceDeleteFailure: bool = False, + redactConfig: RedactionConfig = None, timeoutFunction: bool = True): """Decorate a function to add input validation for resource handler functions, exception handling and send CloudFormation responses. Usage with Lambda: - >>> import accustom - >>> @accustom.sdecorator(expectedProperties=['key1','key2'],genUUID=False) - ... def resource_handler(event, context): - ... sum = (float(event['ResourceProperties']['key1']) + - ... float(event['ResourceProperties']['key2'])) - ... return { 'sum' : sum } + import accustom + @accustom.sdecorator(expectedProperties=['key1','key2'],genUUID=False) + def resource_handler(event, context): + sum = (float(event['ResourceProperties']['key1']) + + float(event['ResourceProperties']['key2'])) + return { 'sum' : sum } Usage outside Lambda: - >>> import accustom - >>> @accustom.sdecorator(expectedProperties=['key1','key2']) - ... def resource_handler(event, context=None) - ... sum = (float(event['ResourceProperties']['key1']) + - ... float(event['ResourceProperties']['key2'])) - ... r = accustom.ResponseObject(data={'sum':sum},physicalResourceId=event['PhysicalResourceId']) - ... return r + import accustom + @accustom.sdecorator(expectedProperties=['key1','key2']) + def resource_handler(event, context=None) + sum = (float(event['ResourceProperties']['key1']) + + float(event['ResourceProperties']['key2'])) + r = accustom.ResponseObject(data={'sum':sum},physicalResourceId=event['PhysicalResourceId']) + return r Args: decoratorHandleDelete (boolean): When set to true, if a delete request is made in event the decorator will @@ -374,6 +409,7 @@ def sdecorator(decoratorHandleDelete=False, expectedProperties=None, genUUID=Tru Raises: FailedToSendResponseException + NotValidRequestObjectException """ if not isinstance(redactConfig, StandaloneRedactionConfig) and logger.getEffectiveLevel() <= logging.DEBUG: logger.warning('A non valid StandaloneRedactionConfig was provided, and ignored') @@ -382,9 +418,11 @@ def sdecorator(decoratorHandleDelete=False, expectedProperties=None, genUUID=Tru def standalone_decorator_inner(func): @wraps(func) @decorator(enforceUseOfClass=enforceUseOfClass, hideResourceDeleteFailure=hideResourceDeleteFailure, - redactConfg=redactConfig, timeoutFunction=timeoutFunction) + redactConfig=redactConfig, timeoutFunction=timeoutFunction) @rdecorator(decoratorHandleDelete=decoratorHandleDelete, expectedProperties=expectedProperties, genUUID=genUUID) - def standalone_decorator_handler(event, lambdaContext=None): - return func(event, lambdaContext) + def standalone_decorator_handler(event: dict, context: dict = None): + return func(event, context) + return standalone_decorator_handler + return standalone_decorator_inner diff --git a/accustom/redaction.py b/accustom/redaction.py index 4a29521..9f5d7d7 100644 --- a/accustom/redaction.py +++ b/accustom/redaction.py @@ -3,9 +3,11 @@ This allows you to define a redaction policy for accustom """ +from .response import is_valid_event from .constants import RedactMode from .Exceptions import ConflictingValue from .Exceptions import CannotApplyRuleToStandaloneRedactionConfig +from .Exceptions import NotValidRequestObjectException import logging import six @@ -15,12 +17,13 @@ logger = logging.getLogger(__name__) _RESOURCEREGEX_DEFAULT = '^.*$' -REDACTED_STRING='[REDACTED]' +REDACTED_STRING = '[REDACTED]' class RedactionRuleSet(object): """Class that allows you to define a redaction rule set for accustom""" - def __init__(self, resourceRegex=_RESOURCEREGEX_DEFAULT): + + def __init__(self, resourceRegex: str = _RESOURCEREGEX_DEFAULT): """Init function for the class Args: @@ -36,7 +39,7 @@ def __init__(self, resourceRegex=_RESOURCEREGEX_DEFAULT): self.resourceRegex = resourceRegex self._properties = [] - def addPropertyRegex(self, propertiesRegex): + def add_property_regex(self, propertiesRegex : str): """Allows you to add a property regex to whitelist/blacklist Args: @@ -50,7 +53,7 @@ def addPropertyRegex(self, propertiesRegex): raise TypeError('propertiesRegex must be a string') self._properties.append(propertiesRegex) - def addProperty(self, propertyName): + def add_property(self, propertyName: str): """Allows you to add a specific property to whitelist/blacklist Args: @@ -67,7 +70,8 @@ def addProperty(self, propertyName): class RedactionConfig(object): """Class that allows you define a redaction policy for accustom""" - def __init__(self, redactMode=RedactMode.BLACKLIST, redactResponseURL=False): + + def __init__(self, redactMode: str = RedactMode.BLACKLIST, redactResponseURL: bool = False): """Init function for the class Args: @@ -90,7 +94,7 @@ def __init__(self, redactMode=RedactMode.BLACKLIST, redactResponseURL=False): self.redactResponseURL = redactResponseURL self._redactProperties = {} - def addRuleSet(self, ruleSet): + def add_rule_set(self, ruleSet: RedactionRuleSet): """ This function will add a RedactionRuleSet object to the RedactionConfig. Args: @@ -109,11 +113,17 @@ def addRuleSet(self, ruleSet): self._redactProperties[ruleSet.resourceRegex] = ruleSet._properties - def _redact(self, event): + def _redact(self, event: dict): """ Internal Function. Not to be consumed outside of accustom Library. This function will take in an event and return the event redacted as per the redaction config. """ + if not is_valid_event(event): + # If it is not a valid event we need to raise an exception + message = 'The event object passed is not a valid Request Object as per ' + \ + 'https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/crpg-ref-requests.html' + logger.error(message) + raise NotValidRequestObjectException(message) ec = copy.deepcopy(event) if self.redactMode == RedactMode.WHITELIST: if 'ResourceProperties' in ec: ec['ResourceProperties'] = {} @@ -149,14 +159,15 @@ def _redact(self, event): return ec def __str__(self): - return 'RedactionConfg(%s)' % self.redactMode + return 'RedactionConfig(%s)' % self.redactMode def __repr__(self): return str(self) class StandaloneRedactionConfig(RedactionConfig): - def __init__(self, ruleSet, redactMode=RedactMode.BLACKLIST, redactResponseURL=False): + def __init__(self, ruleSet: RedactionRuleSet, redactMode: str = RedactMode.BLACKLIST, + redactResponseURL: bool = False): """Init function for the class Args: @@ -171,13 +182,13 @@ def __init__(self, ruleSet, redactMode=RedactMode.BLACKLIST, redactResponseURL=F """ RedactionConfig.__init__(self, redactMode=redactMode, redactResponseURL=redactResponseURL) - ruleSet.resourceRegex=_RESOURCEREGEX_DEFAULT - # override resource regex to be default - assert(ruleSet is not None) - RedactionConfig.addRuleSet(self, ruleSet) + ruleSet.resourceRegex = _RESOURCEREGEX_DEFAULT + # override resource regex to be default + assert (ruleSet is not None) + RedactionConfig.add_rule_set(self, ruleSet) - def addRuleSet(self, ruleSet): - """ Overrides the addRuleSet operation with one that will immediately throw an exception + def add_rule_set(self, ruleSet: RedactionRuleSet): + """ Overrides the add_rule_set operation with one that will immediately throw an exception Raises CannotApplyRuleToStandaloneRedactionConfig diff --git a/accustom/response.py b/accustom/response.py index 49709cc..8eb2423 100644 --- a/accustom/response.py +++ b/accustom/response.py @@ -9,22 +9,65 @@ from .Exceptions import NoPhysicalResourceIdException from .Exceptions import InvalidResponseStatusException from .Exceptions import FailedToSendResponseException +from .Exceptions import NotValidRequestObjectException # Constants from .constants import Status +from .constants import RequestType # Required Libraries import json import logging import sys import six +from urllib.parse import urlparse from botocore.vendored import requests logger = logging.getLogger(__name__) -def cfnresponse(event, responseStatus, responseReason=None, responseData=None, physicalResourceId=None, - lambdaContext=None, squashPrintResponse=False): +def is_valid_event(event: dict) -> bool: + """This function takes in a CloudFormation Request Object and checks for the required fields as per: + https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/crpg-ref-requests.html + + Args: + event (dict): The request object being processed. + + Returns: + bool: If the request object is a valid request object + + """ + if not all(v in event for v in [ + 'RequestType', + 'ResponseURL', + 'StackId', + 'RequestId', + 'ResourceType', + 'LogicalResourceId' + ]): + # Check we have all the required fields + return False + + if event['RequestType'] not in [RequestType.CREATE, RequestType.DELETE, RequestType.UPDATE]: + # Check if the request type is a valid request type + return False + + scheme = urlparse(event['ResponseURL']).scheme + if scheme == '' or scheme not in ('http', 'https'): + # Check if the URL appears to be a valid HTTP or HTTPS URL + # Technically it should always be an HTTPS URL but hedging bets for testing to allow http + return False + + if event['RequestType'] in [RequestType.UPDATE, RequestType.DELETE] and 'PhysicalResourceId' not in event: + # If it is an Update or Delete request there needs to be a PhysicalResourceId key + return False + + # All checks passed + return True + + +def cfnresponse(event: dict, responseStatus: str, responseReason: str = None, responseData: dict = None, + physicalResourceId: str = None, context: dict = None, squashPrintResponse: bool = False): """Format and send CloudFormation Custom Resource Objects This section is derived off the cfnresponse source code provided by Amazon: @@ -40,13 +83,13 @@ def cfnresponse(event, responseStatus, responseReason=None, responseData=None, p on FAILED this is given as reason overriding responseReason. responseReason (str): The reason for this result. physicalResourceId (str): The PhysicalResourceID to be sent back to CloudFormation - lambdaContext (context object): Can be used in lieu of a PhysicalResourceId to use the Lambda Context to derive + context (context object): Can be used in lieu of a PhysicalResourceId to use the Lambda Context to derive an ID. squashPrintResponse (boolean): When logging set to debug and this is set to False, it will print the response (defaults to False). If set to True this will also send the response with NoEcho set to True. - Note that either physicalResourceId or lambdaContext must be defined, and physicalResourceId supersedes - lambdaContext + Note that either physicalResourceId or context must be defined, and physicalResourceId supersedes + context Returns: Dictionary of Response Sent @@ -56,10 +99,18 @@ def cfnresponse(event, responseStatus, responseReason=None, responseData=None, p InvalidResponseStatusException DataIsNotDictException FailedToSendResponseException + NotValidRequestObjectException """ - if physicalResourceId is None and lambdaContext is None and 'PhysicalResourceId' not in event: - raise NoPhysicalResourceIdException("Both physicalResourceId and lambdaContext are None, and there is no" + + if not is_valid_event(event): + # If it is not a valid event we need to raise an exception + message = 'The event object passed is not a valid Request Object as per ' + \ + 'https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/crpg-ref-requests.html' + logger.error(message) + raise NotValidRequestObjectException(message) + + if physicalResourceId is None and context is None and 'PhysicalResourceId' not in event: + raise NoPhysicalResourceIdException("Both physicalResourceId and context are None, and there is no" + "physicalResourceId in the event") if responseStatus != Status.FAILED and responseStatus != Status.SUCCESS: @@ -74,21 +125,20 @@ def cfnresponse(event, responseStatus, responseReason=None, responseData=None, p elif responseReason is None: responseReason = 'Unknown failure occurred' - if lambdaContext is not None: + if context is not None: responseReason = "%s -- See the details in CloudWatch Log Stream: %s" % (responseReason, - lambdaContext.log_stream_name) + context.log_stream_name) - elif lambdaContext is not None and responseReason is None: - responseReason = "See the details in CloudWatch Log Stream: %s" % lambdaContext.log_stream_name + elif context is not None and responseReason is None: + responseReason = "See the details in CloudWatch Log Stream: %s" % context.log_stream_name responseUrl = event['ResponseURL'] if physicalResourceId is None and 'PhysicalResourceId' in event: physicalResourceId = event['PhysicalResourceId'] - responseBody = {} - responseBody['Status'] = responseStatus + responseBody = {'Status': responseStatus} if responseReason is not None: responseBody['Reason'] = responseReason - responseBody['PhysicalResourceId'] = physicalResourceId or lambdaContext.log_stream_name + responseBody['PhysicalResourceId'] = physicalResourceId or context.log_stream_name responseBody['StackId'] = event['StackId'] responseBody['RequestId'] = event['RequestId'] responseBody['LogicalResourceId'] = event['LogicalResourceId'] @@ -119,7 +169,8 @@ def cfnresponse(event, responseStatus, responseReason=None, responseData=None, p # Exceptions will only be thrown on timeout or other errors, in order to catch an invalid # status code like 403 we will need to explicitly check the status code. In normal operation # we should get a "200 OK" response to our PUT. - message = "Unable to send response to URL, status code received: %d %s" % (response.status_code, response.reason) + message = "Unable to send response to URL, status code received: %d %s" % (response.status_code, + response.reason) logger.error(message) raise FailedToSendResponseException(message) logger.debug("Response status code: %d %s" % (response.status_code, response.reason)) @@ -137,8 +188,8 @@ def cfnresponse(event, responseStatus, responseReason=None, responseData=None, p class ResponseObject(object): """Class that allows you to init a ResponseObject for easy function writing""" - def __init__(self, data=None, physicalResourceId=None, reason=None, responseStatus=Status.SUCCESS, - squashPrintResponse=False): + def __init__(self, data: dict = None, physicalResourceId: str = None, reason: str = None, + responseStatus: str = Status.SUCCESS, squashPrintResponse: bool = False): """Init function for the class Args: @@ -176,7 +227,7 @@ def __init__(self, data=None, physicalResourceId=None, reason=None, responseStat self.responseStatus = responseStatus self.squashPrintResponse = squashPrintResponse - def send(self, event, lambdaContext=None): + def send(self, event: dict, context: dict = None): """Send this CloudFormation Custom Resource Object Creates a JSON payload that is sent back to the ResponseURL (pre-signed S3 URL) based upon this response object @@ -184,10 +235,10 @@ def send(self, event, lambdaContext=None): Args: event: A dict containing CloudFormation custom resource request field - lambdaContext: Can be used in lieu of a PhysicalResourceId to use the Lambda Context to derive an ID. + context: Can be used in lieu of a PhysicalResourceId to use the Lambda Context to derive an ID. - Note that either physicalResourceId in the object or lambdaContext must be defined, and physicalResourceId - supersedes lambdaContext + Note that either physicalResourceId in the object or context must be defined, and physicalResourceId + supersedes context Returns: Dictionary of Response Sent @@ -197,8 +248,9 @@ def send(self, event, lambdaContext=None): InvalidResponseStatusException DataIsNotDictException FailedToSendResponseException + NotValidRequestObjectException """ - return cfnresponse(event, self.responseStatus, self.reason, self.data, self.physicalResourceId, lambdaContext, + return cfnresponse(event, self.responseStatus, self.reason, self.data, self.physicalResourceId, context, self.squashPrintResponse) def __str__(self): diff --git a/requirements.txt b/requirements.txt index 1f1a35c..57c4531 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ botocore>=1.10 -boto3>=1.8 +boto3>=1.8 \ No newline at end of file