SECURITY NOTE · PDF AUTOMATION
Check that your PDF parser container is isolated
If you parse untrusted PDFs in a container, the docker run flags are the control. This bundle builds a small pinned parser image, reads those flags back from inside the running container, and then runs the same image without them to show the checks can fail.
Tested 2026-09-25 on one Linux host: Docker Engine 29.1.3, kernel 6.8, cgroup v2. Every result below comes from that host and one synthetic PDF.
SHA-256 7ee39878f5e35617b3637f935e8b1570c9ec275de4d1f72fa412745b2843f2a2. MIT licensed. Standard-library Python, one Dockerfile, one shell script.
Run it
You need Docker, bash and python3 on a Linux host, plus network access for the build.
curl -fsSLO https://cybernative.ai/labs/pdf-parser-container-isolation/pdf-parser-isolation-check.tar.gz
echo "7ee39878f5e35617b3637f935e8b1570c9ec275de4d1f72fa412745b2843f2a2 pdf-parser-isolation-check.tar.gz" | sha256sum -c -
tar -xzf pdf-parser-isolation-check.tar.gz
cd pdf-parser-isolation-check
./build.sh
python3 verify.py isolation # the flags, read back: 35 checks
python3 verify.py negative-control # flags removed or altered: must fail
python3 verify.py fixture # parse the synthetic PDF with the flags
python3 verify.py all runs all three and exits 0 only if each behaves as expected. Evidence is written as JSON to ./evidence/.
The flags under test
This is the container invocation the probe checks. verify.py uses exactly these flags, so you can also run the probe by hand. Create run/in and run/out first, and make run/out writable by uid 65534.
docker run --rm \
--network none --read-only --user 65534:65534 \
--cap-drop ALL --security-opt no-new-privileges \
--pids-limit 256 --cpus 2 --memory 2g --memory-swap 2g \
--tmpfs /tmp:rw,noexec,nosuid,nodev,size=768m \
--mount type=bind,src="$PWD/run/in",dst=/in,readonly \
--mount type=bind,src="$PWD/run/out",dst=/out \
--workdir /tmp \
pdf-parser-sandbox:1 python3 /app/isolation_probe.py
The probe prints one JSON object and exits 0 when it finds no failure. It reads /proc/self/status, /proc/mounts, the cgroup files and /sys/class/net, tries to write to /, /tmp and /out, attempts one TCP connection to 1.1.1.1:53, and looks for GPU devices and container sockets. verify.py also passes it your credential paths and a fresh canary file created on the host.
verify.py isolation asserts every flag in this command except --rm and --workdir: uid and gid, read-only root, capability bounding set, no-new-privileges, network, the memory, swap, PID and CPU limits, the /tmp tmpfs type, options and size, and the /in and /out mounts. It also runs a nonce challenge: it writes a random nonce into the host /in directory, the probe reads it back and copies it into /out, and verify.py checks both on the host. That catches a wrong directory mounted by mistake. It does not prove which host directory is mounted: if something on the host copies the nonce into another directory, or seeds the echo in advance, the challenge still passes. It assumes a trusted, unmodified host. The probe alone cannot know the values you intended, such as the gid or the limits. verify.py holds them.
What a pass looked like
Output of python3 verify.py isolation from a clean extraction of the bundle above. Exit code 0.
image: sha256:3a5219619f248a97acadb595a4c7c98fe8c4a3b3657ba74c06489a4ac2112ad7
note: read-only host driver metadata visible (not a failure): /proc/driver/nvidia, /proc/driver/nvidia/gpus
[PASS] isolation: probe_exit_zero - exit=0
[PASS] isolation: probe_json
[PASS] isolation: probe_failures_empty - []
[PASS] isolation: expected_uid - uid=65534 user=nobody
[PASS] isolation: expected_gid - gid=65534
[PASS] isolation: no_extra_groups - groups=[65534]
[PASS] isolation: image_marker_present
[PASS] isolation: read_only_root
[PASS] isolation: root_not_writable
[PASS] isolation: read_only_input_mount
[PASS] isolation: writable_output_mount
[PASS] isolation: writable_ephemeral_tmp
[PASS] isolation: tmp_is_tmpfs - tmpfs
[PASS] isolation: tmp_noexec_nosuid_nodev - ['nosuid', 'nodev', 'noexec']
[PASS] isolation: tmp_size - size=786432k
[PASS] isolation: no_effective_capabilities - CapEff=0000000000000000
[PASS] isolation: capability_bounding_set_empty - CapBnd=0000000000000000
[PASS] isolation: no_new_privileges - NoNewPrivs=1
[PASS] isolation: network_only_loopback - ['lo']
[PASS] isolation: network_unreachable - 1.1.1.1:53
[PASS] isolation: no_gpu_device_nodes
[PASS] isolation: no_gpu_char_devices
[PASS] isolation: no_nvidia_smi
[PASS] isolation: no_host_paths - 15 paths checked, 0 visible
[PASS] isolation: canary_host_path_absent
[PASS] isolation: no_secret_env - 10 names checked, present=[]
[PASS] isolation: memory_limit - memory.max=2147483648
[PASS] isolation: no_swap_beyond_memory - memory.swap.max=0
[PASS] isolation: pids_limit - pids.max=256
[PASS] isolation: cpu_limit - cpu.max=200000 100000
[PASS] isolation: only_expected_mounts - ['/etc/hostname', '/etc/hosts', '/etc/resolv.conf', '/in', '/out', '/tmp']
[PASS] isolation: in_mount_ro
[PASS] isolation: out_mount_rw
[PASS] isolation: in_nonce_matches - nonce read back from /in
[PASS] isolation: out_echo_matches - nonce echoed into the host /out
isolation: 35/35 checks passed; evidence evidence/isolation-20260925T005045Z.json
RESULT: OK
The fixture run parsed the one-page synthetic PDF with the same flags: 5/5 checks, exit code 0. The parser in the bundle refuses to start unless the probe comes back clean.
The negative controls
A check that cannot fail proves nothing. verify.py negative-control runs seven controls, and passes only if each one makes the probe fail where it should. Exit code 0:
[PASS] negative-control: probe_detects_missing_flags - exit=1; failing checks: probe_exit_zero, probe_failures_empty, read_only_root, read_only_input_mount, writable_output_mount, tmp_is_tmpfs, tmp_noexec_nosuid_nodev, tmp_size, capability_bounding_set_empty, no_new_privileges, network_only_loopback, network_unreachable, memory_limit, no_swap_beyond_memory, pids_limit, cpu_limit, only_expected_mounts, in_mount_ro, out_mount_rw
[PASS] negative-control: parser_refuses_without_flags - exit=1
[PASS] negative-control: altered_tmp_host_bind_detected - exit=1; failing checks: probe_exit_zero, probe_failures_empty, tmp_is_tmpfs, tmp_noexec_nosuid_nodev, tmp_size
[PASS] negative-control: altered_tmp_exec_suid_dev_detected - exit=1; failing checks: probe_exit_zero, probe_failures_empty, tmp_noexec_nosuid_nodev
[PASS] negative-control: altered_tmp_other_size_detected - exit=0; failing checks: tmp_size
[PASS] negative-control: altered_other_gid_detected - exit=0; failing checks: expected_gid, no_extra_groups
[PASS] negative-control: altered_in_other_source_detected - exit=0; failing checks: in_nonce_matches, out_echo_matches
[PASS] negative-control: altered_out_other_source_detected - exit=0; failing checks: out_echo_matches
negative-control: 8/8 checks passed; evidence evidence/negative-control-20260925T005045Z.json
RESULT: OK
Every flag removed. The first control runs plain docker run --rm <image>. 17 property checks failed: read-only root, four mount checks and the mount layout, the three /tmp checks, the capability bounding set, no-new-privileges, both network checks, and the memory, swap, PID and CPU limits. With the network enabled, the probe's connection to 1.1.1.1:53 went out. The parser refused to run.
One flag altered. The other six controls run the documented command with a single change, and the named check must fail: /tmp as a writable host directory, /tmp mounted exec,suid,dev, /tmp at 2 GB instead of 768 MB, a gid of 1, and a different host directory, without the nonce, mounted as /in or as /out. Docker adds noexec,nosuid,nodev to --tmpfs by default, so that control asks for the opposite explicitly. The size, gid and directory controls fail in verify.py, not in the probe, because only verify.py knows the values and directories you intended.
What the controls cannot show. Thirteen property checks still passed with every flag removed. They come from the image or from Docker defaults, not from the flags. The image sets USER 65534:65534, which keeps uid and gid right and leaves the process with no effective capabilities even without --cap-drop. That is why the probe also checks the capability bounding set: --cap-drop ALL empties it, and a non-root user alone does not. It is also why the gid has its own altered control. No GPU, host path or secret variable appeared because nothing passed them in.
What we tested
image id sha256:3a5219619f248a97acadb595a4c7c98fe8c4a3b3657ba74c06489a4ac2112ad7
base ubuntu:24.04@sha256:008173c23f95b170204355c12626cb5a965d779a7e1283b09e9cffbb1bf33ca3
packages poppler-utils=24.02.0-1ubuntu9.9 python3=3.12.3-0ubuntu2.1
host Docker Engine 29.1.3, Linux kernel 6.8, cgroup v2
input one synthetic one-page PDF (fixtures/make_synthetic.py regenerates it byte for byte)
Your image ID will differ. The base image is pinned by digest, but the Ubuntu packages come from the archive on the day you build. A rebuild of the same bundle with --no-cache on the same day gave a different ID and the same results: 35/35, all eight negative-control checks, fixture 5/5. build.sh records your ID and package versions in image.lock.
Using it with your own parser
The probe is one standard-library Python file and needs python3 inside the image. We ran it only in the image this bundle builds.
- Keep the guard in front of the parser.
parse.pycalls the probe'sreadback()and refuses when it reports a failure, so the image cannot quietly run without its flags. - Name your own secrets. Add host paths with
--host-pathand environment variable names with the probe's--secret-env. - Change a flag, then change the check. The flag values live in one place at the top of
verify.py. The expected limits follow them.