Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

                       __
   ___  ___  __ ______/ /
  / _ \/ _ \/ // / __/ _ \
 / .__/\___/\_,_/\__/_//_/
/_/

Pouch is a process runner for Linux and Unix-like systems that delivers secrets to your app over a dedicated file descriptor—gloriously over-engineered to implement every form of secure hardening imaginable, regardless of utility.

It’s for people who wouldn’t trust corporate cloud vaults like AWS Secrets Manager or Google Secret Manager, and who roll their eyes at hardware like TPMs and HSMs. For a detailed walkthrough of how Pouch delivers secrets and the reasoning behind each safeguard see Execution Flow.


Table of Contents


Installation

Install via Cargo:

cargo install pouch-run --locked

The binary will be installed at $CARGO_HOME/bin/pouch.


Usage

CLI

  • --from <SOURCE> selects the secret source; see Sources.
  • --fd <N> chooses the secret FD number (≥3) to avoid stdio and reserved descriptors; otherwise a safe descriptor is auto-selected.
  • --max-bytes <N> bounds the accepted size (default 262144) to limit memory usage and denial-of-service risk.
  • --consume-timeout <seconds> waits for a validated ACK after delivery (default 3.0) to prove receipt; 0 disables the wait and requires --i-accept-no-ack.
  • --i-accept-no-ack explicitly opts out of ACK enforcement and prints a warning, reducing guarantees to fire-and-forget.
  • --keep-fd <N> keeps additional FDs (≥3) open in the child besides {0,1,2,secret,ack}; use sparingly since extra FDs widen the attack surface.
  • --no-containment disables Linux cgroup v2 containment used for teardown isolation, trading compatibility for weaker cleanup guarantees.
  • -v / --verbose emits structural diagnostics to stderr only and never logs secret content, avoiding accidental leaks.
  • --keep-env preserves the child's environment instead of a clean allowlist; Pouch still scrubs LD_*/DYLD_* and requires an absolute command path to reduce hijacking risk.

The child command and its arguments follow -- and are passed verbatim. The command must be an absolute path (no PATH lookup) to avoid PATH hijacking and ensure deterministic execution.


Sources

- (stdin) reads from stdin (must not be a TTY) with size capped by --max-bytes; use prompt for interactive input to avoid echoing secrets.

file:/ABSOLUTE/PATH reads from an absolute path only, rejects .., allows only regular files, and rejects symlinks with O_NOFOLLOW plus fstat verification; group/world‑writable files are refused and size is checked before reading to enforce --max-bytes, all to prevent path/symlink attacks and tampering.

fd:N reads from an already‑open FD N, rejects 0, 1, and 2, duplicates the FD with CLOEXEC, and reads up to --max-bytes, avoiding stdio confusion, handle leaks, and unbounded reads.

prompt reads interactively from /dev/tty and requires a controlling TTY so a human can enter the secret securely.

cred:NAME or cred:UNIT/NAME is shorthand for systemd credentials: it reads from ${CREDENTIALS_DIRECTORY}/NAME or /run/credentials/UNIT.cred/NAME as applicable, and applies the same file: safety checks to preserve file‑source hardening.


Exit Codes

  • 0 indicates success (validated ACK observed and child exit 0; or fire‑and‑forget mirrors child 0).
  • 1 indicates a usage or validation error (invalid source, TTY on stdin, invalid path, or --consume-timeout 0 without the guard flag).
  • 2 indicates delivery failure when validated ACK fails (EOF before 48 bytes, timeout, oversized/truncated payload, nonce/digest mismatch, or premature child exit). It also occurs on EPIPE while writing the secret; with ACK enforcement, pouch tears down the subtree (process group or cgroup) before exiting.
  • 3 indicates an I/O or system error (open/dup/pipe failures, etc.).
  • If the child exits non‑zero, Pouch propagates that exact code after ACK; if the child is signaled, Pouch exits with 128 + signal.

ACK Wire Format

When enforcement is enabled, the parent generates a 16‑byte random nonce and exposes it to the child as a 32‑character lowercase hex string via POUCH_ACK_NONCE to bind the ACK to this delivery and prevent replay. The child must then write exactly 48 bytes to POUCH_ACK_FD: the raw 16‑byte nonce followed by a 32‑byte BLAKE2s digest keyed with that nonce over the exact secret bytes, which proves receipt of the exact data and prevents tampering. The parent verifies both values in constant time and rejects any deviation (size errors, extra bytes, mismatch, or premature EOF) to avoid timing and truncation/extension attacks.

+-----------------------+       write secret        +-------------+
| Parent (pouch runner) | ------------------------> | Secret Pipe |
+-----------------------+                           +-------------+
       |    ^                                              |
       |    | 48-byte ACK (nonce + digest)                 v  POUCH_SECRET_FD
       |    +----------------------+                 +---------------+
       |                           |                 |    Child      |
       |   read from               |                 | (exec target) |
       +------------------------- ACK Pipe <---------+---------------+
                              (POUCH_ACK_FD)

A validated ACK proves only that the child has read the complete secret. It does not guarantee how the secret is used afterward. The child should close the ACK descriptor immediately after sending the 48‑byte payload. Extra data beyond this is ignored.


Examples

Consumer Example

import os, hashlib, binascii

fd = int(os.environ["POUCH_SECRET_FD"])  # secret FD number
buf = []
while True:
    chunk = os.read(fd, 65536)
    if not chunk:
        break
    buf.append(chunk)
os.close(fd)
data = b"".join(buf)

nonce = binascii.unhexlify(os.environ["POUCH_ACK_NONCE"])  # 16-byte raw nonce
digest = hashlib.blake2s(data, digest_size=32, key=nonce).digest()
os.write(int(os.environ["POUCH_ACK_FD"]), nonce + digest)  # 48-byte ACK
os.close(int(os.environ["POUCH_ACK_FD"]))

CI/CD Example

Logging in to a container registry in GitHub Actions without exposing the token in environment variables or logs. This example uses the drop‑in Python helper from examples/example.py to emit the secret on stdout and send a validated ACK over POUCH_ACK_FD, then pipes the secret into docker login:

name: ci
on: [push]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@stable
      - name: Install pouch (Cargo)
        run: cargo install pouch-run --locked
      - name: Registry login via FD (no env)
        env:
          REGISTRY: ghcr.io
          USERNAME: ${{ github.actor }}
        run: |
          printf '%s' '${{ secrets.REGISTRY_TOKEN }}' \
            | pouch --from - -- /usr/bin/python3 examples/example.py \
            | docker login -u "$USERNAME" --password-stdin "$REGISTRY"

Notes:

  • The token is never placed in the child’s environment because Pouch starts from a clean env, preventing leaks via ps, crash dumps, or inherited subprocesses.
  • The secret never appears on stdout and diagnostics go to stderr only, keeping pipelines safe to consume without credential exposure.
  • Replace docker login with any tool that accepts credentials on stdin to avoid writing secrets to files or environment variables.
  • If the consumer cannot ACK, --consume-timeout 0 --i-accept-no-ack disables enforcement, trading delivery assurance for compatibility.

Other Languages

For other languages, see the examples directory.


Execution Flow

Phase 1: Pre-Exec Hardening

  1. The parent ignores SIGPIPE so writes to a closed pipe return EPIPE instead of killing the runner, preventing a child from terminating the parent by closing the pipe.

  2. Core dumps are disabled and the secret buffer is page‑locked and marked no‑core‑dump, keeping plaintext out of crash dumps and swap to reduce accidental leakage while the buffer is live.

  3. The delivery pipe is created with FD_CLOEXEC on both ends so unintended exec’d processes cannot inherit the handles, preventing secret leaks to unrelated children.

  4. A safe target file descriptor ≥3 is chosen (or honored from --fd) while avoiding stdio, systemd sockets and runtime IPC FDs, preventing accidental collisions or hijacking.

  5. The pipe’s read end is duplicated onto the chosen descriptor with CLOEXEC so the secret exists only on the intended FD, confining access to a predictable, non‑leaking handle.

  6. All redundant read ends in the parent are closed so EOF is unambiguous and cannot be masked, ensuring the child can reliably detect completion.


Phase 2: Secret Ingestion

  1. Memory is pre‑allocated up to --max-bytes to bound usage and prevent unbounded allocation or fragmentation attacks.

  2. The secret is read only after validating the source (rejecting TTY stdin, symlinks, and relative paths) to block TOCTTOU tricks and path hijacks.

  3. On any read error the buffer is immediately wiped so partial plaintext cannot be recovered later.


