A serverless AWS pipeline that performs Content Disarmament and Reconstruction (CDR) on files uploaded to S3. Files are structurally disarmed — active/executable content is stripped — and routed to a sanitised or quarantine bucket. The pipeline is designed to fail closed: anything it cannot prove safe is quarantined, never labelled sanitised.
Status: production-ready code, Deployed and smoke tested on AWS. Follow, the deployment steps below require live AWS credentials and either the SAM CLI or OpenTofu/Terraform.
Detailed diagrams of the full dual-layer (CDR + malware scan + aggregator) design:
EventBridge fires on S3 ObjectCreated (PutObject / CompleteMultipartUpload) and
invokes the CDR Lambda, which strips active content and routes each file to the sanitised
or quarantine bucket. This repo contains the CDR Lambda and its infrastructure — the
malware-scan Lambda, result aggregator, and presigned-upload backend are out of scope
(see Known gaps).
| Format | Approach | What is removed |
|---|---|---|
| Office (all 17 OOXML/ZIP variants) | ZIP-level surgery — drop/scrub parts, never re-serialise through an Office library | VBA, ActiveX, embedded OLE/package objects, external links, query tables, connections, web add-ins, macro content types, dangerous .rels, field codes (DDE/AUTOOPEN/WEBSERVICE/…), altChunk imports; external hyperlink targets neutralised in place |
| xlsb (with worksheet binaries) | Format conversion via pyxlsb → openpyxl |
Everything — only plain cached cell values survive; no BIFF12 records pass through |
pikepdf surgery + full re-serialise |
JavaScript, /OpenAction, /AA, embedded files, all annotation actions, /FileAttachment specs, multimedia annotations, outline (bookmark) actions, AcroForm field + root actions |
|
| Images (jpg/png/gif/bmp/tiff/webp) | Re-encode through Pillow | All EXIF / ICC / XMP metadata; GIF comment blocks; per-frame metadata on multi-frame TIFFs |
| Legacy OLE (doc/xls/ppt) | Quarantine, no CDR | — (format too opaque to safely reconstruct) |
| Unknown extensions | Fail closed — quarantine | Never reaches the sanitised bucket |
- Decompression-bomb defence — every ZIP entry is read through a chunked byte counter
(
_read_zip_entry_safe) that never trusts the attacker-controlled central-directoryfile_size. A 90 KB entry that inflates to gigabytes is killed at the per-entry limit. - ZIP structural hard-rejects — bad magic, non-standard compression, duplicate entries,
local/central method mismatch, or a missing
[Content_Types].xml→ quarantine, no CDR. - ReDoS-hardened — all neutralisation regexes are bounded; a crafted text node cannot hang the Lambda past its timeout.
- Fault isolation — SNS publish, source delete, and metric emission can never turn a successful CDR into an EventBridge retry of an already-sanitised file. adds a magic-byte gate; security guards are kept at behavioural parity.
The CDR code has been through five review passes (Codex → Gemini → a multi-agent
adversarial audit → two remediation rounds); the Terraform/build/CI was separately
audited and hardened. See docs/progress.md.
| Path | Purpose |
|---|---|
src/lambda_function.py |
General CDR Lambda — all Office/PDF/image formats; exposes the pure cdr_dispatch decision core |
src/app.py |
Local CDR service — FastAPI wrapper around cdr_dispatch; disarm files over HTTP with no AWS account |
src/requirements-local.txt |
Extra deps for the local service (fastapi, uvicorn) — not part of the Lambda layer |
src/requirements-dev.txt |
Test-only deps (pytest, python-docx) — in neither the Lambda layer nor the container image |
src/test_cdr_local.py |
Tests for the local service + cdr_dispatch core (own file, per the per-module rule) |
docs/local-cdr.md |
Local CDR service guide — run, configure, embed, proxy, API contract, security model |
docs/local_cdr_architecture.svg |
"One core, two front-ends" diagram (cloud handler + local app.py over cdr_dispatch) |
Dockerfile |
Container image for the local CDR service (slim, non-root, healthcheck) |
docker-compose.yml |
Runnable sidecar example (hardened: read-only, cap-drop, healthcheck) |
docs/deploy-container.md |
Container / Compose / Kubernetes-sidecar deploy guide + hardening checklist |
src/template.yaml |
AWS SAM infrastructure (buckets, IAM, DLQ, alarms, EventBridge) |
terraform/ |
Terraform port of the SAM template (parallel deploy path) |
scripts/build.sh |
Builds the Lambda zip with Linux wheels (for the Terraform path) |
src/requirements.txt |
Pinned dependencies for the tests, the container image and local dev |
scripts/lambda-requirements.txt |
What the deployed Lambda is actually built from — the same versions, hash-pinned and installed --require-hashes. Regenerate with scripts/regen_lambda_requirements.py; CI fails if it drifts from src/requirements.txt |
docs/00–04 |
Production-readiness manuals (setup, smoke tests, IAM review, runbook) |
docs/deployment-runbook.md |
End-to-end staging deploy guide |
docs/05-alarm-demo-walkthrough.md |
Subscribe to alarm notifications + fire the ZIP-anomaly alarm (live demo) |
docs/benchmark.py |
Standalone load benchmark with tuning recommendations |
docs/fixtures/ |
Threat fixtures + generator covering every CDR path |
docs/comparison-docbleach.md |
CDR coverage compared against DocBleach (per-format, with audit rationale) |
docs/cdr-gap-analysis-stevens.md |
Gap analysis vs Didier Stevens' maldoc toolkit (oledump/pdfid/zipdump/emldump) — what's caught, where, and the JBIG2/JPX hardening |
The Lambda runtime is Python 3.12 (per template.yaml); the local dev venv may be
newer. Dependencies are pinned in two files that must stay in step —
src/requirements.txt (tests, container image, local dev) and
scripts/lambda-requirements.txt (hash-pinned, what the deployed Lambda is built from).
Bumping a dependency means changing both; see Bumping a dependency.
# Create a venv (none is committed to the repo)
python3.12 -m venv .venv && source .venv/bin/activate
# Install dependencies (Lambda + local-service + test-only deps)
pip install -r src/requirements.txt -r src/requirements-local.txt -r src/requirements-dev.txt
# Run the full test suite (454 tests: 404 CDR Lambda + 50 local variant)
cd src && pytest test_cdr.py test_cdr_local.py -v
# Run one class or test
cd src && pytest test_cdr.py::TestOfficeCDR -v
cd src && pytest test_cdr.py::TestOfficeCDR::test_vba_macro_removed -v
# Lint (byte-compile)
cd src && python -m py_compile lambda_function.py app.pyTests construct malicious fixtures entirely in memory; S3/SNS are mocked. No live AWS
credentials are needed to run them. src/requirements-local.txt (FastAPI/uvicorn) is only
needed for the local CDR service and its tests — it is
deliberately excluded from the deployed Lambda package.
Run pytest bare — do not export SANITISED_BUCKET/QUARANTINE_BUCKET around it. The
test defaults are set with os.environ.setdefault, which yields to anything already in the
environment, so exported bucket names override them and fail two tests that assert on the
literal names — an environmental failure that reads exactly like a code regression.
src/requirements.txt is what the tests and the container image install;
scripts/lambda-requirements.txt is what the deployed Lambda is built from. A bump
applied to one file alone ships a different wheel than the one the tests exercised —
pikepdf and Pillow drifted two releases behind exactly this way, and the Pillow gap
moved bundled native code (liblcms2, libpng16) that CI never ran. Dependabot watches
both directories, but each PR bumps a single file, so the two still have to be reconciled.
# 1. edit the version in src/requirements.txt, then regenerate the hash-pinned file
python scripts/regen_lambda_requirements.py
# 2. verify they agree (this is what CI runs)
python scripts/check_lambda_requirements.pyThe regenerator resolves wheels for the Lambda target (manylinux_2_28_x86_64, CPython
3.12) and rewrites the pins with their sha256 hashes. boto3 is exempt — the Lambda
runtime provides it, so it is deliberately absent from the shipped set.
Drop-in file disarming for any app — one POST, no AWS, no state. Run it as a
container sidecar next to your service: every upload goes through CDR before you trust it,
and you get back a clean file (or a fail-closed rejection). Same disarm engine as the AWS
pipeline, on plain HTTP, in any language.
- 🛡️ Same engine as the cloud — the local service and the Lambda share one pure,
I/O-free core (
cdr_dispatch), so a file disarmed locally is disarmed by identical logic. No second implementation to drift or fall behind. - 🔌 Language-agnostic — it's an HTTP endpoint.
POSTbytes, read bytes. Python, Go, Node, Java, a shell script — all integrate the same way. - ☁️ Zero AWS — no account, no credentials, no S3/SNS. Runs on a laptop, in CI, on-prem, or air-gapped.
- 📦 Container-ready — a small, non-root, read-only-capable image with a built-in
healthcheck.
docker runand you have a disarming sidecar. - 🔒 Fail-closed by design — anything it can't disarm (RTF, legacy OLE, unknown extensions, malformed archives) is rejected, never passed through as "clean".
The Lambda's CDR routing — size guard, fail-closed unknown-extension gate, RTF/legacy
rejection, ZIP structural validation, and the per-format disarm of Office/PDF/images — is
factored into a pure, I/O-free function cdr_dispatch(data, ext) in
lambda_function.py. The cloud handler and the local app.py both call it, so the two
cannot drift on a security decision; there is no second CDR implementation.
Full reference:
docs/local-cdr.md— run, configure, embed, deploy-behind-a-proxy, the API contract, the security model, and the threat/hardening matrix.
app.py is a thin FastAPI wrapper: it never touches S3,
SNS, or any AWS service. Any local app can disarm a file by POSTing it.
cd src
pip install -r requirements.txt -r requirements-local.txt
uvicorn app:app --host 127.0.0.1 --port 8000 # or: python app.py# Disarm a macro-enabled doc → get a clean .docx back (note the extension remap)
curl -sS -o clean.docx -D - -F file=@dirty.docm http://127.0.0.1:8000/sanitise
# 200 OK
# x-cdr-status: sanitised
# x-cdr-sanitised-ext: docx
# x-cdr-removals: 1
# x-cdr-report: {"format":"docm","removed":["word/vbaProject.bin"],...}
# Fail-closed: RTF and unknown extensions are rejected, never "sanitised"
curl -sS -F file=@evil.rtf http://127.0.0.1:8000/sanitise
# 422 {"status":"unsupported-format","reason":"format rejected by design: rtf",...}| Endpoint | Behaviour |
|---|---|
POST /sanitise (multipart file) |
200 + clean bytes (X-CDR-* headers carry status/report) on success; 413 JSON when the upload exceeds CDR_MAX_FILE_BYTES; 422 JSON for rejected/unsupported input; 500 JSON for an unparseable file |
GET /healthz |
Liveness + the formats this build will attempt to disarm |
This is a single-process service for trusted local/internal use (a sidecar, a desktop
integration, a batch tool) — it has no built-in auth or rate limiting. Put it behind your
own controls before exposing it beyond localhost. The HTTP layer is nonetheless hardened:
the body is size-bounded before it is fully buffered (early Content-Length reject
plus an authoritative counted read, so a multi-GB upload can't OOM the process); the
Content-Disposition filename is RFC 6266/5987 encoded (no header-injection via a crafted
filename); response headers carry only sanitised, length-capped values; and internal
errors return a generic message (the real exception is logged server-side only).
Tests for the local variant live in src/test_cdr_local.py (its own file, per the
per-module test rule) and prove both that cdr_dispatch does no I/O and that the endpoint
returns the right status for every routing branch:
cd src && pytest test_cdr_local.py -vA small, non-root, healthcheck-equipped image makes the service a drop-in disarming sidecar:
# Pull the published multi-arch image (amd64 + arm64)
docker run --rm -p 8000:8000 ghcr.io/douglasmun/aws-cdr-gateway:latest
# …or build from source
docker build -t cdr-gateway:local .
docker run --rm -p 8000:8000 cdr-gateway:local
curl -sS -o clean.docx -F file=@dirty.docm http://localhost:8000/sanitise
# or wire it next to your app (with hardening) via Compose:
docker compose up --buildYour app calls http://cdr:8000/sanitise and uses the disarmed response — see the
commented app: service in docker-compose.yml. Full container,
Compose, and Kubernetes-sidecar guidance (plus the hardening checklist) is in
docs/deploy-container.md.
main has no server-side branch protection (a GitHub free-private-repo limitation — see
Known Gap #6). A local pre-push hook stands in
for it: it blocks force-pushes and deletions of main in your clone. Install it once after
cloning:
./scripts/install-hooks.shThe hook is tracked in scripts/hooks/; .git/hooks/ is not version-controlled, so each
clone must install it. Bypass for a one-off with git push --no-verify.
Two equivalent IaC paths provision the same stack — pick one. Both require live AWS
credentials. See docs/deployment-runbook.md for the full
guide and docs/00-production-readiness-index.md
for the sign-off checklist.
Option A — AWS SAM (needs the SAM CLI):
cd src
sam build
sam deploy --guided # choose bucket names, quarantine bucket, SNS subscribersOption B — OpenTofu / Terraform (build the package, then apply; see terraform/README.md):
./scripts/build.sh # Linux wheels + handler → build/lambda.zip
cd terraform
cp terraform.tfvars.example terraform.tfvars # set bucket names + region
tofu init && tofu apply # or: terraform init && terraform applySmoke test + load benchmark against the deployed stack:
python docs/benchmark.py --bucket <source-bucket> --files docs/fixtures/ \
--log-group /aws/lambda/cdr-lambda| Variable | Required | Default | Purpose |
|---|---|---|---|
SANITISED_BUCKET |
yes | — | destination for clean files |
QUARANTINE_BUCKET |
no | — | destination for rejected/errored/unsupported files |
RESULT_TOPIC_ARN |
no | — | SNS topic for CDR result metadata |
CDR_MAX_FILE_BYTES |
no | 100 MB | pre-download size limit |
CDR_MAX_ENTRY_BYTES |
no | 200 MB | per-ZIP-entry decompression limit |
The Lambda runs at 1024 MB / 300 s timeout with ReservedConcurrentExecutions: 20; an
SQS DLQ and CloudWatch alarms (errors, p99 duration, throttles, DLQ depth, passthrough,
ZIP anomalies) are provisioned by the template.
MIT © 2026 Douglas Mun
