---
# 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 reporting Add-On for Amazon S3
AWSTemplateFormatVersion: "2010-09-09"
Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label:
          default: Required Parameters
        Parameters:
          - BucketAVStackName
          - ReportReceiver1
      - Label:
          default: Reporting Parameters
        Parameters:
          - ReportReceiver2
          - ReportReceiver3
          - ReportReceiver4
          - ReportReceiver5
          - ReportingFrequency
          - ReportingFrequencyHourOfDay
          - ReportingFrequencyDayOfWeek
          - ReportsRetentionInDays
          - SourceBucketNames
          - LogsRetentionInDays
      - Label:
          default: Permissions Parameters
        Parameters:
          - PermissionsBoundary
          - AccessLogBucketName
      - Label:
          default: Lambda Parameters
        Parameters:
          - LambdaFunctionReservedConcurrentExecutions
          - StateMachineNameGeneratorFunctionReservedConcurrentExecutions
          - LambdaVpc
          - LambdaSubnets
Parameters:
  BucketAVStackName:
    Type: String
    Description: CloudFormation stack name of bucketAV (if you followed our docs, the name is bucketav, or s3-virusscan for older installations)
  SourceBucketNames:
    Type: String
    Default: ""
    Description: Optinally, reporting on files from a specific source bucket(s) only (leave blank to work with all buckets; comma separate multiple buckets).
  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.
  ReportingFrequency:
    Type: String
    Default: Weekly
    AllowedValues:
      - Daily
      - Weekly
      - Monthly
    Description: How often do you wish to receive an report via email?
  ReportingFrequencyHourOfDay:
    Type: String
    Default: "5"
    AllowedValues:
      - "0"
      - "1"
      - "2"
      - "3"
      - "4"
      - "5"
      - "6"
      - "7"
      - "8"
      - "9"
      - "10"
      - "11"
      - "12"
      - "13"
      - "14"
      - "15"
      - "16"
      - "17"
      - "18"
      - "19"
      - "20"
      - "21"
      - "22"
      - "23"
    Description: Specifies the hour of the day when the report is generated (UTC time zone).
  ReportingFrequencyDayOfWeek:
    Type: String
    Default: MON
    AllowedValues:
      - SUN
      - MON
      - TUE
      - WED
      - THU
      - FRI
      - SAT
    Description: Specifies the day of the week when the report is generated (UTC time zone; only used if ReportingFrequency:=Weekly).
  ReportsRetentionInDays:
    Type: Number
    Default: 90
    Description: Specifies the number of days you want to retain reports (set to 0 to keep reports forever).
    MinValue: 0
  ReportReceiver1:
    Type: String
    AllowedPattern: (.{1,})@(.{1,})
    Description: Reports are send to this email address.
  ReportReceiver2:
    Type: String
    Default: ""
    Description: Optinally, additional email address.
  ReportReceiver3:
    Type: String
    Default: ""
    Description: Optinally, additional email address.
  ReportReceiver4:
    Type: String
    Default: ""
    Description: Optinally, additional email address.
  ReportReceiver5:
    Type: String
    Default: ""
    Description: Optinally, additional email address.
  PermissionsBoundary:
    Type: String
    Default: ""
    Description: Optional IAM policy ARN that will be used as the permissions boundary for all roles.
  AccessLogBucketName:
    Type: String
    Default: ""
    Description: Optional name of an S3 bucket to store access logs.
  LambdaFunctionReservedConcurrentExecutions:
    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
  LambdaSubnets:
    Type: CommaDelimitedList
    Default: ""
    Description: Optionally configure Lambda functions to run in theses subnets, requires route to NAT Gateway or VPC Endpoints.
  LambdaVpc:
    Type: String
    Default: ""
    Description: Optionally configure Lambda functions to run in this VPC, requires route to NAT Gateway or VPC Endpoints.
  StateMachineNameGeneratorFunctionReservedConcurrentExecutions:
    Type: Number
    Default: 0
    Description: Maximum number of execution environment instances for the Lambda function (set to 0 to disable; Check out the CloudWatch metric ConcurrentExecutions to get the maximum concurrent invocations of the past).
    MinValue: 0
Conditions:
  HasLambdaVpc:
    Fn::And:
      - Fn::Not:
          - Fn::Equals:
              - Fn::Join:
                  - ""
                  - Ref: LambdaSubnets
              - ""
      - Fn::Not:
          - Fn::Equals:
              - Ref: LambdaVpc
              - ""
  HasReportingFrequencyDaily:
    Fn::Equals:
      - Ref: ReportingFrequency
      - Daily
  HasReportingFrequencyWeekly:
    Fn::Equals:
      - Ref: ReportingFrequency
      - Weekly
  HasSourceBucketNames:
    Fn::Not:
      - Fn::Equals:
          - Ref: SourceBucketNames
          - ""
  HasReportsRetentionInDays:
    Fn::Not:
      - Fn::Equals:
          - Ref: ReportsRetentionInDays
          - "0"
  HasReportReceiver2:
    Fn::Not:
      - Fn::Equals:
          - Ref: ReportReceiver2
          - ""
  HasReportReceiver3:
    Fn::Not:
      - Fn::Equals:
          - Ref: ReportReceiver3
          - ""
  HasReportReceiver4:
    Fn::Not:
      - Fn::Equals:
          - Ref: ReportReceiver4
          - ""
  HasReportReceiver5:
    Fn::Not:
      - Fn::Equals:
          - Ref: ReportReceiver5
          - ""
  HasPermissionsBoundary:
    Fn::Not:
      - Fn::Equals:
          - Ref: PermissionsBoundary
          - ""
  HasAccessLogBucketName:
    Fn::Not:
      - Fn::Equals:
          - Ref: AccessLogBucketName
          - ""
  HasLambdaFunctionReservedConcurrentExecutions:
    Fn::Not:
      - Fn::Equals:
          - Ref: LambdaFunctionReservedConcurrentExecutions
          - 0
  HasStateMachineNameGeneratorFunctionReservedConcurrentExecutions:
    Fn::Not:
      - Fn::Equals:
          - Ref: StateMachineNameGeneratorFunctionReservedConcurrentExecutions
          - 0
Mappings:
  ReportingFrequencyMap:
    Daily:
      ExpirationInDays: 8
    Weekly:
      ExpirationInDays: 8
    Monthly:
      ExpirationInDays: 32
