--- # 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 Cloudflare R2 powered by Sophos (shared VPC) - Antivirus protection for Cloudflare R2 AWSTemplateFormatVersion: "2010-09-09" Metadata: AWS::CloudFormation::Interface: ParameterGroups: - Label: default: Required Parameters Parameters: - KeyName - InfrastructureAlarmsEmail - CloudflareAccountId - CloudflareApiToken - CloudflareAccessKeyId - CloudflareAccessKeySecret - VPC - Subnets - AssociatePublicIpAddress - Label: default: Scan Parameters Parameters: - DeleteInfectedFiles - ReportEventBridge - ScanDelayInSeconds - SignCallbackInvocations - SophosLiveProtectionCloudLookups - Label: default: VPC Parameters Parameters: - SSHIngressCidrIp - SSHIngressSecurityGroupId - SecurityGroupIds - LambdaSubnets - HttpsProxy - Label: default: Auto Scaling Group Parameters Parameters: - AutoScalingMinSize - AutoScalingMaxSize - CapacityStrategy - Label: default: EC2 Parameters Parameters: - AMI2110 - InstanceType - VolumeSize - VolumeIops - VolumeThroughput - VolumeKmsKeyId - LogsRetentionInDays - SystemsManagerAccess - Label: default: Permissions Parameters Parameters: - ManagedPolicyArns - PermissionsBoundary - Label: default: Lambda Parameters Parameters: - AutoScalingGroupCalculatorFunctionReservedConcurrentExecutions - CloudflareQueueFunctionReservedConcurrentExecutions - PrivateKeyGeneratorFunctionReservedConcurrentExecutions - DashboardLambdaFunctionReservedConcurrentExecutions Parameters: DeleteInfectedFiles: Type: String Default: 'true' AllowedValues: - 'true' - 'false' Description: Automatically delete infected files. 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. 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). 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. CloudflareAccountId: Type: String Description: Cloudflare account ID. MinLength: 1 CloudflareApiToken: Type: String Description: Cloudflare API token. MinLength: 1 NoEcho: true CloudflareAccessKeyId: Type: String Description: Cloudflare access key ID. MinLength: 1 CloudflareAccessKeySecret: Type: String Description: Cloudflare access key secret. MinLength: 1 NoEcho: true 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. HttpsProxy: Type: String Default: '' Description: Optional forward proxy for outbound HTTPS communication to https://metering.marketplace.REGION.amazonaws.com, https://REGION.savmirror.bucketav.com, CLOUDFLARE_ACCOUNT_ID.r2.cloudflarestorage.com. You must add a security group in parameter SecurityGroupIds to allow outbound communication with your reverse proxy. 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 CloudflareQueueFunctionReservedConcurrentExecutions: 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 InstanceType: Type: String Default: m6i.large AllowedValues: - t3a.nano - t3a.micro - t3a.small - t3a.medium - t3a.large - t3a.xlarge - t3a.2xlarge - t3.nano - t3.micro - t3.small - t3.medium - t3.large - t3.xlarge - t3.2xlarge - 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 - m6i.large - m6i.xlarge - m6i.2xlarge - m6i.4xlarge - m6i.8xlarge - m6i.12xlarge - m6i.16xlarge - m6i.24xlarge - m6i.32xlarge - m6a.large - m6a.xlarge - m6a.2xlarge - m6a.4xlarge - m6a.8xlarge - m6a.12xlarge - m6a.16xlarge - m6a.24xlarge - m6a.32xlarge - m6a.48xlarge - m7i.large - m7i.xlarge - m7i.2xlarge - m7i.4xlarge - m7i.8xlarge - m7i.12xlarge - m7i.16xlarge - m7i.24xlarge - m7i.48xlarge - m7a.medium - m7a.large - m7a.xlarge - m7a.2xlarge - m7a.4xlarge - m7a.8xlarge - m7a.12xlarge - m7a.16xlarge - m7a.24xlarge - m7a.32xlarge - m7a.48xlarge - c5.large - c5.xlarge - c5.2xlarge - c5.4xlarge - c5.9xlarge - c5.12xlarge - c5.18xlarge - c5.24xlarge - c5a.large - c5a.xlarge - c5a.2xlarge - c5a.4xlarge - c5a.8xlarge - c5a.12xlarge - c5a.16xlarge - c5a.24xlarge - c6i.large - c6i.xlarge - c6i.2xlarge - c6i.4xlarge - c6i.8xlarge - c6i.12xlarge - c6i.16xlarge - c6i.24xlarge - c6i.32xlarge - c6a.large - c6a.xlarge - c6a.2xlarge - c6a.4xlarge - c6a.8xlarge - c6a.12xlarge - c6a.16xlarge - c6a.24xlarge - c6a.32xlarge - c6a.48xlarge - c7i.large - c7i.xlarge - c7i.2xlarge - c7i.4xlarge - c7i.8xlarge - c7i.12xlarge - c7i.16xlarge - c7i.24xlarge - c7i.48xlarge - c7a.medium - c7a.large - c7a.xlarge - c7a.2xlarge - c7a.4xlarge - c7a.8xlarge - c7a.12xlarge - c7a.16xlarge - c7a.24xlarge - c7a.32xlarge - c7a.48xlarge - m5zn.large - m5zn.xlarge - m5zn.2xlarge - m5zn.3xlarge - m5zn.6xlarge - m5zn.12xlarge Description: Specifies the instance type of the EC2 instance. SophosLiveProtectionCloudLookups: Type: String Default: 'false' AllowedValues: - 'true' - 'false' Description: Live Protection cloud lookups use Sophos' SXL technology and infrastructure to enable the antivirus engine to determine whether a suspicious file is malicious or clean by querying Sophos's extensive database of both malware and clean files. SXL improves detection rates and lowers false-positives. The file hash is shared with Sophos if you enable this feature! VolumeSize: Type: Number Default: 32 ConstraintDescription: Must be in the range [32-5136] Description: The size of the EBS volume, in gibibytes (GiB). You can only scan files that are smaller than VolumeSize-16. Max S3 file size 5120 GiB. MaxValue: 5136 MinValue: 32 VolumeIops: Type: Number Default: 3000 ConstraintDescription: Must be in the range [3000-16000] Description: The provisioned I/O operations per second (IOPS) MaxValue: 16000 MinValue: 3000 VolumeThroughput: Type: Number Default: 125 ConstraintDescription: Must be in the range [125-1000] Description: The provisioned throughput per second in MiB. MaxValue: 1000 MinValue: 125 AMI2110: Type: AWS::SSM::Parameter::Value Default: /aws/service/marketplace/prod-7sfxdj6o4c2qa/2.11.0 AllowedValues: - /aws/service/marketplace/prod-7sfxdj6o4c2qa/2.11.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-7sfxdj6o4c2qa/2.11.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-7sfxdj6o4c2qa/2.11.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 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 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.nano - t3.micro - t3.small - t3.medium - t3.large - t3.xlarge - t3.2xlarge - m6i.large - m6i.xlarge - m6i.2xlarge - m6i.4xlarge - m6i.8xlarge - m6i.12xlarge - m6i.16xlarge - m6i.24xlarge - m6i.32xlarge - m7i.large - m7i.xlarge - m7i.2xlarge - m7i.4xlarge - m7i.8xlarge - m7i.12xlarge - m7i.16xlarge - m7i.24xlarge - m7i.48xlarge - c6i.large - c6i.xlarge - c6i.2xlarge - c6i.4xlarge - c6i.8xlarge - c6i.12xlarge - c6i.16xlarge - c6i.24xlarge - c6i.32xlarge - c7i.large - c7i.xlarge - c7i.2xlarge - c7i.4xlarge - c7i.8xlarge - c7i.12xlarge - c7i.16xlarge - c7i.24xlarge - c7i.48xlarge - Ref: InstanceType AssertDescription: Region ap-southeast-5 supports t3.nano, t3.micro, t3.small, t3.medium, t3.large, t3.xlarge, t3.2xlarge, m6i.large, m6i.xlarge, m6i.2xlarge, m6i.4xlarge, m6i.8xlarge, m6i.12xlarge, m6i.16xlarge, m6i.24xlarge, m6i.32xlarge, m7i.large, m7i.xlarge, m7i.2xlarge, m7i.4xlarge, m7i.8xlarge, m7i.12xlarge, m7i.16xlarge, m7i.24xlarge, m7i.48xlarge, c6i.large, c6i.xlarge, c6i.2xlarge, c6i.4xlarge, c6i.8xlarge, c6i.12xlarge, c6i.16xlarge, c6i.24xlarge, c6i.32xlarge, c7i.large, c7i.xlarge, c7i.2xlarge, c7i.4xlarge, c7i.8xlarge, c7i.12xlarge, c7i.16xlarge, c7i.24xlarge, c7i.48xlarge InstanceTypeAsiaPacificThailand: RuleCondition: Fn::Equals: - Ref: AWS::Region - ap-southeast-7 Assertions: - Assert: Fn::Contains: - - t3.nano - t3.micro - t3.small - t3.medium - t3.large - t3.xlarge - t3.2xlarge - m6i.large - m6i.xlarge - m6i.2xlarge - m6i.4xlarge - m6i.8xlarge - m6i.12xlarge - m6i.16xlarge - m6i.24xlarge - m6i.32xlarge - m7i.large - m7i.xlarge - m7i.2xlarge - m7i.4xlarge - m7i.8xlarge - m7i.12xlarge - m7i.16xlarge - m7i.24xlarge - m7i.48xlarge - c6i.large - c6i.xlarge - c6i.2xlarge - c6i.4xlarge - c6i.8xlarge - c6i.12xlarge - c6i.16xlarge - c6i.24xlarge - c6i.32xlarge - c7i.large - c7i.xlarge - c7i.2xlarge - c7i.4xlarge - c7i.8xlarge - c7i.12xlarge - c7i.16xlarge - c7i.24xlarge - c7i.48xlarge - Ref: InstanceType AssertDescription: Region ap-southeast-7 supports t3.nano, t3.micro, t3.small, t3.medium, t3.large, t3.xlarge, t3.2xlarge, m6i.large, m6i.xlarge, m6i.2xlarge, m6i.4xlarge, m6i.8xlarge, m6i.12xlarge, m6i.16xlarge, m6i.24xlarge, m6i.32xlarge, m7i.large, m7i.xlarge, m7i.2xlarge, m7i.4xlarge, m7i.8xlarge, m7i.12xlarge, m7i.16xlarge, m7i.24xlarge, m7i.48xlarge, c6i.large, c6i.xlarge, c6i.2xlarge, c6i.4xlarge, c6i.8xlarge, c6i.12xlarge, c6i.16xlarge, c6i.24xlarge, c6i.32xlarge, c7i.large, c7i.xlarge, c7i.2xlarge, c7i.4xlarge, c7i.8xlarge, c7i.12xlarge, c7i.16xlarge, c7i.24xlarge, c7i.48xlarge InstanceTypeMexicoCentral: RuleCondition: Fn::Equals: - Ref: AWS::Region - mx-central-1 Assertions: - Assert: Fn::Contains: - - t3.nano - t3.micro - t3.small - t3.medium - t3.large - t3.xlarge - t3.2xlarge - m6i.large - m6i.xlarge - m6i.2xlarge - m6i.4xlarge - m6i.8xlarge - m6i.12xlarge - m6i.16xlarge - m6i.24xlarge - m6i.32xlarge - m7i.large - m7i.xlarge - m7i.2xlarge - m7i.4xlarge - m7i.8xlarge - m7i.12xlarge - m7i.16xlarge - m7i.24xlarge - m7i.48xlarge - c6i.large - c6i.xlarge - c6i.2xlarge - c6i.4xlarge - c6i.8xlarge - c6i.12xlarge - c6i.16xlarge - c6i.24xlarge - c6i.32xlarge - c7i.large - c7i.xlarge - c7i.2xlarge - c7i.4xlarge - c7i.8xlarge - c7i.12xlarge - c7i.16xlarge - c7i.24xlarge - c7i.48xlarge - Ref: InstanceType AssertDescription: Region mx-central-1 supports t3.nano, t3.micro, t3.small, t3.medium, t3.large, t3.xlarge, t3.2xlarge, m6i.large, m6i.xlarge, m6i.2xlarge, m6i.4xlarge, m6i.8xlarge, m6i.12xlarge, m6i.16xlarge, m6i.24xlarge, m6i.32xlarge, m7i.large, m7i.xlarge, m7i.2xlarge, m7i.4xlarge, m7i.8xlarge, m7i.12xlarge, m7i.16xlarge, m7i.24xlarge, m7i.48xlarge, c6i.large, c6i.xlarge, c6i.2xlarge, c6i.4xlarge, c6i.8xlarge, c6i.12xlarge, c6i.16xlarge, c6i.24xlarge, c6i.32xlarge, c7i.large, c7i.xlarge, c7i.2xlarge, c7i.4xlarge, c7i.8xlarge, c7i.12xlarge, c7i.16xlarge, c7i.24xlarge, c7i.48xlarge Conditions: HasVolumeKmsKeyId: Fn::Not: - Fn::Equals: - Ref: VolumeKmsKeyId - "" HasSystemsManagerAccess: Fn::Equals: - Ref: SystemsManagerAccess - "true" 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 - "" 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 - "" HasAutoScalingGroupCalculatorFunctionReservedConcurrentExecutions: Fn::Not: - Fn::Equals: - Ref: AutoScalingGroupCalculatorFunctionReservedConcurrentExecutions - 0 HasCloudflareQueueFunctionReservedConcurrentExecutions: Fn::Not: - Fn::Equals: - Ref: CloudflareQueueFunctionReservedConcurrentExecutions - 0 HasPrivateKeyGeneratorFunctionReservedConcurrentExecutions: Fn::Not: - Fn::Equals: - Ref: PrivateKeyGeneratorFunctionReservedConcurrentExecutions - 0 HasSignCallbackInvocationsAndLambdaVpc: Fn::And: - Condition: HasSignCallbackInvocations - Condition: HasLambdaVpc HasDashboardLambdaFunctionReservedConcurrentExecutions: Fn::Not: - Fn::Equals: - Ref: DashboardLambdaFunctionReservedConcurrentExecutions - 0 Resources: CloudflareApiTokenSecret: Type: AWS::SecretsManager::Secret Properties: SecretString: Ref: CloudflareApiToken CloudflareAccessKeySecretSecret: Type: AWS::SecretsManager::Secret Properties: SecretString: Ref: CloudflareAccessKeySecret ServiceDiscoveryCloudflareAccessKeyId: Type: AWS::SSM::Parameter Properties: Description: bucketAV managed value. DO NOT CHANGE! Name: Fn::Join: - "" - - /bucketAV/ - Ref: AWS::StackName - /CloudflareAccessKeyId Type: String Value: Ref: CloudflareAccessKeyId ServiceDiscoveryCloudflareAccessKeySecretArn: Type: AWS::SSM::Parameter Properties: Description: bucketAV managed value. DO NOT CHANGE! Name: Fn::Join: - "" - - /bucketAV/ - Ref: AWS::StackName - /CloudflareAccessKeySecretArn Type: String Value: Ref: CloudflareAccessKeySecretSecret ServiceDiscoveryCloudflareR2EndpointHostname: Type: AWS::SSM::Parameter Properties: Description: bucketAV managed value. DO NOT CHANGE! Name: Fn::Join: - "" - - /bucketAV/ - Ref: AWS::StackName - /CloudflareR2EndpointHostname Type: String Value: Fn::Join: - "" - - Ref: CloudflareAccountId - .r2.cloudflarestorage.com 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 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 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 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 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 CloudflareUser: Type: AWS::IAM::User Properties: PermissionsBoundary: Fn::If: - HasPermissionsBoundary - Ref: PermissionsBoundary - Ref: AWS::NoValue Policies: - PolicyDocument: Statement: - Effect: Allow Action: sqs:SendMessage Resource: Fn::GetAtt: - ScanQueue - Arn PolicyName: sqs CloudflareAccessKey: Type: AWS::IAM::AccessKey Properties: UserName: Ref: CloudflareUser CloudflareQueueRole: 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: secretsmanager:GetSecretValue Resource: Ref: CloudflareApiTokenSecret PolicyName: core CloudflareQueueFunction: 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/cloudflare-queue.js var cloudflare_queue_exports = {}; __export(cloudflare_queue_exports, { handler: () => handler }); module.exports = __toCommonJS(cloudflare_queue_exports); var import_client_secrets_manager2 = require("@aws-sdk/client-secrets-manager"); // 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/fetch-retry.js function fetch_retry_default(fetch3, defaults) { defaults = defaults || {}; if (typeof fetch3 !== "function") { throw new ArgumentError("fetch must be a function"); } if (typeof defaults !== "object") { throw new ArgumentError("defaults must be an object"); } if (defaults.retries !== void 0 && !isPositiveInteger(defaults.retries)) { throw new ArgumentError("retries must be a positive integer"); } if (defaults.retryDelay !== void 0 && !isPositiveInteger(defaults.retryDelay) && typeof defaults.retryDelay !== "function") { throw new ArgumentError("retryDelay must be a positive integer or a function returning a positive integer"); } if (defaults.retryOn !== void 0 && !Array.isArray(defaults.retryOn) && typeof defaults.retryOn !== "function") { throw new ArgumentError("retryOn property expects an array or function"); } var baseDefaults = { retries: 3, retryDelay: 1e3, retryOn: [] }; defaults = Object.assign(baseDefaults, defaults); return function fetchRetry(input, init) { var retries = defaults.retries; var retryDelay = defaults.retryDelay; var retryOn = defaults.retryOn; if (init && init.retries !== void 0) { if (isPositiveInteger(init.retries)) { retries = init.retries; } else { throw new ArgumentError("retries must be a positive integer"); } } if (init && init.retryDelay !== void 0) { if (isPositiveInteger(init.retryDelay) || typeof init.retryDelay === "function") { retryDelay = init.retryDelay; } else { throw new ArgumentError("retryDelay must be a positive integer or a function returning a positive integer"); } } if (init && init.retryOn) { if (Array.isArray(init.retryOn) || typeof init.retryOn === "function") { retryOn = init.retryOn; } else { throw new ArgumentError("retryOn property expects an array or function"); } } return new Promise(function(resolve, reject) { var wrappedFetch = function(attempt) { var _input = typeof Request !== "undefined" && input instanceof Request ? input.clone() : input; fetch3(_input, init).then(function(response) { if (Array.isArray(retryOn) && retryOn.indexOf(response.status) === -1) { resolve(response); } else if (typeof retryOn === "function") { try { return Promise.resolve(retryOn(attempt, null, response)).then(function(retryOnResponse) { if (retryOnResponse) { retry(attempt, null, response); } else { resolve(response); } }).catch(reject); } catch (error) { reject(error); } } else { if (attempt < retries) { retry(attempt, null, response); } else { resolve(response); } } }).catch(function(error) { if (typeof retryOn === "function") { try { Promise.resolve(retryOn(attempt, error, null)).then(function(retryOnResponse) { if (retryOnResponse) { retry(attempt, error, null); } else { reject(error); } }).catch(function(error2) { reject(error2); }); } catch (error2) { reject(error2); } } else if (attempt < retries) { retry(attempt, error, null); } else { reject(error); } }); }; function retry(attempt, error, response) { var delay = typeof retryDelay === "function" ? retryDelay(attempt, error, response) : retryDelay; setTimeout(function() { wrappedFetch(++attempt); }, delay); } wrappedFetch(0); }); }; } function isPositiveInteger(value) { return Number.isInteger(value) && value >= 0; } function ArgumentError(message) { this.name = "ArgumentError"; this.message = message; } // lambda/lib-cloudflare.js var BASIC_WAIT_TIME_IN_MILLIS = 300; var MAX_WAIT_TIME_IN_MILLIS = 5e3; var DEFAULT_TIMEOUT_IN_MILLIS = 1e4; async function fetch2(url, options) { options.signal = AbortSignal.timeout(DEFAULT_TIMEOUT_IN_MILLIS); return fetch_retry_default(global.fetch, { retryDelay: (attempt) => { const delay = Math.floor(Math.random() * Math.min(MAX_WAIT_TIME_IN_MILLIS, BASIC_WAIT_TIME_IN_MILLIS * Math.pow(2, attempt))); options.signal = AbortSignal.timeout(DEFAULT_TIMEOUT_IN_MILLIS + delay); return delay; }, retryOn: (attempt, error, response) => { if (attempt >= 3) { return false; } if (error || response.status === 429 || response.status >= 500) { return true; } } })(url, options); } async function createQueueConsumer(apiToken, accountId, queueId, scriptName, dlqName) { const response = await fetch2(`https://api.cloudflare.com/client/v4/accounts/${accountId}/queues/${queueId}/consumers`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiToken}` }, body: JSON.stringify({ type: "worker", dead_letter_queue: dlqName, script_name: scriptName, settings: { batch_size: 10, // SQS supports no more than 10 in SendMessageBatch max_retries: 3, max_wait_time_ms: 1e3, retry_delay: 1 // in seconds } }) }); if (response.status === 400) { const body2 = await response.json(); if (body2.errors.some((e) => e.code === 11004)) { return; } else { console.log("response url", response.url); console.log("response status", response.status); console.log("response", body2); throw new Error("unexpected error code"); } } if (response.status !== 200) { console.log("response url", response.url); console.log("response status", response.status); console.log("response", await response.text()); throw new Error("unexpected status code"); } const body = await response.json(); if (body.success !== true) { console.log("body", body); throw new Error(`unsuccessful request: ${body.errors.map((error) => `${error.code}: ${error.message}`).join(", ")}`); } } async function listQueueConsumers(apiToken, accountId, queueId) { const response = await fetch2(`https://api.cloudflare.com/client/v4/accounts/${accountId}/queues/${queueId}/consumers`, { method: "GET", headers: { Authorization: `Bearer ${apiToken}` } }); if (response.status === 404) { return []; } if (response.status !== 200) { console.log("response url", response.url); console.log("response status", response.status); console.log("response", await response.text()); throw new Error("unexpected status code"); } const body = await response.json(); if (body.success !== true) { console.log("body", body); throw new Error(`unsuccessful request: ${body.errors.map((error) => `${error.code}: ${error.message}`).join(", ")}`); } if (body.result_info?.total_pages > 1) { throw new Error("implement paging"); } return body.result; } async function deleteQueueConsumer(apiToken, accountId, queueId, consumerId) { const response = await fetch2(`https://api.cloudflare.com/client/v4/accounts/${accountId}/queues/${queueId}/consumers/${consumerId}`, { method: "DELETE", headers: { Authorization: `Bearer ${apiToken}` } }); if (response.status === 404) { return; } if (response.status !== 200) { console.log("response url", response.url); console.log("response status", response.status); console.log("response", await response.text()); throw new Error("unexpected status code"); } const body = await response.json(); if (body.success !== true) { console.log("body", body); throw new Error(`unsuccessful request: ${body.errors.map((error) => `${error.code}: ${error.message}`).join(", ")}`); } } async function listQueues(apiToken, accountId) { const queues = []; let page = 1; while (true) { const response = await fetch2(`https://api.cloudflare.com/client/v4/accounts/${accountId}/queues?per_page=100&page=${page}`, { method: "GET", headers: { Authorization: `Bearer ${apiToken}` } }); if (response.status !== 200) { console.log("response url", response.url); console.log("response status", response.status); console.log("response", await response.text()); throw new Error("unexpected status code"); } const body = await response.json(); if (body.success !== true) { console.log("body", body); throw new Error(`unsuccessful request: ${body.errors.map((error) => `${error.code}: ${error.message}`).join(", ")}`); } queues.push(...body.result); if (page === body.result_info.total_pages) { break; } else { page++; } } return queues; } async function createQueue(apiToken, accountId, queueName) { const response = await fetch2(`https://api.cloudflare.com/client/v4/accounts/${accountId}/queues`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiToken}` }, body: JSON.stringify({ queue_name: queueName }) }); if (response.status === 409) { const body2 = await response.json(); if (body2.errors.some((e) => e.code === 11009)) { const queues = await listQueues(apiToken, accountId); const queue = queues.find((queue2) => queue2.queue_name === queueName); if (queue === void 0) { throw new Error("Queue not found after error 11009"); } return queue.queue_id; } else { console.log("response url", response.url); console.log("response status", response.status); console.log("response", body2); throw new Error("unexpected error code"); } } if (response.status !== 200) { console.log("response url", response.url); console.log("response status", response.status); console.log("response", await response.text()); throw new Error("unexpected status code"); } const body = await response.json(); if (body.success !== true) { console.log("body", body); throw new Error(`unsuccessful request: ${body.errors.map((error) => `${error.code}: ${error.message}`).join(", ")}`); } else { const queueId = body.result.queue_id; return queueId; } } async function deleteQueue(apiToken, accountId, queueId) { const response = await fetch2(`https://api.cloudflare.com/client/v4/accounts/${accountId}/queues/${queueId}`, { method: "DELETE", headers: { Authorization: `Bearer ${apiToken}` } }); if (response.status === 404) { return; } if (response.status === 400) { const body2 = await response.json(); if (body2.errors.some((e) => e.code === 11017)) { return; } else { console.log("response url", response.url); console.log("response status", response.status); console.log("response", body2); throw new Error("unexpected error code"); } } if (response.status !== 200) { console.log("response url", response.url); console.log("response status", response.status); console.log("response", await response.text()); throw new Error("unexpected status code"); } const body = await response.json(); if (body.success !== true) { console.log("body", body); throw new Error(`unsuccessful request: ${body.errors.map((error) => `${error.code}: ${error.message}`).join(", ")}`); } } async function putWorker(apiToken, accountId, scriptName, scriptCode, environmentVariables, secrets) { const data = new FormData(); data.append("worker.js", new Blob([scriptCode], { type: "application/javascript+module" }), "worker.js"); data.append("metadata", JSON.stringify({ bindings: [ ...Object.keys(environmentVariables).map((key) => ({ name: key, text: environmentVariables[key], type: "plain_text" })), ...Object.keys(secrets).map((key) => ({ name: key, text: secrets[key], type: "secret_text" })) ], compatibility_date: "2024-07-08", main_module: "worker.js" })); const response = await fetch2(`https://api.cloudflare.com/client/v4/accounts/${accountId}/workers/scripts/${scriptName}`, { method: "PUT", headers: { Authorization: `Bearer ${apiToken}` }, body: data }); if (response.status !== 200) { console.log("response url", response.url); console.log("response status", response.status); console.log("response", await response.text()); throw new Error("unexpected status code"); } const body = await response.json(); if (body.success !== true) { console.log("body", body); throw new Error(`unsuccessful request: ${body.errors.map((error) => `${error.code}: ${error.message}`).join(", ")}`); } } async function deleteWorker(apiToken, accountId, scriptName) { const response = await fetch2(`https://api.cloudflare.com/client/v4/accounts/${accountId}/workers/scripts/${scriptName}`, { method: "DELETE", headers: { Authorization: `Bearer ${apiToken}` } }); if (response.status === 404) { return; } if (response.status !== 200) { console.log("response url", response.url); console.log("response status", response.status); console.log("response", await response.text()); throw new Error("unexpected status code"); } const body = await response.json(); if (body.success !== true) { console.log("body", body); throw new Error(`unsuccessful request: ${body.errors.map((error) => `${error.code}: ${error.message}`).join(", ")}`); } } // lambda/lib.js var paginateListRuleNamesByTarget = createPaginator(import_client_eventbridge.EventBridgeClient, import_client_eventbridge.ListRuleNamesByTargetCommand, "NextToken", "NextToken", "Limit"); async function fetchCloudflareApiToken(secretsmanager2, cloudflareApiTokenSecretArn) { const { SecretString: cloudflareApiToken } = await secretsmanager2.send(new import_client_secrets_manager.GetSecretValueCommand({ SecretId: cloudflareApiTokenSecretArn })); return cloudflareApiToken; } 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/cloudflare-queue.js var secretsmanager = new import_client_secrets_manager2.SecretsManagerClient({ apiVersion: "2017-10-17" }); var CLOUDFLARE_ACCOUNT_ID = process.env.CLOUDFLARE_ACCOUNT_ID; var CLOUDFLARE_API_TOKEN_SECRET_ARN = process.env.CLOUDFLARE_API_TOKEN_SECRET_ARN; function generateQueueName(stackId) { const s = stackId.split("/"); return s[1].substr(0, 63).toLowerCase(); } async function handler(event) { console.log(`Invoke: ${JSON.stringify(event)}`); const apiToken = await fetchCloudflareApiToken(secretsmanager, CLOUDFLARE_API_TOKEN_SECRET_ARN); if (event.RequestType === "Create") { try { const queueName = generateQueueName(event.StackId); const deadLetterQueueName = `${queueName}-dlq`; const scriptName = queueName; const queueId = await createQueue(apiToken, CLOUDFLARE_ACCOUNT_ID, queueName); const deadLetterQueueId = await createQueue(apiToken, CLOUDFLARE_ACCOUNT_ID, deadLetterQueueName); const physicalResourceId = `${queueId}:${deadLetterQueueId}`; await putWorker(apiToken, CLOUDFLARE_ACCOUNT_ID, scriptName, event.ResourceProperties.ScriptCode, { AWS_SQS_SCAN_QUEUE_URL: event.ResourceProperties.ScanQueueUrl, AWS_REGION: event.ResourceProperties.AwsRegion, AWS_ACCESS_KEY_ID: event.ResourceProperties.AwsAccessKeyId }, { AWS_SECRET_ACCESS_KEY: event.ResourceProperties.AwsSecretAccessKey }); await createQueueConsumer(apiToken, CLOUDFLARE_ACCOUNT_ID, queueId, scriptName, deadLetterQueueName); await cfnCustomResourceSuccess(event, physicalResourceId, { QueueId: queueId, QueueName: queueName, DeadLetterQueueId: deadLetterQueueId, DeadLetterQueueName: deadLetterQueueName }); } catch (err) { await cfnCustomResourceFailed(event, void 0, err.message); } } else if (event.RequestType === "Update") { try { const [queueId, deadLetterQueueId] = event.PhysicalResourceId.split(":"); const queueName = generateQueueName(event.StackId); const deadLetterQueueName = `${queueName}-dlq`; const scriptName = queueName; await putWorker(apiToken, CLOUDFLARE_ACCOUNT_ID, scriptName, event.ResourceProperties.ScriptCode, { AWS_SQS_SCAN_QUEUE_URL: event.ResourceProperties.ScanQueueUrl, AWS_REGION: event.ResourceProperties.AwsRegion, AWS_ACCESS_KEY_ID: event.ResourceProperties.AwsAccessKeyId }, { AWS_SECRET_ACCESS_KEY: event.ResourceProperties.AwsSecretAccessKey }); await cfnCustomResourceSuccess(event, event.PhysicalResourceId, { QueueId: queueId, QueueName: queueName, DeadLetterQueueId: deadLetterQueueId, DeadLetterQueueName: deadLetterQueueName }); } catch (err) { await cfnCustomResourceFailed(event, event.PhysicalResourceId, err.message); console.log(err); } } else if (event.RequestType === "Delete") { if (event.PhysicalResourceId.split(":").length !== 2) { return await cfnCustomResourceSuccess(event, event.PhysicalResourceId); } try { const [queueId, deadLetterQueueId] = event.PhysicalResourceId.split(":"); const queueName = generateQueueName(event.StackId); const deadLetterQueueName = `${queueName}-dlq`; const scriptName = queueName; const consumers = await listQueueConsumers(apiToken, CLOUDFLARE_ACCOUNT_ID, queueId); const consumer = consumers.find((consumer2) => consumer2.script === scriptName); if (consumer !== void 0) { await deleteQueueConsumer(apiToken, CLOUDFLARE_ACCOUNT_ID, queueId, consumer.consumer_id); } await deleteWorker(apiToken, CLOUDFLARE_ACCOUNT_ID, scriptName); await deleteQueue(apiToken, CLOUDFLARE_ACCOUNT_ID, queueId); await deleteQueue(apiToken, CLOUDFLARE_ACCOUNT_ID, deadLetterQueueId); await cfnCustomResourceSuccess(event, queueId, { QueueId: queueId, QueueName: queueName, DeadLetterQueueId: deadLetterQueueId, DeadLetterQueueName: deadLetterQueueName }); } catch (err) { await cfnCustomResourceFailed(event, event.PhysicalResourceId, err.message); } } else { throw new Error("unsupported request type"); } } // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { handler }); Environment: Variables: CLOUDFLARE_ACCOUNT_ID: Ref: CloudflareAccountId CLOUDFLARE_API_TOKEN_SECRET_ARN: Ref: CloudflareApiTokenSecret Handler: index.handler MemorySize: 1769 ReservedConcurrentExecutions: Fn::If: - HasCloudflareQueueFunctionReservedConcurrentExecutions - Ref: CloudflareQueueFunctionReservedConcurrentExecutions - Ref: AWS::NoValue Role: Fn::GetAtt: - CloudflareQueueRole - Arn Runtime: nodejs22.x Timeout: 300 VpcConfig: Fn::If: - HasLambdaVpc - SecurityGroupIds: - Ref: LambdaSecurityGroup SubnetIds: Ref: LambdaSubnets - Ref: AWS::NoValue Metadata: bucketav::CloudflareQueueLambdaVpcAllowPolicy: Fn::If: - HasLambdaVpc - Ref: CloudflareQueueLambdaVpcAllowPolicy - Ref: AWS::NoValue CloudflareQueueLambdaVpcAllowPolicy: 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: CloudflareQueueRole Condition: HasLambdaVpc CloudflareQueueLambdaVpcDenyPolicy: 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: - CloudflareQueueFunction - Arn PolicyName: vpc-deny Roles: - Ref: CloudflareQueueRole Condition: HasLambdaVpc CloudflareQueueLogGroup: Type: AWS::Logs::LogGroup Properties: LogGroupName: Fn::Join: - "" - - /aws/lambda/ - Ref: CloudflareQueueFunction RetentionInDays: Ref: LogsRetentionInDays CloudflareQueuePolicy: Type: AWS::IAM::Policy Properties: PolicyDocument: Statement: - Effect: Allow Action: - logs:CreateLogStream - logs:PutLogEvents Resource: Fn::GetAtt: - CloudflareQueueLogGroup - Arn PolicyName: lambda Roles: - Ref: CloudflareQueueRole CloudflareQueue: Type: Custom::CloudflareQueue Properties: ServiceToken: Fn::GetAtt: - CloudflareQueueFunction - Arn ServiceTimeout: "300" Version: 2.0.0 ScriptCode: | // workers/node_modules/aws4fetch/dist/aws4fetch.esm.mjs var encoder = new TextEncoder(); var HOST_SERVICES = { appstream2: "appstream", cloudhsmv2: "cloudhsm", email: "ses", marketplace: "aws-marketplace", mobile: "AWSMobileHubService", pinpoint: "mobiletargeting", queue: "sqs", "git-codecommit": "codecommit", "mturk-requester-sandbox": "mturk-requester", "personalize-runtime": "personalize" }; var UNSIGNABLE_HEADERS = /* @__PURE__ */ new Set([ "authorization", "content-type", "content-length", "user-agent", "presigned-expires", "expect", "x-amzn-trace-id", "range", "connection" ]); var AwsClient = class { constructor({ accessKeyId, secretAccessKey, sessionToken, service, region, cache, retries, initRetryMs }) { if (accessKeyId == null) throw new TypeError("accessKeyId is a required option"); if (secretAccessKey == null) throw new TypeError("secretAccessKey is a required option"); this.accessKeyId = accessKeyId; this.secretAccessKey = secretAccessKey; this.sessionToken = sessionToken; this.service = service; this.region = region; this.cache = cache || /* @__PURE__ */ new Map(); this.retries = retries != null ? retries : 10; this.initRetryMs = initRetryMs || 50; } async sign(input, init) { if (input instanceof Request) { const { method, url, headers, body } = input; init = Object.assign({ method, url, headers }, init); if (init.body == null && headers.has("Content-Type")) { init.body = body != null && headers.has("X-Amz-Content-Sha256") ? body : await input.clone().arrayBuffer(); } input = url; } const signer = new AwsV4Signer(Object.assign({ url: input.toString() }, init, this, init && init.aws)); const signed = Object.assign({}, init, await signer.sign()); delete signed.aws; try { return new Request(signed.url.toString(), signed); } catch (e) { if (e instanceof TypeError) { return new Request(signed.url.toString(), Object.assign({ duplex: "half" }, signed)); } throw e; } } async fetch(input, init) { for (let i = 0; i <= this.retries; i++) { const fetched = fetch(await this.sign(input, init)); if (i === this.retries) { return fetched; } const res = await fetched; if (res.status < 500 && res.status !== 429) { return res; } await new Promise((resolve) => setTimeout(resolve, Math.random() * this.initRetryMs * Math.pow(2, i))); } throw new Error("An unknown error occurred, ensure retries is not negative"); } }; var AwsV4Signer = class { constructor({ method, url, headers, body, accessKeyId, secretAccessKey, sessionToken, service, region, cache, datetime, signQuery, appendSessionToken, allHeaders, singleEncode }) { if (url == null) throw new TypeError("url is a required option"); if (accessKeyId == null) throw new TypeError("accessKeyId is a required option"); if (secretAccessKey == null) throw new TypeError("secretAccessKey is a required option"); this.method = method || (body ? "POST" : "GET"); this.url = new URL(url); this.headers = new Headers(headers || {}); this.body = body; this.accessKeyId = accessKeyId; this.secretAccessKey = secretAccessKey; this.sessionToken = sessionToken; let guessedService, guessedRegion; if (!service || !region) { [guessedService, guessedRegion] = guessServiceRegion(this.url, this.headers); } this.service = service || guessedService || ""; this.region = region || guessedRegion || "us-east-1"; this.cache = cache || /* @__PURE__ */ new Map(); this.datetime = datetime || (/* @__PURE__ */ new Date()).toISOString().replace(/[:-]|\.\d{3}/g, ""); this.signQuery = signQuery; this.appendSessionToken = appendSessionToken || this.service === "iotdevicegateway"; this.headers.delete("Host"); if (this.service === "s3" && !this.signQuery && !this.headers.has("X-Amz-Content-Sha256")) { this.headers.set("X-Amz-Content-Sha256", "UNSIGNED-PAYLOAD"); } const params = this.signQuery ? this.url.searchParams : this.headers; params.set("X-Amz-Date", this.datetime); if (this.sessionToken && !this.appendSessionToken) { params.set("X-Amz-Security-Token", this.sessionToken); } this.signableHeaders = ["host", ...this.headers.keys()].filter((header) => allHeaders || !UNSIGNABLE_HEADERS.has(header)).sort(); this.signedHeaders = this.signableHeaders.join(";"); this.canonicalHeaders = this.signableHeaders.map((header) => header + ":" + (header === "host" ? this.url.host : (this.headers.get(header) || "").replace(/\s+/g, " "))).join("\n"); this.credentialString = [this.datetime.slice(0, 8), this.region, this.service, "aws4_request"].join("/"); if (this.signQuery) { if (this.service === "s3" && !params.has("X-Amz-Expires")) { params.set("X-Amz-Expires", "86400"); } params.set("X-Amz-Algorithm", "AWS4-HMAC-SHA256"); params.set("X-Amz-Credential", this.accessKeyId + "/" + this.credentialString); params.set("X-Amz-SignedHeaders", this.signedHeaders); } if (this.service === "s3") { try { this.encodedPath = decodeURIComponent(this.url.pathname.replace(/\+/g, " ")); } catch (e) { this.encodedPath = this.url.pathname; } } else { this.encodedPath = this.url.pathname.replace(/\/+/g, "/"); } if (!singleEncode) { this.encodedPath = encodeURIComponent(this.encodedPath).replace(/%2F/g, "/"); } this.encodedPath = encodeRfc3986(this.encodedPath); const seenKeys = /* @__PURE__ */ new Set(); this.encodedSearch = [...this.url.searchParams].filter(([k]) => { if (!k) return false; if (this.service === "s3") { if (seenKeys.has(k)) return false; seenKeys.add(k); } return true; }).map((pair) => pair.map((p) => encodeRfc3986(encodeURIComponent(p)))).sort(([k1, v1], [k2, v2]) => k1 < k2 ? -1 : k1 > k2 ? 1 : v1 < v2 ? -1 : v1 > v2 ? 1 : 0).map((pair) => pair.join("=")).join("&"); } async sign() { if (this.signQuery) { this.url.searchParams.set("X-Amz-Signature", await this.signature()); if (this.sessionToken && this.appendSessionToken) { this.url.searchParams.set("X-Amz-Security-Token", this.sessionToken); } } else { this.headers.set("Authorization", await this.authHeader()); } return { method: this.method, url: this.url, headers: this.headers, body: this.body }; } async authHeader() { return [ "AWS4-HMAC-SHA256 Credential=" + this.accessKeyId + "/" + this.credentialString, "SignedHeaders=" + this.signedHeaders, "Signature=" + await this.signature() ].join(", "); } async signature() { const date = this.datetime.slice(0, 8); const cacheKey = [this.secretAccessKey, date, this.region, this.service].join(); let kCredentials = this.cache.get(cacheKey); if (!kCredentials) { const kDate = await hmac("AWS4" + this.secretAccessKey, date); const kRegion = await hmac(kDate, this.region); const kService = await hmac(kRegion, this.service); kCredentials = await hmac(kService, "aws4_request"); this.cache.set(cacheKey, kCredentials); } return buf2hex(await hmac(kCredentials, await this.stringToSign())); } async stringToSign() { return [ "AWS4-HMAC-SHA256", this.datetime, this.credentialString, buf2hex(await hash(await this.canonicalString())) ].join("\n"); } async canonicalString() { return [ this.method.toUpperCase(), this.encodedPath, this.encodedSearch, this.canonicalHeaders + "\n", this.signedHeaders, await this.hexBodyHash() ].join("\n"); } async hexBodyHash() { let hashHeader = this.headers.get("X-Amz-Content-Sha256") || (this.service === "s3" && this.signQuery ? "UNSIGNED-PAYLOAD" : null); if (hashHeader == null) { if (this.body && typeof this.body !== "string" && !("byteLength" in this.body)) { throw new Error("body must be a string, ArrayBuffer or ArrayBufferView, unless you include the X-Amz-Content-Sha256 header"); } hashHeader = buf2hex(await hash(this.body || "")); } return hashHeader; } }; async function hmac(key, string) { const cryptoKey = await crypto.subtle.importKey( "raw", typeof key === "string" ? encoder.encode(key) : key, { name: "HMAC", hash: { name: "SHA-256" } }, false, ["sign"] ); return crypto.subtle.sign("HMAC", cryptoKey, encoder.encode(string)); } async function hash(content) { return crypto.subtle.digest("SHA-256", typeof content === "string" ? encoder.encode(content) : content); } var HEX_CHARS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"]; function buf2hex(arrayBuffer) { const buffer = new Uint8Array(arrayBuffer); let out = ""; for (let idx = 0; idx < buffer.length; idx++) { const n = buffer[idx]; out += HEX_CHARS[n >>> 4 & 15]; out += HEX_CHARS[n & 15]; } return out; } function encodeRfc3986(urlEncodedStr) { return urlEncodedStr.replace(/[!'()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase()); } function guessServiceRegion(url, headers) { const { hostname, pathname } = url; if (hostname.endsWith(".on.aws")) { const match2 = hostname.match(/^[^.]{1,63}\.lambda-url\.([^.]{1,63})\.on\.aws$/); return match2 != null ? ["lambda", match2[1] || ""] : ["", ""]; } if (hostname.endsWith(".r2.cloudflarestorage.com")) { return ["s3", "auto"]; } if (hostname.endsWith(".backblazeb2.com")) { const match2 = hostname.match(/^(?:[^.]{1,63}\.)?s3\.([^.]{1,63})\.backblazeb2\.com$/); return match2 != null ? ["s3", match2[1] || ""] : ["", ""]; } const match = hostname.replace("dualstack.", "").match(/([^.]{1,63})\.(?:([^.]{0,63})\.)?amazonaws\.com(?:\.cn)?$/); let service = match && match[1] || ""; let region = match && match[2]; if (region === "us-gov") { region = "us-gov-west-1"; } else if (region === "s3" || region === "s3-accelerate") { region = "us-east-1"; service = "s3"; } else if (service === "iot") { if (hostname.startsWith("iot.")) { service = "execute-api"; } else if (hostname.startsWith("data.jobs.iot.")) { service = "iot-jobs-data"; } else { service = pathname === "/mqtt" ? "iotdevicegateway" : "iotdata"; } } else if (service === "autoscaling") { const targetPrefix = (headers.get("X-Amz-Target") || "").split(".")[0]; if (targetPrefix === "AnyScaleFrontendService") { service = "application-autoscaling"; } else if (targetPrefix === "AnyScaleScalingPlannerFrontendService") { service = "autoscaling-plans"; } } else if (region == null && service.startsWith("s3-")) { region = service.slice(3).replace(/^fips-|^external-1/, ""); service = "s3"; } else if (service.endsWith("-fips")) { service = service.slice(0, -5); } else if (region && /-\d$/.test(service) && !/-\d$/.test(region)) { [service, region] = [region, service]; } return [HOST_SERVICES[service] || service, region || ""]; } // workers/queue.js var queue_default = { async queue(batch, env) { const aws = new AwsClient({ accessKeyId: env.AWS_ACCESS_KEY_ID, secretAccessKey: env.AWS_SECRET_ACCESS_KEY }); const response = await aws.fetch(`https://sqs.${env.AWS_REGION}.amazonaws.com/`, { method: "POST", headers: { "X-Amz-Target": "AmazonSQS.SendMessageBatch", "Content-Type": "application/x-amz-json-1.0" }, body: JSON.stringify({ Entries: batch.messages.map((message, i) => ({ Id: `id${i}`, MessageBody: JSON.stringify(message.body) })), QueueUrl: env.AWS_SQS_SCAN_QUEUE_URL }) }); if (response.status !== 200) { console.log("response status", response.status); console.log("response", await response.text()); throw new Error("unexpected status code"); } const body = await response.json(); if (body?.Failed?.length > 0) { body.Failed.forEach((failure) => { console.log(`batch ${failure.Id} failed with ${failure.Code} (${failure.SenderFault}): ${failure.Message}`); }); throw new Error("SQS batch failed"); } } }; export { queue_default as default }; /*! Bundled license information: aws4fetch/dist/aws4fetch.esm.mjs: (** * @license MIT * @copyright Michael Hart 2024 *) */ ScanQueueUrl: Ref: ScanQueue AwsRegion: Ref: AWS::Region AwsAccessKeyId: Ref: CloudflareAccessKey AwsSecretAccessKey: Fn::GetAtt: - CloudflareAccessKey - SecretAccessKey DependsOn: - CloudflareQueuePolicy 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: - 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 - PolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Action: secretsmanager:GetSecretValue Resource: - Ref: CloudflareApiTokenSecret - Ref: CloudflareAccessKeySecretSecret PolicyName: cloudflare - 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: aws-marketplace:MeterUsage Resource: "*" PolicyName: awsmp - 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 Iops: Ref: VolumeIops KmsKeyId: Fn::If: - HasVolumeKmsKeyId - Ref: VolumeKmsKeyId - Ref: AWS::NoValue Throughput: Ref: VolumeThroughput VolumeSize: Ref: VolumeSize VolumeType: gp3 IamInstanceProfile: Name: Ref: ScanInstanceProfile ImageId: Ref: AMI2110 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 '/opt/aws/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 cat <<"EOF" | tee /opt/bucketav/bucketav.conf > /dev/null mode: consumer platform: cloudflare delete: - Ref: DeleteInfectedFiles - |- tag_files: false report_eventbridge: - Ref: ReportEventBridge - |- region: ' - Ref: AWS::Region - |- ' queue: ' - Ref: ScanQueue - |- ' cloudflare_account_id: - Ref: CloudflareAccountId - |- cloudflare_r2_endpoint_hostname: - Ref: CloudflareAccountId - |- .r2.cloudflarestorage.com cloudflare_access_key_id: - Ref: CloudflareAccessKeyId - |- cloudflare_access_key_secret_arn: - Ref: CloudflareAccessKeySecretSecret - |- topic: ' - Ref: FindingsTopic - |- ' volume_size: - Ref: VolumeSize - |- stack_name: ' - Ref: AWS::StackName - |- ' core_stack_name: ' - Ref: AWS::StackName - |- ' https_proxy: ' - Ref: HttpsProxy - |- ' 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 echo "threadcount: $(/opt/bucketav/bucketav --savdi-threadcount)" >> /usr/local/savdi/savdid.conf systemctl enable savdi.service systemctl start savdi.service if [[ " - Ref: SophosLiveProtectionCloudLookups - |- " == "true" ]]; then sed -e 's/BUCKETAV_PLACEHOLDER_SXLLIVEPROTECTION/1/g' -i /usr/local/savdi/savdid.conf sed -e "s/BUCKETAV_PLACEHOLDER_SXLDNSIP1/$(/opt/bucketav/bucketav --savdi-dns1)/g" -i /usr/local/savdi/savdid.conf sed -e "s/BUCKETAV_PLACEHOLDER_SXLHEXIDMACHINE/$(openssl rand -hex 16)/g" -i /usr/local/savdi/savdid.conf else sed -e 's/BUCKETAV_PLACEHOLDER_SXLLIVEPROTECTION/0/g' -i /usr/local/savdi/savdid.conf sed -e 's/BUCKETAV_PLACEHOLDER_SXLDNSIP1/169.254.169.253/g' -i /usr/local/savdi/savdid.conf sed -e "s/BUCKETAV_PLACEHOLDER_SXLHEXIDMACHINE/$(openssl rand -hex 16)/g" -i /usr/local/savdi/savdid.conf fi systemctl enable bucketav.service systemctl start bucketav.service /opt/aws/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 - 200 MetricIntervalUpperBound: 500 ScalingAdjustment: 1 - MetricIntervalLowerBound: 500 MetricIntervalUpperBound: 2000 ScalingAdjustment: 2 - MetricIntervalLowerBound: 2000 MetricIntervalUpperBound: 8000 ScalingAdjustment: 4 - MetricIntervalLowerBound: 8000 MetricIntervalUpperBound: 32000 ScalingAdjustment: 8 - MetricIntervalLowerBound: 32000 MetricIntervalUpperBound: 128000 ScalingAdjustment: 16 - MetricIntervalLowerBound: 128000 MetricIntervalUpperBound: 512000 ScalingAdjustment: 32 - MetricIntervalLowerBound: 512000 ScalingAdjustment: 64 FallbackLaunchTemplate: Type: AWS::EC2::LaunchTemplate Properties: LaunchTemplateData: BlockDeviceMappings: - DeviceName: /dev/xvda Ebs: Encrypted: true Iops: Ref: VolumeIops Throughput: Ref: VolumeThroughput VolumeSize: Ref: VolumeSize VolumeType: gp3 IamInstanceProfile: Name: Ref: ScanInstanceProfile ImageId: Ref: AMI2110 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 '/opt/aws/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 cat <<"EOF" | tee /opt/bucketav/bucketav.conf > /dev/null mode: consumer platform: cloudflare delete: - Ref: DeleteInfectedFiles - |- tag_files: false report_eventbridge: - Ref: ReportEventBridge - |- region: ' - Ref: AWS::Region - |- ' queue: ' - Ref: ScanQueue - |- ' cloudflare_account_id: - Ref: CloudflareAccountId - |- cloudflare_r2_endpoint_hostname: - Ref: CloudflareAccountId - |- .r2.cloudflarestorage.com cloudflare_access_key_id: - Ref: CloudflareAccessKeyId - |- cloudflare_access_key_secret_arn: - Ref: CloudflareAccessKeySecretSecret - |- topic: ' - Ref: FindingsTopic - |- ' volume_size: - Ref: VolumeSize - |- stack_name: ' - Ref: AWS::StackName - |- ' core_stack_name: ' - Ref: AWS::StackName - |- ' https_proxy: ' - Ref: HttpsProxy - |- ' 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 echo "threadcount: $(/opt/bucketav/bucketav --savdi-threadcount)" >> /usr/local/savdi/savdid.conf systemctl enable savdi.service systemctl start savdi.service if [[ " - Ref: SophosLiveProtectionCloudLookups - |- " == "true" ]]; then sed -e 's/BUCKETAV_PLACEHOLDER_SXLLIVEPROTECTION/1/g' -i /usr/local/savdi/savdid.conf sed -e "s/BUCKETAV_PLACEHOLDER_SXLDNSIP1/$(/opt/bucketav/bucketav --savdi-dns1)/g" -i /usr/local/savdi/savdid.conf sed -e "s/BUCKETAV_PLACEHOLDER_SXLHEXIDMACHINE/$(openssl rand -hex 16)/g" -i /usr/local/savdi/savdid.conf else sed -e 's/BUCKETAV_PLACEHOLDER_SXLLIVEPROTECTION/0/g' -i /usr/local/savdi/savdid.conf sed -e 's/BUCKETAV_PLACEHOLDER_SXLDNSIP1/169.254.169.253/g' -i /usr/local/savdi/savdid.conf sed -e "s/BUCKETAV_PLACEHOLDER_SXLHEXIDMACHINE/$(openssl rand -hex 16)/g" -i /usr/local/savdi/savdid.conf fi systemctl enable bucketav.service systemctl start bucketav.service /opt/aws/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 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 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: - Effect: Allow Action: secretsmanager:GetSecretValue Resource: - Ref: CloudflareApiTokenSecret - Ref: CloudflareAccessKeySecretSecret - Effect: Allow Action: ssm:GetParameter Resource: - Fn::Join: - "" - - "arn:" - Ref: AWS::Partition - ":ssm:" - Ref: AWS::Region - ":" - Ref: AWS::AccountId - :parameter - Ref: ServiceDiscoveryCloudflareAccessKeyId - Fn::Join: - "" - - "arn:" - Ref: AWS::Partition - ":ssm:" - Ref: AWS::Region - ":" - Ref: AWS::AccountId - :parameter - Ref: ServiceDiscoveryCloudflareAccessKeySecretArn - Fn::Join: - "" - - "arn:" - Ref: AWS::Partition - ":ssm:" - Ref: AWS::Region - ":" - Ref: AWS::AccountId - :parameter - Ref: ServiceDiscoveryCloudflareR2EndpointHostname - 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-cloudflare.js var dashboard_cloudflare_exports = {}; __export(dashboard_cloudflare_exports, { handler: () => handler }); module.exports = __toCommonJS(dashboard_cloudflare_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_secrets_manager2 = require("@aws-sdk/client-secrets-manager"); // 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/fetch-retry.js function fetch_retry_default(fetch3, defaults) { defaults = defaults || {}; if (typeof fetch3 !== "function") { throw new ArgumentError("fetch must be a function"); } if (typeof defaults !== "object") { throw new ArgumentError("defaults must be an object"); } if (defaults.retries !== void 0 && !isPositiveInteger(defaults.retries)) { throw new ArgumentError("retries must be a positive integer"); } if (defaults.retryDelay !== void 0 && !isPositiveInteger(defaults.retryDelay) && typeof defaults.retryDelay !== "function") { throw new ArgumentError("retryDelay must be a positive integer or a function returning a positive integer"); } if (defaults.retryOn !== void 0 && !Array.isArray(defaults.retryOn) && typeof defaults.retryOn !== "function") { throw new ArgumentError("retryOn property expects an array or function"); } var baseDefaults = { retries: 3, retryDelay: 1e3, retryOn: [] }; defaults = Object.assign(baseDefaults, defaults); return function fetchRetry(input, init) { var retries = defaults.retries; var retryDelay = defaults.retryDelay; var retryOn = defaults.retryOn; if (init && init.retries !== void 0) { if (isPositiveInteger(init.retries)) { retries = init.retries; } else { throw new ArgumentError("retries must be a positive integer"); } } if (init && init.retryDelay !== void 0) { if (isPositiveInteger(init.retryDelay) || typeof init.retryDelay === "function") { retryDelay = init.retryDelay; } else { throw new ArgumentError("retryDelay must be a positive integer or a function returning a positive integer"); } } if (init && init.retryOn) { if (Array.isArray(init.retryOn) || typeof init.retryOn === "function") { retryOn = init.retryOn; } else { throw new ArgumentError("retryOn property expects an array or function"); } } return new Promise(function(resolve, reject) { var wrappedFetch = function(attempt) { var _input = typeof Request !== "undefined" && input instanceof Request ? input.clone() : input; fetch3(_input, init).then(function(response) { if (Array.isArray(retryOn) && retryOn.indexOf(response.status) === -1) { resolve(response); } else if (typeof retryOn === "function") { try { return Promise.resolve(retryOn(attempt, null, response)).then(function(retryOnResponse) { if (retryOnResponse) { retry(attempt, null, response); } else { resolve(response); } }).catch(reject); } catch (error) { reject(error); } } else { if (attempt < retries) { retry(attempt, null, response); } else { resolve(response); } } }).catch(function(error) { if (typeof retryOn === "function") { try { Promise.resolve(retryOn(attempt, error, null)).then(function(retryOnResponse) { if (retryOnResponse) { retry(attempt, error, null); } else { reject(error); } }).catch(function(error2) { reject(error2); }); } catch (error2) { reject(error2); } } else if (attempt < retries) { retry(attempt, error, null); } else { reject(error); } }); }; function retry(attempt, error, response) { var delay = typeof retryDelay === "function" ? retryDelay(attempt, error, response) : retryDelay; setTimeout(function() { wrappedFetch(++attempt); }, delay); } wrappedFetch(0); }); }; } function isPositiveInteger(value) { return Number.isInteger(value) && value >= 0; } function ArgumentError(message) { this.name = "ArgumentError"; this.message = message; } // lambda/lib-cloudflare.js var BASIC_WAIT_TIME_IN_MILLIS = 300; var MAX_WAIT_TIME_IN_MILLIS = 5e3; var DEFAULT_TIMEOUT_IN_MILLIS = 1e4; async function fetch2(url, options) { options.signal = AbortSignal.timeout(DEFAULT_TIMEOUT_IN_MILLIS); return fetch_retry_default(global.fetch, { retryDelay: (attempt) => { const delay = Math.floor(Math.random() * Math.min(MAX_WAIT_TIME_IN_MILLIS, BASIC_WAIT_TIME_IN_MILLIS * Math.pow(2, attempt))); options.signal = AbortSignal.timeout(DEFAULT_TIMEOUT_IN_MILLIS + delay); return delay; }, retryOn: (attempt, error, response) => { if (attempt >= 3) { return false; } if (error || response.status === 429 || response.status >= 500) { return true; } } })(url, options); } async function readR2EventNotification(apiToken, accountId, bucketName) { const response = await fetch2(`https://api.cloudflare.com/client/v4/accounts/${accountId}/event_notifications/r2/${bucketName}/configuration`, { method: "GET", headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiToken}` } }); if (response.status === 404) { return []; } if (response.status !== 200) { console.log("response url", response.url); console.log("response status", response.status); console.log("response", await response.text()); throw new Error("unexpected status code"); } const body = await response.json(); if (body.success !== true) { console.log("body", body); throw new Error(`unsuccessful request: ${body.errors.map((error) => `${error.code}: ${error.message}`).join(", ")}`); } return body.result; } async function createR2EventNotification(apiToken, accountId, bucketName, queueId) { const response = await fetch2(`https://api.cloudflare.com/client/v4/accounts/${accountId}/event_notifications/r2/${bucketName}/configuration/queues/${queueId}`, { method: "PUT", headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiToken}` }, body: JSON.stringify({ rules: [{ actions: ["PutObject", "CopyObject", "CompleteMultipartUpload"] }] }) }); if (response.status !== 200) { console.log("response url", response.url); console.log("response status", response.status); console.log("response", await response.text()); throw new Error("unexpected status code"); } const body = await response.json(); if (body.success !== true) { console.log("body", body); throw new Error(`unsuccessful request: ${body.errors.map((error) => `${error.code}: ${error.message}`).join(", ")}`); } } async function deleteR2EventNotification(apiToken, accountId, bucketName, queueId) { const response = await fetch2(`https://api.cloudflare.com/client/v4/accounts/${accountId}/event_notifications/r2/${bucketName}/configuration/queues/${queueId}`, { method: "DELETE", headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiToken}` } }); if (response.status === 404) { return; } if (response.status !== 200) { console.log("response url", response.url); console.log("response status", response.status); console.log("response", await response.text()); throw new Error("unexpected status code"); } const body = await response.json(); if (body.success !== true) { console.log("body", body); throw new Error(`unsuccessful request: ${body.errors.map((error) => `${error.code}: ${error.message}`).join(", ")}`); } } // 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; } var paginateListRuleNamesByTarget = createPaginator(import_client_eventbridge.EventBridgeClient, import_client_eventbridge.ListRuleNamesByTargetCommand, "NextToken", "NextToken", "Limit"); async function enrichR2Bucket(cloudflareApiToken, cloudflareAccountId, cloudflareQueueId, scheduledStacks, bucketName) { const bucket = { name: bucketName }; let realtimeEnabled = false; let realtimeEventNotificationEnablePossible = true; let realtimeEventNotificationDisablePossible = false; let scheduledEnabled = false; let scheduledStackDisablePossible = false; let scheduledStackId = void 0; try { const eventNotification = await readR2EventNotification(cloudflareApiToken, cloudflareAccountId, bucketName); if (eventNotification?.queues?.length >= 5) { realtimeEventNotificationEnablePossible = false; } const queue = eventNotification?.queues?.find((queue2) => queue2.queueId.replaceAll("-", "") === cloudflareQueueId); if (queue !== void 0) { realtimeEventNotificationEnablePossible = false; if (queue.rules.find((rule) => rule.actions.length === 3 && rule.actions.includes("PutObject") && rule.actions.includes("CopyObject") && rule.actions.includes("CompleteMultipartUpload") && rule.prefix === "" && rule.suffix === "") !== void 0) { realtimeEnabled = true; realtimeEventNotificationDisablePossible = true; } } } 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, bucketName)); if (scheduledStacksIncludesBucket.length > 0) { scheduledEnabled = true; scheduledStackId = scheduledStacksIncludesBucket[0].id; } const scheduledStacksHasBucket = scheduledStacks.stacks.filter((scheduledStack) => hasBucket(scheduledStack, bucketName)); if (scheduledStacksHasBucket.length === 1) { scheduledEnabled = true; scheduledStackDisablePossible = true; scheduledStackId = scheduledStacksHasBucket[0].id; } return { ...bucket, realtimeEnabled, realtimeEventNotificationEnablePossible, realtimeEventNotificationDisablePossible, scheduledEnabled, scheduledStackDisablePossible, scheduledStackId }; } 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 listR2Buckets(s3, ssm, cloudformation, coreStackName, cloudflareApiToken, cloudflareAccountId, cloudflareQueueId) { const listBucketsData = await s3.send(new import_client_s3.ListBucketsCommand({})); const scheduledStacks = await getScheduledStacks(ssm, cloudformation, coreStackName); const buckets = await Promise.all(listBucketsData.Buckets.map((bucket) => enrichR2Bucket(cloudflareApiToken, cloudflareAccountId, cloudflareQueueId, scheduledStacks, bucket.Name))); return buckets; } async function fetchR2(ssm, secretsmanager2, coreStackName) { const { Parameter: { Value: cloudflareR2EndpointHostname } } = await ssm.send(new import_client_ssm.GetParameterCommand({ Name: `/bucketAV/${coreStackName}/CloudflareR2EndpointHostname` })); const { Parameter: { Value: cloudflareAccessKeyId } } = await ssm.send(new import_client_ssm.GetParameterCommand({ Name: `/bucketAV/${coreStackName}/CloudflareAccessKeyId` })); const { Parameter: { Value: cloudflareAccessKeySecretArn } } = await ssm.send(new import_client_ssm.GetParameterCommand({ Name: `/bucketAV/${coreStackName}/CloudflareAccessKeySecretArn` })); const { SecretString: cloudflareAccessKey } = await secretsmanager2.send(new import_client_secrets_manager.GetSecretValueCommand({ SecretId: cloudflareAccessKeySecretArn })); return new import_client_s32.S3Client({ region: "auto", endpoint: `https://${cloudflareR2EndpointHostname}`, credentials: { accessKeyId: cloudflareAccessKeyId, secretAccessKey: cloudflareAccessKey } }); } async function fetchCloudflareApiToken(secretsmanager2, cloudflareApiTokenSecretArn) { const { SecretString: cloudflareApiToken } = await secretsmanager2.send(new import_client_secrets_manager.GetSecretValueCommand({ SecretId: cloudflareApiTokenSecretArn })); return cloudflareApiToken; } 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"); // 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 bucketsCloudflare(defaultSsm2, defaultCloudformation2, secretsmanager2, event, context, scheduledTemplateUrl, awsRegion, awsAccountId, coreStackName, cloudflareAccountId, cloudflareApiTokenSecretArn, cloudflareQueueId) { const cloudflareApiToken = await fetchCloudflareApiToken(secretsmanager2, cloudflareApiTokenSecretArn); if (event.action === "enableEventNotification") { await createR2EventNotification(cloudflareApiToken, cloudflareAccountId, event.bucketName, cloudflareQueueId); } else if (event.action === "disableEventNotification") { await deleteR2EventNotification(cloudflareApiToken, cloudflareAccountId, event.bucketName, cloudflareQueueId); } const s3 = await fetchR2(defaultSsm2, secretsmanager2, coreStackName); const buckets = await listR2Buckets(s3, defaultSsm2, defaultCloudformation2, coreStackName, cloudflareApiToken, cloudflareAccountId, cloudflareQueueId); return bucketsBase(event, context, buckets, scheduledTemplateUrl, "cloudflare", awsRegion, awsAccountId, false, coreStackName); } 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-cloudflare.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 secretsmanager = new import_client_secrets_manager2.SecretsManagerClient({ apiVersion: "2017-10-17" }); 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 CLOUDFLARE_QUEUE_ID = process.env.CLOUDFLARE_QUEUE_ID; var CLOUDFLARE_ACCOUNT_ID = process.env.CLOUDFLARE_ACCOUNT_ID; var CLOUDFLARE_API_TOKEN_SECRET_ARN = process.env.CLOUDFLARE_API_TOKEN_SECRET_ARN; async function handler(event, context) { console.log(`Invoke: ${JSON.stringify(event)} ${JSON.stringify(context)}`); const latest = await fetchLatest(); const scheduledTemplateUrl = latest["add-on-scheduled-bucket-scan-cloudflare"].template; if (event.widgetContext.params.view === "update") { return await update(defaultCloudformation, defaultSsm, dynamodb, "cloudflare", latest, false, AWS_PARTITION, AWS_REGION, AWS_ACCOUNT_ID, CORE_STACK_NAME, CORE_STACK_ID, ENGINE, void 0); } else if (event.widgetContext.params.view === "buckets") { return await bucketsCloudflare(defaultSsm, defaultCloudformation, secretsmanager, event, context, scheduledTemplateUrl, AWS_REGION, AWS_ACCOUNT_ID, CORE_STACK_NAME, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_TOKEN_SECRET_ARN, CLOUDFLARE_QUEUE_ID); } else if (event.widgetContext.params.view === "scanResults") { return await scanResults(cwLogs, event, context, SCAN_LOG_GROUP_NAME, "/journald/bucketav.service"); } else if (event.widgetContext.params.view === "reports") { return await reports(defaultS3, defaultSsm, event, context, AWS_REGION, CORE_STACK_NAME); } } // 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: sophos AWS_PARTITION: Ref: AWS::Partition AWS_ACCOUNT_ID: Ref: AWS::AccountId SCAN_LOG_GROUP_NAME: Ref: Logs CLOUDFLARE_QUEUE_ID: Fn::GetAtt: - CloudflareQueue - QueueId CLOUDFLARE_ACCOUNT_ID: Ref: CloudflareAccountId CLOUDFLARE_API_TOKEN_SECRET_ARN: Ref: CloudflareApiTokenSecret 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":"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)"}],[".","billed_scanned_data",{"stat":"Sum","label":"Billed data (GB)"}]],"region":"' - Ref: AWS::Region - '","title":"bucketAV for Cloudflare R2 powered by Sophos (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":"custom","x":8,"y":8,"width":8,"height":8,"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 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.11.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: sophos Outputs: CloudflareAccessKeySecretArn: Description: The ARN of the Cloudflare access key secret. Value: Ref: CloudflareAccessKeySecretSecret Export: Name: Fn::Join: - "" - - Ref: AWS::StackName - -CloudflareAccessKeySecretArn InfrastructureAlarmsTopicArn: Description: The ARN of the Infrastructure Alarms Topic. Value: Ref: InfrastructureAlarmsTopic Export: Name: Fn::Join: - "" - - Ref: AWS::StackName - -InfrastructureAlarmsTopicArn InfrastructureAlarmsTopicName: Description: The name of the Infrastructure Alarms Topic. Value: Fn::GetAtt: - InfrastructureAlarmsTopic - TopicName Export: Name: Fn::Join: - "" - - Ref: AWS::StackName - -InfrastructureAlarmsTopicName FindingsTopicArn: Description: The ARN of the Findings Topic. Value: Ref: FindingsTopic Export: Name: Fn::Join: - "" - - Ref: AWS::StackName - -FindingsTopicArn FindingsTopicName: Description: The name of the Findings Topic. Value: Fn::GetAtt: - FindingsTopic - TopicName Export: Name: Fn::Join: - "" - - Ref: AWS::StackName - -FindingsTopicName ScanQueueArn: Description: The ARN of the Scan Queue. Value: Fn::GetAtt: - ScanQueue - Arn Export: Name: Fn::Join: - "" - - Ref: AWS::StackName - -ScanQueueArn ScanQueueName: Description: The name of the Scan Queue. Value: Fn::GetAtt: - ScanQueue - QueueName Export: Name: Fn::Join: - "" - - Ref: AWS::StackName - -ScanQueueName ScanQueueUrl: Description: The URL of the Scan Queue. Value: Ref: ScanQueue Export: Name: Fn::Join: - "" - - Ref: AWS::StackName - -ScanQueueUrl DeadLetterQueueArn: Description: The ARN of the Dead Letter Queue. Value: Fn::GetAtt: - DeadLetterQueue - Arn DeadLetterQueueName: Description: The name of the Dead Letter Queue. Value: Fn::GetAtt: - DeadLetterQueue - QueueName DeadLetterQueueUrl: Description: The URL of the Dead Letter Queue. Value: Ref: DeadLetterQueue CloudflareQueueId: Description: The ID of the Cloudflare queue. Value: Fn::GetAtt: - CloudflareQueue - QueueId CloudflareQueueName: Description: The name of the Cloudflare queue. Value: Fn::GetAtt: - CloudflareQueue - QueueName DashboardLambdaRoleArn: Description: The ARN of the Dashboard Role. Value: Fn::GetAtt: - DashboardLambdaRole - Arn Export: Name: Fn::Join: - "" - - Ref: AWS::StackName - -DashboardLambdaRoleArn FulfillmentOption: Description: Fulfillment option. Value: shared-vpc Version: Description: bucketAV version. Value: 2.11.0 Platform: Description: bucketAV platform. Value: cloudflare Engine: Description: bucketAV engine. Value: sophos 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 ScanAutoScalingGroupName: Description: Name of the scan ASG. Value: Ref: ScanAutoScalingGroup FallbackAutoScalingGroupName: Description: Name of the fallback ASG. Value: Ref: FallbackAutoScalingGroup Condition: HasOnDemandFallback 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.nano: AlternativeInstanceType: t3.nano t3a.micro: AlternativeInstanceType: t3.micro 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.nano: AlternativeInstanceType: t3a.nano t3.micro: AlternativeInstanceType: t3a.micro 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 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 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 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 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 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 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 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 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 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 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 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 m5zn.large: AlternativeInstanceType: m5.large m5zn.xlarge: AlternativeInstanceType: m5.xlarge m5zn.2xlarge: AlternativeInstanceType: m5.2xlarge m5zn.3xlarge: AlternativeInstanceType: m5.2xlarge m5zn.6xlarge: AlternativeInstanceType: m5.4xlarge m5zn.12xlarge: AlternativeInstanceType: m5.12xlarge