Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 23 min read

S3 malware scanning: check uploads with GuardDuty

S3 malware scanning is one of those topics that tends to be missing from architecture diagrams. We build uploads with presigned URLs, we care about file sizes and CORS, and in the end a PDF from a complete stranger lands

S3 malware scanning is one of those topics that tends to be missing from architecture diagrams. We build uploads with presigned URLs, we care about file sizes and CORS, and in the end a PDF from a complete stranger lands in our bucket. From there, someone in HR might open it, a thumbnail generator processes it or an AI pipeline extracts its text. At that point the question is fair: how do you actually know there is nothing nasty inside that file?

Earlier today I showed how to build a secure file upload with S3 presigned URLs. This article is the logical next step. You will learn how GuardDuty Malware Protection for S3 checks every new file automatically, how to lock unscanned files with a bucket policy and how a Lambda function passes clean files on. The example is the job application portal of a fictional bakery that no longer wants to receive rΓ©sumΓ©s by email. All the code is written with AWS CDK and TypeScript and is ready to use on GitHub.

In short

GuardDuty Malware Protection for S3 automatically scans new objects in a bucket, sets a tag with the result and reports it to EventBridge. With a bucket policy on that tag and a small Lambda function, you get an upload path where only checked files are processed further. The biggest stumbling block is the IAM role for GuardDuty: if a permission is missing, the plan stays in a warning state and files remain unscanned.

What you will learn in this article

  • How GuardDuty Malware Protection for S3 works and what it costs
  • Locking unscanned files with a tag and a bucket policy
  • Processing scan results idempotently in a Lambda function
  • The complete CDK stack with tests, deployment and a test run
  • Typical mistakes with the IAM role, tags and KMS, and how to avoid them

Why S3 malware scanning belongs in every upload flow

S3 stores whatever you put into it. That is by design, because an object store is meant to keep data, not judge it. For a public upload, however, it means every file starts out as unknown cargo. Checking the file type helps, but it does not replace a look at the content. After all, a PDF with embedded malicious code is still a PDF.

For a long time, the answer was ClamAV inside a Lambda function. It works, but it is work: keeping signatures up to date, maintaining the layer or container image, watching memory limits and building workarounds for large files. Since 2024, GuardDuty Malware Protection for S3 has offered a managed service for this. You enable it per bucket, optionally only for certain prefixes, and AWS takes care of scan engines, signatures and scaling. According to AWS, several engines are used, both its own and third-party ones.

One detail I find particularly handy: you do not have to turn on GuardDuty for the whole account. Malware protection for S3 also runs as a standalone feature. Without an enabled GuardDuty detector you get no findings in the GuardDuty dashboard, but the scan result still arrives as a tag on the object and as an event on the default event bus. In other words, for our upload that is all we need.

How GuardDuty Malware Protection for S3 works

The flow at a glance

You create a so-called Malware Protection plan. First of all, the plan connects a bucket with an IAM role that GuardDuty is allowed to assume. Through this role, GuardDuty sets up a managed EventBridge rule that reports new objects, reads the file, scans it in an isolated environment and then writes the result to the object as a tag. In addition, GuardDuty publishes an event with the result. Finally, our own Lambda function reacts to that event.

🎬 Flow: S3 malware scanning with GuardDuty and Lambda – this animated diagram only runs on my site: watch it on sascha-ms.de

Which scan results are there?

The tag is always called GuardDutyMalwareScanStatus. Its value is one of five possible results. For further processing, it matters that you do not just distinguish between β€œclean” and β€œinfected”. Three of the results simply mean: we do not know.

Value Meaning What our example does
NO_THREATS_FOUND Scan finished, nothing found Copy to the clean bucket
THREATS_FOUND Scan finished, potential threat found Delete the file, log the finding
UNSUPPORTED Scan skipped, for example because of password protection, extreme compression or quota limits Manual review
ACCESS_DENIED GuardDuty could not read the object Hold for review
FAILED Internal error during the scan Flag for a human check

Limits and costs

The most important quotas cannot be adjusted. GuardDuty scans objects up to 100 GB, extracts up to 100,000 files and goes up to 100 levels deep into nested archives. Per account and region you can protect up to 25 buckets and choose up to five prefixes per bucket. Therefore, for job applications that is more than enough. If you want to check huge data exports, on the other hand, you should know these limits in advance.

