---
# 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 OpsCenter integration Add-On for Amazon S3
AWSTemplateFormatVersion: "2010-09-09"
Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label:
          default: Required Parameters
        Parameters:
          - BucketAVStackName
      - Label:
          default: OpsCenter Parameters
        Parameters:
          - IgnoreClientSideEncryptedFiles
          - LogsRetentionInDays
      - Label:
          default: Permissions Parameters
        Parameters:
          - PermissionsBoundary
      - Label:
          default: Lambda Parameters
        Parameters:
          - LambdaFunctionReservedConcurrentExecutions
          - 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)
  IgnoreClientSideEncryptedFiles:
    Type: String
    Default: "false"
    AllowedValues:
      - "true"
      - "false"
    Description: Ignore client-side encrypted files (e.g., password-protected ZIP and PDF).
  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.
  PermissionsBoundary:
    Type: String
    Default: ""
    Description: Optional IAM policy ARN that will be used as the permissions boundary for all roles.
  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.
Conditions:
  HasLambdaVpc:
    Fn::And:
      - Fn::Not:
          - Fn::Equals:
              - Fn::Join:
                  - ""
                  - Ref: LambdaSubnets
              - ""
      - Fn::Not:
          - Fn::Equals:
              - Ref: LambdaVpc
              - ""
  HasIgnoreClientSideEncryptedFiles:
    Fn::Equals:
      - Ref: IgnoreClientSideEncryptedFiles
      - "true"
  HasPermissionsBoundary:
    Fn::Not:
      - Fn::Equals:
          - Ref: PermissionsBoundary
          - ""
  HasLambdaFunctionReservedConcurrentExecutions:
    Fn::Not:
      - Fn::Equals:
          - Ref: LambdaFunctionReservedConcurrentExecutions
          - 0
