diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml index 80f7462..51d6276 100644 --- a/.github/workflows/semgrep.yml +++ b/.github/workflows/semgrep.yml @@ -2,8 +2,8 @@ name: semgrep on: # Scan changed files in PRs, block on new issues only (existing issues ignored) - pull_request: - branches: [ main ] + # pull_request: + # branches: [ main ] # Scan all files on branches, block on any issues push: @@ -15,26 +15,30 @@ on: # schedule: # - cron: '30 0 1,15 * *' # scheduled for 00:30 UTC on both the 1st and 15th of the month +permissions: + contents: read # for actions/checkout to fetch code + security-events: write # for github/codeql-action/upload-sarif to upload SARIF results + defaults: run: working-directory: src jobs: semgrep: - permissions: - contents: read # for actions/checkout to fetch code - security-events: write # for github/codeql-action/upload-sarif to upload SARIF results name: scan runs-on: ubuntu-latest - container: - image: returntocorp/semgrep - # Skip any PR created by dependabot to avoid permission issues - if: (github.actor != 'dependabot[bot]') steps: # Fetch project source - uses: actions/checkout@v3 - - run: semgrep ci --config "p/ci" --config "p/python" --config "p/owasp-top-ten" --sarif --output=semgrep.sarif + - uses: returntocorp/semgrep-action@v1 + with: + generateSarif: "1" + config: >- + p/ci + p/python + p/owasp-top-ten + p/cwe-top-25 - name: Upload SARIF file uses: github/codeql-action/upload-sarif@v2 diff --git a/.semgrepignore b/.semgrepignore new file mode 100644 index 0000000..bbf1835 --- /dev/null +++ b/.semgrepignore @@ -0,0 +1 @@ +terraform/ diff --git a/README.md b/README.md index a00b79e..c205fbf 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,10 @@ This tool generates a new IAM access key pair every X number of days and informs ![aws-iam-key-rotator](iam-key-rotator.jpeg "AWS IAM Key Rotator") - CloudWatch triggers lambda function which checks the age of access key for all the IAM users who have **IKR:EMAIL**(case-insensitive) tag attached. -- If existing access key age is greater than `ACCESS_KEY_AGE` environment variable or `IKR:ROTATE_AFTER_DAYS` tag associated to the IAM user and if the user ONLY has a single key pair associated a new key pair is generated and the same is mailed to the user via your selected mail service. -- The existing access key is than stored in DynamoDB table with user details and an expiration timestamp. -- DynamoDB stream triggers destructor lambda function which is responsible for deleting the old access key associated to IAM user if the stream event is `delete`. -- In case it fails to delete the existing key pair the entry is added back to the DynamoDB table so that the same can be picked up later for retry. +- If existing access key age is greater than `ACCESS_KEY_AGE` environment variable or `IKR:ROTATE_AFTER_DAYS` tag associated to the IAM user and if the user ONLY has a single key pair associated, a new key pair is generated and if `ENCRYPT_KEY_PAIR` environment variable is set to true the new key pair is encrypted using a symmetric key which is stored in SSM parameter (`/ikr/secret/iam/IAM_USERNAME`) before the same is mailed to the user via the selected mail service. +- The existing access key is then stored in DynamoDB table with user details and an expiration timestamp. +- DynamoDB stream triggers destructor lambda function which is responsible for deleting the old access key associated to IAM user and the SSM parameter that stores the symmetric encryption key if `ENCRYPT_KEY_PAIR` environment variable is set to true. The destruction operation is carried out only if the DynamoDB stream event is of type `delete`. +- In case the destructor function fails to delete the existing key pair, the entry is added back to the DynamoDB table for retry. ### Setup: - Use the [terraform module](terraform) included in this repo to create all the AWS resources required to automate IAM key rotation @@ -41,3 +41,4 @@ This tool generates a new IAM access key pair every X number of days and informs ### Helper Script: - `tag-iam-users.py`: Tags IAM users by reading **iam-user-tags.json** file +- `decryption.py`: Decrypt cipher text using the encryption key stored in the SSM parmeter store diff --git a/decryption.py b/decryption.py new file mode 100644 index 0000000..de190e2 --- /dev/null +++ b/decryption.py @@ -0,0 +1,15 @@ +import os + +from cryptography.fernet import Fernet + +ENC_KEY = os.environ.get('ENC_KEY', None) +CIPHER_TEXT = os.environ.get('CIPHER_TEXT', None) + +if ENC_KEY is None: + raise KeyError('ENC_KEY is required to decrypt the cipher text') + +if CIPHER_TEXT is None: + raise KeyError('CIPHER_TEXT is required') + +f = Fernet(ENC_KEY) +print(f.decrypt(CIPHER_TEXT.encode('utf-8')).decode('utf-8')) diff --git a/iam-key-rotator.drawio b/iam-key-rotator.drawio index aaa77e6..f16af0c 100644 --- a/iam-key-rotator.drawio +++ b/iam-key-rotator.drawio @@ -1 +1,124 @@ -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 \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iam-key-rotator.jpeg b/iam-key-rotator.jpeg index 38a442c..018735a 100644 Binary files a/iam-key-rotator.jpeg and b/iam-key-rotator.jpeg differ diff --git a/src/creator.py b/src/creator.py index 93f9967..1d25f6d 100644 --- a/src/creator.py +++ b/src/creator.py @@ -8,6 +8,7 @@ from botocore.exceptions import ClientError import shared_functions +import encryption # Table name which holds existing access key pair details to be deleted IAM_KEY_ROTATOR_TABLE = os.environ.get('IAM_KEY_ROTATOR_TABLE', None) @@ -18,6 +19,9 @@ # No. of days to wait for deleting existing key pair after a new key pair is generated DELETE_AFTER_DAYS = os.environ.get('DELETE_AFTER_DAYS', 5) +# Whether to share encrypted version of key pair +ENCRYPT_KEY_PAIR = os.environ.get('ENCRYPT_KEY_PAIR', True) + # Mail client to use for sending new key creation or existing key deletion mail MAIL_CLIENT = os.environ.get('MAIL_CLIENT', 'ses') @@ -190,6 +194,9 @@ def mark_key_for_destroy(userName, ak, existingKeyDeleteAge, email): }, 'delete_on': { 'N': str(round(datetime(today.year, today.month, today.day, tzinfo=pytz.utc).timestamp()) + (existingKeyDeleteAge * 24 * 60 * 60)) + }, + 'delete_enc_key': { + 'S': 'Y' if ENCRYPT_KEY_PAIR else 'N' } } ) @@ -217,7 +224,16 @@ def create_user_key(userName, user): # Email keys to user existingKeyDeleteAge = user['attributes']['delete_after_days'] if 'delete_after_days' in user['attributes'] else DELETE_AFTER_DAYS - send_email(user['attributes']['email'], userName, resp['AccessKey']['AccessKeyId'], resp['AccessKey']['SecretAccessKey'], user['attributes']['instruction'], user['keys'][0]['ak'], int(existingKeyDeleteAge)) + + if ENCRYPT_KEY_PAIR: + userAccessKey, userSecretAccessKey = encryption.encrypt(userName, resp['AccessKey']['AccessKeyId'], resp['AccessKey']['SecretAccessKey']) + userInstruction = 'The above key pair is encrypted so you need to decrypt it using the encryption key stored in SSM parameter /ikr/secret/iam/{} before using the key pair. You can use the *decryption.py* file present in the *skildops/aws-iam-key-rotator* repo. {}'.format(userName, user['attributes']['instruction']) + else: + userAccessKey = resp['AccessKey']['AccessKeyId'] + userSecretAccessKey = resp['AccessKey']['SecretAccessKey'] + userInstruction = user['attributes']['instruction'] + + send_email(user['attributes']['email'], userName, userAccessKey, userSecretAccessKey, userInstruction, user['keys'][0]['ak'], int(existingKeyDeleteAge)) # Mark exisiting key to destory after X days mark_key_for_destroy(userName, user['keys'][0]['ak'], int(existingKeyDeleteAge), user['attributes']['email']) @@ -229,6 +245,9 @@ def create_user_keys(users): [executor.submit(create_user_key, user, users[user]) for user in users] def handler(event, context): + global ENCRYPT_KEY_PAIR + ENCRYPT_KEY_PAIR = False if ENCRYPT_KEY_PAIR == 'false' else True + if IAM_KEY_ROTATOR_TABLE is None: logger.error('IAM_KEY_ROTATOR_TABLE is required. Current value: {}'.format(IAM_KEY_ROTATOR_TABLE)) elif MAIL_FROM is None: diff --git a/src/destructor.py b/src/destructor.py index de1fc1b..944ff37 100644 --- a/src/destructor.py +++ b/src/destructor.py @@ -11,7 +11,7 @@ IAM_KEY_ROTATOR_TABLE = os.environ.get('IAM_KEY_ROTATOR_TABLE', None) # In case lambda fails to delete the old key, how long should it wait before the next try -RETRY_AFTER_MINS = os.environ.get('RETRY_AFTER_MINS', 5) +RETRY_AFTER_MINS = int(os.environ.get('RETRY_AFTER_MINS', 5)) # Mail client to use for sending new key creation or existing key deletion mail MAIL_CLIENT = os.environ.get('MAIL_CLIENT', 'ses') @@ -22,6 +22,7 @@ # AWS_REGION is by default available within lambda environment iam = boto3.client('iam', region_name=os.environ.get('AWS_REGION')) ses = boto3.client('ses', region_name=os.environ.get('AWS_REGION')) +ssm = boto3.client('ssm', region_name=os.environ.get('AWS_REGION')) dynamodb = boto3.client('dynamodb', region_name=os.environ.get('AWS_REGION')) logger = logging.getLogger('destructor') @@ -77,12 +78,26 @@ def send_email(email, userName, existingAccessKey): except (Exception, ClientError) as ce: logger.error('Failed to send mail to user {} ({}). Reason: {}'.format(userName, email, ce)) +def delete_encryption_key(userName): + try: + logger.info('Deleting encryption key for {} user'.format(userName)) + ssm.delete_parameter( + Name='/ikr/secret/iam/{}'.format(userName) + ) + logger.info('Encryption key deleted for {} user'.format(userName)) + except ClientError as ce: + logger.error('Unable to delete encryption key for {} user. Reason: {}'.format(userName, ce)) + return False + + return True + def destroy_user_key(rec): if rec['eventName'] == 'REMOVE': key = rec['dynamodb']['OldImage'] userName = key['user']['S'] userEmail = key['email']['S'] accessKey = key['ak']['S'] + delEncKey = key['delete_enc_key']['S'] try: logger.info('Deleting access key {} assocaited with user {}'.format(accessKey, userName)) iam.delete_access_key( @@ -91,14 +106,19 @@ def destroy_user_key(rec): ) logger.info('Access Key {} has been deleted'.format(accessKey)) + # Delete user encryption key stored in ssm + if delEncKey == 'Y': + encKeyDeleted = delete_encryption_key(userName) + # Send mail to user about key deletion send_email(userEmail, userName, accessKey) except (Exception, ClientError) as ce: logger.error('Failed to delete access key {}. Reason: {}'.format(accessKey, ce)) logger.info('Adding access key {} back to the database'.format(accessKey)) + dynamodb.put_item( TableName=IAM_KEY_ROTATOR_TABLE, - Key={ + Item={ 'user': { 'S': userName }, @@ -110,6 +130,9 @@ def destroy_user_key(rec): }, 'delete_on': { 'N': str(int(key['delete_on']['N']) + (RETRY_AFTER_MINS * 60)) + }, + 'delete_enc_key': { + 'S': 'N' if delEncKey == 'Y' and encKeyDeleted else delEncKey } } ) diff --git a/src/encryption.py b/src/encryption.py new file mode 100644 index 0000000..968fba4 --- /dev/null +++ b/src/encryption.py @@ -0,0 +1,41 @@ +import boto3 +import logging +import sys +import os + +from cryptography.fernet import Fernet +from botocore.exceptions import ClientError + +ssm = boto3.client('ssm', region_name=os.environ.get('AWS_REGION')) + +logger = logging.getLogger('encryption') +logger.setLevel(logging.INFO) + +def store_in_ssm(userName, encryptionKey): + try: + logger.info('Creating SSM parameter to store encryption key for {} user'.format(userName)) + ssm.put_parameter( + Name='/ikr/secret/iam/{}'.format(userName), + Description='Encryption key used to encrypt access key pair of {} user'.format(userName), + Value=encryptionKey, + Type='SecureString', + Overwrite=True + ) + logger.info('SSM parameter created for {} user'.format(userName)) + except ClientError as ce: + logger.error('Failed to create SSM parameter to store encryption key for {} user. Reason: {}'.format(userName, ce)) + return False + + return True + +def encrypt(userName, accessKey, secretAccessKey): + encryptionKey = Fernet.generate_key().decode('utf-8') + + if not store_in_ssm(userName, encryptionKey): + sys.exit(1) + + f = Fernet(encryptionKey) + encryptedAccessKey = f.encrypt(accessKey.encode('utf-8')).decode('utf-8') + encryptedSecretAccessKey = f.encrypt(secretAccessKey.encode('utf-8')).decode('utf-8') + + return encryptedAccessKey, encryptedSecretAccessKey diff --git a/src/shared_functions.py b/src/shared_functions.py index 582408a..0ea789f 100644 --- a/src/shared_functions.py +++ b/src/shared_functions.py @@ -9,6 +9,6 @@ def fetch_account_info(): return { - 'id': os.environ['AWS_ACCOUNT_ID'], + 'id': os.environ.get('ACCOUNT_ID', ''), 'name': iam.list_account_aliases()['AccountAliases'][0] if len(iam.list_account_aliases()['AccountAliases']) > 0 else '' } diff --git a/tag-iam-users.py b/tag-iam-users.py index 28b2410..231d9e8 100644 --- a/tag-iam-users.py +++ b/tag-iam-users.py @@ -6,21 +6,21 @@ from botocore.exceptions import ClientError # AWS Profile to use for API calls -IKR_AWS_PROFILE = os.environ.get('IKR_AWS_PROFILE', None) +AWS_PROFILE = os.environ.get('AWS_PROFILE', None) # AWS Access Key to use for API calls -IKR_AWS_ACCESS_KEY_ID = os.environ.get('IKR_AWS_ACCESS_KEY_ID', None) +AWS_ACCESS_KEY_ID = os.environ.get('AWS_ACCESS_KEY_ID', None) # AWS Secret Access Key to use for API calls -IKR_AWS_SECRET_ACCESS_KEY = os.environ.get('IKR_AWS_SECRET_ACCESS_KEY', None) +AWS_SECRET_ACCESS_KEY = os.environ.get('AWS_SECRET_ACCESS_KEY', None) # AWS Session Token to use for API calls -IKR_AWS_SESSION_TOKEN = os.environ.get('IKR_AWS_SESSION_TOKEN', None) +AWS_SESSION_TOKEN = os.environ.get('AWS_SESSION_TOKEN', None) # AWS region to use -IKR_AWS_REGION = os.environ.get('IKR_AWS_REGION', 'us-east-1') +AWS_REGION = 'us-east-1' -session = boto3.Session(aws_access_key_id=IKR_AWS_ACCESS_KEY_ID, aws_secret_access_key=IKR_AWS_SECRET_ACCESS_KEY, aws_session_token=IKR_AWS_SESSION_TOKEN, region_name=IKR_AWS_REGION, profile_name=IKR_AWS_PROFILE) +session = boto3.Session(aws_access_key_id=AWS_ACCESS_KEY_ID, aws_secret_access_key=AWS_SECRET_ACCESS_KEY, aws_session_token=AWS_SESSION_TOKEN, region_name=AWS_REGION, profile_name=AWS_PROFILE) iam = session.client('iam') iamUsers = json.load(open('iam-user-tags.json')) diff --git a/terraform/README.md b/terraform/README.md index 4c1865d..3c54226 100644 --- a/terraform/README.md +++ b/terraform/README.md @@ -1,6 +1,6 @@ # IAM Key Rotator -![Test](https://img.shields.io/github/workflow/status/skildops/aws-iam-key-rotator/test/main?label=Test&style=for-the-badge) ![Checkov](https://img.shields.io/github/workflow/status/skildops/aws-iam-key-rotator/checkov/main?label=Checkov&style=for-the-badge) +![Test](https://img.shields.io/github/actions/workflow/status/skildops/aws-iam-key-rotator/test.yml?branch=main&label=Test&style=for-the-badge) ![Checkov](https://img.shields.io/github/actions/workflow/status/skildops/aws-iam-key-rotator/checkov.yml?branch=main&label=Checkov&style=for-the-badge) This terraform module will deploy the following services: - DynamoDB Table @@ -11,6 +11,11 @@ This terraform module will deploy the following services: **Note:** You need to implement [remote backend](https://www.terraform.io/docs/language/settings/backends/index.html) by yourself and is recommended for state management. +**Important:** `cryptography` library has issues with AWS lambda so use the below command to build the package whenever required +```bash +pip install --platform manylinux2014_x86_64 --implementation cp --python 3.9 --only-binary=:all: --target . cryptography +``` + ## Requirements | Name | Version | @@ -44,6 +49,7 @@ This terraform module will deploy the following services: | rotate_after_days | Days after which a new access key pair should be generated. **Note:** If `IKR:ROTATE_AFTER_DAYS` tag is set for the IAM user, this is ignored | `number` | `85` | no | | delete_after_days | No. of days to wait for deleting existing key pair after a new key pair is generated. **Note:** If `IKR:DELETE_AFTER_DAYS` tag is set for the IAM user, this is ignored | `number` | `5` | no | | retry_after_mins | In case lambda fails to delete the old key, how long should it wait before the next try | `number` | `5` | no | +| encrypt_key_pair | Whether to share encrypted version of key pair with the user instead of sending them in plain text. The encryption key will be stored in SSM paramter store in `/ikr/secret/iam/USERNAME` format | `bool` | `true` | no | | mail_client | Mail client to use. **Supported Clients:** smtp, ses and mailgun | `string` | `"ses"` | no | | mail_from | Email address which should be used for sending mails. **Note:** Prior setup of mail client is required | `string` | n/a | yes | | smtp_protocol | Security protocol to use for SMTP connection. **Supported values:** ssl and tls. **Note:** Required if mail client is set to smtp | `string` | `null` | no | diff --git a/terraform/archive.tf b/terraform/archive.tf index 0ec848b..20efe3b 100644 --- a/terraform/archive.tf +++ b/terraform/archive.tf @@ -1,48 +1,28 @@ -data "template_file" "creator" { - template = file("../src/creator.py") -} - -data "template_file" "destructor" { - template = file("../src/destructor.py") -} - -data "template_file" "shared_functions" { - template = file("../src/shared_functions.py") -} - -data "template_file" "ses_mailer" { - template = file("../src/ses_mailer.py") -} - -data "template_file" "mailgun_mailer" { - template = file("../src/mailgun_mailer.py") -} - -data "template_file" "smtp_mailer" { - template = file("../src/smtp_mailer.py") -} - data "archive_file" "creator" { type = "zip" output_path = "${path.module}/creator.zip" source { - content = data.template_file.creator.rendered + content = file("../src/creator.py") filename = "creator.py" } source { - content = data.template_file.shared_functions.rendered + content = file("../src/shared_functions.py") filename = "shared_functions.py" } source { - content = data.template_file.ses_mailer.rendered + content = file("../src/encryption.py") + filename = "encryption.py" + } + source { + content = file("../src/ses_mailer.py") filename = "ses_mailer.py" } source { - content = data.template_file.mailgun_mailer.rendered + content = file("../src/mailgun_mailer.py") filename = "mailgun_mailer.py" } source { - content = data.template_file.smtp_mailer.rendered + content = file("../src/smtp_mailer.py") filename = "smtp_mailer.py" } } @@ -51,23 +31,23 @@ data "archive_file" "destructor" { type = "zip" output_path = "${path.module}/destructor.zip" source { - content = data.template_file.destructor.rendered + content = file("../src/destructor.py") filename = "destructor.py" } source { - content = data.template_file.shared_functions.rendered + content = file("../src/shared_functions.py") filename = "shared_functions.py" } source { - content = data.template_file.ses_mailer.rendered + content = file("../src/ses_mailer.py") filename = "ses_mailer.py" } source { - content = data.template_file.mailgun_mailer.rendered + content = file("../src/mailgun_mailer.py") filename = "mailgun_mailer.py" } source { - content = data.template_file.smtp_mailer.rendered + content = file("../src/smtp_mailer.py") filename = "smtp_mailer.py" } } diff --git a/terraform/cryptography.zip b/terraform/cryptography.zip new file mode 100644 index 0000000..0ecd4ca Binary files /dev/null and b/terraform/cryptography.zip differ diff --git a/terraform/main.tf b/terraform/main.tf index 69d888d..e1d0500 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -59,20 +59,27 @@ resource "aws_dynamodb_table" "iam_key_rotator" { # ====== Lambda Layers ===== resource "aws_lambda_layer_version" "pytz" { - filename = "pytz.zip" - source_code_hash = filebase64sha256("pytz.zip") - description = "https://pypi.org/project/pytz/" - layer_name = "pytz" - + filename = "pytz.zip" + source_code_hash = filebase64sha256("pytz.zip") + description = "https://pypi.org/project/pytz/" + layer_name = "pytz" compatible_runtimes = ["python3.6", "python3.7", "python3.8", "python3.9"] + } resource "aws_lambda_layer_version" "requests" { - filename = "requests.zip" - source_code_hash = filebase64sha256("requests.zip") - description = "https://pypi.org/project/requests/" - layer_name = "requests" + filename = "requests.zip" + source_code_hash = filebase64sha256("requests.zip") + description = "https://pypi.org/project/requests/" + layer_name = "requests" + compatible_runtimes = ["python3.6", "python3.7", "python3.8", "python3.9"] +} +resource "aws_lambda_layer_version" "cryptography" { + filename = "cryptography.zip" + source_code_hash = filebase64sha256("cryptography.zip") + description = "https://cryptography.io/en/latest/" + layer_name = "cryptography" compatible_runtimes = ["python3.6", "python3.7", "python3.8", "python3.9"] } @@ -84,48 +91,61 @@ resource "aws_iam_role" "iam_key_creator" { tags = var.tags } -resource "aws_iam_role_policy" "iam_key_creator_policy" { - name = "${var.key_creator_role_name}-policy" - role = aws_iam_role.iam_key_creator.id - - policy = <<-EOF - { - "Version": "2012-10-17", - "Statement": [ - { - "Action": [ - "iam:ListUserTags", - "iam:ListAccessKeys", - "iam:ListUsers", - "iam:CreateAccessKey" - ], - "Effect": "Allow", - "Resource": "*" - }, - { - "Action": [ - "dynamodb:PutItem" - ], - "Effect": "Allow", - "Resource": "${aws_dynamodb_table.iam_key_rotator.arn}" - }, - { - "Action": [ - "ssm:GetParameter" - ], - "Effect": "Allow", - "Resource": "arn:aws:ssm:${var.region}:${local.account_id}:parameter/iakr/*" - }, - { - "Action": [ - "ses:SendEmail" - ], - "Effect": "Allow", - "Resource": "*" - } +data "aws_iam_policy_document" "iam_key_creator_policy" { + # checkov:skip=CKV_AWS_109: Ensure IAM policies does not allow permissions management / resource exposure without constraints + # checkov:skip=CKV_AWS_110: Ensure IAM policies does not allow privilege escalation + # checkov:skip=CKV_AWS_107: Ensure IAM policies does not allow credentials exposure + statement { + effect = "Allow" + actions = [ + "iam:ListUserTags", + "iam:ListAccessKeys", + "iam:ListUsers", + "iam:CreateAccessKey", + "iam:ListAccountAliases" + ] + resources = ["*"] + } + + statement { + effect = "Allow" + actions = [ + "dynamodb:PutItem" ] + resources = [aws_dynamodb_table.iam_key_rotator.arn] } - EOF + + statement { + effect = "Allow" + actions = [ + "ssm:GetParameter" + ] + resources = ["arn:aws:ssm:${var.region}:${local.account_id}:parameter/ikr/*"] + } + + dynamic "statement" { + for_each = var.encrypt_key_pair == true ? [0] : [] + content { + effect = "Allow" + actions = ["ssm:PutParameter"] + resources = ["arn:aws:ssm:${var.region}:${local.account_id}:parameter/ikr/*"] + } + } + + dynamic "statement" { + for_each = var.mail_client == "ses" ? [0] : [] + content { + effect = "Allow" + actions = ["ses:SendEmail"] + resources = ["*"] + } + } +} + +resource "aws_iam_role_policy" "iam_key_creator_policy" { + name = "${var.key_creator_role_name}-policy" + role = aws_iam_role.iam_key_creator.id + policy = data.aws_iam_policy_document.iam_key_creator_policy.json } resource "aws_iam_role_policy_attachment" "iam_key_creator_logs" { @@ -134,11 +154,11 @@ resource "aws_iam_role_policy_attachment" "iam_key_creator_logs" { } resource "aws_cloudwatch_event_rule" "iam_key_creator" { - name = "IAMAccessKeyCreator" - description = "Triggers a lambda function periodically which creates a set of new access key pair for a user if the existing key pair is X days old" - is_enabled = true - + name = "IAMAccessKeyCreator" + description = "Triggers a lambda function periodically which creates a set of new access key pair for a user if the existing key pair is X days old" + is_enabled = true schedule_expression = "cron(${var.cron_expression})" + tags = var.tags } resource "aws_cloudwatch_event_target" "iam_key_creator" { @@ -157,7 +177,7 @@ resource "aws_lambda_permission" "iam_key_creator" { resource "aws_ssm_parameter" "mailgun" { count = var.mail_client == "mailgun" ? 1 : 0 - name = "/iakr/secret/mailgun" + name = "/ikr/secret/mailgun" value = var.mailgun_api_key type = "SecureString" tags = var.tags @@ -165,7 +185,7 @@ resource "aws_ssm_parameter" "mailgun" { resource "aws_ssm_parameter" "smtp_password" { count = var.mail_client == "smtp" ? 1 : 0 - name = "/iakr/secret/smtp" + name = "/ikr/secret/smtp" value = var.smtp_password type = "SecureString" tags = var.tags @@ -184,6 +204,7 @@ resource "aws_lambda_function" "iam_key_creator" { # checkov:skip=CKV_AWS_116: DLQ not required # checkov:skip=CKV_AWS_117: VPC deployment not required # checkov:skip=CKV_AWS_173: By default environment variables are encrypted at rest + # checkov:skip=CKV_AWS_272: Code-signing not required function_name = var.key_creator_function_name description = "Create new access key pair for IAM user" role = aws_iam_role.iam_key_creator.arn @@ -196,7 +217,7 @@ resource "aws_lambda_function" "iam_key_creator" { timeout = var.function_timeout reserved_concurrent_executions = var.reserved_concurrent_executions - layers = [aws_lambda_layer_version.pytz.arn, aws_lambda_layer_version.requests.arn] + layers = [aws_lambda_layer_version.pytz.arn, aws_lambda_layer_version.requests.arn, aws_lambda_layer_version.cryptography.arn] tracing_config { mode = var.xray_tracing_mode @@ -207,6 +228,7 @@ resource "aws_lambda_function" "iam_key_creator" { IAM_KEY_ROTATOR_TABLE = aws_dynamodb_table.iam_key_rotator.name ROTATE_AFTER_DAYS = var.rotate_after_days DELETE_AFTER_DAYS = var.delete_after_days + ENCRYPT_KEY_PAIR = var.encrypt_key_pair MAIL_CLIENT = var.mail_client MAIL_FROM = var.mail_from SMTP_PROTOCOL = var.smtp_protocol @@ -230,60 +252,60 @@ resource "aws_iam_role" "iam_key_destructor" { tags = var.tags } -resource "aws_iam_role_policy" "iam_key_destructor_policy" { - name = "${var.key_destructor_role_name}-policy" - role = aws_iam_role.iam_key_destructor.id - - policy = <<-EOF - { - "Version": "2012-10-17", - "Statement": [ - { - "Action": [ - "iam:DeleteAccessKey" - ], - "Effect": "Allow", - "Resource": "*" - }, - { - "Action": [ - "dynamodb:PutItem" - ], - "Effect": "Allow", - "Resource": [ - "${aws_dynamodb_table.iam_key_rotator.arn}" - ] - }, - { - "Action": [ - "dynamodb:DescribeStream", - "dynamodb:GetRecords", - "dynamodb:GetShardIterator", - "dynamodb:ListShards", - "dynamodb:ListStreams" - ], - "Effect": "Allow", - "Resource": [ - "${aws_dynamodb_table.iam_key_rotator.stream_arn}" - ] - }, - { - "Action": [ - "ssm:GetParameter" - ], - "Effect": "Allow", - "Resource": "arn:aws:ssm:${var.region}:${local.account_id}:parameter/iakr/*" - }, - { - "Action": [ - "ses:SendEmail" - ], - "Effect": "Allow", - "Resource": "*" - } +data "aws_iam_policy_document" "iam_key_destructor_policy" { + # checkov:skip=CKV_AWS_109: Ensure IAM policies does not allow permissions management / resource exposure without constraints + statement { + effect = "Allow" + actions = [ + "iam:DeleteAccessKey", + "iam:ListAccountAliases" + ] + resources = ["*"] + } + + statement { + effect = "Allow" + actions = [ + "dynamodb:PutItem" + ] + resources = [aws_dynamodb_table.iam_key_rotator.arn] + } + + statement { + effect = "Allow" + actions = [ + "dynamodb:DescribeStream", + "dynamodb:GetRecords", + "dynamodb:GetShardIterator", + "dynamodb:ListShards", + "dynamodb:ListStreams" ] + resources = [aws_dynamodb_table.iam_key_rotator.stream_arn] + } + + dynamic "statement" { + for_each = var.encrypt_key_pair == true ? [0] : [] + content { + effect = "Allow" + actions = ["ssm:DeleteParameter"] + resources = ["arn:aws:ssm:${var.region}:${local.account_id}:parameter/ikr/secret/iam/*"] + } } - EOF + + dynamic "statement" { + for_each = var.mail_client == "ses" ? [0] : [] + content { + effect = "Allow" + actions = ["ses:SendEmail"] + resources = ["*"] + } + } +} + +resource "aws_iam_role_policy" "iam_key_destructor_policy" { + name = "${var.key_destructor_role_name}-policy" + role = aws_iam_role.iam_key_destructor.id + policy = data.aws_iam_policy_document.iam_key_destructor_policy.json } resource "aws_iam_role_policy_attachment" "iam_key_destructor_logs" { @@ -292,9 +314,10 @@ resource "aws_iam_role_policy_attachment" "iam_key_destructor_logs" { } resource "aws_lambda_event_source_mapping" "iam_key_destructor" { - event_source_arn = aws_dynamodb_table.iam_key_rotator.stream_arn - function_name = aws_lambda_function.iam_key_destructor.arn - starting_position = "LATEST" + event_source_arn = aws_dynamodb_table.iam_key_rotator.stream_arn + function_name = aws_lambda_function.iam_key_destructor.arn + starting_position = "LATEST" + maximum_retry_attempts = 0 } resource "aws_cloudwatch_log_group" "iam_key_destructor" { @@ -310,6 +333,7 @@ resource "aws_lambda_function" "iam_key_destructor" { # checkov:skip=CKV_AWS_116: DLQ not required # checkov:skip=CKV_AWS_117: VPC deployment not required # checkov:skip=CKV_AWS_173: By default environment variables are encrypted at rest + # checkov:skip=CKV_AWS_272: Code-signing not required function_name = var.key_destructor_function_name description = "Delete existing access key pair for IAM user" role = aws_iam_role.iam_key_destructor.arn diff --git a/terraform/vars.tf b/terraform/vars.tf index 327490d..2cdcf68 100644 --- a/terraform/vars.tf +++ b/terraform/vars.tf @@ -136,6 +136,12 @@ variable "retry_after_mins" { description = "In case lambda fails to delete the old key, how long should it wait before the next try" } +variable "encrypt_key_pair" { + type = bool + default = true + description = "Whether to share encrypted version of key pair with the user instead of sending them in plain text. The encryption key will be stored in SSM paramter store in `/ikr/secret/iam/USERNAME` format" +} + variable "mail_client" { type = string default = "ses"