Skip to content

Wrapper Design

The wrapper is a pure Go binary that runs as PID 1 of the application container. It authenticates to an EU-hosted vault, fetches secrets, sets them as environment variables in its own process, strips all CLOUDTASER_* configuration variables, and then fork+execs the original application command. The child process inherits the environment -- including secrets -- through the standard Unix execve() mechanism.

Nothing is written to disk. No shared memory. No interception libraries. No C code.


Why Fork+Exec

Applications expect secrets in environment variables. Postgres reads PGPASSWORD. Redis reads REDIS_PASSWORD. Node apps read DATABASE_URL. This is the universal convention.

The alternatives each have problems:

  • JSON files on tmpfs force every customer to modify their application to read from a custom path in a custom format. This breaks the "zero code changes" promise and makes it impossible to wrap off-the-shelf software like postgres, mysql, or nginx without custom entrypoint scripts.
  • Real environment variables in the container spec expose secrets through the Kubernetes API, etcd, container runtime inspection, and cloud provider audit logs. This violates the data sovereignty guarantee.
  • LD_PRELOAD was the original design -- a shared library intercepting getenv() calls. This was abandoned because static Go binaries reimplement os.Getenv by reading /proc/self/environ directly, bypassing libc entirely. It also required C code, increasing attack surface and maintenance burden.

Fork+exec achieves the same security properties with dramatically less complexity -- pure Go, approximately 400 lines, zero native dependencies.


How It Works

Using a vanilla postgres container as an example:

1. Pod starts
2. Init container copies the wrapper binary to a shared volume
3. Postgres container starts with modified entrypoint:
   command: ["/cloudtaser/wrapper"]
   env:
     - CLOUDTASER_ORIGINAL_CMD=docker-entrypoint.sh
     - CLOUDTASER_ORIGINAL_ARGS=postgres
     - CLOUDTASER_VAULT_ADDR=https://vault.eu.skipops.io:8200
     - CLOUDTASER_SECRET_PATHS=secret/data/prod/db
     - CLOUDTASER_ENV_MAP=password=PGPASSWORD,username=PGUSER

4. Wrapper (PID 1 of the container):
   a. Authenticates to EU vault via Kubernetes service account
   b. Fetches secrets from configured paths
   c. Sets secrets as env vars in its own process (os.Setenv)
   d. Strips all CLOUDTASER_* vars from its own environment
   e. fork+execs the original command (docker-entrypoint.sh postgres)

5. Child process (docker-entrypoint.sh -> postgres):
   a. Inherits env from wrapper via execve()
   b. Postgres calls getenv("PGPASSWORD") -- standard behavior
   c. Gets the secret value from inherited environment
   d. Postgres authenticates. Works normally.

6. Wrapper stays alive as parent:
   a. Renews vault token periodically
   b. Renews secret leases
   c. On secret rotation: kills child, re-fork+execs with new env
   d. Forwards signals to child (SIGTERM, SIGINT, etc.)

Postgres does not know CloudTaser exists. No config file changes. No custom entrypoint. No JSON parsing. The standard docker-entrypoint.sh postgres runs exactly as documented by the postgres image maintainers.


Component Architecture

+---------------------------------------------------------+
|  Application Pod                                         |
|                                                          |
|  +----------------------------------------------------+  |
|  |  Application Container                              |  |
|  |                                                     |  |
|  |  /cloudtaser/wrapper (PID 1)                       |  |
|  |    |                                                |  |
|  |    +-- Authenticates to EU vault                    |  |
|  |    +-- Fetches secrets                              |  |
|  |    +-- Sets secrets as env vars in own process      |  |
|  |    +-- Strips CLOUDTASER_* vars from env           |  |
|  |    +-- Manages token + lease renewal                |  |
|  |    +-- Handles secret rotation (re-fork+exec)       |  |
|  |    |                                                |  |
|  |    +-- fork+exec --> original command (PID 2+)     |  |
|  |                        |                            |  |
|  |                        +-- Inherits env with secrets|  |
|  |                        +-- Runs unmodified          |  |
|  +----------------------------------------------------+  |
|                                                          |
|  +------------------------+  +------------------------+  |
|  |  Shared Volume         |  |  eBPF DaemonSet        |  |
|  |  (emptyDir)            |  |  (node-level)          |  |
|  |                        |  |                        |  |
|  |  /cloudtaser/         |  |  Prevents:             |  |
|  |    wrapper (binary)    |  |  - ptrace attach       |  |
|  |                        |  |  - /proc/pid/mem reads |  |
|  |                        |  |  - /proc/pid/environ   |  |
|  |                        |  |    reads by others     |  |
|  |                        |  |  - core dump writes    |  |
|  +------------------------+  |  - secret exfiltration |  |
|                              +------------------------+  |
+---------------------------------------------------------+
          |
          | mTLS / Kubernetes auth
          v
