Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Keeps the three pinned surfaces current: the uv lockfile, the SHA-pinned
# actions in ci.yml, and the digest-pinned images in the Dockerfile.
version: 2
updates:
- package-ecosystem: uv
directory: /
schedule:
interval: weekly
groups:
dev-tooling:
patterns: [ruff, pyright, pytest, pre-commit]
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
- package-ecosystem: docker
directory: /
schedule:
interval: weekly
25 changes: 18 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,26 @@ on:
branches: [main]
pull_request:

# Least privilege: nothing here writes to the repository.
permissions:
contents: read

# A newer push to the same branch or PR supersedes the run in flight.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
quality:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
- name: Sync dependencies
run: uv sync --frozen
run: uv sync --locked
- name: Lint
run: uv run ruff check .
- name: Format check
Expand All @@ -24,16 +34,17 @@ jobs:
- name: Tests
run: uv run pytest
- name: Validate decision register
run: python3 docs/scripts/validate_decisions.py docs/DECISIONS.md
run: uv run python docs/scripts/validate_decisions.py docs/DECISIONS.md

docker:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
- name: Sync dependencies
run: uv sync --frozen
run: uv sync --locked
- name: Container contract tests (builds the image)
run: uv run pytest -m container
28 changes: 20 additions & 8 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,21 +1,33 @@
# The Python tools run through uv so that pre-commit, CI and a developer's
# shell all use the single version pinned in uv.lock. Only the generic
# hygiene hooks come from a remote repo.
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
rev: v6.0.0
hooks:
- id: check-yaml
- id: check-toml
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-added-large-files

- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.9
- repo: local
hooks:
- id: ruff-check
args: ["--fix"]
name: ruff check
entry: uv run --locked ruff check --fix --force-exclude
language: system
types_or: [python, pyi]
require_serial: true
- id: ruff-format

- repo: https://github.com/RobertCraigie/pyright-python
rev: v1.1.408
hooks:
name: ruff format
entry: uv run --locked ruff format --force-exclude
language: system
types_or: [python, pyi]
require_serial: true
- id: pyright
name: pyright
entry: uv run --locked pyright
language: system
types_or: [python, pyi]
pass_filenames: false
40 changes: 32 additions & 8 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,16 +1,40 @@
# Deliberately simple starter image. Planned hardening: pinned-by-digest base,
# multi-stage uv build (no pip/build tooling in the runtime layer), verified
# read-only-rootfs posture.
FROM python:3.14-slim
# Build stage: uv resolves the locked dependency set into a self-contained
# virtualenv. Nothing from this stage's tooling (uv, caches) reaches the
# runtime image.
FROM python:3.14-slim@sha256:cad9a2c871761c413caa6fdd6441c783451e740a48aaeba60ae62a8b53525ef6 AS build
COPY --from=ghcr.io/astral-sh/uv:0.10@sha256:72ab0aeb448090480ccabb99fb5f52b0dc3c71923bffb5e2e26517a1c27b7fec /uv /bin/uv

ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=never
WORKDIR /app
COPY pyproject.toml README.md LICENSE ./

# Dependencies first: this layer only rebuilds when the lockfile changes.
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --frozen --no-install-project --no-dev

COPY pyproject.toml README.md LICENSE uv.lock ./
COPY src ./src
RUN pip install --no-cache-dir .
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev --no-editable

# Runtime: the digest-pinned interpreter, the built virtualenv, and nothing
# else. No package installer, no build tooling, a fixed non-root UID, and a
# root filesystem that works read-only (all writes go to the output mount).
FROM python:3.14-slim@sha256:cad9a2c871761c413caa6fdd6441c783451e740a48aaeba60ae62a8b53525ef6

RUN /usr/local/bin/python -m pip uninstall --yes pip \
&& rm -r /usr/local/lib/python3.14/ensurepip \
&& useradd --uid 10001 --create-home appuser

COPY --from=build /app/.venv /app/.venv
ENV PATH="/app/.venv/bin:$PATH" \
PYTHONDONTWRITEBYTECODE=1

# Non-root from day one; fixed UID so volume-permission guidance stays concrete.
RUN useradd --uid 10001 --create-home appuser
USER 10001
WORKDIR /app

# All behavior comes from PDFOPS_* environment variables - no arguments, no CMD.
ENTRYPOINT ["python", "-m", "pdf_ops"]
62 changes: 48 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,40 @@ operation per container run - **merge** multiple PDFs into one, or **extract** t
attachments embedded in a PDF - configured entirely through environment variables.

The design - architecture, library tradeoffs, security posture, limitations - is
summarized in [`docs/DESIGN.md`](docs/DESIGN.md); the working notes behind it are
[`docs/DESIGN_NOTES.md`](docs/DESIGN_NOTES.md), and individual choices, with their
alternatives and status, live in the decision register at
[`docs/DECISIONS.md`](docs/DECISIONS.md).
summarized in [`docs/DESIGN.md`](docs/DESIGN.md) and drawn, view by view, in
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md); the runtime behavior contract - mounts,
passwords, output policy, log events, error codes - is [`docs/OPERATIONS.md`](docs/OPERATIONS.md);
the working notes behind the design are [`docs/DESIGN_NOTES.md`](docs/DESIGN_NOTES.md),
and individual choices, with their alternatives and status, live in the decision
register at [`docs/DECISIONS.md`](docs/DECISIONS.md).

