ExSandbox.Hardening.Linux (ExSandbox v1.0.1)

Copy Markdown View Source

OS-level confinement for the BEAM mechanism on Linux (005 T004 – T012, contracts/hardening.md, research R2, R3, R9).

The security boundary is here, not in the BEAM

Nothing inside the Erlang VM can cap a process's memory or deny it a socket. 005 settled that the boundary is the operating system, so this module composes five kernel-enforced constructions and refuses to launch when it cannot build all of them.

systemd-run --scope -p MemoryMax= -p CPUQuota=   # FR-008
  setpriv --reuid= --regid= --clear-groups        # FR-007
    bwrap --ro-bind / --bind <storage>              # FR-010
      env -i <granted> BINDIR=<>                  # FR-004
        <erlexec> <peer args>

Refusal, never degradation

build_command/2 returns {:error, :hardening_unavailable} when any capability is missing. It never emits a command with a layer omitted, because that failure is invisible: the sandbox launches, the happy path is identical, and the only difference is whether tenant code is confined.

This is why available?/0 requires all five capabilities rather than most. Cgroup caps without privilege separation still permits reading platform files — "mostly hardened" describes a host that is not hardened.

Probes attempt; they never infer

Every probe below performs the operation it reports on. Reading :os.type() would report true on a Linux host with no cgroup delegation, no setpriv, or an unprivileged process — which is exactly the silent-failure mode R9 exists to prevent, and exactly what an operator cannot see.

What verify_applied/1 can and cannot establish

It reads the limits in force on the running process — the cgroup's effective values — rather than the command that requested them. R9b measured a limiter invoked with correct arguments, present in the process tree, named in configuration, and silently not applied. Reading back the request would have reported success.

Inspection is still not proof that a limit stops a breach; establishing that is the conformance suite's job (012-FR-012a). The two are complementary and neither substitutes for the other.

Summary

Functions

True only when every capability is present.

Builds the command that launches a sandbox under full confinement.

Probes what this host can actually enforce.

The command build_command/2 would emit, without the availability gate.

Create this sandbox's writable storage, owned by the uid it will drop to (FR-009, FR-010).

The systemd scope unit a sandbox runs in.

Where a sandbox's writable storage is bound, on the host and inside the sandbox alike — bwrap binds it at the same path on both sides.

Reads the confinement actually in force on a running sandbox process.

Types

capability_map()

@type capability_map() :: %{
  resource_limits: boolean(),
  privilege_separation: boolean(),
  filesystem_confinement: boolean(),
  network_restriction: boolean(),
  disk_quota: boolean()
}

Functions

available?()

@spec available?() :: boolean()

True only when every capability is present.

There is no partial state: see the moduledoc.

build_command(sandbox, granted_env \\ [])

@spec build_command(ExSandbox.Sandbox.t(), [{String.t(), String.t()}]) ::
  {:ok, {String.t(), [String.t()]}}
  | {:error,
     :hardening_unavailable | :invalid_limits | {:forbidden_env, [String.t()]}}

Builds the command that launches a sandbox under full confinement.

granted_env is an allowlist — the sandbox receives these pairs and nothing else. Returns {:error, :hardening_unavailable} when any capability is missing, and {:error, {:forbidden_env, keys}} when granted_env carries a platform-shaped secret.

capabilities()

@spec capabilities() :: capability_map()

Probes what this host can actually enforce.

Probed once at gateway startup rather than per provision — the answer is a property of the host, and probing per launch would put shell-outs on the provisioning path for a value that does not change.

compose_for_inspection(sandbox, granted_env \\ [])

(since 005 T009)
@spec compose_for_inspection(ExSandbox.Sandbox.t(), [{String.t(), String.t()}]) ::
  {:ok, {String.t(), [String.t()]}} | {:error, term()}

The command build_command/2 would emit, without the availability gate.

Public for one reason: the command's shape must be testable on hosts where hardening is unavailable, which is every developer machine that is not Linux. The alternative is that env -i -- one word, easily dropped, and invisible when missing because the sandbox still launches -- goes unverified until CI.

Callers must use build_command/2. This deliberately skips the availability check, so a caller reaching for it directly would build exactly the degraded command R9 forbids.

prepare_storage(sandbox)

@spec prepare_storage(ExSandbox.Sandbox.t()) :: :ok | {:error, term()}

Create this sandbox's writable storage, owned by the uid it will drop to (FR-009, FR-010).

Called before build_command/2's output is spawned. bwrap refuses a bind whose source does not exist -- "Can't find source path ..." -- so without this every launch exits 1 having constructed a perfectly correct command.

Ownership matters as much as existence. Created as root and left that way, the sandbox drops privilege and cannot write to its own storage: the launch succeeds and every write fails, which is harder to diagnose than an outright refusal. Mode 0o700 keeps it to the owning uid, since every sandbox on a gateway shares this root under a different uid.

scope_unit_name(sandbox)

@spec scope_unit_name(ExSandbox.Sandbox.t()) :: String.t()

The systemd scope unit a sandbox runs in.

Public because provision_failure_reason/1 must name the same unit to read its Result after death, and a mismatch between the two would silently report every breach as a platform fault.

storage_path(sandbox)

@spec storage_path(ExSandbox.Sandbox.t()) :: String.t()

Where a sandbox's writable storage is bound, on the host and inside the sandbox alike — bwrap binds it at the same path on both sides.

Public so callers and tests name the real location rather than assuming one. A test writing to a made-up path gets :enoent, which satisfies "the sandbox could not write here" just as well as a quota would — so the assertion passes while measuring nothing.

verify_applied(os_pid)

@spec verify_applied(integer()) ::
  {:ok, map()} | {:error, {:not_applied, atom()} | :not_applied | :unverifiable}

Reads the confinement actually in force on a running sandbox process.

{:error, :not_applied} means the process is running unconfined and the caller must terminate it — a running unconfined sandbox is what Principle II forbids. {:error, :unverifiable} means this host cannot answer, which is never reported as :ok.