はじめに

最近アップロードされたファイルウィルススキャンする要件を満たすために、ClamAVをLambdaで動かしてウィルススキャンする様開発した機会があったので、ClamAVをAWS Lambdaで動かすまでの諸々を記載したいと思います。

下図の様にS3にファイルがアップロードされたトリガーで、Lambdaを起動し、LambdaでClamAVのスキャンを実行してウィルススキャンする流れです。
ClamAV構成

今回はClamAVは使ってウィルススキャンすることとなりましたが、
先日、AWS re:Inforce 2024でS3のファイルに対してマルウェア検出と保護の機能である「Amazon GuardDuty Malware Protection for Amazon S3」のリリースが発表されたため、今後S3のファイルに対して ClamAV を使ってウィルススキャンする方式が採用される機会は少なくなるのではと予想されますが、今回の開発を通して、ClamAVについて学ぶことがあったのでアウトプットします。

ClamAVとは

正式名称:Clam AntiVirus で 略称: ClamAV で本記事では略称で記載しています。
オープンソース (GPL) で提供されているクロスプラットフォームのウィルススキャンソフトウェアです。
ClamAV公式サイト ClamAVGithub

コマンド
Lambdaで実行する際に必要なClamAVのコマンドは2つだけです。

  1. clamscan
  2. freshclam

・clamscan
clamscan {ファイルパス} でファイルパスで指定したファイルに対してウィルススキャンを実行します。
下記サイトに安全なテスト用のマルウェアファイルがあるためダウンロードして使います。
https://www.eicar.org/download-anti-malware-testfile/

実行例:
sh-5.2# ls /tmp
EICAR.TXT eicar_com.zip eicarcom2.zip
sh-5.2# clamscan /tmp
WARNING: Failed to set locale
LibClamAV Warning: **************************************************
LibClamAV Warning: *** The virus database is older than 7 days! ***
LibClamAV Warning: *** Please update it as soon as possible. ***
LibClamAV Warning: **************************************************
/tmp/eicar_com.zip: Win.Test.EICAR_HDB-1 FOUND
/tmp/eicarcom2.zip: Win.Test.EICAR_HDB-1 FOUND
/tmp/.gitignore: OK
/tmp/EICAR.TXT: Win.Test.EICAR_HDB-1 FOUND

----------- SCAN SUMMARY -----------
Known viruses: 8685899
Engine version: 0.103.9
Scanned directories: 1
Scanned files: 4
Infected files: 3
Data scanned: 0.00 MB
Data read: 0.00 MB (ratio 0.00:1)
Time: 17.847 sec (0 m 17 s)
Start Date: 2024:07:02 01:43:51
End Date: 2024:07:02 01:44:09

・ウィルス定義ファイルが古いとWarningが出力されます。
・ファイル毎のスキャン結果とSUMMARYが出力されます。
・SUMMARYには、スキャンしたファイル数、ウィルス検出したファイル数、処理時間等が出力されます。

・freshclam
ウィルス定義ファイルを更新します。

実行例:
sh-5.2# freshclam 
ClamAV update process started at Fri Jul 5 18:24:34 2024
Trying to retrieve CVD header from https://database.clamav.net/daily.cvd
Time: 1.3s, ETA: 0.0s [========================>] 512B/512B
OK
daily database available for update (local version: 27203, remote version: 27326)
Current database is 123 versions behind.
Downloading database patch # 27204...
Time: 16.0s, ETA: 0.0s [========================>] 60.89MiB/60.89MiB
Testing database: '/var/lib/clamav/tmp.01c90f3f6a/clamav-0ef08e4da800f4c5fe35e8fece256a5a.tmp-daily.cvd' ...
Database test passed.
daily.cvd updated (version: 27326, sigs: 2063837, f-level: 90, builder: raynman)
Trying to retrieve CVD header from https://database.clamav.net/main.cvd
main.cvd database is up-to-date (version: 62, sigs: 6647427, f-level: 90, builder: sigmgr)
Trying to retrieve CVD header from https://database.clamav.net/bytecode.cvd
bytecode.cld database is up-to-date (version: 335, sigs: 86, f-level: 90, builder: raynman)
sh-5.2# 

やること

Dockerfile 作成

  • lambda/python:3.12 のイメージに、ClamAVをインストール
  • Dockerビルド時にウィルス定義ファイルを更新 ・・・ Lambda起動の度にウィルス定義ファイルを更新すると処理時間がかかるためDockerビルド時に更新する様にしました
FROM public.ecr.aws/lambda/python:3.12

RUN set -ex 
 && dnf -y update 
 && dnf install -y tar gzip

 # Copy function code
COPY lambda/ ${LAMBDA_TASK_ROOT} 

# Install dependencies
RUN pip3 install -r requirements.txt

# Install ClamAV
RUN set -ex 
 && dnf install -y clamav clamav-update

# Update virus definition file
RUN set -ex 
 && freshclam

ENV 
 TZ='Asia/Tokyo'

CMD [ "app.lambda_handler" ]

ClamAVを使ってウィルススキャンする Lambda 処理作成

  • S3イベントトリガーで渡ってくる引数からバケット、ファイルパスを取得してLambda処理内にダウンロード
  • ダウンロードしたファイルをClamAVでスキャン
import os
import json
import boto3
import subprocess
import urllib.parse

s3 = boto3.client('s3')


