Scan uploaded objects for malware and quarantine them on AWS
Land untrusted uploads in a private Amazon S3 bucket, scan them with GuardDuty Malware Protection for S3, route scan results through EventBridge and SQS, and promote only clean objects to a bucket consumers can read.
Official AWS sources reviewed 2026-08-29.
Architecture flow
Land the upload: A presigned PUT or Transfer Family session writes the object into a private landing bucket encrypted with a KMS key. No consumer role has read access to that bucket.
Scan: GuardDuty Malware Protection for S3 reacts to object-created events, downloads the object over PrivateLink into an isolated scanning environment, and scans it.
Publish the result: GuardDuty publishes the scan result to the default EventBridge event bus and, when tagging was enabled before upload, writes a GuardDutyMalwareScanStatus tag on the object.
Buffer the decision: An EventBridge rule delivers matching results to an SQS queue with a dead-letter queue, so a scan burst cannot overrun the promotion worker.
Promote or quarantine: A worker copies NO_THREATS_FOUND objects to the clean bucket, moves THREATS_FOUND objects to a quarantine bucket, and records the decision for audit.
Monitor: Scan metrics, queue age, dead-letter depth, and worker errors expose failed scans and stuck objects.
Text alternative: An untrusted client uploads an object to a private Amazon S3 landing bucket encrypted with AWS KMS. Amazon GuardDuty Malware Protection for S3 scans the new object and publishes the result to the default Amazon EventBridge bus, optionally tagging the object. An EventBridge rule delivers the result to an Amazon SQS queue backed by a dead-letter queue, and an AWS Lambda worker copies clean objects to a clean bucket or moves threats to a quarantine bucket. Amazon CloudWatch collects scan metrics, queue depth, and worker errors.
How the services connect
The landing bucket is the trust boundary. Give it a bucket policy that grants access to the scanner role and the promotion worker and nobody else, and let the clean bucket be the only object surface application roles can read. Quarantined objects move to a third bucket whose access is limited to security investigation.
Object tagging has to be enabled before an object is uploaded. GuardDuty cannot add a tag to an object whose scan already started, so tag-based access control protects objects uploaded after configuration and leaves everything earlier undecided. Deny reads on untagged objects explicitly if tags are load-bearing.
Scans start from object-created events, which include CompleteMultipartUpload, so a multipart upload is scanned once when it completes rather than per part. The scanner needs kms:Decrypt on the bucket key; a key policy that omits it produces an ACCESS_DENIED scan status rather than a clean result. Enabling the feature does not scan objects that already exist, so backfill through an on-demand scan.
Only NO_THREATS_FOUND means clean. UNSUPPORTED, ACCESS_DENIED, and FAILED are all states where nothing scanned the bytes, and a promotion worker that treats 'not THREATS_FOUND' as safe will publish unscanned files. Route those statuses to a review path and alarm on them.
GuardDuty delivers scan results at least once, so the same object can produce more than one result event. Key the promotion decision on bucket, key, and version ID, make the copy safe to repeat, and rehearse the whole path with the EICAR test file before trusting it.
Tradeoffs and caveats
Managed scanning removes definition updates, engine upgrades, and scanner capacity from the team, in exchange for living inside documented quotas: 100 GB per object, 100,000 extracted files, 100 nesting levels, 25 protected buckets per account per Region, and same-Region buckets only.
Separate landing, clean, and quarantine buckets add a copy and a delay between upload and availability. Tag-based access control on a single bucket avoids the copy but leaves the pre-tag window and the non-clean statuses to policy detail.
GuardDuty per-GB and per-object scanning, S3 tagging requests, EventBridge, SQS, Lambda, and a second copy of every promoted object are separate charges that all scale with upload volume.
Decision points
Managed scanning versus self-managed scanning
Option
Choose when
Who operates the scanner
Main limits
GuardDuty Malware Protection for S3
Scanning can be a bucket-level setting and the documented quotas fit your objects.
AWS operates engines, definitions, and scan capacity.
100 GB per object, 100,000 extracted files, 100 nesting levels, 25 protected buckets per account per Region, no cross-Region buckets.
Self-managed ClamAV in Lambda
Objects are small, you need custom result handling, or a requirement the managed feature does not meet.
You own definition refresh, package or image size, memory, and timeout.
Function memory and ephemeral storage cap file size; definition loading affects cold starts; a broken refresh job leaves stale definitions with no signal.
Self-managed scanning on ECS or AWS Batch
Files are large or scanning is long-running, and you need a specific engine or version.
You own the cluster, image, dispatch queue, and definition refresh.
More infrastructure and a longer path to the first scan; keeping the object unreadable until the verdict is still your job.
Bucket separation versus tag-based access control
Control
How access is denied
Fails safe when
Watch out for
Separate landing, clean, and quarantine buckets
Consumer roles hold no permission on the landing bucket at all.
A scan never completes, because the object never reaches the clean bucket.
Copy cost and latency; the promotion worker becomes a critical path with its own failure modes.
One bucket with tag-based access control
A bucket policy denies reads unless the object carries GuardDutyMalwareScanStatus equal to NO_THREATS_FOUND.
Tagging was enabled before upload and the policy also denies untagged objects.
Objects uploaded before tagging was enabled, plus UNSUPPORTED, ACCESS_DENIED, and FAILED statuses that must be denied explicitly.
How companies use this
Outcomes below are attributed to their sources, not independently measured. Sources reviewed 2026-08-29.
Truework's income and employment verification flow accepts documents from outside parties, and those PDFs and images reach internal reviewers and customers.
Before
Uploaded files were handled without an automated malware verdict attached to the object itself.
After
A Lambda function scans an object with ClamAV, loads virus definitions from an S3 bucket named by the CLAMAV_BUCKET_NAME variable, and writes the verdict back as S3 object tags: virusScanStatus with values CLEAN, INFECTED, SKIPPED, or ERROR, plus a virusScanTimestamp tag.
Service bundle
Amazon S3 for both objects and virus definitions, AWS Lambda running ClamAV binaries packaged with the function, and S3 object tags as the result contract.
Disclosed scale
The published function asks for at least 1024 MB of memory to run clamscan and freshclam, and defaults to a 300 MB maximum file size through the MAX_FILE_SIZE variable.
Failure modes
Files over the size limit are tagged SKIPPED instead of scanned, and errors are tagged ERROR, so any consumer that reads 'not INFECTED' as safe will open unscanned files. Definitions stay current only while a separate refresh keeps running.
Reported outcome
Truework published the function under Apache-2.0. The repository was archived read-only on 2024-12-19, and the accompanying engineering post no longer resolves at its original truework.com URL, checked 2026-08-29.
What generalizes
Three things carry over to any self-managed scanner: an explicit not-scanned state in the result contract, per-object tags as the verdict, and virus definitions stored outside the deployment package.
What does not generalize
The memory floor and file-size ceiling are consequences of scanning inside a Lambda function, and GuardDuty Malware Protection for S3 did not exist when this design was written. Building the same thing today needs a reason beyond the absence of a managed option.
Delivery semantics and failure handling
S3 event notifications are designed for at-least-once delivery and are not guaranteed to arrive in the order the events occurred.
GuardDuty publishes scan results at least once, so the same object can produce duplicate result events; billing still counts each scanned object once.
Key promotion on bucket, key, and version ID so a repeated result event is a no-op rather than a second copy.
Treat UNSUPPORTED, ACCESS_DENIED, and FAILED as not clean, and send them to a review path instead of the clean bucket.
Set the queue visibility timeout above the copy time for the largest object you accept, and give the scan-result queue a dead-letter queue you redrive after fixing the cause.
Preserve quarantined objects and their scan metadata; a security investigation needs the evidence, not just the counter.
Operational signals to watch
GuardDuty scan metrics in CloudWatch: objects scanned and bytes scanned per protected bucket.
Result counts by status, with alarms on THREATS_FOUND, FAILED, and ACCESS_DENIED.
SQS ApproximateAgeOfOldestMessage and ApproximateNumberOfMessagesVisible on the scan-result queue.
Dead-letter queue depth, alarmed above zero.
Lambda errors, throttles, and concurrent executions for the promotion worker.
End-to-end latency from object creation to promotion, which is the number users feel, not scan duration alone.
Cost drivers and quota pressure
GuardDuty Malware Protection bills per GB scanned and per object scanned, so many small objects and few large objects move the bill differently.
Optional result tagging adds S3 tagging requests on every scanned object.
Promotion copies every clean object a second time: request charges plus storage in two buckets until a lifecycle rule expires the landing copy.
Quota pressure points: 25 protected buckets per account per Region, 5 prefixes per protected bucket, 100 GB per object, and Lambda concurrency shared with everything else in the account.
AWS Periodic Table recommendations
Keep the landing bucket unreadable by every downstream role and let the clean bucket be the only path to consumers; use tag-based access control as a second control rather than the first.
Expire landing-bucket objects with a lifecycle rule once the promotion window closes, and retain quarantined objects for as long as the security team needs them.
Test with the EICAR test file on every deployment: verify the quarantine path, and verify that a failed scan does not promote.
Backfill existing objects with an on-demand scan when you enable the feature on a bucket that already holds data.