Phase 3: Child Environment and Process Prep

  1. Diagnostics in verbose mode include only structural metadata on stderr, never secret bytes, to prevent exfiltration via logs.

  2. The child starts from a cleared environment to neutralize dangerous variables (e.g., LD_*, DYLD_*, and PATH) that could hijack execution.

  3. Only an allowlisted set of env vars is restored and PATH is sanitized to a root‑owned, non‑writable set of directories, reducing the risk of spoofed binaries.

  4. The child receives only POUCH_SECRET_FD, POUCH_ACK_FD, and POUCH_ACK_NONCE (as 32‑char hex) so it has the capability to read and acknowledge without seeing the secret in env.

  5. Just before exec, all descriptors except {0,1,2,POUCH_SECRET_FD,POUCH_ACK_FD} are closed (preferring close_range or /proc/self/fd on Linux and closefrom or a bounded loop on BSD/macOS) to prevent unintended handle inheritance.

  6. The process is spawned only after environment and descriptor cleanup so no race or leakage occurs during the exec transition.


Phase 4: Delivery and ACK

  1. All duplicate parent handles to the pipe are closed so EOF cannot be masked, ensuring readers see end‑of‑data promptly.

  2. The secret is written and flushed fully so delivery either completes or fails clearly, avoiding partial reads.

  3. Any EPIPE on write is treated as failure and triggers zeroization, making early child closure a safe, detectable error (rc=2).

  4. The in‑memory buffer is wiped immediately after sending to minimize the lifetime of plaintext.

  5. The write end is closed to signal EOF to the child, allowing deterministic completion and ACK computation.

  6. When enforced, the child must write exactly 48 bytes (raw 16‑byte nonce + 32‑byte BLAKE2s digest keyed with the nonce over the secret), and the parent verifies both in constant time; this proves the exact bytes were received and defends against truncation, tampering, and timing attacks. Timeouts, premature EOF, size mismatches, or digest failures trigger logging, subtree teardown, and rc=2. With --consume-timeout 0 --i-accept-no-ack, the wait and teardown are skipped, but EPIPE during send still yields rc=2.


Phase 5: Post-Exec

  1. Pouch exits with the child’s status (or 128+signal if signaled) so the runner preserves application semantics after enforcing delivery policy.

  2. Core‑dump rlimits are restored after secret handling to avoid surprising global settings outside the secret’s lifetime.


Phase 6: Additional Safeguards

  1. When ACK is enforced, a missing or invalid ACK yields rc=2 and best‑effort teardown of the launched subtree (process group or cgroup v2) so failed deliveries don’t leave stray processes; with --consume-timeout 0 --i-accept-no-ack, enforcement and teardown are skipped by design.

  2. Secrets are never passed via argv, environment, stdout, or stderr to avoid discovery by ps, logs, or inheritance across subprocesses.

  3. File sources require absolute paths, reject .. and symlinks, and must be regular files to prevent path tricks and time‑of‑check/time‑of‑use races.

  4. All diagnostics are restricted to stderr so stdout can be safely piped to downstream tools without secret contamination.

  5. Input size is bounded by --max-bytes to reject oversized data early and limit memory usage and denial‑of‑service risk.


Case Study

Travis CI Secret Environment Variable Exposure

In September 2021, Travis CI suffered a vulnerability that exposed sensitive information from public repositories. During a one-week window (2021-09-03 through 2021-09-10), secure environment variables including API tokens, signing keys, and other credentials were injected into builds triggered by pull requests from untrusted forks. Attackers could submit a pull request and gain access to secrets never intended to leave trusted builds.

The root cause was Travis CI's handling of pull request builds and secret injection. Normally, CI systems prevent secrets from being available to code from outside contributors. Here, the protection failed, and "secure environment variables" were passed along despite coming from forks.

This type of exposure is exactly what Pouch is designed to avoid. Because Pouch never delivers secrets via environment variables or temporary files, the Travis CI class of failure cannot occur.

About

Tiny process runner for Linux / Unix-like systems that securely hands off a secret to a child process over a dedicated file descriptor.

Topics

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages