Skip to content

Repository files navigation

scrubexif

Tag CI cosign badge License

Docker Pulls Base OS # clones Milestone

🎯 Stats powered by ClonePulse

GitHub: per2jensen/scrubexif

Docker Hub: per2jensen/scrubexif

License

GPL-3.0-or-later

Licensed under GNU GENERAL PUBLIC LICENSE v3, see the supplied file "LICENSE" for details.

THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW, not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See section 15 and section 16 in the supplied "LICENSE" file.

High‑trust EXIF removal with a transparent supply chain

Removes common privacy-sensitive JPEG metadata—including GPS coordinates, serial numbers, and maker notes—using a byte-level APP wipe followed by an allowlisted metadata rebuild.

Verify the integrity and origin of every published release and refresh image using Sigstore signatures, and review the accompanying vulnerability scan.

Output safety behavior: scrubexif creates output files from the completed scrubbing pipeline; it does not copy a failed input directly into the output directory. If the JPEG scrubbing pipeline fails, the command reports an error and does not publish that JPEG to its intended output path. In output-directory modes, existing destination entries are never overwritten.

This is a failure-handling safeguard, not a guarantee that an image contains no privacy-sensitive information. Default mode preserves a few selected technical tags and the ICC profile. --paranoia removes JPEG APP metadata more aggressively, but scrubexif does not inspect visible image content, filenames unless --rename is used, sidecar files, or unsupported future formats. Always check the command’s exit status before publishing output.

High-stakes use: Independently verify the resulting files before publishing or distributing them.

Full documentation movedDETAILS.md
This README is intentionally short for Docker Hub visibility.

Quick Start

Simple command line usage (default safe mode, non-destructive)

Scrub all JPEGs in the current directory ($PWD) and write cleaned copies to $PWD/output/:

RUN_AS_UID=${RUN_AS_UID:-$(id -u)}
RUN_AS_GID=${RUN_AS_GID:-$(id -g)}
if [ "$RUN_AS_UID" -eq 0 ]; then
    echo "Running as root is not allowed"
    exit 1
fi
docker run --rm \
  --user "$RUN_AS_UID:$RUN_AS_GID" \
  -v "$PWD:/photos" \
  per2jensen/scrubexif:0.7.26

Verify the results: Verify that the sensitive metadata you expect to remove is absent and that only the intended technical tags remain.

Worth noting:

  • scans your current directory ($PWD) for *.jpg / *.jpeg (also in capital letters)
  • writes scrubbed copies to $PWD/output/ (or a custom --output dir)
  • leaves the originals untouched in $PWD/
  • refuses to run if the $PWD/output directory already exists
  • prints host paths by default (use --show-container-paths to include /photos/... paths)

Demo script

The demo script shows how to non-destructively scrub JPEGs in a directory and output the scrubbed JPEGs to another directory. Features:

  • Run as the user calling the script, root is not allowed
  • write a log in /tmp/scrubexif.log (keep it under 100k)
  • sanity checks
  • send notifications to desktop

Write to specific directory

Use -o to control where scrubbed files are written.

Output to a subdirectory of $PWDscrubexif creates it if it does not exist:

RUN_AS_UID=${RUN_AS_UID:-$(id -u)}
RUN_AS_GID=${RUN_AS_GID:-$(id -g)}
if [ "$RUN_AS_UID" -eq 0 ]; then
    echo "Running as root is not allowed"
    exit 1
fi
docker run --rm \
    --user "$RUN_AS_UID:$RUN_AS_GID" \
    -v "$PWD:/photos" \
    per2jensen/scrubexif:0.7.26 \
    -o scrubbed

Scrubbed files are written to $PWD/scrubbed/. The run is refused if scrubbed/ already exists (safety guard — use -o with a bind-mount instead if you need to reuse a directory).

Output to an arbitrary host directory — mount it independently and pass the container path to -o:

RUN_AS_UID=${RUN_AS_UID:-$(id -u)}
RUN_AS_GID=${RUN_AS_GID:-$(id -g)}
if [ "$RUN_AS_UID" -eq 0 ]; then
    echo "Running as root is not allowed"
    exit 1
fi
docker run --rm \
  --user "$RUN_AS_UID:$RUN_AS_GID" \
  -v "$PWD:/photos" \
  -v "/tmp/scrub-test:/scrubbed" \
  per2jensen/scrubexif:0.7.26 \
  -o /scrubbed

-v "/tmp/scrub-test:/scrubbed" maps /tmp/scrub-test on the host to /scrubbed inside the container. -o /scrubbed tells scrubexif to write there. Because -o was explicitly supplied, scrubexif accepts the directory even though it already exists (created by Docker as the mount point).