Beyond that, billing is based on the data scanned and the number of objects evaluated. The pricing example on the GuardDuty pricing page uses 0.09 US dollars per GB and 0.215 US dollars per 1,000 objects. On top of that, every account gets a monthly free tier of 1,000 requests and 1 GB per region. Let us be generous with our bakery: 20,000 applications per month at 2 MB each add up to 40 GB. That makes 3.60 US dollars for the data plus 4.30 US dollars for the objects, so just under 8 US dollars. Small amounts for S3 requests, object tags, EventBridge and Lambda come on top. However, prices differ by region, so take a look at the pricing page before you start.

The architecture for the application portal

Our fictional bakery Crumb receives applications as PDF, PNG or JPEG. The browser first requests an upload permission and then uploads the file directly to S3. After that, the frontend polls the status until the file is accepted or rejected. This is the classic asynchronous pattern: the API answers immediately, and the actual work happens in the background.

I deliberately use two buckets. The intake bucket accepts everything that is uploaded and lets nothing out as long as it has not been checked. The clean bucket only contains files without findings. In addition, all downstream systems, such as HR software, preview images or text recognition, only get read access to the clean bucket. That way, a misconfigured function cannot even get hold of an unscanned file.

A DynamoDB table keeps track of the status of each upload: SCANNING, ACCEPTED, REJECTED or NEEDS_REVIEW. Apart from that, three small Lambda functions do the rest. One issues the upload permission, one answers the status request and one processes the scan results from EventBridge.

Step by step: S3 malware scanning with AWS CDK

Prerequisites

  • An AWS account with permissions for CloudFormation, IAM, S3, Lambda, DynamoDB, EventBridge, SQS and GuardDuty
  • Node.js 22 or later and the AWS CLI with valid credentials
  • A region where GuardDuty Malware Protection for S3 is available, eu-central-1 in this example
  • jq for the test run on the command line

Step 1: Create the project

We start with an empty folder and install the dependencies. Later we bundle the AWS SDK into the functions so that the versions match your code.

mkdir application-upload && cd application-upload
npm init -y
npm pkg set type=module
npm install @aws-sdk/client-s3 @aws-sdk/s3-presigned-post @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
npm install -D aws-cdk aws-cdk-lib constructs esbuild tsx typescript vitest @types/aws-lambda @types/node
mkdir bin lib src test

The tsconfig.json gets a lean configuration for ES modules. The cdk.json tells the CDK CLI how to start the app.

{
  "compilerOptions": {
    "target": "ES2023",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "types": ["node"],
    "noEmit": true
  },
  "include": ["bin", "lib", "src", "test"]
}
{
  "app": "npx tsx bin/app.ts"
}

Step 2: The decision as a pure function

Before we touch anything in AWS, we write the logic that turns a scan result into a decision. It knows neither S3 nor DynamoDB and can therefore be tested in milliseconds. The default case at the end is what matters: anything that is not clearly clean or infected goes to manual review. A file that GuardDuty could not scan is not automatically harmless.

// src/scan-decision.ts
// Pure logic without AWS calls: what happens to a file after the scan?

export type ScanResultStatus =
  | 'NO_THREATS_FOUND'
  | 'THREATS_FOUND'
  | 'UNSUPPORTED'
  | 'ACCESS_DENIED'
  | 'FAILED';

export interface ScanResultDetail {
  scanStatus: 'COMPLETED' | 'SKIPPED' | 'FAILED';
  resourceType: 'S3_OBJECT';
  s3ObjectDetails: {
    bucketName: string;
    objectKey: string;
    eTag?: string;
    versionId?: string;
  };
  scanResultDetails: {
    scanResultStatus: ScanResultStatus;
    threats: { name: string }[] | null;
  };
}

export type Decision =
  | { action: 'accept'; uploadId: string; sourceKey: string; targetKey: string; eTag?: string }
  | { action: 'reject'; uploadId: string; sourceKey: string; threats: string[] }
  | { action: 'review'; uploadId: string; sourceKey: string; reason: string }
  | { action: 'ignore'; reason: string };

const INCOMING_PREFIX = 'incoming/';

export function decide(detail: ScanResultDetail): Decision {
  const key = detail.s3ObjectDetails.objectKey;
  if (!key.startsWith(INCOMING_PREFIX)) {
    return { action: 'ignore', reason: `key outside ${INCOMING_PREFIX}: ${key}` };
  }
  const uploadId = key.slice(INCOMING_PREFIX.length);
  const status = detail.scanResultDetails.scanResultStatus;

  switch (status) {
    case 'NO_THREATS_FOUND':
      return {
        action: 'accept',
        uploadId,
        sourceKey: key,
        targetKey: `accepted/${uploadId}`,
        eTag: detail.s3ObjectDetails.eTag,
      };
    case 'THREATS_FOUND':
      return {
        action: 'reject',
        uploadId,
        sourceKey: key,
        threats: (detail.scanResultDetails.threats ?? []).map((t) => t.name),
      };
    default:
      // UNSUPPORTED, ACCESS_DENIED, FAILED: never let it through automatically
      return { action: 'review', uploadId, sourceKey: key, reason: status };
  }
}