def lambda_handler(event, context):
    """
    Lambdaメイン処理
    """

    print(json.dumps(event, ensure_ascii=False))

    # s3からファイルダウンロード
    bucket_name = event['Records'][0]['s3']['bucket']['name']
    key = urllib.parse.unquote_plus(
        event['Records'][0]['s3']['object']['key'], encoding='utf-8')
    file_name = os.path.basename(key)
    download_path = f'/tmp/{file_name}'
    s3.download_file(bucket_name, key, download_path)

    print('スキャン対象:'+event['Records'][0]['s3']['object']['key'])

    # スキャン対象ファイルの存在出力
    res = subprocess.run(["ls", "-la", "/tmp"],
                         stdout=subprocess.PIPE, text=True)
    print(res.stdout)

    # ClamAV スキャン実行
    res = subprocess.run(["clamscan", download_path],
                         stdout=subprocess.PIPE, text=True)
    print(res.stdout)

    return {
        'statusCode': 200,
    }

LambdaのCloudFormationテンプレート 作成

  • Lambda、Lambdaの実行ロール、S3、S3イベントトリガーを定義
  • Lambdaは指定されたECR イメージのコンテナを起動する指定
  • Lambdaのメモリーは1536M(1.5G)を指定、1024MだとClamAVのスキャンが実行できない(タイムアウトする)
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31

Parameters:
  Project:
    Type: String
  AccountId:
    Type: String

Resources:
  ClamAVFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: !Sub ${Project}
      CodeUri: lambda/
      MemorySize: 1536
      Timeout: 900
      EphemeralStorage:
        Size: 512
      Role: !GetAtt ClamAVFunctionRole.Arn
      PackageType: Image
      ImageUri: !Sub ${AWS::AccountId}.dkr.ecr.ap-northeast-1.amazonaws.com/${Project}-ecr:latest
      ImageConfig:
        Command:
          - "app.lambda_handler"
      Events:
        S3TriggerFunctionEvent:
          Type: S3
          Properties:
            Bucket: !Ref ClamAVTriggerBucket
            Events: s3:ObjectCreated:*

  ClamAVFunctionRole:
    Type: "AWS::IAM::Role"
    Properties:
      RoleName: !Sub ${Project}-run-role
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - sts:AssumeRole
      Policies:
        - PolicyName: !Sub ${Project}-run-policy
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Resource: "*"
              - Effect: Allow
                Action:
                  - s3:GetObject
                Resource: "*"

  ClamAVTriggerFunctionPermission:
    Type: "AWS::Lambda::Permission"
    Properties:
      Action: "lambda:InvokeFunction"
      FunctionName: !GetAtt ClamAVFunction.Arn
      Principal: "s3.amazonaws.com"
      SourceArn: !GetAtt ClamAVTriggerBucket.Arn

  ClamAVTriggerBucket:
    Type: "AWS::S3::Bucket"
    Properties:
      BucketName: !Sub ${Project}-${AWS::AccountId}

GitHub Actions ワークフローファイル作成

name: Lambda Deploy Development
on:
  workflow_dispatch:

env:
  AWS_ACCOUNT_ID: XXXXX
  AWS_IAM_ROLE_ARN: arn:aws:iam::XXXXX:role/lambda-ecr-deploy-role
  AWS_DEFAULT_REGION: ap-northeast-1
  PROJECT: clamav-lambda
  SAM_CLI_TELEMETRY: 1

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read

    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ env.AWS_IAM_ROLE_ARN }}
          role-session-name: github-actions-${{ github.run_id }}
          aws-region: ${{ env.AWS_DEFAULT_REGION }}

      - run: aws sts get-caller-identity

      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build Docker
        working-directory: ./
        run: |
          docker build -t ${{ env.PROJECT }}-ecr -f ./Dockerfile .
          docker tag ${{ env.PROJECT }}-ecr:latest ${AWS_ACCOUNT_ID}.dkr.ecr.ap-northeast-1.amazonaws.com/${{ env.PROJECT }}-ecr:latest

      - name: Push Image to Amazon ECR
        working-directory: ./
        run: |
          docker push ${AWS_ACCOUNT_ID}.dkr.ecr.ap-northeast-1.amazonaws.com/${{ env.PROJECT }}-ecr:latest

      - name: ECR Lambda Stack
        env:
          STACK_NAME: ${{ env.PROJECT }}-stack
          TEMPLATE_NAME: lambda-stack.yaml
        working-directory: ./
        run: |
          sam build --use-container 
            --template-file ${TEMPLATE_NAME}
          sam deploy --no-confirm-changeset 
            --stack-name ${STACK_NAME} 
            --template-file ${TEMPLATE_NAME} 
            --s3-bucket ${{ env.PROJECT }}-${AWS_ACCOUNT_ID} 
            --image-repository ${AWS_ACCOUNT_ID}.dkr.ecr.ap-northeast-1.amazonaws.com/${{ env.PROJECT }}-ecr 
            --parameter-overrides Project=${{ env.PROJECT }} AccountId=${AWS_ACCOUNT_ID} 
            --capabilities CAPABILITY_NAMED_IAM 
            --no-fail-on-empty-changeset

      - name: Lambda Renew Image
        run: |
          aws lambda update-function-code 
            --function-name ${{ env.PROJECT }} 
            --image-uri ${AWS_ACCOUNT_ID}.dkr.ecr.ap-northeast-1.amazonaws.com/${{ env.PROJECT }}-ecr:latest

デプロイ前準備とデプロイ

  • 先にECR リポジトリ作成します。LambdaのCloudFormationテンプレートで指定されている名前でプライベートなECR リポジトリを作成します。
  • ECR のコンテナイメージで動作する AWS Lambda を GitHub Actions からデプロイする では、CloudFormationテンプレートでLambda関数のみ作成していますが、今回は、IAMロール、S3バケット、S3イベントトリガーを作成するために、必要な権限をGitHub ActionsのIAMロールに権限付与します。
  • GitHub Actions 実行します。実行例は、こちらの記事 を参考にしてください。

動作テスト

Lambda実行結果

ClamAVをAWS Lambdaで動かして、ウィルススキャンできたこと確認できました!