The AWS Console is really handy to get something up and running in the prototyping stage of a project. But often times the temptation is there to just take the 'ok, thats working now, phew!' approach and move on. Inevitably, 6 months or more down the track you've got to either recreate a similar resource or redeploy the same resource again, and despite being sure you'd remember all the little aspects, you've got to go on the voyage of rediscovery all over again to re-identify all the little settings you need.

Because I have the memory of a goldfish, I prefer to take a 'Infrastructure as Code' approach, even for personal side projects. So, here's a walk through how I wrote up a Cloudformation template to deploy the Prowler open source security scanner as a Docker container in AWS Elastic Container Service (ECS) using Fargate.

The end goal is for a scheduled ECS task that will periodically scan my AWS account using the Prowler security tool that will write its report output to an S3 bucket and send new findings to AWS Security Hub to be actioned.

To begin with, we'll set up a couple of Parameters that will be used to provide inputs for the resources that will be created so names are created consistently, as well as some parameters for configuration settings that we might want to be able to easily change on redeployment of the stack if necessary.

AWSTemplateFormatVersion: '2010-09-09'
Description: A stack for deploying the containerized prowler security scanner in AWS ECS Fargate.

Parameters:
  ServiceName:
    Type: String
    Default: prowler-scanner
    Description: A name for the service
  ECRRepository:
    Type: String
    Default: prowler-repository
    Description: The name of the ECR repository where the docker image is stored
                        Note that this repository needs to already exist, with an image tagged appropriately
                        already pushed to this repository.
  ECRTag:
    Type: String
    Default: latest-prowler
    Description: The tag of the container image to use
  ContainerCpu:
    Type: Number
    Default: 1024
    Description: How much CPU to give the container. 1024 is 1 CPU
  ContainerMemory:
    Type: Number
    Default: 4096
    Description: How much memory in megabytes to give the container
  DesiredCount:
    Type: Number
    Default: 1
    Description: How many copies of the service task to run
  S3BucketName:
    Type: String
    Default: 'log-archive' 
    Description: The S3 bucket name for the scan output files.
                        This bucket needs to already exist - it is not created in this template.

The first resource to create is a log group that the ECS tasks can log to. Rather simple & straight forward. Be sure to add a retention period that works for you - too long and you'll be keeping logs (and paying for them) longer than necessary.

Resources:

  # ECS log group
  LogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub '/ecs/${ServiceName}'
      RetentionInDays: 14

Next is to create the ECS cluster. Again, not particularly complex. One thing is to enable Container Insights. This is one of the checks done by AWS Security Hub, so enable it to get a pass.

  # ECS Resources
  ECSCluster:
    Type: AWS::ECS::Cluster
    Properties:
      ClusterName: !Sub '${ServiceName}-cluster'
      ClusterSettings:
        - Name: containerInsights
          Value: enabled

Next, a security group is defined that will be used by the ECS task. Only HTTPS outgoing is required.

  ECSContainerSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupName: !Sub '${ServiceName}-sg'
      GroupDescription: HTTPS outbound to call AWS API
      VpcId: !ImportValue 'vpc-id'
      SecurityGroupEgress:
        - IpProtocol: TCP
          FromPort: 443
          ToPort: 443
          CidrIp: 0.0.0.0/0

Note some of these properties import values (!ImportValue) from Cloudformation Exports. These have been created previously by the VPC stack deployed as part of Building out a simple AWS VPC. Where you see any !ImportValue calls, either change these to either new parameters added to the template, or substitute as appropriate for your own VPC if necessary.

Next is an IAM Role that allows the ECS task agent to access necessary AWS resources to be able to fetch the ECR image and write Cloudwatch logs. These are seperate permissions to the permissions required by the task itself.

  ECSExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub '${ServiceName}-execution-role'
      Description: Role for the ECS agent task manager execution
      AssumeRolePolicyDocument:
        Statement:
        - Effect: Allow
          Principal:
            Service: [ecs-tasks.amazonaws.com]
          Action: ['sts:AssumeRole']
      Path: /
      Policies:
        - PolicyName: ECSTaskManagerEcrTokenAccessPolicy
          PolicyDocument:
            Statement:
            - Effect: Allow
              Action:
                - 'ecr:GetAuthorizationToken'
              Resource: "*"
        - PolicyName: ECSTaskManagerEcrAccessPolicy
          PolicyDocument:
            Statement:
            - Effect: Allow
              Action:
                - 'ecr:GetAuthorizationToken'
                - 'ecr:BatchCheckLayerAvailability'
                - 'ecr:GetDownloadUrlForLayer'
                - 'ecr:BatchGetImage'
              Resource: !Sub 'arn:aws:ecr:${AWS::Region}:${AWS::AccountId}:repository/${ECRRepository}'
        - PolicyName: ECSTaskManagerLogPolicy
          PolicyDocument:
            Statement:
            - Effect: Allow
              Action:
                - 'logs:CreateLogStream'
                - 'logs:PutLogEvents'
              Resource: !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/ecs/${ServiceName}:log-stream:*'

