--- # Copyright widdix GmbH # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT # OLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER # DEALINGS IN THE SOFTWARE. Description: bucketAV for Amazon S3 powered by ClamAV (shared VPC) - Antivirus protection for Amazon S3 AWSTemplateFormatVersion: "2010-09-09" Metadata: AWS::CloudFormation::Interface: ParameterGroups: - Label: default: Required Parameters Parameters: - KeyName - InfrastructureAlarmsEmail - VPC - Subnets - AssociatePublicIpAddress - Label: default: Scan Parameters Parameters: - DeleteInfectedFiles - TagFiles - TagKey - ReportCleanFiles - ReportEventBridge - ScanDelayInSeconds - SignCallbackInvocations - EnableCache - AdditionalDatabaseUrls - Governance - Label: default: VPC Parameters Parameters: - SSHIngressCidrIp - SSHIngressSecurityGroupId - SecurityGroupIds - LambdaSubnets - Label: default: Auto Scaling Group Parameters Parameters: - AutoScalingMinSize - AutoScalingMaxSize - CapacityStrategy - Label: default: EC2 Parameters Parameters: - AMI2310 - InstanceType - LogsRetentionInDays - VolumeKmsKeyId - SystemsManagerAccess - Label: default: Permissions Parameters Parameters: - ManagedPolicyArns - AWSAccountRestriction - AWSOrganizationRestriction - S3BucketRestriction - S3ObjectRestriction - KMSKeyRestriction - PermissionsBoundary - Label: default: Lambda Parameters Parameters: - AutoScalingGroupCalculatorFunctionReservedConcurrentExecutions - PrivateKeyGeneratorFunctionReservedConcurrentExecutions - AccountConnectionLambdaFunctionReservedConcurrentExecutions - RefreshServiceDiscoveryLambdaFunctionFunctionReservedConcurrentExecutions - RefreshBucketCacheFunctionReservedConcurrentExecutions - StateMachineNameGeneratorFunctionReservedConcurrentExecutions - DashboardLambdaFunctionReservedConcurrentExecutions - GovernanceLambdaFunctionReservedConcurrentExecutions - Label: default: Deprecated, will be removed in v3; please use add-ons instead (https://bucketav.com/add-ons/). Parameters: - SecurityHubIntegration - OpsCenterIntegration - CloudWatchIntegration - Label: default: Reserved for internal use, must not be used by customer. Parameters: - InternalMultiStackName Parameters: DeleteInfectedFiles: Type: String Default: 'true' AllowedValues: - 'true' - 'false' Description: Automatically delete infected files. ReportCleanFiles: Type: String Default: 'true' AllowedValues: - 'true' - 'false' Description: Report clean files to the SNS Findings Topic (recommended for better visibility). TagFiles: Type: String Default: 'true' AllowedValues: - 'true' - 'false' Description: Tag S3 object upon successful scan accordingly with values of "clean", "infected", or "no" (infected only works if DeleteInfectedFiles != true) using the tag key specified by TagKey. TagKey: Type: String Default: bucketav Description: S3 object tag key used to specify values of "clean", "infected", or "no". ReportEventBridge: Type: String Default: 'false' AllowedValues: - 'true' - 'false' Description: Report scan results to EventBridge. AutoScalingMinSize: Type: Number Default: 1 ConstraintDescription: Must be >= 0 Description: Minimum number of EC2 instances scanning files (in production, we recommend 2 for high availability). MinValue: 0 AutoScalingMaxSize: Type: Number Default: 1 ConstraintDescription: Must be >= 1 Description: Maximum number of EC2 instances scanning files (in production, we recommend at least 2 for high availability). MinValue: 1 KeyName: Type: AWS::EC2::KeyPair::KeyName Description: 'Name of the EC2 key pair to log in via SSH (username: ec2-user).' LogsRetentionInDays: Type: Number Default: 14 AllowedValues: - 1 - 3 - 5 - 7 - 14 - 30 - 60 - 90 - 120 - 150 - 180 - 365 - 400 - 545 - 731 - 1827 - 3653 Description: Specifies the number of days you want to retain log events. SystemsManagerAccess: Type: String Default: 'false' AllowedValues: - 'true' - 'false' Description: Enable AWS Systems Manager Session Manager to connect to the EC2 instances. To fully enable SSM, add arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore to the ManagedPolicyArns configuration parameter as well. AWSAccountRestriction: Type: CommaDelimitedList Default: '' Description: Optional allowlist of all the AWS accounts that send S3 Notifications (e.g., 111111111111,222222222222,333333333333; only required in Multi-Account setups). AWSOrganizationRestriction: Type: CommaDelimitedList Default: '' Description: Optional allowlist of all the AWS organizations that send S3 Notifications (e.g., o-1111111111,o-2222222222,o-3333333333; only required in Multi-Account setups). S3BucketRestriction: Type: CommaDelimitedList Default: '*' Description: Restrict access to specific S3 buckets (e.g. arn:aws:s3:::bucket-a,arn:aws:s3:::bucket-b or * to allow access to all S3 buckets). S3ObjectRestriction: Type: CommaDelimitedList Default: '*' Description: Restrict access to specific S3 objects (e.g. arn:aws:s3:::bucket-a/*,arn:aws:s3:::bucket-b/* or * to allow access to all S3 objects). KMSKeyRestriction: Type: CommaDelimitedList Default: '*' Description: Restrict access to specific KMS keys (e.g. arn:aws:kms:us-east-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab,arn:aws:kms:us-east-1:111122223333:key/0987dcba-09fe-87dc-65ba-ab0987654321 or * to allow access to all KMS keys). PermissionsBoundary: Type: String Default: '' Description: Optional IAM policy ARN that will be used as the permissions boundary for all roles. CapacityStrategy: Type: String Default: SpotWithoutAlternativeInstanceTypeWithOnDemandFallback AllowedValues: - SpotWithOnDemandFallback - OnDemandOnly - SpotOnly - SpotOnlyWithoutAlternativeInstanceType - SpotWithoutAlternativeInstanceTypeWithOnDemandFallback Description: Take advantage of unused EC2 capacity in the AWS cloud by launching spot instances that are up to 90% cheaper than on-demand prices. Keep in mind that spot instances can be interrupted at any time and are replaced automatically! ManagedPolicyArns: Type: String Default: '' Description: Optional comma-delimited list of IAM managed policy ARNs to attach to the IAM role of the EC2 instances. ScanDelayInSeconds: Type: Number Default: 0 Description: Delay the scanning of objects by 0-900 seconds. MaxValue: 900 MinValue: 0 InfrastructureAlarmsEmail: Type: String Default: '' Description: Optional but strongly recommended email address receiving infrastructure alarms (for more than one email address, please subscribe to the Infrastructure Alarms SNS topic after stack creation). InternalMultiStackName: Type: String Default: '' Description: CloudFormation stack name of parent in multi deployment (reserved for internal use, must not be used by customer). VolumeKmsKeyId: Type: String Default: '' Description: By default the AWS-managed key aws/ebs is used to encrypt the EBS volumes. Define the customer-managed KMS key only when necessary. ID = key ID, key alias, key ARN, or alias ARN. SignCallbackInvocations: Type: String Default: 'false' AllowedValues: - 'true' - 'false' Description: 'Add X-Signature and X-Timestamp headers when invoking the callback URL defined in custom scan job with downloads. Learn more here: https://bucketav.com/help/developer/receiving-scan-result.html#callback-verify' SSHIngressCidrIp: Type: String Default: '' Description: 'Optional ingress rule allows SSH access from this IP address range (e.g., access from anywhere: 0.0.0.0/0, from single public IP address 91.45.138.21/32).' SSHIngressSecurityGroupId: Type: String Default: '' Description: Optional ingress rule allows SSH access from this security group. VPC: Type: AWS::EC2::VPC::Id Description: EC2 instances that scan the files are launched into this VPC. Subnets: Type: List Description: Subnets used for scanners. AssociatePublicIpAddress: Type: String Default: 'true' AllowedValues: - 'true' - 'false' Description: Specifies whether to assign a public IP address to the group's instances (set to true in public subnets, false in private subnets). SecurityGroupIds: Type: String Default: '' Description: Optional comma-delimited list of security group IDs to attach to the EC2 instances. LambdaSubnets: Type: CommaDelimitedList Default: '' Description: Optionally configure Lambda functions to run in theses subnets, requires route to NAT Gateway or VPC Endpoints. AutoScalingGroupCalculatorFunctionReservedConcurrentExecutions: Type: Number Default: 0 Description: Maximum number of execution environment instances for the Lambda function (set to 0 to disable; Check out the CloudWatch metric ConcurrentExecutions to get the maximum concurrent invocations of the past). MinValue: 0 PrivateKeyGeneratorFunctionReservedConcurrentExecutions: Type: Number Default: 0 Description: Maximum number of execution environment instances for the Lambda function (set to 0 to disable; Check out the CloudWatch metric ConcurrentExecutions to get the maximum concurrent invocations of the past). MinValue: 0 SecurityHubIntegration: Type: String Default: 'false' AllowedValues: - 'true' - 'false' Description: Deprecated, will be removed in v3; please use the Security Hub integration add-on instead (https://bucketav.com/add-ons/security-hub/). OpsCenterIntegration: Type: String Default: 'false' AllowedValues: - 'true' - 'false' Description: Deprecated, will be removed in v3; please use the OpsCenter integration add-on instead (https://bucketav.com/add-ons/ops-center/). CloudWatchIntegration: Type: String Default: 'false' AllowedValues: - 'true' - 'false' Description: Deprecated, will be removed in v3; this is now on by default. InstanceType: Type: String Default: m6i.large AllowedValues: - t3a.small - t3a.medium - t3a.large - t3a.xlarge - t3a.2xlarge - t3.small - t3.medium - t3.large - t3.xlarge - t3.2xlarge - m7a.medium - m7a.large - m7a.xlarge - m7a.2xlarge - m7a.4xlarge - m7a.8xlarge - m7a.12xlarge - m7a.16xlarge - m7a.24xlarge - m7a.32xlarge - m7a.48xlarge - m7i.large - m7i.xlarge - m7i.2xlarge - m7i.4xlarge - m7i.8xlarge - m7i.12xlarge - m7i.16xlarge - m7i.24xlarge - m7i.48xlarge - m6a.large - m6a.xlarge - m6a.2xlarge - m6a.4xlarge - m6a.8xlarge - m6a.12xlarge - m6a.16xlarge - m6a.24xlarge - m6a.32xlarge - m6a.48xlarge - m6i.large - m6i.xlarge - m6i.2xlarge - m6i.4xlarge - m6i.8xlarge - m6i.12xlarge - m6i.16xlarge - m6i.24xlarge - m6i.32xlarge - m5a.large - m5a.xlarge - m5a.2xlarge - m5a.4xlarge - m5a.8xlarge - m5a.12xlarge - m5a.16xlarge - m5a.24xlarge - m5.large - m5.xlarge - m5.2xlarge - m5.4xlarge - m5.8xlarge - m5.12xlarge - m5.16xlarge - m5.24xlarge - m4.large - m4.xlarge - m4.2xlarge - m4.4xlarge - m4.10xlarge - m4.16xlarge - c7a.medium - c7a.large - c7a.xlarge - c7a.2xlarge - c7a.4xlarge - c7a.8xlarge - c7a.12xlarge - c7a.16xlarge - c7a.24xlarge - c7a.32xlarge - c7a.48xlarge - c7i.large - c7i.xlarge - c7i.2xlarge - c7i.4xlarge - c7i.8xlarge - c7i.12xlarge - c7i.16xlarge - c7i.24xlarge - c7i.48xlarge - c6a.large - c6a.xlarge - c6a.2xlarge - c6a.4xlarge - c6a.8xlarge - c6a.12xlarge - c6a.16xlarge - c6a.24xlarge - c6a.32xlarge - c6a.48xlarge - c6i.large - c6i.xlarge - c6i.2xlarge - c6i.4xlarge - c6i.8xlarge - c6i.12xlarge - c6i.16xlarge - c6i.24xlarge - c6i.32xlarge - c5a.large - c5a.xlarge - c5a.2xlarge - c5a.4xlarge - c5a.8xlarge - c5a.12xlarge - c5a.16xlarge - c5a.24xlarge - c5.large - c5.xlarge - c5.2xlarge - c5.4xlarge - c5.9xlarge - c5.12xlarge - c5.18xlarge - c5.24xlarge Description: Specifies the instance type of the EC2 instance (low performance for t3a.small and t3.small) EnableCache: Type: String Default: 'true' AllowedValues: - 'true' - 'false' Description: Enable cache checks for hash sums of scanned files (disable only during performance tests). AdditionalDatabaseUrls: Type: String Default: '' Description: Optional comma-delimited list of ClamAV database files available via http(s). AMI2310: Type: AWS::SSM::Parameter::Value Default: /aws/service/marketplace/prod-3xuywjflttu5c/2.31.0 AllowedValues: - /aws/service/marketplace/prod-3xuywjflttu5c/2.31.0 Description: 'This is the alias of the Marketplace AMI that will be deployed as part of this stack. Ensure this parameter is set to the following value: /aws/service/marketplace/prod-3xuywjflttu5c/2.31.0.' ConstraintDescription: 'The provided parameter value does not match the current version of the template. Update the parameter to the following value: /aws/service/marketplace/prod-3xuywjflttu5c/2.31.0.' AccountConnectionLambdaFunctionReservedConcurrentExecutions: Type: Number Default: 0 Description: Maximum number of execution environment instances for the Lambda function (set to 0 to disable; Check out the CloudWatch metric ConcurrentExecutions to get the maximum concurrent invocations of the past). MinValue: 0 RefreshServiceDiscoveryLambdaFunctionFunctionReservedConcurrentExecutions: Type: Number Default: 0 Description: Maximum number of execution environment instances for the Lambda function (set to 0 to disable; Check out the CloudWatch metric ConcurrentExecutions to get the maximum concurrent invocations of the past). MinValue: 0 RefreshBucketCacheFunctionReservedConcurrentExecutions: Type: Number Default: 0 Description: Maximum number of execution environment instances for the Lambda function (set to 0 to disable; Check out the CloudWatch metric ConcurrentExecutions to get the maximum concurrent invocations of the past). MinValue: 0 StateMachineNameGeneratorFunctionReservedConcurrentExecutions: Type: Number Default: 0 Description: Maximum number of execution environment instances for the Lambda function (set to 0 to disable; Check out the CloudWatch metric ConcurrentExecutions to get the maximum concurrent invocations of the past). MinValue: 0 DashboardLambdaFunctionReservedConcurrentExecutions: Type: Number Default: 0 Description: Maximum number of execution environment instances for the Lambda function (set to 0 to disable; Check out the CloudWatch metric ConcurrentExecutions to get the maximum concurrent invocations of the past). MinValue: 0 Governance: Type: String Default: 'true' AllowedValues: - 'true' - 'false' Description: Enable governance checks. GovernanceLambdaFunctionReservedConcurrentExecutions: Type: Number Default: 0 Description: Maximum number of execution environment instances for the Lambda function (set to 0 to disable; Check out the CloudWatch metric ConcurrentExecutions to get the maximum concurrent invocations of the past). MinValue: 0 Rules: CapacityStrategyMalaysia: RuleCondition: Fn::Equals: - Ref: AWS::Region - ap-southeast-5 Assertions: - Assert: Fn::Contains: - - OnDemandOnly - SpotOnlyWithoutAlternativeInstanceType - SpotWithoutAlternativeInstanceTypeWithOnDemandFallback - Ref: CapacityStrategy AssertDescription: Region ap-southeast-5 supports OnDemandOnly, SpotOnlyWithoutAlternativeInstanceType, SpotWithoutAlternativeInstanceTypeWithOnDemandFallback MultiDeployment: RuleCondition: Fn::Not: - Fn::Equals: - Ref: InternalMultiStackName - "" Assertions: - Assert: Fn::Equals: - Ref: DeleteInfectedFiles - "false" AssertDescription: DeleteInfectedFiles must be false in a multi deployment - Assert: Fn::Equals: - Ref: TagFiles - "false" AssertDescription: TagFiles must be false in a multi deployment - Assert: Fn::Equals: - Ref: ReportCleanFiles - "true" AssertDescription: ReportCleanFiles must be true in a multi deployment - Assert: Fn::Equals: - Ref: ReportEventBridge - "false" AssertDescription: ReportEventBridge must be false in a multi deployment - Assert: Fn::Equals: - Ref: ScanDelayInSeconds - "0" AssertDescription: ScanDelayInSeconds must be 0 in a multi deployment SubnetsInVPC: Assertions: - Assert: Fn::EachMemberEquals: - Fn::ValueOf: - Subnets - VpcId - Ref: VPC AssertDescription: All subnets must be in the selected VPC InstanceTypeAsiaPacificMalaysia: RuleCondition: Fn::Equals: - Ref: AWS::Region - ap-southeast-5 Assertions: - Assert: Fn::Contains: - - t3.small - t3.medium - t3.large - t3.xlarge - t3.2xlarge - m7i.large - m7i.xlarge - m7i.2xlarge - m7i.4xlarge - m7i.8xlarge - m7i.12xlarge - m7i.16xlarge - m7i.24xlarge - m7i.48xlarge - m6i.large - m6i.xlarge - m6i.2xlarge - m6i.4xlarge - m6i.8xlarge - m6i.12xlarge - m6i.16xlarge - m6i.24xlarge - m6i.32xlarge - c7i.large - c7i.xlarge - c7i.2xlarge - c7i.4xlarge - c7i.8xlarge - c7i.12xlarge - c7i.16xlarge - c7i.24xlarge - c7i.48xlarge - c6i.large - c6i.xlarge - c6i.2xlarge - c6i.4xlarge - c6i.8xlarge - c6i.12xlarge - c6i.16xlarge - c6i.24xlarge - c6i.32xlarge - Ref: InstanceType AssertDescription: Region ap-southeast-5 supports t3.small, t3.medium, t3.large, t3.xlarge, t3.2xlarge, m7i.large, m7i.xlarge, m7i.2xlarge, m7i.4xlarge, m7i.8xlarge, m7i.12xlarge, m7i.16xlarge, m7i.24xlarge, m7i.48xlarge, m6i.large, m6i.xlarge, m6i.2xlarge, m6i.4xlarge, m6i.8xlarge, m6i.12xlarge, m6i.16xlarge, m6i.24xlarge, m6i.32xlarge, c7i.large, c7i.xlarge, c7i.2xlarge, c7i.4xlarge, c7i.8xlarge, c7i.12xlarge, c7i.16xlarge, c7i.24xlarge, c7i.48xlarge, c6i.large, c6i.xlarge, c6i.2xlarge, c6i.4xlarge, c6i.8xlarge, c6i.12xlarge, c6i.16xlarge, c6i.24xlarge, c6i.32xlarge InstanceTypeAsiaPacificThailand: RuleCondition: Fn::Equals: - Ref: AWS::Region - ap-southeast-7 Assertions: - Assert: Fn::Contains: - - t3.small - t3.medium - t3.large - t3.xlarge - t3.2xlarge - m7i.large - m7i.xlarge - m7i.2xlarge - m7i.4xlarge - m7i.8xlarge - m7i.12xlarge - m7i.16xlarge - m7i.24xlarge - m7i.48xlarge - m6i.large - m6i.xlarge - m6i.2xlarge - m6i.4xlarge - m6i.8xlarge - m6i.12xlarge - m6i.16xlarge - m6i.24xlarge - m6i.32xlarge - c7i.large - c7i.xlarge - c7i.2xlarge - c7i.4xlarge - c7i.8xlarge - c7i.12xlarge - c7i.16xlarge - c7i.24xlarge - c7i.48xlarge - c6i.large - c6i.xlarge - c6i.2xlarge - c6i.4xlarge - c6i.8xlarge - c6i.12xlarge - c6i.16xlarge - c6i.24xlarge - c6i.32xlarge - Ref: InstanceType AssertDescription: Region ap-southeast-7 supports t3.small, t3.medium, t3.large, t3.xlarge, t3.2xlarge, m7i.large, m7i.xlarge, m7i.2xlarge, m7i.4xlarge, m7i.8xlarge, m7i.12xlarge, m7i.16xlarge, m7i.24xlarge, m7i.48xlarge, m6i.large, m6i.xlarge, m6i.2xlarge, m6i.4xlarge, m6i.8xlarge, m6i.12xlarge, m6i.16xlarge, m6i.24xlarge, m6i.32xlarge, c7i.large, c7i.xlarge, c7i.2xlarge, c7i.4xlarge, c7i.8xlarge, c7i.12xlarge, c7i.16xlarge, c7i.24xlarge, c7i.48xlarge, c6i.large, c6i.xlarge, c6i.2xlarge, c6i.4xlarge, c6i.8xlarge, c6i.12xlarge, c6i.16xlarge, c6i.24xlarge, c6i.32xlarge InstanceTypeMexicoCentral: RuleCondition: Fn::Equals: - Ref: AWS::Region - mx-central-1 Assertions: - Assert: Fn::Contains: - - t3.small - t3.medium - t3.large - t3.xlarge - t3.2xlarge - m7i.large - m7i.xlarge - m7i.2xlarge - m7i.4xlarge - m7i.8xlarge - m7i.12xlarge - m7i.16xlarge - m7i.24xlarge - m7i.48xlarge - m6i.large - m6i.xlarge - m6i.2xlarge - m6i.4xlarge - m6i.8xlarge - m6i.12xlarge - m6i.16xlarge - m6i.24xlarge - m6i.32xlarge - c7i.large - c7i.xlarge - c7i.2xlarge - c7i.4xlarge - c7i.8xlarge - c7i.12xlarge - c7i.16xlarge - c7i.24xlarge - c7i.48xlarge - c6i.large - c6i.xlarge - c6i.2xlarge - c6i.4xlarge - c6i.8xlarge - c6i.12xlarge - c6i.16xlarge - c6i.24xlarge - c6i.32xlarge - Ref: InstanceType AssertDescription: Region mx-central-1 supports t3.small, t3.medium, t3.large, t3.xlarge, t3.2xlarge, m7i.large, m7i.xlarge, m7i.2xlarge, m7i.4xlarge, m7i.8xlarge, m7i.12xlarge, m7i.16xlarge, m7i.24xlarge, m7i.48xlarge, m6i.large, m6i.xlarge, m6i.2xlarge, m6i.4xlarge, m6i.8xlarge, m6i.12xlarge, m6i.16xlarge, m6i.24xlarge, m6i.32xlarge, c7i.large, c7i.xlarge, c7i.2xlarge, c7i.4xlarge, c7i.8xlarge, c7i.12xlarge, c7i.16xlarge, c7i.24xlarge, c7i.48xlarge, c6i.large, c6i.xlarge, c6i.2xlarge, c6i.4xlarge, c6i.8xlarge, c6i.12xlarge, c6i.16xlarge, c6i.24xlarge, c6i.32xlarge Conditions: HasNotMultiDeployment: Fn::Equals: - Ref: InternalMultiStackName - "" HasMultiDeployment: Fn::Not: - Condition: HasNotMultiDeployment HasVolumeKmsKeyId: Fn::Not: - Fn::Equals: - Ref: VolumeKmsKeyId - "" HasDeleteInfectedFiles: Fn::Equals: - Ref: DeleteInfectedFiles - "true" HasTagFiles: Fn::Equals: - Ref: TagFiles - "true" HasSystemsManagerAccess: Fn::Equals: - Ref: SystemsManagerAccess - "true" HasAWSAccountRestriction: Fn::Not: - Fn::Equals: - Fn::Join: - "" - Ref: AWSAccountRestriction - "" HasAWSOrganizationRestriction: Fn::Not: - Fn::Equals: - Fn::Join: - "" - Ref: AWSOrganizationRestriction - "" HasPermissionsBoundary: Fn::Not: - Fn::Equals: - Ref: PermissionsBoundary - "" HasAlternativeInstanceType: Fn::Or: - Fn::Equals: - Ref: CapacityStrategy - SpotWithOnDemandFallback - Fn::Equals: - Ref: CapacityStrategy - SpotOnly HasOnDemandFallback: Fn::Or: - Fn::Equals: - Ref: CapacityStrategy - SpotWithOnDemandFallback - Fn::Equals: - Ref: CapacityStrategy - SpotWithoutAlternativeInstanceTypeWithOnDemandFallback HasManagedPolicyArns: Fn::Not: - Fn::Equals: - Ref: ManagedPolicyArns - "" HasInfrastructureAlarmsEmail: Fn::Not: - Fn::Equals: - Ref: InfrastructureAlarmsEmail - "" HasCrossAccount: Fn::Or: - Fn::Not: - Fn::Equals: - Fn::Join: - "," - Ref: AWSAccountRestriction - "" - Fn::Not: - Fn::Equals: - Fn::Join: - "," - Ref: AWSOrganizationRestriction - "" HasAutoScalingMinSizeZero: Fn::Equals: - Ref: AutoScalingMinSize - 0 HasReportEventBridge: Fn::Equals: - Ref: ReportEventBridge - "true" HasSignCallbackInvocations: Fn::Equals: - Ref: SignCallbackInvocations - "true" HasLambdaVpc: Fn::Not: - Fn::Equals: - Fn::Join: - "" - Ref: LambdaSubnets - "" HasSSHIngressCidrIp: Fn::Not: - Fn::Equals: - Ref: SSHIngressCidrIp - "" HasSSHIngressSecurityGroupId: Fn::Not: - Fn::Equals: - Ref: SSHIngressSecurityGroupId - "" HasSecurityGroupIds: Fn::Not: - Fn::Equals: - Ref: SecurityGroupIds - "" HasCrossAccountAndLambdaVpc: Fn::And: - Condition: HasCrossAccount - Condition: HasLambdaVpc HasAutoScalingGroupCalculatorFunctionReservedConcurrentExecutions: Fn::Not: - Fn::Equals: - Ref: AutoScalingGroupCalculatorFunctionReservedConcurrentExecutions - 0 HasPrivateKeyGeneratorFunctionReservedConcurrentExecutions: Fn::Not: - Fn::Equals: - Ref: PrivateKeyGeneratorFunctionReservedConcurrentExecutions - 0 HasSignCallbackInvocationsAndLambdaVpc: Fn::And: - Condition: HasSignCallbackInvocations - Condition: HasLambdaVpc HasSecurityHubIntegration: Fn::Equals: - Ref: SecurityHubIntegration - "true" HasOpsCenterIntegration: Fn::Equals: - Ref: OpsCenterIntegration - "true" HasCrossAccountAndNotMultiDeployment: Fn::And: - Condition: HasCrossAccount - Fn::Not: - Condition: HasMultiDeployment HasCrossAccountAndLambdaVpcAndNotMultiDeployment: Fn::And: - Condition: HasCrossAccountAndLambdaVpc - Fn::Not: - Condition: HasMultiDeployment HasAccountConnectionLambdaFunctionReservedConcurrentExecutions: Fn::Not: - Fn::Equals: - Ref: AccountConnectionLambdaFunctionReservedConcurrentExecutions - 0 HasRefreshServiceDiscoveryLambdaFunctionReservedConcurrentExecutions: Fn::Not: - Fn::Equals: - Ref: RefreshServiceDiscoveryLambdaFunctionFunctionReservedConcurrentExecutions - 0 HasRefreshBucketCacheFunctionReservedConcurrentExecutions: Fn::Not: - Fn::Equals: - Ref: RefreshBucketCacheFunctionReservedConcurrentExecutions - 0 HasStateMachineNameGeneratorFunctionReservedConcurrentExecutions: Fn::Not: - Fn::Equals: - Ref: StateMachineNameGeneratorFunctionReservedConcurrentExecutions - 0 HasLambdaVpcAndStateMachineNameGeneratorCondition: Fn::And: - Condition: HasLambdaVpc - Condition: HasCrossAccountAndNotMultiDeployment HasDashboardLambdaFunctionReservedConcurrentExecutions: Fn::Not: - Fn::Equals: - Ref: DashboardLambdaFunctionReservedConcurrentExecutions - 0 HasGovernance: Fn::Equals: - Ref: Governance - "true" HasGovernanceAndLambdaVpc: Fn::And: - Condition: HasGovernance - Condition: HasLambdaVpc HasGovernanceAndNotMultiDeployment: Fn::And: - Condition: HasGovernance - Fn::Not: - Condition: HasMultiDeployment HasGovernanceAndLambdaVpcAndNotMultiDeployment: Fn::And: - Condition: HasGovernanceAndLambdaVpc - Fn::Not: - Condition: HasMultiDeployment HasGovernanceLambdaFunctionReservedConcurrentExecutions: Fn::Not: - Fn::Equals: - Ref: GovernanceLambdaFunctionReservedConcurrentExecutions - 0 HasOnDemandFallbackAndNotMultiDeployment: Fn::And: - Condition: HasOnDemandFallback - Condition: HasNotMultiDeployment Mappings: CapacityStrategyMap: SpotWithOnDemandFallback: OnDemandBaseCapacity: 0 OnDemandPercentageAboveBaseCapacity: 0 SpotWithoutAlternativeInstanceTypeWithOnDemandFallback: OnDemandBaseCapacity: 0 OnDemandPercentageAboveBaseCapacity: 0 SpotOnly: OnDemandBaseCapacity: 0 OnDemandPercentageAboveBaseCapacity: 0 SpotOnlyWithoutAlternativeInstanceType: OnDemandBaseCapacity: 0 OnDemandPercentageAboveBaseCapacity: 0 OnDemandOnly: OnDemandBaseCapacity: 0 OnDemandPercentageAboveBaseCapacity: 100 InstanceTypeMap: t3a.small: AlternativeInstanceType: t3.small t3a.medium: AlternativeInstanceType: t3.medium t3a.large: AlternativeInstanceType: t3.large t3a.xlarge: AlternativeInstanceType: t3.xlarge t3a.2xlarge: AlternativeInstanceType: t3.2xlarge t3.small: AlternativeInstanceType: t3a.small t3.medium: AlternativeInstanceType: t3a.medium t3.large: AlternativeInstanceType: t3a.large t3.xlarge: AlternativeInstanceType: t3a.xlarge t3.2xlarge: AlternativeInstanceType: t3a.2xlarge m7a.medium: AlternativeInstanceType: t3.medium m7a.large: AlternativeInstanceType: m5.large m7a.xlarge: AlternativeInstanceType: m5.xlarge m7a.2xlarge: AlternativeInstanceType: m5.2xlarge m7a.4xlarge: AlternativeInstanceType: m5.4xlarge m7a.8xlarge: AlternativeInstanceType: m5.8xlarge m7a.12xlarge: AlternativeInstanceType: m5.12xlarge m7a.16xlarge: AlternativeInstanceType: m5.16xlarge m7a.24xlarge: AlternativeInstanceType: m5.24xlarge m7a.32xlarge: AlternativeInstanceType: m5.24xlarge m7a.48xlarge: AlternativeInstanceType: m7i.48xlarge m7i.large: AlternativeInstanceType: m5.large m7i.xlarge: AlternativeInstanceType: m5.xlarge m7i.2xlarge: AlternativeInstanceType: m5.2xlarge m7i.4xlarge: AlternativeInstanceType: m5.4xlarge m7i.8xlarge: AlternativeInstanceType: m5.8xlarge m7i.12xlarge: AlternativeInstanceType: m5.12xlarge m7i.16xlarge: AlternativeInstanceType: m5.16xlarge m7i.24xlarge: AlternativeInstanceType: m5.24xlarge m7i.48xlarge: AlternativeInstanceType: m7a.48xlarge m6a.large: AlternativeInstanceType: m5.large m6a.xlarge: AlternativeInstanceType: m5.xlarge m6a.2xlarge: AlternativeInstanceType: m5.2xlarge m6a.4xlarge: AlternativeInstanceType: m5.4xlarge m6a.8xlarge: AlternativeInstanceType: m5.8xlarge m6a.12xlarge: AlternativeInstanceType: m5.12xlarge m6a.16xlarge: AlternativeInstanceType: m5.16xlarge m6a.24xlarge: AlternativeInstanceType: m5.24xlarge m6a.32xlarge: AlternativeInstanceType: m6i.32xlarge m6a.48xlarge: AlternativeInstanceType: m7a.48xlarge m6i.large: AlternativeInstanceType: m5.large m6i.xlarge: AlternativeInstanceType: m5.xlarge m6i.2xlarge: AlternativeInstanceType: m5.2xlarge m6i.4xlarge: AlternativeInstanceType: m5.4xlarge m6i.8xlarge: AlternativeInstanceType: m5.8xlarge m6i.12xlarge: AlternativeInstanceType: m5.12xlarge m6i.16xlarge: AlternativeInstanceType: m5.16xlarge m6i.24xlarge: AlternativeInstanceType: m5.24xlarge m6i.32xlarge: AlternativeInstanceType: m6a.32xlarge m5a.large: AlternativeInstanceType: m5.large m5a.xlarge: AlternativeInstanceType: m5.xlarge m5a.2xlarge: AlternativeInstanceType: m5.2xlarge m5a.4xlarge: AlternativeInstanceType: m5.4xlarge m5a.8xlarge: AlternativeInstanceType: m5.8xlarge m5a.12xlarge: AlternativeInstanceType: m5.12xlarge m5a.16xlarge: AlternativeInstanceType: m5.16xlarge m5a.24xlarge: AlternativeInstanceType: m5.24xlarge m5.large: AlternativeInstanceType: m5a.large m5.xlarge: AlternativeInstanceType: m5a.xlarge m5.2xlarge: AlternativeInstanceType: m5a.2xlarge m5.4xlarge: AlternativeInstanceType: m5a.4xlarge m5.8xlarge: AlternativeInstanceType: m5a.8xlarge m5.12xlarge: AlternativeInstanceType: m5a.12xlarge m5.16xlarge: AlternativeInstanceType: m5a.16xlarge m5.24xlarge: AlternativeInstanceType: m5a.24xlarge m4.large: AlternativeInstanceType: m5.large m4.xlarge: AlternativeInstanceType: m5.xlarge m4.2xlarge: AlternativeInstanceType: m5.2xlarge m4.4xlarge: AlternativeInstanceType: m5.4xlarge m4.10xlarge: AlternativeInstanceType: m5.8xlarge m4.16xlarge: AlternativeInstanceType: m5.16xlarge c7a.medium: AlternativeInstanceType: t3.medium c7a.large: AlternativeInstanceType: c5.large c7a.xlarge: AlternativeInstanceType: c5.xlarge c7a.2xlarge: AlternativeInstanceType: c5.2xlarge c7a.4xlarge: AlternativeInstanceType: c5.4xlarge c7a.8xlarge: AlternativeInstanceType: c5.9xlarge c7a.12xlarge: AlternativeInstanceType: c5.12xlarge c7a.16xlarge: AlternativeInstanceType: c5.18xlarge c7a.24xlarge: AlternativeInstanceType: c5.24xlarge c7a.32xlarge: AlternativeInstanceType: c6a.32xlarge c7a.48xlarge: AlternativeInstanceType: c6a.48xlarge c7i.large: AlternativeInstanceType: c5.large c7i.xlarge: AlternativeInstanceType: c5.xlarge c7i.2xlarge: AlternativeInstanceType: c5.2xlarge c7i.4xlarge: AlternativeInstanceType: c5.4xlarge c7i.8xlarge: AlternativeInstanceType: c5.9xlarge c7i.12xlarge: AlternativeInstanceType: c5.12xlarge c7i.16xlarge: AlternativeInstanceType: c5.18xlarge c7i.24xlarge: AlternativeInstanceType: c5.24xlarge c7i.48xlarge: AlternativeInstanceType: c7a.48xlarge c6a.large: AlternativeInstanceType: c5.large c6a.xlarge: AlternativeInstanceType: c5.xlarge c6a.2xlarge: AlternativeInstanceType: c5.2xlarge c6a.4xlarge: AlternativeInstanceType: c5.4xlarge c6a.8xlarge: AlternativeInstanceType: c5.9xlarge c6a.12xlarge: AlternativeInstanceType: c5.12xlarge c6a.16xlarge: AlternativeInstanceType: c5.18xlarge c6a.24xlarge: AlternativeInstanceType: c5.24xlarge c6a.32xlarge: AlternativeInstanceType: c6i.32xlarge c6a.48xlarge: AlternativeInstanceType: c7a.48xlarge c6i.large: AlternativeInstanceType: c5.large c6i.xlarge: AlternativeInstanceType: c5.xlarge c6i.2xlarge: AlternativeInstanceType: c5.2xlarge c6i.4xlarge: AlternativeInstanceType: c5.4xlarge c6i.8xlarge: AlternativeInstanceType: c5.9xlarge c6i.12xlarge: AlternativeInstanceType: c5.12xlarge c6i.16xlarge: AlternativeInstanceType: c5.18xlarge c6i.24xlarge: AlternativeInstanceType: c5.24xlarge c6i.32xlarge: AlternativeInstanceType: c6a.32xlarge c5a.large: AlternativeInstanceType: c5.large c5a.xlarge: AlternativeInstanceType: c5.xlarge c5a.2xlarge: AlternativeInstanceType: c5.2xlarge c5a.4xlarge: AlternativeInstanceType: c5.4xlarge c5a.8xlarge: AlternativeInstanceType: c5.9xlarge c5a.12xlarge: AlternativeInstanceType: c5.12xlarge c5a.16xlarge: AlternativeInstanceType: c5.18xlarge c5a.24xlarge: AlternativeInstanceType: c5.24xlarge c5.large: AlternativeInstanceType: c5a.large c5.xlarge: AlternativeInstanceType: c5a.xlarge c5.2xlarge: AlternativeInstanceType: c5a.2xlarge c5.4xlarge: AlternativeInstanceType: c5a.4xlarge c5.9xlarge: AlternativeInstanceType: c5a.8xlarge c5.12xlarge: AlternativeInstanceType: c5a.12xlarge c5.18xlarge: AlternativeInstanceType: c5a.16xlarge c5.24xlarge: AlternativeInstanceType: c5a.24xlarge Resources: InfrastructureAlarmsTopic: Type: AWS::SNS::Topic Properties: Tags: - Key: bucketav:cloudformation:logical-id Value: InfrastructureAlarmsTopic - Key: bucketav:cloudformation:stack-id Value: Ref: AWS::StackId - Key: bucketav:cloudformation:stack-name Value: Ref: AWS::StackName InfrastructureAlarmsSubscription: Type: AWS::SNS::Subscription Properties: Endpoint: Ref: InfrastructureAlarmsEmail Protocol: email TopicArn: Ref: InfrastructureAlarmsTopic Condition: HasInfrastructureAlarmsEmail FindingsTopic: Type: AWS::SNS::Topic Properties: Tags: - Key: bucketav:cloudformation:logical-id Value: FindingsTopic - Key: bucketav:cloudformation:stack-id Value: Ref: AWS::StackId - Key: bucketav:cloudformation:stack-name Value: Ref: AWS::StackName ServiceDiscoveryFindingsTopicArn: Type: AWS::SSM::Parameter Properties: Description: bucketAV managed value. DO NOT CHANGE! Name: Fn::Join: - "" - - /bucketAV/ - Ref: AWS::StackName - /FindingsTopicArn Type: String Value: Ref: FindingsTopic Condition: HasNotMultiDeployment DeadLetterQueue: Type: AWS::SQS::Queue Properties: MessageRetentionPeriod: 1209600 SqsManagedSseEnabled: true Tags: - Key: bucketav:cloudformation:logical-id Value: DeadLetterQueue - Key: bucketav:cloudformation:stack-id Value: Ref: AWS::StackId - Key: bucketav:cloudformation:stack-name Value: Ref: AWS::StackName ScanQueue: Type: AWS::SQS::Queue Properties: DelaySeconds: Ref: ScanDelayInSeconds MessageRetentionPeriod: 1209600 RedrivePolicy: deadLetterTargetArn: Fn::GetAtt: - DeadLetterQueue - Arn maxReceiveCount: 3 SqsManagedSseEnabled: true Tags: - Key: bucketav:cloudformation:logical-id Value: ScanQueue - Key: bucketav:cloudformation:stack-id Value: Ref: AWS::StackId - Key: bucketav:cloudformation:stack-name Value: Ref: AWS::StackName VisibilityTimeout: 300 ScanQueuePolicy: Type: AWS::SQS::QueuePolicy Properties: PolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Principal: Service: - s3.amazonaws.com - sns.amazonaws.com - events.amazonaws.com Action: sqs:SendMessage Resource: Fn::GetAtt: - ScanQueue - Arn Condition: StringEquals: aws:SourceAccount: Ref: AWS::AccountId - Fn::If: - HasAWSAccountRestriction - Effect: Allow Principal: Service: - s3.amazonaws.com - sns.amazonaws.com - events.amazonaws.com Action: sqs:SendMessage Resource: Fn::GetAtt: - ScanQueue - Arn Condition: StringEquals: aws:SourceAccount: Ref: AWSAccountRestriction - Ref: AWS::NoValue - Fn::If: - HasAWSOrganizationRestriction - Effect: Allow Principal: Service: - s3.amazonaws.com - sns.amazonaws.com - events.amazonaws.com Action: sqs:SendMessage Resource: Fn::GetAtt: - ScanQueue - Arn Condition: StringEquals: aws:SourceOrgID: Ref: AWSOrganizationRestriction - Ref: AWS::NoValue Queues: - Ref: ScanQueue DeadLetterQueueAlarm: Type: AWS::CloudWatch::Alarm Properties: AlarmActions: - Ref: InfrastructureAlarmsTopic AlarmDescription: Dead letter queue contains messages. Some scan jobs were dropped. Please follow https://bucketav.com/help/operations/monitoring-alerting.html#deadletterqueuealarm ComparisonOperator: GreaterThanOrEqualToThreshold Dimensions: - Name: QueueName Value: Fn::GetAtt: - DeadLetterQueue - QueueName EvaluationPeriods: 1 MetricName: ApproximateNumberOfMessagesVisible Namespace: AWS/SQS Period: 60 Statistic: Sum Threshold: 1 TreatMissingData: notBreaching ScanQueueOldMessagesAlarm: Type: AWS::CloudWatch::Alarm Properties: AlarmActions: - Ref: InfrastructureAlarmsTopic AlarmDescription: Scan queue contains messages older than 12 hours. Please follow https://bucketav.com/help/operations/monitoring-alerting.html#scanqueueoldmessagesalarm ComparisonOperator: GreaterThanOrEqualToThreshold Dimensions: - Name: QueueName Value: Fn::GetAtt: - ScanQueue - QueueName EvaluationPeriods: 1 MetricName: ApproximateAgeOfOldestMessage Namespace: AWS/SQS Period: 60 Statistic: Maximum Threshold: 43200 TreatMissingData: notBreaching ServiceDiscoveryScanQueueArn: Type: AWS::SSM::Parameter Properties: Description: bucketAV managed value. DO NOT CHANGE! Name: Fn::Join: - "" - - /bucketAV/ - Ref: AWS::StackName - /ScanQueueArn Type: String Value: Fn::GetAtt: - ScanQueue - Arn Condition: HasNotMultiDeployment ServiceDiscoveryScanQueueUrl: Type: AWS::SSM::Parameter Properties: Description: bucketAV managed value. DO NOT CHANGE! Name: Fn::Join: - "" - - /bucketAV/ - Ref: AWS::StackName - /ScanQueueUrl Type: String Value: Ref: ScanQueue Condition: HasNotMultiDeployment LambdaSecurityGroup: Type: AWS::EC2::SecurityGroup Properties: GroupDescription: Fn::Join: - "" - - Ref: AWS::StackName - -lambda SecurityGroupEgress: - CidrIp: 0.0.0.0/0 Description: Allow outgoing HTTPS traffic. FromPort: 443 IpProtocol: tcp ToPort: 443 VpcId: Ref: VPC Condition: HasLambdaVpc ScanSecurityGroup: Type: AWS::EC2::SecurityGroup Properties: GroupDescription: Fn::Join: - "" - - Ref: AWS::StackName - -scan SecurityGroupEgress: - CidrIp: 0.0.0.0/0 Description: Accessing DNS, if VPC is configured with enableDnsSupport=false. FromPort: 53 IpProtocol: tcp ToPort: 53 - CidrIp: 0.0.0.0/0 Description: Accessing DNS, if VPC is configured with enableDnsSupport=false. FromPort: 53 IpProtocol: udp ToPort: 53 - CidrIp: 0.0.0.0/0 Description: Accessing AWS APIs and fetching virus database updates. FromPort: 443 IpProtocol: tcp ToPort: 443 VpcId: Ref: VPC ScanSecurityGroupInSSH: Type: AWS::EC2::SecurityGroupIngress Properties: CidrIp: Ref: SSHIngressCidrIp FromPort: 22 GroupId: Ref: ScanSecurityGroup IpProtocol: tcp ToPort: 22 Condition: HasSSHIngressCidrIp ScanSecurityGroupInSSH2: Type: AWS::EC2::SecurityGroupIngress Properties: FromPort: 22 GroupId: Ref: ScanSecurityGroup IpProtocol: tcp SourceSecurityGroupId: Ref: SSHIngressSecurityGroupId ToPort: 22 Condition: HasSSHIngressSecurityGroupId AutoScalingGroupCalculatorRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Principal: Service: lambda.amazonaws.com Action: sts:AssumeRole PermissionsBoundary: Fn::If: - HasPermissionsBoundary - Ref: PermissionsBoundary - Ref: AWS::NoValue AutoScalingGroupCalculatorFunction: Type: AWS::Lambda::Function Properties: Code: ZipFile: | var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // lambda/asg-calculator.js var asg_calculator_exports = {}; __export(asg_calculator_exports, { calculate: () => calculate, handler: () => handler }); module.exports = __toCommonJS(asg_calculator_exports); // lambda/lib.js var import_client_cloudformation = require("@aws-sdk/client-cloudformation"); var import_client_s3 = require("@aws-sdk/client-s3"); var import_client_sns = require("@aws-sdk/client-sns"); var import_client_eventbridge = require("@aws-sdk/client-eventbridge"); var import_client_dynamodb = require("@aws-sdk/client-dynamodb"); var import_client_s32 = require("@aws-sdk/client-s3"); var import_client_ssm = require("@aws-sdk/client-ssm"); var import_client_secrets_manager = require("@aws-sdk/client-secrets-manager"); var import_client_organizations = require("@aws-sdk/client-organizations"); var import_credential_providers = require("@aws-sdk/credential-providers"); // lambda/node_modules/@smithy/core/dist-es/pagination/createPaginator.js var makePagedClientRequest = async (CommandCtor, client, input, withCommand = (_) => _, ...args) => { let command = new CommandCtor(input); command = withCommand(command) ?? command; return await client.send(command, ...args); }; function createPaginator(ClientCtor, CommandCtor, inputTokenName, outputTokenName, pageSizeTokenName) { return async function* paginateOperation(config, input, ...additionalArguments) { const _input = input; let token = config.startingToken ?? _input[inputTokenName]; let hasNext = true; let page; while (hasNext) { _input[inputTokenName] = token; if (pageSizeTokenName) { _input[pageSizeTokenName] = _input[pageSizeTokenName] ?? config.pageSize; } if (config.client instanceof ClientCtor) { page = await makePagedClientRequest(CommandCtor, config.client, input, config.withCommand, ...additionalArguments); } else { throw new Error(`Invalid client, expected instance of ${ClientCtor.name}`); } yield page; const prevToken = token; token = get(page, outputTokenName); hasNext = !!(token && (!config.stopOnSameToken || token !== prevToken)); } return void 0; }; } var get = (fromObject, path) => { let cursor = fromObject; const pathComponents = path.split("."); for (const step of pathComponents) { if (!cursor || typeof cursor !== "object") { return void 0; } cursor = cursor[step]; } return cursor; }; // lambda/lib.js var paginateListRuleNamesByTarget = createPaginator(import_client_eventbridge.EventBridgeClient, import_client_eventbridge.ListRuleNamesByTargetCommand, "NextToken", "NextToken", "Limit"); var MAX_S3_COPY_SIZE = 5 * 1024 * 1024 * 1024; async function cfnCustomResourceSuccess(event, physicalResourceId, optionalData) { const response = await fetch(event.ResponseURL, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ Status: "SUCCESS", PhysicalResourceId: physicalResourceId, StackId: event.StackId, RequestId: event.RequestId, LogicalResourceId: event.LogicalResourceId, ...optionalData !== void 0 && { Data: optionalData } }) }); if (response.status !== 200) { console.log("response status", response.status); console.log("response", await response.text()); throw new Error("unexpected status code"); } } // lambda/asg-calculator.js function calculate(minSize, maxSize) { return { MaxBatchSize: Math.max(1, Math.floor(minSize / 2)), MinInstancesInService: maxSize === minSize ? Math.max(0, minSize - 1) : minSize }; } async function handler(event) { console.log(`Invoke: ${JSON.stringify(event)}`); if (event.RequestType === "Create" || event.RequestType === "Update" || event.RequestType === "Delete") { const minSize = parseInt(event.ResourceProperties.MinSize, 10); const maxSize = parseInt(event.ResourceProperties.MaxSize, 10); await cfnCustomResourceSuccess(event, "asg-calculator", calculate(minSize, maxSize)); } else { throw new Error("unsupported request type"); } } // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { calculate, handler }); Handler: index.handler MemorySize: 1769 ReservedConcurrentExecutions: Fn::If: - HasAutoScalingGroupCalculatorFunctionReservedConcurrentExecutions - Ref: AutoScalingGroupCalculatorFunctionReservedConcurrentExecutions - Ref: AWS::NoValue Role: Fn::GetAtt: - AutoScalingGroupCalculatorRole - Arn Runtime: nodejs22.x Timeout: 30 VpcConfig: Fn::If: - HasLambdaVpc - SecurityGroupIds: - Ref: LambdaSecurityGroup SubnetIds: Ref: LambdaSubnets - Ref: AWS::NoValue Metadata: bucketav::AutoScalingGroupCalculatorLambdaVpcAllowPolicy: Fn::If: - HasLambdaVpc - Ref: AutoScalingGroupCalculatorLambdaVpcAllowPolicy - Ref: AWS::NoValue AutoScalingGroupCalculatorLambdaVpcAllowPolicy: Type: AWS::IAM::Policy Properties: PolicyDocument: Statement: - Effect: Allow Action: - ec2:CreateNetworkInterface - ec2:DeleteNetworkInterface - ec2:AssignPrivateIpAddresses - ec2:UnassignPrivateIpAddresses - ec2:DescribeNetworkInterfaces - ec2:DescribeSubnets Resource: - "*" PolicyName: vpc-allow Roles: - Ref: AutoScalingGroupCalculatorRole Condition: HasLambdaVpc AutoScalingGroupCalculatorLambdaVpcDenyPolicy: Type: AWS::IAM::Policy Properties: PolicyDocument: Statement: - Effect: Deny Action: - ec2:CreateNetworkInterface - ec2:DescribeNetworkInterfaces - ec2:DescribeSubnets - ec2:DeleteNetworkInterface - ec2:AssignPrivateIpAddresses - ec2:UnassignPrivateIpAddresses Resource: "*" Condition: ArnEquals: lambda:SourceFunctionArn: - Fn::GetAtt: - AutoScalingGroupCalculatorFunction - Arn PolicyName: vpc-deny Roles: - Ref: AutoScalingGroupCalculatorRole Condition: HasLambdaVpc AutoScalingGroupCalculatorLogGroup: Type: AWS::Logs::LogGroup Properties: LogGroupName: Fn::Join: - "" - - /aws/lambda/ - Ref: AutoScalingGroupCalculatorFunction RetentionInDays: Ref: LogsRetentionInDays AutoScalingGroupCalculatorPolicy: Type: AWS::IAM::Policy Properties: PolicyDocument: Statement: - Effect: Allow Action: - logs:CreateLogStream - logs:PutLogEvents Resource: Fn::GetAtt: - AutoScalingGroupCalculatorLogGroup - Arn PolicyName: lambda Roles: - Ref: AutoScalingGroupCalculatorRole AutoScalingGroupCalculator: Type: Custom::AutoScalingGroupCalculator Properties: ServiceToken: Fn::GetAtt: - AutoScalingGroupCalculatorFunction - Arn ServiceTimeout: "30" Version: 2.0.0 MinSize: Ref: AutoScalingMinSize MaxSize: Ref: AutoScalingMaxSize DependsOn: - AutoScalingGroupCalculatorPolicy UpdateReplacePolicy: Delete DeletionPolicy: Delete SignaturesAgeAlarm: Type: AWS::CloudWatch::Alarm Properties: AlarmActions: - Ref: InfrastructureAlarmsTopic AlarmDescription: Signatures are older than 7 days. Are signature updates working? Please follow https://bucketav.com/help/operations/monitoring-alerting.html#signaturesagealarm ComparisonOperator: GreaterThanThreshold EvaluationPeriods: 1 MetricName: signatures_age Namespace: Ref: AWS::StackName Period: 600 Statistic: Maximum Threshold: 604800 TreatMissingData: notBreaching Logs: Type: AWS::Logs::LogGroup Properties: RetentionInDays: Ref: LogsRetentionInDays PrivateKeySecret: Type: AWS::SecretsManager::Secret Condition: HasSignCallbackInvocations ServiceDiscoveryPublicKeyPEMPKCS1: Type: AWS::SSM::Parameter Properties: Description: bucketAV managed value. DO NOT CHANGE! Name: Fn::Join: - "" - - /bucketAV/ - Ref: AWS::StackName - /PublicKeyPEMPKCS1 Type: String Value: INIT Condition: HasSignCallbackInvocations ServiceDiscoveryPublicKeyPEMX509: Type: AWS::SSM::Parameter Properties: Description: bucketAV managed value. DO NOT CHANGE! Name: Fn::Join: - "" - - /bucketAV/ - Ref: AWS::StackName - /PublicKeyPEMX509 Type: String Value: INIT Condition: HasSignCallbackInvocations PrivateKeyGeneratorLogGroup: Type: AWS::Logs::LogGroup Properties: RetentionInDays: Ref: LogsRetentionInDays Condition: HasSignCallbackInvocations PrivateKeyGeneratorRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Principal: Service: lambda.amazonaws.com Action: sts:AssumeRole PermissionsBoundary: Fn::If: - HasPermissionsBoundary - Ref: PermissionsBoundary - Ref: AWS::NoValue Policies: - PolicyDocument: Statement: - Effect: Allow Action: - logs:CreateLogStream - logs:PutLogEvents Resource: Fn::GetAtt: - PrivateKeyGeneratorLogGroup - Arn - Effect: Allow Action: secretsmanager:PutSecretValue Resource: Ref: PrivateKeySecret - Effect: Allow Action: ssm:PutParameter Resource: - Fn::Join: - "" - - "arn:" - Ref: AWS::Partition - ":ssm:" - Ref: AWS::Region - ":" - Ref: AWS::AccountId - :parameter - Ref: ServiceDiscoveryPublicKeyPEMPKCS1 - Fn::Join: - "" - - "arn:" - Ref: AWS::Partition - ":ssm:" - Ref: AWS::Region - ":" - Ref: AWS::AccountId - :parameter - Ref: ServiceDiscoveryPublicKeyPEMX509 PolicyName: core Condition: HasSignCallbackInvocations PrivateKeyGeneratorFunction: Type: AWS::Lambda::Function Properties: Code: ZipFile: | var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // lambda/private-key-generator.js var private_key_generator_exports = {}; __export(private_key_generator_exports, { handler: () => handler }); module.exports = __toCommonJS(private_key_generator_exports); // lambda/lib.js var import_client_cloudformation = require("@aws-sdk/client-cloudformation"); var import_client_s3 = require("@aws-sdk/client-s3"); var import_client_sns = require("@aws-sdk/client-sns"); var import_client_eventbridge = require("@aws-sdk/client-eventbridge"); var import_client_dynamodb = require("@aws-sdk/client-dynamodb"); var import_client_s32 = require("@aws-sdk/client-s3"); var import_client_ssm = require("@aws-sdk/client-ssm"); var import_client_secrets_manager = require("@aws-sdk/client-secrets-manager"); var import_client_organizations = require("@aws-sdk/client-organizations"); var import_credential_providers = require("@aws-sdk/credential-providers"); // lambda/node_modules/@smithy/core/dist-es/pagination/createPaginator.js var makePagedClientRequest = async (CommandCtor, client, input, withCommand = (_) => _, ...args) => { let command = new CommandCtor(input); command = withCommand(command) ?? command; return await client.send(command, ...args); }; function createPaginator(ClientCtor, CommandCtor, inputTokenName, outputTokenName, pageSizeTokenName) { return async function* paginateOperation(config, input, ...additionalArguments) { const _input = input; let token = config.startingToken ?? _input[inputTokenName]; let hasNext = true; let page; while (hasNext) { _input[inputTokenName] = token; if (pageSizeTokenName) { _input[pageSizeTokenName] = _input[pageSizeTokenName] ?? config.pageSize; } if (config.client instanceof ClientCtor) { page = await makePagedClientRequest(CommandCtor, config.client, input, config.withCommand, ...additionalArguments); } else { throw new Error(`Invalid client, expected instance of ${ClientCtor.name}`); } yield page; const prevToken = token; token = get(page, outputTokenName); hasNext = !!(token && (!config.stopOnSameToken || token !== prevToken)); } return void 0; }; } var get = (fromObject, path) => { let cursor = fromObject; const pathComponents = path.split("."); for (const step of pathComponents) { if (!cursor || typeof cursor !== "object") { return void 0; } cursor = cursor[step]; } return cursor; }; // lambda/lib.js var paginateListRuleNamesByTarget = createPaginator(import_client_eventbridge.EventBridgeClient, import_client_eventbridge.ListRuleNamesByTargetCommand, "NextToken", "NextToken", "Limit"); var MAX_S3_COPY_SIZE = 5 * 1024 * 1024 * 1024; async function cfnCustomResourceSuccess(event, physicalResourceId, optionalData) { const response = await fetch(event.ResponseURL, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ Status: "SUCCESS", PhysicalResourceId: physicalResourceId, StackId: event.StackId, RequestId: event.RequestId, LogicalResourceId: event.LogicalResourceId, ...optionalData !== void 0 && { Data: optionalData } }) }); if (response.status !== 200) { console.log("response status", response.status); console.log("response", await response.text()); throw new Error("unexpected status code"); } } // lambda/private-key-generator.js var import_node_crypto = require("node:crypto"); var import_client_secrets_manager2 = require("@aws-sdk/client-secrets-manager"); var import_client_ssm2 = require("@aws-sdk/client-ssm"); var secretsmanager = new import_client_secrets_manager2.SecretsManagerClient({ apiVersion: "2017-10-17" }); var ssm = new import_client_ssm2.SSMClient({ apiVersion: "2014-11-06" }); async function handler(event) { console.log(`Invoke: ${JSON.stringify(event)}`); if (event.RequestType === "Create" || event.RequestType === "Update") { const { publicKey, privateKey } = (0, import_node_crypto.generateKeyPairSync)("rsa", { modulusLength: 2048 }); await secretsmanager.send(new import_client_secrets_manager2.PutSecretValueCommand({ SecretId: event.ResourceProperties.PrivateKeySecretArn, SecretString: privateKey.export({ type: "pkcs1", format: "pem" }) })); await ssm.send(new import_client_ssm2.PutParameterCommand({ Name: event.ResourceProperties.PublicKeyPEMPKCS1ParameterName, Value: publicKey.export({ type: "pkcs1", format: "pem" }), Overwrite: true })); await ssm.send(new import_client_ssm2.PutParameterCommand({ Name: event.ResourceProperties.PublicKeyPEMX509ParameterName, Value: publicKey.export({ type: "spki", format: "pem" }), Overwrite: true })); await cfnCustomResourceSuccess(event, "private-key-generator"); } else if (event.RequestType === "Delete") { await cfnCustomResourceSuccess(event, "private-key-generator"); } else { throw new Error("unsupported request type"); } } // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { handler }); Handler: index.handler LoggingConfig: LogGroup: Ref: PrivateKeyGeneratorLogGroup MemorySize: 1769 ReservedConcurrentExecutions: Fn::If: - HasPrivateKeyGeneratorFunctionReservedConcurrentExecutions - Ref: PrivateKeyGeneratorFunctionReservedConcurrentExecutions - Ref: AWS::NoValue Role: Fn::GetAtt: - PrivateKeyGeneratorRole - Arn Runtime: nodejs22.x Timeout: 60 VpcConfig: Fn::If: - HasLambdaVpc - SecurityGroupIds: - Ref: LambdaSecurityGroup SubnetIds: Ref: LambdaSubnets - Ref: AWS::NoValue Metadata: bucketav::PrivateKeyGeneratorLambdaVpcAllowPolicy: Fn::If: - HasSignCallbackInvocationsAndLambdaVpc - Ref: PrivateKeyGeneratorLambdaVpcAllowPolicy - Ref: AWS::NoValue Condition: HasSignCallbackInvocations PrivateKeyGeneratorLambdaVpcAllowPolicy: Type: AWS::IAM::Policy Properties: PolicyDocument: Statement: - Effect: Allow Action: - ec2:CreateNetworkInterface - ec2:DeleteNetworkInterface - ec2:AssignPrivateIpAddresses - ec2:UnassignPrivateIpAddresses - ec2:DescribeNetworkInterfaces - ec2:DescribeSubnets Resource: - "*" PolicyName: vpc-allow Roles: - Ref: PrivateKeyGeneratorRole Condition: HasSignCallbackInvocationsAndLambdaVpc PrivateKeyGeneratorLambdaVpcDenyPolicy: Type: AWS::IAM::Policy Properties: PolicyDocument: Statement: - Effect: Deny Action: - ec2:CreateNetworkInterface - ec2:DescribeNetworkInterfaces - ec2:DescribeSubnets - ec2:DeleteNetworkInterface - ec2:AssignPrivateIpAddresses - ec2:UnassignPrivateIpAddresses Resource: "*" Condition: ArnEquals: lambda:SourceFunctionArn: - Fn::GetAtt: - PrivateKeyGeneratorFunction - Arn PolicyName: vpc-deny Roles: - Ref: PrivateKeyGeneratorRole Condition: HasSignCallbackInvocationsAndLambdaVpc PrivateKeyGenerator: Type: Custom::PrivateKeyGenerator Properties: ServiceToken: Fn::GetAtt: - PrivateKeyGeneratorFunction - Arn ServiceTimeout: "60" Version: 2.0.0 PrivateKeySecretArn: Ref: PrivateKeySecret PublicKeyPEMPKCS1ParameterName: Ref: ServiceDiscoveryPublicKeyPEMPKCS1 PublicKeyPEMX509ParameterName: Ref: ServiceDiscoveryPublicKeyPEMX509 UpdateReplacePolicy: Delete DeletionPolicy: Delete Condition: HasSignCallbackInvocations ScanIAMRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Principal: Service: ec2.amazonaws.com Action: sts:AssumeRole ManagedPolicyArns: Fn::If: - HasManagedPolicyArns - Fn::Split: - "," - Ref: ManagedPolicyArns - Ref: AWS::NoValue PermissionsBoundary: Fn::If: - HasPermissionsBoundary - Ref: PermissionsBoundary - Ref: AWS::NoValue Policies: - PolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Action: s3:GetObject* Resource: Ref: S3ObjectRestriction - Effect: Allow Action: s3:ListBucket* Resource: Ref: S3BucketRestriction - Effect: Allow Action: kms:Decrypt Resource: Ref: KMSKeyRestriction Condition: StringLike: kms:ViaService: s3.*.amazonaws.com PolicyName: s3read - PolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Action: - sqs:DeleteMessage - sqs:ReceiveMessage - sqs:ChangeMessageVisibility Resource: Fn::GetAtt: - ScanQueue - Arn - Effect: Allow Action: sns:Publish Resource: Ref: FindingsTopic - Fn::If: - HasReportEventBridge - Effect: Allow Action: events:PutEvents Resource: Fn::Join: - "" - - "arn:" - Ref: AWS::Partition - ":events:" - Ref: AWS::Region - ":" - Ref: AWS::AccountId - :event-bus/default Condition: StringEquals: events:detail-type: Scan Result events:source: com.bucketav - Ref: AWS::NoValue PolicyName: core - Fn::If: - HasDeleteInfectedFiles - PolicyName: s3deletev2 PolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Action: s3:DeleteObject* Resource: Ref: S3ObjectRestriction - Ref: AWS::NoValue - Fn::If: - HasTagFiles - PolicyName: s3objecttagv2 PolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Action: - s3:GetObjectTagging - s3:GetObjectVersionTagging - s3:PutObjectTagging - s3:PutObjectVersionTagging Resource: Ref: S3ObjectRestriction - Ref: AWS::NoValue - Fn::If: - HasCrossAccount - PolicyName: crossaccount PolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Action: dynamodb:GetItem Resource: Fn::Join: - "" - - "arn:" - Ref: AWS::Partition - ":dynamodb:" - Ref: AWS::Region - ":" - Ref: AWS::AccountId - :table/ - Fn::If: - HasMultiDeployment - Fn::Join: - "" - - Ref: InternalMultiStackName - -BucketCache - Fn::Join: - "" - - Ref: AWS::StackName - -BucketCache - Effect: Allow Action: sts:AssumeRole Resource: Fn::If: - HasMultiDeployment - Fn::Join: - "" - - "arn:" - Ref: AWS::Partition - :iam::*:role/ - Ref: InternalMultiStackName - -AccountConnection - Fn::Join: - "" - - "arn:" - Ref: AWS::Partition - :iam::*:role/ - Ref: AWS::StackName - -AccountConnection - Ref: AWS::NoValue - Fn::If: - HasSignCallbackInvocations - PolicyName: signcallback PolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Action: secretsmanager:GetSecretValue Resource: Ref: PrivateKeySecret - Ref: AWS::NoValue - PolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Action: cloudwatch:PutMetricData Resource: "*" Condition: StringEquals: cloudwatch:namespace: Ref: AWS::StackName - Effect: Allow Action: - logs:CreateLogGroup - logs:CreateLogStream - logs:PutLogEvents - logs:DescribeLogStreams Resource: Fn::GetAtt: - Logs - Arn - Effect: Allow Action: logs:DescribeLogGroups Resource: "*" PolicyName: cloudwatch - PolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Action: autoscaling:DescribeAutoScalingInstances Resource: "*" - Effect: Allow Action: - autoscaling:CompleteLifecycleAction - autoscaling:RecordLifecycleActionHeartbeat Resource: "*" Condition: StringEquals: autoscaling:ResourceTag/aws:cloudformation:stack-id: Ref: AWS::StackId PolicyName: asg - Fn::If: - HasSystemsManagerAccess - PolicyName: ssmv2 PolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Action: - ssm:UpdateInstanceInformation - ssm:ListAssociations - ssm:ListInstanceAssociations - ssmmessages:CreateControlChannel - ssmmessages:CreateDataChannel - ssmmessages:OpenControlChannel - ssmmessages:OpenDataChannel - ec2messages:AcknowledgeMessage - ec2messages:DeleteMessage - ec2messages:FailMessage - ec2messages:GetEndpoint - ec2messages:GetMessages - ec2messages:SendReply Resource: "*" - Ref: AWS::NoValue ScanInstanceProfile: Type: AWS::IAM::InstanceProfile Properties: Roles: - Ref: ScanIAMRole ScanLaunchTemplate: Type: AWS::EC2::LaunchTemplate Properties: LaunchTemplateData: BlockDeviceMappings: - DeviceName: /dev/xvda Ebs: Encrypted: true KmsKeyId: Fn::If: - HasVolumeKmsKeyId - Ref: VolumeKmsKeyId - Ref: AWS::NoValue VolumeSize: 32 VolumeType: gp3 IamInstanceProfile: Name: Ref: ScanInstanceProfile ImageId: Ref: AMI2310 InstanceType: Ref: InstanceType KeyName: Ref: KeyName MetadataOptions: HttpPutResponseHopLimit: 1 HttpTokens: required NetworkInterfaces: - AssociatePublicIpAddress: Ref: AssociatePublicIpAddress DeviceIndex: 0 Groups: Fn::If: - HasSecurityGroupIds - Fn::Split: - "," - Fn::Join: - "" - - Ref: ScanSecurityGroup - "," - Ref: SecurityGroupIds - - Ref: ScanSecurityGroup TagSpecifications: - ResourceType: volume Tags: - Key: bucketav:cloudformation:logical-id Value: ScanLaunchTemplate - Key: bucketav:cloudformation:stack-id Value: Ref: AWS::StackId - Key: bucketav:cloudformation:stack-name Value: Ref: AWS::StackName - Key: Name Value: Ref: AWS::StackName - ResourceType: network-interface Tags: - Key: bucketav:cloudformation:logical-id Value: ScanLaunchTemplate - Key: bucketav:cloudformation:stack-id Value: Ref: AWS::StackId - Key: bucketav:cloudformation:stack-name Value: Ref: AWS::StackName - Key: Name Value: Ref: AWS::StackName UserData: Fn::Base64: Fn::Join: - "" - - |- #!/bin/bash -ex trap '/usr/bin/cfn-signal -e 1 --stack - Ref: AWS::StackName - " --resource ScanAutoScalingGroup --region " - Ref: AWS::Region - |- ' ERR sed -e 's/__REGION__/ - Ref: AWS::Region - |- /g' -i /etc/vector/vector.yaml sed -e 's/__LOG_GROUP_NAME__/ - Ref: Logs - |- /g' -i /etc/vector/vector.yaml sed -e "s/__INSTANCE_ID__/$(ec2-metadata -i --quiet)/g" -i /etc/vector/vector.yaml systemctl enable vector.service systemctl --no-block start vector.service sed -e 's/__LOG_GROUP_NAME__/ - Ref: Logs - |- /g' -i /opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json sed -e 's/__NAMESPACE__/ - Ref: AWS::StackName - |- /g' -i /opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json systemctl enable amazon-cloudwatch-agent.service systemctl --no-block start amazon-cloudwatch-agent.service if [[ " - Ref: SystemsManagerAccess - |- " == "true" ]]; then systemctl enable amazon-ssm-agent.service systemctl --no-block start amazon-ssm-agent.service fi if [[ " - Ref: EnableCache - |- " == "false" ]]; then echo "DisableCache yes" >> /usr/local/etc/clamd.conf fi systemctl enable clamd.service systemctl start clamd.service IFS="," read -r -a urls <<< " - Ref: AdditionalDatabaseUrls - |- " for url in "${urls[@]}"; do echo "DatabaseCustomURL ${url}" >> /usr/local/etc/freshclam.conf; done echo "PrivateMirror https://bucketav-clamav-mirror- - Ref: AWS::Region - .s3. - Ref: AWS::Region - |- .amazonaws.com" >> /usr/local/etc/freshclam.conf systemctl enable freshclam.service systemctl start freshclam.service cat <<"EOF" | tee /opt/bucketav/bucketav.conf > /dev/null mode: consumer platform: aws delete: - Ref: DeleteInfectedFiles - |- report_clean: - Ref: ReportCleanFiles - |- tag_files: - Ref: TagFiles - |- tag_key: ' - Ref: TagKey - |- ' report_eventbridge: - Ref: ReportEventBridge - |- region: ' - Ref: AWS::Region - |- ' queue: ' - Ref: ScanQueue - |- ' topic: ' - Ref: FindingsTopic - |- ' stack_name: ' - Ref: AWS::StackName - |- ' core_stack_name: ' - Ref: AWS::StackName - |- ' cross_account: - Fn::If: - HasCrossAccount - true - false - |- bucket_cache_table_name: ' - Fn::If: - HasCrossAccount - Fn::If: - HasMultiDeployment - Fn::Join: - "" - - Ref: InternalMultiStackName - -BucketCache - Fn::Join: - "" - - Ref: AWS::StackName - -BucketCache - "" - |- ' product: 'bucketav' debug: false EOF if [[ " - Ref: SignCallbackInvocations - |- " == "true" ]]; then echo "private_key_secret_arn: ' - Fn::If: - HasSignCallbackInvocations - Ref: PrivateKeySecret - "" - |- '" >> /opt/bucketav/bucketav.conf fi chown ec2-user:ec2-user /opt/bucketav/bucketav.conf systemctl enable bucketav.service systemctl start bucketav.service /usr/bin/cfn-signal -e 0 --stack - Ref: AWS::StackName - " --resource ScanAutoScalingGroup --region " - Ref: AWS::Region - "\n" ScanAutoScalingGroup: Type: AWS::AutoScaling::AutoScalingGroup Properties: CapacityRebalance: true InstanceMaintenancePolicy: MaxHealthyPercentage: 200 MinHealthyPercentage: 100 LifecycleHookSpecificationList: - DefaultResult: CONTINUE HeartbeatTimeout: 300 LifecycleHookName: bucketav_ready LifecycleTransition: autoscaling:EC2_INSTANCE_LAUNCHING - DefaultResult: CONTINUE HeartbeatTimeout: 432 LifecycleHookName: bucketav_terminate_gracefully LifecycleTransition: autoscaling:EC2_INSTANCE_TERMINATING MaxInstanceLifetime: 604800 MaxSize: Ref: AutoScalingMaxSize MetricsCollection: - Granularity: 1Minute Metrics: - GroupInServiceInstances - GroupDesiredCapacity MinSize: Ref: AutoScalingMinSize MixedInstancesPolicy: InstancesDistribution: OnDemandAllocationStrategy: prioritized OnDemandBaseCapacity: Fn::FindInMap: - CapacityStrategyMap - Ref: CapacityStrategy - OnDemandBaseCapacity OnDemandPercentageAboveBaseCapacity: Fn::FindInMap: - CapacityStrategyMap - Ref: CapacityStrategy - OnDemandPercentageAboveBaseCapacity SpotAllocationStrategy: capacity-optimized-prioritized LaunchTemplate: LaunchTemplateSpecification: LaunchTemplateId: Ref: ScanLaunchTemplate Version: Fn::GetAtt: - ScanLaunchTemplate - LatestVersionNumber Overrides: - InstanceType: Ref: InstanceType WeightedCapacity: "1" - Fn::If: - HasAlternativeInstanceType - InstanceType: Fn::FindInMap: - InstanceTypeMap - Ref: InstanceType - AlternativeInstanceType WeightedCapacity: "1" - Ref: AWS::NoValue Tags: - Key: Name PropagateAtLaunch: true Value: Ref: AWS::StackName VPCZoneIdentifier: Ref: Subnets UpdatePolicy: AutoScalingRollingUpdate: PauseTime: PT300S MaxBatchSize: Fn::GetAtt: - AutoScalingGroupCalculator - MaxBatchSize MinInstancesInService: Fn::GetAtt: - AutoScalingGroupCalculator - MinInstancesInService SuspendProcesses: - HealthCheck - ReplaceUnhealthy - AZRebalance - AlarmNotification - ScheduledActions - InstanceRefresh WaitOnResourceSignals: true ScanScaleUp: Type: AWS::AutoScaling::ScalingPolicy Properties: AdjustmentType: ChangeInCapacity AutoScalingGroupName: Ref: ScanAutoScalingGroup EstimatedInstanceWarmup: 300 MetricAggregationType: Maximum PolicyType: StepScaling StepAdjustments: - MetricIntervalLowerBound: Fn::If: - HasAutoScalingMinSizeZero - 0 - 10 MetricIntervalUpperBound: 25 ScalingAdjustment: 1 - MetricIntervalLowerBound: 25 MetricIntervalUpperBound: 100 ScalingAdjustment: 2 - MetricIntervalLowerBound: 100 MetricIntervalUpperBound: 400 ScalingAdjustment: 4 - MetricIntervalLowerBound: 400 MetricIntervalUpperBound: 1600 ScalingAdjustment: 8 - MetricIntervalLowerBound: 1600 MetricIntervalUpperBound: 6400 ScalingAdjustment: 16 - MetricIntervalLowerBound: 6400 MetricIntervalUpperBound: 25600 ScalingAdjustment: 32 - MetricIntervalLowerBound: 25600 ScalingAdjustment: 64 FallbackLaunchTemplate: Type: AWS::EC2::LaunchTemplate Properties: LaunchTemplateData: BlockDeviceMappings: - DeviceName: /dev/xvda Ebs: Encrypted: true VolumeSize: 32 VolumeType: gp3 IamInstanceProfile: Name: Ref: ScanInstanceProfile ImageId: Ref: AMI2310 InstanceType: Ref: InstanceType KeyName: Ref: KeyName MetadataOptions: HttpPutResponseHopLimit: 1 HttpTokens: required NetworkInterfaces: - AssociatePublicIpAddress: Ref: AssociatePublicIpAddress DeviceIndex: 0 Groups: Fn::If: - HasSecurityGroupIds - Fn::Split: - "," - Fn::Join: - "" - - Ref: ScanSecurityGroup - "," - Ref: SecurityGroupIds - - Ref: ScanSecurityGroup TagSpecifications: - ResourceType: volume Tags: - Key: bucketav:cloudformation:logical-id Value: FallbackLaunchTemplate - Key: bucketav:cloudformation:stack-id Value: Ref: AWS::StackId - Key: bucketav:cloudformation:stack-name Value: Ref: AWS::StackName - Key: Name Value: Ref: AWS::StackName - ResourceType: network-interface Tags: - Key: bucketav:cloudformation:logical-id Value: FallbackLaunchTemplate - Key: bucketav:cloudformation:stack-id Value: Ref: AWS::StackId - Key: bucketav:cloudformation:stack-name Value: Ref: AWS::StackName - Key: Name Value: Ref: AWS::StackName UserData: Fn::Base64: Fn::Join: - "" - - |- #!/bin/bash -ex trap '/usr/bin/cfn-signal -e 1 --stack - Ref: AWS::StackName - " --resource FallbackAutoScalingGroup --region " - Ref: AWS::Region - |- ' ERR sed -e 's/__REGION__/ - Ref: AWS::Region - |- /g' -i /etc/vector/vector.yaml sed -e 's/__LOG_GROUP_NAME__/ - Ref: Logs - |- /g' -i /etc/vector/vector.yaml sed -e "s/__INSTANCE_ID__/$(ec2-metadata -i --quiet)/g" -i /etc/vector/vector.yaml systemctl enable vector.service systemctl --no-block start vector.service sed -e 's/__LOG_GROUP_NAME__/ - Ref: Logs - |- /g' -i /opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json sed -e 's/__NAMESPACE__/ - Ref: AWS::StackName - |- /g' -i /opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json systemctl enable amazon-cloudwatch-agent.service systemctl --no-block start amazon-cloudwatch-agent.service if [[ " - Ref: SystemsManagerAccess - |- " == "true" ]]; then systemctl enable amazon-ssm-agent.service systemctl --no-block start amazon-ssm-agent.service fi if [[ " - Ref: EnableCache - |- " == "false" ]]; then echo "DisableCache yes" >> /usr/local/etc/clamd.conf fi systemctl enable clamd.service systemctl start clamd.service IFS="," read -r -a urls <<< " - Ref: AdditionalDatabaseUrls - |- " for url in "${urls[@]}"; do echo "DatabaseCustomURL ${url}" >> /usr/local/etc/freshclam.conf; done echo "PrivateMirror https://bucketav-clamav-mirror- - Ref: AWS::Region - .s3. - Ref: AWS::Region - |- .amazonaws.com" >> /usr/local/etc/freshclam.conf systemctl enable freshclam.service systemctl start freshclam.service cat <<"EOF" | tee /opt/bucketav/bucketav.conf > /dev/null mode: consumer platform: aws delete: - Ref: DeleteInfectedFiles - |- report_clean: - Ref: ReportCleanFiles - |- tag_files: - Ref: TagFiles - |- tag_key: ' - Ref: TagKey - |- ' report_eventbridge: - Ref: ReportEventBridge - |- region: ' - Ref: AWS::Region - |- ' queue: ' - Ref: ScanQueue - |- ' topic: ' - Ref: FindingsTopic - |- ' stack_name: ' - Ref: AWS::StackName - |- ' core_stack_name: ' - Ref: AWS::StackName - |- ' cross_account: - Fn::If: - HasCrossAccount - true - false - |- bucket_cache_table_name: ' - Fn::If: - HasCrossAccount - Fn::If: - HasMultiDeployment - Fn::Join: - "" - - Ref: InternalMultiStackName - -BucketCache - Fn::Join: - "" - - Ref: AWS::StackName - -BucketCache - "" - |- ' product: 'bucketav' debug: false EOF if [[ " - Ref: SignCallbackInvocations - |- " == "true" ]]; then echo "private_key_secret_arn: ' - Fn::If: - HasSignCallbackInvocations - Ref: PrivateKeySecret - "" - |- '" >> /opt/bucketav/bucketav.conf fi chown ec2-user:ec2-user /opt/bucketav/bucketav.conf systemctl enable bucketav.service systemctl start bucketav.service /usr/bin/cfn-signal -e 0 --stack - Ref: AWS::StackName - " --resource FallbackAutoScalingGroup --region " - Ref: AWS::Region - "\n" Condition: HasOnDemandFallback FallbackAutoScalingGroup: Type: AWS::AutoScaling::AutoScalingGroup Properties: InstanceMaintenancePolicy: MaxHealthyPercentage: 200 MinHealthyPercentage: 100 LaunchTemplate: LaunchTemplateId: Ref: FallbackLaunchTemplate Version: Fn::GetAtt: - FallbackLaunchTemplate - LatestVersionNumber LifecycleHookSpecificationList: - DefaultResult: CONTINUE HeartbeatTimeout: 300 LifecycleHookName: bucketav_ready LifecycleTransition: autoscaling:EC2_INSTANCE_LAUNCHING - DefaultResult: CONTINUE HeartbeatTimeout: 432 LifecycleHookName: bucketav_terminate_gracefully LifecycleTransition: autoscaling:EC2_INSTANCE_TERMINATING MaxInstanceLifetime: 604800 MaxSize: Ref: AutoScalingMaxSize MetricsCollection: - Granularity: 1Minute Metrics: - GroupInServiceInstances - GroupDesiredCapacity MinSize: "0" Tags: - Key: Name PropagateAtLaunch: true Value: Fn::Join: - "" - - Ref: AWS::StackName - -fallback VPCZoneIdentifier: Ref: Subnets DependsOn: - ScanAutoScalingGroup UpdatePolicy: AutoScalingRollingUpdate: PauseTime: PT300S MaxBatchSize: Fn::GetAtt: - AutoScalingGroupCalculator - MaxBatchSize MinInstancesInService: Fn::GetAtt: - AutoScalingGroupCalculator - MinInstancesInService SuspendProcesses: - HealthCheck - ReplaceUnhealthy - AZRebalance - AlarmNotification - ScheduledActions - InstanceRefresh WaitOnResourceSignals: true Condition: HasOnDemandFallback SecurityHubIntegrationLambdaRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Principal: Service: lambda.amazonaws.com Action: sts:AssumeRole PermissionsBoundary: Fn::If: - HasPermissionsBoundary - Ref: PermissionsBoundary - Ref: AWS::NoValue Policies: - PolicyDocument: Statement: - Effect: Allow Action: securityhub:BatchImportFindings Resource: "*" Condition: StringEquals: securityhub:TargetAccount: Ref: AWS::AccountId PolicyName: securityhub Condition: HasSecurityHubIntegration SecurityHubIntegrationLambdaFunction: Type: AWS::Lambda::Function Properties: Code: ZipFile: | var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // lambda/integration-security-hub.js var integration_security_hub_exports = {}; __export(integration_security_hub_exports, { handler: () => handler }); module.exports = __toCommonJS(integration_security_hub_exports); var import_client_securityhub = require("@aws-sdk/client-securityhub"); // lambda/lib2.js function generateSecurityHubTitle(record) { if (record.Sns.MessageAttributes.status.Value === "no") { return "File scan failed"; } else if (record.Sns.MessageAttributes.status.Value === "infected") { return "Infected file found"; } else { throw new Error(`unsupported status: ${record.Sns.MessageAttributes.status.Value}`); } } function generateSecurityHubDescription(record) { if (record.Sns.MessageAttributes.status.Value === "no") { let description = `File in S3 bucket ${record.Sns.MessageAttributes.bucket.Value} with key ${record.Sns.MessageAttributes.key.Value}`; if ("version" in record.Sns.MessageAttributes) { description += ` and version ${record.Sns.MessageAttributes.version.Value}`; } description += " could not be scanned"; if ("finding" in record.Sns.MessageAttributes) { description += ` (${record.Sns.MessageAttributes.finding.Value})`; } description += `, ${record.Sns.MessageAttributes.action.Value} action executed`; return description; } else if (record.Sns.MessageAttributes.status.Value === "infected") { let description = "Infected file"; if ("finding" in record.Sns.MessageAttributes) { description += ` (${record.Sns.MessageAttributes.finding.Value})`; } description += ` found in S3 bucket ${record.Sns.MessageAttributes.bucket.Value} with key ${record.Sns.MessageAttributes.key.Value}`; if ("version" in record.Sns.MessageAttributes) { description += ` and version ${record.Sns.MessageAttributes.version.Value}`; } description += `, ${record.Sns.MessageAttributes.action.Value} action executed`; return description; } else { throw new Error(`unsupported status: ${record.Sns.MessageAttributes.status.Value}`); } } // lambda/integration-security-hub.js var securityhub = new import_client_securityhub.SecurityHubClient({ apiVersion: "2018-10-26" }); async function handler(event) { console.log(`Invoke: ${JSON.stringify(event)}`); try { await securityhub.send(new import_client_securityhub.BatchImportFindingsCommand({ Findings: event.Records.map((record) => { const finding = { SchemaVersion: "2018-10-08", Id: record.Sns.MessageId, ProductArn: `arn:${process.env.AWS_PARTITION}:securityhub:${process.env.AWS_REGION}:${process.env.AWS_ACCOUNT_ID}:product/${process.env.AWS_ACCOUNT_ID}/default`, GeneratorId: "clamav", AwsAccountId: process.env.AWS_ACCOUNT_ID, Types: ["Unusual Behaviors/Data"], CreatedAt: record.Sns.Timestamp, UpdatedAt: record.Sns.Timestamp, Severity: { Label: "HIGH" }, Confidence: 100, Title: generateSecurityHubTitle(record), Description: generateSecurityHubDescription(record), Resources: [{ Type: "AwsS3Object", Id: `arn:aws:s3:::${record.Sns.MessageAttributes.bucket.Value}/${record.Sns.MessageAttributes.key.Value}` }, { Type: "AwsS3Bucket", Id: `arn:aws:s3:::${record.Sns.MessageAttributes.bucket.Value}` }] }; if ("version" in record.Sns.MessageAttributes) { finding.Resources[0].Details = { AwsS3Object: { VersionId: record.Sns.MessageAttributes.version.Value } }; } return finding; }) })); } catch (err) { if (err.name === "AccessDeniedException") { return false; } else { throw err; } } return true; } // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { handler }); Environment: Variables: AWS_ACCOUNT_ID: Ref: AWS::AccountId AWS_PARTITION: Ref: AWS::Partition Handler: index.handler MemorySize: 1769 Role: Fn::GetAtt: - SecurityHubIntegrationLambdaRole - Arn Runtime: nodejs22.x Timeout: 60 Condition: HasSecurityHubIntegration SecurityHubIntegrationLambdaLogGroup: Type: AWS::Logs::LogGroup Properties: LogGroupName: Fn::Join: - "" - - /aws/lambda/ - Ref: SecurityHubIntegrationLambdaFunction RetentionInDays: Ref: LogsRetentionInDays Condition: HasSecurityHubIntegration SecurityHubIntegrationLambdaPolicy: Type: AWS::IAM::Policy Properties: PolicyDocument: Statement: - Effect: Allow Action: - logs:CreateLogStream - logs:PutLogEvents Resource: Fn::GetAtt: - SecurityHubIntegrationLambdaLogGroup - Arn PolicyName: logs Roles: - Ref: SecurityHubIntegrationLambdaRole Condition: HasSecurityHubIntegration SecurityHubIntegrationLambdaPermission: Type: AWS::Lambda::Permission Properties: Action: lambda:InvokeFunction FunctionName: Ref: SecurityHubIntegrationLambdaFunction Principal: sns.amazonaws.com SourceArn: Ref: FindingsTopic Condition: HasSecurityHubIntegration SecurityHubIntegrationSubscription: Type: AWS::SNS::Subscription Properties: Endpoint: Fn::GetAtt: - SecurityHubIntegrationLambdaFunction - Arn FilterPolicy: status: - infected - "no" Protocol: lambda TopicArn: Ref: FindingsTopic DependsOn: - SecurityHubIntegrationLambdaPermission - SecurityHubIntegrationLambdaPolicy Condition: HasSecurityHubIntegration SecurityHubIntegrationLambdaErrorsAlarm: Type: AWS::CloudWatch::Alarm Properties: AlarmActions: - Ref: InfrastructureAlarmsTopic AlarmDescription: Fn::Join: - "" - - "AWS Security Hub integration failed. Check logs of AWS Lambda Function " - Ref: SecurityHubIntegrationLambdaFunction - "!" ComparisonOperator: GreaterThanOrEqualToThreshold Dimensions: - Name: FunctionName Value: Ref: SecurityHubIntegrationLambdaFunction EvaluationPeriods: 1 MetricName: Errors Namespace: AWS/Lambda Period: 360 Statistic: Sum Threshold: 1 TreatMissingData: notBreaching Condition: HasSecurityHubIntegration SecurityHubIntegrationLambdaThrottlesAlarm: Type: AWS::CloudWatch::Alarm Properties: AlarmActions: - Ref: InfrastructureAlarmsTopic AlarmDescription: AWS Security Hub integration throttled. ComparisonOperator: GreaterThanOrEqualToThreshold Dimensions: - Name: FunctionName Value: Ref: SecurityHubIntegrationLambdaFunction EvaluationPeriods: 1 MetricName: Throttles Namespace: AWS/Lambda Period: 360 Statistic: Sum Threshold: 1 TreatMissingData: notBreaching Condition: HasSecurityHubIntegration OpsCenterIntegrationLambdaRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Principal: Service: lambda.amazonaws.com Action: sts:AssumeRole PermissionsBoundary: Fn::If: - HasPermissionsBoundary - Ref: PermissionsBoundary - Ref: AWS::NoValue Policies: - PolicyDocument: Statement: - Effect: Allow Action: ssm:CreateOpsItem Resource: "*" PolicyName: ssm Condition: HasOpsCenterIntegration OpsCenterIntegrationLambdaFunction: Type: AWS::Lambda::Function Properties: Code: ZipFile: | var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // lambda/integration-ops-center.js var integration_ops_center_exports = {}; __export(integration_ops_center_exports, { handler: () => handler }); module.exports = __toCommonJS(integration_ops_center_exports); var import_client_ssm = require("@aws-sdk/client-ssm"); // lambda/lib2.js function generateSecurityHubTitle(record) { if (record.Sns.MessageAttributes.status.Value === "no") { return "File scan failed"; } else if (record.Sns.MessageAttributes.status.Value === "infected") { return "Infected file found"; } else { throw new Error(`unsupported status: ${record.Sns.MessageAttributes.status.Value}`); } } function generateSecurityHubDescription(record) { if (record.Sns.MessageAttributes.status.Value === "no") { let description = `File in S3 bucket ${record.Sns.MessageAttributes.bucket.Value} with key ${record.Sns.MessageAttributes.key.Value}`; if ("version" in record.Sns.MessageAttributes) { description += ` and version ${record.Sns.MessageAttributes.version.Value}`; } description += " could not be scanned"; if ("finding" in record.Sns.MessageAttributes) { description += ` (${record.Sns.MessageAttributes.finding.Value})`; } description += `, ${record.Sns.MessageAttributes.action.Value} action executed`; return description; } else if (record.Sns.MessageAttributes.status.Value === "infected") { let description = "Infected file"; if ("finding" in record.Sns.MessageAttributes) { description += ` (${record.Sns.MessageAttributes.finding.Value})`; } description += ` found in S3 bucket ${record.Sns.MessageAttributes.bucket.Value} with key ${record.Sns.MessageAttributes.key.Value}`; if ("version" in record.Sns.MessageAttributes) { description += ` and version ${record.Sns.MessageAttributes.version.Value}`; } description += `, ${record.Sns.MessageAttributes.action.Value} action executed`; return description; } else { throw new Error(`unsupported status: ${record.Sns.MessageAttributes.status.Value}`); } } function generateOpsCenterTitle(record) { return generateSecurityHubTitle(record); } function generateOpsCenterDescription(record) { return generateSecurityHubDescription(record); } // lambda/integration-ops-center.js var ssm = new import_client_ssm.SSMClient({ apiVersion: "2014-11-06" }); async function handler(event) { console.log(`Invoke: ${JSON.stringify(event)}`); await Promise.all(event.Records.map((record) => { return ssm.send(new import_client_ssm.CreateOpsItemCommand({ Description: generateOpsCenterDescription(record), OperationalData: { "/aws/resources": { Value: `[{"arn": "arn:aws:s3:::${record.Sns.MessageAttributes.bucket.Value}"}]`, Type: "SearchableString" }, "bucket": { Value: record.Sns.MessageAttributes.bucket.Value, Type: "SearchableString" }, "key": { Value: record.Sns.MessageAttributes.key.Value, Type: "SearchableString" } }, Priority: 4, Source: "bucketAV", Title: generateOpsCenterTitle(record) })); })); return true; } // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { handler }); Handler: index.handler MemorySize: 1769 Role: Fn::GetAtt: - OpsCenterIntegrationLambdaRole - Arn Runtime: nodejs22.x Timeout: 60 Condition: HasOpsCenterIntegration OpsCenterIntegrationLambdaLogGroup: Type: AWS::Logs::LogGroup Properties: LogGroupName: Fn::Join: - "" - - /aws/lambda/ - Ref: OpsCenterIntegrationLambdaFunction RetentionInDays: Ref: LogsRetentionInDays Condition: HasOpsCenterIntegration OpsCenterIntegrationLambdaPolicy: Type: AWS::IAM::Policy Properties: PolicyDocument: Statement: - Effect: Allow Action: - logs:CreateLogStream - logs:PutLogEvents Resource: Fn::GetAtt: - OpsCenterIntegrationLambdaLogGroup - Arn PolicyName: logs Roles: - Ref: OpsCenterIntegrationLambdaRole Condition: HasOpsCenterIntegration OpsCenterIntegrationLambdaPermission: Type: AWS::Lambda::Permission Properties: Action: lambda:InvokeFunction FunctionName: Ref: OpsCenterIntegrationLambdaFunction Principal: sns.amazonaws.com SourceArn: Ref: FindingsTopic Condition: HasOpsCenterIntegration OpsCenterIntegrationSubscription: Type: AWS::SNS::Subscription Properties: Endpoint: Fn::GetAtt: - OpsCenterIntegrationLambdaFunction - Arn FilterPolicy: status: - infected - "no" Protocol: lambda TopicArn: Ref: FindingsTopic DependsOn: - OpsCenterIntegrationLambdaPermission - OpsCenterIntegrationLambdaPolicy Condition: HasOpsCenterIntegration OpsCenterIntegrationLambdaErrorsAlarm: Type: AWS::CloudWatch::Alarm Properties: AlarmActions: - Ref: InfrastructureAlarmsTopic AlarmDescription: Fn::Join: - "" - - "AWS Systems Manager OpsCenter integration failed. Check logs of AWS Lambda Function " - Ref: OpsCenterIntegrationLambdaFunction - "!" ComparisonOperator: GreaterThanOrEqualToThreshold Dimensions: - Name: FunctionName Value: Ref: OpsCenterIntegrationLambdaFunction EvaluationPeriods: 1 MetricName: Errors Namespace: AWS/Lambda Period: 360 Statistic: Sum Threshold: 1 TreatMissingData: notBreaching Condition: HasOpsCenterIntegration OpsCenterIntegrationLambdaThrottlesAlarm: Type: AWS::CloudWatch::Alarm Properties: AlarmActions: - Ref: InfrastructureAlarmsTopic AlarmDescription: AWS Systems Manager OpsCenter integration throttled. ComparisonOperator: GreaterThanOrEqualToThreshold Dimensions: - Name: FunctionName Value: Ref: OpsCenterIntegrationLambdaFunction EvaluationPeriods: 1 MetricName: Throttles Namespace: AWS/Lambda Period: 360 Statistic: Sum Threshold: 1 TreatMissingData: notBreaching Condition: HasOpsCenterIntegration ScanScaleDown: Type: AWS::AutoScaling::ScalingPolicy Properties: AdjustmentType: PercentChangeInCapacity AutoScalingGroupName: Ref: ScanAutoScalingGroup Cooldown: "300" MinAdjustmentMagnitude: 1 PolicyType: SimpleScaling ScalingAdjustment: -25 ScanQueueFullAlarm: Type: AWS::CloudWatch::Alarm Properties: AlarmActions: - Ref: ScanScaleUp AlarmDescription: Don't be worried about this alarm. It is used to trigger auto-scaling policies. Please follow https://bucketav.com/help/operations/monitoring-alerting.html#scanqueuefullalarm ComparisonOperator: GreaterThanThreshold Dimensions: - Name: QueueName Value: Fn::GetAtt: - ScanQueue - QueueName EvaluationPeriods: 1 MetricName: ApproximateNumberOfMessagesVisible Namespace: AWS/SQS Period: 300 Statistic: Maximum Threshold: 0 TreatMissingData: notBreaching ScanQueueEmptyAlarm: Type: AWS::CloudWatch::Alarm Properties: AlarmActions: - Ref: ScanScaleDown AlarmDescription: Don't be worried about this alarm. It is used to trigger auto-scaling policies. Please follow https://bucketav.com/help/operations/monitoring-alerting.html#scanqueueemptyalarm ComparisonOperator: LessThanOrEqualToThreshold EvaluationPeriods: 1 Metrics: - Id: m1 Label: ApproximateNumberOfMessagesVisible MetricStat: Metric: Dimensions: - Name: QueueName Value: Fn::GetAtt: - ScanQueue - QueueName MetricName: ApproximateNumberOfMessagesVisible Namespace: AWS/SQS Period: 300 Stat: Maximum ReturnData: false - Id: m2 Label: ApproximateNumberOfMessagesNotVisible MetricStat: Metric: Dimensions: - Name: QueueName Value: Fn::GetAtt: - ScanQueue - QueueName MetricName: ApproximateNumberOfMessagesNotVisible Namespace: AWS/SQS Period: 300 Stat: Maximum ReturnData: false - Expression: m1+m2 Id: "e1" Label: ApproximateNumberOfMessages ReturnData: true Threshold: 0 TreatMissingData: notBreaching FallbackScaleUp: Type: AWS::AutoScaling::ScalingPolicy Properties: AdjustmentType: ChangeInCapacity AutoScalingGroupName: Ref: FallbackAutoScalingGroup EstimatedInstanceWarmup: 300 MetricAggregationType: Average PolicyType: StepScaling StepAdjustments: - MetricIntervalLowerBound: 0 MetricIntervalUpperBound: 2 ScalingAdjustment: 1 - MetricIntervalLowerBound: 2 MetricIntervalUpperBound: 3 ScalingAdjustment: 2 - MetricIntervalLowerBound: 3 MetricIntervalUpperBound: 4 ScalingAdjustment: 3 - MetricIntervalLowerBound: 4 MetricIntervalUpperBound: 5 ScalingAdjustment: 4 - MetricIntervalLowerBound: 5 MetricIntervalUpperBound: 10 ScalingAdjustment: 5 - MetricIntervalLowerBound: 10 MetricIntervalUpperBound: 25 ScalingAdjustment: 10 - MetricIntervalLowerBound: 25 ScalingAdjustment: 25 Condition: HasOnDemandFallback FallbackScaleDown: Type: AWS::AutoScaling::ScalingPolicy Properties: AdjustmentType: ChangeInCapacity AutoScalingGroupName: Ref: FallbackAutoScalingGroup EstimatedInstanceWarmup: 300 MetricAggregationType: Average PolicyType: StepScaling StepAdjustments: - MetricIntervalLowerBound: -2 MetricIntervalUpperBound: 0 ScalingAdjustment: -1 - MetricIntervalLowerBound: -3 MetricIntervalUpperBound: -2 ScalingAdjustment: -2 - MetricIntervalLowerBound: -4 MetricIntervalUpperBound: -3 ScalingAdjustment: -3 - MetricIntervalLowerBound: -5 MetricIntervalUpperBound: -4 ScalingAdjustment: -4 - MetricIntervalUpperBound: -5 ScalingAdjustment: -5 Condition: HasOnDemandFallback FallbackScaleUpAlarm: Type: AWS::CloudWatch::Alarm Properties: AlarmActions: - Ref: FallbackScaleUp AlarmDescription: Don't be worried about this alarm. It is used to trigger auto-scaling policies. Please follow https://bucketav.com/help/operations/monitoring-alerting.html#fallbackscaleupalarm ComparisonOperator: GreaterThanThreshold EvaluationPeriods: 3 Metrics: - Id: running Label: running MetricStat: Metric: Dimensions: - Name: AutoScalingGroupName Value: Ref: ScanAutoScalingGroup MetricName: GroupInServiceInstances Namespace: AWS/AutoScaling Period: 60 Stat: Maximum ReturnData: false - Id: desired Label: desired MetricStat: Metric: Dimensions: - Name: AutoScalingGroupName Value: Ref: ScanAutoScalingGroup MetricName: GroupDesiredCapacity Namespace: AWS/AutoScaling Period: 60 Stat: Maximum ReturnData: false - Id: desiredfallback Label: desiredfallback MetricStat: Metric: Dimensions: - Name: AutoScalingGroupName Value: Ref: FallbackAutoScalingGroup MetricName: GroupDesiredCapacity Namespace: AWS/AutoScaling Period: 60 Stat: Maximum ReturnData: false - Expression: desired-running-desiredfallback Id: "e1" Label: fallback ReturnData: true Threshold: 0 TreatMissingData: notBreaching Condition: HasOnDemandFallback FallbackScaleDownAlarm: Type: AWS::CloudWatch::Alarm Properties: AlarmActions: - Ref: FallbackScaleDown AlarmDescription: Don't be worried about this alarm. It is used to trigger auto-scaling policies. Please follow https://bucketav.com/help/operations/monitoring-alerting.html#fallbackscaledownalarm ComparisonOperator: LessThanThreshold EvaluationPeriods: 3 Metrics: - Id: running Label: running MetricStat: Metric: Dimensions: - Name: AutoScalingGroupName Value: Ref: ScanAutoScalingGroup MetricName: GroupInServiceInstances Namespace: AWS/AutoScaling Period: 60 Stat: Maximum ReturnData: false - Id: desired Label: desired MetricStat: Metric: Dimensions: - Name: AutoScalingGroupName Value: Ref: ScanAutoScalingGroup MetricName: GroupDesiredCapacity Namespace: AWS/AutoScaling Period: 60 Stat: Maximum ReturnData: false - Id: desiredfallback Label: desiredfallback MetricStat: Metric: Dimensions: - Name: AutoScalingGroupName Value: Ref: FallbackAutoScalingGroup MetricName: GroupDesiredCapacity Namespace: AWS/AutoScaling Period: 60 Stat: Maximum ReturnData: false - Expression: desired-running-desiredfallback Id: "e1" Label: fallback ReturnData: true Threshold: 0 TreatMissingData: notBreaching Condition: HasOnDemandFallback ServiceDiscoveryOrganizationId: Type: AWS::SSM::Parameter Properties: Description: bucketAV managed value. DO NOT CHANGE! Name: Fn::Join: - "" - - /bucketAV/ - Ref: AWS::StackName - /OrganizationId Type: String Value: INIT Condition: HasCrossAccountAndNotMultiDeployment AccountConnectionTopic: Type: AWS::SNS::Topic Properties: Tags: - Key: bucketav:cloudformation:logical-id Value: AccountConnectionTopic - Key: bucketav:cloudformation:stack-id Value: Ref: AWS::StackId - Key: bucketav:cloudformation:stack-name Value: Ref: AWS::StackName TopicName: Fn::Join: - "" - - Ref: AWS::StackName - -AccountConnectionTopic Condition: HasCrossAccountAndNotMultiDeployment AccountConnectionTopicPolicy: Type: AWS::SNS::TopicPolicy Properties: PolicyDocument: Version: "2012-10-17" Statement: - Sid: "1" Effect: Allow Principal: Service: events.amazonaws.com Action: sns:Publish Resource: Ref: AccountConnectionTopic - Fn::If: - HasAWSAccountRestriction - Sid: "10" Effect: Allow Principal: "*" Action: sns:Publish Resource: Ref: AccountConnectionTopic Condition: StringEquals: aws:PrincipalAccount: Ref: AWSAccountRestriction - Ref: AWS::NoValue - Fn::If: - HasAWSOrganizationRestriction - Sid: "20" Effect: Allow Principal: "*" Action: sns:Publish Resource: Ref: AccountConnectionTopic Condition: StringEquals: aws:PrincipalOrgID: Ref: AWSOrganizationRestriction - Ref: AWS::NoValue Topics: - Ref: AccountConnectionTopic Condition: HasCrossAccountAndNotMultiDeployment AccountConnectionTable: Type: AWS::DynamoDB::Table Properties: AttributeDefinitions: - AttributeName: account_connection_id AttributeType: S BillingMode: PAY_PER_REQUEST KeySchema: - AttributeName: account_connection_id KeyType: HASH PointInTimeRecoverySpecification: PointInTimeRecoveryEnabled: true SSESpecification: SSEEnabled: true TableName: Fn::Join: - "" - - Ref: AWS::StackName - -AccountConnection Condition: HasCrossAccountAndNotMultiDeployment BucketCacheTable: Type: AWS::DynamoDB::Table Properties: AttributeDefinitions: - AttributeName: bucket_name AttributeType: S BillingMode: PAY_PER_REQUEST KeySchema: - AttributeName: bucket_name KeyType: HASH SSESpecification: SSEEnabled: true TableName: Fn::Join: - "" - - Ref: AWS::StackName - -BucketCache TimeToLiveSpecification: AttributeName: ttl Enabled: true Condition: HasCrossAccountAndNotMultiDeployment AccountConnectionLambdaRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Principal: Service: lambda.amazonaws.com Action: sts:AssumeRole PermissionsBoundary: Fn::If: - HasPermissionsBoundary - Ref: PermissionsBoundary - Ref: AWS::NoValue Policies: - PolicyDocument: Statement: - Effect: Allow Action: - dynamodb:GetItem - dynamodb:PutItem - dynamodb:UpdateItem - dynamodb:DeleteItem Resource: Fn::GetAtt: - AccountConnectionTable - Arn - Effect: Allow Action: dynamodb:PutItem Resource: Fn::GetAtt: - BucketCacheTable - Arn - Effect: Allow Action: ssm:GetParametersByPath Resource: Fn::Join: - "" - - "arn:" - Ref: AWS::Partition - ":ssm:" - Ref: AWS::Region - ":" - Ref: AWS::AccountId - :parameter/bucketAV/ - Ref: AWS::StackName - /AddOn/scheduled-bucket-scan/* - Effect: Allow Action: - cloudformation:DescribeStacks - s3:ListAllMyBuckets - s3:GetBucketNotification - sns:ListSubscriptionsByTopic - events:ListRuleNamesByTarget - events:DescribeRule - organizations:DescribeOrganization Resource: "*" - Effect: Allow Action: events:DescribeEventBus Resource: Fn::Join: - "" - - "arn:" - Ref: AWS::Partition - ":events:" - Ref: AWS::Region - ":" - Ref: AWS::AccountId - :event-bus/default - Effect: Allow Action: sts:AssumeRole Resource: Fn::Join: - "" - - "arn:" - Ref: AWS::Partition - :iam::*:role/ - Ref: AWS::StackName - -AccountConnection - Effect: Allow Action: sns:Publish Resource: Fn::GetAtt: - InfrastructureAlarmsTopic - TopicArn - Effect: Allow Action: ssm:GetParameter Resource: Fn::Join: - "" - - "arn:" - Ref: AWS::Partition - ":ssm:" - Ref: AWS::Region - ":" - Ref: AWS::AccountId - :parameter - Ref: ServiceDiscoveryOrganizationId PolicyName: main - PolicyDocument: Statement: - Effect: Allow Action: sqs:SendMessage Resource: Fn::GetAtt: - AccountConnectionLambdaDeadLetterQueue - Arn PolicyName: dlq Condition: HasCrossAccountAndNotMultiDeployment AccountConnectionLambdaFunction: Type: AWS::Lambda::Function Properties: Code: ZipFile: | var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // lambda/account-connection.js var account_connection_exports = {}; __export(account_connection_exports, { handler: () => handler }); module.exports = __toCommonJS(account_connection_exports); var import_client_dynamodb3 = require("@aws-sdk/client-dynamodb"); var import_client_cloudformation2 = require("@aws-sdk/client-cloudformation"); var import_client_ssm2 = require("@aws-sdk/client-ssm"); var import_client_sns3 = require("@aws-sdk/client-sns"); var import_client_organizations2 = require("@aws-sdk/client-organizations"); var import_credential_providers3 = require("@aws-sdk/credential-providers"); // lambda/lib.js var import_client_cloudformation = require("@aws-sdk/client-cloudformation"); var import_client_s3 = require("@aws-sdk/client-s3"); var import_client_sns = require("@aws-sdk/client-sns"); var import_client_eventbridge = require("@aws-sdk/client-eventbridge"); var import_client_dynamodb = require("@aws-sdk/client-dynamodb"); var import_client_s32 = require("@aws-sdk/client-s3"); var import_client_ssm = require("@aws-sdk/client-ssm"); var import_client_secrets_manager = require("@aws-sdk/client-secrets-manager"); var import_client_organizations = require("@aws-sdk/client-organizations"); var import_credential_providers = require("@aws-sdk/credential-providers"); // lambda/node_modules/@smithy/core/dist-es/pagination/createPaginator.js var makePagedClientRequest = async (CommandCtor, client, input, withCommand = (_) => _, ...args) => { let command = new CommandCtor(input); command = withCommand(command) ?? command; return await client.send(command, ...args); }; function createPaginator(ClientCtor, CommandCtor, inputTokenName, outputTokenName, pageSizeTokenName) { return async function* paginateOperation(config, input, ...additionalArguments) { const _input = input; let token = config.startingToken ?? _input[inputTokenName]; let hasNext = true; let page; while (hasNext) { _input[inputTokenName] = token; if (pageSizeTokenName) { _input[pageSizeTokenName] = _input[pageSizeTokenName] ?? config.pageSize; } if (config.client instanceof ClientCtor) { page = await makePagedClientRequest(CommandCtor, config.client, input, config.withCommand, ...additionalArguments); } else { throw new Error(`Invalid client, expected instance of ${ClientCtor.name}`); } yield page; const prevToken = token; token = get(page, outputTokenName); hasNext = !!(token && (!config.stopOnSameToken || token !== prevToken)); } return void 0; }; } var get = (fromObject, path) => { let cursor = fromObject; const pathComponents = path.split("."); for (const step of pathComponents) { if (!cursor || typeof cursor !== "object") { return void 0; } cursor = cursor[step]; } return cursor; }; // lambda/lib.js async function fetchOrganizationId(organizations) { try { const { Organization } = await organizations.send(new import_client_organizations.DescribeOrganizationCommand({})); return Organization.Id; } catch (err) { if (err.name === "AWSOrganizationsNotInUseException") { return void 0; } else { throw err; } } } async function fetchCachedOrganizationId(ssm2, coreStackName) { const data = await ssm2.send(new import_client_ssm.GetParameterCommand({ Name: `/bucketAV/${coreStackName}/OrganizationId` })); if (data.Parameter.Value === "INIT" || data.Parameter.Value === "NONE") { return void 0; } return data.Parameter.Value; } function includesBucket(scheduledStack, bucketName) { let excludeFilterExpression = "^$"; if (scheduledStack.params.ExcludeBucketNameFilter) { excludeFilterExpression = "^" + scheduledStack.params.ExcludeBucketNameFilter.replaceAll(".", ".").replaceAll("-", "-").replaceAll("*", ".*").replaceAll(",", "$|^") + "$"; } if (bucketName.match(new RegExp(excludeFilterExpression))) { return false; } if (scheduledStack.params.BucketName.includes("*")) { const filterExpression = "^" + scheduledStack.params.BucketName.replaceAll(".", ".").replaceAll("-", "-").replaceAll("*", ".*").replaceAll(",", "$|^") + "$"; return bucketName.match(new RegExp(filterExpression)) !== null; } else { return scheduledStack.params.BucketName.split(",").includes(bucketName); } } function hasBucket(scheduledStack, bucketName) { if (scheduledStack.params.BucketName === bucketName && (scheduledStack.params.ExcludeBucketNameFilter === "" || !("ExcludeBucketNameFilter" in scheduledStack.params))) { return true; } return false; } async function listBucketsLight(s3, region, accountId) { const allBuckets = []; const input = {}; if (region !== void 0 && region !== null) { input.BucketRegion = region; } const paginator = (0, import_client_s3.paginateListBuckets)({ client: s3, pageSize: 1e3 }, input); for await (const page of paginator) { allBuckets.push(...page.Buckets.map((bucket) => { if ("BucketRegion" in bucket) { return { name: bucket.Name, region: bucket.BucketRegion, accountId }; } else { return { name: bucket.Name, accountId, errorMessage: `Can not get region for bucket ${bucket.Name}` }; } })); } if (region !== void 0 && region !== null) { return allBuckets.filter((bucket) => bucket.region === region || bucket.region === void 0); } else { return allBuckets; } } var paginateListRuleNamesByTarget = createPaginator(import_client_eventbridge.EventBridgeClient, import_client_eventbridge.ListRuleNamesByTargetCommand, "NextToken", "NextToken", "Limit"); async function checkEventBridgeRules(eventbridge, ruleNames, bucketName) { const rules = await Promise.all(ruleNames.map((ruleName) => eventbridge.send(new import_client_eventbridge.DescribeRuleCommand({ Name: ruleName })))); return rules.filter((rule) => rule.State === "ENABLED" && rule.EventPattern).map((rule) => JSON.parse(rule.EventPattern)).find((pattern) => pattern.source.includes("aws.s3") && pattern["detail-type"].includes("Object Created") && (pattern?.detail?.bucket?.name?.includes(bucketName) || pattern?.detail?.bucket?.name === void 0)) !== void 0; } async function enrichBucket(s3, sns2, eventbridge, coreEventbridge, partition, region, accountId, organizationId, coreAccountId, coreStackName, scanQueueArn, scheduledStacks, bucketName, bucketRegion) { const bucket = { name: bucketName, accountId, organizationId }; if (bucketRegion === void 0) { bucket.region = void 0; bucket.errorMessage = `Can not get region for bucket ${bucketName}`; } else { bucket.region = bucketRegion; } if (bucket.region === region || bucket.region === void 0) { let realtimeEnabled = false; let realtimeEventNotificationEnablePossible = bucket.region !== void 0; let realtimeEventNotificationDisablePossible = false; let scheduledEnabled = false; let scheduledStackDisablePossible = false; let scheduledStackId = void 0; try { const notificationData = await s3.send(new import_client_s3.GetBucketNotificationConfigurationCommand({ Bucket: bucket.name, ExpectedBucketOwner: accountId })); if (notificationData?.QueueConfigurations?.find((config) => config.QueueArn === scanQueueArn && config.Events.includes("s3:ObjectCreated:*")) !== void 0) { realtimeEnabled = true; realtimeEventNotificationDisablePossible = true; } if (Array.isArray(notificationData?.TopicConfigurations)) { await Promise.all(notificationData.TopicConfigurations.filter((config) => config.Events.includes("s3:ObjectCreated:*")).map(async (config) => { const paginatorListSubscriptionsByTopic = await (0, import_client_sns.paginateListSubscriptionsByTopic)({ client: sns2, pageSize: 50 }, { TopicArn: config.TopicArn }); for await (const page of paginatorListSubscriptionsByTopic) { if (page.Subscriptions.find((subscription) => subscription.Protocol === "sqs" && subscription.Endpoint === scanQueueArn) !== void 0) { realtimeEnabled = true; } } })); } if (notificationData?.EventBridgeConfiguration) { if (accountId !== coreAccountId) { let crossAccoutRule = false; const paginatorListRuleNamesByTargetDefaultBus = await paginateListRuleNamesByTarget({ client: eventbridge, pageSize: 10 }, { TargetArn: `arn:${partition}:events:${region}:${coreAccountId}:event-bus/default` }); for await (const page of paginatorListRuleNamesByTargetDefaultBus) { if (await checkEventBridgeRules(eventbridge, page.RuleNames, bucket.name) === true) { crossAccoutRule = true; } } const { Policy: defaultBusPolicyJson } = await coreEventbridge.send(new import_client_eventbridge.DescribeEventBusCommand({ Name: "default" })); if (defaultBusPolicyJson !== void 0) { const defaultBusPolicy = JSON.parse(defaultBusPolicyJson); if (defaultBusPolicy.Statement.find((s) => s?.Effect === "Allow" && s?.Action === "events:PutEvents" && (s?.Principal?.AWS === accountId || s?.Principal === "*" && s?.Condition?.StringEquals["aws:PrincipalOrgID"] === organizationId)) !== void 0) { if (crossAccoutRule === true) { realtimeEnabled = true; } } } } const paginatorListRuleNamesByTargetScanQueue = await paginateListRuleNamesByTarget({ client: coreEventbridge, pageSize: 10 }, { TargetArn: scanQueueArn }); for await (const page of paginatorListRuleNamesByTargetScanQueue) { if (await checkEventBridgeRules(coreEventbridge, page.RuleNames, bucket.name) === true) { realtimeEnabled = true; } } } if (notificationData?.TopicConfigurations?.find((config) => config.Events.filter((event) => event.startsWith("s3:ObjectCreated:")).length > 0)) { realtimeEventNotificationEnablePossible = false; } if (notificationData?.QueueConfigurations?.find((config) => config.Events.filter((event) => event.startsWith("s3:ObjectCreated:")).length > 0)) { realtimeEventNotificationEnablePossible = false; } if (notificationData?.LambdaFunctionConfigurations?.find((config) => config.Events.filter((event) => event.startsWith("s3:ObjectCreated:")).length > 0)) { realtimeEventNotificationEnablePossible = false; } if (notificationData?.EventBridgeConfiguration !== void 0) { realtimeEventNotificationEnablePossible = false; } } catch (err) { console.log(err); bucket.errorMessage = `Can not get details for bucket ${bucket.name}: ${err.name}`; realtimeEventNotificationEnablePossible = false; } const scheduledStacksIncludesBucket = scheduledStacks.stacks.filter((scheduledStack) => includesBucket(scheduledStack, bucket.name)); if (scheduledStacksIncludesBucket.length > 0) { scheduledEnabled = true; scheduledStackId = scheduledStacksIncludesBucket[0].id; } const scheduledStacksHasBucket = scheduledStacks.stacks.filter((scheduledStack) => hasBucket(scheduledStack, bucket.name)); if (scheduledStacksHasBucket.length === 1) { scheduledEnabled = true; scheduledStackDisablePossible = true; scheduledStackId = scheduledStacksHasBucket[0].id; } return { ...bucket, realtimeEnabled, realtimeEventNotificationEnablePossible, realtimeEventNotificationDisablePossible, scheduledEnabled, scheduledStackDisablePossible, scheduledStackId }; } else { return null; } } async function getScheduledStacks(ssm2, cloudformation2, coreStackName) { const paginatorGetParametersByPath = await (0, import_client_ssm.paginateGetParametersByPath)({ client: ssm2 }, { Recursive: true, Path: `/bucketAV/${coreStackName}/AddOn/scheduled-bucket-scan/` }); const stacks = []; for await (const page of paginatorGetParametersByPath) { const scheduledStackNames = page.Parameters.filter((p) => p.Name.endsWith("/Version")).map((p) => p.Name.split("/")[5]); const describeStacksDataList = await Promise.all(scheduledStackNames.map((stackName) => cloudformation2.send(new import_client_cloudformation.DescribeStacksCommand({ StackName: stackName })))); describeStacksDataList.forEach((describeStacksData) => { const stack = { name: describeStacksData.Stacks[0].StackName, id: describeStacksData.Stacks[0].StackId, params: describeStacksData.Stacks[0].Parameters?.reduce((acc, param) => { acc[param.ParameterKey] = param.ParameterValue; return acc; }, {}) || {}, outputs: describeStacksData.Stacks[0].Outputs?.reduce((acc, output) => { acc[output.OutputKey] = output.OutputValue; return acc; }, {}) || {} }; if (stack.params.BucketAVStackName === coreStackName && stack.outputs.AddOn === "scheduled-bucket-scan") { stacks.push(stack); } }); } return { stacks }; } async function listBuckets(s3, ssm2, cloudformation2, sns2, eventbridge, coreEventbridge, partition, region, accountId, organizationId, coreAccountId, coreStackName, scanQueueArn) { const lightBuckets = await listBucketsLight(s3, region, accountId); const scheduledStacks = await getScheduledStacks(ssm2, cloudformation2, coreStackName); const buckets = await Promise.all(lightBuckets.map((lightBucket) => enrichBucket(s3, sns2, eventbridge, coreEventbridge, partition, region, accountId, organizationId, coreAccountId, coreStackName, scanQueueArn, scheduledStacks, lightBucket.name, lightBucket.region))); return buckets.filter((bucket) => bucket !== null); } function generateAccountConnectionId(partition, region, accountId) { return `${partition}:${region}:${accountId}`; } var MAX_S3_COPY_SIZE = 5 * 1024 * 1024 * 1024; function generateRoleArn(partition, accountId, roleName) { return `arn:${partition}:iam::${accountId}:role/${roleName}`; } function generateRoleArnFromItem(accountConnectionItem) { return generateRoleArn(accountConnectionItem.partition.S, accountConnectionItem.account_id.S, accountConnectionItem.role_name.S); } function generateExternalId(stackId) { return stackId.split("/")[2]; } function generateExternalIdFromItem(accountConnectionItem) { return generateExternalId(accountConnectionItem.stack_id.S); } async function cfnCustomResourceSuccess(event, physicalResourceId, optionalData) { const response = await fetch(event.ResponseURL, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ Status: "SUCCESS", PhysicalResourceId: physicalResourceId, StackId: event.StackId, RequestId: event.RequestId, LogicalResourceId: event.LogicalResourceId, ...optionalData !== void 0 && { Data: optionalData } }) }); if (response.status !== 200) { console.log("response status", response.status); console.log("response", await response.text()); throw new Error("unexpected status code"); } } async function cfnCustomResourceFailed(event, physicalResourceId, optionalReason) { const response = await fetch(event.ResponseURL, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ Status: "FAILED", ...optionalReason !== void 0 && { Reason: optionalReason }, PhysicalResourceId: physicalResourceId === void 0 || physicalResourceId === null ? event.LogicalResourceId : physicalResourceId, // physicalResourceId might not be available if create fails StackId: event.StackId, RequestId: event.RequestId, LogicalResourceId: event.LogicalResourceId }) }); if (response.status !== 200) { console.log("response status", response.status); console.log("response", await response.text()); throw new Error("unexpected status code"); } } // lambda/lib-refresh-bucket-cache.js var import_client_dynamodb2 = require("@aws-sdk/client-dynamodb"); var import_client_sns2 = require("@aws-sdk/client-sns"); var import_client_eventbridge2 = require("@aws-sdk/client-eventbridge"); var import_client_s33 = require("@aws-sdk/client-s3"); var import_credential_providers2 = require("@aws-sdk/credential-providers"); var TTL_IN_SECONDS = 3600; async function cacheBucket(dynamodb2, partition, region, accountId, { accountConnectionId, roleArn, externalId }, ttl, bucket, bucketCacheTableName) { const bucketCacheItem = { bucket_name: { S: bucket.name }, bucket_account_id: { S: bucket.accountId }, bucket_realtime_enabled: { BOOL: bucket.realtimeEnabled }, bucket_realtime_event_notification_disable_possible: { BOOL: bucket.realtimeEventNotificationDisablePossible }, bucket_realtime_event_notification_enable_possible: { BOOL: bucket.realtimeEventNotificationEnablePossible }, bucket_scheduled_enabled: { BOOL: bucket.scheduledEnabled }, bucket_scheduled_stack_disable_possible: { BOOL: bucket.scheduledStackDisablePossible }, partition: { S: partition }, region: { S: region }, account_id: { S: accountId }, ttl: { N: ttl.toString() } }; if (bucket.organizationId !== void 0) { bucketCacheItem.bucket_organization_id = { S: bucket.organizationId }; } if (accountConnectionId !== void 0) { bucketCacheItem.account_connection_id = { S: accountConnectionId }; bucketCacheItem.role_arn = { S: roleArn }; bucketCacheItem.external_id = { S: externalId }; } if (bucket.errorMessage !== void 0) { bucketCacheItem.bucket_error_message = { S: bucket.errorMessage }; } if (bucket.region !== void 0) { bucketCacheItem.bucket_region = { S: bucket.region }; } if (bucket.scheduledStackId !== void 0) { bucketCacheItem.bucket_scheduled_stack_id = { S: bucket.scheduledStackId }; } await dynamodb2.send(new import_client_dynamodb2.PutItemCommand({ TableName: bucketCacheTableName, Item: bucketCacheItem })); } function getCredentials(accountConnectionId, roleArn, externalId) { if (accountConnectionId !== void 0 && roleArn !== void 0 && externalId !== void 0) { return (0, import_credential_providers2.fromTemporaryCredentials)({ params: { RoleArn: roleArn, ExternalId: externalId, RoleSessionName: "bucketav", DurationSeconds: 3600 } }); } else { return (0, import_credential_providers2.fromNodeProviderChain)(); } } function generateTtl() { return Math.floor(Date.now() / 1e3) + TTL_IN_SECONDS; } async function refreshAccount2(dynamodb2, ssm2, cloudformation2, partition, region, accountId, { accountConnectionId, roleArn, externalId, organizationId }, ttl, coreAccountId, coreStackName, scanQueueArn, bucketCacheTableName) { const credentials = getCredentials(accountConnectionId, roleArn, externalId); const s3 = new import_client_s33.S3Client({ apiVersion: "2006-03-01", credentials }); const sns2 = new import_client_sns2.SNSClient({ apiVersion: "2010-03-31", credentials }); const eventbridge = new import_client_eventbridge2.EventBridgeClient({ apiVersion: "2015-10-07", credentials }); const coreEventbridge = new import_client_eventbridge2.EventBridgeClient({ apiVersion: "2015-10-07" }); const buckets = await listBuckets(s3, ssm2, cloudformation2, sns2, eventbridge, coreEventbridge, partition, region, accountId, organizationId, coreAccountId, coreStackName, scanQueueArn); for (const bucket of buckets) { await cacheBucket(dynamodb2, partition, region, accountId, { accountConnectionId, roleArn, externalId }, ttl, bucket, bucketCacheTableName); } } async function refreshAccount(dynamodb2, ssm2, cloudformation2, partition, region, accountId, coreAccountId, coreStackName, scanQueueArn, bucketCacheTableName, accountConnectionTableName) { const accountConnectionId = generateAccountConnectionId(partition, region, accountId); const accountConnectionData = await dynamodb2.send(new import_client_dynamodb2.GetItemCommand({ TableName: accountConnectionTableName, Key: { account_connection_id: { S: accountConnectionId } }, ConsistentRead: true })); let accountConnection = {}; if (accountConnectionData.Item !== void 0) { const roleArn = generateRoleArnFromItem(accountConnectionData.Item); const externalId = generateExternalIdFromItem(accountConnectionData.Item); accountConnection.accountConnectionId = accountConnectionId; accountConnection.roleArn = roleArn; accountConnection.externalId = externalId; accountConnection.organizationId = accountConnectionData.Item.organization_id?.S; } else { accountConnection.organizationId = await fetchCachedOrganizationId(ssm2, coreStackName); } return refreshAccount2(dynamodb2, ssm2, cloudformation2, partition, region, accountId, accountConnection, generateTtl(), coreAccountId, coreStackName, scanQueueArn, bucketCacheTableName); } // lambda/account-connection.js var ssm = new import_client_ssm2.SSMClient({ apiVersion: "2014-11-06" }); var cloudformation = new import_client_cloudformation2.CloudFormationClient({ apiVersion: "2006-03-01", maxAttempts: 10 }); var dynamodb = new import_client_dynamodb3.DynamoDBClient({ apiVersion: "2012-08-10" }); var sns = new import_client_sns3.SNSClient({ apiVersion: "2010-03-31" }); async function handler(event, context) { const now = Date.now(); console.log(`Invoke: ${JSON.stringify(event)} ${JSON.stringify(context)}`); const bucketCacheTableName = `${process.env.CORE_STACK_NAME}-BucketCache`; const accountConnectionTableName = `${process.env.CORE_STACK_NAME}-AccountConnection`; for (const record of event.Records) { if (record.Sns.Type === "Notification") { const message = JSON.parse(record.Sns.Message); if ("action" in message) { if (message.action === "ping") { const accountConnectionId = generateAccountConnectionId(message.partition, message.region, message.accountId); const accountConnectionData = await dynamodb.send(new import_client_dynamodb3.GetItemCommand({ TableName: accountConnectionTableName, Key: { account_connection_id: { S: accountConnectionId } }, ConsistentRead: true })); if (accountConnectionData.Item === void 0) { throw new Error("account connection not found"); } const credentials = (0, import_credential_providers3.fromTemporaryCredentials)({ params: { RoleArn: generateRoleArnFromItem(accountConnectionData.Item), ExternalId: generateExternalIdFromItem(accountConnectionData.Item), RoleSessionName: "bucketav", DurationSeconds: 3600 } }); const organizations = new import_client_organizations2.OrganizationsClient({ apiVersion: "2016-11-28", credentials }); const organizationId = await fetchOrganizationId(organizations); const expressionAttributeValues = { ":now": { N: now.toString() } }; let updateExpression = "SET pinged_at=:now"; if (accountConnectionData.Item.organization_id?.S !== organizationId) { if (organizationId === void 0) { updateExpression += " REMOVE organization_id"; } else { updateExpression += ", organization_id=:organizationId"; expressionAttributeValues[":organizationId"] = { S: organizationId }; } } await dynamodb.send(new import_client_dynamodb3.UpdateItemCommand({ TableName: accountConnectionTableName, Key: { account_connection_id: { S: accountConnectionId } }, UpdateExpression: updateExpression, ExpressionAttributeValues: expressionAttributeValues, ConditionExpression: "attribute_exists(account_connection_id)" })); if (accountConnectionData.Item.organization_id?.S !== organizationId) { await refreshAccount(dynamodb, ssm, cloudformation, accountConnectionData.Item.partition.S, accountConnectionData.Item.region.S, accountConnectionData.Item.account_id.S, process.env.AWS_ACCOUNT_ID, process.env.CORE_STACK_NAME, process.env.SCAN_QUEUE_ARN, bucketCacheTableName, accountConnectionTableName); } } else if (message.action === "refreshAccount") { await refreshAccount(dynamodb, ssm, cloudformation, message.partition, message.region, message.accountId, process.env.AWS_ACCOUNT_ID, process.env.CORE_STACK_NAME, process.env.SCAN_QUEUE_ARN, bucketCacheTableName, accountConnectionTableName); } else { throw new Error("Unsupported action"); } } else if ("RequestType" in message) { try { if (message.RequestType === "Create") { if (process.env.AWS_PARTITION === message.ResourceProperties.Partition && process.env.AWS_ACCOUNT_ID === message.ResourceProperties.AccountId) { throw new Error("No need to connect the bucketAV account to itself"); } else { const credentials = (0, import_credential_providers3.fromTemporaryCredentials)({ params: { RoleArn: generateRoleArn(message.ResourceProperties.Partition, message.ResourceProperties.AccountId, message.ResourceProperties.RoleName), ExternalId: generateExternalId(message.ResourceProperties.StackId), RoleSessionName: "bucketav", DurationSeconds: 3600 } }); const organizations = new import_client_organizations2.OrganizationsClient({ apiVersion: "2016-11-28", credentials }); const organizationId = await fetchOrganizationId(organizations); await dynamodb.send(new import_client_dynamodb3.PutItemCommand({ TableName: accountConnectionTableName, Item: { account_connection_id: { S: generateAccountConnectionId(message.ResourceProperties.Partition, message.ResourceProperties.Region, message.ResourceProperties.AccountId) }, partition: { S: message.ResourceProperties.Partition }, region: { S: message.ResourceProperties.Region }, account_id: { S: message.ResourceProperties.AccountId }, ...organizationId !== void 0 && { organization_id: { S: organizationId } }, stack_id: { S: message.ResourceProperties.StackId }, stack_name: { S: message.ResourceProperties.StackName }, stack_version: { S: message.ResourceProperties.StackVersion }, role_name: { S: message.ResourceProperties.RoleName }, created_at: { N: now.toString() }, updated_at: { N: now.toString() }, pinged_at: { N: now.toString() } } })); await refreshAccount(dynamodb, ssm, cloudformation, message.ResourceProperties.Partition, message.ResourceProperties.Region, message.ResourceProperties.AccountId, process.env.AWS_ACCOUNT_ID, process.env.CORE_STACK_NAME, process.env.SCAN_QUEUE_ARN, bucketCacheTableName, accountConnectionTableName); } } else if (message.RequestType === "Update") { const id = generateAccountConnectionId(message.ResourceProperties.Partition, message.ResourceProperties.Region, message.ResourceProperties.AccountId); const oldId = generateAccountConnectionId(message.OldResourceProperties.Partition, message.OldResourceProperties.Region, message.OldResourceProperties.AccountId); if (id !== oldId) { throw new Error("unexpected id change during update"); } await dynamodb.send(new import_client_dynamodb3.UpdateItemCommand({ TableName: accountConnectionTableName, Key: { account_connection_id: { S: id } }, UpdateExpression: "SET stack_version=:stack_version, role_name=:role_name, updated_at=:now", ExpressionAttributeValues: { ":now": { N: now.toString() }, ":stack_version": { S: message.ResourceProperties.StackVersion }, ":role_name": { S: message.ResourceProperties.RoleName } }, ConditionExpression: "attribute_exists(account_connection_id)" })); await refreshAccount(dynamodb, ssm, cloudformation, message.ResourceProperties.Partition, message.ResourceProperties.Region, message.ResourceProperties.AccountId, process.env.AWS_ACCOUNT_ID, process.env.CORE_STACK_NAME, process.env.SCAN_QUEUE_ARN, bucketCacheTableName, accountConnectionTableName); } else if (message.RequestType === "Delete") { await dynamodb.send(new import_client_dynamodb3.DeleteItemCommand({ TableName: accountConnectionTableName, Key: { account_connection_id: { S: generateAccountConnectionId(message.ResourceProperties.Partition, message.ResourceProperties.Region, message.ResourceProperties.AccountId) } } })); } else { throw new Error("Unsupported RequestType"); } await cfnCustomResourceSuccess(message, message.ResourceProperties.StackId); } catch (err) { await cfnCustomResourceFailed(message, message.ResourceProperties.StackId); throw err; } } else if (message.source === "aws.cloudwatch") { await sns.send(new import_client_sns3.PublishCommand({ TopicArn: process.env.INFRASTRUCTURE_ALARMS_TOPIC_ARN, Message: JSON.stringify(message) })); } else { throw new Error("Unsupported message"); } } else { throw new Error("Unsupported SNS Type"); } } } // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { handler }); DeadLetterConfig: TargetArn: Fn::GetAtt: - AccountConnectionLambdaDeadLetterQueue - Arn Environment: Variables: CORE_STACK_NAME: Ref: AWS::StackName SCAN_QUEUE_ARN: Fn::GetAtt: - ScanQueue - Arn AWS_PARTITION: Ref: AWS::Partition AWS_ACCOUNT_ID: Ref: AWS::AccountId INFRASTRUCTURE_ALARMS_TOPIC_ARN: Fn::GetAtt: - InfrastructureAlarmsTopic - TopicArn Handler: index.handler MemorySize: 1769 ReservedConcurrentExecutions: Fn::If: - HasAccountConnectionLambdaFunctionReservedConcurrentExecutions - Ref: AccountConnectionLambdaFunctionReservedConcurrentExecutions - Ref: AWS::NoValue Role: Fn::GetAtt: - AccountConnectionLambdaRole - Arn Runtime: nodejs22.x Timeout: 900 VpcConfig: Fn::If: - HasLambdaVpc - SecurityGroupIds: - Ref: LambdaSecurityGroup SubnetIds: Ref: LambdaSubnets - Ref: AWS::NoValue Metadata: bucketav::AccountConnectionLambdaVpcAllowPolicy: Fn::If: - HasCrossAccountAndLambdaVpcAndNotMultiDeployment - Ref: AccountConnectionLambdaVpcAllowPolicy - Ref: AWS::NoValue Condition: HasCrossAccountAndNotMultiDeployment AccountConnectionLambdaVpcAllowPolicy: Type: AWS::IAM::Policy Properties: PolicyDocument: Statement: - Effect: Allow Action: - ec2:CreateNetworkInterface - ec2:DeleteNetworkInterface - ec2:AssignPrivateIpAddresses - ec2:UnassignPrivateIpAddresses - ec2:DescribeNetworkInterfaces - ec2:DescribeSubnets Resource: - "*" PolicyName: vpc-allow Roles: - Ref: AccountConnectionLambdaRole Condition: HasCrossAccountAndLambdaVpcAndNotMultiDeployment AccountConnectionLambdaVpcDenyPolicy: Type: AWS::IAM::Policy Properties: PolicyDocument: Statement: - Effect: Deny Action: - ec2:CreateNetworkInterface - ec2:DescribeNetworkInterfaces - ec2:DescribeSubnets - ec2:DeleteNetworkInterface - ec2:AssignPrivateIpAddresses - ec2:UnassignPrivateIpAddresses Resource: "*" Condition: ArnEquals: lambda:SourceFunctionArn: - Fn::GetAtt: - AccountConnectionLambdaFunction - Arn PolicyName: vpc-deny Roles: - Ref: AccountConnectionLambdaRole Condition: HasCrossAccountAndLambdaVpcAndNotMultiDeployment AccountConnectionLambdaErrorsAlarm: Type: AWS::CloudWatch::Alarm Properties: AlarmActions: - Ref: InfrastructureAlarmsTopic AlarmDescription: Fn::Join: - "" - - "bucketAV Account Connection failed. Check logs of AWS Lambda Function " - Ref: AccountConnectionLambdaFunction - "!" ComparisonOperator: GreaterThanOrEqualToThreshold Dimensions: - Name: FunctionName Value: Ref: AccountConnectionLambdaFunction EvaluationPeriods: 1 MetricName: Errors Namespace: AWS/Lambda Period: 1200 Statistic: Sum Threshold: 1 TreatMissingData: notBreaching Condition: HasCrossAccountAndNotMultiDeployment AccountConnectionLambdaThrottlesAlarm: Type: AWS::CloudWatch::Alarm Properties: AlarmActions: - Ref: InfrastructureAlarmsTopic AlarmDescription: bucketAV Account Connection throttled. ComparisonOperator: GreaterThanOrEqualToThreshold Dimensions: - Name: FunctionName Value: Ref: AccountConnectionLambdaFunction EvaluationPeriods: 1 MetricName: Throttles Namespace: AWS/Lambda Period: 1200 Statistic: Sum Threshold: 1 TreatMissingData: notBreaching Condition: HasCrossAccountAndNotMultiDeployment AccountConnectionLambdaDeadLetterQueue: Type: AWS::SQS::Queue Properties: MessageRetentionPeriod: 1209600 SqsManagedSseEnabled: true Condition: HasCrossAccountAndNotMultiDeployment AccountConnectionLambdaDeadLetterQueueAlarm: Type: AWS::CloudWatch::Alarm Properties: AlarmActions: - Ref: InfrastructureAlarmsTopic AlarmDescription: bucketAV Account Connection has dead letter queue messages. ComparisonOperator: GreaterThanOrEqualToThreshold Dimensions: - Name: QueueName Value: Fn::GetAtt: - AccountConnectionLambdaDeadLetterQueue - QueueName EvaluationPeriods: 1 MetricName: ApproximateNumberOfMessagesVisible Namespace: AWS/SQS Period: 1200 Statistic: Sum Threshold: 1 TreatMissingData: notBreaching Condition: HasCrossAccountAndNotMultiDeployment AccountConnectionLambdaDeadLetterErrorsAlarm: Type: AWS::CloudWatch::Alarm Properties: AlarmActions: - Ref: InfrastructureAlarmsTopic AlarmDescription: bucketAV Account Connection has dead letter queue errors. ComparisonOperator: GreaterThanOrEqualToThreshold Dimensions: - Name: FunctionName Value: Ref: AccountConnectionLambdaFunction EvaluationPeriods: 1 MetricName: DeadLetterErrors Namespace: AWS/Lambda Period: 1200 Statistic: Sum Threshold: 1 TreatMissingData: notBreaching Condition: HasCrossAccountAndNotMultiDeployment LambdaLogGroup: Type: AWS::Logs::LogGroup Properties: LogGroupName: Fn::Join: - "" - - /aws/lambda/ - Ref: AccountConnectionLambdaFunction RetentionInDays: Ref: LogsRetentionInDays Condition: HasCrossAccountAndNotMultiDeployment AccountConnectionLambdaPolicy: Type: AWS::IAM::Policy Properties: PolicyDocument: Statement: - Effect: Allow Action: - logs:CreateLogStream - logs:PutLogEvents Resource: Fn::GetAtt: - LambdaLogGroup - Arn PolicyName: logs Roles: - Ref: AccountConnectionLambdaRole Condition: HasCrossAccountAndNotMultiDeployment AccountConnectionLambdaPermission: Type: AWS::Lambda::Permission Properties: Action: lambda:InvokeFunction FunctionName: Ref: AccountConnectionLambdaFunction Principal: sns.amazonaws.com SourceArn: Ref: AccountConnectionTopic Condition: HasCrossAccountAndNotMultiDeployment AccountConnectionTopicSubscription: Type: AWS::SNS::Subscription Properties: Endpoint: Fn::GetAtt: - AccountConnectionLambdaFunction - Arn Protocol: lambda TopicArn: Ref: AccountConnectionTopic DependsOn: - AccountConnectionLambdaPermission - AccountConnectionLambdaPolicy Condition: HasCrossAccountAndNotMultiDeployment RefreshServiceDiscoveryLambdaRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Principal: Service: lambda.amazonaws.com Action: sts:AssumeRole PermissionsBoundary: Fn::If: - HasPermissionsBoundary - Ref: PermissionsBoundary - Ref: AWS::NoValue Policies: - PolicyDocument: Statement: - Effect: Allow Action: - ssm:GetParameter - ssm:PutParameter Resource: Fn::Join: - "" - - "arn:" - Ref: AWS::Partition - ":ssm:" - Ref: AWS::Region - ":" - Ref: AWS::AccountId - :parameter - Ref: ServiceDiscoveryOrganizationId - Effect: Allow Action: organizations:DescribeOrganization Resource: "*" PolicyName: lambda - PolicyDocument: Statement: - Effect: Allow Action: sqs:SendMessage Resource: Fn::GetAtt: - RefreshServiceDiscoveryLambdaDeadLetterQueue - Arn PolicyName: dlq Condition: HasCrossAccountAndNotMultiDeployment RefreshServiceDiscoveryLambdaFunction: Type: AWS::Lambda::Function Properties: Code: ZipFile: | var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // lambda/refresh-service-discovery.js var refresh_service_discovery_exports = {}; __export(refresh_service_discovery_exports, { handler: () => handler }); module.exports = __toCommonJS(refresh_service_discovery_exports); var import_client_organizations2 = require("@aws-sdk/client-organizations"); var import_client_ssm2 = require("@aws-sdk/client-ssm"); // lambda/lib.js var import_client_cloudformation = require("@aws-sdk/client-cloudformation"); var import_client_s3 = require("@aws-sdk/client-s3"); var import_client_sns = require("@aws-sdk/client-sns"); var import_client_eventbridge = require("@aws-sdk/client-eventbridge"); var import_client_dynamodb = require("@aws-sdk/client-dynamodb"); var import_client_s32 = require("@aws-sdk/client-s3"); var import_client_ssm = require("@aws-sdk/client-ssm"); var import_client_secrets_manager = require("@aws-sdk/client-secrets-manager"); var import_client_organizations = require("@aws-sdk/client-organizations"); var import_credential_providers = require("@aws-sdk/credential-providers"); // lambda/node_modules/@smithy/core/dist-es/pagination/createPaginator.js var makePagedClientRequest = async (CommandCtor, client, input, withCommand = (_) => _, ...args) => { let command = new CommandCtor(input); command = withCommand(command) ?? command; return await client.send(command, ...args); }; function createPaginator(ClientCtor, CommandCtor, inputTokenName, outputTokenName, pageSizeTokenName) { return async function* paginateOperation(config, input, ...additionalArguments) { const _input = input; let token = config.startingToken ?? _input[inputTokenName]; let hasNext = true; let page; while (hasNext) { _input[inputTokenName] = token; if (pageSizeTokenName) { _input[pageSizeTokenName] = _input[pageSizeTokenName] ?? config.pageSize; } if (config.client instanceof ClientCtor) { page = await makePagedClientRequest(CommandCtor, config.client, input, config.withCommand, ...additionalArguments); } else { throw new Error(`Invalid client, expected instance of ${ClientCtor.name}`); } yield page; const prevToken = token; token = get(page, outputTokenName); hasNext = !!(token && (!config.stopOnSameToken || token !== prevToken)); } return void 0; }; } var get = (fromObject, path) => { let cursor = fromObject; const pathComponents = path.split("."); for (const step of pathComponents) { if (!cursor || typeof cursor !== "object") { return void 0; } cursor = cursor[step]; } return cursor; }; // lambda/lib.js async function fetchOrganizationId(organizations2) { try { const { Organization } = await organizations2.send(new import_client_organizations.DescribeOrganizationCommand({})); return Organization.Id; } catch (err) { if (err.name === "AWSOrganizationsNotInUseException") { return void 0; } else { throw err; } } } var paginateListRuleNamesByTarget = createPaginator(import_client_eventbridge.EventBridgeClient, import_client_eventbridge.ListRuleNamesByTargetCommand, "NextToken", "NextToken", "Limit"); var MAX_S3_COPY_SIZE = 5 * 1024 * 1024 * 1024; async function cfnCustomResourceSuccess(event, physicalResourceId, optionalData) { const response = await fetch(event.ResponseURL, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ Status: "SUCCESS", PhysicalResourceId: physicalResourceId, StackId: event.StackId, RequestId: event.RequestId, LogicalResourceId: event.LogicalResourceId, ...optionalData !== void 0 && { Data: optionalData } }) }); if (response.status !== 200) { console.log("response status", response.status); console.log("response", await response.text()); throw new Error("unexpected status code"); } } async function cfnCustomResourceFailed(event, physicalResourceId, optionalReason) { const response = await fetch(event.ResponseURL, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ Status: "FAILED", ...optionalReason !== void 0 && { Reason: optionalReason }, PhysicalResourceId: physicalResourceId === void 0 || physicalResourceId === null ? event.LogicalResourceId : physicalResourceId, // physicalResourceId might not be available if create fails StackId: event.StackId, RequestId: event.RequestId, LogicalResourceId: event.LogicalResourceId }) }); if (response.status !== 200) { console.log("response status", response.status); console.log("response", await response.text()); throw new Error("unexpected status code"); } } // lambda/refresh-service-discovery.js var organizations = new import_client_organizations2.OrganizationsClient({ apiVersion: "2016-11-28" }); var ssm = new import_client_ssm2.SSMClient({ apiVersion: "2014-11-06" }); async function refresh() { const organizationId = await fetchOrganizationId(organizations); const newValue = organizationId === void 0 ? "NONE" : organizationId; const { Parameter: { Value: oldValue } } = await ssm.send(new import_client_ssm2.GetParameterCommand({ Name: `/bucketAV/${process.env.CORE_STACK_NAME}/OrganizationId` })); if (oldValue !== newValue) { await ssm.send(new import_client_ssm2.PutParameterCommand({ Name: `/bucketAV/${process.env.CORE_STACK_NAME}/OrganizationId`, Value: newValue, Overwrite: true })); } } async function handler(event, context) { console.log(`Invoke: ${JSON.stringify(event)} ${JSON.stringify(context)}`); if ("RequestType" in event) { if (event.RequestType === "Create" || event.RequestType === "Update") { try { await refresh(); await cfnCustomResourceSuccess(event, process.env.CORE_STACK_NAME, {}); } catch (err) { console.log(err); await cfnCustomResourceFailed(event, process.env.CORE_STACK_NAME, err.message); } } else if (event.RequestType === "Delete") { await cfnCustomResourceSuccess(event, process.env.CORE_STACK_NAME, {}); } else { throw new Error("unsupported request type"); } } else { await refresh(); } } // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { handler }); DeadLetterConfig: TargetArn: Fn::GetAtt: - RefreshServiceDiscoveryLambdaDeadLetterQueue - Arn Environment: Variables: CORE_STACK_NAME: Ref: AWS::StackName Handler: index.handler MemorySize: 1769 ReservedConcurrentExecutions: Fn::If: - HasRefreshServiceDiscoveryLambdaFunctionReservedConcurrentExecutions - Ref: RefreshServiceDiscoveryLambdaFunctionFunctionReservedConcurrentExecutions - Ref: AWS::NoValue Role: Fn::GetAtt: - RefreshServiceDiscoveryLambdaRole - Arn Runtime: nodejs22.x Timeout: 300 VpcConfig: Fn::If: - HasLambdaVpc - SecurityGroupIds: - Ref: LambdaSecurityGroup SubnetIds: Ref: LambdaSubnets - Ref: AWS::NoValue Metadata: bucketav::RefreshServiceDiscoveryLambdaVpcAllowPolicy: Fn::If: - HasCrossAccountAndLambdaVpcAndNotMultiDeployment - Ref: RefreshServiceDiscoveryLambdaVpcAllowPolicy - Ref: AWS::NoValue Condition: HasCrossAccountAndNotMultiDeployment RefreshServiceDiscoveryLambdaVpcAllowPolicy: Type: AWS::IAM::Policy Properties: PolicyDocument: Statement: - Effect: Allow Action: - ec2:CreateNetworkInterface - ec2:DeleteNetworkInterface - ec2:AssignPrivateIpAddresses - ec2:UnassignPrivateIpAddresses - ec2:DescribeNetworkInterfaces - ec2:DescribeSubnets Resource: - "*" PolicyName: vpc-allow Roles: - Ref: RefreshServiceDiscoveryLambdaRole Condition: HasCrossAccountAndLambdaVpcAndNotMultiDeployment RefreshServiceDiscoveryLambdaVpcDenyPolicy: Type: AWS::IAM::Policy Properties: PolicyDocument: Statement: - Effect: Deny Action: - ec2:CreateNetworkInterface - ec2:DescribeNetworkInterfaces - ec2:DescribeSubnets - ec2:DeleteNetworkInterface - ec2:AssignPrivateIpAddresses - ec2:UnassignPrivateIpAddresses Resource: "*" Condition: ArnEquals: lambda:SourceFunctionArn: - Fn::GetAtt: - RefreshServiceDiscoveryLambdaFunction - Arn PolicyName: vpc-deny Roles: - Ref: RefreshServiceDiscoveryLambdaRole Condition: HasCrossAccountAndLambdaVpcAndNotMultiDeployment RefreshServiceDiscoveryLambdaErrorsAlarm: Type: AWS::CloudWatch::Alarm Properties: AlarmActions: - Ref: InfrastructureAlarmsTopic AlarmDescription: Fn::Join: - "" - - "bucketAV refresh Service Discovery failed. Check logs of AWS Lambda Function " - Ref: RefreshServiceDiscoveryLambdaFunction - "!" ComparisonOperator: GreaterThanOrEqualToThreshold Dimensions: - Name: FunctionName Value: Ref: RefreshServiceDiscoveryLambdaFunction EvaluationPeriods: 1 MetricName: Errors Namespace: AWS/Lambda Period: 600 Statistic: Sum Threshold: 1 TreatMissingData: notBreaching Condition: HasCrossAccountAndNotMultiDeployment RefreshServiceDiscoveryLambdaThrottlesAlarm: Type: AWS::CloudWatch::Alarm Properties: AlarmActions: - Ref: InfrastructureAlarmsTopic AlarmDescription: bucketAV refresh Service Discovery throttled. ComparisonOperator: GreaterThanOrEqualToThreshold Dimensions: - Name: FunctionName Value: Ref: RefreshServiceDiscoveryLambdaFunction EvaluationPeriods: 1 MetricName: Throttles Namespace: AWS/Lambda Period: 600 Statistic: Sum Threshold: 1 TreatMissingData: notBreaching Condition: HasCrossAccountAndNotMultiDeployment RefreshServiceDiscoveryLambdaDeadLetterQueue: Type: AWS::SQS::Queue Properties: MessageRetentionPeriod: 1209600 SqsManagedSseEnabled: true Condition: HasCrossAccountAndNotMultiDeployment RefreshServiceDiscoveryLambdaDeadLetterQueueAlarm: Type: AWS::CloudWatch::Alarm Properties: AlarmActions: - Ref: InfrastructureAlarmsTopic AlarmDescription: bucketAV refresh Service Discovery has dead letter queue messages. ComparisonOperator: GreaterThanOrEqualToThreshold Dimensions: - Name: QueueName Value: Fn::GetAtt: - RefreshServiceDiscoveryLambdaDeadLetterQueue - QueueName EvaluationPeriods: 1 MetricName: ApproximateNumberOfMessagesVisible Namespace: AWS/SQS Period: 600 Statistic: Sum Threshold: 1 TreatMissingData: notBreaching Condition: HasCrossAccountAndNotMultiDeployment RefreshServiceDiscoveryLambdaDeadLetterErrorsAlarm: Type: AWS::CloudWatch::Alarm Properties: AlarmActions: - Ref: InfrastructureAlarmsTopic AlarmDescription: bucketAV refresh Service Discovery has dead letter queue errors. ComparisonOperator: GreaterThanOrEqualToThreshold Dimensions: - Name: FunctionName Value: Ref: RefreshServiceDiscoveryLambdaFunction EvaluationPeriods: 1 MetricName: DeadLetterErrors Namespace: AWS/Lambda Period: 600 Statistic: Sum Threshold: 1 TreatMissingData: notBreaching Condition: HasCrossAccountAndNotMultiDeployment RefreshServiceDiscoveryLambdaLogGroup: Type: AWS::Logs::LogGroup Properties: LogGroupName: Fn::Join: - "" - - /aws/lambda/ - Ref: RefreshServiceDiscoveryLambdaFunction RetentionInDays: Ref: LogsRetentionInDays Condition: HasCrossAccountAndNotMultiDeployment RefreshServiceDiscoveryLambdaPolicy: Type: AWS::IAM::Policy Properties: PolicyDocument: Statement: - Effect: Allow Action: - logs:CreateLogStream - logs:PutLogEvents Resource: Fn::GetAtt: - RefreshServiceDiscoveryLambdaLogGroup - Arn PolicyName: logs Roles: - Ref: RefreshServiceDiscoveryLambdaRole Condition: HasCrossAccountAndNotMultiDeployment RefreshServiceDiscoveryLambdaCron: Type: AWS::Events::Rule Properties: ScheduleExpression: rate(1 day) Targets: - Arn: Fn::GetAtt: - RefreshServiceDiscoveryLambdaFunction - Arn Id: lambda DependsOn: - RefreshServiceDiscoveryLambdaPolicy Condition: HasCrossAccountAndNotMultiDeployment RefreshServiceDiscoveryLambdaPermission: Type: AWS::Lambda::Permission Properties: Action: lambda:InvokeFunction FunctionName: Ref: RefreshServiceDiscoveryLambdaFunction Principal: events.amazonaws.com SourceArn: Fn::GetAtt: - RefreshServiceDiscoveryLambdaCron - Arn Condition: HasCrossAccountAndNotMultiDeployment RefreshServiceDiscovery: Type: Custom::RefreshServiceDiscovery Properties: ServiceToken: Fn::GetAtt: - RefreshServiceDiscoveryLambdaFunction - Arn ServiceTimeout: "300" Version: 2.0.0 DependsOn: - RefreshServiceDiscoveryLambdaPolicy UpdateReplacePolicy: Delete DeletionPolicy: Delete Condition: HasCrossAccountAndNotMultiDeployment RefreshBucketCacheRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Principal: Service: lambda.amazonaws.com Action: sts:AssumeRole PermissionsBoundary: Fn::If: - HasPermissionsBoundary - Ref: PermissionsBoundary - Ref: AWS::NoValue Policies: - PolicyDocument: Statement: - Effect: Allow Action: dynamodb:PutItem Resource: Fn::GetAtt: - BucketCacheTable - Arn - Effect: Allow Action: dynamodb:Scan Resource: Fn::GetAtt: - AccountConnectionTable - Arn - Effect: Allow Action: ssm:GetParametersByPath Resource: Fn::Join: - "" - - "arn:" - Ref: AWS::Partition - ":ssm:" - Ref: AWS::Region - ":" - Ref: AWS::AccountId - :parameter/bucketAV/ - Ref: AWS::StackName - /AddOn/scheduled-bucket-scan/* - Effect: Allow Action: - cloudformation:DescribeStacks - s3:ListAllMyBuckets - s3:GetBucketNotification - sns:ListSubscriptionsByTopic - events:ListRuleNamesByTarget - events:DescribeRule Resource: "*" - Effect: Allow Action: ssm:GetParameter Resource: Fn::Join: - "" - - "arn:" - Ref: AWS::Partition - ":ssm:" - Ref: AWS::Region - ":" - Ref: AWS::AccountId - :parameter - Ref: ServiceDiscoveryOrganizationId - Effect: Allow Action: events:DescribeEventBus Resource: Fn::Join: - "" - - "arn:" - Ref: AWS::Partition - ":events:" - Ref: AWS::Region - ":" - Ref: AWS::AccountId - :event-bus/default - Effect: Allow Action: sts:AssumeRole Resource: Fn::Join: - "" - - "arn:" - Ref: AWS::Partition - :iam::*:role/ - Ref: AWS::StackName - -AccountConnection PolicyName: main Condition: HasCrossAccountAndNotMultiDeployment RefreshBucketCacheFunction: Type: AWS::Lambda::Function Properties: Code: ZipFile: | var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // lambda/refresh-bucket-cache.js var refresh_bucket_cache_exports = {}; __export(refresh_bucket_cache_exports, { handler: () => handler }); module.exports = __toCommonJS(refresh_bucket_cache_exports); var import_client_dynamodb3 = require("@aws-sdk/client-dynamodb"); var import_client_cloudformation2 = require("@aws-sdk/client-cloudformation"); var import_client_ssm2 = require("@aws-sdk/client-ssm"); // lambda/lib-refresh-bucket-cache.js var import_client_dynamodb2 = require("@aws-sdk/client-dynamodb"); var import_client_sns2 = require("@aws-sdk/client-sns"); var import_client_eventbridge2 = require("@aws-sdk/client-eventbridge"); var import_client_s33 = require("@aws-sdk/client-s3"); var import_credential_providers2 = require("@aws-sdk/credential-providers"); // lambda/lib.js var import_client_cloudformation = require("@aws-sdk/client-cloudformation"); var import_client_s3 = require("@aws-sdk/client-s3"); var import_client_sns = require("@aws-sdk/client-sns"); var import_client_eventbridge = require("@aws-sdk/client-eventbridge"); var import_client_dynamodb = require("@aws-sdk/client-dynamodb"); var import_client_s32 = require("@aws-sdk/client-s3"); var import_client_ssm = require("@aws-sdk/client-ssm"); var import_client_secrets_manager = require("@aws-sdk/client-secrets-manager"); var import_client_organizations = require("@aws-sdk/client-organizations"); var import_credential_providers = require("@aws-sdk/credential-providers"); // lambda/node_modules/@smithy/core/dist-es/pagination/createPaginator.js var makePagedClientRequest = async (CommandCtor, client, input, withCommand = (_) => _, ...args) => { let command = new CommandCtor(input); command = withCommand(command) ?? command; return await client.send(command, ...args); }; function createPaginator(ClientCtor, CommandCtor, inputTokenName, outputTokenName, pageSizeTokenName) { return async function* paginateOperation(config, input, ...additionalArguments) { const _input = input; let token = config.startingToken ?? _input[inputTokenName]; let hasNext = true; let page; while (hasNext) { _input[inputTokenName] = token; if (pageSizeTokenName) { _input[pageSizeTokenName] = _input[pageSizeTokenName] ?? config.pageSize; } if (config.client instanceof ClientCtor) { page = await makePagedClientRequest(CommandCtor, config.client, input, config.withCommand, ...additionalArguments); } else { throw new Error(`Invalid client, expected instance of ${ClientCtor.name}`); } yield page; const prevToken = token; token = get(page, outputTokenName); hasNext = !!(token && (!config.stopOnSameToken || token !== prevToken)); } return void 0; }; } var get = (fromObject, path) => { let cursor = fromObject; const pathComponents = path.split("."); for (const step of pathComponents) { if (!cursor || typeof cursor !== "object") { return void 0; } cursor = cursor[step]; } return cursor; }; // lambda/lib.js async function fetchCachedOrganizationId(ssm2, coreStackName) { const data = await ssm2.send(new import_client_ssm.GetParameterCommand({ Name: `/bucketAV/${coreStackName}/OrganizationId` })); if (data.Parameter.Value === "INIT" || data.Parameter.Value === "NONE") { return void 0; } return data.Parameter.Value; } function includesBucket(scheduledStack, bucketName) { let excludeFilterExpression = "^$"; if (scheduledStack.params.ExcludeBucketNameFilter) { excludeFilterExpression = "^" + scheduledStack.params.ExcludeBucketNameFilter.replaceAll(".", ".").replaceAll("-", "-").replaceAll("*", ".*").replaceAll(",", "$|^") + "$"; } if (bucketName.match(new RegExp(excludeFilterExpression))) { return false; } if (scheduledStack.params.BucketName.includes("*")) { const filterExpression = "^" + scheduledStack.params.BucketName.replaceAll(".", ".").replaceAll("-", "-").replaceAll("*", ".*").replaceAll(",", "$|^") + "$"; return bucketName.match(new RegExp(filterExpression)) !== null; } else { return scheduledStack.params.BucketName.split(",").includes(bucketName); } } function hasBucket(scheduledStack, bucketName) { if (scheduledStack.params.BucketName === bucketName && (scheduledStack.params.ExcludeBucketNameFilter === "" || !("ExcludeBucketNameFilter" in scheduledStack.params))) { return true; } return false; } async function listBucketsLight(s3, region, accountId) { const allBuckets = []; const input = {}; if (region !== void 0 && region !== null) { input.BucketRegion = region; } const paginator = (0, import_client_s3.paginateListBuckets)({ client: s3, pageSize: 1e3 }, input); for await (const page of paginator) { allBuckets.push(...page.Buckets.map((bucket) => { if ("BucketRegion" in bucket) { return { name: bucket.Name, region: bucket.BucketRegion, accountId }; } else { return { name: bucket.Name, accountId, errorMessage: `Can not get region for bucket ${bucket.Name}` }; } })); } if (region !== void 0 && region !== null) { return allBuckets.filter((bucket) => bucket.region === region || bucket.region === void 0); } else { return allBuckets; } } var paginateListRuleNamesByTarget = createPaginator(import_client_eventbridge.EventBridgeClient, import_client_eventbridge.ListRuleNamesByTargetCommand, "NextToken", "NextToken", "Limit"); async function checkEventBridgeRules(eventbridge, ruleNames, bucketName) { const rules = await Promise.all(ruleNames.map((ruleName) => eventbridge.send(new import_client_eventbridge.DescribeRuleCommand({ Name: ruleName })))); return rules.filter((rule) => rule.State === "ENABLED" && rule.EventPattern).map((rule) => JSON.parse(rule.EventPattern)).find((pattern) => pattern.source.includes("aws.s3") && pattern["detail-type"].includes("Object Created") && (pattern?.detail?.bucket?.name?.includes(bucketName) || pattern?.detail?.bucket?.name === void 0)) !== void 0; } async function enrichBucket(s3, sns, eventbridge, coreEventbridge, partition, region, accountId, organizationId, coreAccountId, coreStackName, scanQueueArn, scheduledStacks, bucketName, bucketRegion) { const bucket = { name: bucketName, accountId, organizationId }; if (bucketRegion === void 0) { bucket.region = void 0; bucket.errorMessage = `Can not get region for bucket ${bucketName}`; } else { bucket.region = bucketRegion; } if (bucket.region === region || bucket.region === void 0) { let realtimeEnabled = false; let realtimeEventNotificationEnablePossible = bucket.region !== void 0; let realtimeEventNotificationDisablePossible = false; let scheduledEnabled = false; let scheduledStackDisablePossible = false; let scheduledStackId = void 0; try { const notificationData = await s3.send(new import_client_s3.GetBucketNotificationConfigurationCommand({ Bucket: bucket.name, ExpectedBucketOwner: accountId })); if (notificationData?.QueueConfigurations?.find((config) => config.QueueArn === scanQueueArn && config.Events.includes("s3:ObjectCreated:*")) !== void 0) { realtimeEnabled = true; realtimeEventNotificationDisablePossible = true; } if (Array.isArray(notificationData?.TopicConfigurations)) { await Promise.all(notificationData.TopicConfigurations.filter((config) => config.Events.includes("s3:ObjectCreated:*")).map(async (config) => { const paginatorListSubscriptionsByTopic = await (0, import_client_sns.paginateListSubscriptionsByTopic)({ client: sns, pageSize: 50 }, { TopicArn: config.TopicArn }); for await (const page of paginatorListSubscriptionsByTopic) { if (page.Subscriptions.find((subscription) => subscription.Protocol === "sqs" && subscription.Endpoint === scanQueueArn) !== void 0) { realtimeEnabled = true; } } })); } if (notificationData?.EventBridgeConfiguration) { if (accountId !== coreAccountId) { let crossAccoutRule = false; const paginatorListRuleNamesByTargetDefaultBus = await paginateListRuleNamesByTarget({ client: eventbridge, pageSize: 10 }, { TargetArn: `arn:${partition}:events:${region}:${coreAccountId}:event-bus/default` }); for await (const page of paginatorListRuleNamesByTargetDefaultBus) { if (await checkEventBridgeRules(eventbridge, page.RuleNames, bucket.name) === true) { crossAccoutRule = true; } } const { Policy: defaultBusPolicyJson } = await coreEventbridge.send(new import_client_eventbridge.DescribeEventBusCommand({ Name: "default" })); if (defaultBusPolicyJson !== void 0) { const defaultBusPolicy = JSON.parse(defaultBusPolicyJson); if (defaultBusPolicy.Statement.find((s) => s?.Effect === "Allow" && s?.Action === "events:PutEvents" && (s?.Principal?.AWS === accountId || s?.Principal === "*" && s?.Condition?.StringEquals["aws:PrincipalOrgID"] === organizationId)) !== void 0) { if (crossAccoutRule === true) { realtimeEnabled = true; } } } } const paginatorListRuleNamesByTargetScanQueue = await paginateListRuleNamesByTarget({ client: coreEventbridge, pageSize: 10 }, { TargetArn: scanQueueArn }); for await (const page of paginatorListRuleNamesByTargetScanQueue) { if (await checkEventBridgeRules(coreEventbridge, page.RuleNames, bucket.name) === true) { realtimeEnabled = true; } } } if (notificationData?.TopicConfigurations?.find((config) => config.Events.filter((event) => event.startsWith("s3:ObjectCreated:")).length > 0)) { realtimeEventNotificationEnablePossible = false; } if (notificationData?.QueueConfigurations?.find((config) => config.Events.filter((event) => event.startsWith("s3:ObjectCreated:")).length > 0)) { realtimeEventNotificationEnablePossible = false; } if (notificationData?.LambdaFunctionConfigurations?.find((config) => config.Events.filter((event) => event.startsWith("s3:ObjectCreated:")).length > 0)) { realtimeEventNotificationEnablePossible = false; } if (notificationData?.EventBridgeConfiguration !== void 0) { realtimeEventNotificationEnablePossible = false; } } catch (err) { console.log(err); bucket.errorMessage = `Can not get details for bucket ${bucket.name}: ${err.name}`; realtimeEventNotificationEnablePossible = false; } const scheduledStacksIncludesBucket = scheduledStacks.stacks.filter((scheduledStack) => includesBucket(scheduledStack, bucket.name)); if (scheduledStacksIncludesBucket.length > 0) { scheduledEnabled = true; scheduledStackId = scheduledStacksIncludesBucket[0].id; } const scheduledStacksHasBucket = scheduledStacks.stacks.filter((scheduledStack) => hasBucket(scheduledStack, bucket.name)); if (scheduledStacksHasBucket.length === 1) { scheduledEnabled = true; scheduledStackDisablePossible = true; scheduledStackId = scheduledStacksHasBucket[0].id; } return { ...bucket, realtimeEnabled, realtimeEventNotificationEnablePossible, realtimeEventNotificationDisablePossible, scheduledEnabled, scheduledStackDisablePossible, scheduledStackId }; } else { return null; } } async function getScheduledStacks(ssm2, cloudformation2, coreStackName) { const paginatorGetParametersByPath = await (0, import_client_ssm.paginateGetParametersByPath)({ client: ssm2 }, { Recursive: true, Path: `/bucketAV/${coreStackName}/AddOn/scheduled-bucket-scan/` }); const stacks = []; for await (const page of paginatorGetParametersByPath) { const scheduledStackNames = page.Parameters.filter((p) => p.Name.endsWith("/Version")).map((p) => p.Name.split("/")[5]); const describeStacksDataList = await Promise.all(scheduledStackNames.map((stackName) => cloudformation2.send(new import_client_cloudformation.DescribeStacksCommand({ StackName: stackName })))); describeStacksDataList.forEach((describeStacksData) => { const stack = { name: describeStacksData.Stacks[0].StackName, id: describeStacksData.Stacks[0].StackId, params: describeStacksData.Stacks[0].Parameters?.reduce((acc, param) => { acc[param.ParameterKey] = param.ParameterValue; return acc; }, {}) || {}, outputs: describeStacksData.Stacks[0].Outputs?.reduce((acc, output) => { acc[output.OutputKey] = output.OutputValue; return acc; }, {}) || {} }; if (stack.params.BucketAVStackName === coreStackName && stack.outputs.AddOn === "scheduled-bucket-scan") { stacks.push(stack); } }); } return { stacks }; } async function listBuckets(s3, ssm2, cloudformation2, sns, eventbridge, coreEventbridge, partition, region, accountId, organizationId, coreAccountId, coreStackName, scanQueueArn) { const lightBuckets = await listBucketsLight(s3, region, accountId); const scheduledStacks = await getScheduledStacks(ssm2, cloudformation2, coreStackName); const buckets = await Promise.all(lightBuckets.map((lightBucket) => enrichBucket(s3, sns, eventbridge, coreEventbridge, partition, region, accountId, organizationId, coreAccountId, coreStackName, scanQueueArn, scheduledStacks, lightBucket.name, lightBucket.region))); return buckets.filter((bucket) => bucket !== null); } var MAX_S3_COPY_SIZE = 5 * 1024 * 1024 * 1024; function generateRoleArn(partition, accountId, roleName) { return `arn:${partition}:iam::${accountId}:role/${roleName}`; } function generateRoleArnFromItem(accountConnectionItem) { return generateRoleArn(accountConnectionItem.partition.S, accountConnectionItem.account_id.S, accountConnectionItem.role_name.S); } function generateExternalId(stackId) { return stackId.split("/")[2]; } function generateExternalIdFromItem(accountConnectionItem) { return generateExternalId(accountConnectionItem.stack_id.S); } async function cfnCustomResourceSuccess(event, physicalResourceId, optionalData) { const response = await fetch(event.ResponseURL, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ Status: "SUCCESS", PhysicalResourceId: physicalResourceId, StackId: event.StackId, RequestId: event.RequestId, LogicalResourceId: event.LogicalResourceId, ...optionalData !== void 0 && { Data: optionalData } }) }); if (response.status !== 200) { console.log("response status", response.status); console.log("response", await response.text()); throw new Error("unexpected status code"); } } async function cfnCustomResourceFailed(event, physicalResourceId, optionalReason) { const response = await fetch(event.ResponseURL, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ Status: "FAILED", ...optionalReason !== void 0 && { Reason: optionalReason }, PhysicalResourceId: physicalResourceId === void 0 || physicalResourceId === null ? event.LogicalResourceId : physicalResourceId, // physicalResourceId might not be available if create fails StackId: event.StackId, RequestId: event.RequestId, LogicalResourceId: event.LogicalResourceId }) }); if (response.status !== 200) { console.log("response status", response.status); console.log("response", await response.text()); throw new Error("unexpected status code"); } } // lambda/lib-refresh-bucket-cache.js var TTL_IN_SECONDS = 3600; async function cacheBucket(dynamodb2, partition, region, accountId, { accountConnectionId, roleArn, externalId }, ttl, bucket, bucketCacheTableName) { const bucketCacheItem = { bucket_name: { S: bucket.name }, bucket_account_id: { S: bucket.accountId }, bucket_realtime_enabled: { BOOL: bucket.realtimeEnabled }, bucket_realtime_event_notification_disable_possible: { BOOL: bucket.realtimeEventNotificationDisablePossible }, bucket_realtime_event_notification_enable_possible: { BOOL: bucket.realtimeEventNotificationEnablePossible }, bucket_scheduled_enabled: { BOOL: bucket.scheduledEnabled }, bucket_scheduled_stack_disable_possible: { BOOL: bucket.scheduledStackDisablePossible }, partition: { S: partition }, region: { S: region }, account_id: { S: accountId }, ttl: { N: ttl.toString() } }; if (bucket.organizationId !== void 0) { bucketCacheItem.bucket_organization_id = { S: bucket.organizationId }; } if (accountConnectionId !== void 0) { bucketCacheItem.account_connection_id = { S: accountConnectionId }; bucketCacheItem.role_arn = { S: roleArn }; bucketCacheItem.external_id = { S: externalId }; } if (bucket.errorMessage !== void 0) { bucketCacheItem.bucket_error_message = { S: bucket.errorMessage }; } if (bucket.region !== void 0) { bucketCacheItem.bucket_region = { S: bucket.region }; } if (bucket.scheduledStackId !== void 0) { bucketCacheItem.bucket_scheduled_stack_id = { S: bucket.scheduledStackId }; } await dynamodb2.send(new import_client_dynamodb2.PutItemCommand({ TableName: bucketCacheTableName, Item: bucketCacheItem })); } function getCredentials(accountConnectionId, roleArn, externalId) { if (accountConnectionId !== void 0 && roleArn !== void 0 && externalId !== void 0) { return (0, import_credential_providers2.fromTemporaryCredentials)({ params: { RoleArn: roleArn, ExternalId: externalId, RoleSessionName: "bucketav", DurationSeconds: 3600 } }); } else { return (0, import_credential_providers2.fromNodeProviderChain)(); } } function generateTtl() { return Math.floor(Date.now() / 1e3) + TTL_IN_SECONDS; } async function refreshAccount2(dynamodb2, ssm2, cloudformation2, partition, region, accountId, { accountConnectionId, roleArn, externalId, organizationId }, ttl, coreAccountId, coreStackName, scanQueueArn, bucketCacheTableName) { const credentials = getCredentials(accountConnectionId, roleArn, externalId); const s3 = new import_client_s33.S3Client({ apiVersion: "2006-03-01", credentials }); const sns = new import_client_sns2.SNSClient({ apiVersion: "2010-03-31", credentials }); const eventbridge = new import_client_eventbridge2.EventBridgeClient({ apiVersion: "2015-10-07", credentials }); const coreEventbridge = new import_client_eventbridge2.EventBridgeClient({ apiVersion: "2015-10-07" }); const buckets = await listBuckets(s3, ssm2, cloudformation2, sns, eventbridge, coreEventbridge, partition, region, accountId, organizationId, coreAccountId, coreStackName, scanQueueArn); for (const bucket of buckets) { await cacheBucket(dynamodb2, partition, region, accountId, { accountConnectionId, roleArn, externalId }, ttl, bucket, bucketCacheTableName); } } async function refreshCoreAccount(dynamodb2, ssm2, cloudformation2, region, accountId, coreAccountId, coreStackName, ttl, scanQueueArn, bucketCacheTableName) { const organizationId = await fetchCachedOrganizationId(ssm2, coreStackName); await refreshAccount2(dynamodb2, ssm2, cloudformation2, "aws", region, accountId, { organizationId }, ttl, coreAccountId, coreStackName, scanQueueArn, bucketCacheTableName); } // lambda/refresh-bucket-cache.js var ssm = new import_client_ssm2.SSMClient({ apiVersion: "2014-11-06" }); var cloudformation = new import_client_cloudformation2.CloudFormationClient({ apiVersion: "2006-03-01", maxAttempts: 10 }); var dynamodb = new import_client_dynamodb3.DynamoDBClient({ apiVersion: "2012-08-10" }); async function handler(event, context) { console.log(`Invoke: ${JSON.stringify(event)} ${JSON.stringify(context)}`); const bucketCacheTableName = `${process.env.CORE_STACK_NAME}-BucketCache`; const ttl = generateTtl(); if ("RequestType" in event) { if (event.RequestType === "Create" || event.RequestType === "Update") { try { await refreshCoreAccount(dynamodb, ssm, cloudformation, process.env.AWS_REGION, process.env.AWS_ACCOUNT_ID, process.env.AWS_ACCOUNT_ID, process.env.CORE_STACK_NAME, ttl, process.env.SCAN_QUEUE_ARN, bucketCacheTableName); await cfnCustomResourceSuccess(event, process.env.CORE_STACK_NAME, {}); } catch (err) { console.log(err); await cfnCustomResourceFailed(event, process.env.CORE_STACK_NAME, err.message); } } else if (event.RequestType === "Delete") { await cfnCustomResourceSuccess(event, process.env.CORE_STACK_NAME, {}); } else { throw new Error("unsupported request type"); } } else { if ("eof" in event) { const scanParams = { TableName: `${process.env.CORE_STACK_NAME}-AccountConnection`, Limit: 5, ConsistentRead: true }; if ("exclusiveStartKey" in event) { scanParams.ExclusiveStartKey = event.exclusiveStartKey; } const scanData = await dynamodb.send(new import_client_dynamodb3.ScanCommand(scanParams)); for (const accountConnectionItem of scanData.Items) { const accountConnectionId = accountConnectionItem.account_connection_id.S; const roleArn = generateRoleArnFromItem(accountConnectionItem); const externalId = generateExternalIdFromItem(accountConnectionItem); const organizationId = accountConnectionItem.organization_id?.S; await refreshAccount2(dynamodb, ssm, cloudformation, accountConnectionItem.partition.S, accountConnectionItem.region.S, accountConnectionItem.account_id.S, { accountConnectionId, roleArn, externalId, organizationId }, ttl, process.env.AWS_ACCOUNT_ID, process.env.CORE_STACK_NAME, process.env.SCAN_QUEUE_ARN, bucketCacheTableName); } if ("LastEvaluatedKey" in scanData) { return { exclusiveStartKey: scanData.LastEvaluatedKey, eof: false }; } else { return { eof: true }; } } else { await refreshCoreAccount(dynamodb, ssm, cloudformation, process.env.AWS_REGION, process.env.AWS_ACCOUNT_ID, process.env.AWS_ACCOUNT_ID, process.env.CORE_STACK_NAME, ttl, process.env.SCAN_QUEUE_ARN, bucketCacheTableName); return { eof: false }; } } } // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { handler }); Environment: Variables: AWS_ACCOUNT_ID: Ref: AWS::AccountId AWS_PARTITION: Ref: AWS::Partition CORE_STACK_NAME: Ref: AWS::StackName SCAN_QUEUE_ARN: Fn::GetAtt: - ScanQueue - Arn Handler: index.handler MemorySize: 1769 ReservedConcurrentExecutions: Fn::If: - HasRefreshBucketCacheFunctionReservedConcurrentExecutions - Ref: RefreshBucketCacheFunctionReservedConcurrentExecutions - Ref: AWS::NoValue Role: Fn::GetAtt: - RefreshBucketCacheRole - Arn Runtime: nodejs22.x Timeout: 900 VpcConfig: Fn::If: - HasLambdaVpc - SecurityGroupIds: - Ref: LambdaSecurityGroup SubnetIds: Ref: LambdaSubnets - Ref: AWS::NoValue Metadata: bucketav::RefreshBucketCacheLambdaVpcAllowPolicy: Fn::If: - HasCrossAccountAndLambdaVpcAndNotMultiDeployment - Ref: RefreshBucketCacheLambdaVpcAllowPolicy - Ref: AWS::NoValue Condition: HasCrossAccountAndNotMultiDeployment RefreshBucketCacheLambdaVpcAllowPolicy: Type: AWS::IAM::Policy Properties: PolicyDocument: Statement: - Effect: Allow Action: - ec2:CreateNetworkInterface - ec2:DeleteNetworkInterface - ec2:AssignPrivateIpAddresses - ec2:UnassignPrivateIpAddresses - ec2:DescribeNetworkInterfaces - ec2:DescribeSubnets Resource: - "*" PolicyName: vpc-allow Roles: - Ref: RefreshBucketCacheRole Condition: HasCrossAccountAndLambdaVpcAndNotMultiDeployment RefreshBucketCacheLambdaVpcDenyPolicy: Type: AWS::IAM::Policy Properties: PolicyDocument: Statement: - Effect: Deny Action: - ec2:CreateNetworkInterface - ec2:DescribeNetworkInterfaces - ec2:DescribeSubnets - ec2:DeleteNetworkInterface - ec2:AssignPrivateIpAddresses - ec2:UnassignPrivateIpAddresses Resource: "*" Condition: ArnEquals: lambda:SourceFunctionArn: - Fn::GetAtt: - RefreshBucketCacheFunction - Arn PolicyName: vpc-deny Roles: - Ref: RefreshBucketCacheRole Condition: HasCrossAccountAndLambdaVpcAndNotMultiDeployment RefreshBucketCacheLogGroup: Type: AWS::Logs::LogGroup Properties: LogGroupName: Fn::Join: - "" - - /aws/lambda/ - Ref: RefreshBucketCacheFunction RetentionInDays: Ref: LogsRetentionInDays Condition: HasCrossAccountAndNotMultiDeployment RefreshBucketCachePolicy: Type: AWS::IAM::Policy Properties: PolicyDocument: Statement: - Effect: Allow Action: - logs:CreateLogStream - logs:PutLogEvents Resource: Fn::GetAtt: - RefreshBucketCacheLogGroup - Arn PolicyName: logs Roles: - Ref: RefreshBucketCacheRole Condition: HasCrossAccountAndNotMultiDeployment StateMachineNameGeneratorRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Principal: Service: lambda.amazonaws.com Action: sts:AssumeRole PermissionsBoundary: Fn::If: - HasPermissionsBoundary - Ref: PermissionsBoundary - Ref: AWS::NoValue Condition: HasCrossAccountAndNotMultiDeployment StateMachineNameGeneratorFunction: Type: AWS::Lambda::Function Properties: Code: ZipFile: | var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // lambda/state-machine-name-generator.js var state_machine_name_generator_exports = {}; __export(state_machine_name_generator_exports, { generateName: () => generateName, handler: () => handler }); module.exports = __toCommonJS(state_machine_name_generator_exports); // lambda/lib.js var import_client_cloudformation = require("@aws-sdk/client-cloudformation"); var import_client_s3 = require("@aws-sdk/client-s3"); var import_client_sns = require("@aws-sdk/client-sns"); var import_client_eventbridge = require("@aws-sdk/client-eventbridge"); var import_client_dynamodb = require("@aws-sdk/client-dynamodb"); var import_client_s32 = require("@aws-sdk/client-s3"); var import_client_ssm = require("@aws-sdk/client-ssm"); var import_client_secrets_manager = require("@aws-sdk/client-secrets-manager"); var import_client_organizations = require("@aws-sdk/client-organizations"); var import_credential_providers = require("@aws-sdk/credential-providers"); // lambda/node_modules/@smithy/core/dist-es/pagination/createPaginator.js var makePagedClientRequest = async (CommandCtor, client, input, withCommand = (_) => _, ...args) => { let command = new CommandCtor(input); command = withCommand(command) ?? command; return await client.send(command, ...args); }; function createPaginator(ClientCtor, CommandCtor, inputTokenName, outputTokenName, pageSizeTokenName) { return async function* paginateOperation(config, input, ...additionalArguments) { const _input = input; let token = config.startingToken ?? _input[inputTokenName]; let hasNext = true; let page; while (hasNext) { _input[inputTokenName] = token; if (pageSizeTokenName) { _input[pageSizeTokenName] = _input[pageSizeTokenName] ?? config.pageSize; } if (config.client instanceof ClientCtor) { page = await makePagedClientRequest(CommandCtor, config.client, input, config.withCommand, ...additionalArguments); } else { throw new Error(`Invalid client, expected instance of ${ClientCtor.name}`); } yield page; const prevToken = token; token = get(page, outputTokenName); hasNext = !!(token && (!config.stopOnSameToken || token !== prevToken)); } return void 0; }; } var get = (fromObject, path) => { let cursor = fromObject; const pathComponents = path.split("."); for (const step of pathComponents) { if (!cursor || typeof cursor !== "object") { return void 0; } cursor = cursor[step]; } return cursor; }; // lambda/lib.js var paginateListRuleNamesByTarget = createPaginator(import_client_eventbridge.EventBridgeClient, import_client_eventbridge.ListRuleNamesByTargetCommand, "NextToken", "NextToken", "Limit"); var MAX_S3_COPY_SIZE = 5 * 1024 * 1024 * 1024; async function cfnCustomResourceSuccess(event, physicalResourceId, optionalData) { const response = await fetch(event.ResponseURL, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ Status: "SUCCESS", PhysicalResourceId: physicalResourceId, StackId: event.StackId, RequestId: event.RequestId, LogicalResourceId: event.LogicalResourceId, ...optionalData !== void 0 && { Data: optionalData } }) }); if (response.status !== 200) { console.log("response status", response.status); console.log("response", await response.text()); throw new Error("unexpected status code"); } } // lambda/state-machine-name-generator.js var MAX_LENGTH = 80; function generateName(stackName, stackId, suffix) { if (stackName.length + suffix.length > MAX_LENGTH) { const resumingLength = MAX_LENGTH - (stackId.length + 1); const maxPrefixLength = Math.floor(resumingLength / 2); const maxSuffixLength = resumingLength - maxPrefixLength; const stackNameLength = Math.min(stackName.length, resumingLength - Math.min(suffix.length, maxSuffixLength)); const suffixLength = Math.min(suffix.length, resumingLength - Math.min(stackName.length, maxPrefixLength)); return stackName.substr(0, stackNameLength) + "-" + stackId + suffix.substr(0, suffixLength); } else { return stackName + suffix; } } async function handler(event) { console.log(`Invoke: ${JSON.stringify(event)}`); const stackName = event.StackId.split("/")[1]; const stackId = event.StackId.split("/")[2].replaceAll("-", ""); if (event.RequestType === "Create") { await cfnCustomResourceSuccess(event, generateName(stackName, stackId, event.ResourceProperties.Suffix)); } else if (event.RequestType === "Update") { if (event.ResourceProperties.Suffix !== event.OldResourceProperties.Suffix) { await cfnCustomResourceSuccess(event, generateName(stackName, stackId, event.ResourceProperties.Suffix)); } else { await cfnCustomResourceSuccess(event, event.PhysicalResourceId); } } else if (event.RequestType === "Delete") { await cfnCustomResourceSuccess(event, event.PhysicalResourceId); } else { throw new Error("unsupported request type"); } } // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { generateName, handler }); Handler: index.handler MemorySize: 1769 ReservedConcurrentExecutions: Fn::If: - HasStateMachineNameGeneratorFunctionReservedConcurrentExecutions - Ref: StateMachineNameGeneratorFunctionReservedConcurrentExecutions - Ref: AWS::NoValue Role: Fn::GetAtt: - StateMachineNameGeneratorRole - Arn Runtime: nodejs22.x Timeout: 30 VpcConfig: Fn::If: - HasLambdaVpc - SecurityGroupIds: - Ref: LambdaSecurityGroup SubnetIds: Ref: LambdaSubnets - Ref: AWS::NoValue Metadata: bucketav::StateMachineNameGeneratorFunctionLambdaVpcAllowPolicy: Fn::If: - HasLambdaVpcAndStateMachineNameGeneratorCondition - Ref: StateMachineNameGeneratorFunctionLambdaVpcAllowPolicy - Ref: AWS::NoValue Condition: HasCrossAccountAndNotMultiDeployment StateMachineNameGeneratorFunctionLambdaVpcAllowPolicy: Type: AWS::IAM::Policy Properties: PolicyDocument: Statement: - Effect: Allow Action: - ec2:CreateNetworkInterface - ec2:DeleteNetworkInterface - ec2:AssignPrivateIpAddresses - ec2:UnassignPrivateIpAddresses - ec2:DescribeNetworkInterfaces - ec2:DescribeSubnets Resource: - "*" PolicyName: vpc-allow Roles: - Ref: StateMachineNameGeneratorRole Condition: HasLambdaVpcAndStateMachineNameGeneratorCondition StateMachineNameGeneratorFunctionLambdaVpcDenyPolicy: Type: AWS::IAM::Policy Properties: PolicyDocument: Statement: - Effect: Deny Action: - ec2:CreateNetworkInterface - ec2:DescribeNetworkInterfaces - ec2:DescribeSubnets - ec2:DeleteNetworkInterface - ec2:AssignPrivateIpAddresses - ec2:UnassignPrivateIpAddresses Resource: "*" Condition: ArnEquals: lambda:SourceFunctionArn: - Fn::GetAtt: - StateMachineNameGeneratorFunction - Arn PolicyName: vpc-deny Roles: - Ref: StateMachineNameGeneratorRole Condition: HasLambdaVpcAndStateMachineNameGeneratorCondition StateMachineNameGeneratorLogGroup: Type: AWS::Logs::LogGroup Properties: LogGroupName: Fn::Join: - "" - - /aws/lambda/ - Ref: StateMachineNameGeneratorFunction RetentionInDays: Ref: LogsRetentionInDays Condition: HasCrossAccountAndNotMultiDeployment StateMachineNameGeneratorPolicy: Type: AWS::IAM::Policy Properties: PolicyDocument: Statement: - Effect: Allow Action: - logs:CreateLogStream - logs:PutLogEvents Resource: Fn::GetAtt: - StateMachineNameGeneratorLogGroup - Arn PolicyName: lambda Roles: - Ref: StateMachineNameGeneratorRole Condition: HasCrossAccountAndNotMultiDeployment StateMachineNameGeneratorRefreshBucketCache: Type: Custom::StateMachineNameGenerator Properties: ServiceToken: Fn::GetAtt: - StateMachineNameGeneratorFunction - Arn ServiceTimeout: "30" Suffix: -refresh-bucket-cache DependsOn: - StateMachineNameGeneratorPolicy UpdateReplacePolicy: Delete DeletionPolicy: Delete Condition: HasCrossAccountAndNotMultiDeployment RefreshBucketCacheStateMachineLogGroup: Type: AWS::Logs::LogGroup Properties: LogGroupName: Fn::Join: - "" - - /aws/vendedlogs/states/ - Ref: AWS::StackName - -RefreshBucketCacheStateMachine RetentionInDays: Ref: LogsRetentionInDays Condition: HasCrossAccountAndNotMultiDeployment RefreshBucketCacheStateMachineRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Principal: Service: states.amazonaws.com Action: sts:AssumeRole PermissionsBoundary: Fn::If: - HasPermissionsBoundary - Ref: PermissionsBoundary - Ref: AWS::NoValue Policies: - PolicyDocument: Statement: - Effect: Allow Action: lambda:InvokeFunction Resource: Fn::GetAtt: - RefreshBucketCacheFunction - Arn - Effect: Allow Action: - logs:CreateLogDelivery - logs:GetLogDelivery - logs:UpdateLogDelivery - logs:DeleteLogDelivery - logs:ListLogDeliveries - logs:PutResourcePolicy - logs:DescribeResourcePolicies - logs:DescribeLogGroups Resource: "*" - Effect: Allow Action: - logs:CreateLogStream - logs:PutLogEvents Resource: Fn::GetAtt: - RefreshBucketCacheStateMachineLogGroup - Arn PolicyName: states Condition: HasCrossAccountAndNotMultiDeployment RefreshBucketCacheStateMachine: Type: AWS::StepFunctions::StateMachine Properties: Definition: Comment: bucketAV (refresh bucket cache). TimeoutSeconds: 1800 StartAt: RefreshBucketCache Version: "1.0" States: RefreshBucketCache: Type: Task Resource: Fn::GetAtt: - RefreshBucketCacheFunction - Arn Retry: - ErrorEquals: - States.TaskFailed IntervalSeconds: 2 MaxAttempts: 10 BackoffRate: 2 Next: CheckEndOfPage CheckEndOfPage: Type: Choice Choices: - Variable: $.eof BooleanEquals: true Next: Done Default: RefreshBucketCache Done: Type: Succeed LoggingConfiguration: Destinations: - CloudWatchLogsLogGroup: LogGroupArn: Fn::GetAtt: - RefreshBucketCacheStateMachineLogGroup - Arn IncludeExecutionData: true Level: ERROR RoleArn: Fn::GetAtt: - RefreshBucketCacheStateMachineRole - Arn StateMachineName: Ref: StateMachineNameGeneratorRefreshBucketCache DependsOn: - RefreshBucketCachePolicy Condition: HasCrossAccountAndNotMultiDeployment RefreshBucketCacheStateMachineAlarmExecutionsFailed: Type: AWS::CloudWatch::Alarm Properties: AlarmActions: - Ref: InfrastructureAlarmsTopic AlarmDescription: Fn::Join: - "" - - "Refresh bucket cache failed (state machine " - Ref: RefreshBucketCacheStateMachine - ). Please follow https://bucketav.com/help/operations/monitoring-alerting.html#refreshbucketcachestatemachinealarmexecutionsfailed ComparisonOperator: GreaterThanThreshold Dimensions: - Name: StateMachineArn Value: Ref: RefreshBucketCacheStateMachine EvaluationPeriods: 1 MetricName: ExecutionsFailed Namespace: AWS/States Period: 300 Statistic: Sum Threshold: 0 TreatMissingData: notBreaching Condition: HasCrossAccountAndNotMultiDeployment RefreshBucketCacheStateMachineAlarmExecutionsTimedOut: Type: AWS::CloudWatch::Alarm Properties: AlarmActions: - Ref: InfrastructureAlarmsTopic AlarmDescription: Fn::Join: - "" - - "Refresh bucket cache timed out(state machine " - Ref: RefreshBucketCacheStateMachine - ). Please follow https://bucketav.com/help/operations/monitoring-alerting.html#refreshbucketcachestatemachinealarmexecutionstimedout ComparisonOperator: GreaterThanThreshold Dimensions: - Name: StateMachineArn Value: Ref: RefreshBucketCacheStateMachine EvaluationPeriods: 1 MetricName: ExecutionsTimedOut Namespace: AWS/States Period: 300 Statistic: Sum Threshold: 0 TreatMissingData: notBreaching Condition: HasCrossAccountAndNotMultiDeployment RefreshBucketCacheCronRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Principal: Service: events.amazonaws.com Action: sts:AssumeRole PermissionsBoundary: Fn::If: - HasPermissionsBoundary - Ref: PermissionsBoundary - Ref: AWS::NoValue Policies: - PolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Action: states:StartExecution Resource: Ref: RefreshBucketCacheStateMachine PolicyName: events Condition: HasCrossAccountAndNotMultiDeployment RefreshBucketCacheCron: Type: AWS::Events::Rule Properties: ScheduleExpression: rate(30 minutes) Targets: - Arn: Ref: RefreshBucketCacheStateMachine Id: states Input: "{}" RoleArn: Fn::GetAtt: - RefreshBucketCacheCronRole - Arn Condition: HasCrossAccountAndNotMultiDeployment RefreshBucketCache: Type: Custom::RefreshBucketCache Properties: ServiceToken: Fn::GetAtt: - RefreshBucketCacheFunction - Arn ServiceTimeout: "900" Version: 2.0.0 DependsOn: - RefreshBucketCachePolicy - RefreshBucketCacheRole - RefreshServiceDiscovery UpdateReplacePolicy: Delete DeletionPolicy: Delete Condition: HasCrossAccountAndNotMultiDeployment RefreshBucketCacheRule: Type: AWS::Events::Rule Properties: EventPattern: detail-type: - AWS API Call via CloudTrail detail: eventSource: - s3.amazonaws.com - events.amazonaws.com eventName: - CreateBucket - DeleteBucket - PutBucketNotification - PutBucketNotificationConfiguration - PutRule - DeleteRule - PutTargets - RemoveTargets - EnableRule - DisableRule - PutPermission - RemovePermission Targets: - Arn: Ref: AccountConnectionTopic Id: sns InputTransformer: InputTemplate: Fn::Join: - "" - - '{"action": "refreshAccount", "partition": "' - Ref: AWS::Partition - '", "region": "' - Ref: AWS::Region - '", "accountId": "' - Ref: AWS::AccountId - '"}' Condition: HasCrossAccountAndNotMultiDeployment DashboardLambdaRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Principal: Service: lambda.amazonaws.com Action: sts:AssumeRole PermissionsBoundary: Fn::If: - HasPermissionsBoundary - Ref: PermissionsBoundary - Ref: AWS::NoValue Policies: - PolicyDocument: Statement: - Fn::If: - HasMultiDeployment - Ref: AWS::NoValue - Effect: Allow Action: - s3:ListAllMyBuckets - s3:GetBucketNotification - s3:GetBucketTagging - sns:ListSubscriptionsByTopic - events:ListRuleNamesByTarget - events:DescribeRule Resource: "*" - Fn::If: - HasCrossAccountAndNotMultiDeployment - Effect: Allow Action: ssm:GetParameter Resource: Fn::Join: - "" - - "arn:" - Ref: AWS::Partition - ":ssm:" - Ref: AWS::Region - ":" - Ref: AWS::AccountId - :parameter - Ref: ServiceDiscoveryOrganizationId - Ref: AWS::NoValue - Fn::If: - HasMultiDeployment - Ref: AWS::NoValue - Effect: Allow Action: events:DescribeEventBus Resource: Fn::Join: - "" - - "arn:" - Ref: AWS::Partition - ":events:" - Ref: AWS::Region - ":" - Ref: AWS::AccountId - :event-bus/default - Fn::If: - HasCrossAccountAndNotMultiDeployment - Effect: Allow Action: dynamodb:Scan Resource: - Fn::GetAtt: - AccountConnectionTable - Arn - Fn::GetAtt: - BucketCacheTable - Arn - Ref: AWS::NoValue - Fn::If: - HasCrossAccountAndNotMultiDeployment - Effect: Allow Action: dynamodb:GetItem Resource: Fn::GetAtt: - AccountConnectionTable - Arn - Ref: AWS::NoValue - Fn::If: - HasCrossAccountAndNotMultiDeployment - Effect: Allow Action: - dynamodb:GetItem - dynamodb:PutItem Resource: Fn::GetAtt: - BucketCacheTable - Arn - Ref: AWS::NoValue - Fn::If: - HasCrossAccountAndNotMultiDeployment - Effect: Allow Action: sts:AssumeRole Resource: Fn::Join: - "" - - "arn:" - Ref: AWS::Partition - :iam::*:role/ - Ref: AWS::StackName - -AccountConnection - Ref: AWS::NoValue - Fn::If: - HasCrossAccountAndNotMultiDeployment - Effect: Allow Action: states:StartExecution Resource: Fn::GetAtt: - RefreshBucketCacheStateMachine - Arn - Ref: AWS::NoValue - Fn::If: - HasMultiDeployment - Ref: AWS::NoValue - Effect: Allow Action: s3:PutBucketNotification Resource: Ref: S3BucketRestriction - Effect: Allow Action: cloudformation:DescribeStacks Resource: "*" - Effect: Allow Action: ssm:GetParametersByPath Resource: Fn::Join: - "" - - "arn:" - Ref: AWS::Partition - ":ssm:" - Ref: AWS::Region - ":" - Ref: AWS::AccountId - :parameter/bucketAV/ - Ref: AWS::StackName - /AddOn/* - Effect: Allow Action: logs:StartQuery Resource: Fn::GetAtt: - Logs - Arn - Effect: Allow Action: logs:GetQueryResults Resource: Fn::GetAtt: - Logs - Arn PolicyName: lambda DashboardLambdaFunction: Type: AWS::Lambda::Function Properties: Code: ZipFile: | var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // lambda/dashboard-aws.js var dashboard_aws_exports = {}; __export(dashboard_aws_exports, { handler: () => handler }); module.exports = __toCommonJS(dashboard_aws_exports); var import_client_cloudformation2 = require("@aws-sdk/client-cloudformation"); var import_client_cloudwatch_logs2 = require("@aws-sdk/client-cloudwatch-logs"); var import_client_s36 = require("@aws-sdk/client-s3"); var import_client_ssm3 = require("@aws-sdk/client-ssm"); var import_client_dynamodb4 = require("@aws-sdk/client-dynamodb"); var import_client_sns3 = require("@aws-sdk/client-sns"); var import_client_eventbridge3 = require("@aws-sdk/client-eventbridge"); var import_client_sfn2 = require("@aws-sdk/client-sfn"); // lambda/lib.js var import_client_cloudformation = require("@aws-sdk/client-cloudformation"); var import_client_s3 = require("@aws-sdk/client-s3"); var import_client_sns = require("@aws-sdk/client-sns"); var import_client_eventbridge = require("@aws-sdk/client-eventbridge"); var import_client_dynamodb = require("@aws-sdk/client-dynamodb"); var import_client_s32 = require("@aws-sdk/client-s3"); var import_client_ssm = require("@aws-sdk/client-ssm"); var import_client_secrets_manager = require("@aws-sdk/client-secrets-manager"); var import_client_organizations = require("@aws-sdk/client-organizations"); var import_credential_providers = require("@aws-sdk/credential-providers"); // lambda/node_modules/@smithy/core/dist-es/pagination/createPaginator.js var makePagedClientRequest = async (CommandCtor, client, input, withCommand = (_) => _, ...args) => { let command = new CommandCtor(input); command = withCommand(command) ?? command; return await client.send(command, ...args); }; function createPaginator(ClientCtor, CommandCtor, inputTokenName, outputTokenName, pageSizeTokenName) { return async function* paginateOperation(config, input, ...additionalArguments) { const _input = input; let token = config.startingToken ?? _input[inputTokenName]; let hasNext = true; let page; while (hasNext) { _input[inputTokenName] = token; if (pageSizeTokenName) { _input[pageSizeTokenName] = _input[pageSizeTokenName] ?? config.pageSize; } if (config.client instanceof ClientCtor) { page = await makePagedClientRequest(CommandCtor, config.client, input, config.withCommand, ...additionalArguments); } else { throw new Error(`Invalid client, expected instance of ${ClientCtor.name}`); } yield page; const prevToken = token; token = get(page, outputTokenName); hasNext = !!(token && (!config.stopOnSameToken || token !== prevToken)); } return void 0; }; } var get = (fromObject, path) => { let cursor = fromObject; const pathComponents = path.split("."); for (const step of pathComponents) { if (!cursor || typeof cursor !== "object") { return void 0; } cursor = cursor[step]; } return cursor; }; // lambda/node_modules/yocto-queue/index.js var Node = class { value; next; constructor(value) { this.value = value; } }; var Queue = class { #head; #tail; #size; constructor() { this.clear(); } enqueue(value) { const node = new Node(value); if (this.#head) { this.#tail.next = node; this.#tail = node; } else { this.#head = node; this.#tail = node; } this.#size++; } dequeue() { const current = this.#head; if (!current) { return; } this.#head = this.#head.next; this.#size--; return current.value; } peek() { if (!this.#head) { return; } return this.#head.value; } clear() { this.#head = void 0; this.#tail = void 0; this.#size = 0; } get size() { return this.#size; } *[Symbol.iterator]() { let current = this.#head; while (current) { yield current.value; current = current.next; } } *drain() { while (this.#head) { yield this.dequeue(); } } }; // lambda/node_modules/p-limit/index.js function pLimit(concurrency) { validateConcurrency(concurrency); const queue = new Queue(); let activeCount = 0; const resumeNext = () => { if (activeCount < concurrency && queue.size > 0) { queue.dequeue()(); activeCount++; } }; const next = () => { activeCount--; resumeNext(); }; const run = async (function_, resolve, arguments_) => { const result = (async () => function_(...arguments_))(); resolve(result); try { await result; } catch { } next(); }; const enqueue = (function_, resolve, arguments_) => { new Promise((internalResolve) => { queue.enqueue(internalResolve); }).then( run.bind(void 0, function_, resolve, arguments_) ); (async () => { await Promise.resolve(); if (activeCount < concurrency) { resumeNext(); } })(); }; const generator = (function_, ...arguments_) => new Promise((resolve) => { enqueue(function_, resolve, arguments_); }); Object.defineProperties(generator, { activeCount: { get: () => activeCount }, pendingCount: { get: () => queue.size }, clearQueue: { value() { queue.clear(); } }, concurrency: { get: () => concurrency, set(newConcurrency) { validateConcurrency(newConcurrency); concurrency = newConcurrency; queueMicrotask(() => { while (activeCount < concurrency && queue.size > 0) { resumeNext(); } }); } } }); return generator; } function validateConcurrency(concurrency) { if (!((Number.isInteger(concurrency) || concurrency === Number.POSITIVE_INFINITY) && concurrency > 0)) { throw new TypeError("Expected `concurrency` to be a number from 1 and up"); } } // lambda/lib.js async function fetchCachedOrganizationId(ssm, coreStackName) { const data = await ssm.send(new import_client_ssm.GetParameterCommand({ Name: `/bucketAV/${coreStackName}/OrganizationId` })); if (data.Parameter.Value === "INIT" || data.Parameter.Value === "NONE") { return void 0; } return data.Parameter.Value; } function includesBucket(scheduledStack, bucketName) { let excludeFilterExpression = "^$"; if (scheduledStack.params.ExcludeBucketNameFilter) { excludeFilterExpression = "^" + scheduledStack.params.ExcludeBucketNameFilter.replaceAll(".", ".").replaceAll("-", "-").replaceAll("*", ".*").replaceAll(",", "$|^") + "$"; } if (bucketName.match(new RegExp(excludeFilterExpression))) { return false; } if (scheduledStack.params.BucketName.includes("*")) { const filterExpression = "^" + scheduledStack.params.BucketName.replaceAll(".", ".").replaceAll("-", "-").replaceAll("*", ".*").replaceAll(",", "$|^") + "$"; return bucketName.match(new RegExp(filterExpression)) !== null; } else { return scheduledStack.params.BucketName.split(",").includes(bucketName); } } function hasBucket(scheduledStack, bucketName) { if (scheduledStack.params.BucketName === bucketName && (scheduledStack.params.ExcludeBucketNameFilter === "" || !("ExcludeBucketNameFilter" in scheduledStack.params))) { return true; } return false; } function mapCachedBucketItem(item) { const bucket = { name: item.bucket_name.S, accountId: item.bucket_account_id.S, realtimeEnabled: item.bucket_realtime_enabled.BOOL, realtimeEventNotificationEnablePossible: item.bucket_realtime_event_notification_enable_possible.BOOL, realtimeEventNotificationDisablePossible: item.bucket_realtime_event_notification_disable_possible.BOOL, scheduledEnabled: item.bucket_scheduled_enabled.BOOL, scheduledStackDisablePossible: item.bucket_scheduled_stack_disable_possible.BOOL }; if ("bucket_organization_id" in item) { bucket.organizationId = item.bucket_organization_id.S; } if ("bucket_region" in item) { bucket.region = item.bucket_region.S; } if ("bucket_error_message" in item) { bucket.errorMessage = item.bucket_error_message.S; } if ("bucket_scheduled_stack_id" in item) { bucket.scheduledStackId = item.bucket_scheduled_stack_id.S; } return bucket; } async function listCachedBuckets(dynamodb2, bucketCacheTableName) { const paginatorScan = await (0, import_client_dynamodb.paginateScan)({ client: dynamodb2 }, { TableName: bucketCacheTableName }); const buckets = []; for await (const page of paginatorScan) { for (const item of page.Items) { buckets.push(mapCachedBucketItem(item)); } } return buckets; } async function listBucketsLight(s3, region, accountId) { const allBuckets = []; const input = {}; if (region !== void 0 && region !== null) { input.BucketRegion = region; } const paginator = (0, import_client_s3.paginateListBuckets)({ client: s3, pageSize: 1e3 }, input); for await (const page of paginator) { allBuckets.push(...page.Buckets.map((bucket) => { if ("BucketRegion" in bucket) { return { name: bucket.Name, region: bucket.BucketRegion, accountId }; } else { return { name: bucket.Name, accountId, errorMessage: `Can not get region for bucket ${bucket.Name}` }; } })); } if (region !== void 0 && region !== null) { return allBuckets.filter((bucket) => bucket.region === region || bucket.region === void 0); } else { return allBuckets; } } var paginateListRuleNamesByTarget = createPaginator(import_client_eventbridge.EventBridgeClient, import_client_eventbridge.ListRuleNamesByTargetCommand, "NextToken", "NextToken", "Limit"); async function checkEventBridgeRules(eventbridge2, ruleNames, bucketName) { const rules = await Promise.all(ruleNames.map((ruleName) => eventbridge2.send(new import_client_eventbridge.DescribeRuleCommand({ Name: ruleName })))); return rules.filter((rule) => rule.State === "ENABLED" && rule.EventPattern).map((rule) => JSON.parse(rule.EventPattern)).find((pattern) => pattern.source.includes("aws.s3") && pattern["detail-type"].includes("Object Created") && (pattern?.detail?.bucket?.name?.includes(bucketName) || pattern?.detail?.bucket?.name === void 0)) !== void 0; } async function enrichBucket(s3, sns2, eventbridge2, coreEventbridge, partition, region, accountId, organizationId, coreAccountId, coreStackName, scanQueueArn, scheduledStacks, bucketName, bucketRegion) { const bucket = { name: bucketName, accountId, organizationId }; if (bucketRegion === void 0) { bucket.region = void 0; bucket.errorMessage = `Can not get region for bucket ${bucketName}`; } else { bucket.region = bucketRegion; } if (bucket.region === region || bucket.region === void 0) { let realtimeEnabled = false; let realtimeEventNotificationEnablePossible = bucket.region !== void 0; let realtimeEventNotificationDisablePossible = false; let scheduledEnabled = false; let scheduledStackDisablePossible = false; let scheduledStackId = void 0; try { const notificationData = await s3.send(new import_client_s3.GetBucketNotificationConfigurationCommand({ Bucket: bucket.name, ExpectedBucketOwner: accountId })); if (notificationData?.QueueConfigurations?.find((config) => config.QueueArn === scanQueueArn && config.Events.includes("s3:ObjectCreated:*")) !== void 0) { realtimeEnabled = true; realtimeEventNotificationDisablePossible = true; } if (Array.isArray(notificationData?.TopicConfigurations)) { await Promise.all(notificationData.TopicConfigurations.filter((config) => config.Events.includes("s3:ObjectCreated:*")).map(async (config) => { const paginatorListSubscriptionsByTopic = await (0, import_client_sns.paginateListSubscriptionsByTopic)({ client: sns2, pageSize: 50 }, { TopicArn: config.TopicArn }); for await (const page of paginatorListSubscriptionsByTopic) { if (page.Subscriptions.find((subscription) => subscription.Protocol === "sqs" && subscription.Endpoint === scanQueueArn) !== void 0) { realtimeEnabled = true; } } })); } if (notificationData?.EventBridgeConfiguration) { if (accountId !== coreAccountId) { let crossAccoutRule = false; const paginatorListRuleNamesByTargetDefaultBus = await paginateListRuleNamesByTarget({ client: eventbridge2, pageSize: 10 }, { TargetArn: `arn:${partition}:events:${region}:${coreAccountId}:event-bus/default` }); for await (const page of paginatorListRuleNamesByTargetDefaultBus) { if (await checkEventBridgeRules(eventbridge2, page.RuleNames, bucket.name) === true) { crossAccoutRule = true; } } const { Policy: defaultBusPolicyJson } = await coreEventbridge.send(new import_client_eventbridge.DescribeEventBusCommand({ Name: "default" })); if (defaultBusPolicyJson !== void 0) { const defaultBusPolicy = JSON.parse(defaultBusPolicyJson); if (defaultBusPolicy.Statement.find((s) => s?.Effect === "Allow" && s?.Action === "events:PutEvents" && (s?.Principal?.AWS === accountId || s?.Principal === "*" && s?.Condition?.StringEquals["aws:PrincipalOrgID"] === organizationId)) !== void 0) { if (crossAccoutRule === true) { realtimeEnabled = true; } } } } const paginatorListRuleNamesByTargetScanQueue = await paginateListRuleNamesByTarget({ client: coreEventbridge, pageSize: 10 }, { TargetArn: scanQueueArn }); for await (const page of paginatorListRuleNamesByTargetScanQueue) { if (await checkEventBridgeRules(coreEventbridge, page.RuleNames, bucket.name) === true) { realtimeEnabled = true; } } } if (notificationData?.TopicConfigurations?.find((config) => config.Events.filter((event) => event.startsWith("s3:ObjectCreated:")).length > 0)) { realtimeEventNotificationEnablePossible = false; } if (notificationData?.QueueConfigurations?.find((config) => config.Events.filter((event) => event.startsWith("s3:ObjectCreated:")).length > 0)) { realtimeEventNotificationEnablePossible = false; } if (notificationData?.LambdaFunctionConfigurations?.find((config) => config.Events.filter((event) => event.startsWith("s3:ObjectCreated:")).length > 0)) { realtimeEventNotificationEnablePossible = false; } if (notificationData?.EventBridgeConfiguration !== void 0) { realtimeEventNotificationEnablePossible = false; } } catch (err) { console.log(err); bucket.errorMessage = `Can not get details for bucket ${bucket.name}: ${err.name}`; realtimeEventNotificationEnablePossible = false; } const scheduledStacksIncludesBucket = scheduledStacks.stacks.filter((scheduledStack) => includesBucket(scheduledStack, bucket.name)); if (scheduledStacksIncludesBucket.length > 0) { scheduledEnabled = true; scheduledStackId = scheduledStacksIncludesBucket[0].id; } const scheduledStacksHasBucket = scheduledStacks.stacks.filter((scheduledStack) => hasBucket(scheduledStack, bucket.name)); if (scheduledStacksHasBucket.length === 1) { scheduledEnabled = true; scheduledStackDisablePossible = true; scheduledStackId = scheduledStacksHasBucket[0].id; } return { ...bucket, realtimeEnabled, realtimeEventNotificationEnablePossible, realtimeEventNotificationDisablePossible, scheduledEnabled, scheduledStackDisablePossible, scheduledStackId }; } else { return null; } } async function getScheduledStacks(ssm, cloudformation, coreStackName) { const paginatorGetParametersByPath = await (0, import_client_ssm.paginateGetParametersByPath)({ client: ssm }, { Recursive: true, Path: `/bucketAV/${coreStackName}/AddOn/scheduled-bucket-scan/` }); const stacks = []; for await (const page of paginatorGetParametersByPath) { const scheduledStackNames = page.Parameters.filter((p) => p.Name.endsWith("/Version")).map((p) => p.Name.split("/")[5]); const describeStacksDataList = await Promise.all(scheduledStackNames.map((stackName) => cloudformation.send(new import_client_cloudformation.DescribeStacksCommand({ StackName: stackName })))); describeStacksDataList.forEach((describeStacksData) => { const stack = { name: describeStacksData.Stacks[0].StackName, id: describeStacksData.Stacks[0].StackId, params: describeStacksData.Stacks[0].Parameters?.reduce((acc, param) => { acc[param.ParameterKey] = param.ParameterValue; return acc; }, {}) || {}, outputs: describeStacksData.Stacks[0].Outputs?.reduce((acc, output) => { acc[output.OutputKey] = output.OutputValue; return acc; }, {}) || {} }; if (stack.params.BucketAVStackName === coreStackName && stack.outputs.AddOn === "scheduled-bucket-scan") { stacks.push(stack); } }); } return { stacks }; } async function getBucket(s3, ssm, cloudformation, sns2, eventbridge2, coreEventbridge, partition, region, accountId, organizationId, coreAccountId, coreStackName, scanQueueArn, bucketName, bucketRegion) { const scheduledStacks = await getScheduledStacks(ssm, cloudformation, coreStackName); const bucket = await enrichBucket(s3, sns2, eventbridge2, coreEventbridge, partition, region, accountId, organizationId, coreAccountId, coreStackName, scanQueueArn, scheduledStacks, bucketName, bucketRegion); if (bucket === null) { throw new Error("bucket region does not match region"); } else { return bucket; } } async function listBuckets(s3, ssm, cloudformation, sns2, eventbridge2, coreEventbridge, partition, region, accountId, organizationId, coreAccountId, coreStackName, scanQueueArn) { const lightBuckets = await listBucketsLight(s3, region, accountId); const scheduledStacks = await getScheduledStacks(ssm, cloudformation, coreStackName); const buckets = await Promise.all(lightBuckets.map((lightBucket) => enrichBucket(s3, sns2, eventbridge2, coreEventbridge, partition, region, accountId, organizationId, coreAccountId, coreStackName, scanQueueArn, scheduledStacks, lightBucket.name, lightBucket.region))); return buckets.filter((bucket) => bucket !== null); } async function fetchS3(defaultS32, dynamodb2, bucketCache, isCrossAccount, bucketName, bucketCacheTableName) { if (isCrossAccount === true) { const cachedS3 = bucketCache.get(bucketName); if (cachedS3 !== void 0) { return cachedS3; } else { const bucketCacheData = await dynamodb2.send(new import_client_dynamodb.GetItemCommand({ TableName: bucketCacheTableName, Key: { bucket_name: { S: bucketName } }, ConsistentRead: true })); if (bucketCacheData.Item === void 0) { console.log(`uncached S3 bucket ${bucketName}`); bucketCache.set(bucketName, defaultS32); return defaultS32; } else { if ("role_arn" in bucketCacheData.Item && "external_id" in bucketCacheData.Item) { const configuration = { apiVersion: "2006-03-01", credentials: (0, import_credential_providers.fromTemporaryCredentials)({ params: { ExternalId: bucketCacheData.Item.external_id.S, RoleArn: bucketCacheData.Item.role_arn.S, RoleSessionName: "bucketav" } }) }; if ("bucket_region" in bucketCacheData.Item) { configuration.region = bucketCacheData.Item.bucket_region.S; } const s3 = new import_client_s32.S3Client(configuration); bucketCache.set(bucketName, s3); return s3; } else { bucketCache.set(bucketName, defaultS32); return defaultS32; } } } } else { return defaultS32; } } function generateAccountConnectionId(partition, region, accountId) { return `${partition}:${region}:${accountId}`; } var MAX_S3_COPY_SIZE = 5 * 1024 * 1024 * 1024; function generateRoleArn(partition, accountId, roleName) { return `arn:${partition}:iam::${accountId}:role/${roleName}`; } function generateRoleArnFromItem(accountConnectionItem) { return generateRoleArn(accountConnectionItem.partition.S, accountConnectionItem.account_id.S, accountConnectionItem.role_name.S); } function generateExternalId(stackId) { return stackId.split("/")[2]; } function generateExternalIdFromItem(accountConnectionItem) { return generateExternalId(accountConnectionItem.stack_id.S); } async function fetchLatest() { const url = "https://bucketav-release-data.s3.eu-west-1.amazonaws.com/latest.json"; const res = await fetch(url); if (res.status !== 200) { console.log("request", url); console.log("response status", res.status); console.log("response", await res.text()); throw new Error("unexpected status code"); } return res.json(); } async function fetchOutdatedAddons(defaultCloudformation2, defaultSsm2, dynamodb2, platform, latest, isCrossAccount, corePartition, coreRegion, coreAccountId, coreStackName, accountConnectionTableName) { const outdatedAddons = []; const fn = async (ssm, cloudformation, partition, region, accountId) => { const cloudformaionDescribeStacksLimit = pLimit(8); const paginatorGetParametersByPath = await (0, import_client_ssm.paginateGetParametersByPath)({ client: ssm }, { Recursive: true, Path: `/bucketAV/${coreStackName}/AddOn/` }); for await (const page of paginatorGetParametersByPath) { const addons = page.Parameters.filter((p) => p.Name.endsWith("/Version")).map((p) => { const [, , , , addonType, addonStackName] = p.Name.split("/"); const addonVersion = p.Value; const addonId = `add-on-${addonType}-${platform}`; const addon = { type: addonType, partition, region, accountId, stackName: addonStackName, version: addonVersion, latestVersion: latest[addonId].version.substr(1), releaseNotesPageUrl: latest[addonId].releaseNotesPageUrl }; if ("template" in latest[addonId]) { addon.latestTemplateUrl = latest[addonId].template; } if ("templates" in latest[addonId]) { addon.latestTemplateUrls = latest[addonId].templates; } return addon; }); const innerOutdatedAddons = await Promise.all(addons.filter((addon) => addon.version !== addon.latestVersion).map((addon) => cloudformaionDescribeStacksLimit(async () => { const data = await cloudformation.send(new import_client_cloudformation.DescribeStacksCommand({ StackName: addon.stackName })).then((data2) => data2.Stacks[0]); addon.stackId = data.StackId; if (!("latestTemplateUrl" in addon)) { if ("latestTemplateUrls" in addon) { const engine = data?.Outputs?.find((output) => output.OutputKey === "Engine")?.OutputValue; const fulfillmentOption = data?.Outputs?.find((output) => output.OutputKey === "FulfillmentOption")?.OutputValue; addon.latestTemplateUrl = addon.latestTemplateUrls?.[engine]?.[fulfillmentOption]; delete addon.latestTemplateUrls; } else { throw new Error("missing latestTemplateUrl and latestTemplateUrls"); } } return addon; }))); outdatedAddons.push(...innerOutdatedAddons); } }; if (isCrossAccount === true) { await fn(defaultSsm2, defaultCloudformation2, corePartition, coreRegion, coreAccountId); const paginatorScan = await (0, import_client_dynamodb.paginateScan)({ client: dynamodb2 }, { TableName: accountConnectionTableName }); for await (const page of paginatorScan) { await Promise.all(page.Items.map((item) => { const externalId = generateExternalIdFromItem(item); const roleArn = generateRoleArnFromItem(item); const credentials = (0, import_credential_providers.fromTemporaryCredentials)({ params: { ExternalId: externalId, RoleArn: roleArn, RoleSessionName: "bucketav" } }); const ssm = new import_client_ssm.SSMClient({ apiVersion: "2014-11-06", credentials }); const cloudformation = new import_client_cloudformation.CloudFormationClient({ apiVersion: "2006-03-01", credentials, maxAttempts: 10 }); return fn(ssm, cloudformation, item.partition.S, item.region.S, item.account_id.S); })); } } else { await fn(defaultSsm2, defaultCloudformation2, corePartition, coreRegion, coreAccountId); } return outdatedAddons; } async function checkVersion(cloudformation, ssm, dynamodb2, platform, latest, isCrossAccount, corePartition, coreRegion, coreAccountId, coreStackName, accountConnectionTableName) { const describeStacksData = await cloudformation.send(new import_client_cloudformation.DescribeStacksCommand({ StackName: coreStackName })); const runningEngine = describeStacksData.Stacks[0].Outputs.find((output) => output.OutputKey === "Engine").OutputValue; const runningVersion = describeStacksData.Stacks[0].Outputs.find((output) => output.OutputKey === "Version").OutputValue; const runningFulfillmentOption = describeStacksData.Stacks[0].Outputs.find((output) => output.OutputKey === "FulfillmentOption").OutputValue; const outdatedAddons = await fetchOutdatedAddons(cloudformation, ssm, dynamodb2, platform, latest, isCrossAccount, corePartition, coreRegion, coreAccountId, coreStackName, accountConnectionTableName); const coreId = `core-${platform}-${runningEngine}`; const latestVersion = latest[coreId].version.substr(1); const latestTemplateUrl = latest[coreId].templates[runningFulfillmentOption]; return { runningVersion, latestVersion, latestTemplateUrl, outdatedAddons }; } // lambda/lib-dashboard.js var import_client_cloudwatch_logs = require("@aws-sdk/client-cloudwatch-logs"); var import_client_s34 = require("@aws-sdk/client-s3"); var import_client_ssm2 = require("@aws-sdk/client-ssm"); var import_client_s35 = require("@aws-sdk/client-s3"); var import_client_sfn = require("@aws-sdk/client-sfn"); var import_client_dynamodb3 = require("@aws-sdk/client-dynamodb"); // lambda/lib-refresh-bucket-cache.js var import_client_dynamodb2 = require("@aws-sdk/client-dynamodb"); var import_client_sns2 = require("@aws-sdk/client-sns"); var import_client_eventbridge2 = require("@aws-sdk/client-eventbridge"); var import_client_s33 = require("@aws-sdk/client-s3"); var import_credential_providers2 = require("@aws-sdk/credential-providers"); var TTL_IN_SECONDS = 3600; async function cacheBucket(dynamodb2, partition, region, accountId, { accountConnectionId, roleArn, externalId }, ttl, bucket, bucketCacheTableName) { const bucketCacheItem = { bucket_name: { S: bucket.name }, bucket_account_id: { S: bucket.accountId }, bucket_realtime_enabled: { BOOL: bucket.realtimeEnabled }, bucket_realtime_event_notification_disable_possible: { BOOL: bucket.realtimeEventNotificationDisablePossible }, bucket_realtime_event_notification_enable_possible: { BOOL: bucket.realtimeEventNotificationEnablePossible }, bucket_scheduled_enabled: { BOOL: bucket.scheduledEnabled }, bucket_scheduled_stack_disable_possible: { BOOL: bucket.scheduledStackDisablePossible }, partition: { S: partition }, region: { S: region }, account_id: { S: accountId }, ttl: { N: ttl.toString() } }; if (bucket.organizationId !== void 0) { bucketCacheItem.bucket_organization_id = { S: bucket.organizationId }; } if (accountConnectionId !== void 0) { bucketCacheItem.account_connection_id = { S: accountConnectionId }; bucketCacheItem.role_arn = { S: roleArn }; bucketCacheItem.external_id = { S: externalId }; } if (bucket.errorMessage !== void 0) { bucketCacheItem.bucket_error_message = { S: bucket.errorMessage }; } if (bucket.region !== void 0) { bucketCacheItem.bucket_region = { S: bucket.region }; } if (bucket.scheduledStackId !== void 0) { bucketCacheItem.bucket_scheduled_stack_id = { S: bucket.scheduledStackId }; } await dynamodb2.send(new import_client_dynamodb2.PutItemCommand({ TableName: bucketCacheTableName, Item: bucketCacheItem })); } function getCredentials(accountConnectionId, roleArn, externalId) { if (accountConnectionId !== void 0 && roleArn !== void 0 && externalId !== void 0) { return (0, import_credential_providers2.fromTemporaryCredentials)({ params: { RoleArn: roleArn, ExternalId: externalId, RoleSessionName: "bucketav", DurationSeconds: 3600 } }); } else { return (0, import_credential_providers2.fromNodeProviderChain)(); } } function generateTtl() { return Math.floor(Date.now() / 1e3) + TTL_IN_SECONDS; } async function refreshBucket(dynamodb2, ssm, cloudformation, partition, region, accountId, coreAccountId, coreStackName, scanQueueArn, bucketName, bucketRegion, bucketCacheTableName, accountConnectionTableName) { const accountConnectionId = generateAccountConnectionId(partition, region, accountId); const accountConnectionData = await dynamodb2.send(new import_client_dynamodb2.GetItemCommand({ TableName: accountConnectionTableName, Key: { account_connection_id: { S: accountConnectionId } }, ConsistentRead: true })); let accountConnection = {}; let credentials; if (accountConnectionData.Item !== void 0) { const roleArn = generateRoleArnFromItem(accountConnectionData.Item); const externalId = generateExternalIdFromItem(accountConnectionData.Item); credentials = getCredentials(accountConnectionId, roleArn, externalId); accountConnection.accountConnectionId = accountConnectionId; accountConnection.roleArn = roleArn; accountConnection.externalId = externalId; accountConnection.organizationId = accountConnectionData.Item.organization_id?.S; } else { accountConnection.organizationId = await fetchCachedOrganizationId(ssm, coreStackName); credentials = getCredentials(void 0, void 0, void 0); } const s3 = new import_client_s33.S3Client({ apiVersion: "2006-03-01", credentials }); const sns2 = new import_client_sns2.SNSClient({ apiVersion: "2010-03-31", credentials }); const eventbridge2 = new import_client_eventbridge2.EventBridgeClient({ apiVersion: "2015-10-07", credentials }); const coreEventbridge = new import_client_eventbridge2.EventBridgeClient({ apiVersion: "2015-10-07" }); const bucket = await getBucket(s3, ssm, cloudformation, sns2, eventbridge2, coreEventbridge, partition, region, accountId, accountConnection.organizationId, coreAccountId, coreStackName, scanQueueArn, bucketName, bucketRegion); await cacheBucket(dynamodb2, partition, region, accountId, accountConnection, generateTtl(), bucket, bucketCacheTableName); } // lambda/lib-dashboard.js var SCAN_RESULTS_MESSAGE_PARSER_REGEX = /s3:\/\/(?[^\\/]+)\/(?.*)\s(?(is clean|is infected|could not be scanned because it is|does no longer exist|not downloadable|access denied))/; var LOGS_QUERY_CHECKS_INTERVAL = 500; var MAX_LOGS_QUERY_CHECKS = 20; var QUERY_SCAN_RESULTS_LIMIT = 1e4; var sortConstants = { DESC: "desc", ASC: "asc" }; var filterConstants = { DEFAULT: "default", ENABLED: "enabled", DISABLED: "disabled" }; var statusFilterConstants = { ALL: "all", CLEAN: "clean", INFECTED: "infected", UNSCANNABLE: "unscannable" }; function createUrl(region, templateUrl, stackName, params) { let url = `https://console.aws.amazon.com/cloudformation/home?region=${region}#/stacks/create/review?templateURL=${encodeURIComponent(templateUrl)}&stackName=${encodeURIComponent(stackName)}`; Object.keys(params).forEach((key) => { url += `¶m_${key}=${encodeURIComponent(params[key])}`; }); return url; } function detailsUrl(region, stackId) { return `https://console.aws.amazon.com/cloudformation/home?region=${region}#/stacks/parameters?stackId=${encodeURIComponent(stackId)}`; } function createInstructions(coreAccountId, region, optionalAccountId, templateUrl, stackName, params) { let html = "
    "; if (optionalAccountId !== coreAccountId) { if (optionalAccountId === void 0) { html += "
  1. Login to the new AWS account in a fresh browser session.
  2. "; } else { html += `
  3. Login to AWS account ${optionalAccountId} in a fresh browser session.
  4. `; } } html += `
  5. Open AWS CloudFormation.
  6. `; html += "
  7. Review the parameters.
  8. "; html += "
  9. Scroll to the bottom of the page.
  10. "; html += "
  11. Enable I acknowledge that AWS CloudFormation might create IAM resources.
  12. "; html += "
  13. Click Create stack.
  14. "; html += "
"; return html; } function deleteInstructions(coreAccountId, region, accountId, stackId) { let html = "
    "; if (accountId !== coreAccountId) { html += `
  1. Login to AWS account ${accountId} in fresh browser session.
  2. `; } html += `
  3. Open AWS CloudFormation.
  4. `; html += "
  5. Click Delete.
  6. "; html += "
"; return html; } function updateUrl(region, stackId, optionalTemplateUrl) { if (optionalTemplateUrl !== void 0) { return `https://console.aws.amazon.com/cloudformation/home?region=${region}#/stacks/update?stackId=${encodeURIComponent(stackId)}&templateURL=${optionalTemplateUrl}`; } else { return `https://console.aws.amazon.com/cloudformation/home?region=${region}#/stacks/update?stackId=${encodeURIComponent(stackId)}`; } } function updateInstructions(coreAccountId, region, accountId, stackId, optionalTemplateUrl, optionalParameterInstructions) { let html = "
    "; if (accountId !== coreAccountId) { html += `
  1. Login to AWS account ${accountId} in fresh browser session.
  2. `; } html += `
  3. Open AWS CloudFormation.
  4. `; html += "
  5. Click Next.
  6. "; if (optionalParameterInstructions !== void 0) { html += `
  7. ${optionalParameterInstructions}
  8. `; } html += "
  9. Click Next.
  10. "; html += "
  11. Select I acknowledge that AWS CloudFormation might create IAM resources and click Next.
  12. "; html += "
  13. Click Submit.
  14. "; html += "
"; return html; } async function update(defaultCloudformation2, defaultSsm2, dynamodb2, platform, latest, crossAccount, awsPartition, awsRegion, awsAccountId, coreStackName, coreStackId, engine, accountConnectionTableName) { const { runningVersion, latestVersion, latestTemplateUrl, outdatedAddons } = await checkVersion(defaultCloudformation2, defaultSsm2, dynamodb2, platform, latest, crossAccount, awsPartition, awsRegion, awsAccountId, coreStackName, accountConnectionTableName); let html = 'Monthly digest of security updates, new capabilities, and best practices: Subscribe to the bucketAV newsletter!'; html += "
"; if (latestVersion !== runningVersion) { html += "

\u26A0\uFE0F bucketAV requires an update.

"; html += ''; html += ""; html += ""; html += ""; html += ""; html += ""; html += ""; html += ""; html += ""; html += ""; html += ``; html += ``; html += '"; html += ""; html += ""; html += "
CloudFormation
Stack Name
Version 
${coreStackName}Running: ${runningVersion}
Latest: ${latestVersion}
Update'; html += ''; html += "

Update bucketAV

"; html += "

bucketAV supports updates without downtime. You don\u2019t need to be afraid of updating bucketAV, even when files are scanned.

"; html += updateInstructions(awsAccountId, awsRegion, awsAccountId, coreStackId, latestTemplateUrl); html += `

Release Notes`; html += "
"; } else { html += "

\u2705 bucketAV is up-to-date.

"; } if (latestVersion !== runningVersion && outdatedAddons.length > 0) { html += "
"; } if (outdatedAddons.length > 0) { html += "

\u26A0\uFE0F Add-Ons require updates*.

"; html += ''; html += ""; html += ""; html += ""; html += ""; html += ""; html += ""; html += ""; html += ""; html += ""; html += ""; for (const outdatedAddon of outdatedAddons) { html += ""; html += ``; html += ``; html += ``; html += ``; html += '"; html += ""; } html += ""; html += "
Add-OnAWS AccountCloudFormation
Stack Name
Version 
${outdatedAddon.type}${outdatedAddon.accountId}${outdatedAddon.stackName}Running: ${outdatedAddon.version}
Latest: ${outdatedAddon.latestVersion}
Update'; html += ''; html += "

Update Add-On

"; html += `

To update Add-On ${outdatedAddon.type} (stack name ${outdatedAddon.stackName}) in AWS account ${outdatedAddon.accountId}:

`; html += updateInstructions(awsAccountId, outdatedAddon.region, outdatedAddon.accountId, outdatedAddon.stackId, outdatedAddon.latestTemplateUrl); html += `

Release Notes`; html += "
"; } else { html += "

\u2705 Add-Ons are up-to-date*.

"; } html += '

* Add-Ons with service discovery are included in the out-of-date check. Please update older Add-Ons manually.

'; return html; } function htmlRealtime(bucket, lambdaArn) { if ("errorMessage" in bucket) { return `\u26A0\uFE0F Error (Details)

Real-time file scan

An error occurred: ${bucket.errorMessage}.
`; } else if (bucket.realtimeEnabled === true) { if (bucket.realtimeEventNotificationDisablePossible === true) { return `\u2705 Disable{"action": "disableEventNotification", "bucketName": "${bucket.name}", "bucketRegion": "${bucket.region}", "bucketAccountId": "${bucket.accountId}"}`; } else { return '\u2705 Disable

Real-time file scan

It is not possible to disable real-time file scanning automatically. To manually disable real-time file scanning, please follow our documentation.
'; } } else { if (bucket.realtimeEventNotificationEnablePossible === true) { return `\u274C Enable{"action": "enableEventNotification", "bucketName": "${bucket.name}", "bucketRegion": "${bucket.region}", "bucketAccountId": "${bucket.accountId}"}`; } else { return '\u274C Enable

Real-time file scan

It is not possible to enable real-time file scanning automatically because of an existing S3 Event Notification or EventBridge configuration. To manually enable real-time file scanning, please follow our documentation.
'; } } } function htmlScheduled(bucket, region, coreAccountId, coreStackName, scheduledTemplateUrl) { let scheduledStackName = "bucketav-scheduled-bucket-scan-"; scheduledStackName += bucket.name.replaceAll(".", "D"); if ("errorMessage" in bucket) { return `\u26A0\uFE0F Error (Details)

Scheduled bucket scan

An error occurred: ${bucket.errorMessage}.
`; } else if (bucket.scheduledEnabled === true) { if (bucket.scheduledStackDisablePossible === true) { let html = '\u2705 Disable'; html += ''; html += "

Scheduled bucket scan

"; html += `

To disable scheduled bucket scanning for bucket ${bucket.name}:

`; html += deleteInstructions(coreAccountId, region, coreAccountId, bucket.scheduledStackId); html += "
"; return html; } else { let html = '\u2705 Disable'; html += ''; html += "

Scheduled bucket scan

"; html += `

It is not yet possible to disable scheduled bucket scanning for bucket ${bucket.name} automatically. `; if (bucket.scheduledStackId) { html += `The CloudFormation stack ${bucket.scheduledStackId} scans this bucket.`; } html += 'To manually disable scheduled bucket scanning, please follow our documentation.

'; html += "
"; return html; } } else { let html = '\u274C Enable'; html += ''; html += "

Scheduled bucket scan

"; html += `

To enable scheduled bucket scanning for bucket ${bucket.name}:

`; html += createInstructions(coreAccountId, region, coreAccountId, scheduledTemplateUrl, scheduledStackName, { BucketAVStackName: coreStackName, BucketName: bucket.name }); html += "
"; return html; } } async function bucketsBase(event, context, buckets, scheduledTemplateUrl, platform, awsRegion, awsAccountId, crossAccount, coreStackName) { const bucketSearch = event?.widgetContext?.forms?.all?.bucketSearch || event.bucketSearch || ""; if (bucketSearch !== "") { buckets = buckets.filter((bucket) => bucket.name.includes(bucketSearch)); } const accountSearch = event?.widgetContext?.forms?.all?.accountSearch || event.accountSearch || ""; if (accountSearch !== "") { buckets = buckets.filter((bucket) => bucket.accountId.includes(accountSearch)); } buckets = filterByRealtimeScan(buckets, event.widgetContext.forms?.all?.realtimeScanFilter); buckets = filterByScheduledScan(buckets, event.widgetContext.forms?.all?.scheduledScanFilter); buckets.sort((bucket1, bucket2) => bucket1.name.localeCompare(bucket2.name)); const page = "page" in event ? event.page : 0; const limit = 100; const pageStartInclusive = page * limit; const pageEndExclusive = pageStartInclusive + limit; const tbody = buckets.slice(pageStartInclusive, pageEndExclusive).map((bucket) => { let html2 = ""; html2 += `${bucket.name}`; if (platform === "aws") { html2 += `${bucket.accountId}`; } html2 += `${htmlRealtime(bucket, context.invokedFunctionArn)}`; html2 += `${htmlScheduled(bucket, awsRegion, awsAccountId, coreStackName, scheduledTemplateUrl)}`; html2 += ""; return html2; }).join(""); let html = "
"; html += ''; if (platform === "aws") { if (crossAccount) { html += ``; } else { html += ``; } } html += ""; html += ""; html += ``; if (platform === "aws") { html += ``; } const realtimeScanSelect = buildFilterSelect("realtimeScanFilter", "Real-time file scan", event.widgetContext.forms?.all?.realtimeScanFilter); html += ``; const scheduledScanSelect = buildFilterSelect("scheduledScanFilter", "Scheduled bucket scan", event.widgetContext.forms?.all?.scheduledScanFilter); html += ``; html += ""; html += ""; html += `${tbody}`; if (platform === "aws" && crossAccount) { html += ""; html += ""; html += '"; html += ""; html += ""; } html += "
Includes buckets from AWS region ${awsRegion}.
Buckets are cached and may be stale (Refresh bucket cache).{"action": "refreshBucketCache"}
Includes buckets from AWS region ${awsRegion}.
 \u{1F50D}{} \u{1F50D}{}${realtimeScanSelect} \u{1F50D}{}${scheduledScanSelect} \u{1F50D}{}
'; if (pageStartInclusive > 0) { html += `Previous{"page": ${page - 1}}`; } if (pageStartInclusive > 0 && pageEndExclusive < buckets.length) { html += " | "; } if (pageEndExclusive < buckets.length) { html += `Next{"page": ${page + 1}}`; } html += "
"; html += "
"; return html; } async function bucketsAWS(defaultS32, defaultSsm2, defaultCloudformation2, sns2, eventbridge2, dynamodb2, sfn2, event, context, scheduledTemplateUrl, awsPartition, awsRegion, awsAccountId, crossAccount, coreStackName, scanQueueArn, refreshBucketCacheStateMachineArn, bucketCacheTableName, accountConnectionTableName) { if (event.action === "enableEventNotification") { const s3 = await fetchS3(defaultS32, dynamodb2, /* @__PURE__ */ new Map(), crossAccount, event.bucketName, bucketCacheTableName); const notificationConfiguration = await s3.send(new import_client_s35.GetBucketNotificationConfigurationCommand({ Bucket: event.bucketName, ExpectedBucketOwner: event.bucketAccountId })); if (!("QueueConfigurations" in notificationConfiguration)) { notificationConfiguration.QueueConfigurations = []; } notificationConfiguration.QueueConfigurations.push({ Id: "bucketav", Events: ["s3:ObjectCreated:*"], QueueArn: scanQueueArn }); await s3.send(new import_client_s35.PutBucketNotificationConfigurationCommand({ Bucket: event.bucketName, NotificationConfiguration: notificationConfiguration, ExpectedBucketOwner: event.bucketAccountId })); if (crossAccount) { await refreshBucket(dynamodb2, defaultSsm2, defaultCloudformation2, awsPartition, event.bucketRegion, event.bucketAccountId, awsAccountId, coreStackName, scanQueueArn, event.bucketName, event.bucketRegion, bucketCacheTableName, accountConnectionTableName); } } else if (event.action === "disableEventNotification") { const s3 = await fetchS3(defaultS32, dynamodb2, /* @__PURE__ */ new Map(), crossAccount, event.bucketName, bucketCacheTableName); const notificationConfiguration = await s3.send(new import_client_s35.GetBucketNotificationConfigurationCommand({ Bucket: event.bucketName, ExpectedBucketOwner: event.bucketAccountId })); notificationConfiguration.QueueConfigurations = notificationConfiguration.QueueConfigurations.filter((config) => !(config.QueueArn === scanQueueArn && config.Events.includes("s3:ObjectCreated:*"))); await s3.send(new import_client_s35.PutBucketNotificationConfigurationCommand({ Bucket: event.bucketName, NotificationConfiguration: notificationConfiguration, ExpectedBucketOwner: event.bucketAccountId })); if (crossAccount) { await refreshBucket(dynamodb2, defaultSsm2, defaultCloudformation2, awsPartition, event.bucketRegion, event.bucketAccountId, awsAccountId, coreStackName, scanQueueArn, event.bucketName, event.bucketRegion, bucketCacheTableName, accountConnectionTableName); } } else if (event.action === "refreshBucketCache" && crossAccount) { await sfn2.send(new import_client_sfn.StartExecutionCommand({ stateMachineArn: refreshBucketCacheStateMachineArn })); return `

\u{1F504} Bucket cache refresh initiated. Please wait a minute and then reload.{}

`; } const buckets = crossAccount ? await listCachedBuckets(dynamodb2, bucketCacheTableName) : await listBuckets(defaultS32, defaultSsm2, defaultCloudformation2, sns2, eventbridge2, eventbridge2, awsPartition, awsRegion, awsAccountId, void 0, awsAccountId, coreStackName, scanQueueArn); return bucketsBase(event, context, buckets, scheduledTemplateUrl, "aws", awsRegion, awsAccountId, crossAccount, coreStackName); } async function accounts(dynamodb2, latest, awsRegion, awsAccountId, crossAccount, coreStackName, coreStackId, accountConnectionTableName) { if (crossAccount) { const accountConnectionTemplateUrl = latest["add-on-account-connection-aws"].template; const paginatorScan = await (0, import_client_dynamodb3.paginateScan)({ client: dynamodb2 }, { TableName: accountConnectionTableName }); const accounts2 = []; for await (const page of paginatorScan) { page.Items.forEach((item) => { accounts2.push({ accountId: item.account_id.S, region: item.region.S, stackId: item.stack_id.S }); }); } accounts2.sort((account1, account2) => account1.accountId.localeCompare(account2.accountId)); const tbody = accounts2.map((account) => { let html2 = ""; html2 += `${account.accountId}`; html2 += 'Disconnect'; html2 += ''; html2 += "

Disconnect

"; html2 += `

To disconnect AWS account ${account.accountId}:

`; html2 += deleteInstructions(awsAccountId, account.region, account.accountId, account.stackId); html2 += "
"; html2 += ""; html2 += ""; return html2; }).join(""); let html = ''; html += ""; html += ""; html += ""; html += ""; html += ""; html += ""; html += ""; html += ""; html += ``; html += ""; html += ""; html += tbody; html += ""; html += ""; html += ""; html += ""; html += '"; html += ""; html += ""; html += "
AWS account 
${awsAccountId}Runs bucketAV, always connected;
 Connect AWS account'; html += ''; html += "

Connect

"; html += "

Connecting a new AWS account with bucketAV is a two-step process:

"; html += "
    "; html += "
  1. Configure bucketAV to trust the new AWS account.
  2. "; html += "
  3. Connect the new AWS account to bucketAV.
  4. "; html += "
"; html += "

Trust AWS account

"; html += "

To trust the new AWS account, in this AWS account:

"; html += "
    "; html += updateInstructions(awsAccountId, awsRegion, awsAccountId, coreStackId, void 0, 'Modify the AWSOrganizationRestriction or AWSAccountRestriction parameter. Learn more!'); html += "
"; html += "

Connect AWS account

"; html += '

If you plan to connect many AWS accounts we recommend to use AWS CloudFormation StackSets. Learn more!

'; html += "

To connect the new AWS account:

"; html += createInstructions(awsAccountId, awsRegion, void 0, accountConnectionTemplateUrl, "bucketav-account-connection", { BucketAVStackName: coreStackName, BucketAVAccountId: awsAccountId }); html += "
"; html += "
"; return html; } else { return 'You can use bucketAV with multiple AWS accounts. Learn more!'; } } async function startQuery(client, logGroupName, logStreamSuffix, startTimeMillis, endTimeMillis, options = {}) { const bucketFilter = options.bucketSearch ? `| filter bucketName like '${options.bucketSearch}'` : ""; const objectFilter = options.objectSearch ? `| filter objectKey like '${options.objectSearch}'` : ""; const statusFilter = options.statusFilter && options.statusFilter !== statusFilterConstants.ALL ? `| filter status like '${options.statusFilter}'` : ""; const sort = options.sort || sortConstants.DESC; const query = `fields @message | filter ${logStreamSuffix ? `@logStream like '${logStreamSuffix}' and ` : ""}@message like 's3://' | parse @message /${SCAN_RESULTS_MESSAGE_PARSER_REGEX.source}/ | fields replace(rawStatus, 'is clean', 'clean') as status2 | fields replace(status2, 'is infected', 'infected') as status3 | fields replace(status3, 'could not be scanned because it is', 'unscannable') as status4 | fields replace(status4, 'does no longer exist', 'unscannable') as status5 | fields replace(status5, 'not downloadable', 'unscannable') as status6 | fields replace(status6, 'access denied', 'unscannable') as status | filter ispresent(bucketName) and ispresent(objectKey) and ispresent(status) ${bucketFilter} ${objectFilter} ${statusFilter} | display @timestamp, @message, bucketName, objectKey, status | sort @timestamp ${sort} | limit ${QUERY_SCAN_RESULTS_LIMIT}`; console.debug(`using query for scan results: ${query}`); const startTime = Math.floor(startTimeMillis / 1e3); const endTime = Math.floor(endTimeMillis / 1e3); const { queryId } = await client.send(new import_client_cloudwatch_logs.StartQueryCommand({ logGroupName, queryLanguage: "CWLI", queryString: query, startTime, endTime })); return queryId; } async function waitForQueryResults(client, queryId) { let isDone = false; let retryCount = 0; let result; do { if (retryCount > 0) { await new Promise((resolve) => setTimeout(resolve, LOGS_QUERY_CHECKS_INTERVAL)); console.debug(`#${retryCount} retry to get query results for ${queryId}`); } result = await client.send(new import_client_cloudwatch_logs.GetQueryResultsCommand({ queryId })); isDone = result.status !== "Running" && result.status !== "Scheduled"; } while (!isDone && retryCount++ < MAX_LOGS_QUERY_CHECKS); if (!isDone && retryCount >= MAX_LOGS_QUERY_CHECKS) { throw new Error(`RUNNING#${queryId}`); } if (["Cancelled", "Failed", "Timeout", "Unknown"].includes(result.status)) { throw new Error(`Could not get scan results: ${result.status}`); } return result.results; } async function queryScanResults(client, logGroupName, logStreamSuffix, startTimeMillis, endTimeMillis, options) { const queryId = options?.queryId || await startQuery(client, logGroupName, logStreamSuffix, startTimeMillis, endTimeMillis, options); const results = await waitForQueryResults(client, queryId); return results.map((logRow) => ({ timestamp: logRow.find((e) => e.field === "@timestamp")?.value, rawMessage: logRow.find((e) => e.field === "@message")?.value, bucketName: logRow.find((e) => e.field === "bucketName")?.value, objectKey: logRow.find((e) => e.field === "objectKey")?.value?.replace(/\s[^\s]*$/, ""), // Due to limitatiosn with regular expresion, remove last whitespace and everything afterwards from object key, as it is most likely the object version. status: logRow.find((e) => e.field === "status")?.value })); } function buildFilterSelectOption(label, value, isSelected) { return ``; } function buildFilterSelect(selectName, title, selectedOptionValue) { let select = `"; return select; } function buildScanStatusDropdown(selectName, selectedOptionValue) { let select = `"; return select; } function buildTextSearchHeader(context, columnName, placeholder, inputValue) { return ` \u{1F50D}{}`; } function createHtmlLink(link, text, openNewTab = true) { return `${text}`; } function filterByRealtimeScan(buckets, filterValue) { if (!!filterValue && filterValue !== filterConstants.DEFAULT) { const isEnabled = filterValue === filterConstants.ENABLED; return buckets.filter((bucket) => bucket.realtimeEnabled === isEnabled); } return buckets; } function filterByScheduledScan(buckets, filterValue) { if (!!filterValue && filterValue !== filterConstants.DEFAULT) { const isEnabled = filterValue === filterConstants.ENABLED; return buckets.filter((bucket) => bucket.scheduledEnabled === isEnabled); } return buckets; } async function getAllReportingBuckets(ssmClient, coreStackName) { const bucketNames = []; const paginator = (0, import_client_ssm2.paginateGetParametersByPath)({ client: ssmClient }, { Path: `/bucketAV/${coreStackName}/AddOn/reporting/`, Recursive: true }); for await (const page of paginator) { const bucketNamesOfResponse = page.Parameters?.filter((entry) => entry.Name.endsWith("/BucketName"))?.map((entry) => entry.Value); bucketNames.push(...bucketNamesOfResponse || []); } return bucketNames; } async function getStackInfoOfBucket(s3Client, bucketName) { const response = await s3Client.send(new import_client_s34.GetBucketTaggingCommand({ Bucket: bucketName })); const stackId = response.TagSet?.find((tag) => tag.Key === "aws:cloudformation:stack-id")?.Value; const stackName = response.TagSet?.find((tag) => tag.Key === "aws:cloudformation:stack-name")?.Value; if (!stackId || !stackName) { throw new Error(`missing CloudFormation tags for stack information on bucket ${bucketName}`); } return { stackId, stackName }; } async function getReportKeysOfBucket(s3Client, bucketName, startDate, endDate, reportTypeSearch = "") { const prefix = "report/html/bucketav_report_"; const reports2 = []; let continuationToken = void 0; do { const response = await s3Client.send(new import_client_s34.ListObjectsV2Command({ Bucket: bucketName, Prefix: prefix, ContinuationToken: continuationToken })); continuationToken = response.NextContinuationToken; if (!response.Contents) { continue; } for (const obj of response.Contents) { const lastModified = new Date(obj.LastModified); if (!(lastModified.getTime() >= startDate.getTime() && lastModified.getTime() <= endDate.getTime())) { continue; } const key = obj.Key; const type = key.replace(prefix, "").split("_")[0]; if (reportTypeSearch && !type.includes(reportTypeSearch)) { continue; } reports2.push({ bucket: bucketName, key, type, lastModified }); } } while (continuationToken !== void 0); return reports2; } async function getAllReports(s3Client, reportingBuckets, startTimeMillis, endTimeMillis, stackSearch = "", reportTypeSearch = "") { const startDate = new Date(startTimeMillis); const endDate = new Date(endTimeMillis); const reports2 = []; for (const bucket of reportingBuckets) { const { stackId, stackName } = await getStackInfoOfBucket(s3Client, bucket); if (stackSearch && !stackName.includes(stackSearch)) { continue; } const bucketReports = await getReportKeysOfBucket(s3Client, bucket, startDate, endDate, reportTypeSearch); reports2.push(...bucketReports.map((report) => ({ ...report, stackId, stackName }))); } return reports2; } function sortByLastModified(sort) { return (one, two) => { if (sort === sortConstants.DESC) { return two.lastModified.getTime() - one.lastModified.getTime(); } return one.lastModified.getTime() - two.lastModified.getTime(); }; } function createLinkToObject(region, bucketName, objectKeyOrPrefix) { return `https://s3.console.aws.amazon.com/s3/object/${encodeURIComponent(bucketName)}?region=${encodeURIComponent(region)}&prefix=${encodeURIComponent(objectKeyOrPrefix)}`; } function createLinkToCloudFormationStack(region, stackId) { return `https://console.aws.amazon.com/cloudformation/home?region=${encodeURIComponent(region)}#/stacks/stackinfo?stackId=${encodeURIComponent(stackId)}`; } async function scanResults(cwLogs2, event, context, logGroupName, logStreamSuffix = void 0) { const startTimeMillis = event.widgetContext.timeRange?.start; const endTimeMillis = event.widgetContext.timeRange?.end; const queryId = event.widgetContext.forms?.all?.queryId; const bucketSearch = event.widgetContext.forms?.all?.bucketSearch || ""; const objectSearch = event?.widgetContext.forms?.all?.objectSearch || ""; const statusFilter = event?.widgetContext.forms?.all?.statusFilter || ""; const sort = event.sort || event.widgetContext.forms?.all?.sort || sortConstants.DESC; const hiddenSortInput = ``; let hiddenQueryIdInput = ''; let tbody; try { const scanResults2 = await queryScanResults(cwLogs2, logGroupName, logStreamSuffix, startTimeMillis, endTimeMillis, { queryId, bucketSearch, objectSearch, statusFilter, sort }); tbody = scanResults2.map((result) => `${result.timestamp}${result.bucketName}${result.objectKey}${result.status}`).join(""); } catch (err) { if (err.message.startsWith("RUNNING#")) { const currentQueryId = err.message.replace("RUNNING#", ""); tbody = `Query is still running... \u{1F504}{}`; hiddenQueryIdInput = ``; } else { tbody = `${err.message}`; } } const otherSort = sort === sortConstants.DESC ? sortConstants.ASC : sortConstants.DESC; let html = `${hiddenQueryIdInput}${hiddenSortInput}`; html += ""; html += ""; html += ``; html += buildTextSearchHeader(context, "bucketSearch", "Bucket", bucketSearch); html += buildTextSearchHeader(context, "objectSearch", "Object", objectSearch); html += ``; html += ""; html += ""; html += ""; html += tbody; html += ""; html += "
Timestamp ${sort === sortConstants.DESC ? "\u{1F53C}" : "\u{1F53D}"}{"sort": "${otherSort}"}${buildScanStatusDropdown("statusFilter", statusFilter)} \u{1F50D}{}
"; return html; } async function reports(defaultS32, defaultSsm2, event, context, awsRegion, coreStackName) { const startTimeMillis = event.widgetContext.timeRange?.start; const endTimeMillis = event.widgetContext.timeRange?.end; const stackSearch = event.widgetContext.forms?.all?.stackSearch || ""; const reportTypeSearch = event.widgetContext.forms?.all?.reportTypeSearch || ""; const sort = event.sort || event.widgetContext.forms?.all?.sort || sortConstants.DESC; const hiddenSortInput = ``; const reportingBuckets = await getAllReportingBuckets(defaultSsm2, coreStackName); let tbody = ""; if (reportingBuckets.length === 0) { tbody = 'Reporting add-on is not installed. Follow the setup instructions to see your reports.'; } else { const allReports = await getAllReports(defaultS32, reportingBuckets, startTimeMillis, endTimeMillis, stackSearch, reportTypeSearch); tbody = allReports.sort(sortByLastModified(sort)).map((report) => `${report.lastModified.toUTCString()}${createHtmlLink(createLinkToCloudFormationStack(awsRegion, report.stackId), report.stackName)}${report.type}${createHtmlLink(createLinkToObject(awsRegion, report.bucket, report.key), "\u{1F4C1}")}`).join(""); } const otherSort = sort === sortConstants.DESC ? sortConstants.ASC : sortConstants.DESC; let html = `${hiddenSortInput}`; html += ""; html += ""; html += ``; html += buildTextSearchHeader(context, "stackSearch", "Stack Name", stackSearch); html += buildTextSearchHeader(context, "reportTypeSearch", "Type", reportTypeSearch); html += ""; html += ""; html += ""; html += ""; html += tbody; html += ""; html += "
Creation date ${sort === sortConstants.DESC ? "\u{1F53C}" : "\u{1F53D}"}{"sort": "${otherSort}"} 
"; return html; } // lambda/dashboard-aws.js var defaultCloudformation = new import_client_cloudformation2.CloudFormationClient({ apiVersion: "2006-03-01", maxAttempts: 10 }); var defaultS3 = new import_client_s36.S3Client({ apiVersion: "2006-03-01" }); var defaultSsm = new import_client_ssm3.SSMClient({ apiVersion: "2014-11-06" }); var dynamodb = new import_client_dynamodb4.DynamoDBClient({ apiVersion: "2012-08-10" }); var sns = new import_client_sns3.SNSClient({ apiVersion: "2010-03-31" }); var eventbridge = new import_client_eventbridge3.EventBridgeClient({ apiVersion: "2015-10-07" }); var sfn = new import_client_sfn2.SFNClient({ apiVersion: "2016-11-23" }); var cwLogs = new import_client_cloudwatch_logs2.CloudWatchLogsClient({ apiVersion: "2014-03-28" }); var CORE_STACK_NAME = process.env.CORE_STACK_NAME; var CORE_STACK_ID = process.env.CORE_STACK_ID; var ENGINE = process.env.ENGINE; var AWS_PARTITION = process.env.AWS_PARTITION; var AWS_REGION = process.env.AWS_REGION; var AWS_ACCOUNT_ID = process.env.AWS_ACCOUNT_ID; var SCAN_LOG_GROUP_NAME = process.env.SCAN_LOG_GROUP_NAME; var SCAN_LOG_STREAM_SUFFIX = process.env.SCAN_LOG_STREAM_SUFFIX; var SCAN_QUEUE_ARN = process.env.SCAN_QUEUE_ARN; var CROSS_ACCOUNT = process.env.CROSS_ACCOUNT === "true"; var MULTI_DEPLOYMENT = process.env.MULTI_DEPLOYMENT === "true"; var REFRESH_BUCKET_CACHE_STATE_MACHINE_ARN = process.env.REFRESH_BUCKET_CACHE_STATE_MACHINE_ARN; var NA_MULTI = "Not available. Please check the multi/parent dashboard!"; async function handler(event, context) { console.log(`Invoke: ${JSON.stringify(event)} ${JSON.stringify(context)}`); const accountConnectionTableName = `${CORE_STACK_NAME}-AccountConnection`; const latest = await fetchLatest(); const scheduledTemplateUrl = latest["add-on-scheduled-bucket-scan-aws"].template; if (event.widgetContext.params.view === "update") { if (MULTI_DEPLOYMENT === false) { return await update(defaultCloudformation, defaultSsm, dynamodb, "aws", latest, CROSS_ACCOUNT, AWS_PARTITION, AWS_REGION, AWS_ACCOUNT_ID, CORE_STACK_NAME, CORE_STACK_ID, ENGINE, accountConnectionTableName); } else { return NA_MULTI; } } else if (event.widgetContext.params.view === "buckets") { if (MULTI_DEPLOYMENT === false) { return await bucketsAWS(defaultS3, defaultSsm, defaultCloudformation, sns, eventbridge, dynamodb, sfn, event, context, scheduledTemplateUrl, AWS_PARTITION, AWS_REGION, AWS_ACCOUNT_ID, CROSS_ACCOUNT, CORE_STACK_NAME, SCAN_QUEUE_ARN, REFRESH_BUCKET_CACHE_STATE_MACHINE_ARN, `${CORE_STACK_NAME}-BucketCache`, accountConnectionTableName); } else { return NA_MULTI; } } else if (event.widgetContext.params.view === "accounts") { if (MULTI_DEPLOYMENT === false) { return await accounts(dynamodb, latest, AWS_REGION, AWS_ACCOUNT_ID, CROSS_ACCOUNT, CORE_STACK_NAME, CORE_STACK_ID, accountConnectionTableName); } else { return NA_MULTI; } } else if (event.widgetContext.params.view === "scanResults") { return await scanResults(cwLogs, event, context, SCAN_LOG_GROUP_NAME, SCAN_LOG_STREAM_SUFFIX); } else if (event.widgetContext.params.view === "reports") { if (MULTI_DEPLOYMENT === false) { return await reports(defaultS3, defaultSsm, event, context, AWS_REGION, CORE_STACK_NAME); } else { return NA_MULTI; } } } // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { handler }); Environment: Variables: CORE_STACK_NAME: Ref: AWS::StackName CORE_STACK_ID: Ref: AWS::StackId ENGINE: clamav AWS_PARTITION: Ref: AWS::Partition AWS_ACCOUNT_ID: Ref: AWS::AccountId SCAN_LOG_GROUP_NAME: Ref: Logs SCAN_QUEUE_ARN: Fn::GetAtt: - ScanQueue - Arn CROSS_ACCOUNT: Fn::If: - HasCrossAccount - "true" - "false" MULTI_DEPLOYMENT: Fn::If: - HasMultiDeployment - "true" - "false" SCAN_LOG_STREAM_SUFFIX: /journald/bucketav.service REFRESH_BUCKET_CACHE_STATE_MACHINE_ARN: Fn::If: - HasCrossAccountAndNotMultiDeployment - Fn::GetAtt: - RefreshBucketCacheStateMachine - Arn - Ref: AWS::NoValue Handler: index.handler MemorySize: 1769 ReservedConcurrentExecutions: Fn::If: - HasDashboardLambdaFunctionReservedConcurrentExecutions - Ref: DashboardLambdaFunctionReservedConcurrentExecutions - Ref: AWS::NoValue Role: Fn::GetAtt: - DashboardLambdaRole - Arn Runtime: nodejs22.x Timeout: 300 VpcConfig: Fn::If: - HasLambdaVpc - SecurityGroupIds: - Ref: LambdaSecurityGroup SubnetIds: Ref: LambdaSubnets - Ref: AWS::NoValue Metadata: bucketav::DashboardLambdaVpcAllowPolicy: Fn::If: - HasLambdaVpc - Ref: DashboardLambdaVpcAllowPolicy - Ref: AWS::NoValue DashboardLambdaVpcAllowPolicy: Type: AWS::IAM::Policy Properties: PolicyDocument: Statement: - Effect: Allow Action: - ec2:CreateNetworkInterface - ec2:DeleteNetworkInterface - ec2:AssignPrivateIpAddresses - ec2:UnassignPrivateIpAddresses - ec2:DescribeNetworkInterfaces - ec2:DescribeSubnets Resource: - "*" PolicyName: vpc-allow Roles: - Ref: DashboardLambdaRole Condition: HasLambdaVpc DashboardLambdaVpcDenyPolicy: Type: AWS::IAM::Policy Properties: PolicyDocument: Statement: - Effect: Deny Action: - ec2:CreateNetworkInterface - ec2:DescribeNetworkInterfaces - ec2:DescribeSubnets - ec2:DeleteNetworkInterface - ec2:AssignPrivateIpAddresses - ec2:UnassignPrivateIpAddresses Resource: "*" Condition: ArnEquals: lambda:SourceFunctionArn: - Fn::GetAtt: - DashboardLambdaFunction - Arn PolicyName: vpc-deny Roles: - Ref: DashboardLambdaRole Condition: HasLambdaVpc DashboardLambdaLogGroup: Type: AWS::Logs::LogGroup Properties: LogGroupName: Fn::Join: - "" - - /aws/lambda/ - Ref: DashboardLambdaFunction RetentionInDays: Ref: LogsRetentionInDays DashboardLambdaPolicy: Type: AWS::IAM::Policy Properties: PolicyDocument: Statement: - Effect: Allow Action: - logs:CreateLogStream - logs:PutLogEvents Resource: Fn::GetAtt: - DashboardLambdaLogGroup - Arn PolicyName: logs Roles: - Ref: DashboardLambdaRole Dashboard: Type: AWS::CloudWatch::Dashboard Properties: DashboardBody: Fn::Join: - "" - - '{"start":"-PT24H","widgets":[{"type":"custom","x":16,"y":16,"width":8,"height":8,"properties":{"endpoint":"' - Fn::GetAtt: - DashboardLambdaFunction - Arn - '","updateOn":{"refresh":true,"resize":false,"timeRange":false},"params":{"view":"accounts"},"title":"AWS accounts"}},{"type":"metric","x":0,"y":0,"height":3,"width":16,"properties":{"sparkline":false,"view":"singleValue","metrics":[["AWS/SQS","NumberOfMessagesSent","QueueName","' - Fn::GetAtt: - ScanQueue - QueueName - '",{"yAxis":"right","stat":"Sum","label":"Files enqueued"}],[".","NumberOfMessagesDeleted",".",".",{"yAxis":"right","stat":"Sum","label":"Files processed"}],["' - Ref: AWS::StackName - '","clean",{"stat":"Sum","label":"Clean"}],[".","infected",{"stat":"Sum","color":"#d62728","label":"Infected"}],[".","no",{"stat":"Sum","color":"#ff7f0e","label":"Unscannable (too big, access denied)"}],[".","scanned_data",{"stat":"Sum","label":"Scanned data (GB)"}]],"region":"' - Ref: AWS::Region - '","title":"bucketAV for Amazon S3 powered by ClamAV (shared-vpc) - Overview","liveData":true,"setPeriodToTimeRange":true,"trend":false,"singleValueFullPrecision":false}},{"type":"custom","x":16,"y":0,"width":8,"height":8,"properties":{"endpoint":"' - Fn::GetAtt: - DashboardLambdaFunction - Arn - '","updateOn":{"refresh":true,"resize":false,"timeRange":false},"params":{"view":"update"},"title":"Update"}},{"type":"metric","x":0,"y":3,"width":4,"height":5,"properties":{"metrics":[["' - Ref: AWS::StackName - '","clean",{"stat":"Sum","label":"Clean"}],[".","infected",{"stat":"Sum","color":"#d62728","label":"Infected"}],[".","no",{"stat":"Sum","color":"#ff7f0e","label":"Unscannable (too big, access denied)"}]],"view":"timeSeries","stacked":true,"region":"' - Ref: AWS::Region - '","title":"Scan Results","period":60,"liveData":true}},{"type":"metric","x":4,"y":3,"width":4,"height":5,"properties":{"metrics":[["AWS/SQS","ApproximateNumberOfMessagesVisible","QueueName","' - Fn::GetAtt: - ScanQueue - QueueName - '",{"stat":"Maximum","label":"Queue Length"}],[".","NumberOfMessagesSent",".",".",{"yAxis":"right","stat":"Sum","label":"Files enqueued"}],[".","NumberOfMessagesDeleted",".",".",{"yAxis":"right","stat":"Sum","label":"Files processed"}]],"view":"timeSeries","stacked":false,"region":"' - Ref: AWS::Region - '","title":"Scan Queue","period":60,"liveData":true}},{"type":"alarm","x":12,"y":3,"width":4,"height":5,"properties":{"title":"Infrastructure Alarms","alarms":["' - Fn::GetAtt: - DeadLetterQueueAlarm - Arn - '","' - Fn::GetAtt: - ScanQueueOldMessagesAlarm - Arn - '","' - Fn::GetAtt: - SignaturesAgeAlarm - Arn - '"]}},{"type":"custom","x":0,"y":8,"width":8,"height":8,"properties":{"endpoint":"' - Fn::GetAtt: - DashboardLambdaFunction - Arn - '","updateOn":{"refresh":true,"resize":false,"timeRange":false},"params":{"view":"scanResults"},"title":"Scan Results"}},{"type":"text","x":8,"y":8,"width":8,"height":2,"properties":{"markdown":"**Stop zero-day attacks ClamAV misses** [button:Upgrade to Sophos](https://bucketav.com/help/migration-guide/?platform=aws&utm_source=dashboard&utm_campaign=upsell&#clamav-to-sophos) \n\nEnterprise protection + 20x scanning throughut reduces EC2 costs + 5 TB file size limit + upgrade without downtime","background":"transparent"}},{"type":"custom","x":8,"y":10,"width":8,"height":6,"properties":{"endpoint":"' - Fn::GetAtt: - DashboardLambdaFunction - Arn - '","updateOn":{"refresh":true,"resize":false,"timeRange":false},"params":{"view":"reports"},"title":"Reports"}},{"type":"custom","x":16,"y":8,"width":8,"height":8,"properties":{"endpoint":"' - Fn::GetAtt: - DashboardLambdaFunction - Arn - '","updateOn":{"refresh":true,"resize":false,"timeRange":false},"params":{"view":"buckets"},"title":"Buckets"}},{"type":"metric","x":8,"y":3,"width":4,"height":5,"properties":{"metrics":[["AWS/AutoScaling","GroupInServiceInstances","AutoScalingGroupName","' - Ref: ScanAutoScalingGroup - '",{"stat":"Average","label":"Running Instances"}]],"view":"timeSeries","stacked":false,"region":"' - Ref: AWS::Region - '","title":"Scan Fleet","period":60,"liveData":true,"annotations":{"horizontal":[{"label":"AutoScalingMinSize","value":' - Ref: AutoScalingMinSize - '},{"label":"AutoScalingMaxSize","value":' - Ref: AutoScalingMaxSize - '}]}}},{"type":"metric","x":0,"y":16,"width":4,"height":8,"properties":{"view":"timeSeries","stacked":false,"metrics":[["AWS/EC2","CPUUtilization","AutoScalingGroupName","' - Ref: ScanAutoScalingGroup - '",{"label":"Utilization"}]],"region":"' - Ref: AWS::Region - '","title":"CPU","period":300,"liveData":true,"yAxis":{"left":{"min":0,"max":100}}}},{"type":"metric","x":4,"y":16,"width":4,"height":8,"properties":{"view":"timeSeries","stacked":false,"metrics":[["' - Ref: AWS::StackName - '","mem_used_percent",{"label":"Memory utilization"}],["' - Ref: AWS::StackName - '","swap_used_percent",{"label":"Swap utilization"}]],"region":"' - Ref: AWS::Region - '","title":"Memory","period":300,"liveData":true,"yAxis":{"left":{"min":0,"max":100}}}},{"type":"metric","x":8,"y":16,"width":4,"height":8,"properties":{"view":"timeSeries","stacked":false,"metrics":[["' - Ref: AWS::StackName - '","disk_used_percent","path","/","fstype","xfs",{"label":"Storage utilization"}],[{"expression":"(wops+rops)/300","label":"IOPS","id":"e1","yAxis":"right"}],["AWS/EC2","EBSWriteOps","AutoScalingGroupName","' - Ref: ScanAutoScalingGroup - '",{"id":"wops","stat":"Sum","visible":false}],["AWS/EC2","EBSReadOps","AutoScalingGroupName","' - Ref: ScanAutoScalingGroup - '",{"id":"rops","stat":"Sum","visible":false}],[{"expression":"(w+r)/300/1024/1024","label":"Throughput (MiB/s)","id":"e2","yAxis":"right"}],["AWS/EC2","EBSWriteBytes","AutoScalingGroupName","' - Ref: ScanAutoScalingGroup - '",{"id":"w","stat":"Sum","visible":false}],["AWS/EC2","EBSReadBytes","AutoScalingGroupName","' - Ref: ScanAutoScalingGroup - '",{"id":"r","stat":"Sum","visible":false}]],"region":"' - Ref: AWS::Region - '","title":"Disk","period":300,"liveData":true,"yAxis":{"left":{"min":0,"max":100}}}},{"type":"metric","x":12,"y":16,"width":4,"height":8,"properties":{"view":"timeSeries","stacked":true,"metrics":[[{"expression":"(in+out)/300*8/1000/1000/1000","label":"Throughput (Gbit/s)","id":"e1"}],["AWS/EC2","NetworkIn","AutoScalingGroupName","' - Ref: ScanAutoScalingGroup - '",{"id":"in","stat":"Sum","visible":false}],["AWS/EC2","NetworkOut","AutoScalingGroupName","' - Ref: ScanAutoScalingGroup - '",{"id":"out","stat":"Sum","visible":false}]],"region":"' - Ref: AWS::Region - '","title":"Network","period":300,"liveData":true,"yAxis":{"left":{"showUnits":false}}}}]}' DashboardName: Fn::Join: - "" - - Ref: AWS::StackName - "-" - Ref: AWS::Region DependsOn: - DashboardLambdaPolicy GovernanceFindingTable: Type: AWS::DynamoDB::Table Properties: AttributeDefinitions: - AttributeName: finding_id AttributeType: S BillingMode: PAY_PER_REQUEST KeySchema: - AttributeName: finding_id KeyType: HASH SSESpecification: SSEEnabled: true TimeToLiveSpecification: AttributeName: ttl Enabled: true Condition: HasGovernanceAndNotMultiDeployment GovernanceLambdaRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Principal: Service: lambda.amazonaws.com Action: sts:AssumeRole PermissionsBoundary: Fn::If: - HasPermissionsBoundary - Ref: PermissionsBoundary - Ref: AWS::NoValue Policies: - PolicyDocument: Statement: - Fn::If: - HasCrossAccount - Effect: Allow Action: dynamodb:Scan Resource: Fn::GetAtt: - BucketCacheTable - Arn - Ref: AWS::NoValue - Effect: Allow Action: - cloudformation:DescribeStacks - s3:ListAllMyBuckets - s3:GetBucketNotification - sns:ListSubscriptionsByTopic - events:ListRuleNamesByTarget - events:DescribeRule Resource: "*" - Effect: Allow Action: ssm:GetParametersByPath Resource: Fn::Join: - "" - - "arn:" - Ref: AWS::Partition - ":ssm:" - Ref: AWS::Region - ":" - Ref: AWS::AccountId - :parameter/bucketAV/ - Ref: AWS::StackName - /AddOn/* - Effect: Allow Action: events:DescribeEventBus Resource: Fn::Join: - "" - - "arn:" - Ref: AWS::Partition - ":events:" - Ref: AWS::Region - ":" - Ref: AWS::AccountId - :event-bus/default - Fn::If: - HasCrossAccount - Effect: Allow Action: dynamodb:Scan Resource: Fn::GetAtt: - AccountConnectionTable - Arn - Ref: AWS::NoValue - Fn::If: - HasCrossAccount - Effect: Allow Action: sts:AssumeRole Resource: Fn::Join: - "" - - "arn:" - Ref: AWS::Partition - :iam::*:role/ - Ref: AWS::StackName - -AccountConnection - Ref: AWS::NoValue - Effect: Allow Action: cloudwatch:GetMetricStatistics Resource: "*" - Effect: Allow Action: sns:Publish Resource: Fn::GetAtt: - InfrastructureAlarmsTopic - TopicArn - Effect: Allow Action: - dynamodb:PutItem - dynamodb:DeleteItem - dynamodb:Scan Resource: Fn::GetAtt: - GovernanceFindingTable - Arn PolicyName: lambda - PolicyDocument: Statement: - Effect: Allow Action: sqs:SendMessage Resource: Fn::GetAtt: - GovernanceLambdaDeadLetterQueue - Arn PolicyName: dlq Condition: HasGovernanceAndNotMultiDeployment GovernanceLambdaFunction: Type: AWS::Lambda::Function Properties: Code: ZipFile: | var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // lambda/governance.js var governance_exports = {}; __export(governance_exports, { handler: () => handler }); module.exports = __toCommonJS(governance_exports); var import_client_cloudformation2 = require("@aws-sdk/client-cloudformation"); var import_client_s33 = require("@aws-sdk/client-s3"); var import_client_ssm2 = require("@aws-sdk/client-ssm"); var import_client_sns2 = require("@aws-sdk/client-sns"); var import_client_eventbridge2 = require("@aws-sdk/client-eventbridge"); var import_client_dynamodb2 = require("@aws-sdk/client-dynamodb"); var import_client_cloudwatch = require("@aws-sdk/client-cloudwatch"); // lambda/lib-governance.js var ONE_DAY_IN_SECONDS = 60 * 60 * 24; var FINDING_TYPE_REAL_TIME_FILE_SCAN_MISSING = "bucketav:real-time-file-scan-missing"; var FINDING_TYPE_SCHEDULED_BUCKET_SCAN_MISSING = "bucketav:scheduled-bucket-scan-missing"; var FINDING_TYPE_NO_SCAN_ACTIVITY = "bucketav:no-scan-activity"; var FINDING_TYPE_NO_SCAN_ACTIVITY_DAYS = 14; var FINDING_TYPE_OUTDATED_CORE = "bucketav:outdated-core"; var FINDING_TYPE_OUTDATED_ADDONS = "bucketav:outdated-addons"; var FINDING_DATA = { [FINDING_TYPE_REAL_TIME_FILE_SCAN_MISSING]: { findingTitle: "Real-time file scan not enabled", findingDescription: "The bucket is not protected by real-time file scanning.", remediationDescription: "To enable real-time file scanning, please follow", remediationUrl: "https://bucketav.com/help/scan-modes/real-time-file-scan.html" }, [FINDING_TYPE_SCHEDULED_BUCKET_SCAN_MISSING]: { findingTitle: "Scheduled bucket scan not enabled", findingDescription: "The bucket is not protected by scheduled bucket scanning.", remediationDescription: "To enable scheduled-bucket scanning, please follow", remediationUrl: "https://bucketav.com/help/scan-modes/scheduled-bucket-scan.html" }, [FINDING_TYPE_NO_SCAN_ACTIVITY]: { findingTitle: "No scan activity", findingDescription: `Your bucketAV setup is likely incomplete\u2014no scan activity has been found in the past ${FINDING_TYPE_NO_SCAN_ACTIVITY_DAYS} days.`, remediationDescription: "To complete your bucketAV setup, please follow", remediationUrl: "https://bucketav.com/help/setup-guide/", ttlInSeconds: ONE_DAY_IN_SECONDS * FINDING_TYPE_NO_SCAN_ACTIVITY_DAYS }, [FINDING_TYPE_OUTDATED_CORE]: { findingTitle: "bucketAV requires an update", findingDescription: "Your bucketAV setup is running on outdated version.", remediationDescription: "To update bucketAV, please follow", remediationUrl: "https://bucketav.com/help/update-guide/" }, [FINDING_TYPE_OUTDATED_ADDONS]: { findingTitle: "bucketAV Add-Ons require updates", findingDescription: "Your bucketAV setup is running outdated Add-On versions.", remediationDescription: "To update bucketAV, please follow", remediationUrl: "https://bucketav.com/help/update-guide/" } }; // lambda/lib.js var import_client_cloudformation = require("@aws-sdk/client-cloudformation"); var import_client_s3 = require("@aws-sdk/client-s3"); var import_client_sns = require("@aws-sdk/client-sns"); var import_client_eventbridge = require("@aws-sdk/client-eventbridge"); var import_client_dynamodb = require("@aws-sdk/client-dynamodb"); var import_client_s32 = require("@aws-sdk/client-s3"); var import_client_ssm = require("@aws-sdk/client-ssm"); var import_client_secrets_manager = require("@aws-sdk/client-secrets-manager"); var import_client_organizations = require("@aws-sdk/client-organizations"); var import_credential_providers = require("@aws-sdk/credential-providers"); // lambda/node_modules/@smithy/core/dist-es/pagination/createPaginator.js var makePagedClientRequest = async (CommandCtor, client, input, withCommand = (_) => _, ...args) => { let command = new CommandCtor(input); command = withCommand(command) ?? command; return await client.send(command, ...args); }; function createPaginator(ClientCtor, CommandCtor, inputTokenName, outputTokenName, pageSizeTokenName) { return async function* paginateOperation(config, input, ...additionalArguments) { const _input = input; let token = config.startingToken ?? _input[inputTokenName]; let hasNext = true; let page; while (hasNext) { _input[inputTokenName] = token; if (pageSizeTokenName) { _input[pageSizeTokenName] = _input[pageSizeTokenName] ?? config.pageSize; } if (config.client instanceof ClientCtor) { page = await makePagedClientRequest(CommandCtor, config.client, input, config.withCommand, ...additionalArguments); } else { throw new Error(`Invalid client, expected instance of ${ClientCtor.name}`); } yield page; const prevToken = token; token = get(page, outputTokenName); hasNext = !!(token && (!config.stopOnSameToken || token !== prevToken)); } return void 0; }; } var get = (fromObject, path) => { let cursor = fromObject; const pathComponents = path.split("."); for (const step of pathComponents) { if (!cursor || typeof cursor !== "object") { return void 0; } cursor = cursor[step]; } return cursor; }; // lambda/node_modules/yocto-queue/index.js var Node = class { value; next; constructor(value) { this.value = value; } }; var Queue = class { #head; #tail; #size; constructor() { this.clear(); } enqueue(value) { const node = new Node(value); if (this.#head) { this.#tail.next = node; this.#tail = node; } else { this.#head = node; this.#tail = node; } this.#size++; } dequeue() { const current = this.#head; if (!current) { return; } this.#head = this.#head.next; this.#size--; return current.value; } peek() { if (!this.#head) { return; } return this.#head.value; } clear() { this.#head = void 0; this.#tail = void 0; this.#size = 0; } get size() { return this.#size; } *[Symbol.iterator]() { let current = this.#head; while (current) { yield current.value; current = current.next; } } *drain() { while (this.#head) { yield this.dequeue(); } } }; // lambda/node_modules/p-limit/index.js function pLimit(concurrency) { validateConcurrency(concurrency); const queue = new Queue(); let activeCount = 0; const resumeNext = () => { if (activeCount < concurrency && queue.size > 0) { queue.dequeue()(); activeCount++; } }; const next = () => { activeCount--; resumeNext(); }; const run = async (function_, resolve, arguments_) => { const result = (async () => function_(...arguments_))(); resolve(result); try { await result; } catch { } next(); }; const enqueue = (function_, resolve, arguments_) => { new Promise((internalResolve) => { queue.enqueue(internalResolve); }).then( run.bind(void 0, function_, resolve, arguments_) ); (async () => { await Promise.resolve(); if (activeCount < concurrency) { resumeNext(); } })(); }; const generator = (function_, ...arguments_) => new Promise((resolve) => { enqueue(function_, resolve, arguments_); }); Object.defineProperties(generator, { activeCount: { get: () => activeCount }, pendingCount: { get: () => queue.size }, clearQueue: { value() { queue.clear(); } }, concurrency: { get: () => concurrency, set(newConcurrency) { validateConcurrency(newConcurrency); concurrency = newConcurrency; queueMicrotask(() => { while (activeCount < concurrency && queue.size > 0) { resumeNext(); } }); } } }); return generator; } function validateConcurrency(concurrency) { if (!((Number.isInteger(concurrency) || concurrency === Number.POSITIVE_INFINITY) && concurrency > 0)) { throw new TypeError("Expected `concurrency` to be a number from 1 and up"); } } // lambda/lib.js function includesBucket(scheduledStack, bucketName) { let excludeFilterExpression = "^$"; if (scheduledStack.params.ExcludeBucketNameFilter) { excludeFilterExpression = "^" + scheduledStack.params.ExcludeBucketNameFilter.replaceAll(".", ".").replaceAll("-", "-").replaceAll("*", ".*").replaceAll(",", "$|^") + "$"; } if (bucketName.match(new RegExp(excludeFilterExpression))) { return false; } if (scheduledStack.params.BucketName.includes("*")) { const filterExpression = "^" + scheduledStack.params.BucketName.replaceAll(".", ".").replaceAll("-", "-").replaceAll("*", ".*").replaceAll(",", "$|^") + "$"; return bucketName.match(new RegExp(filterExpression)) !== null; } else { return scheduledStack.params.BucketName.split(",").includes(bucketName); } } function hasBucket(scheduledStack, bucketName) { if (scheduledStack.params.BucketName === bucketName && (scheduledStack.params.ExcludeBucketNameFilter === "" || !("ExcludeBucketNameFilter" in scheduledStack.params))) { return true; } return false; } function mapCachedBucketItem(item) { const bucket = { name: item.bucket_name.S, accountId: item.bucket_account_id.S, realtimeEnabled: item.bucket_realtime_enabled.BOOL, realtimeEventNotificationEnablePossible: item.bucket_realtime_event_notification_enable_possible.BOOL, realtimeEventNotificationDisablePossible: item.bucket_realtime_event_notification_disable_possible.BOOL, scheduledEnabled: item.bucket_scheduled_enabled.BOOL, scheduledStackDisablePossible: item.bucket_scheduled_stack_disable_possible.BOOL }; if ("bucket_organization_id" in item) { bucket.organizationId = item.bucket_organization_id.S; } if ("bucket_region" in item) { bucket.region = item.bucket_region.S; } if ("bucket_error_message" in item) { bucket.errorMessage = item.bucket_error_message.S; } if ("bucket_scheduled_stack_id" in item) { bucket.scheduledStackId = item.bucket_scheduled_stack_id.S; } return bucket; } async function listCachedBuckets(dynamodb2, bucketCacheTableName) { const paginatorScan = await (0, import_client_dynamodb.paginateScan)({ client: dynamodb2 }, { TableName: bucketCacheTableName }); const buckets = []; for await (const page of paginatorScan) { for (const item of page.Items) { buckets.push(mapCachedBucketItem(item)); } } return buckets; } async function listBucketsLight(s32, region, accountId) { const allBuckets = []; const input = {}; if (region !== void 0 && region !== null) { input.BucketRegion = region; } const paginator = (0, import_client_s3.paginateListBuckets)({ client: s32, pageSize: 1e3 }, input); for await (const page of paginator) { allBuckets.push(...page.Buckets.map((bucket) => { if ("BucketRegion" in bucket) { return { name: bucket.Name, region: bucket.BucketRegion, accountId }; } else { return { name: bucket.Name, accountId, errorMessage: `Can not get region for bucket ${bucket.Name}` }; } })); } if (region !== void 0 && region !== null) { return allBuckets.filter((bucket) => bucket.region === region || bucket.region === void 0); } else { return allBuckets; } } var paginateListRuleNamesByTarget = createPaginator(import_client_eventbridge.EventBridgeClient, import_client_eventbridge.ListRuleNamesByTargetCommand, "NextToken", "NextToken", "Limit"); async function checkEventBridgeRules(eventbridge2, ruleNames, bucketName) { const rules = await Promise.all(ruleNames.map((ruleName) => eventbridge2.send(new import_client_eventbridge.DescribeRuleCommand({ Name: ruleName })))); return rules.filter((rule) => rule.State === "ENABLED" && rule.EventPattern).map((rule) => JSON.parse(rule.EventPattern)).find((pattern) => pattern.source.includes("aws.s3") && pattern["detail-type"].includes("Object Created") && (pattern?.detail?.bucket?.name?.includes(bucketName) || pattern?.detail?.bucket?.name === void 0)) !== void 0; } async function enrichBucket(s32, sns2, eventbridge2, coreEventbridge, partition, region, accountId, organizationId, coreAccountId, coreStackName, scanQueueArn, scheduledStacks, bucketName, bucketRegion) { const bucket = { name: bucketName, accountId, organizationId }; if (bucketRegion === void 0) { bucket.region = void 0; bucket.errorMessage = `Can not get region for bucket ${bucketName}`; } else { bucket.region = bucketRegion; } if (bucket.region === region || bucket.region === void 0) { let realtimeEnabled = false; let realtimeEventNotificationEnablePossible = bucket.region !== void 0; let realtimeEventNotificationDisablePossible = false; let scheduledEnabled = false; let scheduledStackDisablePossible = false; let scheduledStackId = void 0; try { const notificationData = await s32.send(new import_client_s3.GetBucketNotificationConfigurationCommand({ Bucket: bucket.name, ExpectedBucketOwner: accountId })); if (notificationData?.QueueConfigurations?.find((config) => config.QueueArn === scanQueueArn && config.Events.includes("s3:ObjectCreated:*")) !== void 0) { realtimeEnabled = true; realtimeEventNotificationDisablePossible = true; } if (Array.isArray(notificationData?.TopicConfigurations)) { await Promise.all(notificationData.TopicConfigurations.filter((config) => config.Events.includes("s3:ObjectCreated:*")).map(async (config) => { const paginatorListSubscriptionsByTopic = await (0, import_client_sns.paginateListSubscriptionsByTopic)({ client: sns2, pageSize: 50 }, { TopicArn: config.TopicArn }); for await (const page of paginatorListSubscriptionsByTopic) { if (page.Subscriptions.find((subscription) => subscription.Protocol === "sqs" && subscription.Endpoint === scanQueueArn) !== void 0) { realtimeEnabled = true; } } })); } if (notificationData?.EventBridgeConfiguration) { if (accountId !== coreAccountId) { let crossAccoutRule = false; const paginatorListRuleNamesByTargetDefaultBus = await paginateListRuleNamesByTarget({ client: eventbridge2, pageSize: 10 }, { TargetArn: `arn:${partition}:events:${region}:${coreAccountId}:event-bus/default` }); for await (const page of paginatorListRuleNamesByTargetDefaultBus) { if (await checkEventBridgeRules(eventbridge2, page.RuleNames, bucket.name) === true) { crossAccoutRule = true; } } const { Policy: defaultBusPolicyJson } = await coreEventbridge.send(new import_client_eventbridge.DescribeEventBusCommand({ Name: "default" })); if (defaultBusPolicyJson !== void 0) { const defaultBusPolicy = JSON.parse(defaultBusPolicyJson); if (defaultBusPolicy.Statement.find((s) => s?.Effect === "Allow" && s?.Action === "events:PutEvents" && (s?.Principal?.AWS === accountId || s?.Principal === "*" && s?.Condition?.StringEquals["aws:PrincipalOrgID"] === organizationId)) !== void 0) { if (crossAccoutRule === true) { realtimeEnabled = true; } } } } const paginatorListRuleNamesByTargetScanQueue = await paginateListRuleNamesByTarget({ client: coreEventbridge, pageSize: 10 }, { TargetArn: scanQueueArn }); for await (const page of paginatorListRuleNamesByTargetScanQueue) { if (await checkEventBridgeRules(coreEventbridge, page.RuleNames, bucket.name) === true) { realtimeEnabled = true; } } } if (notificationData?.TopicConfigurations?.find((config) => config.Events.filter((event) => event.startsWith("s3:ObjectCreated:")).length > 0)) { realtimeEventNotificationEnablePossible = false; } if (notificationData?.QueueConfigurations?.find((config) => config.Events.filter((event) => event.startsWith("s3:ObjectCreated:")).length > 0)) { realtimeEventNotificationEnablePossible = false; } if (notificationData?.LambdaFunctionConfigurations?.find((config) => config.Events.filter((event) => event.startsWith("s3:ObjectCreated:")).length > 0)) { realtimeEventNotificationEnablePossible = false; } if (notificationData?.EventBridgeConfiguration !== void 0) { realtimeEventNotificationEnablePossible = false; } } catch (err) { console.log(err); bucket.errorMessage = `Can not get details for bucket ${bucket.name}: ${err.name}`; realtimeEventNotificationEnablePossible = false; } const scheduledStacksIncludesBucket = scheduledStacks.stacks.filter((scheduledStack) => includesBucket(scheduledStack, bucket.name)); if (scheduledStacksIncludesBucket.length > 0) { scheduledEnabled = true; scheduledStackId = scheduledStacksIncludesBucket[0].id; } const scheduledStacksHasBucket = scheduledStacks.stacks.filter((scheduledStack) => hasBucket(scheduledStack, bucket.name)); if (scheduledStacksHasBucket.length === 1) { scheduledEnabled = true; scheduledStackDisablePossible = true; scheduledStackId = scheduledStacksHasBucket[0].id; } return { ...bucket, realtimeEnabled, realtimeEventNotificationEnablePossible, realtimeEventNotificationDisablePossible, scheduledEnabled, scheduledStackDisablePossible, scheduledStackId }; } else { return null; } } async function getScheduledStacks(ssm2, cloudformation2, coreStackName) { const paginatorGetParametersByPath = await (0, import_client_ssm.paginateGetParametersByPath)({ client: ssm2 }, { Recursive: true, Path: `/bucketAV/${coreStackName}/AddOn/scheduled-bucket-scan/` }); const stacks = []; for await (const page of paginatorGetParametersByPath) { const scheduledStackNames = page.Parameters.filter((p) => p.Name.endsWith("/Version")).map((p) => p.Name.split("/")[5]); const describeStacksDataList = await Promise.all(scheduledStackNames.map((stackName) => cloudformation2.send(new import_client_cloudformation.DescribeStacksCommand({ StackName: stackName })))); describeStacksDataList.forEach((describeStacksData) => { const stack = { name: describeStacksData.Stacks[0].StackName, id: describeStacksData.Stacks[0].StackId, params: describeStacksData.Stacks[0].Parameters?.reduce((acc, param) => { acc[param.ParameterKey] = param.ParameterValue; return acc; }, {}) || {}, outputs: describeStacksData.Stacks[0].Outputs?.reduce((acc, output) => { acc[output.OutputKey] = output.OutputValue; return acc; }, {}) || {} }; if (stack.params.BucketAVStackName === coreStackName && stack.outputs.AddOn === "scheduled-bucket-scan") { stacks.push(stack); } }); } return { stacks }; } async function listBuckets(s32, ssm2, cloudformation2, sns2, eventbridge2, coreEventbridge, partition, region, accountId, organizationId, coreAccountId, coreStackName, scanQueueArn) { const lightBuckets = await listBucketsLight(s32, region, accountId); const scheduledStacks = await getScheduledStacks(ssm2, cloudformation2, coreStackName); const buckets = await Promise.all(lightBuckets.map((lightBucket) => enrichBucket(s32, sns2, eventbridge2, coreEventbridge, partition, region, accountId, organizationId, coreAccountId, coreStackName, scanQueueArn, scheduledStacks, lightBucket.name, lightBucket.region))); return buckets.filter((bucket) => bucket !== null); } var MAX_S3_COPY_SIZE = 5 * 1024 * 1024 * 1024; function generateRoleArn(partition, accountId, roleName) { return `arn:${partition}:iam::${accountId}:role/${roleName}`; } function generateRoleArnFromItem(accountConnectionItem) { return generateRoleArn(accountConnectionItem.partition.S, accountConnectionItem.account_id.S, accountConnectionItem.role_name.S); } function generateExternalId(stackId) { return stackId.split("/")[2]; } function generateExternalIdFromItem(accountConnectionItem) { return generateExternalId(accountConnectionItem.stack_id.S); } async function fetchLatest() { const url = "https://bucketav-release-data.s3.eu-west-1.amazonaws.com/latest.json"; const res = await fetch(url); if (res.status !== 200) { console.log("request", url); console.log("response status", res.status); console.log("response", await res.text()); throw new Error("unexpected status code"); } return res.json(); } async function fetchOutdatedAddons(defaultCloudformation, defaultSsm, dynamodb2, platform, latest, isCrossAccount, corePartition, coreRegion, coreAccountId, coreStackName, accountConnectionTableName) { const outdatedAddons = []; const fn = async (ssm2, cloudformation2, partition, region, accountId) => { const cloudformaionDescribeStacksLimit = pLimit(8); const paginatorGetParametersByPath = await (0, import_client_ssm.paginateGetParametersByPath)({ client: ssm2 }, { Recursive: true, Path: `/bucketAV/${coreStackName}/AddOn/` }); for await (const page of paginatorGetParametersByPath) { const addons = page.Parameters.filter((p) => p.Name.endsWith("/Version")).map((p) => { const [, , , , addonType, addonStackName] = p.Name.split("/"); const addonVersion = p.Value; const addonId = `add-on-${addonType}-${platform}`; const addon = { type: addonType, partition, region, accountId, stackName: addonStackName, version: addonVersion, latestVersion: latest[addonId].version.substr(1), releaseNotesPageUrl: latest[addonId].releaseNotesPageUrl }; if ("template" in latest[addonId]) { addon.latestTemplateUrl = latest[addonId].template; } if ("templates" in latest[addonId]) { addon.latestTemplateUrls = latest[addonId].templates; } return addon; }); const innerOutdatedAddons = await Promise.all(addons.filter((addon) => addon.version !== addon.latestVersion).map((addon) => cloudformaionDescribeStacksLimit(async () => { const data = await cloudformation2.send(new import_client_cloudformation.DescribeStacksCommand({ StackName: addon.stackName })).then((data2) => data2.Stacks[0]); addon.stackId = data.StackId; if (!("latestTemplateUrl" in addon)) { if ("latestTemplateUrls" in addon) { const engine = data?.Outputs?.find((output) => output.OutputKey === "Engine")?.OutputValue; const fulfillmentOption = data?.Outputs?.find((output) => output.OutputKey === "FulfillmentOption")?.OutputValue; addon.latestTemplateUrl = addon.latestTemplateUrls?.[engine]?.[fulfillmentOption]; delete addon.latestTemplateUrls; } else { throw new Error("missing latestTemplateUrl and latestTemplateUrls"); } } return addon; }))); outdatedAddons.push(...innerOutdatedAddons); } }; if (isCrossAccount === true) { await fn(defaultSsm, defaultCloudformation, corePartition, coreRegion, coreAccountId); const paginatorScan = await (0, import_client_dynamodb.paginateScan)({ client: dynamodb2 }, { TableName: accountConnectionTableName }); for await (const page of paginatorScan) { await Promise.all(page.Items.map((item) => { const externalId = generateExternalIdFromItem(item); const roleArn = generateRoleArnFromItem(item); const credentials = (0, import_credential_providers.fromTemporaryCredentials)({ params: { ExternalId: externalId, RoleArn: roleArn, RoleSessionName: "bucketav" } }); const ssm2 = new import_client_ssm.SSMClient({ apiVersion: "2014-11-06", credentials }); const cloudformation2 = new import_client_cloudformation.CloudFormationClient({ apiVersion: "2006-03-01", credentials, maxAttempts: 10 }); return fn(ssm2, cloudformation2, item.partition.S, item.region.S, item.account_id.S); })); } } else { await fn(defaultSsm, defaultCloudformation, corePartition, coreRegion, coreAccountId); } return outdatedAddons; } async function checkVersion(cloudformation2, ssm2, dynamodb2, platform, latest, isCrossAccount, corePartition, coreRegion, coreAccountId, coreStackName, accountConnectionTableName) { const describeStacksData = await cloudformation2.send(new import_client_cloudformation.DescribeStacksCommand({ StackName: coreStackName })); const runningEngine = describeStacksData.Stacks[0].Outputs.find((output) => output.OutputKey === "Engine").OutputValue; const runningVersion = describeStacksData.Stacks[0].Outputs.find((output) => output.OutputKey === "Version").OutputValue; const runningFulfillmentOption = describeStacksData.Stacks[0].Outputs.find((output) => output.OutputKey === "FulfillmentOption").OutputValue; const outdatedAddons = await fetchOutdatedAddons(cloudformation2, ssm2, dynamodb2, platform, latest, isCrossAccount, corePartition, coreRegion, coreAccountId, coreStackName, accountConnectionTableName); const coreId = `core-${platform}-${runningEngine}`; const latestVersion = latest[coreId].version.substr(1); const latestTemplateUrl = latest[coreId].templates[runningFulfillmentOption]; return { runningVersion, latestVersion, latestTemplateUrl, outdatedAddons }; } // lambda/governance.js var cloudformation = new import_client_cloudformation2.CloudFormationClient({ apiVersion: "2006-03-01", maxAttempts: 10 }); var s3 = new import_client_s33.S3Client({ apiVersion: "2006-03-01" }); var ssm = new import_client_ssm2.SSMClient({ apiVersion: "2014-11-06" }); var sns = new import_client_sns2.SNSClient({ apiVersion: "2010-03-31" }); var eventbridge = new import_client_eventbridge2.EventBridgeClient({ apiVersion: "2015-10-07" }); var dynamodb = new import_client_dynamodb2.DynamoDBClient({ apiVersion: "2012-08-10" }); var cloudwatch = new import_client_cloudwatch.CloudWatchClient({ apiVersion: "2010-08-01" }); function generateFindingId(findingType, bucket = null) { if (bucket === null) { return `bucketav:${process.env.CORE_STACK_NAME}:core:${process.env.AWS_REGION}:${process.env.AWS_ACCOUNT_ID}:finding/${findingType}`; } return `bucketav:${process.env.CORE_STACK_NAME}:core:${process.env.AWS_REGION}:${process.env.AWS_ACCOUNT_ID}:finding/${findingType}/${bucket.name}`; } function toString(bucketName, bucketAccountId) { if (process.env.CROSS_ACCOUNT === "true") { return `${bucketName} (${bucketAccountId})`; } else { return bucketName; } } function truncateValue(input, maxLength) { const suffix = "..."; const str = String(input); const maxLengthWithoutSuffix = maxLength - suffix.length; if (str.length > maxLength) { return str.substr(0, maxLengthWithoutSuffix) + suffix; } return str; } async function publish(subject, message, data) { await sns.send(new import_client_sns2.PublishCommand({ TopicArn: process.env.INFRASTRUCTURE_ALARMS_TOPIC_ARN, Subject: subject, // max 100 chars long Message: JSON.stringify({ default: JSON.stringify(data), email: message }), MessageStructure: "json", MessageAttributes: { action: { DataType: "String", StringValue: data.action } } })); } function toActionLabel(action) { switch (action) { case "report": return "created"; case "close": return "closed"; case "archive": return "archived"; default: throw new Error("unsupported action"); } } function generateSubject(data) { let subject = "bucketAV Governance finding "; subject += truncateValue(toActionLabel(data.action), 8); subject += ": "; subject += truncateValue(process.env.CORE_STACK_NAME, 35); subject += " ("; subject += truncateValue(process.env.AWS_REGION, 10); subject += ", "; subject += truncateValue(process.env.AWS_ACCOUNT_ID, 12); subject += ")"; return truncateValue(subject, 100); } async function reportFinding(now, findingType) { const createdAt = now.toISOString(); const findingId = generateFindingId(findingType); const { findingTitle, findingDescription, remediationDescription, remediationUrl, ttlInSeconds = null } = FINDING_DATA[findingType]; const data = { action: "report", findings: [{ id: findingId, type: findingType, createdAt, updatedAt: createdAt }] }; let message = `AWS Region: ${process.env.AWS_REGION}\r `; message += `AWS Account ID: ${process.env.AWS_ACCOUNT_ID}\r `; message += `bucketAV Stack Name: ${process.env.CORE_STACK_NAME}\r `; message += "\r\n\r\n"; message += "==================================================\r\n"; message += `Finding created: ${findingTitle}\r `; message += "==================================================\r\n\r\n"; message += `${findingDescription}\r `; message += "\r\n\r\n"; message += `${remediationDescription} <${remediationUrl}>\r `; message += "\r\n\r\n"; message += "Do you need help with this email? Reach out to us \r\n"; message += "Warmly, The bucketAV Team.\r\n"; await publish(generateSubject(data), message, data); const item = { finding_id: { S: findingId }, finding_type: { S: findingType }, created_at: { S: createdAt } }; if (ttlInSeconds !== null) { item.ttl = { N: (now.getTime() / 1e3 + ttlInSeconds).toFixed(0) }; } await dynamodb.send(new import_client_dynamodb2.PutItemCommand({ TableName: process.env.TABLE_NAME, Item: item })); } async function reportFindings(now, buckets, findingType) { const createdAt = now.toISOString(); const { findingTitle, remediationDescription, remediationUrl, ttlInSeconds = null } = FINDING_DATA[findingType]; if (buckets.length > 0) { const data = { action: "report", findings: buckets.slice(0, 500).map((bucket) => ({ id: generateFindingId(findingType, bucket), type: findingType, bucketName: bucket.name, bucketAccountId: bucket.accountId, createdAt, updatedAt: createdAt })), truncated: buckets.length > 500 }; let message = `AWS Region: ${process.env.AWS_REGION}\r `; message += `AWS Account ID: ${process.env.AWS_ACCOUNT_ID}\r `; message += `bucketAV Stack Name: ${process.env.CORE_STACK_NAME}\r `; message += "\r\n\r\n"; message += "==================================================\r\n"; message += `Finding created: ${findingTitle}\r `; message += "==================================================\r\n\r\n"; message += "The following buckets are not protected:\r\n\r\n"; message += buckets.slice(0, 500).map((bucket) => toString(bucket.name, bucket.accountId)).join("\r\n"); if (buckets.length > 500) { message += "...\r\n"; } message += "\r\n\r\n"; message += `${remediationDescription} <${remediationUrl}>\r `; message += "\r\n\r\n"; message += "Do you need help with this email? Reach out to us \r\n"; message += "Warmly, The bucketAV Team.\r\n"; await publish(generateSubject(data), message, data); } for (const bucket of buckets) { const item = { finding_id: { S: generateFindingId(findingType, bucket) }, finding_type: { S: findingType }, bucket_name: { S: bucket.name }, bucket_account_id: { S: bucket.accountId }, created_at: { S: createdAt } }; if (ttlInSeconds !== null) { item.ttl = { N: (now.getTime() / 1e3 + ttlInSeconds).toFixed(0) }; } await dynamodb.send(new import_client_dynamodb2.PutItemCommand({ TableName: process.env.TABLE_NAME, Item: item })); } } async function closeFindings(now, reportedFindingsById, buckets, findingType) { const updatedAt = now.toISOString(); if (buckets.length > 0) { const { findingTitle } = FINDING_DATA[findingType]; const data = { action: "close", findings: buckets.slice(0, 500).map((bucket) => { const id = generateFindingId(findingType, bucket); return { id, type: findingType, bucketName: bucket.name, bucketAccountId: bucket.accountId, createdAt: reportedFindingsById[id].createdAt, updatedAt }; }), truncated: buckets.length > 500 }; let message = `AWS Region: ${process.env.AWS_REGION}\r `; message += `AWS Account ID: ${process.env.AWS_ACCOUNT_ID}\r `; message += `bucketAV Stack Name: ${process.env.CORE_STACK_NAME}\r `; message += "\r\n\r\n"; message += "==================================================\r\n"; message += `Finding closed: ${findingTitle}\r `; message += "==================================================\r\n\r\n"; message += "The following buckets are protected:\r\n\r\n"; message += buckets.slice(0, 500).map((bucket) => toString(bucket.name, bucket.accountId)).join("\r\n"); if (buckets.length > 500) { message += "...\r\n"; } message += "\r\n\r\n"; message += "Do you need help with this email? Reach out to us \r\n"; message += "Warmly, The bucketAV Team.\r\n"; await publish(generateSubject(data), message, data); } for (const bucket of buckets) { await dynamodb.send(new import_client_dynamodb2.DeleteItemCommand({ TableName: process.env.TABLE_NAME, Key: { finding_id: { S: generateFindingId(findingType, bucket) } } })); } } async function archiveFindings(now, reportedFindings, findingType) { const updatedAt = now.toISOString(); if (reportedFindings.length > 0) { const { findingTitle } = FINDING_DATA[findingType]; const data = { action: "archive", findings: reportedFindings.slice(0, 500).map((reportedFinding) => ({ id: reportedFinding.findingId, type: findingType, bucketName: reportedFinding.bucketName, bucketAccountId: reportedFinding.bucketAccountId, createdAt: reportedFinding.createdAt, updatedAt })), truncated: reportedFindings.length > 500 }; let message = `AWS Region: ${process.env.AWS_REGION}\r `; message += `AWS Account ID: ${process.env.AWS_ACCOUNT_ID}\r `; message += `bucketAV Stack Name: ${process.env.CORE_STACK_NAME}\r `; message += "\r\n\r\n"; message += "==================================================\r\n"; message += `Finding archived: ${findingTitle}\r `; message += "==================================================\r\n\r\n"; message += "The following buckets have been deleted and are therefore no longer unprotected:\r\n\r\n"; message += reportedFindings.slice(0, 500).map((reportedFinding) => toString(reportedFinding.bucketName, reportedFinding.bucketAccountId)).join("\r\n"); if (reportedFindings.length > 500) { message += "...\r\n"; } message += "\r\n\r\n"; message += "Do you need help with this email? Reach out to us \r\n"; message += "Warmly, The bucketAV Team.\r\n"; await publish(generateSubject(data), message, data); } for (const reportedFinding of reportedFindings) { await dynamodb.send(new import_client_dynamodb2.DeleteItemCommand({ TableName: process.env.TABLE_NAME, Key: { finding_id: { S: reportedFinding.findingId } } })); } } async function reportRealTimeFileScanMissingFindings(now, buckets) { await reportFindings(now, buckets, FINDING_TYPE_REAL_TIME_FILE_SCAN_MISSING); } async function closeRealTimeFileScanMissingFindings(now, reportedFindingsById, buckets) { await closeFindings(now, reportedFindingsById, buckets, FINDING_TYPE_REAL_TIME_FILE_SCAN_MISSING); } async function archiveRealTimeFileScanMissingFindings(now, reportedFindings) { await archiveFindings(now, reportedFindings, FINDING_TYPE_REAL_TIME_FILE_SCAN_MISSING); } async function reportScheduledBucketScanMissingFindings(now, buckets) { await reportFindings(now, buckets, FINDING_TYPE_SCHEDULED_BUCKET_SCAN_MISSING); } async function closeScheduledBucketScanMissingFindings(now, reportedFindingsById, buckets) { await closeFindings(now, reportedFindingsById, buckets, FINDING_TYPE_SCHEDULED_BUCKET_SCAN_MISSING); } async function archiveScheduledBucketScanMissingFindings(now, reportedFindings) { await archiveFindings(now, reportedFindings, FINDING_TYPE_SCHEDULED_BUCKET_SCAN_MISSING); } async function getMetricSum(metricName, start, end) { const { Datapoints: datapoints } = await cloudwatch.send(new import_client_cloudwatch.GetMetricStatisticsCommand({ Namespace: process.env.CORE_STACK_NAME, MetricName: metricName, Statistics: ["Sum"], StartTime: start, EndTime: end, Period: ONE_DAY_IN_SECONDS })); return datapoints?.reduce((acc, datapoint) => acc + datapoint.Sum, 0) || 0; } async function checkScanHistory(now, days) { const start = new Date(now.getTime() - 1e3 * ONE_DAY_IN_SECONDS * days); const clean = await getMetricSum("clean", start, now); const infected = await getMetricSum("infected", start, now); const no = await getMetricSum("no", start, now); if (clean + infected + no === 0) { await reportFinding(now, FINDING_TYPE_NO_SCAN_ACTIVITY); } } async function handler(event, context) { console.log(`Invoke: ${JSON.stringify(event)} ${JSON.stringify(context)}`); const buckets = process.env.CROSS_ACCOUNT === "true" ? await listCachedBuckets(dynamodb, `${process.env.CORE_STACK_NAME}-BucketCache`) : await listBuckets(s3, ssm, cloudformation, sns, eventbridge, eventbridge, process.env.AWS_PARTITION, process.env.AWS_REGION, process.env.AWS_ACCOUNT_ID, void 0, process.env.AWS_ACCOUNT_ID, process.env.CORE_STACK_NAME, process.env.SCAN_QUEUE_ARN); const bucketsByName = buckets?.reduce((acc, bucket) => { acc[bucket.name] = bucket; return acc; }, {}) || {}; const now = "time" in event ? new Date(event.time) : /* @__PURE__ */ new Date(); const reportedFindings = []; const reportedFindingsById = {}; const paginator = (0, import_client_dynamodb2.paginateScan)({ client: dynamodb }, { TableName: process.env.TABLE_NAME }); for await (const page of paginator) { for (const item of page.Items) { const finding = { findingId: item.finding_id.S, findingType: item.finding_type.S, bucketName: null, bucketAccountId: null, createdAt: item.created_at.S }; if ("bucket_name" in item) { finding.bucketName = item.bucket_name.S; if ("bucket_account_id" in item) { finding.bucketAccountId = item.bucket_account_id.S; } else { finding.bucketAccountId = process.env.AWS_ACCOUNT_ID; } } reportedFindings.push(finding); reportedFindingsById[item.finding_id.S] = finding; } } const latest = await fetchLatest(); const { runningVersion, latestVersion, outdatedAddons } = await checkVersion(cloudformation, ssm, dynamodb, process.env.PLATFORM, latest, process.env.CROSS_ACCOUNT === "true", process.env.AWS_PARTITION, process.env.AWS_REGION, process.env.AWS_ACCOUNT_ID, process.env.CORE_STACK_NAME, `${process.env.CORE_STACK_NAME}-AccountConnection`); if (runningVersion !== latestVersion) { if (!(generateFindingId(FINDING_TYPE_OUTDATED_CORE) in reportedFindingsById)) { await reportFinding(now, FINDING_TYPE_OUTDATED_CORE); } } if (outdatedAddons.length > 0) { if (!(generateFindingId(FINDING_TYPE_OUTDATED_ADDONS) in reportedFindingsById)) { await reportFinding(now, FINDING_TYPE_OUTDATED_ADDONS); } } await reportRealTimeFileScanMissingFindings(now, buckets.filter((bucket) => bucket.realtimeEnabled === false).filter((bucket) => !(generateFindingId(FINDING_TYPE_REAL_TIME_FILE_SCAN_MISSING, bucket) in reportedFindingsById))); await closeRealTimeFileScanMissingFindings(now, reportedFindingsById, buckets.filter((bucket) => bucket.realtimeEnabled === true).filter((bucket) => generateFindingId(FINDING_TYPE_REAL_TIME_FILE_SCAN_MISSING, bucket) in reportedFindingsById)); await archiveRealTimeFileScanMissingFindings(now, reportedFindings.filter((finding) => finding.findingType === FINDING_TYPE_REAL_TIME_FILE_SCAN_MISSING).filter((finding) => !(finding.bucketName in bucketsByName))); await reportScheduledBucketScanMissingFindings(now, buckets.filter((bucket) => bucket.scheduledEnabled === false).filter((bucket) => !(generateFindingId(FINDING_TYPE_SCHEDULED_BUCKET_SCAN_MISSING, bucket) in reportedFindingsById))); await closeScheduledBucketScanMissingFindings(now, reportedFindingsById, buckets.filter((bucket) => bucket.scheduledEnabled === true).filter((bucket) => generateFindingId(FINDING_TYPE_SCHEDULED_BUCKET_SCAN_MISSING, bucket) in reportedFindingsById)); await archiveScheduledBucketScanMissingFindings(now, reportedFindings.filter((finding) => finding.findingType === FINDING_TYPE_SCHEDULED_BUCKET_SCAN_MISSING).filter((finding) => !(finding.bucketName in bucketsByName))); if (!(generateFindingId(FINDING_TYPE_NO_SCAN_ACTIVITY) in reportedFindingsById)) { await checkScanHistory(now, FINDING_TYPE_NO_SCAN_ACTIVITY_DAYS); } } // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { handler }); DeadLetterConfig: TargetArn: Fn::GetAtt: - GovernanceLambdaDeadLetterQueue - Arn Environment: Variables: CORE_STACK_NAME: Ref: AWS::StackName AWS_PARTITION: Ref: AWS::Partition AWS_ACCOUNT_ID: Ref: AWS::AccountId SCAN_QUEUE_ARN: Fn::GetAtt: - ScanQueue - Arn INFRASTRUCTURE_ALARMS_TOPIC_ARN: Fn::GetAtt: - InfrastructureAlarmsTopic - TopicArn TABLE_NAME: Ref: GovernanceFindingTable CROSS_ACCOUNT: Fn::If: - HasCrossAccount - "true" - "false" PLATFORM: aws Handler: index.handler MemorySize: 1769 ReservedConcurrentExecutions: Fn::If: - HasGovernanceLambdaFunctionReservedConcurrentExecutions - Ref: GovernanceLambdaFunctionReservedConcurrentExecutions - Ref: AWS::NoValue Role: Fn::GetAtt: - GovernanceLambdaRole - Arn Runtime: nodejs22.x Timeout: 300 VpcConfig: Fn::If: - HasLambdaVpc - SecurityGroupIds: - Ref: LambdaSecurityGroup SubnetIds: Ref: LambdaSubnets - Ref: AWS::NoValue Metadata: bucketav::GovernanceLambdaVpcAllowPolicy: Fn::If: - HasGovernanceAndLambdaVpcAndNotMultiDeployment - Ref: GovernanceLambdaVpcAllowPolicy - Ref: AWS::NoValue Condition: HasGovernanceAndNotMultiDeployment GovernanceLambdaVpcAllowPolicy: Type: AWS::IAM::Policy Properties: PolicyDocument: Statement: - Effect: Allow Action: - ec2:CreateNetworkInterface - ec2:DeleteNetworkInterface - ec2:AssignPrivateIpAddresses - ec2:UnassignPrivateIpAddresses - ec2:DescribeNetworkInterfaces - ec2:DescribeSubnets Resource: - "*" PolicyName: vpc-allow Roles: - Ref: GovernanceLambdaRole Condition: HasGovernanceAndLambdaVpcAndNotMultiDeployment GovernanceLambdaVpcDenyPolicy: Type: AWS::IAM::Policy Properties: PolicyDocument: Statement: - Effect: Deny Action: - ec2:CreateNetworkInterface - ec2:DescribeNetworkInterfaces - ec2:DescribeSubnets - ec2:DeleteNetworkInterface - ec2:AssignPrivateIpAddresses - ec2:UnassignPrivateIpAddresses Resource: "*" Condition: ArnEquals: lambda:SourceFunctionArn: - Fn::GetAtt: - GovernanceLambdaFunction - Arn PolicyName: vpc-deny Roles: - Ref: GovernanceLambdaRole Condition: HasGovernanceAndLambdaVpcAndNotMultiDeployment GovernanceLambdaErrorsAlarm: Type: AWS::CloudWatch::Alarm Properties: AlarmActions: - Ref: InfrastructureAlarmsTopic AlarmDescription: Fn::Join: - "" - - "bucketAV Governance check failed. Check logs of AWS Lambda Function " - Ref: GovernanceLambdaFunction - "!" ComparisonOperator: GreaterThanOrEqualToThreshold Dimensions: - Name: FunctionName Value: Ref: GovernanceLambdaFunction EvaluationPeriods: 1 MetricName: Errors Namespace: AWS/Lambda Period: 600 Statistic: Sum Threshold: 1 TreatMissingData: notBreaching Condition: HasGovernanceAndNotMultiDeployment GovernanceLambdaThrottlesAlarm: Type: AWS::CloudWatch::Alarm Properties: AlarmActions: - Ref: InfrastructureAlarmsTopic AlarmDescription: bucketAV Governance check throttled. ComparisonOperator: GreaterThanOrEqualToThreshold Dimensions: - Name: FunctionName Value: Ref: GovernanceLambdaFunction EvaluationPeriods: 1 MetricName: Throttles Namespace: AWS/Lambda Period: 600 Statistic: Sum Threshold: 1 TreatMissingData: notBreaching Condition: HasGovernanceAndNotMultiDeployment GovernanceLambdaDeadLetterQueue: Type: AWS::SQS::Queue Properties: MessageRetentionPeriod: 1209600 SqsManagedSseEnabled: true Condition: HasGovernanceAndNotMultiDeployment GovernanceLambdaDeadLetterQueueAlarm: Type: AWS::CloudWatch::Alarm Properties: AlarmActions: - Ref: InfrastructureAlarmsTopic AlarmDescription: bucketAV overnance check has dead letter queue messages. ComparisonOperator: GreaterThanOrEqualToThreshold Dimensions: - Name: QueueName Value: Fn::GetAtt: - GovernanceLambdaDeadLetterQueue - QueueName EvaluationPeriods: 1 MetricName: ApproximateNumberOfMessagesVisible Namespace: AWS/SQS Period: 600 Statistic: Sum Threshold: 1 TreatMissingData: notBreaching Condition: HasGovernanceAndNotMultiDeployment GovernanceLambdaDeadLetterErrorsAlarm: Type: AWS::CloudWatch::Alarm Properties: AlarmActions: - Ref: InfrastructureAlarmsTopic AlarmDescription: bucketAV overnance check has dead letter queue errors. ComparisonOperator: GreaterThanOrEqualToThreshold Dimensions: - Name: FunctionName Value: Ref: GovernanceLambdaFunction EvaluationPeriods: 1 MetricName: DeadLetterErrors Namespace: AWS/Lambda Period: 600 Statistic: Sum Threshold: 1 TreatMissingData: notBreaching Condition: HasGovernanceAndNotMultiDeployment GovernanceLambdaLogGroup: Type: AWS::Logs::LogGroup Properties: LogGroupName: Fn::Join: - "" - - /aws/lambda/ - Ref: GovernanceLambdaFunction RetentionInDays: Ref: LogsRetentionInDays Condition: HasGovernanceAndNotMultiDeployment GovernanceLambdaPolicy: Type: AWS::IAM::Policy Properties: PolicyDocument: Statement: - Effect: Allow Action: - logs:CreateLogStream - logs:PutLogEvents Resource: Fn::GetAtt: - GovernanceLambdaLogGroup - Arn PolicyName: logs Roles: - Ref: GovernanceLambdaRole Condition: HasGovernanceAndNotMultiDeployment GovernanceLambdaCron: Type: AWS::Events::Rule Properties: ScheduleExpression: rate(1 day) Targets: - Arn: Fn::GetAtt: - GovernanceLambdaFunction - Arn Id: lambda DependsOn: - GovernanceLambdaPolicy Condition: HasGovernanceAndNotMultiDeployment GovernanceLambdaPermission: Type: AWS::Lambda::Permission Properties: Action: lambda:InvokeFunction FunctionName: Ref: GovernanceLambdaFunction Principal: events.amazonaws.com SourceArn: Fn::GetAtt: - GovernanceLambdaCron - Arn Condition: HasGovernanceAndNotMultiDeployment ServiceDiscoveryVersion: Type: AWS::SSM::Parameter Properties: Description: bucketAV managed value. DO NOT CHANGE! Name: Fn::Join: - "" - - /bucketAV/ - Ref: AWS::StackName - /Version Type: String Value: 2.31.0 ServiceDiscoveryFulfillmentOption: Type: AWS::SSM::Parameter Properties: Description: bucketAV managed value. DO NOT CHANGE! Name: Fn::Join: - "" - - /bucketAV/ - Ref: AWS::StackName - /FulfillmentOption Type: String Value: shared-vpc ServiceDiscoveryEngine: Type: AWS::SSM::Parameter Properties: Description: bucketAV managed value. DO NOT CHANGE! Name: Fn::Join: - "" - - /bucketAV/ - Ref: AWS::StackName - /Engine Type: String Value: clamav ServiceDiscoveryCrossAccount: Type: AWS::SSM::Parameter Properties: Description: bucketAV managed value. DO NOT CHANGE! Name: Fn::Join: - "" - - /bucketAV/ - Ref: AWS::StackName - /CrossAccount Type: String Value: Fn::If: - HasCrossAccount - "true" - "false" Outputs: InfrastructureAlarmsTopicArn: Description: The ARN of the Infrastructure Alarms Topic. Value: Ref: InfrastructureAlarmsTopic Export: Name: Fn::Join: - "" - - Ref: AWS::StackName - -InfrastructureAlarmsTopicArn Condition: HasNotMultiDeployment InfrastructureAlarmsTopicName: Description: The name of the Infrastructure Alarms Topic. Value: Fn::GetAtt: - InfrastructureAlarmsTopic - TopicName Export: Name: Fn::Join: - "" - - Ref: AWS::StackName - -InfrastructureAlarmsTopicName Condition: HasNotMultiDeployment FindingsTopicArn: Description: The ARN of the Findings Topic. Value: Ref: FindingsTopic Export: Name: Fn::Join: - "" - - Ref: AWS::StackName - -FindingsTopicArn Condition: HasNotMultiDeployment InternalFindingsTopicArn: Description: The ARN of the Findings Topic. Value: Ref: FindingsTopic Condition: HasMultiDeployment FindingsTopicName: Description: The name of the Findings Topic. Value: Fn::GetAtt: - FindingsTopic - TopicName Export: Name: Fn::Join: - "" - - Ref: AWS::StackName - -FindingsTopicName Condition: HasNotMultiDeployment ScanQueueArn: Description: The ARN of the Scan Queue. Value: Fn::GetAtt: - ScanQueue - Arn Export: Name: Fn::Join: - "" - - Ref: AWS::StackName - -ScanQueueArn Condition: HasNotMultiDeployment InternalScanQueueArn: Description: The ARN of the Scan Queue. Value: Fn::GetAtt: - ScanQueue - Arn Condition: HasMultiDeployment ScanQueueName: Description: The name of the Scan Queue. Value: Fn::GetAtt: - ScanQueue - QueueName Export: Name: Fn::Join: - "" - - Ref: AWS::StackName - -ScanQueueName Condition: HasNotMultiDeployment ScanQueueUrl: Description: The URL of the Scan Queue. Value: Ref: ScanQueue Export: Name: Fn::Join: - "" - - Ref: AWS::StackName - -ScanQueueUrl Condition: HasNotMultiDeployment InternalScanQueueUrl: Description: The URL of the Scan Queue. Value: Ref: ScanQueue Condition: HasMultiDeployment DeadLetterQueueArn: Description: The ARN of the Dead Letter Queue. Value: Fn::GetAtt: - DeadLetterQueue - Arn Condition: HasNotMultiDeployment DeadLetterQueueName: Description: The name of the Dead Letter Queue. Value: Fn::GetAtt: - DeadLetterQueue - QueueName Condition: HasNotMultiDeployment DeadLetterQueueUrl: Description: The URL of the Dead Letter Queue. Value: Ref: DeadLetterQueue Condition: HasNotMultiDeployment AccountConnectionLambdaDeadLetterQueueName: Description: The name of the Dead Letter Queue used by the Lambda function. Value: Fn::GetAtt: - AccountConnectionLambdaDeadLetterQueue - QueueName Condition: HasCrossAccountAndNotMultiDeployment RefreshServiceDiscoveryLambdaDeadLetterQueueName: Description: The name of the Dead Letter Queue used by the Lambda function. Value: Fn::GetAtt: - RefreshServiceDiscoveryLambdaDeadLetterQueue - QueueName Condition: HasCrossAccountAndNotMultiDeployment DashboardLambdaRoleArn: Description: The ARN of the Dashboard Role. Value: Fn::GetAtt: - DashboardLambdaRole - Arn Export: Name: Fn::Join: - "" - - Ref: AWS::StackName - -DashboardLambdaRoleArn Condition: HasNotMultiDeployment GovernanceLambdaDeadLetterQueueName: Description: The name of the Dead Letter Queue used by the Lambda function. Value: Fn::GetAtt: - GovernanceLambdaDeadLetterQueue - QueueName Condition: HasGovernanceAndNotMultiDeployment FulfillmentOption: Description: Fulfillment option. Value: shared-vpc Version: Description: bucketAV version. Value: 2.31.0 Platform: Description: bucketAV platform. Value: aws Engine: Description: bucketAV engine. Value: clamav StackName: Description: Stack name. Value: Ref: AWS::StackName ScanRoleArn: Description: The ARN of the Scan Role. Value: Fn::GetAtt: - ScanIAMRole - Arn Export: Name: Fn::Join: - "" - - Ref: AWS::StackName - -ScanRoleArn Condition: HasNotMultiDeployment ScanAutoScalingGroupName: Description: Name of the scan ASG. Value: Ref: ScanAutoScalingGroup Condition: HasNotMultiDeployment FallbackAutoScalingGroupName: Description: Name of the fallback ASG. Value: Ref: FallbackAutoScalingGroup Condition: HasOnDemandFallbackAndNotMultiDeployment