AWSTemplateFormatVersion: '2010-09-09'
Description: 'Lambda Scanning Target Account - EventBridge rules to forward Lambda events to Service Account'

Metadata:
  QualysTemplateVersion: 1.0.0-0

Parameters:
  ServiceAccountId:
    Type: String
    Description: AWS Account ID of the service account where Lambda scanning infrastructure is deployed
    AllowedPattern: '^\d{12}$'
    ConstraintDescription: Must be a valid 12-digit AWS account ID

  ServiceAccountRegion:
    Type: String
    Description: AWS region where the service account stack is deployed (e.g., us-west-2)
    AllowedPattern: '^[a-z]{2}-[a-z]+-[0-9]+$'
    ConstraintDescription: Must be a valid AWS region name

  SecondaryRegions:
    Type: CommaDelimitedList
    Description: Additional regions (beyond the stack deploy region) to deploy EventBridge rules for cross-region Lambda detection (e.g., sa-east-1,eu-west-1). Must not include the stack deploy region.
    Default: ''

Conditions:
  IsDifferentAccount: !Not [!Equals [!Ref AWS::AccountId, !Ref ServiceAccountId]]
  HasSecondaryRegions: !And
    - !Condition IsDifferentAccount
    - !Not [!Equals [!Join ['', !Ref SecondaryRegions], '']]
  ShouldNotify: !Condition IsDifferentAccount

