From 5300b490803b2fdee816ea7e85129f1c3047c6dd Mon Sep 17 00:00:00 2001 From: Rick Date: Mon, 3 Feb 2025 18:06:39 -0500 Subject: [PATCH] Add Confused Deputy Prevention (#449) Add Confused Deputy Prevention --- environment/metadata.go | 16 + generator/test_case_generator.go | 7 +- terraform/ec2/assume_role/main.tf | 227 ++++++++ terraform/ec2/assume_role/providers.tf | 6 + terraform/ec2/assume_role/variables.tf | 114 ++++ terraform/ec2/common/linux/output.tf | 4 + test/assume_role/agent_configs/config.json | 7 +- test/assume_role/assume_role_test.go | 12 +- test/assume_role/assume_role_unix.go | 536 ++++++++++++++++-- .../amazon-cloudwatch-agent.service | 24 + .../agent_config.json | 2 +- .../agent_configs/config.json | 26 + .../credentials_file/credentials_file_test.go | 22 + .../credentials_file/credentials_file_unix.go | 129 +++++ .../credentials_file_windows.go} | 2 +- .../parameters.yml | 0 16 files changed, 1086 insertions(+), 48 deletions(-) create mode 100644 terraform/ec2/assume_role/main.tf create mode 100644 terraform/ec2/assume_role/providers.tf create mode 100644 terraform/ec2/assume_role/variables.tf create mode 100644 test/assume_role/service_configs/amazon-cloudwatch-agent.service rename test/{assume_role => credentials_file}/agent_config.json (95%) create mode 100644 test/credentials_file/agent_configs/config.json create mode 100644 test/credentials_file/credentials_file_test.go create mode 100644 test/credentials_file/credentials_file_unix.go rename test/{assume_role/assume_role_windows.go => credentials_file/credentials_file_windows.go} (99%) rename test/{assume_role => credentials_file}/parameters.yml (100%) diff --git a/environment/metadata.go b/environment/metadata.go index ae7aa3174..a7a930e29 100644 --- a/environment/metadata.go +++ b/environment/metadata.go @@ -40,6 +40,7 @@ type MetaData struct { EKSClusterName string ProxyUrl string AssumeRoleArn string + InstanceArn string InstanceId string InstancePlatform string AgentStartCommand string @@ -62,6 +63,7 @@ type MetaData struct { PrometheusConfig string OtelConfig string SampleApp string + AccountId string } type MetaDataStrings struct { @@ -81,6 +83,7 @@ type MetaDataStrings struct { EKSClusterName string ProxyUrl string AssumeRoleArn string + InstanceArn string InstanceId string InstancePlatform string AgentStartCommand string @@ -103,6 +106,7 @@ type MetaDataStrings struct { PrometheusConfig string OtelConfig string SampleApp string + AccountId string } func registerComputeType(dataString *MetaDataStrings) { @@ -179,6 +183,10 @@ func registerAssumeRoleArn(dataString *MetaDataStrings) { flag.StringVar(&(dataString.AssumeRoleArn), "assumeRoleArn", "", "Arn for assume role to be used") } +func registerInstanceArn(dataString *MetaDataStrings) { + flag.StringVar(&(dataString.InstanceArn), "instanceArn", "", "ec2 instance ARN that is being used by a test") +} + func registerInstanceId(dataString *MetaDataStrings) { flag.StringVar(&(dataString.InstanceId), "instanceId", "", "ec2 instance ID that is being used by a test") } @@ -276,6 +284,10 @@ func registerAmpWorkspaceId(dataString *MetaDataStrings) { flag.StringVar(&(dataString.AmpWorkspaceId), "ampWorkspaceId", "", "workspace Id for Amazon Managed Prometheus (AMP)") } +func registerAccountId(dataString *MetaDataStrings) { + flag.StringVar(&(dataString.AccountId), "accountId", "", "AWS account Id") +} + func RegisterEnvironmentMetaDataFlags() *MetaDataStrings { registerComputeType(registeredMetaDataStrings) registerECSData(registeredMetaDataStrings) @@ -289,10 +301,12 @@ func RegisterEnvironmentMetaDataFlags() *MetaDataStrings { registerExcludedTests(registeredMetaDataStrings) registerProxyUrl(registeredMetaDataStrings) registerAssumeRoleArn(registeredMetaDataStrings) + registerInstanceArn(registeredMetaDataStrings) registerInstanceId(registeredMetaDataStrings) registerInstancePlatform(registeredMetaDataStrings) registerAgentStartCommand(registeredMetaDataStrings) registerAmpWorkspaceId(registeredMetaDataStrings) + registerAccountId(registeredMetaDataStrings) return registeredMetaDataStrings } @@ -314,6 +328,7 @@ func GetEnvironmentMetaData() *MetaData { metaDataStorage.CaCertPath = registeredMetaDataStrings.CaCertPath metaDataStorage.ProxyUrl = registeredMetaDataStrings.ProxyUrl metaDataStorage.AssumeRoleArn = registeredMetaDataStrings.AssumeRoleArn + metaDataStorage.InstanceArn = registeredMetaDataStrings.InstanceArn metaDataStorage.InstanceId = registeredMetaDataStrings.InstanceId metaDataStorage.InstancePlatform = registeredMetaDataStrings.InstancePlatform metaDataStorage.AgentStartCommand = registeredMetaDataStrings.AgentStartCommand @@ -336,6 +351,7 @@ func GetEnvironmentMetaData() *MetaData { metaDataStorage.PrometheusConfig = registeredMetaDataStrings.PrometheusConfig metaDataStorage.OtelConfig = registeredMetaDataStrings.OtelConfig metaDataStorage.SampleApp = registeredMetaDataStrings.SampleApp + metaDataStorage.AccountId = registeredMetaDataStrings.AccountId return metaDataStorage } diff --git a/generator/test_case_generator.go b/generator/test_case_generator.go index 7258f362d..50f6ff94d 100644 --- a/generator/test_case_generator.go +++ b/generator/test_case_generator.go @@ -109,7 +109,7 @@ var testTypeToTestConfig = map[string][]testConfig{ targets: map[string]map[string]struct{}{"os": {"ol9": {}}}, }, { - testDir: "./test/assume_role", + testDir: "./test/credentials_file", terraformDir: "terraform/ec2/creds", targets: map[string]map[string]struct{}{"os": {"al2": {}}}, }, @@ -121,6 +121,11 @@ var testTypeToTestConfig = map[string][]testConfig{ testDir: "./test/agent_otel_merging", targets: map[string]map[string]struct{}{"os": {"al2": {}}, "arc": {"amd64": {}}}, }, + { + testDir: "./test/assume_role", + terraformDir: "terraform/ec2/assume_role", + targets: map[string]map[string]struct{}{"os": {"al2": {}}}, + }, }, /* You can only place 1 mac instance on a dedicate host a single time. diff --git a/terraform/ec2/assume_role/main.tf b/terraform/ec2/assume_role/main.tf new file mode 100644 index 000000000..a3d9d5de4 --- /dev/null +++ b/terraform/ec2/assume_role/main.tf @@ -0,0 +1,227 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: MIT + +module "common" { + source = "../../common" +} + +module "basic_components" { + source = "../../basic_components" + + region = var.region +} + +data "aws_caller_identity" "account_id" {} + +output "account_id" { + value = data.aws_caller_identity.account_id.account_id +} + +##################################################################### +# Generate EC2 Key Pair for log in access to EC2 +##################################################################### + +resource "tls_private_key" "ssh_key" { + count = var.ssh_key_name == "" ? 1 : 0 + algorithm = "RSA" + rsa_bits = 4096 +} + +resource "aws_key_pair" "aws_ssh_key" { + count = var.ssh_key_name == "" ? 1 : 0 + key_name = "ec2-key-pair-${module.common.testing_id}" + public_key = tls_private_key.ssh_key[0].public_key_openssh +} + +locals { + ssh_key_name = var.ssh_key_name != "" ? var.ssh_key_name : aws_key_pair.aws_ssh_key[0].key_name + private_key_content = var.ssh_key_name != "" ? var.ssh_key_value : tls_private_key.ssh_key[0].private_key_pem + // Canary downloads latest binary. Integration test downloads binary connect to git hash. + binary_uri = var.is_canary ? "${var.s3_bucket}/release/amazon_linux/${var.arc}/latest/${var.binary_name}" : "${var.s3_bucket}/integration-test/binary/${var.cwa_github_sha}/linux/${var.arc}/${var.binary_name}" +} + + +##################################################################### +# Generate EC2 Instance and execute test commands +##################################################################### +resource "aws_instance" "cwagent" { + ami = data.aws_ami.latest.id + instance_type = var.ec2_instance_type + key_name = local.ssh_key_name + iam_instance_profile = module.basic_components.instance_profile + vpc_security_group_ids = [module.basic_components.security_group] + associate_public_ip_address = true + instance_initiated_shutdown_behavior = "terminate" + + metadata_options { + http_endpoint = "enabled" + http_tokens = "required" + } + + tags = { + Name = "cwagent-integ-test-ec2-${var.test_name}-${module.common.testing_id}" + } +} + +##################################################################### +# Generate IAM Roles for Credentials +##################################################################### + +locals { + roles = { + no_context_keys = { + suffix = "" + condition = {} + } + source_arn_key = { + suffix = "-source_arn_key" + condition = { + "aws:SourceArn" = aws_instance.cwagent.arn + } + } + source_account_key = { + suffix = "-source_account_key" + condition = { + "aws:SourceAccount" = data.aws_caller_identity.account_id.account_id + } + } + all_context_keys = { + suffix = "-all_context_keys" + condition = { + "aws:SourceArn" = aws_instance.cwagent.arn + "aws:SourceAccount" = data.aws_caller_identity.account_id.account_id + } + } + } +} + +resource "aws_iam_role" "roles" { + for_each = local.roles + + name = "cwa-integ-assume-role-${module.common.testing_id}${each.value.suffix}" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + AWS = module.basic_components.role_arn + } + Condition = length(each.value.condition) > 0 ? { + StringEquals = each.value.condition + } : {} + } + ] + }) +} + +resource "aws_iam_role_policy" "cloudwatch_policy" { + for_each = aws_iam_role.roles + + name = "${each.value.name}_policy" + role = each.value.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = [ + "cloudwatch:PutMetricData", + "cloudwatch:ListMetrics", + "cloudwatch:GetMetricStatistics", + "cloudwatch:GetMetricData", + "logs:PutRetentionPolicy", + "logs:PutLogEvents", + "logs:GetLogEvents", + "logs:DescribeLogStreams", + "logs:DescribeLogGroups", + "logs:DeleteLogStream", + "logs:DeleteLogGroup", + "logs:CreateLogStream", + "logs:CreateLogGroup", + "ssm:List*", + "ssm:Get*", + "ssm:Describe*", + "s3:PutObject", + "s3:ListBucket", + "s3:GetObjectAcl", + "s3:GetObject" + ] + Effect = "Allow" + Resource = "*" + } + ] + }) +} + +##################################################################### +# Run the integration test +##################################################################### + +resource "null_resource" "integration_test_setup" { + connection { + type = "ssh" + user = var.user + private_key = local.private_key_content + host = aws_instance.cwagent.public_ip + } + + # Prepare Integration Test + provisioner "remote-exec" { + inline = [ + "echo sha ${var.cwa_github_sha}", + "sudo cloud-init status --wait", + "echo clone and install agent", + "git clone --branch ${var.github_test_repo_branch} ${var.github_test_repo}", + "cd amazon-cloudwatch-agent-test", + "aws s3 cp s3://${local.binary_uri} .", + "export PATH=$PATH:/snap/bin:/usr/local/go/bin", + var.install_agent, + ] + } + + depends_on = [ + aws_iam_role.roles, + aws_iam_role_policy.cloudwatch_policy + ] +} + +resource "null_resource" "integration_test_run" { + connection { + type = "ssh" + user = var.user + private_key = local.private_key_content + host = aws_instance.cwagent.public_ip + } + + #Run sanity check and integration test + provisioner "remote-exec" { + inline = [ + "echo prepare environment", + "export AWS_REGION=${var.region}", + "export PATH=$PATH:/snap/bin:/usr/local/go/bin", + "echo run integration test", + "cd ~/amazon-cloudwatch-agent-test", + "echo run sanity test && go test ./test/sanity -p 1 -v", + "echo base assume role arn is ${aws_iam_role.roles["no_context_keys"].arn}", + "go test ${var.test_dir} -p 1 -timeout 1h -computeType=EC2 -bucket=${var.s3_bucket} -assumeRoleArn=${aws_iam_role.roles["no_context_keys"].arn} -instanceArn=${aws_instance.cwagent.arn} -accountId=${data.aws_caller_identity.account_id.account_id} -v" + ] + } + + depends_on = [ + null_resource.integration_test_setup, + ] +} + +data "aws_ami" "latest" { + most_recent = true + + filter { + name = "name" + values = [var.ami] + } +} + + diff --git a/terraform/ec2/assume_role/providers.tf b/terraform/ec2/assume_role/providers.tf new file mode 100644 index 000000000..d8a1f722b --- /dev/null +++ b/terraform/ec2/assume_role/providers.tf @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: MIT + +provider "aws" { + region = var.region +} \ No newline at end of file diff --git a/terraform/ec2/assume_role/variables.tf b/terraform/ec2/assume_role/variables.tf new file mode 100644 index 000000000..503d66523 --- /dev/null +++ b/terraform/ec2/assume_role/variables.tf @@ -0,0 +1,114 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: MIT + +variable "region" { + type = string + default = "us-west-2" +} + +variable "ec2_instance_type" { + type = string + default = "t3a.medium" +} + +variable "ssh_key_name" { + type = string + default = "" +} + +variable "ami" { + type = string + default = "cloudwatch-agent-integration-test-ubuntu*" +} + +variable "ssh_key_value" { + type = string + default = "" +} + +variable "user" { + type = string + default = "" +} + +variable "install_agent" { + description = "go run ./install/install_agent.go deb or go run ./install/install_agent.go rpm" + type = string + default = "go run ./install/install_agent.go rpm" +} + +variable "ca_cert_path" { + type = string + default = "" +} + +variable "arc" { + type = string + default = "amd64" + + validation { + condition = contains(["amd64", "arm64"], var.arc) + error_message = "Valid values for arc are (amd64, arm64)." + } +} + +variable "binary_name" { + type = string + default = "" +} + +variable "local_stack_host_name" { + type = string + default = "localhost.localstack.cloud" +} + +variable "s3_bucket" { + type = string + default = "" +} + +variable "test_name" { + type = string + default = "" +} + +variable "test_dir" { + type = string + default = "" +} + +variable "cwa_github_sha" { + type = string + default = "" +} + +variable "github_test_repo" { + type = string + default = "https://github.com/aws/amazon-cloudwatch-agent-test.git" +} + +variable "github_test_repo_branch" { + type = string + default = "main" +} + +variable "is_canary" { + type = bool + default = false +} + +variable "excluded_tests" { + type = string + default = "" +} + +variable "plugin_tests" { + type = string + default = "" +} + +variable "agent_start" { + description = "default command should be for ec2 with linux" + type = string + default = "sudo /opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl -a fetch-config -m ec2 -s -c " +} \ No newline at end of file diff --git a/terraform/ec2/common/linux/output.tf b/terraform/ec2/common/linux/output.tf index 1487d50b9..d84412d56 100644 --- a/terraform/ec2/common/linux/output.tf +++ b/terraform/ec2/common/linux/output.tf @@ -13,6 +13,10 @@ output "cwagent_id" { value = aws_instance.cwagent.id } +output "cwagent_arn" { + value = aws_instance.cwagent.arn +} + output "proxy_instance_proxy_ip" { value = module.proxy_instance.proxy_ip } diff --git a/test/assume_role/agent_configs/config.json b/test/assume_role/agent_configs/config.json index e3a6c2c19..cfd26b042 100644 --- a/test/assume_role/agent_configs/config.json +++ b/test/assume_role/agent_configs/config.json @@ -1,12 +1,15 @@ { "agent": { "metrics_collection_interval": 15, + "credentials": { + "role_arn": "ROLE_ARN_PLACEHOLDER" + }, "run_as_user": "root", "debug": true, - "logfile": "" + "aws_sdk_log_level": "LogDebugWithHTTPBody" }, "metrics": { - "namespace": "AssumeRoleTest", + "namespace": "NAMESPACE_PLACEHOLDER", "append_dimensions": { "InstanceId": "${aws:InstanceId}" }, diff --git a/test/assume_role/assume_role_test.go b/test/assume_role/assume_role_test.go index cfb46f364..90a820d3b 100644 --- a/test/assume_role/assume_role_test.go +++ b/test/assume_role/assume_role_test.go @@ -8,15 +8,9 @@ package assume_role import ( "testing" - "github.com/aws/amazon-cloudwatch-agent-test/test/status" - "github.com/aws/amazon-cloudwatch-agent-test/test/test_runner" + "github.com/stretchr/testify/suite" ) -func TestAssumeRole(t *testing.T) { - runner := test_runner.TestRunner{TestRunner: &RoleTestRunner{test_runner.BaseTestRunner{}}} - result := runner.Run() - if result.GetStatus() != status.SUCCESSFUL { - t.Fatal("Assume Role Test failed") - result.Print() - } +func TestAssumeRoleTestSuite(t *testing.T) { + suite.Run(t, new(AssumeRoleTestSuite)) } diff --git a/test/assume_role/assume_role_unix.go b/test/assume_role/assume_role_unix.go index d8ec171ef..0241d9446 100644 --- a/test/assume_role/assume_role_unix.go +++ b/test/assume_role/assume_role_unix.go @@ -6,10 +6,16 @@ package assume_role import ( + "bufio" + "fmt" "log" + "os" + "os/exec" + "strings" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/cloudwatch/types" + "github.com/stretchr/testify/suite" "github.com/aws/amazon-cloudwatch-agent-test/environment" "github.com/aws/amazon-cloudwatch-agent-test/test/metric" @@ -20,44 +26,180 @@ import ( ) const ( - namespace = "AssumeRoleTest" - credsDir = "/tmp/.aws" + configOutputPath = "/opt/aws/amazon-cloudwatch-agent/bin/config.json" ) +var metadata *environment.MetaData + func init() { environment.RegisterEnvironmentMetaDataFlags() } -type RoleTestRunner struct { +type AssumeRoleTestSuite struct { + suite.Suite + test_runner.TestSuite +} + +func (suite *AssumeRoleTestSuite) SetupSuite() { + log.Println(">>>> Starting AssumeRoleTestSuite") +} + +func (suite *AssumeRoleTestSuite) TearDownSuite() { + suite.Result.Print() + log.Println(">>>> Finished AssumeRoleTestSuite") +} + +var ( + testRunners []*test_runner.TestRunner = []*test_runner.TestRunner{ + { + TestRunner: &ConfusedDeputyAssumeRoleTestRunner{ + AssumeRoleTestRunner: AssumeRoleTestRunner{ + BaseTestRunner: test_runner.BaseTestRunner{}, + roleSuffix: "-source_arn_key", + name: "SourceArnKeyOnlyTest", + }, + setSourceArnEnvVar: true, + setSourceAccountEnvVar: true, + useIncorrectSourceArn: false, + useIncorrectSourceAccount: false, + expectAssumeRoleFailure: false, + }, + }, + { + TestRunner: &ConfusedDeputyAssumeRoleTestRunner{ + AssumeRoleTestRunner: AssumeRoleTestRunner{ + BaseTestRunner: test_runner.BaseTestRunner{}, + roleSuffix: "-source_account_key", + name: "SourceAccountKeyOnlyTest", + }, + setSourceArnEnvVar: true, + setSourceAccountEnvVar: true, + useIncorrectSourceArn: false, + useIncorrectSourceAccount: false, + expectAssumeRoleFailure: false, + }, + }, + { + TestRunner: &ConfusedDeputyAssumeRoleTestRunner{ + AssumeRoleTestRunner: AssumeRoleTestRunner{ + BaseTestRunner: test_runner.BaseTestRunner{}, + roleSuffix: "-all_context_keys", + name: "AllKeysTest", + }, + setSourceArnEnvVar: true, + setSourceAccountEnvVar: true, + useIncorrectSourceArn: false, + useIncorrectSourceAccount: false, + expectAssumeRoleFailure: false, + }, + }, + { + TestRunner: &ConfusedDeputyAssumeRoleTestRunner{ + AssumeRoleTestRunner: AssumeRoleTestRunner{ + BaseTestRunner: test_runner.BaseTestRunner{}, + roleSuffix: "-source_arn_key", + name: "MissingSourceArnEnvTest", + }, + setSourceArnEnvVar: false, + setSourceAccountEnvVar: true, + useIncorrectSourceArn: false, + useIncorrectSourceAccount: false, + expectAssumeRoleFailure: true, + }, + }, + { + TestRunner: &ConfusedDeputyAssumeRoleTestRunner{ + AssumeRoleTestRunner: AssumeRoleTestRunner{ + BaseTestRunner: test_runner.BaseTestRunner{}, + roleSuffix: "-source_account_key", + name: "MissingSourceAccountEnvTest", + }, + setSourceArnEnvVar: true, + setSourceAccountEnvVar: false, + useIncorrectSourceArn: false, + useIncorrectSourceAccount: false, + expectAssumeRoleFailure: true, + }, + }, + { + TestRunner: &ConfusedDeputyAssumeRoleTestRunner{ + AssumeRoleTestRunner: AssumeRoleTestRunner{ + BaseTestRunner: test_runner.BaseTestRunner{}, + roleSuffix: "-all_context_keys", + name: "ContextKeyMismatchAccountTest", + }, + setSourceArnEnvVar: true, + setSourceAccountEnvVar: true, + useIncorrectSourceArn: false, + useIncorrectSourceAccount: true, + expectAssumeRoleFailure: true, + }, + }, + { + TestRunner: &ConfusedDeputyAssumeRoleTestRunner{ + AssumeRoleTestRunner: AssumeRoleTestRunner{ + BaseTestRunner: test_runner.BaseTestRunner{}, + roleSuffix: "-all_context_keys", + name: "ContextKeyMismatchArnTest", + }, + setSourceArnEnvVar: true, + setSourceAccountEnvVar: true, + useIncorrectSourceArn: true, + useIncorrectSourceAccount: false, + expectAssumeRoleFailure: true, + }, + }, + } +) + +func (suite *AssumeRoleTestSuite) TestAllInSuite() { + metadata = environment.GetEnvironmentMetaData() + + for _, testRunner := range testRunners { + suite.AddToSuiteResult(testRunner.Run()) + } + suite.Assert().Equal(status.SUCCESSFUL, suite.Result.GetStatus(), "Assume Role Test Suite Failed") +} + +type AssumeRoleTestRunner struct { test_runner.BaseTestRunner + + name string + + // terraform will create several roles which all share a base name and have a unique prefix. the base ARN is passed + // in via command line parameter, and the other roles can be referenced by appending a suffix to the base ARN + roleSuffix string } -func (t RoleTestRunner) Validate() status.TestGroupResult { +func (t AssumeRoleTestRunner) Validate() status.TestGroupResult { + return status.TestGroupResult{ + Name: t.GetTestName(), + TestResults: t.validateMetrics(), + } +} + +func (t AssumeRoleTestRunner) validateMetrics() []status.TestResult { metricsToFetch := t.GetMeasuredMetrics() testResults := make([]status.TestResult, len(metricsToFetch)) for i, metricName := range metricsToFetch { testResults[i] = t.validateMetric(metricName) } - - return status.TestGroupResult{ - Name: t.GetTestName(), - TestResults: testResults, - } + return testResults } -func (t *RoleTestRunner) validateMetric(metricName string) status.TestResult { +func (t *AssumeRoleTestRunner) validateMetric(metricName string) status.TestResult { testResult := status.TestResult{ Name: metricName, Status: status.FAILED, } - dims := getDimensions(environment.GetEnvironmentMetaData().InstanceId) + dims := getDimensions() if len(dims) == 0 { return testResult } fetcher := metric.MetricValueFetcher{} - values, err := fetcher.Fetch(namespace, metricName, dims, metric.AVERAGE, metric.HighResolutionStatPeriod) + values, err := fetcher.Fetch(t.GetTestName(), metricName, dims, metric.AVERAGE, metric.HighResolutionStatPeriod) log.Printf("metric values are %v", values) if err != nil { @@ -72,40 +214,57 @@ func (t *RoleTestRunner) validateMetric(metricName string) status.TestResult { return testResult } -func (t RoleTestRunner) GetTestName() string { - return namespace +func (t AssumeRoleTestRunner) GetTestName() string { + return t.name } -func (t RoleTestRunner) GetAgentConfigFileName() string { - return "config.json" +func (t AssumeRoleTestRunner) GetAgentConfigFileName() string { + return "agent_configs/config.json" } -func (t RoleTestRunner) GetMeasuredMetrics() []string { +func (t AssumeRoleTestRunner) GetMeasuredMetrics() []string { return metric.CpuMetrics } -func (t *RoleTestRunner) SetupBeforeAgentRun() error { - err := common.RunCommands(getCommands(environment.GetEnvironmentMetaData().AssumeRoleArn)) - if err != nil { - return err - } - return t.SetUpConfig() +func (t *AssumeRoleTestRunner) SetupBeforeAgentRun() error { + return t.setupAgentConfig() } -var _ test_runner.ITestRunner = (*RoleTestRunner)(nil) +func (t *AssumeRoleTestRunner) getRoleArn() string { + // Role ARN used by these tests assume a basic role name (given by the AssumeRoleArn environment metadata) with + // and optional suffix + return metadata.AssumeRoleArn + t.roleSuffix +} -func getCommands(roleArn string) []string { - return []string{ - "mkdir -p " + credsDir, - "printf '[default]\naws_access_key_id=%s\naws_secret_access_key=%s\naws_session_token=%s' $(aws sts assume-role --role-arn " + roleArn + " --role-session-name test --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]' --output text) | tee " + credsDir + "/credentials>/dev/null", - "printf '[default]\nregion = us-west-2' > " + credsDir + "/config", - "printf '[credentials]\n shared_credential_profile = \"default\"\n shared_credential_file = \"" + credsDir + "/credentials\"' | sudo tee /opt/aws/amazon-cloudwatch-agent/etc/common-config.toml>/dev/null", +func (t *AssumeRoleTestRunner) setupAgentConfig() error { + + log.Printf("Role ARN: %s\n", t.getRoleArn()) + log.Printf("Metric namespace: %s\n", t.GetTestName()) + + // The default agent config file conatins a ROLE_ARN_PLACEHOLDER value which should be replaced with the ARN of the role + // that the agent should assume. The ARN is not known until runtime. Test runner does not have sudo permissions, + // but it can execute sudo commands. Use sed to update the ROLE_ARN_PLACEHOLDER value instead of using built-ins + common.CopyFile(t.AgentConfig.ConfigFileName, configOutputPath) + + sedCmd := fmt.Sprintf("sudo sed -i 's|ROLE_ARN_PLACEHOLDER|%s|g' %s", t.getRoleArn(), configOutputPath) + cmd := exec.Command("bash", "-c", sedCmd) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed replace ROLE_ARN_PLACEHOLDER value: %w", err) } + + sedCmd = fmt.Sprintf("sudo sed -i 's|NAMESPACE_PLACEHOLDER|%s|g' %s", t.GetTestName(), configOutputPath) + cmd = exec.Command("bash", "-c", sedCmd) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed replace NAMESPACE_PLACEHOLDER value: %w", err) + } + + return nil } -func getDimensions(instanceId string) []types.Dimension { - env := environment.GetEnvironmentMetaData() - factory := dimension.GetDimensionFactory(*env) +var _ test_runner.ITestRunner = (*AssumeRoleTestRunner)(nil) + +func getDimensions() []types.Dimension { + factory := dimension.GetDimensionFactory(*metadata) dims, failed := factory.GetDimensions([]dimension.Instruction{ { Key: "InstanceId", @@ -124,6 +283,315 @@ func getDimensions(instanceId string) []types.Dimension { return dims } -func Validate(assumeRoleArn string) error { +type ConfusedDeputyAssumeRoleTestRunner struct { + AssumeRoleTestRunner + + setSourceArnEnvVar bool + useIncorrectSourceArn bool + + setSourceAccountEnvVar bool + useIncorrectSourceAccount bool + + expectAssumeRoleFailure bool +} + +func (t *ConfusedDeputyAssumeRoleTestRunner) GetTestName() string { + return t.name +} + +func (t *ConfusedDeputyAssumeRoleTestRunner) Validate() status.TestGroupResult { + + result := status.TestGroupResult{ + Name: t.GetTestName(), + } + + if t.expectAssumeRoleFailure { + result.TestResults = append(result.TestResults, t.validateNoMetrics()...) + result.TestResults = append(result.TestResults, t.validateAccessDenied()) + } else { + result.TestResults = append(result.TestResults, t.validateMetrics()...) + result.TestResults = append(result.TestResults, t.validateFoundConfusedDeputyHeaders()) + } + + return result +} + +// validateNoMetrics checks that there were no metrics emitted related to the specific test run +func (t *ConfusedDeputyAssumeRoleTestRunner) validateNoMetrics() []status.TestResult { + metricsToFetch := t.GetMeasuredMetrics() + testResults := make([]status.TestResult, len(metricsToFetch)) + for i, metricName := range metricsToFetch { + testResults[i] = t.validateMetricMissing(metricName) + } + return testResults +} + +// validateNoMetrics checks that there were no metric data points for a specific metric related to a specific test run +func (t *AssumeRoleTestRunner) validateMetricMissing(metricName string) status.TestResult { + testResult := status.TestResult{ + Name: metricName, + Status: status.FAILED, + } + + dims := getDimensions() + if len(dims) == 0 { + return testResult + } + + fetcher := metric.MetricValueFetcher{} + values, err := fetcher.Fetch(t.GetTestName(), metricName, dims, metric.AVERAGE, metric.HighResolutionStatPeriod) + if err != nil { + log.Printf("Unable to fetch metrics: %s", err) + return testResult + } + + // fetcher should return no data as the agent should not be able to assume the role it was given + // If there are values, then something went wrong + if len(values) > 0 { + log.Printf("Found %d data values when none were expected\n", len(values)) + return testResult + } + + testResult.Status = status.SUCCESSFUL + return testResult +} + +// validateAccessDenied checks that the agent's STS Assume Role call failed using the agent logs +func (t *ConfusedDeputyAssumeRoleTestRunner) validateAccessDenied() status.TestResult { + + testResult := status.TestResult{ + Name: "access_denied", + Status: status.FAILED, + } + + content, err := os.ReadFile(common.AgentLogFile) + if err != nil { + log.Printf("Unable to open agent log file: %s\n", err) + return testResult + } + + // Check for accsess denied error in the agent log + // + // Example log + // ---[ RESPONSE ]-------------------------------------- + // HTTP/1.1 403 Forbidden + // Content-Length: 444 + // Content-Type: text/xml + // Date: Wed, 20 Nov 2024 22:56:17 GMT + // X-Amzn-Requestid: + // + // + // ----------------------------------------------------- + // 2024-11-20T22:56:17Z I! + // + // Sender + // AccessDenied + // User: arn:aws:sts:::assumed-role// is not authorized to perform: sts:AssumeRole on resource: arn:aws:iam:::role/CloudWatchLogsPusher + // + // + // + if strings.Contains(string(content), fmt.Sprintf("not authorized to perform: sts:AssumeRole on resource: %s", t.getRoleArn())) { + log.Println("Found 'not authorized to perform...' in the file") + testResult.Status = status.SUCCESSFUL + } else { + log.Println("Did not find 'not authorized to perform...' in the file") + testResult.Status = status.FAILED + } + + return testResult +} + +// validateFoundConfusedDeputyHeaders checks that the agent used confued deputy headers in the STS assume role calls +// using the agent's logs +func (t *ConfusedDeputyAssumeRoleTestRunner) validateFoundConfusedDeputyHeaders() status.TestResult { + + testResult := status.TestResult{ + Name: "confused_deputy_headers", + Status: status.FAILED, + } + + file, err := os.Open(common.AgentLogFile) + if err != nil { + log.Printf("Error opening agent log file: %v\n", err) + return testResult + } + defer file.Close() + + scanner := bufio.NewScanner(file) + + inHttpDebug := false + isStsAssumeRoleRequest := false + httpDebugLog := []string{} + + // Example HTTP debug log + // + // ---[ REQUEST POST-SIGN ]----------------------------- + // POST / HTTP/1.1 + // Host: sts.us-west-2.amazonaws.com + // User-Agent: aws-sdk-go/1.48.6 (go1.22.11; linux; arm64) + // Content-Length: 199 + // Authorization: AWS4-HMAC-SHA256 Credential=//us-west-2/sts/aws4_request, SignedHeaders=content-length;content-type;host;x-amz-date;x-amz-security-token;x-amz-source-account;x-amz-source-arn, Signature= + // Content-Type: application/x-www-form-urlencoded; charset=utf-8 + // X-Amz-Date: 20250129T170140Z + // X-Amz-Security-Token: + // X-Amz-Source-Account: 0123456789012 + // X-Amz-Source-Arn: arn:aws:ec2:us-west-2:123456789012:instance/i-1234567890abcdef0 + // Accept-Encoding: gzip + // + // Action=AssumeRole&DurationSeconds=900&RoleArn=arn%3Aaws%3Aiam%3A%3A506463145083%3Arole%2Fcwa-integ-assume-role-5be6d1574e9843bb-all_context_keys&RoleSessionName=1738170071781577224&Version=2011-06-15 + // ----------------------------------------------------- + for scanner.Scan() { + line := scanner.Text() + + // Look for the start of an HTTP request debug log + if strings.Contains(line, "---[ REQUEST POST-SIGN ]-----------------------------") { + inHttpDebug = true + httpDebugLog = []string{} + isStsAssumeRoleRequest = false + continue + } + + // Ignore anything thats not part of an HTTP request debug log + if !inHttpDebug { + continue + } + + httpDebugLog = append(httpDebugLog, line) + + if strings.Contains(line, "Action=AssumeRole") { + isStsAssumeRoleRequest = true + } + + // Look for the end of an HTTP request debug log + if strings.Contains(line, "-----------------------------------------------------") { + + if isStsAssumeRoleRequest && checkForConfusedDeputyHeaders(httpDebugLog) { + log.Println("Found confused deputy headers in the HTTP debug log") + testResult.Status = status.SUCCESSFUL + return testResult + } + + // Reset the search + inHttpDebug = false + isStsAssumeRoleRequest = false + httpDebugLog = []string{} + } + + } + + if err := scanner.Err(); err != nil { + log.Printf("Error reading file: %v\n", err) + } + + return testResult +} + +// checkForConfusedDeputyHeaders checks for the presence of the confused deputy headers in the HTTP debug log +func checkForConfusedDeputyHeaders(httpDebugLog []string) bool { + foundSourceAccount := false + foundSourceArn := false + for _, line := range httpDebugLog { + if strings.Contains(line, fmt.Sprintf("X-Amz-Source-Account: %s", metadata.AccountId)) { + log.Println("Found X-Amz-Source-Account in the HTTP Debug Log") + foundSourceAccount = true + } + if strings.Contains(line, fmt.Sprintf("X-Amz-Source-Arn: %s", metadata.InstanceArn)) { + log.Println("Found X-Amz-Source-Arn in the HTTP Debug Log") + foundSourceArn = true + } + } + + return foundSourceAccount && foundSourceArn +} + +func (t *ConfusedDeputyAssumeRoleTestRunner) SetupBeforeAgentRun() error { + err := t.setupEnvironmentVariables() + if err != nil { + return fmt.Errorf("failed to setup environment variables: %w", err) + } + + // Clear out log file since we'll need to check the logs on each run and we don't want logs from another test + // being checked + common.RecreateAgentLogfile(common.AgentLogFile) + + return t.setupAgentConfig() +} + +// setupEnvironmentVariables sets the agent's environment variables using the systemd service file +func (t *ConfusedDeputyAssumeRoleTestRunner) setupEnvironmentVariables() error { + + // Set or remove the environment variables in the service file + common.CopyFile("service_configs/amazon-cloudwatch-agent.service", "/etc/systemd/system/amazon-cloudwatch-agent.service") + + if t.setSourceAccountEnvVar { + sourceAccount := metadata.AccountId + if t.useIncorrectSourceAccount { + sourceAccount = "123456789012" + } + + log.Printf("AMZ_SOURCE_ACCOUNT: %s\n", sourceAccount) + + sedCmd := fmt.Sprintf("sudo sed -i 's|ACCOUNT_PLACEHOLDER|%s|g' /etc/systemd/system/amazon-cloudwatch-agent.service", sourceAccount) + cmd := exec.Command("bash", "-c", sedCmd) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to replace AMZ_SOURCE_ACCOUNT value: %w", err) + } + } else { + log.Println("Removing AMZ_SOURCE_ACCOUNT from service file") + + sedCmd := "sudo sed -i '/AMZ_SOURCE_ACCOUNT/d' /etc/systemd/system/amazon-cloudwatch-agent.service" + cmd := exec.Command("bash", "-c", sedCmd) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed remove PLACEHOLDER value: %w", err) + } + } + + if t.setSourceArnEnvVar { + sourceArn := metadata.InstanceArn + if t.useIncorrectSourceArn { + sourceArn = "arn:aws:ec2:us-west-2:123456789012:instance/i-1234567890abcdef0" + } + + log.Printf("AMZ_SOURCE_ARN: %s\n", sourceArn) + + sedCmd := fmt.Sprintf("sudo sed -i 's|ARN_PLACEHOLDER|%s|g' /etc/systemd/system/amazon-cloudwatch-agent.service", sourceArn) + cmd := exec.Command("bash", "-c", sedCmd) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to replace AMZ_SOURCE_ARN value: %w", err) + } + + } else { + log.Println("Removing AMZ_SOURCE_ARN from service file") + + sedCmd := "sudo sed -i '/AMZ_SOURCE_ARN/d' /etc/systemd/system/amazon-cloudwatch-agent.service" + cmd := exec.Command("bash", "-c", sedCmd) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to remove AMZ_SOURCE_ARN value: %w", err) + } + } + + err := t.daemonReload() + if err != nil { + return err + } + + return nil +} + +func (t *ConfusedDeputyAssumeRoleTestRunner) daemonReload() error { + cmd := exec.Command("sudo", "systemctl", "daemon-reload") + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("failed to daemon-reload: %w; command output: %s", err, string(output)) + } + return nil +} + +func (t *ConfusedDeputyAssumeRoleTestRunner) clearLogFile() error { + cmd := exec.Command("sudo", "rm", common.AgentLogFile) + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("failed to clear log file: %w; command output: %s", err, string(output)) + } return nil } diff --git a/test/assume_role/service_configs/amazon-cloudwatch-agent.service b/test/assume_role/service_configs/amazon-cloudwatch-agent.service new file mode 100644 index 000000000..0a539e73d --- /dev/null +++ b/test/assume_role/service_configs/amazon-cloudwatch-agent.service @@ -0,0 +1,24 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT + +# Location: /etc/systemd/system/amazon-cloudwatch-agent.service +# systemctl enable amazon-cloudwatch-agent +# systemctl start amazon-cloudwatch-agent +# systemctl | grep amazon-cloudwatch-agent +# https://www.freedesktop.org/software/systemd/man/systemd.unit.html + +[Unit] +Description=Amazon CloudWatch Agent +After=network.target + +[Service] +Type=simple +ExecStart=/opt/aws/amazon-cloudwatch-agent/bin/start-amazon-cloudwatch-agent +KillMode=process +Restart=on-failure +RestartSec=60s +Environment="AMZ_SOURCE_ACCOUNT=ACCOUNT_PLACEHOLDER" +Environment="AMZ_SOURCE_ARN=ARN_PLACEHOLDER" + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/test/assume_role/agent_config.json b/test/credentials_file/agent_config.json similarity index 95% rename from test/assume_role/agent_config.json rename to test/credentials_file/agent_config.json index 42eef0608..eaf07f3bf 100644 --- a/test/assume_role/agent_config.json +++ b/test/credentials_file/agent_config.json @@ -6,7 +6,7 @@ "logfile": "" }, "metrics": { - "namespace": "AssumeRoleTest", + "namespace": "CredentialsFileTest", "append_dimensions": { "InstanceId": "${aws:InstanceId}" }, diff --git a/test/credentials_file/agent_configs/config.json b/test/credentials_file/agent_configs/config.json new file mode 100644 index 000000000..626417df7 --- /dev/null +++ b/test/credentials_file/agent_configs/config.json @@ -0,0 +1,26 @@ +{ + "agent": { + "metrics_collection_interval": 15, + "run_as_user": "root", + "debug": true, + "logfile": "" + }, + "metrics": { + "namespace": "CredentialsFileTest", + "append_dimensions": { + "InstanceId": "${aws:InstanceId}" + }, + "metrics_collected": { + "cpu": { + "measurement": [ + "time_active", "time_guest", "time_guest_nice", "time_idle", "time_iowait", "time_irq", + "time_nice", "time_softirq", "time_steal", "time_system", "time_user", + "usage_active", "usage_guest", "usage_guest_nice", "usage_idle", "usage_iowait", "usage_irq", + "usage_nice", "usage_softirq", "usage_steal", "usage_system", "usage_user" + ], + "metrics_collection_interval": 1 + } + }, + "force_flush_interval": 5 + } +} \ No newline at end of file diff --git a/test/credentials_file/credentials_file_test.go b/test/credentials_file/credentials_file_test.go new file mode 100644 index 000000000..d584b40d0 --- /dev/null +++ b/test/credentials_file/credentials_file_test.go @@ -0,0 +1,22 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: MIT + +//go:build !windows + +package credentials_file + +import ( + "testing" + + "github.com/aws/amazon-cloudwatch-agent-test/test/status" + "github.com/aws/amazon-cloudwatch-agent-test/test/test_runner" +) + +func TestAssumeRole(t *testing.T) { + runner := test_runner.TestRunner{TestRunner: &CredentialsFileTestRunner{test_runner.BaseTestRunner{}}} + result := runner.Run() + if result.GetStatus() != status.SUCCESSFUL { + t.Fatal("Credentials File Test failed") + result.Print() + } +} diff --git a/test/credentials_file/credentials_file_unix.go b/test/credentials_file/credentials_file_unix.go new file mode 100644 index 000000000..ec20711e9 --- /dev/null +++ b/test/credentials_file/credentials_file_unix.go @@ -0,0 +1,129 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: MIT + +//go:build !windows + +package credentials_file + +import ( + "log" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/cloudwatch/types" + + "github.com/aws/amazon-cloudwatch-agent-test/environment" + "github.com/aws/amazon-cloudwatch-agent-test/test/metric" + "github.com/aws/amazon-cloudwatch-agent-test/test/metric/dimension" + "github.com/aws/amazon-cloudwatch-agent-test/test/status" + "github.com/aws/amazon-cloudwatch-agent-test/test/test_runner" + "github.com/aws/amazon-cloudwatch-agent-test/util/common" +) + +const ( + namespace = "CredentialsFileTest" + credsDir = "/tmp/.aws" +) + +func init() { + environment.RegisterEnvironmentMetaDataFlags() +} + +type CredentialsFileTestRunner struct { + test_runner.BaseTestRunner +} + +func (t CredentialsFileTestRunner) Validate() status.TestGroupResult { + metricsToFetch := t.GetMeasuredMetrics() + testResults := make([]status.TestResult, len(metricsToFetch)) + for i, metricName := range metricsToFetch { + testResults[i] = t.validateMetric(metricName) + } + + return status.TestGroupResult{ + Name: t.GetTestName(), + TestResults: testResults, + } +} + +func (t *CredentialsFileTestRunner) validateMetric(metricName string) status.TestResult { + testResult := status.TestResult{ + Name: metricName, + Status: status.FAILED, + } + + dims := getDimensions(environment.GetEnvironmentMetaData().InstanceId) + if len(dims) == 0 { + return testResult + } + + fetcher := metric.MetricValueFetcher{} + values, err := fetcher.Fetch(namespace, metricName, dims, metric.AVERAGE, metric.HighResolutionStatPeriod) + + log.Printf("metric values are %v", values) + if err != nil { + return testResult + } + + if !metric.IsAllValuesGreaterThanOrEqualToExpectedValue(metricName, values, 0) { + return testResult + } + + testResult.Status = status.SUCCESSFUL + return testResult +} + +func (t CredentialsFileTestRunner) GetTestName() string { + return namespace +} + +func (t CredentialsFileTestRunner) GetAgentConfigFileName() string { + return "config.json" +} + +func (t CredentialsFileTestRunner) GetMeasuredMetrics() []string { + return metric.CpuMetrics +} + +func (t *CredentialsFileTestRunner) SetupBeforeAgentRun() error { + err := common.RunCommands(getCommands(environment.GetEnvironmentMetaData().AssumeRoleArn)) + if err != nil { + return err + } + return t.SetUpConfig() +} + +var _ test_runner.ITestRunner = (*CredentialsFileTestRunner)(nil) + +func getCommands(roleArn string) []string { + return []string{ + "mkdir -p " + credsDir, + "printf '[default]\naws_access_key_id=%s\naws_secret_access_key=%s\naws_session_token=%s' $(aws sts assume-role --role-arn " + roleArn + " --role-session-name test --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]' --output text) | tee " + credsDir + "/credentials>/dev/null", + "printf '[default]\nregion = us-west-2' > " + credsDir + "/config", + "printf '[credentials]\n shared_credential_profile = \"default\"\n shared_credential_file = \"" + credsDir + "/credentials\"' | sudo tee /opt/aws/amazon-cloudwatch-agent/etc/common-config.toml>/dev/null", + } +} + +func getDimensions(instanceId string) []types.Dimension { + env := environment.GetEnvironmentMetaData() + factory := dimension.GetDimensionFactory(*env) + dims, failed := factory.GetDimensions([]dimension.Instruction{ + { + Key: "InstanceId", + Value: dimension.UnknownDimensionValue(), + }, + { + Key: "cpu", + Value: dimension.ExpectedDimensionValue{Value: aws.String("cpu-total")}, + }, + }) + + if len(failed) > 0 { + return []types.Dimension{} + } + + return dims +} + +func Validate(assumeRoleArn string) error { + return nil +} diff --git a/test/assume_role/assume_role_windows.go b/test/credentials_file/credentials_file_windows.go similarity index 99% rename from test/assume_role/assume_role_windows.go rename to test/credentials_file/credentials_file_windows.go index 76a8409a8..99870bac9 100644 --- a/test/assume_role/assume_role_windows.go +++ b/test/credentials_file/credentials_file_windows.go @@ -4,7 +4,7 @@ //go:build windows // +build windows -package assume_role +package credentials_file import ( "log" diff --git a/test/assume_role/parameters.yml b/test/credentials_file/parameters.yml similarity index 100% rename from test/assume_role/parameters.yml rename to test/credentials_file/parameters.yml