+----------------------+
|  EU OpenBao / Vault  |
|  (EU-hosted only)    |
+----------------------+

Wrapper Binary (Go)

The wrapper is a pure Go binary (approximately 400 lines) that runs as PID 1 of the application container. It is not a sidecar -- it is the container's entrypoint. Minimal dependencies, no CGO, no native code. Responsibilities:

  • Vault authentication: Kubernetes service account auth against EU-hosted OpenBao. The operator may provide a short-lived token instead.
  • Secret fetching: Reads secrets from configured vault paths.
  • Environment preparation: Sets secret values as environment variables in its own process via os.Setenv. Strips all CLOUDTASER_* configuration variables so they are not leaked to the child.
  • Process execution: fork+exec of the original container command. The child inherits the prepared environment through the standard execve() mechanism.
  • Signal forwarding: Forwards SIGTERM, SIGINT, SIGHUP, and other signals to the child process.
  • Lease management: Renews vault token and secret leases on a configurable interval.
  • Secret rotation: When secrets change, kills the child process and re-fork+execs with an updated environment.
  • Health endpoint: HTTP server on configurable port for Kubernetes liveness/readiness probes.

eBPF Enforcement Layer

The eBPF daemonset runs at the node level and enforces runtime protections for all CloudTaser-injected pods:

  • Block ptrace attach: Prevents debuggers, gcore, strace from reading process memory containing secrets
  • Block /proc/pid/mem reads: Prevents direct memory reads by other processes
  • Block /proc/pid/environ reads: Prevents other processes from reading the child's environment, which contains the secrets. This is the critical enforcement -- without it, any process in the same pid namespace could read /proc/<child_pid>/environ
  • Block core dumps: Prevents core dump generation that could include secret environment data
  • Detect heap dump tools: Intercepts Java jmap, Go profiling tools, and other memory dump utilities

Entrypoint Resolution

The operator webhook must know the container's original command and args to pass to the wrapper as CLOUDTASER_ORIGINAL_CMD and CLOUDTASER_ORIGINAL_ARGS.

Case What the webhook does
command and args are explicit in pod spec Read them directly from the submitted spec
command or args are missing (image defaults) Query the container registry API to read ENTRYPOINT and CMD from the image config

The operator uses image pull secrets from the pod spec (or service account) to authenticate to private registries. It caches image metadata to avoid repeated registry calls for the same image digest.

This auto-detection is critical for the "zero changes" promise: customers using stock images with default entrypoints (postgres, nginx, redis) do not need to specify command in their pod specs.


Pod Mutation Details

The webhook applies the following mutations to matching pods:

Add init container:

initContainers:
  - name: cloudtaser-init
    image: ghcr.io/skipopsltd/cloudtaser-wrapper:<version>
    command: ["sh", "-c", "cp /cloudtaser-wrapper /cloudtaser/wrapper"]
    volumeMounts:
      - name: cloudtaser-bin
        mountPath: /cloudtaser
    resources:
      limits:
        cpu: 50m
        memory: 32Mi

Modify application container:

containers:
  - name: <original-name>
    command: ["/cloudtaser/wrapper"]
    env:
      - name: CLOUDTASER_ORIGINAL_CMD
        value: <resolved command>
      - name: CLOUDTASER_ORIGINAL_ARGS
        value: <resolved args>
      - name: CLOUDTASER_VAULT_ADDR
        value: <from annotation or operator config>
      - name: CLOUDTASER_SECRET_PATHS
        value: <from annotation>
      - name: CLOUDTASER_ENV_MAP
        value: <from annotation>
    volumeMounts:
      - name: cloudtaser-bin
        mountPath: /cloudtaser

Add shared volume:

volumes:
  - name: cloudtaser-bin
    emptyDir:
      medium: Memory
      sizeLimit: 8Mi

No LD_PRELOAD, no libcloudtaser.so. The init container copies only the wrapper binary. The emptyDir is memory-backed and holds only the single binary.


kubectl exec Threat Model

A common question: "If secrets are in the child's environment variables, can kubectl exec -- env see them?"