Scrubbed photos end up in /tmp/scrub-test/ on the host.

Note: mount the output directory at a top-level container path (e.g. /scrubbed) rather than nested under /photos (e.g. /photos/scrubbed). Nesting requires $PWD to be writable so Docker can create the mount point there.

Failure handling (important)

Scrubexif is designed to not place an unscrubbed JPEG into an output directory. If a scrub fails, no output file should be created for that JPEG; processing continues for the remaining files, and the final exit status is nonzero.

What happens on failed scrubs depends on the mode scrubexif is run in:

  • Default safe mode (the one-liner): failed files stay in the original directory, and no file is written to the output directory for those failures.
  • Auto mode (--from-input): Failed files are archived in processed/ for inspection when possible. If archival also fails, the original remains in place and the run reports an error.
  • Manual (destructive) in-place (--clean-inline): a failure leaves the original unchanged; there is no output directory involved.

Hardened (destructive) in-line scrub (current directory)

Same idea, but with container hardening and in-line (destructive) overwrite:

RUN_AS_UID=${RUN_AS_UID:-$(id -u)}
RUN_AS_GID=${RUN_AS_GID:-$(id -g)}
if [ "$RUN_AS_UID" -eq 0 ]; then
    echo "Running as root is not allowed"
    exit 1
fi
docker run -it --rm \
  --user "$RUN_AS_UID:$RUN_AS_GID" \
  --read-only --security-opt no-new-privileges \
  --tmpfs /tmp \
  -v "$PWD:/photos" \
  per2jensen/scrubexif:0.7.26 --clean-inline

Nautilus integration

Example of Gnome File Manager (Nautilus) integration can be seen in my file manager scripts

Filename sanitisation (--rename)

Filenames can leak as much as EXIF. A name like 2026-04-07_11-13-45.jpeg reveals the exact capture time, and prefixes like D80_ identify the camera body. --rename replaces the output filename with a format string of your choice so the original identifying filename does not survive.

# Randomized filename — 8-character random hex name
RUN_AS_UID=${RUN_AS_UID:-$(id -u)}
RUN_AS_GID=${RUN_AS_GID:-$(id -g)}
if [ "$RUN_AS_UID" -eq 0 ]; then
    echo "Running as root is not allowed"
    exit 1
fi
docker run -it --rm \
  --user "$RUN_AS_UID:$RUN_AS_GID" \
  --read-only --security-opt no-new-privileges \
  --tmpfs /tmp \
  -v "$PWD:/photos" \
  per2jensen/scrubexif:0.7.26 --clean-inline --rename "%r8" --recursive

# Keep your camera prefix, remove the timestamp
RUN_AS_UID=${RUN_AS_UID:-$(id -u)}
RUN_AS_GID=${RUN_AS_GID:-$(id -g)}
if [ "$RUN_AS_UID" -eq 0 ]; then
    echo "Running as root is not allowed"
    exit 1
fi
docker run -it --rm \
  --user "$RUN_AS_UID:$RUN_AS_GID" \
  --read-only --security-opt no-new-privileges \
  --tmpfs /tmp \
  -v "$PWD:/photos" \
  per2jensen/scrubexif:0.7.26 --clean-inline --rename "D80_%r6" --recursive

--paranoia implies --rename "%r8" when no --rename is given.
Common tokens: %r (random hex), %u (UUID), %n (sequential counter), %Y (year from EXIF), %m (month from EXIF).

Before modifying any file, scrubexif builds a complete collision-checked rename plan in a bounded temporary SQLite database. It prints progress for long scans, re-rolls random/UUID collisions up to three times, and aborts the whole batch if a collision cannot be resolved. Final files are published with an atomic no-overwrite operation. If a destination appears concurrently, scrubexif logs the active-filesystem event, reserves another random name, and continues without repeating the scrub; the concurrent file is never replaced. Planning defaults to at most 250,000 files, 30 minutes, and 512 MiB of temporary storage; the corresponding --rename-plan-* options can tune those safeguards.

Full specification → doc/rename-spec.md

Batch workflow (PhotoPrism / intake style)

Use auto mode with explicit input/output/processed directories:

mkdir input scrubbed processed errors
RUN_AS_UID=${RUN_AS_UID:-$(id -u)}
RUN_AS_GID=${RUN_AS_GID:-$(id -g)}
if [ "$RUN_AS_UID" -eq 0 ]; then
    echo "Running as root is not allowed"
    exit 1
