From d537643b67fe62433464793ac98775401efc3315 Mon Sep 17 00:00:00 2001 From: Moez Ezzeddine Date: Mon, 16 Feb 2026 10:44:37 +0100 Subject: [PATCH 1/3] Add SecurityAudit policy attachment to agentless scanning templates Enhance the existing agentless API call Lambda to ensure the SecurityAudit AWS managed policy is attached to the Datadog integration role during stack creation. This is required for agentless scanning to work, even when CSPM or Resource Collection are not explicitly enabled. Co-Authored-By: Claude Opus 4.6 --- aws_quickstart/datadog_agentless_api_call.py | 25 ++++++ .../datadog_agentless_api_call_test.py | 89 +++++++++++++++++++ .../datadog_agentless_delegate_role.yaml | 34 +++++++ ...adog_agentless_delegate_role_stackset.yaml | 34 +++++++ .../datadog_agentless_scanning.yaml | 23 +++++ 5 files changed, 205 insertions(+) diff --git a/aws_quickstart/datadog_agentless_api_call.py b/aws_quickstart/datadog_agentless_api_call.py index 79f04c69..fd99b914 100644 --- a/aws_quickstart/datadog_agentless_api_call.py +++ b/aws_quickstart/datadog_agentless_api_call.py @@ -6,6 +6,8 @@ from urllib.request import build_opener, HTTPHandler, HTTPError, Request import urllib.parse +import boto3 + LOGGER = logging.getLogger() @@ -112,11 +114,34 @@ def is_agentless_scanning_enabled(url_account, headers): return True +def ensure_security_audit_policy(role_name, partition): + """Ensure the SecurityAudit policy is attached to the integration role.""" + if not role_name: + LOGGER.info("No integration role name provided, skipping SecurityAudit policy attachment.") + return + + policy_arn = f"arn:{partition}:iam::aws:policy/SecurityAudit" + iam = boto3.client("iam") + + paginator = iam.get_paginator("list_attached_role_policies") + for page in paginator.paginate(RoleName=role_name): + for policy in page["AttachedPolicies"]: + if policy["PolicyArn"] == policy_arn: + LOGGER.info("SecurityAudit policy is already attached to role %s.", role_name) + return + + LOGGER.info("Attaching SecurityAudit policy to role %s.", role_name) + iam.attach_role_policy(RoleName=role_name, PolicyArn=policy_arn) + + def handler(event, context): """Handle Lambda event from AWS""" try: if event["RequestType"] == "Create": LOGGER.info("Received Create request.") + role_name = event["ResourceProperties"].get("IntegrationRoleName", "") + partition = event["ResourceProperties"].get("Partition", "aws") + ensure_security_audit_policy(role_name, partition) response = call_datadog_agentless_api(context, event, "POST") send_response( event, diff --git a/aws_quickstart/datadog_agentless_api_call_test.py b/aws_quickstart/datadog_agentless_api_call_test.py index c7a05cc2..70e37ff9 100644 --- a/aws_quickstart/datadog_agentless_api_call_test.py +++ b/aws_quickstart/datadog_agentless_api_call_test.py @@ -10,6 +10,7 @@ from datadog_agentless_api_call import ( call_datadog_agentless_api, is_agentless_scanning_enabled, + ensure_security_audit_policy, ) @@ -246,5 +247,93 @@ def test_other_error_raises_exception(self, mock_urlopen): is_agentless_scanning_enabled(self.url, self.headers) +class TestEnsureSecurityAuditPolicy(unittest.TestCase): + """Test cases for ensure_security_audit_policy function""" + + def setUp(self): + """Set up test fixtures""" + self.role_name = "DatadogIntegrationRole" + self.partition = "aws" + self.policy_arn = "arn:aws:iam::aws:policy/SecurityAudit" + + @patch("datadog_agentless_api_call.boto3.client") + def test_policy_already_attached(self, mock_boto3_client): + """Test that function skips attachment when SecurityAudit is already attached""" + mock_iam = Mock() + mock_boto3_client.return_value = mock_iam + mock_paginator = Mock() + mock_iam.get_paginator.return_value = mock_paginator + mock_paginator.paginate.return_value = [ + { + "AttachedPolicies": [ + {"PolicyName": "SecurityAudit", "PolicyArn": self.policy_arn}, + ] + } + ] + + ensure_security_audit_policy(self.role_name, self.partition) + + mock_iam.attach_role_policy.assert_not_called() + + @patch("datadog_agentless_api_call.boto3.client") + def test_policy_not_attached(self, mock_boto3_client): + """Test that function attaches SecurityAudit when it is not present""" + mock_iam = Mock() + mock_boto3_client.return_value = mock_iam + mock_paginator = Mock() + mock_iam.get_paginator.return_value = mock_paginator + mock_paginator.paginate.return_value = [ + { + "AttachedPolicies": [ + {"PolicyName": "OtherPolicy", "PolicyArn": "arn:aws:iam::aws:policy/OtherPolicy"}, + ] + } + ] + + ensure_security_audit_policy(self.role_name, self.partition) + + mock_iam.attach_role_policy.assert_called_once_with( + RoleName=self.role_name, + PolicyArn=self.policy_arn, + ) + + @patch("datadog_agentless_api_call.boto3.client") + def test_empty_role_name_skips(self, mock_boto3_client): + """Test that function skips when role name is empty""" + ensure_security_audit_policy("", self.partition) + + mock_boto3_client.assert_not_called() + + @patch("datadog_agentless_api_call.boto3.client") + def test_error_propagates(self, mock_boto3_client): + """Test that IAM errors propagate to the caller""" + mock_iam = Mock() + mock_boto3_client.return_value = mock_iam + mock_paginator = Mock() + mock_iam.get_paginator.return_value = mock_paginator + mock_paginator.paginate.side_effect = Exception("IAM error") + + with self.assertRaises(Exception): + ensure_security_audit_policy(self.role_name, self.partition) + + @patch("datadog_agentless_api_call.boto3.client") + def test_govcloud_partition(self, mock_boto3_client): + """Test that function uses the correct partition for GovCloud""" + mock_iam = Mock() + mock_boto3_client.return_value = mock_iam + mock_paginator = Mock() + mock_iam.get_paginator.return_value = mock_paginator + mock_paginator.paginate.return_value = [ + {"AttachedPolicies": []} + ] + + ensure_security_audit_policy(self.role_name, "aws-us-gov") + + mock_iam.attach_role_policy.assert_called_once_with( + RoleName=self.role_name, + PolicyArn="arn:aws-us-gov:iam::aws:policy/SecurityAudit", + ) + + if __name__ == "__main__": unittest.main() diff --git a/aws_quickstart/datadog_agentless_delegate_role.yaml b/aws_quickstart/datadog_agentless_delegate_role.yaml index d7c0c76a..c9439098 100644 --- a/aws_quickstart/datadog_agentless_delegate_role.yaml +++ b/aws_quickstart/datadog_agentless_delegate_role.yaml @@ -74,10 +74,19 @@ Parameters: Description: The name of the role assumed by the Datadog Agentless Scanner Default: DatadogAgentlessScannerDelegateRole + DatadogIntegrationRoleName: + Type: String + Description: The name of IAM role used by the Datadog AWS integration. If provided, the SecurityAudit policy will be attached to this role. + Default: '' + Conditions: DSPMEnabled: !Equals - !Ref 'AgentlessSensitiveDataScanning' - 'true' + AttachSecurityAuditPolicy: !Not + - !Equals + - !Ref 'DatadogIntegrationRoleName' + - '' Rules: MustMatchAccountId: @@ -313,6 +322,28 @@ Resources: ManagedPolicyArns: - !Sub "arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + SecurityAuditPolicyAttachmentPermissions: + Type: AWS::IAM::RolePolicy + Condition: AttachSecurityAuditPolicy + Properties: + RoleName: !Ref LambdaExecutionRoleDatadogAgentlessAPICall + PolicyName: SecurityAuditPolicyAttachmentPermissions + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - iam:ListAttachedRolePolicies + Resource: !Sub "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/${DatadogIntegrationRoleName}" + - Effect: Allow + Action: + - iam:AttachRolePolicy + Resource: !Sub "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/${DatadogIntegrationRoleName}" + Condition: + ArnEquals: + 'iam:PolicyARN': + - !Sub "arn:${AWS::Partition}:iam::aws:policy/SecurityAudit" + DatadogAgentlessAPICall: Type: "Custom::DatadogAgentlessAPICall" Properties: @@ -326,6 +357,8 @@ Resources: Containers: !Ref "AgentlessContainerScanning" Lambdas: !Ref "AgentlessLambdaScanning" SensitiveData: !Ref "AgentlessSensitiveDataScanning" + IntegrationRoleName: !Ref "DatadogIntegrationRoleName" + Partition: !Ref "AWS::Partition" # Optional parameters DelegateRoleArn: !GetAtt "ScannerDelegateRole.Arn" OrchestratorPolicyArn: !Ref "ScannerDelegateRoleOrchestratorPolicy" @@ -355,6 +388,7 @@ Metadata: Parameters: - ScannerInstanceRoleARN - ScannerDelegateRoleName + - DatadogIntegrationRoleName - Label: default: "Advanced" Parameters: diff --git a/aws_quickstart/datadog_agentless_delegate_role_stackset.yaml b/aws_quickstart/datadog_agentless_delegate_role_stackset.yaml index dcf94998..951f2b7c 100644 --- a/aws_quickstart/datadog_agentless_delegate_role_stackset.yaml +++ b/aws_quickstart/datadog_agentless_delegate_role_stackset.yaml @@ -52,10 +52,19 @@ Parameters: Description: Enable Agentless Scanning of datastores (S3 buckets). Default: false + DatadogIntegrationRoleName: + Type: String + Description: The name of IAM role used by the Datadog AWS integration. If provided, the SecurityAudit policy will be attached to this role. + Default: '' + Conditions: DSPMEnabled: !Equals - !Ref 'AgentlessSensitiveDataScanning' - 'true' + AttachSecurityAuditPolicy: !Not + - !Equals + - !Ref 'DatadogIntegrationRoleName' + - '' Resources: ScannerDelegateRoleOrchestratorPolicy: @@ -281,6 +290,28 @@ Resources: ManagedPolicyArns: - !Sub "arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + SecurityAuditPolicyAttachmentPermissions: + Type: AWS::IAM::RolePolicy + Condition: AttachSecurityAuditPolicy + Properties: + RoleName: !Ref LambdaExecutionRoleDatadogAgentlessAPICall + PolicyName: SecurityAuditPolicyAttachmentPermissions + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - iam:ListAttachedRolePolicies + Resource: !Sub "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/${DatadogIntegrationRoleName}" + - Effect: Allow + Action: + - iam:AttachRolePolicy + Resource: !Sub "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/${DatadogIntegrationRoleName}" + Condition: + ArnEquals: + 'iam:PolicyARN': + - !Sub "arn:${AWS::Partition}:iam::aws:policy/SecurityAudit" + DatadogAgentlessAPICall: Type: "Custom::DatadogAgentlessAPICall" Properties: @@ -294,6 +325,8 @@ Resources: Containers: !Ref "AgentlessVulnerabilityScanning" Lambdas: !Ref "AgentlessVulnerabilityScanning" SensitiveData: !Ref "AgentlessSensitiveDataScanning" + IntegrationRoleName: !Ref "DatadogIntegrationRoleName" + Partition: !Ref "AWS::Partition" # Optional parameters DelegateRoleArn: !GetAtt "ScannerDelegateRole.Arn" OrchestratorPolicyArn: !Ref "ScannerDelegateRoleOrchestratorPolicy" @@ -352,6 +385,7 @@ Metadata: - DatadogAPIKey - DatadogAPPKey - DatadogSite + - DatadogIntegrationRoleName - Label: default: "Scanning Options" Parameters: diff --git a/aws_quickstart/datadog_agentless_scanning.yaml b/aws_quickstart/datadog_agentless_scanning.yaml index 3b51386f..c63af2d4 100644 --- a/aws_quickstart/datadog_agentless_scanning.yaml +++ b/aws_quickstart/datadog_agentless_scanning.yaml @@ -1043,6 +1043,27 @@ Resources: ManagedPolicyArns: - !Sub "arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + SecurityAuditPolicyAttachmentPermissions: + Type: AWS::IAM::RolePolicy + Properties: + RoleName: !Ref LambdaExecutionRoleDatadogAgentlessAPICall + PolicyName: SecurityAuditPolicyAttachmentPermissions + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - iam:ListAttachedRolePolicies + Resource: !Sub "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/${DatadogIntegrationRoleName}" + - Effect: Allow + Action: + - iam:AttachRolePolicy + Resource: !Sub "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/${DatadogIntegrationRoleName}" + Condition: + ArnEquals: + 'iam:PolicyARN': + - !Sub "arn:${AWS::Partition}:iam::aws:policy/SecurityAudit" + # Retrieving secrets passed in via SecretsManager Arn DatadogAgentlessAPICall: Type: "Custom::DatadogAgentlessAPICall" @@ -1057,6 +1078,8 @@ Resources: Containers: !Ref "AgentlessContainerScanning" Lambdas: !Ref "AgentlessLambdaScanning" SensitiveData: !Ref "AgentlessSensitiveDataScanning" + IntegrationRoleName: !Ref "DatadogIntegrationRoleName" + Partition: !Ref "AWS::Partition" # Optional parameters LaunchTemplateId: !Ref "ScannerLaunchTemplate" AutoScalingGroupArn: !GetAtt "ScannerAutoScalingGroup.AutoScalingGroupARN" From 1c5540470f79435c88132f1bff289461c6b7c58d Mon Sep 17 00:00:00 2001 From: Moez Ezzeddine Date: Mon, 16 Feb 2026 11:01:07 +0100 Subject: [PATCH 2/3] Move boto3 import inside function to fix CI boto3 is available in the Lambda runtime but not in CI. Move the import inside ensure_security_audit_policy so the module loads without boto3, and mock it in tests via sys.modules. Co-Authored-By: Claude Opus 4.6 --- aws_quickstart/datadog_agentless_api_call.py | 4 ++-- .../datadog_agentless_api_call_test.py | 18 ++++++++++++------ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/aws_quickstart/datadog_agentless_api_call.py b/aws_quickstart/datadog_agentless_api_call.py index fd99b914..2aced84f 100644 --- a/aws_quickstart/datadog_agentless_api_call.py +++ b/aws_quickstart/datadog_agentless_api_call.py @@ -6,8 +6,6 @@ from urllib.request import build_opener, HTTPHandler, HTTPError, Request import urllib.parse -import boto3 - LOGGER = logging.getLogger() @@ -120,6 +118,8 @@ def ensure_security_audit_policy(role_name, partition): LOGGER.info("No integration role name provided, skipping SecurityAudit policy attachment.") return + import boto3 + policy_arn = f"arn:{partition}:iam::aws:policy/SecurityAudit" iam = boto3.client("iam") diff --git a/aws_quickstart/datadog_agentless_api_call_test.py b/aws_quickstart/datadog_agentless_api_call_test.py index 70e37ff9..384e2149 100644 --- a/aws_quickstart/datadog_agentless_api_call_test.py +++ b/aws_quickstart/datadog_agentless_api_call_test.py @@ -1,11 +1,17 @@ #!/usr/bin/env python3 import json +import sys import unittest from types import SimpleNamespace -from unittest.mock import patch, Mock +from unittest.mock import patch, Mock, MagicMock from urllib.error import HTTPError +# boto3 is available in Lambda runtime but not necessarily in CI. +# Insert a mock module so the lazy import inside ensure_security_audit_policy works. +if "boto3" not in sys.modules: + sys.modules["boto3"] = MagicMock() + # Import the functions to test from datadog_agentless_api_call import ( call_datadog_agentless_api, @@ -256,7 +262,7 @@ def setUp(self): self.partition = "aws" self.policy_arn = "arn:aws:iam::aws:policy/SecurityAudit" - @patch("datadog_agentless_api_call.boto3.client") + @patch("boto3.client") def test_policy_already_attached(self, mock_boto3_client): """Test that function skips attachment when SecurityAudit is already attached""" mock_iam = Mock() @@ -275,7 +281,7 @@ def test_policy_already_attached(self, mock_boto3_client): mock_iam.attach_role_policy.assert_not_called() - @patch("datadog_agentless_api_call.boto3.client") + @patch("boto3.client") def test_policy_not_attached(self, mock_boto3_client): """Test that function attaches SecurityAudit when it is not present""" mock_iam = Mock() @@ -297,14 +303,14 @@ def test_policy_not_attached(self, mock_boto3_client): PolicyArn=self.policy_arn, ) - @patch("datadog_agentless_api_call.boto3.client") + @patch("boto3.client") def test_empty_role_name_skips(self, mock_boto3_client): """Test that function skips when role name is empty""" ensure_security_audit_policy("", self.partition) mock_boto3_client.assert_not_called() - @patch("datadog_agentless_api_call.boto3.client") + @patch("boto3.client") def test_error_propagates(self, mock_boto3_client): """Test that IAM errors propagate to the caller""" mock_iam = Mock() @@ -316,7 +322,7 @@ def test_error_propagates(self, mock_boto3_client): with self.assertRaises(Exception): ensure_security_audit_policy(self.role_name, self.partition) - @patch("datadog_agentless_api_call.boto3.client") + @patch("boto3.client") def test_govcloud_partition(self, mock_boto3_client): """Test that function uses the correct partition for GovCloud""" mock_iam = Mock() From f103ee6f2c115462f8a2f763c64ae73feb5a603c Mon Sep 17 00:00:00 2001 From: Moez Ezzeddine Date: Mon, 16 Feb 2026 11:23:10 +0100 Subject: [PATCH 3/3] Bump aws_quickstart version to v4.6.0 Co-Authored-By: Claude Opus 4.6 --- aws_quickstart/version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_quickstart/version.txt b/aws_quickstart/version.txt index af6f175b..2707569b 100644 --- a/aws_quickstart/version.txt +++ b/aws_quickstart/version.txt @@ -1 +1 @@ -v4.5.1 +v4.6.0