diff --git a/moto/secretsmanager/exceptions.py b/moto/secretsmanager/exceptions.py index 99d74f281..a72a32645 100644 --- a/moto/secretsmanager/exceptions.py +++ b/moto/secretsmanager/exceptions.py @@ -13,3 +13,17 @@ class ResourceNotFoundException(SecretsManagerClientError): "ResourceNotFoundException", "Secrets Manager can't find the specified secret" ) + + +class ClientError(SecretsManagerClientError): + def __init__(self, message): + super(ClientError, self).__init__( + 'InvalidParameterValue', + message) + + +class InvalidParameterException(SecretsManagerClientError): + def __init__(self, message): + super(InvalidParameterException, self).__init__( + 'InvalidParameterException', + message) diff --git a/moto/secretsmanager/models.py b/moto/secretsmanager/models.py index a553953d4..3923f90b0 100644 --- a/moto/secretsmanager/models.py +++ b/moto/secretsmanager/models.py @@ -6,7 +6,12 @@ import json import boto3 from moto.core import BaseBackend, BaseModel -from .exceptions import ResourceNotFoundException +from .exceptions import ( + ResourceNotFoundException, + InvalidParameterException, + ClientError +) +from .utils import random_password, secret_arn class SecretsManager(BaseModel): @@ -40,7 +45,7 @@ class SecretsManagerBackend(BaseBackend): raise ResourceNotFoundException() response = json.dumps({ - "ARN": self.secret_arn(self.region, self.secret_id), + "ARN": secret_arn(self.region, self.secret_id), "Name": self.secret_id, "VersionId": "A435958A-D821-4193-B719-B7769357AER4", "SecretString": self.secret_string, @@ -58,16 +63,41 @@ class SecretsManagerBackend(BaseBackend): self.secret_id = name response = json.dumps({ - "ARN": self.secret_arn(self.region, name), + "ARN": secret_arn(self.region, name), "Name": self.secret_id, "VersionId": "A435958A-D821-4193-B719-B7769357AER4", }) return response - def secret_arn(self, region, secret_id): - return "arn:aws:secretsmanager:{0}:1234567890:secret:{1}-rIjad".format( - region, secret_id) + def get_random_password(self, password_length, + exclude_characters, exclude_numbers, + exclude_punctuation, exclude_uppercase, + exclude_lowercase, include_space, + require_each_included_type): + # password size must have value less than or equal to 4096 + if password_length > 4096: + raise ClientError( + "ClientError: An error occurred (ValidationException) \ + when calling the GetRandomPassword operation: 1 validation error detected: Value '{}' at 'passwordLength' \ + failed to satisfy constraint: Member must have value less than or equal to 4096".format(password_length)) + if password_length < 4: + raise InvalidParameterException( + "InvalidParameterException: An error occurred (InvalidParameterException) \ + when calling the GetRandomPassword operation: Password length is too short based on the required types.") + + response = json.dumps({ + "RandomPassword": random_password(password_length, + exclude_characters, + exclude_numbers, + exclude_punctuation, + exclude_uppercase, + exclude_lowercase, + include_space, + require_each_included_type) + }) + + return response available_regions = ( diff --git a/moto/secretsmanager/responses.py b/moto/secretsmanager/responses.py index 52a838732..06387560a 100644 --- a/moto/secretsmanager/responses.py +++ b/moto/secretsmanager/responses.py @@ -23,3 +23,24 @@ class SecretsManagerResponse(BaseResponse): name=name, secret_string=secret_string ) + + def get_random_password(self): + password_length = self._get_param('PasswordLength', if_none=32) + exclude_characters = self._get_param('ExcludeCharacters', if_none='') + exclude_numbers = self._get_param('ExcludeNumbers', if_none=False) + exclude_punctuation = self._get_param('ExcludePunctuation', if_none=False) + exclude_uppercase = self._get_param('ExcludeUppercase', if_none=False) + exclude_lowercase = self._get_param('ExcludeLowercase', if_none=False) + include_space = self._get_param('IncludeSpace', if_none=False) + require_each_included_type = self._get_param( + 'RequireEachIncludedType', if_none=True) + return secretsmanager_backends[self.region].get_random_password( + password_length=password_length, + exclude_characters=exclude_characters, + exclude_numbers=exclude_numbers, + exclude_punctuation=exclude_punctuation, + exclude_uppercase=exclude_uppercase, + exclude_lowercase=exclude_lowercase, + include_space=include_space, + require_each_included_type=require_each_included_type + ) diff --git a/moto/secretsmanager/utils.py b/moto/secretsmanager/utils.py new file mode 100644 index 000000000..2cb92020a --- /dev/null +++ b/moto/secretsmanager/utils.py @@ -0,0 +1,72 @@ +from __future__ import unicode_literals + +import random +import string +import six +import re + + +def random_password(password_length, exclude_characters, exclude_numbers, + exclude_punctuation, exclude_uppercase, exclude_lowercase, + include_space, require_each_included_type): + + password = '' + required_characters = '' + + if not exclude_lowercase and not exclude_uppercase: + password += string.ascii_letters + required_characters += random.choice(_exclude_characters( + string.ascii_lowercase, exclude_characters)) + required_characters += random.choice(_exclude_characters( + string.ascii_uppercase, exclude_characters)) + elif not exclude_lowercase: + password += string.ascii_lowercase + required_characters += random.choice(_exclude_characters( + string.ascii_lowercase, exclude_characters)) + elif not exclude_uppercase: + password += string.ascii_uppercase + required_characters += random.choice(_exclude_characters( + string.ascii_uppercase, exclude_characters)) + if not exclude_numbers: + password += string.digits + required_characters += random.choice(_exclude_characters( + string.digits, exclude_characters)) + if not exclude_punctuation: + password += string.punctuation + required_characters += random.choice(_exclude_characters( + string.punctuation, exclude_characters)) + if include_space: + password += " " + required_characters += " " + + password = ''.join( + six.text_type(random.choice(password)) + for x in range(password_length)) + + if require_each_included_type: + password = _add_password_require_each_included_type( + password, required_characters) + + password = _exclude_characters(password, exclude_characters) + return password + + +def secret_arn(region, secret_id): + return "arn:aws:secretsmanager:{0}:1234567890:secret:{1}-rIjad".format( + region, secret_id) + + +def _exclude_characters(password, exclude_characters): + for c in exclude_characters: + if c in string.punctuation: + # Escape punctuation regex usage + c = "\{0}".format(c) + password = re.sub(c, '', str(password)) + return password + + +def _add_password_require_each_included_type(password, required_characters): + password_with_required_char = password[:-len(required_characters)] + password_with_required_char += required_characters + + return password_with_required_char diff --git a/tests/test_secretsmanager/test_secretsmanager.py b/tests/test_secretsmanager/test_secretsmanager.py index d5abd6abd..6fefeb56f 100644 --- a/tests/test_secretsmanager/test_secretsmanager.py +++ b/tests/test_secretsmanager/test_secretsmanager.py @@ -5,6 +5,8 @@ import boto3 from moto import mock_secretsmanager from botocore.exceptions import ClientError import sure # noqa +import string +import unittest from nose.tools import assert_raises @mock_secretsmanager @@ -33,3 +35,111 @@ def test_create_secret(): assert result['Name'] == 'test-secret' secret = conn.get_secret_value(SecretId='test-secret') assert secret['SecretString'] == 'foosecret' + +@mock_secretsmanager +def test_get_random_password_default_length(): + conn = boto3.client('secretsmanager', region_name='us-west-2') + + random_password = conn.get_random_password() + assert len(random_password['RandomPassword']) == 32 + +@mock_secretsmanager +def test_get_random_password_default_requirements(): + # When require_each_included_type, default true + conn = boto3.client('secretsmanager', region_name='us-west-2') + + random_password = conn.get_random_password() + # Should contain lowercase, upppercase, digit, special character + assert any(c.islower() for c in random_password['RandomPassword']) + assert any(c.isupper() for c in random_password['RandomPassword']) + assert any(c.isdigit() for c in random_password['RandomPassword']) + assert any(c in string.punctuation + for c in random_password['RandomPassword']) + +@mock_secretsmanager +def test_get_random_password_custom_length(): + conn = boto3.client('secretsmanager', region_name='us-west-2') + + random_password = conn.get_random_password(PasswordLength=50) + assert len(random_password['RandomPassword']) == 50 + +@mock_secretsmanager +def test_get_random_exclude_lowercase(): + conn = boto3.client('secretsmanager', region_name='us-west-2') + + random_password = conn.get_random_password(PasswordLength=55, + ExcludeLowercase=True) + assert any(c.islower() for c in random_password['RandomPassword']) == False + +@mock_secretsmanager +def test_get_random_exclude_uppercase(): + conn = boto3.client('secretsmanager', region_name='us-west-2') + + random_password = conn.get_random_password(PasswordLength=55, + ExcludeUppercase=True) + assert any(c.isupper() for c in random_password['RandomPassword']) == False + +@mock_secretsmanager +def test_get_random_exclude_characters_and_symbols(): + conn = boto3.client('secretsmanager', region_name='us-west-2') + + random_password = conn.get_random_password(PasswordLength=20, + ExcludeCharacters='xyzDje@?!.') + assert any(c in 'xyzDje@?!.' for c in random_password['RandomPassword']) == False + +@mock_secretsmanager +def test_get_random_exclude_numbers(): + conn = boto3.client('secretsmanager', region_name='us-west-2') + + random_password = conn.get_random_password(PasswordLength=100, + ExcludeNumbers=True) + assert any(c.isdigit() for c in random_password['RandomPassword']) == False + +@mock_secretsmanager +def test_get_random_exclude_punctuation(): + conn = boto3.client('secretsmanager', region_name='us-west-2') + + random_password = conn.get_random_password(PasswordLength=100, + ExcludePunctuation=True) + assert any(c in string.punctuation + for c in random_password['RandomPassword']) == False + +@mock_secretsmanager +def test_get_random_include_space_false(): + conn = boto3.client('secretsmanager', region_name='us-west-2') + + random_password = conn.get_random_password(PasswordLength=300) + assert any(c.isspace() for c in random_password['RandomPassword']) == False + +@mock_secretsmanager +def test_get_random_include_space_true(): + conn = boto3.client('secretsmanager', region_name='us-west-2') + + random_password = conn.get_random_password(PasswordLength=4, + IncludeSpace=True) + assert any(c.isspace() for c in random_password['RandomPassword']) == True + +@mock_secretsmanager +def test_get_random_require_each_included_type(): + conn = boto3.client('secretsmanager', region_name='us-west-2') + + random_password = conn.get_random_password(PasswordLength=4, + RequireEachIncludedType=True) + assert any(c in string.punctuation for c in random_password['RandomPassword']) == True + assert any(c in string.ascii_lowercase for c in random_password['RandomPassword']) == True + assert any(c in string.ascii_uppercase for c in random_password['RandomPassword']) == True + assert any(c in string.digits for c in random_password['RandomPassword']) == True + +@mock_secretsmanager +def test_get_random_too_short_password(): + conn = boto3.client('secretsmanager', region_name='us-west-2') + + with assert_raises(ClientError): + random_password = conn.get_random_password(PasswordLength=3) + +@mock_secretsmanager +def test_get_random_too_long_password(): + conn = boto3.client('secretsmanager', region_name='us-west-2') + + with assert_raises(Exception): + random_password = conn.get_random_password(PasswordLength=5555)