No. Understanding why requires knowing how kubectl exec works at the container runtime level:

  1. kubectl exec sends an exec request to the kubelet
  2. The kubelet calls the CRI (Container Runtime Interface) ExecSync or Exec RPC on the container runtime (containerd, CRI-O)
  3. The runtime creates a new process inside the container's namespaces. This new process is not a child of PID 1 (the wrapper) or PID 2+ (the application). It is spawned directly by the runtime
  4. The new process's environment is constructed from the container spec -- the same env fields visible in the pod YAML -- not inherited from any running process
  5. Since secrets are set by the wrapper via os.Setenv after the container starts, the container spec does not contain them

Therefore, kubectl exec -- env shows only the environment from the container spec (which contains CLOUDTASER_* variables -- vault address, secret paths, env mapping -- not the secrets themselves). The wrapper strips CLOUDTASER_* vars from the child's inherited env, but that is a separate concern -- the kubectl exec process never inherits from the child at all.

Defense in depth

The eBPF layer provides additional protection: even if an attacker gets a shell via kubectl exec and tries to read /proc/<child_pid>/environ, the eBPF program denies the read.


Security Properties

Attack Vector Secret Visible? Notes
kubectl exec -- env No New process from container spec -- does not inherit wrapper's env
kubectl exec -- cat /proc/<child>/environ Blocked by eBPF eBPF denies cross-process /proc/pid/environ reads
kubectl exec -- cat /proc/<child>/mem Blocked by eBPF eBPF denies /proc/pid/mem reads
kubectl cp of any file No Secrets are not on any filesystem
Core dump / crash dump Blocked by eBPF eBPF prevents core dump generation
Container runtime inspect No Secrets are not in container env spec
etcd / Kubernetes Secrets No Secrets never enter Kubernetes API
Node access + read /proc Blocked by eBPF eBPF blocks cross-process environ/mem reads
Cloud provider audit logs No Secrets transit only from EU vault to pod memory
strace / ltrace on process Blocked by eBPF ptrace attach denied
Heap dump (jmap, gcore) Blocked by eBPF ptrace / process_vm_readv denied
Application logging env var values Yes Application-level responsibility

Trust Boundaries

  1. EU vault is the trust root. Secrets originate only from EU-hosted OpenBao/Vault.
  2. Wrapper is trusted code. It runs as PID 1 and handles vault authentication, secret fetching, and environment preparation.
  3. The application is untrusted. It receives secrets only through inherited environment variables. It cannot read other processes' environments (eBPF enforcement).
  4. The cloud provider is untrusted. Secrets never enter provider-controlled storage, APIs, or logging systems.
  5. The Kubernetes control plane is untrusted. Secrets never enter etcd, Kubernetes Secrets, ConfigMaps, or container env specs.

Secret Rotation Strategies

When a vault secret lease expires or a secret value changes:

  1. Wrapper detects the change during its periodic lease renewal check
  2. Wrapper fetches the new secret values from the vault
  3. Wrapper updates its own environment with the new values
  4. Wrapper acts according to the configured rotation strategy
Strategy Behavior Use Case
restart (default) Wrapper sends SIGTERM to child, waits for exit, re-fork+execs with updated env Databases, stateless services, connection-pooling apps
sighup Wrapper sends SIGHUP to child; child is expected to re-read env nginx, HAProxy, apps with config reload support
none No action; secrets are updated in wrapper's env but child retains old values Short-lived jobs, CronJobs

The restart strategy is clean and universal: the child exits, the wrapper prepares the new environment, and a fresh child starts with the correct secrets. This matches the Kubernetes-native pattern of pod restarts for configuration changes, but happens within a single container without pod-level disruption.


Runtime Compatibility

The fork+exec model works with all binaries and runtimes. Environment variable inheritance via execve() is a fundamental Unix/Linux primitive -- it does not depend on the dynamic linker, libc implementation, or language runtime.

Runtime Works? Notes
C / C++ Yes getenv() reads inherited env
Python Yes os.environ reads inherited env
Ruby Yes ENV[] reads inherited env
Java (JVM) Yes System.getenv() reads inherited env
Node.js Yes process.env reads inherited env at startup
PHP Yes getenv() reads inherited env
Rust Yes std::env::var reads inherited env
.NET Yes Environment.GetEnvironmentVariable reads inherited env
Go (static, CGO_ENABLED=0) Yes Go reads /proc/self/environ at startup -- works because the env is real
Go (CGO_ENABLED=1) Yes Calls libc getenv which reads real env
Shell scripts (bash, sh) Yes $VAR reads inherited env

No special cases. No workarounds. No SDK required.