## Quick start

```sh
docker build -t pdf-ops .

# The container runs as UID 10001 - the output dir must be writable by it
mkdir -p in out && chmod 777 out
mkdir -p in out secret && chmod 777 out

# No PDFs at hand? Conjure the inputs the examples below use:
uv sync && uv run python - <<'PY'
from pypdf import PdfWriter
for name, pages in (("a.pdf", 1), ("b.pdf", 2)):
w = PdfWriter()
for _ in range(pages):
w.add_blank_page(width=200, height=300)
with open(f"in/{name}", "wb") as h:
w.write(h)
w = PdfWriter(); w.add_blank_page(width=200, height=300)
w.add_attachment("data.csv", b"x,y\n1,2\n")
with open("in/report.pdf", "wb") as h:
w.write(h)
w = PdfWriter(); w.add_blank_page(width=200, height=300)
w.encrypt(user_password="s3cret-pw", algorithm="AES-256")
with open("in/locked.pdf", "wb") as h:
w.write(h)
open("secret/pw", "w").write("s3cret-pw\n")
PY

# Merge two PDFs from a mounted input dir into a mounted output dir
docker run --rm \
Expand All @@ -41,7 +63,7 @@ docker run --rm \
docker run --rm \
-v "$PWD/in:/in:ro" -v "$PWD/out:/out" -v "$PWD/secret:/secret:ro" \
-e PDFOPS_OPERATION=merge \
-e PDFOPS_INPUTS=/in/locked.pdf:/in/plain.pdf \
-e PDFOPS_INPUTS=/in/locked.pdf:/in/a.pdf \
-e PDFOPS_OUTPUT=/out/merged.pdf \
-e PDFOPS_PASSWORD_FILE=/secret/pw \
-e PDFOPS_OUTPUT_ENCRYPTION=inherit \
Expand All @@ -66,10 +88,10 @@ from `PDFOPS_*` variables, and the mounted volumes provide inputs and receive ou
| `PDFOPS_FAIL_ON_NO_ATTACHMENTS` | extract | no | `true`, `false` (case-insensitive) - fail (exit 3) when the PDF has no attachments | `false` |
| `PDFOPS_PASSWORD_FILE` | both | no | path to a mounted secret file holding the password (preferred channel; one trailing newline stripped) | - |
| `PDFOPS_PASSWORD` | both | no | the password itself - discouraged: env values leak via `kubectl describe`, `/proc/<pid>/environ`, crash tooling | - |
| `PDFOPS_OUTPUT_ENCRYPTION` | merge | no | `never`, `inherit`, `always` (case-insensitive) - see below | `never` |
| `PDFOPS_OUTPUT_ENCRYPTION` | merge | no | `never`, `inherit`, `always` (case-insensitive) - see [Output encryption](docs/OPERATIONS.md#output-encryption) | `never` |
| `PDFOPS_OUTPUT_PASSWORD_FILE` | merge | no | secret file holding the password for the merged output | - |
| `PDFOPS_OUTPUT_PASSWORD` | merge | no | output password as a direct value (same caveats as `PDFOPS_PASSWORD`) | - |
| `PDFOPS_ON_EXISTS` | both | no | `fail`, `overwrite`, `skip` (case-insensitive) - see Retries | `fail` |
| `PDFOPS_ON_EXISTS` | both | no | `fail`, `overwrite`, `skip` (case-insensitive) - see [Existing outputs](docs/OPERATIONS.md#atomic-writes-and-existing-outputs) | `fail` |
| `PDFOPS_LOG_LEVEL` | - | no | `debug`, `info`, `warning`, `error` (case-insensitive) | `info` |

Strictness rules, all exit 2: any other `PDFOPS_*` variable is rejected as a probable
Expand All @@ -80,7 +102,8 @@ repeated path is almost always a templating bug that would silently duplicate co
## Path conventions and output behavior

The full behavior contract - mounts and permissions, password semantics, output
encryption, atomic writes, the existing-output policy, and attachment-name safety -
encryption, atomic writes, the existing-output policy, attachment-name safety, and
resource sizing -
lives in [`docs/OPERATIONS.md`](docs/OPERATIONS.md). The short version: everything is
mounted volumes with absolute in-container paths, the container runs as non-root UID
10001, outputs are written atomically (a complete file or nothing), and
Expand All @@ -98,11 +121,14 @@ mounted volumes with absolute in-container paths, the container runs as non-root
| 5 | password required/wrong/unsupported |
| 6 | output conflict or output location unusable |

The finer-grained `error_code` carried by every `operation_failed` event is listed
per exit code in [`docs/OPERATIONS.md`](docs/OPERATIONS.md#error-codes).

## Logging

Output is JSON lines on stdout - one event per line, stderr stays empty. Lifecycle
events narrate progress and respect `PDFOPS_LOG_LEVEL`; passwords are echoed as
presence only (`unset` / `set(env)` / `set(file)`), never as values. Every run ends
events narrate progress and respect `PDFOPS_LOG_LEVEL`; the `config_loaded` event
echoes passwords as presence only (`unset` / `set(env)` / `set(file)`), never as values. Every run ends
with exactly one terminal event - `operation_complete` or `operation_failed` (with a
machine-readable `error_code`) - which no log level suppresses, so a workflow engine
can always branch on the last line:
Expand All @@ -129,24 +155,32 @@ code:
| 5 | no | the password will still be wrong |
| 6 | usually no | output conflict/location - `DISK_FULL` is the judgment call (space may free up) |

Argo example - retry only on unexpected errors, with `skip` making any retry after a
lost-but-successful pod a free no-op:
Argo example - retry unexpected errors (exit 1) and pod-level errors, which never
produce an exit code (a lost pod reports "-1"); with `skip` in the environment, a
retry after a lost-but-successful pod is a free no-op:

```yaml
retryStrategy:
limit: "3"
expression: "asInt(lastRetry.exitCode) == 1"
expression: >-
lastRetry.status == "Error" or asInt(lastRetry.exitCode) == 1
# and in the container env:
# PDFOPS_ON_EXISTS: skip
```

A complete `WorkflowTemplate` - security context, secret-mounted password,
retry strategy, measured resource sizing - lives in
[`deploy/argo-example.yaml`](deploy/argo-example.yaml).

## Development

```sh
uv sync # deps + venv
uv run pre-commit install # once: the hooks run the same tools through uv
uv run pytest # unit + integration tests
uv run pytest -m container # container-contract tests (needs Docker)
uv run ruff check . # lint
uv run ruff format --check . # formatting (CI enforces it)
uv run pyright # strict type check
uv run pre-commit run -a # full hook chain
```
23 changes: 23 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Security policy

pdf-ops parses untrusted PDFs and writes their attachment names to a mounted
filesystem, so input handling is security-relevant by design. The posture -
attachment-name sanitization, the layered no-leak guarantee for passwords,
atomic outputs, a hardened read-only container - is described in
`docs/DESIGN.md`.

## Reporting a vulnerability

Please do not open a public issue. Use GitHub's private vulnerability reporting
on this repository (Security tab, "Report a vulnerability"); if that is not
enabled, contact the maintainer directly. Include a way to reproduce: the PDF
or how to build one, the `PDFOPS_*` configuration, and the log lines.

## In scope

- An attachment name that writes outside `PDFOPS_OUTPUT_DIR`.
- Password material appearing in any output: stdout, stderr, files, tracebacks.
- A partial or mixed output surviving a failed or killed run.
- A deterministic bad input classified as retryable (exit 1).
- A hostile PDF structure that escapes the error taxonomy (exit 1) instead of
classifying as a data problem.
67 changes: 67 additions & 0 deletions deploy/argo-example.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Argo Workflows example: pdf-ops as a single workflow step.
#
# What this template encodes:
# - the full security posture the image is tested against (read-only root
# filesystem, non-root UID 10001, no capabilities, no privilege escalation);
# - fsGroup so the output volume is writable by the container UID;
# - the password arriving as a mounted secret file, never as an env value;
# - retryStrategy that retries exit 1 (the one nondeterministic class; see
# the exit-code table in the README) and pod-level errors, which never
# produce an exit code (Argo reports "-1") - the lost-pod case. Paired
# with PDFOPS_ON_EXISTS=skip, a retry after a lost-but-successful pod is
# a free no-op;
# - memory sized by the measured rule of thumb: total expected input size
# plus 128 MB (see docs/OPERATIONS.md, "Resource sizing").
apiVersion: argoproj.io/v1alpha1
kind: WorkflowTemplate
metadata:
name: pdf-ops-merge
spec:
entrypoint: merge
securityContext:
fsGroup: 10001
volumes:
- name: documents
persistentVolumeClaim:
claimName: documents # the shared volume carrying inputs and outputs
- name: pdf-password
secret:
secretName: pdf-password # key "password" -> file /secrets/password
templates:
- name: merge
retryStrategy:
limit: "3"
expression: >-
lastRetry.status == "Error" or asInt(lastRetry.exitCode) == 1
container:
image: pdf-ops:latest # replace with your registry reference
env:
- name: PDFOPS_OPERATION
value: merge
- name: PDFOPS_INPUTS
value: /data/in/a.pdf:/data/in/b.pdf
- name: PDFOPS_OUTPUT
value: /data/out/merged.pdf
- name: PDFOPS_ON_EXISTS
value: skip
- name: PDFOPS_PASSWORD_FILE
value: /secrets/password
resources:
requests:
memory: 640Mi # ~500 MB of inputs + 128 MB headroom
cpu: 500m
limits:
memory: 640Mi
securityContext:
runAsNonRoot: true
runAsUser: 10001
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
volumeMounts:
- name: documents
mountPath: /data
- name: pdf-password
mountPath: /secrets
readOnly: true
Loading
Loading