Resources:
  DeliveryStreamLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      RetentionInDays:
        Ref: LogsRetentionInDays
  DeliveryStreamLogStream:
    Type: AWS::Logs::LogStream
    Properties:
      LogGroupName:
        Ref: DeliveryStreamLogGroup
      LogStreamName: s3
  Bucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256
      LifecycleConfiguration:
        Rules:
          - AbortIncompleteMultipartUpload:
              DaysAfterInitiation: 7
            Status: Enabled
          - ExpiredObjectDeleteMarker: true
            Status: Enabled
          - NoncurrentVersionExpirationInDays:
              Fn::FindInMap:
                - ReportingFrequencyMap
                - Ref: ReportingFrequency
                - ExpirationInDays
            Status: Enabled
          - ExpirationInDays:
              Fn::FindInMap:
                - ReportingFrequencyMap
                - Ref: ReportingFrequency
                - ExpirationInDays
            Prefix: data/
            Status: Enabled
          - Fn::If:
              - HasReportsRetentionInDays
              - ExpirationInDays:
                  Ref: ReportsRetentionInDays
                Prefix: report/
                Status: Enabled
              - Ref: AWS::NoValue
      LoggingConfiguration:
        Fn::If:
          - HasAccessLogBucketName
          - DestinationBucketName:
              Ref: AccessLogBucketName
            LogFilePrefix:
              Ref: AWS::StackName
          - Ref: AWS::NoValue
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      VersioningConfiguration:
        Status: Enabled
  BucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket:
        Ref: Bucket
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Sid: AllowSSLRequestsOnly
            Effect: Deny
            Principal: "*"
            Action: s3:*
            Resource:
              - Fn::GetAtt:
                  - Bucket
                  - Arn
              - Fn::Join:
                  - ""
                  - - Fn::GetAtt:
                        - Bucket
                        - Arn
                    - /*
            Condition:
              Bool:
                aws:SecureTransport: false
          - Sid: AllowBucketAccessFromCoreDashboard
            Effect: Allow
            Principal:
              AWS:
                Fn::ImportValue:
                  Fn::Join:
                    - ""
                    - - Ref: BucketAVStackName
                      - -DashboardLambdaRoleArn
            Action:
              - s3:ListBucket
              - s3:GetBucketTagging
            Resource:
              - Fn::GetAtt:
                  - Bucket
                  - Arn
  DeliveryStreamRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: firehose.amazonaws.com
            Action: sts:AssumeRole
      PermissionsBoundary:
        Fn::If:
          - HasPermissionsBoundary
          - Ref: PermissionsBoundary
          - Ref: AWS::NoValue
      Policies:
        - PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - s3:AbortMultipartUpload
                  - s3:GetObject
                  - s3:ListMultipartUploadParts
                  - s3:PutObject
                Resource:
                  Fn::Join:
                    - ""
                    - - Fn::GetAtt:
                          - Bucket
                          - Arn
                      - /*
              - Effect: Allow
                Action:
                  - s3:GetBucketLocation
                  - s3:ListBucket
                  - s3:ListBucketMultipartUploads
                Resource:
                  Fn::GetAtt:
                    - Bucket
                    - Arn
              - Effect: Allow
                Action: logs:PutLogEvents
                Resource:
                  Fn::GetAtt:
                    - DeliveryStreamLogGroup
                    - Arn
          PolicyName: firehose
  DeliveryStream:
    Type: AWS::KinesisFirehose::DeliveryStream
    Properties:
      DeliveryStreamEncryptionConfigurationInput:
        KeyType: AWS_OWNED_CMK
      DeliveryStreamType: DirectPut
      S3DestinationConfiguration:
        BucketARN:
          Fn::GetAtt:
            - Bucket
            - Arn
        BufferingHints:
          IntervalInSeconds: 300
          SizeInMBs: 128
        CloudWatchLoggingOptions:
          Enabled: true
          LogGroupName:
            Ref: DeliveryStreamLogGroup
          LogStreamName:
            Ref: DeliveryStreamLogStream
        CompressionFormat: GZIP
        ErrorOutputPrefix: error
        Prefix: data/year=!{timestamp:yyyy}/month=!{timestamp:MM}/day=!{timestamp:dd}/
        RoleARN:
          Fn::GetAtt:
            - DeliveryStreamRole
            - Arn
  SubscriptionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: sns.amazonaws.com
            Action: sts:AssumeRole
      PermissionsBoundary:
        Fn::If:
          - HasPermissionsBoundary
          - Ref: PermissionsBoundary
          - Ref: AWS::NoValue
      Policies:
        - PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - firehose:DescribeDeliveryStream
                  - firehose:ListDeliveryStreams
                  - firehose:ListTagsForDeliveryStream
                  - firehose:PutRecord
                  - firehose:PutRecordBatch
                Resource:
                  Fn::GetAtt:
                    - DeliveryStream
                    - Arn
          PolicyName: firehose
  Subscription:
    Type: AWS::SNS::Subscription
    Properties:
      Endpoint:
        Fn::GetAtt:
          - DeliveryStream
          - Arn
      FilterPolicy:
        Fn::If:
          - HasSourceBucketNames
          - bucket:
              Fn::Split:
                - ","
                - Ref: SourceBucketNames
          - Ref: AWS::NoValue
      Protocol: firehose
      SubscriptionRoleArn:
        Fn::GetAtt:
          - SubscriptionRole
          - Arn
      TopicArn:
        Fn::ImportValue:
          Fn::Join:
            - ""
            - - Ref: BucketAVStackName
              - -FindingsTopicArn
  GlueDatabase:
    Type: AWS::Glue::Database
    Properties:
      CatalogId:
        Ref: AWS::AccountId
      DatabaseInput:
        Description: bucketAV add-on (reporting)
        Name:
          Ref: AWS::StackName
  AthenaWorkGroup:
    Type: AWS::Athena::WorkGroup
    Properties:
      Description: bucketAV add-on (reporting)
      Name:
        Ref: AWS::StackName
      RecursiveDeleteOption: true
      State: ENABLED
      WorkGroupConfiguration:
        EngineVersion:
          SelectedEngineVersion: Athena engine version 3
        PublishCloudWatchMetricsEnabled: true
        ResultConfiguration:
          OutputLocation:
            Fn::Join:
              - ""
              - - s3://
                - Ref: Bucket
                - /report/
  Topic:
    Type: AWS::SNS::Topic
  Subscriber1:
    Type: AWS::SNS::Subscription
    Properties:
      Endpoint:
        Ref: ReportReceiver1
      Protocol: email
      TopicArn:
        Ref: Topic
  Subscriber2:
    Type: AWS::SNS::Subscription
    Properties:
      Endpoint:
        Ref: ReportReceiver2
      Protocol: email
      TopicArn:
        Ref: Topic
    Condition: HasReportReceiver2
  Subscriber3:
    Type: AWS::SNS::Subscription
    Properties:
      Endpoint:
        Ref: ReportReceiver3
      Protocol: email
      TopicArn:
        Ref: Topic
    Condition: HasReportReceiver3
  Subscriber4:
    Type: AWS::SNS::Subscription
    Properties:
      Endpoint:
        Ref: ReportReceiver4
      Protocol: email
      TopicArn:
        Ref: Topic
    Condition: HasReportReceiver4
  Subscriber5:
    Type: AWS::SNS::Subscription
    Properties:
      Endpoint:
        Ref: ReportReceiver5
      Protocol: email
      TopicArn:
        Ref: Topic
    Condition: HasReportReceiver5
  LambdaRole:
    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: s3:GetObject
                Resource:
                  Fn::Join:
                    - ""
                    - - Fn::GetAtt:
                          - Bucket
                          - Arn
                      - /report/*
              - Effect: Allow
                Action: s3:PutObject
                Resource:
                  Fn::Join:
                    - ""
                    - - Fn::GetAtt:
                          - Bucket
                          - Arn
                      - /report/html/*
              - Effect: Allow
                Action: sns:Publish
                Resource:
                  Ref: Topic
              - Effect: Allow
                Action: cloudwatch:DescribeAlarms
                Resource: "*"
              - Effect: Allow
                Action: ssm:GetParameter
                Resource:
                  Fn::Join:
                    - ""
                    - - "arn:"
                      - Ref: AWS::Partition
                      - ":ssm:"
                      - Ref: AWS::Region
                      - ":"
                      - Ref: AWS::AccountId
                      - :parameter/bucketAV/
                      - Ref: BucketAVStackName
                      - /AddOn/scheduled-bucket-scan/*/OrchestratorStateMachineName
          PolicyName: lambda
  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: LambdaVpc
    Condition: HasLambdaVpc
  LambdaFunction:
    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/add-on-reporting.js
          var add_on_reporting_exports = {};
          __export(add_on_reporting_exports, {
            buildReportKey: () => buildReportKey,
            convertTextReportToHtml: () => convertTextReportToHtml,
            handler: () => handler,
            saveReportToS3: () => saveReportToS3
          });
          module.exports = __toCommonJS(add_on_reporting_exports);
          var import_client_s3 = require("@aws-sdk/client-s3");
          var import_client_sns = require("@aws-sdk/client-sns");
          var import_client_cloudwatch = require("@aws-sdk/client-cloudwatch");
          var import_client_ssm = require("@aws-sdk/client-ssm");
          var s3 = new import_client_s3.S3Client({ apiVersion: "2006-03-01" });
          var sns = new import_client_sns.SNSClient({ apiVersion: "2010-03-31" });
          var cloudwatch = new import_client_cloudwatch.CloudWatchClient({ apiVersion: "2010-08-01" });
          var ssm = new import_client_ssm.SSMClient({ apiVersion: "2014-11-06" });
          var HTML_REPORT_PREFIX = "report/html/";
          function calculateBegin(end) {
            const begin = new Date(end.getTime());
            switch (process.env.REPORTING_FREQUENCY) {
              case "Daily":
                return begin;
              case "Weekly":
                begin.setDate(begin.getDate() - 6);
                return begin;
              case "Monthly":
                begin.setDate(1);
                return begin;
              default:
                throw new Error("unsupported reporting frequency: " + process.env.REPORTING_FREQUENCY);
            }
          }
          function generateDates(begin, end) {
            let tmp = new Date(begin.getTime());
            const dates = [];
            do {
              dates.push(new Date(tmp.getTime()));
              tmp.setDate(tmp.getDate() + 1);
            } while (tmp.getTime() <= end.getTime());
            return dates;
          }
          function isLastDayOfMonth(end) {
            const tmp = new Date(end.getTime());
            tmp.setDate(tmp.getDate() + 1);
            return tmp.getDate() === 1;
          }
          function generateWhereClauses(begin, end) {
            const dates = generateDates(begin, end);
            if (begin.getFullYear() === end.getFullYear() && begin.getMonth() === end.getMonth() && begin.getDate() === 1 && isLastDayOfMonth(end)) {
              const [year, month] = begin.toISOString().slice(0, 10).split("-");
              return `year='${year}' AND month='${month}'`;
            } else {
              return "(" + dates.map((d) => {
                const [year, month, day] = d.toISOString().slice(0, 10).split("-");
                return `(year='${year}' AND month='${month}' AND day='${day}')`;
              }).join(" OR ") + ")";
            }
          }
          function generatePeriod(begin, end) {
            const beginStr = begin.toISOString().slice(0, 10);
            const endStr = end.toISOString().slice(0, 10);
            if (beginStr === endStr) {
              return beginStr;
            }
            return `${beginStr} - ${endStr}`;
          }
          function prepare(event) {
            if ("traceId" in event && "begin" in event && "end" in event) {
              const begin = new Date(event.begin);
              const end = new Date(event.end);
              if (begin.getTime() > end.getTime()) {
                throw new Error("begin > end");
              }
              if ((end.getTime() - begin.getTime()) / 864e5 > 7) {
                throw new Error("more thaan 7 days requested");
              }
              return {
                begin,
                end,
                clauses: `${generateWhereClauses(begin, end)} AND messageattributes.trace_id.Value = '${event.traceId}'`
              };
            } else {
              const now = "time" in event ? new Date(event.time) : /* @__PURE__ */ new Date();
              const end = new Date(now.getTime());
              end.setDate(end.getDate() - 1);
              end.setHours(0);
              end.setMinutes(0);
              end.setMilliseconds(0);
              const begin = calculateBegin(end);
              return {
                begin,
                end,
                clauses: generateWhereClauses(begin, end)
              };
            }
          }
          function parseS3Uri(uri) {
            const [, bucket, key] = uri.match(/^s3\:\/\/([^/]{1,})\/(.*)/);
            return {
              bucket,
              key
            };
          }
          async function downloadCSV(uri) {
            const { bucket, key } = parseS3Uri(uri);
            const data = await s3.send(new import_client_s3.GetObjectCommand({
              Bucket: bucket,
              Key: key
            }));
            const body = await data.Body.transformToString("utf8");
            return body.split("\n").filter((l) => l.length > 0).map((l) => {
              const t = l.split('","');
              t[0] = t[0].substr(1);
              if (t.length > 1) t[t.length - 1] = t[t.length - 1].substr(0, t[t.length - 1].length - 1);
              return t;
            });
          }
          function extractFileStatistics(fileStatistics, status) {
            const l = fileStatistics.slice(1).find((l2) => l2[0] === status);
            if (l === void 0) {
              return 0;
            }
            return parseInt(l[1], 10);
          }
          function formatRel(rel) {
            return Math.round(rel * 1e4) / 100;
          }
          function truncateValue(input, maxLength) {
            const suffix = "...";
            const str = String(input);
            const maxLengthWithoutSuffix = maxLength - suffix.length;
            if (str.length > maxLength) {
              return str.substr(0, maxLengthWithoutSuffix) + suffix;
            }
            return str;
          }
          async function buildReport(event) {
            const fileStatistics = await downloadCSV(event.athena.fileStatisticsUri);
            const infectionStatistics = await downloadCSV(event.athena.infectionStatisticsUri);
            const bucketStatistics = await downloadCSV(event.athena.bucketStatisticsUri);
            const { bucket, key: nonCleanKey } = parseS3Uri(event.athena.nonCleanUri);
            const { key: allKey } = parseS3Uri(event.athena.allUri);
            const filesCleanAbs = extractFileStatistics(fileStatistics, "clean");
            const filesInfectedAbs = extractFileStatistics(fileStatistics, "infected");
            const filesNoAbs = extractFileStatistics(fileStatistics, "no");
            const filesScannedAbs = filesCleanAbs + filesInfectedAbs + filesNoAbs;
            const alarms = await cloudwatch.send(new import_client_cloudwatch.DescribeAlarmsCommand({
              ActionPrefix: "arn:aws:sns:",
              AlarmNamePrefix: process.env.CORE_STACK_NAME
            })).then((data) => data.MetricAlarms.filter((a) => a.StateValue !== "OK"));
            let report = "";
            let subject = "";
            if ("bucketScan" in event) {
              subject += "bucketAV Bucket Scan Report: ";
              subject += truncateValue(process.env.ADDON_STACK_NAME, 35);
              subject += " (";
              subject += truncateValue(process.env.AWS_REGION, 10);
              subject += ", ";
              subject += truncateValue(process.env.AWS_ACCOUNT_ID, 12);
              subject += ")";
              report += `Bucket scan ID: ${event.bucketScan.scanId}\r
          `;
              report += `AWS Region: ${process.env.AWS_REGION}\r
          `;
              report += `AWS Account ID: ${process.env.AWS_ACCOUNT_ID}\r
          `;
              report += `bucketAV Stack Name: ${process.env.CORE_STACK_NAME}\r
          `;
              report += `Add-On (reporting) Stack Name: ${process.env.ADDON_STACK_NAME}\r
          `;
              report += `Add-On (scheduled bucket scan) Stack Name: ${event.bucketScan.addOnStackName}\r
          `;
              let orchestratorStateMachineName = `${event.bucketScan.addOnStackName}-orchestrator`;
              try {
                const data = await ssm.send(new import_client_ssm.GetParameterCommand({ Name: `/bucketAV/${process.env.CORE_STACK_NAME}/AddOn/scheduled-bucket-scan/${event.bucketScan.addOnStackName}/OrchestratorStateMachineName` }));
                if (data?.Parameter?.Value) {
                  orchestratorStateMachineName = data.Parameter.Value;
                }
              } catch (err) {
                if (err.name !== "ParameterNotFound") {
                  throw err;
                }
              }
              report += `\r
          Bucket scan details: <https://console.aws.amazon.com/states/home?region=${event.bucketScan.region}#/v2/executions/details/arn:aws:states:${event.bucketScan.region}:${event.bucketScan.accountId}:execution:${orchestratorStateMachineName}:${event.bucketScan.scanId}>\r
          `;
            } else {
              subject += "bucketAV ";
              subject += process.env.REPORTING_FREQUENCY;
              subject += " Report: ";
              subject += truncateValue(process.env.ADDON_STACK_NAME, 35);
              subject += " (";
              subject += truncateValue(process.env.AWS_REGION, 10);
              subject += ", ";
              subject += truncateValue(process.env.AWS_ACCOUNT_ID, 12);
              subject += ")";
              report += `Reporting period: ${event.period}\r
          `;
              report += `AWS Region: ${process.env.AWS_REGION}\r
          `;
              report += `AWS Account ID: ${process.env.AWS_ACCOUNT_ID}\r
          `;
              report += `bucketAV Stack Name: ${process.env.CORE_STACK_NAME}\r
          `;
              report += `Add-On Stack Name: ${process.env.ADDON_STACK_NAME}\r
          `;
            }
            report += "\r\n\r\n";
            report += "==================================================\r\n";
            report += "Overview\r\n";
            report += "==================================================\r\n\r\n";
            report += `${filesScannedAbs} scanned\r
          `;
            if (filesScannedAbs) {
              report += `${formatRel(filesCleanAbs / filesScannedAbs)}% clean\r
          `;
              report += `${formatRel(filesInfectedAbs / filesScannedAbs)}% infected\r
          `;
              report += `${formatRel(filesNoAbs / filesScannedAbs)}% unscannable\r
          `;
            }
            if (filesInfectedAbs > 0) {
              report += "\r\n\r\n";
              report += "==================================================\r\n";
              report += "Top 10 infections\r\n";
              report += "==================================================\r\n\r\n";
              infectionStatistics.slice(1).forEach((l) => {
                report += `${l[1]}x ${l[0]}\r
          `;
              });
            }
            if (filesScannedAbs) {
              report += "\r\n\r\n";
              report += "==================================================\r\n";
              report += "Top 10 buckets\r\n";
              report += "==================================================\r\n";
              bucketStatistics.slice(1).forEach((l) => {
                report += `\r
          ${l[0]}\r
          ${l[1]} scanned\r
          ${formatRel(parseInt(l[2], 10) / parseInt(l[1], 10))}% clean\r
          ${formatRel(parseInt(l[3], 10) / parseInt(l[1], 10))}% infected\r
          ${formatRel(parseInt(l[4], 10) / parseInt(l[1], 10))}% unscannable\r
          `;
              });
            }
            if (alarms.length > 0) {
              report += "\r\n\r\n";
              report += "==================================================\r\n";
              report += "bucketAV needs your attention\r\n";
              report += "==================================================\r\n";
              alarms.forEach((a) => {
                report += `\r
          ${a.AlarmName}\r
          `;
                report += `${a.AlarmDescription}\r
          `;
                report += `${a.StateReason}\r
          `;
              });
              report += `\r
          Details: <https://console.aws.amazon.com/cloudwatch/home?region=${process.env.AWS_REGION}#dashboards:name=${process.env.CORE_STACK_NAME}-${process.env.AWS_REGION}>\r
          `;
            }
            report += "\r\n\r\n";
            report += "==================================================\r\n";
            report += "CSV reports\r\n";
            report += "==================================================\r\n\r\n";
            if (process.env.RETENTION_IN_DAYS === "0") {
              report += "The following CSV reports are available forever:\r\n";
            } else {
              report += `The following CSV reports are available for ${process.env.RETENTION_IN_DAYS} days:\r
          `;
            }
            report += `Infected & unscannable files: <https://s3.console.aws.amazon.com/s3/object/${bucket}?region=${process.env.AWS_REGION}&prefix=${nonCleanKey}>\r
          `;
            report += `All files: <https://s3.console.aws.amazon.com/s3/object/${bucket}?region=${process.env.AWS_REGION}&prefix=${allKey}>\r
          `;
            report += "\r\n\r\n";
            report += "Do you need help with this report? Reach out to us <hello@bucketav.com>\r\n";
            report += "Warmly, The bucketAV Team.\r\n";
            return {
              subject,
              report
            };
          }
          async function sendEmail(subject, report) {
            await sns.send(new import_client_sns.PublishCommand({
              TopicArn: process.env.TOPIC_ARN,
              Message: report,
              Subject: subject
              // max 100 chars long
            }));
          }
          function convertEmailLinksToHref(text) {
            const urlRegex = /<(https?:\/\/[^\s]+)>/g;
            const textWithHref = text.replace(urlRegex, (_fullMatch, url) => {
              return `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`;
            });
            const emailRegex = /<([^\s]+@[^\s]+)>/g;
            return textWithHref.replace(emailRegex, (_fullMatch, email) => {
              return `<a href="mailto:${email}">${email}</a>`;
            });
          }
          function convertTextReportToHtml(subject, report) {
            const body = report.split("\r\n\r\n").map(
              (part) => part.split("\r\n").map((row) => `<p>${convertEmailLinksToHref(row)}</p>`).join("\n")
            ).join("\n<br>\n");
            return `<!DOCTYPE html>
          <html lang="en">
          <head>
              <meta charset="UTF-8">
              <meta name="viewport" content="width=device-width, initial-scale=1.0">
              <title>${subject}</title>
          </head>
          <body>
              ${body}
          </body>
          </html>`;
          }
          function buildReportKey(event) {
            if (event.bucketScan) {
              return `${HTML_REPORT_PREFIX}bucketav_report_bucketScan_${event.bucketScan.scanId}.html`;
            }
            const type = process.env.REPORTING_FREQUENCY.toLowerCase();
            return `${HTML_REPORT_PREFIX}bucketav_report_${type}_${event.period.replaceAll(" ", "_")}.html`;
          }
          async function saveReportToS3(event, subject, report) {
            const htmlContent = convertTextReportToHtml(subject, report);
            const key = buildReportKey(event);
            await s3.send(new import_client_s3.PutObjectCommand({
              Bucket: process.env.BUCKET_NAME,
              Key: key,
              Body: htmlContent,
              ContentType: "text/html"
            }));
          }
          function generateQueries(event) {
            const {
              begin,
              end,
              clauses
            } = prepare(event);
            const ret = {
              period: generatePeriod(begin, end),
              queries: {
                fetchFileStatistics: `SELECT messageattributes.Status.Value, COUNT(*) AS cnt FROM data WHERE ${clauses} GROUP BY messageattributes.Status.Value`,
                fetchInfectionStatistics: `SELECT COALESCE(messageattributes.Finding.Value, 'Unknown') AS finding, COUNT(*) AS cnt FROM data WHERE ${clauses} AND messageattributes.Status.Value = 'infected' GROUP BY messageattributes.Finding.Value ORDER BY cnt DESC LIMIT 10`,
                fetchBucketStatistics: `SELECT messageattributes.Bucket.Value AS bucket, COUNT(*) AS cnt, SUM(CASE WHEN messageattributes.Status.Value = 'clean' THEN 1 ELSE 0 END) AS clean, SUM(CASE WHEN messageattributes.Status.Value = 'infected' THEN 1 ELSE 0 END) AS infected, SUM(CASE WHEN messageattributes.Status.Value = 'no' THEN 1 ELSE 0 END) AS no FROM data WHERE ${clauses} GROUP BY messageattributes.Bucket.Value ORDER BY cnt DESC LIMIT 10`,
                fetchNonClean: `SELECT timestamp, messageattributes.Bucket.Value AS bucket, messageattributes.Key.Value AS key, messageattributes.Version.Value AS version, messageattributes.Status.Value AS status, messageattributes.Action.Value AS action, COALESCE(messageattributes.Finding.Value, 'Unknown') AS finding, COALESCE(json_extract_scalar(message, '$.realfiletype'), 'Unknown') AS realfiletype FROM data WHERE ${clauses} AND messageattributes.Status.Value <> 'clean' ORDER BY timestamp`,
                fetchAll: `SELECT timestamp, messageattributes.Bucket.Value AS bucket, messageattributes.Key.Value AS key, messageattributes.Version.Value AS version, messageattributes.Status.Value AS status, messageattributes.Action.Value AS action, COALESCE(messageattributes.Finding.Value, 'Unknown') AS finding, COALESCE(json_extract_scalar(message, '$.realfiletype'), 'Unknown') AS realfiletype FROM data WHERE ${clauses} ORDER BY timestamp`
              }
            };
            if ("traceId" in event && "begin" in event && "end" in event) {
              const [, stackName, addOnStackName, region, accountId, resource] = event.traceId.split(":");
              const [, scanId] = resource.split("/");
              ret.bucketScan = {
                stackName,
                addOnStackName,
                region,
                accountId,
                scanId
              };
            }
            return ret;
          }
          async function handler(event) {
            console.log(`Invoke: ${JSON.stringify(event)}`);
            if ("athena" in event) {
              const { subject, report } = await buildReport(event);
              await saveReportToS3(event, subject, report);
              await sendEmail(subject, report);
            } else {
              return generateQueries(event);
            }
          }
          // Annotate the CommonJS export names for ESM import in node:
          0 && (module.exports = {
            buildReportKey,
            convertTextReportToHtml,
            handler,
            saveReportToS3
          });
      Environment:
        Variables:
          AWS_ACCOUNT_ID:
            Ref: AWS::AccountId
          ADDON_STACK_NAME:
            Ref: AWS::StackName
          CORE_STACK_NAME:
            Ref: BucketAVStackName
          REPORTING_FREQUENCY:
            Ref: ReportingFrequency
          TOPIC_ARN:
            Ref: Topic
          RETENTION_IN_DAYS:
            Ref: ReportsRetentionInDays
          BUCKET_NAME:
            Ref: Bucket
      Handler: index.handler
      MemorySize: 1769
      ReservedConcurrentExecutions:
        Fn::If:
          - HasLambdaFunctionReservedConcurrentExecutions
          - Ref: LambdaFunctionReservedConcurrentExecutions
          - Ref: AWS::NoValue
      Role:
        Fn::GetAtt:
          - LambdaRole
          - Arn
      Runtime: nodejs22.x
      Timeout: 900
      VpcConfig:
        Fn::If:
          - HasLambdaVpc
          - SecurityGroupIds:
              - Ref: LambdaSecurityGroup
            SubnetIds:
              Ref: LambdaSubnets
          - Ref: AWS::NoValue
    Metadata:
      bucketav::LambdaVpcAllowPolicy:
        Fn::If:
          - HasLambdaVpc
          - Ref: LambdaVpcAllowPolicy
          - Ref: AWS::NoValue
  LambdaVpcAllowPolicy:
    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: LambdaRole
    Condition: HasLambdaVpc
  LambdaVpcDenyPolicy:
    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:
                      - LambdaFunction
                      - Arn
      PolicyName: vpc-deny
      Roles:
        - Ref: LambdaRole
    Condition: HasLambdaVpc
  LambdaLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName:
        Fn::Join:
          - ""
          - - /aws/lambda/
            - Ref: LambdaFunction
      RetentionInDays:
        Ref: LogsRetentionInDays
  LambdaPolicy:
    Type: AWS::IAM::Policy
    Properties:
      PolicyDocument:
        Statement:
          - Effect: Allow
            Action:
              - logs:CreateLogStream
              - logs:PutLogEvents
            Resource:
              Fn::GetAtt:
                - LambdaLogGroup
                - Arn
      PolicyName: logs
      Roles:
        - Ref: LambdaRole
  StateMachineNameGeneratorRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      PermissionsBoundary:
        Fn::If:
          - HasPermissionsBoundary
          - Ref: PermissionsBoundary
          - Ref: AWS::NoValue
  StateMachineNameGeneratorFunction:
    Type: AWS::Lambda::Function
    Properties:
      Code:
        ZipFile: |
          var __defProp = Object.defineProperty;
          var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
          var __getOwnPropNames = Object.getOwnPropertyNames;
          var __hasOwnProp = Object.prototype.hasOwnProperty;
          var __export = (target, all) => {
            for (var name in all)
              __defProp(target, name, { get: all[name], enumerable: true });
          };
          var __copyProps = (to, from, except, desc) => {
            if (from && typeof from === "object" || typeof from === "function") {
              for (let key of __getOwnPropNames(from))
                if (!__hasOwnProp.call(to, key) && key !== except)
                  __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
            }
            return to;
          };
          var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);

          // lambda/state-machine-name-generator.js
          var state_machine_name_generator_exports = {};
          __export(state_machine_name_generator_exports, {
            generateName: () => generateName,
            handler: () => handler
          });
          module.exports = __toCommonJS(state_machine_name_generator_exports);

          // lambda/lib.js
          var import_client_cloudformation = require("@aws-sdk/client-cloudformation");
          var import_client_s3 = require("@aws-sdk/client-s3");
          var import_client_sns = require("@aws-sdk/client-sns");
          var import_client_eventbridge = require("@aws-sdk/client-eventbridge");
          var import_client_dynamodb = require("@aws-sdk/client-dynamodb");
          var import_client_s32 = require("@aws-sdk/client-s3");
          var import_client_ssm = require("@aws-sdk/client-ssm");
          var import_client_secrets_manager = require("@aws-sdk/client-secrets-manager");
          var import_client_organizations = require("@aws-sdk/client-organizations");
          var import_credential_providers = require("@aws-sdk/credential-providers");

          // lambda/node_modules/@smithy/core/dist-es/pagination/createPaginator.js
          var makePagedClientRequest = async (CommandCtor, client, input, withCommand = (_) => _, ...args) => {
            let command = new CommandCtor(input);
            command = withCommand(command) ?? command;
            return await client.send(command, ...args);
          };
          function createPaginator(ClientCtor, CommandCtor, inputTokenName, outputTokenName, pageSizeTokenName) {
            return async function* paginateOperation(config, input, ...additionalArguments) {
              const _input = input;
              let token = config.startingToken ?? _input[inputTokenName];
              let hasNext = true;
              let page;
              while (hasNext) {
                _input[inputTokenName] = token;
                if (pageSizeTokenName) {
                  _input[pageSizeTokenName] = _input[pageSizeTokenName] ?? config.pageSize;
                }
                if (config.client instanceof ClientCtor) {
                  page = await makePagedClientRequest(CommandCtor, config.client, input, config.withCommand, ...additionalArguments);
                } else {
                  throw new Error(`Invalid client, expected instance of ${ClientCtor.name}`);
                }
                yield page;
                const prevToken = token;
                token = get(page, outputTokenName);
                hasNext = !!(token && (!config.stopOnSameToken || token !== prevToken));
              }
              return void 0;
            };
          }
          var get = (fromObject, path) => {
            let cursor = fromObject;
            const pathComponents = path.split(".");
            for (const step of pathComponents) {
              if (!cursor || typeof cursor !== "object") {
                return void 0;
              }
              cursor = cursor[step];
            }
            return cursor;
          };

          // lambda/lib.js
          var paginateListRuleNamesByTarget = createPaginator(import_client_eventbridge.EventBridgeClient, import_client_eventbridge.ListRuleNamesByTargetCommand, "NextToken", "NextToken", "Limit");
          var MAX_S3_COPY_SIZE = 5 * 1024 * 1024 * 1024;
          async function cfnCustomResourceSuccess(event, physicalResourceId, optionalData) {
            const response = await fetch(event.ResponseURL, {
              method: "PUT",
              headers: {
                "Content-Type": "application/json"
              },
              body: JSON.stringify({
                Status: "SUCCESS",
                PhysicalResourceId: physicalResourceId,
                StackId: event.StackId,
                RequestId: event.RequestId,
                LogicalResourceId: event.LogicalResourceId,
                ...optionalData !== void 0 && { Data: optionalData }
              })
            });
            if (response.status !== 200) {
              console.log("response status", response.status);
              console.log("response", await response.text());
              throw new Error("unexpected status code");
            }
          }

          // lambda/state-machine-name-generator.js
          var MAX_LENGTH = 80;
          function generateName(stackName, stackId, suffix) {
            if (stackName.length + suffix.length > MAX_LENGTH) {
              const resumingLength = MAX_LENGTH - (stackId.length + 1);
              const maxPrefixLength = Math.floor(resumingLength / 2);
              const maxSuffixLength = resumingLength - maxPrefixLength;
              const stackNameLength = Math.min(stackName.length, resumingLength - Math.min(suffix.length, maxSuffixLength));
              const suffixLength = Math.min(suffix.length, resumingLength - Math.min(stackName.length, maxPrefixLength));
              return stackName.substr(0, stackNameLength) + "-" + stackId + suffix.substr(0, suffixLength);
            } else {
              return stackName + suffix;
            }
          }
          async function handler(event) {
            console.log(`Invoke: ${JSON.stringify(event)}`);
            const stackName = event.StackId.split("/")[1];
            const stackId = event.StackId.split("/")[2].replaceAll("-", "");
            if (event.RequestType === "Create") {
              await cfnCustomResourceSuccess(event, generateName(stackName, stackId, event.ResourceProperties.Suffix));
            } else if (event.RequestType === "Update") {
              if (event.ResourceProperties.Suffix !== event.OldResourceProperties.Suffix) {
                await cfnCustomResourceSuccess(event, generateName(stackName, stackId, event.ResourceProperties.Suffix));
              } else {
                await cfnCustomResourceSuccess(event, event.PhysicalResourceId);
              }
            } else if (event.RequestType === "Delete") {
              await cfnCustomResourceSuccess(event, event.PhysicalResourceId);
            } else {
              throw new Error("unsupported request type");
            }
          }
          // Annotate the CommonJS export names for ESM import in node:
          0 && (module.exports = {
            generateName,
            handler
          });
      Handler: index.handler
      MemorySize: 1769
      ReservedConcurrentExecutions:
        Fn::If:
          - HasStateMachineNameGeneratorFunctionReservedConcurrentExecutions
          - Ref: StateMachineNameGeneratorFunctionReservedConcurrentExecutions
          - Ref: AWS::NoValue
      Role:
        Fn::GetAtt:
          - StateMachineNameGeneratorRole
          - Arn
      Runtime: nodejs22.x
      Timeout: 30
      VpcConfig:
        Fn::If:
          - HasLambdaVpc
          - SecurityGroupIds:
              - Ref: LambdaSecurityGroup
            SubnetIds:
              Ref: LambdaSubnets
          - Ref: AWS::NoValue
    Metadata:
      bucketav::StateMachineNameGeneratorFunctionLambdaVpcAllowPolicy:
        Fn::If:
          - HasLambdaVpc
          - Ref: StateMachineNameGeneratorFunctionLambdaVpcAllowPolicy
          - Ref: AWS::NoValue
  StateMachineNameGeneratorFunctionLambdaVpcAllowPolicy:
    Type: AWS::IAM::Policy
    Properties:
      PolicyDocument:
        Statement:
          - Effect: Allow
            Action:
              - ec2:CreateNetworkInterface
              - ec2:DeleteNetworkInterface
              - ec2:AssignPrivateIpAddresses
              - ec2:UnassignPrivateIpAddresses
              - ec2:DescribeNetworkInterfaces
              - ec2:DescribeSubnets
            Resource:
              - "*"
      PolicyName: vpc-allow
      Roles:
        - Ref: StateMachineNameGeneratorRole
    Condition: HasLambdaVpc
  StateMachineNameGeneratorFunctionLambdaVpcDenyPolicy:
    Type: AWS::IAM::Policy
    Properties:
      PolicyDocument:
        Statement:
          - Effect: Deny
            Action:
              - ec2:CreateNetworkInterface
              - ec2:DescribeNetworkInterfaces
              - ec2:DescribeSubnets
              - ec2:DeleteNetworkInterface
              - ec2:AssignPrivateIpAddresses
              - ec2:UnassignPrivateIpAddresses
            Resource: "*"
            Condition:
              ArnEquals:
                lambda:SourceFunctionArn:
                  - Fn::GetAtt:
                      - StateMachineNameGeneratorFunction
                      - Arn
      PolicyName: vpc-deny
      Roles:
        - Ref: StateMachineNameGeneratorRole
    Condition: HasLambdaVpc
  StateMachineNameGeneratorLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName:
        Fn::Join:
          - ""
          - - /aws/lambda/
            - Ref: StateMachineNameGeneratorFunction
      RetentionInDays:
        Ref: LogsRetentionInDays
  StateMachineNameGeneratorPolicy:
    Type: AWS::IAM::Policy
    Properties:
      PolicyDocument:
        Statement:
          - Effect: Allow
            Action:
              - logs:CreateLogStream
              - logs:PutLogEvents
            Resource:
              Fn::GetAtt:
                - StateMachineNameGeneratorLogGroup
                - Arn
      PolicyName: lambda
      Roles:
        - Ref: StateMachineNameGeneratorRole
  StateMachineNameGenerator:
    Type: Custom::StateMachineNameGenerator
    Properties:
      ServiceToken:
        Fn::GetAtt:
          - StateMachineNameGeneratorFunction
          - Arn
      ServiceTimeout: "30"
      Suffix: ""
    DependsOn:
      - StateMachineNameGeneratorPolicy
    UpdateReplacePolicy: Delete
    DeletionPolicy: Delete
  StateMachineLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName:
        Fn::Join:
          - ""
          - - /aws/vendedlogs/states/
            - Ref: AWS::StackName
            - -StateMachine
      RetentionInDays:
        Ref: LogsRetentionInDays
  StateMachineRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                Fn::Join:
                  - ""
                  - - states.
                    - Ref: AWS::Region
                    - .amazonaws.com
            Action: sts:AssumeRole
      PermissionsBoundary:
        Fn::If:
          - HasPermissionsBoundary
          - Ref: PermissionsBoundary
          - Ref: AWS::NoValue
      Policies:
        - PolicyDocument:
            Statement:
              - Effect: Allow
                Action:
                  - athena:StartQueryExecution
                  - athena:GetQueryExecution
                Resource:
                  Fn::Join:
                    - ""
                    - - "arn:aws:athena:"
                      - Ref: AWS::Region
                      - ":"
                      - Ref: AWS::AccountId
                      - :workgroup/
                      - Ref: AthenaWorkGroup
              - Effect: Allow
                Action:
                  - s3:AbortMultipartUpload
                  - s3:GetObject
                  - s3:ListMultipartUploadParts
                  - s3:PutObject
                Resource:
                  Fn::Join:
                    - ""
                    - - Fn::GetAtt:
                          - Bucket
                          - Arn
                      - /*
              - Effect: Allow
                Action:
                  - s3:GetBucketLocation
                  - s3:ListBucket
                  - s3:ListBucketMultipartUploads
                Resource:
                  Fn::GetAtt:
                    - Bucket
                    - Arn
              - Effect: Allow
                Action: glue:*
                Resource: "*"
              - Effect: Allow
                Action: lambda:InvokeFunction
                Resource:
                  Fn::GetAtt:
                    - LambdaFunction
                    - Arn
              - Effect: Allow
                Action:
                  - logs:CreateLogDelivery
                  - logs:GetLogDelivery
                  - logs:UpdateLogDelivery
                  - logs:DeleteLogDelivery
                  - logs:ListLogDeliveries
                  - logs:PutResourcePolicy
                  - logs:DescribeResourcePolicies
                  - logs:DescribeLogGroups
                Resource: "*"
              - Effect: Allow
                Action:
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Resource:
                  Fn::GetAtt:
                    - StateMachineLogGroup
                    - Arn
          PolicyName: states
  StateMachine:
    Type: AWS::StepFunctions::StateMachine
    Properties:
      Definition:
        Comment: bucketAV add-on (reporting)
        TimeoutSeconds: 43200
        StartAt: GenerateQueries
        Version: "1.0"
        States:
          GenerateQueries:
            Type: Task
            Resource:
              Fn::GetAtt:
                - LambdaFunction
                - Arn
            Next: DropTable
          DropTable:
            Type: Task
            Resource: arn:aws:states:::athena:startQueryExecution.sync
            Parameters:
              QueryExecutionContext:
                Database:
                  Ref: GlueDatabase
              QueryString: DROP TABLE IF EXISTS `data`
              WorkGroup:
                Ref: AthenaWorkGroup
            ResultPath: $.athena
            Next: CreateTable
          CreateTable:
            Type: Task
            Resource: arn:aws:states:::athena:startQueryExecution.sync
            Parameters:
              QueryExecutionContext:
                Database:
                  Ref: GlueDatabase
              QueryString:
                Fn::Join:
                  - ""
                  - - CREATE EXTERNAL TABLE `data` (`type` string, `messageid` string, `topicarn` string, `subject` string, `message` string, `timestamp` string, `unsubscribeurl` string, `messageattributes` struct<bucket:struct<type:string,value:string>,action:struct<type:string,value:string>,finding:struct<type:string,value:string>,version:struct<type:string,value:string>,key:struct<type:string,value:string>,status:struct<type:string,value:string>,trace_id:struct<type:string,value:string>>) PARTITIONED BY (`year` string, `month` string, `day` string) ROW FORMAT SERDE 'org.openx.data.jsonserde.JsonSerDe' WITH SERDEPROPERTIES ('paths'='Message,MessageAttributes,MessageId,Subject,Timestamp,TopicArn,Type,UnsubscribeURL') STORED AS INPUTFORMAT 'org.apache.hadoop.mapred.TextInputFormat' OUTPUTFORMAT 'org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat' LOCATION 's3://
                    - Ref: Bucket
                    - /data/' TBLPROPERTIES ('classification'='json', 'compressionType'='gzip', 'projection.year.type'='integer', 'projection.year.range'='2023,2027', 'projection.month.type'='integer', 'projection.month.range'='1,12', 'projection.month.digits'='2', 'projection.day.type'='integer', 'projection.day.range'='1,31', 'projection.day.digits'='2', 'projection.enabled'='true')
              WorkGroup:
                Ref: AthenaWorkGroup
            ResultPath: $.athena
            Next: FetchData
          FetchData:
            Type: Parallel
            Branches:
              - StartAt: FetchFileStatistics
                States:
                  FetchFileStatistics:
                    Type: Task
                    Resource: arn:aws:states:::athena:startQueryExecution.sync
                    Parameters:
                      QueryExecutionContext:
                        Database:
                          Ref: GlueDatabase
                      QueryString.$: $.queries.fetchFileStatistics
                      WorkGroup:
                        Ref: AthenaWorkGroup
                    End: true
              - StartAt: FetchInfectionStatistics
                States:
                  FetchInfectionStatistics:
                    Type: Task
                    Resource: arn:aws:states:::athena:startQueryExecution.sync
                    Parameters:
                      QueryExecutionContext:
                        Database:
                          Ref: GlueDatabase
                      QueryString.$: $.queries.fetchInfectionStatistics
                      WorkGroup:
                        Ref: AthenaWorkGroup
                    End: true
              - StartAt: FetchBucketStatistics
                States:
                  FetchBucketStatistics:
                    Type: Task
                    Resource: arn:aws:states:::athena:startQueryExecution.sync
                    Parameters:
                      QueryExecutionContext:
                        Database:
                          Ref: GlueDatabase
                      QueryString.$: $.queries.fetchBucketStatistics
                      WorkGroup:
                        Ref: AthenaWorkGroup
                    End: true
              - StartAt: FetchNonClean
                States:
                  FetchNonClean:
                    Type: Task
                    Resource: arn:aws:states:::athena:startQueryExecution.sync
                    Parameters:
                      QueryExecutionContext:
                        Database:
                          Ref: GlueDatabase
                      QueryString.$: $.queries.fetchNonClean
                      WorkGroup:
                        Ref: AthenaWorkGroup
                    End: true
              - StartAt: FetchAll
                States:
                  FetchAll:
                    Type: Task
                    Resource: arn:aws:states:::athena:startQueryExecution.sync
                    Parameters:
                      QueryExecutionContext:
                        Database:
                          Ref: GlueDatabase
                      QueryString.$: $.queries.fetchAll
                      WorkGroup:
                        Ref: AthenaWorkGroup
                    End: true
            ResultSelector:
              fileStatisticsUri.$: $[0].QueryExecution.ResultConfiguration.OutputLocation
              infectionStatisticsUri.$: $[1].QueryExecution.ResultConfiguration.OutputLocation
              bucketStatisticsUri.$: $[2].QueryExecution.ResultConfiguration.OutputLocation
              nonCleanUri.$: $[3].QueryExecution.ResultConfiguration.OutputLocation
              allUri.$: $[4].QueryExecution.ResultConfiguration.OutputLocation
            ResultPath: $.athena
            Next: SendEmail
          SendEmail:
            Type: Task
            Resource:
              Fn::GetAtt:
                - LambdaFunction
                - Arn
            End: true
      LoggingConfiguration:
        Destinations:
          - CloudWatchLogsLogGroup:
              LogGroupArn:
                Fn::GetAtt:
                  - StateMachineLogGroup
                  - Arn
        IncludeExecutionData: true
        Level: ERROR
      RoleArn:
        Fn::GetAtt:
          - StateMachineRole
          - Arn
      StateMachineName:
        Ref: StateMachineNameGenerator
    DependsOn:
      - LambdaPolicy
  StateMachineAlarmExecutionsFailed:
    Type: AWS::CloudWatch::Alarm
    Properties:
      AlarmActions:
        - Fn::ImportValue:
            Fn::Join:
              - ""
              - - Ref: BucketAVStackName
                - -InfrastructureAlarmsTopicArn
      AlarmDescription:
        Fn::Join:
          - ""
          - - "bucketAV reporting add-on has failed executions. Check executions of state machine "
            - Ref: StateMachine
            - "!"
      ComparisonOperator: GreaterThanThreshold
      Dimensions:
        - Name: StateMachineArn
          Value:
            Ref: StateMachine
      EvaluationPeriods: 1
      MetricName: ExecutionsFailed
      Namespace: AWS/States
      Period: 300
      Statistic: Sum
      Threshold: 0
      TreatMissingData: notBreaching
  StateMachineAlarmExecutionsTimedOut:
    Type: AWS::CloudWatch::Alarm
    Properties:
      AlarmActions:
        - Fn::ImportValue:
            Fn::Join:
              - ""
              - - Ref: BucketAVStackName
                - -InfrastructureAlarmsTopicArn
      AlarmDescription:
        Fn::Join:
          - ""
          - - "bucketAV reporting add-on has timed out executions. Check executions of state machine "
            - Ref: StateMachine
            - "!"
      ComparisonOperator: GreaterThanThreshold
      Dimensions:
        - Name: StateMachineArn
          Value:
            Ref: StateMachine
      EvaluationPeriods: 1
      MetricName: ExecutionsTimedOut
      Namespace: AWS/States
      Period: 300
      Statistic: Sum
      Threshold: 0
      TreatMissingData: notBreaching
  CronRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: events.amazonaws.com
            Action: sts:AssumeRole
      PermissionsBoundary:
        Fn::If:
          - HasPermissionsBoundary
          - Ref: PermissionsBoundary
          - Ref: AWS::NoValue
      Policies:
        - PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action: states:StartExecution
                Resource:
                  Ref: StateMachine
          PolicyName: events
  CronRule:
    Type: AWS::Events::Rule
    Properties:
      ScheduleExpression:
        Fn::If:
          - HasReportingFrequencyDaily
          - Fn::Join:
              - ""
              - - "cron(0 "
                - Ref: ReportingFrequencyHourOfDay
                - " * * ? *)"
          - Fn::If:
              - HasReportingFrequencyWeekly
              - Fn::Join:
                  - ""
                  - - "cron(0 "
                    - Ref: ReportingFrequencyHourOfDay
                    - " ? * "
                    - Ref: ReportingFrequencyDayOfWeek
                    - " *)"
              - Fn::Join:
                  - ""
                  - - "cron(0 "
                    - Ref: ReportingFrequencyHourOfDay
                    - " 1 * ? *)"
      Targets:
        - Arn:
            Ref: StateMachine
          Id: states
          RoleArn:
            Fn::GetAtt:
              - CronRole
              - Arn
  CronRuleAlarmFailedInvocationsTooHigh:
    Type: AWS::CloudWatch::Alarm
    Properties:
      AlarmActions:
        - Fn::ImportValue:
            Fn::Join:
              - ""
              - - Ref: BucketAVStackName
                - -InfrastructureAlarmsTopicArn
      AlarmDescription: bucketAV scheduled-bucket-scan add-on can not be triggert.
      ComparisonOperator: GreaterThanThreshold
      Dimensions:
        - Name: RuleName
          Value:
            Ref: CronRule
      EvaluationPeriods: 1
      MetricName: FailedInvocations
      Namespace: AWS/Events
      Period: 60
      Statistic: Sum
      Threshold: 0
      TreatMissingData: notBreaching
  ServiceDiscoveryBucketName:
    Type: AWS::SSM::Parameter
    Properties:
      Description: bucketAV managed value. DO NOT CHANGE!
      Name:
        Fn::Join:
          - ""
          - - /bucketAV/
            - Ref: BucketAVStackName
            - /AddOn/reporting/
            - Ref: AWS::StackName
            - /BucketName
      Type: String
      Value:
        Ref: Bucket
  ServiceDiscoveryVersion:
    Type: AWS::SSM::Parameter
    Properties:
      Description: bucketAV managed value. DO NOT CHANGE!
      Name:
        Fn::Join:
          - ""
          - - /bucketAV/
            - Ref: BucketAVStackName
            - /AddOn/reporting/
            - Ref: AWS::StackName
            - /Version
      Type: String
      Value: 2.19.1
Outputs:
  StateMachineName:
    Description: State machine name.
    Value:
      Fn::GetAtt:
        - StateMachine
        - Name
    Export:
      Name:
        Fn::Join:
          - ""
          - - Ref: AWS::StackName
            - -StateMachineName
  StateMachineArn:
    Description: State machine ARN.
    Value:
      Ref: StateMachine
    Export:
      Name:
        Fn::Join:
          - ""
          - - Ref: AWS::StackName
            - -StateMachineArn
  AddOn:
    Description: bucketAV add-on.
    Value: reporting
  Platform:
    Description: bucketAV platform.
    Value: aws
  Version:
    Description: bucketAV add-on version.
    Value: 2.19.1
  StackName:
    Description: Stack name.
    Value:
      Ref: AWS::StackName