Then the IAM role used by the running task itself is defined. This role has a large number of permissions to be able to read many AWS resources that are not covered by the SecurityAudit AWS Managed Policy. Additionally, the role has S3 write access as well as permission to write findings to Security Hub.

  ECSTaskRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub '${ServiceName}-task-role'
      Description: Role for the ECS task 
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
        - Effect: Allow
          Principal:
            Service: [ecs-tasks.amazonaws.com]
          Action: ['sts:AssumeRole']
      Path: "/"
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/SecurityAudit
        - arn:aws:iam::aws:policy/job-function/ViewOnlyAccess
      Policies:
      - PolicyName: s3Access
        PolicyDocument:
          Version: 2012-10-17
          Statement:
          - Effect: Allow
            Action:
              - s3:PutObject
            Resource: [
              !Join [ "", [ "arn:aws:s3:::", !Ref S3BucketName, "/*" ] ]
            ]
      - PolicyName: securityHubAccess
        PolicyDocument:
          Version: 2012-10-17
          Statement:
          - Effect: Allow
            Action:
              - securityhub:BatchImportFindings
              - securityhub:GetFindings
            Resource: "*"
      - PolicyName: apiGatewayReadAccess
        PolicyDocument:
          Version: 2012-10-17
          Statement:
          - Effect: Allow
            Action:
              - apigateway:GET
            Resource: [
              !Sub 'arn:aws:apigateway:${AWS::Region}::/restapis/*',
              !Sub 'arn:aws:apigateway:${AWS::Region}::/apis/*'
            ]
      - PolicyName: extendedProwlerReadAccess
        PolicyDocument:
          Version: 2012-10-17
          Statement:
          - Effect: Allow
            Action:
              - account:Get*
              - appstream:Describe*
              - appstream:List*
              - backup:List*
              - cloudtrail:GetInsightSelectors
              - codeartifact:List*
              - codebuild:BatchGet*
              - dlm:Get*
              - drs:Describe*
              - ds:Get*
              - ds:Describe*
              - ds:List*
              - ec2:GetEbsEncryptionByDefault
              - ecr:Describe*
              - ecr:GetRegistryScanningConfiguration
              - elasticfilesystem:DescribeBackupPolicy
              - glue:GetConnections
              - glue:GetSecurityConfiguration*
              - glue:SearchTables
              - lambda:GetFunction*
              - logs:FilterLogEvents
              - macie2:GetMacieSession
              - s3:GetAccountPublicAccessBlock
              - shield:DescribeProtection
              - shield:GetSubscriptionState
              - securityhub:BatchImportFindings
              - securityhub:GetFindings
              - ssm:GetDocument
              - ssm-incidents:List*
              - support:Describe*
              - tag:GetTagKeys
              - wellarchitected:List*
            Resource: "*"

Next, add the AWSECSTaskDefinition, referencing the resources created above. Of note is the requirement that the root filesystem be set to false so that output can be written locally prior to upload to S3 and Security Hub. A potential modification here would be to add a seperate ephemeral volume that could be attached to /output which would allow the remainder of the root filesystem to be readonly.

  ECSTaskDefinition:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Family: !Ref 'ServiceName'
      Cpu: !Ref 'ContainerCpu'
      Memory: !Ref 'ContainerMemory'
      NetworkMode: awsvpc
      RequiresCompatibilities:
        - FARGATE
      ExecutionRoleArn: !Ref ECSExecutionRole
      TaskRoleArn: !Ref ECSTaskRole
      ContainerDefinitions:
        - Name: !Ref 'ServiceName'
          Cpu: !Ref 'ContainerCpu'
          Memory: !Ref 'ContainerMemory'
          ReadonlyRootFilesystem: false # needs to be false to allow writing to /prowler/output
          Image:
            !Join [
              '.',
              [
                !Ref AWS::AccountId,
                'dkr.ecr',
                !Ref AWS::Region,
                !Sub 'amazonaws.com/${ECRRepository}:${ECRTag}'
              ]
            ]
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-group: !Ref LogGroup
              awslogs-region: !Ref 'AWS::Region'
              awslogs-stream-prefix: ecs
          Command:
            - aws
            - -f
            - ap-southeast-2
            - -M
            - html
            - -B
            - !Ref S3BucketName
            - --security-hub
            - --quiet

Note the Command field which passes command line parameters to the prowler application. These are documented in the Prowler documentation, but this combination is running against AWS for the ap-southeast-2 region outputting a html format report to an S3 bucket as well as publishing the findings to Security Hub. Only failed findings are being reported and published.

The last thing thats needed is a scheduled run of the task. Add the following to the template to create a scheduled run of the task to run weekly. Also note that the task is being run in one of three private subnets. As the task runs only for a relatively short period, running in a public subnet would probably be suitable. For the task to be able to run in a private subnet, a NAT Gateway needs to already exist in a public subnet (which is optionally included in Building out a simple AWS VPC.

  ECSTaskScheduler:
    Type: AWS::Events::Rule
    Properties:
      Description: "A rule to schedule the prowler scanner"
      Name: !Sub '${ServiceName}-scheduler'
      ScheduleExpression: "rate(7 days)"
      State: ENABLED
      Targets:
        - Arn: !GetAtt ECSCluster.Arn
          RoleArn: !GetAtt ECSTaskRole.Arn
          Id: TaskScheduler
          EcsParameters:
            TaskDefinitionArn: !Ref ECSTaskDefinition
            TaskCount: 1
            LaunchType: FARGATE
            PlatformVersion: 'LATEST'
            NetworkConfiguration:
              AwsVpcConfiguration:
                AssignPublicIp: DISABLED
                SecurityGroups:
                  - !Ref ECSContainerSecurityGroup
                Subnets:
                  - !ImportValue subnet-private-a
                  - !ImportValue subnet-private-b
                  - !ImportValue subnet-private-c

The entire template looks like the below

AWSTemplateFormatVersion: '2010-09-09'
Description: A stack for deploying the containerized prowler security scanner in AWS Fargate.

Parameters:
  ServiceName:
    Type: String
    Default: prowler-scanner
    Description: A name for the service
  ECRRepository:
    Type: String
    Default: prowler-repository
    Description: The name of the ECR repository where the docker image is stored
                        Note that this repository needs to already exist, with an image tagged appropriately
                        already pushed to this repository.
  ECRTag:
    Type: String
    Default: latest-prowler
    Description: The tag of the container image to use
  ContainerCpu:
    Type: Number
    Default: 1024
    Description: How much CPU to give the container. 1024 is 1 CPU
  ContainerMemory:
    Type: Number
    Default: 4096
    Description: How much memory in megabytes to give the container
  DesiredCount:
    Type: Number
    Default: 1
    Description: How many copies of the service task to run
  S3BucketName:
    Type: String
    Default: 'log-archive' 
    Description: The S3 bucket name for the scan output files.
                        This bucket needs to already exist - it is not created in this template.

Resources:

  LogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub '/ecs/${ServiceName}'
      RetentionInDays: 14

  ECSCluster:
    Type: AWS::ECS::Cluster
    Properties:
      ClusterName: !Sub '${ServiceName}-cluster'
      ClusterSettings:
        - Name: containerInsights
          Value: enabled

  ECSContainerSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupName: !Sub '${ServiceName}-sg'
      GroupDescription: HTTPS outbound to call AWS API
      VpcId: !ImportValue 'vpc-id'
      SecurityGroupEgress:
        - IpProtocol: TCP
          FromPort: 443
          ToPort: 443
          CidrIp: 0.0.0.0/0

  ECSExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub '${ServiceName}-execution-role'
      Description: Role for the ECS agent task manager execution
      AssumeRolePolicyDocument:
        Statement:
        - Effect: Allow
          Principal:
            Service: [ecs-tasks.amazonaws.com]
          Action: ['sts:AssumeRole']
      Path: /
      Policies:
        - PolicyName: ECSTaskManagerEcrTokenAccessPolicy
          PolicyDocument:
            Statement:
            - Effect: Allow
              Action:
                - 'ecr:GetAuthorizationToken'
              Resource: "*"
        - PolicyName: ECSTaskManagerEcrAccessPolicy
          PolicyDocument:
            Statement:
            - Effect: Allow
              Action:
                - 'ecr:GetAuthorizationToken'
                - 'ecr:BatchCheckLayerAvailability'
                - 'ecr:GetDownloadUrlForLayer'
                - 'ecr:BatchGetImage'
              Resource: !Sub 'arn:aws:ecr:${AWS::Region}:${AWS::AccountId}:repository/${ECRRepository}'
        - PolicyName: ECSTaskManagerLogPolicy
          PolicyDocument:
            Statement:
            - Effect: Allow
              Action:
                - 'logs:CreateLogStream'
                - 'logs:PutLogEvents'
              Resource: !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/ecs/${ServiceName}:log-stream:*'

  ECSTaskRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub '${ServiceName}-task-role'
      Description: Role for the ECS task 
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
        - Effect: Allow
          Principal:
            Service: [ecs-tasks.amazonaws.com]
          Action: ['sts:AssumeRole']
      Path: "/"
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/SecurityAudit
        - arn:aws:iam::aws:policy/job-function/ViewOnlyAccess
      Policies:
      - PolicyName: s3Access
        PolicyDocument:
          Version: 2012-10-17
          Statement:
          - Effect: Allow
            Action:
              - s3:PutObject
            Resource: [
              !Join [ "", [ "arn:aws:s3:::", !Ref S3BucketName, "/*" ] ]
            ]
      - PolicyName: securityHubAccess
        PolicyDocument:
          Version: 2012-10-17
          Statement:
          - Effect: Allow
            Action:
              - securityhub:BatchImportFindings
              - securityhub:GetFindings
            Resource: "*"
      - PolicyName: apiGatewayReadAccess
        PolicyDocument:
          Version: 2012-10-17
          Statement:
          - Effect: Allow
            Action:
              - apigateway:GET
            Resource: [
              !Sub 'arn:aws:apigateway:${AWS::Region}::/restapis/*',
              !Sub 'arn:aws:apigateway:${AWS::Region}::/apis/*'
            ]
      - PolicyName: extendedProwlerReadAccess
        PolicyDocument:
          Version: 2012-10-17
          Statement:
          - Effect: Allow
            Action:
              - account:Get*
              - appstream:Describe*
              - appstream:List*
              - backup:List*
              - cloudtrail:GetInsightSelectors
              - codeartifact:List*
              - codebuild:BatchGet*
              - dlm:Get*
              - drs:Describe*
              - ds:Get*
              - ds:Describe*
              - ds:List*
              - ec2:GetEbsEncryptionByDefault
              - ecr:Describe*
              - ecr:GetRegistryScanningConfiguration
              - elasticfilesystem:DescribeBackupPolicy
              - glue:GetConnections
              - glue:GetSecurityConfiguration*
              - glue:SearchTables
              - lambda:GetFunction*
              - logs:FilterLogEvents
              - macie2:GetMacieSession
              - s3:GetAccountPublicAccessBlock
              - shield:DescribeProtection
              - shield:GetSubscriptionState
              - securityhub:BatchImportFindings
              - securityhub:GetFindings
              - ssm:GetDocument
              - ssm-incidents:List*
              - support:Describe*
              - tag:GetTagKeys
              - wellarchitected:List*
            Resource: "*"

  ECSTaskDefinition:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Family: !Ref 'ServiceName'
      Cpu: !Ref 'ContainerCpu'
      Memory: !Ref 'ContainerMemory'
      NetworkMode: awsvpc
      RequiresCompatibilities:
        - FARGATE
      ExecutionRoleArn: !Ref ECSExecutionRole
      TaskRoleArn: !Ref ECSTaskRole
      ContainerDefinitions:
        - Name: !Ref 'ServiceName'
          Cpu: !Ref 'ContainerCpu'
          Memory: !Ref 'ContainerMemory'
          ReadonlyRootFilesystem: false # needs to be false to allow writing to /prowler/output
          Image:
            !Join [
              '.',
              [
                !Ref AWS::AccountId,
                'dkr.ecr',
                !Ref AWS::Region,
                !Sub 'amazonaws.com/${ECRRepository}:${ECRTag}'
              ]
            ]
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-group: !Ref LogGroup
              awslogs-region: !Ref 'AWS::Region'
              awslogs-stream-prefix: ecs
          Command:
            - aws
            - -f
            - ap-southeast-2
            - -M
            - html
            - -B
            - !Ref S3BucketName
            - --security-hub
            - --quiet

  ECSTaskScheduler:
    Type: AWS::Events::Rule
    Properties:
      Description: "A rule to schedule the prowler scanner"
      Name: !Sub '${ServiceName}-scheduler'
      ScheduleExpression: "rate(7 days)"
      State: ENABLED
      Targets:
        - Arn: !GetAtt ECSCluster.Arn
          RoleArn: !GetAtt ECSTaskRole.Arn
          Id: TaskScheduler
          EcsParameters:
            TaskDefinitionArn: !Ref ECSTaskDefinition
            TaskCount: 1
            LaunchType: FARGATE
            PlatformVersion: 'LATEST'
            NetworkConfiguration:
              AwsVpcConfiguration:
                AssignPublicIp: DISABLED
                SecurityGroups:
                  - !Ref ECSContainerSecurityGroup
                Subnets:
                  - !ImportValue subnet-private-a
                  - !ImportValue subnet-private-b
                  - !ImportValue subnet-private-c