Skip to content

security: env var allowlist + testable OCI config builder#2

Open
jw409 wants to merge 24 commits intofreedomofpress:mainfrom
jw409:security-hardening
Open

security: env var allowlist + testable OCI config builder#2
jw409 wants to merge 24 commits intofreedomofpress:mainfrom
jw409:security-hardening

Conversation

@jw409
Copy link
Copy Markdown

@jw409 jw409 commented Apr 14, 2026

Summary

  • Replaces the denylist of non-forwarded env vars in src/helpers/entrypoint.py with an allowlist of just LANG, LC_ALL, LC_CTYPE, LANGUAGE, and TZ. Any other var in the outer container's environment is dropped entirely — neither name nor value appears in the OCI config that gVisor consumes.
  • Factors the OCI config build into build_oci_config(command, env) so the final config["process"]["env"] array can be tested directly, not just the allowlist set in isolation.
  • Adds tests/helpers/test_entrypoint.py (20 tests, all passing locally) mirroring the src/helpers/ source layout.

Context

This PR ports the env-var hardening from freedomofpress/dangerzone#1454 to this repo, per @apyrgio's guidance that container-image code now lives here. Review comments on 1454 that shaped this rewrite:

  • "Retain the original mask-with-<ENV>= behavior" — went with drop instead; see rationale below.
  • "Don't just test the allowlist set; test the final OCI config" — done, build_oci_config() is now a pure function the test drives directly.
  • "Put tests under tests/container_helpers/ to mirror sources" — done as tests/helpers/ to match this repo's layout.

Why drop rather than mask with <ENV>=

  • Allowlist semantics are stricter: we forward exactly what conversion needs. Masking still implies enumerating what to hide; dropping means the name is never written to the config file at all.
  • Names themselves are signals: AWS_SECRET_ACCESS_KEY, SSH_AUTH_SOCK, KUBECONFIG, ANTHROPIC_API_KEY — leaking the key name tells an attacker inside the sandbox what the outer context is running. Drop removes that channel.
  • gVisor behavior is identical either way: runsc reads config["process"]["env"]; keys not present simply don't exist inside the sandbox. Verified by inspection of the runtime spec.

Test plan

  • pytest tests/helpers/test_entrypoint.py -v --local — 20/20 pass
  • Covers: baseline env always set, allowlisted vars forwarded verbatim, sensitive names and values absent from the env array, sandbox-control vars (RUNSC_DEBUG/RUNSC_FLAGS) dropped, parent cannot shadow baseline PATH/PYTHONPATH/TERM, hostile values (newlines, nulls, shell injection, embedded =) dropped when key is not allowlisted.
  • build_oci_config() is pure — no module-level side effects — so the test loads entrypoint.py via importlib without invoking main().

Supersedes freedomofpress/dangerzone#1454 (moved to draft there, per repo split).

almet and others added 24 commits March 5, 2026 19:54
This is not required anymore, as we will be able to reference multiple
container images after the first two are built.
Conversions are attempted for all the test files inside and outside the
container. When ran inside the container, the output is compared with
reference versions (compressed copy of the output) to detect any
regressions.
Rename the `--only-local` flag to `--local` when running tests locally.
Replaces the denylist of non-forwarded env vars with an allowlist of just
LANG, LC_ALL, LC_CTYPE, LANGUAGE, and TZ. Any other var in the outer
container's environment is dropped entirely -- neither its name nor its
value appears in the OCI config written to gVisor.

Why drop rather than mask with '<ENV>=':
  - Allowlist semantics are stricter: we forward exactly what conversion
    needs and nothing else. Masking implies we still enumerate what to
    hide; dropping means the name is never in the config file.
  - Sensitive names (cloud provider tokens, SSH/GPG agent paths, k8s
    config locations) can themselves be signals. Not writing them at all
    is one fewer leak surface.
  - gVisor behavior is identical either way: it reads the config's env
    array; keys not present simply don't exist inside the sandbox.

Factors the OCI config build into build_oci_config(command, env) so the
full config can be tested, not just the allowlist set in isolation. The
test asserts on the final config['process']['env'] array -- the actual
data reaching gVisor -- and covers baseline presence, allowlist forward,
sensitive names AND values absent, sandbox-control vars dropped, parent
cannot shadow baseline, and hostile values (newlines/nulls/shell inj)
dropped when key is not allowlisted.

Tests live at tests/helpers/test_entrypoint.py, mirroring the source
layout under src/helpers/.
@almet
Copy link
Copy Markdown
Member

almet commented Apr 20, 2026

Hi, this repository is under heavy works, and so I've force-pushed a few times the main branch. You may want to cherry-pick your changes and reapply them on top of the new main.

Now, to the actual changes. Please keep your changes minimal the much you can: if you're doing an allow-list, do only that and avoid making other changes to the code so that it's easier to review, and also doesn't introduce changes in other parts of the code. This is important as this is a security-sensitive project.

Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants