diff --git a/.gitignore b/.gitignore index 2faf43d..e895a5a 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,8 @@ crash.log crash.*.log # Exclude all .tfvars files, which are likely to contain sensitive data, such as -# password, private keys, and other secrets. These should not be part of version -# control as they are data points which are potentially sensitive and subject +# password, private keys, and other secrets. These should not be part of version +# control as they are data points which are potentially sensitive and subject # to change depending on the environment. *.tfvars *.tfvars.json diff --git a/.terraform.lock.hcl b/.terraform.lock.hcl new file mode 100644 index 0000000..157fdf1 --- /dev/null +++ b/.terraform.lock.hcl @@ -0,0 +1,25 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "5.64.0" + constraints = ">= 5.20.0" + hashes = [ + "h1:YH4I78rsS9t+YoGMPNzrM53aWi0Rb9Nud16iusrSXMg=", + "zh:1d361f8062c68c9d5ac14b0aa8390709542129b8a9b258e61bbbabc706078b44", + "zh:39dcbf53e3896bdd77071384c8fad4a5862c222c73f3bcf356aca488101f22fd", + "zh:3fad63505f0c5b6f01cc9a6ef02b2226983b79424126a9caf6eb724f654299f4", + "zh:53a8b90d00829cc27e3171a13a8ff1404ee0ea018e73f31d3f916d246cc39613", + "zh:5734c25ef5a04b40f3c1ac5f817f11e42ee3328f74dbc141c0e64afbb0acc834", + "zh:66ea14dbd87f291ce4a877123363933d3ca4022f209f885807a6689c22c24e80", + "zh:68e79654ad0894a3d93134c3377748ace3058d5fad5ec09d1e9a8f8f9b8a47ea", + "zh:7b74259d0ceef0c49cea6bcd171df997b6bad141085bbadded15b440faeb0eee", + "zh:988ebfb5d115dc57070b5abf2e4200ad49cde535f27fd2ba5e34cf9ab336a57f", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:a0a2d4efe2835f0101a0a5024e044a3f28c00e10a8d87fce89c707ef6db75cea", + "zh:aecb3e4b9121771dee9cac7975bf5d0657b5f3e8b57788c455beaeb0f3c48d93", + "zh:d2d3393170b8ef761d3146f39f6788c4a3e876e6c5d4cedca4870c2680688ae6", + "zh:daba5a005c1baa4a5eefbfb86d43ccf880eb5b42e8136f0d932f55886d72bda0", + "zh:de16a6ff3baacdaf9609a0a89aa1913fc19cccaf5ee0fc1c49c5a075baa47c02", + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ad6b0a..98d5b22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,4 +12,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 #### 🐛 Bug Fixes - bug: up the github minimum provider version, the current minimum version is not compatible with the current codebase (#52) @marwinbaumannsbp - diff --git a/README.md b/README.md new file mode 100644 index 0000000..788bfab --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# terraform-aws-mcaf-energy-labeler + +MCAF Terraform module to create a lambda function that periodically generates an AWS energy label based on [awsenergylabelerlib](https://github.com/schubergphilis/awsenergylabelerlib) + + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.3 | +| [aws](#requirement\_aws) | >= 5.20 | + +## Providers + +| Name | Version | +|------|---------| +| [aws](#provider\_aws) | 5.64.0 | + +## Modules + +No modules. + +## Resources + +| Name | Type | +|------|------| +| [aws_cloudwatch_event_rule.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | +| [aws_cloudwatch_event_target.lambda_target](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_target) | resource | +| [aws_cloudwatch_log_group.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | +| [aws_iam_policy.policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource | +| [aws_iam_role.role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role_policy_attachment.custom](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_iam_role_policy_attachment.lambda](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_lambda_function.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function) | resource | +| [aws_lambda_permission.allow_events](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | +| [aws_security_group.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group) | resource | +| [aws_vpc_security_group_egress_rule.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_egress_rule) | resource | +| [aws_iam_policy_document.policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_region.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/region) | data source | +| [aws_subnet.selected](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/subnet) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [architecture](#input\_architecture) | Instruction set architecture of the Lambda function | `string` | `"arm64"` | no | +| [cloudwatch\_logs](#input\_cloudwatch\_logs) | Whether or not to configure a CloudWatch log group | `bool` | `true` | no | +| [description](#input\_description) | A description of the lambda | `string` | `"Lambda function for the AWS Energy Labeler"` | no | +| [environment](#input\_environment) | The environment variables to set | `map(string)` |
{| no | +| [image\_uri](#input\_image\_uri) | The URI of the aws labeler lambda docker image | `string` | `"ghcr.io/schubergphilis/awsenergylabeler:main-lambda"` | no | +| [kms\_key\_arn](#input\_kms\_key\_arn) | The ARN of the KMS key used to encrypt the cloudwatch log group and environment variables | `string` | `null` | no | +| [labeler\_config](#input\_labeler\_config) | A map containing all labeler configuration options |
"log_level": "DEBUG"
}
object({| `{}` | no | +| [labeler\_cron\_expression](#input\_labeler\_cron\_expression) | The cron expression to be used for triggering the labeler | `string` | `"cron(0 13 ? * SUN *)"` | no | +| [log\_retention](#input\_log\_retention) | Number of days to retain log events in the specified log group | `number` | `365` | no | +| [memory\_size](#input\_memory\_size) | The memory size of the lambda | `number` | `512` | no | +| [name](#input\_name) | The name of the lambda | `string` | `"aws-energy-labeler"` | no | +| [permissions\_boundary](#input\_permissions\_boundary) | The permissions boundary to set on the role | `string` | `null` | no | +| [security\_group\_egress\_rules](#input\_security\_group\_egress\_rules) | Security Group egress rules |
log-level = optional(string)
region = optional(string)
organizations-zone-name = optional(string)
audit-zone-name = optional(string)
single-account-id = optional(string)
frameworks = optional(list(string), [])
allowed-account-ids = optional(list(string), [])
denied-account-ids = optional(list(string), [])
allowed-regions = optional(list(string), [])
denied-regions = optional(list(string), [])
export-path = optional(string)
export-metrics-only = optional(bool, false)
to-json = optional(bool, false)
report-closed-findings-days = optional(number)
report-suppressed-findings = optional(bool, false)
account-thresholds = optional(string)
zone-thresholds = optional(string)
security-hub-query-filter = optional(string)
validate-metadata-file = optional(string)
})
list(object({|
cidr_ipv4 = optional(string)
cidr_ipv6 = optional(string)
description = string
from_port = optional(number, 0)
ip_protocol = optional(string, "-1")
prefix_list_id = optional(string)
referenced_security_group_id = optional(string)
to_port = optional(number, 0)
}))
[| no | +| [security\_group\_name\_prefix](#input\_security\_group\_name\_prefix) | An optional prefix to create a unique name of the security group. If not provided `var.name` will be used | `string` | `null` | no | +| [subnet\_ids](#input\_subnet\_ids) | The subnet ids where this lambda needs to run | `list(string)` | `null` | no | +| [tags](#input\_tags) | A mapping of tags to assign | `map(string)` | `{}` | no | +| [timeout](#input\_timeout) | The timeout of the lambda | `number` | `900` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| [lambda\_iam\_role\_arn](#output\_lambda\_iam\_role\_arn) | n/a | + + +## License + +**Copyright:** Schuberg Philis + +``` +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` diff --git a/examples/basic/kms.tf b/examples/basic/kms.tf new file mode 100644 index 0000000..94af090 --- /dev/null +++ b/examples/basic/kms.tf @@ -0,0 +1,54 @@ +data "aws_caller_identity" "default" {} + +data "aws_region" "default" {} + +module "kms_key" { + source = "github.com/schubergphilis/terraform-aws-mcaf-kms?ref=v0.2.0" + name = "aws-energy-labeler-logs" + description = "KMS key used for encrypting flow logs from the aws-energy-labeler" + policy = data.aws_iam_policy_document.kms_key_policy.json + tags = {} +} + +data "aws_iam_policy_document" "kms_key_policy" { + statement { + sid = "Base Permissions" + actions = ["kms:*"] + effect = "Allow" + resources = ["arn:aws:kms:${data.aws_region.default.name}:${data.aws_caller_identity.default.account_id}:key/*"] + + principals { + type = "AWS" + identifiers = [ + "arn:aws:iam::${data.aws_caller_identity.default.account_id}:root" + ] + } + } + + statement { + sid = "Allow all Cloudwatch groups in this account" + actions = [ + "kms:Encrypt", + "kms:Decrypt", + "kms:ReEncrypt*", + "kms:GenerateDataKey*", + "kms:Describe" + ] + effect = "Allow" + resources = ["arn:aws:kms:${data.aws_region.default.name}:${data.aws_caller_identity.default.account_id}:key/*"] + + principals { + identifiers = ["logs.${data.aws_region.default.name}.amazonaws.com"] + type = "Service" + } + + condition { + test = "ArnLike" + variable = "kms:EncryptionContext:aws:logs:arn" + + values = [ + "arn:aws:logs:${data.aws_region.default.name}:${data.aws_caller_identity.default.account_id}:*" + ] + } + } +} diff --git a/examples/basic/main.tf b/examples/basic/main.tf new file mode 100644 index 0000000..1ebdcb5 --- /dev/null +++ b/examples/basic/main.tf @@ -0,0 +1,13 @@ +provider "aws" { + region = "eu-west-1" +} + +module "aws-energy-labeler" { + source = "../../" + + kms_key_arn = module.kms_key.arn + labeler_config = { + organizations-zone-name = "SOMETHING" + export-path = "s3://bucket-name/folder/" + } +} diff --git a/examples/basic/versions.tf b/examples/basic/versions.tf new file mode 100644 index 0000000..1f26dde --- /dev/null +++ b/examples/basic/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.3" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 4.39.0" + } + } +} diff --git a/main.tf b/main.tf new file mode 100644 index 0000000..d565422 --- /dev/null +++ b/main.tf @@ -0,0 +1,208 @@ +locals { + execution_type = var.subnet_ids == null ? "Basic" : "VPCAccess" + vpc_config = var.subnet_ids != null ? { create : true } : {} + environment = var.environment != null ? { create : true } : {} + labeler_config_list_values = { + frameworks = join(",", var.labeler_config["frameworks"]) + allowed-account-ids = join(",", var.labeler_config["allowed-account-ids"]) + denied-account-ids = join(",", var.labeler_config["denied-account-ids"]) + allowed-regions = join(",", var.labeler_config["allowed-regions"]) + denied-regions = join(",", var.labeler_config["denied-regions"]) + } + labeler_config_merged = merge(var.labeler_config, local.labeler_config_list_values) + labeler_config_list_values_non_null = { + for k, v in local.labeler_config_merged : k => v if v != null && v != "" + } + labeler_config_processed = merge({ region = data.aws_region.current.name, disable-banner = true, disable-spinner = true }, local.labeler_config_list_values_non_null) + s3_export_target = try(strcontains(var.labeler_config["export-path"], "s3://"), false) ? { create : true } : {} + single_account_id = can(local.labeler_config_processed["single-account-id"]) ? { create : true } : {} + not_single_account_id = anytrue([can(local.labeler_config_processed["organizations-zone-name"]), can(local.labeler_config_processed["audit-zone-name"])]) ? { create : true } : {} +} + +data "aws_region" "current" {} + +resource "aws_iam_role" "role" { + name = "${var.name}-lambda-role" + assume_role_policy = jsonencode({ + Version = "2012-10-17", + Statement = [ + { + Effect = "Allow", + Principal = { + Service = "lambda.amazonaws.com" + }, + Action = "sts:AssumeRole" + } + ] + }) + permissions_boundary = try(var.permissions_boundary, null) +} + + +data "aws_iam_policy_document" "policy" { + dynamic "statement" { + for_each = local.s3_export_target + + content { + effect = "Allow" + actions = ["s3:PutObject*"] + resources = [ + "arn:aws:s3:::${trimprefix(var.labeler_config["export-path"], "s3://")}*", + "arn:aws:s3:::${trimprefix(var.labeler_config["export-path"], "s3://")}" + ] + } + } + + dynamic "statement" { + for_each = local.single_account_id + + content { + effect = "Allow" + actions = [ + "iam:ListAccountAliases", + "ec2:DescribeRegions", + "securityhub:ListFindingAggregators", + "securityhub:GetFindings", + "securityhub:ListEnabledProductsForImport" + ] + resources = ["*"] + } + } + + dynamic "statement" { + for_each = local.not_single_account_id + + content { + effect = "Allow" + actions = [ + "organizations:DescribeOrganization", + "organizations:ListAccounts", + "organizations:DescribeAccount", + "iam:ListAccountAliases", + "ec2:DescribeRegions", + "securityhub:ListFindingAggregators", + "securityhub:GetFindings", + "securityhub:ListEnabledProductsForImport" + ] + resources = ["*"] + } + } + +} + +resource "aws_iam_role_policy_attachment" "lambda" { + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambda${local.execution_type}ExecutionRole" + role = aws_iam_role.role.name +} + +resource "aws_iam_policy" "policy" { + name = "${var.name}-lambda-policy" + policy = data.aws_iam_policy_document.policy.json +} + +resource "aws_iam_role_policy_attachment" "custom" { + policy_arn = aws_iam_policy.policy.arn + role = aws_iam_role.role.name +} + +resource "aws_lambda_permission" "allow_events" { + statement_id = "AllowExecutionFromEventBridge" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.default.function_name + principal = "events.amazonaws.com" + source_arn = aws_cloudwatch_event_rule.default.arn +} + +resource "aws_cloudwatch_log_group" "default" { + count = var.cloudwatch_logs ? 1 : 0 + + name = "/aws/lambda/${var.name}" + kms_key_id = var.kms_key_arn + retention_in_days = var.log_retention + tags = var.tags +} + +data "aws_subnet" "selected" { + count = var.subnet_ids != null ? 1 : 0 + + id = var.subnet_ids[0] +} + +resource "aws_security_group" "default" { + #checkov:skip=CKV2_AWS_5: False positive finding, the security group is attached. + count = var.subnet_ids != null ? 1 : 0 + + name = var.security_group_name_prefix == null ? var.name : null + name_prefix = var.security_group_name_prefix != null ? var.security_group_name_prefix : null + description = "Security group for lambda ${var.name}" + vpc_id = data.aws_subnet.selected[0].vpc_id + tags = var.tags + + lifecycle { + create_before_destroy = true + } +} + +resource "aws_vpc_security_group_egress_rule" "default" { + for_each = var.subnet_ids != null && length(var.security_group_egress_rules) != 0 ? { for v in var.security_group_egress_rules : v.description => v } : {} + + cidr_ipv4 = each.value.cidr_ipv4 + cidr_ipv6 = each.value.cidr_ipv6 + description = each.value.description + from_port = each.value.from_port + ip_protocol = each.value.ip_protocol + prefix_list_id = each.value.prefix_list_id + referenced_security_group_id = each.value.referenced_security_group_id + security_group_id = aws_security_group.default[0].id + to_port = each.value.to_port +} + +// tfsec:ignore:aws-lambda-enable-tracing +resource "aws_lambda_function" "default" { + #checkov:skip=CKV_AWS_50: "AWS Lambda functions with tracing not enabled - We are not using X-Ray + #checkov:skip=CKV_AWS_116: "AWS Lambda function is not configured for a DLQ - All logging is visible in CloudWatch + #checkov:skip=CKV_AWS_272: "AWS Lambda function is not configured to validate code-signing - Code is developed internally and signed by the CI/CD pipeline + reserved_concurrent_executions = 1 + architectures = [var.architecture] + description = var.description + function_name = var.name + kms_key_arn = var.environment != null ? var.kms_key_arn : null + image_uri = var.image_uri + memory_size = var.memory_size + role = aws_iam_role.role.arn + tags = var.tags + timeout = var.timeout + package_type = "Image" + + dynamic "environment" { + for_each = local.environment + + content { + variables = var.environment + } + } + + dynamic "vpc_config" { + for_each = local.vpc_config + + content { + subnet_ids = var.subnet_ids + security_group_ids = [aws_security_group.default[0].id] + } + } +} + +resource "aws_cloudwatch_event_rule" "default" { + name = "${var.name}-event-rule" + description = "Trigger lambda with ${var.labeler_cron_expression}" + + schedule_expression = var.labeler_cron_expression +} + +resource "aws_cloudwatch_event_target" "lambda_target" { + rule = aws_cloudwatch_event_rule.default.name + target_id = "SendToLambda" + arn = aws_lambda_function.default.arn + + input = jsonencode(local.labeler_config_processed) +} diff --git a/output.tf b/output.tf new file mode 100644 index 0000000..722a866 --- /dev/null +++ b/output.tf @@ -0,0 +1,3 @@ +output "lambda_iam_role_arn" { + value = aws_iam_role.role.arn +} diff --git a/variables.tf b/variables.tf new file mode 100644 index 0000000..4d57146 --- /dev/null +++ b/variables.tf @@ -0,0 +1,157 @@ +variable "subnet_ids" { + type = list(string) + default = null + description = "The subnet ids where this lambda needs to run" +} + +variable "security_group_egress_rules" { + type = list(object({ + cidr_ipv4 = optional(string) + cidr_ipv6 = optional(string) + description = string + from_port = optional(number, 0) + ip_protocol = optional(string, "-1") + prefix_list_id = optional(string) + referenced_security_group_id = optional(string) + to_port = optional(number, 0) + })) + default = [ + { + cidr_ipv4 = "0.0.0.0/0" + ip_protocol = "tcp" + from_port = 443 + to_port = 443 + description = "Allow outgoing HTTPS traffic for the labeler to work" + } + ] + description = "Security Group egress rules" + + validation { + condition = alltrue([for o in var.security_group_egress_rules : (o.cidr_ipv4 != null || o.cidr_ipv6 != null || o.prefix_list_id != null || o.referenced_security_group_id != null)]) + error_message = "Although \"cidr_ipv4\", \"cidr_ipv6\", \"prefix_list_id\", and \"referenced_security_group_id\" are all marked as optional, you must provide one of them in order to configure the destination of the traffic." + } +} + +variable "security_group_name_prefix" { + type = string + default = null + description = "An optional prefix to create a unique name of the security group. If not provided `var.name` will be used" +} + +variable "cloudwatch_logs" { + type = bool + default = true + description = "Whether or not to configure a CloudWatch log group" +} + +variable "log_retention" { + type = number + default = 365 + description = "Number of days to retain log events in the specified log group" +} + + +variable "permissions_boundary" { + type = string + default = null + description = "The permissions boundary to set on the role" +} + +variable "tags" { + type = map(string) + default = {} + description = "A mapping of tags to assign" +} + +variable "timeout" { + type = number + default = 900 + description = "The timeout of the lambda" +} + +variable "name" { + type = string + description = "The name of the lambda" + default = "aws-energy-labeler" +} + +variable "kms_key_arn" { + type = string + default = null + description = "The ARN of the KMS key used to encrypt the cloudwatch log group and environment variables" +} + +variable "memory_size" { + type = number + default = 512 + description = "The memory size of the lambda" +} + +variable "environment" { + type = map(string) + default = { log_level = "DEBUG" } + description = "The environment variables to set" +} + +variable "description" { + type = string + default = "Lambda function for the AWS Energy Labeler" + description = "A description of the lambda" +} + +variable "architecture" { + type = string + default = "arm64" + description = "Instruction set architecture of the Lambda function" + + validation { + condition = contains(["arm64", "x86_64"], var.architecture) + error_message = "Allowed values are \"arm64\" or \"x86_64\"." + } +} + +variable "labeler_config" { + description = "A map containing all labeler configuration options" + type = object({ + log-level = optional(string) + region = optional(string) + organizations-zone-name = optional(string) + audit-zone-name = optional(string) + single-account-id = optional(string) + frameworks = optional(list(string), []) + allowed-account-ids = optional(list(string), []) + denied-account-ids = optional(list(string), []) + allowed-regions = optional(list(string), []) + denied-regions = optional(list(string), []) + export-path = optional(string) + export-metrics-only = optional(bool, false) + to-json = optional(bool, false) + report-closed-findings-days = optional(number) + report-suppressed-findings = optional(bool, false) + account-thresholds = optional(string) + zone-thresholds = optional(string) + security-hub-query-filter = optional(string) + validate-metadata-file = optional(string) + }) + default = {} + + validation { + condition = length(compact([var.labeler_config.single-account-id, var.labeler_config.audit-zone-name, var.labeler_config.organizations-zone-name])) == 1 + error_message = "Parameters organizations-zone-name, audit-zone-name and single-account-id are mutually exclusive" + } + validation { + condition = var.labeler_config.export-path == null || (startswith(var.labeler_config.export-path, "s3://") && endswith(var.labeler_config.export-path, "/")) + error_message = "The export-path parameter must start with 's3://' and end with a '/'." + } +} + +variable "labeler_cron_expression" { + description = "The cron expression to be used for triggering the labeler" + default = "cron(0 13 ? * SUN *)" + type = string +} + +variable "image_uri" { + type = string + description = "The URI of the aws labeler lambda docker image. Needs to be an ECR image" +} diff --git a/versions.tf b/versions.tf new file mode 100644 index 0000000..a398816 --- /dev/null +++ b/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.3" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.20" + } + } +}
{
"cidr_ipv4": "0.0.0.0/0",
"description": "Allow outgoing HTTPS traffic for the labeler to work",
"from_port": 443,
"ip_protocol": "tcp",
"to_port": 443
}
]