**ADR-009** lists six "non-negotiable" mandatory security defaults. Checked each against `core/container/src/manifest.rs` and `core/security/src/`: - `seccomp_profile: Default` — the string `seccomp` appears **nowhere in `core/`**. Not as code, not as a TODO. This constraint is entirely fictional. - AppArmor — `container_policies.rs` generates and `apparmor_parser -r`s a profile, but its own comment reads `TODO: Configure Podman to use the profile`. `security.apparmor_profile` parses into a manifest field that nothing ever reads. - `user` UID > 1000 — no UID validation exists in the runtime parser at all. - `image_tag` pinned — preflight script only; the parser accepts `:latest`. - `readonly_root` / `no_new_privileges` — safe defaults when omitted, but `validate_security()` never rejects an explicit `false`, so the ADR's "Reject manifests that violate mandatory defaults" step does not exist. Genuinely enforced: the capability allow-list and bind-mount confinement (the latter stronger than the ADR describes). Added an Implementation status section saying so per-row. The decision stands; the claim of enforcement did not, and on a security ADR that gap is the whole point of writing it down. **ADR-004** said Tor carries *all* inter-node communication and runs as the `archy-tor` container. Neither holds: transport priority is mesh → LAN → FIPS → Tor (`TransportKind` 1-4, Tor as last fallback, largely because of the latency this ADR itself lists), and Tor is the host Debian service driven by `archipelago-tor-helper` — `container-doctor.sh` actively removes an `archy-tor` container if it finds one, and no `apps/tor` manifest exists. Added an amendment rather than rewriting the record. Worth flagging that both changes landed without their own ADR. All 10 ADRs are Status: Accepted; 001-003, 005-008 and 011 verified consistent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
100 lines
4.8 KiB
Markdown
100 lines
4.8 KiB
Markdown
# ADR-009: Manifest-Level Container Security Enforcement
|
|
|
|
## Status
|
|
|
|
Accepted
|
|
|
|
## Context
|
|
|
|
Archipelago runs third-party applications as containers. Without enforcement, containers could:
|
|
|
|
- Run as root and escalate privileges
|
|
- Access the host filesystem
|
|
- Modify their own binaries (persistence of malicious code)
|
|
- Acquire unnecessary Linux capabilities
|
|
- Use unverified or tampered container images
|
|
|
|
Other node OS projects (Umbrel, Start9) vary in their security enforcement. Archipelago targets a higher security bar suitable for handling Bitcoin private keys and personal data.
|
|
|
|
## Decision
|
|
|
|
Enforce security constraints at the **manifest level**, applied automatically during container creation. Every container MUST comply with these non-negotiable defaults:
|
|
|
|
### Mandatory Security Defaults
|
|
|
|
| Constraint | Value | Rationale |
|
|
|-----------|-------|-----------|
|
|
| `readonly_root` | `true` | Prevents runtime filesystem modification (anti-persistence) |
|
|
| `no_new_privileges` | `true` | Prevents privilege escalation via setuid/setgid |
|
|
| `user` | UID > 1000 | Never run as root |
|
|
| `capabilities` | Drop ALL, add only required | Principle of least privilege |
|
|
| `image_tag` | Pinned version | No `latest` tags — reproducible deploys |
|
|
| `seccomp_profile` | Default | Blocks dangerous syscalls |
|
|
|
|
### Manifest Enforcement
|
|
|
|
The `core/container/` module validates manifests before container creation:
|
|
|
|
1. **Parse** the YAML manifest
|
|
2. **Validate** all required security fields are present
|
|
3. **Reject** manifests that violate mandatory defaults (e.g., `readonly_root: false` without explicit override)
|
|
4. **Apply** security context during `podman create`
|
|
|
|
### Optional Overrides
|
|
|
|
Some apps legitimately need elevated privileges:
|
|
|
|
- `readonly_root: false` — Only for apps that must write to their root filesystem (documented reason required)
|
|
- Additional capabilities (e.g., `NET_ADMIN` for VPN apps) — must be explicitly listed and justified
|
|
|
|
## Consequences
|
|
|
|
### Positive
|
|
|
|
- Defense in depth — even if a container image is compromised, damage is limited
|
|
- Consistent security posture across all apps
|
|
- Transparent — users can inspect any app's security manifest
|
|
- Aligns with industry best practices (CIS Benchmarks, NIST)
|
|
|
|
### Negative
|
|
|
|
- Some apps may not work without modifications (e.g., apps expecting root)
|
|
- Read-only root requires explicit volume mounts for writable directories
|
|
- Developers must understand and comply with the security model
|
|
- Slightly more complex manifest format than competitors
|
|
|
|
### Mitigations
|
|
|
|
- Clear documentation in `docs/app-manifest-spec.md`
|
|
- Example manifests for common app patterns
|
|
- Build-time validation catches issues before deployment
|
|
- Override mechanism for legitimate exceptions (with audit trail)
|
|
|
|
## Implementation status
|
|
|
|
The decision above stands; this section records how much of it is actually
|
|
enforced today, because the "non-negotiable" table overstates it. Verified
|
|
against `core/container/src/manifest.rs` and `core/security/src/`:
|
|
|
|
| Constraint | Reality |
|
|
|---|---|
|
|
| `capabilities` drop-all + allow-list | ✅ **Enforced.** A capability outside the nine-entry allow-list is a parse error, so the app cannot install |
|
|
| Bind-mount confinement | ✅ **Enforced** (stronger than this ADR describes): sources must be under `/var/lib/archipelago/`, a named volume, or one of two reviewed exceptions |
|
|
| `readonly_root` / `no_new_privileges` | ◐ **Defaults, not gates.** Both default to `true` when omitted, but `validate_security()` does not reject an explicit `false` — the "reject manifests that violate mandatory defaults" step does not exist |
|
|
| `image_tag` pinned | ◐ **Preflight only.** `scripts/validate-app-manifest.sh` grades it; the parser accepts `:latest` and the app installs |
|
|
| `user` UID > 1000 | ❌ **Not validated.** The runtime manifest parser has no UID check at all (the marketplace schema has an advisory one, which is a different type) |
|
|
| `seccomp_profile` | ❌ **Does not exist.** The string `seccomp` appears nowhere in `core/` — not as code, not as a TODO |
|
|
| AppArmor | ❌ **Inert.** `container_policies.rs` can generate and `apparmor_parser -r` a profile, but its own comment says `TODO: Configure Podman to use the profile`. `security.apparmor_profile` is parsed into a manifest field that nothing ever reads |
|
|
|
|
So the accurate summary is: capability and mount confinement are hard gates,
|
|
the process-hardening flags are safe-by-default rather than enforced, and the
|
|
kernel-level sandboxing (seccomp/AppArmor) named in the decision was never
|
|
wired up. Closing the last two rows is tracked in
|
|
[`1.8.0-RELEASE-HARDENING-PLAN.md`](../1.8.0-RELEASE-HARDENING-PLAN.md).
|
|
|
|
## References
|
|
|
|
- `docs/app-manifest-spec.md` — Full manifest specification
|
|
- `core/container/src/` — Container security implementation
|
|
- `core/security/src/` — AppArmor profiles and secrets management
|