fi
docker run -it --rm \
  --user "$RUN_AS_UID:$RUN_AS_GID" \
  --read-only --security-opt no-new-privileges \
  --tmpfs /tmp \
  -v "$PWD/input:/photos/input" \
  -v "$PWD/scrubbed:/photos/output" \
  -v "$PWD/processed:/photos/processed" \
  -v "$PWD/errors:/photos/errors" \
  per2jensen/scrubexif:0.7.26 --from-input

These are the physical directories used on your file system:

Uploads → $PWD/input/
Scrubbed → $PWD/scrubbed/
Originals → $PWD/processed/ (or deleted with --delete-original)
Duplicates → deleted by default; use --on-duplicate move to move them into $PWD/errors/
Failed scrubs (e.g., corrupted files) → logged as failures; originals are moved to $PWD/processed/ for inspection
errors/ is a misnomer today; it is only used for duplicates when --on-duplicate move is set. Will be fixed in a later version.

Archive entries are never overwritten. If the usual filename is already occupied in processed/ or errors/, scrubexif preserves it and publishes the incoming original under a fresh random-suffixed name. The source is removed only after the archive copy has been safely published; if archival fails, the source stays in place and the run reports an error. This also works when intake and archive are on different filesystems or mounts.

Data flow overview (auto mode: --from-input)

This flow diagram describes what happens only in auto mode (--from-input), where four directories (input/, output/, processed/, errors/) are used.

Please observe these directories are named like this inside the container. Your physical directories in your file system are mapped when you run the docker run ... command. See the -v .... options in the above example.

[input/]  -->  <scrubexif> runs  -->  [output/]
                 |
                 +-->  [processed/]   (original JPEGs moved here after successful scrub,
                                       unless --delete-original is used)
                 |
                 +-->  [errors/]      (duplicates only — only used when
                                       --on-duplicate move)

Meaning:

  • input/ New JPEGs arrive here (e.g. from uploads, for example PhotoSync).

  • output/ Scrubbed JPEGs with allowlisted technical metadata.

  • processed/ Original JPEGs moved here after scrub (or deleted when requested).

  • errors/ Only created/used when --on-duplicate move is enabled.

Build & Run Locally

# build the image from the Dockerfile in this repo
docker build -t scrubexif:local .

# show CLI usage (ENTRYPOINT runs python -m scrubexif.scrub)
docker run --rm scrubexif:local --help

# scrub the current directory with hardened defaults
RUN_AS_UID=${RUN_AS_UID:-$(id -u)}
RUN_AS_GID=${RUN_AS_GID:-$(id -g)}
if [ "$RUN_AS_UID" -eq 0 ]; then
    echo "Running as root is not allowed"
    exit 1
fi
docker run -it --rm \
  --user "$RUN_AS_UID:$RUN_AS_GID" \
  --read-only --security-opt no-new-privileges \
  --tmpfs /tmp \
  -v "$PWD:/photos" \
  scrubexif:local

Any arguments appended to docker run … scrubexif:* are forwarded to the underlying python3 -m scrubexif.scrub entrypoint.

Key Features

  • Scrubexif is JPEG‑only by design. This avoids format‑specific edge cases and ensures predictable behavior.
  • Allowlist-based scrubbing: jpegtran strips all JPEG APP segments at the byte level (including unknown/proprietary vendor segments), then ExifTool writes back a small allowlist of technical tags (exposure, ISO, focal length, orientation)
  • Removes common privacy-sensitive metadata, including GPS coordinates, serial numbers, and maker notes
  • Preserves color profiles (ICC) by default; normal mode re-embeds the ICC profile after the jpegtran strip
  • Auto mode with duplicate handling (--on-duplicate delete|move)
  • Optional stability gate for hot upload directories (e.g., PhotoSync, rclone, FTP uploads) (--stable-seconds, --state-file)
  • Metadata inspection and dry-run support (--show-tags, --preview, --dry-run)
  • Optional stamping of copyright and comment into EXIF/XMP (--copyright, --comment)
  • Hardened container defaults in examples (read-only + no-new-privileges)

Acknowledgements

  • ExifTool by Phil Harvey — used for metadata extraction and selective tag write-back (GPL-1.0-or-later / Artistic License)
  • jpegtran from libjpeg-turbo — used for lossless byte-level JPEG transformation (IJG / BSD licence)
  • sigstore/cosign used to sign/upload artifacts
  • Syft used to generate a Software Bill Of Materials
  • Grype used for image vulnerability scanning
  • Ubuntu for the base image Scrubexif is based on

Supply Chain Transparency