Resources:
  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: ssm:CreateOpsItem
                Resource: "*"
          PolicyName: ssm
        - PolicyDocument:
            Statement:
              - Effect: Allow
                Action: sqs:SendMessage
                Resource:
                  Fn::GetAtt:
                    - LambdaDeadLetterQueue
                    - Arn
          PolicyName: dlq
  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-ops-center.js
          var add_on_ops_center_exports = {};
          __export(add_on_ops_center_exports, {
            handler: () => handler
          });
          module.exports = __toCommonJS(add_on_ops_center_exports);
          var import_client_ssm = require("@aws-sdk/client-ssm");

          // lambda/lib2.js
          function generateSecurityHubTitle(record) {
            if (record.Sns.MessageAttributes.status.Value === "no") {
              return "File scan failed";
            } else if (record.Sns.MessageAttributes.status.Value === "infected") {
              return "Infected file found";
            } else {
              throw new Error(`unsupported status: ${record.Sns.MessageAttributes.status.Value}`);
            }
          }
          function generateSecurityHubDescription(record) {
            if (record.Sns.MessageAttributes.status.Value === "no") {
              let description = `File in S3 bucket ${record.Sns.MessageAttributes.bucket.Value} with key ${record.Sns.MessageAttributes.key.Value}`;
              if ("version" in record.Sns.MessageAttributes) {
                description += ` and version ${record.Sns.MessageAttributes.version.Value}`;
              }
              description += " could not be scanned";
              if ("finding" in record.Sns.MessageAttributes) {
                description += ` (${record.Sns.MessageAttributes.finding.Value})`;
              }
              description += `, ${record.Sns.MessageAttributes.action.Value} action executed`;
              return description;
            } else if (record.Sns.MessageAttributes.status.Value === "infected") {
              let description = "Infected file";
              if ("finding" in record.Sns.MessageAttributes) {
                description += ` (${record.Sns.MessageAttributes.finding.Value})`;
              }
              description += ` found in S3 bucket ${record.Sns.MessageAttributes.bucket.Value} with key ${record.Sns.MessageAttributes.key.Value}`;
              if ("version" in record.Sns.MessageAttributes) {
                description += ` and version ${record.Sns.MessageAttributes.version.Value}`;
              }
              description += `, ${record.Sns.MessageAttributes.action.Value} action executed`;
              return description;
            } else {
              throw new Error(`unsupported status: ${record.Sns.MessageAttributes.status.Value}`);
            }
          }
          function generateOpsCenterTitle(record) {
            return generateSecurityHubTitle(record);
          }
          function generateOpsCenterDescription(record) {
            return generateSecurityHubDescription(record);
          }

          // lambda/add-on-ops-center.js
          var ssm = new import_client_ssm.SSMClient({ apiVersion: "2014-11-06" });
          async function handler(event) {
            console.log(`Invoke: ${JSON.stringify(event)}`);
            await Promise.all(event.Records.map((record) => {
              return ssm.send(new import_client_ssm.CreateOpsItemCommand({
                Description: generateOpsCenterDescription(record),
                OperationalData: {
                  "/aws/resources": {
                    Value: `[{"arn": "arn:aws:s3:::${record.Sns.MessageAttributes.bucket.Value}"}]`,
                    Type: "SearchableString"
                  },
                  "bucket": {
                    Value: record.Sns.MessageAttributes.bucket.Value,
                    Type: "SearchableString"
                  },
                  "key": {
                    Value: record.Sns.MessageAttributes.key.Value,
                    Type: "SearchableString"
                  }
                },
                Priority: 4,
                Source: "bucketAV",
                Title: generateOpsCenterTitle(record)
              }));
            }));
            return true;
          }
          // Annotate the CommonJS export names for ESM import in node:
          0 && (module.exports = {
            handler
          });
      DeadLetterConfig:
        TargetArn:
          Fn::GetAtt:
            - LambdaDeadLetterQueue
            - Arn
      Handler: index.handler
      MemorySize: 1769
      ReservedConcurrentExecutions:
        Fn::If:
          - HasLambdaFunctionReservedConcurrentExecutions
          - Ref: LambdaFunctionReservedConcurrentExecutions
          - Ref: AWS::NoValue
      Role:
        Fn::GetAtt:
          - LambdaRole
          - Arn
      Runtime: nodejs22.x
      Timeout: 60
      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
  LambdaErrorsAlarm:
    Type: AWS::CloudWatch::Alarm
    Properties:
      AlarmActions:
        - Fn::ImportValue:
            Fn::Join:
              - ""
              - - Ref: BucketAVStackName
                - -InfrastructureAlarmsTopicArn
      AlarmDescription:
        Fn::Join:
          - ""
          - - "bucketAV ops-center add-on failed. Check logs of AWS Lambda Function "
            - Ref: LambdaFunction
            - "!"
      ComparisonOperator: GreaterThanOrEqualToThreshold
      Dimensions:
        - Name: FunctionName
          Value:
            Ref: LambdaFunction
      EvaluationPeriods: 1
      MetricName: Errors
      Namespace: AWS/Lambda
      Period: 360
      Statistic: Sum
      Threshold: 1
      TreatMissingData: notBreaching
  LambdaThrottlesAlarm:
    Type: AWS::CloudWatch::Alarm
    Properties:
      AlarmActions:
        - Fn::ImportValue:
            Fn::Join:
              - ""
              - - Ref: BucketAVStackName
                - -InfrastructureAlarmsTopicArn
      AlarmDescription: bucketAV ops-center add-on throttled.
      ComparisonOperator: GreaterThanOrEqualToThreshold
      Dimensions:
        - Name: FunctionName
          Value:
            Ref: LambdaFunction
      EvaluationPeriods: 1
      MetricName: Throttles
      Namespace: AWS/Lambda
      Period: 360
      Statistic: Sum
      Threshold: 1
      TreatMissingData: notBreaching
  LambdaDeadLetterQueue:
    Type: AWS::SQS::Queue
    Properties:
      MessageRetentionPeriod: 1209600
      SqsManagedSseEnabled: true
  LambdaDeadLetterQueueAlarm:
    Type: AWS::CloudWatch::Alarm
    Properties:
      AlarmActions:
        - Fn::ImportValue:
            Fn::Join:
              - ""
              - - Ref: BucketAVStackName
                - -InfrastructureAlarmsTopicArn
      AlarmDescription: bucketAV ops-center add-on has dead letter queue messages.
      ComparisonOperator: GreaterThanOrEqualToThreshold
      Dimensions:
        - Name: QueueName
          Value:
            Fn::GetAtt:
              - LambdaDeadLetterQueue
              - QueueName
      EvaluationPeriods: 1
      MetricName: ApproximateNumberOfMessagesVisible
      Namespace: AWS/SQS
      Period: 360
      Statistic: Sum
      Threshold: 1
      TreatMissingData: notBreaching
  LambdaDeadLetterErrorsAlarm:
    Type: AWS::CloudWatch::Alarm
    Properties:
      AlarmActions:
        - Fn::ImportValue:
            Fn::Join:
              - ""
              - - Ref: BucketAVStackName
                - -InfrastructureAlarmsTopicArn
      AlarmDescription: bucketAV ops-center add-on has dead letter queue errors.
      ComparisonOperator: GreaterThanOrEqualToThreshold
      Dimensions:
        - Name: FunctionName
          Value:
            Ref: LambdaFunction
      EvaluationPeriods: 1
      MetricName: DeadLetterErrors
      Namespace: AWS/Lambda
      Period: 360
      Statistic: Sum
      Threshold: 1
      TreatMissingData: notBreaching
  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
  LambdaPermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName:
        Ref: LambdaFunction
      Principal: sns.amazonaws.com
      SourceArn:
        Fn::ImportValue:
          Fn::Join:
            - ""
            - - Ref: BucketAVStackName
              - -FindingsTopicArn
  Subscription:
    Type: AWS::SNS::Subscription
    Properties:
      Endpoint:
        Fn::GetAtt:
          - LambdaFunction
          - Arn
      FilterPolicy:
        Fn::If:
          - HasIgnoreClientSideEncryptedFiles
          - $or:
              - status:
                  - infected
              - status:
                  - "no"
                finding:
                  - anything-but:
                      - bucketav:encrypted
                      - Heuristics.Encrypted.7Zip
                      - Heuristics.Encrypted.PDF
                      - Heuristics.Encrypted.Zip
                      - Heuristics.Encrypted.RAR
                      - Heuristics.Encrypted.EGG
          - status:
              - infected
              - "no"
      Protocol: lambda
      TopicArn:
        Fn::ImportValue:
          Fn::Join:
            - ""
            - - Ref: BucketAVStackName
              - -FindingsTopicArn
    DependsOn:
      - LambdaPermission
      - LambdaPolicy
  ServiceDiscoveryVersion:
    Type: AWS::SSM::Parameter
    Properties:
      Description: bucketAV managed value. DO NOT CHANGE!
      Name:
        Fn::Join:
          - ""
          - - /bucketAV/
            - Ref: BucketAVStackName
            - /AddOn/ops-center/
            - Ref: AWS::StackName
            - /Version
      Type: String
      Value: 2.11.0
Outputs:
  LambdaDeadLetterQueueName:
    Description: The name of the Dead Letter Queue used by the Lambda function.
    Value:
      Fn::GetAtt:
        - LambdaDeadLetterQueue
        - QueueName
  AddOn:
    Description: bucketAV add-on.
    Value: ops-center
  Platform:
    Description: bucketAV platform.
    Value: aws
  Version:
    Description: bucketAV add-on version.
    Value: 2.11.0
  StackName:
    Description: Stack name.
    Value:
      Ref: AWS::StackName