Resources:
  # ========================================
  # Cross-Account IAM Role for Lambda Scanning
  # ========================================
  CrossAccountRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: QualysCloudOperatorRole
      Description: Role assumed by cloud-operator to scan Lambda functions
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              AWS: !Sub 'arn:aws:iam::${ServiceAccountId}:root'
            Action: sts:AssumeRole
            Condition:
              StringEquals:
                sts:ExternalId: !Ref ServiceAccountId
      Policies:
        - PolicyName: LambdaScanningPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Sid: ListLambdaFunctions
                Effect: Allow
                Action:
                  - lambda:ListFunctions
                Resource: '*'
              - Sid: GetLambdaDetails
                Effect: Allow
                Action:
                  - lambda:GetFunction
                  - lambda:GetFunctionConfiguration
                  - lambda:GetFunctionCodeSigningConfig
                  - lambda:GetPolicy
                Resource: 'arn:aws:lambda:*:*:function:*'
              - Sid: GetLambdaLayers
                Effect: Allow
                Action:
                  - lambda:ListLayerVersions
                  - lambda:GetLayerVersion
                Resource: 'arn:aws:lambda:*:*:layer:*'
              - Sid: PullECRImages
                Effect: Allow
                Action:
                  - ecr:GetAuthorizationToken
                  - ecr:BatchCheckLayerAvailability
                  - ecr:GetDownloadUrlForLayer
                  - ecr:BatchGetImage
                  - ecr:DescribeImages
                  - ecr:DescribeRepositories
                Resource: '*'
      MaxSessionDuration: 3600
      Tags:
        - Key: Name
          Value: QualysCloudOperatorRole
        - Key: ManagedBy
          Value: CloudFormation
        - Key: Purpose
          Value: CloudOperatorLambdaScanning

  # ========================================
  # EventBridge IAM Role (for sending to cross-account SQS)
  # ========================================
  EventBridgeToSQSRole:
    Type: AWS::IAM::Role
    Condition: IsDifferentAccount
    Properties:
      RoleName: qualys-cloud-scanning-eventbridge-to-sqs-role
      Description: Role for EventBridge to send Lambda events to service account SQS
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: events.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: SendToServiceAccountSQS
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - sqs:SendMessage
                Resource: !Sub 'arn:${AWS::Partition}:sqs:${ServiceAccountRegion}:${ServiceAccountId}:qualys-lambda-events'
      Tags:
        - Key: Name
          Value: qualys-cloud-scanning-eventbridge-to-sqs-role
        - Key: ManagedBy
          Value: CloudFormation

  # ========================================
  # EventBridge Rule for Lambda Events
  # ========================================
  LambdaFunctionEventsRule:
    Type: AWS::Events::Rule
    Condition: IsDifferentAccount
    Properties:
      Name: qualys-lambda-function-events-to-service-account
      Description: !Sub 'Capture Lambda function events in ${AWS::Region} and send to service account'
      State: ENABLED
      EventPattern:
        source:
          - aws.lambda
        detail-type:
          - AWS API Call via CloudTrail
        detail:
          eventSource:
            - lambda.amazonaws.com
          eventName:
            - CreateFunction20150331
            - UpdateFunctionCode20150331v2
            - DeleteFunction20150331
          errorCode:
            - exists: false
      Targets:
        - Arn: !Sub 'arn:${AWS::Partition}:sqs:${ServiceAccountRegion}:${ServiceAccountId}:qualys-lambda-events'
          Id: ServiceAccountSQSTarget
          RoleArn: !GetAtt EventBridgeToSQSRole.Arn

  # ========================================
  # StackSet for Cross-Region EventBridge Rules
  # ========================================
  StackSetAdministrationRole:
    Type: AWS::IAM::Role
    Condition: HasSecondaryRegions
    Properties:
      RoleName: QualysCloudScanningTargetStackSetAdminRole
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: cloudformation.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: AssumeStackSetExecutionRole
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - sts:AssumeRole
                Resource: !Sub 'arn:${AWS::Partition}:iam::${AWS::AccountId}:role/QualysCloudScanningTargetStackSetExecutionRole'

  StackSetExecutionRole:
    Type: AWS::IAM::Role
    Condition: HasSecondaryRegions
    Properties:
      RoleName: QualysCloudScanningTargetStackSetExecutionRole
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              AWS: !GetAtt StackSetAdministrationRole.Arn
            Action: sts:AssumeRole
      Policies:
        - PolicyName: StackSetExecutionPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Sid: EventBridgeManagement
                Effect: Allow
                Action:
                  - events:PutRule
                  - events:DeleteRule
                  - events:DescribeRule
                  - events:EnableRule
                  - events:DisableRule
                  - events:PutTargets
                  - events:RemoveTargets
                  - events:ListTargetsByRule
                  - events:TagResource
                  - events:UntagResource
                Resource: !Sub 'arn:${AWS::Partition}:events:*:${AWS::AccountId}:rule/qualys-lambda-function-events-*'
              - Sid: PassEventBridgeRole
                Effect: Allow
                Action:
                  - iam:PassRole
                Resource:
                  - !GetAtt EventBridgeToSQSRole.Arn
                  - !Sub 'arn:${AWS::Partition}:iam::${AWS::AccountId}:role/StackSet-*'
              - Sid: IAMRoleManagement
                Effect: Allow
                Action:
                  - iam:CreateRole
                  - iam:DeleteRole
                  - iam:PutRolePolicy
                  - iam:DeleteRolePolicy
                  - iam:GetRole
                  - iam:GetRolePolicy
                  - iam:TagRole
                  - iam:UntagRole
                Resource: !Sub 'arn:${AWS::Partition}:iam::${AWS::AccountId}:role/StackSet-*'
              - Sid: CloudFormationStackManagement
                Effect: Allow
                Action:
                  - cloudformation:CreateStack
                  - cloudformation:UpdateStack
                  - cloudformation:DeleteStack
                  - cloudformation:DescribeStacks
                  - cloudformation:DescribeStackEvents
                  - cloudformation:GetTemplate
                Resource: !Sub 'arn:${AWS::Partition}:cloudformation:*:${AWS::AccountId}:stack/StackSet-*'

  SecondaryRegionEventBridgeStackSet:
    Type: AWS::CloudFormation::StackSet
    Condition: HasSecondaryRegions
    DependsOn:
      - StackSetAdministrationRole
      - StackSetExecutionRole
    Properties:
      StackSetName: qualys-cloud-scanning-target-eventbridge-secondary-regions
      Description: EventBridge rules for Lambda events in secondary regions of the target account
      PermissionModel: SELF_MANAGED
      AdministrationRoleARN: !GetAtt StackSetAdministrationRole.Arn
      ExecutionRoleName: QualysCloudScanningTargetStackSetExecutionRole
      Capabilities:
        - CAPABILITY_IAM
      Parameters:
        - ParameterKey: ServiceAccountEventBusArn
          ParameterValue: !Sub 'arn:${AWS::Partition}:events:${AWS::Region}:${ServiceAccountId}:event-bus/default'
      TemplateBody: |
        AWSTemplateFormatVersion: '2010-09-09'
        Description: 'EventBridge Rule for Lambda Events (Target Account Secondary Region)'

        Parameters:
          ServiceAccountEventBusArn:
            Type: String
            Description: ARN of the service account event bus in the deploy region

        Resources:
          EventBridgeToEventBusRole:
            Type: AWS::IAM::Role
            Properties:
              AssumeRolePolicyDocument:
                Version: '2012-10-17'
                Statement:
                  - Effect: Allow
                    Principal:
                      Service: events.amazonaws.com
                    Action: sts:AssumeRole
              Policies:
                - PolicyName: AllowPutEventsToServiceAccount
                  PolicyDocument:
                    Version: '2012-10-17'
                    Statement:
                      - Effect: Allow
                        Action:
                          - events:PutEvents
                        Resource: !Ref ServiceAccountEventBusArn

          LambdaFunctionEventsRule:
            Type: AWS::Events::Rule
            Properties:
              Name: qualys-lambda-function-events-to-service-account
              Description: !Sub 'Capture Lambda function events in ${AWS::Region} and forward to service account event bus'
              State: ENABLED
              EventPattern:
                source:
                  - aws.lambda
                detail-type:
                  - AWS API Call via CloudTrail
                detail:
                  eventSource:
                    - lambda.amazonaws.com
                  eventName:
                    - CreateFunction20150331
                    - UpdateFunctionCode20150331v2
                    - DeleteFunction20150331
                  errorCode:
                    - exists: false
              Targets:
                - Arn: !Ref ServiceAccountEventBusArn
                  Id: ServiceAccountEventBusTarget
                  RoleArn: !GetAtt EventBridgeToEventBusRole.Arn

        Outputs:
          EventBridgeRuleArn:
            Description: ARN of the EventBridge rule
            Value: !GetAtt LambdaFunctionEventsRule.Arn
      StackInstancesGroup:
        - DeploymentTargets:
            Accounts:
              - !Ref AWS::AccountId
          Regions: !Ref SecondaryRegions

  # ========================================
  # Target Account Onboarding Notifier (Custom Resource)
  # Sends a synthetic scan-trigger message to the service account SQS queue
  # on every stack CREATE and UPDATE so existing lambdas are discovered
  # immediately without waiting for an organic CloudTrail event.
  # ========================================

  TargetAccountOnboardingNotifierRole:
    Type: AWS::IAM::Role
    Condition: ShouldNotify
    Properties:
      RoleName: qualys-cloud-scanning-notifier-role
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: SendOnboardingMessage
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - sqs:SendMessage
                Resource: !Sub 'arn:${AWS::Partition}:sqs:${ServiceAccountRegion}:${ServiceAccountId}:qualys-onboarding-events'
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Resource: !Sub 'arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/qualys-target-account-onboarding-notifier:*'
      Tags:
        - Key: ManagedBy
          Value: CloudFormation

  TargetAccountOnboardingNotifierFunction:
    Type: AWS::Lambda::Function
    Condition: ShouldNotify
    Properties:
      FunctionName: qualys-target-account-onboarding-notifier
      Runtime: python3.12
      Handler: index.handler
      Role: !GetAtt TargetAccountOnboardingNotifierRole.Arn
      Timeout: 60
      Code:
        ZipFile: |
          import json
          import boto3
          import urllib.request
          import traceback

          def send_cfn_response(event, context, status, reason=''):
              body = json.dumps({
                  'Status': status,
                  'Reason': reason,
                  'PhysicalResourceId': event.get('PhysicalResourceId', event['LogicalResourceId']),
                  'StackId': event['StackId'],
                  'RequestId': event['RequestId'],
                  'LogicalResourceId': event['LogicalResourceId'],
              }).encode()
              req = urllib.request.Request(
                  url=event['ResponseURL'],
                  data=body,
                  method='PUT',
                  headers={'Content-Type': '', 'Content-Length': len(body)},
              )
              urllib.request.urlopen(req)

          def handler(event, context):
              print(json.dumps({'event_type': event.get('RequestType'), 'event': event}))
              try:
                  if event['RequestType'] == 'Delete':
                      send_cfn_response(event, context, 'SUCCESS')
                      return
                  props = event['ResourceProperties']
                  sqs = boto3.client('sqs', region_name=props['ServiceAccountRegion'])
                  regions = [r.strip() for r in props['AllRegions'].split(',') if r.strip()]
                  msg = {
                      'source': 'qualys.target-account',
                      'detail-type': 'TargetAccountOnboarded',
                      'account': props['TargetAccountId'],
                      'detail': {
                          'accountId': props['TargetAccountId'],
                          'regions': regions,
                          'stackName': props['StackName'],
                          'requestType': event['RequestType'],
                      }
                  }
                  sqs.send_message(
                      QueueUrl=props['ServiceAccountSQSQueueUrl'],
                      MessageBody=json.dumps(msg),
                  )
                  print(json.dumps({'sent_notification': regions, 'account': props['TargetAccountId']}))
                  send_cfn_response(event, context, 'SUCCESS')
              except Exception as e:
                  print(traceback.format_exc())
                  send_cfn_response(event, context, 'FAILED', str(e))
      Tags:
        - Key: ManagedBy
          Value: CloudFormation

  TargetAccountOnboardingNotifier:
    Type: Custom::TargetAccountOnboardingNotifier
    Condition: ShouldNotify
    DeletionPolicy: Retain
    DependsOn:
      - TargetAccountOnboardingNotifierFunction
      - EventBridgeToSQSRole
    Properties:
      ServiceToken: !GetAtt TargetAccountOnboardingNotifierFunction.Arn
      TargetAccountId: !Ref AWS::AccountId
      ServiceAccountRegion: !Ref ServiceAccountRegion
      ServiceAccountSQSQueueUrl: !Sub 'https://sqs.${ServiceAccountRegion}.amazonaws.com/${ServiceAccountId}/qualys-onboarding-events'
      AllRegions: !If
        - HasSecondaryRegions
        - !Sub
          - '${DeployRegion},${Secondary}'
          - DeployRegion: !Ref AWS::Region
            Secondary: !Join [',', !Ref SecondaryRegions]
        - !Ref AWS::Region
      StackName: !Ref AWS::StackName

Outputs:
  CrossAccountRoleArn:
    Description: ARN of the cross-account role for Lambda scanning
    Value: !GetAtt CrossAccountRole.Arn
    Export:
      Name: !Sub '${AWS::StackName}-CrossAccountRoleArn'

  EventBridgeRuleArn:
    Condition: IsDifferentAccount
    Description: ARN of the EventBridge rule forwarding Lambda events
    Value: !GetAtt LambdaFunctionEventsRule.Arn
    Export:
      Name: !Sub '${AWS::StackName}-EventBridgeRuleArn'

  ServiceAccountId:
    Description: Service account ID that can assume the cross-account role
    Value: !Ref ServiceAccountId
    Export:
      Name: !Sub '${AWS::StackName}-ServiceAccountId'