Every release image is cryptographically signed using cosign keyless signing via the Sigstore public infrastructure. The signature is tied directly to the specific GitHub Actions run that built the image, ensuring there are no long-lived signing keys that could be compromised. Anyone can verify that a pulled image genuinely came from this repository and was not tampered with in transit or on Docker Hub.

Verify any release in one command (requires cosign):

cosign verify per2jensen/scrubexif:0.7.26 \
  --certificate-identity-regexp="https://github.com/per2jensen/scrubexif" \
  --certificate-oidc-issuer="https://token.actions.githubusercontent.com"

Replace the tag with the version you wish to verify.

A successful verification prints the signing certificate, which includes the exact workflow URL, the Git commit SHA, and the GitHub Actions run URL — proving provenance down to the individual CI run.

Full details on installation, verification, and what the certificate fields mean → doc/DETAILS.md#image-signing-and-supply-chain-verification

The :latest image is rebuilt every Saturday from the exact source of the latest stable release. A successful refresh is tested, scanned, signed, and published with an immutable numeric suffix such as :0.7.26. Weekly refreshes do not change the stable release version shown in this README; their complete audit trail is recorded in doc/build-history.json, with compressed SBOM and SARIF files stored under doc/.

Additional supply chain artefacts per release and scheduled refresh:

  • SPDX SBOM (sbom-<version>.spdx.json) — attached to each GitHub Release and as a signed in-toto attestation on the image itself
  • Grype vulnerability scan (grype-results-<version>.sarif) — attached to the release, uploaded to the GitHub Security tab; releases are blocked on any high or critical CVE
  • doc/build-history.json — tracks every release and refresh with Git commit, image digest, Grype counts, cosign Rekor log entry, and CI run URL

Common Options

--from-input          auto mode
--clean-inline        in-place scrub (destructive)
--rename FORMAT       rename output files using a format string (see doc/rename-spec.md)
--rename-plan-max-files N      planning file-count circuit breaker (default: 250000)
--rename-plan-timeout-seconds S planning time circuit breaker (default: 1800)
--rename-plan-max-mib MIB       planning storage circuit breaker (default: 512)
--show-container-paths include container paths in output
-q, --quiet           no output on success
--preview             no write, view only
--paranoia            byte-level wipe via jpegtran only — removes JPEG APP metadata, including EXIF and ICC profiles
--comment             stamp comment into EXIF/XMP
--copyright           stamp copyright into EXIF/XMP
--on-duplicate        delete | move
--stable-seconds N    intake stability window
--state-file PATH     override queue DB
-o, --output DIR      write scrubbed files to DIR (default safe mode)

Full CLI reference → in DETAILS.md

The process exits 0 only when the run completes without scrub or post-processing errors. A failed file, failed preview, unresolved collision, or unsafe archive/delete operation returns 1; handled skips and duplicates do not. With --quiet, successful runs remain silent, while failure diagnostics and the summary are replayed to standard error.

Example setup

This is an example of my workflow to quickly upload JPEG files to PhotoPrism. One use case is to quickly show dog owners photos at exhibitions.

Host filesystem path Container path Purpose
/some/directory/ /photos/input/ Location for new JPEG uploads on the server
/photoprism/sooc/ /photos/output/ Destination for scrubbed JPEG versions, for PhotoPrism import
/photoprism/processed/ /photos/processed/ Holding area for already-imported files.

Systemd

/etc/systemd/system/scrubexif.service:

    [Service]
    ExecStart=/usr/bin/docker run --rm \
      --read-only --security-opt no-new-privileges \
      --tmpfs /tmp \
      -v /some/directory:/photos/input \
      -v /photoprism/sooc:/photos/output \
      -v /photoprism/processed:/photos/processed \
      per2jensen/scrubexif:0.7.26 --from-input --stable-seconds 10

/etc/systemd/system/scrubexif.timer:

    [Unit]
    Description=Run scrubexif every 5 minutes

    [Timer]
    OnBootSec=1min
    OnUnitActiveSec=5min
    Persistent=true

    [Install]
    WantedBy=timers.target

Photoprism systemd script

I use scrubexif to clean my jpegs on dog exhibitions. I upload the files to a server using rclone and a systemd timer runs the script below every 5 minutes.

You can see my (anonymized) script in the Github scrubexif repo

Development

make dev-clean   # remove dev image
make test        # make dev image and run full test suite
pytest -m soak   # optional 10 min run or try scripts/soak.sh

GitHub: per2jensen/scrubexif

Docker Hub: per2jensen/scrubexif

Full docs → DETAILS.md

Releases

Packages

Contributors

Languages