Step 3: Issue the upload permission

The first function checks the file type, generates a random ID and issues a presigned POST form. The content-length-range condition makes S3 itself reject files that are too large. Also, the key always starts with incoming/, because GuardDuty only scans this prefix. At the same time, we create an item in DynamoDB with the status SCANNING.

// src/create-upload.ts
import { randomUUID } from 'node:crypto';
import { S3Client } from '@aws-sdk/client-s3';
import { createPresignedPost } from '@aws-sdk/s3-presigned-post';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb';
import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda';

const s3 = new S3Client({});
const db = DynamoDBDocumentClient.from(new DynamoDBClient({}));

const BUCKET = process.env.INTAKE_BUCKET!;
const TABLE = process.env.UPLOADS_TABLE!;
const MAX_BYTES = 10 * 1024 * 1024; // 10 MB
const ALLOWED_TYPES = new Set(['application/pdf', 'image/png', 'image/jpeg']);

export const handler = async (event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> => {
  const body = JSON.parse(event.body ?? '{}') as { fileName?: string; contentType?: string };
  if (!body.fileName || !body.contentType || !ALLOWED_TYPES.has(body.contentType)) {
    return { statusCode: 400, body: JSON.stringify({ message: 'Only PDF, PNG or JPEG allowed' }) };
  }

  const uploadId = randomUUID();
  const key = `incoming/${uploadId}`;

  const post = await createPresignedPost(s3, {
    Bucket: BUCKET,
    Key: key,
    Conditions: [
      ['content-length-range', 1, MAX_BYTES],
      ['eq', '$Content-Type', body.contentType],
    ],
    Fields: { 'Content-Type': body.contentType },
    Expires: 300, // seconds
  });

  await db.send(
    new PutCommand({
      TableName: TABLE,
      Item: {
        uploadId,
        status: 'SCANNING',
        fileName: body.fileName.slice(0, 200),
        createdAt: new Date().toISOString(),
        expiresAt: Math.floor(Date.now() / 1000) + 30 * 24 * 3600, // TTL: 30 days
      },
    }),
  );

  return { statusCode: 201, body: JSON.stringify({ uploadId, url: post.url, fields: post.fields }) };
};

In contrast, the status endpoint is deliberately short. It only returns what the frontend should display and does not reveal internal bucket names or keys.

// src/get-upload.ts
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb';
import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda';

const db = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE = process.env.UPLOADS_TABLE!;

export const handler = async (event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> => {
  const uploadId = event.pathParameters?.uploadId ?? '';
  const { Item } = await db.send(new GetCommand({ TableName: TABLE, Key: { uploadId } }));
  if (!Item) return { statusCode: 404, body: JSON.stringify({ message: 'Unknown upload' }) };

  // Only return what the frontend needs, no internal keys
  return { statusCode: 200, body: JSON.stringify({ uploadId, status: Item.status, fileName: Item.fileName }) };
};

Step 4: Process the scan result

Now for the core. EventBridge invokes this function with the event GuardDuty Malware Protection Object Scan Result. It gets the decision from step 2 and carries it out. Three details make the difference between a demo and production.

First, EventBridge delivers events at least once, so possibly twice. That is why the function first checks in DynamoDB whether the upload is still SCANNING and only writes the result back with a condition. You can read more about this in my article on idempotency in AWS Lambda. Second, the function uses CopySourceIfMatch to copy only the exact version whose ETag is in the scan result. If someone uploads a second file under the same key within the five-minute validity, the copy fails instead of waving through an unscanned file. Third, the function rethrows unexpected errors so that EventBridge retries and the event eventually ends up in the dead-letter queue.

// src/on-scan-result.ts
import { CopyObjectCommand, DeleteObjectCommand, S3Client, S3ServiceException } from '@aws-sdk/client-s3';
import { ConditionalCheckFailedException, DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
import type { EventBridgeEvent } from 'aws-lambda';
import { decide, type ScanResultDetail } from './scan-decision.js';

const s3 = new S3Client({});
const db = DynamoDBDocumentClient.from(new DynamoDBClient({}));

const INTAKE = process.env.INTAKE_BUCKET!;
const CLEAN = process.env.CLEAN_BUCKET!;
const TABLE = process.env.UPLOADS_TABLE!;

type ScanEvent = EventBridgeEvent<'GuardDuty Malware Protection Object Scan Result', ScanResultDetail>;

export const handler = async (event: ScanEvent): Promise<void> => {
  const decision = decide(event.detail);
  if (decision.action === 'ignore') {
    console.info(JSON.stringify({ msg: 'ignored', reason: decision.reason }));
    return;
  }

  // Idempotency: EventBridge delivers at least once
  const { Item } = await db.send(new GetCommand({ TableName: TABLE, Key: { uploadId: decision.uploadId } }));
  if (Item?.status !== 'SCANNING') {
    console.info(JSON.stringify({ msg: 'already processed', uploadId: decision.uploadId, status: Item?.status }));
    return;
  }

  if (decision.action === 'accept') {
    try {
      await s3.send(
        new CopyObjectCommand({
          Bucket: CLEAN,
          Key: decision.targetKey,
          CopySource: `${INTAKE}/${decision.sourceKey}`,
          // Only copy exactly the version that was scanned
          CopySourceIfMatch: decision.eTag,
        }),
      );
    } catch (err) {
      if (err instanceof S3ServiceException && err.name === 'PreconditionFailed') {
        await setStatus(decision.uploadId, 'NEEDS_REVIEW', { reason: 'object changed after scan' });
        return;
      }
      throw err; // EventBridge retries, then the DLQ takes over
    }
    await s3.send(new DeleteObjectCommand({ Bucket: INTAKE, Key: decision.sourceKey }));
    await setStatus(decision.uploadId, 'ACCEPTED', { cleanKey: decision.targetKey });
    return;
  }

  if (decision.action === 'reject') {
    await s3.send(new DeleteObjectCommand({ Bucket: INTAKE, Key: decision.sourceKey }));
    await setStatus(decision.uploadId, 'REJECTED', { threats: decision.threats });
    console.warn(JSON.stringify({ msg: 'malware found', uploadId: decision.uploadId, threats: decision.threats }));
    return;
  }

  // review: the file stays locked in the intake bucket until someone looks at it
  await setStatus(decision.uploadId, 'NEEDS_REVIEW', { reason: decision.reason });
  console.warn(JSON.stringify({ msg: 'needs review', uploadId: decision.uploadId, reason: decision.reason }));
};

async function setStatus(uploadId: string, status: string, extra: Record<string, unknown>): Promise<void> {
  try {
    await db.send(
      new UpdateCommand({
        TableName: TABLE,
        Key: { uploadId },
        UpdateExpression: 'SET #s = :s, scannedAt = :t, details = :d',
        ConditionExpression: '#s = :scanning',
        ExpressionAttributeNames: { '#s': 'status' },
        ExpressionAttributeValues: { ':s': status, ':t': new Date().toISOString(), ':d': extra, ':scanning': 'SCANNING' },
      }),
    );
  } catch (err) {
    if (err instanceof ConditionalCheckFailedException) return; // a parallel run was faster
    throw err;
  }
}

Step 5: The CDK stack

The stack is the longest part, but this is where the actual security lives. I will walk through it section by section after the code.

// lib/upload-scan-stack.ts
import { fileURLToPath } from 'node:url';
import { CfnOutput, Duration, RemovalPolicy, Stack, type StackProps } from 'aws-cdk-lib';
import type { Construct } from 'constructs';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as nodejs from 'aws-cdk-lib/aws-lambda-nodejs';
import * as events from 'aws-cdk-lib/aws-events';
import * as targets from 'aws-cdk-lib/aws-events-targets';
import * as sqs from 'aws-cdk-lib/aws-sqs';
import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';
import * as guardduty from 'aws-cdk-lib/aws-guardduty';
import { HttpApi, HttpMethod, CorsHttpMethod } from 'aws-cdk-lib/aws-apigatewayv2';
import { HttpLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations';

const SCAN_TAG = 'GuardDutyMalwareScanStatus';
const src = (file: string) => fileURLToPath(new URL(`../src/${file}`, import.meta.url));

export class UploadScanStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    // 1. Intake bucket: uploads land here and stay locked until scanned
    const intake = new s3.Bucket(this, 'IntakeBucket', {
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      encryption: s3.BucketEncryption.S3_MANAGED,
      enforceSSL: true,
      cors: [{ allowedMethods: [s3.HttpMethods.POST], allowedOrigins: ['*'], allowedHeaders: ['*'] }],
      lifecycleRules: [{ prefix: 'incoming/', expiration: Duration.days(14) }],
      removalPolicy: RemovalPolicy.RETAIN,
    });

    // 2. Clean bucket: only files without findings end up here
    const clean = new s3.Bucket(this, 'CleanBucket', {
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      encryption: s3.BucketEncryption.S3_MANAGED,
      enforceSSL: true,
      removalPolicy: RemovalPolicy.RETAIN,
    });

    const table = new dynamodb.TableV2(this, 'UploadsTable', {
      partitionKey: { name: 'uploadId', type: dynamodb.AttributeType.STRING },
      timeToLiveAttribute: 'expiresAt',
      removalPolicy: RemovalPolicy.DESTROY,
    });

    // 3. IAM role that GuardDuty assumes for scanning and tagging
    const scanRole = new iam.Role(this, 'MalwareScanRole', {
      assumedBy: new iam.ServicePrincipal('malware-protection-plan.guardduty.amazonaws.com'),
    });
    const managedRuleArn = this.formatArn({
      service: 'events',
      resource: 'rule',
      resourceName: 'DO-NOT-DELETE-AmazonGuardDutyMalwareProtectionS3*',
    });
    scanRole.addToPolicy(new iam.PolicyStatement({
      actions: ['events:PutRule', 'events:DeleteRule', 'events:PutTargets', 'events:RemoveTargets'],
      resources: [managedRuleArn],
      conditions: { StringLike: { 'events:ManagedBy': 'malware-protection-plan.guardduty.amazonaws.com' } },
    }));
    scanRole.addToPolicy(new iam.PolicyStatement({
      actions: ['events:DescribeRule', 'events:ListTargetsByRule'],
      resources: [managedRuleArn],
    }));
    scanRole.addToPolicy(new iam.PolicyStatement({
      actions: ['s3:GetObject', 's3:GetObjectVersion', 's3:PutObjectTagging', 's3:GetObjectTagging',
        's3:PutObjectVersionTagging', 's3:GetObjectVersionTagging'],
      resources: [intake.arnForObjects('*')],
    }));
    scanRole.addToPolicy(new iam.PolicyStatement({
      actions: ['s3:PutBucketNotification', 's3:GetBucketNotification', 's3:ListBucket'],
      resources: [intake.bucketArn],
    }));
    scanRole.addToPolicy(new iam.PolicyStatement({
      actions: ['s3:PutObject'],
      resources: [intake.arnForObjects('malware-protection-resource-validation-object')],
    }));

    // 4. Malware Protection plan: scan incoming/ and tag the objects
    const plan = new guardduty.CfnMalwareProtectionPlan(this, 'MalwareProtectionPlan', {
      role: scanRole.roleArn,
      protectedResource: { s3Bucket: { bucketName: intake.bucketName, objectPrefixes: ['incoming/'] } },
      actions: { tagging: { status: 'ENABLED' } },
    });
    plan.node.addDependency(scanRole); // the policy must exist before GuardDuty validates the plan

    // 5. Bucket policy: nobody reads unscanned or infected files, nobody else sets the tag
    intake.addToResourcePolicy(new iam.PolicyStatement({
      sid: 'DenyReadUntilClean',
      effect: iam.Effect.DENY,
      principals: [new iam.AnyPrincipal()],
      actions: ['s3:GetObject', 's3:GetObjectVersion'],
      resources: [intake.arnForObjects('incoming/*')],
      conditions: {
        StringNotEquals: { [`s3:ExistingObjectTag/${SCAN_TAG}`]: 'NO_THREATS_FOUND' },
        ArnNotEquals: { 'aws:PrincipalArn': scanRole.roleArn },
      },
    }));
    intake.addToResourcePolicy(new iam.PolicyStatement({
      sid: 'OnlyGuardDutyTags',
      effect: iam.Effect.DENY,
      principals: [new iam.AnyPrincipal()],
      actions: ['s3:PutObjectTagging', 's3:PutObjectVersionTagging', 's3:DeleteObjectTagging'],
      resources: [intake.arnForObjects('*')],
      conditions: { ArnNotEquals: { 'aws:PrincipalArn': scanRole.roleArn } },
    }));

    // 6. Lambda functions
    const common: Partial<nodejs.NodejsFunctionProps> = {
      runtime: lambda.Runtime.NODEJS_24_X,
      architecture: lambda.Architecture.ARM_64,
      memorySize: 256,
      timeout: Duration.seconds(30),
      environment: {
        INTAKE_BUCKET: intake.bucketName,
        CLEAN_BUCKET: clean.bucketName,
        UPLOADS_TABLE: table.tableName,
      },
      bundling: { format: nodejs.OutputFormat.ESM, minify: true, externalModules: [] }, // bundle the SDK too
    };

    const createUpload = new nodejs.NodejsFunction(this, 'CreateUploadFn', { ...common, entry: src('create-upload.ts') });
    intake.grantPut(createUpload, 'incoming/*');
    table.grantWriteData(createUpload);

    const getUpload = new nodejs.NodejsFunction(this, 'GetUploadFn', { ...common, entry: src('get-upload.ts') });
    table.grantReadData(getUpload);

    const onScanResult = new nodejs.NodejsFunction(this, 'OnScanResultFn', { ...common, entry: src('on-scan-result.ts') });
    intake.grantRead(onScanResult, 'incoming/*');
    intake.grantDelete(onScanResult, 'incoming/*');
    clean.grantPut(onScanResult, 'accepted/*');
    table.grantReadWriteData(onScanResult);

    // 7. EventBridge rule for the scan results of this bucket, with retries and DLQ
    const dlq = new sqs.Queue(this, 'ScanResultDlq', { retentionPeriod: Duration.days(14), enforceSSL: true });
    new events.Rule(this, 'ScanResultRule', {
      eventPattern: {
        source: ['aws.guardduty'],
        detailType: ['GuardDuty Malware Protection Object Scan Result'],
        detail: { s3ObjectDetails: { bucketName: [intake.bucketName] } },
      },
      targets: [new targets.LambdaFunction(onScanResult, {
        retryAttempts: 4,
        maxEventAge: Duration.hours(2),
        deadLetterQueue: dlq,
      })],
    });

    new cloudwatch.Alarm(this, 'ScanResultDlqAlarm', {
      metric: dlq.metricApproximateNumberOfMessagesVisible(),
      threshold: 1,
      evaluationPeriods: 1,
      alarmDescription: 'Scan results could not be processed',
    });

    // 8. HTTP API for the frontend
    const api = new HttpApi(this, 'UploadApi', {
      corsPreflight: { allowOrigins: ['*'], allowMethods: [CorsHttpMethod.GET, CorsHttpMethod.POST], allowHeaders: ['content-type'] },
    });
    api.addRoutes({ path: '/uploads', methods: [HttpMethod.POST], integration: new HttpLambdaIntegration('CreateUpload', createUpload) });
    api.addRoutes({ path: '/uploads/{uploadId}', methods: [HttpMethod.GET], integration: new HttpLambdaIntegration('GetUpload', getUpload) });

    new CfnOutput(this, 'ApiUrl', { value: api.apiEndpoint });
    new CfnOutput(this, 'IntakeBucketName', { value: intake.bucketName });
    new CfnOutput(this, 'CleanBucketName', { value: clean.bucketName });
    new CfnOutput(this, 'MalwareProtectionPlanId', { value: plan.attrMalwareProtectionPlanId });
  }
}
// bin/app.ts
import { App } from 'aws-cdk-lib';
import { UploadScanStack } from '../lib/upload-scan-stack.js';

const app = new App();
new UploadScanStack(app, 'BakeryApplicationUploads', {
  env: { region: process.env.CDK_DEFAULT_REGION ?? 'eu-central-1' },
});

What the stack does in detail

The IAM role follows the template from the GuardDuty documentation. GuardDuty needs permissions to create the managed EventBridge rule with the prefix DO-NOT-DELETE-AmazonGuardDutyMalwareProtectionS3, to turn on S3 notifications to EventBridge, to read and tag objects and to put a test object called malware-protection-resource-validation-object. Specifically, GuardDuty uses this test object to check whether the plan works. If your bucket uses a customer managed KMS key, you also need kms:Decrypt and kms:GenerateDataKey. In the example I use S3 managed encryption, so this is not needed.

The plan itself is an L1 construct, because there is no higher-level abstraction for AWS::GuardDuty::MalwareProtectionPlan yet. The dependency on the role makes sure that CloudFormation creates the policy before GuardDuty validates the plan. Without it, the plan often ends up in a warning state because the permissions are still missing during the first test.

The bucket policy is my safety net. The first rule denies everyone read access to objects under incoming/ as long as the tag is not NO_THREATS_FOUND. Notably, this also applies to objects without any tag, because negated conditions like StringNotEquals match when the key is missing. The only exception is the GuardDuty role, which has to read in order to scan. The second rule prevents anyone except GuardDuty from setting or removing the tag. Otherwise a compromised function could simply declare a file clean by itself.

The EventBridge rule filters on our bucket, retries failed invocations up to four times and then puts the event into an SQS queue. A CloudWatch alarm fires as soon as something lands there. I show how to wrap such a queue neatly and replay events in the article dead-letter queue with CDK. If you are unsure whether EventBridge or a queue is the right trigger for your case, the AWS service finder helps you decide.

Step 6: Tests with Vitest

Because the decision is a pure function, the tests need no mocks. Add the scripts "test": "vitest run" and "build": "tsc --noEmit" to your package.json.

// test/scan-decision.test.ts
import { describe, expect, it } from 'vitest';
import { decide, type ScanResultDetail } from '../src/scan-decision.js';

const base = (status: ScanResultDetail['scanResultDetails']['scanResultStatus'], key = 'incoming/abc-123'): ScanResultDetail => ({
  scanStatus: status === 'NO_THREATS_FOUND' || status === 'THREATS_FOUND' ? 'COMPLETED' : 'SKIPPED',
  resourceType: 'S3_OBJECT',
  s3ObjectDetails: { bucketName: 'intake-bucket', objectKey: key, eTag: 'etag-1' },
  scanResultDetails: {
    scanResultStatus: status,
    threats: status === 'THREATS_FOUND' ? [{ name: 'EICAR-Test-File (not a virus)' }] : null,
  },
});

describe('decide', () => {
  it('accepts clean files and moves them to accepted/', () => {
    expect(decide(base('NO_THREATS_FOUND'))).toEqual({
      action: 'accept',
      uploadId: 'abc-123',
      sourceKey: 'incoming/abc-123',
      targetKey: 'accepted/abc-123',
      eTag: 'etag-1',
    });
  });

  it('rejects infected files and keeps the threat names', () => {
    const d = decide(base('THREATS_FOUND'));
    expect(d).toMatchObject({ action: 'reject', threats: ['EICAR-Test-File (not a virus)'] });
  });

  it.each(['UNSUPPORTED', 'ACCESS_DENIED', 'FAILED'] as const)('sends %s to manual review', (status) => {
    expect(decide(base(status))).toMatchObject({ action: 'review', reason: status });
  });

  it('ignores objects outside incoming/', () => {
    expect(decide(base('NO_THREATS_FOUND', 'other/file.pdf')).action).toBe('ignore');
  });
});

Running npm test should report six green tests. This secures the most important rule: only NO_THREATS_FOUND leads to approval.

Step 7: Deploy and try it out

npm run build
npm test
npx cdk bootstrap   # only needed once per account and region
npx cdk deploy

After the deployment, CDK shows you the API URL, both bucket names and the plan ID. First check whether the plan is active. This can take a moment after creation.

aws guardduty list-malware-protection-plans
aws guardduty get-malware-protection-plan \
  --malware-protection-plan-id <PLAN_ID> \
  --query Status

You should see "ACTIVE". Then upload any PDF file. The script fetches the upload permission, turns it into form fields for curl and sends the file directly to S3.

API=https://abc123.execute-api.eu-central-1.amazonaws.com   # value from the CDK output

RESP=$(curl -s -X POST "$API/uploads" \
  -H 'content-type: application/json' \
  -d '{"fileName":"resume.pdf","contentType":"application/pdf"}')
UPLOAD_ID=$(echo "$RESP" | jq -r .uploadId)
URL=$(echo "$RESP" | jq -r .url)

# Form fields of the upload permission as -F arguments for curl
FIELDS=()
while IFS= read -r line; do FIELDS+=("$line"); done < <(
  echo "$RESP" | jq -r '.fields | to_entries[] | "-F", "\(.key)=\(.value)"')

# The file must be the last field in the form
curl -s -o /dev/null -w '%{http_code}\n' "${FIELDS[@]}" -F "[email protected]" "$URL"

S3 responds with 204. Now query the status. Right after the upload it says SCANNING, and shortly after ACCEPTED. The file is then stored under accepted/ in the clean bucket and carries the copied tag as proof.

curl -s "$API/uploads/$UPLOAD_ID"
# {"uploadId":"…","status":"SCANNING","fileName":"resume.pdf"}

sleep 30
curl -s "$API/uploads/$UPLOAD_ID"
# {"uploadId":"…","status":"ACCEPTED","fileName":"resume.pdf"}

aws s3api get-object-tagging --bucket <CLEAN_BUCKET> --key "accepted/$UPLOAD_ID"
# "TagSet": [{ "Key": "GuardDutyMalwareScanStatus", "Value": "NO_THREATS_FOUND" }]

Step 8: Simulate a detection

For the counter test there is the EICAR test file. It is harmless, but practically every virus scanner reports it as a detection, including GuardDuty. You can find its content on the EICAR project website. I am deliberately not printing it here, because virus scanners and security proxies might otherwise flag this page or your repository as suspicious. Create the file locally as eicar.pdf, upload it with the script from step 7 and query the status. After the scan it says REJECTED, the log of the evaluation function contains the name of the finding and the file has disappeared from the intake bucket.

Common mistakes with S3 malware scanning

The plan stays in warning or error state

Almost always, the role is missing a permission. The status reason usually tells you which one. INSUFFICIENT_TEST_OBJECT_PERMISSIONS, for example, means that GuardDuty was not allowed to put the test object. Read the reason with get-malware-protection-plan and compare the policy with the template in the documentation. By the way, GuardDuty publishes separate events for status changes, which you can put an alarm on.

Objects do not get a tag

First check whether tagging is enabled in the plan at all. Next, look at the prefix: if the file is not under incoming/, it is not scanned. A third reason is the S3 tag limit. If an object already has ten tags, GuardDuty cannot add an eleventh and reports a post-scan tagging failure.

The bucket policy locks you out

The read lock applies to administrators too. If you want to open a file in the intake bucket for debugging, you get AccessDenied. That is intended; nevertheless, it surprises you the first time. For real investigations, create a dedicated role for your security team and add it to the policy exception on purpose. This also helps when you tear down the stack, because I keep the buckets with RETAIN so that no applications get lost by accident.

Encrypted buckets with a customer managed KMS key

With SSE-KMS, the GuardDuty role also needs permissions on the key, restricted via kms:ViaService to S3 in your region. If you forget this, every scan ends with ACCESS_DENIED. In our example, the file then goes to manual review, which is at least safe, but quickly creates a lot of open cases.

My take

For most teams, the managed scan is the better choice today compared to running ClamAV yourself. You save yourself signature updates, memory problems and a component that otherwise only gets noticed when it is broken. Moreover, with normal upload volumes the costs are manageable. And the combination of tag, bucket policy and EventBridge fits the Serverless mindset very well: no server that fetches files, but events you react to.

Still, a virus scan is not a free pass. It recognizes known patterns, but it cannot guarantee to catch every new variant. I see it as one layer of several: limit file type and size at upload, only let checked files into the clean bucket, give downstream systems minimal permissions and process documents in an isolated environment where possible. Where I disagree is the idea of dropping every other precaution after a green scan. And if you serve several clouds or work in a region without the service, your own solution is still a good option.

FAQ

Does GuardDuty have to be enabled for S3 malware scanning?

No. Malware Protection for S3 also works without an enabled GuardDuty detector. You then get no findings in GuardDuty, but you do get the result as an object tag, as an EventBridge event and as a CloudWatch metric.

Can GuardDuty scan files that are already in the bucket?

Yes, with on-demand scans you can check existing objects or have them scanned again. These scans are not included in the free tier, though. The automatic scan only applies to newly uploaded objects.

How long does a scan take?

AWS does not state a fixed duration, it depends on the size and type of the file, among other things. So build the upload asynchronously: store the status, show β€œbeing checked” in the frontend and poll regularly.

Does this also work with the Serverless Framework?

Yes. You define the plan, the role and the bucket policy as CloudFormation in the resources section of your serverless.yml. The evaluation function gets an eventBridge event with the same pattern as in the CDK stack.

Your next step

Look at where external files enter your applications. There are often more places than you think: profile pictures, imports, attachments in the support form. Pick the one with the highest risk, deploy the example to a test account and run the EICAR file through it once. As a result, within an hour you will know how the flow feels. I am curious: do you already scan uploads today, and if so, with what?

All code from this article, ready to run: smirowstanitzok/sascha-ms-examples

Sources

This post was created with AI assistance.

πŸ‘‰ Next step: Need a schedule in EventBridge? My EventBridge cron helper builds and explains the expression for you.

πŸ“¬ Stay updated: New articles on serverless, AI and leading tech teams via RSS, browser push or the weekly newsletter (in German).

Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.